fakecloud_persistence/
version.rs1use 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
11const IGNORED_DIR_ENTRIES: &[&str] = &["lost+found", ".snapshot"];
16
17fn 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, }
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}