1use 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
31pub const FORMAT_VERSION: u32 = 1;
33
34#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[non_exhaustive]
37pub struct LocalState {
38 #[serde(default)]
40 pub projects: BTreeMap<String, ProjectState>,
41 #[serde(default)]
43 pub touched: BTreeMap<String, i64>,
44 #[serde(default)]
46 pub audit: BTreeMap<String, ChainHead>,
47 #[serde(default)]
50 pub no_expiry: BTreeSet<String>,
51 #[serde(default)]
54 pub pins: BTreeMap<String, Pins>,
55 #[serde(default)]
57 pub rekey: Option<RekeyPlan>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64#[non_exhaustive]
65pub struct ProjectState {
66 pub server: String,
68 #[serde(default)]
71 pub held: Vec<String>,
72 #[serde(default)]
74 pub tree: BTreeMap<String, TreeNode>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80#[non_exhaustive]
81pub struct TreeNode {
82 pub vault_id: VaultId,
84 #[serde(default)]
87 pub sealed: bool,
88}
89
90impl TreeNode {
91 pub fn new(vault_id: VaultId, sealed: bool) -> TreeNode {
93 TreeNode { vault_id, sealed }
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(deny_unknown_fields)]
102#[non_exhaustive]
103pub struct RekeyPlan {
104 pub path: String,
106 pub kit: PathBuf,
108 pub nodes: Vec<String>,
111 pub old_ids: Vec<VaultId>,
113 pub new_ids: Vec<VaultId>,
115 #[serde(default)]
117 pub remint: Vec<String>,
118 pub step: RekeyStep,
120 #[serde(default)]
122 pub linked: bool,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
127#[serde(rename_all = "lowercase")]
128#[non_exhaustive]
129pub enum RekeyStep {
130 Planned,
132 Created,
134 Migrated,
136 Linked,
138 Retired,
140}
141
142impl RekeyStep {
143 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 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 pub fn knows(&self, path: &EnvPath) -> bool {
167 self.tree.contains_key(&path.to_string())
168 }
169
170 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 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 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 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 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 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 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
239pub trait StateStore: Send + Sync {
242 fn load(&self) -> Result<LocalState, StoreError>;
244 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#[derive(Debug, Clone)]
260pub struct FileStateStore {
261 dir: PathBuf,
262}
263
264#[derive(Default, Serialize, Deserialize)]
266#[serde(deny_unknown_fields)]
267struct ConfigFile {
268 #[serde(default, skip_serializing_if = "Option::is_none")]
271 version: Option<u32>,
272 #[serde(default)]
273 projects: BTreeMap<String, ProjectState>,
274}
275
276#[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 pub fn new(dir: impl Into<PathBuf>) -> FileStateStore {
295 FileStateStore { dir: dir.into() }
296 }
297
298 pub fn dir(&self) -> &Path {
300 &self.dir
301 }
302
303 pub fn config_path(&self) -> PathBuf {
305 self.dir.join("config.toml")
306 }
307
308 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
332pub(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#[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 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 #[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 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}