1use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use std::path::Path;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct LocalIdentity {
18 pub machine: String,
20 pub fingerprint: String,
22 pub algorithm: String,
24 pub attested_at_secs: u64,
26 pub self_attested: bool,
30}
31
32pub fn register(cloud_dir: &Path, machine: &str, fingerprint: &str, algorithm: &str) -> Result<()> {
37 let dir = cloud_dir.join("identities");
38 std::fs::create_dir_all(&dir)
39 .with_context(|| format!("creating identity dir {}", dir.display()))?;
40
41 let id = LocalIdentity {
42 machine: machine.to_string(),
43 fingerprint: fingerprint.to_string(),
44 algorithm: algorithm.to_string(),
45 attested_at_secs: unix_now_secs(),
46 self_attested: true,
47 };
48
49 let path = dir.join(format!("{machine}.json"));
50 let tmp = path.with_extension("json.tmp");
51 let content = serde_json::to_string_pretty(&id).context("serializing local identity")?;
52 std::fs::write(&tmp, &content).with_context(|| format!("writing {}", tmp.display()))?;
53 std::fs::rename(&tmp, &path)
54 .with_context(|| format!("renaming {} → {}", tmp.display(), path.display()))?;
55 Ok(())
56}
57
58pub fn lookup(cloud_dir: &Path, machine: &str) -> Result<Option<LocalIdentity>> {
61 let path = cloud_dir.join("identities").join(format!("{machine}.json"));
62 if !path.exists() {
63 return Ok(None);
64 }
65 let content =
66 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
67 serde_json::from_str(&content)
68 .map(Some)
69 .with_context(|| format!("parsing {}", path.display()))
70}
71
72fn unix_now_secs() -> u64 {
73 std::time::SystemTime::now()
74 .duration_since(std::time::UNIX_EPOCH)
75 .unwrap_or_default()
76 .as_secs()
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use tempfile::TempDir;
83
84 const MACHINE: &str = "noisetable-pdx-1";
85 const FP: &str = "SHA256:HAo2DsB7cN+GmrEbJ8SR305rJagwQhgP2dNyUemUBbU";
86 const ALGO: &str = "ssh-ed25519";
87
88 #[test]
89 fn lookup_returns_none_when_not_registered() {
90 let tmp = TempDir::new().unwrap();
91 let cloud_dir = tmp.path();
92 assert!(lookup(cloud_dir, MACHINE).unwrap().is_none());
93 }
94
95 #[test]
96 fn register_then_lookup_round_trips() {
97 let tmp = TempDir::new().unwrap();
98 let cloud_dir = tmp.path();
99 register(cloud_dir, MACHINE, FP, ALGO).unwrap();
100 let entry = lookup(cloud_dir, MACHINE).unwrap().unwrap();
101 assert_eq!(entry.machine, MACHINE);
102 assert_eq!(entry.fingerprint, FP);
103 assert_eq!(entry.algorithm, ALGO);
104 assert!(entry.self_attested);
105 assert!(entry.attested_at_secs > 0);
106 }
107
108 #[test]
109 fn register_is_idempotent_and_overwrites() {
110 let tmp = TempDir::new().unwrap();
111 let cloud_dir = tmp.path();
112 register(cloud_dir, MACHINE, FP, ALGO).unwrap();
113 let new_fp = "SHA256:ZZZnewfingerprint";
114 register(cloud_dir, MACHINE, new_fp, ALGO).unwrap();
115 let entry = lookup(cloud_dir, MACHINE).unwrap().unwrap();
116 assert_eq!(entry.fingerprint, new_fp);
117 }
118
119 #[test]
120 fn register_creates_identities_subdir() {
121 let tmp = TempDir::new().unwrap();
122 let cloud_dir = tmp.path();
123 assert!(!cloud_dir.join("identities").exists());
125 register(cloud_dir, MACHINE, FP, ALGO).unwrap();
126 assert!(cloud_dir.join("identities").is_dir());
127 assert!(cloud_dir
128 .join("identities")
129 .join(format!("{MACHINE}.json"))
130 .exists());
131 }
132
133 #[test]
134 fn separate_machines_have_separate_files() {
135 let tmp = TempDir::new().unwrap();
136 let cloud_dir = tmp.path();
137 register(cloud_dir, "machine-a", FP, ALGO).unwrap();
138 register(cloud_dir, "machine-b", "SHA256:other", ALGO).unwrap();
139 let a = lookup(cloud_dir, "machine-a").unwrap().unwrap();
140 let b = lookup(cloud_dir, "machine-b").unwrap().unwrap();
141 assert_eq!(a.fingerprint, FP);
142 assert_eq!(b.fingerprint, "SHA256:other");
143 }
144}