377 lines
9.6 KiB
JavaScript
377 lines
9.6 KiB
JavaScript
/**
|
|
* Hardening regressions for high-traffic /bin utilities.
|
|
*/
|
|
import { readFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import test from 'brittle'
|
|
import b4a from 'b4a'
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
|
|
|
async function loadBin(name) {
|
|
const runtime = await readFile(path.join(__dirname, '../lib/runtime.js'), 'utf8')
|
|
const bodyRaw = await readFile(path.join(__dirname, `../src/${name}.js`), 'utf8')
|
|
const body = bodyRaw.replace(/^export\s+\{[^}]*\}\s*;?\s*$/gm, '')
|
|
return new AsyncFunction(
|
|
'ctx',
|
|
'argv',
|
|
`${runtime}\n${body}\nif (typeof run === 'function') return await run(ctx, argv)\n`
|
|
)
|
|
}
|
|
|
|
function mkCtx(vfs = {}) {
|
|
const logs = []
|
|
const errs = []
|
|
const raw = []
|
|
return {
|
|
b4a,
|
|
exitCode: 0,
|
|
shellStdin: '',
|
|
console: {
|
|
log: (m) => logs.push(String(m)),
|
|
error: (m) => errs.push(String(m))
|
|
},
|
|
vfs: { env: {}, ...vfs },
|
|
bareOsBinWrite: (u8) => {
|
|
raw.push(u8 instanceof Uint8Array ? u8 : b4a.from(u8))
|
|
},
|
|
_logs: logs,
|
|
_errs: errs,
|
|
_raw: raw
|
|
}
|
|
}
|
|
|
|
function memVfs(files = {}) {
|
|
const store = { ...files }
|
|
return {
|
|
env: {},
|
|
files: store,
|
|
resolveLogical: (p) => String(p || ''),
|
|
async lstat(p) {
|
|
if (!Object.prototype.hasOwnProperty.call(store, p)) return null
|
|
const e = store[p]
|
|
if (e && e.stat) return e.stat
|
|
return { type: 'file', mode: 0o644, mtimeMs: 1, size: e ? e.length : 0 }
|
|
},
|
|
async stat(p) {
|
|
return this.lstat(p)
|
|
},
|
|
async readFile(p) {
|
|
if (!Object.prototype.hasOwnProperty.call(store, p)) return null
|
|
const e = store[p]
|
|
if (e && e.body) return e.body
|
|
return e
|
|
},
|
|
async writeFile(p, buf, opts) {
|
|
store[p] = buf
|
|
return opts
|
|
},
|
|
async unlink(p) {
|
|
if (!Object.prototype.hasOwnProperty.call(store, p)) {
|
|
const err = new Error('ENOENT')
|
|
err.code = 'ENOENT'
|
|
throw err
|
|
}
|
|
delete store[p]
|
|
},
|
|
async mkdir(p) {
|
|
store[p] = { stat: { type: 'directory', mode: 0o755, mtimeMs: 1 } }
|
|
},
|
|
async readdir(p) {
|
|
const prefix = String(p).replace(/\/+$/, '') + '/'
|
|
const names = new Set()
|
|
for (const k of Object.keys(store)) {
|
|
if (k.startsWith(prefix)) {
|
|
const rest = k.slice(prefix.length)
|
|
if (rest && !rest.includes('/')) names.add(rest)
|
|
}
|
|
}
|
|
return [...names]
|
|
},
|
|
async rm(p) {
|
|
if (!Object.prototype.hasOwnProperty.call(store, p)) {
|
|
const err = new Error('ENOENT')
|
|
err.code = 'ENOENT'
|
|
throw err
|
|
}
|
|
delete store[p]
|
|
},
|
|
async chmod() {
|
|
return true
|
|
},
|
|
async chown() {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
test('mv refuses self-move', async (t) => {
|
|
const run = await loadBin('mv')
|
|
const vfs = memVfs({ '/a': b4a.from('keep') })
|
|
const ctx = mkCtx(vfs)
|
|
await run(ctx, ['mv', '/a', '/a'])
|
|
t.is(ctx.exitCode, 1)
|
|
t.ok(vfs.files['/a'])
|
|
t.ok(ctx._errs.join('\n').includes('cannot move'))
|
|
})
|
|
|
|
test('cp refuses copy into self', async (t) => {
|
|
const run = await loadBin('cp')
|
|
const vfs = memVfs({
|
|
'/dir': { stat: { type: 'directory', mode: 0o755 } },
|
|
'/dir/f': b4a.from('x')
|
|
})
|
|
const ctx = mkCtx(vfs)
|
|
await run(ctx, ['cp', '-R', '/dir', '/dir'])
|
|
t.is(ctx.exitCode, 1)
|
|
t.ok(ctx._errs.join('\n').includes('itself'))
|
|
})
|
|
|
|
test('touch does not replace a directory with an empty file', async (t) => {
|
|
const run = await loadBin('touch')
|
|
const vfs = memVfs({
|
|
'/d': { stat: { type: 'directory', mode: 0o755, mtimeMs: 1 } }
|
|
})
|
|
const ctx = mkCtx(vfs)
|
|
await run(ctx, ['touch', '/d'])
|
|
t.is(ctx.exitCode, 0)
|
|
t.is(vfs.files['/d'].stat.type, 'directory')
|
|
})
|
|
|
|
test('truncate does not clobber unread existing file', async (t) => {
|
|
const run = await loadBin('truncate')
|
|
const vfs = memVfs({
|
|
'/secret': { stat: { type: 'file', mode: 0o600, mtimeMs: 1 } }
|
|
})
|
|
vfs.readFile = async () => null
|
|
const ctx = mkCtx(vfs)
|
|
await run(ctx, ['truncate', '-s', '0', '/secret'])
|
|
t.is(ctx.exitCode, 1)
|
|
t.ok(ctx._errs.join('\n').includes('cannot read'))
|
|
})
|
|
|
|
test('rm -i is rejected rather than deleting', async (t) => {
|
|
const run = await loadBin('rm')
|
|
const vfs = memVfs({ '/f': b4a.from('x') })
|
|
const ctx = mkCtx(vfs)
|
|
await run(ctx, ['rm', '-i', '/f'])
|
|
t.is(ctx.exitCode, 1)
|
|
t.ok(vfs.files['/f'])
|
|
t.ok(ctx._errs.join('\n').includes('invalid option'))
|
|
})
|
|
|
|
test('rm -f with no operands exits 0', async (t) => {
|
|
const run = await loadBin('rm')
|
|
const ctx = mkCtx(memVfs())
|
|
await run(ctx, ['rm', '-f'])
|
|
t.is(ctx.exitCode, 0)
|
|
})
|
|
|
|
test('head -n 0 prints nothing; missing file exits 1', async (t) => {
|
|
const run = await loadBin('head')
|
|
const ctx = mkCtx({
|
|
env: {},
|
|
async readFile() {
|
|
return b4a.from('a\nb\n')
|
|
}
|
|
})
|
|
ctx.shellStdin = 'a\nb\n'
|
|
await run(ctx, ['head', '-n', '0'])
|
|
t.is(ctx.exitCode, 0)
|
|
t.is(ctx._logs.join(''), '')
|
|
|
|
const ctx2 = mkCtx({
|
|
env: {},
|
|
async readFile() {
|
|
return null
|
|
}
|
|
})
|
|
await run(ctx2, ['head', '/missing'])
|
|
t.is(ctx2.exitCode, 1)
|
|
})
|
|
|
|
test('wc missing file exits 1', async (t) => {
|
|
const run = await loadBin('wc')
|
|
const ctx = mkCtx({
|
|
env: {},
|
|
async readFile() {
|
|
return null
|
|
}
|
|
})
|
|
await run(ctx, ['wc', '/missing'])
|
|
t.is(ctx.exitCode, 1)
|
|
})
|
|
|
|
test('du missing path exits 1', async (t) => {
|
|
const run = await loadBin('du')
|
|
const ctx = mkCtx({
|
|
env: {},
|
|
resolveLogical: (p) => p,
|
|
async lstat() {
|
|
return null
|
|
},
|
|
async stat() {
|
|
return null
|
|
}
|
|
})
|
|
await run(ctx, ['du', '/nope'])
|
|
t.is(ctx.exitCode, 1)
|
|
t.ok(ctx._errs.join('\n').includes('No such file'))
|
|
})
|
|
|
|
test('mkdir without -p fails if directory exists', async (t) => {
|
|
const run = await loadBin('mkdir')
|
|
const ctx = mkCtx({
|
|
env: {},
|
|
async lstat() {
|
|
return { type: 'directory' }
|
|
},
|
|
async mkdir() {
|
|
throw new Error('should not mkdir')
|
|
}
|
|
})
|
|
await run(ctx, ['mkdir', '/already'])
|
|
t.is(ctx.exitCode, 1)
|
|
t.ok(ctx._errs.join('\n').includes('File exists'))
|
|
})
|
|
|
|
test('base64 decode uses portable alphabet (not Node Buffer)', async (t) => {
|
|
const run = await loadBin('base64')
|
|
const ctx = mkCtx()
|
|
ctx.b4a = {
|
|
from: (s) => new TextEncoder().encode(String(s)),
|
|
toString: (u8) => new TextDecoder().decode(u8)
|
|
}
|
|
ctx.shellStdin = 'YWJj'
|
|
await run(ctx, ['base64', '-d'])
|
|
t.is(ctx.exitCode, 0)
|
|
t.is(new TextDecoder().decode(ctx._raw[0]), 'abc')
|
|
})
|
|
|
|
test('basenc hex decode rejects junk', async (t) => {
|
|
const run = await loadBin('basenc')
|
|
const ctx = mkCtx()
|
|
ctx.shellStdin = 'zzzz'
|
|
await run(ctx, ['basenc', '--base16', '-d'])
|
|
t.is(ctx.exitCode, 1)
|
|
t.ok(ctx._errs.join('\n').includes('invalid hex'))
|
|
})
|
|
|
|
test('seq -5 5 works and huge spans are capped', async (t) => {
|
|
const run = await loadBin('seq')
|
|
const ctx = mkCtx()
|
|
await run(ctx, ['seq', '-5', '5'])
|
|
t.is(ctx.exitCode, 0)
|
|
t.ok(ctx._logs.includes('-5'))
|
|
t.ok(ctx._logs.includes('5'))
|
|
|
|
const ctx2 = mkCtx({ env: { BARE_OS_SEQ_MAX_LINES: '10' } })
|
|
await run(ctx2, ['seq', '1', '100'])
|
|
t.is(ctx2.exitCode, 1)
|
|
t.ok(ctx2._errs.join('\n').includes('exceeds'))
|
|
})
|
|
|
|
test('grep -m 0 matches nothing', async (t) => {
|
|
const run = await loadBin('grep')
|
|
const ctx = mkCtx()
|
|
ctx.shellStdin = 'foo\nbar\n'
|
|
await run(ctx, ['grep', '-m', '0', 'foo'])
|
|
t.is(ctx.exitCode, 1)
|
|
t.is(ctx._logs.join(''), '')
|
|
})
|
|
|
|
test('expr zero result exits 1', async (t) => {
|
|
const run = await loadBin('expr')
|
|
const ctx = mkCtx()
|
|
await run(ctx, ['expr', '1', '-', '1'])
|
|
t.is(ctx.exitCode, 1)
|
|
t.is(ctx._logs.join(''), '0')
|
|
})
|
|
|
|
test('cmp missing file exits 2', async (t) => {
|
|
const run = await loadBin('cmp')
|
|
const ctx = mkCtx({
|
|
async readFile() {
|
|
return null
|
|
}
|
|
})
|
|
await run(ctx, ['cmp', '/a', '/b'])
|
|
t.is(ctx.exitCode, 2)
|
|
})
|
|
|
|
test('pathchk empty path exits 1', async (t) => {
|
|
const run = await loadBin('pathchk')
|
|
const ctx = mkCtx()
|
|
await run(ctx, ['pathchk', ''])
|
|
t.is(ctx.exitCode, 1)
|
|
})
|
|
|
|
test('chmod rejects garbage symbolic mode', async (t) => {
|
|
const run = await loadBin('chmod')
|
|
const ctx = mkCtx({
|
|
async stat() {
|
|
return { type: 'file', mode: 0o644 }
|
|
},
|
|
async chmod() {
|
|
return true
|
|
}
|
|
})
|
|
await run(ctx, ['chmod', 'garbage', '/f'])
|
|
t.is(ctx.exitCode, 1)
|
|
t.ok(ctx._errs.join('\n').includes('invalid mode'))
|
|
})
|
|
|
|
test('sum empty file reports 0 blocks', async (t) => {
|
|
const run = await loadBin('sum')
|
|
const ctx = mkCtx()
|
|
ctx.shellStdin = ''
|
|
await run(ctx, ['sum'])
|
|
t.is(ctx.exitCode, 0)
|
|
t.ok(ctx._logs[0].startsWith('0\t0\t'))
|
|
})
|
|
|
|
test('hostname set does not throw', async (t) => {
|
|
const run = await loadBin('hostname')
|
|
const ctx = mkCtx()
|
|
await run(ctx, ['hostname', '-S', 'box'])
|
|
t.is(ctx.exitCode, 1)
|
|
t.ok(ctx._errs.join('\n').includes('setting disabled'))
|
|
})
|
|
|
|
test('sleep missing operand exits 1', async (t) => {
|
|
const run = await loadBin('sleep')
|
|
const ctx = mkCtx()
|
|
await run(ctx, ['sleep'])
|
|
t.is(ctx.exitCode, 1)
|
|
})
|
|
|
|
test('factor rejects non-integers', async (t) => {
|
|
const run = await loadBin('factor')
|
|
const ctx = mkCtx()
|
|
await run(ctx, ['factor', 'foo'])
|
|
t.is(ctx.exitCode, 1)
|
|
})
|
|
|
|
test('tsort odd token count exits 1', async (t) => {
|
|
const run = await loadBin('tsort')
|
|
const ctx = mkCtx()
|
|
ctx.shellStdin = 'a b c'
|
|
await run(ctx, ['tsort'])
|
|
t.is(ctx.exitCode, 1)
|
|
})
|
|
|
|
test('unlink -- -foo unlinks dashed name', async (t) => {
|
|
const run = await loadBin('unlink')
|
|
const seen = []
|
|
const ctx = mkCtx({
|
|
async unlink(p) {
|
|
seen.push(p)
|
|
}
|
|
})
|
|
await run(ctx, ['unlink', '--', '-foo'])
|
|
t.is(ctx.exitCode, 0)
|
|
t.alike(seen, ['-foo'])
|
|
})
|