Skip to main content

act_credentials/backend/
file.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::index::{Index, write_private};
7use crate::record::{SecretInfo, SecretRecord};
8use crate::store::{CredentialStore, StoreError};
9
10/// Secrets in one JSON file next to the index. This is the only backend: the
11/// records are plaintext on disk, protected by filesystem permissions alone,
12/// and it is always selected explicitly (design §7.4).
13pub struct FileStore {
14    root: PathBuf,
15}
16
17#[derive(Debug, Default, Serialize, Deserialize)]
18struct Secrets {
19    /// component -> key -> record
20    entries: BTreeMap<String, BTreeMap<String, SecretRecord>>,
21}
22
23/// The file holding the plaintext records, given the store root.
24///
25/// Public because `act secret`'s first-write disclosure names it: an operator
26/// told that permissions are the only protection needs to know which file to
27/// chmod, back up, or keep out of a sync client. One definition, so the notice
28/// cannot name a file the store does not use.
29pub fn secrets_path(root: &Path) -> PathBuf {
30    root.join("secrets.json")
31}
32
33impl FileStore {
34    pub fn new(root: PathBuf) -> Self {
35        Self { root }
36    }
37
38    fn secrets_path(&self) -> PathBuf {
39        secrets_path(&self.root)
40    }
41
42    fn load(&self) -> Result<Secrets, StoreError> {
43        let p = self.secrets_path();
44        if !p.exists() {
45            return Ok(Secrets::default());
46        }
47        let text = std::fs::read_to_string(p)?;
48        serde_json::from_str(&text).map_err(|e| StoreError::Encoding(e.to_string()))
49    }
50
51    fn save(&self, s: &Secrets) -> Result<(), StoreError> {
52        let text =
53            serde_json::to_string_pretty(s).map_err(|e| StoreError::Encoding(e.to_string()))?;
54        write_private(&self.secrets_path(), text.as_bytes())
55    }
56}
57
58impl CredentialStore for FileStore {
59    fn get(&self, component: &str, key: &str) -> Result<Option<SecretRecord>, StoreError> {
60        Ok(self
61            .load()?
62            .entries
63            .get(component)
64            .and_then(|m| m.get(key))
65            .cloned())
66    }
67
68    fn put(&self, component: &str, key: &str, rec: &SecretRecord) -> Result<(), StoreError> {
69        let mut s = self.load()?;
70        s.entries
71            .entry(component.to_string())
72            .or_default()
73            .insert(key.to_string(), rec.clone());
74        self.save(&s)?;
75
76        let mut idx = Index::load(&self.root)?;
77        idx.upsert(component, rec.info(key));
78        idx.save(&self.root)
79    }
80
81    fn erase(&self, component: &str, key: &str) -> Result<(), StoreError> {
82        let mut s = self.load()?;
83        if let Some(m) = s.entries.get_mut(component) {
84            m.remove(key);
85            if m.is_empty() {
86                s.entries.remove(component);
87            }
88        }
89        self.save(&s)?;
90
91        let mut idx = Index::load(&self.root)?;
92        idx.remove(component, key);
93        idx.save(&self.root)
94    }
95
96    fn list(&self, component: Option<&str>) -> Result<Vec<SecretInfo>, StoreError> {
97        Ok(Index::load(&self.root)?.list(component))
98    }
99
100    fn components(&self) -> Result<Vec<String>, StoreError> {
101        Ok(Index::load(&self.root)?.entries.into_keys().collect())
102    }
103
104    fn update(
105        &self,
106        component: &str,
107        key: &str,
108        mutate: &mut dyn FnMut(&mut SecretRecord),
109    ) -> Result<Option<SecretRecord>, StoreError> {
110        // The lock is taken on a file beside the store rather than on the store
111        // itself: `save` replaces the store by rename, so a lock held on the
112        // old inode would protect a file nobody is writing to any more.
113        let _guard = ExclusiveLock::acquire(&self.root)?;
114
115        let mut s = self.load()?;
116        let Some(rec) = s.entries.get_mut(component).and_then(|m| m.get_mut(key)) else {
117            return Ok(None);
118        };
119        mutate(rec);
120        let updated = rec.clone();
121        self.save(&s)?;
122
123        let mut idx = Index::load(&self.root)?;
124        idx.upsert(component, updated.info(key));
125        idx.save(&self.root)?;
126        Ok(Some(updated))
127    }
128}
129
130/// An exclusive advisory lock over one credential store.
131///
132/// Released when dropped, including on panic — and by the OS if the process
133/// dies, which is what keeps a crashed `act` from wedging every other one.
134struct ExclusiveLock(std::fs::File);
135
136impl ExclusiveLock {
137    fn acquire(root: &Path) -> Result<Self, StoreError> {
138        let path = root.join("secrets.lock");
139        crate::index::create_dir_private(root)?;
140        let file = std::fs::OpenOptions::new()
141            .create(true)
142            .truncate(false)
143            .write(true)
144            .open(&path)?;
145        // Blocking, not try-and-fail: the wait is bounded by one HTTP round
146        // trip against a token endpoint, and failing here would surface as a
147        // credential error on a call that had nothing wrong with it.
148        <std::fs::File as fs4::FileExt>::lock(&file)?;
149        Ok(Self(file))
150    }
151}
152
153impl Drop for ExclusiveLock {
154    fn drop(&mut self) {
155        let _ = <std::fs::File as fs4::FileExt>::unlock(&self.0);
156    }
157}