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
39 let id = LocalIdentity {
40 machine: machine.to_string(),
41 fingerprint: fingerprint.to_string(),
42 algorithm: algorithm.to_string(),
43 attested_at_secs: unix_now_secs(),
44 self_attested: true,
45 };
46
47 let path = dir.join(format!("{machine}.json"));
48 let content = serde_json::to_string_pretty(&id).context("serializing local identity")?;
49 crate::atomic_write::write_atomic(&path, content.as_bytes())
62 .with_context(|| format!("registering local identity {}", path.display()))
63}
64
65pub fn lookup(cloud_dir: &Path, machine: &str) -> Result<Option<LocalIdentity>> {
68 let path = cloud_dir.join("identities").join(format!("{machine}.json"));
69 if !path.exists() {
70 return Ok(None);
71 }
72 let content =
73 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
74 serde_json::from_str(&content)
75 .map(Some)
76 .with_context(|| format!("parsing {}", path.display()))
77}
78
79fn unix_now_secs() -> u64 {
80 std::time::SystemTime::now()
81 .duration_since(std::time::UNIX_EPOCH)
82 .unwrap_or_default()
83 .as_secs()
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89 use tempfile::TempDir;
90
91 const MACHINE: &str = "noisetable-pdx-1";
92 const FP: &str = "SHA256:HAo2DsB7cN+GmrEbJ8SR305rJagwQhgP2dNyUemUBbU";
93 const ALGO: &str = "ssh-ed25519";
94
95 #[test]
96 fn lookup_returns_none_when_not_registered() {
97 let tmp = TempDir::new().unwrap();
98 let cloud_dir = tmp.path();
99 assert!(lookup(cloud_dir, MACHINE).unwrap().is_none());
100 }
101
102 #[test]
103 fn register_then_lookup_round_trips() {
104 let tmp = TempDir::new().unwrap();
105 let cloud_dir = tmp.path();
106 register(cloud_dir, MACHINE, FP, ALGO).unwrap();
107 let entry = lookup(cloud_dir, MACHINE).unwrap().unwrap();
108 assert_eq!(entry.machine, MACHINE);
109 assert_eq!(entry.fingerprint, FP);
110 assert_eq!(entry.algorithm, ALGO);
111 assert!(entry.self_attested);
112 assert!(entry.attested_at_secs > 0);
113 }
114
115 #[test]
116 fn register_is_idempotent_and_overwrites() {
117 let tmp = TempDir::new().unwrap();
118 let cloud_dir = tmp.path();
119 register(cloud_dir, MACHINE, FP, ALGO).unwrap();
120 let new_fp = "SHA256:ZZZnewfingerprint";
121 register(cloud_dir, MACHINE, new_fp, ALGO).unwrap();
122 let entry = lookup(cloud_dir, MACHINE).unwrap().unwrap();
123 assert_eq!(entry.fingerprint, new_fp);
124
125 let strays: Vec<_> = std::fs::read_dir(cloud_dir.join("identities"))
133 .unwrap()
134 .flatten()
135 .map(|e| e.file_name().to_string_lossy().into_owned())
136 .filter(|n| n.ends_with(".tmp"))
137 .collect();
138 assert!(strays.is_empty(), "staging files leaked: {strays:?}");
139 }
140
141 #[test]
142 fn register_creates_identities_subdir() {
143 let tmp = TempDir::new().unwrap();
144 let cloud_dir = tmp.path();
145 assert!(!cloud_dir.join("identities").exists());
147 register(cloud_dir, MACHINE, FP, ALGO).unwrap();
148 assert!(cloud_dir.join("identities").is_dir());
149 assert!(cloud_dir
150 .join("identities")
151 .join(format!("{MACHINE}.json"))
152 .exists());
153 }
154
155 #[test]
156 fn separate_machines_have_separate_files() {
157 let tmp = TempDir::new().unwrap();
158 let cloud_dir = tmp.path();
159 register(cloud_dir, "machine-a", FP, ALGO).unwrap();
160 register(cloud_dir, "machine-b", "SHA256:other", ALGO).unwrap();
161 let a = lookup(cloud_dir, "machine-a").unwrap().unwrap();
162 let b = lookup(cloud_dir, "machine-b").unwrap().unwrap();
163 assert_eq!(a.fingerprint, FP);
164 assert_eq!(b.fingerprint, "SHA256:other");
165 }
166}