diff --git a/README.md b/README.md index 3479a11..4768fda 100644 --- a/README.md +++ b/README.md @@ -347,10 +347,16 @@ Capture your entire dev environment and restore it on a new machine. An AI agent ```bash crab env snapshot # Agent explores machine → encrypted bundle crab env snapshot --dry-run # Preview what would be captured +crab env encrypt STAGING_DIR # Retry encryption without rerunning the agent crab env restore --from FILE # Decrypt + agent sets up new machine crab env restore --from URL # Download and restore from URL ``` +If snapshot encryption fails, retry with the staging directory printed by the +command. An optional second argument selects the output file. The output must +be a new file outside the staging directory. Successful encryption removes the +staging directory; a failed attempt preserves it for retry. + The snapshot captures: Homebrew packages, Node/Python/Go versions, shell config, git identity, editor settings, database schemas, Docker config, cloud tools, project inventory, .env files, and API tokens — all encrypted with a password. ## Setup Flow diff --git a/src/crabcode b/src/crabcode index c0e4420..f377c2e 100755 --- a/src/crabcode +++ b/src/crabcode @@ -11306,11 +11306,15 @@ handle_env_command() { "restore") env_restore "$@" ;; + "encrypt") + env_encrypt "$@" + ;; ""|"help"|"-h"|"--help") echo -e "${BOLD}crab env${NC} - Environment snapshot & restore" echo "" echo -e " ${CYAN}crab env snapshot${NC} Capture environment recipe" echo -e " ${CYAN}crab env snapshot --dry-run${NC} Preview what would be captured" + echo -e " ${CYAN}crab env encrypt [output-file]${NC} Retry snapshot encryption" echo -e " ${CYAN}crab env restore --from FILE${NC} Restore from snapshot" echo "" echo "Snapshot launches an AI agent to explore your machine and build" @@ -11492,7 +11496,30 @@ PROMPT_EOF ${EDITOR:-vim} "$staging_dir/recipe.md" fi - # Package and encrypt + env_encrypt "$staging_dir" "$output_path" +} + +# Encrypt a staging directory into a portable .enc bundle +env_encrypt() { + local staging_dir="${1:-}" + local output_path="${2:-}" + + if [ -z "$staging_dir" ] || [ ! -d "$staging_dir" ]; then + error "Staging directory not found: ${staging_dir:-}" + return 1 + fi + + if [ ! -f "$staging_dir/recipe.md" ]; then + error "No recipe.md found in $staging_dir — is this a valid snapshot?" + return 1 + fi + + staging_dir=$(cd "$staging_dir" && pwd -P) || return 1 + if [ "$staging_dir" = / ]; then + error "The filesystem root cannot be a staging directory" + return 1 + fi + echo "" echo -e "${CYAN}Encrypting snapshot...${NC}" echo -e "${GRAY}Choose a password to protect this snapshot.${NC}" @@ -11501,13 +11528,31 @@ PROMPT_EOF # Determine output path if [ -z "$output_path" ]; then + local timestamp=$(date +%Y%m%d-%H%M%S) output_path="$ENV_SNAPSHOT_DIR/env-snapshot-$timestamp.enc" fi - mkdir -p "$(dirname "$output_path")" + mkdir -p "$(dirname "$output_path")" || return 1 + local output_dir + output_dir=$(cd "$(dirname "$output_path")" && pwd -P) || return 1 + output_path="$output_dir/$(basename "$output_path")" + case "$output_path" in + "$staging_dir"/*) + error "Output file must be outside the staging directory" + return 1 + ;; + esac + if [ -e "$output_path" ] || [ -L "$output_path" ]; then + error "Output file already exists: $output_path" + return 1 + fi + + local encrypted_tmp + encrypted_tmp=$(mktemp "$output_dir/.crab-env-XXXXXX") || return 1 # Encrypt with openssl (use pipefail to catch tar or openssl failures) - if ( set -o pipefail; tar czf - -C "$(dirname "$staging_dir")" "$(basename "$staging_dir")" \ - | openssl enc -aes-256-cbc -pbkdf2 -salt -out "$output_path" ); then + if ( set -o pipefail; tar czf - -C "$(dirname "$staging_dir")" -- "$(basename "$staging_dir")" \ + | openssl enc -aes-256-cbc -pbkdf2 -salt -out "$encrypted_tmp" ) \ + && mv "$encrypted_tmp" "$output_path"; then # Clean up staging rm -rf "$staging_dir" @@ -11520,6 +11565,7 @@ PROMPT_EOF echo -e " 2. Install crabcode: ${CYAN}curl -fsSL | bash${NC}" echo -e " 3. Run: ${CYAN}crab env restore --from $output_path${NC}" else + rm -f "$encrypted_tmp" error "Encryption failed" echo "Unencrypted snapshot remains at: $staging_dir" return 1 diff --git a/tests/unit/test_env_encrypt.bats b/tests/unit/test_env_encrypt.bats new file mode 100644 index 0000000..0e3bfea --- /dev/null +++ b/tests/unit/test_env_encrypt.bats @@ -0,0 +1,101 @@ +#!/usr/bin/env bats + +load '../test_helper/bats-support/load' +load '../test_helper/bats-assert/load' + +setup() { + TEST_TMPDIR=$(mktemp -d) + source "${BATS_TEST_DIRNAME}/../../src/crabcode" + ENV_SNAPSHOT_DIR="$TEST_TMPDIR/snapshots" + staging_dir="$TEST_TMPDIR/staging" + mkdir -p "$staging_dir" + printf 'Fixture recipe\n' > "$staging_dir/recipe.md" + output_file="$TEST_TMPDIR/snapshot.enc" + + # Use a fixture password so tests exercise real encryption without a prompt. + openssl() { command openssl "$@" -pass pass:crabcode-test-fixture; } +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +@test "env encrypt rejects missing staging directories and recipes" { + run env_encrypt + assert_failure + assert_output --partial 'Staging directory not found' + rm "$staging_dir/recipe.md" + run env_encrypt "$staging_dir" "$output_file" + assert_failure + assert_output --partial 'No recipe.md' + [ ! -e "$output_file" ] +} + +@test "env encrypt round-trips a relative staging path and removes it after success" { + cd "$TEST_TMPDIR" + run handle_env_command encrypt ./staging ./snapshot.enc + assert_success + [ ! -e "$staging_dir" ] + mkdir "$TEST_TMPDIR/restored" + openssl enc -d -aes-256-cbc -pbkdf2 -in "$output_file" \ + | tar xzf - -C "$TEST_TMPDIR/restored" + run cat "$TEST_TMPDIR/restored/staging/recipe.md" + assert_output 'Fixture recipe' +} + +@test "env encrypt supports staging names that begin with a dash" { + mv "$staging_dir" "$TEST_TMPDIR/--fixture" + run env_encrypt "$TEST_TMPDIR/--fixture" "$output_file" + assert_success + run bash -c 'openssl enc -d -aes-256-cbc -pbkdf2 -in "$1" -pass pass:crabcode-test-fixture | tar tzf -' _ "$output_file" + assert_success + assert_output --partial '--fixture/recipe.md' +} + +@test "env encrypt preserves an existing output file" { + printf 'Previous backup\n' > "$output_file" + run env_encrypt "$staging_dir" "$output_file" + assert_failure + assert_output --partial 'already exists' + [ -f "$staging_dir/recipe.md" ] + run cat "$output_file" + assert_output 'Previous backup' +} + +@test "env encrypt rejects output inside staging including symlinked directories" { + ln -s "$staging_dir" "$TEST_TMPDIR/link" + for destination in "$staging_dir/snapshot.enc" "$TEST_TMPDIR/link/snapshot.enc"; do + run env_encrypt "$staging_dir" "$destination" + assert_failure + assert_output --partial 'outside the staging directory' + [ -f "$staging_dir/recipe.md" ] + [ ! -e "$destination" ] + done +} + +@test "env encrypt preserves staging and removes partial output on encryption failure" { + openssl() { cat >/dev/null; return 1; } + run env_encrypt "$staging_dir" "$output_file" + assert_failure + [ -f "$staging_dir/recipe.md" ] + [ ! -e "$output_file" ] + run find "$TEST_TMPDIR" -name '.crab-env-*' + assert_output '' +} + +@test "env encrypt preserves staging when tar fails" { + tar() { return 1; } + run env_encrypt "$staging_dir" "$output_file" + assert_failure + [ -f "$staging_dir/recipe.md" ] + [ ! -e "$output_file" ] +} + +@test "env encrypt uses the default snapshot directory when output is omitted" { + run handle_env_command encrypt "$staging_dir" + assert_success + [ ! -e "$staging_dir" ] + run find "$ENV_SNAPSHOT_DIR" -name 'env-snapshot-*.enc' + assert_success + [ -n "$output" ] +}