1use std::path::{Path, PathBuf};
13
14use microvms_app::error::{Error, ErrorKind};
15use microvms_app::names::{NameRecord, NameStore, default_state_root, validate_name};
16
17#[derive(Clone, Debug)]
19pub struct FileNameStore {
20 dir: PathBuf,
21}
22
23impl FileNameStore {
24 pub fn new(dir: impl Into<PathBuf>) -> Self {
26 Self { dir: dir.into() }
27 }
28
29 pub fn in_state_root(state_root: &Path) -> Self {
34 Self::new(state_root.join("names"))
35 }
36
37 pub fn default_location(env: &dyn Fn(&str) -> Option<String>) -> Self {
39 Self::in_state_root(&default_state_root(env))
40 }
41
42 pub fn dir(&self) -> &Path {
44 &self.dir
45 }
46
47 pub fn path_of(&self, name: &str) -> PathBuf {
49 self.dir.join(format!("{name}.json"))
50 }
51
52 fn io(&self, action: &str, path: &Path, error: std::io::Error) -> Error {
53 Error::new(
54 ErrorKind::Precondition,
55 format!("could not {action} name record {}: {error}", path.display()),
56 )
57 .with_source(error)
58 }
59}
60
61impl NameStore for FileNameStore {
62 fn get(&self, name: &str) -> Result<Option<NameRecord>, Error> {
66 validate_name(name).map_err(Error::invalid_arg)?;
67 let path = self.path_of(name);
68 let text = match std::fs::read_to_string(&path) {
69 Ok(text) => text,
70 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
71 Err(error) => return Err(self.io("read", &path, error)),
72 };
73 serde_json::from_str(&text).map(Some).map_err(|_| {
74 Error::new(
75 ErrorKind::Precondition,
76 format!(
77 "name record {} is unreadable; the name stays taken until you inspect \
78 or delete that file",
79 path.display()
80 ),
81 )
82 })
83 }
84
85 fn put(&self, record: &NameRecord) -> Result<(), Error> {
86 validate_name(&record.name).map_err(Error::invalid_arg)?;
87 std::fs::create_dir_all(&self.dir).map_err(|error| self.io("create", &self.dir, error))?;
88 let path = self.path_of(&record.name);
89 let text = serde_json::to_string_pretty(record)
90 .map_err(|_| Error::new(ErrorKind::Unexpected, "a name record failed to serialize"))?;
91 write_private(&path, text.as_bytes()).map_err(|error| self.io("write", &path, error))
92 }
93
94 fn delete(&self, name: &str) -> Result<bool, Error> {
95 validate_name(name).map_err(Error::invalid_arg)?;
96 let path = self.path_of(name);
97 match std::fs::remove_file(&path) {
98 Ok(()) => Ok(true),
99 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
100 Err(error) => Err(self.io("delete", &path, error)),
101 }
102 }
103
104 fn describe(&self) -> String {
105 self.dir.display().to_string()
106 }
107
108 fn list(&self) -> Result<Vec<NameRecord>, Error> {
111 let entries = match std::fs::read_dir(&self.dir) {
112 Ok(entries) => entries,
113 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
114 Err(error) => return Err(self.io("list", &self.dir, error)),
115 };
116 let mut records: Vec<NameRecord> = entries
117 .filter_map(Result::ok)
118 .map(|entry| entry.path())
119 .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
120 .filter_map(|path| std::fs::read_to_string(path).ok())
121 .filter_map(|text| serde_json::from_str(&text).ok())
122 .collect();
123 records.sort_by(|a, b| a.name.cmp(&b.name));
124 Ok(records)
125 }
126}
127
128fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
135 use std::io::Write as _;
136 let mut options = std::fs::OpenOptions::new();
137 options.write(true).create(true).truncate(true);
138 #[cfg(unix)]
139 {
140 use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
141 options.mode(0o600);
142 if path.exists() {
143 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
144 }
145 }
146 let mut file = options.open(path)?;
147 file.write_all(bytes)?;
148 file.sync_all()
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use microvms_app::names::resolve;
155 use microvms_app::region::Region;
156
157 struct TempDir(PathBuf);
158
159 impl TempDir {
160 fn new(tag: &str) -> Self {
161 let unique = format!(
162 "microvms-names-{tag}-{}-{}",
163 std::process::id(),
164 std::time::SystemTime::now()
165 .duration_since(std::time::UNIX_EPOCH)
166 .map(|d| d.as_nanos())
167 .unwrap_or(0)
168 );
169 let path = std::env::temp_dir().join(unique);
170 std::fs::create_dir_all(&path).expect("temp dir");
171 Self(path)
172 }
173 }
174
175 impl Drop for TempDir {
176 fn drop(&mut self) {
177 let _ = std::fs::remove_dir_all(&self.0);
178 }
179 }
180
181 fn record(name: &str, id: &str) -> NameRecord {
182 NameRecord::new_at(
184 name,
185 id,
186 "https://vm.example",
187 "secret-token",
188 "us-east-1",
189 1_767_225_600,
190 )
191 .expect("ok")
192 }
193
194 #[test]
197 fn records_written_by_earlier_cli_versions_still_resolve() {
198 let dir = TempDir::new("compat");
199 let store = FileNameStore::in_state_root(&dir.0);
200 std::fs::create_dir_all(store.dir()).expect("dir");
201 std::fs::write(
202 store.path_of("old"),
203 r#"{
204 "name": "old",
205 "microvmId": "microvm-1",
206 "endpoint": "https://vm.example",
207 "agentToken": "tok",
208 "region": "us-east-1",
209 "at": 1789000000
210}"#,
211 )
212 .expect("write");
213 std::fs::write(
214 store.path_of("pinned"),
215 r#"{"name":"pinned","microvmId":"microvm-2","endpoint":"e","agentToken":"t",
216 "region":"us-west-2","at":1,"identityHostSeed":"c2VlZA==",
217 "identityVmPublicKey":"cHVi","egressPosture":"managed"}"#,
218 )
219 .expect("write");
220 let old = store.get("old").expect("reads").expect("present");
221 assert_eq!(
222 (old.microvm_id.as_str(), old.at, old.egress_posture),
223 ("microvm-1", 1_789_000_000, None)
224 );
225 let pinned = store.get("pinned").expect("reads").expect("present");
226 assert_eq!(pinned.identity_host_seed.as_deref(), Some("c2VlZA=="));
227 assert_eq!(pinned.egress_posture.as_deref(), Some("managed"));
228 let written = serde_json::to_value(&pinned).expect("json");
230 for key in [
231 "name",
232 "microvmId",
233 "endpoint",
234 "agentToken",
235 "region",
236 "at",
237 ] {
238 assert!(written.get(key).is_some(), "{key} missing from {written}");
239 }
240 }
241
242 #[test]
243 fn put_get_list_delete_and_release_round_trip() {
244 let dir = TempDir::new("crud");
245 let store = FileNameStore::in_state_root(&dir.0);
246 assert_eq!(store.get("ci").expect("ok"), None);
247 assert!(store.list().expect("ok").is_empty());
248 store.put(&record("ci", "microvm-a")).expect("put");
249 store.put(&record("alias", "microvm-a")).expect("put");
250 store.put(&record("other", "microvm-b")).expect("put");
251 assert_eq!(
252 store.get("ci").expect("ok").map(|r| r.microvm_id),
253 Some("microvm-a".to_string())
254 );
255 let names: Vec<_> = store
256 .list()
257 .expect("ok")
258 .into_iter()
259 .map(|r| r.name)
260 .collect();
261 assert_eq!(names, ["alias", "ci", "other"]);
262 assert_eq!(
263 store.release_by_vm("microvm-a").expect("ok"),
264 ["alias", "ci"]
265 );
266 assert!(store.delete("other").expect("ok"));
267 assert!(!store.delete("other").expect("ok"));
268 assert!(store.list().expect("ok").is_empty());
269 }
270
271 #[test]
272 fn a_torn_record_keeps_its_name_taken_without_hiding_the_others() {
273 let dir = TempDir::new("torn");
274 let store = FileNameStore::in_state_root(&dir.0);
275 store.put(&record("good", "microvm-a")).expect("put");
276 std::fs::write(store.path_of("torn"), "{\"name\": \"to").expect("write");
277 let error = store
278 .get("torn")
279 .expect_err("a torn file is not a free name");
280 assert_eq!(error.kind(), ErrorKind::Precondition);
281 assert!(error.to_string().contains("torn.json"), "{error}");
282 let names: Vec<_> = store
283 .list()
284 .expect("ok")
285 .into_iter()
286 .map(|r| r.name)
287 .collect();
288 assert_eq!(names, ["good"]);
289 }
290
291 #[test]
293 fn a_path_shaped_name_is_refused_before_it_reaches_the_filesystem() {
294 let dir = TempDir::new("illegal");
295 let store = FileNameStore::in_state_root(&dir.0);
296 assert!(store.get("../escape").is_err());
297 assert!(store.delete("../escape").is_err());
298 }
299
300 #[test]
301 fn resolve_refuses_a_missing_name_and_a_foreign_region() {
302 let dir = TempDir::new("resolve");
303 let store = FileNameStore::in_state_root(&dir.0);
304 let missing = resolve(&store, "ci", None).expect_err("no such name");
305 assert_eq!(missing.kind(), ErrorKind::Precondition);
306 assert!(missing.to_string().contains("names"), "{missing}");
307 store.put(&record("ci", "microvm-a")).expect("put");
308 assert_eq!(
309 resolve(&store, "ci", Some(&Region::UsEast1))
310 .expect("same region")
311 .microvm_id,
312 "microvm-a"
313 );
314 let foreign = resolve(&store, "ci", Some(&Region::UsWest2)).expect_err("other region");
315 assert_eq!(foreign.kind(), ErrorKind::InvalidArg);
316 assert!(!foreign.to_string().contains("secret-token"), "{foreign}");
317 }
318
319 #[cfg(unix)]
320 #[test]
321 fn a_record_file_is_owner_only_from_creation_and_after_overwrite() {
322 use std::os::unix::fs::PermissionsExt as _;
323 let dir = TempDir::new("mode");
324 let store = FileNameStore::in_state_root(&dir.0);
325 store.put(&record("ci", "microvm-a")).expect("put");
326 let mode = |p: &Path| std::fs::metadata(p).expect("meta").permissions().mode() & 0o777;
327 assert_eq!(mode(&store.path_of("ci")), 0o600);
328 std::fs::set_permissions(store.path_of("ci"), std::fs::Permissions::from_mode(0o644))
329 .expect("widen");
330 store.put(&record("ci", "microvm-b")).expect("overwrite");
331 assert_eq!(mode(&store.path_of("ci")), 0o600);
332 }
333
334 #[test]
335 fn the_default_location_is_the_clis() {
336 let env = |key: &str| match key {
337 "HOME" => Some("/home/u".to_string()),
338 _ => None,
339 };
340 assert_eq!(
341 FileNameStore::default_location(&env).dir(),
342 Path::new("/home/u/.microvm/runs/names")
343 );
344 let pinned = |key: &str| (key == "MICROVM_STATE_DIR").then(|| "/state".to_string());
345 assert_eq!(default_state_root(&pinned), PathBuf::from("/state"));
346 }
347}