From d1a52b6b294d208fb7c2b5d51beba45886120d23 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Fri, 23 Oct 2026 00:55:34 -0500 Subject: [PATCH 1/4] test(hush): failing regressions for --value stdin reads and hyphen values to bws --- bws-tui/tests/bws_boundary.rs | 214 ++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 bws-tui/tests/bws_boundary.rs diff --git a/bws-tui/tests/bws_boundary.rs b/bws-tui/tests/bws_boundary.rs new file mode 100644 index 0000000..84a4f94 --- /dev/null +++ b/bws-tui/tests/bws_boundary.rs @@ -0,0 +1,214 @@ +//! Runs the built `hush` against a stub `bws` on PATH. The stub refuses a +//! hyphen-leading token before `--` unless it is a known option, and a split +//! `--opt value` whose value starts with `-`, as bws 2.1.0's parser does. + +use std::fs; +use std::io::Write; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process::{Child, Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +const PEM: &str = + "-----BEGIN HUSH TEST VALUE-----\nbm90IGEgc2VjcmV0\n-----END HUSH TEST VALUE-----\n"; + +const STUB: &str = r#"#!/bin/sh +printf '%s\0' "$@" >> "$HUSH_STUB_LOG" +after=0 +want=0 +for arg in "$@"; do + [ "$after" = 1 ] && continue + if [ "$want" = 1 ]; then + case "$arg" in -*) exit 2 ;; esac + want=0 + continue + fi + case "$arg" in + --) after=1 ;; + -o|--output|--note|--key|--value) want=1 ;; + --output=*|--note=*|--key=*|--value=*) ;; + -*) exit 2 ;; + esac +done +[ "$want" = 0 ] || exit 2 +secret='{"id":"s1","key":"HUSH_TEST_KEY","value":"old","note":"","projectId":"p1"}' +case " $* " in + *" project list "*) echo '[{"id":"p1","name":"proj"}]' ;; + *" secret list "*) echo "[$secret]" ;; + *" secret create "*|*" secret edit "*) echo "$secret" ;; + *) exit 3 ;; +esac +"#; + +struct Sandbox { + root: PathBuf, +} + +impl Sandbox { + fn new(name: &str) -> Self { + let root = std::env::temp_dir().join(format!("hush-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("bin")).unwrap(); + let stub = root.join("bin/bws"); + fs::write(&stub, STUB).unwrap(); + fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).unwrap(); + Self { root } + } + + fn log(&self) -> PathBuf { + self.root.join("bws.log") + } + + fn hush(&self, args: &[&str], stdin: Stdio) -> Child { + let path = format!( + "{}:{}", + self.root.join("bin").display(), + std::env::var("PATH").unwrap_or_default() + ); + Command::new(env!("CARGO_BIN_EXE_hush")) + .args(args) + .env("PATH", path) + .env("HOME", &self.root) + .env("XDG_STATE_HOME", self.root.join("state")) + .env("XDG_CACHE_HOME", self.root.join("cache")) + .env("HUSH_STUB_LOG", self.log()) + .stdin(stdin) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap() + } + + /// Every argument bws received, across all calls. + fn bws_args(&self) -> Vec { + let log = fs::read(self.log()).unwrap_or_default(); + String::from_utf8(log) + .unwrap() + .split('\0') + .map(str::to_owned) + .collect() + } +} + +impl Drop for Sandbox { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn finish_within(mut child: Child, limit: Duration) -> Output { + let start = Instant::now(); + while child.try_wait().unwrap().is_none() { + if start.elapsed() > limit { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("hush did not exit within {limit:?}"); + } + std::thread::sleep(Duration::from_millis(20)); + } + child.wait_with_output().unwrap() +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "hush failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn sent_whole(sandbox: &Sandbox, value: &str) -> bool { + sandbox + .bws_args() + .iter() + .any(|arg| arg == value || arg.strip_prefix("--value=") == Some(value)) +} + +#[test] +fn add_with_value_never_waits_on_an_open_stdin() { + let sandbox = Sandbox::new("add-open-stdin"); + let mut child = sandbox.hush( + &[ + "add", + "--key", + "HUSH_TEST_KEY", + "--project", + "proj", + "--value", + "v1", + ], + Stdio::piped(), + ); + let held_open = child.stdin.take(); + let output = finish_within(child, Duration::from_secs(10)); + drop(held_open); + assert_success(&output); + assert!(sent_whole(&sandbox, "v1"), "bws never got the --value"); +} + +#[test] +fn add_sends_a_pem_value_to_bws_intact() { + let sandbox = Sandbox::new("add-pem"); + let child = sandbox.hush( + &[ + "add", + "--key", + "HUSH_TEST_KEY", + "--project", + "proj", + "--value", + PEM, + ], + Stdio::null(), + ); + let output = finish_within(child, Duration::from_secs(10)); + assert_success(&output); + assert!(sent_whole(&sandbox, PEM), "bws did not get the PEM whole"); +} + +#[test] +fn edit_sends_a_piped_pem_value_to_bws_intact() { + let sandbox = Sandbox::new("edit-pem"); + let mut child = sandbox.hush( + &[ + "edit", + "--key", + "HUSH_TEST_KEY", + "--project", + "proj", + "--stdin", + ], + Stdio::piped(), + ); + child + .stdin + .take() + .unwrap() + .write_all(PEM.as_bytes()) + .unwrap(); + let output = finish_within(child, Duration::from_secs(10)); + assert_success(&output); + assert!(sent_whole(&sandbox, PEM), "bws did not get the PEM whole"); +} + +#[test] +fn hyphen_leading_key_and_note_reach_bws() { + let sandbox = Sandbox::new("edit-hyphen-fields"); + let child = sandbox.hush( + &[ + "edit", + "--key", + "HUSH_TEST_KEY", + "--project", + "proj", + "--new-key=-HUSH_TEST_RENAMED", + "--note=-- rotated", + ], + Stdio::null(), + ); + let output = finish_within(child, Duration::from_secs(10)); + assert_success(&output); + let args = sandbox.bws_args(); + assert!(args.iter().any(|a| a.ends_with("-HUSH_TEST_RENAMED"))); + assert!(args.iter().any(|a| a.ends_with("-- rotated"))); +} From 4294be3303fed1ae288eac0898ae0bb096dcd6a0 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Fri, 23 Oct 2026 00:58:19 -0500 Subject: [PATCH 2/4] fix(hush): never read stdin when --value is set, and pass hyphen values to bws intact add evaluated stdin_value() eagerly through Option::or, so --value still read stdin to EOF and hung on an open ssh connection. bws 2.1.0 parses a hyphen-leading positional or split --opt value as a flag and exits 2. create and edit now put options in --opt=value form and positionals after --, with -o json first. add and edit --value take hyphen-leading values. --- bws-tui/src/bws.rs | 28 +++++++++++++--------------- bws-tui/src/commands.rs | 11 ++++++----- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/bws-tui/src/bws.rs b/bws-tui/src/bws.rs index 8327e85..2895b94 100644 --- a/bws-tui/src/bws.rs +++ b/bws-tui/src/bws.rs @@ -28,8 +28,8 @@ fn operation_name(args: &[&str]) -> String { fn run(args: &[&str]) -> Result { let operation = operation_name(args); let out = Command::new("bws") - .args(args) .args(["-o", "json"]) + .args(args) .output() .context("failed to run `bws` — is it installed and on PATH?")?; if !out.status.success() { @@ -64,10 +64,11 @@ pub fn create_secret( ) -> Result { // ponytail: bws 2.1.0 accepts values only as argv — briefly visible in `ps`. // No stdin/file input exists upstream; nothing a wrapper can do. - let mut args = vec!["secret", "create", key, value.expose_secret(), project_id]; - if let Some(n) = note { - args.extend(["--note", n]); - } + // Values may start with `-`, so options take `=` and positionals follow `--`. + let note = note.map(|n| format!("--note={n}")); + let mut args = vec!["secret", "create"]; + args.extend(note.as_deref()); + args.extend(["--", key, value.expose_secret(), project_id]); Ok(serde_json::from_str(&run(&args)?)?) } @@ -77,17 +78,14 @@ pub fn edit_secret( value: Option<&SecretString>, note: Option<&str>, ) -> Result { + let key = key.map(|k| format!("--key={k}")); + let value = value.map(|v| SecretString::from(format!("--value={}", v.expose_secret()))); + let note = note.map(|n| format!("--note={n}")); let mut args = vec!["secret", "edit"]; - if let Some(k) = key { - args.extend(["--key", k]); - } - if let Some(v) = value { - args.extend(["--value", v.expose_secret()]); - } - if let Some(n) = note { - args.extend(["--note", n]); - } - args.push(id); + args.extend(key.as_deref()); + args.extend(value.as_ref().map(ExposeSecret::expose_secret)); + args.extend(note.as_deref()); + args.extend(["--", id]); Ok(serde_json::from_str(&run(&args)?)?) } diff --git a/bws-tui/src/commands.rs b/bws-tui/src/commands.rs index 93dedc4..e259525 100644 --- a/bws-tui/src/commands.rs +++ b/bws-tui/src/commands.rs @@ -13,7 +13,7 @@ pub(crate) enum Cmd { /// Project name or UUID. Prompts interactively if omitted and a TTY is present. #[arg(long)] project: Option, - #[arg(long)] + #[arg(long, allow_hyphen_values = true)] value: Option, #[arg(long)] note: Option, @@ -54,7 +54,7 @@ pub(crate) enum Cmd { project: Option, #[arg(long)] new_key: Option, - #[arg(long)] + #[arg(long, allow_hyphen_values = true)] value: Option, /// Read the new value from stdin. Explicit so note/key-only edits never block. #[arg(long, conflicts_with = "value")] @@ -94,9 +94,10 @@ fn add( Some(query) => bws::resolve_project(&projects, query)?.id.clone(), None => pick_project_interactive(&projects)?, }; - let value = value - .or(stdin_value()?) - .context("no --value given and nothing piped on stdin")?; + let value = match value { + Some(value) => value, + None => stdin_value()?.context("no --value given and nothing piped on stdin")?, + }; audit::record("add", &[&key])?; let created = bws::create_secret( &key, From d60056d642ba21d759f0513cecd81777c69ae88a Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Fri, 23 Oct 2026 01:10:10 -0500 Subject: [PATCH 3/4] fix(hush): keep --value from swallowing the next flag, and harden boundary tests allow_hyphen_values let --value take a following flag as the secret, so --value --stdin stored the text --stdin. Hyphen values go in as --value=... or on stdin, which already parsed. The boundary tests now assert the exact bws argv and never pass a real token to bws. --- bws-tui/src/bws.rs | 2 +- bws-tui/src/commands.rs | 4 +- bws-tui/tests/bws_boundary.rs | 114 ++++++++++++++++++++++++++-------- 3 files changed, 91 insertions(+), 29 deletions(-) diff --git a/bws-tui/src/bws.rs b/bws-tui/src/bws.rs index 2895b94..5e8ffe3 100644 --- a/bws-tui/src/bws.rs +++ b/bws-tui/src/bws.rs @@ -141,7 +141,7 @@ mod tests { "secret create" ); assert_eq!( - operation_name(&["secret", "edit", "--value", "super-secret", "secret-id"]), + operation_name(&["secret", "edit", "--value=super-secret", "--", "secret-id"]), "secret edit" ); } diff --git a/bws-tui/src/commands.rs b/bws-tui/src/commands.rs index e259525..29ef923 100644 --- a/bws-tui/src/commands.rs +++ b/bws-tui/src/commands.rs @@ -13,7 +13,7 @@ pub(crate) enum Cmd { /// Project name or UUID. Prompts interactively if omitted and a TTY is present. #[arg(long)] project: Option, - #[arg(long, allow_hyphen_values = true)] + #[arg(long)] value: Option, #[arg(long)] note: Option, @@ -54,7 +54,7 @@ pub(crate) enum Cmd { project: Option, #[arg(long)] new_key: Option, - #[arg(long, allow_hyphen_values = true)] + #[arg(long)] value: Option, /// Read the new value from stdin. Explicit so note/key-only edits never block. #[arg(long, conflicts_with = "value")] diff --git a/bws-tui/tests/bws_boundary.rs b/bws-tui/tests/bws_boundary.rs index 84a4f94..939feda 100644 --- a/bws-tui/tests/bws_boundary.rs +++ b/bws-tui/tests/bws_boundary.rs @@ -11,9 +11,10 @@ use std::time::{Duration, Instant}; const PEM: &str = "-----BEGIN HUSH TEST VALUE-----\nbm90IGEgc2VjcmV0\n-----END HUSH TEST VALUE-----\n"; +const CALL_END: &str = "@@end-of-call@@"; const STUB: &str = r#"#!/bin/sh -printf '%s\0' "$@" >> "$HUSH_STUB_LOG" +printf '%s\0' "$@" '@@end-of-call@@' >> "$HUSH_STUB_LOG" after=0 want=0 for arg in "$@"; do @@ -59,6 +60,7 @@ impl Sandbox { self.root.join("bws.log") } + /// A real token in the environment must never reach a bws that shadows the stub. fn hush(&self, args: &[&str], stdin: Stdio) -> Child { let path = format!( "{}:{}", @@ -67,6 +69,8 @@ impl Sandbox { ); Command::new(env!("CARGO_BIN_EXE_hush")) .args(args) + .env_remove("BWS_ACCESS_TOKEN") + .env_remove("BWS_SERVER_URL") .env("PATH", path) .env("HOME", &self.root) .env("XDG_STATE_HOME", self.root.join("state")) @@ -79,14 +83,16 @@ impl Sandbox { .unwrap() } - /// Every argument bws received, across all calls. - fn bws_args(&self) -> Vec { - let log = fs::read(self.log()).unwrap_or_default(); - String::from_utf8(log) - .unwrap() - .split('\0') - .map(str::to_owned) - .collect() + /// The argv of the one `bws secret ` call, if hush made it. + fn secret_call(&self, subcommand: &str) -> Option> { + let log = String::from_utf8(fs::read(self.log()).unwrap_or_default()).unwrap(); + let args: Vec = log.split('\0').map(str::to_owned).collect(); + args.split(|arg| arg == CALL_END) + .find(|call| { + call.windows(2) + .any(|pair| pair[0] == "secret" && pair[1] == subcommand) + }) + .map(<[String]>::to_vec) } } @@ -117,11 +123,8 @@ fn assert_success(output: &Output) { ); } -fn sent_whole(sandbox: &Sandbox, value: &str) -> bool { - sandbox - .bws_args() - .iter() - .any(|arg| arg == value || arg.strip_prefix("--value=") == Some(value)) +fn argv(parts: &[&str]) -> Option> { + Some(parts.iter().map(|part| part.to_string()).collect()) } #[test] @@ -143,27 +146,52 @@ fn add_with_value_never_waits_on_an_open_stdin() { let output = finish_within(child, Duration::from_secs(10)); drop(held_open); assert_success(&output); - assert!(sent_whole(&sandbox, "v1"), "bws never got the --value"); + assert_eq!( + sandbox.secret_call("create"), + argv(&[ + "-o", + "json", + "secret", + "create", + "--", + "HUSH_TEST_KEY", + "v1", + "p1" + ]) + ); } #[test] -fn add_sends_a_pem_value_to_bws_intact() { +fn add_sends_hyphen_leading_key_value_and_note_to_bws_intact() { let sandbox = Sandbox::new("add-pem"); + let value = format!("--value={PEM}"); let child = sandbox.hush( &[ "add", - "--key", - "HUSH_TEST_KEY", + "--key=-HUSH_TEST_KEY", "--project", "proj", - "--value", - PEM, + &value, + "--note=-n", ], Stdio::null(), ); let output = finish_within(child, Duration::from_secs(10)); assert_success(&output); - assert!(sent_whole(&sandbox, PEM), "bws did not get the PEM whole"); + assert_eq!( + sandbox.secret_call("create"), + argv(&[ + "-o", + "json", + "secret", + "create", + "--note=-n", + "--", + "-HUSH_TEST_KEY", + PEM, + "p1" + ]) + ); } #[test] @@ -188,11 +216,15 @@ fn edit_sends_a_piped_pem_value_to_bws_intact() { .unwrap(); let output = finish_within(child, Duration::from_secs(10)); assert_success(&output); - assert!(sent_whole(&sandbox, PEM), "bws did not get the PEM whole"); + let value = format!("--value={PEM}"); + assert_eq!( + sandbox.secret_call("edit"), + argv(&["-o", "json", "secret", "edit", &value, "--", "s1"]) + ); } #[test] -fn hyphen_leading_key_and_note_reach_bws() { +fn edit_sends_hyphen_leading_key_and_note_to_bws_intact() { let sandbox = Sandbox::new("edit-hyphen-fields"); let child = sandbox.hush( &[ @@ -208,7 +240,37 @@ fn hyphen_leading_key_and_note_reach_bws() { ); let output = finish_within(child, Duration::from_secs(10)); assert_success(&output); - let args = sandbox.bws_args(); - assert!(args.iter().any(|a| a.ends_with("-HUSH_TEST_RENAMED"))); - assert!(args.iter().any(|a| a.ends_with("-- rotated"))); + assert_eq!( + sandbox.secret_call("edit"), + argv(&[ + "-o", + "json", + "secret", + "edit", + "--key=-HUSH_TEST_RENAMED", + "--note=-- rotated", + "--", + "s1" + ]) + ); +} + +#[test] +fn value_never_takes_the_next_flag_as_the_secret() { + let sandbox = Sandbox::new("edit-value-swallow"); + let child = sandbox.hush( + &[ + "edit", + "--key", + "HUSH_TEST_KEY", + "--project", + "proj", + "--value", + "--stdin", + ], + Stdio::null(), + ); + let output = finish_within(child, Duration::from_secs(10)); + assert!(!output.status.success(), "hush accepted --value --stdin"); + assert_eq!(sandbox.secret_call("edit"), None); } From 89ced3eab94b82005467506cb150306377aaf709 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sun, 25 Oct 2026 19:02:48 -0500 Subject: [PATCH 4/4] test(hush): pin piped stdin as the add value when --value is absent --- bws-tui/tests/bws_boundary.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/bws-tui/tests/bws_boundary.rs b/bws-tui/tests/bws_boundary.rs index 939feda..337e7dd 100644 --- a/bws-tui/tests/bws_boundary.rs +++ b/bws-tui/tests/bws_boundary.rs @@ -161,6 +161,36 @@ fn add_with_value_never_waits_on_an_open_stdin() { ); } +#[test] +fn add_without_value_reads_piped_stdin() { + let sandbox = Sandbox::new("add-piped"); + let mut child = sandbox.hush( + &["add", "--key", "HUSH_TEST_KEY", "--project", "proj"], + Stdio::piped(), + ); + child + .stdin + .take() + .unwrap() + .write_all(PEM.as_bytes()) + .unwrap(); + let output = finish_within(child, Duration::from_secs(10)); + assert_success(&output); + assert_eq!( + sandbox.secret_call("create"), + argv(&[ + "-o", + "json", + "secret", + "create", + "--", + "HUSH_TEST_KEY", + PEM, + "p1" + ]) + ); +} + #[test] fn add_sends_hyphen_leading_key_value_and_note_to_bws_intact() { let sandbox = Sandbox::new("add-pem");