test/buildcontext.test.js
Rama main · réplica de lectura del repositorio original.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* What the Docker build context actually carries out of `config/`.
*
* The image runs with FORGE_CONFIG_DIR=/data/config, so nothing copied to
* /app/config is ever read — which is exactly why this went unnoticed. An
* image layer is still readable by anyone who pulls it, and `COPY config
* ./config` was shipping the account database.
*
* The previous .dockerignore listed the state files to exclude by name, and
* that list was written before accounts moved to SQLite: it named users.json
* and sessions.json and knew nothing about forge.db, config/uploads or
* config/identities. An allow-list of things to exclude goes stale every time
* a subsystem adds a file, silently and on the unsafe side. The rule is now
* deny-by-default, and this test is what keeps it that way — a .dockerignore
* is otherwise never exercised by anything.
*
* The named-path cases matter more than the present-file ones: a clean
* checkout has no forge.db, so a test that only walked the working tree would
* pass while proving nothing.
*/
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
/**
* Docker's ignore matching, as much of it as this check needs: patterns in
* order with the last match winning, `!` for a re-include, and a path excluded
* when it or any parent directory is excluded. That last part is why a bare
* `config/uploads` entry hides the files beneath it.
*/
function patternToRegExp(pattern) {
const parts = pattern.split("/");
const rx = parts
.map((part) => {
if (part === "**") return "(?:[^/]+/)*[^/]+";
return part
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, "[^/]*")
.replace(/\?/g, "[^/]");
})
.join("/");
return new RegExp(`^${rx}$`);
}
function parseDockerignore(text) {
return text
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"))
.map((line) => {
const exception = line.startsWith("!");
const body = exception ? line.slice(1).trim() : line;
const clean = path.posix.normalize(body).replace(/\/+$/, "");
return { exception, regex: patternToRegExp(clean), source: line };
});
}
/** Every prefix of a path, so a parent-directory exclusion is seen. */
function selfAndParents(file) {
const parts = file.split("/");
return parts.map((_, i) => parts.slice(0, i + 1).join("/"));
}
function inBuildContext(patterns, file) {
let excluded = false;
for (const p of patterns) {
// A re-include only counts against the file itself. Docker cannot resurrect
// a file whose parent directory was excluded without a pattern reaching it,
// and treating `!a/b.yaml` as un-excluding `a/` would make this check
// report a leak-free context that docker build does not produce.
const candidates = p.exception ? [file] : selfAndParents(file);
if (candidates.some((c) => p.regex.test(c))) excluded = !p.exception;
}
return !excluded;
}
const patterns = parseDockerignore(await readFile(path.join(ROOT, ".dockerignore"), "utf8"));
test("the two config templates the image seeds reach the image", async () => {
// The entrypoint seeds /data/config from /app/config/*.yaml on first boot, so
// excluding these would leave a fresh volume with no models.yaml at all.
// This is intentionally an explicit deployment contract, rather than a
// `git ls-files` subprocess. The image must seed exactly these templates;
// any later config file must be added deliberately instead of becoming an
// accidental part of its initial state merely because it was committed.
for (const file of ["config/identity.example.yaml", "config/models.yaml"]) {
assert.equal(inBuildContext(patterns, file), true, `${file} is committed but excluded from the build context`);
}
});
test("no account, credential or user-content file under config/ reaches the image", () => {
// Named rather than discovered: a clean checkout and CI have none of these on
// disk, and those are the runs where a stale rule would go unreported.
const mustNotShip = [
"config/forge.db",
"config/forge.db-wal",
"config/forge.db-shm",
"config/users.json",
"config/sessions.json",
"config/identity.yaml",
"config/local.yaml",
"config/hosted.json",
"config/identities/0123456789abcdef.yaml",
"config/uploads/a-users-private-paper.pdf",
"config/presets/some-preset.json",
];
for (const file of mustNotShip) {
assert.equal(inBuildContext(patterns, file), false, `${file} would be copied into the image`);
}
});
test("config/ is deny-by-default, so a new stateful file is excluded without an edit here", () => {
// The property, not the instances: anything a later subsystem invents under
// config/ must already be out. This is the half the old allow-list lacked.
const invented = [
"config/whatever-comes-next.json",
"config/quotas.db",
"config/tokens/refresh.json",
"config/a/deeply/nested/secret",
];
for (const file of invented) {
assert.equal(inBuildContext(patterns, file), false, `${file} reached the image — config/ is no longer deny-by-default`);
}
});
/**
* The same question asked of git, because it is the same mistake twice.
*
* `.gitignore` listed `config/identity.yaml`, `brand/logos/` and
* `brand/generated/` — the single-operator paths — and per-account storage
* added `config/identities/` and `brand/users/<hash>/` underneath them, which
* those entries do not reach: `brand/logos/` does not match
* `brand/users/abc/logos/`. Both directories hold a real account's institution
* and its crest, and both showed up as untracked the first time an account
* saved anything.
*/
test("per-account state is ignored by git, not merely untracked so far", () => {
const mustBeIgnored = [
"config/identities/0123456789abcdef.yaml",
"config/forge.db",
"config/uploads/a-users-paper.pdf",
"brand/users/0123456789abcdef/logos/crest.png",
"brand/users/0123456789abcdef/generated/crest.png",
"reference/users/0123456789abcdef/their-template.docx",
"decks/some-slug/deck.yaml",
];
for (const file of mustBeIgnored) {
const res = spawnSync("git", ["check-ignore", "-q", file], { cwd: ROOT });
assert.equal(res.status, 0, `${file} is not ignored by .gitignore — it would be committable`);
}
});
test("the repo's own state directories stay out of the context", () => {
for (const file of ["decks/some-slug/deck.yaml", ".env", "brand/logos/crest.png", "reference/institution.docx"]) {
assert.equal(inBuildContext(patterns, file), false, `${file} would be copied into the image`);
}
// The entrypoint creates the volume-backed reference directory. A donor is
// user data and no reference file, including the placeholder, belongs in the
// app image.
assert.equal(inBuildContext(patterns, "reference/.gitkeep"), false);
});