Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions scripts/lib/selfhost-deploy-common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,14 @@ env_put() {
local key="$1"
local value="$2"
local file="${3:-$ENV_FILE}"
local dir base tmp
local dir base tmp mode

touch "$file"
# Preserve the target file's mode across the atomic rename below. mktemp creates $tmp at 0600, so a bare
# `mv "$tmp" "$file"` would silently narrow $file's permissions to 0600 on every write (#7766). Capture the
# existing mode first and re-apply it to $tmp before the swap. GNU stat with a BSD `stat -f` fallback,
# matching backup-metrics.sh's own stat-portability idiom.
mode="$(stat -c '%a' "$file" 2>/dev/null || stat -f '%Lp' "$file")"
dir="$(dirname "$file")"
base="$(basename "$file")"
tmp="$(mktemp "$dir/.${base}.tmp.XXXXXX")"
Expand All @@ -75,8 +80,11 @@ env_put() {
}
}
' "$file" >"$tmp"
cat "$tmp" >"$file"
rm -f "$tmp"
# Atomic swap: a rename can't leave $file truncated/corrupted if the process is killed mid-write, unlike the
# previous `cat "$tmp" >"$file"` truncate-then-copy the same-directory temp file was always meant to enable
# (#7766). chmod first so the rename preserves the target's original mode (see the stat above).
chmod "$mode" "$tmp"
mv "$tmp" "$file"
}

# Optional Infisical wrapper (#5120): when SELFHOST_USE_INFISICAL=1 (opt-in, off by default), prefixes the
Expand Down
62 changes: 61 additions & 1 deletion test/unit/selfhost-deploy-common.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
Expand Down Expand Up @@ -115,3 +115,63 @@ describe("maybe_infisical_run (#5120)", () => {
}
});
});

describe("env_put (#7766 -- atomic write + mode preservation)", () => {
// Source the lib and invoke env_put directly with (key, value, file) positional args.
function runEnvPut(file: string, key: string, value: string) {
const script = `set -euo pipefail; . "${libPath.replace(/\\/g, "/")}"; env_put "$1" "$2" "$3"`;
return spawnSync("bash", ["-c", script, "bash", key, value, file], { encoding: "utf8" });
}

function tempEnvFile(contents: string): { dir: string; file: string } {
const dir = mkdtempSync(join(tmpdir(), "loopover-env-put-"));
const file = join(dir, ".env");
writeFileSync(file, contents);
return { dir, file };
}

it("updates an existing key in place, leaving the rest of the file intact", () => {
const { dir, file } = tempEnvFile("FOO=1\nBAR=old\n");
try {
const r = runEnvPut(file, "BAR", "new");
expect(r.status, r.stderr).toBe(0);
expect(readFileSync(file, "utf8")).toBe("FOO=1\nBAR=new\n");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("appends a key that is not present yet", () => {
const { dir, file } = tempEnvFile("FOO=1\n");
try {
const r = runEnvPut(file, "BAZ", "added");
expect(r.status, r.stderr).toBe(0);
expect(readFileSync(file, "utf8")).toBe("FOO=1\nBAZ=added\n");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("preserves the target file's non-default mode across the write (does not narrow to mktemp's 0600)", () => {
const { dir, file } = tempEnvFile("FOO=1\n");
try {
chmodSync(file, 0o640);
const r = runEnvPut(file, "FOO", "2");
expect(r.status, r.stderr).toBe(0);
expect(statSync(file).mode & 0o777).toBe(0o640);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("leaves no leftover temp file behind (an atomic rename, not a copy)", () => {
const { dir, file } = tempEnvFile("FOO=1\n");
try {
const r = runEnvPut(file, "FOO", "2");
expect(r.status, r.stderr).toBe(0);
expect(readdirSync(dir).filter((name) => name.includes(".tmp."))).toEqual([]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});