Skip to main content

fakecloud_persistence/
version.rs

1use std::ffi::OsStr;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8pub const FORMAT_VERSION: u32 = 1;
9pub const VERSION_FILE_NAME: &str = "fakecloud.version.toml";
10
11/// Filesystem artifacts that may exist in an otherwise-empty data directory and
12/// must not count toward the "non-empty" emptiness check — e.g. ext4 always creates
13/// `lost+found` at the root of a freshly formatted volume, and NetApp exposes
14/// `.snapshot`. Dot-prefixed names are also ignored (see [`is_benign_entry`]).
15const IGNORED_DIR_ENTRIES: &[&str] = &["lost+found", ".snapshot"];
16
17/// Whether a directory entry is a benign filesystem artifact that should not make
18/// the data directory count as "non-empty".
19fn is_benign_entry(name: &OsStr) -> bool {
20    match name.to_str() {
21        Some(s) => s.starts_with('.') || IGNORED_DIR_ENTRIES.contains(&s),
22        None => false, // non-UTF8 name => treat as a real entry, be conservative
23    }
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct FormatVersion {
28    pub format_version: u32,
29    pub fakecloud_version: String,
30    pub created_at: String,
31}
32
33#[derive(Debug, Error)]
34pub enum VersionError {
35    #[error("io error at {path}: {source}")]
36    Io {
37        path: PathBuf,
38        #[source]
39        source: io::Error,
40    },
41    #[error("failed to parse {path}: {source}")]
42    Parse {
43        path: PathBuf,
44        #[source]
45        source: toml::de::Error,
46    },
47    #[error("failed to serialize version file: {0}")]
48    Serialize(#[from] toml::ser::Error),
49    #[error(
50        "persistence format version mismatch at {path}: on-disk format_version={on_disk}, binary expects {expected}. \
51         Either point --data-path at a matching directory, or delete the directory to start fresh."
52    )]
53    FormatMismatch {
54        path: PathBuf,
55        on_disk: u32,
56        expected: u32,
57    },
58    #[error(
59        "persistence data directory {dir} is not empty but has no {file} file. \
60         Refusing to initialize it: either point --data-path at an empty directory or restore the missing version file."
61    )]
62    NonEmptyDirectoryWithoutVersionFile { dir: PathBuf, file: String },
63}
64
65fn version_file_path(dir: &Path) -> PathBuf {
66    dir.join(VERSION_FILE_NAME)
67}
68
69pub fn write_version_file(dir: &Path, fakecloud_version: &str) -> Result<(), VersionError> {
70    let path = version_file_path(dir);
71    let value = FormatVersion {
72        format_version: FORMAT_VERSION,
73        fakecloud_version: fakecloud_version.to_string(),
74        created_at: chrono::Utc::now().to_rfc3339(),
75    };
76    crate::atomic::write_atomic_toml(&path, &value).map_err(|source| VersionError::Io {
77        path: path.clone(),
78        source,
79    })?;
80    Ok(())
81}
82
83pub fn check_version_file(dir: &Path) -> Result<(), VersionError> {
84    let path = version_file_path(dir);
85    if !path.exists() {
86        return Ok(());
87    }
88    let text = std::fs::read_to_string(&path).map_err(|source| VersionError::Io {
89        path: path.clone(),
90        source,
91    })?;
92    let parsed: FormatVersion = toml::from_str(&text).map_err(|source| VersionError::Parse {
93        path: path.clone(),
94        source,
95    })?;
96    if parsed.format_version != FORMAT_VERSION {
97        return Err(VersionError::FormatMismatch {
98            path,
99            on_disk: parsed.format_version,
100            expected: FORMAT_VERSION,
101        });
102    }
103    Ok(())
104}
105
106pub fn ensure_version_file(dir: &Path, fakecloud_version: &str) -> Result<(), VersionError> {
107    let path = version_file_path(dir);
108    if path.exists() {
109        return check_version_file(dir);
110    }
111    if dir.exists() {
112        let has_real_entry = std::fs::read_dir(dir)
113            .map_err(|source| VersionError::Io {
114                path: dir.to_path_buf(),
115                source,
116            })?
117            .filter_map(Result::ok)
118            .any(|entry| !is_benign_entry(&entry.file_name()));
119        if has_real_entry {
120            return Err(VersionError::NonEmptyDirectoryWithoutVersionFile {
121                dir: dir.to_path_buf(),
122                file: VERSION_FILE_NAME.to_string(),
123            });
124        }
125    }
126    write_version_file(dir, fakecloud_version)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn ensure_creates_version_file_in_empty_dir() {
135        let tmp = tempfile::tempdir().unwrap();
136        ensure_version_file(tmp.path(), "test").unwrap();
137        assert!(tmp.path().join(VERSION_FILE_NAME).exists());
138    }
139
140    #[test]
141    fn ensure_rejects_non_empty_dir_without_version_file() {
142        let tmp = tempfile::tempdir().unwrap();
143        std::fs::write(tmp.path().join("stray.txt"), b"hello").unwrap();
144        let err = ensure_version_file(tmp.path(), "test").unwrap_err();
145        matches!(
146            err,
147            VersionError::NonEmptyDirectoryWithoutVersionFile { .. }
148        );
149    }
150
151    #[test]
152    fn ensure_ok_when_dir_has_only_lost_found() {
153        let tmp = tempfile::tempdir().unwrap();
154        std::fs::create_dir(tmp.path().join("lost+found")).unwrap();
155        ensure_version_file(tmp.path(), "test").unwrap();
156        assert!(tmp.path().join(VERSION_FILE_NAME).exists());
157    }
158
159    #[test]
160    fn ensure_ok_when_dir_has_only_dotfiles() {
161        let tmp = tempfile::tempdir().unwrap();
162        std::fs::write(tmp.path().join(".DS_Store"), b"junk").unwrap();
163        std::fs::create_dir(tmp.path().join(".snapshot")).unwrap();
164        ensure_version_file(tmp.path(), "test").unwrap();
165        assert!(tmp.path().join(VERSION_FILE_NAME).exists());
166    }
167
168    #[test]
169    fn ensure_rejects_real_file_alongside_lost_found() {
170        let tmp = tempfile::tempdir().unwrap();
171        std::fs::create_dir(tmp.path().join("lost+found")).unwrap();
172        std::fs::write(tmp.path().join("data.bin"), b"real data").unwrap();
173        let err = ensure_version_file(tmp.path(), "test").unwrap_err();
174        assert!(matches!(
175            err,
176            VersionError::NonEmptyDirectoryWithoutVersionFile { .. }
177        ));
178        assert!(!tmp.path().join(VERSION_FILE_NAME).exists());
179    }
180
181    #[test]
182    fn ensure_ok_when_version_file_already_present() {
183        let tmp = tempfile::tempdir().unwrap();
184        write_version_file(tmp.path(), "test").unwrap();
185        std::fs::write(tmp.path().join("stray.txt"), b"hello").unwrap();
186        ensure_version_file(tmp.path(), "test").unwrap();
187    }
188}