feat(ql3): inventory legacy data directory

This commit is contained in:
whyour
2026-08-21 01:34:18 +08:00
parent c9e41812cb
commit 878a360b09
9 changed files with 1329 additions and 3 deletions
@@ -1,6 +1,8 @@
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { runLegacyCrontabAdoptionCommandFile } from './adoption';
import { isLocalDataDirectoryAdoptionOperation } from './data-directory-adoption/contract';
import type { LocalDataDirectoryAdoptionInspectResult } from './data-directory-adoption/inventory';
import {
isLocalSqliteAdoptionProductOperation,
type LocalSqliteAdoptionProductOperation,
@@ -9,7 +11,8 @@ import type { LocalSqliteAdoptionProductCommandResult } from './sqlite-adoption/
export type LocalAdoptionProductCommandResult =
| Awaited<ReturnType<typeof runLegacyCrontabAdoptionCommandFile>>
| LocalSqliteAdoptionProductCommandResult;
| LocalSqliteAdoptionProductCommandResult
| LocalDataDirectoryAdoptionInspectResult;
function operation(value: unknown): unknown {
return value && typeof value === 'object' && !Array.isArray(value)
@@ -28,6 +31,12 @@ export async function runLocalAdoptionProductCommandFile(
return runLegacyCrontabAdoptionCommandFile(commandFilePath);
}
const selected = operation(candidate);
if (isLocalDataDirectoryAdoptionOperation(selected)) {
const { inspectLocalDataDirectoryAdoption } = await import(
'./data-directory-adoption/inventory.js'
);
return inspectLocalDataDirectoryAdoption(candidate);
}
if (!isLocalSqliteAdoptionProductOperation(selected)) {
return runLegacyCrontabAdoptionCommandFile(commandFilePath);
}
@@ -0,0 +1,88 @@
import path from 'node:path';
const MAX_PATH_BYTES = 4_096;
export const LOCAL_DATA_DIRECTORY_ADOPTION_INSPECT_OPERATION =
'local-data-directory.adoption.inspect' as const;
export interface InspectLocalDataDirectoryAdoptionCommand {
readonly schemaVersion: 1;
readonly operation: typeof LOCAL_DATA_DIRECTORY_ADOPTION_INSPECT_OPERATION;
readonly options: {
readonly dataRoot: string;
readonly profile: 'edge' | 'standalone';
};
}
export class LocalDataDirectoryAdoptionConfigurationError extends TypeError {
readonly code = 'LOCAL_DATA_DIRECTORY_ADOPTION_CONFIGURATION_INVALID';
constructor(message: string, readonly cause?: unknown) {
super(`Local data directory adoption configuration is invalid: ${message}`);
this.name = 'LocalDataDirectoryAdoptionConfigurationError';
}
}
function exactKeys(value: object, expected: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
return (
actual.length === canonical.length &&
actual.every((key, index) => key === canonical[index])
);
}
function normalizedAbsolutePath(value: unknown): value is string {
return (
typeof value === 'string' &&
path.isAbsolute(value) &&
path.parse(value).root !== value &&
path.normalize(value) === value &&
!value.includes('\0') &&
Buffer.byteLength(value, 'utf8') <= MAX_PATH_BYTES
);
}
export function isLocalDataDirectoryAdoptionOperation(
value: unknown,
): value is typeof LOCAL_DATA_DIRECTORY_ADOPTION_INSPECT_OPERATION {
return value === LOCAL_DATA_DIRECTORY_ADOPTION_INSPECT_OPERATION;
}
export function normalizeInspectLocalDataDirectoryAdoptionCommand(
value: unknown,
): Readonly<InspectLocalDataDirectoryAdoptionCommand> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, ['schemaVersion', 'operation', 'options'])
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'command shape is invalid',
);
}
const candidate = value as Record<string, unknown>;
if (
candidate.schemaVersion !== 1 ||
!isLocalDataDirectoryAdoptionOperation(candidate.operation) ||
!candidate.options ||
typeof candidate.options !== 'object' ||
Array.isArray(candidate.options) ||
!exactKeys(candidate.options, ['dataRoot', 'profile'])
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'command value is invalid',
);
}
const options = candidate.options as Record<string, unknown>;
if (
!normalizedAbsolutePath(options.dataRoot) ||
(options.profile !== 'edge' && options.profile !== 'standalone')
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'command options are invalid',
);
}
return Object.freeze(value as InspectLocalDataDirectoryAdoptionCommand);
}
@@ -0,0 +1,696 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import {
LOCAL_DATA_DIRECTORY_ADOPTION_INSPECT_OPERATION,
LocalDataDirectoryAdoptionConfigurationError,
normalizeInspectLocalDataDirectoryAdoptionCommand,
type InspectLocalDataDirectoryAdoptionCommand,
} from './contract';
const HASH_BUFFER_BYTES = 64 * 1024;
const MAX_RELATIVE_PATH_BYTES = 4_096;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
type DataDirectoryDisposition =
| 'copy_reviewed'
| 'transform'
| 'retain_external'
| 'regenerate';
type DataDirectoryInspection = 'recursive_content' | 'root_only';
interface CategoryPolicy {
readonly name: string;
readonly disposition: DataDirectoryDisposition;
readonly inspection: DataDirectoryInspection;
}
interface InventoryBudget {
readonly maxEntries: number;
readonly maxHashedBytes: number;
readonly maxFileBytes: number;
readonly maxDepth: number;
}
interface MutableCategorySummary {
entries: number;
directories: number;
regularFiles: number;
logicalBytes: number;
allocatedBytes: number;
broadReadableEntries: number;
unsafeEntries: number;
activeSqliteSidecars: number;
primaryDatabaseFiles: number;
legacyKeyValueDatabaseFiles: number;
hashedBytes: number;
}
export interface LocalDataDirectoryCategoryEvidence {
readonly name: string;
readonly disposition: DataDirectoryDisposition;
readonly inspection: DataDirectoryInspection;
readonly present: boolean;
readonly entries: number;
readonly directories: number;
readonly regularFiles: number;
readonly logicalBytes: number | null;
readonly allocatedBytes: number | null;
readonly broadReadableEntries: number;
readonly unsafeEntries: number;
readonly activeSqliteSidecars: number;
readonly primaryDatabaseFiles: number;
readonly legacyKeyValueDatabaseFiles: number;
readonly contentDigest: string;
}
export interface LocalDataDirectoryAdoptionEvidence {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-legacy-data-directory-adoption-plan';
readonly profile: 'edge' | 'standalone';
readonly policyVersion: 1;
readonly dataRootPathDigest: string;
readonly budget: Readonly<InventoryBudget>;
readonly assessment: 'reviewable' | 'manual_review';
readonly categories: readonly LocalDataDirectoryCategoryEvidence[];
readonly unknownTopLevelEntries: number;
readonly unknownTopLevelDigest: string;
readonly totalInspectedEntries: number;
readonly totalHashedBytes: number;
readonly totalUnsafeEntries: number;
readonly planDigest: string;
}
export interface LocalDataDirectoryAdoptionInspectResult {
readonly schemaVersion: 1;
readonly operation: typeof LOCAL_DATA_DIRECTORY_ADOPTION_INSPECT_OPERATION;
readonly status: 'inspected';
readonly evidence: Readonly<LocalDataDirectoryAdoptionEvidence>;
}
const POLICIES: readonly CategoryPolicy[] = Object.freeze([
Object.freeze({
name: 'config',
disposition: 'transform',
inspection: 'recursive_content',
}),
Object.freeze({
name: 'scripts',
disposition: 'copy_reviewed',
inspection: 'recursive_content',
}),
Object.freeze({
name: 'db',
disposition: 'transform',
inspection: 'recursive_content',
}),
Object.freeze({
name: 'upload',
disposition: 'copy_reviewed',
inspection: 'recursive_content',
}),
Object.freeze({
name: 'ssh.d',
disposition: 'transform',
inspection: 'recursive_content',
}),
Object.freeze({
name: 'log',
disposition: 'retain_external',
inspection: 'root_only',
}),
Object.freeze({
name: 'syslog',
disposition: 'retain_external',
inspection: 'root_only',
}),
Object.freeze({
name: 'bak',
disposition: 'retain_external',
inspection: 'root_only',
}),
Object.freeze({
name: 'repo',
disposition: 'regenerate',
inspection: 'root_only',
}),
Object.freeze({
name: 'raw',
disposition: 'regenerate',
inspection: 'root_only',
}),
Object.freeze({
name: 'dep_cache',
disposition: 'regenerate',
inspection: 'root_only',
}),
Object.freeze({
name: 'deps',
disposition: 'regenerate',
inspection: 'root_only',
}),
]);
const EMPTY_DIGEST = crypto.createHash('sha256').digest('hex');
function digestText(value: string): string {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
}
function budget(profile: 'edge' | 'standalone'): Readonly<InventoryBudget> {
return Object.freeze(
profile === 'edge'
? {
maxEntries: 8_192,
maxHashedBytes: 512 * 1024 * 1024,
maxFileBytes: 64 * 1024 * 1024,
maxDepth: 32,
}
: {
maxEntries: 65_536,
maxHashedBytes: 4 * 1024 * 1024 * 1024,
maxFileBytes: 512 * 1024 * 1024,
maxDepth: 64,
},
);
}
function currentUid(): number {
if (typeof process.getuid !== 'function') {
throw new LocalDataDirectoryAdoptionConfigurationError(
'POSIX ownership is unavailable',
);
}
return process.getuid();
}
function sameStat(left: fs.BigIntStats, right: fs.BigIntStats): boolean {
return (
left.dev === right.dev &&
left.ino === right.ino &&
left.mode === right.mode &&
left.nlink === right.nlink &&
left.uid === right.uid &&
left.size === right.size &&
left.mtimeNs === right.mtimeNs &&
left.ctimeNs === right.ctimeNs
);
}
function stableRoot(dataRoot: string, uid: number): fs.BigIntStats {
let stat: fs.BigIntStats;
try {
stat = fs.lstatSync(dataRoot, { bigint: true });
} catch (error) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'dataRoot is unavailable',
error,
);
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.uid !== BigInt(uid) ||
(stat.mode & 0o022n) !== 0n ||
fs.realpathSync(dataRoot) !== dataRoot
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'dataRoot must be an owner-controlled canonical directory',
);
}
return stat;
}
function sortedDirectoryNames(
directoryPath: string,
maxNames: number,
): readonly string[] {
const directory = fs.opendirSync(directoryPath);
const names: string[] = [];
try {
for (;;) {
const entry = directory.readSync();
if (entry === null) break;
names.push(entry.name);
if (names.length > maxNames) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'data directory entry count exceeds the Profile budget',
);
}
}
} finally {
directory.closeSync();
}
return names.sort((left, right) =>
Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')),
);
}
function relativePath(base: string, candidate: string): string {
const relative = path.relative(base, candidate);
if (
relative.length < 1 ||
path.isAbsolute(relative) ||
relative === '..' ||
relative.startsWith(`..${path.sep}`) ||
Buffer.byteLength(relative, 'utf8') > MAX_RELATIVE_PATH_BYTES
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'data directory entry path is invalid or too long',
);
}
return relative.split(path.sep).join('/');
}
function updateMetadata(
hash: crypto.Hash,
relative: string,
stat: fs.BigIntStats,
kind: string,
contentDigest?: string,
): void {
hash.update(
`${JSON.stringify({
relative,
kind,
mode: (stat.mode & 0o777n).toString(8),
uid: stat.uid.toString(),
links: stat.nlink.toString(),
bytes: stat.size.toString(),
modifiedAtNs: stat.mtimeNs.toString(),
changedAtNs: stat.ctimeNs.toString(),
...(contentDigest === undefined ? {} : { contentDigest }),
})}\n`,
'utf8',
);
}
function stableFileDigest(filePath: string, expected: fs.BigIntStats): string {
const descriptor = fs.openSync(
filePath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
);
const buffer = Buffer.allocUnsafe(HASH_BUFFER_BYTES);
try {
const before = fs.fstatSync(descriptor, { bigint: true });
if (!sameStat(expected, before) || !before.isFile()) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'data directory file identity changed before inspection',
);
}
const hash = crypto.createHash('sha256');
for (;;) {
const count = fs.readSync(descriptor, buffer, 0, buffer.length, null);
if (count === 0) break;
hash.update(buffer.subarray(0, count));
}
const after = fs.fstatSync(descriptor, { bigint: true });
if (!sameStat(before, after)) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'data directory file changed during inspection',
);
}
return hash.digest('hex');
} finally {
buffer.fill(0);
fs.closeSync(descriptor);
}
}
function mutableSummary(): MutableCategorySummary {
return {
entries: 0,
directories: 0,
regularFiles: 0,
logicalBytes: 0,
allocatedBytes: 0,
broadReadableEntries: 0,
unsafeEntries: 0,
activeSqliteSidecars: 0,
primaryDatabaseFiles: 0,
legacyKeyValueDatabaseFiles: 0,
hashedBytes: 0,
};
}
function safeNumber(value: bigint, label: string): number {
if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new LocalDataDirectoryAdoptionConfigurationError(
`${label} exceeds the supported numeric range`,
);
}
return Number(value);
}
function addEntryBytes(
summary: MutableCategorySummary,
stat: fs.BigIntStats,
): void {
const logical = safeNumber(stat.size, 'entry bytes');
const allocated = safeNumber(stat.blocks * 512n, 'allocated entry bytes');
if (
!Number.isSafeInteger(summary.logicalBytes + logical) ||
!Number.isSafeInteger(summary.allocatedBytes + allocated)
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'category byte total exceeds the supported numeric range',
);
}
summary.logicalBytes += logical;
summary.allocatedBytes += allocated;
}
function inspectRecursiveCategory(
dataRoot: string,
categoryRoot: string,
rootStat: fs.BigIntStats,
uid: number,
limits: Readonly<InventoryBudget>,
shared: { entries: number; hashedBytes: number },
): Readonly<{
summary: MutableCategorySummary;
contentDigest: string;
}> {
const summary = mutableSummary();
const hash = crypto.createHash('sha256');
const visitDirectory = (
directoryPath: string,
expected: fs.BigIntStats,
depth: number,
): void => {
if (depth > limits.maxDepth) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'data directory depth exceeds the Profile budget',
);
}
const names = sortedDirectoryNames(
directoryPath,
limits.maxEntries - shared.entries,
);
for (const name of names) {
const entryPath = path.join(directoryPath, name);
const relative = relativePath(dataRoot, entryPath);
const stat = fs.lstatSync(entryPath, { bigint: true });
shared.entries += 1;
summary.entries += 1;
if (shared.entries > limits.maxEntries) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'data directory entry count exceeds the Profile budget',
);
}
addEntryBytes(summary, stat);
const mode = stat.mode & 0o777n;
if ((mode & 0o044n) !== 0n) summary.broadReadableEntries += 1;
const unsafeIdentity =
stat.uid !== BigInt(uid) ||
(mode & 0o022n) !== 0n ||
stat.isSymbolicLink() ||
(!stat.isDirectory() && !stat.isFile()) ||
(stat.isFile() && stat.nlink !== 1n);
if (unsafeIdentity) {
summary.unsafeEntries += 1;
updateMetadata(hash, relative, stat, 'unsafe');
continue;
}
if (stat.isDirectory()) {
summary.directories += 1;
updateMetadata(hash, relative, stat, 'directory');
visitDirectory(entryPath, stat, depth + 1);
const after = fs.lstatSync(entryPath, { bigint: true });
if (!sameStat(stat, after)) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'data directory changed during inspection',
);
}
continue;
}
const size = safeNumber(stat.size, 'file bytes');
if (size > limits.maxFileBytes) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'data directory file exceeds the Profile budget',
);
}
if (shared.hashedBytes + size > limits.maxHashedBytes) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'data directory hashed bytes exceed the Profile budget',
);
}
shared.hashedBytes += size;
summary.hashedBytes += size;
summary.regularFiles += 1;
const baseName = path.basename(entryPath);
if (relative === 'db/database.sqlite') summary.primaryDatabaseFiles += 1;
if (relative === 'db/keyv.sqlite')
summary.legacyKeyValueDatabaseFiles += 1;
if (/^(?:database|keyv)\.sqlite-(?:wal|shm|journal)$/.test(baseName)) {
summary.activeSqliteSidecars += 1;
}
updateMetadata(
hash,
relative,
stat,
'file',
stableFileDigest(entryPath, stat),
);
}
const after = fs.lstatSync(directoryPath, { bigint: true });
if (!sameStat(expected, after)) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'data directory changed during inspection',
);
}
};
visitDirectory(categoryRoot, rootStat, 1);
return Object.freeze({ summary, contentDigest: hash.digest('hex') });
}
function inspectCategory(
policy: Readonly<CategoryPolicy>,
dataRoot: string,
uid: number,
limits: Readonly<InventoryBudget>,
shared: { entries: number; hashedBytes: number },
): Readonly<LocalDataDirectoryCategoryEvidence> {
const categoryRoot = path.join(dataRoot, policy.name);
let stat: fs.BigIntStats;
try {
stat = fs.lstatSync(categoryRoot, { bigint: true });
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
return Object.freeze({
...policy,
present: false,
entries: 0,
directories: 0,
regularFiles: 0,
logicalBytes: policy.inspection === 'root_only' ? null : 0,
allocatedBytes: policy.inspection === 'root_only' ? null : 0,
broadReadableEntries: 0,
unsafeEntries: 0,
activeSqliteSidecars: 0,
primaryDatabaseFiles: 0,
legacyKeyValueDatabaseFiles: 0,
contentDigest: EMPTY_DIGEST,
});
}
throw new LocalDataDirectoryAdoptionConfigurationError(
'data category is unavailable',
error,
);
}
const mode = stat.mode & 0o777n;
const safeDirectory =
stat.isDirectory() &&
!stat.isSymbolicLink() &&
stat.uid === BigInt(uid) &&
(mode & 0o022n) === 0n;
if (!safeDirectory || policy.inspection === 'root_only') {
const evidence = Object.freeze({
...policy,
present: true,
entries: 0,
directories: 0,
regularFiles: 0,
logicalBytes: null,
allocatedBytes: null,
broadReadableEntries: (mode & 0o044n) !== 0n ? 1 : 0,
unsafeEntries: safeDirectory ? 0 : 1,
activeSqliteSidecars: 0,
primaryDatabaseFiles: 0,
legacyKeyValueDatabaseFiles: 0,
contentDigest: digestText(
JSON.stringify({
category: policy.name,
mode: mode.toString(8),
uid: stat.uid.toString(),
kind: safeDirectory ? 'directory' : 'unsafe',
}),
),
});
const after = fs.lstatSync(categoryRoot, { bigint: true });
if (!sameStat(stat, after)) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'data category changed during inspection',
);
}
return evidence;
}
const inspected = inspectRecursiveCategory(
dataRoot,
categoryRoot,
stat,
uid,
limits,
shared,
);
return Object.freeze({
...policy,
present: true,
entries: inspected.summary.entries,
directories: inspected.summary.directories,
regularFiles: inspected.summary.regularFiles,
logicalBytes: inspected.summary.logicalBytes,
allocatedBytes: inspected.summary.allocatedBytes,
broadReadableEntries: inspected.summary.broadReadableEntries,
unsafeEntries: inspected.summary.unsafeEntries,
activeSqliteSidecars: inspected.summary.activeSqliteSidecars,
primaryDatabaseFiles: inspected.summary.primaryDatabaseFiles,
legacyKeyValueDatabaseFiles: inspected.summary.legacyKeyValueDatabaseFiles,
contentDigest: inspected.contentDigest,
});
}
function unknownTopLevelEvidence(
dataRoot: string,
known: ReadonlySet<string>,
limits: Readonly<InventoryBudget>,
shared: { entries: number; hashedBytes: number },
): Readonly<{ count: number; digest: string }> {
const hash = crypto.createHash('sha256');
let count = 0;
const names = sortedDirectoryNames(
dataRoot,
limits.maxEntries - shared.entries + known.size,
);
for (const name of names) {
if (known.has(name)) continue;
count += 1;
shared.entries += 1;
if (shared.entries > limits.maxEntries) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'data directory entry count exceeds the Profile budget',
);
}
const stat = fs.lstatSync(path.join(dataRoot, name), { bigint: true });
hash.update(
`${JSON.stringify({
nameDigest: digestText(name),
mode: (stat.mode & 0o777n).toString(8),
uid: stat.uid.toString(),
kind: stat.isDirectory()
? 'directory'
: stat.isFile()
? 'file'
: stat.isSymbolicLink()
? 'symlink'
: 'special',
})}\n`,
'utf8',
);
const after = fs.lstatSync(path.join(dataRoot, name), { bigint: true });
if (!sameStat(stat, after)) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'unknown data directory entry changed during inspection',
);
}
}
return Object.freeze({ count, digest: hash.digest('hex') });
}
function planDigest(
evidence: Omit<LocalDataDirectoryAdoptionEvidence, 'planDigest'>,
): string {
return digestText(JSON.stringify(evidence));
}
export function inspectLocalDataDirectoryAdoption(
value: unknown,
): Readonly<LocalDataDirectoryAdoptionInspectResult> {
try {
const command: Readonly<InspectLocalDataDirectoryAdoptionCommand> =
normalizeInspectLocalDataDirectoryAdoptionCommand(value);
const uid = currentUid();
const rootBefore = stableRoot(command.options.dataRoot, uid);
const limits = budget(command.options.profile);
const shared = { entries: 0, hashedBytes: 0 };
const categories = POLICIES.map((policy) =>
inspectCategory(policy, command.options.dataRoot, uid, limits, shared),
);
const unknown = unknownTopLevelEvidence(
command.options.dataRoot,
new Set(POLICIES.map((policy) => policy.name)),
limits,
shared,
);
const rootAfter = stableRoot(command.options.dataRoot, uid);
if (!sameStat(rootBefore, rootAfter)) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'dataRoot changed during inspection',
);
}
const totalUnsafeEntries = categories.reduce(
(total, category) => total + category.unsafeEntries,
0,
);
const payload = Object.freeze({
schemaVersion: 1 as const,
kind: 'qinglong3-legacy-data-directory-adoption-plan' as const,
profile: command.options.profile,
policyVersion: 1 as const,
dataRootPathDigest: digestText(command.options.dataRoot),
budget: limits,
assessment:
totalUnsafeEntries === 0 && unknown.count === 0
? ('reviewable' as const)
: ('manual_review' as const),
categories: Object.freeze(categories),
unknownTopLevelEntries: unknown.count,
unknownTopLevelDigest: unknown.digest,
totalInspectedEntries: shared.entries,
totalHashedBytes: shared.hashedBytes,
totalUnsafeEntries,
});
const evidence = Object.freeze({
...payload,
planDigest: planDigest(payload),
});
if (!DIGEST_PATTERN.test(evidence.planDigest)) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'data directory plan digest is invalid',
);
}
return Object.freeze({
schemaVersion: 1,
operation: LOCAL_DATA_DIRECTORY_ADOPTION_INSPECT_OPERATION,
status: 'inspected',
evidence,
});
} catch (error) {
if (error instanceof LocalDataDirectoryAdoptionConfigurationError) {
throw error;
}
throw new LocalDataDirectoryAdoptionConfigurationError(
'data directory inspection failed',
error,
);
}
}
@@ -0,0 +1,274 @@
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);
});