This commit is contained in:
Raven Scott
2026-04-03 18:46:37 -04:00
parent c02ffc6021
commit 7c9c9ae83c
19 changed files with 675 additions and 52 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ HTTP in Pear/Bare uses **WHATWG Fetch** (Node `fetch` or **`bare-fetch`** on `ba
| `--connect-timeout`, `-Y`, `-y` | out of scope | Fetch exposes one abort timer |
| TLS client certs, `--cacert`, `-k` | out of scope | Stack / trust store |
| HTTP/2, HTTP/3, SOCKS, FTP, SCP | out of scope | |
| `-J` content-disposition filename | stub / future | Not implemented |
| `-J` content-disposition filename | supported | With **`-O`**; uses **`Content-Disposition`** basename only; rejects **`..`** and absolute paths in the server-provided name |
## wget ([`lib/wget-cli.js`](lib/wget-cli.js))
+71
View File
@@ -4,6 +4,7 @@
*/
import b4a from 'b4a'
import path from 'path'
import {
DEFAULT_CURL_USER_AGENT,
defaultFetchSaveName,
@@ -45,6 +46,49 @@ function basicAuthHeader(user, pass) {
throw new Error('curl: cannot encode Basic auth (no btoa/Buffer)')
}
/**
* RFC 2183 / 5987 style Content-Disposition filename (best-effort).
* @param {string | null} headerVal
* @returns {string | null} basename-safe segment or null
*/
export function filenameFromContentDisposition(headerVal) {
if (!headerVal || typeof headerVal !== 'string') return null
const h = headerVal.trim()
const star = h.match(/filename\*\s*=\s*(?:UTF-8''|utf-8'')?([^;\s]+)/i)
if (star) {
try {
const raw = star[1].replace(/^["']|["']$/g, '')
const decoded = decodeURIComponent(raw)
const base = decoded.split(/[/\\]/).pop()
return base || null
} catch {
const base = star[1].split(/[/\\]/).pop()
return base || null
}
}
const q = h.match(/filename\s*=\s*"((?:\\.|[^"\\])*)"/i)
if (q) {
const inner = q[1].replace(/\\(.)/g, '$1')
const base = inner.split(/[/\\]/).pop()
return base || null
}
const plain = h.match(/filename\s*=\s*([^;\s]+)/i)
if (plain) {
const base = plain[1].replace(/^["']|["']$/g, '').split(/[/\\]/).pop()
return base || null
}
return null
}
/** Reject absolute paths and `..` segments from server-provided names (-J). */
function safeRemoteFilename(name) {
if (!name || typeof name !== 'string') return null
const t = name.trim()
if (!t || t.includes('..') || path.posix.isAbsolute(t)) return null
const base = path.posix.basename(t)
return base || null
}
function usage() {
return (
'usage: curl [options] URL...\n' +
@@ -170,6 +214,8 @@ export async function runCurlCli(ctx, argv) {
/** @type {string | null} */
let outputPath = null
let remoteName = false
/** Use Content-Disposition filename with -O (curl -J); server-controlled path — only save under trusted cwd. */
let remoteHeaderName = false
/** @type {string | null} */
let writeOut = null
let maxTimeMs = 0
@@ -327,6 +373,12 @@ export async function runCurlCli(ctx, argv) {
continue
}
if (a === '-J' || a === '--remote-header-name') {
remoteHeaderName = true
i++
continue
}
if (a === '-T' || a === '--upload-file') {
if (i + 1 >= args.length) {
ctx.console.error('curl: option requires an argument: ' + a)
@@ -447,6 +499,9 @@ export async function runCurlCli(ctx, argv) {
case 'O':
remoteName = true
break
case 'J':
remoteHeaderName = true
break
default:
ctx.console.error('curl: invalid option -- ' + c)
ctx.exitCode = 2
@@ -480,6 +535,14 @@ export async function runCurlCli(ctx, argv) {
return
}
if (remoteHeaderName && !remoteName) {
ctx.console.error(
'curl: -J / --remote-header-name requires -O / --remote-name'
)
ctx.exitCode = 2
return
}
let m = method
if (!m) {
if (headOnly) m = 'HEAD'
@@ -608,6 +671,14 @@ export async function runCurlCli(ctx, argv) {
lastUrl = res.url || url
if (remoteName && remoteHeaderName && res.ok && m !== 'HEAD') {
const raw = filenameFromContentDisposition(
res.headers.get('Content-Disposition')
)
const safe = raw ? safeRemoteFilename(raw) : null
if (safe) outForUrl = safe
}
if (!location && res.status >= 300 && res.status < 400) {
const loc = res.headers.get('Location')
if (loc && (m === 'GET' || m === 'HEAD')) {
+80 -2
View File
@@ -101,12 +101,27 @@ Commands:
stash (push|pop|list|apply|drop)
reset [--hard] [commit]
cherry-pick <commit>
clean -fd Remove untracked (not implemented)
clean -fd Remove untracked files (-f required); -d prunes empty dirs
hash-object|rev-parse|ls-files|diff (limited)
See https://isomorphic-git.org for API behavior.`)
}
/**
* True if argv contains `-x` or a bundled short-flag group like `-fd` (git-style).
* @param {string[]} argv
* @param {string} letter
*/
function argvHasShortFlag(argv, letter) {
for (const a of argv) {
if (a === '-' + letter) return true
if (a.startsWith('-') && !a.startsWith('--') && a.length > 1) {
if (a.slice(1).includes(letter)) return true
}
}
return false
}
/**
* @param {string[]} args
* @returns {{ rest: string[], dir: string, gitdir?: string }}
@@ -474,7 +489,70 @@ export async function runGitCli(ctx, argv) {
}
if (sub === 'clean') {
ctx.console.error('git clean: not implemented; remove files via shell rm')
const force =
tail.includes('-f') ||
tail.includes('--force') ||
argvHasShortFlag(tail, 'f')
const dirs = tail.includes('-d') || argvHasShortFlag(tail, 'd')
if (!force) {
ctx.console.error(
'git clean: refusing without -f (would remove untracked files)'
)
return
}
const matrix = await git.statusMatrix({ ...baseOpts() })
/** Untracked `??` per isomorphic-git statusMatrix (HEAD absent, WORKDIR present, stage absent). */
const untracked = matrix.filter(
([_fp, head, workdir, stage]) =>
head === 0 && workdir === 2 && stage === 0
)
if (untracked.length === 0) {
ctx.console.log('Nothing to clean')
return
}
const rels = untracked.map(([fp]) => fp).sort((a, b) => b.length - a.length)
/** @type {Set<string>} */
const parents = new Set()
for (const rel of rels) {
const full = path.posix.join(dir, rel)
try {
const st = await fs.promises.stat(full)
if (st.isDirectory()) {
if (dirs) {
await fs.promises.rm(full, { recursive: true, force: true })
let parent = path.posix.dirname(rel)
while (parent && parent !== '.' && parent !== '/') {
parents.add(parent)
parent = path.posix.dirname(parent)
}
}
} else {
await fs.promises.unlink(full)
let parent = path.posix.dirname(rel)
while (parent && parent !== '.' && parent !== '/') {
parents.add(parent)
parent = path.posix.dirname(parent)
}
}
} catch (e) {
ctx.console.error('git clean: ' + rel + ': ' + (e?.message || e))
}
}
if (dirs && parents.size) {
const ordered = [...parents].sort(
(a, b) => b.split('/').length - a.split('/').length
)
for (const p of ordered) {
const full = path.posix.join(dir, p)
try {
const names = await fs.promises.readdir(full)
if (names && names.length === 0) await fs.promises.rmdir(full)
} catch {
/* not empty or gone */
}
}
}
ctx.console.log(`Removed ${rels.length} untracked path(s)`)
return
}
+177 -4
View File
@@ -830,6 +830,176 @@ async function execParsedPipeline(ctx, pipeline) {
return 'ok'
}
/**
* Split on `;` only at depth 0 (`if` / `fi` nesting).
* @param {Token[]} tokens
* @returns {Token[][]}
*/
function splitTopLevelStatements(tokens) {
/** @type {Token[][]} */
const out = []
/** @type {Token[]} */
let cur = []
let depth = 0
for (const t of tokens) {
if (t.type === 'word') {
if (t.value === 'if') depth++
else if (t.value === 'fi') depth = Math.max(0, depth - 1)
}
if (t.type === 'op' && t.value === ';' && depth === 0) {
if (cur.length) out.push(cur)
cur = []
} else {
cur.push(t)
}
}
if (cur.length) out.push(cur)
return out
}
/**
* @param {Token[]} tokens
* @param {number} start index after `if`
*/
function findThenIndex(tokens, start) {
let d = 1
for (let j = start; j < tokens.length; j++) {
const t = tokens[j]
if (t.type !== 'word') continue
if (t.value === 'if') d++
else if (t.value === 'fi') d--
else if (t.value === 'then' && d === 1) return j
}
return -1
}
/**
* After `then`, find `else` at depth 1 or closing `fi` at depth 0.
* @param {Token[]} tokens
* @param {number} start index after `then` token
* @returns {{ kind: 'else' | 'fi', idx: number } | null}
*/
function findElseOrFiAfterThen(tokens, start) {
let d = 1
for (let j = start; j < tokens.length; j++) {
const t = tokens[j]
if (t.type !== 'word') continue
if (t.value === 'if') d++
else if (t.value === 'fi') {
d--
if (d === 0) return { kind: 'fi', idx: j }
} else if (t.value === 'else' && d === 1) return { kind: 'else', idx: j }
}
return null
}
/**
* @param {Token[]} tokens
* @param {number} start index after `else`
*/
function findFiAfterElse(tokens, start) {
let d = 1
for (let j = start; j < tokens.length; j++) {
const t = tokens[j]
if (t.type !== 'word') continue
if (t.value === 'if') d++
else if (t.value === 'fi') {
d--
if (d === 0) return j
}
}
return -1
}
/**
* `if` COMPOUND `then` COMPOUND [ `else` COMPOUND ] `fi`
* Condition / branch bodies use the same `&&` / `||` / `|` rules as a normal line.
* @param {Record<string, unknown>} ctx
* @param {Token[]} tokens
* @returns {Promise<'exit' | 'ok'>}
*/
/**
* Run `;`-separated lists (same as outside `if`); last command sets exit status.
* @param {Record<string, unknown>} ctx
* @param {Token[]} toks
*/
async function execSemicolonLists(ctx, toks) {
const lists = splitTokensBySemicolon(toks)
for (const list of lists) {
if (!list.length) continue
const r = await execAndOrList(ctx, list)
if (r === 'exit') return 'exit'
}
return 'ok'
}
async function execIfConstruct(ctx, tokens) {
const thenIdx = findThenIndex(tokens, 1)
if (thenIdx < 0) {
ctx.console.error('shell: syntax error: if without matching then')
ctx.exitCode = 2
return 'ok'
}
const cond = tokens.slice(1, thenIdx)
const tail = findElseOrFiAfterThen(tokens, thenIdx + 1)
if (!tail) {
ctx.console.error('shell: syntax error: if without fi')
ctx.exitCode = 2
return 'ok'
}
let closingFiIdx
if (tail.kind === 'fi') {
closingFiIdx = tail.idx
} else {
closingFiIdx = findFiAfterElse(tokens, tail.idx + 1)
if (closingFiIdx < 0) {
ctx.console.error('shell: syntax error: else without fi')
ctx.exitCode = 2
return 'ok'
}
}
if (closingFiIdx !== tokens.length - 1) {
ctx.console.error('shell: syntax error: unexpected tokens after fi')
ctx.exitCode = 2
return 'ok'
}
const condLists = splitTokensBySemicolon(cond).filter((c) => c.length)
if (!condLists.length) {
ctx.console.error('shell: invalid null command')
ctx.exitCode = 2
return 'ok'
}
for (const c of condLists) {
const r = await execAndOrList(ctx, c)
if (r === 'exit') return 'exit'
}
const condOk = (Number(ctx.exitCode) || 0) === 0
if (tail.kind === 'fi') {
const thenToks = tokens.slice(thenIdx + 1, tail.idx)
if (condOk) {
const r = await execSemicolonLists(ctx, thenToks)
if (r === 'exit') return 'exit'
} else {
ctx.exitCode = 0
}
return 'ok'
}
const elseIdx = tail.idx
const thenToks = tokens.slice(thenIdx + 1, elseIdx)
const elseToks = tokens.slice(elseIdx + 1, closingFiIdx)
if (condOk) {
const r = await execSemicolonLists(ctx, thenToks)
if (r === 'exit') return 'exit'
} else {
const r = await execSemicolonLists(ctx, elseToks)
if (r === 'exit') return 'exit'
}
return 'ok'
}
/**
* @param {Record<string, unknown>} ctx
* @param {Token[]} tokens
@@ -880,10 +1050,13 @@ export async function execShellLine(ctx, line) {
return 'ok'
}
const lists = splitTokensBySemicolon(tokens)
for (const listTok of lists) {
if (!listTok.length) continue
const r = await execAndOrList(ctx, listTok)
const statements = splitTopLevelStatements(tokens)
for (const stmt of statements) {
if (!stmt.length) continue
const r =
stmt[0]?.type === 'word' && stmt[0].value === 'if'
? await execIfConstruct(ctx, stmt)
: await execAndOrList(ctx, stmt)
if (r === 'exit') {
syncBareOsExitStatusEnv(ctx)
return 'exit'
+114
View File
@@ -51,6 +51,7 @@ import {
DEFAULT_CURL_USER_AGENT,
DEFAULT_WGET_USER_AGENT
} from './lib/http-fetch-url.js'
import { filenameFromContentDisposition } from './lib/curl-cli.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
function headerFromInit(init, name) {
@@ -808,6 +809,67 @@ async function run(ctx, argv) {
rmSync(dir, { recursive: true, force: true })
})
test('execShellLine if then else fi', async (t) => {
const dir = testCorestoreDir('shif')
const store = new Corestore(dir)
const drive = new Hyperdrive(store)
const personal = new Hyperdrive(store.namespace('sif'))
await drive.ready()
await personal.ready()
await drive.put(
'/bin/echo',
b4a.from(`
async function run(ctx, argv) {
ctx.console.log(argv.slice(1).join(' '))
}
`)
)
await drive.put(
'/bin/true',
b4a.from(`
async function run(ctx) {
ctx.exitCode = 0
}
`)
)
await drive.put(
'/bin/false',
b4a.from(`
async function run(ctx) {
ctx.exitCode = 1
}
`)
)
const lines = []
const ctx = testCtx(drive, personal)
ctx.console = {
log: (s) => lines.push(String(s)),
error: (...a) => lines.push(a.join(' '))
}
await execShellLine(ctx, 'if false; then echo no; else echo yes; fi')
t.ok(lines.some((l) => l.includes('yes')))
lines.length = 0
await execShellLine(ctx, 'if true; then echo ok; fi')
t.ok(lines.some((l) => l.includes('ok')))
lines.length = 0
await execShellLine(ctx, 'if false; then echo x; fi')
t.ok(!lines.some((l) => l.includes('x')))
await store.close()
rmSync(dir, { recursive: true, force: true })
})
test('filenameFromContentDisposition parses attachment names', async (t) => {
t.is(
filenameFromContentDisposition('attachment; filename="a b.txt"'),
'a b.txt'
)
t.is(
filenameFromContentDisposition("attachment; filename*=UTF-8''x%20y.bin"),
'x y.bin'
)
t.is(filenameFromContentDisposition(null), null)
})
test('cron fieldMatches and dowFieldMatches', async (t) => {
t.ok(fieldMatches('*', 0, 0, 59))
t.ok(fieldMatches('*/5', 10, 0, 59))
@@ -1592,6 +1654,58 @@ test('runGitCli init and status on personal drive', async (t) => {
rmSync(dir, { recursive: true, force: true })
})
test('git clean -fd removes untracked files only', async (t) => {
const dir = testCorestoreDir('gitclean')
const store = new Corestore(dir)
const drive = new Hyperdrive(store)
const personal = new Hyperdrive(store.namespace('pgcl'))
await drive.ready()
await personal.ready()
const ctx = testCtx(drive, personal)
ctx.console = { log() {}, error() {} }
await runGitCli(ctx, ['git', 'init', '-C', '/home/user/clrepo'])
await ctx.vfs.writeFile('/home/user/clrepo/a.txt', b4a.from('a'))
await runGitCli(ctx, ['git', '-C', '/home/user/clrepo', 'add', 'a.txt'])
await runGitCli(ctx, ['git', '-C', '/home/user/clrepo', 'commit', '-m', 'init'])
await ctx.vfs.writeFile('/home/user/clrepo/junk.txt', b4a.from('j'))
await runGitCli(ctx, ['git', '-C', '/home/user/clrepo', 'clean', '-fd'])
t.is(await ctx.vfs.readFile('/home/user/clrepo/junk.txt'), null)
t.ok(await ctx.vfs.readFile('/home/user/clrepo/a.txt'))
await store.close()
rmSync(dir, { recursive: true, force: true })
})
test('curl -O -J saves Content-Disposition filename', async (t) => {
const dir = testCorestoreDir('curlj')
const store = new Corestore(dir)
const drive = new Hyperdrive(store)
const personal = new Hyperdrive(store.namespace('pcj'))
await drive.ready()
await personal.ready()
const ctx = testCtx(drive, personal)
ctx.exitCode = 0
ctx.httpFetch = async () =>
new Response(b4a.from('payload'), {
status: 200,
headers: {
'Content-Disposition': 'attachment; filename="from-server.bin"'
}
})
await runBinCommand(ctx, [
'curl',
'-s',
'-O',
'-J',
'https://stub.example/blob'
])
t.is(ctx.exitCode, 0)
const out = await ctx.vfs.readFile('/home/user/from-server.bin')
t.ok(out)
t.is(ctx.b4a.toString(out), 'payload')
await store.close()
rmSync(dir, { recursive: true, force: true })
})
test('kernel share/man/man.json page count matches coreutils + extras + handbook + devguide', async (t) => {
const manPath = path.join(__dirname, '../../kernel/share/man/man.json')
const raw = JSON.parse(await readFile(manPath, 'utf8'))
+12
View File
@@ -46,6 +46,18 @@ export function setupSeedChannel(mux, localRAM, replicateDrive) {
chan.addMessage({
encoding: msgRpcReqEncoding,
onmessage(m) {
if (m.module === 'bare_os' && m.method === 'version') {
chan.messages[6].send({
id: m.id,
success: true,
result: JSON.stringify({
protocol: PROTOCOL_NAME,
seederRpc: 'bare_os.version'
}),
error: ''
})
return
}
chan.messages[6].send({
id: m.id,
success: false,
@@ -1,4 +1,4 @@
# Optional boot-time shell lines (one command per non-comment line).
# Executed by /boot/init.js after /etc/os-release and this motd, before the main banner.
# Executed by /boot/init.js after /etc/os-release and /etc/motd, then /etc/bare-os/rc.d/*, before the main banner.
# Example (uncomment to use):
# export BARE_OS_SHOW_RC=1
@@ -0,0 +1,11 @@
# Optional boot snippets (trusted)
Files in this directory are executed after `/etc/bare-os/rc`, in **lexicographic
order** by filename. Use numeric prefixes (e.g. `10-local`, `20-proxy`) to
control order. Each file is treated like `rc`: one shell command per non-empty,
non-comment line.
Lines starting with `#` and blank lines are ignored. Dotfiles and names ending
in `~` are skipped.
Only ship snippets you trust — they run with full `execLine` power.
+91 -18
View File
@@ -1,31 +1,96 @@
/**
* Hyperdrive-resident kernel (staged as /boot/init.js).
* Loaded by the booter with an injected ctx object (trusted replication source).
*
* Boot order: /etc/os-release → /etc/motd → /etc/bare-os/rc → /etc/bare-os/rc.d/*
* (sorted by filename) → session banner → interactive loop.
* Use ctx.registerKernelShutdownHook(fn) for teardown before initd disposers.
*/
/**
* Print /etc/motd and run /etc/bare-os/rc lines (comments and blank lines skipped).
* @param {Record<string, unknown>} ctx
* @param {string} text
*/
async function runRcLines(ctx, text) {
const { execLine, console } = ctx
for (const line of text.split(/\r?\n/)) {
const t = line.trim()
if (!t || t.startsWith('#')) continue
try {
await execLine(t)
} catch (e) {
console.error((e && e.message) || String(e))
}
}
}
/**
* @param {Record<string, unknown>} ctx
*/
async function runEtcSnippets(ctx) {
const { drive, execLine, b4a, console } = ctx
async function printOsRelease(ctx) {
const { drive, b4a, console } = ctx
try {
const rel = await drive.get('/etc/os-release')
if (rel) console.log(b4a.toString(rel))
} catch (e) {
console.error((e && e.message) || String(e))
}
}
/**
* @param {Record<string, unknown>} ctx
*/
async function printMotd(ctx) {
const { drive, b4a, console } = ctx
try {
const motd = await drive.get('/etc/motd')
if (motd) console.log(b4a.toString(motd).trimEnd())
} catch (e) {
console.error((e && e.message) || String(e))
}
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} drivePath absolute path on system drive
* @param {string} label for errors
*/
async function runRcFileAt(ctx, drivePath, label) {
const { drive, b4a, console } = ctx
try {
const rc = await drive.get('/etc/bare-os/rc')
if (!rc) return
const text = b4a.toString(rc)
for (const line of text.split(/\r?\n/)) {
const t = line.trim()
if (!t || t.startsWith('#')) continue
const buf = await drive.get(drivePath)
if (!buf) return
await runRcLines(ctx, b4a.toString(buf))
} catch (e) {
console.error(`${label}: ` + ((e && e.message) || String(e)))
}
}
/**
* Optional snippets under /etc/bare-os/rc.d/ — executed in lexicographic order.
* Skips dotfiles and names ending in ~.
* @param {Record<string, unknown>} ctx
*/
async function runBareOsRcDir(ctx) {
const { drive, b4a, console } = ctx
try {
/** @type {string[]} */
const names = []
try {
for await (const n of drive.readdir('/etc/bare-os/rc.d')) names.push(n)
} catch {
return
}
names.sort()
for (const name of names) {
if (!name || name.startsWith('.') || name.endsWith('~')) continue
const p = `/etc/bare-os/rc.d/${name}`
try {
await execLine(t)
const buf = await drive.get(p)
if (!buf) continue
await runRcLines(ctx, b4a.toString(buf))
} catch (e) {
console.error((e && e.message) || String(e))
console.error(`rc.d/${name}: ` + ((e && e.message) || String(e)))
}
}
} catch (e) {
@@ -33,14 +98,22 @@ async function runEtcSnippets(ctx) {
}
}
async function start(ctx) {
const { console, drive, readLine, execLine, b4a } = ctx
const rel = await drive.get('/etc/os-release')
if (rel) console.log(b4a.toString(rel))
await runEtcSnippets(ctx)
console.log(
'Bare operating system — session: guest (login [--new] <passphrase> to unlock identity) | shell: cd, export, && || ;, exit, login, logout | try: help, getconf PATH_MAX, ls /bin, pwd, crontab -l'
/**
* @param {Record<string, unknown>} ctx
*/
function printSessionBanner(ctx) {
ctx.console.log(
'Bare operating system — guest session (login [--new] <passphrase> to unlock) | shell: cd, export, if/fi, && || ;, |, exit | services: systemctl list-units, journalctl -u UNIT | try: help, ls /bin, crontab -l'
)
}
async function start(ctx) {
const { readLine, execLine, console } = ctx
await printOsRelease(ctx)
await printMotd(ctx)
await runRcFileAt(ctx, '/etc/bare-os/rc', 'rc')
await runBareOsRcDir(ctx)
printSessionBanner(ctx)
while (true) {
const line = await readLine('')
if (line == null) break
File diff suppressed because one or more lines are too long