Skip to content

Commit da332fe

Browse files
authored
Merge pull request #21 from lucatescari/feat/global-gpg-keyring
feat: global GPG keyring with interactive picker
2 parents 10f117d + 5d646f9 commit da332fe

13 files changed

Lines changed: 1811 additions & 9 deletions

File tree

.github/workflows/ci.yml

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,24 @@ jobs:
3434
target
3535
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
3636

37-
- name: Install git-crypt (Linux)
37+
- name: Install git-crypt and GPG (Linux)
3838
if: runner.os == 'Linux'
39-
run: sudo apt-get update && sudo apt-get install -y git-crypt
39+
run: sudo apt-get update && sudo apt-get install -y git-crypt gnupg
4040

41-
- name: Install git-crypt (macOS)
41+
- name: Install git-crypt and GPG (macOS)
4242
if: runner.os == 'macOS'
43-
run: brew install git-crypt
43+
run: brew install git-crypt gnupg
44+
45+
- name: Install GPG (Windows)
46+
if: runner.os == 'Windows'
47+
shell: pwsh
48+
run: |
49+
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force
50+
if (-not (Get-Command scoop -ErrorAction SilentlyContinue)) {
51+
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
52+
}
53+
scoop install gnupg
54+
gpg --version
4455
4556
- name: Check formatting
4657
run: cargo fmt --check
@@ -54,5 +65,8 @@ jobs:
5465
- name: Integration tests
5566
run: cargo test --test integration
5667

68+
- name: GPG integration tests
69+
run: cargo test --test gpg_integration
70+
5771
- name: Cross-compatibility tests
5872
run: cargo test --test cross_compat

CLAUDE.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ When adding features, changing CLI flags, modifying commands, or altering behavi
77
- Update CONTRIBUTING.md if project structure, test count, build steps, or development guidelines changed
88
- Keep the test count in CONTRIBUTING.md accurate after adding/removing tests
99

10+
## Testing Requirements
11+
12+
Every command, subcommand, and code path MUST have integration tests. This is non-negotiable for security-critical software.
13+
14+
- **Every CLI command** must have integration tests covering happy path AND error paths
15+
- **Every flag/option** on every command must be tested
16+
- **GPG-dependent commands** (add-gpg-user, rm-gpg-user, ls-gpg-users, unlock via GPG) must have tests that exercise real GPG operations using test keys in a temp GNUPGHOME
17+
- **Tests must run on all 3 CI platforms** (Linux, macOS, Windows). Only use `#[cfg(unix)]` for genuinely Unix-only concepts (file mode bits, Unix symlinks)
18+
- **When adding a new command or feature**: write tests BEFORE or WITH the implementation, never after. No PR should add a command without corresponding tests.
19+
- **When modifying an existing command**: verify existing tests still cover the behavior, add new tests if the change adds flags or code paths
20+
- **GPG tests** should auto-skip gracefully if GPG is not available (use a `skip_without_gpg!()` macro, same pattern as `skip_without_git_crypt!()` in cross_compat.rs)
21+
- Keep the test count in CONTRIBUTING.md accurate after adding/removing tests
22+
1023
## Code Quality
1124

1225
- Run `cargo clippy` and fix warnings before committing

CONTRIBUTING.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ cargo build
2525
cargo test
2626
```
2727

28-
All 54 tests should pass (28 unit + 20 integration + 6 cross-compatibility). They cover:
28+
All 91 tests should pass (31 unit + 40 integration + 14 GPG integration + 6 cross-compatibility). They cover:
2929
- AES-256-CTR encryption/decryption round-trips
3030
- HMAC-SHA1 known-answer vectors
3131
- Key file TLV serialization/deserialization
@@ -36,6 +36,15 @@ All 54 tests should pass (28 unit + 20 integration + 6 cross-compatibility). The
3636
- Status, export-key, quiet mode, error messages (integration)
3737
- Edge cases: empty files, binary files, multi-key lock (integration)
3838
- Pipe deadlock regression: many-file and large-blob status, unlock, lock (integration)
39+
- Global config: XDG resolution, keyring path save/load/remove, permissions (unit)
40+
- Config CLI: set-keyring, unset-keyring, show, overwrite, canonicalization, symlinks (integration)
41+
- Keyring fallback: add-gpg-user with no args, empty dir, deleted dir, precedence (integration)
42+
- Scan security: symlink skipping, non-key extensions, empty directory (integration)
43+
- GPG add-gpg-user: by email, fingerprint, --trusted, --no-commit, -k, --from file (GPG integration)
44+
- GPG rm-gpg-user: remove, --no-commit, user not found (GPG integration)
45+
- GPG ls-gpg-users: list, no users, named key (GPG integration)
46+
- GPG unlock roundtrip: add user, lock, unlock via GPG (GPG integration)
47+
- GPG multi-user: add 2 users, remove 1, verify count (GPG integration)
3948
- Cross-tool: key exchange, encrypt/decrypt, named keys, binary files (cross-compatibility)
4049

4150
The cross-compatibility tests (`tests/cross_compat.rs`) verify interoperability with
@@ -60,16 +69,18 @@ src/
6069
key/ Key file format (TLV serialization, entries, key container)
6170
filter/ Git clean/smudge/diff filters
6271
commands/ User-facing commands (init, lock, unlock, status, export-key,
63-
add/rm/ls-gpg-users)
72+
add/rm/ls-gpg-users, config)
6473
git/ Git repository helpers (config, checkout, repo inspection)
6574
gpg/ GPG integration (key import, encrypt/decrypt via gpg CLI)
6675
cli.rs clap CLI definitions + shell completion generation
76+
config.rs Global configuration (XDG keyring path)
6777
constants.rs Shared constants (magic bytes, sizes, field IDs)
6878
error.rs Error types
6979
main.rs Entry point
7080
tests/
71-
integration.rs E2E tests using temporary git repos
72-
cross_compat.rs Cross-tool tests against git-crypt
81+
integration.rs E2E tests using temporary git repos
82+
gpg_integration.rs GPG user management tests (add/rm/ls, unlock via GPG)
83+
cross_compat.rs Cross-tool tests against git-crypt
7384
benchmark/
7485
bench.sh Status command scaling by file count
7586
bench_large_files.sh Status with large binary files (Unity-like repos)

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ gitveil add-gpg-user [-k <key-name>] [-n] [--trusted] [--from <source>] [<GPG_US
186186
| `--trusted` | Skip GPG Web of Trust verification |
187187
| `--from <source>` | Import GPG key(s) from a file, directory, or git URL |
188188

189+
When called with no arguments and no `--from`, gitveil checks for a globally configured keyring directory (see `gitveil config set-keyring`). If configured, it scans the directory and shows an interactive picker.
190+
189191
#### Import keys from a shared keyring
190192

191193
If your team stores GPG public keys in a shared repository, you can import them directly:
@@ -203,6 +205,37 @@ gitveil add-gpg-user --from git@github.com:company/gpg-keys.git
203205

204206
When pointing at a directory (or git URL), gitveil scans for `.asc`, `.gpg`, `.pub`, and `.key` files, shows a list of found keys (name, email, fingerprint), and lets you select one or more to add as collaborators.
205207

208+
### `gitveil config`
209+
210+
Manage global gitveil configuration.
211+
212+
```bash
213+
# Set a global GPG keyring directory
214+
gitveil config set-keyring /path/to/team-keys
215+
216+
# Show current configuration
217+
gitveil config show
218+
219+
# Remove the keyring setting
220+
gitveil config unset-keyring
221+
```
222+
223+
When a keyring directory is configured, `gitveil add-gpg-user` (with no arguments and no `--from`) will automatically scan the keyring directory and present an interactive picker to select GPG keys. This is useful when your team stores GPG public keys in a shared folder or git repository.
224+
225+
The keyring directory has no special format -- it's just a folder containing GPG public key files (exported with `gpg --export` or `gpg --armor --export`). Files are matched by extension (`.asc`, `.gpg`, `.pub`, `.key`) and can be organized in subdirectories. Non-key files and symlinks are ignored.
226+
227+
```
228+
team-keys/
229+
├── engineering/
230+
│ ├── alice.asc
231+
│ └── bob.pub
232+
├── design/
233+
│ └── carol.gpg
234+
└── README.md # ignored (not a key extension)
235+
```
236+
237+
The keyring path is stored in `~/.config/gitveil/config` (respects `$XDG_CONFIG_HOME`). The config file is created with 0600 permissions and the config directory with 0700 permissions.
238+
206239
### `gitveil rm-gpg-user`
207240

208241
Remove a GPG user's access.
@@ -309,6 +342,7 @@ The clean filter must read the entire file into memory to compute the HMAC-SHA1
309342
src/
310343
main.rs # Entry point + CLI dispatch
311344
cli.rs # clap CLI definitions
345+
config.rs # Global configuration (XDG keyring path)
312346
constants.rs # Magic bytes, sizes, field IDs
313347
error.rs # Error types
314348
crypto/
@@ -330,6 +364,7 @@ src/
330364
status.rs # Show encryption status
331365
export_key.rs # Export symmetric key
332366
add_gpg_user.rs # Add GPG collaborator
367+
config.rs # Global config management
333368
rm_gpg_user.rs # Remove GPG collaborator
334369
ls_gpg_users.rs # List GPG collaborators
335370
git/

src/cli.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@ pub enum Commands {
124124
key_name: Option<String>,
125125
},
126126

127+
/// Manage global gitveil configuration
128+
Config {
129+
#[command(subcommand)]
130+
action: ConfigAction,
131+
},
132+
127133
/// Generate shell completions for bash, zsh, or fish
128134
Completions {
129135
/// Shell to generate completions for
@@ -161,6 +167,24 @@ pub enum Commands {
161167
},
162168
}
163169

170+
#[derive(Subcommand)]
171+
pub enum ConfigAction {
172+
/// Set the global GPG keyring directory
173+
#[command(name = "set-keyring")]
174+
SetKeyring {
175+
/// Path to a directory containing GPG public key files
176+
#[arg()]
177+
path: PathBuf,
178+
},
179+
180+
/// Remove the global keyring directory setting
181+
#[command(name = "unset-keyring")]
182+
UnsetKeyring,
183+
184+
/// Show current configuration
185+
Show,
186+
}
187+
164188
/// Generate shell completions and write to stdout.
165189
pub fn print_completions(shell: Shell) {
166190
clap_complete::generate(

src/commands/add_gpg_user.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::process::Command;
33

44
use colored::Colorize;
55

6+
use crate::config;
67
use crate::constants::DEFAULT_KEY_NAME;
78
use crate::error::GitVeilError;
89
use crate::git::repo::{find_git_dir, find_repo_root, git_crypt_dir, key_path};
@@ -47,9 +48,29 @@ pub fn add_gpg_user(
4748
}
4849
}
4950
None => {
51+
// Try global keyring if no --from and no gpg_user_id
52+
if gpg_user_id.is_none() {
53+
match config::load_keyring_path() {
54+
Ok(Some(keyring_path)) => {
55+
return add_from_path(
56+
key_name,
57+
no_commit,
58+
trusted,
59+
&keyring_path,
60+
&git_dir,
61+
);
62+
}
63+
Ok(None) => {} // No keyring configured, fall through to error
64+
Err(e) => {
65+
eprintln!("{} global keyring: {}", "Warning:".yellow().bold(), e);
66+
// Fall through to error
67+
}
68+
}
69+
}
70+
5071
let gpg_user_id = gpg_user_id.ok_or_else(|| {
5172
GitVeilError::Other(
52-
"GPG user ID is required (or use --from to import from a file/directory/URL)"
73+
"GPG user ID is required (or use --from to import from a file/directory/URL, or configure a global keyring with 'gitveil config set-keyring <path>')"
5374
.into(),
5475
)
5576
})?;

src/commands/config.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use std::path::Path;
2+
3+
use colored::Colorize;
4+
5+
use crate::config;
6+
use crate::error::GitVeilError;
7+
8+
/// Set the global GPG keyring directory.
9+
pub fn config_set_keyring(path: &Path) -> Result<(), GitVeilError> {
10+
config::save_keyring_path(path)?;
11+
12+
// Re-load to show the canonicalized path
13+
let canonical = config::load_keyring_path()?.unwrap_or_default();
14+
eprintln!(
15+
"{} keyring path: {}",
16+
"Set".green().bold(),
17+
canonical.display()
18+
);
19+
Ok(())
20+
}
21+
22+
/// Remove the global GPG keyring directory setting.
23+
pub fn config_unset_keyring() -> Result<(), GitVeilError> {
24+
config::remove_keyring_path()?;
25+
eprintln!("{} keyring path.", "Removed".green().bold());
26+
Ok(())
27+
}
28+
29+
/// Show current configuration.
30+
pub fn config_show() -> Result<(), GitVeilError> {
31+
match config::load_keyring_path() {
32+
Ok(Some(path)) => {
33+
println!("keyring-path: {}", path.display());
34+
}
35+
Ok(None) => {
36+
println!("keyring-path: (not set)");
37+
}
38+
Err(e) => {
39+
println!("keyring-path: (error: {})", e);
40+
}
41+
}
42+
Ok(())
43+
}

src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod add_gpg_user;
2+
pub mod config;
23
pub mod export_key;
34
pub mod init;
45
pub mod lock;

0 commit comments

Comments
 (0)