Compare commits

...
3 Commits
Author SHA1 Message Date
ges 31b0156f6c fix: updated outdated ci dependency 2026-10-13 01:05:48 +02:00
ges abf205bd93 feat: add missing tests 2026-10-13 01:03:46 +02:00
ges 8c6ec3a7f4 fix: branch name 2026-10-13 00:32:43 +02:00
12 changed files with 650 additions and 34 deletions

No files matched your search

+5 -5
View File
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [main]
branches: [master]
pull_request:
branches: [main]
branches: [master]
env:
CARGO_TERM_COLOR: always
@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-action@stable
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo registry
uses: actions/cache@v4
@@ -38,7 +38,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-action@stable
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo registry
uses: actions/cache@v4
with:
@@ -58,7 +58,7 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-action@stable
uses: dtolnay/rust-toolchain@stable
- name: Check formatting
run: cargo fmt -- --check
+1
View File
@@ -1 +1,2 @@
/target
/.direnv
Generated
+69 -3
View File
@@ -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"
+3
View File
@@ -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"
+64 -17
View File
@@ -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<String> {
pub async fn fetch(&self, entry: &PunlockConfigurationEntry) -> anyhow::Result<String> {
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<String> {
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<String> {
Ok(())
}
}
pub(crate) fn extract_secret(data: &serde_json::Value, query: &str) -> anyhow::Result<String> {
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());
}
}
+125 -1
View File
@@ -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<Vec<String>>,
@@ -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 = "[email protected]"
[[entries]]
id = "11111111-2222-3333-4444-555555555555"
query = "details.fields[[email protected]=='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("[email protected]"));
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::<PartialPunlockConfiguration>("this is not = = toml").is_err());
}
#[test]
fn conversion_defaults_version_and_entries() {
let partial = PartialPunlockConfiguration {
domain: None,
version: None,
email: Some("[email protected]".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(), "[email protected]");
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("[email protected]".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::<PartialPunlockConfiguration>(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());
}
}
+49 -1
View File
@@ -29,8 +29,10 @@ impl Email {
}
}
}
}
pub fn as_ref(&self) -> &str {
impl AsRef<str> 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 [
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
] {
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 [email protected]",
"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("[email protected]").unwrap();
assert_eq!(email.to_string(), "[email protected]");
let as_string: String = email.into();
assert_eq!(as_string, "[email protected]");
}
}
+110
View File
@@ -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<str> for UuidV4 {
fn as_ref(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for UuidV4 {
type Error = anyhow::Error;
fn try_from(value: String) -> anyhow::Result<Self> {
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> {
Self::try_from(value.to_string())
}
}
impl From<UuidV4> 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::<UuidV4>("\"not-a-uuid\"").is_err());
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod bitwarden;
pub mod config;
pub mod email;
pub mod item_id;
pub mod statics;
pub mod store;
+4
View File
@@ -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();
}
+53 -7
View File
@@ -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")
);
}
}
+166
View File
@@ -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<String> {
Bitwarden::<String> {
email: Email::try_from("[email protected]").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)"
);
}