From 7e3b7b64d1fc4b189c01f0c74c9bd590ee5c3ae9 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 6 Aug 2026 10:23:02 -0400 Subject: [PATCH 1/2] feat(docs): validate USB Creator screenshot imports --- scripts/import-usb-creator-flow.mjs | 155 +++++++++++++++++++++++ scripts/import-usb-creator-flow.test.mjs | 104 +++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 scripts/import-usb-creator-flow.mjs create mode 100644 scripts/import-usb-creator-flow.test.mjs diff --git a/scripts/import-usb-creator-flow.mjs b/scripts/import-usb-creator-flow.mjs new file mode 100644 index 00000000000..583781813d7 --- /dev/null +++ b/scripts/import-usb-creator-flow.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { + copyFile, + lstat, + mkdir, + readFile, + realpath, + rename, + rm, +} from 'node:fs/promises'; +import path from 'node:path'; + +const EXPECTED_CATEGORY = 'unraid-os'; +const EXPECTED_FLOW = 'create-unraid-usb'; +const DESTINATION = path.resolve( + 'static/img/unraid-os/getting-started/create-unraid-usb', +); + +function fail(message) { + throw new Error(`USB Creator screenshot import failed: ${message}`); +} + +function safeSlug(value, label) { + if (typeof value !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) { + fail(`${label} is not a safe slug`); + } + return value; +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +async function sourceImage(bundleRoot, relativeImage) { + if (typeof relativeImage !== 'string' || path.isAbsolute(relativeImage)) { + fail('step image must be a relative path'); + } + + const candidate = path.resolve(bundleRoot, relativeImage); + const relative = path.relative(bundleRoot, candidate); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + fail(`step image escapes the bundle: ${relativeImage}`); + } + + const metadata = await lstat(candidate); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + fail(`step image is not a regular file: ${relativeImage}`); + } + + const resolved = await realpath(candidate); + const resolvedRelative = path.relative(bundleRoot, resolved); + if (resolvedRelative.startsWith('..') || path.isAbsolute(resolvedRelative)) { + fail(`step image resolves outside the bundle: ${relativeImage}`); + } + return resolved; +} + +async function main() { + const bundleArgument = process.argv[2]; + if (!bundleArgument || process.argv.length !== 3) { + fail('usage: import-usb-creator-flow.mjs '); + } + + const bundleRoot = await realpath(path.resolve(bundleArgument)); + const input = JSON.parse( + await readFile(path.join(bundleRoot, 'guide-input.json'), 'utf8'), + ); + + if (input.schemaVersion !== 2) fail('schemaVersion must be 2'); + if (input.categoryCount !== 1 || input.categories?.length !== 1) { + fail('bundle must contain exactly one category'); + } + + const category = input.categories[0]; + if (safeSlug(category.slug, 'category slug') !== EXPECTED_CATEGORY) { + fail(`unexpected category: ${category.slug}`); + } + if (category.flowCount !== 1 || category.flows?.length !== 1) { + fail('bundle must contain exactly one flow'); + } + + const flow = category.flows[0]; + if (safeSlug(flow.slug, 'flow slug') !== EXPECTED_FLOW) { + fail(`unexpected flow: ${flow.slug}`); + } + if (!Array.isArray(flow.steps) || flow.steps.length === 0) { + fail('flow must contain at least one step'); + } + if (flow.stepCount !== flow.steps.length) fail('stepCount does not match steps'); + + const identity = flow.publicationIdentity; + if ( + typeof identity?.publicationKey !== 'string' || + identity.publicationKey.length === 0 || + typeof identity?.captureId !== 'string' || + identity.captureId.length === 0 + ) { + fail('flow publication identity is incomplete'); + } + + const parent = path.dirname(DESTINATION); + await mkdir(parent, { recursive: true }); + const staging = path.join( + parent, + `.create-unraid-usb-${process.pid}-${Date.now()}`, + ); + await mkdir(staging); + + try { + const names = new Set(); + for (const [index, step] of flow.steps.entries()) { + if (step.order !== index + 1) fail('steps must have contiguous order'); + if ( + step.publicationKey !== identity.publicationKey || + step.captureId !== identity.captureId + ) { + fail(`step ${step.order} does not match the flow publication identity`); + } + if (typeof step.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(step.sha256)) { + fail(`step ${step.order} has an invalid SHA-256 digest`); + } + + const source = await sourceImage(bundleRoot, step.image); + const bytes = await readFile(source); + if (sha256(bytes) !== step.sha256) { + fail(`step ${step.order} does not match its accepted digest`); + } + + const filename = path.basename(step.image); + if (!/^\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.png$/.test(filename)) { + fail(`step ${step.order} has an unsafe public filename`); + } + if (names.has(filename)) fail(`duplicate public filename: ${filename}`); + names.add(filename); + await copyFile(source, path.join(staging, filename)); + } + + await rm(DESTINATION, { recursive: true, force: true }); + await rename(staging, DESTINATION); + } catch (error) { + await rm(staging, { recursive: true, force: true }); + throw error; + } + + console.log( + `Imported ${flow.steps.length} accepted USB Creator screenshots from ${identity.captureId}`, + ); +} + +main().catch((error) => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/scripts/import-usb-creator-flow.test.mjs b/scripts/import-usb-creator-flow.test.mjs new file mode 100644 index 00000000000..6f1d9834c8b --- /dev/null +++ b/scripts/import-usb-creator-flow.test.mjs @@ -0,0 +1,104 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const repository = path.resolve(import.meta.dirname, '..'); +const importer = path.join(repository, 'scripts/import-usb-creator-flow.mjs'); +const destination = path.join( + repository, + 'static/img/unraid-os/getting-started/create-unraid-usb', +); + +function digest(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +async function bundleFixture() { + const root = await mkdtemp(path.join(tmpdir(), 'usb-creator-bundle-')); + const imageDirectory = path.join(root, 'images/unraid-os/create-unraid-usb'); + await mkdir(imageDirectory, { recursive: true }); + const image = Buffer.from('accepted screenshot fixture'); + const imagePath = 'images/unraid-os/create-unraid-usb/01-select-release.png'; + await writeFile(path.join(root, imagePath), image); + const guide = { + schemaVersion: 2, + categoryCount: 1, + flowCount: 1, + categories: [ + { + category: 'unraid-os', + slug: 'unraid-os', + flowCount: 1, + flows: [ + { + flow: 'create-unraid-usb', + slug: 'create-unraid-usb', + stepCount: 1, + publicationIdentity: { + publicationKey: 'usb-creator:linux:create-unraid-usb', + captureId: 'test-capture', + }, + steps: [ + { + order: 1, + image: imagePath, + publicationKey: 'usb-creator:linux:create-unraid-usb', + captureId: 'test-capture', + sha256: digest(image), + }, + ], + }, + ], + }, + ], + }; + await writeFile(path.join(root, 'guide-input.json'), `${JSON.stringify(guide)}\n`); + return { root, guide }; +} + +test('imports the verified image set and removes stale images', async () => { + const fixture = await bundleFixture(); + await mkdir(destination, { recursive: true }); + await writeFile(path.join(destination, 'stale.png'), 'stale'); + + try { + const result = spawnSync(process.execPath, [importer, fixture.root], { + cwd: repository, + encoding: 'utf8', + }); + assert.equal(result.status, 0, result.stderr); + assert.equal( + await readFile(path.join(destination, '01-select-release.png'), 'utf8'), + 'accepted screenshot fixture', + ); + await assert.rejects(readFile(path.join(destination, 'stale.png'))); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + await rm(destination, { recursive: true, force: true }); + } +}); + +test('rejects a screenshot whose bytes do not match the accepted digest', async () => { + const fixture = await bundleFixture(); + fixture.guide.categories[0].flows[0].steps[0].sha256 = '0'.repeat(64); + await writeFile( + path.join(fixture.root, 'guide-input.json'), + `${JSON.stringify(fixture.guide)}\n`, + ); + + try { + const result = spawnSync(process.execPath, [importer, fixture.root], { + cwd: repository, + encoding: 'utf8', + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /does not match its accepted digest/); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + await rm(destination, { recursive: true, force: true }); + } +}); From 502abd68ce18d70d62bd0865a42615ff9a1673e2 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 6 Aug 2026 10:27:44 -0400 Subject: [PATCH 2/2] fix(docs): harden screenshot publication --- scripts/import-usb-creator-flow.mjs | 63 ++++++++++++++++++------ scripts/import-usb-creator-flow.test.mjs | 48 +++++++++++++----- 2 files changed, 83 insertions(+), 28 deletions(-) diff --git a/scripts/import-usb-creator-flow.mjs b/scripts/import-usb-creator-flow.mjs index 583781813d7..8ce849df579 100644 --- a/scripts/import-usb-creator-flow.mjs +++ b/scripts/import-usb-creator-flow.mjs @@ -2,19 +2,23 @@ import { createHash } from 'node:crypto'; import { - copyFile, lstat, mkdir, readFile, realpath, rename, rm, + writeFile, } from 'node:fs/promises'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; const EXPECTED_CATEGORY = 'unraid-os'; const EXPECTED_FLOW = 'create-unraid-usb'; -const DESTINATION = path.resolve( +const EXPECTED_PUBLICATION_KEY = 'usb-creator:linux:create-unraid-usb'; +const REPOSITORY = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DESTINATION = path.join( + REPOSITORY, 'static/img/unraid-os/getting-started/create-unraid-usb', ); @@ -57,12 +61,30 @@ async function sourceImage(bundleRoot, relativeImage) { return resolved; } -async function main() { - const bundleArgument = process.argv[2]; - if (!bundleArgument || process.argv.length !== 3) { - fail('usage: import-usb-creator-flow.mjs '); +async function publishDirectory(staging, destination, renameDirectory = rename) { + const backup = `${destination}.previous-${process.pid}-${Date.now()}`; + let hadDestination = false; + try { + await lstat(destination); + hadDestination = true; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + + if (hadDestination) await renameDirectory(destination, backup); + try { + await renameDirectory(staging, destination); + } catch (error) { + if (hadDestination) await renameDirectory(backup, destination); + throw error; } + if (hadDestination) await rm(backup, { recursive: true, force: true }); +} +export async function importBundle( + bundleArgument, + { destination = DESTINATION, renameDirectory = rename } = {}, +) { const bundleRoot = await realpath(path.resolve(bundleArgument)); const input = JSON.parse( await readFile(path.join(bundleRoot, 'guide-input.json'), 'utf8'), @@ -92,15 +114,15 @@ async function main() { const identity = flow.publicationIdentity; if ( - typeof identity?.publicationKey !== 'string' || - identity.publicationKey.length === 0 || + identity?.publicationKey !== EXPECTED_PUBLICATION_KEY || typeof identity?.captureId !== 'string' || identity.captureId.length === 0 ) { fail('flow publication identity is incomplete'); } - const parent = path.dirname(DESTINATION); + const resolvedDestination = path.resolve(destination); + const parent = path.dirname(resolvedDestination); await mkdir(parent, { recursive: true }); const staging = path.join( parent, @@ -134,11 +156,10 @@ async function main() { } if (names.has(filename)) fail(`duplicate public filename: ${filename}`); names.add(filename); - await copyFile(source, path.join(staging, filename)); + await writeFile(path.join(staging, filename), bytes); } - await rm(DESTINATION, { recursive: true, force: true }); - await rename(staging, DESTINATION); + await publishDirectory(staging, resolvedDestination, renameDirectory); } catch (error) { await rm(staging, { recursive: true, force: true }); throw error; @@ -149,7 +170,17 @@ async function main() { ); } -main().catch((error) => { - console.error(error.message); - process.exitCode = 1; -}); +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const bundleArgument = process.argv[2]; + if (!bundleArgument || process.argv.length !== 3) { + console.error( + 'USB Creator screenshot import failed: usage: import-usb-creator-flow.mjs ', + ); + process.exitCode = 1; + } else { + importBundle(bundleArgument).catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); + } +} diff --git a/scripts/import-usb-creator-flow.test.mjs b/scripts/import-usb-creator-flow.test.mjs index 6f1d9834c8b..e72744efb55 100644 --- a/scripts/import-usb-creator-flow.test.mjs +++ b/scripts/import-usb-creator-flow.test.mjs @@ -1,17 +1,14 @@ import { createHash } from 'node:crypto'; -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import test from 'node:test'; import assert from 'node:assert/strict'; +import { importBundle } from './import-usb-creator-flow.mjs'; const repository = path.resolve(import.meta.dirname, '..'); const importer = path.join(repository, 'scripts/import-usb-creator-flow.mjs'); -const destination = path.join( - repository, - 'static/img/unraid-os/getting-started/create-unraid-usb', -); function digest(bytes) { return createHash('sha256').update(bytes).digest('hex'); @@ -62,15 +59,13 @@ async function bundleFixture() { test('imports the verified image set and removes stale images', async () => { const fixture = await bundleFixture(); + const testRoot = await mkdtemp(path.join(tmpdir(), 'usb-creator-import-')); + const destination = path.join(testRoot, 'published'); await mkdir(destination, { recursive: true }); await writeFile(path.join(destination, 'stale.png'), 'stale'); try { - const result = spawnSync(process.execPath, [importer, fixture.root], { - cwd: repository, - encoding: 'utf8', - }); - assert.equal(result.status, 0, result.stderr); + await importBundle(fixture.root, { destination }); assert.equal( await readFile(path.join(destination, '01-select-release.png'), 'utf8'), 'accepted screenshot fixture', @@ -78,7 +73,7 @@ test('imports the verified image set and removes stale images', async () => { await assert.rejects(readFile(path.join(destination, 'stale.png'))); } finally { await rm(fixture.root, { recursive: true, force: true }); - await rm(destination, { recursive: true, force: true }); + await rm(testRoot, { recursive: true, force: true }); } }); @@ -99,6 +94,35 @@ test('rejects a screenshot whose bytes do not match the accepted digest', async assert.match(result.stderr, /does not match its accepted digest/); } finally { await rm(fixture.root, { recursive: true, force: true }); - await rm(destination, { recursive: true, force: true }); + } +}); + +test('restores the previous image set when publication fails', async () => { + const fixture = await bundleFixture(); + const testRoot = await mkdtemp(path.join(tmpdir(), 'usb-creator-rollback-')); + const destination = path.join(testRoot, 'published'); + await mkdir(destination, { recursive: true }); + await writeFile(path.join(destination, 'previous.png'), 'previous screenshot'); + let renameCount = 0; + + try { + await assert.rejects( + importBundle(fixture.root, { + destination, + renameDirectory: async (source, target) => { + renameCount += 1; + if (renameCount === 2) throw new Error('simulated publication failure'); + await rename(source, target); + }, + }), + /simulated publication failure/, + ); + assert.equal( + await readFile(path.join(destination, 'previous.png'), 'utf8'), + 'previous screenshot', + ); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + await rm(testRoot, { recursive: true, force: true }); } });