Skip to main content

coven_foundation/files/
device_reset.rs

1//! Swapping a loaded replacement into a store's directory (§19.3).
2//!
3//! `reset_device` and `restore_device` load the new installation in its own
4//! directory inside the store's, then move it into place. The durable record
5//! names the old and new device ids and the call that began the replacement:
6//! written before the fresh device registers, it makes ordinary opens refuse
7//! the store, naming that call, and every later call finishes the same
8//! replacement (§19.3). It says, too, whether the swap has begun.
9//! The bootstrap marker separates the swap's two moves. Without it, the old
10//! local state is still moving into its archive; with it, the new installation
11//! is moving in. The settings change last. Each step is idempotent, so a retry
12//! after any interruption continues forward where it stopped.
13
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19use super::{
20    atomic_file, bootstrap, settings, DirectoryCreationError, FileError, SettingsError, StoreFile,
21};
22use crate::id_source::{DeviceId, StoreId};
23
24pub(crate) const RECORD: &str = "device-reset.json";
25const ARCHIVE_PREFIX: &str = "discarded-device-";
26/// The directory inside a store's directory where its replacement loads.
27pub(super) const AREA: &str = ".coven-replacement";
28/// Files describing the store's location and this installation's custody,
29/// which the replacement keeps in place. Everything else is local state.
30const KEPT: [StoreFile; 3] =
31    [StoreFile::StorageSettings, StoreFile::StoreKeys, StoreFile::MemberKeys];
32
33/// A replacement of this installation, not a membership removal (E5).
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
35pub struct DeviceReset {
36    /// The id whose local copy was discarded.
37    pub old_device: DeviceId,
38    /// The fresh id the replacement registers.
39    pub new_device: DeviceId,
40}
41
42/// The call that began a replacement, and so the only call that finishes it
43/// (§19.3). The other call would load under different keys, or keep or remove
44/// a different copy, so it is refused before anything changes.
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub enum ReplacedBy {
47    /// `CovenBuilder::reset_device`, which keeps the old copy for inspection.
48    Reset,
49    /// `CovenBuilder::restore_device`, which commits the restore code's member
50    /// keys and removes the old copy (§13.1).
51    Restore,
52}
53
54impl std::fmt::Display for ReplacedBy {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.write_str(match self {
57            Self::Reset => "reset_device",
58            Self::Restore => "restore_device",
59        })
60    }
61}
62
63/// How far a recorded replacement has come (§19.3).
64#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
65pub enum ReplacementPhase {
66    /// Recorded before the fresh device registers. The new installation is
67    /// in its hidden directory and the old one in the store's place.
68    Registering,
69    /// The swap has begun: the installations are moving, or have moved.
70    Swapping,
71}
72
73/// The durable record of a replacement past its point of no return: from
74/// just before the fresh device registers until it is published (§19.3).
75#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct ReplacementRecord {
78    /// The installation being replaced and its fresh successor.
79    pub reset: DeviceReset,
80    /// The call that began the replacement and finishes it.
81    pub by: ReplacedBy,
82    /// The store's name in the new installation's loaded database. While the
83    /// replacement is unfinished the store doesn't open, so listings show this.
84    pub name: String,
85    /// How far it has come.
86    pub phase: ReplacementPhase,
87}
88
89pub(super) fn read(directory: &Path) -> Result<Option<ReplacementRecord>, FileError> {
90    atomic_file::read_json(&directory.join(RECORD), "read replacement record")
91}
92
93/// The record of a replacement that has not finished: one still
94/// registering, one whose marker is still present, or one whose settings do
95/// not yet name the new device. A record that outlived publication is not
96/// returned.
97pub(super) fn unfinished(
98    directory: &Path,
99    id: StoreId,
100) -> Result<Option<ReplacementRecord>, SettingsError> {
101    let Some(record) = read(directory)? else {
102        return Ok(None);
103    };
104    if record.phase == ReplacementPhase::Swapping
105        && !bootstrap::is_pending(directory)?
106        && settings::read(directory, id)?.device_id == record.reset.new_device
107    {
108        return Ok(None);
109    }
110    Ok(Some(record))
111}
112
113/// Keep the first decision: a resumed replacement never changes the chosen
114/// fresh id. A record already there is returned as it is.
115pub(super) fn record(
116    directory: &Path,
117    record: &ReplacementRecord,
118) -> Result<ReplacementRecord, FileError> {
119    if let Some(recorded) = read(directory)? {
120        return Ok(recorded);
121    }
122    if record.reset.old_device == record.reset.new_device {
123        return Err(FileError::at(
124            "record device reset",
125            &directory.join(RECORD),
126            std::io::Error::new(
127                std::io::ErrorKind::InvalidInput,
128                "a reset needs a fresh device id",
129            ),
130        ));
131    }
132    let bytes = serde_json::to_vec(record).expect("replacement record encodes");
133    atomic_file::replace(&directory.join(RECORD), &bytes)?;
134    Ok(record.clone())
135}
136
137/// Mark the recorded replacement's swap as begun, before anything moves.
138pub(super) fn swapping(directory: &Path, recorded: &ReplacementRecord) -> Result<(), FileError> {
139    if recorded.phase == ReplacementPhase::Swapping {
140        return Ok(());
141    }
142    let record = ReplacementRecord { phase: ReplacementPhase::Swapping, ..recorded.clone() };
143    let bytes = serde_json::to_vec(&record).expect("replacement record encodes");
144    atomic_file::replace(&directory.join(RECORD), &bytes)
145}
146
147/// The directory where the replacement of the store at `directory` loads.
148pub(super) fn loading(directory: &Path, id: StoreId) -> PathBuf {
149    directory.join(AREA).join(id.to_string())
150}
151
152/// The discarded installation's local state, kept for inspection after a reset.
153fn archive(directory: &Path, old_device: DeviceId) -> PathBuf {
154    directory.join(format!("{ARCHIVE_PREFIX}{}", old_device.0))
155}
156
157/// Under the writer, bootstrap and excluded reader locks, move the old local
158/// state into its archive and the loaded installation into its place, then
159/// name the new device in the settings. Resumes any interrupted swap.
160pub(super) fn forward(
161    directory: &Path,
162    id: StoreId,
163    reset: DeviceReset,
164) -> Result<(), DirectoryCreationError> {
165    let marker = bootstrap::marker(directory);
166    if !atomic_file::exists("inspect bootstrap marker", &marker)? {
167        let archive = archive(directory, reset.old_device);
168        if !atomic_file::exists("inspect discarded device archive", &archive)? {
169            fs::create_dir(&archive).map_err(|source| {
170                FileError::at("create discarded device archive", &archive, source)
171            })?;
172        }
173        // The archive stays for inspection, so what unfinished saves left,
174        // sealed keys among it, doesn't go in. The writer lock is held.
175        atomic_file::remove_temporaries(directory)?;
176        move_local_state(directory, &archive)?;
177        atomic_file::replace(&marker, b"")?;
178    }
179    let loaded = loading(directory, id);
180    if atomic_file::exists("inspect loaded replacement", &loaded)? {
181        move_local_state(&loaded, directory)?;
182    }
183    name_device(directory, id, reset.new_device)
184}
185
186/// Remove a swap's archive: a restore keeps no old copy (§13.1).
187pub(super) fn discard(directory: &Path, old_device: DeviceId) -> Result<(), FileError> {
188    let archive = archive(directory, old_device);
189    if atomic_file::exists("inspect discarded device archive", &archive)? {
190        fs::remove_dir_all(&archive)
191            .map_err(|source| FileError::at("remove discarded device archive", &archive, source))?;
192        atomic_file::sync_publication(&archive)?;
193    }
194    Ok(())
195}
196
197/// The archives of earlier resets in the store's directory, by name. Nothing
198/// reads one once its reset's notice is in the database (§19.3).
199pub(super) fn archives(directory: &Path) -> Result<Vec<PathBuf>, FileError> {
200    let entries = fs::read_dir(directory)
201        .map_err(|source| FileError::at("list discarded device archives", directory, source))?;
202    let mut found = Vec::new();
203    for entry in entries {
204        let entry = entry
205            .map_err(|source| FileError::at("list discarded device archives", directory, source))?;
206        if entry.file_name().to_str().is_some_and(|name| name.starts_with(ARCHIVE_PREFIX)) {
207            found.push(entry.path());
208        }
209    }
210    found.sort();
211    Ok(found)
212}
213
214/// Remove every archive, then sync the directory. Removing a directory tree
215/// is not atomic, so a failure leaves the archive part-removed under its name,
216/// listed by `archives`, and a later call removes the rest.
217pub(super) fn delete_archives(directory: &Path) -> Result<(), FileError> {
218    let archives = archives(directory)?;
219    for archive in &archives {
220        match fs::remove_dir_all(archive) {
221            Ok(()) => {}
222            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
223            Err(source) => {
224                return Err(FileError::at("remove discarded device archive", archive, source))
225            }
226        }
227    }
228    if let Some(last) = archives.last() {
229        atomic_file::sync_publication(last)?;
230    }
231    Ok(())
232}
233
234/// Whether an entry stays where it is while installations swap: the custody
235/// files the replacement keeps, the settings, which change last, the swap's
236/// own record and marker, the replacement's area and earlier archives.
237fn stays(name: &str) -> bool {
238    KEPT.iter().any(|file| file.name() == name)
239        || name == settings::SETTINGS_FILE
240        || name == RECORD
241        || name == bootstrap::MARKER
242        || name == AREA
243        || name.starts_with(ARCHIVE_PREFIX)
244}
245
246/// Move every local entry of `from` into `to`, then sync both directories.
247fn move_local_state(from: &Path, to: &Path) -> Result<(), FileError> {
248    let entries = fs::read_dir(from)
249        .map_err(|source| FileError::at("list installation files", from, source))?;
250    for entry in entries {
251        let entry =
252            entry.map_err(|source| FileError::at("list installation files", from, source))?;
253        let name = entry.file_name();
254        if name.to_str().is_some_and(stays) {
255            continue;
256        }
257        let source = entry.path();
258        atomic_file::rename_new(&source, &to.join(&name))
259            .map_err(|error| FileError::at("move installation file", &source, error))?;
260    }
261    #[cfg(unix)]
262    for path in [from, to] {
263        atomic_file::sync_directory(path)
264            .map_err(|source| FileError::at("sync installation files", path, source))?;
265    }
266    Ok(())
267}
268
269fn name_device(
270    directory: &Path,
271    id: StoreId,
272    device: DeviceId,
273) -> Result<(), DirectoryCreationError> {
274    let mut current = settings::read(directory, id)?;
275    if current.device_id != device {
276        current.device_id = device;
277        settings::write(directory, &current)?;
278    }
279    Ok(())
280}
281
282/// The database a swap moved aside, in its discarded-device archive.
283pub(super) fn discarded_database(directory: &Path, old_device: DeviceId) -> PathBuf {
284    archive(directory, old_device).join(super::directory::DATABASE)
285}
286
287/// Remove the record once the replacement is published, its database
288/// keeping the notice, or once the facts end a replacement before its swap.
289pub(super) fn finish(directory: &Path) -> Result<(), FileError> {
290    atomic_file::remove(&directory.join(RECORD))
291}