diff --git a/.gitignore b/.gitignore index ea8c4bf..6abfe1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/.direnv diff --git a/Cargo.lock b/Cargo.lock index 755278a..beab7af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -225,6 +225,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "futures" version = "0.3.31" @@ -325,6 +341,17 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "gimli" version = "0.31.1" @@ -385,9 +412,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.172" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libredox" @@ -399,6 +426,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.12" @@ -541,6 +574,7 @@ dependencies = [ "rpassword", "serde", "serde_json", + "tempfile", "tokio", "toml", "tracing", @@ -557,6 +591,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_syscall" version = "0.5.11" @@ -572,7 +612,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ - "getrandom", + "getrandom 0.2.16", "libredox", "thiserror 2.0.12", ] @@ -633,6 +673,19 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rustversion" version = "1.0.20" @@ -762,6 +815,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index 1577a1b..ab97d25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,3 +26,6 @@ toml = "0.8.20" tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } users = "0.11.0" + +[dev-dependencies] +tempfile = "3.20.0" diff --git a/src/bitwarden.rs b/src/bitwarden.rs index 1df1d21..3192835 100644 --- a/src/bitwarden.rs +++ b/src/bitwarden.rs @@ -26,7 +26,7 @@ impl Bitwarden<()> { .ok(); if let Some(ref d) = domain { Command::new("bw") - .args(["config", "server", &d]) + .args(["config", "server", d]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() @@ -77,7 +77,7 @@ impl Bitwarden<()> { impl Bitwarden { pub async fn fetch(&self, entry: &PunlockConfigurationEntry) -> anyhow::Result { let bw = Command::new("bw") - .args(["get", "item", &entry.id, "--session", &self.session]) + .args(["get", "item", entry.id.as_ref(), "--session", &self.session]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() @@ -96,21 +96,7 @@ impl Bitwarden { let data: serde_json::Value = serde_json::from_slice(&output.stdout) .inspect_err(|error| tracing::error!(?error, json = ?output.stdout, "invalid json"))?; - let expr = jmespath::compile(&entry.query) - .inspect_err(|error| tracing::error!(?error, ?entry, "input/expression mismatch"))?; - let result = expr - .search(&data) - .inspect_err(|error| tracing::error!(?error, ?expr, "query failed to apply"))?; - - let secret = match result.as_string() { - Some(s) => s.to_owned(), - None => { - tracing::error!(?data, ?expr, "find secret"); - anyhow::bail!("invalid item") - } - }; - - Ok(secret) + extract_secret(&data, &entry.query) } pub async fn logout(&self) -> anyhow::Result<()> { @@ -125,3 +111,64 @@ impl Bitwarden { Ok(()) } } + +pub(crate) fn extract_secret(data: &serde_json::Value, query: &str) -> anyhow::Result { + let expr = jmespath::compile(query) + .inspect_err(|error| tracing::error!(?error, ?query, "input/expression mismatch"))?; + let result = expr + .search(data) + .inspect_err(|error| tracing::error!(?error, ?expr, "query failed to apply"))?; + + match result.as_string() { + Some(s) => Ok(s.to_owned()), + None => { + tracing::error!(?data, ?expr, "find secret"); + anyhow::bail!("invalid item") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn extracts_a_string_value() { + let item = json!({ "login": { "username": "u", "password": "s3cr3t" } }); + let secret = extract_secret(&item, "login.password").expect("should extract password"); + assert_eq!(secret, "s3cr3t"); + } + + #[test] + fn extracts_custom_field_via_filter_and_index() { + let item = json!({ + "fields": [ + { "name": "other", "value": "ignored" }, + { "name": "ssh_private_key", "value": "-----BEGIN KEY-----" } + ] + }); + let query = "fields[?name=='ssh_private_key'].value | [0]"; + let secret = extract_secret(&item, query).expect("should extract custom field"); + assert_eq!(secret, "-----BEGIN KEY-----"); + } + + #[test] + fn rejects_query_that_yields_a_non_string() { + let item = json!({ "fields": [{ "name": "ssh_private_key", "value": "k" }] }); + // a filter projection yields an array, not a string + assert!(extract_secret(&item, "fields[?name=='ssh_private_key'].value").is_err()); + } + + #[test] + fn rejects_missing_key() { + let item = json!({ "login": {} }); + assert!(extract_secret(&item, "login.password").is_err()); + } + + #[test] + fn rejects_invalid_jmespath_expression() { + let item = json!({}); + assert!(extract_secret(&item, "fields[?name==").is_err()); + } +} diff --git a/src/config.rs b/src/config.rs index 9dfb65d..fcf58de 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,12 +6,13 @@ use tokio::{fs::File, io::AsyncWriteExt}; use crate::{ email::Email, + item_id::UuidV4, statics::{LATEST_CONFIGURATION_VERSION, SYSTEM_CONFIG_PATH_CANDIDATES}, }; #[derive(Deserialize, Serialize, Debug)] pub struct PunlockConfigurationEntry { - pub id: String, + pub id: UuidV4, pub query: String, pub path: String, pub links: Option>, @@ -105,3 +106,126 @@ impl PunlockConfiguration { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_ID: &str = "11111111-2222-3333-4444-555555555555"; + + #[test] + fn partial_config_deserializes_full_document() { + let toml = r#" +domain = "https://bw.example.com" +version = "0.1.0" +email = "user@example.com" + +[[entries]] +id = "11111111-2222-3333-4444-555555555555" +query = "details.fields[?@.name=='ssh_private_key'].value" +path = "ssh/id_rsa" +public = true +links = ["~/.ssh/id_rsa"] +"#; + let cfg: PartialPunlockConfiguration = + toml::from_str(toml).expect("valid configuration should deserialize"); + + assert_eq!(cfg.domain.as_deref(), Some("https://bw.example.com")); + assert_eq!(cfg.version.as_deref(), Some("0.1.0")); + assert_eq!(cfg.email.as_deref(), Some("user@example.com")); + + let entries = cfg.entries.expect("entries should be present"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id.as_ref(), VALID_ID); + assert_eq!(entries[0].path, "ssh/id_rsa"); + assert!(entries[0].public); + assert_eq!(entries[0].links, Some(vec!["~/.ssh/id_rsa".to_string()])); + } + + #[test] + fn partial_config_applies_field_defaults() { + let toml = r#" +[[entries]] +id = "11111111-2222-3333-4444-555555555555" +query = "details.login.password" +path = "api/token" +"#; + let cfg: PartialPunlockConfiguration = + toml::from_str(toml).expect("minimal configuration should deserialize"); + + assert!(cfg.domain.is_none()); + assert!(cfg.version.is_none()); + assert!(cfg.email.is_none()); + + let entries = cfg.entries.expect("entries should be present"); + assert_eq!(entries.len(), 1); + assert!(!entries[0].public, "public should default to false"); + assert!(entries[0].links.is_none(), "links should default to none"); + } + + #[test] + fn partial_config_rejects_invalid_toml() { + assert!(toml::from_str::("this is not = = toml").is_err()); + } + + #[test] + fn conversion_defaults_version_and_entries() { + let partial = PartialPunlockConfiguration { + domain: None, + version: None, + email: Some("user@example.com".to_string()), + entries: None, + }; + let cfg: PunlockConfiguration = partial.try_into().expect("valid email should convert"); + + assert_eq!(cfg.version, *LATEST_CONFIGURATION_VERSION); + assert_eq!(cfg.email.as_ref(), "user@example.com"); + assert!(cfg.domain.is_none()); + assert!(cfg.entries.is_empty()); + } + + #[test] + fn conversion_preserves_provided_version_and_entries() { + let entry = PunlockConfigurationEntry { + id: UuidV4::try_from(VALID_ID).unwrap(), + query: "details.login.password".to_string(), + path: "api/token".to_string(), + links: None, + public: false, + }; + let partial = PartialPunlockConfiguration { + domain: Some("https://bw.example.com".to_string()), + version: Some("0.1.0".to_string()), + email: Some("user@example.com".to_string()), + entries: Some(vec![entry]), + }; + let cfg: PunlockConfiguration = partial.try_into().expect("valid config should convert"); + + assert_eq!(cfg.version, "0.1.0"); + assert_eq!(cfg.domain.as_deref(), Some("https://bw.example.com")); + assert_eq!(cfg.entries.len(), 1); + assert_eq!(cfg.entries[0].id.as_ref(), VALID_ID); + } + + #[test] + fn partial_config_rejects_invalid_item_id() { + let toml = r#" +[[entries]] +id = "not-a-uuid" +query = "details.login.password" +path = "api/token" +"#; + assert!(toml::from_str::(toml).is_err()); + } + + #[test] + fn conversion_rejects_invalid_email() { + let partial = PartialPunlockConfiguration { + domain: None, + version: Some("0.1.0".to_string()), + email: Some("not-an-email".to_string()), + entries: Some(vec![]), + }; + assert!(PunlockConfiguration::try_from(partial).is_err()); + } +} diff --git a/src/email.rs b/src/email.rs index ce38c66..2eaf6b3 100644 --- a/src/email.rs +++ b/src/email.rs @@ -29,8 +29,10 @@ impl Email { } } } +} - pub fn as_ref(&self) -> &str { +impl AsRef for Email { + fn as_ref(&self) -> &str { &self.0 } } @@ -58,3 +60,49 @@ impl std::fmt::Display for Email { write!(f, "{}", self.0) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid_emails() { + for case in [ + "user@example.com", + "first.last@sub.domain.org", + "a@b.co", + "name+tag@example.io", + ] { + let email = Email::try_from(case).expect("should accept valid email"); + assert_eq!(email.as_ref(), case); + } + } + + #[test] + fn rejects_invalid_emails() { + for case in [ + "", + "plainaddress", + "@missing-local.org", + "missing-at-sign.org", + "no-tld@domain", + "space in@local.com", + "user@domain@", + "user @domain.com", + ] { + assert!( + Email::try_from(case).is_err(), + "should reject invalid email: {:?}", + case + ); + } + } + + #[test] + fn display_and_into_string_round_trip() { + let email = Email::try_from("user@example.com").unwrap(); + assert_eq!(email.to_string(), "user@example.com"); + let as_string: String = email.into(); + assert_eq!(as_string, "user@example.com"); + } +} diff --git a/src/item_id.rs b/src/item_id.rs new file mode 100644 index 0000000..7e26f30 --- /dev/null +++ b/src/item_id.rs @@ -0,0 +1,110 @@ +use std::convert::TryFrom; + +use serde::{Deserialize, Serialize}; + +use crate::statics::UUID_V4_REGEX; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "String")] +pub struct UuidV4(String); + +impl UuidV4 { + fn is_valid(value: &str) -> bool { + UUID_V4_REGEX.is_match(value) + } +} + +impl AsRef for UuidV4 { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl TryFrom for UuidV4 { + type Error = anyhow::Error; + + fn try_from(value: String) -> anyhow::Result { + if Self::is_valid(&value) { + Ok(Self(value)) + } else { + anyhow::bail!("invalid item id {}", value) + } + } +} + +impl TryFrom<&str> for UuidV4 { + type Error = anyhow::Error; + + fn try_from(value: &str) -> anyhow::Result { + Self::try_from(value.to_string()) + } +} + +impl From for String { + fn from(val: UuidV4) -> Self { + val.0 + } +} + +impl std::fmt::Display for UuidV4 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid_uuids() { + for case in [ + "11111111-2222-3333-4444-555555555555", + "2f1e4b6a-9c3d-4a7e-8b2f-1c5d6e7f8a90", + "00000000-0000-0000-0000-000000000000", + "ABCDEF01-2345-6789-ABCD-EF0123456789", + ] { + let id = UuidV4::try_from(case).expect("should accept valid uuid"); + assert_eq!(id.as_ref(), case); + } + } + + #[test] + fn rejects_invalid_uuids() { + for case in [ + "", + "item-uuid", + "11111111222233334444555555555555", + "11111111-2222-3333-4444-55555555555", + "11111111-2222-3333-4444-5555555555555", + "11111111-2222-3333-4444-55555555555g", + "11111111 2222 3333 4444 555555555555", + "z1111111-2222-3333-4444-555555555555", + ] { + assert!( + UuidV4::try_from(case).is_err(), + "should reject invalid uuid: {:?}", + case + ); + } + } + + #[test] + fn display_and_into_string_round_trip() { + let id = UuidV4::try_from("11111111-2222-3333-4444-555555555555").unwrap(); + assert_eq!(id.to_string(), "11111111-2222-3333-4444-555555555555"); + let as_string: String = id.into(); + assert_eq!(as_string, "11111111-2222-3333-4444-555555555555"); + } + + #[test] + fn deserializes_valid_uuid_from_string() { + let id: UuidV4 = serde_json::from_str("\"11111111-2222-3333-4444-555555555555\"").unwrap(); + assert_eq!(id.as_ref(), "11111111-2222-3333-4444-555555555555"); + } + + #[test] + fn deserialization_rejects_invalid_uuid() { + assert!(serde_json::from_str::("\"not-a-uuid\"").is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 4b9abe2..bcdea96 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod bitwarden; pub mod config; pub mod email; +pub mod item_id; pub mod statics; pub mod store; diff --git a/src/statics.rs b/src/statics.rs index 75d8006..d6b90be 100644 --- a/src/statics.rs +++ b/src/statics.rs @@ -19,4 +19,8 @@ lazy_static! { PathBuf::from("/etc/punlock/").join("CONFIG_FILE_NAME")] .to_vec(); pub static ref EMAIL_REGEX: Regex = Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap(); + pub static ref UUID_V4_REGEX: Regex = Regex::new( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + ) + .unwrap(); } diff --git a/src/store.rs b/src/store.rs index fe54042..8228156 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,6 +1,6 @@ use std::{ os::unix::fs::PermissionsExt, - path::PathBuf, + path::{Path, PathBuf}, process::{Command, Stdio}, sync::Arc, }; @@ -97,11 +97,7 @@ impl UnixSecretStore { if let Some(ref links) = entry.links { for link in links { - let link_path: PathBuf = if PathBuf::from(link).is_absolute() { - PathBuf::from(link) - } else { - HOME_DIRECTORY.join(link) - }; + let link_path = resolve_link_path(link, &HOME_DIRECTORY); tokio::fs::create_dir_all(link_path.parent().unwrap_or(&link_path)) .await?; match tokio::fs::symlink_metadata(&link_path).await { @@ -129,7 +125,7 @@ impl UnixSecretStore { }) .inspect_err(|error| tracing::error!(src = ?path, dst = ?link_path, ?error, "symlink failed")) .await - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))??; + .map_err(std::io::Error::other)??; tracing::info!(src =? path, destination = ?link_path, "created/updated symlink"); } @@ -179,3 +175,53 @@ impl UnixSecretStore { Ok(self) } } + +fn resolve_link_path(link: &str, home: &Path) -> PathBuf { + let path = PathBuf::from(link); + if path.is_absolute() { + return path; + } + if link == "~" { + return home.to_path_buf(); + } + if let Some(rest) = link.strip_prefix("~/") { + return home.join(rest); + } + home.join(link) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn absolute_link_paths_are_left_untouched() { + let home = Path::new("/home/testuser"); + assert_eq!( + resolve_link_path("/etc/punlock/key", home), + PathBuf::from("/etc/punlock/key") + ); + } + + #[test] + fn tilde_is_expanded_to_home() { + let home = Path::new("/home/testuser"); + assert_eq!( + resolve_link_path("~", home), + PathBuf::from("/home/testuser") + ); + assert_eq!( + resolve_link_path("~/.ssh/id_rsa", home), + PathBuf::from("/home/testuser/.ssh/id_rsa") + ); + } + + #[test] + fn relative_link_paths_are_joined_onto_home() { + let home = Path::new("/home/testuser"); + assert_eq!( + resolve_link_path("sub/key", home), + PathBuf::from("/home/testuser/sub/key") + ); + } +} diff --git a/tests/io.rs b/tests/io.rs new file mode 100644 index 0000000..8299ecf --- /dev/null +++ b/tests/io.rs @@ -0,0 +1,166 @@ +use std::{ + convert::TryFrom, + ffi::OsString, + fs, + os::unix::fs::PermissionsExt, + sync::{Mutex, MutexGuard}, +}; + +use punlock::{ + bitwarden::Bitwarden, config::PunlockConfigurationEntry, email::Email, item_id::UuidV4, + store::UnixSecretStore, +}; + +static PATH_LOCK: Mutex<()> = Mutex::new(()); + +struct FakeBw { + _dir: tempfile::TempDir, + _lock: MutexGuard<'static, ()>, + old_path: OsString, +} + +impl FakeBw { + fn new(script: &str) -> Self { + let lock = PATH_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let dir = tempfile::tempdir().expect("tempdir"); + let bw = dir.path().join("bw"); + fs::write(&bw, format!("#!/usr/bin/env bash\n{script}\n")).expect("write fake bw"); + fs::set_permissions(&bw, fs::Permissions::from_mode(0o755)).expect("chmod fake bw"); + + let old_path = std::env::var_os("PATH").unwrap_or_default(); + std::env::set_var( + "PATH", + format!("{}:{}", dir.path().display(), old_path.to_string_lossy()), + ); + + Self { + _dir: dir, + _lock: lock, + old_path, + } + } +} + +impl Drop for FakeBw { + fn drop(&mut self) { + std::env::set_var("PATH", &self.old_path); + } +} + +fn bitwarden() -> Bitwarden { + Bitwarden:: { + email: Email::try_from("user@example.com").expect("valid email"), + session: "fake-session".to_string(), + } +} + +fn entry(id: &str, path: &str, public: bool) -> PunlockConfigurationEntry { + PunlockConfigurationEntry { + id: UuidV4::try_from(id).expect("valid uuid"), + query: "login.password".to_string(), + path: path.to_string(), + links: None, + public, + } +} + +#[tokio::test] +async fn fetch_returns_secret_from_fake_bw() { + let _fake = FakeBw::new( + r#"case "$1" in + get) printf '%s' '{"login":{"password":"s3cr3t"}}' ;; +esac"#, + ); + + let secret = bitwarden() + .fetch(&entry( + "11111111-1111-4111-8111-111111111111", + "x/token", + false, + )) + .await + .expect("fetch should succeed"); + + assert_eq!(secret, "s3cr3t"); +} + +#[tokio::test] +async fn fetch_errors_when_bw_fails() { + let _fake = FakeBw::new( + r#"case "$1" in + get) echo "item not found" >&2; exit 1 ;; +esac"#, + ); + + let result = bitwarden() + .fetch(&entry( + "11111111-1111-4111-8111-111111111111", + "x/token", + false, + )) + .await; + + assert!(result.is_err(), "fetch should fail when bw exits non-zero"); +} + +#[tokio::test] +async fn logout_succeeds_against_fake_bw() { + let _fake = FakeBw::new("exit 0"); + bitwarden().logout().await.expect("logout should succeed"); +} + +#[tokio::test] +async fn write_secrets_writes_files_and_skips_failed_items() { + let _fake = FakeBw::new( + r#"case "$1" in + sync) exit 0 ;; + get) + case "$3" in + 11111111-1111-4111-8111-111111111111) printf '%s' '{"login":{"password":"alpha"}}' ;; + 22222222-2222-4222-8222-222222222222) printf '%s' '{"login":{"password":"beta"}}' ;; + 33333333-3333-4333-8333-333333333333) echo "missing item" >&2; exit 1 ;; + *) printf '%s' '{}' ;; + esac ;; +esac"#, + ); + + let root = tempfile::tempdir().expect("tempdir"); + let store = UnixSecretStore::new(bitwarden(), root.path().to_path_buf()); + + let entries = vec![ + entry("11111111-1111-4111-8111-111111111111", "a/token", false), + entry("22222222-2222-4222-8222-222222222222", "b/token", true), + entry("33333333-3333-4333-8333-333333333333", "c/token", false), + ]; + + store + .write_secrets(&entries) + .await + .expect("write_secrets should not fail on a single bad item"); + + let private = root.path().join("a/token"); + let public = root.path().join("b/token"); + let missing = root.path().join("c/token"); + + assert_eq!( + fs::read_to_string(&private).expect("private written"), + "alpha\n" + ); + assert_eq!( + fs::read_to_string(&public).expect("public written"), + "beta\n" + ); + assert!(!missing.exists(), "failed item should not be written"); + + let private_mode = fs::metadata(&private).expect("meta").permissions().mode(); + assert_eq!(private_mode & 0o777, 0o400, "private secret should be 0400"); + + let public_mode = fs::metadata(&public).expect("meta").permissions().mode(); + assert_eq!( + public_mode & 0o222, + 0, + "public secret should still be read-only (no write bits)" + ); +}