From f5fa9b5cc494f284b2da63c2df3b751ce3bccc93 Mon Sep 17 00:00:00 2001 From: nmccready Date: Wed, 22 Apr 2026 22:11:06 -0400 Subject: [PATCH 1/2] feat: add Rust CLI implementation - Cargo.toml with clap, serde, chrono, anyhow - 4 modules: config, secrets, cache, resolve - Feature parity with shell version: TTL, first-login-of-day, force, secrets-file-change detection - 13 unit tests (secrets parsing, cache read/write, permissions, refresh logic) - Output formats: export (shell eval), dotenv, JSON - CI: tests.yml runs both shell (bats) and Rust (fmt + clippy + test) in parallel - rust-release.yml: cross-compile for linux/macOS amd64/arm64, upload to GitHub Releases - Cargo.lock committed (binary project) - Release profile: LTO, strip, size-optimized Co-Authored-By: Claude Opus 4.6 --- .github/workflows/rust-release.yml | 82 +++ .github/workflows/tests.yml | 35 +- .gitignore | 4 +- Cargo.lock | 783 +++++++++++++++++++++++++++++ Cargo.toml | 31 ++ src/cache.rs | 263 ++++++++++ src/config.rs | 25 + src/main.rs | 107 ++++ src/resolve.rs | 67 +++ src/secrets.rs | 141 ++++++ 10 files changed, 1534 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/rust-release.yml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/cache.rs create mode 100644 src/config.rs create mode 100644 src/main.rs create mode 100644 src/resolve.rs create mode 100644 src/secrets.rs diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml new file mode 100644 index 0000000..650cc96 --- /dev/null +++ b/.github/workflows/rust-release.yml @@ -0,0 +1,82 @@ +name: rust-release + +# Builds cross-platform Rust binaries and uploads to GitHub Releases. +# Triggered by auto-release creating a v* tag, or manually. +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g. v0.1.0)' + required: true + +permissions: + contents: write + +jobs: + build: + name: build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + artifact: envcache-linux-amd64 + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + artifact: envcache-linux-arm64 + - target: x86_64-apple-darwin + os: macos-latest + artifact: envcache-darwin-amd64 + - target: aarch64-apple-darwin + os: macos-latest + artifact: envcache-darwin-arm64 + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross-compilation tools (Linux ARM64) + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + + - name: Build release binary + run: cargo build --release --target ${{ matrix.target }} + + - name: Package binary + run: | + mkdir -p dist + cp target/${{ matrix.target }}/release/envcache dist/${{ matrix.artifact }} + chmod +x dist/${{ matrix.artifact }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: dist/${{ matrix.artifact }} + + upload: + name: upload to release + needs: [build] + runs-on: ubuntu-latest + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Upload binaries to release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.release.tag_name || github.event.inputs.tag }} + files: dist/**/* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c679f03..0b4ad55 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,8 @@ on: - '.devcontainer/**' - '.github/workflows/**' jobs: - test: + test-shell: + name: shell (lint + bats) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -21,9 +22,39 @@ jobs: - run: npm run lint - run: npm test + test-rust: + name: rust (fmt + clippy + test) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Check formatting + run: cargo fmt --check + + - name: Run clippy + run: cargo clippy -- -D warnings + + - name: Run tests + run: cargo test + tests: name: tests - needs: [test] + needs: [test-shell, test-rust] runs-on: ubuntu-latest if: always() steps: diff --git a/.gitignore b/.gitignore index e809ffd..409a479 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,6 @@ CLAUDE.md .envcache.secrets envcache.secrets -# Rust (future) +# Rust build artifacts /target/ -Cargo.lock +# Note: Cargo.lock IS committed — this is a binary, not a library diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..19f228c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,783 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "envcache" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5a2b891 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "envcache" +version = "0.1.0" +edition = "2021" +authors = ["Nicholas McCready"] +description = "Cache environment secrets from 1Password and other secret managers with TTL" +license = "MIT" +repository = "https://github.com/brickhouse-tech/envcache" +readme = "README.md" +keywords = ["env", "cache", "secrets", "1password", "dotenv"] +categories = ["command-line-utilities", "development-tools"] + +[[bin]] +name = "envcache" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive", "env"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = "0.4" +anyhow = "1" + +[dev-dependencies] +tempfile = "3" + +[profile.release] +opt-level = "z" +lto = true +strip = true +codegen-units = 1 diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..8349ff6 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,263 @@ +use anyhow::{Context, Result}; +use chrono::Local; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use crate::config::Config; +use crate::secrets; + +/// Metadata stored alongside the cache for freshness checks. +struct CacheMeta { + date: String, // YYYY-MM-DD + timestamp: i64, // epoch seconds + secrets_hash: String, // hash of secrets file +} + +/// Check if the cache needs to be refreshed. +pub fn needs_refresh(config: &Config) -> Result { + // Force refresh + if config.force { + return Ok(true); + } + + // No cache or meta file + if !Path::new(&config.cache_file).exists() || !Path::new(&config.meta_file).exists() { + return Ok(true); + } + + // Read meta + let meta = read_meta(config)?; + + // Secrets file changed + let current_hash = secrets::hash_secrets_file(&config.secrets_file)?; + if meta.secrets_hash != current_hash { + return Ok(true); + } + + let now = Local::now(); + + // First login of the day + let today = now.format("%Y-%m-%d").to_string(); + if meta.date != today { + return Ok(true); + } + + // TTL expired + let elapsed = now.timestamp() - meta.timestamp; + if elapsed > config.ttl as i64 { + return Ok(true); + } + + Ok(false) +} + +/// Write resolved secrets to the cache file and update meta. +pub fn write_cache(config: &Config, resolved: &[(String, String)]) -> Result<()> { + // Write cache file (export format) + let mut content = String::new(); + for (key, value) in resolved { + content.push_str(&format!( + "export {}='{}'\n", + key, + value.replace('\'', "'\\''") + )); + } + + fs::write(&config.cache_file, &content) + .with_context(|| format!("failed to write cache: {}", config.cache_file))?; + + // Set permissions to 600 + let perms = fs::Permissions::from_mode(0o600); + fs::set_permissions(&config.cache_file, perms)?; + + // Write meta + let now = Local::now(); + let secrets_hash = secrets::hash_secrets_file(&config.secrets_file)?; + let meta_content = format!( + "{}\n{}\n{}\n", + now.format("%Y-%m-%d"), + now.timestamp(), + secrets_hash + ); + + fs::write(&config.meta_file, &meta_content) + .with_context(|| format!("failed to write meta: {}", config.meta_file))?; + + let perms = fs::Permissions::from_mode(0o600); + fs::set_permissions(&config.meta_file, perms)?; + + Ok(()) +} + +/// Read resolved secrets from the cache file. +pub fn read_cache(config: &Config) -> Result> { + let content = fs::read_to_string(&config.cache_file) + .with_context(|| format!("failed to read cache: {}", config.cache_file))?; + + let mut resolved = Vec::new(); + for line in content.lines() { + // Parse: export KEY='value' + let line = line.trim(); + if let Some(rest) = line.strip_prefix("export ") { + if let Some(eq_pos) = rest.find('=') { + let key = rest[..eq_pos].to_string(); + let value = rest[eq_pos + 1..] + .trim_start_matches('\'') + .trim_end_matches('\'') + .to_string(); + resolved.push((key, value)); + } + } + } + + Ok(resolved) +} + +/// Get a human-readable cache age string. +pub fn cache_age(config: &Config) -> Result { + let meta = read_meta(config)?; + let now = Local::now().timestamp(); + let diff = now - meta.timestamp; + + let hours = diff / 3600; + let mins = (diff % 3600) / 60; + + if hours > 0 { + Ok(format!("{}h{}m old", hours, mins)) + } else { + Ok(format!("{}m old", mins)) + } +} + +/// Read the meta file. +fn read_meta(config: &Config) -> Result { + let content = fs::read_to_string(&config.meta_file) + .with_context(|| format!("failed to read meta: {}", config.meta_file))?; + + let lines: Vec<&str> = content.lines().collect(); + if lines.len() < 3 { + anyhow::bail!("malformed meta file: expected 3 lines, got {}", lines.len()); + } + + Ok(CacheMeta { + date: lines[0].to_string(), + timestamp: lines[1] + .parse() + .with_context(|| "failed to parse timestamp from meta")?, + secrets_hash: lines[2].to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::TempDir; + + fn setup_test_config(dir: &TempDir, secrets_content: &str) -> Config { + let secrets_path = dir.path().join(".envcache.secrets"); + let mut f = fs::File::create(&secrets_path).unwrap(); + f.write_all(secrets_content.as_bytes()).unwrap(); + + Config { + secrets_file: secrets_path.to_str().unwrap().to_string(), + cache_file: dir + .path() + .join(".envrc.cache") + .to_str() + .unwrap() + .to_string(), + meta_file: dir + .path() + .join(".envrc.cache.meta") + .to_str() + .unwrap() + .to_string(), + ttl: 28800, + force: false, + } + } + + #[test] + fn test_needs_refresh_no_cache() { + let dir = TempDir::new().unwrap(); + let config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); + assert!(needs_refresh(&config).unwrap()); + } + + #[test] + fn test_needs_refresh_force() { + let dir = TempDir::new().unwrap(); + let mut config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); + config.force = true; + assert!(needs_refresh(&config).unwrap()); + } + + #[test] + fn test_write_and_read_cache() { + let dir = TempDir::new().unwrap(); + let config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); + + let secrets = vec![ + ("MY_KEY".to_string(), "secret-value".to_string()), + ("OTHER".to_string(), "other-value".to_string()), + ]; + + write_cache(&config, &secrets).unwrap(); + let read_back = read_cache(&config).unwrap(); + + assert_eq!(read_back.len(), 2); + assert_eq!( + read_back[0], + ("MY_KEY".to_string(), "secret-value".to_string()) + ); + assert_eq!( + read_back[1], + ("OTHER".to_string(), "other-value".to_string()) + ); + } + + #[test] + fn test_cache_permissions() { + let dir = TempDir::new().unwrap(); + let config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); + + let secrets = vec![("KEY".to_string(), "val".to_string())]; + write_cache(&config, &secrets).unwrap(); + + let metadata = fs::metadata(&config.cache_file).unwrap(); + let mode = metadata.permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + + #[test] + fn test_needs_refresh_after_write() { + let dir = TempDir::new().unwrap(); + let config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); + + let secrets = vec![("KEY".to_string(), "val".to_string())]; + write_cache(&config, &secrets).unwrap(); + + // Should not need refresh — cache is fresh + assert!(!needs_refresh(&config).unwrap()); + } + + #[test] + fn test_needs_refresh_secrets_changed() { + let dir = TempDir::new().unwrap(); + let config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); + + let secrets = vec![("KEY".to_string(), "val".to_string())]; + write_cache(&config, &secrets).unwrap(); + + // Modify secrets file + fs::write( + &config.secrets_file, + "VAR|op://vault/item/field\nNEW|op://vault/item/new\n", + ) + .unwrap(); + + assert!(needs_refresh(&config).unwrap()); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..f7e2e5e --- /dev/null +++ b/src/config.rs @@ -0,0 +1,25 @@ +/// Configuration for envcache, resolved from CLI args, env vars, or defaults. +pub struct Config { + pub secrets_file: String, + pub cache_file: String, + pub meta_file: String, + pub ttl: u64, + pub force: bool, +} + +impl Config { + pub fn default_secrets_file() -> String { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + format!("{}/.envcache.secrets", home) + } + + pub fn default_cache_file() -> String { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + format!("{}/.envrc.cache", home) + } + + pub fn default_meta_file() -> String { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + format!("{}/.envrc.cache.meta", home) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..fb42a55 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,107 @@ +mod cache; +mod config; +mod resolve; +mod secrets; + +use anyhow::Result; +use clap::Parser; +use config::Config; + +/// Cache environment secrets from 1Password (and other secret managers) with TTL. +/// +/// Resolves secrets once, caches the results, and reuses them until a refresh +/// is triggered by TTL expiry, first login of the day, secrets file change, +/// or the --force flag. +#[derive(Parser, Debug)] +#[command(name = "envcache", version, about)] +struct Cli { + /// Force refresh, ignoring cache + #[arg(short, long)] + force: bool, + + /// Path to secrets definition file + #[arg(short, long, env = "ENVCACHE_SECRETS_FILE")] + secrets_file: Option, + + /// Path to cache file + #[arg(short, long, env = "ENVCACHE_CACHE_FILE")] + cache_file: Option, + + /// Path to meta file + #[arg(short, long, env = "ENVCACHE_META_FILE")] + meta_file: Option, + + /// Cache TTL in seconds (default: 28800 = 8 hours) + #[arg(short, long, env = "ENVCACHE_TTL", default_value = "28800")] + ttl: u64, + + /// Output format for shell eval + #[arg(long, default_value = "export")] + format: OutputFormat, +} + +#[derive(Debug, Clone, clap::ValueEnum)] +enum OutputFormat { + /// export VAR=value (for bash/zsh eval) + Export, + /// VAR=value (for .env file format) + Dotenv, + /// JSON object + Json, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + let config = Config { + secrets_file: cli + .secrets_file + .unwrap_or_else(Config::default_secrets_file), + cache_file: cli.cache_file.unwrap_or_else(Config::default_cache_file), + meta_file: cli.meta_file.unwrap_or_else(Config::default_meta_file), + ttl: cli.ttl, + force: cli.force, + }; + + let secrets = secrets::parse_secrets_file(&config.secrets_file)?; + + let resolved = if cache::needs_refresh(&config)? { + eprintln!("[envcache] resolving secrets from 1Password..."); + let resolved = resolve::resolve_secrets(&secrets)?; + let errors = secrets.len() - resolved.len(); + cache::write_cache(&config, &resolved)?; + eprintln!( + "[envcache] resolved {} secrets ({} errors)", + resolved.len(), + errors + ); + resolved + } else { + let age = cache::cache_age(&config)?; + eprintln!("[envcache] using cache ({})", age); + cache::read_cache(&config)? + }; + + // Output resolved secrets in the requested format + match cli.format { + OutputFormat::Export => { + for (key, value) in &resolved { + println!("export {}='{}'", key, value.replace('\'', "'\\''")); + } + } + OutputFormat::Dotenv => { + for (key, value) in &resolved { + println!("{}={}", key, value); + } + } + OutputFormat::Json => { + let map: std::collections::HashMap<&str, &str> = resolved + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + println!("{}", serde_json::to_string_pretty(&map)?); + } + } + + Ok(()) +} diff --git a/src/resolve.rs b/src/resolve.rs new file mode 100644 index 0000000..93ba405 --- /dev/null +++ b/src/resolve.rs @@ -0,0 +1,67 @@ +use anyhow::Result; +use std::process::Command; + +use crate::secrets::{PostProcessor, SecretDef}; + +/// Resolve all secrets by calling `op read` for each definition. +/// Returns a Vec of (var_name, resolved_value) pairs. +pub fn resolve_secrets(secrets: &[SecretDef]) -> Result> { + let mut resolved = Vec::new(); + + for secret in secrets { + match resolve_one(&secret.op_uri) { + Ok(mut value) => { + // Apply post-processing + if let Some(ref processor) = secret.post_processor { + value = apply_post_processor(&value, processor); + } + resolved.push((secret.var_name.clone(), value)); + } + Err(e) => { + eprintln!( + "[envcache] WARNING: failed to resolve {}: {}", + secret.var_name, e + ); + } + } + } + + Ok(resolved) +} + +/// Resolve a single secret via `op read`. +fn resolve_one(op_uri: &str) -> Result { + let output = Command::new("op").args(["read", op_uri]).output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("op read failed: {}", stderr.trim()); + } + + let value = String::from_utf8(output.stdout)?; + Ok(value.trim_end_matches('\n').to_string()) +} + +/// Apply a post-processor to a resolved value. +fn apply_post_processor(value: &str, processor: &PostProcessor) -> String { + match processor { + PostProcessor::StripWhitespace => value.chars().filter(|c| !c.is_whitespace()).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_whitespace() { + let result = apply_post_processor("hello \n world\r\n", &PostProcessor::StripWhitespace); + assert_eq!(result, "helloworld"); + } + + #[test] + fn test_strip_whitespace_no_change() { + let result = apply_post_processor("clean-value", &PostProcessor::StripWhitespace); + assert_eq!(result, "clean-value"); + } +} diff --git a/src/secrets.rs b/src/secrets.rs new file mode 100644 index 0000000..031afea --- /dev/null +++ b/src/secrets.rs @@ -0,0 +1,141 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::Path; + +/// A secret definition parsed from the secrets file. +#[derive(Debug, Clone)] +pub struct SecretDef { + pub var_name: String, + pub op_uri: String, + pub post_processor: Option, +} + +#[derive(Debug, Clone)] +pub enum PostProcessor { + StripWhitespace, +} + +/// Parse the secrets file into a list of secret definitions. +/// +/// Format: VAR_NAME|op://vault/item/field[|strip_whitespace] +/// Lines starting with # are comments. Blank lines are ignored. +pub fn parse_secrets_file(path: &str) -> Result> { + let path = Path::new(path); + if !path.exists() { + anyhow::bail!( + "secrets file not found: {}\nCreate it with lines like: MY_VAR|op://vault/item/field", + path.display() + ); + } + + let content = fs::read_to_string(path) + .with_context(|| format!("failed to read secrets file: {}", path.display()))?; + + let mut secrets = Vec::new(); + + for line in content.lines() { + let line = line.trim(); + + // Skip comments and blank lines + if line.is_empty() || line.starts_with('#') { + continue; + } + + let parts: Vec<&str> = line.splitn(3, '|').collect(); + if parts.len() < 2 { + eprintln!("[envcache] WARNING: skipping malformed line: {}", line); + continue; + } + + let var_name = parts[0].to_string(); + let op_uri = parts[1].to_string(); + let post_processor = parts.get(2).and_then(|p| match *p { + "strip_whitespace" => Some(PostProcessor::StripWhitespace), + other => { + eprintln!( + "[envcache] WARNING: unknown post-processor '{}' for {}", + other, var_name + ); + None + } + }); + + secrets.push(SecretDef { + var_name, + op_uri, + post_processor, + }); + } + + Ok(secrets) +} + +/// Compute a hash of the secrets file for change detection. +pub fn hash_secrets_file(path: &str) -> Result { + let content = fs::read(path) + .with_context(|| format!("failed to read secrets file for hashing: {}", path))?; + + // Simple hash: use the content length + first/last bytes + a rolling sum. + // Not cryptographic, just for change detection (like md5 in the shell version). + let mut hash: u64 = content.len() as u64; + for (i, byte) in content.iter().enumerate() { + hash = hash.wrapping_add((*byte as u64).wrapping_mul(i as u64 + 1)); + } + Ok(format!("{:016x}", hash)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn write_temp_secrets(content: &str) -> NamedTempFile { + let mut f = NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f + } + + #[test] + fn test_parse_basic() { + let f = write_temp_secrets("MY_VAR|op://vault/item/password\n"); + let secrets = parse_secrets_file(f.path().to_str().unwrap()).unwrap(); + assert_eq!(secrets.len(), 1); + assert_eq!(secrets[0].var_name, "MY_VAR"); + assert_eq!(secrets[0].op_uri, "op://vault/item/password"); + assert!(secrets[0].post_processor.is_none()); + } + + #[test] + fn test_parse_with_post_processor() { + let f = write_temp_secrets("TOKEN|op://vault/item/password|strip_whitespace\n"); + let secrets = parse_secrets_file(f.path().to_str().unwrap()).unwrap(); + assert_eq!(secrets.len(), 1); + assert!(matches!( + secrets[0].post_processor, + Some(PostProcessor::StripWhitespace) + )); + } + + #[test] + fn test_parse_skips_comments_and_blanks() { + let f = write_temp_secrets("# comment\n\nMY_VAR|op://vault/item/field\n# another\n"); + let secrets = parse_secrets_file(f.path().to_str().unwrap()).unwrap(); + assert_eq!(secrets.len(), 1); + } + + #[test] + fn test_parse_missing_file() { + let result = parse_secrets_file("/tmp/nonexistent-envcache-test"); + assert!(result.is_err()); + } + + #[test] + fn test_hash_changes_on_content_change() { + let f1 = write_temp_secrets("VAR1|op://vault/item/field\n"); + let f2 = write_temp_secrets("VAR1|op://vault/item/field\nVAR2|op://vault/item/other\n"); + let h1 = hash_secrets_file(f1.path().to_str().unwrap()).unwrap(); + let h2 = hash_secrets_file(f2.path().to_str().unwrap()).unwrap(); + assert_ne!(h1, h2); + } +} From bbd451642fcb1cfc1f8f87fb32b52cf4e927f27f Mon Sep 17 00:00:00 2001 From: nmccready Date: Wed, 22 Apr 2026 22:30:08 -0400 Subject: [PATCH 2/2] refactor: move inline tests to tests/ directory (integration test convention) - Create src/lib.rs to expose modules as a library crate alongside the binary - Move #[cfg(test)] modules from cache.rs, secrets.rs, resolve.rs into tests/ - Make resolve::apply_post_processor pub for integration test access - Update main.rs to import from the library crate instead of local modules - Add [lib] section and exclude = ["tests/"] to Cargo.toml Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 5 +++ src/cache.rs | 113 ----------------------------------------------- src/lib.rs | 4 ++ src/main.rs | 10 ++--- src/resolve.rs | 19 +------- src/secrets.rs | 56 ----------------------- tests/cache.rs | 112 ++++++++++++++++++++++++++++++++++++++++++++++ tests/resolve.rs | 14 ++++++ tests/secrets.rs | 52 ++++++++++++++++++++++ 9 files changed, 192 insertions(+), 193 deletions(-) create mode 100644 src/lib.rs create mode 100644 tests/cache.rs create mode 100644 tests/resolve.rs create mode 100644 tests/secrets.rs diff --git a/Cargo.toml b/Cargo.toml index 5a2b891..e775347 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,11 @@ repository = "https://github.com/brickhouse-tech/envcache" readme = "README.md" keywords = ["env", "cache", "secrets", "1password", "dotenv"] categories = ["command-line-utilities", "development-tools"] +exclude = ["tests/"] + +[lib] +name = "envcache" +path = "src/lib.rs" [[bin]] name = "envcache" diff --git a/src/cache.rs b/src/cache.rs index 8349ff6..8bb2f7d 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -148,116 +148,3 @@ fn read_meta(config: &Config) -> Result { secrets_hash: lines[2].to_string(), }) } - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use tempfile::TempDir; - - fn setup_test_config(dir: &TempDir, secrets_content: &str) -> Config { - let secrets_path = dir.path().join(".envcache.secrets"); - let mut f = fs::File::create(&secrets_path).unwrap(); - f.write_all(secrets_content.as_bytes()).unwrap(); - - Config { - secrets_file: secrets_path.to_str().unwrap().to_string(), - cache_file: dir - .path() - .join(".envrc.cache") - .to_str() - .unwrap() - .to_string(), - meta_file: dir - .path() - .join(".envrc.cache.meta") - .to_str() - .unwrap() - .to_string(), - ttl: 28800, - force: false, - } - } - - #[test] - fn test_needs_refresh_no_cache() { - let dir = TempDir::new().unwrap(); - let config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); - assert!(needs_refresh(&config).unwrap()); - } - - #[test] - fn test_needs_refresh_force() { - let dir = TempDir::new().unwrap(); - let mut config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); - config.force = true; - assert!(needs_refresh(&config).unwrap()); - } - - #[test] - fn test_write_and_read_cache() { - let dir = TempDir::new().unwrap(); - let config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); - - let secrets = vec![ - ("MY_KEY".to_string(), "secret-value".to_string()), - ("OTHER".to_string(), "other-value".to_string()), - ]; - - write_cache(&config, &secrets).unwrap(); - let read_back = read_cache(&config).unwrap(); - - assert_eq!(read_back.len(), 2); - assert_eq!( - read_back[0], - ("MY_KEY".to_string(), "secret-value".to_string()) - ); - assert_eq!( - read_back[1], - ("OTHER".to_string(), "other-value".to_string()) - ); - } - - #[test] - fn test_cache_permissions() { - let dir = TempDir::new().unwrap(); - let config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); - - let secrets = vec![("KEY".to_string(), "val".to_string())]; - write_cache(&config, &secrets).unwrap(); - - let metadata = fs::metadata(&config.cache_file).unwrap(); - let mode = metadata.permissions().mode() & 0o777; - assert_eq!(mode, 0o600); - } - - #[test] - fn test_needs_refresh_after_write() { - let dir = TempDir::new().unwrap(); - let config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); - - let secrets = vec![("KEY".to_string(), "val".to_string())]; - write_cache(&config, &secrets).unwrap(); - - // Should not need refresh — cache is fresh - assert!(!needs_refresh(&config).unwrap()); - } - - #[test] - fn test_needs_refresh_secrets_changed() { - let dir = TempDir::new().unwrap(); - let config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); - - let secrets = vec![("KEY".to_string(), "val".to_string())]; - write_cache(&config, &secrets).unwrap(); - - // Modify secrets file - fs::write( - &config.secrets_file, - "VAR|op://vault/item/field\nNEW|op://vault/item/new\n", - ) - .unwrap(); - - assert!(needs_refresh(&config).unwrap()); - } -} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..24281c7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,4 @@ +pub mod cache; +pub mod config; +pub mod resolve; +pub mod secrets; diff --git a/src/main.rs b/src/main.rs index fb42a55..19a9154 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,9 @@ -mod cache; -mod config; -mod resolve; -mod secrets; - use anyhow::Result; use clap::Parser; -use config::Config; +use envcache::cache; +use envcache::config::Config; +use envcache::resolve; +use envcache::secrets; /// Cache environment secrets from 1Password (and other secret managers) with TTL. /// diff --git a/src/resolve.rs b/src/resolve.rs index 93ba405..5763ffb 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -43,25 +43,8 @@ fn resolve_one(op_uri: &str) -> Result { } /// Apply a post-processor to a resolved value. -fn apply_post_processor(value: &str, processor: &PostProcessor) -> String { +pub fn apply_post_processor(value: &str, processor: &PostProcessor) -> String { match processor { PostProcessor::StripWhitespace => value.chars().filter(|c| !c.is_whitespace()).collect(), } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_strip_whitespace() { - let result = apply_post_processor("hello \n world\r\n", &PostProcessor::StripWhitespace); - assert_eq!(result, "helloworld"); - } - - #[test] - fn test_strip_whitespace_no_change() { - let result = apply_post_processor("clean-value", &PostProcessor::StripWhitespace); - assert_eq!(result, "clean-value"); - } -} diff --git a/src/secrets.rs b/src/secrets.rs index 031afea..0b51e45 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -83,59 +83,3 @@ pub fn hash_secrets_file(path: &str) -> Result { } Ok(format!("{:016x}", hash)) } - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use tempfile::NamedTempFile; - - fn write_temp_secrets(content: &str) -> NamedTempFile { - let mut f = NamedTempFile::new().unwrap(); - f.write_all(content.as_bytes()).unwrap(); - f - } - - #[test] - fn test_parse_basic() { - let f = write_temp_secrets("MY_VAR|op://vault/item/password\n"); - let secrets = parse_secrets_file(f.path().to_str().unwrap()).unwrap(); - assert_eq!(secrets.len(), 1); - assert_eq!(secrets[0].var_name, "MY_VAR"); - assert_eq!(secrets[0].op_uri, "op://vault/item/password"); - assert!(secrets[0].post_processor.is_none()); - } - - #[test] - fn test_parse_with_post_processor() { - let f = write_temp_secrets("TOKEN|op://vault/item/password|strip_whitespace\n"); - let secrets = parse_secrets_file(f.path().to_str().unwrap()).unwrap(); - assert_eq!(secrets.len(), 1); - assert!(matches!( - secrets[0].post_processor, - Some(PostProcessor::StripWhitespace) - )); - } - - #[test] - fn test_parse_skips_comments_and_blanks() { - let f = write_temp_secrets("# comment\n\nMY_VAR|op://vault/item/field\n# another\n"); - let secrets = parse_secrets_file(f.path().to_str().unwrap()).unwrap(); - assert_eq!(secrets.len(), 1); - } - - #[test] - fn test_parse_missing_file() { - let result = parse_secrets_file("/tmp/nonexistent-envcache-test"); - assert!(result.is_err()); - } - - #[test] - fn test_hash_changes_on_content_change() { - let f1 = write_temp_secrets("VAR1|op://vault/item/field\n"); - let f2 = write_temp_secrets("VAR1|op://vault/item/field\nVAR2|op://vault/item/other\n"); - let h1 = hash_secrets_file(f1.path().to_str().unwrap()).unwrap(); - let h2 = hash_secrets_file(f2.path().to_str().unwrap()).unwrap(); - assert_ne!(h1, h2); - } -} diff --git a/tests/cache.rs b/tests/cache.rs new file mode 100644 index 0000000..7278324 --- /dev/null +++ b/tests/cache.rs @@ -0,0 +1,112 @@ +use envcache::cache::{needs_refresh, read_cache, write_cache}; +use envcache::config::Config; +use std::fs; +use std::io::Write; +use std::os::unix::fs::PermissionsExt; +use tempfile::TempDir; + +fn setup_test_config(dir: &TempDir, secrets_content: &str) -> Config { + let secrets_path = dir.path().join(".envcache.secrets"); + let mut f = fs::File::create(&secrets_path).unwrap(); + f.write_all(secrets_content.as_bytes()).unwrap(); + + Config { + secrets_file: secrets_path.to_str().unwrap().to_string(), + cache_file: dir + .path() + .join(".envrc.cache") + .to_str() + .unwrap() + .to_string(), + meta_file: dir + .path() + .join(".envrc.cache.meta") + .to_str() + .unwrap() + .to_string(), + ttl: 28800, + force: false, + } +} + +#[test] +fn test_needs_refresh_no_cache() { + let dir = TempDir::new().unwrap(); + let config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); + assert!(needs_refresh(&config).unwrap()); +} + +#[test] +fn test_needs_refresh_force() { + let dir = TempDir::new().unwrap(); + let mut config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); + config.force = true; + assert!(needs_refresh(&config).unwrap()); +} + +#[test] +fn test_write_and_read_cache() { + let dir = TempDir::new().unwrap(); + let config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); + + let secrets = vec![ + ("MY_KEY".to_string(), "secret-value".to_string()), + ("OTHER".to_string(), "other-value".to_string()), + ]; + + write_cache(&config, &secrets).unwrap(); + let read_back = read_cache(&config).unwrap(); + + assert_eq!(read_back.len(), 2); + assert_eq!( + read_back[0], + ("MY_KEY".to_string(), "secret-value".to_string()) + ); + assert_eq!( + read_back[1], + ("OTHER".to_string(), "other-value".to_string()) + ); +} + +#[test] +fn test_cache_permissions() { + let dir = TempDir::new().unwrap(); + let config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); + + let secrets = vec![("KEY".to_string(), "val".to_string())]; + write_cache(&config, &secrets).unwrap(); + + let metadata = fs::metadata(&config.cache_file).unwrap(); + let mode = metadata.permissions().mode() & 0o777; + assert_eq!(mode, 0o600); +} + +#[test] +fn test_needs_refresh_after_write() { + let dir = TempDir::new().unwrap(); + let config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); + + let secrets = vec![("KEY".to_string(), "val".to_string())]; + write_cache(&config, &secrets).unwrap(); + + // Should not need refresh — cache is fresh + assert!(!needs_refresh(&config).unwrap()); +} + +#[test] +fn test_needs_refresh_secrets_changed() { + let dir = TempDir::new().unwrap(); + let config = setup_test_config(&dir, "VAR|op://vault/item/field\n"); + + let secrets = vec![("KEY".to_string(), "val".to_string())]; + write_cache(&config, &secrets).unwrap(); + + // Modify secrets file + fs::write( + &config.secrets_file, + "VAR|op://vault/item/field\nNEW|op://vault/item/new\n", + ) + .unwrap(); + + assert!(needs_refresh(&config).unwrap()); +} diff --git a/tests/resolve.rs b/tests/resolve.rs new file mode 100644 index 0000000..3e72035 --- /dev/null +++ b/tests/resolve.rs @@ -0,0 +1,14 @@ +use envcache::resolve::apply_post_processor; +use envcache::secrets::PostProcessor; + +#[test] +fn test_strip_whitespace() { + let result = apply_post_processor("hello \n world\r\n", &PostProcessor::StripWhitespace); + assert_eq!(result, "helloworld"); +} + +#[test] +fn test_strip_whitespace_no_change() { + let result = apply_post_processor("clean-value", &PostProcessor::StripWhitespace); + assert_eq!(result, "clean-value"); +} diff --git a/tests/secrets.rs b/tests/secrets.rs new file mode 100644 index 0000000..0fcfd76 --- /dev/null +++ b/tests/secrets.rs @@ -0,0 +1,52 @@ +use envcache::secrets::{hash_secrets_file, parse_secrets_file, PostProcessor}; +use std::io::Write; +use tempfile::NamedTempFile; + +fn write_temp_secrets(content: &str) -> NamedTempFile { + let mut f = NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f +} + +#[test] +fn test_parse_basic() { + let f = write_temp_secrets("MY_VAR|op://vault/item/password\n"); + let secrets = parse_secrets_file(f.path().to_str().unwrap()).unwrap(); + assert_eq!(secrets.len(), 1); + assert_eq!(secrets[0].var_name, "MY_VAR"); + assert_eq!(secrets[0].op_uri, "op://vault/item/password"); + assert!(secrets[0].post_processor.is_none()); +} + +#[test] +fn test_parse_with_post_processor() { + let f = write_temp_secrets("TOKEN|op://vault/item/password|strip_whitespace\n"); + let secrets = parse_secrets_file(f.path().to_str().unwrap()).unwrap(); + assert_eq!(secrets.len(), 1); + assert!(matches!( + secrets[0].post_processor, + Some(PostProcessor::StripWhitespace) + )); +} + +#[test] +fn test_parse_skips_comments_and_blanks() { + let f = write_temp_secrets("# comment\n\nMY_VAR|op://vault/item/field\n# another\n"); + let secrets = parse_secrets_file(f.path().to_str().unwrap()).unwrap(); + assert_eq!(secrets.len(), 1); +} + +#[test] +fn test_parse_missing_file() { + let result = parse_secrets_file("/tmp/nonexistent-envcache-test"); + assert!(result.is_err()); +} + +#[test] +fn test_hash_changes_on_content_change() { + let f1 = write_temp_secrets("VAR1|op://vault/item/field\n"); + let f2 = write_temp_secrets("VAR1|op://vault/item/field\nVAR2|op://vault/item/other\n"); + let h1 = hash_secrets_file(f1.path().to_str().unwrap()).unwrap(); + let h2 = hash_secrets_file(f2.path().to_str().unwrap()).unwrap(); + assert_ne!(h1, h2); +}