diff --git a/docs/PROJECT_LOG.md b/docs/PROJECT_LOG.md index 2fa8a6e..a88ce2e 100644 --- a/docs/PROJECT_LOG.md +++ b/docs/PROJECT_LOG.md @@ -69,6 +69,11 @@ Payments/monetization P0s are separately listed in `docs/MVP_STATUS_AND_DEV_PLAN ## Log +### 2026-09-30 — Opt-in exact-source asset import (#198) + +- `process.mjs --only=` may be repeated for newly reserved staged sources; it retains and validates all registered runtime entries and the global budget but never consumes unselected legacy inputs. Absolute, traversal, runtime/package, duplicate, symlink, and nonexistent selections fail before mutation. The no-flag global behavior remains unchanged. +- Needed for #197 car-loadout art: global dry run currently fails on an unrelated tracked app icon. No existing icons or package textures were modified or moved by this tooling change. + ### 2026-09-30 — Welcome hint no longer steals the phone Launch Attack tap (#193) - At 375×812 the fixed welcome hint sits on top of the enabled OPS PLAN Launch Attack control. The hint stays visible so it can still teach the next step, and `pointer-events: none` lets the tap pass through to the button. diff --git a/frontend/scripts/assets/process.mjs b/frontend/scripts/assets/process.mjs index e6878b6..9f71a3e 100644 --- a/frontend/scripts/assets/process.mjs +++ b/frontend/scripts/assets/process.mjs @@ -20,6 +20,8 @@ // node scripts/assets/process.mjs # dry run // node scripts/assets/process.mjs --write // node scripts/assets/process.mjs --write --quality-pass=high +// node scripts/assets/process.mjs --only=generated/environments/street/new_plate.png +// node scripts/assets/process.mjs --write --only=generated/environments/street/new_plate.png // ============================================================ import { promises as fs } from 'node:fs'; @@ -30,6 +32,7 @@ import sharp from 'sharp'; const ARGV = process.argv.slice(2); const WRITE = ARGV.includes('--write'); const HIGH = ARGV.includes('--quality-pass=high'); +const ONLY = ARGV.filter(arg => arg.startsWith('--only=')).map(arg => arg.slice('--only='.length)); const FRONTEND = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../..'); const REPO = path.resolve(FRONTEND, '..'); @@ -240,7 +243,30 @@ async function cleanup(buf, needsAlpha) { // ─── Main ──────────────────────────────────────────────────── async function main() { - const files = await walk(LEGACY_ASSETS); + if (ARGV.some(arg => arg === '--only' || (arg.startsWith('--only') && !arg.startsWith('--only=')))) { + throw new Error('Invalid --only source: expected --only='); + } + const seen = new Set(); + const selected = []; + for (const rel of ONLY) { + if (!rel || rel.includes('\\') || path.posix.isAbsolute(rel) || path.isAbsolute(rel) || + path.posix.normalize(rel) !== rel || rel.startsWith('.') || + rel.startsWith('runtime/') || rel.startsWith('packages/') || + !/\.(png|webp|jpe?g)$/i.test(rel) || seen.has(rel)) { + throw new Error(`Invalid --only source: ${rel}`); + } + const abs = path.resolve(LEGACY_ASSETS, rel); + if (!abs.startsWith(LEGACY_ASSETS + path.sep)) throw new Error(`Invalid --only source: ${rel}`); + try { + const stat = await fs.lstat(abs); + if (!stat.isFile() || await fs.realpath(abs) !== abs) throw new Error('not a regular source'); + } catch { + throw new Error(`Invalid --only source: ${rel} is missing or not a regular file`); + } + seen.add(rel); + selected.push(abs); + } + const files = ONLY.length ? selected : await walk(LEGACY_ASSETS); const mb = (b) => (b / 1048576).toFixed(2) + ' MB'; let previous = null; try { diff --git a/frontend/scripts/assets/process.scoped.test.mjs b/frontend/scripts/assets/process.scoped.test.mjs new file mode 100644 index 0000000..6c2ede0 --- /dev/null +++ b/frontend/scripts/assets/process.scoped.test.mjs @@ -0,0 +1,43 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; + +const frontend = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const source = path.join(frontend, 'public/assets/generated/environments/street/block_scoped_pipeline_test_v001.png'); +const manifest = path.join(frontend, 'src/assets/runtimeManifest.json'); +const runtime = path.join(frontend, 'public/assets/runtime/generated/environments/street/block_scoped_pipeline_test_v001.webp'); +const rel = 'generated/environments/street/block_scoped_pipeline_test_v001.png'; +function run(...args) { + return spawnSync(process.execPath, ['scripts/assets/process.mjs', ...args], { + cwd: frontend, encoding: 'utf8', timeout: 120000, + }); +} + +test('exact-source dry run ignores unrelated legacy art and retains the registered manifest without writes', async () => { + await fs.mkdir(path.dirname(source), { recursive: true }); + const before = await fs.readFile(manifest); + try { + await sharp({ create: { width: 48, height: 32, channels: 3, background: '#23374a' } }).png().toFile(source); + const result = run(`--only=${rel}`); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /1 source images/); + assert.match(result.stdout, /Retaining \d+ registered runtime images/); + assert.match(result.stdout, /block_scoped_pipeline_test_v001/); + assert.doesNotMatch(result.stdout, /icons\/apps\/market\/hover\.png ->/); + assert.deepEqual(await fs.readFile(manifest), before); + await assert.rejects(fs.stat(runtime), { code: 'ENOENT' }); + assert.ok((await fs.stat(source)).isFile()); + } finally { await fs.rm(source, { force: true }); } +}); + +test('refuses traversal and runtime paths rather than expanding scope', () => { + for (const invalid of ['../outside.png', '/tmp/outside.png', 'runtime/private.png', 'generated/../../icons/apps/market/hover.png']) { + const result = run(`--only=${invalid}`); + assert.notEqual(result.status, 0, `unsafe source ${invalid} must fail`); + assert.match(result.stderr, /Invalid --only source/); + } +});