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
1133 lines
30 KiB
JavaScript
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
|
|
}
|