Skip to main content

kernoid_sync/
lib.rs

1// SPDX-License-Identifier: MIT
2//! kernoid-sync: a folder kept in step with a file store of kernoid (RFC 0005) – the logic every one of kernoid's apps
3//! shares, whatever shows the files (a folder, the system's file manager through a native shell).
4//!
5//! One round ([`Folder::sync`]): the store's changes since the state last read (`FileNode/changes`; the whole store
6//! when there is no state or it is too old), the folder scanned (size and modification time tell a change), both
7//! compared with what they were when they last agreed ([`plan`]), and the actions carried out. A change on both sides
8//! keeps both: the server's version takes the name, the local one becomes a copy beside it. The local state is a
9//! SQLite database outside the folder ([`state`]).
10//!
11//! Moves are carried over as moves ([`plan`]). A download that breaks off goes on where it stopped (an HTTP range); a
12//! large upload goes in pieces of 8 MiB joined on the server (`Blob/upload`, RFC 9404), and one that breaks off goes on
13//! with the pieces not sent yet. Vaults (RFC 0006, 6) are kept in step with the account's key unlocked
14//! ([`Folder::sync_with`]): names and contents are decrypted here and encrypted before they go; while a vault's key is
15//! not at hand, the vault and what is in it are left as they are on both sides. Names a file system cannot hold – on
16//! Windows its reserved names and characters, and where case does not tell names apart a second name that differs
17//! only in case – are left out ([`Names`]).
18//!
19//! OPEN: content hashes to tell a change that keeps size and time; vault contents in pieces (a file there is encrypted
20//! in memory, as in the web app); file ids on Windows (moves there are a deletion and a creation).
21
22pub mod plan;
23pub mod state;
24
25use std::{
26    collections::BTreeMap,
27    fmt,
28    path::{Path, PathBuf},
29    time::UNIX_EPOCH,
30};
31
32use kernoid_client::{
33    Client, FILENODE,
34    e2e::E2e,
35    files::{self, FileNode},
36};
37use serde_json::{Value, json};
38use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
39
40use plan::{Action, Base, Local, RelPath, Remote};
41use state::{Node, Pieces, State};
42
43/// Changes asked for at once.
44const MAX_CHANGES: u64 = 500;
45/// The suffix of a download not finished yet; such files are not part of the folder.
46const PARTIAL: &str = ".kernoid-part";
47/// Files larger than this go up in pieces of this size, each of which can be sent again alone.
48pub const PIECE: u64 = 8 << 20;
49/// Pieces older than this are sent again: the server keeps uploads for a day.
50const PIECES_KEPT: i64 = 20 * 3600;
51
52/// Which names the folder's file system can hold.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct Names {
55    /// Windows' rules: no `<>:"|?*` or control characters, no name ending in a dot or space, none of its reserved
56    /// names (`CON`, `NUL`, `COM1` …, with any extension).
57    pub windows: bool,
58    /// Names that differ only in case are the same name (Windows, macOS by default): of two such, the second is left
59    /// out.
60    pub ignore_case: bool,
61}
62
63impl Names {
64    /// The rules of the system this runs on, as its file systems are by default.
65    pub fn of_this_system() -> Names {
66        Names { windows: cfg!(windows), ignore_case: cfg!(any(windows, target_os = "macos", target_os = "ios")) }
67    }
68
69    /// Whether a name of the store can be a name in the folder.
70    pub fn usable(&self, name: &str) -> bool {
71        if name.is_empty() || name == "." || name == ".." || name.len() > 255 {
72            return false;
73        }
74        if name.contains(['/', '\\', '\0']) || name.ends_with(PARTIAL) {
75            return false;
76        }
77        if self.windows {
78            if name.chars().any(|c| c < ' ' || "<>:\"|?*".contains(c)) || name.ends_with(['.', ' ']) {
79                return false;
80            }
81            let stem = name.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase();
82            let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
83                || ((stem.starts_with("COM") || stem.starts_with("LPT"))
84                    && stem.len() == 4
85                    && stem.as_bytes()[3].is_ascii_digit()
86                    && stem.as_bytes()[3] != b'0');
87            if reserved {
88                return false;
89            }
90        }
91        true
92    }
93}
94
95impl Default for Names {
96    fn default() -> Names {
97        Names::of_this_system()
98    }
99}
100
101#[derive(Debug)]
102pub enum Error {
103    /// Talking to kernoid.
104    Client(kernoid_client::Error),
105    /// The local state.
106    State(String),
107    /// The folder.
108    Io(std::io::Error),
109}
110
111impl fmt::Display for Error {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match self {
114            Self::Client(error) => write!(f, "{error}"),
115            Self::State(error) => write!(f, "the local state: {error}"),
116            Self::Io(error) => write!(f, "{error}"),
117        }
118    }
119}
120
121impl std::error::Error for Error {}
122
123impl From<kernoid_client::Error> for Error {
124    fn from(error: kernoid_client::Error) -> Self {
125        Self::Client(error)
126    }
127}
128
129impl From<std::io::Error> for Error {
130    fn from(error: std::io::Error) -> Self {
131        Self::Io(error)
132    }
133}
134
135pub type Result<T> = std::result::Result<T, Error>;
136
137/// What a round did.
138#[derive(Debug, Clone, Default, PartialEq, Eq)]
139pub struct Report {
140    pub downloaded: usize,
141    pub uploaded: usize,
142    pub deleted_here: usize,
143    pub deleted_there: usize,
144    pub directories: usize,
145    /// Moved here because they were moved on the server, or the other way round.
146    pub moved: usize,
147    /// Local files kept as copies beside the server's version.
148    pub conflicts: Vec<RelPath>,
149    /// Actions that failed, with why; the next round tries them again.
150    pub failed: Vec<(RelPath, String)>,
151    /// Nodes of the store left out: names a folder cannot hold.
152    pub skipped: Vec<String>,
153    /// Vaults left as they are, their key not at hand (their ids).
154    pub locked: Vec<String>,
155}
156
157/// A folder kept in step with one store (an account's own files, a drive, or files shared with the user).
158#[derive(Debug)]
159pub struct Folder {
160    root: PathBuf,
161    store: String,
162    state: State,
163    names: Names,
164}
165
166impl Folder {
167    /// The folder at `root` for the store `store` (a JMAP account id), with its state in `state` (made if missing).
168    pub fn open(root: impl Into<PathBuf>, store: &str, state: &Path) -> Result<Folder> {
169        let root = root.into();
170        std::fs::create_dir_all(&root)?;
171        let state = State::open(state)?;
172        match state.meta("store")? {
173            Some(known) if known != store => {
174                return Err(Error::State(format!("this state belongs to the store {known}, not {store}")));
175            }
176            Some(_) => {}
177            None => state.set_meta("store", store)?,
178        }
179        Ok(Folder { root, store: store.to_owned(), state, names: Names::default() })
180    }
181
182    /// Other rules for names than the system's (a file system of another kind).
183    pub fn set_names(&mut self, names: Names) {
184        self.names = names;
185    }
186
187    pub fn root(&self) -> &Path {
188        &self.root
189    }
190
191    /// One round: reads what changed on both sides and brings them into step. Vaults are left as they are.
192    pub async fn sync(&mut self, client: &Client) -> Result<Report> {
193        self.sync_with(client, None).await
194    }
195
196    /// One round, with the account's end-to-end key (RFC 0006): vaults whose key it opens are kept in step too.
197    pub async fn sync_with(&mut self, client: &Client, mut e2e: Option<&mut E2e>) -> Result<Report> {
198        let mut report = Report::default();
199        let mut nodes = self.read_store(client, e2e.as_deref_mut()).await?;
200        let remote = tree_with(&nodes, self.names, &mut report.skipped);
201        let mut local = scan(&self.root)?;
202        let mut base = self.state.base()?;
203        // Vaults whose key is not at hand stay as they are on both sides: what was known of them is kept, nothing
204        // in them is compared.
205        let frozen = frozen(&nodes, &base, &mut report.locked);
206        let kept: BTreeMap<RelPath, Base> = base.extract_if(.., |path, _| inside(path, &frozen)).collect();
207        local.retain(|path, _| !inside(path, &frozen));
208        let actions = plan::plan(&base, &paths(&remote), &local);
209        let mut trash = None;
210        // Paths changed on the server by this round: their node is read again at the end.
211        let mut touched: Vec<RelPath> = Vec::new();
212        let mut downloading = Vec::new();
213        for action in actions {
214            let path = action_path(&action).to_owned();
215            if let Action::Download { path, .. } = &action {
216                downloading.push(path.clone());
217            }
218            let result =
219                self.run(client, e2e.as_deref_mut(), &action, &remote, &mut base, &mut trash, &mut touched).await;
220            match result {
221                Ok(()) => count(&mut report, &action),
222                Err(error) => report.failed.push((path, error.to_string())),
223            }
224        }
225        // Partial downloads nothing wants any more.
226        for path in self.state.downloads()? {
227            if !downloading.contains(&path) {
228                let _ = std::fs::remove_file(partial_of(&self.local(&path)));
229                self.state.set_download(&path, None)?;
230            }
231        }
232        // Read the store again: what this round made there gets the server's ids and blobs. Changes by others in
233        // the meantime stay with their old base, so the next round sees them.
234        if !touched.is_empty() {
235            nodes = self.read_store(client, e2e).await?;
236            let after = tree_with(&nodes, self.names, &mut Vec::new());
237            for path in touched {
238                if let (Some((node, _)), Some(entry)) = (after.get(&path), base.get_mut(&path)) {
239                    entry.remote = remote_of(node);
240                }
241            }
242        }
243        base.extend(kept);
244        self.state.set_base(&base)?;
245        Ok(report)
246    }
247
248    /// The store's tree, brought up to date with its changes – or read whole; names in vaults opened where the key
249    /// is at hand.
250    async fn read_store(&mut self, client: &Client, e2e: Option<&mut E2e>) -> Result<BTreeMap<String, Node>> {
251        let mut nodes = self.state.remote()?;
252        let mut since = self.state.meta("state")?.filter(|state| !state.is_empty());
253        let state = loop {
254            let Some(state) = since.clone() else {
255                let snapshot = files::snapshot(client, &self.store).await?;
256                nodes = snapshot.nodes.iter().map(|node| (node.id.clone(), node_of(node))).collect();
257                break snapshot.state;
258            };
259            match files::changes(client, &self.store, &state, MAX_CHANGES).await {
260                Ok(changes) => {
261                    for id in &changes.destroyed {
262                        nodes.remove(id);
263                    }
264                    for node in &changes.changed {
265                        nodes.insert(node.id.clone(), node_of(node));
266                    }
267                    if !changes.has_more_changes {
268                        break changes.new_state;
269                    }
270                    self.state.set_remote(&nodes, &changes.new_state)?;
271                    since = Some(changes.new_state);
272                }
273                Err(kernoid_client::Error::Method { kind, .. }) if kind == "cannotCalculateChanges" => since = None,
274                Err(error) => return Err(error.into()),
275            }
276        };
277        if let Some(e2e) = e2e {
278            for node in nodes.values_mut().filter(|node| node.locked) {
279                let (Some(vault), Some(sealed)) = (node.vault.clone(), node.sealed_name.clone()) else { continue };
280                if let Some(name) = e2e.open_name(client, &self.store, &vault, &sealed).await {
281                    node.name = name;
282                    node.locked = false;
283                }
284            }
285        }
286        self.state.set_remote(&nodes, &state)?;
287        Ok(nodes)
288    }
289
290    #[allow(clippy::too_many_arguments)]
291    async fn run(
292        &self,
293        client: &Client,
294        e2e: Option<&mut E2e>,
295        action: &Action,
296        remote: &BTreeMap<RelPath, (Node, Remote)>,
297        base: &mut BTreeMap<RelPath, Base>,
298        trash: &mut Option<Option<String>>,
299        touched: &mut Vec<RelPath>,
300    ) -> Result<()> {
301        match action {
302            Action::KeepLocalCopy(path) => {
303                let from = self.local(path);
304                tokio::fs::rename(&from, conflict_copy(&from)).await?;
305                base.remove(path);
306            }
307            Action::DeleteLocal(path) => {
308                let at = self.local(path);
309                match tokio::fs::symlink_metadata(&at).await {
310                    Ok(meta) if meta.is_dir() => tokio::fs::remove_dir_all(&at).await?,
311                    Ok(_) => tokio::fs::remove_file(&at).await?,
312                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
313                    Err(error) => return Err(error.into()),
314                }
315                forget(base, path);
316            }
317            Action::MakeLocalDirectory(path) => {
318                let at = self.local(path);
319                tokio::fs::create_dir_all(&at).await?;
320                let (_, node) = remote.get(path).ok_or_else(|| gone(path))?;
321                base.insert(path.clone(), Base { remote: node.clone(), local: stat(&at)? });
322            }
323            Action::Download { path, id, blob } => {
324                let (node, known) = remote.get(path).filter(|(n, _)| &n.id == id).ok_or_else(|| gone(path))?;
325                let at = self.local(path);
326                match (&node.vault, e2e) {
327                    (Some(vault), Some(e2e)) => self.download_sealed(client, e2e, vault, blob, &at).await?,
328                    (Some(_), None) => return Err(locked()),
329                    (None, _) => self.download(client, path, blob, &at).await?,
330                }
331                base.insert(path.clone(), Base { remote: known.clone(), local: stat(&at)? });
332            }
333            Action::MakeRemoteDirectory(path) => {
334                let (parent, name) = self.parent_and_name(path, remote, base)?;
335                let vault = vault_of(plan::parent(path), remote, base);
336                let id = match (&vault, &parent, e2e) {
337                    (Some(vault), Some(parent), Some(e2e)) => {
338                        e2e.make_directory_in(client, &self.store, vault, parent, &name).await?
339                    }
340                    (Some(_), _, _) => return Err(locked()),
341                    (None, _, _) => {
342                        let create =
343                            json!({ "create": { "d": { "@type": "Directory", "parentId": parent, "name": name } } });
344                        let result = client.set(&[FILENODE], "FileNode", &self.store, create, json!({})).await?;
345                        created_id(&result)?
346                    }
347                };
348                let local = stat(&self.local(path))?;
349                let remote = Remote { id, directory: true, blob: None, size: 0, vault };
350                base.insert(path.clone(), Base { remote, local });
351                touched.push(path.clone());
352            }
353            Action::Upload { path, id } => {
354                let at = self.local(path);
355                // The state of the file before it is read: a change while it travels shows in the next round.
356                let local = stat(&at)?;
357                let name = path.rsplit('/').next().unwrap_or(path).to_owned();
358                let kind = files::media_type(&name);
359                let vault = vault_of(plan::parent(path), remote, base);
360                let mut e2e = e2e;
361                let upload = match (&vault, e2e.as_deref_mut()) {
362                    (Some(vault), Some(e2e)) => {
363                        let data = tokio::fs::read(&at).await?;
364                        let sealed = e2e.seal_content(client, &self.store, vault, data).await?;
365                        let size = sealed.len() as u64;
366                        client
367                            .upload(
368                                &self.store,
369                                "application/octet-stream",
370                                std::io::Cursor::new(sealed),
371                                size,
372                                |_, _| {},
373                            )
374                            .await?
375                    }
376                    (Some(_), None) => return Err(locked()),
377                    (None, _) => self.upload(client, path, &at, &local, kind).await?,
378                };
379                let node_id = match id {
380                    Some(id) => {
381                        let update =
382                            json!({ "update": { id.as_str(): { "blobId": upload.blob_id, "mediaType": kind } } });
383                        client.set(&[FILENODE], "FileNode", &self.store, update, json!({})).await?;
384                        id.clone()
385                    }
386                    None => {
387                        let (parent, _) = self.parent_and_name(path, remote, base)?;
388                        let mut create = json!({
389                            "@type": "File", "parentId": parent, "name": name, "blobId": upload.blob_id,
390                            "mediaType": kind,
391                        });
392                        if let (Some(vault), Some(e2e)) = (&vault, e2e) {
393                            let (opaque, sealed) = e2e.name_in(client, &self.store, vault, &name).await?;
394                            create["name"] = json!(opaque);
395                            create["kernoid:encryptedName"] = json!(sealed);
396                        }
397                        let create = json!({ "create": { "f": create } });
398                        let result = client.set(&[FILENODE], "FileNode", &self.store, create, json!({})).await?;
399                        created_id(&result)?
400                    }
401                };
402                let remote =
403                    Remote { id: node_id, directory: false, blob: Some(upload.blob_id), size: upload.size, vault };
404                base.insert(path.clone(), Base { remote, local });
405                touched.push(path.clone());
406            }
407            Action::DeleteRemote { path, id } => {
408                if trash.is_none() {
409                    *trash = Some(files::trash(client, &self.store).await?.map(|node| node.id));
410                }
411                match trash.as_ref().and_then(Option::as_ref) {
412                    Some(trash) => files::move_to(client, &self.store, id, Some(trash)).await?,
413                    None => files::destroy(client, &self.store, id).await?,
414                }
415                forget(base, path);
416            }
417            Action::Adopt(path) => {
418                let (_, node) = remote.get(path).ok_or_else(|| gone(path))?;
419                base.insert(path.clone(), Base { remote: node.clone(), local: stat(&self.local(path))? });
420            }
421            Action::MoveLocal { from, to } => {
422                let target = self.local(to);
423                if let Some(parent) = target.parent() {
424                    tokio::fs::create_dir_all(parent).await?;
425                }
426                tokio::fs::rename(self.local(from), &target).await?;
427                plan::rename(base, from, to);
428            }
429            Action::MoveRemote { from, to, id } => {
430                let (parent, name) = self.parent_and_name(to, remote, base)?;
431                let vault = base.get(from).and_then(|entry| entry.remote.vault.clone());
432                match (&vault, &parent, e2e) {
433                    // A vault's root moves like any directory, its name encrypted.
434                    (Some(vault), parent, Some(e2e)) if vault == id => {
435                        let (opaque, sealed) = e2e.name_in(client, &self.store, vault, &name).await?;
436                        let update = json!({ "update": { id.as_str(): {
437                            "parentId": parent, "name": opaque, "kernoid:encryptedName": sealed,
438                        } } });
439                        client.set(&[FILENODE], "FileNode", &self.store, update, json!({})).await?;
440                    }
441                    (Some(vault), Some(parent), Some(e2e)) => {
442                        e2e.move_in(client, &self.store, vault, id, parent, &name).await?;
443                    }
444                    (Some(_), _, _) => return Err(locked()),
445                    (None, _, _) => {
446                        let update = json!({ "update": { id.as_str(): { "parentId": parent, "name": name } } });
447                        client.set(&[FILENODE], "FileNode", &self.store, update, json!({})).await?;
448                    }
449                }
450                plan::rename(base, from, to);
451                touched.push(to.clone());
452            }
453        }
454        Ok(())
455    }
456
457    /// Downloads a blob to a path through its partial file – going on with one that broke off for the same blob.
458    async fn download(&self, client: &Client, path: &str, blob: &str, at: &Path) -> Result<()> {
459        let partial = partial_of(at);
460        let resume = self.state.download(path)?.as_deref() == Some(blob);
461        let from = if resume { tokio::fs::metadata(&partial).await.map(|m| m.len()).unwrap_or(0) } else { 0 };
462        let mut file = if from > 0 {
463            tokio::fs::OpenOptions::new().append(true).open(&partial).await?
464        } else {
465            tokio::fs::File::create(&partial).await?
466        };
467        self.state.set_download(path, Some(blob))?;
468        let name = path.rsplit('/').next().unwrap_or(path);
469        let result = client
470            .download_from(&self.store, blob, name, "application/octet-stream", from, &mut file, &mut |_, _| {})
471            .await;
472        match result {
473            Ok(_) => {}
474            // The range was not served (the server's content changed under the same blob, or it does not do
475            // ranges): from the start next time.
476            Err(error @ kernoid_client::Error::Status(..)) if from > 0 => {
477                drop(file);
478                let _ = tokio::fs::remove_file(&partial).await;
479                self.state.set_download(path, None)?;
480                return Err(error.into());
481            }
482            Err(error) => {
483                // What came is kept for the next round.
484                let _ = file.flush().await;
485                return Err(error.into());
486            }
487        }
488        file.sync_all().await?;
489        drop(file);
490        tokio::fs::rename(&partial, at).await?;
491        self.state.set_download(path, None)?;
492        Ok(())
493    }
494
495    /// Downloads a file of a vault, decrypted here (in memory, as the SDK does).
496    async fn download_sealed(&self, client: &Client, e2e: &mut E2e, vault: &str, blob: &str, at: &Path) -> Result<()> {
497        let mut sealed = Vec::new();
498        client
499            .download(&self.store, blob, "encrypted", "application/octet-stream", &mut sealed, &mut |_, _| {})
500            .await?;
501        let data = e2e.open_content(client, &self.store, vault, sealed).await?;
502        let partial = partial_of(at);
503        let mut file = tokio::fs::File::create(&partial).await?;
504        file.write_all(&data).await?;
505        file.sync_all().await?;
506        drop(file);
507        tokio::fs::rename(&partial, at).await?;
508        Ok(())
509    }
510
511    /// Uploads a file: at once when small, else in pieces – those of an upload that broke off are not sent again
512    /// while the file is unchanged and they are fresh.
513    async fn upload(
514        &self,
515        client: &Client,
516        path: &str,
517        at: &Path,
518        local: &Local,
519        kind: &str,
520    ) -> Result<kernoid_client::Upload> {
521        if local.size <= PIECE {
522            let file = tokio::fs::File::open(at).await?;
523            return Ok(client.upload(&self.store, kind, file, local.size, |_, _| {}).await?);
524        }
525        let now = chrono::Utc::now().timestamp();
526        let mut pieces = self
527            .state
528            .upload(path)?
529            .filter(|p| p.size == local.size && p.modified == local.modified && now - p.started < PIECES_KEPT)
530            .unwrap_or(Pieces { size: local.size, modified: local.modified, blobs: Vec::new(), started: now });
531        let mut file = tokio::fs::File::open(at).await?;
532        let count = local.size.div_ceil(PIECE);
533        while (pieces.blobs.len() as u64) < count {
534            let start = pieces.blobs.len() as u64 * PIECE;
535            let length = PIECE.min(local.size - start);
536            file.seek(std::io::SeekFrom::Start(start)).await?;
537            let mut piece = Vec::with_capacity(length as usize);
538            (&mut file).take(length).read_to_end(&mut piece).await?;
539            if piece.len() as u64 != length {
540                return Err(Error::Io(std::io::Error::other("the file changed while it was sent")));
541            }
542            let sent = client
543                .upload(&self.store, "application/octet-stream", std::io::Cursor::new(piece), length, |_, _| {})
544                .await?;
545            pieces.blobs.push(sent.blob_id);
546            self.state.set_upload(path, Some(&pieces))?;
547        }
548        let joined = client.join(&self.store, kind, &pieces.blobs).await;
549        // Joined or not, these pieces are done with: a failure (pieces gone) starts afresh next round.
550        self.state.set_upload(path, None)?;
551        Ok(joined?)
552    }
553
554    fn local(&self, path: &str) -> PathBuf {
555        path.split('/').fold(self.root.clone(), |at, part| at.join(part))
556    }
557
558    /// The id of a path's parent in the store – `None` at the top – and its name.
559    fn parent_and_name(
560        &self,
561        path: &str,
562        remote: &BTreeMap<RelPath, (Node, Remote)>,
563        base: &BTreeMap<RelPath, Base>,
564    ) -> Result<(Option<String>, String)> {
565        let Some((parent, name)) = path.rsplit_once('/') else { return Ok((None, path.to_owned())) };
566        let id = base
567            .get(parent)
568            .map(|entry| entry.remote.id.clone())
569            .or_else(|| remote.get(parent).map(|(node, _)| node.id.clone()))
570            .ok_or_else(|| gone(parent))?;
571        Ok((Some(id), name.to_owned()))
572    }
573}
574
575fn gone(path: &str) -> Error {
576    Error::State(format!("{path}: not in the store (any more)"))
577}
578
579fn locked() -> Error {
580    Error::Client(kernoid_client::Error::E2e("the vault's key is not at hand".into()))
581}
582
583fn partial_of(at: &Path) -> PathBuf {
584    PathBuf::from(format!("{}{PARTIAL}", at.display()))
585}
586
587/// The vault a path lies in, from what this round knows: what it made (`base`) or what the store has.
588fn vault_of(
589    mut path: &str,
590    remote: &BTreeMap<RelPath, (Node, Remote)>,
591    base: &BTreeMap<RelPath, Base>,
592) -> Option<String> {
593    while !path.is_empty() {
594        if let Some(entry) = base.get(path) {
595            return entry.remote.vault.clone();
596        }
597        if let Some((_, node)) = remote.get(path) {
598            return node.vault.clone();
599        }
600        path = plan::parent(path);
601    }
602    None
603}
604
605/// The paths (as last agreed) of vaults, and of what lies in them, whose names are not open: their key not at hand.
606fn frozen(nodes: &BTreeMap<String, Node>, base: &BTreeMap<RelPath, Base>, locked: &mut Vec<String>) -> Vec<RelPath> {
607    let mut vaults: Vec<&str> =
608        nodes.values().filter(|node| node.locked).filter_map(|node| node.vault.as_deref()).collect();
609    vaults.sort_unstable();
610    vaults.dedup();
611    locked.extend(vaults.iter().map(|vault| (*vault).to_owned()));
612    // A vault's root as known before, by its id.
613    base.iter().filter(|(_, entry)| vaults.contains(&entry.remote.id.as_str())).map(|(path, _)| path.clone()).collect()
614}
615
616/// Whether a path is one of `roots` or lies below one.
617fn inside(path: &str, roots: &[RelPath]) -> bool {
618    roots.iter().any(|root| path == root || path.strip_prefix(root.as_str()).is_some_and(|rest| rest.starts_with('/')))
619}
620
621fn action_path(action: &Action) -> &str {
622    match action {
623        Action::MakeLocalDirectory(path)
624        | Action::MakeRemoteDirectory(path)
625        | Action::DeleteLocal(path)
626        | Action::KeepLocalCopy(path)
627        | Action::Adopt(path) => path,
628        Action::Download { path, .. } | Action::Upload { path, .. } | Action::DeleteRemote { path, .. } => path,
629        Action::MoveLocal { to, .. } | Action::MoveRemote { to, .. } => to,
630    }
631}
632
633fn count(report: &mut Report, action: &Action) {
634    match action {
635        Action::Download { .. } => report.downloaded += 1,
636        Action::Upload { .. } => report.uploaded += 1,
637        Action::DeleteLocal(_) => report.deleted_here += 1,
638        Action::DeleteRemote { .. } => report.deleted_there += 1,
639        Action::MakeLocalDirectory(_) | Action::MakeRemoteDirectory(_) => report.directories += 1,
640        Action::KeepLocalCopy(path) => report.conflicts.push(path.clone()),
641        Action::MoveLocal { .. } | Action::MoveRemote { .. } => report.moved += 1,
642        Action::Adopt(_) => {}
643    }
644}
645
646/// Forgets a path and what lies below it.
647fn forget(base: &mut BTreeMap<RelPath, Base>, path: &str) {
648    let prefix = format!("{path}/");
649    base.retain(|known, _| known != path && !known.starts_with(&prefix));
650}
651
652fn created_id(result: &Value) -> Result<String> {
653    result
654        .get("created")
655        .and_then(Value::as_object)
656        .and_then(|created| created.values().next())
657        .and_then(|node| node.get("id"))
658        .and_then(Value::as_str)
659        .map(str::to_owned)
660        .ok_or_else(|| Error::Client(kernoid_client::Error::Protocol("no id for what was made".into())))
661}
662
663fn node_of(node: &FileNode) -> Node {
664    Node {
665        id: node.id.clone(),
666        parent: node.parent_id.clone(),
667        name: node.name.clone(),
668        directory: node.is_directory(),
669        blob: node.blob_id.clone(),
670        size: node.size.unwrap_or_default(),
671        role: node.role.clone(),
672        vault: node.vault.clone(),
673        sealed_name: node.encrypted_name.clone(),
674        // Opened when the key is at hand (`read_store`).
675        locked: node.vault.is_some() && node.encrypted_name.is_some(),
676    }
677}
678
679fn remote_of(node: &Node) -> Remote {
680    Remote {
681        id: node.id.clone(),
682        directory: node.directory,
683        blob: node.blob.clone(),
684        size: node.size,
685        vault: node.vault.clone(),
686    }
687}
688
689/// The store's nodes by path – without the trash and what is in it, without vaults whose names are not open, and
690/// without names the folder cannot hold (they go to `skipped`, with what is below them).
691pub fn tree(nodes: &BTreeMap<String, Node>, skipped: &mut Vec<String>) -> BTreeMap<RelPath, (Node, Remote)> {
692    tree_with(nodes, Names::default(), skipped)
693}
694
695/// [`tree`] with the rules for names of another file system.
696pub fn tree_with(
697    nodes: &BTreeMap<String, Node>,
698    names: Names,
699    skipped: &mut Vec<String>,
700) -> BTreeMap<RelPath, (Node, Remote)> {
701    // Where case does not tell names apart, the first of a parent's children by id keeps a name; the others are left
702    // out.
703    let mut taken: BTreeMap<(Option<&str>, String), &str> = BTreeMap::new();
704    if names.ignore_case {
705        for node in nodes.values() {
706            taken.entry((node.parent.as_deref(), node.name.to_lowercase())).or_insert(node.id.as_str());
707        }
708    }
709    let fits = |node: &Node| {
710        names.usable(&node.name)
711            && (!names.ignore_case
712                || taken.get(&(node.parent.as_deref(), node.name.to_lowercase())) == Some(&node.id.as_str()))
713    };
714    let mut out = BTreeMap::new();
715    for node in nodes.values() {
716        let mut parts = Vec::new();
717        let mut at = Some(node);
718        let mut ok = true;
719        while let Some(current) = at {
720            if current.role.as_deref() == Some("trash") || current.locked || !fits(current) || parts.len() > 64 {
721                ok = false;
722                if !current.locked && !fits(current) && current.id == node.id {
723                    skipped.push(node.name.clone());
724                }
725                break;
726            }
727            parts.push(current.name.as_str());
728            at = current.parent.as_ref().and_then(|parent| nodes.get(parent));
729            if at.is_none() && current.parent.is_some() {
730                // A parent the user cannot see: the node is not reachable from the top.
731                ok = false;
732            }
733        }
734        if ok {
735            parts.reverse();
736            out.insert(parts.join("/"), (node.clone(), remote_of(node)));
737        }
738    }
739    out
740}
741
742fn paths(tree: &BTreeMap<RelPath, (Node, Remote)>) -> BTreeMap<RelPath, Remote> {
743    tree.iter().map(|(path, (_, remote))| (path.clone(), remote.clone())).collect()
744}
745
746fn stat(path: &Path) -> Result<Local> {
747    let meta = std::fs::symlink_metadata(path)?;
748    let modified = meta
749        .modified()
750        .ok()
751        .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
752        .map_or(0, |since| since.as_nanos() as i128);
753    Ok(Local {
754        directory: meta.is_dir(),
755        size: if meta.is_dir() { 0 } else { meta.len() },
756        modified,
757        file: file_id(&meta),
758    })
759}
760
761/// The file's id on its file system: the inode on Unix; none elsewhere yet (**OPEN**: Windows' file index).
762#[cfg(unix)]
763fn file_id(meta: &std::fs::Metadata) -> u64 {
764    std::os::unix::fs::MetadataExt::ino(meta)
765}
766
767#[cfg(not(unix))]
768fn file_id(_: &std::fs::Metadata) -> u64 {
769    0
770}
771
772/// The files and directories of a folder by path; links, unfinished downloads and other kinds are left out.
773pub fn scan(root: &Path) -> Result<BTreeMap<RelPath, Local>> {
774    let mut out = BTreeMap::new();
775    let mut stack = vec![(root.to_path_buf(), String::new())];
776    while let Some((dir, prefix)) = stack.pop() {
777        for entry in std::fs::read_dir(&dir)? {
778            let entry = entry?;
779            let Some(name) = entry.file_name().to_str().map(str::to_owned) else { continue };
780            if name.is_empty() || name.ends_with(PARTIAL) {
781                continue;
782            }
783            let kind = entry.file_type()?;
784            if !kind.is_dir() && !kind.is_file() {
785                continue;
786            }
787            let path = if prefix.is_empty() { name } else { format!("{prefix}/{name}") };
788            out.insert(path.clone(), stat(&entry.path())?);
789            if kind.is_dir() {
790                stack.push((entry.path(), path));
791            }
792        }
793    }
794    Ok(out)
795}
796
797/// `report (conflict 2026-10-30 1412).txt` beside `report.txt` – a name not taken yet.
798pub fn conflict_copy(path: &Path) -> PathBuf {
799    let stem = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
800    let extension = path.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
801    let when = chrono::Local::now().format("%Y-%m-%d %H%M");
802    let mut candidate = path.with_file_name(format!("{stem} (conflict {when}){extension}"));
803    let mut n = 2;
804    while candidate.exists() {
805        candidate = path.with_file_name(format!("{stem} (conflict {when} {n}){extension}"));
806        n += 1;
807    }
808    candidate
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    fn node(id: &str, parent: Option<&str>, name: &str, directory: bool) -> (String, Node) {
816        (
817            id.into(),
818            Node {
819                id: id.into(),
820                parent: parent.map(Into::into),
821                name: name.into(),
822                directory,
823                blob: (!directory).then(|| format!("b-{id}")),
824                size: 1,
825                role: None,
826                vault: None,
827                sealed_name: None,
828                locked: false,
829            },
830        )
831    }
832
833    #[test]
834    fn a_round_runs_on_any_thread() {
835        fn send<T: Send>(_: T) {}
836        fn shared<T: Send + Sync>() {}
837        shared::<Folder>();
838        let client: Option<&Client> = None;
839        if let Some(client) = client {
840            let mut folder = Folder::open(std::env::temp_dir(), "x", Path::new("unused.db")).unwrap();
841            send(async move { folder.sync_with(client, None).await });
842        }
843    }
844
845    #[test]
846    fn the_tree_by_path_without_the_trash_and_bad_names() {
847        let mut nodes: BTreeMap<String, Node> = [
848            node("1", None, "Projects", true),
849            node("2", Some("1"), "plan.txt", false),
850            node("3", None, "Trash", true),
851            node("4", Some("3"), "old.txt", false),
852            node("5", None, "..", false),
853            node("6", Some("9"), "orphan.txt", false),
854        ]
855        .into_iter()
856        .collect();
857        nodes.get_mut("3").unwrap().role = Some("trash".into());
858        let mut skipped = Vec::new();
859        let tree = tree_with(&nodes, Names { windows: false, ignore_case: false }, &mut skipped);
860        assert_eq!(tree.keys().collect::<Vec<_>>(), ["Projects", "Projects/plan.txt"]);
861        assert_eq!(skipped, [".."]);
862    }
863
864    #[test]
865    fn names_a_file_system_cannot_hold_are_left_out() {
866        let nodes: BTreeMap<String, Node> = [
867            node("1", None, "Notes.txt", false),
868            node("2", None, "notes.TXT", false),
869            node("3", None, "a:b.txt", false),
870            node("4", None, "CON.txt", false),
871            node("5", None, "trailing.", false),
872            node("6", None, "COM0", false),
873        ]
874        .into_iter()
875        .collect();
876        let mut skipped = Vec::new();
877        let unix = tree_with(&nodes, Names { windows: false, ignore_case: false }, &mut skipped);
878        assert_eq!(unix.len(), 6);
879        let mut skipped = Vec::new();
880        let windows = tree_with(&nodes, Names { windows: true, ignore_case: true }, &mut skipped);
881        assert_eq!(windows.keys().collect::<Vec<_>>(), ["COM0", "Notes.txt"]);
882        assert_eq!(skipped, ["notes.TXT", "a:b.txt", "CON.txt", "trailing."]);
883        let names = Names { windows: true, ignore_case: true };
884        assert!(!names.usable("lpt1") && !names.usable("aux.tar.gz") && !names.usable("a\u{1}b"));
885        assert!(names.usable("COM10") && names.usable("console.txt"));
886    }
887
888    #[test]
889    fn a_locked_vault_is_left_out() {
890        let mut nodes: BTreeMap<String, Node> =
891            [node("1", None, "k-1", true), node("2", Some("1"), "k-2", false), node("3", None, "open.txt", false)]
892                .into_iter()
893                .collect();
894        for id in ["1", "2"] {
895            let node = nodes.get_mut(id).unwrap();
896            node.vault = Some("1".into());
897            node.locked = true;
898        }
899        let tree = tree_with(&nodes, Names { windows: false, ignore_case: false }, &mut Vec::new());
900        assert_eq!(tree.keys().collect::<Vec<_>>(), ["open.txt"]);
901        let base = BTreeMap::from([(
902            "Vault".to_owned(),
903            Base {
904                remote: Remote { id: "1".into(), directory: true, blob: None, size: 0, vault: Some("1".into()) },
905                local: Local { directory: true, size: 0, modified: 0, file: 0 },
906            },
907        )]);
908        let mut locked = Vec::new();
909        assert_eq!(frozen(&nodes, &base, &mut locked), ["Vault"]);
910        assert_eq!(locked, ["1"]);
911        assert!(inside("Vault/a", &["Vault".into()]) && !inside("Vaults", &["Vault".into()]));
912    }
913
914    #[test]
915    fn a_folder_scanned_and_a_conflict_copy_named() {
916        let root = std::env::temp_dir().join(format!("kernoid-sync-scan-{}", std::process::id()));
917        std::fs::create_dir_all(root.join("D")).unwrap();
918        std::fs::write(root.join("D/a.txt"), "abc").unwrap();
919        std::fs::write(root.join(format!("b.txt{PARTIAL}")), "half").unwrap();
920        let found = scan(&root).unwrap();
921        assert_eq!(found.keys().collect::<Vec<_>>(), ["D", "D/a.txt"]);
922        assert_eq!((found["D/a.txt"].size, found["D"].directory), (3, true));
923        let copy = conflict_copy(&root.join("D/a.txt"));
924        let name = copy.file_name().unwrap().to_string_lossy().into_owned();
925        assert!(name.starts_with("a (conflict ") && name.ends_with(").txt"), "{name}");
926        std::fs::remove_dir_all(&root).unwrap();
927    }
928}