ReviewOS

also looking at this

stacks/drone

test: qualify partial host image writes and recovery

#57
Merged chris wants to merge codex/runtime-file-write-faults into main
9 files +311 -7
.github/workflows/ci.ymlmodified+20-0
Changes to .github/workflows/ci.yml
@@ -32,7 +32,27 @@ jobs:
3232 - run: python3 -m unittest discover -s tests/hardware
3333 - name: Verify a kernel-confirmed full input pipe on Linux
3434 run: bun test tests/unit/runtime.test.ts --test-name-pattern 'kernel-confirmed full input FIFO'
35 - name: Verify an actual partial host image write and fresh-process recovery
36 env:
37 RUNTIME_HOST_FILE_LIMIT_EVIDENCE: ${{ runner.temp }}/runtime-host-file-limit-evidence
38 run: bun test tests/unit/updates-host-writes.test.ts
39 - uses: actions/upload-artifact@v4
40 if: always()
41 with:
42 name: runtime-host-file-limit-evidence
43 if-no-files-found: ignore
44 retention-days: 7
45 path: ${{ runner.temp }}/runtime-host-file-limit-evidence
3546 - run: ./buddy test
47 env:
48 RUNTIME_HOST_FILE_LIMIT_EVIDENCE: ${{ runner.temp }}/runtime-host-file-limit-full-evidence
49 - uses: actions/upload-artifact@v4
50 if: always()
51 with:
52 name: runtime-host-file-limit-full-evidence
53 if-no-files-found: ignore
54 retention-days: 7
55 path: ${{ runner.temp }}/runtime-host-file-limit-full-evidence
3656 - run: bun run runtime:cli
3757 - name: Verify signed test-image staging through the portable CLI outside the checkout
3858 run: bun tests/fixtures/updates/qualify.ts "$RUNNER_TEMP/runtime-update-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
docs/adr/0030-host-storage-fault-fixtures.mdadded+36-0
Changes to docs/adr/0030-host-storage-fault-fixtures.md
@@ -0,0 +1,36 @@
1# Host storage fault fixtures
2
3Status: qualification in progress for #42. Physical deployment remains disabled.
4
5Exercise storage failures through the existing public update coordinator with
6real signed repositories and separate processes. Keep the enrollment, protocol
7store, owned candidates, host slots and current-policy checks in the path.
8Assertions read public status and actual retained files. No filesystem method or
9SDK verifier is replaced.
10
11The first campaign uses POSIX `RLIMIT_FSIZE` on macOS and Linux. A test-only C++17
12launcher sets and reads back the child's hard and soft limit, ignores `SIGXFSZ`
13so an over-limit write can report `EFBIG`, then executes Bun. This small native
14test exception supplies the kernel resource control that the TypeScript harness
15needs. It is not linked into the application or board runtime. Stacks' bounded
16process capture owns command execution and output.
17
18A disposable signed source checkout adds initialized padding to its host image.
19It retains the neutral kernel behavior and forces the authentic executable past
20the file-size limit. Prepare candidates and the prior known-good image before
21the limited process starts. New signed metadata is accepted during the limited
22trial. The host-slot copy must then make real partial progress and fail before
23runtime launch or health confirmation.
24
25Retain the partial slot file and prior known-good bytes. Verify metadata and
26release floors after rejection and a fresh recovery process. A later unrestricted
27trial needs a new slot and actual fresh runtime health; it cannot complete the
28failed slot by marker inspection. The large padded image is a host fixture,
29without a board memory or timing qualification claim.
30
31This establishes a process-local file-size-limit failure, including an actual
32short image write. It does not establish zero-return writes, ENOSPC, EDQUOT, EIO,
33sync failure, full-medium behavior or electrical power-loss durability. Those
34remain explicit parts of the full storage campaign. Authenticated maintenance,
35board installation, boot recovery and hardware anti-rollback retain their own
36acceptance gates.
docs/roadmap/issues.jsonmodified+5-1
Changes to docs/roadmap/issues.json
@@ -287,7 +287,11 @@
287287 "tests/unit/updates-host-policy.test.ts",
288288 "tests/unit/updates-host-slots-cli.test.ts",
289289 "tests/fixtures/updates/qualify-host-slots.ts",
290 "tests/fixtures/updates/host-slot-interruptions.ts"
290 "tests/fixtures/updates/host-slot-interruptions.ts",
291 "tests/fixtures/updates/host-write-limit.ts",
292 "tests/fixtures/updates/file-write-limit.cpp",
293 "tests/unit/updates-host-writes.test.ts",
294 "docs/adr/0030-host-storage-fault-fixtures.md"
291295 ],
292296 "remaining": [
293297 "Live runtime/sensor/estimator/authority/mission/payload correlation and measured clock relations",
docs/runtime-updates.mdmodified+11-0
Changes to docs/runtime-updates.md
@@ -581,3 +581,14 @@ An actual CMake output-overflow case also stops before runtime start, retains
581581release floor 9 through recovery, then observes a fresh healthy trial after the
582582private build fault is removed. Build tools use native bounded process capture
583583with the existing 120-second command deadline and 2 MB per-stream limits.
584
585The [host storage fixture](adr/0030-host-storage-fault-fixtures.md) also runs an
586actual signed large host image under a read-back 1 MiB POSIX file-size limit.
587The slot copy retains exactly 1 MiB of the authenticated prefix before `EFBIG`,
588with no trial runtime launch or health confirmation. New root/target metadata and
589release floor 9 survive, and the prior known-good image remains intact. Recovery
590and the later unrestricted trial run in separate fresh Bun processes. Only the
591new slot can become known-good through actual fresh runtime health; the failed
592partial file stays retained. CI keeps the synthetic campaign artifacts for seven
593days. This file-size-limit evidence leaves zero-return writes, full-medium/quota,
594EIO, sync and physical power-loss campaigns open.
tests/fixtures/updates/file-write-limit.cppadded+28-0
Changes to tests/fixtures/updates/file-write-limit.cpp
@@ -0,0 +1,28 @@
1// Test-only inherited POSIX file-size limit. No application or board linkage.
2#include <cerrno>
3#include <csignal>
4#include <cstdio>
5#include <cstdlib>
6#include <cstring>
7#include <sys/resource.h>
8#include <unistd.h>
9
10int main(int argc, char** argv) {
11 if (argc < 3 || !argv[1][0] || std::strlen(argv[1]) > 8
12 || std::strspn(argv[1], "0123456789") != std::strlen(argv[1])) return 125;
13 errno = 0;
14 char* end = nullptr;
15 const auto requested = std::strtoull(argv[1], &end, 10);
16 if (errno || !end || *end || requested < 1 || requested > 16777216) return 125;
17 const rlim_t bytes = static_cast<rlim_t>(requested);
18 const rlimit limit{bytes, bytes};
19 if (setrlimit(RLIMIT_FSIZE, &limit) != 0 || std::signal(SIGXFSZ, SIG_IGN) == SIG_ERR) return 125;
20 rlimit observed{};
21 if (getrlimit(RLIMIT_FSIZE, &observed) != 0 || observed.rlim_cur != bytes || observed.rlim_max != bytes) return 125;
22 if (setenv("DRONE_TEST_FILE_LIMIT", argv[1], 1) != 0) return 125;
23 std::fprintf(stderr, "FILE_LIMIT_ACTIVE %llu\n", requested);
24 std::fflush(stderr);
25 execvp(argv[2], argv + 2);
26 std::perror("File-limit test exec");
27 return 127;
28}
tests/fixtures/updates/host-project.tsmodified+8-2
Changes to tests/fixtures/updates/host-project.ts
@@ -1,12 +1,18 @@
11import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
22import { join } from 'node:path'
33
4/** Actual disposable source repo with explicit stop-reply or configure-output faults. */
5export async function createHostSourceProject(root: string, options: { fault?: 'stop-session' | 'configure-output' } = {}) {
4/** Actual disposable source repo with explicit faults and optional host-only file padding. */
5export async function createHostSourceProject(root: string, options: { fault?: 'stop-session' | 'configure-output', imagePadding?: true } = {}) {
66 const project = join(root, 'project')
77 await mkdir(project, { mode: 0o700 })
88 await mkdir(join(project, 'firmware'), { mode: 0o700 })
99 await cp(join(import.meta.dir, '../../../firmware/runtime'), join(project, 'firmware/runtime'), { recursive: true })
10 if (options.imagePadding) {
11 const path = join(project, 'firmware/runtime/src/host.cpp'), source = await readFile(path, 'utf8')
12 const entry = 'int main(int argc, char** argv) {'
13 if (source.split(entry).length !== 2) throw new Error('Owned host entry no longer matches the large-image fixture')
14 await writeFile(path, source.replace(entry, 'struct TestImagePadding { unsigned char first; unsigned char bytes[2 * 1024 * 1024]; unsigned char last; };\nvolatile TestImagePadding test_image_padding{1, {}, 1};\n' + entry + '\n if (test_image_padding.first != 1 || test_image_padding.last != 1) return 2;'))
15 }
1016 if (options.fault === 'stop-session') {
1117 const path = join(project, 'firmware/runtime/src/host.cpp'), source = await readFile(path, 'utf8')
1218 const original = 'drone::encode(runtime.status(now), bytes);'
tests/fixtures/updates/host-write-limit.tsadded+155-0
Changes to tests/fixtures/updates/host-write-limit.ts
@@ -0,0 +1,155 @@
1import assert from 'node:assert/strict'
2import { randomBytes } from 'node:crypto'
3import { mkdir, readFile, writeFile } from 'node:fs/promises'
4import { basename, dirname, join, resolve } from 'node:path'
5import { buildRuntime } from '../../../resources/functions/drone/runtime/build'
6import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment'
7import { createRuntimeTrustStore, inspectRuntimeFirmwareSlots, recoverRuntimeTrustStore, reserveRuntimeFirmwareCandidate, trialRuntimeFirmwareCandidate } from '../../../resources/functions/drone/updates/store'
8import { runBoundedHostProcess } from '../../../resources/functions/host/process'
9import { createHostSourceProject } from './host-project'
10import { digest, repository, target } from './repository'
11
12const fileLimitBytes = 1048576
13const clock = { source: 'independent-resource-fixture-clock', read: () => new Date('2030-01-01T00:00:00Z') }
14
15function errorCodes(error: unknown) {
16 const pending = [error], seen = new Set<unknown>(), codes: string[] = []
17 while (pending.length && seen.size < 32) {
18 const item = pending.shift()
19 if (!item || typeof item !== 'object' || seen.has(item)) continue
20 seen.add(item)
21 const code = Object.getOwnPropertyDescriptor(item, 'code')?.value
22 if ((typeof code === 'string' || typeof code === 'number') && String(code).length <= 64) codes.push(String(code))
23 const cause = Object.getOwnPropertyDescriptor(item, 'cause')?.value
24 if (cause !== undefined) pending.push(cause)
25 const errors = Object.getOwnPropertyDescriptor(item, 'errors')?.value
26 if (Array.isArray(errors)) pending.push(...errors.slice(0, 32))
27 }
28 return codes
29}
30
31async function fixtureInput(inputPath: string) {
32 assert.equal(basename(inputPath), 'worker-input.json')
33 const root = dirname(resolve(inputPath))
34 const input = await Bun.file(inputPath).json()
35 const marker = await Bun.file(join(root, 'fixture.json')).json()
36 assert.equal(marker.kind, 'host-file-write-limit-v1'); assert.equal(input.nonce, marker.nonce)
37 assert.match(input.nonce, /^[a-f0-9]{32}$/)
38 assert.equal(input.reference.directory, join(root, 'store'))
39 assert.equal(input.reference.enrollmentDirectory, join(root, 'enrollment'))
40 assert.equal(input.project, join(root, 'project')); assert.equal(input.repository, join(root, 'repository'))
41 return input
42}
43
44async function limitedTrial(inputPath: string) {
45 assert.equal(process.env.DRONE_TEST_FILE_LIMIT, String(fileLimitBytes))
46 const input = await fixtureInput(inputPath)
47 const phases: string[] = []
48 let caught = false, codes: string[] = []
49 try {
50 await trialRuntimeFirmwareCandidate({ ...input.reference, repository: input.repository, project: input.project, candidateId: input.candidateId, clock, onProgress(event) {
51 assert.ok(phases.length < 512); phases.push(event.phase)
52 } })
53 }
54 catch (error) { caught = true; codes = errorCodes(error) }
55 assert.equal(caught, true, 'The actual kernel file limit must reject the trial')
56 return { errorCodes: codes, phases }
57}
58
59/** Real signed public coordinator, actual short OS write, retained bytes and fresh recovery. */
60export async function qualifyHostFileWriteLimit(directory: string) {
61 directory = resolve(directory)
62 await mkdir(directory, { mode: 0o700 })
63 const nonce = randomBytes(16).toString('hex')
64 await writeFile(join(directory, 'fixture.json'), JSON.stringify({ kind: 'host-file-write-limit-v1', nonce }), { mode: 0o600 })
65 const launcher = join(directory, 'file-write-limit')
66 const compiled = await runBoundedHostProcess(['c++', '-std=c++17', '-O2', join(import.meta.dir, 'file-write-limit.cpp'), '-o', launcher], { timeoutMs: 10000, stdoutBytes: 16384, stderrBytes: 16384, label: 'Host file-limit launcher build' })
67 assert.equal(compiled.exitCode, 0, compiled.stderr)
68 const { project, sourceRevision } = await createHostSourceProject(directory, { imagePadding: true })
69 const built = await buildRuntime({ project }), image = await readFile(built.artifact)
70 assert.ok(image.length > fileLimitBytes && image.length < 4 * fileLimitBytes)
71 const source = join(directory, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } })
72 const enrollmentDirectory = join(directory, 'enrollment')
73 const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
74 const store = join(directory, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: store })
75 const reference = { directory: store, enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, storeSha256: created.storeSha256 }
76 const first = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
77 const good = await trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, project, candidateId: first.candidate!.id })
78 assert.equal(good.knownGood!.description.releaseSequence, 7)
79 await signed.publish({ image, version: 2, custom: { releaseSequence: 9 } })
80 const next = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
81 assert.equal(next.store.releaseFloor, 9)
82 await signed.publish({ image, version: 3, custom: { releaseSequence: 9 }, rotateTargets: true })
83 const config = join(directory, 'worker.bunfig.toml'), input = join(directory, 'worker-input.json')
84 await writeFile(config, 'preload=[]\n', { mode: 0o600 })
85 await writeFile(input, JSON.stringify({ nonce, reference, project, repository: source, candidateId: next.candidate!.id }), { mode: 0o600 })
86 const workerEnv = { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }
87 const command = [launcher, String(fileLimitBytes), process.execPath, '--no-env-file', `--config=${config}`, import.meta.path, 'limited', input]
88 const worker = await runBoundedHostProcess(command, { cwd: directory, env: workerEnv, timeoutMs: 60000, stdoutBytes: 16384, stderrBytes: 16384, label: 'Actual host image short-write trial' })
89 await writeFile(join(directory, 'worker.stdout'), worker.stdout)
90 await writeFile(join(directory, 'worker.stderr'), worker.stderr)
91 await writeFile(join(directory, 'worker.json'), JSON.stringify({ exitCode: worker.exitCode }))
92 assert.equal(worker.exitCode, 0, worker.stderr)
93 assert.equal(worker.stderr, `FILE_LIMIT_ACTIVE ${fileLimitBytes}\n`)
94 const rejected = JSON.parse(worker.stdout)
95 assert.ok(Array.isArray(rejected.errorCodes) && rejected.errorCodes.length <= 32 && rejected.errorCodes.every((code: unknown) => typeof code === 'string' && code.length <= 64) && rejected.errorCodes.includes('EFBIG'))
96 assert.ok(Array.isArray(rejected.phases) && rejected.phases.length <= 512 && rejected.phases.every((phase: unknown) => typeof phase === 'string' && phase.length <= 64))
97 assert.ok(rejected.phases.includes('slot-selected'))
98 assert.ok(!rejected.phases.includes('slot-image-written') && !rejected.phases.includes('runtime-started') && !rejected.phases.includes('runtime-input-committed'))
99 const failed = await inspectRuntimeFirmwareSlots(reference)
100 await writeFile(join(directory, 'failed-slots.json'), JSON.stringify(failed, null, 2))
101 assert.deepEqual(failed.knownGood, good.knownGood); assert.deepEqual(failed.installed, good.installed)
102 assert.equal(failed.trial!.phase, 'failed'); assert.equal(failed.trial!.health, null)
103 assert.equal(failed.trial!.integrity, 'mismatch'); assert.equal(failed.store.attempt!.phase, 'maintenance')
104 assert.equal(failed.store.releaseFloor, 9)
105 const version = (role: string) => failed.store.metadata.find(row => row.role === role)!.version
106 assert.equal(version('root'), 2); assert.equal(version('targets'), 3)
107 const partialPath = join(store, 'host-slots', failed.trial!.id, 'image.bin'), partial = await readFile(partialPath)
108 assert.equal(partial.length, fileLimitBytes); assert.deepEqual(partial, image.subarray(0, fileLimitBytes))
109 const oldImage = join(store, 'host-slots', good.knownGood!.id, 'image.bin')
110 assert.deepEqual(await readFile(oldImage), image)
111 async function unrestricted(mode: 'recover' | 'fresh') {
112 const result = await runBoundedHostProcess([process.execPath, '--no-env-file', `--config=${config}`, import.meta.path, mode, input], { cwd: directory, env: { ...workerEnv, DRONE_TEST_FILE_LIMIT: undefined }, timeoutMs: 60000, stdoutBytes: 16384, stderrBytes: 16384, label: `Fresh host write-limit ${mode}` })
113 await writeFile(join(directory, `${mode}.stdout`), result.stdout)
114 await writeFile(join(directory, `${mode}.stderr`), result.stderr)
115 await writeFile(join(directory, `${mode}.json`), JSON.stringify({ exitCode: result.exitCode }))
116 assert.equal(result.exitCode, 0, result.stderr)
117 assert.equal(result.stderr, '')
118 assert.equal(result.stdout, `${mode === 'recover' ? 'RECOVERY_COMPLETED' : 'FRESH_TRIAL_COMPLETED'}\n`)
119 }
120 await unrestricted('recover')
121 const recovered = await inspectRuntimeFirmwareSlots(reference)
122 await writeFile(join(directory, 'recovered-slots.json'), JSON.stringify(recovered, null, 2))
123 assert.deepEqual(recovered.trial, failed.trial); assert.deepEqual(recovered.knownGood, good.knownGood)
124 assert.equal(recovered.store.releaseFloor, 9)
125 assert.deepEqual(recovered.store.metadata, failed.store.metadata)
126 await unrestricted('fresh')
127 const fresh = await inspectRuntimeFirmwareSlots(reference)
128 await writeFile(join(directory, 'confirmed-slots.json'), JSON.stringify(fresh, null, 2))
129 assert.notEqual(fresh.knownGood!.id, failed.trial!.id)
130 assert.equal(fresh.knownGood!.description.releaseSequence, 9)
131 assert.equal(fresh.knownGood!.health!.cleanProcessExit, true)
132 assert.notEqual(fresh.knownGood!.health!.bootId, good.knownGood!.health!.bootId)
133 assert.notEqual(fresh.knownGood!.health!.sessionId, good.knownGood!.health!.sessionId)
134 assert.deepEqual(await readFile(oldImage), image); assert.deepEqual(await readFile(partialPath), partial)
135 assert.deepEqual(await readFile(join(store, 'host-slots', fresh.knownGood!.id, 'image.bin')), image)
136 const result = { evidence: 'host-slot-actual-short-write-v1', sourceRevision, authorizedImageSha256: signed.imageHash, fileLimitBytes, partialImageBytes: partial.length, partialImageSha256: digest(partial), authorizedImageBytes: image.length, errorCodes: rejected.errorCodes as string[], acceptedRootVersion: version('root'), acceptedTargetsVersion: version('targets'), releaseFloor: fresh.store.releaseFloor, previousSlotId: good.knownGood!.id, failedSlotId: failed.trial!.id, confirmedSlotId: fresh.knownGood!.id, previousBootId: good.knownGood!.health!.bootId, freshBootId: fresh.knownGood!.health!.bootId, physicalDeploymentAllowed: fresh.physicalDeploymentAllowed }
137 await writeFile(join(directory, 'qualification.json'), JSON.stringify(result, null, 2))
138 return result
139}
140
141if (import.meta.main) {
142 try {
143 assert.equal(process.argv.length, 4)
144 const mode = process.argv[2], inputPath = resolve(process.argv[3]!)
145 if (mode === 'limited') console.log(JSON.stringify(await limitedTrial(inputPath)))
146 else if (mode === 'recover' || mode === 'fresh') {
147 assert.equal(process.env.DRONE_TEST_FILE_LIMIT, undefined)
148 const input = await fixtureInput(inputPath)
149 if (mode === 'recover') { await recoverRuntimeTrustStore(input.reference); console.log('RECOVERY_COMPLETED') }
150 else { await trialRuntimeFirmwareCandidate({ ...input.reference, repository: input.repository, project: input.project, candidateId: input.candidateId, clock }); console.log('FRESH_TRIAL_COMPLETED') }
151 }
152 else { assert.equal(mode, 'qualify'); console.log(JSON.stringify(await qualifyHostFileWriteLimit(inputPath))) }
153 }
154 catch (error) { console.error('Host file-limit fixture failed', error); process.exitCode = 1 }
155}
tests/helpers/public-safety-purge-fixture.tsmodified+16-4
Changes to tests/helpers/public-safety-purge-fixture.ts
@@ -2,19 +2,31 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
22import { tmpdir } from 'node:os'
33import { join } from 'node:path'
44
5/** Await one guarded native fixture and own its terminal process boundary. */
6export async function runPurgeQualificationFixture(name: string, timeoutMs: number): Promise<{ code: number, stdout: string, stderr: string }> {
5/**
6 * Await one guarded native fixture. Elapsed time includes output drain; the
7 * deadline marker and observed signal are separate facts, not a causal verdict.
8 */
9export async function runPurgeQualificationFixture(name: string, timeoutMs: number): Promise<{ code: number, stdout: string, stderr: string, lifecycle: Readonly<{ pid: number, timeoutMs: number, deadlineFired: boolean, elapsedMs: number, signal: string | number | null }> }> {
710 if (!['purge-faults', 'purge-critical', 'purge-budget', 'purge-capacity', 'purge-sql', 'purge-relations', 'purge-seal', 'purge-bounds', 'purge-mixed', 'purge-namespace-identity'].includes(name)) throw new Error('Unknown qualification fixture')
811 const root = await mkdtemp(join(tmpdir(), `drone-incident-access-${name}-`))
912 let child: ReturnType<typeof Bun.spawn> | undefined
1013 try {
1114 const config = join(root, 'bunfig.toml'); await writeFile(config, 'preload=[]\n')
15 const started = performance.now()
16 let deadlineFired = false
1217 const running = Bun.spawn([process.execPath, `--config=${config}`, '--no-env-file', `${import.meta.dir}/../fixtures/public-safety-access/${name}.ts`], { cwd: root, env: { ...process.env, APP_ENV: 'test', NODE_ENV: 'test', DB_CONNECTION: 'sqlite', DB_DATABASE_PATH: join(root, 'access.sqlite') }, stdout: 'pipe', stderr: 'pipe' })
1318 child = running
14 const timer = setTimeout(() => { if (running.exitCode === null) running.kill('SIGKILL') }, timeoutMs)
19 const timer = setTimeout(() => {
20 if (running.exitCode !== null) return
21 deadlineFired = true
22 running.kill('SIGKILL')
23 console.error('PURGE_FIXTURE_DEADLINE', JSON.stringify({ fixture: name, pid: running.pid, timeoutMs, elapsedMs: performance.now() - started, requestedSignal: 'SIGKILL' }))
24 }, timeoutMs)
1525 try {
1626 const [code, stdout, stderr] = await Promise.all([running.exited, new Response(running.stdout).text(), new Response(running.stderr).text()])
17 return { code, stdout, stderr }
27 const lifecycle = Object.freeze({ pid: running.pid, timeoutMs, deadlineFired, elapsedMs: performance.now() - started, signal: running.signalCode ?? null })
28 if (code !== 0) console.error('PURGE_FIXTURE_EXIT', JSON.stringify({ fixture: name, code, ...lifecycle }))
29 return { code, stdout, stderr, lifecycle }
1830 }
1931 finally { clearTimeout(timer) }
2032 }
tests/unit/updates-host-writes.test.tsadded+32-0
Changes to tests/unit/updates-host-writes.test.ts
@@ -0,0 +1,32 @@
1import { expect, test } from 'bun:test'
2import { mkdir, mkdtemp, rm } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join, resolve } from 'node:path'
5import { qualifyHostFileWriteLimit } from '../fixtures/updates/host-write-limit'
6
7// Public coordinator plus actual OS writes catches lost floors or false health
8// after a partial image copy. Physical media and other errno classes are separate.
9test('an actual short host-image write preserves accepted trust and needs fresh runtime confirmation after recovery', async () => {
10 const evidence = process.env.RUNTIME_HOST_FILE_LIMIT_EVIDENCE
11 const root = evidence ? resolve(evidence) : await mkdtemp(join(tmpdir(), 'drone-host-write-limit-'))
12 if (evidence) await mkdir(root, { mode: 0o700 })
13 let completed = false
14 try {
15 const result = await qualifyHostFileWriteLimit(join(root, 'campaign'))
16 expect(result.fileLimitBytes).toBe(1048576)
17 expect(result.partialImageBytes).toBe(1048576)
18 expect(result.authorizedImageBytes).toBeGreaterThan(result.partialImageBytes)
19 expect(result.errorCodes).toContain('EFBIG')
20 expect(result.acceptedRootVersion).toBe(2)
21 expect(result.acceptedTargetsVersion).toBe(3)
22 expect(result.releaseFloor).toBe(9)
23 expect(result.freshBootId).not.toBe(result.previousBootId)
24 expect(result.physicalDeploymentAllowed).toBe(false)
25 completed = true
26 }
27 finally {
28 if (completed && !evidence) await rm(root, { recursive: true, force: true })
29 else if (completed) console.log(`Host file-limit campaign retained at ${root}`)
30 else console.error(`Failed host file-limit campaign retained at ${root}`)
31 }
32}, 120000)