Skip to main content

microvms_edges/
run_store.rs

1// SPDX-License-Identifier: Apache-2.0
2//! File storage compatible with the CLI's existing run and history documents.
3
4use std::io::{self, Write};
5use std::path::{Path, PathBuf};
6
7use microvms_app::run_store::{RunStore, StoredLedger};
8
9/// The shared state directory: run JSON at its root and history JSON Lines below it.
10/// Non-Unicode filename stems remain read-only diagnostics; a string key cannot identify
11/// them exactly, and a replacement character could name a different, writable file.
12#[derive(Clone, Debug)]
13pub struct FileRunStore {
14    root: PathBuf,
15}
16
17impl FileRunStore {
18    pub fn new(root: impl Into<PathBuf>) -> Self {
19        Self { root: root.into() }
20    }
21
22    fn ledger_path(&self, key: &str) -> io::Result<PathBuf> {
23        Self::check_component(key)?;
24        Ok(self.root.join(format!("{key}.json")))
25    }
26
27    fn history_path(&self, vm_id: &str) -> io::Result<PathBuf> {
28        Self::check_component(vm_id)?;
29        Ok(self.root.join("history").join(format!("{vm_id}.jsonl")))
30    }
31
32    fn check_component(value: &str) -> io::Result<()> {
33        // Check both platforms' separators and drive prefixes regardless of the host.
34        // The public store must not let a ledger key address the names or history namespace.
35        if value.is_empty() || matches!(value, "." | "..") || value.contains(['/', '\\', ':']) {
36            return Err(io::Error::new(
37                io::ErrorKind::InvalidInput,
38                "record key must be one file name component",
39            ));
40        }
41        Ok(())
42    }
43
44    fn prepare(path: &Path) -> io::Result<()> {
45        if let Some(parent) = path.parent() {
46            std::fs::create_dir_all(parent)?;
47        }
48        Ok(())
49    }
50}
51
52impl RunStore for FileRunStore {
53    fn ledgers(&self) -> Vec<StoredLedger> {
54        let Ok(entries) = std::fs::read_dir(&self.root) else {
55            return Vec::new();
56        };
57        entries
58            .filter_map(Result::ok)
59            .map(|entry| entry.path())
60            .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
61            .map(|path| {
62                let Some(key) = path.file_stem().and_then(|stem| stem.to_str()) else {
63                    // An empty key is rejected by every file operation. With no text, the
64                    // app also cannot reopen this entry and flush it through a lossy alias.
65                    // Debug escapes the raw filename, keeping it distinct from a real U+FFFD.
66                    return StoredLedger {
67                        key: String::new(),
68                        location: format!("{path:?}"),
69                        text: None,
70                    };
71                };
72                StoredLedger {
73                    key: key.to_owned(),
74                    location: path.to_string_lossy().into_owned(),
75                    text: std::fs::read_to_string(&path).ok(),
76                }
77            })
78            .collect()
79    }
80
81    fn write_ledger(&self, key: &str, text: &str) -> io::Result<()> {
82        let path = self.ledger_path(key)?;
83        Self::prepare(&path)?;
84        std::fs::write(path, text)
85    }
86
87    fn remove_ledger(&self, key: &str) -> io::Result<()> {
88        std::fs::remove_file(self.ledger_path(key)?)
89    }
90
91    fn read_history(&self, vm_id: &str) -> io::Result<String> {
92        std::fs::read_to_string(self.history_path(vm_id)?)
93    }
94
95    fn append_history(&self, vm_id: &str, line: &str) -> io::Result<()> {
96        let path = self.history_path(vm_id)?;
97        Self::prepare(&path)?;
98        let mut file = std::fs::OpenOptions::new()
99            .append(true)
100            .create(true)
101            .open(path)?;
102        writeln!(file, "{line}")
103    }
104}