Repository navigation
private: encrypted dev archive with lock/unlock script #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| [test] | ||
| # Zed clones the whole repository here when the dev extension is installed. | ||
| pathIgnorePatterns = ["packages/zed/grammars/**"] | ||
| # Zed clones the whole repository into packages/zed/grammars when the dev extension is installed. | ||
| # private/dev holds unlocked material whose tests need their own setup. | ||
| pathIgnorePatterns = ["packages/zed/grammars/**", "private/**"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| # private | ||
|
|
||
| Development material that is not public. It lives here as one encrypted archive, `dev.tar.age`. | ||
|
|
||
| ```sh | ||
| bun scripts/private.ts unlock # decrypt dev.tar.age into private/dev/ | ||
| bun scripts/private.ts lock # encrypt private/dev/ back into dev.tar.age | ||
| ``` | ||
|
|
||
| `unlock` refuses to write into a non-empty `private/dev/`; pass `--force` to overwrite it. | ||
|
|
||
| `private/dev/` is ignored by git, so only the archive is committed. Run `lock` and commit `dev.tar.age` after changing anything under `private/dev/`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| import { afterEach, beforeEach, expect, test } from "bun:test"; | ||
| import { spawnSync } from "node:child_process"; | ||
| import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join, relative } from "node:path"; | ||
|
|
||
| const script = join(import.meta.dir, "private.ts"); | ||
| let scratch: string; | ||
| let identityFile: string; | ||
|
|
||
| beforeEach(() => { | ||
| scratch = mkdtempSync(join(tmpdir(), "lore-private-test-")); | ||
| identityFile = join(scratch, "identity.txt"); | ||
| const keygen = spawnSync("age-keygen", ["-o", identityFile], { encoding: "utf8" }); | ||
| if (keygen.status !== 0) throw new Error(`age-keygen failed: ${keygen.stderr}`); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| rmSync(scratch, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| function privateCli(root: string, ...args: string[]) { | ||
| return spawnSync("bun", [script, ...args], { | ||
| encoding: "utf8", | ||
| env: { ...process.env, LORE_PRIVATE_ROOT: root, LORE_PRIVATE_IDENTITY_FILE: identityFile }, | ||
| }); | ||
| } | ||
|
|
||
| function makeRoot(name: string): string { | ||
| const root = join(scratch, name); | ||
| mkdirSync(join(root, "private"), { recursive: true }); | ||
| return root; | ||
| } | ||
|
|
||
| function snapshot(dir: string): Record<string, string> { | ||
| const files: Record<string, string> = {}; | ||
| const walk = (current: string) => { | ||
| for (const entry of readdirSync(current)) { | ||
| const path = join(current, entry); | ||
| if (statSync(path).isDirectory()) walk(path); | ||
| else files[relative(dir, path)] = readFileSync(path, "utf8"); | ||
| } | ||
| }; | ||
| walk(dir); | ||
| return files; | ||
| } | ||
|
|
||
| test("lock then unlock into a fresh root restores the tree and skips .DS_Store and node_modules", () => { | ||
| const source = makeRoot("source"); | ||
| const dev = join(source, "private", "dev"); | ||
| mkdirSync(join(dev, "notes", "deep"), { recursive: true }); | ||
| mkdirSync(join(dev, "node_modules", "pkg"), { recursive: true }); | ||
| writeFileSync(join(dev, "top.md"), "top\n"); | ||
| writeFileSync(join(dev, "notes", "deep", "a.json"), "{\"a\":1}\n"); | ||
| writeFileSync(join(dev, ".DS_Store"), "junk"); | ||
| writeFileSync(join(dev, "node_modules", "pkg", "index.js"), "junk"); | ||
|
|
||
| const locked = privateCli(source, "lock"); | ||
| expect(locked.stderr).toBe(""); | ||
| expect(locked.status).toBe(0); | ||
| expect(locked.stdout).toContain("KiB"); | ||
|
|
||
| const target = makeRoot("target"); | ||
| writeFileSync(join(target, "private", "dev.tar.age"), readFileSync(join(source, "private", "dev.tar.age"))); | ||
| const unlocked = privateCli(target, "unlock"); | ||
| expect(unlocked.stderr).toBe(""); | ||
| expect(unlocked.status).toBe(0); | ||
|
|
||
| expect(snapshot(join(target, "private", "dev"))).toEqual({ | ||
| "top.md": "top\n", | ||
| "notes/deep/a.json": "{\"a\":1}\n", | ||
| }); | ||
| }); | ||
|
|
||
| test("lock refuses an empty dev folder", () => { | ||
| const root = makeRoot("empty"); | ||
| mkdirSync(join(root, "private", "dev")); | ||
| const result = privateCli(root, "lock"); | ||
| expect(result.status).toBe(1); | ||
| expect(result.stderr).toContain("nothing to lock"); | ||
| }); | ||
|
|
||
| test("lock refuses a dev folder that holds only excluded entries and keeps the old archive", () => { | ||
| const root = makeRoot("junk"); | ||
| const dev = join(root, "private", "dev"); | ||
| mkdirSync(dev); | ||
| writeFileSync(join(dev, "real.md"), "real\n"); | ||
| expect(privateCli(root, "lock").status).toBe(0); | ||
| const before = readFileSync(join(root, "private", "dev.tar.age")); | ||
|
|
||
| rmSync(join(dev, "real.md")); | ||
| mkdirSync(join(dev, "node_modules", "pkg"), { recursive: true }); | ||
| writeFileSync(join(dev, ".DS_Store"), "junk"); | ||
| writeFileSync(join(dev, "node_modules", "pkg", "index.js"), "junk"); | ||
|
|
||
| const result = privateCli(root, "lock"); | ||
| expect(result.status).toBe(1); | ||
| expect(result.stderr).toContain("nothing to lock"); | ||
| expect(readFileSync(join(root, "private", "dev.tar.age")).equals(before)).toBe(true); | ||
| expect(readdirSync(join(root, "private")).sort()).toEqual(["dev", "dev.tar.age"]); | ||
| }); | ||
|
|
||
| test("unlock refuses a non-empty dev folder unless --force", () => { | ||
| const root = makeRoot("busy"); | ||
| const dev = join(root, "private", "dev"); | ||
| mkdirSync(dev); | ||
| writeFileSync(join(dev, "kept.md"), "original\n"); | ||
| expect(privateCli(root, "lock").status).toBe(0); | ||
| writeFileSync(join(dev, "kept.md"), "edited\n"); | ||
|
|
||
| const refused = privateCli(root, "unlock"); | ||
| expect(refused.status).toBe(1); | ||
| expect(refused.stderr).toContain("pass --force"); | ||
| expect(readFileSync(join(dev, "kept.md"), "utf8")).toBe("edited\n"); | ||
|
|
||
| const forced = privateCli(root, "unlock", "--force"); | ||
| expect(forced.status).toBe(0); | ||
| expect(readFileSync(join(dev, "kept.md"), "utf8")).toBe("original\n"); | ||
| }); | ||
|
|
||
| test("unlock fails with one line when there is no archive", () => { | ||
| const root = makeRoot("bare"); | ||
| const result = privateCli(root, "unlock"); | ||
| expect(result.status).toBe(1); | ||
| expect(result.stderr.trim().split("\n")).toHaveLength(1); | ||
| expect(result.stderr).toContain("no archive"); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import { spawnSync } from "node:child_process"; | ||
| import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
|
|
||
| const root = process.env.LORE_PRIVATE_ROOT ?? join(import.meta.dir, ".."); | ||
| const devDir = join(root, "private", "dev"); | ||
| const archive = join(root, "private", "dev.tar.age"); | ||
|
|
||
| class Failure extends Error {} | ||
|
|
||
| function run(command: string, args: string[], options: { input?: string; env?: NodeJS.ProcessEnv } = {}): string { | ||
| const result = spawnSync(command, args, { | ||
| input: options.input, | ||
| env: options.env ?? process.env, | ||
| encoding: "utf8", | ||
| maxBuffer: 64 * 1024 * 1024, | ||
| }); | ||
| if (result.error) { | ||
| if ((result.error as NodeJS.ErrnoException).code === "ENOENT") throw new Failure(`${command} is not installed or not on PATH`); | ||
| throw new Failure(`${command} failed: ${result.error.message}`); | ||
| } | ||
| if (result.status !== 0) { | ||
| const reason = result.stderr.trim().split("\n")[0] || `exit ${result.status}`; | ||
| throw new Failure(`${command} failed: ${reason}`); | ||
| } | ||
| return result.stdout; | ||
| } | ||
|
|
||
| function readIdentity(): string { | ||
| const file = process.env.LORE_PRIVATE_IDENTITY_FILE; | ||
| if (file) { | ||
| if (!existsSync(file)) throw new Failure(`identity file not found: ${file}`); | ||
| return readFileSync(file, "utf8"); | ||
| } | ||
| const result = spawnSync("security", ["find-generic-password", "-a", "mattstack", "-s", "mattstack-age-key", "-w"], { encoding: "utf8" }); | ||
| if (result.error || result.status !== 0 || result.stdout.trim() === "") { | ||
| throw new Failure("keychain item mattstack-age-key (account mattstack) not found"); | ||
| } | ||
| return result.stdout.trim() + "\n"; | ||
| } | ||
|
|
||
| function isEmptyDir(path: string): boolean { | ||
| return !existsSync(path) || (statSync(path).isDirectory() && readdirSync(path).length === 0); | ||
| } | ||
|
|
||
| function withScratch<T>(body: (scratch: string) => T): T { | ||
| const scratch = mkdtempSync(join(tmpdir(), "lore-private-")); | ||
| try { | ||
| return body(scratch); | ||
| } finally { | ||
| rmSync(scratch, { recursive: true, force: true }); | ||
| } | ||
| } | ||
|
|
||
| function lock(): void { | ||
| if (isEmptyDir(devDir)) throw new Failure(`nothing to lock: ${devDir} is missing or empty`); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject a lock when every entry is excluded. If
|
||
| const recipient = run("age-keygen", ["-y"], { input: readIdentity() }).trim(); | ||
| withScratch((scratch) => { | ||
| const tarball = join(scratch, "dev.tar.gz"); | ||
| run("tar", ["-czf", tarball, "--exclude", ".DS_Store", "--exclude", "node_modules", "-C", devDir, "."], { | ||
| env: { ...process.env, COPYFILE_DISABLE: "1" }, | ||
| }); | ||
| const entries = run("tar", ["-tzf", tarball]).split("\n"); | ||
| if (!entries.some((entry) => entry !== "" && !entry.endsWith("/"))) { | ||
| throw new Failure(`nothing to lock: ${devDir} holds only excluded entries`); | ||
| } | ||
| const pending = `${archive}.partial`; | ||
| run("age", ["-r", recipient, "-o", pending, tarball]); | ||
| renameSync(pending, archive); | ||
| }); | ||
| const kib = (statSync(archive).size / 1024).toFixed(1); | ||
| console.log(`Locked ${devDir} into ${archive} (${kib} KiB).`); | ||
| } | ||
|
|
||
| function unlock(force: boolean): void { | ||
| if (!existsSync(archive)) throw new Failure(`no archive at ${archive}`); | ||
| if (!isEmptyDir(devDir) && !force) throw new Failure(`${devDir} is not empty; pass --force to overwrite it`); | ||
| const identity = readIdentity(); | ||
| withScratch((scratch) => { | ||
| const identityFile = join(scratch, "identity"); | ||
| writeFileSync(identityFile, identity, { mode: 0o600 }); | ||
| chmodSync(identityFile, 0o600); | ||
| const tarball = join(scratch, "dev.tar.gz"); | ||
| run("age", ["-d", "-i", identityFile, "-o", tarball, archive]); | ||
| rmSync(identityFile, { force: true }); | ||
| mkdirSync(devDir, { recursive: true }); | ||
| run("tar", ["-xzf", tarball, "-C", devDir]); | ||
| }); | ||
| console.log(`Unlocked ${archive} into ${devDir}.`); | ||
| } | ||
|
|
||
| const [command, ...flags] = process.argv.slice(2); | ||
| try { | ||
| if (command === "lock") lock(); | ||
| else if (command === "unlock") unlock(flags.includes("--force")); | ||
| else throw new Failure("usage: bun scripts/private.ts lock|unlock [--force]"); | ||
| } catch (error) { | ||
| const reason = error instanceof Failure ? error.message : String(error); | ||
| console.error(`private: ${reason}`); | ||
| process.exit(1); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Install
agebefore the CI test run.Every test calls
age-keygeninbeforeEach. The Ubuntu CI workflow runsbun testwithout installingage, and the supplied pipeline logs show all four tests failing during setup. Add anageinstallation step beforebun testso CI can run these tests. (raw.githubusercontent.com)GitHub Actions: ci / 0_test.txt
[error] 14-15: Test setup failed:
spawnSync("age-keygen", ["-o", identityFile])returned a nonzero status, causingage-keygen failed: undefined. This failure occurred in 4 tests; the test run exited with code 1.GitHub Actions: ci / test
[error] 14-15: Test failed:
age-keygen -o <identityFile>returned a nonzero status. The test threwage-keygen failed: undefinedbecause stderr was unavailable. The test suite failed with 4 failing tests and exited with code 1.Source: Pipeline failures