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
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,21 @@ jobs:
- name: linux
os: ubuntu-latest
exe: target/release/git-zcrypt
max_size: 850000
max_size: 725000
- name: macos
os: macos-latest
exe: target/release/git-zcrypt
max_size: 750000
max_size: 650000
- name: windows
os: windows-latest
exe: target/release/git-zcrypt.exe
max_size: 550000
max_size: 500000
steps:
- uses: actions/checkout@v7
- name: Print build environment
run: |
rustc --version --verbose
cargo --version --verbose
- name: Build release executable
run: cargo build --release --locked
- name: Check executable size
Expand Down
7 changes: 0 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ license = "MIT OR Apache-2.0"
description = "Git clean/smudge filter that compresses and encrypts file contents"

[dependencies]
anyhow = { version = "1.0.100", default-features = false, features = ["std"] }
argon2 = { version = "0.5.3", default-features = false, features = ["alloc", "zeroize"] }
chacha20poly1305 = { version = "0.11.0", default-features = false, features = ["alloc", "zeroize"] }
flate2 = { version = "1.1.5", default-features = false, features = ["rust_backend"] }
Expand All @@ -19,6 +18,6 @@ zeroize = { version = "1.8.2", default-features = false, features = ["alloc"] }
tempfile = { version = "3.23.0", default-features = false, features = ["getrandom"] }

[profile.release]
opt-level = "z"
opt-level = "s"
lto = "fat"
codegen-units = 1
34 changes: 34 additions & 0 deletions codex-notes/executable-size-reduction/feature_list.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"features": [
{
"id": "baseline-measurement",
"description": "Rebuild the current release binary in the dedicated worktree and record exact size plus cargo-bloat output.",
"validation": "cargo build --release; ls -l target/release/git-zcrypt; cargo bloat --release --crates; cargo bloat --release",
"passes": true
},
{
"id": "measure-candidates",
"description": "Test conservative size-reduction candidates that preserve panic behavior, symbol inspection, crypto, KDF, compression, and data formats.",
"validation": "Each retained candidate has before/after release byte size measurements; rejected candidates are left out of the final patch.",
"passes": true
},
{
"id": "apply-size-reduction",
"description": "Apply only measured code/profile changes that reduce executable size without unacceptable readability or diagnostic regressions.",
"validation": "git diff shows only the retained scoped changes; cargo build --release produces a smaller target/release/git-zcrypt than the baseline.",
"passes": true
},
{
"id": "validate-behavior",
"description": "Run the existing test suite after the final size-reduction patch.",
"validation": "cargo test",
"passes": true
},
{
"id": "final-size-report",
"description": "Run final release-size and bloat measurements and report before/after bytes and delta.",
"validation": "cargo build --release; ls -l target/release/git-zcrypt; cargo bloat --release --crates; cargo bloat --release",
"passes": true
}
]
}
136 changes: 136 additions & 0 deletions codex-notes/executable-size-reduction/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Executable Size Reduction Plan

## Overview

Reduce the release executable size without using the rejected levers:

- Do not add `strip = "symbols"`.
- Do not add `panic = "abort"`.
- Do not spend time on platform-specific strip behavior.
- Do not change crypto, randomness, KDF, compression format, or persisted data formats.

The work should be measurement-driven. First establish a clean worktree-local baseline, then test small, reversible candidate changes. Keep only changes that measurably reduce `target/release/git-zcrypt` size without harming CLI behavior, error usefulness, or local symbol inspection.

## Files to Change

Likely files:

- `src/main.rs`: possible top-level error formatting and command dispatch shape changes.
- `src/git_config.rs`: possible factoring of repeated Git subprocess handling if it reduces duplicated `Command::output` usage.
- `src/key_store.rs`: possible factoring of Git directory subprocess handling with `git_config.rs` if measurement supports it.
- `Cargo.toml`: only for profile settings that preserve inspection and panic behavior, if measured useful.

Planning and tracking files:

- `codex-notes/executable-size-reduction/plan.md`
- `codex-notes/executable-size-reduction/feature_list.json`

Files not expected to change:

- `src/crypto.rs`
- `src/kdf.rs`
- `src/compression.rs`
- `src/blob.rs`
- `src/index_json.rs`
- `Cargo.lock`

## Detailed Implementation Steps

1. Rebuild the current worktree baseline:

```sh
cargo build --release
ls -l target/release/git-zcrypt
cargo bloat --release --crates
cargo bloat --release
```

Record exact file bytes, `.text` size, top crates, and top methods.

2. Probe non-invasive profile/link behavior without committing it first:

- Try only options that preserve panic behavior and symbol inspection.
- Do not test `strip = "symbols"` or `panic = "abort"`.
- If a probe requires a platform-specific linker flag, treat it as evidence only and do not commit it unless it is clearly portable or target-gated.

3. Measure a top-level error formatting change:

Current:

```rust
eprintln!("error: {error:#}");
```

Candidate:

```rust
eprintln!("error: {error}");
```

This may reduce formatting and context-chain code reachability, but it weakens multi-context diagnostics. Keep it only if the byte savings are meaningful enough to justify the user-facing tradeoff.

4. Measure command dispatch/code-shape changes around `run`:

- `git_zcrypt::run` is the largest project-local symbol.
- Try factoring repeated `KeyStore::discover()?` branches or splitting branch bodies into small command functions.
- Keep only changes that reduce size after `cargo build --release`; LLVM may inline or outline differently, so source-level intuition is not enough.

5. Measure subprocess helper consolidation:

- `std::process::Command::output` appears high in `cargo bloat --release`.
- `key_store.rs` and `git_config.rs` both shell out to Git and then convert status/stdout/stderr into `anyhow` errors.
- Try a small shared helper only if it does not make error messages vague.
- Keep it only if it reduces binary size and does not create an awkward cross-module abstraction.

6. Stop when marginal candidate changes no longer produce meaningful reductions.

The goal is a smaller executable, not broad refactoring. If a candidate saves only noise-level bytes while making the code harder to read, revert that candidate.

7. Validate the final patch:

```sh
cargo test
cargo build --release
cargo bloat --release --crates
cargo bloat --release
ls -l target/release/git-zcrypt
```

Report before/after byte sizes and delta in the final response.

## Alternatives Considered

- `strip = "symbols"`: rejected because preserving local inspection matters.
- `panic = "abort"`: rejected by user.
- Changing compression libraries or formats: rejected for this task because it risks stored clean/smudge compatibility and touches core behavior.
- Changing KDF or crypto dependencies: rejected because this is core security behavior and the current bloat attribution does not justify that risk.
- Feature-gating subcommands or creating split binaries: allowed in principle, but no coherent feature boundary is apparent in the current CLI. It should not be pursued unless measurement reveals a clear split.
- Replacing `anyhow` wholesale: likely too invasive for a first size pass. Consider only targeted reductions if measurement shows enough value.

## Risks

- Size measurements can fluctuate due to incremental build state or symbol attribution. Use exact file bytes as the primary on-disk metric and `cargo bloat` as diagnostic support.
- Error-format reductions may make failures less actionable.
- Refactoring dispatch or subprocess helpers may save little after optimization and could reduce readability.
- Running Git status in this worktree needs git-zcrypt filter overrides because the worktree lacks the local key for `secrets/secret.txt`.

## Test Strategy

- Run `cargo test` to preserve behavior.
- Run `cargo build --release` and compare `target/release/git-zcrypt` byte size against the baseline.
- Run `cargo bloat --release --crates` and `cargo bloat --release` to confirm which contributors changed.
- Use Git status with filter overrides:

```sh
git -c filter.git-zcrypt.clean=cat -c filter.git-zcrypt.smudge=cat -c filter.git-zcrypt.required=false status --short
```

## Assumptions

- The exact baseline from research is still representative: `781080` bytes for `target/release/git-zcrypt`.
- The desired output is one scoped patch that reduces the release binary size while preserving existing CLI and data format behavior.
- Small, measured readability-neutral changes are preferred over invasive architectural splits.

## Open Questions

- What threshold counts as "meaningful" savings for a readability tradeoff? Default assumption: keep only changes that save at least a few KiB or are readability-neutral.
143 changes: 143 additions & 0 deletions codex-notes/executable-size-reduction/research.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Executable Size Reduction Research

## Relevant Files and Modules

- `Cargo.toml`: release profile and dependency feature selection.
- `Cargo.lock`: concrete dependency graph for binary-size contributors.
- `src/main.rs`: top-level dispatch, stdin/stdout IO, and command routing.
- `src/cli.rs`: hand-written CLI parser, usage text, and parser errors.
- `src/blob.rs`: encrypted blob container encoding/decoding and AAD construction.
- `src/compression.rs`: zlib compression/decompression through `flate2` with the Rust backend.
- `src/crypto.rs`: ChaCha20-Poly1305 encryption/decryption and nonce generation.
- `src/kdf.rs`: Argon2id password-to-key derivation and hidden prompt input.
- `src/key_store.rs`: key file storage, key index management, Git directory discovery, key-id hashing, and durable file writes.
- `src/git_config.rs`: local Git filter configuration and status reporting.
- `src/index_json.rs`: small project-local JSON string-map formatter/parser to avoid a JSON dependency.

## Current Size Baseline

Command:

```sh
cargo bloat --release --crates
```

Observed output summary:

- File size: `762.8KiB` as reported by `cargo bloat`.
- Exact filesystem size: `781080` bytes from `ls -l target/release/git-zcrypt`.
- `.text` section: `333.8KiB`.
- Largest crate-level contributors:
- `std`: `241.0KiB` text, `72.2%` of text.
- `git_zcrypt`: `32.5KiB` text, `9.7%`.
- `miniz_oxide`: `14.8KiB` text, `4.4%`.
- `[Unknown]`: `10.4KiB` text, `3.1%`.
- `anyhow`: `10.4KiB` text, `3.1%`.
- `blake2`: `6.5KiB` text, `1.9%`.
- `rpassword`: `6.3KiB` text, `1.9%`.
- `flate2`: `5.8KiB` text, `1.7%`.
- `argon2`: `3.5KiB` text, `1.0%`.

Command:

```sh
cargo bloat --release
```

Observed top symbols:

- `git_zcrypt::run`: `13.7KiB`.
- Several `std::backtrace_rs::symbolize::gimli` / `addr2line` functions in the `8.4KiB`, `7.6KiB`, `6.5KiB`, `5.7KiB`, `4.1KiB`, `3.7KiB`, `3.1KiB`, and `3.0KiB` range.
- `<std::process::Command>::output`: `6.8KiB`.
- `blake2::Blake2bVarCore::compress`: `6.1KiB`.
- `miniz_oxide::inflate::core::decompress`: `4.8KiB`.
- `<flate2::zlib::read::ZlibEncoder<R> as std::io::Read>::read`: `4.7KiB`.
- `rpassword::prompt_password`: `3.7KiB`.

## Execution Flow and Call Graph

`main` calls `Cli::parse_env()` and then `run(cli)`. Errors are printed with `eprintln!("error: {error:#}")`, then the process exits with status `1`.

`run` matches one enum variant per subcommand:

- `help`: print static usage.
- `init`: discover Git directory and create `.git/git-zcrypt/keys`.
- `generate-key`: discover key store, generate 32 random bytes, write key, update index.
- `import-key`: read raw 32-byte key from a file, write key, update index.
- `derive-key`: read a password from prompt or stdin, derive a 32-byte Argon2id key, write key, zeroize derived material.
- `export-key`: read a stored key and write raw key material to a destination.
- `delete-key`: remove a key file and any index mapping.
- `install-filter`: set local Git filter config.
- `status`: inspect key/index/config state and print a text summary.
- `clean`: read stdin, compress with zlib, encrypt with ChaCha20-Poly1305, encode blob, write stdout.
- `smudge`: read stdin, decode blob, locate key by id, decrypt, decompress, write stdout.

External process use is limited to `git rev-parse --absolute-git-dir` in `key_store::git_dir`, and `git config --local ...` in `git_config`.

## Data Structures and Invariants

- Raw keys are exactly `32` bytes (`RAW_KEY_LEN`).
- Key ids are `sha256:` followed by exactly 64 lowercase hex chars.
- Key names must be non-empty ASCII alphanumeric plus `_` and `-`.
- Stored key files use a fixed 12-byte header: magic `GZCKEY\0\0`, version `1`, key length `32`, and two reserved zero bytes.
- Blob files use a fixed 12-byte header: magic `GZC1\0\0\0\0`, version `1`, key-id length, nonce length `12`, and one reserved zero byte.
- Encryption AAD is the blob header prefix plus key id.
- The key index is a `BTreeMap<String, String>` persisted as a JSON object from key id to key name.
- Secret key buffers are wrapped in `Zeroizing` or manually zeroized after use.

## Existing Architectural Patterns

- The binary avoids common large CLI/JSON dependencies by using a hand-written argument parser and hand-written JSON parser/formatter.
- Error handling uses `anyhow::{Result, Context, bail, ensure}` throughout production modules.
- Dependency feature sets are already minimized with `default-features = false`.
- The release profile already prioritizes size with `opt-level = "z"`, `lto = "fat"`, and `codegen-units = 1`.
- Tests live in module-local `#[cfg(test)]` blocks plus `tests/filter_roundtrip.rs`.

## Naming Conventions

- Public command-level functions use direct verb names: `clean`, `smudge`, `install_filter`, `print_status`.
- Internal helpers use descriptive snake_case names such as `validate_key_name`, `write_secret_file`, and `aad_for_key_id`.
- Constants are all caps with domain prefixes where useful, for example `RAW_KEY_LEN`, `KEY_FILE_MAGIC`, and `ARGON2_MEMORY_KIB`.

## Error Handling Patterns

- Context-rich filesystem and process errors use `.with_context(...)`.
- Validation failures use `ensure!` and `bail!`.
- AEAD errors are mapped to generic user-facing messages to avoid exposing internals.
- The top-level printer uses alternate formatting `{error:#}`, which may keep more formatting code reachable than a simpler formatter.

## Typing Conventions

- Module APIs generally return `anyhow::Result<T>`.
- Secret key data is either `[u8; RAW_KEY_LEN]` or `Zeroizing<Vec<u8>>`.
- Parsed commands are represented by a `Command` enum.
- Paths use `Path`/`PathBuf`; command-line args begin as `OsString` and are converted to UTF-8 only where required.

## Potential Pitfalls

- Changing compression implementation or level can affect stored clean/smudge data compatibility if the output ceases to be zlib-compatible. Any compression change must preserve zlib decode compatibility for existing encrypted blobs.
- Changing key derivation parameters or algorithm breaks derived-key reproducibility. The existing Argon2id test pins the derived bytes for `b"password"`.
- Replacing `sha2` or key-id formatting changes persisted key ids and encrypted blob lookup.
- Removing rich error context may reduce binary size but would make user failures harder to diagnose.
- Some apparent bloat is from `std`, especially backtrace/symbolization code; profile settings may remove much of it without code-level behavior changes, but must be measured.
- `panic = "abort"` can reduce size, but is not acceptable for this task.
- `strip = "symbols"` can reduce on-disk size, but is not acceptable for this task because local inspection should remain easy.
- `cargo bloat` can show different attribution after stripping or profile changes; exact byte size should be reported separately with `ls -l`.

## Constraints

- Must preserve CLI behavior and data format compatibility.
- Must preserve encryption, randomness, and key derivation security properties.
- Dependency changes in crypto/randomness/KDF/compression are essential project behavior and should be deliberate.
- The repo already optimizes for small dependency surface; remaining easy wins are likely release profile settings and small error/dispatch shape changes.
- Before/after release binary byte sizes and delta must be reported after changes.
- Do not use `strip = "symbols"`; preserving local inspection matters more than that on-disk size reduction.
- Do not use `panic = "abort"`.
- Do not spend time investigating platform-specific strip behavior.
- Split binary / feature-gated command layouts are allowed in principle, but should only be pursued if measurement shows a clear, coherent split. The current command set does not obviously suggest a useful feature boundary.

## Unknowns

- Whether reducing `anyhow` usage in hot/high-fanout dispatch paths would produce enough measurable size improvement to justify the loss of rich context.
- Whether `std::process::Command::output` can be avoided or factored in a way that materially changes binary size without making Git integration worse.
- Whether small code-shape changes around `run`, formatting, and status output move the needle after measuring with the rejected profile levers excluded.
3 changes: 2 additions & 1 deletion src/blob.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use anyhow::{Context, Result, bail, ensure};
use crate::error::{Context, Result};
use crate::{bail, ensure};

pub const MAGIC: [u8; 8] = *b"GZC1\0\0\0\0";
pub const VERSION: u8 = 1;
Expand Down
Loading
Loading