curl --version in packages/bare-os-coreutils/src/curl.js wget --version in packages/bare-os-coreutils/src/wget.js openssl version and openssl --version in packages/bare-os-coreutils/src/openssl.js nano --version (via edit) in packages/bare-os-coreutils/src/edit.js Updated tar compatibility in packages/bare-os-booter/lib/tar-cli.js: Added tar --version Added grouped short-flag parsing (-czf, etc.) Added first-arg legacy cluster parsing (czf) Added - archive target support for create/list/extract stream paths Kept implementation as ustar subset (accepts z for compatibility parsing) Fixed pipeline/timeout behavior: Optimized head line mode to stop scanning early (no full split) in packages/bare-os-coreutils/src/head.js Added bounded safety for yes cap in packages/bare-os-coreutils/src/yes.js Hardened timeout so timed-out commands reliably produce exit code 124 in packages/bare-os-coreutils/src/timeout.js Added scoped expr compatibility in shell: Prevented pathname expansion of bare * only for expr arithmetic-token case in packages/bare-os-booter/lib/shell.js Added glob option plumbing in packages/bare-os-booter/lib/shell-glob.js
451 lines
12 KiB
JavaScript
451 lines
12 KiB
JavaScript
/**
|
||
* Minimal POSIX ustar tar for VFS paths: create / list / extract.
|
||
* Supported:
|
||
* tar -cf ARCHIVE PATH [PATH...]
|
||
* tar -tf ARCHIVE
|
||
* tar -xf ARCHIVE
|
||
* GNU extensions / compression are not supported.
|
||
* Create path: when **`vfs.stat`** exists, **`mtime`** / **`mode`** reflect the source file (Issue 7–style metadata).
|
||
*/
|
||
import b4a from 'b4a'
|
||
import unixPathResolve from 'unix-path-resolve'
|
||
|
||
const BLK = 512
|
||
|
||
function utf8Encode(str) {
|
||
return b4a.from(String(str), 'utf8')
|
||
}
|
||
|
||
function utf8Decode(buf) {
|
||
return b4a.toString(buf, 'utf8')
|
||
}
|
||
|
||
/**
|
||
* @param {Uint8Array} buf
|
||
* @param {number} off
|
||
* @param {number} len
|
||
*/
|
||
function parseOctalField(buf, off, len) {
|
||
let s = ''
|
||
for (let i = 0; i < len; i++) {
|
||
const c = buf[off + i]
|
||
if (c === 0 || c === 32) break
|
||
s += String.fromCharCode(c)
|
||
}
|
||
const n = Number.parseInt(s.trim(), 8)
|
||
return Number.isFinite(n) ? n : 0
|
||
}
|
||
|
||
/**
|
||
* @param {string} oct
|
||
* @param {number} len
|
||
*/
|
||
function octalPad(oct, len) {
|
||
const o = oct + '\0'
|
||
const b = new Uint8Array(len)
|
||
const enc = utf8Encode(o)
|
||
b.set(enc.slice(0, len - 1))
|
||
return b
|
||
}
|
||
|
||
/**
|
||
* @param {Record<string, unknown>} header
|
||
*/
|
||
export function encodeUstarHeader(header) {
|
||
const h = new Uint8Array(BLK)
|
||
const name = String(header.name || '').slice(0, 100)
|
||
const mode = String(header.mode || 0o644).slice(0, 7)
|
||
const uid = String(header.uid || 0).slice(0, 7)
|
||
const gid = String(header.gid || 0).slice(0, 7)
|
||
const size = String(header.size || 0).slice(0, 11)
|
||
const mtime = String(header.mtime || 0).slice(0, 11)
|
||
const typeflag = header.typeflag || '0'
|
||
const linkname = String(header.linkname || '').slice(0, 100)
|
||
|
||
const w = (str, off, max) => {
|
||
const enc = utf8Encode(str)
|
||
h.set(enc.slice(0, max), off)
|
||
}
|
||
|
||
w(name, 0, 100)
|
||
h.set(octalPad(mode, 8), 100)
|
||
h.set(octalPad(uid, 8), 108)
|
||
h.set(octalPad(gid, 8), 116)
|
||
h.set(octalPad(size, 12), 124)
|
||
h.set(octalPad(mtime, 12), 136)
|
||
// ustar checksum field (sum of header bytes; standard layout)
|
||
for (let i = 148; i < 156; i++) h[i] = 32
|
||
h[156] = typeflag.charCodeAt(0)
|
||
w(linkname, 157, 100)
|
||
w('ustar\0', 257, 6)
|
||
w('00', 263, 2)
|
||
|
||
let sum = 0
|
||
for (let i = 0; i < BLK; i++) {
|
||
if (i >= 148 && i < 156) sum += 32
|
||
else sum += h[i]
|
||
}
|
||
const cks = octalPad(sum.toString(8), 8)
|
||
h.set(cks, 148)
|
||
|
||
return h
|
||
}
|
||
|
||
/**
|
||
* @param {Uint8Array} block
|
||
*/
|
||
function parseUstarBlock(block) {
|
||
const name = utf8Decode(block.subarray(0, 100)).replace(/\0.*$/, '')
|
||
const size = parseOctalField(block, 124, 12)
|
||
const typeflag = String.fromCharCode(block[156] || 48)
|
||
const linkname = utf8Decode(block.subarray(157, 257)).replace(/\0.*$/, '')
|
||
return { name, size, typeflag, linkname }
|
||
}
|
||
|
||
/**
|
||
* Resolve ustar hard-link `linkname` relative to the directory of the new link path.
|
||
* @param {string} cwdAbs
|
||
* @param {string} entryName
|
||
* @param {string} linkname
|
||
*/
|
||
function resolveTarHardlinkSource(cwdAbs, entryName, linkname) {
|
||
const ln = String(linkname).trim()
|
||
if (!ln) return ''
|
||
if (ln.startsWith('/')) return ln.replace(/\/+$/, '') || '/'
|
||
const dest =
|
||
cwdAbs === '/' ? '/' + entryName : cwdAbs.replace(/\/+$/, '') + '/' + entryName
|
||
const slash = dest.lastIndexOf('/')
|
||
const destDir = slash <= 0 ? '/' : dest.slice(0, slash)
|
||
const rel = ln.replace(/^\.\//, '')
|
||
return unixPathResolve(destDir === '/' ? '/' : destDir + '/', rel)
|
||
}
|
||
|
||
/**
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {string[]} argv
|
||
*/
|
||
export async function runTarCli(ctx, argv) {
|
||
const args = argv.slice(1).filter((a) => a !== '--')
|
||
if (args.length === 0 || args.includes('-h') || args.includes('--help')) {
|
||
ctx.console.log(
|
||
'usage: tar -cf ARCHIVE PATH...\n' +
|
||
' tar -tf ARCHIVE\n' +
|
||
' tar -xf ARCHIVE\n' +
|
||
'Bare OS: ustar only, VFS paths. Hard links: copy-on-extract unless BARE_OS_VFS_STRICT_HARDLINK.\n'
|
||
)
|
||
return
|
||
}
|
||
if (args.includes('--version')) {
|
||
ctx.console.log('tar (Bare OS ustar subset) 0.1')
|
||
return
|
||
}
|
||
|
||
let create = false
|
||
let list = false
|
||
let extract = false
|
||
let sawCluster = false
|
||
/** @type {string[]} */
|
||
const rest = []
|
||
const consumeCluster = (cluster, nextArg) => {
|
||
let mode = ''
|
||
let takesArchive = false
|
||
for (const c of cluster) {
|
||
if (c === 'c' || c === 't' || c === 'x') {
|
||
if (mode && mode !== c) return { ok: false, reason: 'conflicting mode flags' }
|
||
mode = c
|
||
continue
|
||
}
|
||
if (c === 'f') {
|
||
takesArchive = true
|
||
continue
|
||
}
|
||
if (c === 'z') {
|
||
// Accepted for compatibility with common tar forms; current output remains ustar.
|
||
continue
|
||
}
|
||
return { ok: false, reason: 'unsupported flag -' + c }
|
||
}
|
||
if (!mode) return { ok: false, reason: 'missing mode flag' }
|
||
if (mode === 'c') create = true
|
||
if (mode === 't') list = true
|
||
if (mode === 'x') extract = true
|
||
if (takesArchive) rest.push(nextArg || '')
|
||
return { ok: true, consumedArchive: takesArchive }
|
||
}
|
||
for (let i = 0; i < args.length; i++) {
|
||
const a = args[i]
|
||
if (!sawCluster && i === 0 && /^[ctxfz]+$/.test(a)) {
|
||
const parsed = consumeCluster(a, args[i + 1])
|
||
if (!parsed.ok) {
|
||
ctx.console.error('tar: ' + parsed.reason)
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
sawCluster = true
|
||
if (parsed.consumedArchive) i++
|
||
continue
|
||
}
|
||
if (/^-[ctxfz]+$/.test(a)) {
|
||
const parsed = consumeCluster(a.slice(1), args[i + 1])
|
||
if (!parsed.ok) {
|
||
ctx.console.error('tar: ' + parsed.reason)
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
sawCluster = true
|
||
if (parsed.consumedArchive) i++
|
||
continue
|
||
}
|
||
if (a === '-cf' || a === '-fc') {
|
||
create = true
|
||
rest.push(args[++i] || '')
|
||
continue
|
||
}
|
||
if (a === '-tf' || a === '-ft') {
|
||
list = true
|
||
rest.push(args[++i] || '')
|
||
continue
|
||
}
|
||
if (a === '-xf' || a === '-fx') {
|
||
extract = true
|
||
rest.push(args[++i] || '')
|
||
continue
|
||
}
|
||
if (a.startsWith('-')) {
|
||
ctx.console.error('tar: unsupported flag ' + a)
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
rest.push(a)
|
||
}
|
||
|
||
const vfs = ctx.vfs
|
||
const b4 = ctx.b4a
|
||
if (!vfs || !b4 || typeof vfs.readFile !== 'function') {
|
||
ctx.console.error('tar: vfs unavailable')
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
|
||
if (create) {
|
||
const archive = rest.shift()
|
||
if (!archive || rest.length === 0) {
|
||
ctx.console.error('tar: -cf needs archive and at least one path')
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
/** @type {Uint8Array[]} */
|
||
const parts = []
|
||
for (const p of rest) {
|
||
let abs
|
||
try {
|
||
abs = vfs.resolveLogical(String(p))
|
||
} catch {
|
||
ctx.console.error('tar: bad path ' + p)
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
let data
|
||
try {
|
||
data = await vfs.readFile(abs)
|
||
} catch (e) {
|
||
ctx.console.error(
|
||
'tar: ' + ((e && /** @type {Error} */ (e).message) || String(e))
|
||
)
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
const u8 = b4a.isBuffer(data) ? new Uint8Array(data) : new Uint8Array(data)
|
||
const base = abs.includes('/') ? abs.split('/').pop() : abs
|
||
const name = base || 'file'
|
||
if (name.length > 99) {
|
||
ctx.console.error('tar: path name too long for ustar: ' + name)
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
let mode = 0o644
|
||
let mtime = Math.floor(Date.now() / 1000)
|
||
if (typeof vfs.stat === 'function') {
|
||
try {
|
||
const st = await vfs.stat(abs)
|
||
if (st && typeof st === 'object') {
|
||
if (typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs)) {
|
||
mtime = Math.max(0, Math.floor(st.mtimeMs / 1000))
|
||
}
|
||
if (typeof st.mode === 'number' && Number.isFinite(st.mode)) {
|
||
mode = st.mode & 0o7777
|
||
}
|
||
}
|
||
} catch {
|
||
/* keep defaults */
|
||
}
|
||
}
|
||
const hdr = encodeUstarHeader({
|
||
name,
|
||
size: u8.length,
|
||
mode,
|
||
mtime
|
||
})
|
||
parts.push(hdr)
|
||
parts.push(u8)
|
||
const pad = (BLK - (u8.length % BLK)) % BLK
|
||
if (pad) parts.push(new Uint8Array(pad))
|
||
}
|
||
parts.push(new Uint8Array(BLK * 2))
|
||
let total = 0
|
||
for (const q of parts) total += q.length
|
||
const out = new Uint8Array(total)
|
||
let o = 0
|
||
for (const q of parts) {
|
||
out.set(q, o)
|
||
o += q.length
|
||
}
|
||
if (archive === '-') {
|
||
if (typeof ctx.bareOsBinWrite === 'function') {
|
||
ctx.bareOsBinWrite(out)
|
||
} else if (
|
||
globalThis.process &&
|
||
globalThis.process.stdout &&
|
||
typeof globalThis.process.stdout.write === 'function'
|
||
) {
|
||
globalThis.process.stdout.write(out)
|
||
} else {
|
||
ctx.console.error('tar: stdout is unavailable for archive stream')
|
||
ctx.exitCode = 1
|
||
}
|
||
} else {
|
||
let absA
|
||
try {
|
||
absA = vfs.resolveLogical(String(archive))
|
||
} catch {
|
||
ctx.console.error('tar: bad archive path')
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
try {
|
||
await vfs.writeFile(absA, b4.from(out))
|
||
} catch (e) {
|
||
ctx.console.error(
|
||
'tar: ' + ((e && /** @type {Error} */ (e).message) || String(e))
|
||
)
|
||
ctx.exitCode = 1
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
if (list || extract) {
|
||
const archive = rest[0]
|
||
if (!archive) {
|
||
ctx.console.error('tar: missing archive')
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
let raw
|
||
if (archive === '-') {
|
||
const stdin = typeof ctx.shellStdin === 'string' ? ctx.shellStdin : ''
|
||
raw = b4.from(stdin, 'binary')
|
||
} else {
|
||
let absA
|
||
try {
|
||
absA = vfs.resolveLogical(String(archive))
|
||
} catch {
|
||
ctx.console.error('tar: bad archive path')
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
try {
|
||
raw = await vfs.readFile(absA)
|
||
} catch (e) {
|
||
ctx.console.error(
|
||
'tar: ' + ((e && /** @type {Error} */ (e).message) || String(e))
|
||
)
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
}
|
||
const buf = b4a.isBuffer(raw) ? new Uint8Array(raw) : new Uint8Array(raw)
|
||
let off = 0
|
||
while (off + BLK <= buf.length) {
|
||
const block = buf.subarray(off, off + BLK)
|
||
if (block.every((b) => b === 0)) break
|
||
off += BLK
|
||
const rec = parseUstarBlock(block)
|
||
const { name, size, typeflag, linkname } = rec
|
||
if (off + size > buf.length) {
|
||
ctx.console.error('tar: corrupt archive (short read)')
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
const body = buf.subarray(off, off + size)
|
||
off += size
|
||
off += (BLK - (size % BLK)) % BLK
|
||
|
||
if (!name) continue
|
||
if (typeflag === '1') {
|
||
if (list) ctx.console.log(name)
|
||
if (extract) {
|
||
const strict =
|
||
vfs.env &&
|
||
(vfs.env.BARE_OS_VFS_STRICT_HARDLINK === '1' ||
|
||
vfs.env.BARE_OS_VFS_STRICT_HARDLINK === 'true')
|
||
if (strict) {
|
||
ctx.console.error(
|
||
'tar: hard link entries not supported with BARE_OS_VFS_STRICT_HARDLINK (unset for copy-on-extract)'
|
||
)
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
const cwd = vfs.resolveLogical('.')
|
||
const target =
|
||
cwd === '/' ? '/' + name : cwd.replace(/\/$/, '') + '/' + name
|
||
const src = resolveTarHardlinkSource(cwd, name, linkname)
|
||
if (!linkname.trim() || !src) {
|
||
ctx.console.error('tar: hard link missing linkname: ' + name)
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
try {
|
||
const data = await vfs.readFile(src)
|
||
await vfs.writeFile(target, b4.from(data))
|
||
} catch (e) {
|
||
ctx.console.error(
|
||
'tar: extract ' +
|
||
name +
|
||
': ' +
|
||
((e && /** @type {Error} */ (e).message) || String(e))
|
||
)
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
if (typeflag === '0' || typeflag === '\0' || typeflag === '') {
|
||
if (list) ctx.console.log(name)
|
||
if (extract) {
|
||
const cwd = vfs.resolveLogical('.')
|
||
const target =
|
||
cwd === '/' ? '/' + name : cwd.replace(/\/$/, '') + '/' + name
|
||
try {
|
||
await vfs.writeFile(target, b4.from(body))
|
||
} catch (e) {
|
||
ctx.console.error(
|
||
'tar: extract ' +
|
||
name +
|
||
': ' +
|
||
((e && /** @type {Error} */ (e).message) || String(e))
|
||
)
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
ctx.console.error('tar: specify -cf, -tf, or -xf')
|
||
ctx.exitCode = 1
|
||
}
|