fix(deploy): make portal installer runtime portable

This commit is contained in:
DeCentN2Madness authored and AIAMarketing committed 2026-11-05 00:40:18 -07:00
1 parent 4987f5ee1a
commit 36d417a422
8 files changed
+272 -132

No files matched your search

+4 -3
View File
@@ -616,9 +616,10 @@ for those explicit builds. No actual submissions run in automated tests.
The Contabo deployment interface is intentionally separate from NixOS changes.
Use [the release runbook](integrations/nixos/deploy.md) for `npm run deploy`,
dry-run, rollback, the encrypted production portal input and the separate
one-time employee-resource installer. Ordinary releases never copy employee
documents into `dist`; protected staff and management resources remain under
server-controlled Basic Auth namespaces. The accepted interim portal is
one-time employee-resource installer. The server-side installer is a root-only
POSIX shell script using NixOS base tools; it does not require Node.js on
Contabo. Ordinary releases never copy employee documents into `dist`;
protected staff and management resources remain under server-controlled Basic Auth namespaces. The accepted interim portal is
explicitly non-PHI.
### Google Ads consultation-form conversion handoff
+6 -4
View File
@@ -61,7 +61,7 @@ npm run deploy:rollback -- FULL_APPROVED_SHA --confirm
## Employee resources: separate one-time operation
Approved non-PHI employee files are never part of an ordinary release. After
the website release that contains `portal-resource-install.mjs` is promoted and
the website release that contains `portal-resource-install.sh` is promoted and
the VPS SOPS-backed Basic Auth material is independently ready:
```sh
@@ -72,9 +72,11 @@ AIA_DEPLOY_SSH_IDENTITY="$HOME/.ssh/AIAMarketing" \
npm run deploy:portal-resources -- --confirm
```
The installer reads only the private collection manifest, accepts approved
`STAFF-INTERNAL` and `MANAGEMENT-INTERNAL` mappings to `/staff-resources/` and
`/management-resources/`, verifies every local hash, rejects links/traversal and
The installer invokes the promoted root-only `portal-resource-install.sh` through
NixOS's base `/bin/sh` and coreutils path; it does not depend on a globally
installed Node.js runtime. It reads only the private collection manifest,
accepts approved `STAFF-INTERNAL` and `MANAGEMENT-INTERNAL` mappings to
`/staff-resources/` and `/management-resources/`, verifies every local hash, rejects links/traversal and
uploads a root-controlled temporary tree. It never uses `--delete` for resource
directories and refuses to replace a different existing file. The VPS installs
regular files as `root:nginx` mode `0640` and reports metadata only.
+13 -4
View File
@@ -56,6 +56,14 @@ export async function prepareResourcePlan(privateRoot, staging, sourceCommit) {
if (!files.length) fail('No deployment-approved internal resources are present; reconcile the private manifest first');
return { schema: 1, sourceCommit, files: files.sort((a, b) => `${a.role}/${a.name}`.localeCompare(`${b.role}/${b.name}`)) };
}
export function serializeResourcePlan(plan) {
if (plan?.schema !== 1 || !fullSha.test(plan.sourceCommit ?? '') || !Array.isArray(plan.files)) fail('Invalid resource installation plan');
const records = plan.files.map((entry) => {
if (!['staff', 'management'].includes(entry.role) || !safeName(entry.name) || !safeName(entry.stageName) || !/^[0-9a-f]{64}$/.test(entry.sha256 ?? '') || !Number.isInteger(entry.size) || entry.size <= 0) fail('Invalid resource installation plan entry');
return ['file', entry.role, entry.name, entry.stageName, entry.sha256, String(entry.size)].join('\t');
});
return `AIA_PORTAL_RESOURCE_PLAN_V1\nsourceCommit=${plan.sourceCommit}\n${records.join('\n')}\n`;
}
function parse(argv) {
const valid = new Set(['--dry-run','--confirm']);
if (!argv.length || argv.some((arg) => !valid.has(arg)) || (argv.includes('--dry-run') && argv.includes('--confirm'))) fail('Usage: npm run deploy:portal-resources -- --dry-run | npm run deploy:portal-resources -- --confirm');
@@ -69,14 +77,15 @@ async function main() {
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-portal-resources-'));
try {
const staging = path.join(temporary, `portal-resources-${sha}`);
await Promise.all(['staff', 'management'].map((role) => fs.mkdir(path.join(staging, role), { recursive: true, mode: 0o700 })));
const plan = await prepareResourcePlan(privateRoot, staging, sha);
const planFile = path.join(temporary, `aia-portal-resources-${sha}.json`);
await fs.writeFile(planFile, JSON.stringify(plan, null, 2) + '\n', { mode: 0o600 });
const planFile = path.join(temporary, `aia-portal-resources-${sha}.tsv`);
await fs.writeFile(planFile, serializeResourcePlan(plan), { mode: 0o600 });
if (options.dryRun) { console.log(JSON.stringify({ dryRun: true, sourceCommit: sha, resources: plan.files.map(({ role, name, sha256, size }) => ({ role, name, sha256, size })) }, null, 2)); return; }
if (!options.confirm) fail('Refusing employee-file transfer without --confirm');
const host = process.env.AIA_DEPLOY_HOST, identity = process.env.AIA_DEPLOY_SSH_IDENTITY;
const [ssh, ...sshOptions] = sshTarget(host, identity);
const remoteStage = `${root}/staging/portal-resources-${sha}`, remotePlan = `/root/aia-portal-resources-${sha}.json`;
const remoteStage = `${root}/staging/portal-resources-${sha}`, remotePlan = `/root/aia-portal-resources-${sha}.tsv`;
const transport = `${ssh} ${sshOptions.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ')}`;
run(ssh, [...sshOptions, host, `install -d -m 0700 ${remoteStage}/staff ${remoteStage}/management`]);
for (const role of ['staff','management']) {
@@ -85,7 +94,7 @@ async function main() {
run('rsync', args);
}
run('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, planFile, `${host}:${remotePlan}`]);
run(ssh, [...sshOptions, host, `node ${root}/www/tools/portal-resource-install.mjs ${remoteStage} ${remotePlan}`]);
run(ssh, [...sshOptions, host, `env -i PATH=/run/current-system/sw/bin:/usr/bin:/bin HOME=/root /bin/sh ${root}/www/tools/portal-resource-install.sh ${remoteStage} ${remotePlan}`]);
console.log(JSON.stringify({ installed: true, sourceCommit: sha, resources: plan.files.length }, null, 2));
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
}
+64 -13
View File
@@ -3,25 +3,76 @@ import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
import { prepareResourcePlan } from './deploy-portal-resources.mjs';
import { prepareResourcePlan, serializeResourcePlan } from './deploy-portal-resources.mjs';
const digest = (value) => createHash('sha256').update(value).digest('hex');
const sha = 'a'.repeat(40);
const scriptSource = path.join(path.dirname(fileURLToPath(import.meta.url)), 'portal-resource-install.sh');
async function fixture(prefix = 'aia-resource-plan-') {
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
const privateRoot = path.join(root, 'private');
const hubfs = path.join(privateRoot, 'hubfs');
const stage = path.join(root, 'staging', `portal-resources-${sha}`);
await fs.mkdir(hubfs, { recursive: true });
await fs.mkdir(path.join(root, 'secure-resources', 'staff'), { recursive: true });
await fs.mkdir(path.join(root, 'secure-resources', 'management'), { recursive: true });
return { root, privateRoot, hubfs, stage };
}
async function writeApprovedResource(privateRoot, hubfs, bytes = Buffer.from('synthetic non-phi document')) {
const localFile = 'a'.repeat(64);
await fs.writeFile(path.join(hubfs, localFile), bytes, { mode: 0o600 });
await fs.writeFile(path.join(privateRoot, 'manifest.json'), JSON.stringify({ resources: [{
classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/guide.pdf', destinationImplemented: true, destinationVerified: true, destinationAccessControlled: true, rewriteApproved: true, localFile, sha256: digest(bytes), size: bytes.length,
}] }));
return { localFile, bytes };
}
test('resource plan copies only hash-verified approved protected resources', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-resource-plan-'));
const { root, privateRoot, hubfs, stage } = await fixture();
try {
const hubfs = path.join(root, 'hubfs'), stage = path.join(root, 'stage');
await fs.mkdir(hubfs, { recursive: true });
const bytes = Buffer.from('synthetic non-phi document');
const localFile = 'a'.repeat(64);
await fs.writeFile(path.join(hubfs, localFile), bytes, { mode: 0o600 });
await fs.writeFile(path.join(root, 'manifest.json'), JSON.stringify({ resources: [{
classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/guide.pdf', destinationImplemented: true, destinationVerified: true, destinationAccessControlled: true, rewriteApproved: true, localFile, sha256: digest(bytes), size: bytes.length,
}] }));
const plan = await prepareResourcePlan(root, stage, 'a'.repeat(40));
const { localFile, bytes } = await writeApprovedResource(privateRoot, hubfs);
const plan = await prepareResourcePlan(privateRoot, stage, sha);
assert.equal(plan.files.length, 1);
assert.equal((await fs.readFile(path.join(stage, 'staff', localFile))).toString(), bytes.toString());
await fs.writeFile(path.join(root, 'manifest.json'), JSON.stringify({ resources: [{ classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/../bad', localFile, sha256: digest(bytes), size: bytes.length }] }));
await assert.rejects(prepareResourcePlan(root, path.join(root, 'second'), 'a'.repeat(40)), /destination/);
assert.match(serializeResourcePlan(plan), /^AIA_PORTAL_RESOURCE_PLAN_V1\nsourceCommit=a{40}\nfile\tstaff\tguide\.pdf\t/);
await fs.writeFile(path.join(privateRoot, 'manifest.json'), JSON.stringify({ resources: [{ classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/../bad', localFile, sha256: digest(bytes), size: bytes.length }] }));
await assert.rejects(prepareResourcePlan(privateRoot, path.join(root, 'second'), sha), /destination/);
} finally { await fs.rm(root, { recursive: true, force: true }); }
});
test('portable shell installer executes the reviewed staged-plan interface with synthetic files', async () => {
const { root, privateRoot, hubfs, stage } = await fixture('aia-portal-install-');
try {
const { bytes } = await writeApprovedResource(privateRoot, hubfs);
const plan = await prepareResourcePlan(privateRoot, stage, sha);
await fs.mkdir(path.join(stage, 'management'), { recursive: true });
const planFile = path.join(root, 'approved-plan.tsv');
await fs.writeFile(planFile, serializeResourcePlan(plan), { mode: 0o600 });
const fakeBin = path.join(root, 'fake-bin');
const log = path.join(root, 'ownership.log');
await fs.mkdir(fakeBin);
await fs.writeFile(path.join(fakeBin, 'id'), '#!/bin/sh\n[ "$1" = -u ] && { echo 0; exit 0; }\nexec /usr/bin/id "$@"\n', { mode: 0o755 });
await fs.writeFile(path.join(fakeBin, 'getent'), '#!/bin/sh\n[ "$1" = group ] && [ "$2" = nginx ] && { echo "nginx:x:999:"; exit 0; }\nexec /run/current-system/sw/bin/getent "$@"\n', { mode: 0o755 });
await fs.writeFile(path.join(fakeBin, 'install'), `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(log)}\nwhile [ "$#" -gt 0 ]; do\n case "$1" in -o|-g|-m) shift 2 ;; *) break ;; esac\ndone\nexec /usr/bin/install -m 0640 "$@"\n`, { mode: 0o755 });
const script = path.join(root, 'portal-resource-install.sh');
const source = await fs.readFile(scriptSource, 'utf8');
assert.ok(!source.includes('node '));
await fs.writeFile(script, source.replace('ROOT=/srv/www/azinstitute4autism.com', `ROOT=${root}`), { mode: 0o755 });
const result = spawnSync('/bin/sh', [script, stage, planFile], { encoding: 'utf8', env: { ...process.env, PATH: process.env.PATH, AIA_PORTAL_INSTALL_TEST_PATH: fakeBin } });
assert.equal(result.status, 0, result.stderr);
assert.equal((await fs.readFile(path.join(root, 'secure-resources/staff/guide.pdf'))).toString(), bytes.toString());
assert.match(await fs.readFile(log, 'utf8'), /-o root -g nginx -m 0640/);
const nonRoot = spawnSync('/bin/sh', [script, stage, planFile], { encoding: 'utf8', env: { ...process.env, PATH: process.env.PATH } });
assert.notEqual(nonRoot.status, 0);
assert.match(nonRoot.stderr, /requires root/);
await fs.writeFile(path.join(stage, 'staff', 'unexpected.pdf'), 'unexpected');
const rejected = spawnSync('/bin/sh', [script, stage, planFile], { encoding: 'utf8', env: { ...process.env, PATH: process.env.PATH, AIA_PORTAL_INSTALL_TEST_PATH: fakeBin } });
assert.notEqual(rejected.status, 0);
assert.match(rejected.stderr, /Unexpected staged resource/);
} finally { await fs.rm(root, { recursive: true, force: true }); }
});
+25 -16
View File
@@ -1,30 +1,39 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
import { releaseManifest, rsyncArguments, sshTarget } from './deploy.mjs';
test('site manifest follows the Contabo schema and detects unsafe public output', async () => {
const fixturePath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', 'contabo-release-manifest-v1.json');
const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8'));
async function writeContractArtifact(root) {
for (const [relative, contents] of Object.entries(fixture.input.files)) {
const target = path.join(root, relative);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, contents);
}
}
test('site manifest follows the pinned merged-Contabo release contract and detects unsafe public output', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-manifest-'));
try {
await fs.mkdir(path.join(root, '_astro'));
await fs.writeFile(path.join(root, 'index.html'), '<title>Synthetic</title>');
await fs.writeFile(path.join(root, '_astro', 'entry.abcdefgh.js'), 'export {}');
const manifest = await releaseManifest(root, 'a'.repeat(40));
assert.deepEqual(Object.keys(manifest).sort(), ['directories','files','immutableAssets','schema','sourceCommit','treeSha256']);
assert.equal(manifest.schema, 1);
assert.equal(manifest.immutableAssets[0], '_astro/entry.abcdefgh.js');
const expected = createHash('sha256').update(Object.entries(manifest.files).sort(([a], [b]) => a.localeCompare(b)).map(([name, meta]) => `${name}\0${meta.sha256}\n`).join('')).digest('hex');
assert.equal(manifest.treeSha256, expected);
const serverTool = path.resolve(process.cwd(), '../../contabo/scripts/website-release.py');
const parity = spawnSync('python3', [serverTool, 'manifest', root, 'a'.repeat(40)], { encoding: 'utf8' });
assert.equal(parity.status, 0, parity.stderr);
assert.deepEqual(manifest, JSON.parse(parity.stdout));
await writeContractArtifact(root);
const manifest = await releaseManifest(root, fixture.input.sourceCommit);
assert.deepEqual(manifest, fixture.expected);
const serverTool = process.env.AIA_CONTABO_RELEASE_TOOL;
if (serverTool) {
const parity = spawnSync('python3', [serverTool, 'manifest', root, fixture.input.sourceCommit], { encoding: 'utf8' });
assert.equal(parity.status, 0, `Configured Contabo release tool failed: ${parity.stderr}`);
assert.deepEqual(manifest, JSON.parse(parity.stdout));
} else {
t.diagnostic(`Live Contabo-tool parity was not run; this test validates the checked-in schema fixture generated from ${fixture.contaboCommit}. Set AIA_CONTABO_RELEASE_TOOL to the merged website-release.py path for direct parity.`);
}
await fs.mkdir(path.join(root, 'private'));
await assert.rejects(releaseManifest(root, 'a'.repeat(40)), /Unexpected private artifact/);
await assert.rejects(releaseManifest(root, fixture.input.sourceCommit), /Unexpected private artifact/);
} finally { await fs.rm(root, { recursive: true, force: true }); }
});
@@ -0,0 +1,32 @@
{
"contract": "AIAMarketing/contabo-vps website-release.py manifest schema",
"contaboCommit": "a421a7b1049244baefa8a6e52efcf97ad2dfa89f",
"input": {
"sourceCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"files": {
"index.html": "<title>Synthetic</title>",
"_astro/entry.abcdefgh.js": "export {}"
}
},
"expected": {
"directories": [
"_astro"
],
"files": {
"_astro/entry.abcdefgh.js": {
"sha256": "f4c5cf9bb78e85f15dc27180260637cf24b2a24bc39e0788783a3accc4dde614",
"size": 9
},
"index.html": {
"sha256": "3a958d616a9a1f0d9229cff90395efb5155029a7f31d018c198c0c7d4cf28989",
"size": 24
}
},
"immutableAssets": [
"_astro/entry.abcdefgh.js"
],
"schema": 1,
"sourceCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"treeSha256": "4ca2127ce195f1975cbe85fdea800fb3177972afa3a1692794ba403939288694"
}
}
-92
View File
@@ -1,92 +0,0 @@
/** Root-only Contabo-side installer for a reviewed employee-resource plan. */
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const root = '/srv/www/azinstitute4autism.com';
const roles = new Set(['staff', 'management']);
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
const safeName = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(value) && value !== '.' && value !== '..';
function fail(message) { throw new Error(message); }
function nginxGroup() {
const result = spawnSync('getent', ['group', 'nginx'], { encoding: 'utf8' });
const gid = Number(result.stdout?.trim().split(':')[2]);
if (!Number.isInteger(gid)) fail('nginx group is unavailable');
return gid;
}
async function readPlan(file) {
const stat = await fs.lstat(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 2_000_000) fail('Resource plan must be a bounded regular file');
const plan = JSON.parse(await fs.readFile(file, 'utf8'));
if (plan.schema !== 1 || !Array.isArray(plan.files) || !/^[0-9a-f]{40}$/.test(plan.sourceCommit ?? '')) fail('Invalid resource installation plan');
const destinations = new Set();
for (const entry of plan.files) {
if (!roles.has(entry.role) || !safeName(entry.name) || !safeName(entry.stageName) || !/^[0-9a-f]{64}$/.test(entry.sha256 ?? '') || !Number.isInteger(entry.size) || entry.size <= 0 || entry.size > 50 * 1024 * 1024) fail('Invalid resource plan entry');
const key = `${entry.role}/${entry.name}`;
if (destinations.has(key)) fail('Duplicate resource destination');
destinations.add(key);
}
return plan;
}
async function verifyStage(stage, plan) {
const expected = new Set(plan.files.map((entry) => `${entry.role}/${entry.stageName}`));
const actual = new Set();
for (const role of roles) {
const directory = path.join(stage, role);
const stat = await fs.lstat(directory).catch(() => null);
if (!stat || !stat.isDirectory() || stat.isSymbolicLink()) fail(`Missing safe ${role} staging directory`);
for (const name of await fs.readdir(directory)) {
const file = path.join(directory, name), relative = `${role}/${name}`, meta = await fs.lstat(file);
if (!meta.isFile() || meta.isSymbolicLink() || meta.nlink !== 1) fail(`Unsafe staged resource: ${relative}`);
actual.add(relative);
}
}
if (actual.size !== expected.size || [...actual].some((value) => !expected.has(value))) fail('Staged resources do not exactly match the reviewed plan');
for (const entry of plan.files) {
const source = path.join(stage, entry.role, entry.stageName);
const bytes = await fs.readFile(source);
if (bytes.length !== entry.size || sha256(bytes) !== entry.sha256) fail(`Staged resource digest mismatch: ${entry.role}/${entry.name}`);
}
}
async function install(stage, plan) {
if (process.getuid?.() !== 0) fail('Resource installation requires root');
const expectedStage = path.join(root, 'staging', `portal-resources-${plan.sourceCommit}`);
if (path.resolve(stage) !== expectedStage) fail('Resource stage is outside the approved root-controlled staging directory');
await verifyStage(stage, plan);
const gid = nginxGroup();
const installed = [];
for (const entry of plan.files) {
const source = path.join(stage, entry.role, entry.stageName);
const destinationDirectory = path.join(root, 'secure-resources', entry.role);
const destination = path.join(destinationDirectory, entry.name);
const existing = await fs.lstat(destination).catch(() => null);
if (existing) {
if (!existing.isFile() || existing.isSymbolicLink()) fail(`Unsafe existing resource destination: ${entry.role}/${entry.name}`);
const bytes = await fs.readFile(destination);
if (bytes.length !== entry.size || sha256(bytes) !== entry.sha256) fail(`Refusing to replace existing resource: ${entry.role}/${entry.name}`);
installed.push({ role: entry.role, name: entry.name, result: 'already-verified' });
continue;
}
const temporary = path.join(destinationDirectory, `.${entry.name}.next-${process.pid}`);
await fs.copyFile(source, temporary, fs.constants.COPYFILE_EXCL);
await fs.chown(temporary, 0, gid);
await fs.chmod(temporary, 0o640);
await fs.rename(temporary, destination);
installed.push({ role: entry.role, name: entry.name, result: 'installed' });
}
return installed;
}
if (process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname) {
const [stage, manifest] = process.argv.slice(2);
(async () => {
if (!stage || !manifest || process.argv.length !== 4) fail('Usage: portal-resource-install.mjs STAGING_DIRECTORY REVIEWED_PLAN.json');
const plan = await readPlan(manifest);
const installed = await install(stage, plan);
console.log(JSON.stringify({ sourceCommit: plan.sourceCommit, installed: installed.length, identifiers: installed }, null, 2));
})().catch((error) => { console.error(`Employee resource installation stopped: ${error.message}`); process.exitCode = 1; });
}
export { readPlan, verifyStage, install };
+128
View File
@@ -0,0 +1,128 @@
#!/bin/sh
# Root-only Contabo-side installer for a reviewed employee-resource plan.
# It deliberately uses the NixOS base shell/coreutils path, not Node.js.
set -eu
fail() { printf '%s\n' "Employee resource installation stopped: $*" >&2; exit 1; }
ROOT=/srv/www/azinstitute4autism.com
ROLES='staff management'
PATH=/run/current-system/sw/bin:/usr/bin:/bin
# The production SSH command uses env -i, so this hook is reachable only from
# the local synthetic-interface test. It never changes the deployed PATH.
case "${AIA_PORTAL_INSTALL_TEST_PATH:-}" in
'') ;;
*/aia-portal-install-*/fake-bin) PATH="$AIA_PORTAL_INSTALL_TEST_PATH:$PATH" ;;
*) fail 'Invalid synthetic installer test path' ;;
esac
export PATH
safe_name() {
value=$1
case "$value" in ''|.|..|.*|*[!A-Za-z0-9._-]*) return 1 ;; esac
[ "${#value}" -le 241 ]
}
valid_hex() {
value=$1 length=$2
[ "${#value}" -eq "$length" ] && printf '%s' "$value" | grep -Eq '^[0-9a-f]+$'
}
regular_file() { [ -f "$1" ] && [ ! -L "$1" ] && [ "$(stat -c %h "$1")" -eq 1 ]; }
digest() {
result=$(sha256sum "$1") || return 1
printf '%s\n' "${result%% *}"
}
cleanup() {
status=$?
[ -z "${temporary:-}" ] || rm -rf "$temporary"
exit "$status"
}
trap cleanup EXIT HUP INT TERM
[ "$(id -u)" -eq 0 ] || fail 'Resource installation requires root'
[ "$#" -eq 2 ] || fail 'Usage: portal-resource-install.sh STAGING_DIRECTORY REVIEWED_PLAN.tsv'
stage=$1
plan_file=$2
regular_file "$plan_file" || fail 'Resource plan must be a bounded regular file'
[ "$(stat -c %s "$plan_file")" -le 2000000 ] || fail 'Resource plan is too large'
IFS= read -r header < "$plan_file" || fail 'Resource plan is empty'
[ "$header" = 'AIA_PORTAL_RESOURCE_PLAN_V1' ] || fail 'Unsupported resource plan schema'
commit_line=$(sed -n '2p' "$plan_file")
[ -n "$commit_line" ] || fail 'Resource plan has no source commit'
case "$commit_line" in sourceCommit=*) source_commit=${commit_line#sourceCommit=} ;; *) fail 'Resource plan source commit is invalid' ;; esac
valid_hex "$source_commit" 40 || fail 'Resource plan source commit is invalid'
expected_stage="$ROOT/staging/portal-resources-$source_commit"
[ "$(readlink -f -- "$stage")" = "$expected_stage" ] || fail 'Resource stage is outside the approved root-controlled staging directory'
[ -d "$stage" ] && [ ! -L "$stage" ] || fail 'Resource stage is unsafe'
temporary=$(mktemp -d)
destinations="$temporary/destinations"
staged="$temporary/staged"
: > "$destinations"
: > "$staged"
sed -n '3,$p' "$plan_file" > "$temporary/records"
count=0
line=2
tab=$(printf '\t')
while IFS= read -r record || [ -n "$record" ]; do
line=$((line + 1))
[ -n "$record" ] || fail "Empty resource plan line $line"
IFS="$tab" read -r kind role name stage_name resource_hash size extra <<EOF_RECORD
$record
EOF_RECORD
[ "$kind" = file ] && [ -z "${extra:-}" ] || fail "Invalid resource plan line $line"
case "$role" in staff|management) ;; *) fail "Invalid resource role on line $line" ;; esac
safe_name "$name" && safe_name "$stage_name" || fail "Unsafe resource path on line $line"
valid_hex "$resource_hash" 64 || fail "Invalid resource digest on line $line"
case "$size" in ''|*[!0-9]*) fail "Invalid resource size on line $line" ;; esac
[ "$size" -gt 0 ] && [ "$size" -le 52428800 ] || fail "Invalid resource size on line $line"
destination="$role/$name"
staged_name="$role/$stage_name"
grep -Fqx -- "$destination" "$destinations" && fail "Duplicate resource destination"
grep -Fqx -- "$staged_name" "$staged" && fail "Duplicate staged resource"
printf '%s\n' "$destination" >> "$destinations"
printf '%s\n' "$staged_name" >> "$staged"
count=$((count + 1))
done < "$temporary/records"
[ "$count" -gt 0 ] || fail 'Resource plan has no files'
for role in $ROLES; do
directory="$stage/$role"
[ -d "$directory" ] && [ ! -L "$directory" ] || fail "Missing safe $role staging directory"
find "$directory" -mindepth 1 -print > "$temporary/actual-$role"
while IFS= read -r source; do
relative=${source#"$stage/"}
grep -Fqx -- "$relative" "$staged" || fail "Unexpected staged resource: $relative"
regular_file "$source" || fail "Unsafe staged resource: $relative"
done < "$temporary/actual-$role"
done
# Re-read only validated plan rows, verify every listed input, then install with
# root:nginx ownership and 0640 permissions. mv -n prevents replacement races.
installed=0
while IFS= read -r record || [ -n "$record" ]; do
IFS="$tab" read -r kind role name stage_name resource_hash size extra <<EOF_RECORD
$record
EOF_RECORD
source="$stage/$role/$stage_name"
regular_file "$source" || fail "Missing staged resource: $role/$name"
[ "$(stat -c %s "$source")" -eq "$size" ] && [ "$(digest "$source")" = "$resource_hash" ] || fail "Staged resource digest mismatch: $role/$name"
destination_directory="$ROOT/secure-resources/$role"
destination="$destination_directory/$name"
[ -d "$destination_directory" ] && [ ! -L "$destination_directory" ] || fail "Protected destination directory is unsafe: $role"
if [ -e "$destination" ] || [ -L "$destination" ]; then
regular_file "$destination" || fail "Unsafe existing resource destination: $role/$name"
[ "$(stat -c %s "$destination")" -eq "$size" ] && [ "$(digest "$destination")" = "$resource_hash" ] || fail "Refusing to replace existing resource: $role/$name"
printf '%s\n' "already-verified $role/$name"
installed=$((installed + 1))
continue
fi
next="$destination_directory/.${name}.next-$$"
[ ! -e "$next" ] && [ ! -L "$next" ] || fail "Temporary destination already exists: $role/$name"
install -o root -g nginx -m 0640 "$source" "$next" || fail "Unable to stage protected resource: $role/$name"
mv -n -- "$next" "$destination" || fail "Unable to install protected resource: $role/$name"
regular_file "$destination" && [ "$(stat -c %s "$destination")" -eq "$size" ] && [ "$(digest "$destination")" = "$resource_hash" ] || fail "Installed resource verification failed: $role/$name"
printf '%s\n' "installed $role/$name"
installed=$((installed + 1))
done < "$temporary/records"
printf '%s\n' "sourceCommit=$source_commit installed=$installed"