/** Shared, side-effect-free saved-environment wire contract. */ export const ENVIRONMENT_MANIFEST_FILE = "dench-env.json"; export const MAX_ENVIRONMENT_SOURCE_BYTES = 16 * 1024 * 1024; export const MAX_ENVIRONMENT_PREPARED_BYTES = 128 * 1024 * 1024; export const MAX_ENVIRONMENT_FILES = 512; export const MAX_WORKSPACE_SECRET_BYTES = 64 * 1024; export const ENVIRONMENT_NAME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/; export const SECRET_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,127}$/; export type EnvironmentManifest = { version: 1; name: string; purpose: string; files: string[]; prepare: string[]; verify: string[]; env: Record; secretEnv: Record; secretFiles: { path: string; secret: string; mutable?: boolean }[]; statePaths: string[]; portable: boolean; }; export type EnvironmentSourceBundle = { version: 1; files: { path: string; contentBase64: string; mode: 420 | 493 }[]; }; export type EnvironmentArtifacts = { snapshotId?: string; bundleStorageId?: string; bundleSha256?: string; bundleBytes?: number; prefix: string; runtimeAbi: string; platform: "linux/amd64" | "linux/arm64"; verifiedAt: number; }; export type EnvironmentRevision = { id: string; name: string; revision: string; status: "pending" | "building" | "ready" | "failed"; manifest: EnvironmentManifest; source: { storageId: string; sha256: string; size: number }; sourcePrefix: string; artifacts?: EnvironmentArtifacts; errorCode?: string; createdAt: number; }; function fail(message: string): never { throw new Error(`validation_failed: ${message}`); } export function validateEnvironmentName(value: string): string { if (!ENVIRONMENT_NAME_PATTERN.test(value)) fail("Environment name must be a lowercase slug of at most 64 characters."); return value; } export function validateSecretName(value: string): string { if (!SECRET_NAME_PATTERN.test(value)) fail("Secret names must use uppercase letters, digits and underscores."); if ( /^(DENCH_|HARNESS_|CONVEX_|VERCEL_|DAYTONA_|NODE_OPTIONS$|NODE_PATH$|LD_|DYLD_|PATH$|HOME$|SHELL$|BASH_ENV$|ENV$|PYTHONPATH$|PYTHONHOME$)/.test( value, ) ) fail("Platform and process-control environment names are reserved."); return value; } export function validateEnvironmentPath(value: string): string { if ( !value || value.length > 512 || value.startsWith("/") || value.includes("\\") || [...value].some((character) => character.charCodeAt(0) < 32) || value.split("/").some((part) => !part || part === "." || part === "..") ) fail( "Environment paths must be normalized relative paths without traversal.", ); return value; } export function isForbiddenEnvironmentSourcePath(value: string): boolean { return value .split("/") .some((part) => /^(\.env(?:\..*)?|\.npmrc|\.pypirc|\.netrc|\.ssh|\.aws|\.config|\.git|node_modules|__pycache__|\.venv|credentials(?:\..*)?)$/i.test( part, ), ); } function record(value: unknown, label: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) fail(`${label} must be an object.`); return value as Record; } function strings(value: unknown, label: string, max = 64): string[] { if ( !Array.isArray(value) || value.length > max || value.some((x) => typeof x !== "string" || !x || x.length > 8192) ) fail(`${label} must be a bounded list of nonempty strings.`); return value as string[]; } export function parseEnvironmentManifest(value: unknown): EnvironmentManifest { const input = record(value, "Manifest"); const keys = new Set([ "version", "name", "purpose", "files", "prepare", "verify", "env", "secretEnv", "secretFiles", "statePaths", "portable", ]); if (Object.keys(input).some((key) => !keys.has(key))) fail("Unknown environment manifest field."); if ( input.version !== 1 || typeof input.name !== "string" || typeof input.purpose !== "string" || !input.purpose.trim() || input.purpose.length > 2000 ) fail("Manifest requires version 1, name and purpose."); validateEnvironmentName(input.name); const files = strings(input.files, "files", MAX_ENVIRONMENT_FILES); for (const path of files) { validateEnvironmentPath(path); if (isForbiddenEnvironmentSourcePath(path)) fail("Credential/cache paths cannot be saved as source files."); } const map = (raw: unknown, label: string, secret: boolean) => { const values = record(raw ?? {}, label); if (Object.keys(values).length > 64) fail(`${label} has too many bindings.`); const result: Record = {}; for (const [key, val] of Object.entries(values)) { validateSecretName(key); if ( !secret && /(?:^|_)(?:API_KEY|ACCESS_KEY|PRIVATE_KEY|SECRET|TOKEN|PASSWORD|CREDENTIALS?)(?:_|$)/.test( key, ) ) fail( "Credential-looking variables must use secretEnv bindings, never public env values.", ); if (typeof val !== "string" || val.length > 8192) fail(`${label} values must be strings of at most 8192 characters.`); if (secret) validateSecretName(val); result[key] = val; } return result; }; const secretFilesRaw = input.secretFiles ?? []; if (!Array.isArray(secretFilesRaw) || secretFilesRaw.length > 32) fail("secretFiles must be a bounded list."); const secretFiles = secretFilesRaw.map((item) => { const binding = record(item, "secretFiles binding"); if ( Object.keys(binding).some( (key) => !["path", "secret", "mutable"].includes(key), ) || typeof binding.path !== "string" || typeof binding.secret !== "string" || (binding.mutable !== undefined && typeof binding.mutable !== "boolean") ) fail("Invalid secretFiles binding."); return { path: validateEnvironmentPath(binding.path as string), secret: validateSecretName(binding.secret as string), ...(binding.mutable !== undefined ? { mutable: binding.mutable as boolean } : {}), }; }); const statePaths = strings(input.statePaths ?? [], "statePaths", 32).map( validateEnvironmentPath, ); const output: EnvironmentManifest = { version: 1, name: input.name, purpose: input.purpose.trim(), files, prepare: strings(input.prepare ?? [], "prepare", 32), verify: strings(input.verify ?? [], "verify", 32), env: map(input.env, "env", false), secretEnv: map(input.secretEnv, "secretEnv", true), secretFiles, statePaths, portable: input.portable !== false, }; if (input.portable !== undefined && typeof input.portable !== "boolean") fail("portable must be a boolean."); if (!output.verify.length) fail("At least one verification command is required before publication."); if (Object.keys(output.env).some((key) => key in output.secretEnv)) fail("A variable cannot be both public and secret."); const destinations = [ ...secretFiles.map((binding) => binding.path), ...statePaths, ]; if (new Set(destinations).size !== destinations.length) fail("Secret/state destinations must be distinct."); if ( destinations.some((path, index) => destinations.some( (other, otherIndex) => index !== otherIndex && path.startsWith(`${other}/`), ), ) ) fail("Secret and state paths must not contain one another."); if (JSON.stringify(output).length > 64 * 1024) fail("Manifest is too large."); return output; } export function environmentRequiredSecrets( manifest: EnvironmentManifest, ): string[] { return [ ...new Set([ ...Object.values(manifest.secretEnv), ...manifest.secretFiles.map((item) => item.secret), ]), ].sort(); } export function environmentPrefix(revision: string): string { if (!/^[a-f0-9]{64}$/.test(revision)) fail("Invalid environment revision."); return `/opt/dench/environments/${revision}`; }