feat: complete TASK-WP0-04 local data boundary
This commit is contained in:
@@ -34,6 +34,7 @@ import {
|
|||||||
type BrowserUnsupportedReason,
|
type BrowserUnsupportedReason,
|
||||||
} from "./browser-support.js";
|
} from "./browser-support.js";
|
||||||
import { EventHub } from "./event-hub.js";
|
import { EventHub } from "./event-hub.js";
|
||||||
|
import type { PublicAssetResolver } from "./local-data-root.js";
|
||||||
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
|
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
|
||||||
|
|
||||||
const defaultBootstrap: BootstrapResponse = {
|
const defaultBootstrap: BootstrapResponse = {
|
||||||
@@ -55,6 +56,7 @@ export interface CreateAppOptions {
|
|||||||
browserSupportSecret?: Buffer;
|
browserSupportSecret?: Buffer;
|
||||||
eventHub?: EventHub;
|
eventHub?: EventHub;
|
||||||
networkBoundary?: NetworkBoundaryOptions;
|
networkBoundary?: NetworkBoundaryOptions;
|
||||||
|
publicAssets?: PublicAssetResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
const supportGateDirectory = resolve("apps/web/support-gate");
|
const supportGateDirectory = resolve("apps/web/support-gate");
|
||||||
@@ -211,6 +213,21 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
status: "ready",
|
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(
|
app.post(
|
||||||
"/api/v1/support/check",
|
"/api/v1/support/check",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<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 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<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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="local-data-page">
|
||||||
|
<div className="local-data-rule" aria-hidden="true" />
|
||||||
|
<main className="local-data-main">
|
||||||
|
<p className="local-data-kicker">SETTINGS / LOCAL DATA</p>
|
||||||
|
<h1 className="local-data-title">本机数据</h1>
|
||||||
|
<p className="local-data-intro">
|
||||||
|
Dada 的 P0-A 测试数据由本地后端管理。浏览器只通过资源 ID 访问文件,不会显示或直接读取本机目录路径。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section className="local-data-notice" aria-labelledby="local-data-warning">
|
||||||
|
<strong id="local-data-warning">{localDataRiskNotice}</strong>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<dl className="local-data-facts">
|
||||||
|
<div className="local-data-fact">
|
||||||
|
<dt>逻辑位置</dt>
|
||||||
|
<dd>当前 Windows 用户的 Dada 本机数据目录</dd>
|
||||||
|
</div>
|
||||||
|
<div className="local-data-fact">
|
||||||
|
<dt>保护方式</dt>
|
||||||
|
<dd>依赖当前 Windows 用户登录和文件系统权限</dd>
|
||||||
|
</div>
|
||||||
|
<div className="local-data-fact">
|
||||||
|
<dt>备份与加密</dt>
|
||||||
|
<dd>不提供 Dada 应用层加密或云备份</dd>
|
||||||
|
</div>
|
||||||
|
<div className="local-data-fact">
|
||||||
|
<dt>恢复边界</dt>
|
||||||
|
<dd>机器损坏、重装或删除本机数据目录后不可恢复</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p className="local-data-download">请主动下载需要保留的原始生成图或 JPG/PNG 成品。</p>
|
||||||
|
</main>
|
||||||
|
<footer className="local-data-footer">P0-A · LOCAL FILESYSTEM · NO BACKUP</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountLocalDataBoundary(element: Element) {
|
||||||
|
createRoot(element).render(
|
||||||
|
<StrictMode>
|
||||||
|
<LocalDataBoundary />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
}
|
||||||
+4
-2
@@ -14,7 +14,7 @@
|
|||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:api": "pnpm check:openapi && vitest run tests/api",
|
"test:api": "pnpm check:openapi && vitest run tests/api",
|
||||||
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs",
|
"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:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"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: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-01": "node scripts/run-wp0-01-validation.mjs",
|
||||||
"test:wp0-02": "node scripts/run-wp0-02-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": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.0",
|
"@playwright/test": "1.62.0",
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Dada 本机数据</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/tests/e2e/fixtures/local-data-boundary.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user