// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini /** * Helpers shared by the container-image specs (not a spec file itself: Playwright only collects * `*.spec.ts`). Kept next to the specs instead of in harness/ so the shared harness stays * untouched; everything here only creates resources named/labelled like the harness does. * * What the harness lacked for these tests: * - running a throwaway, labelled helper container (chown/stat of a data dir for the * `--user 65534:65534` case) — image.ts only has snapshot/remove helpers; * - creating a never-started container for `docker export`, and `docker save` of the image; * - parsing `tar -tv` listings and ELF program headers. */ import fs from 'node:fs'; import path from 'node:path'; import { ALPINE_IMAGE, HarnessError, LABEL_RUN, LABEL_TEST, RESOURCE_PREFIX, dockerCli, expect, runId, runProcess, toolEnv, type CliResult, type ImageContainer, type ImageExecOptions, type ProcResult, type Workspace, } from '../../harness'; let helperSeq = 0; /** Labels every suite-owned container must carry (so teardown removes and leak-checks it). */ export function ownLabels(testKey: string): string[] { return ['--label', `${LABEL_RUN}=${runId()}`, '--label', `${LABEL_TEST}=${testKey}`]; } /** Name of a suite-owned helper container. */ export function helperName(testKey: string, what: string): string { return `${RESOURCE_PREFIX}${runId()}-${testKey}-${what}${++helperSeq}`; } /** * Runs a throwaway alpine container (no network, labelled) with `dir` mounted at /d and returns * its result; a non-zero exit is a HarnessError (helper failures are never app outcomes). */ export async function alpineOnDir(testKey: string, dir: string, script: string): Promise { if (!fs.existsSync(dir)) throw new Error(`helper mount source ${dir} does not exist (dockerd would create a root-owned dir)`); return dockerCli(['run', '--rm', '--name', helperName(testKey, 'helper'), ...ownLabels(testKey), '--network', 'none', '-v', `${dir}:/d`, ALPINE_IMAGE, 'sh', '-c', script]); } /** chown/chmod a host directory through a helper container (e.g. hand it to uid 65534). */ export async function chownDir(testKey: string, dir: string, owner: string, mode: string): Promise { await alpineOnDir(testKey, dir, `chown ${owner} /d && chmod ${mode} /d`); } /** `uid:gid` of a file inside a directory the host user may not be able to traverse. */ export async function ownerOf(testKey: string, dir: string, file: string): Promise { return (await alpineOnDir(testKey, dir, `stat -c %u:%g /d/${file}`)).stdout.trim(); } export interface TarEntry { /** '-' regular, 'd' directory, 'l' symlink, ... */ type: string; perms: string; owner: string; size: number; /** Path as stored (directories keep their trailing slash). */ name: string; linkTarget?: string; } /** `tar -tvf ` parsed (GNU tar format). */ export async function listTar(file: string): Promise { const r = await runProcess('tar', ['-tvf', file], { env: toolEnv(), timeoutMs: 60_000, label: 'tar -tv' }); expect(r.exitCode, `tar -tvf ${file}: ${r.stderr}`).toBe(0); return r.stdout .split('\n') .filter((l) => l.trim()) .map((l) => { const m = /^(\S)(\S{9})\s+(\S+)\s+(\d+)\s+\S+\s+\S+\s+(.*)$/.exec(l); if (!m) throw new Error(`unparseable tar listing line ${JSON.stringify(l)}`); const [name, linkTarget] = m[1] === 'l' ? m[5].split(' -> ') : [m[5], undefined]; return { type: m[1], perms: m[2], owner: m[3], size: Number(m[4]), name, linkTarget }; }); } export interface SavedImage { /** Directory the `docker save` archive was unpacked into. */ dir: string; manifest: Array<{ Config: string; RepoTags: string[] | null; Layers: string[] }>; config: { architecture: string; os: string; config: Record; history: Array<{ created_by?: string; empty_layer?: boolean }>; rootfs: { type: string; diff_ids: string[] }; }; /** Entries of every layer tarball, in layer order. */ layers: TarEntry[][]; } /** `docker save` + unpack: manifest, image config and the listing of every layer (no container is created). */ export async function saveImage(ws: Workspace, image: string): Promise { const tar = ws.path('image-save.tar'); const dir = ws.mkdir('image-save'); await dockerCli(['save', '-o', tar, image], { timeoutMs: 120_000 }); const x = await runProcess('tar', ['-xf', tar, '-C', dir], { env: toolEnv(), timeoutMs: 60_000, label: 'tar -x' }); expect(x.exitCode, x.stderr).toBe(0); const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8')) as SavedImage['manifest']; expect(manifest, 'docker save archive holds exactly one image').toHaveLength(1); const config = JSON.parse(fs.readFileSync(path.join(dir, manifest[0].Config), 'utf8')) as SavedImage['config']; const layers: TarEntry[][] = []; for (const l of manifest[0].Layers) layers.push(await listTar(path.join(dir, l))); return { dir, manifest, config, layers }; } /** * `docker create` (never started) + `docker export`: the container's merged filesystem as Docker * materializes it, including the entries Docker injects at container creation. */ export async function exportCreatedContainer(ws: Workspace, testKey: string, image: string): Promise { const name = helperName(testKey, 'export'); const out = ws.path('container-export.tar'); await dockerCli(['create', '--name', name, ...ownLabels(testKey), '--network', 'none', image]); try { await dockerCli(['export', '-o', out, name], { timeoutMs: 120_000 }); } finally { await dockerCli(['rm', '-f', name], { allowFailure: true }); } return listTar(out); } export interface ElfInfo { elfClass: 32 | 64; littleEndian: boolean; /** e_type: 2 ET_EXEC, 3 ET_DYN. */ type: number; machine: number; /** p_type of every program header. */ programHeaderTypes: number[]; } export const PT_DYNAMIC = 2; export const PT_INTERP = 3; export const EM_BY_ARCH: Record = { amd64: 62, arm64: 183, '386': 3, arm: 40 }; /** Minimal ELF header + program header table parser. */ export function readElf(file: string): ElfInfo { const b = fs.readFileSync(file); expect(b.subarray(0, 4).toString('latin1'), 'ELF magic').toBe('\x7fELF'); const elfClass = b[4] === 2 ? 64 : 32; const le = b[5] === 1; const u16 = (o: number) => (le ? b.readUInt16LE(o) : b.readUInt16BE(o)); const u32 = (o: number) => (le ? b.readUInt32LE(o) : b.readUInt32BE(o)); const u64 = (o: number) => Number(le ? b.readBigUInt64LE(o) : b.readBigUInt64BE(o)); const type = u16(16); const machine = u16(18); const phoff = elfClass === 64 ? u64(32) : u32(28); const phentsize = elfClass === 64 ? u16(54) : u16(42); const phnum = elfClass === 64 ? u16(56) : u16(44); const programHeaderTypes: number[] = []; for (let i = 0; i < phnum; i++) programHeaderTypes.push(u32(phoff + i * phentsize)); return { elfClass, littleEndian: le, type, machine, programHeaderTypes }; } /** Tag of an image reference (`repo/name:tag` → `tag`), undefined for digests/untagged refs. */ export function imageTag(ref: string): string | undefined { if (ref.includes('@')) return undefined; const last = ref.slice(ref.lastIndexOf('/') + 1); const i = last.indexOf(':'); return i < 0 ? undefined : last.slice(i + 1); } /** Gateway of the container's (single) network: the source address docker-proxy uses toward it. Call while the container runs. */ export async function gatewayOf(c: ImageContainer): Promise { const info = await c.inspect(); const nets = (info.NetworkSettings as { Networks: Record }).Networks; const gws = Object.values(nets).map((n) => n.Gateway).filter(Boolean); expect(gws, 'container has exactly one network with a gateway').toHaveLength(1); return gws[0]; } /** * Origins a request from the host to a 127.0.0.1-published port may legitimately carry inside the * container: the network gateway (docker-proxy / masquerade — what this host shows) or loopback * (some userland-proxy-less setups). PLAN only requires "the request's network source address"; * the exact value through Docker's port publishing is environment-defined, asserted as observed. */ export async function expectedOrigins(c: ImageContainer): Promise { return [await gatewayOf(c), '127.0.0.1']; } /** * `docker exec … /app` that also treats daemon-side exec failures as infrastructure errors. * ImageContainer.exec only raises for exit codes >= 125, but the docker CLI exits 1 when the * daemon refuses the exec before starting the process (e.g. unknown --user), which would otherwise * look like the application's exit code 1. */ export async function execApp(c: ImageContainer, args: string[], opts: ImageExecOptions = {}): Promise { const r = await c.exec(args, opts); if (r.exitCode !== 0 && /^(Error response from daemon:|OCI runtime exec failed)/.test(r.stderr + r.stdout)) { throw new HarnessError('infrastructure', `docker exec ${c.name} /app ${args.slice(0, 2).join(' ')} failed at the docker level (exit ${r.exitCode}): ${(r.stderr + r.stdout).trim()}`, r); } return r; } /** Hex of a string's UTF-8 bytes, upper case (as SQLite hex()). */ export const hex = (s: string): string => Buffer.from(s, 'utf8').toString('hex').toUpperCase();