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;