feat: 'file' external type — whole-file sync for .secrets-files (EGB-652)

Built for the Beacon Android upload keystore: binary files outside the
project root can now ride the encrypted store.

- manifest: 'file <path>' (no keys; keys present = rejected loudly)
- push: encrypts the file verbatim (age is binary-safe)
- pull: restores next to target (atomic same-fs mv), mode 600, TOCTOU
  symlink recheck, divergent existing target backed up to .secrets-bak
- _validate_external_target_path parameterized by type (basename
  restriction stays gradle-properties-only; $HOME/../symlink rails apply
  to both)
- README/help/CLAUDE docs + 7 bats tests (133/133 pass)
This commit is contained in:
Brian Majewski 2026-06-07 06:43:45 -07:00
parent ab45c4c94b
commit e0474f3ce9
5 changed files with 226 additions and 40 deletions

View file

@ -1657,3 +1657,104 @@ gradle_project() {
[ "$status" -eq 1 ]
[[ "$output" == *"git clone git@example.com:me/secrets-work.git"* ]]
}
# ─── EGB-652: `file` external type (whole-file sync, e.g. Android keystore) ──
# Helper: write a small binary source file under the sandboxed HOME.
file_src() {
mkdir -p "$HOME/keystores"
printf 'KS\x00\x01\x02\xffDATA-%s\n' "${1:-v1}" > "$HOME/keystores/upload.keystore"
}
# Helper: bind a project dir to a file entry via .secrets-files, cd into it.
file_project() {
local dir="$WORK_DIR/$1"
mkdir -p "$dir"
printf 'file ~/keystores/upload.keystore\n' > "$dir/.secrets-files"
cd "$dir" || exit 1
}
@test "EGB-652: which shows parsed file-type entry" {
init_with_remote
file_src
file_project fproj
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"file"* ]]
[[ "$output" == *"~/keystores/upload.keystore"* ]]
}
@test "EGB-652: push encrypts a file-type entry into external/ blob" {
init_with_remote
file_src
file_project fproj
run "$SECRETS_BIN" push fproj
[ "$status" -eq 0 ]
[[ "$output" == *"Encrypted file"* ]]
run bash -c "ls $SECRETS_DIR/fproj/external/*.file.age"
[ "$status" -eq 0 ]
}
@test "EGB-652: pull restores the file byte-identical with mode 600" {
init_with_remote
file_src v1
file_project fproj
"$SECRETS_BIN" push fproj >/dev/null 2>&1
cp "$HOME/keystores/upload.keystore" "$TEST_TMPDIR/reference"
rm -rf "$HOME/keystores"
run "$SECRETS_BIN" pull fproj
[ "$status" -eq 0 ]
[[ "$output" == *"Restored file"* ]]
cmp "$HOME/keystores/upload.keystore" "$TEST_TMPDIR/reference"
mode=$(stat -f '%Lp' "$HOME/keystores/upload.keystore" 2>/dev/null || stat -c '%a' "$HOME/keystores/upload.keystore")
[ "$mode" = "600" ]
}
@test "EGB-652: pull backs up an existing divergent target before overwriting" {
init_with_remote
file_src v1
file_project fproj
"$SECRETS_BIN" push fproj >/dev/null 2>&1
file_src v2-local-edit
run "$SECRETS_BIN" pull fproj
[ "$status" -eq 0 ]
grep -q 'DATA-v1' "$HOME/keystores/upload.keystore"
grep -q 'DATA-v2-local-edit' "$HOME/keystores/upload.keystore.secrets-bak"
}
@test "EGB-652: file entry with trailing keys is rejected" {
init_with_remote
file_src
local dir="$WORK_DIR/fbad"; mkdir -p "$dir"
printf 'file ~/keystores/upload.keystore strayKey\n' > "$dir/.secrets-files"
cd "$dir"
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"take no keys"* ]]
# The rejected entry must not be listed as parsed (header only prints
# when at least one entry parses).
[[ "$output" != *"external files ("* ]]
}
@test "EGB-652: file target outside HOME is refused on push" {
init_with_remote
local dir="$WORK_DIR/fout"; mkdir -p "$dir"
printf 'file /etc/hosts\n' > "$dir/.secrets-files"
cd "$dir"
run "$SECRETS_BIN" push fout
[ "$status" -ne 0 ]
[[ "$output" == *"inside \$HOME"* ]] || [[ "$output" == *"Refusing"* ]]
}
@test "EGB-652: gradle-properties entries still work alongside a file entry" {
init_with_remote
gradle_src $'beaconClerkPkTest=pk_test_abc\n'
file_src
local dir="$WORK_DIR/fmix"; mkdir -p "$dir"
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\nfile ~/keystores/upload.keystore\n' > "$dir/.secrets-files"
cd "$dir"
run "$SECRETS_BIN" push fmix
[ "$status" -eq 0 ]
[[ "$output" == *"Extracted 1 key"* ]]
[[ "$output" == *"Encrypted file"* ]]
}