const assert = require('node:assert/strict'); const { spawnSync } = require('node:child_process'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { test } = require('node:test'); const BINARY = path.join(__dirname, '../dist/lifecycle/adoptionCli.js'); const OPERATION = 'local-data-directory.adoption.inspect'; const RECURSIVE_CATEGORIES = Object.freeze([ ['config', 'transform'], ['scripts', 'copy_reviewed'], ['db', 'transform'], ['upload', 'copy_reviewed'], ['ssh.d', 'transform'], ]); const ROOT_ONLY_CATEGORIES = Object.freeze([ ['log', 'retain_external'], ['syslog', 'retain_external'], ['bak', 'retain_external'], ['repo', 'regenerate'], ['raw', 'regenerate'], ['dep_cache', 'regenerate'], ['deps', 'regenerate'], ]); function privateDirectory(directoryPath) { fs.mkdirSync(directoryPath, { recursive: true, mode: 0o700 }); fs.chmodSync(directoryPath, 0o700); } function privateFile(filePath, content) { privateDirectory(path.dirname(filePath)); fs.writeFileSync(filePath, content, { mode: 0o600 }); fs.chmodSync(filePath, 0o600); } function fixture(t) { const root = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-data-directory-adoption-')), ); fs.chmodSync(root, 0o700); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const commandsDirectory = path.join(root, 'commands'); const dataRoot = path.join(root, 'data'); privateDirectory(commandsDirectory); privateDirectory(dataRoot); privateFile(path.join(dataRoot, 'config', 'config.sh'), 'export A=1\n'); privateFile( path.join(dataRoot, 'scripts', 'jobs', 'example.sh'), 'echo qinglong\n', ); privateFile(path.join(dataRoot, 'db', 'database.sqlite'), 'legacy-primary'); privateFile(path.join(dataRoot, 'db', 'keyv.sqlite'), 'legacy-keyv'); privateFile(path.join(dataRoot, 'upload', 'avatar.bin'), Buffer.from([1, 2])); privateFile(path.join(dataRoot, 'ssh.d', 'repository-key'), 'private-key'); for (const [category] of ROOT_ONLY_CATEGORIES) { privateFile( path.join(dataRoot, category, 'nested', 'ignored-content'), `ignored-${category}`, ); } return { root, commandsDirectory, dataRoot }; } function runRaw(value, name, command) { const commandPath = path.join(value.commandsDirectory, `${name}.json`); fs.writeFileSync(commandPath, `${JSON.stringify(command)}\n`, { mode: 0o600, }); fs.chmodSync(commandPath, 0o600); return spawnSync( process.execPath, [BINARY, 'run', '--command-file', commandPath], { encoding: 'utf8' }, ); } function inspect(value, name = 'inspect', profile = 'edge') { const child = runRaw(value, name, { schemaVersion: 1, operation: OPERATION, options: { dataRoot: value.dataRoot, profile }, }); assert.equal(child.status, 0, child.stderr); assert.equal(child.stderr, ''); return { child, result: JSON.parse(child.stdout) }; } function categoryMap(result) { return new Map( result.evidence.categories.map((category) => [category.name, category]), ); } test('data directory adoption emits a deterministic content-free migration plan', (t) => { const value = fixture(t); const first = inspect(value, 'first'); const second = inspect(value, 'second'); assert.equal(first.result.schemaVersion, 1); assert.equal(first.result.operation, OPERATION); assert.equal(first.result.status, 'inspected'); assert.equal( first.result.evidence.kind, 'qinglong3-legacy-data-directory-adoption-plan', ); assert.equal(first.result.evidence.profile, 'edge'); assert.equal(first.result.evidence.policyVersion, 1); assert.equal(first.result.evidence.assessment, 'reviewable'); assert.equal(first.result.evidence.unknownTopLevelEntries, 0); assert.equal(first.result.evidence.totalInspectedEntries, 7); assert.equal(first.result.evidence.totalUnsafeEntries, 0); assert.match(first.result.evidence.planDigest, /^[0-9a-f]{64}$/); assert.deepEqual(second.result, first.result); const categories = categoryMap(first.result); assert.equal(categories.size, 12); for (const [name, disposition] of RECURSIVE_CATEGORIES) { assert.deepEqual( { disposition: categories.get(name).disposition, inspection: categories.get(name).inspection, present: categories.get(name).present, }, { disposition, inspection: 'recursive_content', present: true }, ); } for (const [name, disposition] of ROOT_ONLY_CATEGORIES) { assert.deepEqual( { disposition: categories.get(name).disposition, inspection: categories.get(name).inspection, present: categories.get(name).present, entries: categories.get(name).entries, logicalBytes: categories.get(name).logicalBytes, }, { disposition, inspection: 'root_only', present: true, entries: 0, logicalBytes: null, }, ); } assert.equal(categories.get('db').primaryDatabaseFiles, 1); assert.equal(categories.get('db').legacyKeyValueDatabaseFiles, 1); assert.equal(first.child.stdout.includes(value.dataRoot), false); assert.equal(first.child.stdout.includes('repository-key'), false); assert.equal(first.child.stdout.includes('private-key'), false); assert.equal(first.child.stdout.includes('example.sh'), false); }); test('root-only cache and history contents do not enter the adoption plan', (t) => { const value = fixture(t); const before = inspect(value, 'before').result; privateFile( path.join(value.dataRoot, 'repo', 'nested', 'ignored-content'), 'different-cross-architecture-cache', ); const afterRootOnlyChange = inspect(value, 'after-root-only').result; assert.deepEqual(afterRootOnlyChange, before); privateFile( path.join(value.dataRoot, 'scripts', 'jobs', 'example.sh'), 'echo changed\n', ); const afterRelevantChange = inspect(value, 'after-relevant').result; assert.notEqual( afterRelevantChange.evidence.planDigest, before.evidence.planDigest, ); }); test('links and unknown top-level entries fail closed without leaking names', (t) => { const value = fixture(t); const externalSecret = path.join(value.root, 'external-sensitive-value'); privateFile(externalSecret, 'must-not-be-read'); fs.linkSync( externalSecret, path.join(value.dataRoot, 'scripts', 'jobs', 'hard-linked-secret'), ); fs.symlinkSync( externalSecret, path.join(value.dataRoot, 'scripts', 'jobs', 'linked-secret'), ); const unknownName = 'customer-private-extension'; privateFile( path.join(value.dataRoot, unknownName), 'unknown-sensitive-value', ); const inspected = inspect(value, 'unsafe'); assert.equal(inspected.result.evidence.assessment, 'manual_review'); assert.equal(inspected.result.evidence.totalUnsafeEntries, 2); assert.equal(inspected.result.evidence.unknownTopLevelEntries, 1); assert.equal(categoryMap(inspected.result).get('scripts').unsafeEntries, 2); for (const sensitive of [ value.dataRoot, externalSecret, 'hard-linked-secret', 'linked-secret', unknownName, 'must-not-be-read', 'unknown-sensitive-value', ]) { assert.equal(inspected.child.stdout.includes(sensitive), false); } }); test('widened commands and unsafe roots are rejected with a stable public error', (t) => { const value = fixture(t); const widened = runRaw(value, 'widened', { schemaVersion: 1, operation: OPERATION, options: { dataRoot: value.dataRoot, profile: 'edge', extraAuthority: true, }, }); assert.equal(widened.status, 1); assert.equal(widened.stdout, ''); assert.equal( JSON.parse(widened.stderr).code, 'LOCAL_DATA_DIRECTORY_ADOPTION_CONFIGURATION_INVALID', ); fs.chmodSync(value.dataRoot, 0o777); const unsafe = runRaw(value, 'unsafe-root', { schemaVersion: 1, operation: OPERATION, options: { dataRoot: value.dataRoot, profile: 'edge' }, }); assert.equal(unsafe.status, 1); assert.equal(unsafe.stdout, ''); const error = JSON.parse(unsafe.stderr); assert.equal( error.code, 'LOCAL_DATA_DIRECTORY_ADOPTION_CONFIGURATION_INVALID', ); assert.equal(unsafe.stderr.includes(value.dataRoot), false); }); test('edge inspection enforces a per-file budget before reading content', (t) => { const value = fixture(t); const oversized = path.join( value.dataRoot, 'scripts', 'oversized-private-file', ); privateFile(oversized, ''); fs.truncateSync(oversized, 64 * 1024 * 1024 + 1); const child = runRaw(value, 'oversized', { schemaVersion: 1, operation: OPERATION, options: { dataRoot: value.dataRoot, profile: 'edge' }, }); assert.equal(child.status, 1); assert.equal(child.stdout, ''); const error = JSON.parse(child.stderr); assert.equal( error.code, 'LOCAL_DATA_DIRECTORY_ADOPTION_CONFIGURATION_INVALID', ); assert.match(error.message, /Profile budget/); assert.equal(child.stderr.includes(oversized), false); });