also looking at this
test: qualify partial host image writes and recovery
#57
9 files
+311
-7
| @@ -32,7 +32,27 @@ jobs: | ||
| 32 | 32 | - run: python3 -m unittest discover -s tests/hardware |
| 33 | 33 | - name: Verify a kernel-confirmed full input pipe on Linux |
| 34 | 34 | 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 | |
| 35 | 46 | - 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 | |
| 36 | 56 | - run: bun run runtime:cli |
| 37 | 57 | - name: Verify signed test-image staging through the portable CLI outside the checkout |
| 38 | 58 | run: bun tests/fixtures/updates/qualify.ts "$RUNNER_TEMP/runtime-update-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" |
| @@ -0,0 +1,36 @@ | ||
| 1 | # Host storage fault fixtures | |
| 2 | ||
| 3 | Status: qualification in progress for #42. Physical deployment remains disabled. | |
| 4 | ||
| 5 | Exercise storage failures through the existing public update coordinator with | |
| 6 | real signed repositories and separate processes. Keep the enrollment, protocol | |
| 7 | store, owned candidates, host slots and current-policy checks in the path. | |
| 8 | Assertions read public status and actual retained files. No filesystem method or | |
| 9 | SDK verifier is replaced. | |
| 10 | ||
| 11 | The first campaign uses POSIX `RLIMIT_FSIZE` on macOS and Linux. A test-only C++17 | |
| 12 | launcher sets and reads back the child's hard and soft limit, ignores `SIGXFSZ` | |
| 13 | so an over-limit write can report `EFBIG`, then executes Bun. This small native | |
| 14 | test exception supplies the kernel resource control that the TypeScript harness | |
| 15 | needs. It is not linked into the application or board runtime. Stacks' bounded | |
| 16 | process capture owns command execution and output. | |
| 17 | ||
| 18 | A disposable signed source checkout adds initialized padding to its host image. | |
| 19 | It retains the neutral kernel behavior and forces the authentic executable past | |
| 20 | the file-size limit. Prepare candidates and the prior known-good image before | |
| 21 | the limited process starts. New signed metadata is accepted during the limited | |
| 22 | trial. The host-slot copy must then make real partial progress and fail before | |
| 23 | runtime launch or health confirmation. | |
| 24 | ||
| 25 | Retain the partial slot file and prior known-good bytes. Verify metadata and | |
| 26 | release floors after rejection and a fresh recovery process. A later unrestricted | |
| 27 | trial needs a new slot and actual fresh runtime health; it cannot complete the | |
| 28 | failed slot by marker inspection. The large padded image is a host fixture, | |
| 29 | without a board memory or timing qualification claim. | |
| 30 | ||
| 31 | This establishes a process-local file-size-limit failure, including an actual | |
| 32 | short image write. It does not establish zero-return writes, ENOSPC, EDQUOT, EIO, | |
| 33 | sync failure, full-medium behavior or electrical power-loss durability. Those | |
| 34 | remain explicit parts of the full storage campaign. Authenticated maintenance, | |
| 35 | board installation, boot recovery and hardware anti-rollback retain their own | |
| 36 | acceptance gates. | |
| @@ -287,7 +287,11 @@ | ||
| 287 | 287 | "tests/unit/updates-host-policy.test.ts", |
| 288 | 288 | "tests/unit/updates-host-slots-cli.test.ts", |
| 289 | 289 | "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" | |
| 291 | 295 | ], |
| 292 | 296 | "remaining": [ |
| 293 | 297 | "Live runtime/sensor/estimator/authority/mission/payload correlation and measured clock relations", |
| @@ -581,3 +581,14 @@ An actual CMake output-overflow case also stops before runtime start, retains | ||
| 581 | 581 | release floor 9 through recovery, then observes a fresh healthy trial after the |
| 582 | 582 | private build fault is removed. Build tools use native bounded process capture |
| 583 | 583 | with the existing 120-second command deadline and 2 MB per-stream limits. |
| 584 | ||
| 585 | The [host storage fixture](adr/0030-host-storage-fault-fixtures.md) also runs an | |
| 586 | actual signed large host image under a read-back 1 MiB POSIX file-size limit. | |
| 587 | The slot copy retains exactly 1 MiB of the authenticated prefix before `EFBIG`, | |
| 588 | with no trial runtime launch or health confirmation. New root/target metadata and | |
| 589 | release floor 9 survive, and the prior known-good image remains intact. Recovery | |
| 590 | and the later unrestricted trial run in separate fresh Bun processes. Only the | |
| 591 | new slot can become known-good through actual fresh runtime health; the failed | |
| 592 | partial file stays retained. CI keeps the synthetic campaign artifacts for seven | |
| 593 | days. This file-size-limit evidence leaves zero-return writes, full-medium/quota, | |
| 594 | EIO, sync and physical power-loss campaigns open. | |
| @@ -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 | ||
| 10 | int 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 | } | |
| @@ -1,12 +1,18 @@ | ||
| 1 | 1 | import { cp, mkdir, readFile, writeFile } from 'node:fs/promises' |
| 2 | 2 | import { join } from 'node:path' |
| 3 | 3 | |
| 4 | /** Actual disposable source repo with explicit stop-reply or configure-output faults. */ | |
| 5 | export 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. */ | |
| 5 | export async function createHostSourceProject(root: string, options: { fault?: 'stop-session' | 'configure-output', imagePadding?: true } = {}) { | |
| 6 | 6 | const project = join(root, 'project') |
| 7 | 7 | await mkdir(project, { mode: 0o700 }) |
| 8 | 8 | await mkdir(join(project, 'firmware'), { mode: 0o700 }) |
| 9 | 9 | 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 | } | |
| 10 | 16 | if (options.fault === 'stop-session') { |
| 11 | 17 | const path = join(project, 'firmware/runtime/src/host.cpp'), source = await readFile(path, 'utf8') |
| 12 | 18 | const original = 'drone::encode(runtime.status(now), bytes);' |
| @@ -0,0 +1,155 @@ | ||
| 1 | import assert from 'node:assert/strict' | |
| 2 | import { randomBytes } from 'node:crypto' | |
| 3 | import { mkdir, readFile, writeFile } from 'node:fs/promises' | |
| 4 | import { basename, dirname, join, resolve } from 'node:path' | |
| 5 | import { buildRuntime } from '../../../resources/functions/drone/runtime/build' | |
| 6 | import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment' | |
| 7 | import { createRuntimeTrustStore, inspectRuntimeFirmwareSlots, recoverRuntimeTrustStore, reserveRuntimeFirmwareCandidate, trialRuntimeFirmwareCandidate } from '../../../resources/functions/drone/updates/store' | |
| 8 | import { runBoundedHostProcess } from '../../../resources/functions/host/process' | |
| 9 | import { createHostSourceProject } from './host-project' | |
| 10 | import { digest, repository, target } from './repository' | |
| 11 | ||
| 12 | const fileLimitBytes = 1048576 | |
| 13 | const clock = { source: 'independent-resource-fixture-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 14 | ||
| 15 | function 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 | ||
| 31 | async 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 | ||
| 44 | async 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. */ | |
| 60 | export 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 | ||
| 141 | if (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 | } | |
| @@ -2,19 +2,31 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' | ||
| 2 | 2 | import { tmpdir } from 'node:os' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | |
| 5 | /** Await one guarded native fixture and own its terminal process boundary. */ | |
| 6 | export 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 | */ | |
| 9 | export 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 }> }> { | |
| 7 | 10 | 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') |
| 8 | 11 | const root = await mkdtemp(join(tmpdir(), `drone-incident-access-${name}-`)) |
| 9 | 12 | let child: ReturnType<typeof Bun.spawn> | undefined |
| 10 | 13 | try { |
| 11 | 14 | const config = join(root, 'bunfig.toml'); await writeFile(config, 'preload=[]\n') |
| 15 | const started = performance.now() | |
| 16 | let deadlineFired = false | |
| 12 | 17 | 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' }) |
| 13 | 18 | 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) | |
| 15 | 25 | try { |
| 16 | 26 | 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 } | |
| 18 | 30 | } |
| 19 | 31 | finally { clearTimeout(timer) } |
| 20 | 32 | } |
| @@ -0,0 +1,32 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mkdir, mkdtemp, rm } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join, resolve } from 'node:path' | |
| 5 | import { 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. | |
| 9 | test('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) | |