From de98e2d554b39ce3db5fa8d68905c92df800133c Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 20 Sep 2026 21:35:56 +0200 Subject: [PATCH 01/16] chore: stage scope-aware initialization patch --- .github/passless-scope-init.patch | 409 ++++++++++++++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 .github/passless-scope-init.patch diff --git a/.github/passless-scope-init.patch b/.github/passless-scope-init.patch new file mode 100644 index 00000000..99d66600 --- /dev/null +++ b/.github/passless-scope-init.patch @@ -0,0 +1,409 @@ +diff --git a/cmd/passless/src/storage/pass/mod.rs b/cmd/passless/src/storage/pass/mod.rs +index 5bb50c3..0000000 100644 +--- a/cmd/passless/src/storage/pass/mod.rs ++++ b/cmd/passless/src/storage/pass/mod.rs +@@ -128,7 +128,12 @@ impl PassStorageAdapter { + + // Ensure the password store is initialized + // This will prompt the user via notifications if not initialized +- self::init::ensure_initialized(&store_path, gpg_backend, allow_create_without_prompt)?; ++ self::init::ensure_initialized( ++ &store_path, ++ &path, ++ gpg_backend, ++ allow_create_without_prompt, ++ )?; + + if !store_path.exists() { + return Err(Error::Storage(format!( +diff --git a/cmd/passless/src/storage/pass/gpg_id.rs b/cmd/passless/src/storage/pass/gpg_id.rs +index 00c00e3..0000000 100644 +--- a/cmd/passless/src/storage/pass/gpg_id.rs ++++ b/cmd/passless/src/storage/pass/gpg_id.rs +@@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; + + use log::{debug, warn}; ++ + /// Find the nearest `.gpg-id` file by walking from `target`'s parent + /// directory up to `store_root`. Returns the path and raw content. + pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf, String)> { +@@ -18,8 +19,32 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf + target.display() + )) + })?; ++ ++ find_nearest_gpg_id_for_dir(store_root, parent)?.ok_or_else(|| { ++ Error::Storage(format!( ++ "No .gpg-id file found in any parent directory of '{}' up to store root '{}'. \ ++ Make sure the password store is initialized with: pass init ", ++ target.display(), ++ store_root.display() ++ )) ++ }) ++} ++ ++/// Find the effective `.gpg-id` for a directory. ++/// ++/// The lookup starts at `start_dir` itself and walks towards `store_root`, ++/// matching `pass`'s closest-policy-wins semantics. `Ok(None)` means that no ++/// recipient policy applies to the directory; I/O and containment failures are ++/// returned as errors. ++pub fn find_nearest_gpg_id_for_dir( ++ store_root: &Path, ++ start_dir: &Path, ++) -> Result> { ++ if !start_dir.starts_with(store_root) { ++ return Err(Error::Storage(format!( ++ "Directory '{}' is not within store root '{}'", ++ start_dir.display(), ++ store_root.display() ++ ))); ++ } + +- let start_dir = if parent.exists() { +- parent ++ let start_dir = if start_dir.exists() { ++ start_dir + .canonicalize() +- .unwrap_or_else(|_| parent.to_path_buf()) ++ .unwrap_or_else(|_| start_dir.to_path_buf()) + } else { +- parent.to_path_buf() ++ start_dir.to_path_buf() + }; + let root = if store_root.exists() { + store_root +@@ -36,7 +61,7 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf + + if !start_dir.starts_with(&root) { + return Err(Error::Storage(format!( +- "Resolved target path '{}' is not within store root '{}'", ++ "Resolved directory '{}' is not within store root '{}'", + start_dir.display(), + root.display() + ))); +@@ -50,7 +75,7 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf + match std::fs::read_to_string(&gpg_id_path) { + Ok(content) => { + debug!("Found .gpg-id at: {:?}", gpg_id_path); +- return Ok((gpg_id_path, content)); ++ return Ok(Some((gpg_id_path, content))); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { +@@ -71,13 +96,7 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf + } + } + +- Err(Error::Storage(format!( +- "No .gpg-id file found in any parent directory of '{}' up to store root '{}'. \ +- Make sure the password store is initialized with: pass init ", +- target.display(), +- store_root.display() +- ))) ++ Ok(None) + } ++ + /// Resolve GPG recipients for a target file using hierarchical .gpg-id lookup. + /// + /// Walks from `target`'s parent directory up to `store_root` and uses the +@@ -179,4 +198,58 @@ pub fn parse_raw_key_ids(content: &str) -> Vec { + ids.sort(); + ids + } ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ use std::fs; ++ ++ #[test] ++ fn directory_lookup_prefers_scope_policy() { ++ let temp = tempfile::tempdir().unwrap(); ++ let root = temp.path(); ++ let scope = root.join("fido2"); ++ fs::create_dir_all(&scope).unwrap(); ++ fs::write(root.join(".gpg-id"), "ROOT\n").unwrap(); ++ fs::write(scope.join(".gpg-id"), "SCOPE\n").unwrap(); ++ ++ let (path, content) = find_nearest_gpg_id_for_dir(root, &scope) ++ .unwrap() ++ .expect("scope should have an effective policy"); ++ ++ assert_eq!(path, scope.join(".gpg-id")); ++ assert_eq!(content, "SCOPE\n"); ++ } ++ ++ #[test] ++ fn directory_lookup_inherits_root_policy() { ++ let temp = tempfile::tempdir().unwrap(); ++ let root = temp.path(); ++ let scope = root.join("fido2"); ++ fs::create_dir_all(&scope).unwrap(); ++ fs::write(root.join(".gpg-id"), "ROOT\n").unwrap(); ++ ++ let (path, content) = find_nearest_gpg_id_for_dir(root, &scope) ++ .unwrap() ++ .expect("root policy should apply to scope"); ++ ++ assert_eq!(path, root.join(".gpg-id")); ++ assert_eq!(content, "ROOT\n"); ++ } ++ ++ #[test] ++ fn unrelated_subtree_policy_does_not_initialize_scope() { ++ let temp = tempfile::tempdir().unwrap(); ++ let root = temp.path(); ++ let scope = root.join("fido2"); ++ let other = root.join("personal"); ++ fs::create_dir_all(&scope).unwrap(); ++ fs::create_dir_all(&other).unwrap(); ++ fs::write(other.join(".gpg-id"), "OTHER\n").unwrap(); ++ ++ assert!( ++ find_nearest_gpg_id_for_dir(root, &scope) ++ .unwrap() ++ .is_none() ++ ); ++ } ++} +diff --git a/cmd/passless/src/storage/pass/init/mod.rs b/cmd/passless/src/storage/pass/init/mod.rs +index bb17337..0000000 100644 +--- a/cmd/passless/src/storage/pass/init/mod.rs ++++ b/cmd/passless/src/storage/pass/init/mod.rs +@@ -17,12 +17,14 @@ use std::path::Path; + /// Initialize password store, prompting user via desktop notifications if needed + pub fn ensure_initialized( + store_path: &Path, ++ path: &Path, + gpg_backend: GpgBackend, + allow_create_without_prompt: bool, + ) -> Result<()> { +- let init = Uninitialized::new(store_path.to_path_buf(), gpg_backend); ++ let scope_path = store_path.join(path); ++ let init = Uninitialized::new(store_path.to_path_buf(), scope_path, gpg_backend); + + match init.check_if_initialized() { + Ok(init) => init +diff --git a/cmd/passless/src/storage/pass/init/uninitialized.rs b/cmd/passless/src/storage/pass/init/uninitialized.rs +index 465a14b..0000000 100644 +--- a/cmd/passless/src/storage/pass/init/uninitialized.rs ++++ b/cmd/passless/src/storage/pass/init/uninitialized.rs +@@ -3,7 +3,7 @@ + use super::directory_created::DirectoryCreated; + + use crate::notification::{YesNoResult, show_info_notification, show_yes_no_notification}; +-use crate::storage::pass::GpgBackend; ++use crate::storage::pass::{GpgBackend, gpg_id}; + use crate::util::create_secure_dir_all; + + use passless_core::error::{Error, Result}; +@@ -14,21 +14,27 @@ use log::{debug, info, warn}; + + pub struct Uninitialized { + pub(super) store_path: PathBuf, ++ pub(super) scope_path: PathBuf, + pub(super) gpg_backend: GpgBackend, + } + + impl Uninitialized { +- pub fn new(store_path: PathBuf, gpg_backend: GpgBackend) -> Self { ++ pub fn new(store_path: PathBuf, scope_path: PathBuf, gpg_backend: GpgBackend) -> Self { + Self { + store_path, ++ scope_path, + gpg_backend, + } + } + + /// Check if already initialized; returns special error if yes (success case) + pub fn check_if_initialized(self) -> Result { +- let gpg_id_file = self.store_path.join(".gpg-id"); +- +- if gpg_id_file.exists() { ++ if let Some((gpg_id_file, _)) = ++ gpg_id::find_nearest_gpg_id_for_dir(&self.store_path, &self.scope_path)? ++ { + debug!( +- "Password store already initialized at {:?}", +- self.store_path ++ "Password store scope {:?} already initialized by {:?}", ++ self.scope_path, gpg_id_file + ); + return Err(Error::Config("ALREADY_INITIALIZED".to_string())); + } + +- info!("Password store not initialized at {:?}", self.store_path); ++ info!( ++ "Password store scope not initialized at {:?}", ++ self.scope_path ++ ); + Ok(self) + } + +@@ -38,7 +44,7 @@ impl Uninitialized { + "Password Store Not Initialized", + &format!( + "The password store directory does not exist at:\n{}\n\nWould you like to initialize it now?", +- self.store_path.display() ++ self.scope_path.display() + ), + ) { + Ok(YesNoResult::Accepted) => info!("User agreed to initialize"), +@@ -58,21 +64,22 @@ impl Uninitialized { + } + } + +- if !self.store_path.exists() { +- create_secure_dir_all(&self.store_path).map_err(|e| { +- let msg = format!("Failed to create store directory: {}", e); ++ if !self.scope_path.exists() { ++ create_secure_dir_all(&self.scope_path).map_err(|e| { ++ let msg = format!("Failed to create password store scope: {}", e); + let _ = crate::notification::show_error_notification("Initialization Failed", &msg); + Error::Storage(msg) + })?; +- info!("Created store directory at {:?}", self.store_path); ++ info!("Created password store scope at {:?}", self.scope_path); + } + + Ok(DirectoryCreated { + store_path: self.store_path, ++ scope_path: self.scope_path, + gpg_backend: self.gpg_backend, + allow_create_without_prompt, + }) + } + } +diff --git a/cmd/passless/src/storage/pass/init/directory_created.rs b/cmd/passless/src/storage/pass/init/directory_created.rs +index 443c5d5..0000000 100644 +--- a/cmd/passless/src/storage/pass/init/directory_created.rs ++++ b/cmd/passless/src/storage/pass/init/directory_created.rs +@@ -13,6 +13,7 @@ use prs_lib::crypto::{self, Config, IsContext, Proto}; + + pub struct DirectoryCreated { + pub(super) store_path: PathBuf, ++ pub(super) scope_path: PathBuf, + pub(super) gpg_backend: GpgBackend, + pub(super) allow_create_without_prompt: bool, + } +@@ -23,6 +24,7 @@ impl DirectoryCreated { + + Ok(GpgKeySelected { + store_path: self.store_path, ++ scope_path: self.scope_path, + fingerprint, + allow_create_without_prompt: self.allow_create_without_prompt, + }) +diff --git a/cmd/passless/src/storage/pass/init/gpg_key_selected.rs b/cmd/passless/src/storage/pass/init/gpg_key_selected.rs +index ba1b714..0000000 100644 +--- a/cmd/passless/src/storage/pass/init/gpg_key_selected.rs ++++ b/cmd/passless/src/storage/pass/init/gpg_key_selected.rs +@@ -10,12 +10,14 @@ use log::info; + + pub struct GpgKeySelected { + pub(super) store_path: PathBuf, ++ pub(super) scope_path: PathBuf, + pub(super) fingerprint: String, + pub(super) allow_create_without_prompt: bool, + } + + impl GpgKeySelected { + pub fn write_gpg_id(self) -> Result { +- let gpg_id_file = self.store_path.join(".gpg-id"); ++ let gpg_id_file = self.scope_path.join(".gpg-id"); + + fs::write(&gpg_id_file, format!("{}\n", self.fingerprint)).map_err(|e| { + let msg = format!("Failed to write .gpg-id file: {}", e); +@@ -28,6 +30,8 @@ impl GpgKeySelected { + + Ok(StoreInitialized { + store_path: self.store_path, ++ scope_path: self.scope_path, ++ gpg_id_path: gpg_id_file, + fingerprint: self.fingerprint, + allow_create_without_prompt: self.allow_create_without_prompt, + }) +diff --git a/cmd/passless/src/storage/pass/init/store_initialized.rs b/cmd/passless/src/storage/pass/init/store_initialized.rs +index 11eac59..0000000 100644 +--- a/cmd/passless/src/storage/pass/init/store_initialized.rs ++++ b/cmd/passless/src/storage/pass/init/store_initialized.rs +@@ -10,16 +10,21 @@ use log::{info, warn}; + + pub struct StoreInitialized { + pub(super) store_path: PathBuf, ++ pub(super) scope_path: PathBuf, ++ pub(super) gpg_id_path: PathBuf, + pub(super) fingerprint: String, + pub(super) allow_create_without_prompt: bool, + } + + impl StoreInitialized { + pub fn setup_git(self) -> Result { + info!("Setting up git"); +- initialize_git_repo(&self.store_path, self.allow_create_without_prompt)?; ++ initialize_git_repo( ++ &self.store_path, ++ &self.gpg_id_path, ++ self.allow_create_without_prompt, ++ )?; + Ok(Complete { +- store_path: self.store_path, ++ scope_path: self.scope_path, + fingerprint: self.fingerprint, + allow_create_without_prompt: self.allow_create_without_prompt, + }) +@@ -27,7 +32,11 @@ impl StoreInitialized { + } + } + +-fn initialize_git_repo(store_path: &PathBuf, allow_create_without_prompt: bool) -> Result<()> { ++fn initialize_git_repo( ++ store_path: &PathBuf, ++ gpg_id_path: &PathBuf, ++ allow_create_without_prompt: bool, ++) -> Result<()> { + let output = Command::new("git") + .arg("init") + .current_dir(store_path) +@@ -59,9 +68,15 @@ fn initialize_git_repo(store_path: &PathBuf, allow_create_without_prompt: bool) + "*.gpg diff=gpg\n[attr]binary -diff -merge -text\n", + ); + ++ let gpg_id_relative = gpg_id_path.strip_prefix(store_path).map_err(|_| { ++ Error::Storage(format!( ++ "GPG policy path '{}' is outside password store root '{}'", ++ gpg_id_path.display(), ++ store_path.display() ++ )) ++ })?; ++ + let _ = Command::new("git") +- .args(["add", ".gpg-id", ".gitattributes"]) ++ .arg("add") ++ .arg(gpg_id_relative) ++ .arg(".gitattributes") + .current_dir(store_path) + .output(); + +diff --git a/cmd/passless/src/storage/pass/init/complete.rs b/cmd/passless/src/storage/pass/init/complete.rs +index 729450e..0000000 100644 +--- a/cmd/passless/src/storage/pass/init/complete.rs ++++ b/cmd/passless/src/storage/pass/init/complete.rs +@@ -6,7 +6,7 @@ use std::path::PathBuf; + + pub struct Complete { +- pub(super) store_path: PathBuf, ++ pub(super) scope_path: PathBuf, + pub(super) fingerprint: String, + pub(super) allow_create_without_prompt: bool, + } +@@ -18,7 +18,7 @@ impl Complete { + "✅ Password Store Initialized", + &format!( + "Password store successfully initialized at:\n{}\n\nGPG Key: {}", +- self.store_path.display(), ++ self.scope_path.display(), + self.fingerprint + ), + ); From c17185282b0a77a3be203fd3639bcdc2b6a395f1 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 20 Sep 2026 21:36:19 +0200 Subject: [PATCH 02/16] chore: apply scope initialization change --- .github/workflows/apply-pass-scope-init.yml | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/apply-pass-scope-init.yml diff --git a/.github/workflows/apply-pass-scope-init.yml b/.github/workflows/apply-pass-scope-init.yml new file mode 100644 index 00000000..9d930074 --- /dev/null +++ b/.github/workflows/apply-pass-scope-init.yml @@ -0,0 +1,32 @@ +name: Apply pass scope initialization patch + +on: + push: + branches: [fix/pass-scope-initialization] + +permissions: + contents: write + +jobs: + apply: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: fix/pass-scope-initialization + + - name: Apply implementation + run: | + git apply --check .github/passless-scope-init.patch + git apply .github/passless-scope-init.patch + cargo fmt --all + rm .github/passless-scope-init.patch + rm .github/workflows/apply-pass-scope-init.yml + + - name: Commit implementation + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "fix(pass): make initialization respect configured scope" + git push origin HEAD:fix/pass-scope-initialization From 7b5f62c8efa4890e2a69fd45834550211b89e27d Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 20 Sep 2026 21:39:02 +0200 Subject: [PATCH 03/16] chore: capture patch failure details --- .github/workflows/apply-pass-scope-init.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/apply-pass-scope-init.yml b/.github/workflows/apply-pass-scope-init.yml index 9d930074..a4841793 100644 --- a/.github/workflows/apply-pass-scope-init.yml +++ b/.github/workflows/apply-pass-scope-init.yml @@ -9,6 +9,7 @@ permissions: jobs: apply: + if: ${{ !contains(github.event.head_commit.message, 'capture patch diagnostics') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -16,8 +17,18 @@ jobs: ref: fix/pass-scope-initialization - name: Apply implementation + shell: bash run: | - git apply --check .github/passless-scope-init.patch + if ! git apply --check --verbose .github/passless-scope-init.patch > /tmp/apply.log 2>&1; then + cat /tmp/apply.log + cp /tmp/apply.log .github/passless-scope-init-error.txt + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .github/passless-scope-init-error.txt + git commit -m "chore: capture patch diagnostics" + git push origin HEAD:fix/pass-scope-initialization + exit 1 + fi git apply .github/passless-scope-init.patch cargo fmt --all rm .github/passless-scope-init.patch From 06295c1b53503316f88a8dcc7159b59c271daa6c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:39:11 +0000 Subject: [PATCH 04/16] chore: capture patch diagnostics --- .github/passless-scope-init-error.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/passless-scope-init-error.txt diff --git a/.github/passless-scope-init-error.txt b/.github/passless-scope-init-error.txt new file mode 100644 index 00000000..4579f32d --- /dev/null +++ b/.github/passless-scope-init-error.txt @@ -0,0 +1 @@ +error: corrupt patch at .github/passless-scope-init.patch:30 From 724c16025d5cf1a89936e30ef11d76cab05229de Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 20 Sep 2026 21:40:15 +0200 Subject: [PATCH 05/16] chore: remove temporary patch workflow --- .github/workflows/apply-pass-scope-init.yml | 43 --------------------- 1 file changed, 43 deletions(-) delete mode 100644 .github/workflows/apply-pass-scope-init.yml diff --git a/.github/workflows/apply-pass-scope-init.yml b/.github/workflows/apply-pass-scope-init.yml deleted file mode 100644 index a4841793..00000000 --- a/.github/workflows/apply-pass-scope-init.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Apply pass scope initialization patch - -on: - push: - branches: [fix/pass-scope-initialization] - -permissions: - contents: write - -jobs: - apply: - if: ${{ !contains(github.event.head_commit.message, 'capture patch diagnostics') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - ref: fix/pass-scope-initialization - - - name: Apply implementation - shell: bash - run: | - if ! git apply --check --verbose .github/passless-scope-init.patch > /tmp/apply.log 2>&1; then - cat /tmp/apply.log - cp /tmp/apply.log .github/passless-scope-init-error.txt - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add .github/passless-scope-init-error.txt - git commit -m "chore: capture patch diagnostics" - git push origin HEAD:fix/pass-scope-initialization - exit 1 - fi - git apply .github/passless-scope-init.patch - cargo fmt --all - rm .github/passless-scope-init.patch - rm .github/workflows/apply-pass-scope-init.yml - - - name: Commit implementation - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "fix(pass): make initialization respect configured scope" - git push origin HEAD:fix/pass-scope-initialization From 4c3a5eaadb9146add89667239a31354978de4846 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 20 Sep 2026 21:40:42 +0200 Subject: [PATCH 06/16] fix(pass): share hierarchical GPG policy lookup --- cmd/passless/src/storage/pass/gpg_id.rs | 103 +++++++++++++++++++++--- 1 file changed, 91 insertions(+), 12 deletions(-) diff --git a/cmd/passless/src/storage/pass/gpg_id.rs b/cmd/passless/src/storage/pass/gpg_id.rs index 8a515f71..ea2875c3 100644 --- a/cmd/passless/src/storage/pass/gpg_id.rs +++ b/cmd/passless/src/storage/pass/gpg_id.rs @@ -22,12 +22,40 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf, )) })?; - let start_dir = if parent.exists() { - parent + find_nearest_gpg_id_for_dir(store_root, parent)?.ok_or_else(|| { + Error::Storage(format!( + "No .gpg-id file found in any parent directory of '{}' up to store root '{}'. \ + Make sure the password store is initialized with: pass init ", + target.display(), + store_root.display() + )) + }) +} + +/// Find the effective `.gpg-id` for a directory. +/// +/// The lookup starts at `start_dir` itself and walks towards `store_root`, +/// matching `pass`'s closest-policy-wins semantics. `Ok(None)` means that no +/// recipient policy applies to the directory; I/O and containment failures are +/// returned as errors. +pub fn find_nearest_gpg_id_for_dir( + store_root: &Path, + start_dir: &Path, +) -> Result> { + if !start_dir.starts_with(store_root) { + return Err(Error::Storage(format!( + "Directory '{}' is not within store root '{}'", + start_dir.display(), + store_root.display() + ))); + } + + let start_dir = if start_dir.exists() { + start_dir .canonicalize() - .unwrap_or_else(|_| parent.to_path_buf()) + .unwrap_or_else(|_| start_dir.to_path_buf()) } else { - parent.to_path_buf() + start_dir.to_path_buf() }; let root = if store_root.exists() { @@ -40,7 +68,7 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf, if !start_dir.starts_with(&root) { return Err(Error::Storage(format!( - "Resolved target path '{}' is not within store root '{}'", + "Resolved directory '{}' is not within store root '{}'", start_dir.display(), root.display() ))); @@ -55,7 +83,7 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf, match std::fs::read_to_string(&gpg_id_path) { Ok(content) => { debug!("Found .gpg-id at: {:?}", gpg_id_path); - return Ok((gpg_id_path, content)); + return Ok(Some((gpg_id_path, content))); } Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => { @@ -79,12 +107,7 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf, } } - Err(Error::Storage(format!( - "No .gpg-id file found in any parent directory of '{}' up to store root '{}'. \ - Make sure the password store is initialized with: pass init ", - target.display(), - store_root.display() - ))) + Ok(None) } /// Resolve GPG recipients for a target file using hierarchical .gpg-id lookup. @@ -194,3 +217,59 @@ pub fn parse_raw_key_ids(content: &str) -> Vec { ids.sort(); ids } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn directory_lookup_prefers_scope_policy() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let scope = root.join("fido2"); + fs::create_dir_all(&scope).unwrap(); + fs::write(root.join(".gpg-id"), "ROOT\n").unwrap(); + fs::write(scope.join(".gpg-id"), "SCOPE\n").unwrap(); + + let (path, content) = find_nearest_gpg_id_for_dir(root, &scope) + .unwrap() + .expect("scope should have an effective policy"); + + assert_eq!(path, scope.join(".gpg-id")); + assert_eq!(content, "SCOPE\n"); + } + + #[test] + fn directory_lookup_inherits_root_policy() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let scope = root.join("fido2"); + fs::create_dir_all(&scope).unwrap(); + fs::write(root.join(".gpg-id"), "ROOT\n").unwrap(); + + let (path, content) = find_nearest_gpg_id_for_dir(root, &scope) + .unwrap() + .expect("root policy should apply to scope"); + + assert_eq!(path, root.join(".gpg-id")); + assert_eq!(content, "ROOT\n"); + } + + #[test] + fn unrelated_subtree_policy_does_not_initialize_scope() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let scope = root.join("fido2"); + let other = root.join("personal"); + fs::create_dir_all(&scope).unwrap(); + fs::create_dir_all(&other).unwrap(); + fs::write(other.join(".gpg-id"), "OTHER\n").unwrap(); + + assert!( + find_nearest_gpg_id_for_dir(root, &scope) + .unwrap() + .is_none() + ); + } +} From a7a71073d9af6c593c75962b8c64155fa289c610 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 20 Sep 2026 21:40:49 +0200 Subject: [PATCH 07/16] fix(pass): initialize configured scope --- cmd/passless/src/storage/pass/init/mod.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cmd/passless/src/storage/pass/init/mod.rs b/cmd/passless/src/storage/pass/init/mod.rs index bb17337e..717c8c04 100644 --- a/cmd/passless/src/storage/pass/init/mod.rs +++ b/cmd/passless/src/storage/pass/init/mod.rs @@ -16,13 +16,19 @@ use passless_core::error::Result; use std::path::Path; -/// Initialize password store, prompting user via desktop notifications if needed +/// Initialize password store, prompting user via desktop notifications if needed. +/// +/// `store_path` is the password-store / Git repository root. `path` is the +/// Passless-owned relative scope inside that store. Existing recipient policy +/// may be inherited from any `.gpg-id` between that scope and `store_path`. pub fn ensure_initialized( store_path: &Path, + path: &Path, gpg_backend: GpgBackend, allow_create_without_prompt: bool, ) -> Result<()> { - let init = Uninitialized::new(store_path.to_path_buf(), gpg_backend); + let scope_path = store_path.join(path); + let init = Uninitialized::new(store_path.to_path_buf(), scope_path, gpg_backend); match init.check_if_initialized() { Ok(init) => init From debe141bf88b08145611586ec3fe53df2463a702 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 20 Sep 2026 21:40:59 +0200 Subject: [PATCH 08/16] fix(pass): inspect effective policy for scope --- .../src/storage/pass/init/uninitialized.rs | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/cmd/passless/src/storage/pass/init/uninitialized.rs b/cmd/passless/src/storage/pass/init/uninitialized.rs index 465a14b5..d6b73934 100644 --- a/cmd/passless/src/storage/pass/init/uninitialized.rs +++ b/cmd/passless/src/storage/pass/init/uninitialized.rs @@ -3,7 +3,7 @@ use super::directory_created::DirectoryCreated; use crate::notification::{YesNoResult, show_info_notification, show_yes_no_notification}; -use crate::storage::pass::GpgBackend; +use crate::storage::pass::{GpgBackend, gpg_id}; use crate::util::create_secure_dir_all; use passless_core::error::{Error, Result}; @@ -14,30 +14,36 @@ use log::{debug, info, warn}; pub struct Uninitialized { pub(super) store_path: PathBuf, + pub(super) scope_path: PathBuf, pub(super) gpg_backend: GpgBackend, } impl Uninitialized { - pub fn new(store_path: PathBuf, gpg_backend: GpgBackend) -> Self { + pub fn new(store_path: PathBuf, scope_path: PathBuf, gpg_backend: GpgBackend) -> Self { Self { store_path, + scope_path, gpg_backend, } } - /// Check if already initialized; returns special error if yes (success case) + /// Check whether an effective recipient policy already applies to the + /// configured Passless scope; returns a special error if yes (success case). pub fn check_if_initialized(self) -> Result { - let gpg_id_file = self.store_path.join(".gpg-id"); - - if gpg_id_file.exists() { + if let Some((gpg_id_file, _)) = + gpg_id::find_nearest_gpg_id_for_dir(&self.store_path, &self.scope_path)? + { debug!( - "Password store already initialized at {:?}", - self.store_path + "Password store scope {:?} already initialized by {:?}", + self.scope_path, gpg_id_file ); return Err(Error::Config("ALREADY_INITIALIZED".to_string())); } - info!("Password store not initialized at {:?}", self.store_path); + info!( + "Password store scope not initialized at {:?}", + self.scope_path + ); Ok(self) } @@ -46,8 +52,8 @@ impl Uninitialized { match show_yes_no_notification( "Password Store Not Initialized", &format!( - "The password store directory does not exist at:\n{}\n\nWould you like to initialize it now?", - self.store_path.display() + "The Passless password-store scope is not initialized at:\n{}\n\nWould you like to initialize it now?", + self.scope_path.display() ), ) { Ok(YesNoResult::Accepted) => info!("User agreed to initialize"), @@ -66,17 +72,18 @@ impl Uninitialized { } } - if !self.store_path.exists() { - create_secure_dir_all(&self.store_path).map_err(|e| { - let msg = format!("Failed to create store directory: {}", e); + if !self.scope_path.exists() { + create_secure_dir_all(&self.scope_path).map_err(|e| { + let msg = format!("Failed to create password store scope: {}", e); let _ = crate::notification::show_error_notification("Initialization Failed", &msg); Error::Storage(msg) })?; - info!("Created store directory at {:?}", self.store_path); + info!("Created password store scope at {:?}", self.scope_path); } Ok(DirectoryCreated { store_path: self.store_path, + scope_path: self.scope_path, gpg_backend: self.gpg_backend, allow_create_without_prompt, }) From 34f6b07bab242ef449779060176491df2960a547 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 20 Sep 2026 21:41:14 +0200 Subject: [PATCH 09/16] fix(pass): carry scope through key selection --- cmd/passless/src/storage/pass/init/directory_created.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/passless/src/storage/pass/init/directory_created.rs b/cmd/passless/src/storage/pass/init/directory_created.rs index 443c5d53..6cabeaca 100644 --- a/cmd/passless/src/storage/pass/init/directory_created.rs +++ b/cmd/passless/src/storage/pass/init/directory_created.rs @@ -16,6 +16,7 @@ use prs_lib::crypto::{self, Config, IsContext, Proto}; pub struct DirectoryCreated { pub(super) store_path: PathBuf, + pub(super) scope_path: PathBuf, pub(super) gpg_backend: GpgBackend, pub(super) allow_create_without_prompt: bool, } @@ -26,6 +27,7 @@ impl DirectoryCreated { Ok(GpgKeySelected { store_path: self.store_path, + scope_path: self.scope_path, fingerprint, allow_create_without_prompt: self.allow_create_without_prompt, }) From 12a2c1838377ee4484f7652eb9b402b96b1b03a5 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 20 Sep 2026 21:41:22 +0200 Subject: [PATCH 10/16] fix(pass): create scope-local GPG policy --- cmd/passless/src/storage/pass/init/gpg_key_selected.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/passless/src/storage/pass/init/gpg_key_selected.rs b/cmd/passless/src/storage/pass/init/gpg_key_selected.rs index ba1b7147..4f346600 100644 --- a/cmd/passless/src/storage/pass/init/gpg_key_selected.rs +++ b/cmd/passless/src/storage/pass/init/gpg_key_selected.rs @@ -13,13 +13,14 @@ use log::info; pub struct GpgKeySelected { pub(super) store_path: PathBuf, + pub(super) scope_path: PathBuf, pub(super) fingerprint: String, pub(super) allow_create_without_prompt: bool, } impl GpgKeySelected { pub fn write_gpg_id(self) -> Result { - let gpg_id_file = self.store_path.join(".gpg-id"); + let gpg_id_file = self.scope_path.join(".gpg-id"); fs::write(&gpg_id_file, format!("{}\n", self.fingerprint)).map_err(|e| { let msg = format!("Failed to write .gpg-id file: {}", e); @@ -31,6 +32,8 @@ impl GpgKeySelected { Ok(StoreInitialized { store_path: self.store_path, + scope_path: self.scope_path, + gpg_id_path: gpg_id_file, fingerprint: self.fingerprint, allow_create_without_prompt: self.allow_create_without_prompt, }) From 57cbf16a96039d92336f0aa834c64ce04891ad41 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 20 Sep 2026 21:41:32 +0200 Subject: [PATCH 11/16] fix(pass): stage actual scope policy in Git --- .../storage/pass/init/store_initialized.rs | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/cmd/passless/src/storage/pass/init/store_initialized.rs b/cmd/passless/src/storage/pass/init/store_initialized.rs index 11eac597..e1b8c4e6 100644 --- a/cmd/passless/src/storage/pass/init/store_initialized.rs +++ b/cmd/passless/src/storage/pass/init/store_initialized.rs @@ -14,6 +14,8 @@ use log::{info, warn}; pub struct StoreInitialized { pub(super) store_path: PathBuf, + pub(super) scope_path: PathBuf, + pub(super) gpg_id_path: PathBuf, pub(super) fingerprint: String, pub(super) allow_create_without_prompt: bool, } @@ -21,16 +23,24 @@ pub struct StoreInitialized { impl StoreInitialized { pub fn setup_git(self) -> Result { info!("Setting up git"); - initialize_git_repo(&self.store_path, self.allow_create_without_prompt)?; + initialize_git_repo( + &self.store_path, + &self.gpg_id_path, + self.allow_create_without_prompt, + )?; Ok(Complete { - store_path: self.store_path, + scope_path: self.scope_path, fingerprint: self.fingerprint, allow_create_without_prompt: self.allow_create_without_prompt, }) } } -fn initialize_git_repo(store_path: &PathBuf, allow_create_without_prompt: bool) -> Result<()> { +fn initialize_git_repo( + store_path: &PathBuf, + gpg_id_path: &PathBuf, + allow_create_without_prompt: bool, +) -> Result<()> { let output = Command::new("git") .arg("init") .current_dir(store_path) @@ -57,8 +67,18 @@ fn initialize_git_repo(store_path: &PathBuf, allow_create_without_prompt: bool) "*.gpg diff=gpg\n[attr]binary -diff -merge -text\n", ); + let gpg_id_relative = gpg_id_path.strip_prefix(store_path).map_err(|_| { + Error::Storage(format!( + "GPG policy path '{}' is outside password store root '{}'", + gpg_id_path.display(), + store_path.display() + )) + })?; + let _ = Command::new("git") - .args(["add", ".gpg-id", ".gitattributes"]) + .arg("add") + .arg(gpg_id_relative) + .arg(".gitattributes") .current_dir(store_path) .output(); From 0cceb550bde1c4fdf86755c789ca92129cfcf252 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 20 Sep 2026 21:41:43 +0200 Subject: [PATCH 12/16] fix(pass): report initialized scope --- cmd/passless/src/storage/pass/init/complete.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/passless/src/storage/pass/init/complete.rs b/cmd/passless/src/storage/pass/init/complete.rs index 729450e6..e6d55f82 100644 --- a/cmd/passless/src/storage/pass/init/complete.rs +++ b/cmd/passless/src/storage/pass/init/complete.rs @@ -7,7 +7,7 @@ use passless_core::error::Result; use std::path::PathBuf; pub struct Complete { - pub(super) store_path: PathBuf, + pub(super) scope_path: PathBuf, pub(super) fingerprint: String, pub(super) allow_create_without_prompt: bool, } @@ -18,8 +18,8 @@ impl Complete { let _ = show_info_notification( "✅ Password Store Initialized", &format!( - "Password store successfully initialized at:\n{}\n\nGPG Key: {}", - self.store_path.display(), + "Passless password-store scope successfully initialized at:\n{}\n\nGPG Key: {}", + self.scope_path.display(), self.fingerprint ), ); From 6559d17f021d5d5943206a6bcb0cb53f49e478e8 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 20 Sep 2026 21:42:00 +0200 Subject: [PATCH 13/16] chore: prepare adapter call-site update --- .github/update-pass-scope-init.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/update-pass-scope-init.py diff --git a/.github/update-pass-scope-init.py b/.github/update-pass-scope-init.py new file mode 100644 index 00000000..9943077d --- /dev/null +++ b/.github/update-pass-scope-init.py @@ -0,0 +1,14 @@ +from pathlib import Path + +path = Path("cmd/passless/src/storage/pass/mod.rs") +text = path.read_text() +old = " self::init::ensure_initialized(&store_path, gpg_backend, allow_create_without_prompt)?;" +new = """ self::init::ensure_initialized( + &store_path, + &path, + gpg_backend, + allow_create_without_prompt, + )?;""" +if text.count(old) != 1: + raise SystemExit(f"expected one initialization call, found {text.count(old)}") +path.write_text(text.replace(old, new)) From e4d9daf0e8c2442f0f4130df54a947ea9cb23be2 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 20 Sep 2026 21:42:10 +0200 Subject: [PATCH 14/16] chore: apply adapter call-site update --- .github/workflows/apply-pass-mod.yml | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/apply-pass-mod.yml diff --git a/.github/workflows/apply-pass-mod.yml b/.github/workflows/apply-pass-mod.yml new file mode 100644 index 00000000..cdd19b79 --- /dev/null +++ b/.github/workflows/apply-pass-mod.yml @@ -0,0 +1,34 @@ +name: Apply pass adapter scope initialization update + +on: + push: + branches: [fix/pass-scope-initialization] + +permissions: + contents: write + +jobs: + apply: + if: ${{ !contains(github.event.head_commit.message, 'apply adapter scope initialization') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: fix/pass-scope-initialization + + - name: Update call site and format + run: | + python .github/update-pass-scope-init.py + cargo fmt --all + rm -f .github/update-pass-scope-init.py + rm -f .github/passless-scope-init.patch + rm -f .github/passless-scope-init-error.txt + rm -f .github/workflows/apply-pass-mod.yml + + - name: Commit implementation + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "fix(pass): apply adapter scope initialization" + git push origin HEAD:fix/pass-scope-initialization From 981d458157e92bd97dd1aba0ad40833059ed5b70 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:42:55 +0000 Subject: [PATCH 15/16] fix(pass): apply adapter scope initialization --- .github/passless-scope-init-error.txt | 1 - .github/passless-scope-init.patch | 409 ------------------------ .github/update-pass-scope-init.py | 14 - .github/workflows/apply-pass-mod.yml | 34 -- cmd/passless/src/storage/pass/gpg_id.rs | 6 +- cmd/passless/src/storage/pass/mod.rs | 7 +- 6 files changed, 7 insertions(+), 464 deletions(-) delete mode 100644 .github/passless-scope-init-error.txt delete mode 100644 .github/passless-scope-init.patch delete mode 100644 .github/update-pass-scope-init.py delete mode 100644 .github/workflows/apply-pass-mod.yml diff --git a/.github/passless-scope-init-error.txt b/.github/passless-scope-init-error.txt deleted file mode 100644 index 4579f32d..00000000 --- a/.github/passless-scope-init-error.txt +++ /dev/null @@ -1 +0,0 @@ -error: corrupt patch at .github/passless-scope-init.patch:30 diff --git a/.github/passless-scope-init.patch b/.github/passless-scope-init.patch deleted file mode 100644 index 99d66600..00000000 --- a/.github/passless-scope-init.patch +++ /dev/null @@ -1,409 +0,0 @@ -diff --git a/cmd/passless/src/storage/pass/mod.rs b/cmd/passless/src/storage/pass/mod.rs -index 5bb50c3..0000000 100644 ---- a/cmd/passless/src/storage/pass/mod.rs -+++ b/cmd/passless/src/storage/pass/mod.rs -@@ -128,7 +128,12 @@ impl PassStorageAdapter { - - // Ensure the password store is initialized - // This will prompt the user via notifications if not initialized -- self::init::ensure_initialized(&store_path, gpg_backend, allow_create_without_prompt)?; -+ self::init::ensure_initialized( -+ &store_path, -+ &path, -+ gpg_backend, -+ allow_create_without_prompt, -+ )?; - - if !store_path.exists() { - return Err(Error::Storage(format!( -diff --git a/cmd/passless/src/storage/pass/gpg_id.rs b/cmd/passless/src/storage/pass/gpg_id.rs -index 00c00e3..0000000 100644 ---- a/cmd/passless/src/storage/pass/gpg_id.rs -+++ b/cmd/passless/src/storage/pass/gpg_id.rs -@@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; - - use log::{debug, warn}; -+ - /// Find the nearest `.gpg-id` file by walking from `target`'s parent - /// directory up to `store_root`. Returns the path and raw content. - pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf, String)> { -@@ -18,8 +19,32 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf - target.display() - )) - })?; -+ -+ find_nearest_gpg_id_for_dir(store_root, parent)?.ok_or_else(|| { -+ Error::Storage(format!( -+ "No .gpg-id file found in any parent directory of '{}' up to store root '{}'. \ -+ Make sure the password store is initialized with: pass init ", -+ target.display(), -+ store_root.display() -+ )) -+ }) -+} -+ -+/// Find the effective `.gpg-id` for a directory. -+/// -+/// The lookup starts at `start_dir` itself and walks towards `store_root`, -+/// matching `pass`'s closest-policy-wins semantics. `Ok(None)` means that no -+/// recipient policy applies to the directory; I/O and containment failures are -+/// returned as errors. -+pub fn find_nearest_gpg_id_for_dir( -+ store_root: &Path, -+ start_dir: &Path, -+) -> Result> { -+ if !start_dir.starts_with(store_root) { -+ return Err(Error::Storage(format!( -+ "Directory '{}' is not within store root '{}'", -+ start_dir.display(), -+ store_root.display() -+ ))); -+ } - -- let start_dir = if parent.exists() { -- parent -+ let start_dir = if start_dir.exists() { -+ start_dir - .canonicalize() -- .unwrap_or_else(|_| parent.to_path_buf()) -+ .unwrap_or_else(|_| start_dir.to_path_buf()) - } else { -- parent.to_path_buf() -+ start_dir.to_path_buf() - }; - let root = if store_root.exists() { - store_root -@@ -36,7 +61,7 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf - - if !start_dir.starts_with(&root) { - return Err(Error::Storage(format!( -- "Resolved target path '{}' is not within store root '{}'", -+ "Resolved directory '{}' is not within store root '{}'", - start_dir.display(), - root.display() - ))); -@@ -50,7 +75,7 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf - match std::fs::read_to_string(&gpg_id_path) { - Ok(content) => { - debug!("Found .gpg-id at: {:?}", gpg_id_path); -- return Ok((gpg_id_path, content)); -+ return Ok(Some((gpg_id_path, content))); - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { -@@ -71,13 +96,7 @@ pub fn find_nearest_gpg_id(store_root: &Path, target: &Path) -> Result<(PathBuf - } - } - -- Err(Error::Storage(format!( -- "No .gpg-id file found in any parent directory of '{}' up to store root '{}'. \ -- Make sure the password store is initialized with: pass init ", -- target.display(), -- store_root.display() -- ))) -+ Ok(None) - } -+ - /// Resolve GPG recipients for a target file using hierarchical .gpg-id lookup. - /// - /// Walks from `target`'s parent directory up to `store_root` and uses the -@@ -179,4 +198,58 @@ pub fn parse_raw_key_ids(content: &str) -> Vec { - ids.sort(); - ids - } -+ -+#[cfg(test)] -+mod tests { -+ use super::*; -+ use std::fs; -+ -+ #[test] -+ fn directory_lookup_prefers_scope_policy() { -+ let temp = tempfile::tempdir().unwrap(); -+ let root = temp.path(); -+ let scope = root.join("fido2"); -+ fs::create_dir_all(&scope).unwrap(); -+ fs::write(root.join(".gpg-id"), "ROOT\n").unwrap(); -+ fs::write(scope.join(".gpg-id"), "SCOPE\n").unwrap(); -+ -+ let (path, content) = find_nearest_gpg_id_for_dir(root, &scope) -+ .unwrap() -+ .expect("scope should have an effective policy"); -+ -+ assert_eq!(path, scope.join(".gpg-id")); -+ assert_eq!(content, "SCOPE\n"); -+ } -+ -+ #[test] -+ fn directory_lookup_inherits_root_policy() { -+ let temp = tempfile::tempdir().unwrap(); -+ let root = temp.path(); -+ let scope = root.join("fido2"); -+ fs::create_dir_all(&scope).unwrap(); -+ fs::write(root.join(".gpg-id"), "ROOT\n").unwrap(); -+ -+ let (path, content) = find_nearest_gpg_id_for_dir(root, &scope) -+ .unwrap() -+ .expect("root policy should apply to scope"); -+ -+ assert_eq!(path, root.join(".gpg-id")); -+ assert_eq!(content, "ROOT\n"); -+ } -+ -+ #[test] -+ fn unrelated_subtree_policy_does_not_initialize_scope() { -+ let temp = tempfile::tempdir().unwrap(); -+ let root = temp.path(); -+ let scope = root.join("fido2"); -+ let other = root.join("personal"); -+ fs::create_dir_all(&scope).unwrap(); -+ fs::create_dir_all(&other).unwrap(); -+ fs::write(other.join(".gpg-id"), "OTHER\n").unwrap(); -+ -+ assert!( -+ find_nearest_gpg_id_for_dir(root, &scope) -+ .unwrap() -+ .is_none() -+ ); -+ } -+} -diff --git a/cmd/passless/src/storage/pass/init/mod.rs b/cmd/passless/src/storage/pass/init/mod.rs -index bb17337..0000000 100644 ---- a/cmd/passless/src/storage/pass/init/mod.rs -+++ b/cmd/passless/src/storage/pass/init/mod.rs -@@ -17,12 +17,14 @@ use std::path::Path; - /// Initialize password store, prompting user via desktop notifications if needed - pub fn ensure_initialized( - store_path: &Path, -+ path: &Path, - gpg_backend: GpgBackend, - allow_create_without_prompt: bool, - ) -> Result<()> { -- let init = Uninitialized::new(store_path.to_path_buf(), gpg_backend); -+ let scope_path = store_path.join(path); -+ let init = Uninitialized::new(store_path.to_path_buf(), scope_path, gpg_backend); - - match init.check_if_initialized() { - Ok(init) => init -diff --git a/cmd/passless/src/storage/pass/init/uninitialized.rs b/cmd/passless/src/storage/pass/init/uninitialized.rs -index 465a14b..0000000 100644 ---- a/cmd/passless/src/storage/pass/init/uninitialized.rs -+++ b/cmd/passless/src/storage/pass/init/uninitialized.rs -@@ -3,7 +3,7 @@ - use super::directory_created::DirectoryCreated; - - use crate::notification::{YesNoResult, show_info_notification, show_yes_no_notification}; --use crate::storage::pass::GpgBackend; -+use crate::storage::pass::{GpgBackend, gpg_id}; - use crate::util::create_secure_dir_all; - - use passless_core::error::{Error, Result}; -@@ -14,21 +14,27 @@ use log::{debug, info, warn}; - - pub struct Uninitialized { - pub(super) store_path: PathBuf, -+ pub(super) scope_path: PathBuf, - pub(super) gpg_backend: GpgBackend, - } - - impl Uninitialized { -- pub fn new(store_path: PathBuf, gpg_backend: GpgBackend) -> Self { -+ pub fn new(store_path: PathBuf, scope_path: PathBuf, gpg_backend: GpgBackend) -> Self { - Self { - store_path, -+ scope_path, - gpg_backend, - } - } - - /// Check if already initialized; returns special error if yes (success case) - pub fn check_if_initialized(self) -> Result { -- let gpg_id_file = self.store_path.join(".gpg-id"); -- -- if gpg_id_file.exists() { -+ if let Some((gpg_id_file, _)) = -+ gpg_id::find_nearest_gpg_id_for_dir(&self.store_path, &self.scope_path)? -+ { - debug!( -- "Password store already initialized at {:?}", -- self.store_path -+ "Password store scope {:?} already initialized by {:?}", -+ self.scope_path, gpg_id_file - ); - return Err(Error::Config("ALREADY_INITIALIZED".to_string())); - } - -- info!("Password store not initialized at {:?}", self.store_path); -+ info!( -+ "Password store scope not initialized at {:?}", -+ self.scope_path -+ ); - Ok(self) - } - -@@ -38,7 +44,7 @@ impl Uninitialized { - "Password Store Not Initialized", - &format!( - "The password store directory does not exist at:\n{}\n\nWould you like to initialize it now?", -- self.store_path.display() -+ self.scope_path.display() - ), - ) { - Ok(YesNoResult::Accepted) => info!("User agreed to initialize"), -@@ -58,21 +64,22 @@ impl Uninitialized { - } - } - -- if !self.store_path.exists() { -- create_secure_dir_all(&self.store_path).map_err(|e| { -- let msg = format!("Failed to create store directory: {}", e); -+ if !self.scope_path.exists() { -+ create_secure_dir_all(&self.scope_path).map_err(|e| { -+ let msg = format!("Failed to create password store scope: {}", e); - let _ = crate::notification::show_error_notification("Initialization Failed", &msg); - Error::Storage(msg) - })?; -- info!("Created store directory at {:?}", self.store_path); -+ info!("Created password store scope at {:?}", self.scope_path); - } - - Ok(DirectoryCreated { - store_path: self.store_path, -+ scope_path: self.scope_path, - gpg_backend: self.gpg_backend, - allow_create_without_prompt, - }) - } - } -diff --git a/cmd/passless/src/storage/pass/init/directory_created.rs b/cmd/passless/src/storage/pass/init/directory_created.rs -index 443c5d5..0000000 100644 ---- a/cmd/passless/src/storage/pass/init/directory_created.rs -+++ b/cmd/passless/src/storage/pass/init/directory_created.rs -@@ -13,6 +13,7 @@ use prs_lib::crypto::{self, Config, IsContext, Proto}; - - pub struct DirectoryCreated { - pub(super) store_path: PathBuf, -+ pub(super) scope_path: PathBuf, - pub(super) gpg_backend: GpgBackend, - pub(super) allow_create_without_prompt: bool, - } -@@ -23,6 +24,7 @@ impl DirectoryCreated { - - Ok(GpgKeySelected { - store_path: self.store_path, -+ scope_path: self.scope_path, - fingerprint, - allow_create_without_prompt: self.allow_create_without_prompt, - }) -diff --git a/cmd/passless/src/storage/pass/init/gpg_key_selected.rs b/cmd/passless/src/storage/pass/init/gpg_key_selected.rs -index ba1b714..0000000 100644 ---- a/cmd/passless/src/storage/pass/init/gpg_key_selected.rs -+++ b/cmd/passless/src/storage/pass/init/gpg_key_selected.rs -@@ -10,12 +10,14 @@ use log::info; - - pub struct GpgKeySelected { - pub(super) store_path: PathBuf, -+ pub(super) scope_path: PathBuf, - pub(super) fingerprint: String, - pub(super) allow_create_without_prompt: bool, - } - - impl GpgKeySelected { - pub fn write_gpg_id(self) -> Result { -- let gpg_id_file = self.store_path.join(".gpg-id"); -+ let gpg_id_file = self.scope_path.join(".gpg-id"); - - fs::write(&gpg_id_file, format!("{}\n", self.fingerprint)).map_err(|e| { - let msg = format!("Failed to write .gpg-id file: {}", e); -@@ -28,6 +30,8 @@ impl GpgKeySelected { - - Ok(StoreInitialized { - store_path: self.store_path, -+ scope_path: self.scope_path, -+ gpg_id_path: gpg_id_file, - fingerprint: self.fingerprint, - allow_create_without_prompt: self.allow_create_without_prompt, - }) -diff --git a/cmd/passless/src/storage/pass/init/store_initialized.rs b/cmd/passless/src/storage/pass/init/store_initialized.rs -index 11eac59..0000000 100644 ---- a/cmd/passless/src/storage/pass/init/store_initialized.rs -+++ b/cmd/passless/src/storage/pass/init/store_initialized.rs -@@ -10,16 +10,21 @@ use log::{info, warn}; - - pub struct StoreInitialized { - pub(super) store_path: PathBuf, -+ pub(super) scope_path: PathBuf, -+ pub(super) gpg_id_path: PathBuf, - pub(super) fingerprint: String, - pub(super) allow_create_without_prompt: bool, - } - - impl StoreInitialized { - pub fn setup_git(self) -> Result { - info!("Setting up git"); -- initialize_git_repo(&self.store_path, self.allow_create_without_prompt)?; -+ initialize_git_repo( -+ &self.store_path, -+ &self.gpg_id_path, -+ self.allow_create_without_prompt, -+ )?; - Ok(Complete { -- store_path: self.store_path, -+ scope_path: self.scope_path, - fingerprint: self.fingerprint, - allow_create_without_prompt: self.allow_create_without_prompt, - }) -@@ -27,7 +32,11 @@ impl StoreInitialized { - } - } - --fn initialize_git_repo(store_path: &PathBuf, allow_create_without_prompt: bool) -> Result<()> { -+fn initialize_git_repo( -+ store_path: &PathBuf, -+ gpg_id_path: &PathBuf, -+ allow_create_without_prompt: bool, -+) -> Result<()> { - let output = Command::new("git") - .arg("init") - .current_dir(store_path) -@@ -59,9 +68,15 @@ fn initialize_git_repo(store_path: &PathBuf, allow_create_without_prompt: bool) - "*.gpg diff=gpg\n[attr]binary -diff -merge -text\n", - ); - -+ let gpg_id_relative = gpg_id_path.strip_prefix(store_path).map_err(|_| { -+ Error::Storage(format!( -+ "GPG policy path '{}' is outside password store root '{}'", -+ gpg_id_path.display(), -+ store_path.display() -+ )) -+ })?; -+ - let _ = Command::new("git") -- .args(["add", ".gpg-id", ".gitattributes"]) -+ .arg("add") -+ .arg(gpg_id_relative) -+ .arg(".gitattributes") - .current_dir(store_path) - .output(); - -diff --git a/cmd/passless/src/storage/pass/init/complete.rs b/cmd/passless/src/storage/pass/init/complete.rs -index 729450e..0000000 100644 ---- a/cmd/passless/src/storage/pass/init/complete.rs -+++ b/cmd/passless/src/storage/pass/init/complete.rs -@@ -6,7 +6,7 @@ use std::path::PathBuf; - - pub struct Complete { -- pub(super) store_path: PathBuf, -+ pub(super) scope_path: PathBuf, - pub(super) fingerprint: String, - pub(super) allow_create_without_prompt: bool, - } -@@ -18,7 +18,7 @@ impl Complete { - "✅ Password Store Initialized", - &format!( - "Password store successfully initialized at:\n{}\n\nGPG Key: {}", -- self.store_path.display(), -+ self.scope_path.display(), - self.fingerprint - ), - ); diff --git a/.github/update-pass-scope-init.py b/.github/update-pass-scope-init.py deleted file mode 100644 index 9943077d..00000000 --- a/.github/update-pass-scope-init.py +++ /dev/null @@ -1,14 +0,0 @@ -from pathlib import Path - -path = Path("cmd/passless/src/storage/pass/mod.rs") -text = path.read_text() -old = " self::init::ensure_initialized(&store_path, gpg_backend, allow_create_without_prompt)?;" -new = """ self::init::ensure_initialized( - &store_path, - &path, - gpg_backend, - allow_create_without_prompt, - )?;""" -if text.count(old) != 1: - raise SystemExit(f"expected one initialization call, found {text.count(old)}") -path.write_text(text.replace(old, new)) diff --git a/.github/workflows/apply-pass-mod.yml b/.github/workflows/apply-pass-mod.yml deleted file mode 100644 index cdd19b79..00000000 --- a/.github/workflows/apply-pass-mod.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Apply pass adapter scope initialization update - -on: - push: - branches: [fix/pass-scope-initialization] - -permissions: - contents: write - -jobs: - apply: - if: ${{ !contains(github.event.head_commit.message, 'apply adapter scope initialization') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - ref: fix/pass-scope-initialization - - - name: Update call site and format - run: | - python .github/update-pass-scope-init.py - cargo fmt --all - rm -f .github/update-pass-scope-init.py - rm -f .github/passless-scope-init.patch - rm -f .github/passless-scope-init-error.txt - rm -f .github/workflows/apply-pass-mod.yml - - - name: Commit implementation - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "fix(pass): apply adapter scope initialization" - git push origin HEAD:fix/pass-scope-initialization diff --git a/cmd/passless/src/storage/pass/gpg_id.rs b/cmd/passless/src/storage/pass/gpg_id.rs index ea2875c3..08ad1056 100644 --- a/cmd/passless/src/storage/pass/gpg_id.rs +++ b/cmd/passless/src/storage/pass/gpg_id.rs @@ -266,10 +266,6 @@ mod tests { fs::create_dir_all(&other).unwrap(); fs::write(other.join(".gpg-id"), "OTHER\n").unwrap(); - assert!( - find_nearest_gpg_id_for_dir(root, &scope) - .unwrap() - .is_none() - ); + assert!(find_nearest_gpg_id_for_dir(root, &scope).unwrap().is_none()); } } diff --git a/cmd/passless/src/storage/pass/mod.rs b/cmd/passless/src/storage/pass/mod.rs index 793989b6..53681b6c 100644 --- a/cmd/passless/src/storage/pass/mod.rs +++ b/cmd/passless/src/storage/pass/mod.rs @@ -139,7 +139,12 @@ impl PassStorageAdapter { // Ensure the password store is initialized // This will prompt the user via notifications if not initialized - self::init::ensure_initialized(&store_path, gpg_backend, allow_create_without_prompt)?; + self::init::ensure_initialized( + &store_path, + &path, + gpg_backend, + allow_create_without_prompt, + )?; if !store_path.exists() { return Err(Error::Storage(format!( From 9ba0e4b7d671c2f94cd92d620da302b37908b8b9 Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Sun, 20 Sep 2026 20:05:46 +0000 Subject: [PATCH 16/16] fix: use &Path instead of &PathBuf in initialize_git_repo parameter --- cmd/passless/src/storage/pass/init/store_initialized.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/passless/src/storage/pass/init/store_initialized.rs b/cmd/passless/src/storage/pass/init/store_initialized.rs index e1b8c4e6..578ac8be 100644 --- a/cmd/passless/src/storage/pass/init/store_initialized.rs +++ b/cmd/passless/src/storage/pass/init/store_initialized.rs @@ -7,6 +7,7 @@ use crate::notification::{show_error_notification, show_info_notification}; use passless_core::error::{Error, Result}; use std::fs; +use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -38,7 +39,7 @@ impl StoreInitialized { fn initialize_git_repo( store_path: &PathBuf, - gpg_id_path: &PathBuf, + gpg_id_path: &Path, allow_create_without_prompt: bool, ) -> Result<()> { let output = Command::new("git")