Darkwing's round 2 candidate, approved by Filbert (#1518 comment 26730). build-r2.patch a27890d5, manifest 869168c7, 34 files, applied on HEAD and checked 34/34. Integration gate on an export of HEAD plus the patch: business 60/60 on Node 24 and 26, every package test and every scripts/test-*.sh green, test-task 98/98 with the live-provider cases. Conductor, queue, conversation and discord confirmed in git worktrees of HEAD with and without the patch, identical results. Lead decision 63 accepts the vocabulary location, the example path and the business branch. Co-Authored-By: Claude Opus 5.5 <[email protected]>
98 lines
4.3 KiB
JavaScript
98 lines
4.3 KiB
JavaScript
// Credential references (note section 2, addendum A section 7). A
|
|
// reference names where a token lives, never the token. The checks here use
|
|
// lstat and realpath only; nothing in this package opens a token file.
|
|
|
|
import { lstatSync, realpathSync } from "node:fs";
|
|
import { isAbsolute, normalize, relative, sep } from "node:path";
|
|
import { refuse } from "./errors.mjs";
|
|
import { SERVICES } from "./vocabulary.mjs";
|
|
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
|
|
|
|
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" });
|
|
const WARN_DAYS = 7;
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
// Shape check for one reference. Returns a frozen { service, file | env,
|
|
// rotateBy | expires }.
|
|
export function parseCredentialRef(ref, service, where) {
|
|
if (!SERVICES.includes(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
|
|
requireObject(ref, where);
|
|
const dateKey = DATE_KEY[service];
|
|
rejectUnknownKeys(ref, ["file", "env", dateKey], where);
|
|
const hasFile = ref.file !== undefined;
|
|
const hasEnv = ref.env !== undefined;
|
|
if (hasFile === hasEnv) refuse(`${where} must name exactly one of "file" or "env"`);
|
|
const out = { service };
|
|
if (hasFile) {
|
|
if (typeof ref.file !== "string" || !isAbsolute(ref.file) || normalize(ref.file) !== ref.file || ref.file.includes("\0")) {
|
|
refuse(`${where}.file must be a normalized absolute path`);
|
|
}
|
|
out.file = ref.file;
|
|
} else {
|
|
if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`);
|
|
out.env = ref.env;
|
|
}
|
|
if (ref[dateKey] === undefined) refuse(`${where} needs "${dateKey}" (YYYY-MM-DD)`);
|
|
out[dateKey] = requireDate(ref[dateKey], `${where}.${dateKey}`);
|
|
return Object.freeze(out);
|
|
}
|
|
|
|
function inside(root, path) {
|
|
const rel = relative(root, path);
|
|
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
}
|
|
|
|
// Check a parsed reference against the filesystem and the calendar.
|
|
// Returns { problems: [...], warnings: [...] }; a caller that finds any
|
|
// problem refuses. `forbiddenRoots` are directories a token file must not
|
|
// sit in (the repository, dataRoot). `now` is a Date.
|
|
export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(), env = process.env, uid = process.getuid() } = {}) {
|
|
const problems = [];
|
|
const warnings = [];
|
|
const label = `${ref.service} ${ref.file ? `file ${ref.file}` : `env ${ref.env}`}`;
|
|
if (ref.file) {
|
|
let stat = null;
|
|
try {
|
|
stat = lstatSync(ref.file);
|
|
} catch {
|
|
problems.push(`${label}: not found`);
|
|
}
|
|
if (stat) {
|
|
if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`);
|
|
else {
|
|
if (stat.uid !== uid) problems.push(`${label}: owned by uid ${stat.uid}, not ${uid}`);
|
|
if ((stat.mode & 0o077) !== 0) problems.push(`${label}: mode ${(stat.mode & 0o777).toString(8)} gives group or other access; use 600`);
|
|
if (stat.size === 0) problems.push(`${label}: empty`);
|
|
let real = ref.file;
|
|
try {
|
|
real = realpathSync(ref.file);
|
|
} catch {
|
|
problems.push(`${label}: path can't be resolved`);
|
|
}
|
|
for (const root of forbiddenRoots) {
|
|
let realRoot = root;
|
|
try {
|
|
realRoot = realpathSync(root);
|
|
} catch {
|
|
// A root that doesn't exist yet can't contain the file.
|
|
}
|
|
if (inside(realRoot, real)) problems.push(`${label}: inside ${root}; token files live outside the repository and dataRoot`);
|
|
}
|
|
}
|
|
}
|
|
} else if (env[ref.env] === undefined || env[ref.env] === "") {
|
|
warnings.push(`${label}: not set in this environment; the launcher must provide it`);
|
|
}
|
|
const days = (dateText) => Math.floor((Date.parse(`${dateText}T00:00:00Z`) - now.getTime()) / DAY_MS);
|
|
if (ref.expires) {
|
|
const left = days(ref.expires);
|
|
if (Date.parse(`${ref.expires}T00:00:00Z`) <= now.getTime()) problems.push(`${label}: expired on ${ref.expires}`);
|
|
else if (left < WARN_DAYS) warnings.push(`${label}: expires on ${ref.expires}`);
|
|
}
|
|
if (ref.rotateBy && Date.parse(`${ref.rotateBy}T00:00:00Z`) <= now.getTime()) {
|
|
warnings.push(`${label}: rotation was due on ${ref.rotateBy}`);
|
|
}
|
|
return { problems, warnings };
|
|
}
|