diff --git a/CHANGELOG.md b/CHANGELOG.md index 40d347f..b217331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 subdirectory previously failed to find the referenced `.env` file because it was looked up relative to the working directory rather than the project root (#59). Absolute `dotenv` paths are unaffected. +- The `protonpass` provider now works with Proton Pass CLI `pass-cli >= 2.0.3`. + The `item list --output json` payload changed shape in 2.0.3 (the item title + moved from a nested `content.title` to a top-level `title`, and `content` was + dropped from list output), which made `secretspec` report active secrets as + missing. Both the old (`<= 2.0.2`) and new (`>= 2.0.3`) list shapes are now + accepted. ([#104](https://github.com/cachix/secretspec/issues/104)) ## [0.12.0] - 2026-06-08 diff --git a/secretspec/src/provider/protonpass.rs b/secretspec/src/provider/protonpass.rs index 127f2f1..78f393c 100644 --- a/secretspec/src/provider/protonpass.rs +++ b/secretspec/src/provider/protonpass.rs @@ -28,7 +28,8 @@ const DEFAULT_AGENT_REASON: &str = concat!( // // or: // $ pass-cli item list --output json -// {"items": [{"id": "...", "share_id": "...", "content": {"title": "...", "note": "..."}}]} +// pass-cli <= 2.0.2: {"items": [{"id": "...", "share_id": "...", "content": {"title": "..."}}]} +// pass-cli >= 2.0.3: {"items": [{"id": "...", "share_id": "...", "title": "...", "item_type": "note"}]} // // We only use a limited subset of the full data. @@ -40,8 +41,6 @@ struct ProtonPassItemContent { #[derive(Deserialize)] struct ProtonPassItemData { - id: String, - share_id: String, content: ProtonPassItemContent, } @@ -50,9 +49,37 @@ struct ProtonPassViewResponse { item: ProtonPassItemData, } +/// A single entry from `pass-cli item list ... --output json`. +/// +/// The list payload changed shape in pass-cli 2.0.3 (protonpass/pass-cli commit +/// 1c09fd8): the title moved from a nested `content.title` to a top-level +/// `title`, and the per-item `content` object was dropped entirely from list +/// output (it no longer carries any secret material). `id`/`share_id` remain +/// top-level in both shapes, and only those plus the title are used here, so we +/// accept either layout and keep working across pass-cli versions. +#[derive(Deserialize)] +struct ProtonPassListItem { + id: String, + share_id: String, + /// Top-level title (pass-cli >= 2.0.3). + title: Option, + /// Legacy nested content carrying the title (pass-cli <= 2.0.2). + content: Option, +} + +impl ProtonPassListItem { + /// The item title regardless of pass-cli version, preferring the top-level + /// field and falling back to the legacy nested `content.title`. + fn title(&self) -> Option<&str> { + self.title + .as_deref() + .or_else(|| self.content.as_ref().map(|c| c.title.as_str())) + } +} + #[derive(Deserialize)] struct ProtonPassListResponse { - items: Vec, + items: Vec, } // You can get the JSON template for this struct via: @@ -336,7 +363,7 @@ impl Provider for ProtonPassProvider { response .items .into_iter() - .find(|item| item.content.title == title) + .find(|item| item.title() == Some(title.as_str())) }; if let Some(existing_item) = maybe_existing_item { @@ -396,7 +423,10 @@ impl Provider for ProtonPassProvider { let item_map: HashMap = list_response .items .into_iter() - .map(|item| (item.content.title, (item.share_id, item.id))) + .filter_map(|item| { + let title = item.title()?.to_string(); + Some((title, (item.share_id, item.id))) + }) .collect(); let keys_to_fetch: Vec<(&str, String, String)> = keys @@ -524,6 +554,31 @@ mod tests { assert!(found, "PROTON_PASS_AGENT_REASON must be set on the command"); } + #[test] + fn list_response_parses_legacy_nested_title() { + // pass-cli <= 2.0.2: the title lives under a nested `content` object. + let json = + r#"{"items":[{"id":"i1","share_id":"s1","content":{"title":"proj/default/KEY"}}]}"#; + let response: ProtonPassListResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.items.len(), 1); + assert_eq!(response.items[0].title(), Some("proj/default/KEY")); + assert_eq!(response.items[0].id, "i1"); + assert_eq!(response.items[0].share_id, "s1"); + } + + #[test] + fn list_response_parses_top_level_title() { + // pass-cli >= 2.0.3: the title is top-level and `content` is gone from + // list output. Regression test for the issue where active secrets were + // reported as missing because this shape failed to deserialize. + let json = r#"{"items":[{"id":"i1","share_id":"s1","title":"proj/default/KEY","item_type":"note"}]}"#; + let response: ProtonPassListResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.items.len(), 1); + assert_eq!(response.items[0].title(), Some("proj/default/KEY")); + assert_eq!(response.items[0].id, "i1"); + assert_eq!(response.items[0].share_id, "s1"); + } + #[test] fn set_reason_reaches_provider_through_arc() { // Preflight-enabled providers are stored behind an `Arc`, so the reason