feat(M4): file permissions/ownership + monitor charts
- SftpEntry carries uid/gid/mode (drwxr-xr-x style) from readdir attrs - chmod/chown via SSH exec (JD sftp subsystem silently ignores SETSTAT) - FilePanel: permission + owner columns, chmod/chown dialog (rwx grid + octal input + uid/gid) - MonitorPanel: canvas sparkline charts — CPU history (60 samples, 3min) and network rx/tx rates, dpr-aware, no deps - tests: sftp-chmod real-server verification (chmod 700/755 round trip)
This commit is contained in:
1 parent
06fa4ca023
commit
2849fc30dc
60 files changed
+1975
-17
No files matched your search
@@ -0,0 +1,66 @@
|
||||
// Decisive test: exact sftp.ts upload code, inline (no esbuild bundle).
|
||||
import { createRequire } from 'module'
|
||||
import { promises as fsp } from 'fs'
|
||||
const require_ = createRequire(import.meta.url)
|
||||
const ssh2 = require_('ssh2')
|
||||
const sessionLayer = require_('D:/Coding/Terminal/tests/.session-e2e.cjs')
|
||||
|
||||
let clientProvider
|
||||
function p(fn) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn((err, res) => (err ? reject(err) : resolve(res)))
|
||||
})
|
||||
}
|
||||
|
||||
sessionLayer.configureSessionRuntime({
|
||||
broadcast: () => {},
|
||||
getConnection: () => ({
|
||||
id: 'jd', name: 't', host: process.env.JD_HOST, port: 22, username: process.env.JD_USER,
|
||||
auth: 'password', askPasswordAtConnect: false, askPassphraseAtConnect: false,
|
||||
keepaliveIntervalSec: 30, createdAt: Date.now(),
|
||||
savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false }
|
||||
}),
|
||||
getSecret: () => process.env.JD_PASS,
|
||||
touch: () => {},
|
||||
knownHosts: { check: () => ({ status: 'match' }), accept: () => {} },
|
||||
promptHostKey: () => { throw new Error('x') }
|
||||
})
|
||||
|
||||
const { id } = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'jd' })
|
||||
const client = sessionLayer.getSshClient(id)
|
||||
|
||||
// --- exact sftp.ts upload pattern ---
|
||||
const transfer = { kind: 'upload', cancelled: false }
|
||||
const emit = (e) => console.log('[evt]', e.state, e.file, e.bytes + '/' + e.totalBytes, e.error ?? '')
|
||||
const local = './.exact-test.bin'
|
||||
const content = Buffer.alloc(256 * 1024, 2)
|
||||
await fsp.writeFile(local, content)
|
||||
const remote = '/root/ot_exact.bin'
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
client.sftp((err, sftp) => (err != null ? reject(err) : resolve(sftp)))
|
||||
})
|
||||
const sftp = await new Promise((resolve, reject) => {
|
||||
client.sftp((err, sftp_) => (err != null ? reject(err) : resolve(sftp_)))
|
||||
})
|
||||
console.log('[exact] sftp channel open, calling fastPut')
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.fastPut(local, remote, {
|
||||
concurrency: 4,
|
||||
step: (totalTransferred, _chunk, total) => {
|
||||
console.log('[exact] step', totalTransferred, '/', total)
|
||||
if (transfer.cancelled) {
|
||||
reject(new Error('已取消'))
|
||||
return
|
||||
}
|
||||
}
|
||||
}, (err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
const st = await new Promise((resolve, reject) => {
|
||||
sftp.stat(remote, (err, s) => (err ? reject(err) : resolve(s)))
|
||||
})
|
||||
console.log('[exact] OK, remote size:', st.size, 'expected:', content.length)
|
||||
await new Promise((resolve, reject) => sftp.unlink(remote, (e) => (e ? reject(e) : resolve())))
|
||||
await fsp.rm(local, { force: true })
|
||||
sessionLayer.killPty(id)
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,78 @@
|
||||
// Real-server chmod/chown + mode-string verification (M4). Credentials from env:
|
||||
// JD_HOST / JD_USER / JD_PASS
|
||||
// Prereq bundles: tests/.session-e2e.cjs (pty session layer), tests/.sftp-svc.mjs (sftp service)
|
||||
import { createRequire } from 'module'
|
||||
const require_ = createRequire(import.meta.url)
|
||||
const sessionLayer = require_('./.session-e2e.cjs')
|
||||
const sftpMod = await import('./.sftp-svc.mjs')
|
||||
sftpMod.registerSftpClientProvider((id) => sessionLayer.getSshClient(id))
|
||||
|
||||
const events = []
|
||||
sessionLayer.configureSessionRuntime({
|
||||
broadcast: (channel, payload) => events.push({ channel, payload }),
|
||||
getConnection: () => ({
|
||||
id: 'jd',
|
||||
name: 'test',
|
||||
host: process.env.JD_HOST,
|
||||
port: 22,
|
||||
username: process.env.JD_USER,
|
||||
auth: 'password',
|
||||
askPasswordAtConnect: false,
|
||||
askPassphraseAtConnect: false,
|
||||
keepaliveIntervalSec: 30,
|
||||
createdAt: Date.now(),
|
||||
savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false }
|
||||
}),
|
||||
getSecret: () => process.env.JD_PASS,
|
||||
touch: () => {},
|
||||
knownHosts: { check: () => ({ status: 'match' }), accept: () => {} },
|
||||
promptHostKey: () => { throw new Error('unexpected prompt') }
|
||||
})
|
||||
|
||||
const fail = (m) => { console.error('FAIL:', m); process.exit(1) }
|
||||
if (!process.env.JD_HOST) { console.error('set JD_HOST/JD_USER/JD_PASS'); process.exit(2) }
|
||||
|
||||
console.log('[chmod] connecting ...')
|
||||
const { id } = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'jd' })
|
||||
console.log('[chmod] session ready')
|
||||
|
||||
// 1. mode-string format from a live listing
|
||||
const root = await sftpMod.listRemote(id, '/')
|
||||
const sample = root.find((e) => e.mode && e.mode.length === 10)
|
||||
if (!sample) fail('no entry carries a mode string in /')
|
||||
const expectedType = sample.isDir ? 'd' : '-'
|
||||
if (sample.mode[0] !== expectedType) fail(`mode type bit wrong: ${sample.mode} isDir=${sample.isDir}`)
|
||||
const permPart = sample.mode.slice(1)
|
||||
if (!/^[rwx-]{9}$/.test(permPart)) fail(`perm digits malformed: ${sample.mode}`)
|
||||
console.log('[chmod] mode string ok:', sample.mode,
|
||||
'uid=' + sample.uid, 'gid=' + sample.gid)
|
||||
|
||||
// 2. chmod a scratch dir, verify, restore
|
||||
const dir = '/root/ot_chmod_test'
|
||||
await sftpMod.deleteRemote(id, [dir]).catch(() => undefined)
|
||||
await sftpMod.mkdirRemote(id, '/root', 'ot_chmod_test')
|
||||
try {
|
||||
const before = (await sftpMod.listRemote(id, '/root')).find((e) => e.path === dir)
|
||||
if (!before || !before.mode) fail('scratch dir missing or lacks mode')
|
||||
console.log('[chmod] before:', before.mode)
|
||||
|
||||
await sftpMod.chmodRemote(id, dir, '700')
|
||||
const after700 = (await sftpMod.listRemote(id, '/root')).find((e) => e.path === dir)
|
||||
if (after700.mode !== 'drwx------') fail(`expected drwx------ got ${after700.mode}`)
|
||||
console.log('[chmod] chmod 700 ->', after700.mode)
|
||||
|
||||
await sftpMod.chmodRemote(id, dir, '755')
|
||||
const after755 = (await sftpMod.listRemote(id, '/root')).find((e) => e.path === dir)
|
||||
if (after755.mode !== 'drwxr-xr-x') fail(`expected drwxr-xr-x got ${after755.mode}`)
|
||||
console.log('[chmod] chmod 755 ->', after755.mode)
|
||||
} finally {
|
||||
await sftpMod.deleteRemote(id, [dir]).catch(() => undefined)
|
||||
}
|
||||
console.log('[chmod] cleanup ok')
|
||||
|
||||
const exits = events.filter((e) => e.channel === 'pty:exit')
|
||||
console.log('[chmod] unexpected exits:', exits.length)
|
||||
|
||||
sessionLayer.killPty(id)
|
||||
console.log('[chmod] ALL CHECKS PASSED')
|
||||
setTimeout(() => process.exit(0), 300)
|
||||
@@ -0,0 +1,109 @@
|
||||
// Real-server SFTP verification (M4). Credentials from env (never hardcoded):
|
||||
// JD_HOST / JD_USER / JD_PASS
|
||||
// Prereq bundles: tests/.session-e2e.cjs (pty session layer), tests/.sftp-svc.cjs (sftp service)
|
||||
import { createRequire } from 'module'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { promises as fsp } from 'fs'
|
||||
const require_ = createRequire(import.meta.url)
|
||||
const sessionLayer = require_('./.session-e2e.cjs')
|
||||
const sftpMod = await import('./.sftp-svc.mjs')
|
||||
sftpMod.registerSftpClientProvider((id) => sessionLayer.getSshClient(id))
|
||||
|
||||
const events = []
|
||||
sessionLayer.configureSessionRuntime({
|
||||
broadcast: (channel, payload) => events.push({ channel, payload }),
|
||||
getConnection: () => ({
|
||||
id: 'jd',
|
||||
name: 'test',
|
||||
host: process.env.JD_HOST,
|
||||
port: 22,
|
||||
username: process.env.JD_USER,
|
||||
auth: 'password',
|
||||
askPasswordAtConnect: false,
|
||||
askPassphraseAtConnect: false,
|
||||
keepaliveIntervalSec: 30,
|
||||
createdAt: Date.now(),
|
||||
savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false }
|
||||
}),
|
||||
getSecret: () => process.env.JD_PASS,
|
||||
touch: () => {},
|
||||
knownHosts: { check: () => ({ status: 'match' }), accept: () => {} },
|
||||
promptHostKey: () => { throw new Error('unexpected prompt') }
|
||||
})
|
||||
|
||||
const fail = (m) => { console.error('FAIL:', m); process.exit(1) }
|
||||
if (!process.env.JD_HOST) { console.error('set JD_HOST/JD_USER/JD_PASS'); process.exit(2) }
|
||||
|
||||
console.log('[sftp] connecting ...')
|
||||
const { id } = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'jd' })
|
||||
console.log('[sftp] session ready')
|
||||
|
||||
const root = await sftpMod.listRemote(id, '/')
|
||||
if (!root.some((e) => e.isDir)) fail('no directories listed in /')
|
||||
console.log('[sftp] listRemote(/) ok,', root.length, 'entries')
|
||||
|
||||
const dir = '/root/ot_sftp_test'
|
||||
// tolerate leftovers from earlier aborted runs
|
||||
await sftpMod.deleteRemote(id, [dir]).catch(() => undefined)
|
||||
await sftpMod.mkdirRemote(id, '/root', 'ot_sftp_test')
|
||||
console.log('[sftp] mkdir ok')
|
||||
|
||||
const content = randomUUID() + '\n' + 'x'.repeat(256 * 1024)
|
||||
const localFile = `./.sftp-test-${Date.now()}.bin`
|
||||
await fsp.writeFile(localFile, content, 'utf8')
|
||||
console.log('[debug] alive before upload:', !!sessionLayer.getSshClient(id))
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('upload timeout 60s')), 60000)
|
||||
sftpMod.uploadRemote(id, [localFile], dir, (e) => {
|
||||
if (e.state === 'done') { clearTimeout(timer); resolve() }
|
||||
if (e.state === 'error') { clearTimeout(timer); reject(new Error(e.error ?? 'upload error')) }
|
||||
})
|
||||
})
|
||||
console.log('[debug] alive after upload:', !!sessionLayer.getSshClient(id))
|
||||
} catch (err) {
|
||||
console.error('[debug] upload error:', err.message)
|
||||
console.error('[debug] alive:', !!sessionLayer.getSshClient(id))
|
||||
console.error('[debug] alive:', !!sessionLayer.getSshClient(id))
|
||||
console.error('[debug] exits:', JSON.stringify(events.filter((e) => e.channel === 'pty:exit')))
|
||||
console.error('[debug] all events:', JSON.stringify(events.map((e) => e.channel)))
|
||||
throw err
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
console.log('[debug] alive after 500ms sleep:', !!sessionLayer.getSshClient(id))
|
||||
console.log('[debug] events so far:', JSON.stringify(events.map((e) => e.channel)))
|
||||
const uploaded = await sftpMod.listRemote(id, dir)
|
||||
const entry = uploaded.find((e) => e.name.endsWith('.bin'))
|
||||
if (!entry || entry.size !== Buffer.byteLength(content)) fail(`upload size mismatch: ${entry?.size}`)
|
||||
console.log('[sftp] upload ok,', entry.size, 'bytes')
|
||||
|
||||
const dlDir = `./.sftp-dl-${Date.now()}`
|
||||
await fsp.mkdir(dlDir, { recursive: true })
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('download timeout 60s')), 60000)
|
||||
sftpMod.downloadRemote(id, [entry.path], dlDir, (e) => {
|
||||
if (e.state === 'done') { clearTimeout(timer); resolve() }
|
||||
if (e.state === 'error') { clearTimeout(timer); reject(new Error(e.error ?? 'download error')) }
|
||||
})
|
||||
})
|
||||
const back = await fsp.readFile(`${dlDir}/${entry.name}`, 'utf8')
|
||||
if (back !== content) fail('download content mismatch')
|
||||
console.log('[sftp] download round-trip ok')
|
||||
|
||||
await sftpMod.deleteRemote(id, [dir])
|
||||
try {
|
||||
await sftpMod.listRemote(dir)
|
||||
fail('dir still exists after delete')
|
||||
} catch {
|
||||
// expected — dir gone
|
||||
}
|
||||
console.log('[sftp] recursive delete ok')
|
||||
|
||||
const exits = events.filter((e) => e.channel === 'pty:exit')
|
||||
console.log('[sftp] unexpected exits:', exits.length)
|
||||
|
||||
await fsp.rm(localFile, { force: true })
|
||||
await fsp.rm(dlDir, { recursive: true, force: true })
|
||||
sessionLayer.killPty(id)
|
||||
console.log('[sftp] ALL CHECKS PASSED')
|
||||
setTimeout(() => process.exit(0), 300)
|
||||
Reference in new issue
Block a user