1use 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-";
26pub(super) const AREA: &str = ".coven-replacement";
28const KEPT: [StoreFile; 3] =
31 [StoreFile::StorageSettings, StoreFile::StoreKeys, StoreFile::MemberKeys];
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
35pub struct DeviceReset {
36 pub old_device: DeviceId,
38 pub new_device: DeviceId,
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub enum ReplacedBy {
47 Reset,
49 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
65pub enum ReplacementPhase {
66 Registering,
69 Swapping,
71}
72
73#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct ReplacementRecord {
78 pub reset: DeviceReset,
80 pub by: ReplacedBy,
82 pub name: String,
85 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
93pub(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
113pub(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
137pub(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
147pub(super) fn loading(directory: &Path, id: StoreId) -> PathBuf {
149 directory.join(AREA).join(id.to_string())
150}
151
152fn archive(directory: &Path, old_device: DeviceId) -> PathBuf {
154 directory.join(format!("{ARCHIVE_PREFIX}{}", old_device.0))
155}
156
157pub(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 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
186pub(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
197pub(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
214pub(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
234fn 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
246fn 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, ¤t)?;
278 }
279 Ok(())
280}
281
282pub(super) fn discarded_database(directory: &Path, old_device: DeviceId) -> PathBuf {
284 archive(directory, old_device).join(super::directory::DATABASE)
285}
286
287pub(super) fn finish(directory: &Path) -> Result<(), FileError> {
290 atomic_file::remove(&directory.join(RECORD))
291}