fix: harden local installation guide
This commit is contained in:
@@ -4,7 +4,9 @@ set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
output="$(mktemp "${TMPDIR:-/tmp}/thoth-install-docs-test.XXXXXX")"
|
||||
trap 'rm -f "$output"' EXIT HUP INT TERM
|
||||
verifier_functions="$(mktemp "${TMPDIR:-/tmp}/thoth-install-docs-functions.XXXXXX")"
|
||||
negative_root="$(mktemp -d "${TMPDIR:-/tmp}/thoth-install-docs-negative.XXXXXX")"
|
||||
trap 'rm -f "$output" "$verifier_functions"; rm -rf "$negative_root"' EXIT HUP INT TERM
|
||||
|
||||
"$root/scripts/verify-workspace-install-docs.sh" --fixtures-only >"$output"
|
||||
|
||||
@@ -17,7 +19,8 @@ for fixture in \
|
||||
"server manual canonical base+override references" \
|
||||
"canonical local base+override fixture" \
|
||||
"canonical server base+override fixture" \
|
||||
"relative secret-source fixture rejected"; do
|
||||
"relative secret-source fixture rejected" \
|
||||
"CRLF recovery rewrites worktree bytes"; do
|
||||
grep -Fqx "$fixture passed" "$output" >/dev/null || {
|
||||
echo "missing fixture verification: $fixture" >&2
|
||||
cat "$output" >&2
|
||||
@@ -48,3 +51,87 @@ if rg -n 'local-compose\.workspace-registry|server-compose\.workspace-registry|c
|
||||
echo "installation manuals still document a bypassed Compose or copied connector override path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load only the verifier's function definitions so each deliberately unsafe guide can be checked
|
||||
# in isolation without invoking Docker-backed Compose fixtures.
|
||||
sed '/^case "\$mode" in/,$d' "$root/scripts/verify-workspace-install-docs.sh" >"$verifier_functions"
|
||||
# shellcheck source=/dev/null
|
||||
source "$verifier_functions"
|
||||
|
||||
negative_failures=0
|
||||
expect_guide_rejected() {
|
||||
local label="$1" validator="$2" source_guide="$3" relative_path="$4"
|
||||
local mutation="$5" expected_error="$6"
|
||||
local fixture_root="$negative_root/${label// /-}"
|
||||
local fixture_output="$fixture_root/output"
|
||||
mkdir -p "$fixture_root/$(dirname "$relative_path")"
|
||||
cp "$source_guide" "$fixture_root/$relative_path"
|
||||
node - "$fixture_root/$relative_path" "$mutation" <<'NODE'
|
||||
const fs = require("fs");
|
||||
const [path, mutation] = process.argv.slice(2);
|
||||
const original = fs.readFileSync(path, "utf8");
|
||||
let changed = original;
|
||||
switch (mutation) {
|
||||
case "durable-selector":
|
||||
changed = original.replaceAll("--source build", "--source stale-build");
|
||||
break;
|
||||
case "dangerous-volumes":
|
||||
changed = original.replace("Do **not** run `docker compose down --volumes`", "Run `docker compose down --volumes`");
|
||||
break;
|
||||
case "incomplete-powershell":
|
||||
changed = original.replaceAll("icacls.exe", "Write-Output");
|
||||
break;
|
||||
case "broken-crlf":
|
||||
changed = original.replaceAll("git checkout-index --all --force --prefix=", "git add --renormalize . # ");
|
||||
break;
|
||||
case "raw-pi":
|
||||
changed += "\n```sh\ndocker compose exec core pi --version\n```\n";
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown negative-fixture mutation: ${mutation}`);
|
||||
}
|
||||
if (changed === original) throw new Error(`negative-fixture mutation made no change: ${mutation}`);
|
||||
fs.writeFileSync(path, changed);
|
||||
NODE
|
||||
set +e
|
||||
(root="$fixture_root"; set -e; "$validator") >"$fixture_output" 2>&1
|
||||
local status=$?
|
||||
set -e
|
||||
if [[ $status -eq 0 ]]; then
|
||||
echo "negative fixture accepted: $label" >&2
|
||||
cat "$fixture_output" >&2
|
||||
negative_failures=$((negative_failures + 1))
|
||||
elif ! grep -Fq -- "$expected_error" "$fixture_output"; then
|
||||
echo "negative fixture failed for the wrong reason: $label" >&2
|
||||
cat "$fixture_output" >&2
|
||||
negative_failures=$((negative_failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
expect_guide_rejected \
|
||||
"durable selector keeps old core" verify_local_guide \
|
||||
"$root/docs/install/local.md" docs/install/local.md durable-selector \
|
||||
"installation-aware source update lacks structural token: --source build"
|
||||
expect_guide_rejected \
|
||||
"dangerous down volumes instruction" verify_local_guide \
|
||||
"$root/docs/install/local.md" docs/install/local.md dangerous-volumes \
|
||||
"docker compose down --volumes must appear only in an explicit prose prohibition"
|
||||
expect_guide_rejected \
|
||||
"incomplete native PowerShell path" verify_local_guide \
|
||||
"$root/docs/install/local.md" docs/install/local.md incomplete-powershell \
|
||||
"native PowerShell setup lacks structural token: icacls.exe"
|
||||
expect_guide_rejected \
|
||||
"renormalize leaves CRLF worktree bytes" verify_windows_line_endings_guide \
|
||||
"$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md broken-crlf \
|
||||
"Windows line-ending guide lacks required instruction: git checkout-index --all --force"
|
||||
expect_guide_rejected \
|
||||
"raw non-installation-aware Pi access" verify_pi_management_guide \
|
||||
"$root/docs/install/pi-management.md" docs/install/pi-management.md raw-pi \
|
||||
"raw non-installation-aware Compose Pi access is forbidden"
|
||||
|
||||
if (( negative_failures != 0 )); then
|
||||
echo "$negative_failures unsafe installation-document fixtures were accepted" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "unsafe installation-document fixtures rejected passed"
|
||||
|
||||
@@ -100,6 +100,82 @@ verify_local_guide() {
|
||||
"http://127.0.0.1:8080" \
|
||||
"git pull --ff-only" \
|
||||
"docker compose down --volumes"
|
||||
node - "$guide" <<'NODE'
|
||||
const fs = require("fs");
|
||||
const source = fs.readFileSync(process.argv[2], "utf8");
|
||||
|
||||
function section(name) {
|
||||
const marker = `## ${name}`;
|
||||
const start = source.indexOf(marker);
|
||||
if (start < 0) throw new Error(`missing section: ${name}`);
|
||||
const next = source.indexOf("\n## ", start + marker.length);
|
||||
return source.slice(start, next < 0 ? source.length : next);
|
||||
}
|
||||
|
||||
function blocks(name, language) {
|
||||
const expression = new RegExp("```" + language + "\\n([\\s\\S]*?)```", "g");
|
||||
return [...section(name).matchAll(expression)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
function requireTokens(label, text, tokens) {
|
||||
for (const token of tokens) {
|
||||
if (!text.includes(token)) throw new Error(`${label} lacks structural token: ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
let inCodeFence = false;
|
||||
for (const line of source.split(/\n/)) {
|
||||
if (line.trimStart().startsWith("```")) {
|
||||
inCodeFence = !inCodeFence;
|
||||
continue;
|
||||
}
|
||||
if (!line.includes("docker compose down --volumes")) continue;
|
||||
const normalized = line.toLowerCase().replaceAll("*", "");
|
||||
if (inCodeFence || !/(do not|never)/.test(normalized) || /^\s*(docker|&?\s*docker)/.test(normalized)) {
|
||||
throw new Error("docker compose down --volumes must appear only in an explicit prose prohibition");
|
||||
}
|
||||
}
|
||||
|
||||
const setupPowerShell = blocks("Create the local operator files", "powershell").join("\n");
|
||||
requireTokens("native PowerShell setup", setupPowerShell, [
|
||||
"Copy-Item", "New-Item", "icacls.exe", "/inheritance:r", "/grant:r",
|
||||
"WindowsIdentity", "deploy/env/local.env.example", "thothii-installation.yaml",
|
||||
]);
|
||||
|
||||
const healthPowerShell = blocks("Start and verify", "powershell").join("\n");
|
||||
requireTokens("native PowerShell health", healthPowerShell, [
|
||||
"curl.exe --fail", "http://127.0.0.1:8080/health", "http://127.0.0.1:8787/health",
|
||||
"pi doctor", "pi test",
|
||||
]);
|
||||
|
||||
const updateShell = blocks("Update an installation", "sh").join("\n");
|
||||
requireTokens("installation-aware source update", updateShell, [
|
||||
"NEXT_PI_VERSION", "RUNNING_PI_VERSION", "--source build", "git rev-parse HEAD",
|
||||
"pi status", "status", "doctor", "curl --fail",
|
||||
]);
|
||||
if (!updateShell.includes("NEXT_PI_VERSION\" == \"$RUNNING_PI_VERSION") ||
|
||||
!updateShell.includes("exit 1")) {
|
||||
throw new Error("source update must fail closed when thothctl would no-op on the current Pi version");
|
||||
}
|
||||
|
||||
const updatePowerShell = blocks("Update an installation", "powershell").join("\n");
|
||||
requireTokens("native PowerShell source update", updatePowerShell, [
|
||||
"$NextPiVersion", "$RunningPiVersion", "--source build", "git rev-parse HEAD",
|
||||
"pi status", "status", "doctor", "curl.exe --fail", "throw",
|
||||
]);
|
||||
|
||||
const backupPowerShell = blocks("Back up and restore", "powershell").join("\n");
|
||||
requireTokens("native PowerShell backup/restore", backupPowerShell, [
|
||||
"$BackupDir", "$Volume", "-czf", "$TargetVolume", "$Archive", "Split-Path -Parent",
|
||||
"Split-Path -Leaf", "test -z", "-xzf",
|
||||
]);
|
||||
|
||||
for (const block of [setupPowerShell, healthPowerShell, updatePowerShell, backupPowerShell]) {
|
||||
if (/\$\((dirname|basename)\b|\bmkdir -p\b|\bchmod\s+[0-7]/.test(block)) {
|
||||
throw new Error("native PowerShell block contains a POSIX-only command sequence");
|
||||
}
|
||||
}
|
||||
NODE
|
||||
echo "local installation guide contract passed"
|
||||
}
|
||||
|
||||
@@ -118,22 +194,52 @@ verify_windows_line_endings_guide() {
|
||||
"git config --local core.autocrlf false" \
|
||||
"bash scripts/verify-line-endings.sh" \
|
||||
"git add --renormalize ." \
|
||||
"git checkout-index --all --force" \
|
||||
"git diff --cached --check" \
|
||||
"reclone"
|
||||
if grep -Fq 'git reset --hard' "$guide"; then
|
||||
node - "$guide" <<'NODE'
|
||||
node - "$guide" <<'NODE'
|
||||
const fs = require("fs");
|
||||
const lines = fs.readFileSync(process.argv[2], "utf8").split(/\n/);
|
||||
const commands = [
|
||||
"git add --renormalize .",
|
||||
"git checkout-index --all --force --prefix=",
|
||||
"bash scripts/verify-line-endings.sh",
|
||||
];
|
||||
let prior = -1;
|
||||
for (const command of commands) {
|
||||
const index = lines.findIndex((line, candidate) => candidate > prior && line.trim().includes(command));
|
||||
if (index < 0) throw new Error(`CRLF recovery lacks ordered command: ${command}`);
|
||||
prior = index;
|
||||
}
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (!lines[index].includes("git reset --hard")) continue;
|
||||
const command = lines[index].trim();
|
||||
if (command !== 'cp "$REPAIR_DIR/$path" "$path"' &&
|
||||
!command.startsWith("Copy-Item -LiteralPath") &&
|
||||
command !== "git reset --hard") continue;
|
||||
const warning = lines.slice(Math.max(0, index - 4), index).join(" ").toLowerCase();
|
||||
if (!warning.includes("warning") || !warning.includes("destructive") ||
|
||||
!warning.includes("backup") || !warning.includes("commit")) {
|
||||
throw new Error("git reset --hard lacks an immediate destructive warning requiring backup/commit");
|
||||
throw new Error("worktree rewrite lacks an immediate destructive warning requiring backup/commit");
|
||||
}
|
||||
}
|
||||
NODE
|
||||
fi
|
||||
local fixture
|
||||
fixture="$(mktemp -d "${TMPDIR%/}/thoth-crlf-repair.XXXXXX")"
|
||||
trap 'rm -rf "$fixture"' RETURN
|
||||
git -C "$fixture" init -q
|
||||
printf '*.sh text eol=lf\n' >"$fixture/.gitattributes"
|
||||
printf '#!/bin/sh\r\nexit 0\r\n' >"$fixture/repair.sh"
|
||||
git -C "$fixture" add .gitattributes repair.sh 2>/dev/null
|
||||
git -C "$fixture" config --local core.autocrlf false
|
||||
git -C "$fixture" add --renormalize .
|
||||
local export_dir="$fixture-export"
|
||||
mkdir -p "$export_dir"
|
||||
git -C "$fixture" checkout-index --all --force --prefix="$export_dir/"
|
||||
"$root/scripts/verify-line-endings.sh" "$export_dir"
|
||||
cp "$export_dir/repair.sh" "$fixture/repair.sh"
|
||||
"$root/scripts/verify-line-endings.sh" "$fixture"
|
||||
rm -rf "$export_dir"
|
||||
echo "CRLF recovery rewrites worktree bytes passed"
|
||||
echo "Windows line-ending recovery guide contract passed"
|
||||
}
|
||||
|
||||
@@ -163,9 +269,25 @@ verify_pi_management_guide() {
|
||||
"pi maintenance recover --yes" \
|
||||
"pi logs" \
|
||||
"/run/secrets" \
|
||||
"docker compose exec core pi" \
|
||||
"Raw Compose access is unsupported" \
|
||||
"no browser shell" \
|
||||
"does not mount the Docker socket"
|
||||
node - "$guide" <<'NODE'
|
||||
const fs = require("fs");
|
||||
const source = fs.readFileSync(process.argv[2], "utf8");
|
||||
if (/docker\s+compose(?:.|\n){0,160}\bexec\b(?:.|\n){0,80}\bcore\b(?:.|\n){0,80}\bpi\b/i.test(source)) {
|
||||
throw new Error("raw non-installation-aware Compose Pi access is forbidden");
|
||||
}
|
||||
const marker = "## Direct support access";
|
||||
const start = source.indexOf(marker);
|
||||
const support = start < 0 ? "" : source.slice(start, source.indexOf("\n## ", start + marker.length) < 0
|
||||
? source.length : source.indexOf("\n## ", start + marker.length));
|
||||
for (const token of ["unsupported", "thothctl", "pi status", "pi doctor", "pi test", "pi logs"]) {
|
||||
if (!support.toLowerCase().includes(token.toLowerCase())) {
|
||||
throw new Error(`direct support section lacks installation-aware diagnostic: ${token}`);
|
||||
}
|
||||
}
|
||||
NODE
|
||||
echo "Pi management guide contract passed"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user