Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 102 additions & 102 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install protobuf compiler
run: |
if [[ "${{ runner.os }}" == "Linux" ]]; then
sudo apt-get update
sudo apt-get install -y protobuf-compiler
elif [[ "${{ runner.os }}" == "macOS" ]]; then
brew install protobuf
fi
- run: cargo fmt -- --check
- run: cargo clippy -- -D warnings
- run: cargo test
9 changes: 7 additions & 2 deletions .github/workflows/vhs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ on:

env:
CARGO_TERM_COLOR: always
VHS_VERSION: 0.11.0

jobs:
vhs:
Expand All @@ -45,10 +46,14 @@ jobs:
- name: Install VHS runtime deps
run: |
sudo apt-get update
sudo apt-get install -y ffmpeg ttyd
sudo apt-get install -y ffmpeg protobuf-compiler ttyd

- name: Install VHS
uses: charmbracelet/vhs-action@v2
run: |
curl -fsSL \
"https://github.com/charmbracelet/vhs/releases/download/v${VHS_VERSION}/vhs_${VHS_VERSION}_amd64.deb" \
-o /tmp/vhs.deb
sudo apt-get install -y /tmp/vhs.deb

- name: Verify VHS install
run: vhs --version
Expand Down
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@

devShells.coverage = pkgs.mkShell {
inputsFrom = [
derivation
self.devShells.${system}.default
];
};

Expand Down
18 changes: 12 additions & 6 deletions rust/src/cli/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,11 @@ fn build_env_map(
let mut env_map: BTreeMap<String, (String, String)> = BTreeMap::new();

for (r, decoded) in items {
if !want_tags.is_empty() && !want_tags.iter().all(|t| decoded.tags.iter().any(|d| d == t)) {
if !want_tags.is_empty()
&& !want_tags
.iter()
.all(|t| decoded.tags.iter().any(|d| d == t))
{
continue;
}

Expand Down Expand Up @@ -387,7 +391,10 @@ mod tests {
// Two secrets resolving to the same env-var name.
let items = vec![
(rref("a/api-key", None), decoded("first", "", &[])),
(rref("b/API_KEY", Some("API_KEY")), decoded("second", "", &[])),
(
rref("b/API_KEY", Some("API_KEY")),
decoded("second", "", &[]),
),
];
let err = build_env_map(items, &[]).unwrap_err();
let msg = err.to_string();
Expand Down Expand Up @@ -424,10 +431,9 @@ mod tests {
args: ExecArgs,
}

let cli = Cli::try_parse_from([
"test", "prod/API_KEY", "--", "node", "-e", "console.log(1)",
])
.unwrap();
let cli =
Cli::try_parse_from(["test", "prod/API_KEY", "--", "node", "-e", "console.log(1)"])
.unwrap();
assert_eq!(cli.args.r#ref, "prod/API_KEY");
assert_eq!(cli.args.command, vec!["node", "-e", "console.log(1)"]);
assert!(!cli.args.clean);
Expand Down
5 changes: 1 addition & 4 deletions rust/src/cli/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1039,10 +1039,7 @@ mod tests {
normalize_key_path("database/HOST_NAME"),
"database/host-name"
);
assert_eq!(
normalize_key_path("PROD/API_KEY"),
"prod/api-key"
);
assert_eq!(normalize_key_path("PROD/API_KEY"), "prod/api-key");
assert_eq!(normalize_key_path("simple"), "simple");
}

Expand Down
40 changes: 32 additions & 8 deletions rust/src/cli/join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,7 @@ pub fn run(args: JoinArgs, ctx: &Context) -> Result<()> {
fn read_own_pubkey(ctx: &Context) -> Result<String> {
let key_path = ctx.key_path();
let contents = std::fs::read_to_string(&key_path).map_err(|_| {
HimitsuError::Recipient(
"no age key found — run `himitsu init` first".into(),
)
HimitsuError::Recipient("no age key found — run `himitsu init` first".into())
})?;
for line in contents.lines() {
if let Some(rest) = line.strip_prefix("# public key: ") {
Expand Down Expand Up @@ -100,7 +98,6 @@ pub fn is_self_recipient(ctx: &Context) -> bool {
mod tests {
use super::*;
use crate::remote::store as rstore;
use std::path::PathBuf;
use tempfile::TempDir;

fn mk_ctx_with_key(tmp: &TempDir) -> Context {
Expand Down Expand Up @@ -139,7 +136,14 @@ mod tests {
.unwrap();
assert!(!is_self_recipient(&ctx));

run(JoinArgs { name: None, no_push: true }, &ctx).unwrap();
run(
JoinArgs {
name: None,
no_push: true,
},
&ctx,
)
.unwrap();

assert!(is_self_recipient(&ctx));
}
Expand All @@ -149,9 +153,23 @@ mod tests {
let tmp = TempDir::new().unwrap();
let ctx = mk_ctx_with_key(&tmp);

run(JoinArgs { name: Some("me".into()), no_push: true }, &ctx).unwrap();
run(
JoinArgs {
name: Some("me".into()),
no_push: true,
},
&ctx,
)
.unwrap();
// Second call should succeed silently
run(JoinArgs { name: Some("me".into()), no_push: true }, &ctx).unwrap();
run(
JoinArgs {
name: Some("me".into()),
no_push: true,
},
&ctx,
)
.unwrap();
}

#[test]
Expand All @@ -168,7 +186,13 @@ mod tests {
)
.unwrap();

let result = run(JoinArgs { name: None, no_push: true }, &ctx);
let result = run(
JoinArgs {
name: None,
no_push: true,
},
&ctx,
);
assert!(result.is_err());
}

Expand Down
21 changes: 16 additions & 5 deletions rust/src/cli/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use super::Context;
use crate::crypto::{age, secret_value, tags};
use crate::error::{HimitsuError, Result};
use crate::remote::store;
use crate::suggest;

/// Search secrets across all known stores.
#[derive(Debug, Args)]
Expand Down Expand Up @@ -71,11 +72,7 @@ pub struct SearchResult {
/// (AND-semantics). An empty slice disables tag filtering. Validation of
/// individual tag strings is the caller's responsibility — see [`run`] for
/// the CLI path that runs them through [`crate::crypto::tags::validate_tag`].
pub fn search_core(
ctx: &Context,
query: &str,
tag_filter: &[String],
) -> Result<Vec<SearchResult>> {
pub fn search_core(ctx: &Context, query: &str, tag_filter: &[String]) -> Result<Vec<SearchResult>> {
let mut candidates: Vec<SearchResult> = Vec::new();

// Try to load the age identity once so we can best-effort extract the
Expand Down Expand Up @@ -231,6 +228,20 @@ pub fn run(args: SearchArgs, ctx: &Context) -> Result<()> {
print_table(&results, &args.query, use_color, Utc::now());
}

// "Did you mean ..." suggestion: only when the user typed a real query,
// got zero hits, and isn't filtering by tags (a tag-mismatch is a
// different kind of empty). The candidate corpus is every path returned
// by an unfiltered `search_core` so suggestions stay aligned with what
// the search actually traverses.
if !args.json && results.is_empty() && args.tags.is_empty() && !args.query.trim().is_empty() {
let all = search_core(ctx, "", &[])?;
let candidates: Vec<String> = all.into_iter().map(|r| r.path).collect();
let max_dist = suggest::default_max_distance(&args.query);
if let Some(hit) = suggest::suggest_closest(&args.query, &candidates, max_dist) {
eprintln!("did you mean {hit}?");
}
}

Ok(())
}

Expand Down
25 changes: 21 additions & 4 deletions rust/src/cli/set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,18 @@ mod tests {

#[test]
fn tag_flag_accumulates_multiple_invocations() {
let a = parse(&["prod/API_KEY", "secret-value", "--tag", "pci", "--tag", "rotate-2026-q1"]);
assert_eq!(a.tags, vec!["pci".to_string(), "rotate-2026-q1".to_string()]);
let a = parse(&[
"prod/API_KEY",
"secret-value",
"--tag",
"pci",
"--tag",
"rotate-2026-q1",
]);
assert_eq!(
a.tags,
vec!["pci".to_string(), "rotate-2026-q1".to_string()]
);
}

#[test]
Expand All @@ -323,13 +333,20 @@ mod tests {
let raw = vec!["ok".to_string(), "bad tag".to_string()];
let err = validate_tags(&raw).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("bad tag"), "error mentions offending tag: {msg}");
assert!(
msg.contains("bad tag"),
"error mentions offending tag: {msg}"
);
assert!(msg.contains("invalid tag"), "uses canonical prefix: {msg}");
}

#[test]
fn validate_tags_passes_through_valid_list() {
let raw = vec!["pci".to_string(), "team_backend".to_string(), "v1.2.3".to_string()];
let raw = vec![
"pci".to_string(),
"team_backend".to_string(),
"v1.2.3".to_string(),
];
assert_eq!(validate_tags(&raw).unwrap(), raw);
}
}
6 changes: 1 addition & 5 deletions rust/src/cli/tag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,7 @@ mod tests {
apply_add(&mut tags, &["mu".to_string()]);
assert_eq!(
tags,
vec![
"zeta".to_string(),
"alpha".to_string(),
"mu".to_string(),
]
vec!["zeta".to_string(), "alpha".to_string(), "mu".to_string(),]
);
}

Expand Down
5 changes: 2 additions & 3 deletions rust/src/config/env_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,9 +324,8 @@ fn read_entries(conn: &Connection, env_id: i64) -> Result<Vec<EnvEntry>> {
},
"tag" => EnvEntry::Tag(value),
"alias_tag" => EnvEntry::AliasTag {
key: alias_key.ok_or_else(|| {
HimitsuError::Index("alias_tag row missing alias_key".into())
})?,
key: alias_key
.ok_or_else(|| HimitsuError::Index("alias_tag row missing alias_key".into()))?,
tag: value,
},
other => {
Expand Down
19 changes: 9 additions & 10 deletions rust/src/config/env_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,9 +676,7 @@ mod tests {
/// paths fall back to "no tags" rather than erroring, mirroring the
/// expected real-world behaviour for secrets that decrypt cleanly but
/// happen to carry no `SecretValue.tags` field.
fn mk_tag_lookup(
map: BTreeMap<String, Vec<String>>,
) -> impl Fn(&str) -> Result<Vec<String>> {
fn mk_tag_lookup(map: BTreeMap<String, Vec<String>>) -> impl Fn(&str) -> Result<Vec<String>> {
move |path: &str| Ok(map.get(path).cloned().unwrap_or_default())
}

Expand All @@ -687,18 +685,17 @@ mod tests {
// `tag:pci` should pull in every secret carrying the tag, keyed
// by last-path-segment. Non-pci secrets must be excluded.
let e = envs(vec![("dev", vec![EnvEntry::Tag("pci".into())])]);
let secrets = strs(&[
"dev/STRIPE_KEY",
"dev/POSTGRES_URL",
"extras/RATE_LIMITER",
]);
let secrets = strs(&["dev/STRIPE_KEY", "dev/POSTGRES_URL", "extras/RATE_LIMITER"]);
let mut tags = BTreeMap::new();
tags.insert("dev/STRIPE_KEY".to_string(), vec!["pci".to_string()]);
tags.insert(
"dev/POSTGRES_URL".to_string(),
vec!["pci".to_string(), "rotate".to_string()],
);
tags.insert("extras/RATE_LIMITER".to_string(), vec!["mobile".to_string()]);
tags.insert(
"extras/RATE_LIMITER".to_string(),
vec!["mobile".to_string()],
);
let lookup = mk_tag_lookup(tags);

let tree = resolve_with_tags(&e, "dev", &secrets, &lookup).unwrap();
Expand Down Expand Up @@ -809,7 +806,9 @@ mod tests {
let e = envs(vec![("dev", vec![EnvEntry::Tag("pci".into())])]);
let secrets = strs(&["dev/UNREADABLE"]);
let failing = |_: &str| -> Result<Vec<String>> {
Err(HimitsuError::DecryptionFailed("test: cannot decrypt".into()))
Err(HimitsuError::DecryptionFailed(
"test: cannot decrypt".into(),
))
};

let err = resolve_with_tags(&e, "dev", &secrets, &failing).unwrap_err();
Expand Down
3 changes: 1 addition & 2 deletions rust/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,7 @@ impl<'de> Deserialize<'de> for EnvEntry {
// Map form `{ tag: pci }` — the literal key is `tag` and the
// value is the tag name itself.
if key == "tag" {
crate::crypto::tags::validate_tag(&value)
.map_err(serde::de::Error::custom)?;
crate::crypto::tags::validate_tag(&value).map_err(serde::de::Error::custom)?;
return Ok(EnvEntry::Tag(value));
}
// Map form `{ STRIPE: tag:stripe }` — alias whose value is
Expand Down
1 change: 0 additions & 1 deletion rust/src/crypto/secret_value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,6 @@ mod tests {
assert!(!d.has_metadata());
}


#[test]
fn annotations_round_trip() {
let mut annotations = HashMap::new();
Expand Down
1 change: 1 addition & 0 deletions rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod keyring;
pub mod proto;
pub mod reference;
pub mod remote;
pub mod suggest;
pub mod tui;

use clap::Parser;
Expand Down
Loading
Loading