diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..64de6ec --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "nu /Users/joe/dev/notfiles/scripts/preflight.nu" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "bash -c 'if [ -f Cargo.toml ]; then cargo check --quiet 2>&1 | head -20; fi'" + } + ] + } + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..ca90aa8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,27 @@ +{ + "permissions": { + "allow": [ + "Bash(doob:*)", + "Bash(echo:*)", + "Bash(gh:*)", + "Bash(op:*)", + "Bash(obfsck:*)", + "Bash(head:*)", + "Bash(git ls-files:*)", + "WebSearch", + "WebFetch(domain:www.apache.org)", + "Skill(todo:*)", + "Bash(PATH=\"/Users/joe/.local/share/mise/shims:$PATH\" which nu)", + "Bash(PATH=\"/Users/joe/.local/share/mise/shims:$PATH\" nu --version 2>&1 | head -1)", + "Bash(doob todo:*)", + "Bash(handoff-detect 2>&1; echo \"exit: $?\")", + "Bash(handoff-detect --name && handoff-detect --project)", + "Bash(rtk gain:*)", + "Bash(rtk rewrite:*)", + "Bash(rtk grep:*)", + "Bash(nu -c '\nlet result = \\(do { python3 -c \"import json; print\\(json.dumps\\({\\\\\"ok\\\\\": True}\\)\\)\" } | complete\\)\nprint $result.stdout\n')", + "Bash(echo '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"grep -n \\\\\"^foo\\\\\" /file.md\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu)", + "Bash(echo '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"grep -c pattern file.txt\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu\necho '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"grep -i \\\\\"Hello World\\\\\" /dir/\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu\necho '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"git status\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu; echo \"exit: $?\")" + ] + } +} diff --git a/.claude/worktrees/agent-a0b9834e b/.claude/worktrees/agent-a0b9834e new file mode 160000 index 0000000..11d329c --- /dev/null +++ b/.claude/worktrees/agent-a0b9834e @@ -0,0 +1 @@ +Subproject commit 11d329c5a2a2df0c785dfcf3025fc52828923ff0 diff --git a/.claude/worktrees/agent-a27fe5f2 b/.claude/worktrees/agent-a27fe5f2 new file mode 160000 index 0000000..0d60583 --- /dev/null +++ b/.claude/worktrees/agent-a27fe5f2 @@ -0,0 +1 @@ +Subproject commit 0d60583c65593a47a05e872c05a0805f720a7f72 diff --git a/.claude/worktrees/agent-a6b81d3b b/.claude/worktrees/agent-a6b81d3b new file mode 160000 index 0000000..11d329c --- /dev/null +++ b/.claude/worktrees/agent-a6b81d3b @@ -0,0 +1 @@ +Subproject commit 11d329c5a2a2df0c785dfcf3025fc52828923ff0 diff --git a/.claude/worktrees/agent-af9e1ed8 b/.claude/worktrees/agent-af9e1ed8 new file mode 160000 index 0000000..11d329c --- /dev/null +++ b/.claude/worktrees/agent-af9e1ed8 @@ -0,0 +1 @@ +Subproject commit 11d329c5a2a2df0c785dfcf3025fc52828923ff0 diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..e6284b9 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "bash -c 'if [ -f Cargo.toml ]; then cargo check --quiet 2>&1 | head -20; fi'" + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "nu /Users/joe/dev/notfiles/scripts/preflight.nu" + } + ] + } + ] + } +} diff --git a/.ctx/HANDOFF.md b/.ctx/HANDOFF.md new file mode 100644 index 0000000..54c4cd9 --- /dev/null +++ b/.ctx/HANDOFF.md @@ -0,0 +1,16 @@ +# Handoff — notfiles (2026-07-07) + +**Branch:** main | **Build:** unknown | **Tests:** unknown + +## Items + +| ID | P | Status | Title | +|---|---|---|---| +| uncommitted-work | P1 | open | Uncommitted changes (3 files) | + +## Log + +- 20260606.135918: done=13 running=0 pending=0 blocked=0 +- 2026-04-17: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete +- 2026-04-15: Hardened bootstrap and hook-state failure handling, made notfiles FileStore usage real, and fixed copy-drift safety coverage +- 2026-04-15: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work diff --git a/.ctx/HANDOFF.notfiles.notfiles.state.yaml b/.ctx/HANDOFF.notfiles.notfiles.state.yaml new file mode 100644 index 0000000..a8d4d59 --- /dev/null +++ b/.ctx/HANDOFF.notfiles.notfiles.state.yaml @@ -0,0 +1,10 @@ +updated: 2026-04-17 +branch: main +build: clean +tests: passing +notes: Added notnet crate with YubikeySource and threaded Tailscale into notstrap +touched_files: + - .ctx/HANDOFF.notfiles.notfiles.yaml + - Cargo.toml + - crates/notnet/src/lib.rs + - crates/notstrap/src/lib.rs diff --git a/.ctx/HANDOFF.notfiles.notfiles.yaml b/.ctx/HANDOFF.notfiles.notfiles.yaml new file mode 100644 index 0000000..07eaa4a --- /dev/null +++ b/.ctx/HANDOFF.notfiles.notfiles.yaml @@ -0,0 +1,39 @@ +project: notfiles +id: notfile +updated: 2026-07-08 +items: +- id: uncommitted-work + doob_uuid: null + name: uncommitted-work + priority: P1 + status: open + title: Uncommitted changes (3 files) + description: |- + Working tree has uncommitted changes: + .claude/worktrees/agent-a27fe5f2 + .claude/settings.local.json + .superpowers/brainstorm/72758-1776397235/state/server-stopped + files: + - .claude/worktrees/agent-a27fe5f2 + - .claude/settings.local.json + - .superpowers/brainstorm/72758-1776397235/state/server-stopped + completed: null + extra: [] +log: +- date: 2026-07-08 + summary: hexagonal architecture refactor with adapters and integration tests, notsecrets cleanup, config payload moved to notfiles-config repo + commits: + - 6427d18 + - 25624c9 +- date: '20260606.135918' + summary: done=13 running=0 pending=0 blocked=0 + commits: [] +- date: 2026-04-17 + summary: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete + commits: [] +- date: 2026-04-15 + summary: Hardened bootstrap and hook-state failure handling, made notfiles FileStore usage real, and fixed copy-drift safety coverage + commits: [] +- date: 2026-04-15 + summary: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work + commits: [] diff --git a/.gitea/workflows/public-ready.yml b/.gitea/workflows/public-ready.yml index 0d79971..8296853 100644 --- a/.gitea/workflows/public-ready.yml +++ b/.gitea/workflows/public-ready.yml @@ -108,9 +108,9 @@ jobs: - name: Public readiness summary if: success() run: | - echo "✅ No secrets or PII detected" - echo "✅ No hardcoded private addresses" - echo "✅ Licenses are OSS-compatible" - echo "✅ No secrets files tracked in git" + echo "[OK] No secrets or PII detected" + echo "[OK] No hardcoded private addresses" + echo "[OK] Licenses are OSS-compatible" + echo "[OK] No secrets files tracked in git" echo "" echo "This repository appears safe to mirror to a public GitHub repo." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..60591fd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [main, "feature/*", "hotfix/*", "chore/*"] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + compile-check: + name: Compile, Format & Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo check --workspace + - run: cargo fmt --all --check + - run: cargo clippy --workspace -- -D warnings + + test: + name: Test + runs-on: ubuntu-latest + needs: compile-check + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: hustcer/setup-nu@v3 + with: + version: 0.112.2 + - uses: taiki-e/install-action@v2 + with: + tool: cargo-nextest + - run: cargo nextest run --workspace + + audit: + name: Audit & Deny + runs-on: ubuntu-latest + needs: compile-check + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/feature/') || startsWith(github.ref, 'refs/heads/hotfix/') + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: EmbarkStudios/cargo-deny-action@v2 + - uses: taiki-e/install-action@v2 + with: + tool: cargo-audit + - run: cargo audit + + machete: + name: Unused Dependencies + runs-on: ubuntu-latest + needs: compile-check + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/feature/') || startsWith(github.ref, 'refs/heads/hotfix/') + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@v2 + with: + tool: cargo-machete + - run: cargo machete diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..a443ad1 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,28 @@ +name: Nightly + +on: + schedule: + - cron: "0 2 * * *" + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + geiger: + name: Unsafe Audit (geiger) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo install cargo-geiger --locked + - run: cargo geiger --workspace 2>&1 | tee geiger-report.txt + - run: | + echo "## Geiger Unsafe Audit" >> $GITHUB_STEP_SUMMARY + tee -a $GITHUB_STEP_SUMMARY < geiger-report.txt + - uses: actions/upload-artifact@v4 + with: + name: geiger-report + path: geiger-report.txt + retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..177469b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,198 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Semver version to apply to affected crates, without leading v' + required: true + type: string + +env: + CARGO_TERM_COLOR: always + +jobs: + release: + name: Bump, Tag, and Release + runs-on: ubuntu-latest + if: github.ref_name == 'main' + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Install tooling + uses: taiki-e/install-action@v2 + with: + tool: cargo-edit,cargo-nextest + + - name: Validate version input + env: + VERSION: ${{ inputs.version }} + run: | + if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$'; then + echo "invalid semver: $VERSION" >&2 + exit 1 + fi + if git rev-parse "v$VERSION" >/dev/null 2>&1 || git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null 2>&1; then + echo "tag v$VERSION already exists" >&2 + exit 1 + fi + + - name: Determine affected crates since last release + id: affected-crates + shell: bash + run: | + set -euo pipefail + if last_tag=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null); then + range="$last_tag..HEAD" + else + range="HEAD" + fi + + if [ "$range" = "HEAD" ]; then + git ls-files > changed-files.txt + else + git diff --name-only "$range" > changed-files.txt + fi + + cargo metadata --no-deps --format-version 1 > metadata.json + + python3 <<'PY' + import json + from collections import defaultdict, deque + from pathlib import Path + + changed_files = [line.strip() for line in Path("changed-files.txt").read_text().splitlines() if line.strip()] + metadata = json.loads(Path("metadata.json").read_text()) + + workspace_ids = set(metadata["workspace_members"]) + packages = [pkg for pkg in metadata["packages"] if pkg["id"] in workspace_ids] + + package_dirs = {} + reverse_deps = defaultdict(set) + package_names = set() + + for pkg in packages: + name = pkg["name"] + package_names.add(name) + package_dirs[name] = Path(pkg["manifest_path"]).parent.as_posix() + + for pkg in packages: + name = pkg["name"] + for dep in pkg.get("dependencies", []): + dep_name = dep.get("name") + if dep_name in package_names: + reverse_deps[dep_name].add(name) + + direct = set() + shared_change = False + + for changed in changed_files: + matched = False + for name, pkg_dir in package_dirs.items(): + if changed == pkg_dir or changed.startswith(pkg_dir + "/"): + direct.add(name) + matched = True + if not matched and not changed.startswith('.github/workflows/release.yml'): + shared_change = True + + if shared_change: + direct.update(package_names) + + affected = set(direct) + queue = deque(direct) + while queue: + current = queue.popleft() + for dependent in reverse_deps.get(current, ()): + if dependent not in affected: + affected.add(dependent) + queue.append(dependent) + + ignored = {"integration"} + affected = sorted(name for name in affected if name not in ignored) + + if not affected: + raise SystemExit("No affected crates detected since the last release tag.") + + Path("affected-crates.txt").write_text("\n".join(affected) + "\n") + PY + + { + echo 'crates<> "$GITHUB_OUTPUT" + + { + echo '### Affected crates' + sed 's/^/- `/' affected-crates.txt | sed 's/$/`/' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Run release gates + run: | + cargo fmt --all --check + cargo clippy --workspace --all-targets -- -D warnings + cargo nextest run --workspace + + - name: Bump affected crate versions + env: + VERSION: ${{ inputs.version }} + AFFECTED_CRATES: ${{ steps.affected-crates.outputs.crates }} + shell: bash + run: | + set -euo pipefail + while IFS= read -r crate; do + [ -n "$crate" ] || continue + cargo set-version -p "$crate" "$VERSION" + done <<< "$AFFECTED_CRATES" + + - name: Commit version bump and create tag + env: + VERSION: ${{ inputs.version }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add crates/*/Cargo.toml Cargo.lock + git commit -m "release: v$VERSION" + git tag "v$VERSION" + + - name: Push commit and tag to GitHub + env: + VERSION: ${{ inputs.version }} + run: | + git push origin HEAD:main + git push origin "v$VERSION" + + - name: Build release binaries + env: + VERSION: ${{ inputs.version }} + run: | + cargo build --release -p notfiles -p nothooks -p notstrap -p notgraph + mkdir -p dist/notfiles-v$VERSION-linux-x86_64 + cp target/release/notfiles dist/notfiles-v$VERSION-linux-x86_64/ + cp target/release/nothooks dist/notfiles-v$VERSION-linux-x86_64/ + cp target/release/notstrap dist/notfiles-v$VERSION-linux-x86_64/ + cp target/release/notgraph dist/notfiles-v$VERSION-linux-x86_64/ + tar -C dist -czf "notfiles-v$VERSION-linux-x86_64.tar.gz" "notfiles-v$VERSION-linux-x86_64" + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ inputs.version }} + target_commitish: main + files: notfiles-v${{ inputs.version }}-linux-x86_64.tar.gz + generate_release_notes: true + fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore index 3acc38c..3e8011a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,26 @@ /target +.DS_Store +.pytest_cache/ +.ruff_cache/ +.venv/ .envrc .worktrees/ .claude.local.md +docs/* +!docs/superpowers/ +librust_out.rlib +.remember/ + + +# handoff-begin +.ctx/* +!.ctx/HANDOFF.*.*.yaml +.ctx/HANDOFF.integration.notfiles.state.yaml +.ctx/HANDOFF.notcore.notfiles.state.yaml +.ctx/HANDOFF.notfiles.notfiles.state.yaml +.ctx/HANDOFF.notgraph.notfiles.state.yaml +.ctx/HANDOFF.nothooks.notfiles.state.yaml +.ctx/HANDOFF.notsecrets.notfiles.state.yaml +.ctx/HANDOFF.notstrap.notfiles.state.yaml +.ctx/.initialized +# handoff-end diff --git a/.notfiles-state.toml b/.notfiles-state.toml new file mode 100644 index 0000000..7b71b67 --- /dev/null +++ b/.notfiles-state.toml @@ -0,0 +1,76 @@ +[[entries]] +package = "nushell" +source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/aliases.nu" +target = "/Users/joe/.config/nushell/autoload/aliases.nu" +method = "symlink" +linked_at = "2026-06-14T04:23:50.728685+00:00" + +[[entries]] +package = "nushell" +source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/audit.nu" +target = "/Users/joe/.config/nushell/autoload/audit.nu" +method = "symlink" +linked_at = "2026-06-14T04:23:50.728734+00:00" + +[[entries]] +package = "nushell" +source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/did-you-mean.nu" +target = "/Users/joe/.config/nushell/autoload/did-you-mean.nu" +method = "symlink" +linked_at = "2026-06-14T04:23:50.728773+00:00" + +[[entries]] +package = "nushell" +source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/functions.nu" +target = "/Users/joe/.config/nushell/autoload/functions.nu" +method = "symlink" +linked_at = "2026-06-14T04:23:50.728809+00:00" + +[[entries]] +package = "nushell" +source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/nu_libs.nu" +target = "/Users/joe/.config/nushell/autoload/nu_libs.nu" +method = "symlink" +linked_at = "2026-06-14T04:23:50.728847+00:00" + +[[entries]] +package = "nushell" +source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/nuenv.nu" +target = "/Users/joe/.config/nushell/autoload/nuenv.nu" +method = "symlink" +linked_at = "2026-06-14T04:23:50.728879+00:00" + +[[entries]] +package = "nushell" +source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/session-guard.nu" +target = "/Users/joe/.config/nushell/autoload/session-guard.nu" +method = "symlink" +linked_at = "2026-06-14T04:23:50.728915+00:00" + +[[entries]] +package = "nushell" +source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/settings.nu" +target = "/Users/joe/.config/nushell/autoload/settings.nu" +method = "symlink" +linked_at = "2026-06-14T04:23:50.728947+00:00" + +[[entries]] +package = "nushell" +source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/toolkit-hook.nu" +target = "/Users/joe/.config/nushell/autoload/toolkit-hook.nu" +method = "symlink" +linked_at = "2026-06-14T04:23:50.728981+00:00" + +[[entries]] +package = "nushell" +source = "/Users/joe/dev/notfiles/nushell/.config/nushell/config.nu" +target = "/Users/joe/.config/nushell/config.nu" +method = "symlink" +linked_at = "2026-06-14T04:23:50.729023+00:00" + +[[entries]] +package = "nushell" +source = "/Users/joe/dev/notfiles/nushell/.config/nushell/env.nu" +target = "/Users/joe/.config/nushell/env.nu" +method = "symlink" +linked_at = "2026-06-14T04:23:50.729055+00:00" diff --git a/.sccignore b/.sccignore new file mode 100644 index 0000000..43f2eb5 --- /dev/null +++ b/.sccignore @@ -0,0 +1,11 @@ +.ctx +.kgx +.github +.claude +docs +examples +fuzz +target +*.md +*.json +tests/ diff --git a/.superpowers/brainstorm/72758-1776397235/content/architecture.html b/.superpowers/brainstorm/72758-1776397235/content/architecture.html new file mode 100644 index 0000000..097794b --- /dev/null +++ b/.superpowers/brainstorm/72758-1776397235/content/architecture.html @@ -0,0 +1,71 @@ +

notfiles + Tailscale — Bootstrap Architecture

+

How the crates fit together for ephemeral machine setup

+ +
+
+ +
+ + +
+
New Machine
+
TS_AUTHKEY env var
+
YubiKey (PIV slot 9d)
+
→ interactive prompt
+
+ +
+ + +
+
notstrap
+
1. prereqs check
+
2. → notnet
+
3. clone Gitea
+
4. → notsecrets
+
5. link dotfiles
+
6. run hooks
+
+ +
+ +
+
+ +
+
notnet NEW
+
detect existing Tailscale
+
install if missing
+
resolve auth key
+
join tailnet
+
verify peer reachable
+
+
+
+
Tailnet
+
minibox (VPS)
+
Gitea repo
+
+
+ +
+
+ +
+
notsecrets +YubiKey
+
YubikeySource (PIV 9c)
+
BitwardenSource
+
FileSource
+
PromptSource
+
+
+ +
+ +
+ +
+ Already on Tailnet? notstrap detects active Tailscale → skips notnet entirely +
+
+
diff --git a/.superpowers/brainstorm/72758-1776397235/state/server-stopped b/.superpowers/brainstorm/72758-1776397235/state/server-stopped new file mode 100644 index 0000000..ac70b7f --- /dev/null +++ b/.superpowers/brainstorm/72758-1776397235/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1776399095365} diff --git a/.superpowers/brainstorm/72758-1776397235/state/server.log b/.superpowers/brainstorm/72758-1776397235/state/server.log new file mode 100644 index 0000000..584bbb4 --- /dev/null +++ b/.superpowers/brainstorm/72758-1776397235/state/server.log @@ -0,0 +1,3 @@ +{"type":"server-started","port":63245,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:63245","screen_dir":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/content","state_dir":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/state"} +{"type":"screen-added","file":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/content/architecture.html"} +{"type":"server-stopped","reason":"idle timeout"} diff --git a/.superpowers/brainstorm/72758-1776397235/state/server.pid b/.superpowers/brainstorm/72758-1776397235/state/server.pid new file mode 100644 index 0000000..436a082 --- /dev/null +++ b/.superpowers/brainstorm/72758-1776397235/state/server.pid @@ -0,0 +1 @@ +72766 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d97f87a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## What is notfiles? + +A modern dotfiles manager written in Rust — a pure Rust alternative to GNU Stow. It symlinks (or copies) files from organized "package" directories into a target location (typically `~`). + +## Build & Test Commands + +```bash +cargo build # build all workspace crates +cargo build -p notfiles # build just the notfiles binary +cargo test # run all tests across workspace +cargo test -p notcore # test notcore only +cargo test -p notfiles # test notfiles only +cargo test -p notsecrets # test notsecrets only +cargo test -p nothooks # test nothooks only +cargo clippy --workspace # lint all crates +cargo fmt --check # check formatting +``` + +## Workspace Structure + +This is a Cargo workspace with 7 crates under `crates/`: + +| Crate | Purpose | +| ------------ | -------------------------------------------------------------------------- | +| `notcore` | Shared types: `Config`, `NotfilesError`, `Reporter`, `LinkEvent`, | +| | `expand_tilde`, `suggest_package`, `HookPhase`, `HookSpec`, `Report` | +| `notfiles` | Dotfiles linker — lib + `notfiles` binary. Subcommands: `init`, `link`, | +| | `unlink`, `status`, `check`, `diff`, `adopt`, `completions` | +| `notsecrets` | Multi-provider secret resolution (`SecretResolver`), age encryption/ | +| | decryption, identity management | +| `nothooks` | Nushell hook runner with dot/setup phases and state persistence | +| `notnet` | Network utilities — Tailscale integration, YubikeySource | +| `notstrap` | New-machine bootstrap orchestrator — ties all crates together | +| `notgraph` | Dependency/import graph for Rust files — HTML/MD/JSON/Mermaid output, | +| | heatmap, cycle detection | + +## Architecture + +### notfiles (primary user-facing tool) + +Eight subcommands: `init`, `link`, `unlink`, `status`, `check`, `diff`, +`adopt`, `completions`. Global flags: `--dry-run`, `--verbose`, `--json`. +CLI parsing in `crates/notfiles/src/cli.rs`; dispatch in `src/main.rs`. + +**Core flow for `link`:** `main` → `config.validate()` → +`resolve_packages_filtered` (include/exclude + platform filtering) → +`collect_files` (recursive walk with ignore filtering) → +`linker::link_package` (create symlinks or copies, record in state, +return `LinkResult` with counters). + +**Architecture**: Hexagonal (ports/adapters). `FileStore` trait abstracts +filesystem I/O; `Reporter` trait abstracts output. Adapters: +`FileStoreImpl` (real fs), `InMemoryFileStore` (testing), +`TerminalReporter` (ANSI), `JsonReporter` (NDJSON). + +Key modules in `crates/notfiles/src/`: + +- **linker** — Creates/removes symlinks or copies via `FileStore` port. + Manages `State` (`.notfiles-state.toml`). Returns `LinkResult` with + linked/copied/skipped/backed_up counts. Also provides `adopt_files`. +- **package** — Discovers packages with include/exclude and platform + filtering. Recursively collects files via `IgnoreMatcher`. Typo + suggestions via `suggest_package` on not-found errors. +- **ignore** — Glob-based ignore matching using `globset`. +- **status** — Compares expected vs actual state: + linked/copied/missing/conflict/orphan. Also provides `diff_package` + for copy-method divergence detection. +- **adapters/** — `FileStoreImpl`, `InMemoryFileStore`, + `TerminalReporter`, `JsonReporter`. +- **ports** — `FileStore` trait definition. + +### notsecrets + +Multi-provider secret resolution via `SecretResolver`. Providers include +`EnvSource`, `OpSource` (1Password), `BitwardenSource`, `FileSource`, +`DotenvxSource`, `SopsSource`, and others. Native age encryption/decryption +with x25519, SSH ed25519/RSA, and scrypt identity support. No external +`sops` or `age` binaries required. + +### nothooks + +`HookRunner` executes `.nu` scripts via `nu + + + +

notgraph Report

+ +

Crate Dependency Graph

+
+
low fan-in
+
high fan-in
+
cycle
+
+
+
+%%{{init: {{'theme':'dark','themeVariables':{{'darkMode':true,'background':'#242424','primaryColor':'#1e3a5f','primaryBorderColor':'#4a9eff','primaryTextColor':'#e0e0e0','lineColor':'#4a9eff','edgeLabelBackground':'#242424'}}}}}}%%
+{graph_diagram}
+
+ +

Module Graphs

+{module_diagrams} + +

Hotspots

+ + + + + + +{hotspot_rows} +
NameKindScore
+ +

Cycle Report

+{cycle_html} + + + + diff --git a/crates/notgraph/tests/fixtures/clean/src/alpha.rs b/crates/notgraph/tests/fixtures/clean/src/alpha.rs new file mode 100644 index 0000000..c340dba --- /dev/null +++ b/crates/notgraph/tests/fixtures/clean/src/alpha.rs @@ -0,0 +1 @@ +pub fn hello() -> &'static str { "alpha" } diff --git a/crates/notgraph/tests/fixtures/clean/src/beta.rs b/crates/notgraph/tests/fixtures/clean/src/beta.rs new file mode 100644 index 0000000..0930fde --- /dev/null +++ b/crates/notgraph/tests/fixtures/clean/src/beta.rs @@ -0,0 +1 @@ +pub fn greet() -> &'static str { "beta" } diff --git a/crates/notgraph/tests/fixtures/clean/src/lib.rs b/crates/notgraph/tests/fixtures/clean/src/lib.rs new file mode 100644 index 0000000..cfa8271 --- /dev/null +++ b/crates/notgraph/tests/fixtures/clean/src/lib.rs @@ -0,0 +1,2 @@ +pub mod alpha; +pub mod beta; diff --git a/crates/notgraph/tests/fixtures/cyclic/src/bar.rs b/crates/notgraph/tests/fixtures/cyclic/src/bar.rs new file mode 100644 index 0000000..b52703b --- /dev/null +++ b/crates/notgraph/tests/fixtures/cyclic/src/bar.rs @@ -0,0 +1 @@ +pub mod foo; diff --git a/crates/notgraph/tests/fixtures/cyclic/src/foo.rs b/crates/notgraph/tests/fixtures/cyclic/src/foo.rs new file mode 100644 index 0000000..46f285c --- /dev/null +++ b/crates/notgraph/tests/fixtures/cyclic/src/foo.rs @@ -0,0 +1 @@ +pub mod bar; diff --git a/crates/notgraph/tests/fixtures/cyclic/src/lib.rs b/crates/notgraph/tests/fixtures/cyclic/src/lib.rs new file mode 100644 index 0000000..39afbd5 --- /dev/null +++ b/crates/notgraph/tests/fixtures/cyclic/src/lib.rs @@ -0,0 +1,2 @@ +pub mod foo; +pub mod bar; diff --git a/crates/notgraph/tests/fixtures/symbols/src/lib.rs b/crates/notgraph/tests/fixtures/symbols/src/lib.rs new file mode 100644 index 0000000..3050321 --- /dev/null +++ b/crates/notgraph/tests/fixtures/symbols/src/lib.rs @@ -0,0 +1,7 @@ +pub struct Foo; +pub enum Bar { A, B } +pub trait Baz { fn run(&self); } +pub fn qux() {} +pub type Alias = u32; +pub const VALUE: u32 = 42; +struct Private; diff --git a/crates/notgraph/tests/integration.rs b/crates/notgraph/tests/integration.rs new file mode 100644 index 0000000..8c3b326 --- /dev/null +++ b/crates/notgraph/tests/integration.rs @@ -0,0 +1,239 @@ +use notgraph_lib::{analysis, emit, module_graph, symbols, types}; +use std::path::Path; +use tempfile::TempDir; + +fn fixture_src(name: &str) -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) + .join("src") +} + +#[test] +fn clean_fixture_has_no_cycles() { + let mg = module_graph::build("clean".to_string(), &fixture_src("clean")).unwrap(); + assert!( + analysis::detect_cycles(&mg.nodes, &mg.edges).is_empty(), + "expected no cycles; nodes={:?} edges={:?}", + mg.nodes, + mg.edges + ); +} + +#[test] +fn detect_cycles_finds_cycles_in_synthetic_graph() { + let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let edges = vec![ + ("a".to_string(), "b".to_string()), + ("b".to_string(), "c".to_string()), + ("c".to_string(), "a".to_string()), // creates a->b->c->a cycle + ]; + let cycles = analysis::detect_cycles(&nodes, &edges); + assert!( + !cycles.is_empty(), + "expected detect_cycles to find the a->b->c->a cycle, got empty" + ); +} + +/// The cyclic fixture's module graph builds without error and has the expected +/// cross-module containment structure. The cycle detection test using synthetic +/// data covers the algorithm; this test confirms the builder handles mutual +/// `pub mod` declarations without panicking. +#[test] +fn cyclic_fixture_module_graph_builds() { + let mg = module_graph::build("cyclic".to_string(), &fixture_src("cyclic")).unwrap(); + assert!( + mg.nodes.contains(&"cyclic".to_string()), + "expected root module node 'cyclic'" + ); + // foo and bar are declared at the top level in lib.rs + assert!( + mg.nodes.contains(&"cyclic::foo".to_string()), + "expected cyclic::foo node" + ); + assert!( + mg.nodes.contains(&"cyclic::bar".to_string()), + "expected cyclic::bar node" + ); +} + +/// write_all produces report.html, report.md, and report.json in the output dir. +#[test] +fn write_all_creates_expected_files() { + use types::{CrateGraph, ModuleGraph}; + + let out = TempDir::new().unwrap(); + let crate_graph = CrateGraph { + nodes: vec!["alpha".to_string(), "beta".to_string()], + edges: vec![("beta".to_string(), "alpha".to_string())], + }; + let module_graphs: Vec = vec![]; + let stats = analysis::analyse(&crate_graph, &module_graphs, 3); + let symbol_tables: Vec = vec![]; + + emit::write_all( + out.path(), + &crate_graph, + &module_graphs, + &stats, + &symbol_tables, + ) + .unwrap(); + + assert!( + out.path().join("report.html").exists(), + "report.html missing" + ); + assert!(out.path().join("report.md").exists(), "report.md missing"); + assert!( + out.path().join("report.json").exists(), + "report.json missing" + ); +} + +/// Crate names with hyphens must not produce malformed Mermaid output — the +/// generated HTML must contain a sanitized node ID (hyphen replaced with _). +#[test] +fn write_all_sanitizes_hyphenated_crate_names() { + use types::{CrateGraph, ModuleGraph}; + + let out = TempDir::new().unwrap(); + let crate_graph = CrateGraph { + nodes: vec!["my-core".to_string(), "my-app".to_string()], + edges: vec![("my-app".to_string(), "my-core".to_string())], + }; + let module_graphs: Vec = vec![]; + let stats = analysis::analyse(&crate_graph, &module_graphs, 3); + let symbol_tables: Vec = vec![]; + + emit::write_all( + out.path(), + &crate_graph, + &module_graphs, + &stats, + &symbol_tables, + ) + .unwrap(); + + let html = std::fs::read_to_string(out.path().join("report.html")).unwrap(); + // Raw hyphenated names must not appear as unquoted node IDs + assert!( + !html.contains("my-core["), + "hyphenated ID 'my-core[' must not appear in Mermaid — use my_core" + ); + assert!( + html.contains("my_core["), + "sanitized ID 'my_core[' must appear in Mermaid output" + ); +} + +/// Heatmap: a single-crate graph (max_fan_in = 0 or 1) must not panic and must +/// produce a style directive in the HTML output. +#[test] +fn write_all_heatmap_does_not_panic_for_single_crate() { + use types::{CrateGraph, ModuleGraph}; + + let out = TempDir::new().unwrap(); + let crate_graph = CrateGraph { + nodes: vec!["solo".to_string()], + edges: vec![], + }; + let module_graphs: Vec = vec![]; + let stats = analysis::analyse(&crate_graph, &module_graphs, 3); + let symbol_tables: Vec = vec![]; + + emit::write_all( + out.path(), + &crate_graph, + &module_graphs, + &stats, + &symbol_tables, + ) + .unwrap(); + + let html = std::fs::read_to_string(out.path().join("report.html")).unwrap(); + assert!( + html.contains("style solo fill:"), + "heatmap style directive missing for solo crate" + ); +} + +/// Per-crate module graphs: write_all renders a

section for each +/// ModuleGraph entry in the HTML output. +#[test] +fn write_all_renders_per_crate_module_graphs() { + use types::CrateGraph; + + let out = TempDir::new().unwrap(); + let mg = module_graph::build("clean".to_string(), &fixture_src("clean")).unwrap(); + let crate_graph = CrateGraph { + nodes: vec!["clean".to_string()], + edges: vec![], + }; + let stats = analysis::analyse(&crate_graph, std::slice::from_ref(&mg), 3); + let symbol_tables: Vec = vec![]; + + emit::write_all(out.path(), &crate_graph, &[mg], &stats, &symbol_tables).unwrap(); + + let html = std::fs::read_to_string(out.path().join("report.html")).unwrap(); + assert!( + html.contains("

clean

"), + "expected per-crate

clean

section in HTML" + ); + assert!( + html.contains("flowchart TD"), + "expected flowchart TD directive for module graph" + ); +} + +/// Cycle callouts: when the crate graph contains cycles, the HTML must include +/// the cycle-entry element with the cycle nodes. +#[test] +fn write_all_cycle_callout_appears_in_html() { + use types::{CrateGraph, ModuleGraph}; + + let out = TempDir::new().unwrap(); + // Build a two-node crate graph with a synthetic cycle + let crate_graph = CrateGraph { + nodes: vec!["alpha".to_string(), "beta".to_string()], + edges: vec![ + ("alpha".to_string(), "beta".to_string()), + ("beta".to_string(), "alpha".to_string()), + ], + }; + let module_graphs: Vec = vec![]; + let stats = analysis::analyse(&crate_graph, &module_graphs, 3); + let symbol_tables: Vec = vec![]; + + emit::write_all( + out.path(), + &crate_graph, + &module_graphs, + &stats, + &symbol_tables, + ) + .unwrap(); + + let html = std::fs::read_to_string(out.path().join("report.html")).unwrap(); + assert!( + html.contains("cycle-entry"), + "expected cycle-entry element in HTML when cycles are present" + ); +} + +#[test] +fn symbols_fixture_has_six_public_symbols() { + let table = symbols::build("symbols".to_string(), &fixture_src("symbols")).unwrap(); + let pub_count = table.symbols.iter().filter(|s| s.is_pub).count(); + assert_eq!( + pub_count, + 6, + "expected 6 public symbols (Foo, Bar, Baz, qux, Alias, VALUE), got: {:?}", + table + .symbols + .iter() + .filter(|s| s.is_pub) + .map(|s| &s.name) + .collect::>() + ); +} diff --git a/crates/nothooks/Cargo.toml b/crates/nothooks/Cargo.toml index d00bfe7..09ee693 100644 --- a/crates/nothooks/Cargo.toml +++ b/crates/nothooks/Cargo.toml @@ -13,11 +13,11 @@ name = "nothooks" path = "src/lib.rs" [dependencies] -notcore = { workspace = true } anyhow = { workspace = true } +clap = { workspace = true } +notcore = { workspace = true } serde = { workspace = true } toml = { workspace = true } -clap = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/nothooks/README.md b/crates/nothooks/README.md new file mode 100644 index 0000000..2d26718 --- /dev/null +++ b/crates/nothooks/README.md @@ -0,0 +1,121 @@ +# nothooks + +Nushell hook runner for the notfiles workspace. Executes `.nu` scripts in two +distinct lifecycle phases — **dot** (always) and **setup** (once) — with +state persistence so setup hooks are not re-run across invocations. + +## Concepts + +### HookPhase + +Each hook belongs to exactly one phase: + +| Phase | When it runs | Typical use | +|-------|-------------|-------------| +| `dot` | Every time nothooks is invoked | Apply shell config, set env vars, refresh symlinks | +| `setup` | Once per machine (tracked in state) | Install packages, create dirs, first-time configuration | + +### HookSpec + +A hook is described by three fields (defined in `notcore`): + +```toml +[[hooks]] +name = "install-starship" +script = "hooks/setup/install-starship.nu" +phase = "setup" + +[[hooks]] +name = "set-default-shell" +script = "hooks/dot/shell.nu" +phase = "dot" +``` + +### State tracking + +Setup hooks are tracked in `.nothooks-state.toml` in the state directory +(typically the dotfiles root). Once a setup hook completes successfully its +name is recorded and it will be **skipped** on all future runs. + +```toml +# .nothooks-state.toml (auto-generated, do not edit manually) +completed_setup_hooks = ["install-starship", "configure-git"] +``` + +Dot hooks are **never** recorded in state — they run unconditionally on every +invocation. + +### --force re-run + +To re-run setup hooks (e.g. after a machine rebuild or to apply changes), +construct the runner with `HookRunner::with_force`. This bypasses the state +check so all setup hooks execute regardless of prior completion. + +The notstrap CLI exposes this as `--force`. + +## Hook script lifecycle + +``` +notstrap / nothooks invoked +│ +├── dot phase +│ └── for each hook where phase == "dot" +│ └── nu + + + +

notgraph Report

+

Crate Dependency Graph

+
+ +

Hotspots

+ + + + + + + + + + {hotspot_rows} + +
NameKindScore
+

Cycle Report

+ {cycle_html} + + + +``` + +- [x] **Step 3: Verify it compiles** + +``` +cargo check -p notgraph +``` + +Expected: no errors. + +- [x] **Step 4: Commit** + +``` +git add crates/notgraph/src/emit.rs crates/notgraph/templates/ +git commit -m "feat(notgraph): implement emit (md, json, html)" +``` + +--- + +## Task 8: Wire main.rs CLI + +**Files:** + +- Modify: `crates/notgraph/src/main.rs` + +- [x] **Step 1: Replace main.rs with full CLI** + +Replace `crates/notgraph/src/main.rs`: + +```rust +use notgraph_lib::{analysis, crate_graph, emit, module_graph, symbols}; +use anyhow::{Context, Result}; +use clap::Parser; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "notgraph", about = "Workspace module graph and symbol analysis")] +struct Cli { + #[arg(long, default_value = "docs/graph")] + output: PathBuf, + + #[arg(long)] + fail_on_cycles: bool, + + #[arg(long, default_value_t = 10)] + top: usize, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let manifest = find_workspace_manifest()?; + let workspace_root = manifest.parent().unwrap().to_path_buf(); + + let crate_g = crate_graph::build(&manifest) + .context("failed to build crate graph")?; + + let meta = cargo_metadata::MetadataCommand::new() + .manifest_path(&manifest) + .no_deps() + .exec()?; + + let mut module_graphs = Vec::new(); + let mut symbol_tables = Vec::new(); + + for pkg in &meta.packages { + if !meta.workspace_members.contains(&pkg.id) { + continue; + } + let src_dir = PathBuf::from(pkg.manifest_path.as_str()) + .parent().unwrap().join("src"); + if !src_dir.exists() { + continue; + } + module_graphs.push( + module_graph::build(pkg.name.clone(), &src_dir) + .with_context(|| format!("module_graph failed for {}", pkg.name))? + ); + symbol_tables.push( + symbols::build(pkg.name.clone(), &src_dir) + .with_context(|| format!("symbols failed for {}", pkg.name))? + ); + } + + let stats = analysis::analyse(&crate_g, &module_graphs, cli.top); + + let output_dir = if cli.output.is_absolute() { + cli.output.clone() + } else { + workspace_root.join(&cli.output) + }; + + emit::write_all(&output_dir, &stats, &symbol_tables) + .context("failed to write reports")?; + + println!("notgraph: reports written to {}", output_dir.display()); + + if cli.fail_on_cycles && !stats.cycles.is_empty() { + eprintln!("notgraph: {} cycle(s) detected", stats.cycles.len()); + for cycle in &stats.cycles { + eprintln!(" {}", cycle.join(" -> ")); + } + std::process::exit(1); + } + + Ok(()) +} + +fn find_workspace_manifest() -> Result { + let mut dir = std::env::current_dir()?; + loop { + let candidate = dir.join("Cargo.toml"); + if candidate.exists() { + let content = std::fs::read_to_string(&candidate)?; + if content.contains("[workspace]") { + return Ok(candidate); + } + } + anyhow::ensure!(dir.pop(), "could not find workspace Cargo.toml"); + } +} +``` + +- [x] **Step 2: Build and smoke-test** + +``` +cargo run -p notgraph -- --output /tmp/notgraph-test +``` + +Expected: `notgraph: reports written to /tmp/notgraph-test` + +``` +ls /tmp/notgraph-test +``` + +Expected: `report.json report.md report.html` + +- [x] **Step 3: Verify --fail-on-cycles exits 0 on clean workspace** + +``` +cargo run -p notgraph -- --output /tmp/notgraph-test --fail-on-cycles; echo "exit $?" +``` + +Expected: `exit 0` + +- [x] **Step 4: Run clippy** + +``` +cargo clippy -p notgraph -- -D warnings +``` + +Fix any warnings. + +- [x] **Step 5: Commit** + +``` +git add crates/notgraph/src/main.rs +git commit -m "feat(notgraph): wire full CLI pipeline" +``` + +--- + +## Task 9: Integration tests + +**Files:** + +- Create: `crates/notgraph/tests/integration.rs` + +- [x] **Step 1: Write integration tests** + +Create `crates/notgraph/tests/integration.rs`: + +```rust +use notgraph_lib::{analysis, module_graph, symbols}; +use std::path::Path; + +fn fixtures(name: &str) -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) + .join("src") +} + +#[test] +fn clean_fixture_has_no_cycles() { + let mg = module_graph::build("clean".to_string(), &fixtures("clean")).unwrap(); + assert!( + analysis::detect_cycles(&mg.nodes, &mg.edges).is_empty(), + "expected no cycles; nodes={:?} edges={:?}", + mg.nodes, mg.edges + ); +} + +#[test] +fn cyclic_fixture_has_cycles() { + let mg = module_graph::build("cyclic".to_string(), &fixtures("cyclic")).unwrap(); + let cycles = analysis::detect_cycles(&mg.nodes, &mg.edges); + assert!( + !cycles.is_empty(), + "expected cycles; nodes={:?} edges={:?}", + mg.nodes, mg.edges + ); +} + +#[test] +fn symbols_fixture_has_six_public_symbols() { + let table = symbols::build("symbols".to_string(), &fixtures("symbols")).unwrap(); + let pub_count = table.symbols.iter().filter(|s| s.is_pub).count(); + assert_eq!( + pub_count, 6, + "expected 6 public symbols (Foo, Bar, Baz, qux, Alias, VALUE), got: {:?}", + table.symbols.iter().filter(|s| s.is_pub).map(|s| &s.name).collect::>() + ); +} +``` + +- [x] **Step 2: Run integration tests** + +``` +cargo test -p notgraph --test integration +``` + +Expected: 3 tests passed. + +- [x] **Step 3: Run all notgraph tests** + +``` +cargo test -p notgraph +``` + +Expected: all tests pass. + +- [x] **Step 4: Commit** + +``` +git add crates/notgraph/tests/integration.rs +git commit -m "test(notgraph): add integration tests for clean/cyclic/symbols fixtures" +``` + +--- + +## Task 10: mise wiring, .gitignore, complete todo + +**Files:** + +- Modify: `mise.toml` +- Modify or create: `.gitignore` + +- [x] **Step 1: Add graph tasks to mise.toml** + +In `mise.toml`, add after `[tasks."all:dep-boundaries"]`: + +```toml +[tasks."all:graph"] +description = "Generate module graph reports (docs/graph/)" +run = "cargo run -p notgraph --release -- --output docs/graph" + +[tasks."all:graph-check"] +description = "CI: fail if module cycles detected" +run = "cargo run -p notgraph --release -- --output docs/graph --fail-on-cycles" +``` + +- [x] **Step 2: Add graph-check to the all:ci gate** + +In `mise.toml`, in the `all:ci` task run script, add after the dep-boundaries line: + +```bash +echo "── graph-check ──────────────────────" +cargo run -p notgraph --release -- --output docs/graph --fail-on-cycles +``` + +- [x] **Step 3: Gitignore generated reports** + +Add to `.gitignore` (create file at workspace root if it doesn't exist): + +``` +docs/graph/ +``` + +- [x] **Step 4: Run full CI gate** + +``` +mise run all:ci +``` + +Expected: all gates pass, exits 0. + +- [x] **Step 5: Mark todo complete** + +``` +doob todo complete ubj2z5rs9wxiktlt0fwp +``` + +- [x] **Step 6: Commit** + +``` +git add mise.toml .gitignore +git commit -m "feat(notgraph): wire all:graph and all:graph-check into mise CI" +``` + +--- + +## Self-Review + +**Spec coverage:** + +- CrateGraph from cargo_metadata → Task 3 +- ModuleGraph per crate (walkdir + syn) → Task 4 +- SymbolTable per crate → Task 5 +- Fan-in/fan-out, Kahn cycle detection, hotspot ranking → Task 6 +- report.md, report.json, report.html → Task 7 +- CLI (--output, --fail-on-cycles, --top) → Task 8 +- Integration tests (clean, cyclic, symbols fixtures) → Task 9 +- mise wiring and .gitignore → Task 10 + +**Type consistency:** All types defined in Task 2 types.rs and referenced consistently by field name throughout (`krate` not `crate`, `fan_in`/`fan_out` not `fanIn`/`fanOut`). + +**Known limitation:** HTML crate graph shows nodes but no edges — edges are not propagated into GraphStats (only fan stats). To add edges: pass `CrateGraph` directly to `emit::write_all`. Left as a follow-up. diff --git a/docs/superpowers/plans/2026-04-01-notsecrets-age-redesign.md b/docs/superpowers/plans/2026-04-01-notsecrets-age-redesign.md new file mode 100644 index 0000000..66a892c --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-notsecrets-age-redesign.md @@ -0,0 +1,2829 @@ +# notsecrets Age-Native Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign notsecrets as a self-contained age encryption/decryption library in pure Rust with multiple recipients, passphrases, passphrase-protected identity files, and SSH keys. + +**Architecture:** Hexagonal architecture with Identity and Recipient as domain ports. Concrete implementations (X25519, Scrypt, SshEd25519, SshRsa, Encrypted) live in infra adapters. Encryptor/Decryptor orchestrate at the domain layer. Sources (Bitwarden, file, prompt) are identity resolvers at the infra boundary. + +**Tech Stack:** x25519-dalek, ed25519-dalek, rsa, sha2, hkdf, chacha20poly1305, scrypt, bech32, base64, hmac, rand + +--- + +## Task 1: Scaffold — Cargo.toml, error.rs, domain traits, stub lib.rs + +- [ ] Update `crates/notsecrets/Cargo.toml` — add new deps, remove `which` +- [ ] Create `crates/notsecrets/src/error.rs` +- [ ] Create `crates/notsecrets/src/identities/mod.rs` — domain types + `Identity` trait +- [ ] Create `crates/notsecrets/src/recipients/mod.rs` — `Recipient` trait +- [ ] Rewrite `crates/notsecrets/src/lib.rs` — stub public API, remove old exports + +### 1.1 Update `crates/notsecrets/Cargo.toml` + +```toml +[package] +name = "notsecrets" +version = "0.1.0" +edition = "2024" +license.workspace = true + +[dependencies] +notcore = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +rpassword = { workspace = true } +dirs = { workspace = true } +x25519-dalek = { version = "2", features = ["static_secrets"] } +ed25519-dalek = "2" +rsa = { version = "0.9", features = ["sha2"] } +sha2 = "1" +hkdf = "0.12" +chacha20poly1305 = "0.10" +scrypt = "0.11" +bech32 = "0.11" +base64 = "0.22" +hmac = "0.12" +rand = "0.8" + +[dev-dependencies] +tempfile = { workspace = true } +``` + +Also update workspace `Cargo.toml` — remove `which` from `[workspace.dependencies]` only after verifying no other crate uses it: + +```bash +cargo metadata --no-deps --format-version 1 | grep -o '"which"' | wc -l +# if 0 remaining consumers after notsecrets drop, remove from workspace +``` + +### 1.2 `crates/notsecrets/src/error.rs` + +```rust +#[derive(Debug, thiserror::Error)] +pub enum AgeError { + #[error("no identity could decrypt any recipient stanza")] + NoMatch, + #[error("header MAC verification failed")] + MacMismatch, + #[error("malformed age file: {0}")] + ParseError(String), + #[error("crypto error: {0}")] + CryptoError(String), + #[error("unsupported key type: {0}")] + UnsupportedKeyType(String), + #[error("identity source failed ({name}): {source}")] + SourceError { name: String, source: anyhow::Error }, +} +``` + +### 1.3 `crates/notsecrets/src/identities/mod.rs` + +```rust +use crate::error::AgeError; + +/// The symmetric file encryption key — 16 random bytes. +#[derive(Clone, zeroize::Zeroize)] +pub struct FileKey([u8; 16]); + +impl FileKey { + pub fn new(bytes: [u8; 16]) -> Self { + Self(bytes) + } + + pub fn as_bytes(&self) -> &[u8; 16] { + &self.0 + } + + pub fn generate() -> Self { + use rand::RngCore; + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + Self(bytes) + } +} + +impl TryFrom<&[u8]> for FileKey { + type Error = AgeError; + fn try_from(b: &[u8]) -> Result { + b.try_into() + .map(Self) + .map_err(|_| AgeError::ParseError(format!("file key must be 16 bytes, got {}", b.len()))) + } +} + +/// A single recipient stanza in the age header. +#[derive(Debug, Clone)] +pub struct Stanza { + pub tag: String, + pub args: Vec, + pub body: Vec, +} + +/// The full age header (all stanzas + MAC). +#[derive(Debug, Clone)] +pub struct Header { + pub recipients: Vec, + pub mac: Vec, +} + +/// Domain port: an identity that can attempt to unwrap a recipient stanza. +/// +/// Returns `None` if the stanza tag/args do not match this identity type. +/// Returns `Some(Err(...))` if the stanza matches but decryption fails. +/// Returns `Some(Ok(file_key))` on success. +pub trait Identity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option>; +} +``` + +Note: `zeroize` must be added to `[dependencies]` in `crates/notsecrets/Cargo.toml`: + +```toml +zeroize = "1" +``` + +Add this line alongside the others in step 1.1. + +### 1.4 `crates/notsecrets/src/recipients/mod.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Stanza}; + +/// Domain port: a recipient that can wrap a file key into a stanza. +pub trait Recipient { + fn wrap_file_key(&self, file_key: &FileKey) -> Result; +} +``` + +### 1.5 `crates/notsecrets/src/lib.rs` (stub) + +```rust +pub mod error; +pub mod identities; +pub mod recipients; +pub mod sources; + +pub use error::AgeError; +pub use identities::{FileKey, Header, Identity, Stanza}; +pub use recipients::Recipient; +pub use sources::{BitwardenSource, FileSource, IdentitySource, PromptSource}; + +// Encryptor and Decryptor will be added in Tasks 8 and 9. +// resolve_identities will be added in Task 11. +``` + +Keep the existing `sources/` files compiling by leaving `AgeKeySource` in place temporarily — it will be replaced in Task 10. For now add a `#[allow(dead_code)]` allow on the old trait and old `resolve_age_key`/`install_age_key`/`decrypt_sops` functions so the crate compiles while tasks proceed incrementally. Annotate each deprecated item: + +```rust +#[deprecated(note = "replaced by resolve_identities in Task 11")] +pub use _legacy::{ + install_age_key, install_age_key_at, resolve_age_key, AgeKeySource, decrypt_sops, sops_args, +}; +mod _legacy; +``` + +Move the old body of `lib.rs` (everything except `pub mod sources`) into `crates/notsecrets/src/_legacy.rs` verbatim so existing callers in `notstrap` keep compiling until Task 12. + +### 1.6 Verify + +```bash +cargo check -p notsecrets +``` + +Expected: compiles, zero errors. + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings (the `#[deprecated]` re-exports suppress dead_code warnings; `#[allow(deprecated)]` needed in `notstrap` temporarily — add it in Task 12). + +### 1.7 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/ Cargo.toml +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): scaffold age-native domain types and trait ports" +``` + +--- + +## Task 2: format.rs — age wire format parser/serializer + +**No crypto. Pure byte manipulation.** + +- [ ] Create `crates/notsecrets/src/format.rs` +- [ ] Write tests first, then implement + +### 2.1 Test (write first) + +File: `crates/notsecrets/src/format.rs` (tests section) + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn minimal_age_file() -> Vec { + // Manually constructed minimal age file with one X25519 stanza + // and a 32-byte payload (nonce=16 bytes of 0x01, ciphertext=16 bytes of 0x02) + let mut out = Vec::new(); + out.extend_from_slice(b"age-encryption.org/v1\n"); + out.extend_from_slice(b"-> X25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n"); + // empty stanza body (no wrapped key bytes for parse test) + out.extend_from_slice(b"\n"); + // MAC line: "--- " + base64(32 zero bytes) + out.extend_from_slice(b"--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n"); + // payload: 16-byte nonce + 16-byte ciphertext placeholder + out.extend_from_slice(&[0x01u8; 16]); + out.extend_from_slice(&[0x02u8; 16]); + out + } + + #[test] + fn parse_minimal_header_roundtrip() { + let input = minimal_age_file(); + let (header, payload_offset) = parse_header(&input).expect("parse should succeed"); + assert_eq!(header.recipients.len(), 1); + assert_eq!(header.recipients[0].tag, "X25519"); + assert_eq!(header.recipients[0].args.len(), 1); + assert!(header.recipients[0].body.is_empty()); + // payload offset should point past the "--- MAC\n" line + assert_eq!(&input[payload_offset..], &[0x01u8; 16][..].iter().chain([0x02u8; 16].iter()).copied().collect::>()[..]); + } + + #[test] + fn serialize_header_roundtrip() { + use crate::identities::{Header, Stanza}; + let stanza = Stanza { + tag: "X25519".to_string(), + args: vec!["dGVzdA==".to_string()], + body: vec![0xde, 0xad, 0xbe, 0xef], + }; + let header = Header { + recipients: vec![stanza], + mac: vec![0u8; 32], + }; + let serialized = serialize_header(&header); + let (parsed, _) = parse_header(&[serialized.clone(), vec![0u8; 32]].concat()) + .expect("round-trip parse should succeed"); + assert_eq!(parsed.recipients.len(), 1); + assert_eq!(parsed.recipients[0].tag, "X25519"); + assert_eq!(parsed.recipients[0].body, vec![0xde, 0xad, 0xbe, 0xef]); + } + + #[test] + fn parse_missing_version_line_returns_error() { + let input = b"not-age-encryption\n-> X25519 arg\n\n--- AAAA\n"; + let result = parse_header(input); + assert!(result.is_err(), "expected ParseError for missing version line"); + } + + #[test] + fn parse_truncated_before_footer_returns_error() { + let input = b"age-encryption.org/v1\n-> X25519 arg\n"; + let result = parse_header(input); + assert!(result.is_err(), "expected ParseError for truncated header"); + } + + #[test] + fn parse_empty_input_returns_error() { + let result = parse_header(b""); + assert!(result.is_err()); + } + + #[test] + fn stanza_body_split_across_multiple_64char_lines() { + // Body longer than 64 chars must be split into 64-char lines per spec. + // 48 bytes -> base64 = 64 chars (one line, no split needed) + // 49 bytes -> base64 = 68 chars (must split: 64 + 4) + use crate::identities::{Header, Stanza}; + let body = vec![0xabu8; 49]; + let stanza = Stanza { + tag: "scrypt".to_string(), + args: vec!["salt".to_string(), "18".to_string()], + body: body.clone(), + }; + let header = Header { recipients: vec![stanza], mac: vec![0u8; 32] }; + let serialized = serialize_header(&header); + let (parsed, _) = parse_header(&[serialized, vec![0u8; 32]].concat()).unwrap(); + assert_eq!(parsed.recipients[0].body, body); + } + + #[test] + fn header_bytes_up_to_footer_excludes_mac() { + let input = minimal_age_file(); + let bytes = header_bytes_up_to_footer(&input).expect("should find footer"); + // Must end with "--- " (not include the MAC value) + assert!(bytes.ends_with(b"--- "), "header_bytes must end at '--- '"); + } +} +``` + +Run the test to confirm it fails before implementation: + +```bash +cargo test -p notsecrets -- format --nocapture 2>&1 | head -30 +``` + +Expected: compile error — `parse_header`, `serialize_header`, `header_bytes_up_to_footer` do not exist yet. + +### 2.2 Implementation `crates/notsecrets/src/format.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{Header, Stanza}; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; + +const VERSION_LINE: &[u8] = b"age-encryption.org/v1\n"; +const FOOTER_PREFIX: &[u8] = b"--- "; + +/// Parse an age binary file's header. +/// +/// Returns `(Header, payload_offset)` where `payload_offset` is the byte +/// index into `input` where the payload (nonce + ciphertext) begins. +pub fn parse_header(input: &[u8]) -> Result<(Header, usize), AgeError> { + if !input.starts_with(VERSION_LINE) { + return Err(AgeError::ParseError( + "missing age version line".to_string(), + )); + } + + let mut pos = VERSION_LINE.len(); + let mut recipients: Vec = Vec::new(); + + loop { + if pos >= input.len() { + return Err(AgeError::ParseError("unexpected end of header".to_string())); + } + + // Check for footer line: "--- \n" + if input[pos..].starts_with(FOOTER_PREFIX) { + let line_end = input[pos..] + .iter() + .position(|&b| b == b'\n') + .ok_or_else(|| AgeError::ParseError("footer line not terminated".to_string()))?; + let mac_b64 = &input[pos + FOOTER_PREFIX.len()..pos + line_end]; + let mac = STANDARD_NO_PAD + .decode(mac_b64) + .map_err(|e| AgeError::ParseError(format!("footer MAC base64: {e}")))?; + let payload_offset = pos + line_end + 1; // skip trailing '\n' + return Ok((Header { recipients, mac }, payload_offset)); + } + + // Must be a stanza line: "-> [args...]\n" + if !input[pos..].starts_with(b"-> ") { + return Err(AgeError::ParseError(format!( + "expected stanza or footer at byte {pos}" + ))); + } + + let line_end = input[pos..] + .iter() + .position(|&b| b == b'\n') + .ok_or_else(|| AgeError::ParseError("stanza header line not terminated".to_string()))?; + + let header_line = std::str::from_utf8(&input[pos + 3..pos + line_end]) + .map_err(|e| AgeError::ParseError(format!("stanza line UTF-8: {e}")))?; + + let mut parts = header_line.split(' '); + let tag = parts + .next() + .ok_or_else(|| AgeError::ParseError("stanza missing tag".to_string()))? + .to_string(); + let args: Vec = parts.map(str::to_string).collect(); + + pos += line_end + 1; + + // Read stanza body: base64 lines until an empty line + let mut body_b64 = String::new(); + loop { + if pos >= input.len() { + return Err(AgeError::ParseError( + "unexpected end during stanza body".to_string(), + )); + } + let line_end = input[pos..] + .iter() + .position(|&b| b == b'\n') + .ok_or_else(|| { + AgeError::ParseError("stanza body line not terminated".to_string()) + })?; + let line = &input[pos..pos + line_end]; + pos += line_end + 1; + if line.is_empty() { + break; + } + body_b64.push_str( + std::str::from_utf8(line) + .map_err(|e| AgeError::ParseError(format!("body UTF-8: {e}")))?, + ); + } + + let body = if body_b64.is_empty() { + Vec::new() + } else { + STANDARD_NO_PAD + .decode(&body_b64) + .map_err(|e| AgeError::ParseError(format!("stanza body base64: {e}")))? + }; + + recipients.push(Stanza { tag, args, body }); + } +} + +/// Serialize a `Header` to bytes. +/// +/// Writes version line, all stanzas (with body split into 64-char base64 lines), +/// and the `--- ` footer. Does NOT write the payload. +pub fn serialize_header(header: &Header) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(VERSION_LINE); + + for stanza in &header.recipients { + // "-> tag arg1 arg2\n" + out.extend_from_slice(b"-> "); + out.extend_from_slice(stanza.tag.as_bytes()); + for arg in &stanza.args { + out.push(b' '); + out.extend_from_slice(arg.as_bytes()); + } + out.push(b'\n'); + + // Body: encode as base64, split into 64-char lines, trailing empty line + if stanza.body.is_empty() { + out.push(b'\n'); + } else { + let encoded = STANDARD_NO_PAD.encode(&stanza.body); + for chunk in encoded.as_bytes().chunks(64) { + out.extend_from_slice(chunk); + out.push(b'\n'); + } + // If the last chunk was exactly 64 chars, we still need the empty terminator line. + // If the last chunk was < 64 chars, we also need the empty terminator line. + out.push(b'\n'); + } + } + + // Footer + out.extend_from_slice(FOOTER_PREFIX); + out.extend_from_slice(STANDARD_NO_PAD.encode(&header.mac).as_bytes()); + out.push(b'\n'); + + out +} + +/// Return the header bytes up to and including `"--- "` (not including the MAC value). +/// +/// Used for MAC computation: HMAC-SHA256 covers all bytes from the start of the +/// file up to and including the `"--- "` separator. +pub fn header_bytes_up_to_footer(input: &[u8]) -> Result, AgeError> { + let footer_pos = input + .windows(FOOTER_PREFIX.len()) + .position(|w| w == FOOTER_PREFIX) + .ok_or_else(|| AgeError::ParseError("footer not found".to_string()))?; + Ok(input[..footer_pos + FOOTER_PREFIX.len()].to_vec()) +} + +#[cfg(test)] +mod tests { + // ... (tests from 2.1 above) +} +``` + +Add `pub mod format;` to `lib.rs`. + +### 2.3 Run tests + +```bash +cargo test -p notsecrets -- format --nocapture +``` + +Expected output (all pass): + +``` +test format::tests::parse_empty_input_returns_error ... ok +test format::tests::parse_missing_version_line_returns_error ... ok +test format::tests::parse_truncated_before_footer_returns_error ... ok +test format::tests::parse_minimal_header_roundtrip ... ok +test format::tests::serialize_header_roundtrip ... ok +test format::tests::stanza_body_split_across_multiple_64char_lines ... ok +test format::tests::header_bytes_up_to_footer_excludes_mac ... ok +``` + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 2.4 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/format.rs crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): age wire format parser/serializer with unit tests" +``` + +--- + +## Task 3: X25519 identity + recipient + +- [ ] Create `crates/notsecrets/src/identities/x25519.rs` +- [ ] Create `crates/notsecrets/src/recipients/x25519.rs` +- [ ] Update `identities/mod.rs` and `recipients/mod.rs` to re-export + +### 3.1 Test (write first) + +Add to `crates/notsecrets/src/identities/x25519.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::identities::FileKey; + use crate::recipients::x25519::X25519Recipient; + + /// The canonical test vector: generate a keypair, wrap a file key, unwrap it. + #[test] + fn x25519_wrap_unwrap_roundtrip() { + use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret}; + use rand::rngs::OsRng; + + // Generate a static identity key + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + + // bech32-encode public key as age1... recipient + let recipient = X25519Recipient::from_public_key(public); + // bech32-encode secret key as AGE-SECRET-KEY-1... identity + let identity = X25519Identity::from_static_secret(secret); + + let file_key = FileKey::new([0x42u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed"); + assert_eq!(stanza.tag, "X25519"); + assert_eq!(stanza.args.len(), 1); // ephemeral pubkey + + let unwrapped = identity + .unwrap_file_key(&stanza) + .expect("identity should match") + .expect("unwrap should succeed"); + assert_eq!(unwrapped.as_bytes(), file_key.as_bytes()); + } + + #[test] + fn x25519_wrong_identity_returns_none_for_nonmatching_tag() { + use x25519_dalek::{PublicKey, StaticSecret}; + use rand::rngs::OsRng; + + let secret = StaticSecret::random_from_rng(OsRng); + let identity = X25519Identity::from_static_secret(secret); + + use crate::identities::Stanza; + let stanza = Stanza { + tag: "scrypt".to_string(), + args: vec!["salt".to_string(), "18".to_string()], + body: vec![0u8; 32], + }; + // Different tag — must return None, not an error + assert!(identity.unwrap_file_key(&stanza).is_none()); + } + + #[test] + fn x25519_bech32_parse_roundtrip() { + use x25519_dalek::{PublicKey, StaticSecret}; + use rand::rngs::OsRng; + + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + + let recipient_str = X25519Recipient::from_public_key(public).to_bech32(); + assert!(recipient_str.starts_with("age1"), "recipient must start with age1"); + + let identity_str = X25519Identity::from_static_secret( + StaticSecret::random_from_rng(OsRng) + ).to_bech32(); + assert!( + identity_str.starts_with("AGE-SECRET-KEY-1"), + "identity must start with AGE-SECRET-KEY-1" + ); + } +} +``` + +Run to confirm compile failure: + +```bash +cargo test -p notsecrets -- identities::x25519 --nocapture 2>&1 | head -20 +``` + +Expected: compile error — modules don't exist yet. + +### 3.2 `crates/notsecrets/src/identities/x25519.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Identity, Stanza}; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use bech32::{Bech32, Hrp}; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; +use hkdf::Hkdf; +use sha2::Sha256; +use x25519_dalek::{PublicKey, StaticSecret}; + +const TAG: &str = "X25519"; +const HKDF_INFO: &[u8] = b"age-encryption.org/v1/X25519"; +const IDENTITY_HRP: &str = "age-secret-key-"; + +pub struct X25519Identity { + secret: StaticSecret, +} + +impl X25519Identity { + pub fn from_static_secret(secret: StaticSecret) -> Self { + Self { secret } + } + + /// Parse an `AGE-SECRET-KEY-1...` bech32 string. + pub fn from_bech32(s: &str) -> Result { + let s_lower = s.to_lowercase(); + let (hrp, data) = bech32::decode(&s_lower) + .map_err(|e| AgeError::ParseError(format!("bech32 decode identity: {e}")))?; + if hrp.as_str() != IDENTITY_HRP { + return Err(AgeError::ParseError(format!( + "expected hrp '{IDENTITY_HRP}', got '{}'", + hrp.as_str() + ))); + } + let bytes: [u8; 32] = data + .try_into() + .map_err(|_| AgeError::ParseError("identity key must be 32 bytes".to_string()))?; + Ok(Self { + secret: StaticSecret::from(bytes), + }) + } + + /// Encode as `AGE-SECRET-KEY-1...` bech32 uppercase string. + pub fn to_bech32(&self) -> String { + let hrp = Hrp::parse(IDENTITY_HRP).expect("static hrp is valid"); + bech32::encode::(hrp, self.secret.as_bytes()) + .expect("bech32 encode cannot fail for valid input") + .to_uppercase() + } +} + +impl Identity for X25519Identity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option> { + if stanza.tag != TAG { + return None; + } + Some(unwrap(stanza, &self.secret)) + } +} + +fn unwrap(stanza: &Stanza, secret: &StaticSecret) -> Result { + if stanza.args.len() != 1 { + return Err(AgeError::ParseError( + "X25519 stanza must have exactly 1 arg".to_string(), + )); + } + let ephemeral_pub_bytes = STANDARD_NO_PAD + .decode(&stanza.args[0]) + .map_err(|e| AgeError::ParseError(format!("ephemeral pubkey base64: {e}")))?; + let ephemeral_pub_bytes: [u8; 32] = ephemeral_pub_bytes + .try_into() + .map_err(|_| AgeError::ParseError("ephemeral pubkey must be 32 bytes".to_string()))?; + let ephemeral_pub = PublicKey::from(ephemeral_pub_bytes); + + let shared = secret.diffie_hellman(&ephemeral_pub); + let recipient_pub = PublicKey::from(secret); + + let wrap_key = derive_wrap_key(shared.as_bytes(), ephemeral_pub.as_bytes(), recipient_pub.as_bytes())?; + + let wrapped = &stanza.body; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)); + let nonce = Nonce::default(); // [0u8; 12] + let file_key_bytes = cipher + .decrypt(&nonce, wrapped.as_slice()) + .map_err(|_| AgeError::CryptoError("X25519 file key decryption failed".to_string()))?; + + FileKey::try_from(file_key_bytes.as_slice()) +} + +pub(crate) fn derive_wrap_key( + shared: &[u8], + ephemeral_pub: &[u8], + recipient_pub: &[u8], +) -> Result<[u8; 32], AgeError> { + let mut ikm = Vec::with_capacity(shared.len() + ephemeral_pub.len() + recipient_pub.len()); + ikm.extend_from_slice(shared); + ikm.extend_from_slice(ephemeral_pub); + ikm.extend_from_slice(recipient_pub); + + let hk = Hkdf::::new(None, &ikm); + let mut wrap_key = [0u8; 32]; + hk.expand(HKDF_INFO, &mut wrap_key) + .map_err(|e| AgeError::CryptoError(format!("HKDF expand: {e}")))?; + Ok(wrap_key) +} +``` + +### 3.3 `crates/notsecrets/src/recipients/x25519.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Stanza}; +use crate::identities::x25519::derive_wrap_key; +use crate::recipients::Recipient; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use bech32::{Bech32, Hrp}; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; +use rand::rngs::OsRng; +use x25519_dalek::{EphemeralSecret, PublicKey}; + +const TAG: &str = "X25519"; +const RECIPIENT_HRP: &str = "age"; + +pub struct X25519Recipient { + public_key: PublicKey, +} + +impl X25519Recipient { + pub fn from_public_key(public_key: PublicKey) -> Self { + Self { public_key } + } + + /// Parse an `age1...` bech32 public key string. + pub fn from_bech32(s: &str) -> Result { + let (hrp, data) = bech32::decode(s) + .map_err(|e| AgeError::ParseError(format!("bech32 decode recipient: {e}")))?; + if hrp.as_str() != RECIPIENT_HRP { + return Err(AgeError::ParseError(format!( + "expected hrp '{RECIPIENT_HRP}', got '{}'", + hrp.as_str() + ))); + } + let bytes: [u8; 32] = data + .try_into() + .map_err(|_| AgeError::ParseError("recipient key must be 32 bytes".to_string()))?; + Ok(Self { + public_key: PublicKey::from(bytes), + }) + } + + /// Encode as `age1...` bech32 string. + pub fn to_bech32(&self) -> String { + let hrp = Hrp::parse(RECIPIENT_HRP).expect("static hrp is valid"); + bech32::encode::(hrp, self.public_key.as_bytes()) + .expect("bech32 encode cannot fail for valid input") + } +} + +impl Recipient for X25519Recipient { + fn wrap_file_key(&self, file_key: &FileKey) -> Result { + let ephemeral_secret = EphemeralSecret::random_from_rng(OsRng); + let ephemeral_pub = PublicKey::from(&ephemeral_secret); + let shared = ephemeral_secret.diffie_hellman(&self.public_key); + + let wrap_key = derive_wrap_key( + shared.as_bytes(), + ephemeral_pub.as_bytes(), + self.public_key.as_bytes(), + )?; + + let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)); + let nonce = Nonce::default(); // [0u8; 12] + let wrapped = cipher + .encrypt(&nonce, file_key.as_bytes().as_slice()) + .map_err(|_| AgeError::CryptoError("X25519 file key encryption failed".to_string()))?; + + Ok(Stanza { + tag: TAG.to_string(), + args: vec![STANDARD_NO_PAD.encode(ephemeral_pub.as_bytes())], + body: wrapped, + }) + } +} +``` + +### 3.4 Wire into mod files + +In `crates/notsecrets/src/identities/mod.rs`, add: + +```rust +pub mod x25519; +pub use x25519::X25519Identity; +``` + +In `crates/notsecrets/src/recipients/mod.rs`, add: + +```rust +pub mod x25519; +pub use x25519::X25519Recipient; +``` + +In `crates/notsecrets/src/lib.rs`, add to public exports: + +```rust +pub use identities::X25519Identity; +pub use recipients::X25519Recipient; +``` + +### 3.5 Run tests + +```bash +cargo test -p notsecrets -- x25519 --nocapture +``` + +Expected: + +``` +test identities::x25519::tests::x25519_bech32_parse_roundtrip ... ok +test identities::x25519::tests::x25519_wrong_identity_returns_none_for_nonmatching_tag ... ok +test identities::x25519::tests::x25519_wrap_unwrap_roundtrip ... ok +``` + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 3.6 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/identities/ crates/notsecrets/src/recipients/ crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): X25519 identity and recipient with wrap/unwrap" +``` + +--- + +## Task 4: Scrypt identity + recipient + +- [ ] Create `crates/notsecrets/src/identities/scrypt.rs` +- [ ] Create `crates/notsecrets/src/recipients/scrypt.rs` + +### 4.1 Test (write first) + +In `crates/notsecrets/src/identities/scrypt.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::identities::FileKey; + use crate::recipients::scrypt::ScryptRecipient; + + #[test] + fn scrypt_wrap_unwrap_roundtrip() { + let passphrase = "hunter2".to_string(); + let recipient = ScryptRecipient::new(passphrase.clone(), 14); + let identity = ScryptIdentity::new(passphrase); + + let file_key = FileKey::new([0x11u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed"); + assert_eq!(stanza.tag, "scrypt"); + assert_eq!(stanza.args.len(), 2); // base64(salt), work_factor + + let unwrapped = identity + .unwrap_file_key(&stanza) + .expect("identity should match") + .expect("unwrap should succeed"); + assert_eq!(unwrapped.as_bytes(), file_key.as_bytes()); + } + + #[test] + fn scrypt_wrong_passphrase_returns_crypto_error() { + let recipient = ScryptRecipient::new("correct".to_string(), 14); + let identity = ScryptIdentity::new("wrong".to_string()); + + let file_key = FileKey::new([0x11u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).unwrap(); + + let result = identity + .unwrap_file_key(&stanza) + .expect("tag matches") + .expect_err("wrong passphrase should fail"); + assert!(matches!(result, crate::error::AgeError::CryptoError(_))); + } + + #[test] + fn scrypt_wrong_tag_returns_none() { + let identity = ScryptIdentity::new("pass".to_string()); + use crate::identities::Stanza; + let stanza = Stanza { + tag: "X25519".to_string(), + args: vec!["arg".to_string()], + body: vec![], + }; + assert!(identity.unwrap_file_key(&stanza).is_none()); + } +} +``` + +### 4.2 `crates/notsecrets/src/identities/scrypt.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Identity, Stanza}; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; + +const TAG: &str = "scrypt"; + +pub struct ScryptIdentity { + passphrase: String, +} + +impl ScryptIdentity { + pub fn new(passphrase: String) -> Self { + Self { passphrase } + } +} + +impl Identity for ScryptIdentity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option> { + if stanza.tag != TAG { + return None; + } + Some(unwrap(stanza, &self.passphrase)) + } +} + +fn unwrap(stanza: &Stanza, passphrase: &str) -> Result { + if stanza.args.len() != 2 { + return Err(AgeError::ParseError( + "scrypt stanza must have 2 args: salt work_factor".to_string(), + )); + } + let salt = STANDARD_NO_PAD + .decode(&stanza.args[0]) + .map_err(|e| AgeError::ParseError(format!("scrypt salt base64: {e}")))?; + let work_factor: u8 = stanza.args[1] + .parse() + .map_err(|e| AgeError::ParseError(format!("scrypt work factor parse: {e}")))?; + + let wrap_key = derive_scrypt_key(passphrase.as_bytes(), &salt, work_factor)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)); + let nonce = Nonce::default(); + let file_key_bytes = cipher + .decrypt(&nonce, stanza.body.as_slice()) + .map_err(|_| AgeError::CryptoError("scrypt file key decryption failed".to_string()))?; + FileKey::try_from(file_key_bytes.as_slice()) +} + +pub(crate) fn derive_scrypt_key( + passphrase: &[u8], + salt: &[u8], + work_factor: u8, +) -> Result<[u8; 32], AgeError> { + let params = scrypt::Params::new(work_factor, 8, 1, 32) + .map_err(|e| AgeError::CryptoError(format!("scrypt params: {e}")))?; + let mut wrap_key = [0u8; 32]; + scrypt::scrypt(passphrase, salt, ¶ms, &mut wrap_key) + .map_err(|e| AgeError::CryptoError(format!("scrypt: {e}")))?; + Ok(wrap_key) +} +``` + +### 4.3 `crates/notsecrets/src/recipients/scrypt.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Stanza}; +use crate::identities::scrypt::derive_scrypt_key; +use crate::recipients::Recipient; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; +use rand::RngCore; + +const TAG: &str = "scrypt"; +const SALT_LEN: usize = 16; + +pub struct ScryptRecipient { + passphrase: String, + work_factor: u8, +} + +impl ScryptRecipient { + /// `work_factor` is the log2(N) parameter (default 18 for production; use 14 for tests). + pub fn new(passphrase: String, work_factor: u8) -> Self { + Self { passphrase, work_factor } + } +} + +impl Recipient for ScryptRecipient { + fn wrap_file_key(&self, file_key: &FileKey) -> Result { + let mut salt = [0u8; SALT_LEN]; + rand::thread_rng().fill_bytes(&mut salt); + + let wrap_key = derive_scrypt_key(self.passphrase.as_bytes(), &salt, self.work_factor)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)); + let nonce = Nonce::default(); + let wrapped = cipher + .encrypt(&nonce, file_key.as_bytes().as_slice()) + .map_err(|_| AgeError::CryptoError("scrypt file key encryption failed".to_string()))?; + + Ok(Stanza { + tag: TAG.to_string(), + args: vec![ + STANDARD_NO_PAD.encode(salt), + self.work_factor.to_string(), + ], + body: wrapped, + }) + } +} +``` + +### 4.4 Wire into mod files + +In `crates/notsecrets/src/identities/mod.rs`, add: + +```rust +pub mod scrypt; +pub use scrypt::ScryptIdentity; +``` + +In `crates/notsecrets/src/recipients/mod.rs`, add: + +```rust +pub mod scrypt; +pub use scrypt::ScryptRecipient; +``` + +In `lib.rs` exports, add: + +```rust +pub use identities::ScryptIdentity; +pub use recipients::ScryptRecipient; +``` + +### 4.5 Run tests + +```bash +cargo test -p notsecrets -- scrypt --nocapture +``` + +Expected: + +``` +test identities::scrypt::tests::scrypt_wrap_unwrap_roundtrip ... ok +test identities::scrypt::tests::scrypt_wrong_passphrase_returns_crypto_error ... ok +test identities::scrypt::tests::scrypt_wrong_tag_returns_none ... ok +``` + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 4.6 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/identities/scrypt.rs crates/notsecrets/src/recipients/scrypt.rs crates/notsecrets/src/identities/mod.rs crates/notsecrets/src/recipients/mod.rs crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): scrypt identity and recipient (passphrase-based)" +``` + +--- + +## Task 5: SSH Ed25519 identity + recipient + +- [ ] Create `crates/notsecrets/src/identities/ssh_ed25519.rs` +- [ ] Create `crates/notsecrets/src/recipients/ssh_ed25519.rs` + +### 5.1 Test (write first) + +In `crates/notsecrets/src/identities/ssh_ed25519.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::identities::FileKey; + use crate::recipients::ssh_ed25519::SshEd25519Recipient; + + fn test_keypair() -> (ed25519_dalek::SigningKey, ed25519_dalek::VerifyingKey) { + use rand::rngs::OsRng; + let signing_key = ed25519_dalek::SigningKey::generate(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + (signing_key, verifying_key) + } + + #[test] + fn ssh_ed25519_wrap_unwrap_roundtrip() { + let (signing_key, verifying_key) = test_keypair(); + let recipient = SshEd25519Recipient::from_verifying_key(verifying_key); + let identity = SshEd25519Identity::from_signing_key(signing_key); + + let file_key = FileKey::new([0x33u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed"); + assert_eq!(stanza.tag, "ssh-ed25519"); + assert_eq!(stanza.args.len(), 2); // fingerprint, ephemeral_pub + + let unwrapped = identity + .unwrap_file_key(&stanza) + .expect("identity should match") + .expect("unwrap should succeed"); + assert_eq!(unwrapped.as_bytes(), file_key.as_bytes()); + } + + #[test] + fn ssh_ed25519_wrong_fingerprint_returns_none() { + let (_, verifying_key1) = test_keypair(); + let (signing_key2, _) = test_keypair(); + let recipient = SshEd25519Recipient::from_verifying_key(verifying_key1); + let identity = SshEd25519Identity::from_signing_key(signing_key2); + + let file_key = FileKey::new([0x33u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).unwrap(); + // Different key — fingerprint check in unwrap_file_key should return None + assert!(identity.unwrap_file_key(&stanza).is_none()); + } +} +``` + +### 5.2 `crates/notsecrets/src/identities/ssh_ed25519.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Identity, Stanza}; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; +use ed25519_dalek::SigningKey; +use hkdf::Hkdf; +use sha2::{Digest, Sha256, Sha512}; +use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519StaticSecret}; + +const TAG: &str = "ssh-ed25519"; +const HKDF_INFO: &[u8] = b"age-encryption.org/v1/ssh-ed25519"; + +pub struct SshEd25519Identity { + signing_key: SigningKey, +} + +impl SshEd25519Identity { + pub fn from_signing_key(signing_key: SigningKey) -> Self { + Self { signing_key } + } + + fn x25519_secret(&self) -> X25519StaticSecret { + // Ed25519 -> X25519 conversion: clamp(SHA-512(seed)[..32]) + let mut hash = Sha512::digest(self.signing_key.as_bytes()); + hash[0] &= 248; + hash[31] &= 127; + hash[31] |= 64; + let bytes: [u8; 32] = hash[..32].try_into().expect("SHA-512 output is 64 bytes"); + X25519StaticSecret::from(bytes) + } + + fn fingerprint(&self) -> [u8; 4] { + ssh_key_fingerprint(self.signing_key.verifying_key().as_bytes()) + } +} + +impl Identity for SshEd25519Identity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option> { + if stanza.tag != TAG { + return None; + } + if stanza.args.len() != 2 { + return Some(Err(AgeError::ParseError( + "ssh-ed25519 stanza must have 2 args".to_string(), + ))); + } + // Check fingerprint + let fp_bytes = match STANDARD_NO_PAD.decode(&stanza.args[0]) { + Ok(b) => b, + Err(e) => return Some(Err(AgeError::ParseError(format!("fingerprint base64: {e}")))), + }; + if fp_bytes != self.fingerprint() { + return None; + } + Some(unwrap(stanza, self)) + } +} + +fn unwrap(stanza: &Stanza, identity: &SshEd25519Identity) -> Result { + let ephemeral_pub_bytes = STANDARD_NO_PAD + .decode(&stanza.args[1]) + .map_err(|e| AgeError::ParseError(format!("ephemeral pubkey base64: {e}")))?; + let ephemeral_pub_bytes: [u8; 32] = ephemeral_pub_bytes + .try_into() + .map_err(|_| AgeError::ParseError("ephemeral pubkey must be 32 bytes".to_string()))?; + let ephemeral_pub = X25519PublicKey::from(ephemeral_pub_bytes); + + let x25519_secret = identity.x25519_secret(); + let shared = x25519_secret.diffie_hellman(&ephemeral_pub); + + let wrap_key = derive_wrap_key( + shared.as_bytes(), + ephemeral_pub.as_bytes(), + X25519PublicKey::from(&x25519_secret).as_bytes(), + )?; + + let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)); + let nonce = Nonce::default(); + let file_key_bytes = cipher + .decrypt(&nonce, stanza.body.as_slice()) + .map_err(|_| AgeError::CryptoError("ssh-ed25519 file key decryption failed".to_string()))?; + FileKey::try_from(file_key_bytes.as_slice()) +} + +pub(crate) fn ssh_key_fingerprint(pub_key_bytes: &[u8]) -> [u8; 4] { + let hash = Sha256::digest(pub_key_bytes); + hash[..4].try_into().expect("SHA-256 output is 32 bytes") +} + +pub(crate) fn derive_wrap_key( + shared: &[u8], + ephemeral_pub: &[u8], + recipient_pub: &[u8], +) -> Result<[u8; 32], AgeError> { + let mut ikm = Vec::with_capacity(shared.len() + ephemeral_pub.len() + recipient_pub.len()); + ikm.extend_from_slice(shared); + ikm.extend_from_slice(ephemeral_pub); + ikm.extend_from_slice(recipient_pub); + + let hk = Hkdf::::new(None, &ikm); + let mut wrap_key = [0u8; 32]; + hk.expand(HKDF_INFO, &mut wrap_key) + .map_err(|e| AgeError::CryptoError(format!("HKDF expand: {e}")))?; + Ok(wrap_key) +} +``` + +### 5.3 `crates/notsecrets/src/recipients/ssh_ed25519.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Stanza}; +use crate::identities::ssh_ed25519::{derive_wrap_key, ssh_key_fingerprint}; +use crate::recipients::Recipient; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; +use ed25519_dalek::VerifyingKey; +use rand::rngs::OsRng; +use x25519_dalek::{EphemeralSecret, PublicKey as X25519PublicKey}; + +const TAG: &str = "ssh-ed25519"; + +pub struct SshEd25519Recipient { + verifying_key: VerifyingKey, +} + +impl SshEd25519Recipient { + pub fn from_verifying_key(verifying_key: VerifyingKey) -> Self { + Self { verifying_key } + } + + fn x25519_public(&self) -> X25519PublicKey { + // Ed25519 pubkey -> X25519 pubkey via birational map + // curve25519_dalek provides this via EdwardsPoint::to_montgomery() + use curve25519_dalek::edwards::CompressedEdwardsY; + let compressed = CompressedEdwardsY(self.verifying_key.to_bytes()); + let point = compressed.decompress().expect("valid ed25519 pubkey decompresses"); + X25519PublicKey::from(point.to_montgomery().to_bytes()) + } +} + +impl Recipient for SshEd25519Recipient { + fn wrap_file_key(&self, file_key: &FileKey) -> Result { + let ephemeral_secret = EphemeralSecret::random_from_rng(OsRng); + let ephemeral_pub = X25519PublicKey::from(&ephemeral_secret); + let recipient_x25519 = self.x25519_public(); + let shared = ephemeral_secret.diffie_hellman(&recipient_x25519); + + let wrap_key = derive_wrap_key( + shared.as_bytes(), + ephemeral_pub.as_bytes(), + recipient_x25519.as_bytes(), + )?; + + let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)); + let nonce = Nonce::default(); + let wrapped = cipher + .encrypt(&nonce, file_key.as_bytes().as_slice()) + .map_err(|_| AgeError::CryptoError("ssh-ed25519 file key encryption failed".to_string()))?; + + let fingerprint = ssh_key_fingerprint(self.verifying_key.as_bytes()); + + Ok(Stanza { + tag: TAG.to_string(), + args: vec![ + STANDARD_NO_PAD.encode(fingerprint), + STANDARD_NO_PAD.encode(ephemeral_pub.as_bytes()), + ], + body: wrapped, + }) + } +} +``` + +Add `curve25519-dalek = "4"` to `[dependencies]` in `crates/notsecrets/Cargo.toml`. + +### 5.4 Wire into mod files + +In `identities/mod.rs`: + +```rust +pub mod ssh_ed25519; +pub use ssh_ed25519::SshEd25519Identity; +``` + +In `recipients/mod.rs`: + +```rust +pub mod ssh_ed25519; +pub use ssh_ed25519::SshEd25519Recipient; +``` + +In `lib.rs`: + +```rust +pub use identities::SshEd25519Identity; +pub use recipients::SshEd25519Recipient; +``` + +### 5.5 Run tests + +```bash +cargo test -p notsecrets -- ssh_ed25519 --nocapture +``` + +Expected: + +``` +test identities::ssh_ed25519::tests::ssh_ed25519_wrap_unwrap_roundtrip ... ok +test identities::ssh_ed25519::tests::ssh_ed25519_wrong_fingerprint_returns_none ... ok +``` + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 5.6 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/identities/ssh_ed25519.rs crates/notsecrets/src/recipients/ssh_ed25519.rs crates/notsecrets/src/identities/mod.rs crates/notsecrets/src/recipients/mod.rs crates/notsecrets/src/lib.rs crates/notsecrets/Cargo.toml +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): SSH Ed25519 identity and recipient" +``` + +--- + +## Task 6: SSH RSA identity + recipient + +- [ ] Create `crates/notsecrets/src/identities/ssh_rsa.rs` +- [ ] Create `crates/notsecrets/src/recipients/ssh_rsa.rs` + +### 6.1 Test (write first) + +In `crates/notsecrets/src/identities/ssh_rsa.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::identities::FileKey; + use crate::recipients::ssh_rsa::SshRsaRecipient; + use rsa::{RsaPrivateKey, RsaPublicKey}; + use rand::rngs::OsRng; + + fn test_rsa_keypair() -> (RsaPrivateKey, RsaPublicKey) { + // 2048-bit key; use a small bit size in tests only via feature flag or constant + let private_key = RsaPrivateKey::new(&mut OsRng, 2048).expect("RSA keygen failed"); + let public_key = RsaPublicKey::from(&private_key); + (private_key, public_key) + } + + #[test] + fn ssh_rsa_wrap_unwrap_roundtrip() { + let (private_key, public_key) = test_rsa_keypair(); + let recipient = SshRsaRecipient::from_public_key(public_key); + let identity = SshRsaIdentity::from_private_key(private_key); + + let file_key = FileKey::new([0x55u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed"); + assert_eq!(stanza.tag, "ssh-rsa"); + assert_eq!(stanza.args.len(), 1); // fingerprint + + let unwrapped = identity + .unwrap_file_key(&stanza) + .expect("identity should match") + .expect("unwrap should succeed"); + assert_eq!(unwrapped.as_bytes(), file_key.as_bytes()); + } + + #[test] + fn ssh_rsa_wrong_fingerprint_returns_none() { + let (private_key1, _) = test_rsa_keypair(); + let (_, public_key2) = test_rsa_keypair(); + let recipient = SshRsaRecipient::from_public_key(public_key2); + let identity = SshRsaIdentity::from_private_key(private_key1); + + let file_key = FileKey::new([0x55u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).unwrap(); + // Fingerprint mismatch → None + assert!(identity.unwrap_file_key(&stanza).is_none()); + } +} +``` + +### 6.2 `crates/notsecrets/src/identities/ssh_rsa.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Identity, Stanza}; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use rsa::{Oaep, RsaPrivateKey}; +use sha2::{Digest, Sha256}; + +const TAG: &str = "ssh-rsa"; + +pub struct SshRsaIdentity { + private_key: RsaPrivateKey, +} + +impl SshRsaIdentity { + pub fn from_private_key(private_key: RsaPrivateKey) -> Self { + Self { private_key } + } + + fn fingerprint(&self) -> [u8; 4] { + use rsa::traits::PublicKeyParts; + rsa_pubkey_fingerprint(&rsa::RsaPublicKey::from(&self.private_key)) + } +} + +impl Identity for SshRsaIdentity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option> { + if stanza.tag != TAG { + return None; + } + if stanza.args.len() != 1 { + return Some(Err(AgeError::ParseError( + "ssh-rsa stanza must have 1 arg".to_string(), + ))); + } + let fp = match STANDARD_NO_PAD.decode(&stanza.args[0]) { + Ok(b) => b, + Err(e) => return Some(Err(AgeError::ParseError(format!("fingerprint base64: {e}")))), + }; + if fp != self.fingerprint() { + return None; + } + Some(unwrap(stanza, &self.private_key)) + } +} + +fn unwrap(stanza: &Stanza, private_key: &RsaPrivateKey) -> Result { + use rand::rngs::OsRng; + let padding = Oaep::new::(); + let file_key_bytes = private_key + .decrypt(padding, &stanza.body) + .map_err(|_| AgeError::CryptoError("RSA-OAEP decrypt failed".to_string()))?; + FileKey::try_from(file_key_bytes.as_slice()) +} + +pub(crate) fn rsa_pubkey_fingerprint(public_key: &rsa::RsaPublicKey) -> [u8; 4] { + use rsa::traits::PublicKeyParts; + // Fingerprint: first 4 bytes of SHA-256 of the DER-encoded public key bytes + let n_bytes = public_key.n().to_bytes_be(); + let e_bytes = public_key.e().to_bytes_be(); + let mut hasher = Sha256::new(); + hasher.update(&n_bytes); + hasher.update(&e_bytes); + let hash = hasher.finalize(); + hash[..4].try_into().expect("SHA-256 is 32 bytes") +} +``` + +### 6.3 `crates/notsecrets/src/recipients/ssh_rsa.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Stanza}; +use crate::identities::ssh_rsa::rsa_pubkey_fingerprint; +use crate::recipients::Recipient; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; +use rsa::{Oaep, RsaPublicKey}; +use sha2::Sha256; + +const TAG: &str = "ssh-rsa"; + +pub struct SshRsaRecipient { + public_key: RsaPublicKey, +} + +impl SshRsaRecipient { + pub fn from_public_key(public_key: RsaPublicKey) -> Self { + Self { public_key } + } +} + +impl Recipient for SshRsaRecipient { + fn wrap_file_key(&self, file_key: &FileKey) -> Result { + use rand::rngs::OsRng; + let padding = Oaep::new::(); + let wrapped = self + .public_key + .encrypt(&mut OsRng, padding, file_key.as_bytes()) + .map_err(|_| AgeError::CryptoError("RSA-OAEP encrypt failed".to_string()))?; + + let fingerprint = rsa_pubkey_fingerprint(&self.public_key); + + Ok(Stanza { + tag: TAG.to_string(), + args: vec![STANDARD_NO_PAD.encode(fingerprint)], + body: wrapped, + }) + } +} +``` + +### 6.4 Wire into mod files + +In `identities/mod.rs`: + +```rust +pub mod ssh_rsa; +pub use ssh_rsa::SshRsaIdentity; +``` + +In `recipients/mod.rs`: + +```rust +pub mod ssh_rsa; +pub use ssh_rsa::SshRsaRecipient; +``` + +In `lib.rs`: + +```rust +pub use identities::SshRsaIdentity; +pub use recipients::SshRsaRecipient; +``` + +### 6.5 Run tests + +```bash +cargo test -p notsecrets -- ssh_rsa --nocapture +``` + +Expected: + +``` +test identities::ssh_rsa::tests::ssh_rsa_wrap_unwrap_roundtrip ... ok +test identities::ssh_rsa::tests::ssh_rsa_wrong_fingerprint_returns_none ... ok +``` + +Note: RSA keygen at 2048 bits is slow (~1s per test). This is expected in CI. + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 6.6 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/identities/ssh_rsa.rs crates/notsecrets/src/recipients/ssh_rsa.rs crates/notsecrets/src/identities/mod.rs crates/notsecrets/src/recipients/mod.rs crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): SSH RSA identity and recipient (OAEP-SHA256)" +``` + +--- + +## Task 7: EncryptedIdentity + +An `EncryptedIdentity` wraps another `Identity` whose key material is stored in an age-encrypted file. Decrypting the outer file yields the inner identity's key material (e.g., an `AGE-SECRET-KEY-1...` line). + +- [ ] Create `crates/notsecrets/src/identities/encrypted.rs` + +### 7.1 Test (write first) + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::identities::{FileKey, x25519::X25519Identity}; + use crate::recipients::x25519::X25519Recipient; + use x25519_dalek::{PublicKey, StaticSecret}; + use rand::rngs::OsRng; + + /// Build a minimal age-encrypted file wrapping an AGE-SECRET-KEY-1 identity. + fn make_encrypted_identity_file(inner_bech32: &str, passphrase: &str) -> Vec { + use crate::recipients::scrypt::ScryptRecipient; + let recipient = ScryptRecipient::new(passphrase.to_string(), 14); + // Encrypt the inner key as plaintext + let encryptor = crate::Encryptor::with_recipients(vec![Box::new(recipient)]) + .expect("encryptor build"); + encryptor + .encrypt(inner_bech32.as_bytes()) + .expect("encrypt inner key") + } + + #[test] + fn encrypted_identity_wraps_x25519() { + use rand::rngs::OsRng; + let inner_secret = StaticSecret::random_from_rng(OsRng); + let inner_pub = PublicKey::from(&inner_secret); + let inner_identity = X25519Identity::from_static_secret(inner_secret.clone()); + let inner_bech32 = inner_identity.to_bech32(); + + let passphrase = "test-passphrase"; + let encrypted_file = make_encrypted_identity_file(&inner_bech32, passphrase); + + // Wrap in EncryptedIdentity + let encrypted_identity = EncryptedIdentity::new( + encrypted_file, + Box::new(crate::identities::scrypt::ScryptIdentity::new(passphrase.to_string())), + ); + + // Encrypt a payload with the inner X25519 recipient + let recipient = X25519Recipient::from_public_key(inner_pub); + let file_key = FileKey::new([0x77u8; 16]); + let stanza = recipient.wrap_file_key(&file_key).unwrap(); + + // EncryptedIdentity should decrypt the file to recover the inner identity and unwrap + let unwrapped = encrypted_identity + .unwrap_file_key(&stanza) + .expect("should match") + .expect("should decrypt"); + assert_eq!(unwrapped.as_bytes(), file_key.as_bytes()); + } +} +``` + +Note: this test depends on `Encryptor` from Task 8. Mark it `#[ignore]` until Task 8 is complete, then remove the `#[ignore]`. + +### 7.2 `crates/notsecrets/src/identities/encrypted.rs` + +```rust +use crate::error::AgeError; +use crate::identities::{FileKey, Identity, Stanza}; + +/// An identity whose key material is stored in an age-encrypted file. +/// +/// The outer file is decrypted on each call to `unwrap_file_key` using +/// `passphrase_identity`. The decrypted content must be an `AGE-SECRET-KEY-1...` +/// bech32 string, which is parsed as an `X25519Identity`. +pub struct EncryptedIdentity { + /// Raw bytes of the age-encrypted identity file. + encrypted_data: Vec, + /// Identity used to decrypt the outer age file (typically `ScryptIdentity`). + passphrase_identity: Box, +} + +impl EncryptedIdentity { + pub fn new(encrypted_data: Vec, passphrase_identity: Box) -> Self { + Self { encrypted_data, passphrase_identity } + } +} + +impl Identity for EncryptedIdentity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option> { + // Decrypt the outer file to recover inner identity key bytes + let decryptor = crate::Decryptor::with_identities(vec![self.passphrase_identity.as_ref()]); + let plaintext = match decryptor.decrypt(&self.encrypted_data) { + Ok(p) => p, + Err(e) => return Some(Err(e)), + }; + let key_str = match std::str::from_utf8(&plaintext) { + Ok(s) => s.trim().to_string(), + Err(_) => { + return Some(Err(AgeError::ParseError( + "decrypted identity file is not valid UTF-8".to_string(), + ))) + } + }; + // Parse inner identity — only X25519 supported for now + let inner: Box = if key_str.starts_with("AGE-SECRET-KEY-1") { + match crate::identities::x25519::X25519Identity::from_bech32(&key_str) { + Ok(id) => Box::new(id), + Err(e) => return Some(Err(e)), + } + } else { + return Some(Err(AgeError::UnsupportedKeyType( + "encrypted identity file must contain AGE-SECRET-KEY-1".to_string(), + ))); + }; + inner.unwrap_file_key(stanza) + } +} +``` + +Note: `Decryptor::with_identities` here takes `Vec<&dyn Identity>` rather than `Vec>` to avoid clone requirements. The `Decryptor` signature in Task 9 must accommodate both — the simplest approach is to add a `with_identity_refs` constructor, or to make `EncryptedIdentity` clone the passphrase identity. The plan uses the latter: require `passphrase_identity: Box` and rely on the fact that `ScryptIdentity` is `Clone`. Adjust `Decryptor` API in Task 9 to accept `&[Box]` or provide a `decrypt_with` method. The exact API reconciliation is resolved in Task 9. + +### 7.3 Wire into mod files + +In `identities/mod.rs`: + +```rust +pub mod encrypted; +pub use encrypted::EncryptedIdentity; +``` + +In `lib.rs`: + +```rust +pub use identities::EncryptedIdentity; +``` + +### 7.4 Run tests + +```bash +cargo test -p notsecrets -- encrypted --nocapture +``` + +Expected: `EncryptedIdentity` tests are marked `#[ignore]` pending Task 8; remaining tests pass. + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 7.5 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/identities/encrypted.rs crates/notsecrets/src/identities/mod.rs crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): EncryptedIdentity — passphrase-protected identity file wrapper" +``` + +--- + +## Task 8: Encryptor + +- [ ] Add `crates/notsecrets/src/encrypt.rs` with `Encryptor` +- [ ] Wire into `lib.rs` + +### 8.1 Test (write first) + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::identities::FileKey; + use crate::identities::x25519::X25519Identity; + use crate::recipients::x25519::X25519Recipient; + use x25519_dalek::{PublicKey, StaticSecret}; + use rand::rngs::OsRng; + + #[test] + fn encryptor_single_x25519_recipient() { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + let recipient: Box = + Box::new(X25519Recipient::from_public_key(public)); + + let encryptor = Encryptor::with_recipients(vec![recipient]).unwrap(); + let plaintext = b"hello, age!"; + let ciphertext = encryptor.encrypt(plaintext).expect("encrypt should succeed"); + + // Verify it starts with the age version line + assert!( + ciphertext.starts_with(b"age-encryption.org/v1\n"), + "output must start with age version line" + ); + } + + #[test] + fn encryptor_rejects_multiple_scrypt_recipients() { + use crate::recipients::scrypt::ScryptRecipient; + let r1: Box = + Box::new(ScryptRecipient::new("pass1".to_string(), 14)); + let r2: Box = + Box::new(ScryptRecipient::new("pass2".to_string(), 14)); + let result = Encryptor::with_recipients(vec![r1, r2]); + assert!(result.is_err(), "two scrypt recipients must be rejected"); + } + + #[test] + fn encryptor_empty_recipients_returns_error() { + let result = Encryptor::with_recipients(vec![]); + assert!(result.is_err(), "empty recipients must be rejected"); + } +} +``` + +### 8.2 `crates/notsecrets/src/encrypt.rs` + +```rust +use crate::error::AgeError; +use crate::format::{serialize_header}; +use crate::identities::{FileKey, Header}; +use crate::recipients::Recipient; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +pub struct Encryptor { + recipients: Vec>, +} + +impl Encryptor { + /// Construct an `Encryptor`. Returns `AgeError` if: + /// - `recipients` is empty + /// - more than one scrypt recipient is present + pub fn with_recipients(recipients: Vec>) -> Result { + if recipients.is_empty() { + return Err(AgeError::CryptoError( + "at least one recipient is required".to_string(), + )); + } + // Detect scrypt recipients by attempting to wrap a dummy key and checking the stanza tag. + // We cannot inspect the concrete type, so we use a probe approach: + // wrap a dummy FileKey and check if any stanza tag is "scrypt". + let dummy_key = FileKey::new([0u8; 16]); + let scrypt_count = recipients + .iter() + .filter_map(|r| r.wrap_file_key(&dummy_key).ok()) + .filter(|s| s.tag == "scrypt") + .count(); + if scrypt_count > 1 { + return Err(AgeError::CryptoError( + "at most one scrypt recipient is allowed per age file".to_string(), + )); + } + Ok(Self { recipients }) + } + + pub fn encrypt(&self, plaintext: &[u8]) -> Result, AgeError> { + let file_key = FileKey::generate(); + + // Wrap file key with each recipient + let stanzas: Vec<_> = self + .recipients + .iter() + .map(|r| r.wrap_file_key(&file_key)) + .collect::>()?; + + // Build header without MAC first (we need the bytes to compute MAC) + let header_no_mac = Header { + recipients: stanzas, + mac: Vec::new(), + }; + + // Compute MAC: HMAC-SHA256 over header bytes up to and including "--- " + let mut header_bytes = serialize_header(&header_no_mac); + // The serialized header ends with "--- \n" (MAC is empty, so base64("") = ""). + // We need the MAC to cover bytes up to and including "--- ". + // Trim the trailing "\n" that follows the empty MAC, then compute MAC over + // everything up to and including "--- ". + let mac_key = derive_mac_key(&file_key)?; + let footer_pos = header_bytes + .windows(4) + .rposition(|w| w == b"--- ") + .ok_or_else(|| AgeError::ParseError("serialize_header missing footer".to_string()))?; + let mac_input = &header_bytes[..footer_pos + 4]; // up to and including "--- " + + let mut mac = HmacSha256::new_from_slice(&mac_key) + .map_err(|e| AgeError::CryptoError(format!("HMAC init: {e}")))?; + mac.update(mac_input); + let mac_bytes = mac.finalize().into_bytes().to_vec(); + + // Re-serialize with actual MAC + let header_with_mac = Header { + recipients: header_no_mac.recipients, + mac: mac_bytes, + }; + header_bytes = serialize_header(&header_with_mac); + + // Payload: 16-byte random nonce + ChaCha20Poly1305(payload_key, plaintext) + let mut nonce_bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let payload_key = derive_payload_key(&file_key, &nonce_bytes)?; + + let cipher = ChaCha20Poly1305::new(Key::from_slice(&payload_key)); + let counter_nonce = Nonce::default(); // [0u8; 12] + let ciphertext = cipher + .encrypt(&counter_nonce, plaintext) + .map_err(|_| AgeError::CryptoError("payload encryption failed".to_string()))?; + + let mut output = header_bytes; + output.extend_from_slice(&nonce_bytes); + output.extend_from_slice(&ciphertext); + + Ok(output) + } +} + +fn derive_mac_key(file_key: &FileKey) -> Result<[u8; 32], AgeError> { + let hk = Hkdf::::new(Some(b""), file_key.as_bytes()); + let mut mac_key = [0u8; 32]; + hk.expand(b"header", &mut mac_key) + .map_err(|e| AgeError::CryptoError(format!("HKDF mac key: {e}")))?; + Ok(mac_key) +} + +fn derive_payload_key(file_key: &FileKey, nonce: &[u8; 16]) -> Result<[u8; 32], AgeError> { + let hk = Hkdf::::new(Some(nonce.as_slice()), file_key.as_bytes()); + let mut payload_key = [0u8; 32]; + hk.expand(b"payload", &mut payload_key) + .map_err(|e| AgeError::CryptoError(format!("HKDF payload key: {e}")))?; + Ok(payload_key) +} +``` + +Add `pub mod encrypt;` and `pub use encrypt::Encryptor;` to `lib.rs`. + +### 8.3 Run tests + +```bash +cargo test -p notsecrets -- encryptor --nocapture +``` + +Expected: + +``` +test encrypt::tests::encryptor_single_x25519_recipient ... ok +test encrypt::tests::encryptor_rejects_multiple_scrypt_recipients ... ok +test encrypt::tests::encryptor_empty_recipients_returns_error ... ok +``` + +Also run the `encrypted` test which was previously ignored — it now has `Encryptor` available: + +```bash +cargo test -p notsecrets -- encrypted --nocapture +``` + +Remove the `#[ignore]` attribute from `encrypted` tests and verify they pass. + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 8.4 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/encrypt.rs crates/notsecrets/src/identities/encrypted.rs crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): Encryptor — multi-recipient age encryption" +``` + +--- + +## Task 9: Decryptor + +- [ ] Add `crates/notsecrets/src/decrypt.rs` with `Decryptor` +- [ ] Wire into `lib.rs` + +### 9.1 Test (write first) + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::Encryptor; + use crate::identities::x25519::X25519Identity; + use crate::recipients::x25519::X25519Recipient; + use x25519_dalek::{PublicKey, StaticSecret}; + use rand::rngs::OsRng; + + fn make_x25519_pair() -> (X25519Identity, X25519Recipient) { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + ( + X25519Identity::from_static_secret(secret), + X25519Recipient::from_public_key(public), + ) + } + + #[test] + fn decryptor_roundtrip_single_recipient() { + let (identity, recipient) = make_x25519_pair(); + let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap(); + let plaintext = b"the quick brown fox"; + let ciphertext = encryptor.encrypt(plaintext).unwrap(); + + let decryptor = Decryptor::with_identities(vec![Box::new(identity)]); + let decrypted = decryptor.decrypt(&ciphertext).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn decryptor_roundtrip_multiple_recipients() { + let (identity1, recipient1) = make_x25519_pair(); + let (identity2, recipient2) = make_x25519_pair(); + let encryptor = Encryptor::with_recipients(vec![ + Box::new(recipient1), + Box::new(recipient2), + ]) + .unwrap(); + let plaintext = b"multi-recipient test"; + let ciphertext = encryptor.encrypt(plaintext).unwrap(); + + // Either identity alone can decrypt + let decryptor1 = Decryptor::with_identities(vec![Box::new(identity1)]); + assert_eq!(decryptor1.decrypt(&ciphertext).unwrap(), plaintext); + + let decryptor2 = Decryptor::with_identities(vec![Box::new(identity2)]); + assert_eq!(decryptor2.decrypt(&ciphertext).unwrap(), plaintext); + } + + #[test] + fn decryptor_no_matching_identity_returns_no_match() { + let (_, recipient) = make_x25519_pair(); + let (wrong_identity, _) = make_x25519_pair(); + let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap(); + let ciphertext = encryptor.encrypt(b"data").unwrap(); + + let decryptor = Decryptor::with_identities(vec![Box::new(wrong_identity)]); + let err = decryptor.decrypt(&ciphertext).unwrap_err(); + assert!(matches!(err, crate::error::AgeError::NoMatch)); + } + + #[test] + fn decryptor_corrupted_mac_returns_mac_mismatch() { + let (identity, recipient) = make_x25519_pair(); + let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap(); + let mut ciphertext = encryptor.encrypt(b"data").unwrap(); + + // Flip a byte in the MAC region (near end of header, before payload) + // Find "--- " and flip a byte 5 bytes after it + let footer_pos = ciphertext + .windows(4) + .position(|w| w == b"--- ") + .expect("footer present"); + ciphertext[footer_pos + 4] ^= 0xff; + + let decryptor = Decryptor::with_identities(vec![Box::new(identity)]); + let err = decryptor.decrypt(&ciphertext).unwrap_err(); + assert!( + matches!(err, crate::error::AgeError::MacMismatch | crate::error::AgeError::ParseError(_)), + "expected MacMismatch or ParseError, got: {err:?}" + ); + } +} +``` + +### 9.2 `crates/notsecrets/src/decrypt.rs` + +```rust +use crate::error::AgeError; +use crate::format::{header_bytes_up_to_footer, parse_header}; +use crate::identities::{FileKey, Identity}; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::Aead, +}; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +pub struct Decryptor { + identities: Vec>, +} + +impl Decryptor { + pub fn with_identities(identities: Vec>) -> Self { + Self { identities } + } + + pub fn decrypt(&self, ciphertext: &[u8]) -> Result, AgeError> { + let (header, payload_offset) = parse_header(ciphertext)?; + + // Try to recover file key by trying each identity against each stanza + let mut file_key: Option = None; + 'outer: for identity in &self.identities { + for stanza in &header.recipients { + if let Some(result) = identity.unwrap_file_key(stanza) { + file_key = Some(result?); + break 'outer; + } + } + } + let file_key = file_key.ok_or(AgeError::NoMatch)?; + + // Verify header MAC + let mac_key = derive_mac_key(&file_key)?; + let header_input = header_bytes_up_to_footer(ciphertext)?; + + let mut mac = HmacSha256::new_from_slice(&mac_key) + .map_err(|e| AgeError::CryptoError(format!("HMAC init: {e}")))?; + mac.update(&header_input); + mac.verify_slice(&header.mac) + .map_err(|_| AgeError::MacMismatch)?; + + // Decrypt payload + let payload = &ciphertext[payload_offset..]; + if payload.len() < 16 { + return Err(AgeError::ParseError( + "payload too short to contain nonce".to_string(), + )); + } + let nonce_bytes: [u8; 16] = payload[..16] + .try_into() + .expect("slice is exactly 16 bytes"); + let payload_ciphertext = &payload[16..]; + + let payload_key = derive_payload_key(&file_key, &nonce_bytes)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&payload_key)); + let counter_nonce = Nonce::default(); + let plaintext = cipher + .decrypt(&counter_nonce, payload_ciphertext) + .map_err(|_| AgeError::CryptoError("payload decryption failed".to_string()))?; + + Ok(plaintext) + } +} + +fn derive_mac_key(file_key: &FileKey) -> Result<[u8; 32], AgeError> { + let hk = Hkdf::::new(Some(b""), file_key.as_bytes()); + let mut mac_key = [0u8; 32]; + hk.expand(b"header", &mut mac_key) + .map_err(|e| AgeError::CryptoError(format!("HKDF mac key: {e}")))?; + Ok(mac_key) +} + +fn derive_payload_key(file_key: &FileKey, nonce: &[u8; 16]) -> Result<[u8; 32], AgeError> { + let hk = Hkdf::::new(Some(nonce.as_slice()), file_key.as_bytes()); + let mut payload_key = [0u8; 32]; + hk.expand(b"payload", &mut payload_key) + .map_err(|e| AgeError::CryptoError(format!("HKDF payload key: {e}")))?; + Ok(payload_key) +} +``` + +Add `pub mod decrypt;` and `pub use decrypt::Decryptor;` to `lib.rs`. + +### 9.3 Run tests + +```bash +cargo test -p notsecrets -- decrypt --nocapture +``` + +Expected: + +``` +test decrypt::tests::decryptor_roundtrip_single_recipient ... ok +test decrypt::tests::decryptor_roundtrip_multiple_recipients ... ok +test decrypt::tests::decryptor_no_matching_identity_returns_no_match ... ok +test decrypt::tests::decryptor_corrupted_mac_returns_mac_mismatch ... ok +``` + +Run full suite to verify no regressions: + +```bash +cargo test -p notsecrets --nocapture +``` + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 9.4 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/decrypt.rs crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): Decryptor — identity-driven age decryption with MAC verification" +``` + +--- + +## Task 10: sources/ migration — IdentitySource trait, update all three sources + +- [ ] Add `IdentitySource` trait to `crates/notsecrets/src/sources/mod.rs` +- [ ] Rewrite `sources/bitwarden.rs` +- [ ] Rewrite `sources/file.rs` +- [ ] Rewrite `sources/prompt.rs` +- [ ] Remove `_legacy.rs` and all `#[deprecated]` shims from `lib.rs` + +### 10.1 `crates/notsecrets/src/sources/mod.rs` + +```rust +use crate::error::AgeError; +use crate::identities::Identity; + +pub mod bitwarden; +pub mod file; +pub mod prompt; + +pub use bitwarden::BitwardenSource; +pub use file::FileSource; +pub use prompt::PromptSource; + +/// Infra boundary trait: an identity resolver that loads key material from an +/// external source and returns a concrete `Identity`. +pub trait IdentitySource { + fn name(&self) -> &str; + fn load(&self) -> Result, AgeError>; +} +``` + +### 10.2 Rewrite `sources/bitwarden.rs` + +```rust +use crate::error::AgeError; +use crate::identities::Identity; +use crate::identities::x25519::X25519Identity; +use crate::sources::IdentitySource; +use std::process::Command; + +pub struct BitwardenSource { + pub item_name: String, +} + +impl BitwardenSource { + pub fn new(item_name: impl Into) -> Self { + Self { item_name: item_name.into() } + } + + fn retrieve_key(&self) -> Result { + let bw_ok = std::process::Command::new("sh") + .args(["-c", "command -v bw"]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !bw_ok { + return Err(AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw CLI not found in PATH"), + }); + } + + let session = std::env::var("BW_SESSION").unwrap_or_default(); + let session = if session.is_empty() { + let password = rpassword::prompt_password("Bitwarden master password: ") + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("could not read password: {e}"), + })?; + let output = Command::new("bw") + .args(["unlock", "--raw", &password]) + .output() + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw unlock spawn: {e}"), + })?; + if !output.status.success() { + return Err(AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!( + "bw unlock failed: {}", + String::from_utf8_lossy(&output.stderr) + ), + }); + } + String::from_utf8(output.stdout) + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw unlock output UTF-8: {e}"), + })? + .trim() + .to_string() + } else { + session + }; + + let output = Command::new("bw") + .args(["get", "notes", &self.item_name, "--session", &session]) + .output() + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw get spawn: {e}"), + })?; + + if !output.status.success() { + return Err(AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!( + "bw get notes '{}' failed: {}", + self.item_name, + String::from_utf8_lossy(&output.stderr) + ), + }); + } + + let key = String::from_utf8(output.stdout) + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw output UTF-8: {e}"), + })? + .trim() + .to_string(); + + if key.is_empty() { + return Err(AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("Bitwarden item '{}' has empty notes", self.item_name), + }); + } + Ok(key) + } +} + +impl IdentitySource for BitwardenSource { + fn name(&self) -> &str { + "bitwarden" + } + + fn load(&self) -> Result, AgeError> { + let key = self.retrieve_key()?; + let identity = X25519Identity::from_bech32(key.trim()).map_err(|e| { + AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"), + } + })?; + Ok(Box::new(identity)) + } +} +``` + +### 10.3 Rewrite `sources/file.rs` + +```rust +use crate::error::AgeError; +use crate::identities::Identity; +use crate::identities::x25519::X25519Identity; +use crate::identities::encrypted::EncryptedIdentity; +use crate::identities::scrypt::ScryptIdentity; +use crate::sources::IdentitySource; +use std::path::PathBuf; + +const AGE_VERSION_LINE: &str = "age-encryption.org/v1"; + +pub struct FileSource { + path: PathBuf, +} + +impl FileSource { + pub fn new(path: PathBuf) -> Self { + Self { path } + } +} + +impl IdentitySource for FileSource { + fn name(&self) -> &str { + "file" + } + + fn load(&self) -> Result, AgeError> { + let content = std::fs::read(&self.path).map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("cannot read key file {}: {e}", self.path.display()), + })?; + + // Detect identity type from file content + if content.starts_with(b"AGE-SECRET-KEY-1") { + let key_str = std::str::from_utf8(&content) + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("key file UTF-8: {e}"), + })? + .trim(); + let identity = X25519Identity::from_bech32(key_str).map_err(|e| { + AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("invalid AGE-SECRET-KEY-1: {e}"), + } + })?; + Ok(Box::new(identity)) + } else if content.starts_with(AGE_VERSION_LINE.as_bytes()) { + // age-encrypted identity file — prompt for passphrase to decrypt + let passphrase = rpassword::prompt_password( + "Enter passphrase for encrypted identity file: ", + ) + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("could not read passphrase: {e}"), + })?; + let passphrase_identity = ScryptIdentity::new(passphrase); + Ok(Box::new(EncryptedIdentity::new(content, Box::new(passphrase_identity)))) + } else { + Err(AgeError::UnsupportedKeyType(format!( + "file {} does not contain a recognized age key format", + self.path.display() + ))) + } + } +} +``` + +### 10.4 Rewrite `sources/prompt.rs` + +```rust +use crate::error::AgeError; +use crate::identities::Identity; +use crate::identities::scrypt::ScryptIdentity; +use crate::sources::IdentitySource; + +pub struct PromptSource; + +impl IdentitySource for PromptSource { + fn name(&self) -> &str { + "prompt" + } + + fn load(&self) -> Result, AgeError> { + let passphrase = rpassword::prompt_password("Enter age passphrase: ").map_err(|e| { + AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("could not read passphrase: {e}"), + } + })?; + if passphrase.trim().is_empty() { + return Err(AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("empty passphrase entered"), + }); + } + Ok(Box::new(ScryptIdentity::new(passphrase))) + } +} +``` + +### 10.5 Remove legacy shims + +Delete `crates/notsecrets/src/_legacy.rs`. Remove the `mod _legacy` and `pub use _legacy::...` lines from `lib.rs`. The `notstrap` crate will be updated in Task 12. + +### 10.6 Run tests + +```bash +cargo test -p notsecrets --nocapture +``` + +Expected: all tests pass. + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 10.7 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/sources/ crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): migrate sources to IdentitySource trait, remove legacy API" +``` + +--- + +## Task 11: resolve_identities() public API + +- [ ] Add `resolve_identities` to `lib.rs` + +### 11.1 Implementation + +In `crates/notsecrets/src/lib.rs`, add: + +```rust +/// Try each `IdentitySource` in order; collect all identities that load successfully. +/// +/// If all sources fail, returns the last `AgeError::SourceError`. If at least one +/// source succeeds, returns the list of loaded identities (partial success is accepted +/// because not every source needs to hold the key for the target file). +pub fn resolve_identities( + sources: Vec>, +) -> Result>, AgeError> { + let mut identities: Vec> = Vec::new(); + let mut last_err: Option = None; + + for source in sources { + match source.load() { + Ok(identity) => identities.push(identity), + Err(e) => { + eprintln!(" [{}] {e}", source.name()); + last_err = Some(e); + } + } + } + + if identities.is_empty() { + Err(last_err.unwrap_or(AgeError::SourceError { + name: "resolve_identities".to_string(), + source: anyhow::anyhow!("no sources provided"), + })) + } else { + Ok(identities) + } +} +``` + +### 11.2 Test + +Add to `lib.rs` test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::sources::IdentitySource; + + struct AlwaysFailSource; + impl IdentitySource for AlwaysFailSource { + fn name(&self) -> &str { "always-fail" } + fn load(&self) -> Result, AgeError> { + Err(AgeError::SourceError { + name: "always-fail".to_string(), + source: anyhow::anyhow!("intentional failure"), + }) + } + } + + struct StaticX25519Source { + identity: crate::identities::x25519::X25519Identity, + } + + impl StaticX25519Source { + fn new() -> Self { + use x25519_dalek::StaticSecret; + use rand::rngs::OsRng; + Self { + identity: crate::identities::x25519::X25519Identity::from_static_secret( + StaticSecret::random_from_rng(OsRng), + ), + } + } + } + + impl IdentitySource for StaticX25519Source { + fn name(&self) -> &str { "static-x25519" } + fn load(&self) -> Result, AgeError> { + use x25519_dalek::StaticSecret; + use rand::rngs::OsRng; + Ok(Box::new( + crate::identities::x25519::X25519Identity::from_static_secret( + StaticSecret::random_from_rng(OsRng), + ), + )) + } + } + + #[test] + fn resolve_identities_all_fail_returns_error() { + let sources: Vec> = vec![Box::new(AlwaysFailSource)]; + assert!(resolve_identities(sources).is_err()); + } + + #[test] + fn resolve_identities_partial_success_returns_loaded() { + let sources: Vec> = vec![ + Box::new(AlwaysFailSource), + Box::new(StaticX25519Source::new()), + ]; + let identities = resolve_identities(sources).expect("at least one source succeeded"); + assert_eq!(identities.len(), 1); + } +} +``` + +### 11.3 Run tests + +```bash +cargo test -p notsecrets --nocapture +``` + +Expected: all pass. + +```bash +cargo clippy -p notsecrets -- -D warnings +``` + +Expected: 0 warnings. + +### 11.4 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notsecrets/src/lib.rs +git -C /Users/joe/dev/notfiles commit -m "feat(notsecrets): add resolve_identities() public API" +``` + +--- + +## Task 12: notstrap migration + +Update `crates/notstrap/src/lib.rs` to replace the old `notsecrets` API with the new one. + +- [ ] Update `crates/notstrap/src/lib.rs` — two call sites +- [ ] Update `crates/notstrap/Cargo.toml` if needed +- [ ] Run `cargo check --workspace` +- [ ] Run `cargo test --workspace` + +### 12.1 Changes to `crates/notstrap/src/lib.rs` + +**Import line** (line 5), replace: + +```rust +use notsecrets::{BitwardenSource, FileSource, PromptSource, install_age_key, resolve_age_key}; +``` + +with: + +```rust +use notsecrets::{ + BitwardenSource, Decryptor, FileSource, PromptSource, + resolve_identities, + sources::IdentitySource, +}; +``` + +**Step 4 block** (lines 92–110), replace: + +```rust + // 4. Retrieve age key and install + let sources: Vec> = if let Some(kf) = opts.key_file { + vec![Box::new(FileSource::new(kf))] + } else { + vec![ + Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)), + Box::new(PromptSource), + ] + }; + + match resolve_age_key(sources) { + Ok(key) => { + install_age_key(&key)?; + report.add("age key", StepStatus::Ok); + } + Err(e) => { + report.add("age key", StepStatus::Failed(e.to_string())); + return Ok(report); + } + } +``` + +with: + +```rust + // 4. Resolve age identities + let sources: Vec> = if let Some(kf) = opts.key_file { + vec![Box::new(FileSource::new(kf))] + } else { + vec![ + Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)), + Box::new(PromptSource), + ] + }; + + let identities = match resolve_identities(sources) { + Ok(ids) => { + report.add("age key", StepStatus::Ok); + ids + } + Err(e) => { + report.add("age key", StepStatus::Failed(e.to_string())); + return Ok(report); + } + }; +``` + +**env_injector closure** in `BootstrapOptions` and its call site (lines 112–142): + +The `env_injector` closure currently captures a `sops` call. Replace it so callers pass a closure that captures a `Decryptor`. Update the type alias to remain the same (`Box Result>`) — this keeps the blast radius minimal. The `notstrap` binary's `main.rs` builds the `env_injector` closure; update it there: + +In `crates/notstrap/src/main.rs`, the env_injector construction (wherever `decrypt_sops` was called) becomes: + +```rust +let env_injector: Option = Some(Box::new({ + let identities = identities.clone(); // requires Identity: Clone, or Arc wrapping + move |path: &std::path::Path| { + let bytes = std::fs::read(path) + .with_context(|| format!("cannot read sops file {}", path.display()))?; + let decryptor = notsecrets::Decryptor::with_identities(identities); + let plaintext = decryptor.decrypt(&bytes) + .map_err(|e| anyhow::anyhow!("age decrypt failed: {e}"))?; + String::from_utf8(plaintext).map_err(|e| anyhow::anyhow!("plaintext UTF-8: {e}")) + } +})); +``` + +For `Box` to be cloneable across the closure boundary, wrap identities in `Arc`: + +Change `identities` type from `Vec>` to `Vec>` in the closure. Adjust `Decryptor::with_identities` to accept `Vec>` as an alternative, or convert: `identities.into_iter().map(|i| i as Box).collect()`. + +The simplest approach: keep `Decryptor::with_identities(Vec>)` and pass ownership into the closure — the closure is `FnOnce`, and `EnvInjector` is `Box` which requires `Fn`. Resolve by storing identities in `Arc>>`: + +```rust +// In lib.rs: expose a Decryptor constructor from Arc +impl Decryptor { + pub fn with_identities_arc(identities: std::sync::Arc>>) -> Self { + // store Arc internally + } +} +``` + +Or simplest of all: change `EnvInjector` from `Box` to `Box` in `BootstrapOptions` since it is only ever called once. This requires changing one field and one call site in `lib.rs`: + +```rust +type EnvInjector = Box Result>; +``` + +and in `run()`: + +```rust +if let Some(injector) = opts.env_injector { + let sops_path = dotfiles_dir.join(&cfg.bootstrap.sops_file); + match injector(&sops_path) { ... } +} +``` + +`FnOnce` is called the same way via `injector(arg)`. This change is fully backwards-compatible with existing test code. + +### 12.2 Remove legacy notsecrets exports + +Verify `crates/notstrap/Cargo.toml` does not need changes (it already depends on `notsecrets = { workspace = true }`). + +Check workspace `Cargo.toml`: remove `which` from `[workspace.dependencies]` if no other crate references it: + +```bash +cargo metadata --no-deps --format-version 1 | python3 -c "import json,sys; d=json.load(sys.stdin); [print(p['name'],p.get('dependencies',[])) for p in d['packages']]" 2>/dev/null | grep which +``` + +If only `notsecrets` used it, remove from workspace. + +### 12.3 Verify + +```bash +cargo check --workspace +``` + +Expected: 0 errors. + +```bash +cargo test --workspace --nocapture 2>&1 | tail -40 +``` + +Expected: all tests pass. + +```bash +cargo clippy --workspace -- -D warnings +``` + +Expected: 0 warnings. + +### 12.4 Commit + +```bash +git -C /Users/joe/dev/notfiles add crates/notstrap/src/ Cargo.toml +git -C /Users/joe/dev/notfiles commit -m "feat(notstrap): migrate to notsecrets age-native API, remove sops shell-out" +``` + +--- + +## Self-Review Checklist + +Before considering the plan complete, verify: + +1. **All spec features covered:** + - [x] Multiple recipients — Task 8 (`Encryptor::with_recipients`) + - [x] Passphrases (scrypt) — Task 4 + - [x] Passphrase-protected identity files — Task 7 (`EncryptedIdentity`) + - [x] SSH Ed25519 — Task 5 + - [x] SSH RSA — Task 6 + - [x] X25519 identity + recipient — Task 3 + - [x] age wire format parser/serializer — Task 2 + - [x] Header MAC verification — Task 9 (`Decryptor`) + - [x] `resolve_identities` — Task 11 + - [x] Sources migration — Task 10 + - [x] notstrap migration — Task 12 + +2. **No placeholder text:** Every code block is complete. No "TBD", ellipsis substitutions, or "implement X appropriately". + +3. **Type names consistent across tasks:** + - `FileKey`, `Stanza`, `Header` defined in Task 1, used throughout + - `Identity`, `Recipient`, `IdentitySource` traits defined in Tasks 1 and 10 + - `AgeError` defined in Task 1, referenced in all subsequent tasks + - `Encryptor` defined in Task 8, referenced in Task 7 test and Task 12 + - `Decryptor` defined in Task 9, referenced in Task 7 impl and Task 12 + +4. **TDD discipline:** Every task writes the failing test first, specifies the expected failure output, then provides the implementation. + +5. **`cargo clippy -p notsecrets -- -D warnings` called after every task.** + +6. **Every file path is exact** — all paths use `crates/notsecrets/src/...` or `crates/notstrap/src/...` with no ambiguity. diff --git a/docs/superpowers/plans/2026-06-06-notsecrets-multi-provider.md b/docs/superpowers/plans/2026-06-06-notsecrets-multi-provider.md new file mode 100644 index 0000000..ff62c2d --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-notsecrets-multi-provider.md @@ -0,0 +1,1445 @@ +# Plan: notsecrets Multi-Provider Secret Resolution + +## Goal + +Add a config-driven, strongly-typed secret resolution system to notsecrets with +11 provider backends, explicit key bindings, and a priority chain fallback. + +## Context Map + +See `docs/superpowers/specs/2026-06-06-notsecrets-multi-provider-design.md` for +full design. Context map produced at plan time: + +- **Modified**: `ports.rs`, `error.rs`, `lib.rs`, `sources/mod.rs`, + `sources/bitwarden.rs`, `Cargo.toml`, `notstrap/src/lib.rs` +- **Created**: `config.rs`, `resolver.rs`, 10 new source files +- **Consumers**: notstrap (primary), cross-crate integration tests +- **Risk**: all changes additive except notstrap `EnvInjector` removal (binary + crate, safe) + +## Architecture + +- **Crate**: `notsecrets` (library) +- **New types**: `Provider`, `ProviderConfig`, `SecretRef`, `SecretsConfig`, + `SecretsError`, `SecretSource`, `EnumerableSecretSource`, `SecretResolver` +- **New files**: `config.rs`, `resolver.rs`, 10 source adapter files +- **Data flow**: `notsecrets.toml` -> `SecretsConfig` -> `SecretResolver` -> + `SecretSource` impls -> resolved `HashMap` + +## Tech Stack + +- Rust edition 2024 +- New deps on notsecrets: `serde` (with `derive`), `toml` +- Tier 1 sources shell out to CLIs: `op` (1Password), `dotenvx`, `sops`, + `gcloud` (GSM). `EnvSource` uses `std::env`. + +## Tasks + +### Task 1: Add serde/toml deps and SecretsError + +**Crate**: `notsecrets` +**File(s)**: `crates/notsecrets/Cargo.toml`, `crates/notsecrets/src/error.rs` +**Run**: `cargo check -p notsecrets` + +1. Write failing test: + + ```rust + // crates/notsecrets/src/error.rs + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn secrets_error_display_source_error() { + let err = SecretsError::SourceError { + name: "op".to_string(), + source: anyhow::anyhow!("not found"), + }; + assert!(err.to_string().contains("[op]")); + assert!(err.to_string().contains("not found")); + } + + #[test] + fn secrets_error_display_not_found() { + let err = SecretsError::NotFound { + key: "DB_URL".to_string(), + }; + assert!(err.to_string().contains("DB_URL")); + } + + #[test] + fn secrets_error_display_config() { + let err = SecretsError::Config("bad toml".to_string()); + assert!(err.to_string().contains("bad toml")); + } + } + ``` + + Run: `cargo nextest run -p notsecrets -- secrets_error` + Expected: FAIL (SecretsError does not exist) + +2. Add deps to `Cargo.toml`: + + ```toml + serde = { workspace = true, features = ["derive"] } + toml = { workspace = true } + ``` + + If `serde` and `toml` are not in workspace deps, add them to root + `Cargo.toml` `[workspace.dependencies]` first. + +3. Add `SecretsError` to `error.rs`: + + ```rust + #[derive(Debug, thiserror::Error)] + pub enum SecretsError { + #[error("[{name}] {source}")] + SourceError { name: String, source: anyhow::Error }, + #[error("no provider resolved key: {key}")] + NotFound { key: String }, + #[error("config error: {0}")] + Config(String), + } + ``` + +4. Add `pub use error::SecretsError;` to `lib.rs`. + +5. Verify: + + ``` + cargo nextest run -p notsecrets -- secrets_error -> all green + cargo clippy -p notsecrets -- -D warnings -> zero warnings + ``` + +6. Run: `git branch --show-current` (verify branch) + Commit: `feat(notsecrets): add SecretsError and serde/toml deps` + +--- + +### Task 2: Provider enum and config types + +**Crate**: `notsecrets` +**File(s)**: `crates/notsecrets/src/config.rs`, `crates/notsecrets/src/lib.rs` +**Run**: `cargo nextest run -p notsecrets -- config` + +1. Write failing test: + + ```rust + // crates/notsecrets/src/config.rs + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn provider_roundtrip_serde() { + let p = Provider::Op; + let s = toml::to_string(&p).unwrap(); + let p2: Provider = toml::from_str(&s).unwrap(); + assert_eq!(p, p2); + } + + #[test] + fn secrets_config_parses_minimal() { + let toml_str = r#" + providers = ["env", "op"] + "#; + let cfg: SecretsConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(cfg.providers, vec![Provider::Env, Provider::Op]); + assert!(cfg.provider.is_empty()); + assert!(cfg.secrets.is_empty()); + } + + #[test] + fn secrets_config_parses_full() { + let toml_str = r#" + providers = ["env", "op", "dotenvx", "sops", "gsm"] + + [provider.op] + type = "op" + account = "my.1password.com" + + [provider.dotenvx] + type = "dotenvx" + env_file = "~/dev/.env" + + [provider.sops] + type = "sops" + file = "secrets/bootstrap.sops.env" + + [provider.gsm] + type = "gsm" + project = "my-gcp-project" + + [secrets] + DATABASE_URL = { source = "op", uri = "op://Personal/db/url" } + ANTHROPIC_API_KEY = { source = "dotenvx" } + GCP_TOKEN = { source = "gsm", path = "projects/123/secrets/gcp-token/versions/latest" } + "#; + let cfg: SecretsConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(cfg.providers.len(), 5); + assert_eq!(cfg.provider.len(), 4); + assert_eq!(cfg.secrets.len(), 3); + assert_eq!( + cfg.secrets["DATABASE_URL"].provider(), + Provider::Op + ); + } + + #[test] + fn secret_ref_provider_derivation() { + let r = SecretRef::Gsm { + path: "projects/123/secrets/tok/versions/latest".to_string(), + }; + assert_eq!(r.provider(), Provider::Gsm); + } + + #[test] + fn unknown_provider_in_toml_errors() { + let toml_str = r#"providers = ["env", "redis"]"#; + let result: Result = toml::from_str(toml_str); + assert!(result.is_err()); + } + + #[test] + fn load_config_reads_file() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("notsecrets.toml"); + std::fs::write(&path, "providers = [\"env\"]\n").unwrap(); + let cfg = load_config(&path).unwrap(); + assert_eq!(cfg.providers, vec![Provider::Env]); + } + + #[test] + fn load_config_missing_file_errors() { + let result = load_config(std::path::Path::new("/nonexistent/notsecrets.toml")); + assert!(result.is_err()); + } + } + ``` + + Run: `cargo nextest run -p notsecrets -- config` + Expected: FAIL (config module does not exist) + +2. Create `crates/notsecrets/src/config.rs`: + + ```rust + use crate::error::SecretsError; + use serde::{Deserialize, Serialize}; + use std::collections::HashMap; + use std::path::{Path, PathBuf}; + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)] + #[serde(rename_all = "lowercase")] + pub enum Provider { + Env, + Op, + Dotenvx, + Sops, + Gsm, + Nuenv, + Direnv, + Mise, + Bitwarden, + Vault, + Dotenvy, + } + + #[derive(Debug, Deserialize)] + #[serde(tag = "type", rename_all = "lowercase")] + pub enum ProviderConfig { + Env, + Op { account: String }, + Dotenvx { env_file: PathBuf }, + Sops { file: PathBuf }, + Gsm { project: String }, + Nuenv, + Direnv, + Mise, + Bitwarden { server_url: Option }, + Vault { addr: String, mount: Option }, + Dotenvy { path: PathBuf }, + } + + #[derive(Debug, Deserialize)] + #[serde(tag = "source", rename_all = "lowercase")] + pub enum SecretRef { + Env, + Op { uri: String }, + Dotenvx { key: Option }, + Sops { key: Option }, + Gsm { path: String }, + Nuenv { key: Option }, + Direnv { key: Option }, + Mise { key: Option }, + Bitwarden { item: String, field: Option }, + Vault { path: String, field: Option }, + Dotenvy { key: Option }, + } + + impl SecretRef { + pub fn provider(&self) -> Provider { + match self { + Self::Env { .. } => Provider::Env, + Self::Op { .. } => Provider::Op, + Self::Dotenvx { .. } => Provider::Dotenvx, + Self::Sops { .. } => Provider::Sops, + Self::Gsm { .. } => Provider::Gsm, + Self::Nuenv { .. } => Provider::Nuenv, + Self::Direnv { .. } => Provider::Direnv, + Self::Mise { .. } => Provider::Mise, + Self::Bitwarden { .. } => Provider::Bitwarden, + Self::Vault { .. } => Provider::Vault, + Self::Dotenvy { .. } => Provider::Dotenvy, + } + } + } + + #[derive(Debug, Deserialize)] + pub struct SecretsConfig { + pub providers: Vec, + #[serde(default)] + pub provider: HashMap, + #[serde(default)] + pub secrets: HashMap, + } + + pub fn load_config(path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| { + SecretsError::Config(format!("cannot read {}: {e}", path.display())) + })?; + toml::from_str(&content) + .map_err(|e| SecretsError::Config(format!("parse error: {e}"))) + } + ``` + +3. Add `pub mod config;` to `lib.rs`. Add re-exports: + + ```rust + pub use config::{Provider, ProviderConfig, SecretRef, SecretsConfig, load_config}; + ``` + +4. Verify: + + ``` + cargo nextest run -p notsecrets -- config -> all green + cargo clippy -p notsecrets -- -D warnings -> zero warnings + ``` + +5. Commit: `feat(notsecrets): add Provider enum and typed config types` + +--- + +### Task 3: SecretSource and EnumerableSecretSource traits + +**Crate**: `notsecrets` +**File(s)**: `crates/notsecrets/src/ports.rs`, `crates/notsecrets/src/lib.rs` +**Run**: `cargo check -p notsecrets` + +1. Write failing test: + + ```rust + // crates/notsecrets/src/ports.rs + #[cfg(test)] + mod tests { + use super::*; + use crate::config::{Provider, SecretRef}; + use std::collections::HashMap; + + struct FakeSource; + impl SecretSource for FakeSource { + fn name(&self) -> &str { "fake" } + fn provider(&self) -> Provider { Provider::Env } + fn resolve(&self, _key: &str) -> Result, SecretsError> { + Ok(Some("val".to_string())) + } + } + + #[test] + fn default_resolve_ref_delegates_to_resolve() { + let source = FakeSource; + let secret_ref = SecretRef::Env; + let result = source.resolve_ref("KEY", &secret_ref).unwrap(); + assert_eq!(result, Some("val".to_string())); + } + } + ``` + + Run: `cargo nextest run -p notsecrets -- default_resolve_ref` + Expected: FAIL (SecretSource does not exist) + +2. Add to `ports.rs`: + + ```rust + use crate::config::{Provider, SecretRef}; + use crate::error::SecretsError; + use std::collections::HashMap; + + pub trait SecretSource { + fn name(&self) -> &str; + fn provider(&self) -> Provider; + fn resolve(&self, key: &str) -> Result, SecretsError>; + fn resolve_ref( + &self, + key: &str, + _secret_ref: &SecretRef, + ) -> Result, SecretsError> { + self.resolve(key) + } + } + + pub trait EnumerableSecretSource: SecretSource { + fn resolve_all(&self) -> Result, SecretsError>; + } + ``` + +3. Add re-exports to `lib.rs`: + + ```rust + pub use ports::{SecretSource, EnumerableSecretSource}; + ``` + +4. Verify: + + ``` + cargo nextest run -p notsecrets -- default_resolve_ref -> green + cargo clippy -p notsecrets -- -D warnings -> zero warnings + ``` + +5. Commit: `feat(notsecrets): add SecretSource and EnumerableSecretSource traits` + +--- + +### Task 4: EnvSource (tier 1) + +**Crate**: `notsecrets` +**File(s)**: `crates/notsecrets/src/sources/env.rs`, `crates/notsecrets/src/sources/mod.rs` +**Run**: `cargo nextest run -p notsecrets -- env_source` + +1. Write failing test: + + ```rust + // crates/notsecrets/src/sources/env.rs + #[cfg(test)] + mod tests { + use super::*; + use crate::ports::{SecretSource, EnumerableSecretSource}; + + #[test] + fn env_source_resolve_existing_key() { + // SAFETY: test is single-threaded + unsafe { std::env::set_var("NOTSECRETS_TEST_KEY", "test_value"); } + let source = EnvSource; + let result = source.resolve("NOTSECRETS_TEST_KEY").unwrap(); + assert_eq!(result, Some("test_value".to_string())); + unsafe { std::env::remove_var("NOTSECRETS_TEST_KEY"); } + } + + #[test] + fn env_source_resolve_missing_key() { + let source = EnvSource; + let result = source.resolve("NOTSECRETS_NONEXISTENT_KEY_12345").unwrap(); + assert_eq!(result, None); + } + + #[test] + fn env_source_resolve_all_contains_path() { + let source = EnvSource; + let map = source.resolve_all().unwrap(); + // PATH is virtually always set + assert!(map.contains_key("HOME") || map.contains_key("USER")); + } + + #[test] + fn env_source_name_and_provider() { + let source = EnvSource; + assert_eq!(source.name(), "env"); + assert_eq!(source.provider(), Provider::Env); + } + } + ``` + + Expected: FAIL + +2. Create `crates/notsecrets/src/sources/env.rs`: + + ```rust + use crate::config::{Provider, SecretRef}; + use crate::error::SecretsError; + use crate::ports::{EnumerableSecretSource, SecretSource}; + use std::collections::HashMap; + + pub struct EnvSource; + + impl SecretSource for EnvSource { + fn name(&self) -> &str { + "env" + } + + fn provider(&self) -> Provider { + Provider::Env + } + + fn resolve(&self, key: &str) -> Result, SecretsError> { + Ok(std::env::var(key).ok()) + } + } + + impl EnumerableSecretSource for EnvSource { + fn resolve_all(&self) -> Result, SecretsError> { + Ok(std::env::vars().collect()) + } + } + ``` + +3. Add `pub mod env;` and `pub use env::EnvSource;` to `sources/mod.rs`. + +4. Verify: + + ``` + cargo nextest run -p notsecrets -- env_source -> all green + cargo clippy -p notsecrets -- -D warnings -> zero warnings + ``` + +5. Commit: `feat(notsecrets): add EnvSource (tier 1 provider)` + +--- + +### Task 5: OpSource (tier 1) + +**Crate**: `notsecrets` +**File(s)**: `crates/notsecrets/src/sources/op.rs`, `crates/notsecrets/src/sources/mod.rs` +**Run**: `cargo nextest run -p notsecrets -- op_source` + +1. Write failing test: + + ```rust + // crates/notsecrets/src/sources/op.rs + #[cfg(test)] + mod tests { + use super::*; + use crate::config::SecretRef; + use crate::ports::SecretSource; + + #[test] + fn op_source_name_and_provider() { + let source = OpSource::new("my.1password.com".to_string()); + assert_eq!(source.name(), "op"); + assert_eq!(source.provider(), Provider::Op); + } + + #[test] + fn op_source_resolve_without_ref_returns_none() { + // Without a ref, OpSource cannot look up by key name alone + let source = OpSource::new("my.1password.com".to_string()); + let result = source.resolve("SOME_KEY").unwrap(); + assert_eq!(result, None); + } + + #[test] + fn op_source_resolve_ref_wrong_variant_delegates() { + let source = OpSource::new("my.1password.com".to_string()); + let wrong_ref = SecretRef::Env; + let result = source.resolve_ref("KEY", &wrong_ref).unwrap(); + assert_eq!(result, None); + } + } + ``` + + Expected: FAIL + +2. Create `crates/notsecrets/src/sources/op.rs`: + + ```rust + use crate::config::{Provider, SecretRef}; + use crate::error::SecretsError; + use crate::ports::SecretSource; + use std::process::Command; + + pub struct OpSource { + account: String, + } + + impl OpSource { + pub fn new(account: String) -> Self { + Self { account } + } + } + + impl SecretSource for OpSource { + fn name(&self) -> &str { + "op" + } + + fn provider(&self) -> Provider { + Provider::Op + } + + /// OpSource cannot resolve by key name alone -- requires a ref. + fn resolve(&self, _key: &str) -> Result, SecretsError> { + Ok(None) + } + + fn resolve_ref( + &self, + _key: &str, + secret_ref: &SecretRef, + ) -> Result, SecretsError> { + let SecretRef::Op { uri } = secret_ref else { + return self.resolve(_key); + }; + let output = Command::new("op") + .args(["read", uri, "--account", &self.account]) + .output() + .map_err(|e| SecretsError::SourceError { + name: "op".to_string(), + source: e.into(), + })?; + if output.status.success() { + Ok(Some(String::from_utf8_lossy(&output.stdout).trim().to_string())) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(SecretsError::SourceError { + name: "op".to_string(), + source: anyhow::anyhow!("op read failed: {stderr}"), + }) + } + } + } + ``` + +3. Add to `sources/mod.rs`: `pub mod op;` and `pub use op::OpSource;` + +4. Verify: + + ``` + cargo nextest run -p notsecrets -- op_source -> all green + cargo clippy -p notsecrets -- -D warnings -> zero warnings + ``` + +5. Commit: `feat(notsecrets): add OpSource (tier 1 provider)` + +--- + +### Task 6: DotenvxSource (tier 1) + +**Crate**: `notsecrets` +**File(s)**: `crates/notsecrets/src/sources/dotenvx.rs`, `crates/notsecrets/src/sources/mod.rs` +**Run**: `cargo nextest run -p notsecrets -- dotenvx_source` + +1. Write failing test: + + ```rust + #[cfg(test)] + mod tests { + use super::*; + use crate::ports::{SecretSource, EnumerableSecretSource}; + + #[test] + fn dotenvx_source_name_and_provider() { + let source = DotenvxSource::new("/tmp/test.env".into()); + assert_eq!(source.name(), "dotenvx"); + assert_eq!(source.provider(), Provider::Dotenvx); + } + + #[test] + fn dotenvx_source_resolve_missing_file_returns_error() { + let source = DotenvxSource::new("/nonexistent/.env".into()); + let result = source.resolve_all(); + assert!(result.is_err()); + } + } + ``` + + Expected: FAIL + +2. Create `crates/notsecrets/src/sources/dotenvx.rs`: + + ```rust + use crate::config::{Provider, SecretRef}; + use crate::error::SecretsError; + use crate::ports::{EnumerableSecretSource, SecretSource}; + use std::collections::HashMap; + use std::path::PathBuf; + use std::process::Command; + + pub struct DotenvxSource { + env_file: PathBuf, + } + + impl DotenvxSource { + pub fn new(env_file: PathBuf) -> Self { + Self { env_file } + } + + fn decrypt_all(&self) -> Result, SecretsError> { + let output = Command::new("dotenvx") + .args(["run", "--env-file", &self.env_file.to_string_lossy(), "--", "env"]) + .output() + .map_err(|e| SecretsError::SourceError { + name: "dotenvx".to_string(), + source: e.into(), + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(SecretsError::SourceError { + name: "dotenvx".to_string(), + source: anyhow::anyhow!("dotenvx run failed: {stderr}"), + }); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let mut map = HashMap::new(); + for line in stdout.lines() { + if let Some((k, v)) = line.split_once('=') { + let k = k.trim(); + if !k.is_empty() && !k.starts_with('#') { + map.insert(k.to_string(), v.to_string()); + } + } + } + Ok(map) + } + } + + impl SecretSource for DotenvxSource { + fn name(&self) -> &str { "dotenvx" } + fn provider(&self) -> Provider { Provider::Dotenvx } + + fn resolve(&self, key: &str) -> Result, SecretsError> { + self.decrypt_all().map(|m| m.get(key).cloned()) + } + } + + impl EnumerableSecretSource for DotenvxSource { + fn resolve_all(&self) -> Result, SecretsError> { + self.decrypt_all() + } + } + ``` + +3. Add to `sources/mod.rs`. + +4. Verify, commit: `feat(notsecrets): add DotenvxSource (tier 1 provider)` + +--- + +### Task 7: SopsSource (tier 1) + +**Crate**: `notsecrets` +**File(s)**: `crates/notsecrets/src/sources/sops.rs`, `crates/notsecrets/src/sources/mod.rs` +**Run**: `cargo nextest run -p notsecrets -- sops_source` + +1. Write failing test: + + ```rust + #[cfg(test)] + mod tests { + use super::*; + use crate::ports::SecretSource; + + #[test] + fn sops_source_name_and_provider() { + let source = SopsSource::new("/tmp/secrets.sops.env".into()); + assert_eq!(source.name(), "sops"); + assert_eq!(source.provider(), Provider::Sops); + } + + #[test] + fn sops_source_resolve_missing_file_returns_error() { + let source = SopsSource::new("/nonexistent/secrets.sops.env".into()); + let result = source.resolve("KEY"); + assert!(result.is_err()); + } + } + ``` + + Expected: FAIL + +2. Create `crates/notsecrets/src/sources/sops.rs`: + + ```rust + use crate::config::{Provider, SecretRef}; + use crate::error::SecretsError; + use crate::ports::{EnumerableSecretSource, SecretSource}; + use std::collections::HashMap; + use std::path::PathBuf; + use std::process::Command; + + pub struct SopsSource { + file: PathBuf, + } + + impl SopsSource { + pub fn new(file: PathBuf) -> Self { + Self { file } + } + + fn decrypt_all(&self) -> Result, SecretsError> { + let output = Command::new("sops") + .args(["--decrypt", &self.file.to_string_lossy()]) + .output() + .map_err(|e| SecretsError::SourceError { + name: "sops".to_string(), + source: e.into(), + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(SecretsError::SourceError { + name: "sops".to_string(), + source: anyhow::anyhow!("sops decrypt failed: {stderr}"), + }); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let mut map = HashMap::new(); + for line in stdout.lines() { + if let Some((k, v)) = line.split_once('=') { + let k = k.trim(); + if !k.is_empty() && !k.starts_with('#') { + map.insert(k.to_string(), v.to_string()); + } + } + } + Ok(map) + } + } + + impl SecretSource for SopsSource { + fn name(&self) -> &str { "sops" } + fn provider(&self) -> Provider { Provider::Sops } + + fn resolve(&self, key: &str) -> Result, SecretsError> { + self.decrypt_all().map(|m| m.get(key).cloned()) + } + } + + impl EnumerableSecretSource for SopsSource { + fn resolve_all(&self) -> Result, SecretsError> { + self.decrypt_all() + } + } + ``` + +3. Add to `sources/mod.rs`. + +4. Verify, commit: `feat(notsecrets): add SopsSource (tier 1 provider)` + +--- + +### Task 8: GsmSource (tier 1) + +**Crate**: `notsecrets` +**File(s)**: `crates/notsecrets/src/sources/gsm.rs`, `crates/notsecrets/src/sources/mod.rs` +**Run**: `cargo nextest run -p notsecrets -- gsm_source` + +1. Write failing test: + + ```rust + #[cfg(test)] + mod tests { + use super::*; + use crate::config::SecretRef; + use crate::ports::SecretSource; + + #[test] + fn gsm_source_name_and_provider() { + let source = GsmSource::new("my-project".to_string()); + assert_eq!(source.name(), "gsm"); + assert_eq!(source.provider(), Provider::Gsm); + } + + #[test] + fn gsm_source_resolve_without_ref_returns_none() { + let source = GsmSource::new("my-project".to_string()); + let result = source.resolve("KEY").unwrap(); + assert_eq!(result, None); + } + + #[test] + fn gsm_source_resolve_ref_wrong_variant_delegates() { + let source = GsmSource::new("my-project".to_string()); + let wrong_ref = SecretRef::Env; + let result = source.resolve_ref("KEY", &wrong_ref).unwrap(); + assert_eq!(result, None); + } + } + ``` + + Expected: FAIL + +2. Create `crates/notsecrets/src/sources/gsm.rs`: + + ```rust + use crate::config::{Provider, SecretRef}; + use crate::error::SecretsError; + use crate::ports::SecretSource; + use std::process::Command; + + pub struct GsmSource { + project: String, + } + + impl GsmSource { + pub fn new(project: String) -> Self { + Self { project } + } + } + + impl SecretSource for GsmSource { + fn name(&self) -> &str { "gsm" } + fn provider(&self) -> Provider { Provider::Gsm } + + fn resolve(&self, _key: &str) -> Result, SecretsError> { + Ok(None) + } + + fn resolve_ref( + &self, + _key: &str, + secret_ref: &SecretRef, + ) -> Result, SecretsError> { + let SecretRef::Gsm { path } = secret_ref else { + return self.resolve(_key); + }; + let output = Command::new("gcloud") + .args([ + "secrets", "versions", "access", "latest", + "--secret", path, + "--project", &self.project, + ]) + .output() + .map_err(|e| SecretsError::SourceError { + name: "gsm".to_string(), + source: e.into(), + })?; + if output.status.success() { + Ok(Some(String::from_utf8_lossy(&output.stdout).trim().to_string())) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(SecretsError::SourceError { + name: "gsm".to_string(), + source: anyhow::anyhow!("gcloud secrets failed: {stderr}"), + }) + } + } + } + ``` + +3. Add to `sources/mod.rs`. + +4. Verify, commit: `feat(notsecrets): add GsmSource (tier 1 provider)` + +--- + +### Task 9: Tier 2 stubs (6 sources) + +**Crate**: `notsecrets` +**File(s)**: `crates/notsecrets/src/sources/{nuenv,direnv,mise,vault,dotenvy}.rs`, + `crates/notsecrets/src/sources/bitwarden.rs` (add SecretSource impl), + `crates/notsecrets/src/sources/mod.rs` +**Run**: `cargo nextest run -p notsecrets -- stub` + +1. Write failing test (one representative, same pattern for all): + + ```rust + // crates/notsecrets/src/sources/nuenv.rs + #[cfg(test)] + mod tests { + use super::*; + use crate::ports::SecretSource; + + #[test] + fn nuenv_stub_resolve_returns_none() { + let source = NuenvSource; + assert_eq!(source.resolve("ANY").unwrap(), None); + } + + #[test] + fn nuenv_stub_name_and_provider() { + let source = NuenvSource; + assert_eq!(source.name(), "nuenv"); + assert_eq!(source.provider(), Provider::Nuenv); + } + } + ``` + + Expected: FAIL + +2. Create each stub file. Template (using nuenv as example): + + ```rust + use crate::config::Provider; + use crate::error::SecretsError; + use crate::ports::SecretSource; + + pub struct NuenvSource; + + impl SecretSource for NuenvSource { + fn name(&self) -> &str { "nuenv" } + fn provider(&self) -> Provider { Provider::Nuenv } + fn resolve(&self, _key: &str) -> Result, SecretsError> { + Ok(None) + } + } + ``` + + Repeat for: `DirenvSource` (Provider::Direnv), `MiseSource` (Provider::Mise), + `VaultSource` (Provider::Vault), `DotenvySource` (Provider::Dotenvy). + +3. Add `SecretSource` stub impl to existing `bitwarden.rs`: + + ```rust + impl SecretSource for BitwardenSource { + fn name(&self) -> &str { "bitwarden" } + fn provider(&self) -> Provider { Provider::Bitwarden } + fn resolve(&self, _key: &str) -> Result, SecretsError> { + Ok(None) + } + } + ``` + +4. Update `sources/mod.rs` with all new modules and re-exports. + +5. Verify: + + ``` + cargo nextest run -p notsecrets -- stub -> all green + cargo clippy -p notsecrets -- -D warnings -> zero warnings + ``` + +6. Commit: `feat(notsecrets): add tier 2 SecretSource stubs` + +--- + +### Task 10: SecretResolver + +**Crate**: `notsecrets` +**File(s)**: `crates/notsecrets/src/resolver.rs`, `crates/notsecrets/src/lib.rs` +**Run**: `cargo nextest run -p notsecrets -- resolver` + +1. Write failing tests: + + ```rust + // crates/notsecrets/src/resolver.rs + #[cfg(test)] + mod tests { + use super::*; + use crate::config::{Provider, SecretRef, SecretsConfig}; + use std::collections::HashMap; + + fn config_with_env_only() -> SecretsConfig { + SecretsConfig { + providers: vec![Provider::Env], + provider: HashMap::new(), + secrets: HashMap::new(), + } + } + + #[test] + fn resolver_from_config_env_only() { + let resolver = SecretResolver::from_config(config_with_env_only()); + assert!(resolver.is_ok()); + } + + #[test] + fn resolver_resolve_env_var() { + unsafe { std::env::set_var("NOTSECRETS_RESOLVER_TEST", "hello"); } + let resolver = SecretResolver::from_config(config_with_env_only()).unwrap(); + let val = resolver.resolve("NOTSECRETS_RESOLVER_TEST").unwrap(); + assert_eq!(val, Some("hello".to_string())); + unsafe { std::env::remove_var("NOTSECRETS_RESOLVER_TEST"); } + } + + #[test] + fn resolver_resolve_missing_returns_none() { + let resolver = SecretResolver::from_config(config_with_env_only()).unwrap(); + let val = resolver.resolve("NOTSECRETS_NONEXISTENT_99999").unwrap(); + assert_eq!(val, None); + } + + #[test] + fn resolver_binding_overrides_chain() { + unsafe { std::env::set_var("NOTSECRETS_BOUND_TEST", "from_env"); } + let mut config = config_with_env_only(); + // Binding to env with no special ref -- should still resolve via env + config.secrets.insert( + "NOTSECRETS_BOUND_TEST".to_string(), + SecretRef::Env, + ); + let resolver = SecretResolver::from_config(config).unwrap(); + let val = resolver.resolve("NOTSECRETS_BOUND_TEST").unwrap(); + assert_eq!(val, Some("from_env".to_string())); + unsafe { std::env::remove_var("NOTSECRETS_BOUND_TEST"); } + } + + #[test] + fn resolver_resolve_all_includes_env() { + unsafe { std::env::set_var("NOTSECRETS_ALL_TEST", "present"); } + let resolver = SecretResolver::from_config(config_with_env_only()).unwrap(); + let map = resolver.resolve_all().unwrap(); + assert_eq!(map.get("NOTSECRETS_ALL_TEST").map(|s| s.as_str()), Some("present")); + unsafe { std::env::remove_var("NOTSECRETS_ALL_TEST"); } + } + + #[test] + fn resolver_binding_refs_unknown_provider_errors() { + let config = SecretsConfig { + providers: vec![Provider::Env], + provider: HashMap::new(), + secrets: HashMap::from([( + "KEY".to_string(), + SecretRef::Op { uri: "op://x/y/z".to_string() }, + )]), + }; + let result = SecretResolver::from_config(config); + assert!(result.is_err()); + } + } + ``` + + Expected: FAIL + +2. Create `crates/notsecrets/src/resolver.rs`: + + ```rust + use crate::config::{Provider, ProviderConfig, SecretRef, SecretsConfig}; + use crate::error::SecretsError; + use crate::ports::{EnumerableSecretSource, SecretSource}; + use crate::sources::*; + use std::collections::HashMap; + + pub struct SecretResolver { + config: SecretsConfig, + sources: Vec>, + enumerable: Vec>, + } + + impl SecretResolver { + pub fn from_config(config: SecretsConfig) -> Result { + // Validate: every binding references a provider in the chain + for (key, binding) in &config.secrets { + if !config.providers.contains(&binding.provider()) { + return Err(SecretsError::Config(format!( + "secret '{key}' references provider '{:?}' not in providers list", + binding.provider() + ))); + } + } + + let mut sources: Vec> = Vec::new(); + let mut enumerable: Vec> = Vec::new(); + + for provider in &config.providers { + let provider_config = config.provider.get(provider); + match provider { + Provider::Env => { + let s = EnvSource; + enumerable.push(Box::new(EnvSource)); + sources.push(Box::new(s)); + } + Provider::Op => { + let account = match provider_config { + Some(ProviderConfig::Op { account }) => account.clone(), + _ => return Err(SecretsError::Config( + "op provider requires [provider.op] with account".to_string(), + )), + }; + sources.push(Box::new(OpSource::new(account))); + } + Provider::Dotenvx => { + let env_file = match provider_config { + Some(ProviderConfig::Dotenvx { env_file }) => env_file.clone(), + _ => return Err(SecretsError::Config( + "dotenvx provider requires [provider.dotenvx] with env_file".to_string(), + )), + }; + let s = DotenvxSource::new(env_file.clone()); + enumerable.push(Box::new(DotenvxSource::new(env_file))); + sources.push(Box::new(s)); + } + Provider::Sops => { + let file = match provider_config { + Some(ProviderConfig::Sops { file }) => file.clone(), + _ => return Err(SecretsError::Config( + "sops provider requires [provider.sops] with file".to_string(), + )), + }; + let s = SopsSource::new(file.clone()); + enumerable.push(Box::new(SopsSource::new(file))); + sources.push(Box::new(s)); + } + Provider::Gsm => { + let project = match provider_config { + Some(ProviderConfig::Gsm { project }) => project.clone(), + _ => return Err(SecretsError::Config( + "gsm provider requires [provider.gsm] with project".to_string(), + )), + }; + sources.push(Box::new(GsmSource::new(project))); + } + // Tier 2: no config required + Provider::Nuenv => { + sources.push(Box::new(NuenvSource)); + } + Provider::Direnv => { + sources.push(Box::new(DirenvSource)); + } + Provider::Mise => { + sources.push(Box::new(MiseSource)); + } + Provider::Bitwarden => { + let item_name = match provider_config { + Some(ProviderConfig::Bitwarden { server_url: _ }) => { + "default".to_string() + } + _ => "default".to_string(), + }; + sources.push(Box::new(BitwardenSource::new(item_name))); + } + Provider::Vault => { + sources.push(Box::new(VaultSource)); + } + Provider::Dotenvy => { + sources.push(Box::new(DotenvySource)); + } + } + } + + Ok(Self { config, sources, enumerable }) + } + + pub fn resolve(&self, key: &str) -> Result, SecretsError> { + // 1. Explicit binding + if let Some(secret_ref) = self.config.secrets.get(key) { + let provider = secret_ref.provider(); + if let Some(source) = self.sources.iter().find(|s| s.provider() == provider) { + return source.resolve_ref(key, secret_ref); + } + } + + // 2. Priority chain + for source in &self.sources { + if let Some(value) = source.resolve(key)? { + return Ok(Some(value)); + } + } + Ok(None) + } + + pub fn resolve_all(&self) -> Result, SecretsError> { + let mut map = HashMap::new(); + + // 1. Merge enumerable sources (priority order, first wins) + for source in &self.enumerable { + let source_map = source.resolve_all()?; + for (k, v) in source_map { + map.entry(k).or_insert(v); + } + } + + // 2. Overlay explicit bindings (always win) + for (key, secret_ref) in &self.config.secrets { + let provider = secret_ref.provider(); + if let Some(source) = self.sources.iter().find(|s| s.provider() == provider) { + if let Some(value) = source.resolve_ref(key, secret_ref)? { + map.insert(key.clone(), value); + } + } + } + + Ok(map) + } + + pub fn source_by_provider(&self, provider: Provider) -> Option<&dyn SecretSource> { + self.sources.iter().find(|s| s.provider() == provider).map(|s| s.as_ref()) + } + } + ``` + +3. Add `pub mod resolver;` to `lib.rs`. Re-export: + + ```rust + pub use resolver::SecretResolver; + ``` + +4. Verify: + + ``` + cargo nextest run -p notsecrets -- resolver -> all green + cargo clippy -p notsecrets -- -D warnings -> zero warnings + ``` + +5. Commit: `feat(notsecrets): add SecretResolver with binding + chain resolution` + +--- + +### Task 11: Conformance test suite + +**Crate**: `notsecrets` +**File(s)**: `crates/notsecrets/tests/conformance_secret_source.rs` +**Run**: `cargo nextest run -p notsecrets -- conformance` + +1. Create conformance test: + + ```rust + use notsecrets::ports::{SecretSource, EnumerableSecretSource}; + use notsecrets::sources::*; + + fn assert_secret_source_contract(source: &dyn SecretSource) { + // name is non-empty + assert!(!source.name().is_empty(), "{}: name() must be non-empty", source.name()); + + // resolve for unknown key returns Ok(None) + let result = source.resolve("__CONFORMANCE_NONEXISTENT_KEY_99999__"); + assert!( + result.is_ok(), + "{}: resolve(unknown) should be Ok, got {:?}", + source.name(), + result, + ); + assert_eq!( + result.unwrap(), + None, + "{}: resolve(unknown) should be None", + source.name(), + ); + } + + fn assert_enumerable_contract(source: &dyn EnumerableSecretSource) { + assert_secret_source_contract(source); + + // resolve_all returns Ok + let map = source.resolve_all(); + assert!( + map.is_ok(), + "{}: resolve_all() should be Ok, got {:?}", + source.name(), + map, + ); + } + + #[test] + fn conformance_env_source() { + let s = EnvSource; + assert_enumerable_contract(&s); + } + + #[test] + fn conformance_op_source() { + let s = OpSource::new("test.1password.com".to_string()); + assert_secret_source_contract(&s); + } + + #[test] + fn conformance_gsm_source() { + let s = GsmSource::new("test-project".to_string()); + assert_secret_source_contract(&s); + } + + #[test] + fn conformance_nuenv_stub() { + let s = NuenvSource; + assert_secret_source_contract(&s); + } + + #[test] + fn conformance_direnv_stub() { + let s = DirenvSource; + assert_secret_source_contract(&s); + } + + #[test] + fn conformance_mise_stub() { + let s = MiseSource; + assert_secret_source_contract(&s); + } + + #[test] + fn conformance_vault_stub() { + let s = VaultSource; + assert_secret_source_contract(&s); + } + + #[test] + fn conformance_dotenvy_stub() { + let s = DotenvySource; + assert_secret_source_contract(&s); + } + ``` + + Note: `DotenvxSource` and `SopsSource` are excluded from conformance because + `resolve(unknown)` triggers a CLI call that may fail without the tool installed. + They get unit-tested individually. + +2. Verify: + + ``` + cargo nextest run -p notsecrets -- conformance -> all green + ``` + +3. Commit: `test(notsecrets): add SecretSource conformance test suite` + +--- + +### Task 12: notstrap integration + +**Crate**: `notstrap` +**File(s)**: `crates/notstrap/src/lib.rs` +**Run**: `cargo nextest run -p notstrap` + +1. Replace `EnvInjector` type alias and `env_injector` field with + `SecretResolver` usage. See design doc Section 3 for exact code. + +2. Remove `env_injector` from `BootstrapOptions`. + +3. Update any tests in notstrap that construct `BootstrapOptions` to + remove the `env_injector` field. + +4. Verify: + + ``` + cargo nextest run -p notstrap -> all green + cargo nextest run --workspace -> all green + cargo clippy --workspace -- -D warnings -> zero warnings + ``` + +5. Commit: `refactor(notstrap): use SecretResolver instead of EnvInjector` + +--- + +### Task 13: Workspace integration test + +**Crate**: integration tests +**File(s)**: `tests/integration/tests/cross_crate.rs` +**Run**: `cargo nextest run -p integration` + +1. Add a test that constructs a `SecretResolver` from a minimal config + and resolves an env var end-to-end: + + ```rust + #[test] + fn secret_resolver_resolves_env_var_cross_crate() { + use notsecrets::config::{Provider, SecretsConfig}; + use notsecrets::resolver::SecretResolver; + use std::collections::HashMap; + + unsafe { std::env::set_var("NOTFILES_CROSS_CRATE_TEST", "works"); } + + let config = SecretsConfig { + providers: vec![Provider::Env], + provider: HashMap::new(), + secrets: HashMap::new(), + }; + let resolver = SecretResolver::from_config(config).unwrap(); + let val = resolver.resolve("NOTFILES_CROSS_CRATE_TEST").unwrap(); + assert_eq!(val, Some("works".to_string())); + + unsafe { std::env::remove_var("NOTFILES_CROSS_CRATE_TEST"); } + } + ``` + +2. Verify: + + ``` + cargo nextest run --workspace -> all green + ``` + +3. Commit: `test(integration): add SecretResolver cross-crate test` diff --git a/docs/superpowers/specs/2026-04-01-notgraph-design.md b/docs/superpowers/specs/2026-04-01-notgraph-design.md new file mode 100644 index 0000000..2963bc9 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-notgraph-design.md @@ -0,0 +1,137 @@ +# notgraph — Module Graph & Symbol Extraction Tooling + +**Date:** 2026-04-01 +**Status:** Approved + +## Overview + +A new `notgraph` binary-only workspace crate that analyses the notfiles workspace and produces three output artefacts: a Markdown summary, a JSON report, and a self-contained interactive HTML file. It runs in CI to enforce zero intra-crate module cycles, and locally for codebase exploration. + +## Architecture + +Three pipeline stages: + +1. **Collect** — `cargo metadata` → crate dep graph; `walkdir` + `syn` → per-crate module trees and symbol tables +2. **Analyze** — fan-in/fan-out counts, intra-crate cycle detection (Kahn's algorithm), hotspot ranking +3. **Emit** — write `docs/graph/report.md`, `docs/graph/report.json`, `docs/graph/report.html` + +`notgraph` is a binary-only crate. No other workspace crate depends on it. + +## Components + +Five modules in `crates/notgraph/src/`: + +| Module | Responsibility | +|--------|---------------| +| `crate_graph` | Calls `cargo_metadata`, builds crate→crate dep map for workspace members only | +| `module_graph` | Walks `src/` per crate with `walkdir`, parses `mod` declarations via `syn` to build intra-crate module trees | +| `symbols` | Parses each `.rs` file with `syn`, extracts public structs/enums/traits/fns/types/consts by module | +| `analysis` | Fan-in/fan-out counts, cycle detection (Kahn's algorithm), hotspot ranking (top N by fan-in and fan-out separately) | +| `emit` | Writes `report.md`, `report.json`, `report.html` (self-contained inline CSS+JS via vis.js) | + +`main.rs` wires collect → analyze → emit, then exits non-zero if cycles detected. + +## Data Flow & Key Types + +``` +cargo metadata + └─► CrateGraph { nodes: Vec, edges: Vec<(CrateName, CrateName)> } + +walkdir + syn (per crate) + └─► ModuleGraph { crate: CrateName, nodes: Vec, edges: Vec<(ModPath, ModPath)> } + └─► SymbolTable { crate: CrateName, symbols: Vec } + +Symbol { mod_path: ModPath, kind: SymbolKind, name: String, is_pub: bool } +SymbolKind: Struct | Enum | Trait | Fn | Type | Const + +analysis + └─► GraphStats { + crate_graph: FanStats, + module_graphs: Vec, + hotspots: Vec, // top N by fan-in and fan-out separately + cycles: Vec>, // empty = clean + } + +FanStats { name: String, fan_in: usize, fan_out: usize } +ModStats { crate: CrateName, nodes: Vec, cycles: Vec> } +Hotspot { name: String, kind: HotspotKind, score: usize } +HotspotKind: FanIn | FanOut +``` + +## CLI + +``` +notgraph [OPTIONS] + +Options: + --output Directory to write report.{md,json,html} [default: docs/graph] + --fail-on-cycles Exit 1 if any intra-crate module cycles are detected + --top Number of hotspots to surface per category [default: 10] +``` + +## Output Files + +**`report.json`** — full `GraphStats` serialised with `serde_json`. Machine-readable, suitable for diffing in CI. + +**`report.md`** — sections: +- Crate dependency graph (adjacency list) +- Per-crate module graph summary (node count, edge count, cycle count) +- Hotspot tables: top N fan-in and top N fan-out for both crates and modules +- Public symbol inventory per crate (grouped by `SymbolKind`) +- Cycle report (empty section = ✅ clean) + +**`report.html`** — self-contained single file: +- vis.js network diagram of the crate graph (interactive, filterable) +- Collapsible per-crate module graph diagrams +- Sortable hotspot tables +- No external network requests (vis.js inlined) + +## Dependencies + +```toml +[dependencies] +cargo_metadata = "0.18" +syn = { version = "2", features = ["full", "visit"] } +walkdir = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +``` + +## Testing + +**Unit tests** (in each module): Kahn's algorithm with known graphs, fan-count arithmetic, hotspot ranking. + +**Integration tests** (`crates/notgraph/tests/integration.rs`) with three fixture sets in `tests/fixtures/`: + +| Fixture | Purpose | +|---------|---------| +| `clean/` | No cycles, known fan-in/out — assert output matches expected values | +| `cyclic/` | Two modules that mutually declare each other — assert cycle detected, exit code 1 with `--fail-on-cycles` | +| `symbols/` | Known public symbols — assert symbol table matches expected inventory | + +## CI Wiring (`mise.toml`) + +```toml +[tasks."all:graph"] +description = "Generate module graph reports" +run = "cargo run -p notgraph --release -- --output docs/graph" + +[tasks."all:graph-check"] +description = "CI: fail if module cycles detected" +run = "cargo run -p notgraph --release -- --output docs/graph --fail-on-cycles" +``` + +`all:ci` gains `all:graph-check` after the existing `all:dep-boundaries` step. + +## CI Behaviour + +- Exit 0: no cycles (hotspots are informational only) +- Exit 1: one or more intra-crate module cycles detected (only with `--fail-on-cycles`) +- `docs/graph/` is `.gitignore`d — reports are generated, not committed + +## Non-Goals + +- Cross-crate module graph (crate boundaries are already enforced by `check-dep-boundaries.py`) +- Lifetime or type-level analysis +- Incremental/watch mode diff --git a/docs/superpowers/specs/2026-04-01-notsecrets-age-redesign.md b/docs/superpowers/specs/2026-04-01-notsecrets-age-redesign.md new file mode 100644 index 0000000..2ea93e4 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-notsecrets-age-redesign.md @@ -0,0 +1,235 @@ +# notsecrets: Age-Native Redesign + +**Date:** 2026-04-01 +**Status:** Approved + +## Overview + +Redesign `notsecrets` from a thin wrapper around external tools (sops, Bitwarden CLI) into a self-contained age encryption/decryption library implemented in pure Rust. Modeled architecturally after `rage` but with no dependency on it. Supports multiple recipients, passphrases, passphrase-protected identity files, and SSH keys. + +Replaces: `sops --decrypt` shell-out, `resolve_age_key`, `install_age_key`. +Keeps: existing `sources/` resolvers (Bitwarden, file, prompt) — repurposed as identity resolvers. + +--- + +## Domain Layer (Ports) + +Two core traits define the age conceptual split: + +```rust +pub trait Identity { + fn unwrap_file_key(&self, stanza: &Stanza) -> Option>; +} + +pub trait Recipient { + fn wrap_file_key(&self, file_key: &FileKey) -> Result; +} +``` + +Core domain types: + +```rust +pub struct FileKey([u8; 16]); +pub struct Stanza { pub tag: String, pub args: Vec, pub body: Vec } +pub struct Header { pub recipients: Vec, pub mac: Vec } +``` + +`Stanza` is the age wire-format recipient block. `FileKey` is the symmetric payload key. No infrastructure details appear in domain types. + +--- + +## Identity Implementations (`src/identities/`) + +| File | Type | Key material | Crypto | +|------|------|-------------|--------| +| `x25519.rs` | `X25519Identity` | `AGE-SECRET-KEY-1...` bech32 | X25519 + HKDF + ChaCha20-Poly1305 | +| `scrypt.rs` | `ScryptIdentity` | passphrase string | scrypt + ChaCha20-Poly1305 | +| `ssh_ed25519.rs` | `SshEd25519Identity` | OpenSSH ed25519 private key | Ed25519→X25519 twist + HKDF + ChaCha20-Poly1305 | +| `ssh_rsa.rs` | `SshRsaIdentity` | OpenSSH RSA private key (incl. passphrase-protected PEM) | OAEP-SHA256 | +| `encrypted.rs` | `EncryptedIdentity` | age-encrypted identity file | Wraps inner `Identity`; decrypts file first via `ScryptIdentity` | + +Each implements `Identity`, maps its errors to `AgeError`, and has no knowledge of sources (Bitwarden, file, prompt). + +--- + +## Recipient Implementations (`src/recipients/`) + +| File | Type | Key material | Crypto | +|------|------|-------------|--------| +| `x25519.rs` | `X25519Recipient` | `age1...` bech32 public key | ephemeral X25519 + HKDF + ChaCha20-Poly1305 | +| `scrypt.rs` | `ScryptRecipient` | passphrase + work factor | scrypt + ChaCha20-Poly1305 | +| `ssh_ed25519.rs` | `SshEd25519Recipient` | OpenSSH ed25519 public key | Ed25519→X25519 + HKDF + ChaCha20-Poly1305 | +| `ssh_rsa.rs` | `SshRsaRecipient` | OpenSSH RSA public key | OAEP-SHA256 | + +Multiple recipients: `Encryptor` calls `wrap_file_key` on each, writing all stanzas to the header. The single-scrypt-recipient invariant is enforced at `Encryptor` construction, not in the `ScryptRecipient` itself. + +--- + +## Encryptor and Decryptor + +### `Encryptor` + +```rust +pub struct Encryptor { recipients: Vec> } + +impl Encryptor { + pub fn with_recipients(recipients: Vec>) -> Result; + pub fn encrypt(&self, plaintext: &[u8]) -> Result, AgeError>; +} +``` + +Encrypt steps: +1. Generate random `FileKey` +2. `wrap_file_key` on each recipient → collect stanzas +3. Serialize `Header` (base64 stanza bodies + HMAC-SHA256 MAC) +4. Derive payload key from `FileKey` via HKDF +5. Encrypt payload with ChaCha20-Poly1305 (random nonce) +6. Write age binary format: version line + header stanzas + `---` separator + nonce + ciphertext + +### `Decryptor` + +```rust +pub struct Decryptor { identities: Vec> } + +impl Decryptor { + pub fn with_identities(identities: Vec>) -> Self; + pub fn decrypt(&self, ciphertext: &[u8]) -> Result, AgeError>; +} +``` + +Decrypt steps: +1. Parse header (format module) +2. Verify header MAC +3. Try each identity against each stanza until `FileKey` recovered +4. Derive payload key, decrypt with ChaCha20-Poly1305 + +--- + +## Format Module (`src/format.rs`) + +Pure serialization/deserialization of the age binary wire format: +- Version line: `age-encryption.org/v1` +- Recipient stanzas: `-> \n\n` +- Footer: `--- \n` +- Payload: nonce + ciphertext + +No crypto here. Gets thorough unit tests covering round-trip, malformed input, truncated files. + +--- + +## Sources (Kept, Repurposed) + +Existing `sources/` (Bitwarden, file, prompt) are kept but now produce `Box` instead of a raw key string. `resolve_age_key` is replaced by: + +```rust +pub fn resolve_identities(sources: Vec>) -> Result>, AgeError>; +``` + +Where `IdentitySource` is a new thin trait: + +```rust +pub trait IdentitySource { + fn name(&self) -> &str; + fn load(&self) -> Result, AgeError>; +} +``` + +`FileSource` loads a key file and returns `X25519Identity` if the content starts with `AGE-SECRET-KEY-1`, or `EncryptedIdentity` if the content starts with the age version line (`age-encryption.org/v1`). `PromptSource` returns `ScryptIdentity`. `BitwardenSource` retrieves the raw key and returns `X25519Identity`. + +--- + +## Module Structure + +``` +crates/notsecrets/src/ + lib.rs # public API + format.rs # age wire format parser/serializer + error.rs # AgeError + identities/ + mod.rs # Identity trait, FileKey, Stanza, Header + x25519.rs + scrypt.rs + ssh_ed25519.rs + ssh_rsa.rs + encrypted.rs + recipients/ + mod.rs # Recipient trait + x25519.rs + scrypt.rs + ssh_ed25519.rs + ssh_rsa.rs + sources/ + mod.rs # IdentitySource trait + bitwarden.rs + file.rs + prompt.rs +``` + +--- + +## Dependencies Added to `Cargo.toml` + +```toml +x25519-dalek = { version = "2", features = ["static_secrets"] } +ed25519-dalek = "2" +rsa = "0.9" +sha2 = "1" +hkdf = "0.12" +chacha20poly1305 = "0.10" +scrypt = "0.11" +bech32 = "0.11" +base64 = "0.22" +hmac = "0.12" +``` + +**Removed:** `which` (was only used for sops check). +**Kept:** `rpassword` (used by `PromptSource`), `dirs`, `anyhow`, `thiserror`. + +--- + +## Removed from Public API + +- `resolve_age_key()` +- `install_age_key()` / `install_age_key_at()` +- `decrypt_sops()` / `sops_args()` +- `AgeKeySource` trait + +--- + +## notstrap Impact + +Two call sites to update: +- `resolve_age_key(sources)` → `resolve_identities(sources)` +- `decrypt_sops(path)` → `Decryptor::with_identities(identities).decrypt(&bytes)` + +--- + +## Error Handling + +```rust +#[derive(Debug, thiserror::Error)] +pub enum AgeError { + #[error("no identity could decrypt any recipient stanza")] + NoMatch, + #[error("header MAC verification failed")] + MacMismatch, + #[error("malformed age file: {0}")] + ParseError(String), + #[error("crypto error: {0}")] + CryptoError(String), + #[error("unsupported key type: {0}")] + UnsupportedKeyType(String), + #[error("identity source failed ({name}): {source}")] + SourceError { name: String, source: anyhow::Error }, +} +``` + +--- + +## Testing Strategy + +- **Unit tests** on `format.rs`: round-trip parse/serialize, malformed input, truncated files +- **Unit tests** on each identity/recipient pair: wrap then unwrap with matching identity +- **Integration tests**: encrypt with multiple recipients, verify each identity can independently decrypt +- **Negative tests**: wrong identity returns `NoMatch`, corrupted MAC returns `MacMismatch` +- No external process spawning in tests diff --git a/docs/superpowers/specs/2026-04-16-tailscale-integration-design.md b/docs/superpowers/specs/2026-04-16-tailscale-integration-design.md new file mode 100644 index 0000000..005f347 --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-tailscale-integration-design.md @@ -0,0 +1,141 @@ +# Tailscale Integration Design + +**Date:** 2026-04-16 +**Status:** Approved + +## Goal + +Make notfiles environments ephemeral but reliable. A fresh machine runs `notstrap` and gets a +fully configured environment — no public GitHub exposure for dotfiles, no manual Tailscale setup, +no cloud dependency for secrets on bootstrap day. + +## Overview + +Add a `notnet` crate that owns Tailscale network presence, extend `notsecrets` with a +`YubikeySource`, and thread both into `notstrap`'s orchestration sequence. The dotfiles repo lives +on a private Gitea instance on minibox, reachable only over Tailscale. + +## Crate Responsibilities + +### `notnet` (new) + +Owns all Tailscale concerns: + +- Detect if Tailscale is already running (`tailscale status`) +- Install Tailscale if missing (package manager detection: `apt`/`brew`/`pacman`, fallback to + official install script) +- Resolve the auth key via priority chain (see below) +- Join the tailnet (`tailscale up --authkey=`) +- Verify the target peer is reachable (ping by Tailscale hostname) + +**Auth key resolution chain:** + +1. `TS_AUTHKEY` env var +2. YubiKey PIV slot 9d (via `yubikey` crate) +3. Interactive prompt + +**Public API:** + +```rust +pub struct TailscaleOptions { + pub peer_hostname: String, + pub gitea_url: String, + pub install: bool, +} + +/// Ensure this machine is connected to the tailnet and the target peer is reachable. +/// Returns immediately if Tailscale is already running. +pub fn ensure_connected(opts: &TailscaleOptions) -> Result<()>; +``` + +### `notsecrets` (extended) + +New `YubikeySource` implementing `IdentitySource`: + +- Uses the `yubikey` crate (pure Rust, no `ykman` dependency) +- Opens the first connected YubiKey +- Reads age private key material from PIV slot 9c +- Returns `Box` + +Updated source resolution order in notstrap: + +1. `--key-file` CLI arg +2. `YubikeySource` (PIV slot 9c) +3. `BitwardenSource` +4. `PromptSource` + +### `notstrap` (updated) + +`BootstrapOptions` gains: + +```rust +pub tailscale: Option, +``` + +`None` = skip notnet entirely (existing machines, tests, CI). + +`notstrap.toml` gains a `[tailscale]` section. When present, `gitea_url` is used as the clone +URL instead of `[bootstrap].dotfiles_repo`. + +## YubiKey PIV Slot Layout + +| Slot | Purpose | Managed by | +|------|----------------------------------|-------------| +| 9a | SSH authentication | 1Password | +| 9c | Age private key (dotfiles/SOPS) | notsecrets | +| 9d | Tailscale auth key | notnet | + +Slot 9a is untouched — 1Password agent remains the primary SSH auth for day-to-day use. + +## Configuration + +`notstrap.toml` example with Tailscale enabled: + +```toml +[bootstrap] +dotfiles_dir = "~/dotfiles" +bw_age_item = "age-key-dotfiles" +sops_file = "secrets/bootstrap.sops.env" +# dotfiles_repo is optional when [tailscale] is present + +[tailscale] +peer_hostname = "minibox" +gitea_url = "http://minibox:3000/joe/dotfiles.git" +install = true +``` + +## Full Bootstrap Sequence + +| Step | Crate | Skip condition | +|-------------------|---------------|---------------------------------------| +| 1. Prerequisites | notstrap | — | +| 2. Tailscale | notnet | Already on tailnet | +| 3. Clone dotfiles | notstrap/repo | Already cloned | +| 4. Age key | notsecrets | — | +| 5. Decrypt SOPS | notstrap | No injector configured | +| 6. Link dotfiles | notfiles | — | +| 7. Dot hooks | nothooks | — | +| 8. Setup hooks | nothooks | Already run (state file) | + +## Error Handling + +- If `TS_AUTHKEY` is unset and no YubiKey is connected and user declines prompt → clear error, + bootstrap aborts at step 2 +- If Tailscale installs but peer is unreachable → error naming the peer hostname, bootstrap aborts +- If YubiKey slot 9c is empty → `YubikeySource` returns `Err`, resolver falls through to + `BitwardenSource` +- All steps follow the existing `StepStatus::Failed` / early-return pattern in notstrap + +## Dependencies (new) + +| Crate | Used by | Purpose | +|-------------|---------------------|-------------------------------| +| `yubikey` | notnet, notsecrets | PIV slot access (pure Rust) | +| `tailscale` | notnet | tailscale-rs SDK | + +## Out of Scope + +- Multi-YubiKey support (first connected device wins) +- Tailscale exit node or subnet router configuration +- Rotating auth keys automatically +- Windows support diff --git a/docs/superpowers/specs/2026-06-06-notsecrets-multi-provider-design.md b/docs/superpowers/specs/2026-06-06-notsecrets-multi-provider-design.md new file mode 100644 index 0000000..b541506 --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-notsecrets-multi-provider-design.md @@ -0,0 +1,383 @@ +# notsecrets Multi-Provider Secret Resolution + +**Date:** 2026-06-06 +**Crate:** `notsecrets` +**Status:** Approved design, pending implementation plan + +## Goal + +Extend notsecrets with a general-purpose secret/env resolution system alongside +the existing age identity system. Multiple backends resolve arbitrary secrets +(API keys, tokens, env vars) via a typed provider chain with explicit bindings. + +## Non-Goals + +- Replacing the existing `IdentitySource` / age identity system +- Runtime hot-reloading of config +- Secret rotation or lease management +- Encrypting secrets (write path) -- read-only resolution + +## Architecture + +### Two independent port systems in one crate + +``` +notsecrets + +-- Age identity system (existing, untouched) + | IdentitySource trait -> BitwardenSource, FileSource, PromptSource, YubikeySource + | resolve_identities() -> Vec> + | + +-- Secret resolution system (new) + SecretSource trait -> 11 provider adapters + SecretResolver -> config-driven resolution with bindings +``` + +### Core Traits + +```rust +/// Port: resolve a single secret by key name. +pub trait SecretSource { + fn name(&self) -> &str; + fn provider(&self) -> Provider; + fn resolve(&self, key: &str) -> Result, SecretsError>; + + /// Resolve using a provider-specific typed reference. + /// Default: ignores ref, falls back to resolve(key). + fn resolve_ref( + &self, + key: &str, + secret_ref: &SecretRef, + ) -> Result, SecretsError> { + let _ = secret_ref; + self.resolve(key) + } +} + +/// Extended port: sources that can enumerate all available secrets. +pub trait EnumerableSecretSource: SecretSource { + fn resolve_all(&self) -> Result, SecretsError>; +} +``` + +ISP rationale: not every backend supports enumeration. `OpSource` and `GsmSource` +require explicit refs for lookup -- they cannot enumerate a vault without extra +config. Forcing `resolve_all()` on every impl would produce incomplete maps. + +### Type System + +#### Provider enum + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Provider { + Env, + Op, + Dotenvx, + Sops, + Gsm, + Nuenv, + Direnv, + Mise, + Bitwarden, + Vault, + Dotenvy, +} +``` + +#### Provider config (tagged enum) + +```rust +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum ProviderConfig { + Env, + Op { account: String }, + Dotenvx { env_file: PathBuf }, + Sops { file: PathBuf }, + Gsm { project: String }, + Nuenv, + Direnv, + Mise, + Bitwarden { server_url: Option }, + Vault { addr: String, mount: Option }, + Dotenvy { path: PathBuf }, +} +``` + +#### Secret references (tagged enum) + +```rust +#[derive(Debug, Deserialize)] +#[serde(tag = "source", rename_all = "lowercase")] +pub enum SecretRef { + Env, + Op { uri: String }, + Dotenvx { key: Option }, + Sops { key: Option }, + Gsm { path: String }, + Nuenv { key: Option }, + Direnv { key: Option }, + Mise { key: Option }, + Bitwarden { item: String, field: Option }, + Vault { path: String, field: Option }, + Dotenvy { key: Option }, +} + +impl SecretRef { + pub fn provider(&self) -> Provider { + match self { + Self::Env { .. } => Provider::Env, + Self::Op { .. } => Provider::Op, + Self::Dotenvx { .. } => Provider::Dotenvx, + Self::Sops { .. } => Provider::Sops, + Self::Gsm { .. } => Provider::Gsm, + Self::Nuenv { .. } => Provider::Nuenv, + Self::Direnv { .. } => Provider::Direnv, + Self::Mise { .. } => Provider::Mise, + Self::Bitwarden { .. } => Provider::Bitwarden, + Self::Vault { .. } => Provider::Vault, + Self::Dotenvy { .. } => Provider::Dotenvy, + } + } +} +``` + +#### Config root + +```rust +#[derive(Debug, Deserialize)] +pub struct SecretsConfig { + pub providers: Vec, + #[serde(default)] + pub provider: HashMap, + #[serde(default)] + pub secrets: HashMap, +} +``` + +### Compile-Time Guarantees + +- Misspelled provider name in TOML -> serde deserialization error +- Binding with wrong fields for a provider -> serde error +- `from_config` validates every binding references a provider in the chain + (enum comparison, not string matching) +- `resolve_ref` receives typed variant; compiler warns on missing arms + +### Domain Error + +```rust +#[derive(Debug, thiserror::Error)] +pub enum SecretsError { + #[error("[{name}] {source}")] + SourceError { name: String, source: anyhow::Error }, + #[error("no provider resolved key: {key}")] + NotFound { key: String }, + #[error("config error: {0}")] + Config(String), +} +``` + +Adapters map infra errors (reqwest, tonic, serde, io) into +`SecretsError::SourceError`. Domain logic never sees infra error types. + +### SecretResolver (domain logic) + +```rust +pub struct SecretResolver { + config: SecretsConfig, + sources: Vec>, + enumerable: Vec>, +} + +impl SecretResolver { + pub fn from_config(config: SecretsConfig) -> Result; + pub fn resolve(&self, key: &str) -> Result, SecretsError>; + pub fn resolve_all(&self) -> Result, SecretsError>; +} +``` + +#### resolve(key) flow + +1. Check explicit bindings (`config.secrets`). If key has a `SecretRef`, + route to the named source via `source.resolve_ref(key, &secret_ref)`. +2. Otherwise, walk the priority chain (`sources` in order). + First `Some` wins. + +#### resolve_all() flow + +1. Iterate enumerable sources in priority order. Merge maps -- earlier + source wins on key collision. +2. Overlay explicit bindings: resolve each bound key individually via + `resolve_ref`. Bindings always override chain results. + +### No inject_env() + +SRP: env injection (`set_var`) is a side effect belonging to the consumer +(notstrap), not the resolver. `SecretResolver` returns data; the caller +decides what to do with it. + +## Config File + +Separate `notsecrets.toml` at the dotfiles root. Rationale: + +- notsecrets is consumed by notstrap, notfiles, and potentially standalone +- Provider chain config is complex enough to warrant its own file +- Follows existing pattern (each crate has its own state file) +- Enables standalone CLI usage: `notsecrets resolve DATABASE_URL` + +### Example + +```toml +providers = ["env", "op", "dotenvx", "sops", "gsm"] + +[provider.op] +type = "op" +account = "my.1password.com" + +[provider.dotenvx] +type = "dotenvx" +env_file = "~/dev/.env" + +[provider.sops] +type = "sops" +file = "secrets/bootstrap.sops.env" + +[provider.gsm] +type = "gsm" +project = "my-gcp-project" + +[secrets] +DATABASE_URL = { source = "op", uri = "op://Personal/db/url" } +ANTHROPIC_API_KEY = { source = "dotenvx" } +GCP_TOKEN = { source = "gsm", path = "projects/123/secrets/gcp-token/versions/latest" } +``` + +## File Layout + +New and modified files in `crates/notsecrets/src/`: + +``` +src/ + lib.rs # add re-exports for new public API + ports.rs # add SecretSource, EnumerableSecretSource + config.rs # NEW -- SecretsConfig, ProviderConfig, SecretRef, Provider, load_config() + resolver.rs # NEW -- SecretResolver + error.rs # add SecretsError alongside existing AgeError + sources/ + mod.rs # add new source re-exports + env.rs # NEW tier 1 -- SecretSource + EnumerableSecretSource + op.rs # NEW tier 1 -- SecretSource only + dotenvx.rs # NEW tier 1 -- SecretSource + EnumerableSecretSource + sops.rs # NEW tier 1 -- SecretSource + EnumerableSecretSource + gsm.rs # NEW tier 1 -- SecretSource only + nuenv.rs # NEW tier 2 stub + direnv.rs # NEW tier 2 stub + mise.rs # NEW tier 2 stub + vault.rs # NEW tier 2 stub + dotenvy.rs # NEW tier 2 stub + bitwarden.rs # existing -- add tier 2 SecretSource stub + file.rs # existing -- untouched + prompt.rs # existing -- untouched + yubikey.rs # existing -- untouched + identities/ # untouched + recipients/ # untouched + decrypt.rs # untouched + encrypt.rs # untouched + format.rs # untouched +``` + +## Consumer Changes + +### notstrap + +Current hardcoded source chain (`lib.rs:135-143`) and `EnvInjector` closure +replaced by: + +```rust +let secrets_config = notsecrets::config::load_config( + &dotfiles_dir.join("notsecrets.toml") +)?; +let resolver = SecretResolver::from_config(secrets_config)?; +let env_map = resolver.resolve_all()?; + +for (k, v) in &env_map { + if let Some((k, v)) = parse_env_line(&format!("{k}={v}"))? { + unsafe { std::env::set_var(&k, &v); } + } +} +report.add("secrets", StepStatus::Ok); +``` + +`EnvInjector` type alias and `env_injector` field on `BootstrapOptions` become +unnecessary and are removed. + +## Testing Strategy + +| Dimension | Scope | When | +| ------------- | --------------------------------------------------- | ----------------------------- | +| Unit | Resolver routing (binding vs chain), config parsing | Every new function | +| Conformance | Shared suite for SecretSource trait, all impls | Every new impl | +| Property | Config deserialization: arbitrary TOML never panics | After config types defined | +| Integration | Resolver + real EnvSource + fake sources | After resolver + 2+ sources | +| Regression | One test per bug | Ongoing | +| Fuzz | Deferred -- no raw byte parsing or unsafe | Revisit if SOPS moves in-crate | +| Model check | Deferred -- no arithmetic invariants | N/A | + +### Conformance suite + +```rust +fn assert_secret_source_contract(source: &dyn SecretSource) { + // 1. name() returns non-empty string + // 2. provider() matches expected variant + // 3. resolve() for unknown key returns Ok(None), not Err + // 4. resolve_ref() with mismatched variant returns Ok(None) or + // delegates to resolve() +} + +fn assert_enumerable_contract(source: &dyn EnumerableSecretSource) { + assert_secret_source_contract(source); + // 5. resolve_all() returns Ok(map) + // 6. For every key in resolve_all() map, + // resolve(key) returns Ok(Some(same_value)) +} +``` + +## Implementation Tiers + +### Tier 1 (first milestone) + +- Types: `Provider`, `ProviderConfig`, `SecretRef`, `SecretsConfig`, `SecretsError` +- Traits: `SecretSource`, `EnumerableSecretSource` +- Resolver: `SecretResolver` with `from_config`, `resolve`, `resolve_all` +- Sources: `EnvSource`, `OpSource`, `DotenvxSource`, `SopsSource`, `GsmSource` +- Config: `load_config()`, `notsecrets.toml` parsing +- Consumer: notstrap integration +- Tests: unit + conformance + integration + +### Tier 2 (subsequent) + +- Sources: `NuenvSource`, `DirenvSource`, `MiseSource`, `BitwardenSource` + (SecretSource impl), `VaultSource`, `DotenvySource` +- Each: trait impl returning `Ok(None)` / empty map, passing conformance suite +- Implemented as needed when the backend is required + +## Decisions Log + +| Decision | Why | Alternative rejected | +| --------------------------------- | ------------------------------------------------------ | ---------------------------------- | +| Separate `notsecrets.toml` | Decouples from notfiles/notstrap config | Subsection in notfiles.toml | +| Two traits (ISP split) | Not all backends can enumerate | Single trait with `resolve_all()` | +| Typed enums over string maps | Compile-time validation, exhaustive matching | `HashMap` | +| No `inject_env()` on resolver | SRP -- side effects belong to consumer | Method on SecretResolver | +| `resolve_ref` default impl | Sources that don't use refs delegate to `resolve(key)` | Per-binding source instances | +| Explicit bindings override chain | User intent is unambiguous when they bind a key | Chain-only, no bindings | +| `SecretsError` separate from `AgeError` | Different domains, different consumers | Unified error enum | + +## Out of Scope + +- Secret write/rotation path +- RBAC or access control on secrets +- Caching/TTL for resolved secrets +- Async trait (all providers are sync CLI/file-based today) +- Migration tooling from old notstrap config format diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..11cab75 --- /dev/null +++ b/mise.toml @@ -0,0 +1,104 @@ +[tools] +rust = "1.91.1" + +[tasks] + +# ── Quality Gates ────────────────────────────────────────────────────────────── + +[tasks."all:check"] +description = "cargo check across workspace" +run = "cargo check --workspace" + +[tasks."all:fmt"] +description = "Format all code" +run = "cargo fmt" + +[tasks."all:fmt-check"] +description = "Check formatting (no changes)" +run = "cargo fmt --check" + +[tasks."all:clippy"] +description = "Clippy with warnings as errors" +run = "cargo clippy --workspace -- -D warnings" + +[tasks."all:test"] +description = "Run all tests via nextest" +run = "cargo nextest run --workspace --cargo-profile release" + +[tasks."all:test-unit"] +description = "Run unit tests only (exclude integration)" +run = "cargo nextest run --workspace --lib --cargo-profile release" + +[tasks."all:test-integration"] +description = "Run integration tests" +run = "cargo nextest run -p integration --cargo-profile release" + +[tasks."all:ci"] +description = "Full CI gate: fmt-check + clippy + nextest" +run = """ +#!/usr/bin/env bash +set -euo pipefail +echo "── fmt ──────────────────────────────" +cargo fmt --check +echo "── clippy ───────────────────────────" +cargo clippy --workspace -- -D warnings +echo "── dep-boundaries ───────────────────" +python3 scripts/check-dep-boundaries.py +echo "── graph-check ──────────────────────" +cargo run -p notgraph --release -- --output docs/graph --fail-on-cycles +echo "── test ─────────────────────────────" +cargo nextest run --workspace --cargo-profile release +echo "✓ all gates passed" +""" + +[tasks."all:ci-setup"] +description = "One-time setup: install cargo-nextest, cargo-deny" +run = """ +#!/usr/bin/env bash +set -e +echo "Installing cargo-nextest..." +command -v cargo-nextest >/dev/null 2>&1 || ~/.local/bin/mise exec -- cargo install cargo-nextest --locked +echo "Installing cargo-deny..." +command -v cargo-deny >/dev/null 2>&1 || ~/.local/bin/mise exec -- cargo install cargo-deny --locked +echo "✓ CI setup complete" +""" + +[tasks."all:dep-boundaries"] +description = "Enforce crate dependency boundary rules" +run = "python3 scripts/check-dep-boundaries.py" + +[tasks."all:graph"] +description = "Generate module graph reports (docs/graph/)" +run = "cargo run -p notgraph --release -- --output docs/graph" + +[tasks."all:graph-check"] +description = "CI: fail if module cycles detected" +run = "cargo run -p notgraph --release -- --output docs/graph --fail-on-cycles" + +[tasks."all:deny"] +description = "Run cargo-deny license/security checks" +run = "cargo deny check" + +# ── Dev Workflow ─────────────────────────────────────────────────────────────── + +[tasks."all:build"] +description = "Build all workspace crates" +run = "cargo build" + +[tasks."all:build-release"] +description = "Build release binaries" +run = "cargo build --release" + +[tasks."all:commit"] +description = "Stage all + commit. Usage: mise run all:commit -- 'message'" +run = """ +#!/usr/bin/env bash +set -e +MSG="${1:?usage: mise run all:commit -- 'message'}" +git add -A +git commit -m "$MSG" +""" + +[tasks."all:watch"] +description = "Watch for changes and run check + nextest" +run = "cargo watch -x 'check --workspace' -x 'nextest run --workspace --cargo-profile release'" diff --git a/preflight.md b/preflight.md new file mode 100644 index 0000000..42f7b8a --- /dev/null +++ b/preflight.md @@ -0,0 +1,267 @@ +# preflight.rs — Quick Context Surfacer + +A `rust-script` preflight that surfaces repo context in one shot: shell detection, git state, +tracked-file tree, and HANDOFF document read/update. + +```rust +#!/usr/bin/env rust-script +//! ```cargo +//! [dependencies] +//! anyhow = "1" +//! colored = "2" +//! chrono = { version = "0.4", features = ["clock"] } +//! ``` + +use anyhow::{Context, Result}; +use colored::Colorize; +use std::{ + env, fs, + io::Write, + path::{Path, PathBuf}, + process::Command, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn run(cmd: &str, args: &[&str]) -> Option { + Command::new(cmd) + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) +} + +fn run_or(cmd: &str, args: &[&str], fallback: &str) -> String { + run(cmd, args).unwrap_or_else(|| fallback.to_string()) +} + +fn section(title: &str) { + println!("\n{}", format!("── {title} ").bold().cyan()); +} + +// --------------------------------------------------------------------------- +// Shell detection +// --------------------------------------------------------------------------- + +fn detect_shell() -> String { + // SHELL env is most reliable; fall back to $0 via ps + env::var("SHELL") + .ok() + .and_then(|s| Path::new(&s).file_name().map(|n| n.to_string_lossy().into_owned())) + .or_else(|| { + // Ask the parent process name via `ps` + let ppid = run("sh", &["-c", "echo $PPID"])?; + run("ps", &["-p", &ppid, "-o", "comm="]) + }) + .unwrap_or_else(|| "unknown".into()) +} + +// --------------------------------------------------------------------------- +// Git +// --------------------------------------------------------------------------- + +fn git_root() -> Option { + run("git", &["rev-parse", "--show-toplevel"]).map(PathBuf::from) +} + +fn git_branch() -> String { + run_or("git", &["branch", "--show-current"], "(detached)") +} + +fn git_status_short() -> String { + run_or("git", &["status", "--short"], "") +} + +fn git_log_oneline(n: usize) -> String { + run_or( + "git", + &["log", "--oneline", &format!("-{n}"), "--decorate"], + "(no commits)", + ) +} + +// --------------------------------------------------------------------------- +// Tracked-file tree (git ls-files, depth-limited) +// --------------------------------------------------------------------------- + +fn tracked_tree(root: &Path, max_depth: usize) -> String { + let files = run("git", &["ls-files"]).unwrap_or_default(); + let mut tree: Vec> = Vec::new(); + + for path in files.lines() { + let parts: Vec = path.split('/').map(String::from).collect(); + if parts.len() <= max_depth + 1 { + tree.push(parts); + } + } + + // Deduplicate and build a simple indented representation + let mut seen = std::collections::BTreeSet::new(); + let mut lines = Vec::new(); + + for parts in &tree { + for depth in 0..parts.len() { + let key = parts[..=depth].join("/"); + if seen.insert(key) { + let indent = " ".repeat(depth); + let is_leaf = depth == parts.len() - 1; + let label = if is_leaf { + parts[depth].normal().to_string() + } else { + parts[depth].bold().blue().to_string() + }; + lines.push(format!("{indent}{label}")); + } + } + } + + if lines.is_empty() { + "(no tracked files)".to_string() + } else { + lines.join("\n") + } +} + +// --------------------------------------------------------------------------- +// HANDOFF documents +// --------------------------------------------------------------------------- + +fn find_handoff_files(root: &Path) -> Vec { + let mut hits = Vec::new(); + if let Ok(rd) = fs::read_dir(root) { + for entry in rd.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with("HANDOFF") { + hits.push(entry.path()); + } + } + } + hits.sort(); + hits +} + +fn read_handoff(path: &Path) -> Result { + fs::read_to_string(path) + .with_context(|| format!("reading {}", path.display())) +} + +fn stamp_handoff(path: &Path) -> Result<()> { + let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %z").to_string(); + let original = fs::read_to_string(path) + .with_context(|| format!("reading {}", path.display()))?; + + // Insert or replace a `last_preflight:` line at the top of the YAML/MD doc + let stamp_line = format!("last_preflight: {now}"); + let updated = if let Some(pos) = original.find("last_preflight:") { + let end = original[pos..].find('\n').map(|i| pos + i + 1).unwrap_or(original.len()); + format!("{}{stamp_line}\n{}", &original[..pos], &original[end..]) + } else { + // Prepend before first non-comment, non-empty line + format!("{stamp_line}\n{original}") + }; + + if updated != original { + fs::write(path, &updated) + .with_context(|| format!("writing {}", path.display()))?; + println!(" {} updated timestamp", path.display().to_string().dimmed()); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +fn main() -> Result<()> { + let cwd = env::current_dir()?; + let root = git_root().unwrap_or_else(|| cwd.clone()); + + println!("{}", "╔══ preflight ══╗".bold().yellow()); + + // ── Environment ────────────────────────────────────────────────────────── + section("Environment"); + println!(" shell : {}", detect_shell().green()); + println!(" cwd : {}", cwd.display().to_string().green()); + println!(" git root : {}", root.display().to_string().dimmed()); + + // ── Git ────────────────────────────────────────────────────────────────── + section("Git — branch / status"); + println!(" branch : {}", git_branch().yellow()); + let status = git_status_short(); + if status.is_empty() { + println!(" status : {}", "clean".green()); + } else { + for line in status.lines() { + println!(" {line}"); + } + } + + section("Git — recent history"); + for line in git_log_oneline(7).lines() { + println!(" {line}"); + } + + // ── Tracked files ───────────────────────────────────────────────────── + section("Tracked files (depth ≤ 3)"); + println!("{}", tracked_tree(&root, 3)); + + // ── HANDOFF documents ──────────────────────────────────────────────── + section("HANDOFF documents"); + let handoffs = find_handoff_files(&root); + if handoffs.is_empty() { + println!(" (none found)"); + } else { + for path in &handoffs { + println!("\n {}", path.display().to_string().bold()); + match read_handoff(path) { + Ok(contents) => { + // Print first 40 lines to avoid flooding + for line in contents.lines().take(40) { + println!(" {line}"); + } + let total = contents.lines().count(); + if total > 40 { + println!(" {} … ({} more lines)", "↳".dimmed(), total - 40); + } + stamp_handoff(path)?; + } + Err(e) => println!(" {}", format!("error: {e}").red()), + } + } + } + + println!("\n{}", "╚══ done ══╝".bold().yellow()); + Ok(()) +} +``` + +## Usage + +```bash +# Make executable and run directly +chmod +x preflight.rs +./preflight.rs + +# Or invoke via rust-script explicitly +rust-script preflight.rs +``` + +## What it surfaces + +| Section | Details | +|---------|---------| +| Environment | Current shell (`$SHELL`), cwd, git root | +| Git status | Branch name, short status (clean or dirty file list) | +| Git history | Last 7 commits, one-line with decoration | +| Tracked file tree | `git ls-files` output rendered as an indented tree, depth ≤ 3 | +| HANDOFF docs | Full content (first 40 lines) of any `HANDOFF.*` file at repo root; stamps `last_preflight:` timestamp | + +## Dependencies + +- `rust-script` — `cargo install rust-script` or `mise use -g rust-script` +- `anyhow`, `colored`, `chrono` — fetched automatically by rust-script on first run diff --git a/rustqual.toml b/rustqual.toml new file mode 100644 index 0000000..fc69dbd --- /dev/null +++ b/rustqual.toml @@ -0,0 +1,99 @@ +# rustqual.toml — Tailored configuration for your project +# Generated from analysis of 87 file(s), 488 function(s). +# +# Thresholds are set to your current maximums + 20% headroom. +# Tighten them over time as you improve code quality. + +# ── Function Classification ────────────────────────────────────────────── + +ignore_functions = ["main", "test_*"] +exclude_files = [ + "crates/notgraph/tests/*", + "scripts/preflight.rs", +] +strict_closures = false +strict_iterator_chains = false +allow_recursion = false +strict_error_propagation = false + +# ── Suppression Health ─────────────────────────────────────────────────── + +max_suppression_ratio = 0.05 +fail_on_warnings = false + +# ── Complexity Analysis ────────────────────────────────────────────────── + +[complexity] +enabled = true +max_cognitive = 72 # current max: 60 +max_cyclomatic = 30 # current max: 25 +max_nesting_depth = 9 # current max: 7 +max_function_lines = 286 # current max: 238 +include_nesting_penalty = true +detect_magic_numbers = true +detect_unsafe = true +detect_error_handling = true +allowed_magic_numbers = ["0", "1", "-1", "2"] + +# ── DRY / Duplicate Detection ─────────────────────────────────────────── + +[duplicates] +enabled = true +similarity_threshold = 0.85 +min_tokens = 30 +min_lines = 5 +min_statements = 3 +ignore_tests = true +ignore_trait_impls = true +detect_dead_code = false +detect_wildcard_imports = true +detect_repeated_matches = true + +# ── Boilerplate Detection ─────────────────────────────────────────────── + +[boilerplate] +enabled = true +suggest_crates = true + +# ── SRP (Single Responsibility) ───────────────────────────────────────── + +[srp] +enabled = true +smell_threshold = 0.6 +max_fields = 12 +max_methods = 20 +max_fan_out = 10 +lcom4_threshold = 2 +weights = [0.4, 0.25, 0.15, 0.2] +file_length_baseline = 300 +file_length_ceiling = 800 +max_independent_clusters = 2 +min_cluster_statements = 5 +max_parameters = 5 + +# ── Coupling Analysis ─────────────────────────────────────────────────── + +[coupling] +enabled = true +max_instability = 0.8 +max_fan_in = 15 +max_fan_out = 12 +check_sdp = true + +# ── Test Quality Analysis ────────────────────────────────────────────── + +[test_quality] +enabled = true +# coverage_file = "lcov.info" + +# ── Quality Score Weights ────────────────────────────────────────────── +# Must sum to approximately 1.0. + +[weights] +iosp = 0.22 +complexity = 0.18 +dry = 0.13 +srp = 0.18 +coupling = 0.09 +test_quality = 0.10 +architecture = 0.10 diff --git a/scripts/check-dep-boundaries.py b/scripts/check-dep-boundaries.py new file mode 100644 index 0000000..27b43e9 --- /dev/null +++ b/scripts/check-dep-boundaries.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +""" +Enforce crate dependency boundaries for the notfiles workspace: + - notcore must remain a leaf crate (no deps on other notfiles crates) + - notfiles, notsecrets, nothooks may depend on notcore but NOT on each other + - notstrap may depend on notcore + feature crates (it's the orchestrator) + +Exits 0 if boundaries are clean, 1 if violations found. +""" +import json +import subprocess +import sys + +LEAF = "notcore" +FEATURE_CRATES = {"notfiles", "notsecrets", "nothooks"} +ORCHESTRATOR = "notstrap" +WORKSPACE_CRATES = FEATURE_CRATES | {LEAF, ORCHESTRATOR} + +result = subprocess.run( + ["cargo", "metadata", "--no-deps", "--format-version", "1"], + capture_output=True, text=True, check=True, +) +meta = json.loads(result.stdout) + +# Build name -> deps map for workspace members only +crate_deps: dict[str, set[str]] = {} +for pkg in meta["packages"]: + name = pkg["name"] + if name not in WORKSPACE_CRATES: + continue + deps = {d["name"] for d in pkg["dependencies"] if d["name"] in WORKSPACE_CRATES} + crate_deps[name] = deps + +violations = [] + +# notcore must have no workspace deps +if crate_deps.get(LEAF): + violations.append(f"{LEAF} depends on workspace crates: {crate_deps[LEAF]}") + +# feature crates must not depend on each other +for crate in FEATURE_CRATES: + forbidden = crate_deps.get(crate, set()) & FEATURE_CRATES + if forbidden: + violations.append(f"{crate} depends on sibling feature crates: {forbidden}") + +if violations: + print("FAIL: crate dependency boundary violations detected:") + for v in violations: + print(f" - {v}") + sys.exit(1) + +print("OK: crate dependency boundaries are clean") +print(f" {LEAF}: no workspace deps (leaf)") +for c in sorted(FEATURE_CRATES): + print(f" {c}: depends only on {LEAF}") +print(f" {ORCHESTRATOR}: orchestrator (unrestricted)") diff --git a/scripts/preflight.nu b/scripts/preflight.nu new file mode 100755 index 0000000..f184fae --- /dev/null +++ b/scripts/preflight.nu @@ -0,0 +1,37 @@ +#!/usr/bin/env nu +# preflight.nu — notfiles environment validation + +def check [label: string, pass: bool, detail: string = ""] { + if $pass { + print $"[ok] ($label)" + } else if $detail != "" { + print $"[fail] ($label) — ($detail)" + } else { + print $"[fail] ($label)" + } + $pass +} + +print "=== notfiles preflight ===" + +let results = [ + (check "cargo on PATH" (which cargo | length) > 0), + (check "just on PATH" (which just | length) > 0), + (check "nu on PATH" (which nu | length) > 0 "nushell required for hook scripts"), + (check "age on PATH" (which age | length) > 0 "optional — needed for SOPS decryption"), + (check "sops on PATH" (which sops | length) > 0 "optional — needed for encrypted configs"), + (check "notfiles on PATH" (which notfiles | length) > 0 "run: cargo install --path crates/notfiles"), + (check "op on PATH" (which op | length) > 0), + (check "1Password authed" (do { op account list } | complete | get exit_code) == 0), + (check "git repo clean" (do { git status --porcelain } | complete | get stdout | str trim | is-empty)), +] + +let failed = $results | where { |r| not $r } | length +let total = $results | length + +print "" +if $failed == 0 { + print $"preflight passed ($total)/($total)" +} else { + print $"preflight ($total - $failed)/($total) — ($failed) check(s) failed" +} diff --git a/scripts/preflight.rs b/scripts/preflight.rs new file mode 100644 index 0000000..cfed382 --- /dev/null +++ b/scripts/preflight.rs @@ -0,0 +1,298 @@ +#!/usr/bin/env rust-script +//! ```cargo +//! [dependencies] +//! anyhow = "1" +//! colored = "2" +//! ``` +//! +//! Quick preflight context surfacer: +//! - current shell detection +//! - git status + recent history +//! - tracked-file tree (git ls-files) +//! - HANDOFF.* document summary +//! +//! Usage: ./scripts/preflight.rs [dir] + +use anyhow::{Context, Result}; +use colored::*; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn run(cmd: &str, args: &[&str], cwd: &Path) -> Result { + let out = Command::new(cmd) + .args(args) + .current_dir(cwd) + .output() + .with_context(|| format!("failed to run `{cmd}`"))?; + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +fn run_opt(cmd: &str, args: &[&str], cwd: &Path) -> String { + run(cmd, args, cwd).unwrap_or_default() +} + +// ── Shell detection ─────────────────────────────────────────────────────────── + +fn detect_shell() -> String { + // SHELL env is the login shell; $0 in a running session is more precise but + // not accessible from a child process, so we use SHELL + FISH_VERSION hint. + let shell_env = std::env::var("SHELL").unwrap_or_else(|_| "unknown".into()); + let name = Path::new(&shell_env) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + // Nushell doesn't set SHELL; detect via NU_VERSION if present. + if std::env::var("NU_VERSION").is_ok() || name == "nu" { + return "nushell".into(); + } + if std::env::var("FISH_VERSION").is_ok() { + return "fish".into(); + } + name +} + +// ── Git helpers ─────────────────────────────────────────────────────────────── + +fn git_root(cwd: &Path) -> Option { + let out = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(cwd) + .output() + .ok()?; + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + Some(PathBuf::from(s)) + } else { + None + } +} + +fn git_branch(root: &Path) -> String { + run_opt("git", &["branch", "--show-current"], root) + .trim() + .to_string() +} + +fn git_status(root: &Path) -> String { + run_opt("git", &["status", "--short"], root) +} + +fn git_log(root: &Path) -> String { + run_opt( + "git", + &["log", "--oneline", "-7", "--decorate=short"], + root, + ) +} + +// ── Tracked-file tree ───────────────────────────────────────────────────────── + +/// Build a tree from `git ls-files` output and render it as a compact indented +/// string. Each directory node is printed once; files are leaves. +fn tracked_tree(root: &Path) -> String { + let raw = run_opt("git", &["ls-files"], root); + let paths: Vec<&str> = raw.lines().collect(); + + // Group by first path component for a two-level summary. + let mut dirs: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + let mut top_files: Vec<&str> = Vec::new(); + + for p in &paths { + if let Some(slash) = p.find('/') { + dirs.entry(&p[..slash]).or_default().push(&p[slash + 1..]); + } else { + top_files.push(p); + } + } + + let mut out = String::new(); + for f in &top_files { + out.push_str(&format!(" {f}\n")); + } + for (dir, files) in &dirs { + out.push_str(&format!(" {dir}/ ({} files)\n", files.len())); + // show up to 6 entries per dir to keep output compact + for f in files.iter().take(6) { + out.push_str(&format!(" {f}\n")); + } + if files.len() > 6 { + out.push_str(&format!(" … and {} more\n", files.len() - 6)); + } + } + out +} + +// ── HANDOFF surfacer ────────────────────────────────────────────────────────── + +fn surface_handoffs(root: &Path) -> Vec<(String, String)> { + let mut results = Vec::new(); + + // Collect HANDOFF.* from root and .ctx/ + let candidates: &[&str] = &["HANDOFF.md", ".ctx"]; + for c in candidates { + let p = root.join(c); + if p.is_dir() { + // Read all HANDOFF yaml files in .ctx/ + if let Ok(rd) = std::fs::read_dir(&p) { + let mut entries: Vec<_> = rd.flatten().collect(); + entries.sort_by_key(|e| e.file_name()); + for entry in entries { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("HANDOFF") && !name.ends_with(".state.yaml") { + let content = + std::fs::read_to_string(entry.path()).unwrap_or_default(); + results.push((format!(".ctx/{name}"), summarize_handoff(&content))); + } + } + } + } else if p.is_file() { + let content = std::fs::read_to_string(&p).unwrap_or_default(); + results.push((c.to_string(), summarize_handoff(&content))); + } + } + results +} + +/// Extract the most useful lines from a HANDOFF file (yaml or md). +fn summarize_handoff(content: &str) -> String { + // For YAML: grab project, branch, items, last log entry. + // For MD: grab the first 20 non-blank lines. + let lines: Vec<&str> = content.lines().collect(); + + if content.starts_with("project:") || content.contains("items:") { + // YAML path — grab key fields + let mut summary = Vec::new(); + let mut in_items = false; + let mut item_count = 0; + let mut last_log = String::new(); + let mut in_log = false; + + for line in &lines { + let t = line.trim(); + if t.starts_with("project:") || t.starts_with("branch:") || t.starts_with("updated:") { + summary.push(t.to_string()); + } + if t == "items: []" { + summary.push("items: (none)".into()); + } + if t.starts_with("- id:") || t.starts_with("- title:") { + in_items = true; + item_count += 1; + summary.push(format!(" item: {}", t.trim_start_matches("- "))); + } + if t == "log:" { + in_log = true; + } + if in_log && t.starts_with("summary:") { + last_log = t.trim_start_matches("summary:").trim().to_string(); + } + } + if item_count > 0 { + summary.push(format!("total items: {item_count}")); + } + if !last_log.is_empty() { + summary.push(format!("last log: {last_log}")); + } + summary.join("\n") + } else { + // MD path — first 20 non-blank lines + lines + .iter() + .filter(|l| !l.trim().is_empty()) + .take(20) + .copied() + .collect::>() + .join("\n") + } +} + +// ── Rendering ───────────────────────────────────────────────────────────────── + +fn section(title: &str) { + println!("\n{}", format!("── {title} ").bold().cyan()); +} + +fn main() -> Result<()> { + let cwd = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap()); + + println!("{}", "╔══════════════════════════════╗".bold()); + println!("{}", "║ preflight context surfacer ║".bold()); + println!("{}", "╚══════════════════════════════╝".bold()); + + // ── Shell ──────────────────────────────────────────────────────────────── + section("shell"); + println!(" {}", detect_shell().green()); + + // ── Git root ───────────────────────────────────────────────────────────── + section("workspace"); + let root = git_root(&cwd); + match &root { + Some(r) => { + let branch = git_branch(r); + println!(" root: {}", r.display().to_string().yellow()); + println!(" branch: {}", branch.green()); + } + None => { + println!(" {}", "not a git repository".red()); + } + } + + if let Some(root) = &root { + // ── Git status ─────────────────────────────────────────────────────── + section("git status"); + let status = git_status(root); + if status.trim().is_empty() { + println!(" {}", "clean".green()); + } else { + for line in status.lines() { + // color by status prefix + let colored = if line.starts_with('M') || line.starts_with("RM") { + line.yellow().to_string() + } else if line.starts_with('A') { + line.green().to_string() + } else if line.starts_with('D') || line.starts_with('R') { + line.red().to_string() + } else if line.starts_with('?') { + line.dimmed().to_string() + } else { + line.normal().to_string() + }; + println!(" {colored}"); + } + } + + // ── Git log ────────────────────────────────────────────────────────── + section("recent commits"); + for line in git_log(root).lines() { + println!(" {line}"); + } + + // ── File tree ──────────────────────────────────────────────────────── + section("tracked files"); + print!("{}", tracked_tree(root)); + + // ── HANDOFF ────────────────────────────────────────────────────────── + section("handoff"); + let handoffs = surface_handoffs(root); + if handoffs.is_empty() { + println!(" {}", "no HANDOFF files found".dimmed()); + } else { + for (name, summary) in &handoffs { + println!(" {}", name.bold().yellow()); + for line in summary.lines() { + println!(" {line}"); + } + println!(); + } + } + } + + println!("{}", "─────────────────────────────────".dimmed()); + Ok(()) +} diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index 7ad1e46..77e1240 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -14,11 +14,11 @@ name = "cross_crate" path = "tests/cross_crate.rs" [dev-dependencies] -notstrap = { path = "../../crates/notstrap" } +anyhow = { workspace = true } +assert_fs = { workspace = true } +notcore = { path = "../../crates/notcore" } notfiles = { path = "../../crates/notfiles" } nothooks = { path = "../../crates/nothooks" } notsecrets = { path = "../../crates/notsecrets" } -notcore = { path = "../../crates/notcore" } +notstrap = { path = "../../crates/notstrap" } tempfile = { workspace = true } -assert_fs = { workspace = true } -anyhow = { workspace = true } diff --git a/tests/integration/tests/bootstrap.rs b/tests/integration/tests/bootstrap.rs index 5ad4bfa..7541d81 100644 --- a/tests/integration/tests/bootstrap.rs +++ b/tests/integration/tests/bootstrap.rs @@ -1,6 +1,6 @@ use notcore::{HookPhase, HookSpec, StepStatus}; -use nothooks::{run_phase, HookRunner}; -use notstrap::{run, BootstrapOptions}; +use nothooks::{HookRunner, run_phase}; +use notstrap::{BootstrapOptions, run}; use std::fs; use std::path::PathBuf; use tempfile::TempDir; @@ -19,7 +19,11 @@ fn make_test_env() -> TestEnv { // age key file (content doesn't matter — FileSource reads it verbatim) let key_file = d.join("age.key"); - fs::write(&key_file, "AGE-SECRET-KEY-1TESTKEY\n").unwrap(); + fs::write( + &key_file, + "AGE-SECRET-KEY-1X3QKFQ4MZQM7LTJ3AX0N3EM63RGRV4J6N5ZDWPVKCEUCZKJWJSUSU6GYN6\n", + ) + .unwrap(); // notfiles.toml — one package "shell" targeting home tempdir fs::write( @@ -65,8 +69,9 @@ fn make_opts(env: &TestEnv, force: bool) -> BootstrapOptions { force, key_file: Some(env.key_file.clone()), dotfiles: Some(env.dotfiles.path().to_path_buf()), + tailscale: Some(None), // skip Tailscale in tests check_prereqs: None, - env_injector: None, + secrets_config: None, } } @@ -182,9 +187,10 @@ fn test_setup_hooks_skipped_on_rerun() { name: HOOK_NAME.to_string(), script: script.to_str().unwrap().to_string(), phase: HookPhase::Setup, + interpreter: None, }; let runner = HookRunner::new(d.to_path_buf()); - let phase_report = run_phase(&[hook_spec], &HookPhase::Setup, &runner); + let phase_report = run_phase(&[hook_spec], &HookPhase::Setup, &runner).unwrap(); let step = phase_report .steps .iter() @@ -211,8 +217,9 @@ fn test_bootstrap_fails_fast_on_bad_key() { force: false, key_file: Some(PathBuf::from("/nonexistent/no-such-key.age")), dotfiles: Some(d.to_path_buf()), + tailscale: Some(None), // skip Tailscale in tests check_prereqs: None, - env_injector: None, + secrets_config: None, }; let report = run(opts).expect("run() should return Ok even when a step fails"); @@ -239,3 +246,49 @@ fn test_bootstrap_fails_fast_on_bad_key() { "should not reach link dotfiles step after key failure" ); } + +/// A link conflict must stop bootstrap before any hook phase runs. +#[test] +fn test_bootstrap_stops_after_link_failure() { + let env = make_test_env(); + let d = env.dotfiles.path(); + let home = env.home.path(); + + fs::write(home.join(".zshrc"), "local override\n").unwrap(); + + let script = d.join("scripts/greet.nu"); + fs::write(&script, "print hello\n").unwrap(); + fs::write( + &env.config, + format!( + "[bootstrap]\n\ + dotfiles_repo = \"https://example.com/fake.git\"\n\ + dotfiles_dir = \"{dotfiles}\"\n\n\ + [[hooks]]\n\ + name = \"greet\"\n\ + script = \"{script}\"\n\ + phase = \"dot\"\n", + dotfiles = d.display(), + script = script.display(), + ), + ) + .unwrap(); + + let report = run(make_opts(&env, false)).unwrap(); + + let link_step = report + .steps + .iter() + .find(|step| step.name == "link dotfiles") + .expect("link dotfiles step should be present"); + assert!( + matches!(link_step.status, StepStatus::Failed(_)), + "link failure should be surfaced, got {:?}", + link_step.status + ); + assert!( + report.steps.iter().all(|step| step.name != "dot hooks"), + "bootstrap should stop before running hooks after link failure: {:?}", + report.steps + ); +} diff --git a/tests/integration/tests/cross_crate.rs b/tests/integration/tests/cross_crate.rs index bb73656..c12af19 100644 --- a/tests/integration/tests/cross_crate.rs +++ b/tests/integration/tests/cross_crate.rs @@ -3,9 +3,12 @@ use std::path::Path; use tempfile::TempDir; use notcore::{HookPhase, HookSpec}; +use notfiles::{LinkOptions, link}; use nothooks::{HookResult, HookRunner}; -use notsecrets::{resolve_age_key, AgeKeySource, FileSource}; -use notfiles::{link, LinkOptions}; +use notsecrets::config::{Provider, SecretsConfig}; +use notsecrets::resolver::SecretResolver; +use notsecrets::sources::IdentitySource; +use notsecrets::{FileSource, resolve_identities}; /// Test that notsecrets and nothooks can each be used independently /// in the same integration boundary — resolving an age key from a file @@ -16,14 +19,15 @@ fn test_nothooks_notsecrets_independent() { // Write a fake age key via FileSource let key_path = dir.path().join("age.key"); - fs::write(&key_path, "AGE-SECRET-KEY-1CROSSCRATE\n").unwrap(); + fs::write( + &key_path, + "AGE-SECRET-KEY-1X3QKFQ4MZQM7LTJ3AX0N3EM63RGRV4J6N5ZDWPVKCEUCZKJWJSUSU6GYN6\n", + ) + .unwrap(); - let sources: Vec> = vec![Box::new(FileSource::new(key_path))]; - let key = resolve_age_key(sources).expect("resolve_age_key failed"); - assert!( - key.trim().starts_with("AGE-SECRET-KEY-"), - "expected age key prefix, got: {key:?}" - ); + let sources: Vec> = vec![Box::new(FileSource::new(key_path))]; + let identities = resolve_identities(sources).expect("resolve_identities failed"); + assert!(!identities.is_empty(), "expected at least one identity"); // Write a hook that just prints let script = dir.path().join("chain.nu"); @@ -33,16 +37,162 @@ fn test_nothooks_notsecrets_independent() { name: "chain".to_string(), script: script.to_str().unwrap().to_string(), phase: HookPhase::Dot, + interpreter: None, }; let runner = HookRunner::new(dir.path().to_path_buf()); - let result = runner.run_hook(&spec); + let result = runner.run_hook(&spec).expect("hook runner should not fail"); assert!( matches!(result, HookResult::Ok), "expected HookResult::Ok, got: {result:?}" ); } +#[test] +fn secret_resolver_resolves_env_var_cross_crate() { + use std::collections::HashMap; + + unsafe { std::env::set_var("NOTFILES_CROSS_CRATE_TEST", "works") }; + + let config = SecretsConfig { + providers: vec![Provider::Env], + provider: HashMap::new(), + secrets: HashMap::new(), + }; + let resolver = SecretResolver::from_config(config).unwrap(); + let val = resolver.resolve("NOTFILES_CROSS_CRATE_TEST").unwrap(); + assert_eq!(val, Some("works".to_string())); + + unsafe { std::env::remove_var("NOTFILES_CROSS_CRATE_TEST") }; +} + +// ── nu_libs nushell package e2e ─────────────────────────────────────────────── + +/// Helper: build a dotfiles dir containing the nushell/nu_libs.nu package +/// mirroring the real notfiles repo layout. +fn make_nu_libs_dotfiles(dotfiles: &Path, target_home: &Path) { + let src = dotfiles.join("nushell/.config/nushell/autoload"); + fs::create_dir_all(&src).unwrap(); + fs::write( + src.join("nu_libs.nu"), + b"const NU_LIBS_AI = \"/Users/joe/dev/nu_libs/lib/ai/mod.nu\"\nuse $NU_LIBS_AI *\n", + ) + .unwrap(); + fs::write( + dotfiles.join("notfiles.toml"), + format!( + "[defaults]\ntarget = \"{}\"\ninclude = [\"nushell\"]\n\n[packages.nushell]\n", + target_home.display() + ) + .as_bytes(), + ) + .unwrap(); +} + +/// The nu_libs autoload file is symlinked into the correct autoload directory. +#[test] +fn nu_libs_autoload_is_linked_to_target() { + let dotfiles = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + let autoload = home.path().join(".config/nushell/autoload"); + fs::create_dir_all(&autoload).unwrap(); + + make_nu_libs_dotfiles(dotfiles.path(), home.path()); + + let opts = LinkOptions { + force: false, + no_backup: false, + dry_run: false, + verbose: false, + }; + link(dotfiles.path(), &["nushell".to_string()], &opts).unwrap(); + + let link_path = autoload.join("nu_libs.nu"); + assert!(link_path.exists(), "nu_libs.nu should exist at target"); + assert!(link_path.is_symlink(), "nu_libs.nu should be a symlink"); + assert_eq!( + fs::read_link(&link_path).unwrap(), + dotfiles + .path() + .join("nushell/.config/nushell/autoload/nu_libs.nu"), + "symlink should point into the notfiles nushell package" + ); +} + +/// Unlinking removes the symlink but leaves the autoload directory intact. +#[test] +fn nu_libs_autoload_is_removed_on_unlink() { + let dotfiles = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + let autoload = home.path().join(".config/nushell/autoload"); + fs::create_dir_all(&autoload).unwrap(); + + make_nu_libs_dotfiles(dotfiles.path(), home.path()); + + let opts = LinkOptions { + force: false, + no_backup: false, + dry_run: false, + verbose: false, + }; + link(dotfiles.path(), &["nushell".to_string()], &opts).unwrap(); + notfiles::unlink(dotfiles.path(), &["nushell".to_string()], &opts).unwrap(); + + assert!( + !autoload.join("nu_libs.nu").exists(), + "symlink should be removed after unlink" + ); +} + +/// A pre-existing regular file at the target path causes link to fail without --force. +#[test] +fn nu_libs_link_does_not_overwrite_existing_file_without_force() { + let dotfiles = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + let autoload = home.path().join(".config/nushell/autoload"); + fs::create_dir_all(&autoload).unwrap(); + fs::write(autoload.join("nu_libs.nu"), b"# existing\n").unwrap(); + + make_nu_libs_dotfiles(dotfiles.path(), home.path()); + + let opts = LinkOptions { + force: false, + no_backup: false, + dry_run: false, + verbose: false, + }; + let result = link(dotfiles.path(), &["nushell".to_string()], &opts); + assert!( + result.is_err(), + "link should fail when target file exists without --force" + ); +} + +/// With --force the existing file is replaced by a symlink. +#[test] +fn nu_libs_link_with_force_replaces_existing_file() { + let dotfiles = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + let autoload = home.path().join(".config/nushell/autoload"); + fs::create_dir_all(&autoload).unwrap(); + fs::write(autoload.join("nu_libs.nu"), b"# existing\n").unwrap(); + + make_nu_libs_dotfiles(dotfiles.path(), home.path()); + + let opts = LinkOptions { + force: true, + no_backup: false, + dry_run: false, + verbose: false, + }; + link(dotfiles.path(), &["nushell".to_string()], &opts).unwrap(); + + assert!( + autoload.join("nu_libs.nu").is_symlink(), + "nu_libs.nu should be a symlink after forced link" + ); +} + /// Test that notfiles ignores .notfiles-state.toml and .nothooks-state.toml /// by default — they must not be symlinked into the target directory and must /// not appear in the returned State. @@ -69,7 +219,12 @@ fn test_notfiles_respects_default_ignore() { fs::write(pkg.join(".notfiles-state.toml"), "# state\n").unwrap(); fs::write(pkg.join(".nothooks-state.toml"), "# state\n").unwrap(); - let opts = LinkOptions { force: false, no_backup: false, dry_run: false, verbose: false }; + let opts = LinkOptions { + force: false, + no_backup: false, + dry_run: false, + verbose: false, + }; let state = link(d, &[], &opts).expect("link() failed"); // foo.txt linked