use crate::storage::index::load_credential_paths;
use crate::storage::pass::{GpgBackend, PassStorageAdapter as InnerPassStorageAdapter};
use crate::storage::{CredentialFilter, CredentialStorage};
use git2::{Oid, Repository};
use log::{debug, warn};
use passless_core::error::Result;
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct RepositoryGeneration {
head: Option<Oid>,
credential_tree_stamp: u64,
}
pub struct PassStorageAdapter {
inner: InnerPassStorageAdapter,
store_path: PathBuf,
path: PathBuf,
gpg_backend: GpgBackend,
allow_create_without_prompt: bool,
indexed_generation: RepositoryGeneration,
}
impl PassStorageAdapter {
pub fn new_with_options(
store_path: PathBuf,
path: PathBuf,
gpg_backend: GpgBackend,
allow_create_without_prompt: bool,
) -> Result<Self> {
let inner = InnerPassStorageAdapter::new_with_options(
store_path.clone(),
path.clone(),
gpg_backend,
allow_create_without_prompt,
)?;
let indexed_generation = repository_generation(&store_path, &path);
Ok(Self {
inner,
store_path,
path,
gpg_backend,
allow_create_without_prompt,
indexed_generation,
})
}
fn reload_from_repository(&mut self, generation: RepositoryGeneration) -> Result<()> {
debug!(
"Reloading pass credential index after repository change: {:?} -> {:?}",
self.indexed_generation, generation
);
let inner = InnerPassStorageAdapter::new_with_options(
self.store_path.clone(),
self.path.clone(),
self.gpg_backend,
self.allow_create_without_prompt,
)?;
self.inner = inner;
self.indexed_generation = repository_generation(&self.store_path, &self.path);
Ok(())
}
fn refresh_if_repository_changed(&mut self) -> Result<()> {
let generation = repository_generation(&self.store_path, &self.path);
if generation != self.indexed_generation {
self.reload_from_repository(generation)?;
}
Ok(())
}
fn record_current_generation(&mut self) {
self.indexed_generation = repository_generation(&self.store_path, &self.path);
}
fn current_credential_count(&self) -> usize {
let generation = repository_generation(&self.store_path, &self.path);
if generation == self.indexed_generation {
return self.inner.count_credentials();
}
load_credential_paths(&self.store_path.join(&self.path), "gpg")
.map(|indexes| indexes.id.len())
.unwrap_or_else(|error| {
warn!(
"Failed to count credentials from changed repository state: {}",
error
);
self.inner.count_credentials()
})
}
}
impl CredentialStorage for PassStorageAdapter {
fn read_first(
&mut self,
filter: CredentialFilter,
) -> soft_fido2::Result<soft_fido2::Credential> {
self.refresh_if_repository_changed()
.map_err(soft_fido2::Error::from)?;
self.inner.read_first(filter)
}
fn read_next(&mut self) -> soft_fido2::Result<soft_fido2::Credential> {
self.inner.read_next()
}
fn read(&mut self, id: &[u8]) -> soft_fido2::Result<soft_fido2::Credential> {
self.refresh_if_repository_changed()
.map_err(soft_fido2::Error::from)?;
self.inner.read(id)
}
fn write(&mut self, cred: soft_fido2::CredentialRef) -> soft_fido2::Result<()> {
self.refresh_if_repository_changed()
.map_err(soft_fido2::Error::from)?;
let result = self.inner.write(cred);
if result.is_ok() {
self.record_current_generation();
}
result
}
fn delete(&mut self, id: &[u8]) -> soft_fido2::Result<()> {
self.refresh_if_repository_changed()
.map_err(soft_fido2::Error::from)?;
let result = self.inner.delete(id);
if result.is_ok() {
self.record_current_generation();
}
result
}
fn count_credentials(&self) -> usize {
self.current_credential_count()
}
fn disable_user_verification(&self) -> bool {
self.inner.disable_user_verification()
}
fn cleanup_expired_cache(&mut self) {
if let Err(error) = self.refresh_if_repository_changed() {
warn!(
"Failed to refresh pass credential index after repository change: {}",
error
);
}
self.inner.cleanup_expired_cache();
}
}
fn repository_generation(store_path: &Path, credential_path: &Path) -> RepositoryGeneration {
RepositoryGeneration {
head: repository_head(store_path),
credential_tree_stamp: credential_tree_stamp(&store_path.join(credential_path)),
}
}
fn repository_head(store_path: &Path) -> Option<Oid> {
let repository = Repository::open(store_path)
.or_else(|_| Repository::discover(store_path))
.ok()?;
repository.head().ok()?.target()
}
fn credential_tree_stamp(root: &Path) -> u64 {
let mut files = Vec::new();
let Ok(rp_entries) = fs::read_dir(root) else {
return 0;
};
for rp_entry in rp_entries.flatten() {
let rp_path = rp_entry.path();
if !rp_path.is_dir() {
continue;
}
let Ok(credentials) = fs::read_dir(&rp_path) else {
continue;
};
for credential in credentials.flatten() {
let path = credential.path();
if path.extension().and_then(|value| value.to_str()) == Some("gpg") {
files.push(path);
}
}
}
files.sort();
let mut hasher = DefaultHasher::new();
for path in files {
path.strip_prefix(root).unwrap_or(&path).hash(&mut hasher);
if let Ok(metadata) = fs::metadata(&path) {
metadata.len().hash(&mut hasher);
if let Ok(modified) = metadata.modified()
&& let Ok(duration) = modified.duration_since(UNIX_EPOCH)
{
duration.as_nanos().hash(&mut hasher);
}
}
}
hasher.finish()
}
#[cfg(test)]
mod tests {
use super::*;
use git2::Signature;
use std::fs;
fn commit_file(repository: &Repository, relative_path: &Path, contents: &[u8]) -> Oid {
let workdir = repository.workdir().unwrap();
let path = workdir.join(relative_path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, contents).unwrap();
let mut index = repository.index().unwrap();
index.add_path(relative_path).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repository.find_tree(tree_id).unwrap();
let signature = Signature::now("Passless Test", "passless@example.invalid").unwrap();
let parents = repository
.head()
.ok()
.and_then(|head| head.target())
.and_then(|oid| repository.find_commit(oid).ok())
.into_iter()
.collect::<Vec<_>>();
let parent_refs = parents.iter().collect::<Vec<_>>();
repository
.commit(
Some("HEAD"),
&signature,
&signature,
"test commit",
&tree,
&parent_refs,
)
.unwrap()
}
#[test]
fn generation_changes_when_git_head_moves() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::init(temp.path()).unwrap();
commit_file(&repository, Path::new("fido2/example.com/01.gpg"), b"first");
let first = repository_generation(temp.path(), Path::new("fido2"));
commit_file(
&repository,
Path::new("fido2/example.com/02.gpg"),
b"second",
);
let second = repository_generation(temp.path(), Path::new("fido2"));
assert_ne!(first.head, second.head);
assert_ne!(first, second);
}
#[test]
fn generation_changes_for_uncommitted_credential_changes() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::init(temp.path()).unwrap();
commit_file(&repository, Path::new("fido2/example.com/01.gpg"), b"first");
let first = repository_generation(temp.path(), Path::new("fido2"));
fs::write(
temp.path().join("fido2/example.com/01.gpg"),
b"changed payload with different length",
)
.unwrap();
let second = repository_generation(temp.path(), Path::new("fido2"));
assert_eq!(first.head, second.head);
assert_ne!(first.credential_tree_stamp, second.credential_tree_stamp);
}
#[test]
fn generation_changes_when_uncommitted_credential_is_added_or_removed() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::init(temp.path()).unwrap();
commit_file(&repository, Path::new("fido2/example.com/01.gpg"), b"first");
let first = repository_generation(temp.path(), Path::new("fido2"));
let added = temp.path().join("fido2/example.com/02.gpg");
fs::write(&added, b"second").unwrap();
let second = repository_generation(temp.path(), Path::new("fido2"));
assert_ne!(first.credential_tree_stamp, second.credential_tree_stamp);
fs::remove_file(added).unwrap();
let third = repository_generation(temp.path(), Path::new("fido2"));
assert_eq!(first.credential_tree_stamp, third.credential_tree_stamp);
}
}