Files
tyx_AI_xhs/apps/api/src/local-data-root.ts
T
suyx 43d946bb5c
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
fix(POSTV1-02): 修复便携包首次启动链路
2026-08-05 12:00:23 +08:00

354 lines
12 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import {
accessSync,
constants,
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
const require = createRequire(import.meta.url);
const Database = require("better-sqlite3") as typeof import("better-sqlite3");
const assetIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const fixedDirectories = [
"db",
"content/references",
"content/generated",
"content/exports",
"managed-assets",
"derived-assets",
"staging",
"logs/api",
"logs/worker",
"logs/supervisor",
] as const;
export function ensureLocalDataRuntimeDirectories(dataRoot: string) {
for (const directory of fixedDirectories) {
mkdirSync(join(resolve(dataRoot), directory), { recursive: true });
}
}
export const DATA_TRANSFER_POLICY = {
allowed_downloads: ["original_generation", "jpg", "png"],
application_backup: false,
business_import: false,
editable_project_archive: false,
p0b_migration: false,
recovery: false,
} as const;
export interface LocalDataRootBoundaries {
downloadsRoot: string;
programRoot: string;
projectRoots: string[];
readOnlyAssetRoots: string[];
repositoryRoot: string;
}
export type LocalDataRootRejectionReason =
| "repository_root"
| "program_root"
| "project_root"
| "downloads_root"
| "read_only_asset_root"
| "symbolic_link";
export type LocalDataRootValidation =
| { ok: true; normalized_path: string }
| { ok: false; reason: LocalDataRootRejectionReason };
interface InstanceConfiguration {
data_root: string;
initialized: true;
instance_id: string;
schema_version: 1;
}
export function readConfiguredLocalDataRoot(configFile = defaultInstanceConfigPath()) {
const configuration = JSON.parse(readFileSync(configFile, "utf8")) as Record<string, unknown>;
const candidate = configuration.data_root ?? configuration.local_data_root;
if (typeof candidate !== "string" || !isAbsolute(candidate)) throw new Error("data_root_configuration_invalid");
return resolve(candidate);
}
export interface ValidatedReadOnlyAssetRoot {
absolute_root: string;
ok: true;
root_ref: string;
}
interface PublicAssetEntry {
assetId: string;
mimeType: string;
relativePath: string;
resourceVersion: string;
rootRef: string;
sha256: string;
}
export interface PublicAssetPayload {
assetId: string;
bytes: Buffer;
mimeType: string;
resourceVersion: string;
sha256: string;
}
export interface PublicAssetResolver {
read(resourceVersion: string, assetId: string): PublicAssetPayload | undefined;
}
export function defaultLocalDataRoot(environment: NodeJS.ProcessEnv = process.env) {
const localAppData = environment.LOCALAPPDATA;
if (!localAppData || !isAbsolute(localAppData)) throw new Error("local_app_data_unavailable");
return join(localAppData, "Dada", "P0A", "data");
}
export function defaultInstanceConfigPath(environment: NodeJS.ProcessEnv = process.env) {
const localAppData = environment.LOCALAPPDATA;
if (!localAppData || !isAbsolute(localAppData)) throw new Error("local_app_data_unavailable");
return join(localAppData, "Dada", "P0A", "config", "instance.json");
}
function comparisonPath(path: string) {
const normalized = resolve(path).replace(/[\\/]+$/, "");
return process.platform === "win32" ? normalized.toLocaleLowerCase("en-US") : normalized;
}
function isSameOrWithin(candidate: string, root: string) {
const candidatePath = comparisonPath(candidate);
const rootPath = comparisonPath(root);
const child = relative(rootPath, candidatePath);
return child === "" || (!child.startsWith(`..${sep}`) && child !== ".." && !isAbsolute(child));
}
function pathsOverlap(left: string, right: string) {
return isSameOrWithin(left, right) || isSameOrWithin(right, left);
}
function containsSymbolicLink(path: string) {
const absolute = resolve(path);
const parsed = parse(absolute);
let current = parsed.root;
const segments = absolute.slice(parsed.root.length).split(sep).filter(Boolean);
for (const segment of segments) {
current = join(current, segment);
if (!existsSync(current)) break;
if (lstatSync(current).isSymbolicLink()) return true;
}
return false;
}
function canonicalExistingPath(path: string) {
const absolute = resolve(path);
let existing = absolute;
const missing: string[] = [];
while (!existsSync(existing)) {
const parent = dirname(existing);
if (parent === existing) break;
missing.unshift(existing.slice(parent.length + 1));
existing = parent;
}
const canonical = existsSync(existing) ? realpathSync.native(existing) : existing;
return resolve(canonical, ...missing);
}
export function validateLocalDataRoot(
candidate: string,
boundaries: LocalDataRootBoundaries,
): LocalDataRootValidation {
const absolute = resolve(candidate);
if (containsSymbolicLink(absolute)) return { ok: false, reason: "symbolic_link" };
const canonical = canonicalExistingPath(absolute);
const restricted: ReadonlyArray<readonly [LocalDataRootRejectionReason, string]> = [
["repository_root", boundaries.repositoryRoot],
["program_root", boundaries.programRoot],
...boundaries.projectRoots.map((root) => ["project_root", root] as const),
["downloads_root", boundaries.downloadsRoot],
...boundaries.readOnlyAssetRoots.map((root) => ["read_only_asset_root", root] as const),
];
for (const [reason, root] of restricted) {
if (pathsOverlap(canonical, canonicalExistingPath(root))) return { ok: false, reason };
}
return { normalized_path: absolute, ok: true };
}
export function resolvePathWithinRoot(root: string, objectKey: string) {
if (!objectKey || isAbsolute(objectKey) || objectKey.split(/[\\/]/).includes("..")) {
throw new Error("path_escape");
}
if (containsSymbolicLink(root)) throw new Error("symbolic_link");
const target = resolve(root, objectKey);
if (!isSameOrWithin(target, root) || containsSymbolicLink(target)) throw new Error("path_escape");
const canonicalRoot = canonicalExistingPath(root);
const canonicalTarget = canonicalExistingPath(target);
if (!isSameOrWithin(canonicalTarget, canonicalRoot)) throw new Error("path_escape");
return target;
}
function openInstanceDatabase(databasePath: string) {
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
const database = new Database(databasePath, nativeBinding ? { nativeBinding } : undefined);
database.pragma("journal_mode = WAL");
database.exec(`
CREATE TABLE instance_metadata (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
schema_version INTEGER NOT NULL CHECK (schema_version = 1)
);
INSERT INTO instance_metadata (singleton, schema_version) VALUES (1, 1);
`);
database.close();
}
export function initializeLocalDataRoot(input: {
boundaries: LocalDataRootBoundaries;
configFile: string;
dataRoot: string;
}) {
const validation = validateLocalDataRoot(input.dataRoot, input.boundaries);
if (!validation.ok) throw new Error(validation.reason);
if (existsSync(input.configFile)) throw new Error("already_initialized");
if (existsSync(validation.normalized_path) && readdirSync(validation.normalized_path).length > 0) {
throw new Error("data_root_not_empty");
}
const createdRoot = !existsSync(validation.normalized_path);
try {
ensureLocalDataRuntimeDirectories(validation.normalized_path);
openInstanceDatabase(join(validation.normalized_path, "db", "dada.sqlite3"));
const configuration: InstanceConfiguration = {
data_root: validation.normalized_path,
initialized: true,
instance_id: randomUUID(),
schema_version: 1,
};
mkdirSync(dirname(input.configFile), { recursive: true });
const temporaryConfig = `${input.configFile}.${randomUUID()}.tmp`;
writeFileSync(temporaryConfig, `${JSON.stringify(configuration, null, 2)}\n`, { flag: "wx" });
renameSync(temporaryConfig, input.configFile);
return {
database: "db/dada.sqlite3",
directories: [...fixedDirectories],
status: "ready" as const,
};
} catch (error) {
if (createdRoot && existsSync(validation.normalized_path)) {
rmSync(validation.normalized_path, { force: true, recursive: true });
}
throw error;
}
}
export function inspectInitializedLocalDataRoot(input: {
boundaries: LocalDataRootBoundaries;
configFile: string;
}) {
const configuration = JSON.parse(readFileSync(input.configFile, "utf8")) as InstanceConfiguration;
const validation = validateLocalDataRoot(configuration.data_root, input.boundaries);
if (!validation.ok) throw new Error(validation.reason);
const rootExists = existsSync(validation.normalized_path) && statSync(validation.normalized_path).isDirectory();
const databasePath = join(validation.normalized_path, "db", "dada.sqlite3");
const databaseExists = rootExists && existsSync(databasePath) && statSync(databasePath).isFile();
if (!rootExists || !databaseExists) {
return {
database_exists: databaseExists,
root_exists: rootExists,
status: "data_missing" as const,
};
}
let writable = true;
try {
accessSync(validation.normalized_path, constants.R_OK | constants.W_OK);
} catch {
writable = false;
}
return {
database_exists: true,
root_exists: true,
status: "ready" as const,
writable,
};
}
export function validateReadOnlyAssetRoot(input: {
dataRoot: string;
expectedSha256: string;
manifestRelativePath: string;
root: string;
rootRef: string;
}): ValidatedReadOnlyAssetRoot | { ok: false; reason: string } {
const absoluteRoot = resolve(input.root);
if (!existsSync(absoluteRoot) || !statSync(absoluteRoot).isDirectory()) {
return { ok: false, reason: "asset_root_missing" };
}
if (containsSymbolicLink(absoluteRoot)) return { ok: false, reason: "symbolic_link" };
if (pathsOverlap(absoluteRoot, input.dataRoot)) return { ok: false, reason: "data_root_overlap" };
let manifestPath: string;
try {
manifestPath = resolvePathWithinRoot(absoluteRoot, input.manifestRelativePath);
} catch {
return { ok: false, reason: "manifest_path_invalid" };
}
if (!existsSync(manifestPath) || !statSync(manifestPath).isFile()) {
return { ok: false, reason: "manifest_missing" };
}
const actualSha256 = createHash("sha256").update(readFileSync(manifestPath)).digest("hex");
if (actualSha256.toLowerCase() !== input.expectedSha256.toLowerCase()) {
return { ok: false, reason: "manifest_hash_invalid" };
}
return { absolute_root: absoluteRoot, ok: true, root_ref: input.rootRef };
}
export function createPublicAssetResolver(input: {
entries: PublicAssetEntry[];
roots: ValidatedReadOnlyAssetRoot[];
}): PublicAssetResolver {
const roots = new Map(input.roots.map((root) => [root.root_ref, root.absolute_root]));
const entries = new Map<string, PublicAssetEntry>();
for (const entry of input.entries) {
if (!assetIdPattern.test(entry.assetId) || entries.has(entry.assetId)) throw new Error("asset_id_invalid");
if (!roots.has(entry.rootRef)) throw new Error("asset_root_unvalidated");
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(entry.resourceVersion)) throw new Error("resource_version_invalid");
if (!/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)) throw new Error("mime_type_invalid");
entries.set(entry.assetId, { ...entry });
}
return {
read(resourceVersion, assetId) {
if (!assetIdPattern.test(assetId)) return undefined;
const entry = entries.get(assetId);
if (!entry || entry.resourceVersion !== resourceVersion) return undefined;
const root = roots.get(entry.rootRef);
if (!root) return undefined;
let path: string;
try {
path = resolvePathWithinRoot(root, entry.relativePath);
} catch {
return undefined;
}
if (!existsSync(path) || !statSync(path).isFile()) return undefined;
const bytes = readFileSync(path);
const sha256 = createHash("sha256").update(bytes).digest("hex");
if (sha256.toLowerCase() !== entry.sha256.toLowerCase()) return undefined;
return {
assetId: entry.assetId,
bytes,
mimeType: entry.mimeType,
resourceVersion: entry.resourceVersion,
sha256,
};
},
};
}