rightkit_tauri_shell/
recovery.rs1use 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
29pub type PathExtractor = Arc<dyn Fn(&Value) -> Vec<String> + Send + Sync>;
31
32#[derive(Clone)]
34pub struct RecoverySpec {
35 pub name: String,
37 pub version: u64,
38 pub max_bytes: usize,
39 pub paths: PathExtractor,
41 pub exact_path: Option<PathBuf>,
43}
44
45impl RecoverySpec {
46 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 pub fn at_path(mut self, path: impl Into<PathBuf>) -> Self {
68 self.exact_path = Some(path.into());
69 self
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
75#[serde(rename_all = "snake_case")]
76pub enum MigrationOutcome {
77 NoLegacy,
79 AlreadyCurrent,
81 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 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 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
195fn 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}