Skip to main content

act_credentials/
index.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::record::SecretInfo;
7use crate::store::StoreError;
8
9/// Non-secret companion to a store that cannot enumerate itself.
10#[derive(Debug, Default, Serialize, Deserialize)]
11pub struct Index {
12    pub version: u32,
13    /// component -> key -> info
14    pub entries: BTreeMap<String, BTreeMap<String, SecretInfo>>,
15}
16
17impl Index {
18    pub fn path(root: &Path) -> PathBuf {
19        root.join("index.json")
20    }
21
22    pub fn load(root: &Path) -> Result<Self, StoreError> {
23        let p = Self::path(root);
24        if !p.exists() {
25            return Ok(Self {
26                version: 1,
27                entries: BTreeMap::new(),
28            });
29        }
30        let text = std::fs::read_to_string(p)?;
31        serde_json::from_str(&text).map_err(|e| StoreError::Encoding(e.to_string()))
32    }
33
34    pub fn save(&self, root: &Path) -> Result<(), StoreError> {
35        create_dir_private(root)?;
36        let text =
37            serde_json::to_string_pretty(self).map_err(|e| StoreError::Encoding(e.to_string()))?;
38        write_private(&Self::path(root), text.as_bytes())
39    }
40
41    pub fn upsert(&mut self, component: &str, info: SecretInfo) {
42        self.entries
43            .entry(component.to_string())
44            .or_default()
45            .insert(info.key.clone(), info);
46    }
47
48    pub fn remove(&mut self, component: &str, key: &str) {
49        if let Some(m) = self.entries.get_mut(component) {
50            m.remove(key);
51            if m.is_empty() {
52                self.entries.remove(component);
53            }
54        }
55    }
56
57    pub fn list(&self, component: Option<&str>) -> Vec<SecretInfo> {
58        match component {
59            Some(c) => self
60                .entries
61                .get(c)
62                .map(|m| m.values().cloned().collect())
63                .unwrap_or_default(),
64            None => self
65                .entries
66                .values()
67                .flat_map(|m| m.values().cloned())
68                .collect(),
69        }
70    }
71}
72
73/// `create_dir_all`, then narrow the leaf directory to `0700` on unix.
74///
75/// The records are 0600, so the *contents* are safe at any directory mode —
76/// but write permission on the directory is enough for a co-group user to
77/// `rename` their own `secrets.json` over the real one and feed a chosen
78/// credential to every component that reads this store. That is credential
79/// substitution against exactly the threat [`write_private`]'s `create_new`
80/// guard was written to close. The default root under `dirs::data_dir()`
81/// inherits the home directory's own protection; an explicit
82/// `--credentials-backend file:/srv/shared/store` inherits nothing.
83///
84/// Applied on every write rather than only on creation, so a store laid down
85/// by an earlier build heals instead of staying loose forever. A directory
86/// widened on purpose for sharing is the attack above, not a use case this
87/// backend supports.
88pub fn create_dir_private(dir: &Path) -> Result<(), StoreError> {
89    std::fs::create_dir_all(dir)?;
90    #[cfg(unix)]
91    {
92        use std::os::unix::fs::PermissionsExt;
93        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
94    }
95    Ok(())
96}
97
98/// Atomic write with restrictive permissions: temp file then rename.
99///
100/// The mode is set **as the file is created**, not chmodded afterwards.
101/// Writing at the ambient umask and tightening after leaves a window in which
102/// the plaintext temp file is world-readable — and `act secret` prints
103/// filesystem permissions as the store's only protection, so that window is a
104/// broken promise rather than a small imprecision. `rename` carries the mode
105/// with the inode, so the destination is never briefly loose either.
106///
107/// `create_new` for the same reason: the temp file must be one we created. A
108/// leftover from a crash is removed first, but anything that reappears in
109/// between (a hostile pre-created file, a symlink pointed elsewhere) makes the
110/// open fail loudly instead of writing plaintext through someone else's inode.
111pub fn write_private(path: &Path, bytes: &[u8]) -> Result<(), StoreError> {
112    use std::io::Write;
113
114    let dir = path.parent().unwrap_or_else(|| Path::new("."));
115    create_dir_private(dir)?;
116    let tmp = path.with_extension("tmp");
117
118    match std::fs::remove_file(&tmp) {
119        Ok(()) => {}
120        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
121        Err(e) => return Err(e.into()),
122    }
123
124    let mut opts = std::fs::OpenOptions::new();
125    opts.write(true).create_new(true);
126    #[cfg(unix)]
127    {
128        use std::os::unix::fs::OpenOptionsExt;
129        opts.mode(0o600);
130    }
131    let mut f = opts.open(&tmp)?;
132    f.write_all(bytes)?;
133    drop(f);
134
135    std::fs::rename(&tmp, path)?;
136    Ok(())
137}