From 4dab9d7e95930faa177d416990c950c56e5cf739 Mon Sep 17 00:00:00 2001 From: koba-e964 <3303362+koba-e964@users.noreply.github.com> Date: Mon, 6 Jul 2026 07:52:17 +0900 Subject: [PATCH 1/4] perf: reduce release binary size Why: - The release executable carried avoidable size from anyhow and the previous size optimization setting. - The binary-size CI matrix tracks platform limits, so keeping the artifact smaller gives more headroom. What: - Replace anyhow with a small project-local string-backed error type and context helpers. - Keep existing bail/ensure-style validation through local macros. - Switch release opt-level from z to s after measurement showed a smaller local artifact. Impact: - Local macOS release binary changed from 675552 bytes to 598064 bytes, saving 77488 bytes. - No data format, crypto, KDF, or compression behavior changes. - Verified with cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo test --locked, cargo build --release, and cargo bloat --release. Prompt: - Find ways to decrease executable size using cargo bloat at discretion. --- Cargo.lock | 7 ---- Cargo.toml | 3 +- src/blob.rs | 3 +- src/cli.rs | 14 ++++--- src/compression.rs | 2 +- src/crypto.rs | 9 +++-- src/error.rs | 97 ++++++++++++++++++++++++++++++++++++++++++++++ src/git_config.rs | 14 +++---- src/index_json.rs | 3 +- src/kdf.rs | 7 ++-- src/key_store.rs | 17 ++++---- src/main.rs | 3 +- 12 files changed, 139 insertions(+), 40 deletions(-) create mode 100644 src/error.rs diff --git a/Cargo.lock b/Cargo.lock index 4a7969c..7fad5de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,12 +18,6 @@ dependencies = [ "inout", ] -[[package]] -name = "anyhow" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" - [[package]] name = "argon2" version = "0.5.3" @@ -251,7 +245,6 @@ dependencies = [ name = "git-zcrypt" version = "0.1.0" dependencies = [ - "anyhow", "argon2", "chacha20poly1305", "flate2", diff --git a/Cargo.toml b/Cargo.toml index b494c25..9de50d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } @@ -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 diff --git a/src/blob.rs b/src/blob.rs index 947916c..f3db513 100644 --- a/src/blob.rs +++ b/src/blob.rs @@ -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; diff --git a/src/cli.rs b/src/cli.rs index f2d1eb9..63af9af 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,5 @@ -use anyhow::{Context, Result, bail, ensure}; +use crate::error::{Context, Result}; +use crate::{bail, ensure}; use std::env; use std::ffi::OsString; use std::path::PathBuf; @@ -111,8 +112,9 @@ impl Args { fn next_string(&mut self, context: &str) -> Result> { self.next() .map(|arg| { - arg.into_string() - .map_err(|_| anyhow::anyhow!("{context}: argument is not UTF-8")) + arg.into_string().map_err(|_| { + crate::error::Error::msg(format!("{context}: argument is not UTF-8")) + }) }) .transpose() } @@ -206,7 +208,7 @@ fn parse_derive_key(args: &mut Args) -> Result<(String, bool)> { fn parse_option(command: &str, arg: OsString) -> Result<(&'static str, Option)> { let arg = arg .into_string() - .map_err(|_| anyhow::anyhow!("{command}: option is not UTF-8"))?; + .map_err(|_| crate::error::Error::msg(format!("{command}: option is not UTF-8")))?; ensure!( arg.starts_with("--"), "{command}: unexpected positional argument '{arg}'" @@ -248,7 +250,9 @@ fn option_string_value( ) -> Result { option_value(command, option, inline_value, args)? .into_string() - .map_err(|_| anyhow::anyhow!("{command}: value for {option} is not UTF-8")) + .map_err(|_| { + crate::error::Error::msg(format!("{command}: value for {option} is not UTF-8")) + }) } fn set_once(command: &str, option: &str, target: &mut Option, value: T) -> Result<()> { diff --git a/src/compression.rs b/src/compression.rs index 4d96443..0363c98 100644 --- a/src/compression.rs +++ b/src/compression.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use crate::error::{Context, Result}; use flate2::Compression; use flate2::read::{ZlibDecoder, ZlibEncoder}; use std::io::Read; diff --git a/src/crypto.rs b/src/crypto.rs index 21b8dce..70ffb03 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -1,6 +1,7 @@ use crate::blob::{self, Blob, NONCE_LEN}; +use crate::ensure; +use crate::error::{Context, Error, Result}; use crate::key_store::{self, RAW_KEY_LEN}; -use anyhow::{Context, Result, anyhow, ensure}; use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::{ChaCha20Poly1305, Nonce}; @@ -22,7 +23,7 @@ pub fn encrypt(key: &[u8], key_id: &str, plaintext: &[u8]) -> Result { aad: &aad, }, ) - .map_err(|_| anyhow!("encryption failed"))?; + .map_err(|_| Error::msg("encryption failed"))?; Ok(Blob { key_id: key_id.to_owned(), @@ -44,12 +45,12 @@ pub fn decrypt(key: &[u8], blob: &Blob) -> Result> { aad: &blob.aad(), }, ) - .map_err(|_| anyhow!("decryption failed")) + .map_err(|_| Error::msg("decryption failed")) } fn cipher_from_key(key: &[u8]) -> Result { ChaCha20Poly1305::new_from_slice(key) - .map_err(|_| anyhow!("raw key must be exactly {RAW_KEY_LEN} bytes")) + .map_err(|_| Error::msg(format!("raw key must be exactly {RAW_KEY_LEN} bytes"))) } fn ensure_key_len(key: &[u8]) -> Result<()> { diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..6d5afb0 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,97 @@ +use std::fmt; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub struct Error { + message: String, +} + +impl Error { + pub fn msg(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(error: std::io::Error) -> Self { + Self::msg(error.to_string()) + } +} + +impl From for Error { + fn from(error: std::string::FromUtf8Error) -> Self { + Self::msg(error.to_string()) + } +} + +impl From for Error { + fn from(error: std::str::Utf8Error) -> Self { + Self::msg(error.to_string()) + } +} + +pub trait Context { + fn context(self, context: impl fmt::Display) -> Result; + fn with_context(self, context: F) -> Result + where + C: fmt::Display, + F: FnOnce() -> C; +} + +impl Context for std::result::Result +where + E: fmt::Display, +{ + fn context(self, context: impl fmt::Display) -> Result { + self.map_err(|error| Error::msg(format!("{context}: {error}"))) + } + + fn with_context(self, context: F) -> Result + where + C: fmt::Display, + F: FnOnce() -> C, + { + self.map_err(|error| Error::msg(format!("{}: {error}", context()))) + } +} + +impl Context for Option { + fn context(self, context: impl fmt::Display) -> Result { + self.ok_or_else(|| Error::msg(context.to_string())) + } + + fn with_context(self, context: F) -> Result + where + C: fmt::Display, + F: FnOnce() -> C, + { + self.ok_or_else(|| Error::msg(context().to_string())) + } +} + +#[macro_export] +macro_rules! bail { + ($($arg:tt)*) => { + return Err($crate::error::Error::msg(format!($($arg)*))) + }; +} + +#[macro_export] +macro_rules! ensure { + ($condition:expr, $($arg:tt)*) => { + if !$condition { + $crate::bail!($($arg)*); + } + }; +} diff --git a/src/git_config.rs b/src/git_config.rs index 050d43d..1e29527 100644 --- a/src/git_config.rs +++ b/src/git_config.rs @@ -1,5 +1,5 @@ +use crate::error::{Context, Error, Result}; use crate::key_store::{self, KeyStatus, KeyStore}; -use anyhow::{Context, Result, anyhow}; use std::process::Command; const FILTER_NAME: &str = "git-zcrypt"; @@ -88,10 +88,10 @@ fn git_config_set(key: &str, value: &str) -> Result<()> { .with_context(|| format!("failed to set local Git config {key}"))?; if !output.status.success() { - return Err(anyhow!( + return Err(Error::msg(format!( "failed to set local Git config {key}: {}", String::from_utf8_lossy(&output.stderr).trim() - )); + ))); } Ok(()) @@ -111,10 +111,10 @@ fn git_config_get(key: &str) -> Result> { match output.status.code() { Some(1) => Ok(None), - _ => Err(anyhow!( + _ => Err(Error::msg(format!( "failed to read local Git config {key}: {}", String::from_utf8_lossy(&output.stderr).trim() - )), + ))), } } @@ -147,7 +147,7 @@ mod tests { assert_eq!(config.smudge.as_deref(), Some("git-zcrypt smudge")); assert_eq!(config.required.as_deref(), Some("true")); assert!(config.is_installed()); - Ok::<_, anyhow::Error>(()) + Ok::<_, crate::error::Error>(()) })(); std::env::set_current_dir(original_dir).expect("restore cwd"); @@ -203,7 +203,7 @@ mod tests { .iter() .any(|warning| warning.contains("key index mismatch")) ); - Ok::<_, anyhow::Error>(()) + Ok::<_, crate::error::Error>(()) })(); result.expect("status mismatch warning"); diff --git a/src/index_json.rs b/src/index_json.rs index da23762..036356e 100644 --- a/src/index_json.rs +++ b/src/index_json.rs @@ -1,4 +1,5 @@ -use anyhow::{Context, Result, bail, ensure}; +use crate::error::{Context, Result}; +use crate::{bail, ensure}; use std::collections::BTreeMap; pub(crate) fn format_string_map(index: &BTreeMap) -> String { diff --git a/src/kdf.rs b/src/kdf.rs index ffe3373..5b7a7b5 100644 --- a/src/kdf.rs +++ b/src/kdf.rs @@ -1,5 +1,6 @@ +use crate::ensure; +use crate::error::{Context, Error, Result}; use crate::key_store::RAW_KEY_LEN; -use anyhow::{Context, Result, anyhow, ensure}; use argon2::{Algorithm, Argon2, Params, Version}; use std::io::{self, Read}; use zeroize::Zeroizing; @@ -41,12 +42,12 @@ pub fn derive_key_from_password(password: &[u8]) -> Result<[u8; RAW_KEY_LEN]> { ARGON2_PARALLELISM, Some(RAW_KEY_LEN), ) - .map_err(|error| anyhow!("invalid Argon2id parameters: {error:?}"))?; + .map_err(|error| Error::msg(format!("invalid Argon2id parameters: {error:?}")))?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let mut key = [0_u8; RAW_KEY_LEN]; argon2 .hash_password_into(password, PASSWORD_DOMAIN, &mut key) - .map_err(|error| anyhow!("failed to derive key from password: {error:?}"))?; + .map_err(|error| Error::msg(format!("failed to derive key from password: {error:?}")))?; Ok(key) } diff --git a/src/key_store.rs b/src/key_store.rs index 67f881d..2fdec64 100644 --- a/src/key_store.rs +++ b/src/key_store.rs @@ -1,5 +1,6 @@ +use crate::error::{Context, Error, Result}; use crate::index_json; -use anyhow::{Context, Result, anyhow, bail, ensure}; +use crate::{bail, ensure}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -401,10 +402,10 @@ fn git_dir(cwd: Option<&Path>) -> Result { let output = command.output().context("failed to locate Git directory")?; if !output.status.success() { - return Err(anyhow!( + return Err(Error::msg(format!( "not inside a Git repository: {}", String::from_utf8_lossy(&output.stderr).trim() - )); + ))); } let stdout = String::from_utf8(output.stdout).context("Git directory path is not UTF-8")?; @@ -497,7 +498,7 @@ mod tests { assert!(store.root().ends_with(".git/git-zcrypt")); assert!(store.keys_dir().is_dir()); assert!(store.key_path("default")?.ends_with("keys/default.key")); - Ok::<_, anyhow::Error>(()) + Ok::<_, crate::error::Error>(()) })(); result.expect("key store init"); @@ -537,7 +538,7 @@ mod tests { let exported = temp.path().join("exported.key"); store.export_key("imported", &exported)?; assert_eq!(fs::read(exported)?, [9_u8; 32]); - Ok::<_, anyhow::Error>(()) + Ok::<_, crate::error::Error>(()) })(); result.expect("raw key commands"); @@ -565,7 +566,7 @@ mod tests { store .delete_key("default") .expect_err("missing key should fail"); - Ok::<_, anyhow::Error>(()) + Ok::<_, crate::error::Error>(()) })(); result.expect("delete key"); @@ -612,7 +613,7 @@ mod tests { truncated.pop(); fs::write(store.key_path("truncated")?, truncated)?; store.read_key("truncated").expect_err("truncated payload"); - Ok::<_, anyhow::Error>(()) + Ok::<_, crate::error::Error>(()) })(); result.expect("malformed key files"); @@ -674,7 +675,7 @@ mod tests { store .store_key("second", &[5_u8; 32]) .expect_err("duplicate key material"); - Ok::<_, anyhow::Error>(()) + Ok::<_, crate::error::Error>(()) })(); result.expect("duplicate rejection"); diff --git a/src/main.rs b/src/main.rs index 6c684ed..e973592 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ -use anyhow::Result; +use crate::error::Result; use std::io::{self, Read, Write}; mod blob; mod cli; mod compression; mod crypto; +mod error; mod git_config; mod index_json; mod kdf; From 5dc859a583874809b6d1065db0689cade5b7ed49 Mon Sep 17 00:00:00 2001 From: koba-e964 <3303362+koba-e964@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:10:22 +0900 Subject: [PATCH 2/4] docs: add executable size reduction notes Why: - The PR should include the research, plan, and implementation checklist that guided the executable-size reduction. What: - Add the task-scoped codex notes for research, plan, and feature validation. Impact: - No runtime behavior changes. - Notes document the constraints, rejected options, measurements, and completed validation. Prompt: - Include the notes in the executable-size reduction PR. --- .../feature_list.json | 34 +++++ codex-notes/executable-size-reduction/plan.md | 136 +++++++++++++++++ .../executable-size-reduction/research.md | 143 ++++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 codex-notes/executable-size-reduction/feature_list.json create mode 100644 codex-notes/executable-size-reduction/plan.md create mode 100644 codex-notes/executable-size-reduction/research.md diff --git a/codex-notes/executable-size-reduction/feature_list.json b/codex-notes/executable-size-reduction/feature_list.json new file mode 100644 index 0000000..193d4cf --- /dev/null +++ b/codex-notes/executable-size-reduction/feature_list.json @@ -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 + } + ] +} diff --git a/codex-notes/executable-size-reduction/plan.md b/codex-notes/executable-size-reduction/plan.md new file mode 100644 index 0000000..33d2c59 --- /dev/null +++ b/codex-notes/executable-size-reduction/plan.md @@ -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. diff --git a/codex-notes/executable-size-reduction/research.md b/codex-notes/executable-size-reduction/research.md new file mode 100644 index 0000000..e254345 --- /dev/null +++ b/codex-notes/executable-size-reduction/research.md @@ -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. +- `::output`: `6.8KiB`. +- `blake2::Blake2bVarCore::compress`: `6.1KiB`. +- `miniz_oxide::inflate::core::decompress`: `4.8KiB`. +- ` 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` 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`. +- Secret key data is either `[u8; RAW_KEY_LEN]` or `Zeroizing>`. +- 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. From 2ef6e09679bf922c8c0be882cdc2e23282dc4dac Mon Sep 17 00:00:00 2001 From: koba-e964 <3303362+koba-e964@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:33:46 +0900 Subject: [PATCH 3/4] ci: tighten executable size limits Why: - The executable-size reduction creates enough headroom to enforce stricter CI limits. What: - Lower Linux, macOS, and Windows release executable size thresholds. Impact: - No runtime behavior changes. - CI now fails earlier on binary-size regressions. - Limits retain headroom over observed PR sizes: Linux 682552, macOS 598048, Windows 458240. Prompt: - If the size decreased that much, you can make the size limits stricter. --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37da42e..19875cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,15 +32,15 @@ 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: Build release executable From 3cba2c52312312b1bb0b8ead15c5c659f4478be9 Mon Sep 17 00:00:00 2001 From: koba-e964 <3303362+koba-e964@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:21:50 +0900 Subject: [PATCH 4/4] ci: print compiler versions for size checks Why: - Executable-size comparisons need ambient compiler context, especially rustc version. What: - Print rustc --version --verbose and cargo --version --verbose in each executable-size matrix job before building. Impact: - No runtime behavior changes. - CI logs now capture compiler/tool versions for Linux, macOS, and Windows size measurements. Prompt: - Need to include ambient info. rustc version is necessary. --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19875cf..536420f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,10 @@ jobs: 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