Files
bare-operating-system/packages/bare-os-booter/lib/curl-cli.js
T
Raven Scott d286ce19b5 chore(plan): cancel end-to-end seeder-to-booter smoke harness task
test(protocol): add deterministic MBR failover-key coverage
docs(protocol): align package-bare-os-protocol version to 0.9.1
test(booter): add MBR corruption and wrong-topic smoke fixtures
test(peer-seed): add strict pre-MBR bare_os.capabilities negotiation check
feat(seeder): validate BARE_OS_SEED_REQUIRE_MBR_LABELS
feat(seeder): validate BARE_OS_SEED_CAPABILITY_ATTESTATION_JSON schema
docs(boot-policy): add requireProtocolPackageMin 0.9.1 example
test(kernel): cover boot.policy denySeedRpcMethods behavior
test(protocol): add app/cap/chat/meshdrop channel compatibility fixture
test(swarm-disk): cover duplicate Protomux channel null-return path
test(protocol): add 11-word kernelCapabilityWords round-trip fixture
docs(schema): add mbr-layout schema and validate seeder examples
test(protocol): add topicKey() golden hash fixture
docs(trust): document block-0 trust assumptions in boot docs
feat(seeder): add discovery.flushed readiness logging
feat(booter): record peer discovery timings in boot-perf.json
feat(integration): add local testnet mode to integration lab smoke
test(booter): add Hyperswarm connection-budget env regression coverage
test(booter): add swarm plus Corestore suspend/resume integration coverage
feat(booter): mirror swarm ban events into host audit logs
feat(booter): add direct-peer boot via BARE_OS_BOOT_JOIN_PEER_HEX
feat(seeder): pass BARE_OS_SEED_MAX_PEERS to Hyperswarm
feat(seeder): log drive.version and discoveryKey at startup
test(booter): add Hyperdrive.checkout read-only boot probe coverage
feat(booter): prefetch /boot/init.js before kernel handoff
feat(booter): add optional /bin warm replication via downloadDiff
feat(seeder): add manifestPaths SHA-256 generation in stage-kernel-tree
test(peer-seed): cover helper-served block-0 after seeder exit
feat(protocol): add Protomux cork batching for initial channel sends
test(boot-graph): compare kernel/init labels with booter graph proc
docs(boot-policy): add v9-v11 schema examples
feat(release): add requireInitJsSha256 fixture generation step
test(vfs): add BARE_OS_VFS_SYSTEM_RO_ALIAS coverage
test(vfs): strengthen system-drive write-deny path coverage
feat(identity): add personal-drive namespace export/import docs and tests
test(booter): add guest-to-login warm cache invalidation regression
test(vfs): add guest deny coverage for /.bare sensitive paths
test(coreutils): add cross-drive mv failure injection coverage
test(vfs): add .bareos_empty round-trip coverage across mkdir/rmdir/cp/git-fs
test(vfs): add /dev/shm quota enforcement coverage
test(proc): add /proc/bare_os/index.json sortedness and schema checks
test(vfs): add warm read cache invalidation on replication growth
docs(ctx): document bareOsInvalidateWarmReadCaches(reason)
test(kernel): add BARE_OS_BOOT_DRY_RUN behavior coverage
docs(posix): add dashboard rows for all COREUTILS_COMMANDS
feat(curl): expand -w variables beyond http_code/url_effective/size_download
feat(wget): mark -N timestamping as explicit unsupported error
feat(curl): plumb mutual TLS cert/key intent to ctx.httpFetch metadata
feat(shuf): add deterministic seed mode via BARE_OS_SHUF_SEED
docs(sort): document -M month-sort as unsupported
feat(grep): add explicit -E and -G mode handling
test(sed): add Open Group Issue 7 golden fixtures
test(awk): add getline VFS regressions for missing/repeat/boundary cases
test(shell): add non-interactive here-doc coverage
test(shell): add trap delivery coverage for synthetic PIDs/job IDs
test(shell): add set -e compound-body behavior coverage
docs(shell): strengthen read builtin opt-in guidance
test(env): add Bare-runtime coverage for -S and --env-file
docs(man): add examples for pathcap-verify pkg-swarm-index corestorectl
test(identity): add account/vault backup-restore smoke coverage
feat(audit): add tamper detection verification for audit chain rows
test(peer-admission): cover strict empty allowlist deny behavior
test(peer-admission): add denylist precedence over allowlist coverage
test(peer-admission): add BARE_OS_PEER_REQUIRE_CAPS_JSON metadata checks
docs(identity): add trusted-key rotation example for path capabilities
feat(schema): tighten extensionSignerPinsV2-V4 hash validation
test(delegate): add allowlist negative cases for curl/wget/git/hrpc/systemctl
test(proc): extend /proc/self/environ redaction key coverage
docs(security): add peer-assisted block-0 mirroring threat-model notes
feat(bench): add boot budget trend output from real booter phases
test(baretop): align fixture coverage with /proc snapshot key set
test(metrics): validate /proc/bare_os/metrics.prom OpenMetrics shape
docs(ops): add structured seeder NDJSON examples
test(replication): add live stall-hint coverage for no_peers/length_unavailable/ok
docs(release): add corestore-snapshot workflow to checklist
docs(ops): add mirror-drive experiment utility to maintainer workflow
test(booter): add monitor progress coverage for replication live sketch
feat(seeder): validate DHT bootstrap address class JSON inputs
docs(network): add HYPERSWARM_BOOTSTRAP testnet operator guidance
chore(root): add deterministic test:integration script
docs(ci): add local CI runbook for no-.github environments
docs(release): add npm run test:bare after npm test
feat(verify): add protocol docs/package version parity checker
feat(verify): enforce feature-roadmap canonical path consistency
feat(lockfile-drift): add tier-1 strict fail option for mismatches
docs(lockfile-drift): add udx-native and blind-peering upgrade workflow notes
docs(cli-parity): add bare-fetch upstream issue tracking row
feat(bundle-health): generate per-tier bundle size regression thresholds
feat(doc-contracts): verify handbook references to current proc schema versions
feat(pretest): add validate-mermaid-syntax gate
feat(probe): add bare-runtime top-25 critical command lane
docs(protocol): update capability-word prose from bits..bits5 to current words
docs(two-drive): document /tmp /var/log and account-prefix routing
docs(security): add concise boot trust model page and links
docs(dev-guide): add P2P lab cookbook section
docs(dev-guide): add how-to for adding seed RPCs
docs(dev-guide): add how-to for adding /proc/bare_os nodes
docs(dev-guide): add /bin utility checklist for man/posix/build/parity/tests
docs(user-manual): add short What BareOS is not section
2026-04-26 22:28:21 -04:00

1133 lines
30 KiB
JavaScript

/**
* Bare OS curl — HTTP(S) client using Fetch (not Daniel Stenberg's libcurl).
* @see https://curl.se/docs/manpage.html for flag inspiration; behavior is a subset.
*/
import b4a from 'b4a'
import path from '#host-path'
import {
DEFAULT_CURL_USER_AGENT,
defaultFetchSaveName,
isSupportedFetchUrl,
normalizeFetchUrl,
applyBareOsCurlResolveMap
} from './http-fetch-url.js'
import { assertFetchUrlHostAllowedByDnsPolicy } from './bare-os-dns-policy.js'
import {
ensureBareFetchGlobals,
resolveBareOsFetchFn
} from './bare-os-ensure-bare-fetch.js'
function basicAuthHeader(user, pass) {
const s = user + ':' + pass
if (typeof btoa === 'function') return 'Basic ' + btoa(s)
if (typeof Buffer !== 'undefined')
return 'Basic ' + Buffer.from(s, 'utf8').toString('base64')
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' +
'Bare OS curl is fetch-based, not libcurl. See man curl.'
)
}
/**
* Split -H line into headers[] or record User-Agent for argv-order precedence with -A.
* @param {string} line
* @param {string[]} headers
* @param {string[]} userAgentSequence
* @returns {boolean} false if malformed
*/
function pushCurlHeaderLine(line, headers, userAgentSequence) {
const colon = line.indexOf(':')
if (colon < 1) return false
const name = line.slice(0, colon).trim()
const value = line.slice(colon + 1).trim()
if (name.toLowerCase() === 'user-agent') userAgentSequence.push(value)
else headers.push(line)
return true
}
function utf8Encode(str) {
// Bare may not define TextEncoder; b4a works on Node and Bare.
return b4a.from(String(str), 'utf8')
}
/** @param {(string | Uint8Array)[]} parts */
function concatParts(parts) {
let len = 0
for (const p of parts) {
len += typeof p === 'string' ? utf8Encode(p).length : p.length
}
const out = new Uint8Array(len)
let o = 0
for (const p of parts) {
const u8 = typeof p === 'string' ? utf8Encode(p) : p
out.set(u8, o)
o += u8.length
}
return out
}
function expandWriteOut(fmt, vars) {
return fmt.replace(/%\{([^}]+)\}/g, (_, key) => {
if (Object.prototype.hasOwnProperty.call(vars, key))
return String(vars[key])
return ''
})
}
/**
* Best-effort URL pieces for curl -w expansions.
* @param {string} url
*/
function curlWriteOutUrlParts(url) {
try {
const u = new URL(String(url || ''))
return {
scheme: (u.protocol || '').replace(/:$/, ''),
host: u.hostname || '',
port: u.port || '',
path: (u.pathname || '/') + (u.search || '') + (u.hash || '')
}
} catch {
return { scheme: '', host: '', port: '', path: '' }
}
}
const CURL_MAX_REDIRECTS = 50
/** Default persistent jar on the personal drive (JSON per CLI_PARITY). */
export const BARE_OS_CURL_COOKIE_JAR = '~/.config/bare-os/curl/cookies.json'
function curlHostFromUrl(urlStr) {
try {
return new URL(urlStr).hostname
} catch {
return ''
}
}
/** @returns {Record<string, Record<string, string>>} */
function parseCookieJarJson(text) {
try {
const o = JSON.parse(text)
if (!o || typeof o !== 'object') return {}
/** @type {Record<string, Record<string, string>>} */
const out = {}
for (const [h, bag] of Object.entries(o)) {
if (!bag || typeof bag !== 'object') continue
out[String(h)] = {}
for (const [k, v] of Object.entries(bag)) {
out[String(h)][String(k)] = String(v)
}
}
return out
} catch {
return {}
}
}
/**
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
* @param {string} path
*/
async function loadCookieJarFile(vfs, path) {
const buf = await vfs.readFile(path)
if (!buf) return {}
return parseCookieJarJson(b4a.toString(buf, 'utf8'))
}
/**
* @param {Record<string, Record<string, string>>} jar
* @param {string} host
* @param {string} line
*/
function mergeInlineCookieLine(jar, host, line) {
if (!host) return
for (const part of line.split(';')) {
const p = part.trim()
const eq = p.indexOf('=')
if (eq < 1) continue
const name = p.slice(0, eq).trim()
const val = p.slice(eq + 1).trim()
if (!jar[host]) jar[host] = {}
jar[host][name] = val
}
}
/**
* @param {Record<string, Record<string, string>>} target
* @param {Record<string, Record<string, string>>} add
*/
function mergeCookieJars(target, add) {
for (const [h, bag] of Object.entries(add)) {
if (!target[h]) target[h] = {}
Object.assign(target[h], bag)
}
}
/**
* @param {Record<string, Record<string, string>>} jar
* @param {string} host
*/
function cookieHeaderForHost(jar, host) {
const bag = jar[host]
if (!bag || !Object.keys(bag).length) return ''
return Object.entries(bag)
.map(([k, v]) => `${k}=${v}`)
.join('; ')
}
/**
* @param {Record<string, Record<string, string>>} jar
* @param {string} host
* @param {string} headerVal
*/
function applySetCookieHeader(jar, host, headerVal) {
if (!host || !headerVal) return
const first = String(headerVal).split(';')[0].trim()
const eq = first.indexOf('=')
if (eq < 1) return
const name = first.slice(0, eq).trim()
const val = first.slice(eq + 1).trim()
if (!jar[host]) jar[host] = {}
jar[host][name] = val
}
/** @param {Response} res */
function gatherSetCookieValues(res) {
const h = res.headers
if (typeof h.getSetCookie === 'function') return h.getSetCookie()
const v = h.get('Set-Cookie')
return v ? [v] : []
}
/**
* TLS extras for fetch: Node undici `dispatcher`, or `bareOsCurlTls` when `ctx.httpFetch` is set.
* Host `httpFetch` may read `init.bareOsCurlTls` (`insecure`, optional `caPem`/`certPem`/`keyPem`).
* @param {Record<string, unknown>} ctx
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
* @param {{ insecure?: boolean, cacertPath?: string | null, certPath?: string | null, keyPath?: string | null }} opts
* @returns {Promise<{ dispatcher?: unknown, delegatedTls?: { insecure: boolean, caPem?: string, certPem?: string, keyPem?: string }, error?: string }>}
*/
async function buildTlsFetchExtras(ctx, vfs, opts) {
const insecure = !!opts.insecure
const cacertPath = opts.cacertPath || null
const certPath = opts.certPath || null
const keyPath = opts.keyPath || null
if (!insecure && !cacertPath && !certPath && !keyPath) return {}
if (!vfs || typeof vfs.readFile !== 'function') {
return { error: 'curl: TLS file options require ctx.vfs.readFile' }
}
if (typeof ctx.httpFetch === 'function') {
let caPem
let certPem
let keyPem
if (cacertPath) {
const buf = await vfs.readFile(cacertPath)
if (!buf) return { error: 'curl: cannot read --cacert file' }
caPem = b4a.toString(buf, 'utf8')
}
if (certPath) {
const buf = await vfs.readFile(certPath)
if (!buf) return { error: 'curl: cannot read --cert file' }
certPem = b4a.toString(buf, 'utf8')
}
if (keyPath) {
const buf = await vfs.readFile(keyPath)
if (!buf) return { error: 'curl: cannot read --key file' }
keyPem = b4a.toString(buf, 'utf8')
}
const env = ctx.vfs?.env
const pinRaw =
env && typeof env === 'object'
? String(env.BARE_OS_TLS_PIN_SHA256 || '').trim()
: ''
const pinnedSha256 = pinRaw
? pinRaw
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
: undefined
return {
delegatedTls: {
insecure,
...(caPem !== undefined ? { caPem } : {}),
...(certPem !== undefined ? { certPem } : {}),
...(keyPem !== undefined ? { keyPem } : {}),
...(pinnedSha256 && pinnedSha256.length ? { pinnedSha256 } : {})
}
}
}
try {
const undici = await import('undici')
const Agent = undici.Agent
/** @type {Record<string, unknown>} */
const connect = {}
if (insecure) connect.rejectUnauthorized = false
if (cacertPath) {
const buf = await vfs.readFile(cacertPath)
if (!buf) return { error: 'curl: cannot read --cacert file' }
connect.ca = b4a.toString(buf, 'utf8')
}
if (certPath) {
const buf = await vfs.readFile(certPath)
if (!buf) return { error: 'curl: cannot read --cert file' }
connect.cert = b4a.toString(buf, 'utf8')
}
if (keyPath) {
const buf = await vfs.readFile(keyPath)
if (!buf) return { error: 'curl: cannot read --key file' }
connect.key = b4a.toString(buf, 'utf8')
}
return { dispatcher: new Agent({ connect }) }
} catch (e) {
const msg = e && e.message ? e.message : String(e)
return { error: 'curl: TLS options need undici (Node): ' + msg }
}
}
/**
* curl -I -L: send HEAD first; after any 3xx, follow with GET (curl man: "GET is used after redirect").
* Using fetch(HEAD, redirect:'follow') keeps HEAD on each hop and breaks many CDNs; manual hops match curl.
* @returns {{ response: Response, redirectCount: number }}
*/
async function fetchHeadWithLocationFollow(
fetchFn,
startUrl,
hdr,
signal,
fetchExtra = {}
) {
let url = startUrl
let method = 'HEAD'
let redirectCount = 0
for (let hop = 0; hop < CURL_MAX_REDIRECTS; hop++) {
const res = await fetchFn(url, {
method,
headers: hdr,
redirect: 'manual',
signal,
body: undefined,
...fetchExtra
})
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get('Location')
if (!loc) return { response: res, redirectCount }
let nextUrl
try {
nextUrl = new URL(loc, url).href
} catch {
return { response: res, redirectCount }
}
method = 'GET'
redirectCount++
try {
if (res.body && typeof res.body.cancel === 'function')
await res.body.cancel()
} catch {
/* ignore */
}
url = nextUrl
continue
}
return { response: res, redirectCount }
}
return {
response: new Response('', {
status: 310,
statusText: 'Too many redirects'
}),
redirectCount
}
}
/**
* @param {Record<string, unknown>} ctx
* @param {string[]} argv
*/
export async function runCurlCli(ctx, argv) {
const vfs = ctx.vfs
const args = argv.slice(1)
let method = ''
const headers = []
/** @type {string[]} -H User-Agent and -A/--user-agent in argv order; last wins */
const userAgentSequence = []
/** @type {string[]} */
const dataChunks = []
let dataBinary = false
let headOnly = false
let includeHeaders = false
let silent = false
let showError = false
let failOnError = false
let location = false
let verbose = false
/** @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
/** Whole-operation ceiling (Fetch has no separate connect phase; see CLI_PARITY.md). */
let connectTimeMs = 0
/** @type {string | null} */
let userColonPass = null
/** @type {string | null} */
let jsonBody = null
/** @type {string | null} */
let uploadPath = null
let insecureTls = false
/** @type {string | null} */
let cacertPath = null
/** @type {string | null} */
let certPath = null
/** @type {string | null} */
let keyPath = null
/** @type {string | null} */
let cookieJarOut = null
/** @type {string[]} */
const cookieSpecs = []
/** @type {string[]} */
const urls = []
let i = 0
while (i < args.length) {
const a = args[i]
if (a === '--') {
i++
while (i < args.length) {
urls.push(normalizeFetchUrl(String(args[i++])))
}
break
}
if (!a.startsWith('-') || a === '-') {
urls.push(normalizeFetchUrl(String(a)))
i++
continue
}
if (a === '-h' || a === '--help') {
ctx.console.log(usage())
return
}
if (a === '-V' || a === '--version') {
ctx.console.log('curl (Bare OS fetch subset) 0.1 — not libcurl')
return
}
if (a === '-X' || a === '--request') {
if (i + 1 >= args.length) {
ctx.console.error('curl: option requires an argument: ' + a)
ctx.exitCode = 2
return
}
method = String(args[++i]).toUpperCase()
i++
continue
}
if (a.startsWith('-X') && a.length > 2) {
method = a.slice(2).toUpperCase()
i++
continue
}
if (a === '-H' || a === '--header') {
if (i + 1 >= args.length) {
ctx.console.error('curl: option requires an argument: ' + a)
ctx.exitCode = 2
return
}
const line = String(args[++i])
if (!pushCurlHeaderLine(line, headers, userAgentSequence)) {
ctx.console.error('curl: malformed header: ' + line)
ctx.exitCode = 2
return
}
i++
continue
}
if (a.startsWith('-H') && a.length > 2) {
const line = a.slice(2)
if (!pushCurlHeaderLine(line, headers, userAgentSequence)) {
ctx.console.error('curl: malformed header: ' + line)
ctx.exitCode = 2
return
}
i++
continue
}
if (a === '-A' || a === '--user-agent') {
if (i + 1 >= args.length) {
ctx.console.error('curl: option requires an argument: ' + a)
ctx.exitCode = 2
return
}
userAgentSequence.push(String(args[++i]))
i++
continue
}
if (a.startsWith('--user-agent=')) {
userAgentSequence.push(a.slice('--user-agent='.length))
i++
continue
}
if (a.startsWith('-A') && a.length > 2) {
userAgentSequence.push(a.slice(2))
i++
continue
}
if (
a === '-d' ||
a === '--data' ||
a === '--data-ascii' ||
a === '--data-binary' ||
a === '--data-raw'
) {
if (a === '--data-binary') dataBinary = true
if (i + 1 >= args.length) {
ctx.console.error('curl: option requires an argument: ' + a)
ctx.exitCode = 2
return
}
dataChunks.push(String(args[++i]))
i++
continue
}
if (a.startsWith('-d') && a.length > 2) {
dataChunks.push(a.slice(2))
i++
continue
}
if (a.startsWith('--data=')) {
dataChunks.push(a.slice(7))
i++
continue
}
if (a === '--json') {
if (i + 1 >= args.length) {
ctx.console.error('curl: option requires an argument: --json')
ctx.exitCode = 2
return
}
jsonBody = String(args[++i])
i++
continue
}
if (a === '-o' || a === '--output') {
if (i + 1 >= args.length) {
ctx.console.error('curl: option requires an argument: ' + a)
ctx.exitCode = 2
return
}
outputPath = String(args[++i])
i++
continue
}
if (a === '-O' || a === '--remote-name') {
remoteName = true
i++
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)
ctx.exitCode = 2
return
}
uploadPath = String(args[++i])
i++
continue
}
if (a === '-w' || a === '--write-out') {
if (i + 1 >= args.length) {
ctx.console.error('curl: option requires an argument: ' + a)
ctx.exitCode = 2
return
}
writeOut = String(args[++i])
i++
continue
}
if (a === '-u' || a === '--user') {
if (i + 1 >= args.length) {
ctx.console.error('curl: option requires an argument: ' + a)
ctx.exitCode = 2
return
}
userColonPass = String(args[++i])
i++
continue
}
if (a === '--max-time' || a === '-m') {
if (i + 1 >= args.length) {
ctx.console.error('curl: option requires an argument: ' + a)
ctx.exitCode = 2
return
}
const sec = Number(args[++i])
if (!Number.isFinite(sec) || sec < 0) {
ctx.console.error('curl: invalid --max-time')
ctx.exitCode = 2
return
}
maxTimeMs = Math.round(sec * 1000)
i++
continue
}
if (a === '--connect-timeout') {
if (i + 1 >= args.length) {
ctx.console.error(
'curl: option requires an argument: --connect-timeout'
)
ctx.exitCode = 2
return
}
const sec = Number(args[++i])
if (!Number.isFinite(sec) || sec < 0) {
ctx.console.error('curl: invalid --connect-timeout')
ctx.exitCode = 2
return
}
connectTimeMs = Math.round(sec * 1000)
i++
continue
}
if (a === '-k' || a === '--insecure') {
insecureTls = true
i++
continue
}
if (a === '--cacert') {
if (i + 1 >= args.length) {
ctx.console.error('curl: option requires an argument: --cacert')
ctx.exitCode = 2
return
}
cacertPath = String(args[++i])
i++
continue
}
if (a === '--cert') {
if (i + 1 >= args.length) {
ctx.console.error('curl: option requires an argument: --cert')
ctx.exitCode = 2
return
}
certPath = String(args[++i])
i++
continue
}
if (a === '--key') {
if (i + 1 >= args.length) {
ctx.console.error('curl: option requires an argument: --key')
ctx.exitCode = 2
return
}
keyPath = String(args[++i])
i++
continue
}
if (a === '-b' || a === '--cookie') {
if (i + 1 >= args.length) {
ctx.console.error('curl: option requires an argument: ' + a)
ctx.exitCode = 2
return
}
cookieSpecs.push(String(args[++i]))
i++
continue
}
if (a.startsWith('-b') && a.length > 2) {
cookieSpecs.push(a.slice(2))
i++
continue
}
if (a === '-c' || a === '--cookie-jar') {
if (i + 1 >= args.length) {
ctx.console.error('curl: option requires an argument: ' + a)
ctx.exitCode = 2
return
}
cookieJarOut = String(args[++i])
i++
continue
}
if (a === '-I' || a === '--head') {
headOnly = true
i++
continue
}
if (a === '-i' || a === '--include') {
includeHeaders = true
i++
continue
}
if (a === '-s' || a === '--silent') {
silent = true
i++
continue
}
if (a === '-S' || a === '--show-error') {
showError = true
i++
continue
}
if (a === '-f' || a === '--fail') {
failOnError = true
i++
continue
}
if (a === '-L' || a === '--location') {
location = true
i++
continue
}
if (a === '-v' || a === '--verbose') {
verbose = true
i++
continue
}
if (a.startsWith('--')) {
ctx.console.error('curl: unknown option: ' + a)
ctx.exitCode = 2
return
}
const rest = a.slice(1)
for (let j = 0; j < rest.length; j++) {
const c = rest[j]
switch (c) {
case 'I':
headOnly = true
break
case 'i':
includeHeaders = true
break
case 's':
silent = true
break
case 'S':
showError = true
break
case 'f':
failOnError = true
break
case 'L':
location = true
break
case 'v':
verbose = true
break
case 'O':
remoteName = true
break
case 'J':
remoteHeaderName = true
break
case 'k':
insecureTls = true
break
case 'b':
ctx.console.error(
'curl: bundled -b requires a separate argument (use -b cookie.txt)'
)
ctx.exitCode = 2
return
default:
ctx.console.error('curl: invalid option -- ' + c)
ctx.exitCode = 2
return
}
}
i++
}
if (urls.length === 0) {
ctx.console.error(usage())
ctx.exitCode = 2
return
}
for (const u of urls) {
if (!isSupportedFetchUrl(u)) {
ctx.console.error(
'curl: URL rejected (need http(s)://, data:, or file://): ' + u
)
ctx.exitCode = 2
return
}
try {
assertFetchUrlHostAllowedByDnsPolicy(u, ctx.vfs?.env || {})
} catch (e) {
ctx.console.error((e && e.message) || String(e))
ctx.exitCode = 2
return
}
}
if (outputPath != null && remoteName) {
ctx.console.error(
'curl: --remote-name (-O) and --output (-o) cannot be used together'
)
ctx.exitCode = 2
return
}
if (remoteHeaderName && !remoteName) {
ctx.console.error(
'curl: -J / --remote-header-name requires -O / --remote-name'
)
ctx.exitCode = 2
return
}
const deadlineMs =
maxTimeMs > 0 && connectTimeMs > 0
? Math.min(maxTimeMs, connectTimeMs)
: maxTimeMs > 0
? maxTimeMs
: connectTimeMs
let m = method
if (!m) {
if (headOnly) m = 'HEAD'
else if (uploadPath) m = 'PUT'
else if (dataChunks.length > 0 || jsonBody) m = 'POST'
else m = 'GET'
}
await ensureBareFetchGlobals(ctx)
const fetchFn = resolveBareOsFetchFn(ctx)
if (!fetchFn) {
ctx.console.error(
'curl: no HTTP client (need global fetch, ctx.bare.fetch from /lib/bare/bundles, or bare-fetch on Pear/Bare)'
)
ctx.exitCode = 1
return
}
if ((cookieSpecs.length > 0 || cookieJarOut) && !vfs) {
ctx.console.error('curl: -b / -c require ctx.vfs')
ctx.exitCode = 2
return
}
const tlsExtras = await buildTlsFetchExtras(ctx, vfs, {
insecure: insecureTls,
cacertPath,
certPath,
keyPath
})
if (tlsExtras.error) {
if (!silent || showError) ctx.console.error(tlsExtras.error)
ctx.exitCode = 2
return
}
/** @type {Record<string, unknown>} */
const fetchTlsExtra = {}
if (tlsExtras.dispatcher) fetchTlsExtra.dispatcher = tlsExtras.dispatcher
if (tlsExtras.delegatedTls)
fetchTlsExtra.bareOsCurlTls = tlsExtras.delegatedTls
const baseHdr = new Headers()
for (const line of headers) {
const colon = line.indexOf(':')
if (colon < 1) {
ctx.console.error('curl: malformed header: ' + line)
ctx.exitCode = 2
return
}
baseHdr.set(line.slice(0, colon).trim(), line.slice(colon + 1).trim())
}
if (userColonPass) {
const colon = userColonPass.indexOf(':')
const user = colon >= 0 ? userColonPass.slice(0, colon) : userColonPass
const pass = colon >= 0 ? userColonPass.slice(colon + 1) : ''
baseHdr.set('Authorization', basicAuthHeader(user, pass))
}
{
const ua = userAgentSequence.length
? userAgentSequence[userAgentSequence.length - 1]
: DEFAULT_CURL_USER_AGENT
baseHdr.set('User-Agent', ua)
}
/** @type {Record<string, Record<string, string>>} */
const sessionJar = {}
if (vfs && cookieJarOut) {
mergeCookieJars(sessionJar, await loadCookieJarFile(vfs, cookieJarOut))
}
if (vfs && cookieSpecs.length) {
for (const spec of cookieSpecs) {
if (!spec.includes('=')) {
mergeCookieJars(sessionJar, await loadCookieJarFile(vfs, spec))
}
}
}
/** @type {string | Uint8Array | undefined} */
let body = undefined
if (jsonBody) {
if (!baseHdr.has('Content-Type'))
baseHdr.set('Content-Type', 'application/json')
body = jsonBody
} else if (dataChunks.length > 0) {
const joined = dataChunks.join('&')
if (!baseHdr.has('Content-Type') && !dataBinary)
baseHdr.set('Content-Type', 'application/x-www-form-urlencoded')
body = joined
} else if (uploadPath) {
const buf = await vfs.readFile(uploadPath)
if (!buf) {
if (!silent || showError)
ctx.console.error('curl: cannot read upload file: ' + uploadPath)
ctx.exitCode = 26
return
}
body = new Uint8Array(buf)
if (!baseHdr.has('Content-Type'))
baseHdr.set('Content-Type', 'application/octet-stream')
}
let lastSize = 0
let lastUrl = ''
for (let ui = 0; ui < urls.length; ui++) {
const url = applyBareOsCurlResolveMap(urls[ui], vfs?.env)
const reqHdr = new Headers(baseHdr)
const host = curlHostFromUrl(url)
if (vfs && cookieSpecs.length) {
for (const spec of cookieSpecs) {
if (spec.includes('=')) mergeInlineCookieLine(sessionJar, host, spec)
}
}
const jarCookie = cookieHeaderForHost(sessionJar, host)
if (jarCookie) {
if (reqHdr.has('Cookie')) {
reqHdr.set('Cookie', reqHdr.get('Cookie') + '; ' + jarCookie)
} else {
reqHdr.set('Cookie', jarCookie)
}
}
/** @type {string | null} */
let outForUrl = null
if (outputPath != null) {
outForUrl = urls.length > 1 ? `${outputPath}.${ui}` : outputPath
} else if (remoteName) {
outForUrl = defaultFetchSaveName(url)
}
const ac = deadlineMs > 0 ? new AbortController() : null
const t =
deadlineMs > 0
? setTimeout(() => {
try {
ac.abort()
} catch {
/* ignore */
}
}, deadlineMs)
: null
let res
/** @type {number} */
let numRedirects = 0
try {
if (headOnly && location) {
const { response, redirectCount } = await fetchHeadWithLocationFollow(
fetchFn,
url,
reqHdr,
ac ? ac.signal : undefined,
fetchTlsExtra
)
res = response
numRedirects = redirectCount
} else {
res = await fetchFn(url, {
method: m,
headers: reqHdr,
body: m === 'HEAD' || m === 'GET' ? undefined : body,
redirect: location ? 'follow' : 'manual',
signal: ac ? ac.signal : undefined,
...fetchTlsExtra
})
}
} catch (e) {
if (t) clearTimeout(t)
const msg = e && e.message ? e.message : String(e)
if (!silent || showError) ctx.console.error('curl: (' + url + ') ' + msg)
ctx.exitCode = 7
return
}
if (t) clearTimeout(t)
lastUrl = res.url || url
const cookieHost = curlHostFromUrl(lastUrl) || host
for (const sc of gatherSetCookieValues(res)) {
applySetCookieHeader(sessionJar, cookieHost, sc)
}
if (cookieJarOut && vfs) {
await vfs.mkdir('~/.config/bare-os/curl', { recursive: true })
await vfs.writeFile(
cookieJarOut,
b4a.from(JSON.stringify(sessionJar), 'utf8')
)
}
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')) {
if (!silent || showError)
ctx.console.error(
'curl: redirect not followed (use -L): ' + res.status + ' -> ' + loc
)
}
}
const buf =
m === 'HEAD' ? new Uint8Array(0) : new Uint8Array(await res.arrayBuffer())
lastSize = buf.length
if (failOnError && !res.ok) {
if (!silent || showError)
ctx.console.error('curl: HTTP ' + res.status + ' for ' + url)
ctx.exitCode = 22
return
}
if (verbose && !silent) {
ctx.console.error('> ' + m + ' ' + url)
res.headers.forEach((v, k) => {
ctx.console.error('< ' + k + ': ' + v)
})
}
const chunks = []
if (m === 'HEAD' || (includeHeaders && m !== 'HEAD')) {
const statusLine = 'HTTP/1.1 ' + res.status + ' ' + (res.statusText || '')
chunks.push(statusLine + '\r\n')
res.headers.forEach((v, k) => {
chunks.push(k + ': ' + v + '\r\n')
})
chunks.push('\r\n')
}
if (m !== 'HEAD') {
chunks.push(buf)
}
const outBytes = concatParts(chunks)
if (outForUrl) {
await vfs.writeFile(outForUrl, outBytes)
} else {
ctx.console.log(ctx.b4a.toString(outBytes))
}
if (writeOut) {
const parts = curlWriteOutUrlParts(lastUrl)
const line = expandWriteOut(writeOut, {
http_code: res.status,
response_code: res.status,
url_effective: lastUrl,
size_download: lastSize,
num_redirects: numRedirects,
content_type: res.headers.get('content-type') || '',
method: String(m || ''),
scheme: parts.scheme,
host: parts.host,
port: parts.port,
path: parts.path
})
ctx.console.log(line)
}
}
ctx.exitCode = 0
}