Skip to main content

galata_vault/
state.rs

1//! Local bookkeeping, supplied by the caller.
2//!
3//! [`LocalState`] is what an owner remembers between runs, and holds no key
4//! material:
5//! - the projects: each one's pinned server, the paths whose keys are held,
6//!   and the known tree (path → vault id, and whether the key is sealed);
7//! - when each vault was last kept alive, and which servers announce no
8//!   expiry;
9//! - the audit heads verified, and the pins (descriptor and last-seen record
10//!   versions) that catch a server rolling back;
11//! - a rekey in progress, so it can be resumed or aborted.
12//!
13//! [`FileStateStore`] keeps it in the `config.toml` and `state.toml` of a
14//! directory, exactly as `gv` always has, so an existing `gv` home loads
15//! unchanged. On Unix both files are written with mode 0600; elsewhere they
16//! are written without mode bits.
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::path::{Path, PathBuf};
20
21use crate::proto::audit::ChainHead;
22use crate::proto::ids::VaultId;
23use crate::proto::path::EnvPath;
24use serde::de::DeserializeOwned;
25use serde::{Deserialize, Serialize};
26
27use crate::error::{Error, code};
28use crate::store::StoreError;
29use crate::vault::Pins;
30
31/// The local state format this crate reads and writes.
32pub const FORMAT_VERSION: u32 = 1;
33
34/// Everything an owner remembers between runs. No key material, ever.
35#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[non_exhaustive]
37pub struct LocalState {
38    /// Project name → its pinned server, held paths and known tree.
39    #[serde(default)]
40    pub projects: BTreeMap<String, ProjectState>,
41    /// Vault id (hex) → when this client last kept it alive.
42    #[serde(default)]
43    pub touched: BTreeMap<String, i64>,
44    /// Vault id (hex) → the audit head this client last verified.
45    #[serde(default)]
46    pub audit: BTreeMap<String, ChainHead>,
47    /// Vault ids whose server announced no expiry at their last status;
48    /// keep-alive skips them.
49    #[serde(default)]
50    pub no_expiry: BTreeSet<String>,
51    /// Vault id (hex) → the descriptor and latest record versions this
52    /// client saw there.
53    #[serde(default)]
54    pub pins: BTreeMap<String, Pins>,
55    /// A re-root in progress, if any.
56    #[serde(default)]
57    pub rekey: Option<RekeyPlan>,
58}
59
60/// One project: its pinned server, the paths whose keys are held, and the
61/// known tree.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64#[non_exhaustive]
65pub struct ProjectState {
66    /// Pinned at init, import or recover; never taken from anywhere else.
67    pub server: String,
68    /// Paths whose keys are in the key store: the project root, or nodes
69    /// imported from delegation kits.
70    #[serde(default)]
71    pub held: Vec<String>,
72    /// The known tree, path → vault. Refreshed by rediscovery.
73    #[serde(default)]
74    pub tree: BTreeMap<String, TreeNode>,
75}
76
77/// A known environment's vault.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80#[non_exhaustive]
81pub struct TreeNode {
82    /// Its vault id.
83    pub vault_id: VaultId,
84    /// Its key is sealed in its parent's children record (after a rekey)
85    /// rather than derived.
86    #[serde(default)]
87    pub sealed: bool,
88}
89
90impl TreeNode {
91    /// A node of the tree.
92    pub fn new(vault_id: VaultId, sealed: bool) -> TreeNode {
93        TreeNode { vault_id, sealed }
94    }
95}
96
97/// Where a re-root stands, so an interrupted one can be resumed or aborted.
98/// It holds no key: the old and new root keys wait in the key store, and the
99/// new one is in the kit the caller stored.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(deny_unknown_fields)]
102#[non_exhaustive]
103pub struct RekeyPlan {
104    /// The re-rooted node.
105    pub path: String,
106    /// Where the caller stored the new kit, before any change on the server.
107    pub kit: PathBuf,
108    /// The subtree, relative to `path` ("" is the node itself), parents
109    /// before children.
110    pub nodes: Vec<String>,
111    /// Each node's vault before the re-root, in `nodes` order.
112    pub old_ids: Vec<VaultId>,
113    /// Each node's vault after it, in `nodes` order.
114    pub new_ids: Vec<VaultId>,
115    /// The tokens that die with the old vaults: "path  scope  id".
116    #[serde(default)]
117    pub remint: Vec<String>,
118    /// The last step completed.
119    pub step: RekeyStep,
120    /// Whether the parent's children record now seals the new key.
121    #[serde(default)]
122    pub linked: bool,
123}
124
125/// The last step a re-root completed.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
127#[serde(rename_all = "lowercase")]
128#[non_exhaustive]
129pub enum RekeyStep {
130    /// The kit is stored and the keys are in the key store; nothing changed yet.
131    Planned,
132    /// Every new vault exists.
133    Created,
134    /// Every record is copied, and every new children record written.
135    Migrated,
136    /// The parent points at the new key, or the node is detached.
137    Linked,
138    /// Every old vault is deleted.
139    Retired,
140}
141
142impl RekeyStep {
143    /// The step's name, as it appears in `state.toml`.
144    pub fn as_str(self) -> &'static str {
145        match self {
146            RekeyStep::Planned => "planned",
147            RekeyStep::Created => "created",
148            RekeyStep::Migrated => "migrated",
149            RekeyStep::Linked => "linked",
150            RekeyStep::Retired => "retired",
151        }
152    }
153}
154
155impl ProjectState {
156    /// A project pinned to `server`, with nothing held or known yet.
157    pub fn new(server: impl Into<String>) -> ProjectState {
158        ProjectState {
159            server: server.into(),
160            held: Vec::new(),
161            tree: BTreeMap::new(),
162        }
163    }
164
165    /// Whether `path` is in the known tree.
166    pub fn knows(&self, path: &EnvPath) -> bool {
167        self.tree.contains_key(&path.to_string())
168    }
169
170    /// The deepest held path at or above `path`.
171    pub fn held_root_for(&self, path: &EnvPath) -> Option<EnvPath> {
172        self.held
173            .iter()
174            .filter_map(|h| h.parse::<EnvPath>().ok())
175            .filter(|h| path.starts_with(h))
176            .max_by_key(EnvPath::depth)
177    }
178
179    /// Known paths strictly below `path`, deepest first.
180    pub fn descendants(&self, path: &EnvPath) -> Vec<EnvPath> {
181        let mut out: Vec<EnvPath> = self
182            .tree
183            .keys()
184            .filter_map(|k| k.parse::<EnvPath>().ok())
185            .filter(|k| k != path && k.starts_with(path))
186            .collect();
187        out.sort_by_key(|p| std::cmp::Reverse(p.depth()));
188        out
189    }
190
191    /// Known paths directly below `path`.
192    pub fn children_of(&self, path: &EnvPath) -> Vec<EnvPath> {
193        self.descendants(path)
194            .into_iter()
195            .filter(|p| p.depth() == path.depth() + 1)
196            .collect()
197    }
198}
199
200impl LocalState {
201    /// The project `path` belongs to.
202    pub fn project(&self, path: &EnvPath) -> Result<&ProjectState, Error> {
203        let name = path.project_name().as_str();
204        self.projects.get(name).ok_or_else(|| unknown_project(name))
205    }
206
207    /// The project `path` belongs to, to change.
208    pub fn project_mut(&mut self, path: &EnvPath) -> Result<&mut ProjectState, Error> {
209        let name = path.project_name().as_str().to_owned();
210        self.projects
211            .get_mut(&name)
212            .ok_or_else(|| unknown_project(&name))
213    }
214
215    /// Every known path across all projects.
216    pub fn known_paths(&self) -> Vec<String> {
217        self.projects
218            .values()
219            .flat_map(|p| p.tree.keys().cloned())
220            .collect()
221    }
222
223    /// The server of the project whose tree holds `vault`.
224    pub fn server_for_vault(&self, vault: &VaultId) -> Option<&str> {
225        self.projects
226            .values()
227            .find(|p| p.tree.values().any(|n| n.vault_id == *vault))
228            .map(|p| p.server.as_str())
229    }
230}
231
232fn unknown_project(name: &str) -> Error {
233    Error::local(
234        code::UNKNOWN_PROJECT,
235        format!("no project {name:?} is configured here"),
236    )
237}
238
239/// Keeps [`LocalState`]. `load` of a store that holds nothing yet returns the
240/// default (empty) state.
241pub trait StateStore: Send + Sync {
242    /// The state as last saved.
243    fn load(&self) -> Result<LocalState, StoreError>;
244    /// Save the whole state.
245    fn save(&self, state: &LocalState) -> Result<(), StoreError>;
246}
247
248impl<S: StateStore + ?Sized> StateStore for std::sync::Arc<S> {
249    fn load(&self) -> Result<LocalState, StoreError> {
250        (**self).load()
251    }
252
253    fn save(&self, state: &LocalState) -> Result<(), StoreError> {
254        (**self).save(state)
255    }
256}
257
258/// `config.toml` and `state.toml` in one directory: `gv`'s layout.
259#[derive(Debug, Clone)]
260pub struct FileStateStore {
261    dir: PathBuf,
262}
263
264/// `config.toml`: which projects exist, their servers and trees.
265#[derive(Default, Serialize, Deserialize)]
266#[serde(deny_unknown_fields)]
267struct ConfigFile {
268    /// Implicit (1) when absent, and never written until the format changes,
269    /// so older builds keep reading files this one wrote.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    version: Option<u32>,
272    #[serde(default)]
273    projects: BTreeMap<String, ProjectState>,
274}
275
276/// `state.toml`: bookkeeping that changes on every run.
277#[derive(Default, Serialize, Deserialize)]
278#[serde(deny_unknown_fields)]
279struct StateFile {
280    #[serde(default)]
281    touched: BTreeMap<String, i64>,
282    #[serde(default)]
283    audit: BTreeMap<String, ChainHead>,
284    #[serde(default)]
285    no_expiry: BTreeSet<String>,
286    #[serde(default)]
287    pins: BTreeMap<String, Pins>,
288    #[serde(default)]
289    rekey: Option<RekeyPlan>,
290}
291
292impl FileStateStore {
293    /// The store in `dir` (created on the first save).
294    pub fn new(dir: impl Into<PathBuf>) -> FileStateStore {
295        FileStateStore { dir: dir.into() }
296    }
297
298    /// The directory.
299    pub fn dir(&self) -> &Path {
300        &self.dir
301    }
302
303    /// `<dir>/config.toml`.
304    pub fn config_path(&self) -> PathBuf {
305        self.dir.join("config.toml")
306    }
307
308    /// `<dir>/state.toml`.
309    pub fn state_path(&self) -> PathBuf {
310        self.dir.join("state.toml")
311    }
312}
313
314fn load_toml<T: Default + DeserializeOwned>(path: &Path) -> Result<T, StoreError> {
315    match std::fs::read_to_string(path) {
316        Ok(text) => toml::from_str(&text)
317            .map_err(|e| StoreError::with_source(format!("reading {}: {e}", path.display()), e)),
318        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
319        Err(e) => Err(StoreError::with_source(
320            format!("reading {}: {e}", path.display()),
321            e,
322        )),
323    }
324}
325
326fn save_toml<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
327    let text = toml::to_string(value)
328        .map_err(|e| StoreError::with_source(format!("writing {}: {e}", path.display()), e))?;
329    write_private(path, text.as_bytes())
330}
331
332/// Write `bytes` to `path` via a temporary file and a rename; on Unix the
333/// file never exists with a mode other than 0600.
334pub(crate) fn write_private(path: &Path, bytes: &[u8]) -> Result<(), StoreError> {
335    use std::io::Write;
336    let io = |what: &str, p: &Path, e: std::io::Error| {
337        StoreError::with_source(format!("{what} {}: {e}", p.display()), e)
338    };
339    if let Some(dir) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
340        std::fs::create_dir_all(dir).map_err(|e| io("creating", dir, e))?;
341    }
342    let tmp = path.with_extension(format!(
343        "{}.tmp",
344        path.extension()
345            .map(|e| e.to_string_lossy().into_owned())
346            .unwrap_or_default()
347    ));
348    let _ = std::fs::remove_file(&tmp);
349    {
350        let mut options = std::fs::OpenOptions::new();
351        options.write(true).create_new(true);
352        #[cfg(unix)]
353        {
354            use std::os::unix::fs::OpenOptionsExt;
355            options.mode(0o600);
356        }
357        let mut f = options.open(&tmp).map_err(|e| io("creating", &tmp, e))?;
358        f.write_all(bytes).map_err(|e| io("writing", &tmp, e))?;
359        f.sync_all().map_err(|e| io("writing", &tmp, e))?;
360    }
361    std::fs::rename(&tmp, path).map_err(|e| io("writing", path, e))
362}
363
364impl StateStore for FileStateStore {
365    fn load(&self) -> Result<LocalState, StoreError> {
366        let config: ConfigFile = load_toml(&self.config_path())?;
367        if let Some(v) = config.version
368            && v != FORMAT_VERSION
369        {
370            return Err(StoreError::new(format!(
371                "{} is local state format {v}, which this build does not read (it reads {FORMAT_VERSION})",
372                self.config_path().display()
373            )));
374        }
375        let state: StateFile = load_toml(&self.state_path())?;
376        Ok(LocalState {
377            projects: config.projects,
378            touched: state.touched,
379            audit: state.audit,
380            no_expiry: state.no_expiry,
381            pins: state.pins,
382            rekey: state.rekey,
383        })
384    }
385
386    fn save(&self, state: &LocalState) -> Result<(), StoreError> {
387        save_toml(
388            &self.config_path(),
389            &ConfigFile {
390                version: None,
391                projects: state.projects.clone(),
392            },
393        )?;
394        save_toml(
395            &self.state_path(),
396            &StateFile {
397                touched: state.touched.clone(),
398                audit: state.audit.clone(),
399                no_expiry: state.no_expiry.clone(),
400                pins: state.pins.clone(),
401                rekey: state.rekey.clone(),
402            },
403        )
404    }
405}
406
407/// State in memory, for tests (feature `test-util`).
408#[cfg(feature = "test-util")]
409#[derive(Debug, Default)]
410pub struct MemoryStateStore {
411    state: std::sync::Mutex<LocalState>,
412}
413
414#[cfg(feature = "test-util")]
415impl MemoryStateStore {
416    /// An empty store.
417    pub fn new() -> MemoryStateStore {
418        MemoryStateStore::default()
419    }
420}
421
422#[cfg(feature = "test-util")]
423impl StateStore for MemoryStateStore {
424    fn load(&self) -> Result<LocalState, StoreError> {
425        Ok(self.state.lock().unwrap_or_else(|p| p.into_inner()).clone())
426    }
427
428    fn save(&self, state: &LocalState) -> Result<(), StoreError> {
429        *self.state.lock().unwrap_or_else(|p| p.into_inner()) = state.clone();
430        Ok(())
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::proto::ids::Hash32;
438
439    fn p(s: &str) -> EnvPath {
440        s.parse().unwrap()
441    }
442
443    #[test]
444    fn roundtrip_and_tree_queries() {
445        let dir = tempfile::tempdir().unwrap();
446        let store = FileStateStore::new(dir.path());
447        let mut project = ProjectState::new("https://vault.example");
448        project.held = vec!["acme".into(), "acme/prod/eu".into()];
449        for (i, path) in ["acme", "acme/prod", "acme/prod/eu", "acme/dev"]
450            .into_iter()
451            .enumerate()
452        {
453            project
454                .tree
455                .insert(path.into(), TreeNode::new(VaultId([i as u8; 16]), false));
456        }
457        let mut state = LocalState::default();
458        state.projects.insert("acme".into(), project);
459        store.save(&state).unwrap();
460        let back = store.load().unwrap();
461        assert_eq!(back, state);
462        let project = back.project(&p("acme/dev")).unwrap();
463
464        assert_eq!(
465            project.held_root_for(&p("acme/prod/eu")),
466            Some(p("acme/prod/eu"))
467        );
468        assert_eq!(project.held_root_for(&p("acme/prod")), Some(p("acme")));
469        assert_eq!(
470            project.children_of(&p("acme")),
471            [p("acme/dev"), p("acme/prod")]
472        );
473        assert_eq!(project.descendants(&p("acme"))[0], p("acme/prod/eu"));
474        assert_eq!(
475            back.project(&p("other/dev")).unwrap_err().code(),
476            code::UNKNOWN_PROJECT
477        );
478        #[cfg(unix)]
479        {
480            use std::os::unix::fs::PermissionsExt;
481            for f in [store.config_path(), store.state_path()] {
482                let mode = std::fs::metadata(&f).unwrap().permissions().mode() & 0o777;
483                assert_eq!(mode, 0o600, "{}", f.display());
484            }
485        }
486        let text = std::fs::read_to_string(store.config_path()).unwrap();
487        assert!(!text.contains("version"), "the version stays implicit");
488    }
489
490    /// A home as `gv` wrote it before this crate kept it: every project,
491    /// tree node, keep-alive time, audit head, pin and rekey plan loads.
492    #[test]
493    fn a_home_written_by_gv_loads_unchanged() {
494        let dir = tempfile::tempdir().unwrap();
495        let a = "aa".repeat(16);
496        let b = "bb".repeat(16);
497        let hash = "cd".repeat(32);
498        std::fs::write(
499            dir.path().join("config.toml"),
500            format!(
501                "[projects.acme]\nserver = \"https://vault.example\"\nheld = [\"acme\"]\n\n\
502                 [projects.acme.tree.acme]\nvault_id = \"{a}\"\n\n\
503                 [projects.acme.tree.\"acme/dev\"]\nvault_id = \"{b}\"\nsealed = true\n"
504            ),
505        )
506        .unwrap();
507        std::fs::write(
508            dir.path().join("state.toml"),
509            format!(
510                "no_expiry = [\"{b}\"]\n\n[touched]\n{a} = 1757500000\n\n\
511                 [audit.{a}]\nseq = 7\nhash = \"{hash}\"\n\n\
512                 [pins.{a}.descriptor]\ngeneration = 2\nhash = \"{hash}\"\n\n\
513                 [rekey]\npath = \"acme/dev\"\nkit = \"/tmp/acme-dev.gvkit\"\nnodes = [\"\"]\n\
514                 old_ids = [\"{b}\"]\nnew_ids = [\"{a}\"]\nstep = \"created\"\n"
515            ),
516        )
517        .unwrap();
518        let state = FileStateStore::new(dir.path()).load().unwrap();
519        let acme = &state.projects["acme"];
520        assert_eq!(acme.server, "https://vault.example");
521        assert_eq!(acme.held, ["acme"]);
522        assert!(acme.tree["acme/dev"].sealed && !acme.tree["acme"].sealed);
523        assert_eq!(state.touched[&a], 1_757_500_000);
524        assert_eq!(state.audit[&a].seq, 7);
525        assert_eq!(state.audit[&a].hash, Hash32::from_hex(&hash).unwrap());
526        assert_eq!(state.pins[&a].descriptor.unwrap().generation, 2);
527        assert!(state.no_expiry.contains(&b));
528        let plan = state.rekey.as_ref().unwrap();
529        assert_eq!(
530            (plan.path.as_str(), plan.step),
531            ("acme/dev", RekeyStep::Created)
532        );
533
534        // Saved again, it reads back the same, and an unknown key is refused
535        // as before.
536        let again = tempfile::tempdir().unwrap();
537        FileStateStore::new(again.path()).save(&state).unwrap();
538        assert_eq!(FileStateStore::new(again.path()).load().unwrap(), state);
539        std::fs::write(dir.path().join("config.toml"), "surprise = 1\n").unwrap();
540        assert!(FileStateStore::new(dir.path()).load().is_err());
541        std::fs::write(dir.path().join("config.toml"), "version = 2\n").unwrap();
542        let e = FileStateStore::new(dir.path()).load().unwrap_err();
543        assert!(e.message().contains("format 2"), "{e}");
544    }
545}