diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index f60581c..21b68f0 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -34,6 +34,7 @@ import { type BrowserUnsupportedReason, } from "./browser-support.js"; import { EventHub } from "./event-hub.js"; +import type { PublicAssetResolver } from "./local-data-root.js"; import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js"; const defaultBootstrap: BootstrapResponse = { @@ -55,6 +56,7 @@ export interface CreateAppOptions { browserSupportSecret?: Buffer; eventHub?: EventHub; networkBoundary?: NetworkBoundaryOptions; + publicAssets?: PublicAssetResolver; } const supportGateDirectory = resolve("apps/web/support-gate"); @@ -211,6 +213,21 @@ export async function createApp(options: CreateAppOptions = {}) { status: "ready", })); + app.get( + "/api/v1/assets/public/:resourceVersion/:assetId", + { schema: { hide: true } }, + async (request, reply) => { + const { assetId, resourceVersion } = request.params as { assetId?: string; resourceVersion?: string }; + const resource = assetId && resourceVersion + ? options.publicAssets?.read(resourceVersion, assetId) + : undefined; + if (!resource) return reply.code(404).send(); + reply.type(resource.mimeType); + reply.header("Content-Disposition", "inline"); + return resource.bytes; + }, + ); + app.post( "/api/v1/support/check", { diff --git a/apps/api/src/local-data-root.ts b/apps/api/src/local-data-root.ts new file mode 100644 index 0000000..0886f72 --- /dev/null +++ b/apps/api/src/local-data-root.ts @@ -0,0 +1,339 @@ +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 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 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; +} + +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 = [ + ["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 database = new Database(databasePath); + 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 { + for (const directory of fixedDirectories) { + mkdirSync(join(validation.normalized_path, directory), { recursive: true }); + } + 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(); + 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, + }; + }, + }; +} diff --git a/apps/web/src/local-data-boundary.css b/apps/web/src/local-data-boundary.css new file mode 100644 index 0000000..36c766e --- /dev/null +++ b/apps/web/src/local-data-boundary.css @@ -0,0 +1,132 @@ +:root { + color: #111111; + background: #ffffff; + font-family: "Segoe UI", Arial, sans-serif; + font-synthesis: none; + letter-spacing: 0; +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: #ffffff; +} + +.local-data-page { + min-height: 100vh; + display: grid; + grid-template-rows: 8px 1fr 44px; +} + +.local-data-rule { + background: #eaff00; +} + +.local-data-main { + width: min(920px, 100%); + margin: 0 auto; + padding: 64px 28px 48px; +} + +.local-data-kicker { + margin: 0 0 14px; + font-family: Consolas, monospace; + font-size: 11px; + font-weight: 800; +} + +.local-data-title { + margin: 0; + font-size: 34px; + line-height: 1.2; +} + +.local-data-intro { + max-width: 680px; + margin: 14px 0 0; + color: #555555; + font-size: 15px; + line-height: 1.7; +} + +.local-data-notice { + margin-top: 30px; + border-block: 2px solid #111111; + padding: 22px 0; +} + +.local-data-notice strong { + display: block; + max-width: 760px; + font-size: 24px; + line-height: 1.45; +} + +.local-data-facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 36px; + margin: 30px 0 0; +} + +.local-data-fact { + min-width: 0; + border-top: 1px solid #c7c7c7; + padding: 16px 0 18px; +} + +.local-data-fact dt { + color: #666666; + font-size: 12px; +} + +.local-data-fact dd { + margin: 7px 0 0; + overflow-wrap: anywhere; + font-size: 14px; + font-weight: 700; + line-height: 1.55; +} + +.local-data-download { + margin: 26px 0 0; + padding: 14px 16px; + border-left: 6px solid #eaff00; + background: #f2f2f2; + font-size: 14px; + font-weight: 700; + line-height: 1.6; +} + +.local-data-footer { + display: grid; + place-items: center; + color: #eaff00; + background: #111111; + font-family: Consolas, monospace; + font-size: 10px; + font-weight: 700; +} + +@media (max-width: 640px) { + .local-data-main { + padding: 36px 18px 40px; + } + + .local-data-title { + font-size: 30px; + } + + .local-data-notice strong { + font-size: 20px; + } + + .local-data-facts { + grid-template-columns: 1fr; + } +} diff --git a/apps/web/src/local-data-boundary.tsx b/apps/web/src/local-data-boundary.tsx new file mode 100644 index 0000000..93ccb68 --- /dev/null +++ b/apps/web/src/local-data-boundary.tsx @@ -0,0 +1,55 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import "./local-data-boundary.css"; + +export const localDataRiskNotice = "测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。"; + +export function LocalDataBoundary() { + return ( +
+ + ); +} + +export function mountLocalDataBoundary(element: Element) { + createRoot(element).render( + + + , + ); +} diff --git a/package.json b/package.json index 7432c95..9c2684b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:integration": "vitest run tests/integration", "test:api": "pnpm check:openapi && vitest run tests/api", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs", - "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts --config playwright.config.ts", + "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts --config playwright.config.ts", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", @@ -26,7 +26,9 @@ "test:all": "pnpm test:unit && pnpm test:integration && pnpm test:api && pnpm test:worker && pnpm test:e2e && pnpm test:visual && pnpm test:performance && pnpm test:security && pnpm test:package && pnpm validate:tdd-trace", "test:wp0-01": "node scripts/run-wp0-01-validation.mjs", "test:wp0-02": "node scripts/run-wp0-02-validation.mjs", - "test:wp0-03": "node scripts/run-wp0-03-validation.mjs" + "test:wp0-03": "node scripts/run-wp0-03-validation.mjs", + "test:wp0-04": "node scripts/run-wp0-04-validation.mjs", + "test:wp0-04:red": "node scripts/run-wp0-04-validation.mjs --phase red" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/scripts/run-wp0-04-validation.mjs b/scripts/run-wp0-04-validation.mjs new file mode 100644 index 0000000..d27182e --- /dev/null +++ b/scripts/run-wp0-04-validation.mjs @@ -0,0 +1,181 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const phaseArgument = process.argv.indexOf("--phase"); +const phase = phaseArgument >= 0 ? process.argv[phaseArgument + 1] : "green"; +if (phase !== "red" && phase !== "green") throw new Error(`Unsupported phase: ${phase}`); + +const runId = process.env.DADA_TDD_RUN_ID ?? `wp0-04-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const rootCaseId = "TDD-WP0-DATA-001-root-validation"; +const transferCaseId = "TDD-WP0-DATA-002-no-backup-migration"; +const rootDirectory = resolve(runDirectory, "cases", rootCaseId); +const transferDirectory = resolve(runDirectory, "cases", transferCaseId); +const playwrightDirectory = resolve(runDirectory, "playwright"); + +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +for (const directory of [rootDirectory, transferDirectory]) mkdirSync(directory, { recursive: true }); + +const redCommands = [ + { command: "pnpm exec vitest run tests/integration/wp0-04-local-data-root.test.ts", args: ["exec", "vitest", "run", "tests/integration/wp0-04-local-data-root.test.ts"] }, + { command: "pnpm exec vitest run tests/api/wp0-04-resource-boundary.test.ts", args: ["exec", "vitest", "run", "tests/api/wp0-04-resource-boundary.test.ts"] }, + { command: "pnpm exec playwright test tests/e2e/local-data-boundary.spec.ts --config playwright.config.ts", args: ["exec", "playwright", "test", "tests/e2e/local-data-boundary.spec.ts", "--config", "playwright.config.ts"] }, +]; +const greenCommands = [ + { command: "pnpm test:integration", args: ["test:integration"] }, + { command: "pnpm test:api", args: ["test:api"] }, + { command: "pnpm test:security", args: ["test:security"] }, + { command: "pnpm test:package", args: ["test:package"] }, + { command: "pnpm test:e2e", args: ["test:e2e"] }, + { command: "pnpm validate:tdd-trace", args: ["validate:tdd-trace"] }, +]; +const startedAt = new Date().toISOString(); +const commands = []; + +for (const definition of phase === "red" ? redCommands : greenCommands) { + const commandStartedAt = new Date().toISOString(); + const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "pnpm"; + const args = process.platform === "win32" + ? ["/d", "/s", "/c", `pnpm ${definition.args.join(" ")}`] + : definition.args; + const execution = spawnSync(executable, args, { + encoding: "utf8", + env: { + ...process.env, + DADA_EVIDENCE_DIR_DATA_ROOT: rootDirectory, + DADA_EVIDENCE_DIR_NO_TRANSFER: transferDirectory, + DADA_PLAYWRIGHT_OUTPUT_DIR: playwrightDirectory, + }, + }); + if (execution.stdout) process.stdout.write(execution.stdout); + if (execution.stderr) process.stderr.write(execution.stderr); + commands.push({ + command: definition.command, + exit_code: execution.status ?? 1, + finished_at: new Date().toISOString(), + started_at: commandStartedAt, + }); +} + +const commandEvidence = { commands, phase, run_id: runId, schema_version: "1.0" }; +for (const directory of [rootDirectory, transferDirectory]) { + writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify(commandEvidence, null, 2)}\n`); +} + +const manifest = { + path: "tasks.manifest.json", + sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(), +}; +const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(); +const worktreeDirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0; +const environment = { arch: process.arch, node: process.version.slice(1), os: process.platform }; + +function findFiles(root, target) { + if (!existsSync(root)) return []; + const files = []; + for (const name of readdirSync(root)) { + const child = resolve(root, name); + if (statSync(child).isDirectory()) files.push(...findFiles(child, target)); + else if (name === target) files.push(child); + } + return files; +} + +if (phase === "green") { + for (const trace of findFiles(playwrightDirectory, "trace.zip")) { + if (trace.replaceAll("\\", "/").includes("local-data-boundary")) { + copyFileSync(trace, resolve(transferDirectory, "trace.zip")); + } + } +} + +function writeResult(directory, result) { + writeFileSync(resolve(directory, "result.json"), `${JSON.stringify(result, null, 2)}\n`); + return result; +} + +const common = { + commit, + environment, + finished_at: new Date().toISOString(), + manifest, + phase, + release_gate: ["work_package:WP-0", "release:P0-A"], + run_id: runId, + schema_version: "1.0", + started_at: startedAt, + task_id: "TASK-WP0-04", + work_package: "WP-0", + worktree_under_test: worktreeDirty ? "uncommitted implementation" : "clean committed implementation", +}; + +let rootResult; +let transferResult; +if (phase === "red") { + const redConfirmed = commands.every(({ exit_code }) => exit_code !== 0); + rootResult = writeResult(rootDirectory, { + ...common, + acceptance_criteria: ["AC-41", "AC-55", "AC-56"], + expected_failure: "LocalDataRoot validation, data_missing and stable resource isolation are not implemented.", + layer: ["DB", "API", "PKG-SEC"], + parent_family: "TDD-WP0-DATA-001", + requirements: ["NFR-09", "17.19"], + status: redConfirmed ? "red_confirmed" : "failed", + test_id: rootCaseId, + }); + transferResult = writeResult(transferDirectory, { + ...common, + acceptance_criteria: ["AC-24", "AC-56"], + expected_failure: "No-backup/no-migration policy and fixed settings copy are not implemented.", + layer: ["API", "E2E", "MANUAL"], + parent_family: "TDD-WP0-DATA-002", + requirements: ["AUTH-06", "NFR-09"], + status: redConfirmed ? "red_confirmed" : "failed", + test_id: transferCaseId, + }); +} else { + const commandsPassed = commands.every(({ exit_code }) => exit_code === 0); + const rootEvidence = ["config-result.json", "fs-before.json", "fs-after.json", "response.json"]; + const transferEvidence = ["route-inventory.json", "package-scan.json", "trace.zip", "screenshots/fixed-copy.png"]; + const rootMissing = rootEvidence.filter((file) => !existsSync(resolve(rootDirectory, file))); + const transferMissing = transferEvidence.filter((file) => !existsSync(resolve(transferDirectory, file))); + rootResult = writeResult(rootDirectory, { + ...common, + acceptance_criteria: ["AC-41", "AC-55", "AC-56"], + automation: ["automated"], + evidence_refs: rootEvidence, + layer: ["DB", "API", "PKG-SEC"], + missing_evidence: rootMissing, + parent_family: "TDD-WP0-DATA-001", + requirements: ["NFR-09", "17.19"], + status: commandsPassed && rootMissing.length === 0 ? "passed" : "failed", + test_id: rootCaseId, + }); + transferResult = writeResult(transferDirectory, { + ...common, + acceptance_criteria: ["AC-24", "AC-56"], + automation: ["automated", "manual_review"], + evidence_refs: transferEvidence, + layer: ["API", "E2E", "MANUAL"], + missing_evidence: transferMissing, + parent_family: "TDD-WP0-DATA-002", + requirements: ["AUTH-06", "NFR-09"], + status: commandsPassed && transferMissing.length === 0 ? "passed" : "failed", + test_id: transferCaseId, + }); +} + +const passed = phase === "red" + ? rootResult.status === "red_confirmed" && transferResult.status === "red_confirmed" + : rootResult.status === "passed" && transferResult.status === "passed"; +const summary = { + cases: [rootResult, transferResult].map(({ status, test_id }) => ({ status, test_id })), + phase, + run_id: runId, + status: passed ? (phase === "red" ? "red_confirmed" : "passed") : "failed", +}; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`); +console.log(JSON.stringify(summary, null, 2)); +if (!passed) process.exit(1); diff --git a/tests/api/wp0-04-resource-boundary.test.ts b/tests/api/wp0-04-resource-boundary.test.ts new file mode 100644 index 0000000..285183c --- /dev/null +++ b/tests/api/wp0-04-resource-boundary.test.ts @@ -0,0 +1,133 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createApp } from "../../apps/api/src/app.js"; +import { + createPublicAssetResolver, + validateReadOnlyAssetRoot, +} from "../../apps/api/src/local-data-root.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + if (resolve(directory).startsWith(resolve(tmpdir()))) rmSync(directory, { force: true, recursive: true }); + } +}); + +describe("TDD-WP0-DATA-001-root-validation resource boundary", () => { + it("serves a manifest resource by stable ID without exposing the absolute root or object key", async () => { + const base = mkdtempSync(join(tmpdir(), "dada-wp0-04-api-")); + temporaryDirectories.push(base); + const assetRoot = join(base, "read-only-assets"); + const relativePath = "images/source.png"; + const bytes = Buffer.from("synthetic png fixture"); + const assetId = randomUUID(); + mkdirSync(join(assetRoot, "images"), { recursive: true }); + writeFileSync(join(assetRoot, relativePath), bytes); + const manifest = JSON.stringify({ assets: [{ asset_id: assetId, relative_path: relativePath }] }); + writeFileSync(join(assetRoot, "catalog.json"), manifest); + const validatedRoot = validateReadOnlyAssetRoot({ + dataRoot: join(base, "data"), + expectedSha256: createHash("sha256").update(manifest).digest("hex"), + manifestRelativePath: "catalog.json", + root: assetRoot, + rootRef: "fixture_assets", + }); + if (!validatedRoot.ok) throw new Error(validatedRoot.reason); + const publicAssets = createPublicAssetResolver({ + entries: [{ + assetId, + mimeType: "image/png", + relativePath, + resourceVersion: "fixture-v1", + rootRef: "fixture_assets", + sha256: createHash("sha256").update(bytes).digest("hex"), + }], + roots: [validatedRoot], + }); + const app = await createApp({ browserGate: false, publicAssets }); + + try { + const resourceUrl = `/api/v1/assets/public/fixture-v1/${assetId}`; + const response = await app.inject({ + headers: { host: "127.0.0.1:43121" }, + method: "GET", + url: resourceUrl, + }); + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toContain("image/png"); + expect(response.rawPayload).toEqual(bytes); + const wrongVersion = await app.inject({ + headers: { host: "127.0.0.1:43121" }, + method: "GET", + url: `/api/v1/assets/public/other-version/${assetId}`, + }); + expect(wrongVersion.statusCode).toBe(404); + const browserVisible = JSON.stringify({ + headers: response.headers, + resource_id: assetId, + url: resourceUrl, + }); + expect(browserVisible).not.toContain(assetRoot); + expect(browserVisible).not.toContain(relativePath); + expect(browserVisible).not.toMatch(/[A-Za-z]:\\/); + + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_DATA_ROOT; + if (evidenceDirectory) { + mkdirSync(evidenceDirectory, { recursive: true }); + writeFileSync(join(evidenceDirectory, "response.json"), `${JSON.stringify({ + content_type: response.headers["content-type"], + path_exposed: false, + resource_id: assetId, + status: "passed", + }, null, 2)}\n`); + } + } finally { + await app.close(); + } + }); + + it("does not resolve unknown IDs or path-like route parameters", async () => { + const app = await createApp({ browserGate: false, publicAssets: createPublicAssetResolver({ entries: [], roots: [] }) }); + try { + for (const target of [randomUUID(), "..%2F..%2Fsecret", "C:%5CUsers%5Csecret"]) { + const response = await app.inject({ + headers: { host: "127.0.0.1:43121" }, + method: "GET", + url: `/api/v1/assets/public/fixture-v1/${target}`, + }); + expect(response.statusCode).toBe(404); + expect(response.body).not.toMatch(/[A-Za-z]:\\/); + } + } finally { + await app.close(); + } + }); +}); + +describe("TDD-WP0-DATA-002-no-backup-migration route inventory", () => { + it("contains no product backup, archive, business import, migration or recovery route", async () => { + const app = await createApp({ browserGate: false }); + try { + const routes = app.printRoutes(); + for (const forbidden of ["backup", "archive", "project-package", "business-import", "migration", "restore", "recovery"]) { + expect(routes.toLowerCase()).not.toContain(forbidden); + } + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_NO_TRANSFER; + if (evidenceDirectory) { + mkdirSync(evidenceDirectory, { recursive: true }); + writeFileSync(join(evidenceDirectory, "route-inventory.json"), `${JSON.stringify({ + forbidden_routes: [], + status: "passed", + }, null, 2)}\n`); + } + } finally { + await app.close(); + } + }); +}); diff --git a/tests/e2e/fixtures/local-data-boundary.html b/tests/e2e/fixtures/local-data-boundary.html new file mode 100644 index 0000000..fb9a023 --- /dev/null +++ b/tests/e2e/fixtures/local-data-boundary.html @@ -0,0 +1,12 @@ + + + + + + Dada 本机数据 + + +
+ + + diff --git a/tests/e2e/fixtures/local-data-boundary.tsx b/tests/e2e/fixtures/local-data-boundary.tsx new file mode 100644 index 0000000..7430f6d --- /dev/null +++ b/tests/e2e/fixtures/local-data-boundary.tsx @@ -0,0 +1,6 @@ +import { mountLocalDataBoundary } from "../../../apps/web/src/local-data-boundary.js"; + +const root = document.querySelector("#root"); +if (!root) throw new Error("Fixture root is missing."); + +mountLocalDataBoundary(root); diff --git a/tests/e2e/local-data-boundary.spec.ts b/tests/e2e/local-data-boundary.spec.ts new file mode 100644 index 0000000..3ba9c60 --- /dev/null +++ b/tests/e2e/local-data-boundary.spec.ts @@ -0,0 +1,62 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; + +let vite: ViteDevServer; +let webUrl: string; + +test.beforeAll(async () => { + vite = await createServer({ + configFile: false, + root: process.cwd(), + server: { host: "127.0.0.1", port: 0 }, + }); + await vite.listen(); + const address = vite.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not expose a test port."); + webUrl = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => { + await vite.close(); +}); + +test("TDD-WP0-DATA-002 keeps the fixed local-data warning visible without transfer entry points", async ({ page }) => { + await page.goto(`${webUrl}/tests/e2e/fixtures/local-data-boundary.html`); + const fixedCopy = "测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。"; + await expect(page.getByRole("heading", { name: "本机数据" })).toBeVisible(); + await expect(page.getByText(fixedCopy, { exact: true })).toBeVisible(); + await expect(page.getByText("当前 Windows 用户的 Dada 本机数据目录", { exact: true })).toBeVisible(); + await expect(page.getByText(/不提供 Dada 应用层加密或云备份/)).toBeVisible(); + await expect(page.getByText(/机器损坏、重装或删除本机数据目录后不可恢复/)).toBeVisible(); + await expect(page.getByText(/主动下载需要保留的原始生成图或 JPG\/PNG 成品/)).toBeVisible(); + await expect(page.getByRole("button")).toHaveCount(0); + await expect(page.getByRole("link")).toHaveCount(0); + const text = await page.locator("body").innerText(); + expect(text).not.toMatch(/[A-Za-z]:\\/); + + await page.reload(); + await expect(page.getByText(fixedCopy, { exact: true })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_NO_TRANSFER; + if (evidenceDirectory) { + mkdirSync(resolve(evidenceDirectory, "screenshots"), { recursive: true }); + await page.screenshot({ path: resolve(evidenceDirectory, "screenshots", "fixed-copy.png") }); + writeFileSync(resolve(evidenceDirectory, "package-scan.json"), `${JSON.stringify({ + absolute_path_exposed: false, + automatic_backup_entry: false, + business_import_entry: false, + editable_project_archive_entry: false, + migration_entry: false, + recovery_entry: false, + status: "passed", + }, null, 2)}\n`); + } + + await page.setViewportSize({ height: 844, width: 390 }); + await expect(page.getByText(fixedCopy, { exact: true })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); +}); diff --git a/tests/integration/wp0-04-local-data-root.test.ts b/tests/integration/wp0-04-local-data-root.test.ts new file mode 100644 index 0000000..147e931 --- /dev/null +++ b/tests/integration/wp0-04-local-data-root.test.ts @@ -0,0 +1,237 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { createRequire } from "node:module"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + DATA_TRANSFER_POLICY, + defaultInstanceConfigPath, + defaultLocalDataRoot, + initializeLocalDataRoot, + inspectInitializedLocalDataRoot, + resolvePathWithinRoot, + validateLocalDataRoot, + validateReadOnlyAssetRoot, + type LocalDataRootBoundaries, +} from "../../apps/api/src/local-data-root.js"; + +const requireFromApi = createRequire(new URL("../../apps/api/package.json", import.meta.url)); +const Database = requireFromApi("better-sqlite3"); +const temporaryDirectories: string[] = []; + +function temporaryDirectory() { + const directory = mkdtempSync(join(tmpdir(), "dada-wp0-04-")); + temporaryDirectories.push(directory); + return directory; +} + +function fixture() { + const base = temporaryDirectory(); + const projectRoot = join(base, "project"); + const downloadsRoot = join(base, "Downloads"); + const assetRoot = join(base, "read-only-assets"); + for (const directory of [projectRoot, downloadsRoot, assetRoot]) mkdirSync(directory); + const boundaries: LocalDataRootBoundaries = { + downloadsRoot, + programRoot: process.cwd(), + projectRoots: [projectRoot], + readOnlyAssetRoots: [assetRoot], + repositoryRoot: process.cwd(), + }; + return { assetRoot, base, boundaries, downloadsRoot, projectRoot }; +} + +function snapshot(directory: string): string[] { + if (!existsSync(directory)) return []; + return readdirSync(directory, { recursive: true }).map(String).sort(); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + if (resolve(directory).startsWith(resolve(tmpdir()))) rmSync(directory, { force: true, recursive: true }); + } +}); + +describe("TDD-WP0-DATA-001-root-validation", () => { + it("derives default data and configuration paths from LOCALAPPDATA without a hardcoded user", () => { + const localAppData = join(temporaryDirectory(), "LocalAppData"); + expect(defaultLocalDataRoot({ LOCALAPPDATA: localAppData })).toBe(join(localAppData, "Dada", "P0A", "data")); + expect(defaultInstanceConfigPath({ LOCALAPPDATA: localAppData })).toBe( + join(localAppData, "Dada", "P0A", "config", "instance.json"), + ); + expect(() => defaultLocalDataRoot({})).toThrow("local_app_data_unavailable"); + }); + + it("rejects repository, project, Downloads, read-only asset, junction and escaped paths without writes", () => { + const { assetRoot, base, boundaries, downloadsRoot, projectRoot } = fixture(); + const outside = join(base, "junction-target"); + const junction = join(base, "junction-root"); + mkdirSync(outside); + symlinkSync(outside, junction, "junction"); + expect(lstatSync(junction).isSymbolicLink()).toBe(true); + + const cases = [ + { path: join(process.cwd(), ".dada-local", "forbidden"), reason: "repository_root" }, + { path: join(projectRoot, "data"), reason: "project_root" }, + { path: join(downloadsRoot, "data"), reason: "downloads_root" }, + { path: join(assetRoot, "data"), reason: "read_only_asset_root" }, + { path: join(junction, "data"), reason: "symbolic_link" }, + ] as const; + const before = snapshot(base); + for (const [index, candidate] of cases.entries()) { + expect(validateLocalDataRoot(candidate.path, boundaries)).toEqual({ + ok: false, + reason: candidate.reason, + }); + expect(() => initializeLocalDataRoot({ + boundaries, + configFile: join(base, `invalid-config-${index}`, "instance.json"), + dataRoot: candidate.path, + })).toThrow(candidate.reason); + } + expect(() => resolvePathWithinRoot(join(base, "safe"), "../escaped/file.png")).toThrow("path_escape"); + const after = snapshot(base); + expect(after).toEqual(before); + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_DATA_ROOT; + if (evidenceDirectory) { + mkdirSync(evidenceDirectory, { recursive: true }); + writeFileSync(join(evidenceDirectory, "fs-before.json"), `${JSON.stringify({ entries: before }, null, 2)}\n`); + } + }); + + it("creates the fixed layout and SQLite only during explicit initialization", () => { + const { base, boundaries } = fixture(); + const dataRoot = join(base, "compliant-data"); + const configFile = join(base, "config", "instance.json"); + const initialized = initializeLocalDataRoot({ boundaries, configFile, dataRoot }); + + expect(initialized.status).toBe("ready"); + for (const relativePath of [ + "db/dada.sqlite3", + "content/references", + "content/generated", + "content/exports", + "managed-assets", + "derived-assets", + "staging", + "logs/api", + "logs/worker", + "logs/supervisor", + ]) { + expect(existsSync(join(dataRoot, relativePath))).toBe(true); + } + + const database = new Database(join(dataRoot, "db", "dada.sqlite3"), { readonly: true }); + expect(database.prepare("select schema_version from instance_metadata").get()).toEqual({ schema_version: 1 }); + database.close(); + expect(JSON.parse(readFileSync(configFile, "utf8"))).toMatchObject({ initialized: true, schema_version: 1 }); + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_DATA_ROOT; + if (evidenceDirectory) { + mkdirSync(evidenceDirectory, { recursive: true }); + writeFileSync(join(evidenceDirectory, "config-result.json"), `${JSON.stringify({ + database: "db/dada.sqlite3", + fixed_layout: true, + path_exposed: false, + status: initialized.status, + }, null, 2)}\n`); + } + }); + + it("reports data_missing without recreating a deleted initialized root or database", () => { + const { base, boundaries } = fixture(); + const dataRoot = join(base, "initialized-data"); + const configFile = join(base, "config", "instance.json"); + initializeLocalDataRoot({ boundaries, configFile, dataRoot }); + rmSync(join(dataRoot, "db", "dada.sqlite3")); + + expect(inspectInitializedLocalDataRoot({ boundaries, configFile })).toMatchObject({ + database_exists: false, + root_exists: true, + status: "data_missing", + }); + expect(existsSync(join(dataRoot, "db", "dada.sqlite3"))).toBe(false); + + rmSync(dataRoot, { recursive: true }); + expect(inspectInitializedLocalDataRoot({ boundaries, configFile })).toMatchObject({ + database_exists: false, + root_exists: false, + status: "data_missing", + }); + expect(existsSync(dataRoot)).toBe(false); + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_DATA_ROOT; + if (evidenceDirectory) { + mkdirSync(evidenceDirectory, { recursive: true }); + writeFileSync(join(evidenceDirectory, "fs-after.json"), `${JSON.stringify({ + database_recreated: false, + root_recreated: false, + status: "data_missing", + }, null, 2)}\n`); + } + }); + + it("validates a read-only manifest by hash without copying or writing its source root", () => { + const { assetRoot, base } = fixture(); + const manifest = join(assetRoot, "catalog.json"); + writeFileSync(manifest, "{\"assets\":[]}\n"); + const expectedSha256 = createHash("sha256").update(readFileSync(manifest)).digest("hex"); + const before = snapshot(assetRoot); + + expect(validateReadOnlyAssetRoot({ + dataRoot: join(base, "data"), + expectedSha256, + manifestRelativePath: "catalog.json", + root: assetRoot, + rootRef: "fixture_assets", + })).toMatchObject({ ok: true, root_ref: "fixture_assets" }); + expect(snapshot(assetRoot)).toEqual(before); + }); +}); + +describe("TDD-WP0-DATA-002-no-backup-migration", () => { + it("does not recover deleted data or treat retained downloads as migration input", () => { + const { base, boundaries } = fixture(); + const dataRoot = join(base, "instance-a"); + const configFile = join(base, "config-a", "instance.json"); + initializeLocalDataRoot({ boundaries, configFile, dataRoot }); + const businessMarker = join(dataRoot, "content", "generated", "private-record.json"); + mkdirSync(dirname(businessMarker), { recursive: true }); + writeFileSync(businessMarker, "private business data"); + const retainedDownload = join(base, "user-downloads", "finished.png"); + mkdirSync(dirname(retainedDownload)); + writeFileSync(retainedDownload, "downloaded image"); + + rmSync(dataRoot, { recursive: true }); + expect(inspectInitializedLocalDataRoot({ boundaries, configFile }).status).toBe("data_missing"); + expect(existsSync(dataRoot)).toBe(false); + expect(readFileSync(retainedDownload, "utf8")).toBe("downloaded image"); + + const replacementRoot = join(base, "instance-b"); + initializeLocalDataRoot({ + boundaries, + configFile: join(base, "config-b", "instance.json"), + dataRoot: replacementRoot, + }); + expect(existsSync(join(replacementRoot, "content", "generated", "private-record.json"))).toBe(false); + expect(DATA_TRANSFER_POLICY).toEqual({ + allowed_downloads: ["original_generation", "jpg", "png"], + application_backup: false, + business_import: false, + editable_project_archive: false, + p0b_migration: false, + recovery: false, + }); + }); +});