Skip to main content

rightkit_tauri_shell/
recovery.rs

1//! Setup recovery: a crash-safe session snapshot.
2//!
3//! Donor: ViewRight `setup_recovery.rs`. The app periodically saves a JSON snapshot of its open
4//! work; after a crash it loads it back. The write is atomic (`.new` written and synced, previous
5//! file kept as `.old` until the swap succeeds), validated (`version`, size cap) and
6//! *authority-checked*: every file path the snapshot names must already be granted by the
7//! [`PathBroker`], so a snapshot can never smuggle in a path the person did not open. Loading
8//! re-grants the recovered paths. `untitled:` entries have no file and are skipped.
9//!
10//! The snapshot file is `<state>/recovery/<name>-v<version>.json` unless the spec pins an exact
11//! path with [`RecoverySpec::at_path`]. An app that moved its snapshot can migrate the old file once
12//! with [`RecoveryStore::migrate_legacy`].
13//!
14//! Cache-directory repair moved to [`crate::startup_cache`]; it is re-exported here so existing
15//! paths keep working.
16
17use std::fs::{self, OpenOptions};
18use std::io::Write;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21
22use serde_json::Value;
23
24use crate::broker::PathBroker;
25use crate::error::ShellError;
26
27pub use crate::startup_cache::{ensure_cache_dir, CacheDirOutcome};
28
29/// Lists the file paths a parsed snapshot names.
30pub type PathExtractor = Arc<dyn Fn(&Value) -> Vec<String> + Send + Sync>;
31
32/// What a recovery store accepts and how it finds file paths inside a snapshot.
33#[derive(Clone)]
34pub struct RecoverySpec {
35    /// File stem, e.g. `setup-recovery`; the default file is `<state>/recovery/<name>-v<version>.json`.
36    pub name: String,
37    pub version: u64,
38    pub max_bytes: usize,
39    /// File paths named by a parsed snapshot (anything starting `untitled:` is ignored).
40    pub paths: PathExtractor,
41    /// Exact snapshot file, used instead of the default location when set.
42    pub exact_path: Option<PathBuf>,
43}
44
45impl RecoverySpec {
46    /// ViewRight's snapshot shape: `{"version":1,"documents":[{"document":{"path":..}}]}`.
47    pub fn documents() -> Self {
48        Self {
49            name: "setup-recovery".into(),
50            version: 1,
51            max_bytes: 256 * 1024 * 1024,
52            paths: Arc::new(|v| {
53                v["documents"]
54                    .as_array()
55                    .map(|docs| {
56                        docs.iter()
57                            .filter_map(|d| d["document"]["path"].as_str().map(str::to_owned))
58                            .collect()
59                    })
60                    .unwrap_or_default()
61            }),
62            exact_path: None,
63        }
64    }
65
66    /// Store the snapshot at exactly `path` instead of the default location.
67    pub fn at_path(mut self, path: impl Into<PathBuf>) -> Self {
68        self.exact_path = Some(path.into());
69        self
70    }
71}
72
73/// Result of [`RecoveryStore::migrate_legacy`].
74#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
75#[serde(rename_all = "snake_case")]
76pub enum MigrationOutcome {
77    /// No legacy snapshot exists; nothing to do.
78    NoLegacy,
79    /// The current snapshot already exists, so the legacy file was left untouched.
80    AlreadyCurrent,
81    /// The legacy snapshot was validated, written to the current path and removed.
82    Migrated,
83}
84
85pub struct RecoveryStore {
86    spec: RecoverySpec,
87    path: PathBuf,
88}
89
90impl RecoveryStore {
91    pub fn new(spec: RecoverySpec, state_dir: &Path) -> Self {
92        let path = match &spec.exact_path {
93            Some(exact) => exact.clone(),
94            None => state_dir
95                .join("recovery")
96                .join(format!("{}-v{}.json", spec.name, spec.version)),
97        };
98        Self { spec, path }
99    }
100
101    pub fn path(&self) -> &Path {
102        &self.path
103    }
104
105    fn validate(&self, snapshot: &str) -> Result<Value, ShellError> {
106        if snapshot.len() > self.spec.max_bytes {
107            return Err(ShellError::DocumentTooLarge(
108                "setup recovery is too large".into(),
109            ));
110        }
111        let parsed: Value = serde_json::from_str(snapshot)
112            .map_err(|e| ShellError::Invalid(format!("invalid setup recovery: {e}")))?;
113        if parsed["version"].as_u64() != Some(self.spec.version) {
114            return Err(ShellError::Invalid(
115                "unsupported setup recovery version".into(),
116            ));
117        }
118        Ok(parsed)
119    }
120
121    fn file_paths(&self, parsed: &Value) -> Vec<String> {
122        (self.spec.paths)(parsed)
123            .into_iter()
124            .filter(|p| !p.starts_with("untitled:"))
125            .collect()
126    }
127
128    pub fn save(&self, snapshot: &str, broker: &PathBroker) -> Result<(), ShellError> {
129        let parsed = self.validate(snapshot)?;
130        for p in self.file_paths(&parsed) {
131            let normalized = crate::broker::normalize_existing_file(&p)?;
132            if !broker.is_granted_normalized(&normalized)? {
133                return Err(ShellError::BrokerDenied(
134                    "setup recovery path is not authorized".into(),
135                ));
136            }
137        }
138        write_atomic(&self.path, snapshot)
139    }
140
141    /// The saved snapshot, if any. Recovered file paths are re-granted (best effort: a file deleted
142    /// since the crash simply is not granted).
143    pub fn load(&self, broker: &PathBroker) -> Result<Option<String>, ShellError> {
144        let raw = match fs::read_to_string(&self.path) {
145            Ok(r) => r,
146            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
147            Err(e) => return Err(e.into()),
148        };
149        let parsed = self.validate(&raw)?;
150        for p in self.file_paths(&parsed) {
151            let _ = broker.grant_file(&p);
152        }
153        Ok(Some(raw))
154    }
155
156    /// One-time migration of a snapshot an earlier version wrote to `legacy`.
157    ///
158    /// The legacy file is validated (size and version) and copied atomically to the current path,
159    /// then removed. Authority is not re-checked here: [`RecoveryStore::save`] and
160    /// [`RecoveryStore::load`] still apply it. An invalid legacy file is an error and is left in
161    /// place. If the current snapshot already exists, the legacy file is left alone.
162    pub fn migrate_legacy(&self, legacy: &Path) -> Result<MigrationOutcome, ShellError> {
163        let raw = match fs::read_to_string(legacy) {
164            Ok(r) => r,
165            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
166                return Ok(MigrationOutcome::NoLegacy)
167            }
168            Err(e) => return Err(e.into()),
169        };
170        if self.path.exists() {
171            return Ok(MigrationOutcome::AlreadyCurrent);
172        }
173        self.validate(&raw)?;
174        write_atomic(&self.path, &raw)?;
175        let _ = fs::remove_file(legacy);
176        Ok(MigrationOutcome::Migrated)
177    }
178
179    pub fn clear(&self) -> Result<(), ShellError> {
180        for candidate in [
181            self.path.clone(),
182            sidecar(&self.path, "new"),
183            sidecar(&self.path, "old"),
184        ] {
185            match fs::remove_file(candidate) {
186                Ok(()) => {}
187                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
188                Err(e) => return Err(e.into()),
189            }
190        }
191        Ok(())
192    }
193}
194
195/// `<path>.<suffix>`, appended to the whole file name (so `x.json` gives `x.json.new`).
196fn sidecar(path: &Path, suffix: &str) -> PathBuf {
197    let mut name = path.as_os_str().to_owned();
198    name.push(".");
199    name.push(suffix);
200    PathBuf::from(name)
201}
202
203fn write_atomic(path: &Path, content: &str) -> Result<(), ShellError> {
204    let parent = path
205        .parent()
206        .ok_or_else(|| ShellError::Invalid("setup recovery path has no parent".into()))?;
207    fs::create_dir_all(parent)?;
208    let (new, old) = (sidecar(path, "new"), sidecar(path, "old"));
209    let _ = fs::remove_file(&new);
210    let _ = fs::remove_file(&old);
211    let mut f = OpenOptions::new().create_new(true).write(true).open(&new)?;
212    f.write_all(content.as_bytes())?;
213    f.sync_all()?;
214    drop(f);
215    if path.exists() {
216        fs::rename(path, &old)?;
217    }
218    if let Err(e) = fs::rename(&new, path) {
219        if old.exists() {
220            let _ = fs::rename(&old, path);
221        }
222        return Err(e.into());
223    }
224    let _ = fs::remove_file(old);
225    Ok(())
226}