aede_core/backup.rs
1//! One file holding everything Aède knows, so that a disk failure costs a scan
2//! and not a year of listening.
3//!
4//! Six independently versioned stores are nested whole: the mostly derived
5//! catalog (which also holds watched roots), costly byte-level conclusions,
6//! irreplaceable user statements, slowly re-fetchable external sources, and
7//! private account credentials, and parental policies/usage. Versions 1 and 2 carry no accounts; versions 1 through 3 carry no parental store.
8//! A version-1 envelope is still accepted: its embedded legacy conclusions
9//! are extracted into the fourth store before restoration.
10//!
11//! # A document of documents, and why that matters
12//!
13//! The six are nested **as they are**, each keeping its own `format_version`
14//! and read back by its own `from_json`. Nothing here re-encodes a catalog, so
15//! a field added to the catalog tomorrow is in the backup tomorrow, with no
16//! second writer to forget it — the fault that would otherwise be discovered
17//! the day somebody restored one.
18//!
19//! The consequence worth stating: **each store is refused on its own.** A
20//! backup written by an Aède whose catalog format has since moved still
21//! restores the notes and the fetched facts, because the catalog's version
22//! check has nothing to say about `user.json`. A single check at the top would
23//! have thrown away irreplaceable data to protect the rebuildable catalog.
24//!
25//! # It stores, it never fetches or scans
26//!
27//! Reading a backup produces documents; writing one consumes them. Nothing
28//! here touches the library, and — as everywhere in this program — nothing
29//! here touches an audio file.
30
31use std::io::Read;
32use std::path::Path;
33
34use crate::accounts::Accounts;
35use crate::conclusions::Conclusions;
36use crate::json::Json;
37use crate::model::Catalog;
38use crate::sources::Sources;
39use crate::store::StoreError;
40use crate::user::UserData;
41
42/// The version of the wrapper, which is not the version of what it wraps.
43///
44/// It changes when the *shape of the envelope* changes — a fourth store, a
45/// renamed field — and not when a store inside it moves, because each of those
46/// carries its own version and answers for itself.
47pub const BACKUP_FORMAT_VERSION: u32 = 4;
48
49/// One store inside a backup, and what this build can do with it.
50///
51/// Three states rather than an `Option`, because "the backup holds no
52/// `sources.json`" and "the backup holds one this build refuses" call for
53/// opposite things to be said and opposite things to be done. Collapsed into
54/// one, the second would restore as though the file had never existed — which
55/// is the quietest possible way to lose a year of fetching.
56#[derive(Debug, Clone)]
57pub enum Part<T> {
58 /// Held, and readable by this build.
59 Held(T),
60 /// There was no such store when the backup was made.
61 Empty,
62 /// Present, and refused by that store's own rule. Carries what it said.
63 Unreadable(String),
64}
65
66impl<T> Part<T> {
67 /// The store, when there is one this build can use.
68 pub fn held(&self) -> Option<&T> {
69 match self {
70 Part::Held(value) => Some(value),
71 _ => None,
72 }
73 }
74
75 /// Reads one nested document, keeping the three states apart.
76 ///
77 /// A missing key and a `null` are both "there was none": a store that did
78 /// not exist is written as `null` rather than left out, so that a reader
79 /// can tell a complete backup from a truncated one, and both spellings are
80 /// accepted because only one of them is this program's own.
81 fn read(
82 value: &Json,
83 key: &str,
84 parse: impl Fn(&Json) -> Result<T, StoreError>,
85 ) -> Result<Part<T>, StoreError> {
86 match value.get(key) {
87 None | Some(Json::Null) => Ok(Part::Empty),
88 Some(document) => match parse(document) {
89 Ok(store) => Ok(Part::Held(store)),
90 Err(why) => Ok(Part::Unreadable(why.to_string())),
91 },
92 }
93 }
94}
95
96/// Everything Aède knows, at one moment.
97///
98/// Not comparable, and not by omission: a `Catalog` is not `Eq` — two catalogs
99/// of the same library are equal in no useful sense — so a test that wanted to
100/// prove a round trip compares what the documents say rather than the structs.
101#[derive(Debug, Clone)]
102pub struct Backup {
103 /// When it was written, in seconds since the epoch.
104 pub made_at: u64,
105 /// The version of Aède that wrote it.
106 ///
107 /// For a reader, not for the program: nothing branches on it, because the
108 /// six stores each state their own format and a version string is a poor
109 /// substitute for that. What it answers is "which build made this", which
110 /// is the first question asked of a file that will not restore.
111 pub made_by: String,
112 /// The scanned graph and watched roots.
113 pub catalog: Part<Catalog>,
114 /// Byte-level verdicts and imported measurements, independently restorable.
115 pub conclusions: Part<Conclusions>,
116 /// What the user said. The part nothing can rebuild.
117 pub user: Part<UserData>,
118 /// What other sources said. Re-fetchable, slowly.
119 pub sources: Part<Sources>,
120 /// Private salted password verifiers and stable personal owners; no sessions.
121 pub accounts: Part<Accounts>,
122 /// Administrator-owned parental policies and conservative listening usage.
123 pub parental: Part<crate::parental::ParentalStore>,
124}
125
126impl Backup {
127 /// `true` when there is nothing in here worth writing.
128 ///
129 /// A backup of an empty data folder would restore an empty data folder, and
130 /// reporting success for it teaches the reader that the command worked —
131 /// which is exactly the belief that costs them the library later.
132 pub fn is_empty(&self) -> bool {
133 self.catalog.held().is_none()
134 && self.conclusions.held().is_none()
135 && self.user.held().is_none()
136 && self.sources.held().is_none()
137 && self.accounts.held().is_none()
138 && self.parental.held().is_none()
139 }
140}
141
142/// The envelope, with the six documents nested unchanged.
143pub fn to_json(backup: &Backup) -> Json {
144 let mut root = Json::obj();
145 root.set("format_version", BACKUP_FORMAT_VERSION.into());
146 root.set("made_at", backup.made_at.into());
147 root.set("made_by", backup.made_by.clone().into());
148 // Written even when there is nothing to write, as `null`. A key that is
149 // simply absent cannot be told from a file that was cut short.
150 root.set(
151 "catalog",
152 match backup.catalog.held() {
153 Some(catalog) => crate::store::to_json(catalog),
154 None => Json::Null,
155 },
156 );
157 root.set(
158 "conclusions",
159 match backup.conclusions.held() {
160 Some(conclusions) => crate::conclusions::to_json(conclusions),
161 None => Json::Null,
162 },
163 );
164 root.set(
165 "user",
166 match backup.user.held() {
167 Some(user) => crate::user::to_json(user),
168 None => Json::Null,
169 },
170 );
171 root.set(
172 "sources",
173 match backup.sources.held() {
174 Some(sources) => crate::sources::to_json(sources),
175 None => Json::Null,
176 },
177 );
178 root.set(
179 "accounts",
180 match backup.accounts.held() {
181 Some(accounts) => crate::accounts::to_json(accounts),
182 None => Json::Null,
183 },
184 );
185 root.set(
186 "parental",
187 match backup.parental.held() {
188 Some(parental) => crate::parental::to_json(parental),
189 None => Json::Null,
190 },
191 );
192 root
193}
194
195/// Reads back what [`to_json`] wrote.
196///
197/// Versions 1 through 4 are understood; other envelope shapes are refused rather
198/// than guessed at. A *store* inside a readable
199/// envelope is never fatal: it comes back as [`Part::Unreadable`] with what its
200/// own reader said, and the other stores remain restorable.
201pub fn from_json(value: &Json) -> Result<Backup, StoreError> {
202 let found = value.field_u32("format_version").unwrap_or(0);
203 if !matches!(found, 1..=4) {
204 return Err(StoreError::Version {
205 found,
206 expected: BACKUP_FORMAT_VERSION,
207 });
208 }
209 let catalog = Part::read(value, "catalog", crate::store::from_json)?;
210 let conclusions = if found == 1 {
211 match catalog.held() {
212 Some(catalog) => {
213 let gathered = Conclusions::from_catalog(catalog);
214 if gathered.files.is_empty() && gathered.analyses.is_empty() {
215 Part::Empty
216 } else {
217 Part::Held(gathered)
218 }
219 }
220 None => Part::Empty,
221 }
222 } else {
223 Part::read(value, "conclusions", crate::conclusions::from_json)?
224 };
225 Ok(Backup {
226 made_at: value.field_u64("made_at").unwrap_or(0),
227 made_by: value.field_str("made_by").unwrap_or_default(),
228 catalog,
229 conclusions,
230 user: Part::read(value, "user", crate::user::from_json)?,
231 sources: Part::read(value, "sources", crate::sources::from_json)?,
232 parental: if found >= 4 {
233 Part::read(value, "parental", crate::parental::from_json)?
234 } else {
235 Part::Empty
236 },
237 accounts: if found >= 3 {
238 Part::read(value, "accounts", crate::accounts::from_json)?
239 } else {
240 Part::Empty
241 },
242 })
243}
244
245/// Writes a backup through isolated temporary output and atomic replacement.
246///
247/// The parent folder must already exist. Final symbolic links and special
248/// destinations are refused; replacing a hard-link alias never writes through
249/// to its original inode. New files are private to their owner on Unix;
250/// existing destination permissions are preserved. Caller confirmation belongs
251/// to the CLI. This does not guarantee power-loss durability or protect against
252/// another process concurrently replacing ancestor directories.
253pub fn write(backup: &Backup, path: &Path) -> Result<(), StoreError> {
254 if let Part::Held(parental) = &backup.parental {
255 parental
256 .validate()
257 .map_err(|_| StoreError::Invalid("inconsistent parental backup part"))?;
258 }
259 if !matches!(backup.accounts, Part::Empty) || !matches!(backup.parental, Part::Empty) {
260 crate::accounts::check_private_file(path)?;
261 }
262 let parent = path
263 .parent()
264 .filter(|path| !path.as_os_str().is_empty())
265 .unwrap_or(Path::new("."));
266 if !std::fs::metadata(parent)?.is_dir() {
267 return Err(std::io::Error::new(
268 std::io::ErrorKind::NotADirectory,
269 "backup parent is not a folder",
270 )
271 .into());
272 }
273 // Pretty rather than compact, and deliberately: this file exists to be
274 // opened by a worried person, and half of what it is for is that they can
275 // see their notes are in there.
276 crate::atomic_file::write(path, to_json(backup).to_string_pretty().as_bytes())?;
277 Ok(())
278}
279
280/// Reads a backup from an ordinary local file, refusing blocking special files.
281/// A backup with credentials also requires a private, non-symlink source.
282/// The opened file's identity and permissions are checked, and changes during
283/// reading are refused. Reading cannot grow past its inspected length.
284pub fn read(path: &Path) -> Result<Backup, StoreError> {
285 read_with_open(path, |path| std::fs::File::open(path))
286}
287
288fn read_with_open(
289 path: &Path,
290 open: impl FnOnce(&Path) -> std::io::Result<std::fs::File>,
291) -> Result<Backup, StoreError> {
292 let path_metadata = std::fs::symlink_metadata(path)?;
293 let inspected = std::fs::metadata(path)?;
294 check_regular_source(&inspected)?;
295 let mut file = open(path)?;
296 let opened = file.metadata()?;
297 check_regular_source(&opened)?;
298 check_unchanged_source(&inspected, &opened)?;
299 let text = read_document(&mut file, opened.len())?;
300 check_unchanged_source(&opened, &file.metadata()?)?;
301 check_unchanged_source(&opened, &std::fs::metadata(path)?)?;
302 let value = crate::json::parse(&text).map_err(StoreError::Parse)?;
303 let backup = from_json(&value)?;
304 if !matches!(backup.accounts, Part::Empty) || !matches!(backup.parental, Part::Empty) {
305 crate::accounts::validate_private_read(&path_metadata, &opened)?;
306 crate::accounts::validate_private_read(&opened, &file.metadata()?)?;
307 crate::accounts::validate_private_read(&opened, &std::fs::symlink_metadata(path)?)?;
308 }
309 Ok(backup)
310}
311
312fn check_regular_source(metadata: &std::fs::Metadata) -> Result<(), StoreError> {
313 if !metadata.is_file() {
314 return Err(std::io::Error::new(
315 std::io::ErrorKind::InvalidInput,
316 "backup source is not an ordinary file",
317 )
318 .into());
319 }
320 Ok(())
321}
322
323fn check_unchanged_source(
324 inspected: &std::fs::Metadata,
325 current: &std::fs::Metadata,
326) -> Result<(), StoreError> {
327 let changed =
328 inspected.len() != current.len() || inspected.modified()? != current.modified()?;
329 #[cfg(unix)]
330 let changed = {
331 use std::os::unix::fs::MetadataExt;
332 changed || inspected.dev() != current.dev() || inspected.ino() != current.ino()
333 };
334 if changed {
335 return Err(std::io::Error::other("backup source changed while reading; retry").into());
336 }
337 Ok(())
338}
339
340fn read_document(input: &mut impl Read, expected: u64) -> Result<String, StoreError> {
341 let limit = expected.checked_add(1).ok_or_else(|| {
342 std::io::Error::new(
343 std::io::ErrorKind::InvalidData,
344 "backup source is too large",
345 )
346 })?;
347 let mut text = String::new();
348 input.take(limit).read_to_string(&mut text)?;
349 if text.len() as u64 != expected {
350 return Err(std::io::Error::other("backup source changed while reading; retry").into());
351 }
352 Ok(text)
353}
354
355#[cfg(test)]
356#[path = "backup_tests.rs"]
357mod tests;