Skip to main content

rightkit_daemon/
app_root.rs

1//! The per-app data root `<platform data dir>/Orthic Labs/<App>`, next to `Orthic Labs/shared`.
2//!
3//! [`AppRoot`] is built from a [`Suite`], so it honours exactly the root rules the suite already
4//! has (`Suite::resolve`, `per_user_default`, `resolve_ignoring_env_overrides`, `at`,
5//! `per_run_temp`); it adds no environment selector. When the suite root is the canonical
6//! `.../Orthic Labs/shared`, the app root is the sibling `.../Orthic Labs/<App>`. For any other
7//! suite root (an override, a test, a per-run temp) it is `<suite root>/apps/<App>`, so isolation
8//! is preserved.
9//!
10//! Also here: path-escape-proof subpaths, a single-writer lock, and a restart-safe
11//! copy-verify-commit migration from an old root. The old root is never modified. Migration is
12//! strict by default (symlinks, junctions and special files are refused); [`MigrateOptions`] opts
13//! in to copying symlinks as links, skipping sockets and FIFOs, and skipping (never following)
14//! Windows junctions and other reparse points.
15//!
16//! # Completion states and markers
17//!
18//! A migration is in one of three [`MigrationState`]s, written in the versioned state file
19//! [`MIGRATION_STATE`] (version 2; the unversioned `{source, files, bytes}` marker is version 1):
20//!
21//! * `Incomplete`: written before the destination is first touched and left in place by any
22//!   interruption or failure. The old root stays the authority; the destination is not usable. A
23//!   rerun verifies and finishes the whole copy.
24//! * `ReadyWithOmissions`: every required entry is in place; only entries the caller declared
25//!   disposable were omitted (optional links that could not be created, reparse points skipped on
26//!   request). Every omission is recorded. A rerun retries the omitted links only; it does not
27//!   re-verify files the app may have changed since.
28//! * `Ready`: everything migrated.
29//!
30//! The historical marker [`MIGRATION_MARKER`] is written only for `Ready`, after the state file, so
31//! a reader of an earlier version never takes an incomplete or partial destination for a finished
32//! move.
33
34use std::fmt;
35use std::fs::{self, File};
36use std::io::{self, Read};
37use std::path::{Component, Path, PathBuf};
38
39use rightkit_fs::{AtomicWriteOptions, AtomicWriter, FsError, ReplaceMode};
40
41use crate::discovery::{try_lock_exclusive, LockedFile};
42use crate::suite::{make_private_dir, Suite, SuiteError};
43
44/// The longest app name accepted, in bytes.
45pub const MAX_APP_NAME_LEN: usize = 64;
46/// The historical commit marker written at the app root once a migration is fully verified. Only a
47/// whole-tree [`MigrationState::Ready`] migration writes it; readers look only at `source`.
48pub const MIGRATION_MARKER: &str = ".rightkit-migrated.json";
49/// The versioned completion state of a migration (`Ready`, `ReadyWithOmissions` or `Incomplete`),
50/// written at the app root before the destination is first touched and rewritten when the run
51/// ends. Read it with [`AppRoot::migration_state`].
52pub const MIGRATION_STATE: &str = ".rightkit-migration-state.json";
53/// Version of the [`MIGRATION_STATE`] file. Version 1 is the unversioned marker.
54const STATE_VERSION: u64 = 2;
55const SELECTED_STATE_VERSION: u64 = 3;
56const WRITER_LOCK: &str = ".writer.lock";
57
58const WINDOWS_RESERVED: [&str; 22] = [
59    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
60    "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
61];
62
63/// Whether `s` is an app name: 1..=[`MAX_APP_NAME_LEN`] bytes of ASCII letters, digits, `-`, `_`
64/// and `.`; starts and ends with a letter or digit (so no `.`, `..`, hidden or trailing-dot
65/// names); no separators; not a Windows reserved device name (with or without an extension).
66/// Case is preserved on disk but is significant for identity on every platform: two names that
67/// differ only by case are refused as conflicting by [`AppRoot::new`] when one already exists.
68pub fn valid_app_name(s: &str) -> bool {
69    let b = s.as_bytes();
70    if b.is_empty() || b.len() > MAX_APP_NAME_LEN {
71        return false;
72    }
73    if !b
74        .iter()
75        .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.'))
76    {
77        return false;
78    }
79    if !b[0].is_ascii_alphanumeric() || !b[b.len() - 1].is_ascii_alphanumeric() || s.contains("..")
80    {
81        return false;
82    }
83    // `shared` is the suite's shared-resources directory, a sibling of every app root.
84    if s.eq_ignore_ascii_case("shared") {
85        return false;
86    }
87    let stem = s.split('.').next().unwrap_or(s).to_ascii_uppercase();
88    !WINDOWS_RESERVED.contains(&stem.as_str())
89}
90
91#[derive(Debug)]
92pub enum AppRootError {
93    InvalidName(String),
94    /// A subpath was absolute, had `..`, `.`, a prefix, was empty, or resolved outside the root.
95    PathEscape(String),
96    /// An existing sibling differs from the requested app name only by case.
97    CaseConflict {
98        requested: String,
99        existing: String,
100    },
101    /// Another process holds the single-writer lock.
102    WriterBusy(PathBuf),
103    /// The source tree holds a symlink, junction or other reparse point, or special file that this
104    /// migration mode refuses (strict mode refuses every one; an opt-in mode still refuses
105    /// devices and other specials, and a junction or reparse point unless
106    /// [`MigrateOptions::skip_reparse_points`] is on).
107    UnsupportedSource(PathBuf),
108    /// A destination file exists with different content; nothing is overwritten.
109    Collision(PathBuf),
110    /// Source and destination are the same tree or nested in each other.
111    Overlap(PathBuf),
112    /// Post-copy verification found a difference.
113    VerifyFailed(PathBuf),
114    /// The folder the peer process resolved is not this app root
115    /// ([`AppRoot::verify_peer_dir`]). Names neither path: a handshake mismatch must not become
116    /// a way to read where the other process looks.
117    PeerDirMismatch,
118    Suite(SuiteError),
119    Io(io::Error),
120}
121
122impl fmt::Display for AppRootError {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        match self {
125            Self::InvalidName(n) => write!(f, "invalid app name: {n:?}"),
126            Self::PathEscape(p) => write!(f, "path escapes the app root: {p}"),
127            Self::CaseConflict {
128                requested,
129                existing,
130            } => write!(
131                f,
132                "app root {requested:?} conflicts with existing {existing:?} (differs only by case)"
133            ),
134            Self::WriterBusy(p) => write!(f, "another writer holds {}", p.display()),
135            Self::UnsupportedSource(p) => {
136                write!(
137                    f,
138                    "refusing symlink or special file in source: {}",
139                    p.display()
140                )
141            }
142            Self::Collision(p) => write!(f, "destination differs from source: {}", p.display()),
143            Self::Overlap(p) => write!(f, "source and destination overlap: {}", p.display()),
144            Self::VerifyFailed(p) => write!(f, "verification failed: {}", p.display()),
145            Self::PeerDirMismatch => {
146                write!(
147                    f,
148                    "the peer process uses a different data folder than this one"
149                )
150            }
151            Self::Suite(e) => write!(f, "{e}"),
152            Self::Io(e) => write!(f, "{e}"),
153        }
154    }
155}
156impl std::error::Error for AppRootError {}
157
158impl AppRootError {
159    /// The typed link failure carried by an [`AppRootError::Io`], if that is what this is: a
160    /// symlink the destination could not create (Windows without the symlink privilege or
161    /// Developer Mode, or a filesystem without links) during an opt-in migration. Read the link,
162    /// its original target and the target that would have been written from [`LinkNotCreated`].
163    pub fn link_not_created(&self) -> Option<&LinkNotCreated> {
164        match self {
165            Self::Io(e) => e.get_ref()?.downcast_ref::<LinkNotCreated>(),
166            _ => None,
167        }
168    }
169
170    /// True when trying again can succeed without changing the request: a link the destination
171    /// could not create yet ([`AppRootError::link_not_created`]) or a busy writer lock.
172    pub fn is_retryable(&self) -> bool {
173        self.link_not_created().is_some() || matches!(self, Self::WriterBusy(_))
174    }
175}
176
177/// A required symlink that the destination could not create. Carried inside
178/// [`AppRootError::Io`]; read it with [`AppRootError::link_not_created`].
179///
180/// Retryable: it is returned before the completion marker is written, so the destination is not
181/// treated as migrated, the old tree is untouched and still usable, and a later call (after the
182/// privilege is granted) resumes and completes.
183#[derive(Debug, Clone, PartialEq, Eq)]
184#[non_exhaustive]
185pub struct LinkNotCreated {
186    /// Path of the link relative to the migrated tree.
187    pub link: PathBuf,
188    /// The link's target text in the old tree.
189    pub target: PathBuf,
190    /// The target that would have been written (absolute for a rewritten external link).
191    pub resolved: PathBuf,
192    /// The operating system's reason.
193    pub reason: String,
194}
195
196impl fmt::Display for LinkNotCreated {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        write!(
199            f,
200            "cannot create symlink {} -> {} in the app root: {}",
201            self.link.display(),
202            self.target.display(),
203            self.reason
204        )
205    }
206}
207impl std::error::Error for LinkNotCreated {}
208
209impl From<io::Error> for AppRootError {
210    fn from(e: io::Error) -> Self {
211        Self::Io(e)
212    }
213}
214impl From<SuiteError> for AppRootError {
215    fn from(e: SuiteError) -> Self {
216        Self::Suite(e)
217    }
218}
219
220/// Held while this process is the single writer of an app root. Released on drop or process
221/// death (OS lock).
222pub struct WriterLock {
223    _file: LockedFile,
224}
225
226/// What [`AppRoot::migrate_from`] did.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub enum MigrationOutcome {
229    /// Copied and verified; the marker was committed.
230    Migrated { files: u64, bytes: u64 },
231    /// The marker already records this source; nothing was touched.
232    AlreadyMigrated,
233    /// The source does not exist; nothing to do and no marker written.
234    NothingToMigrate,
235}
236
237/// How far a migration into an app root got. Recorded in the versioned state file
238/// ([`MIGRATION_STATE`]); read it with [`AppRoot::migration_state`] or
239/// [`MigrationReport::state`].
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241#[non_exhaustive]
242pub enum MigrationState {
243    /// Everything in the recorded scope was migrated & verified. Whole-tree scope also writes
244    /// the historical marker [`MIGRATION_MARKER`].
245    Ready,
246    /// Every required entry is in place. Only entries the caller declared disposable are missing,
247    /// and each is recorded (optional links that could not be created, reparse points skipped on
248    /// request). The destination is usable. The historical marker is not written, so a reader of
249    /// an earlier version does not take it for a complete move; a rerun retries the omitted links.
250    ReadyWithOmissions,
251    /// The copy was started and not finished (interrupted, or stopped by a failure such as a
252    /// required link the destination cannot create). The destination is not usable: the old root
253    /// stays the authority. A rerun verifies and finishes the copy. No historical marker exists.
254    Incomplete,
255}
256
257impl MigrationState {
258    /// The name written in the state file: `ready`, `ready_with_omissions` or `incomplete`.
259    pub fn as_str(self) -> &'static str {
260        match self {
261            Self::Ready => "ready",
262            Self::ReadyWithOmissions => "ready_with_omissions",
263            Self::Incomplete => "incomplete",
264        }
265    }
266
267    /// True when the destination may be used as the app's data root (`Ready` or
268    /// `ReadyWithOmissions`).
269    pub fn is_usable(self) -> bool {
270        !matches!(self, Self::Incomplete)
271    }
272
273    fn parse(s: &str) -> Option<Self> {
274        match s {
275            "ready" => Some(Self::Ready),
276            "ready_with_omissions" => Some(Self::ReadyWithOmissions),
277            "incomplete" => Some(Self::Incomplete),
278            _ => None,
279        }
280    }
281}
282
283/// Persisted scope of a migration; selected roots include directory descendants.
284#[derive(Debug, Clone, PartialEq, Eq)]
285#[non_exhaustive]
286pub enum MigrationScope {
287    /// Historical whole-tree migration.
288    All,
289    /// Canonical relative roots, including an explicit empty selection.
290    Roots(Vec<PathBuf>),
291}
292
293/// Read-only migration state & its persisted scope for one canonical source.
294#[derive(Debug, Clone, PartialEq, Eq)]
295#[non_exhaustive]
296pub struct MigrationStatus {
297    state: MigrationState,
298    scope: MigrationScope,
299}
300
301impl MigrationStatus {
302    /// Completion state recorded for this scope.
303    pub fn state(&self) -> MigrationState {
304        self.state
305    }
306    /// Exact persisted scope; observation alone does not prove another scope complete.
307    pub fn scope(&self) -> &MigrationScope {
308        &self.scope
309    }
310}
311
312/// What [`AppRoot::migrate_from_with`] did: the [`MigrationOutcome`] plus what an opt-in
313/// migration did with links, special files and reparse points. The lists describe this call's
314/// work only (they are empty when the outcome is `NothingToMigrate`; the state file holds the
315/// record of an earlier call). The one exception is a rerun that retries the omissions of a
316/// `ReadyWithOmissions` migration: its `migrated_links` are the links created now, and
317/// `omitted_optional_links` and `skipped_reparse_points` are what is still unresolved.
318#[derive(Debug, Clone, PartialEq, Eq)]
319#[non_exhaustive]
320pub struct MigrationReport {
321    outcome: MigrationOutcome,
322    migrated_links: Vec<MigratedLink>,
323    skipped_specials: Vec<SkippedSpecial>,
324    omitted_optional_links: Vec<OmittedLink>,
325    skipped_reparse_points: Vec<SkippedReparsePoint>,
326    state: MigrationState,
327}
328
329impl MigrationReport {
330    fn bare(outcome: MigrationOutcome, state: MigrationState) -> Self {
331        Self {
332            outcome,
333            migrated_links: Vec::new(),
334            skipped_specials: Vec::new(),
335            omitted_optional_links: Vec::new(),
336            skipped_reparse_points: Vec::new(),
337            state,
338        }
339    }
340
341    /// How far the migration got. A returned report is `Ready` or `ReadyWithOmissions`; an
342    /// incomplete migration is an error, and its state is read with [`AppRoot::migration_state`].
343    pub fn state(&self) -> MigrationState {
344        self.state
345    }
346
347    /// Junctions and other Windows reparse points skipped on request (see
348    /// [`MigrateOptions::skip_reparse_points`]). None is ever followed, copied or recreated.
349    pub fn skipped_reparse_points(&self) -> &[SkippedReparsePoint] {
350        &self.skipped_reparse_points
351    }
352
353    /// The same value `migrate_from` returns.
354    pub fn outcome(&self) -> &MigrationOutcome {
355        &self.outcome
356    }
357
358    pub fn into_outcome(self) -> MigrationOutcome {
359        self.outcome
360    }
361
362    /// Symlinks recreated in the app root (as link entries, never followed).
363    pub fn migrated_links(&self) -> &[MigratedLink] {
364        &self.migrated_links
365    }
366
367    /// Sockets and FIFOs skipped (see [`MigrateOptions::skip_ephemeral_specials`]).
368    pub fn skipped_specials(&self) -> &[SkippedSpecial] {
369        &self.skipped_specials
370    }
371
372    /// Links the caller's policy marked optional and that could not be created. A required link
373    /// that cannot be created is an error instead ([`AppRootError::link_not_created`]).
374    pub fn omitted_optional_links(&self) -> &[OmittedLink] {
375        &self.omitted_optional_links
376    }
377
378    /// True only for [`MigrationState::Ready`]: false when an optional link was omitted or a
379    /// reparse point was skipped. Skipped ephemeral specials (sockets, FIFOs) do not make a
380    /// migration incomplete. For `AlreadyMigrated` this is the value the earlier state recorded.
381    pub fn complete(&self) -> bool {
382        self.state == MigrationState::Ready
383    }
384}
385
386/// A symlink recreated by a link-preserving migration.
387#[derive(Debug, Clone, PartialEq, Eq)]
388pub struct MigratedLink {
389    /// Path of the link relative to the migrated tree.
390    pub path: PathBuf,
391    /// The link's target text in the old tree, exactly as `read_link` returned it.
392    pub original_target: PathBuf,
393    /// The target text written to the new link.
394    pub written_target: PathBuf,
395    /// True when a relative link leaving the tree was rewritten to an absolute path to its
396    /// original target.
397    pub rewritten: bool,
398}
399
400/// The kind of ephemeral special file skipped by a migration.
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub enum SpecialKind {
403    Socket,
404    Fifo,
405}
406
407impl SpecialKind {
408    fn as_str(self) -> &'static str {
409        match self {
410            Self::Socket => "socket",
411            Self::Fifo => "fifo",
412        }
413    }
414}
415
416/// A socket or FIFO that was skipped (not opened, not copied).
417#[derive(Debug, Clone, PartialEq, Eq)]
418pub struct SkippedSpecial {
419    /// Path relative to the migrated tree.
420    pub path: PathBuf,
421    pub kind: SpecialKind,
422}
423
424/// The kind of Windows reparse point skipped by a migration.
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426#[non_exhaustive]
427pub enum ReparseKind {
428    /// A junction (mount point) or volume mount point. Std reports it as a directory symlink, but
429    /// it is not one: it is never recreated as a symlink, because that would change what it is.
430    Junction,
431    /// Any other reparse point that is not a symlink or an `AF_UNIX` socket (cloud placeholders,
432    /// WSL links, app execution aliases, deduplicated files, ...).
433    Other,
434}
435
436impl ReparseKind {
437    fn as_str(self) -> &'static str {
438        match self {
439            Self::Junction => "junction",
440            Self::Other => "other",
441        }
442    }
443}
444
445/// A reparse point that was skipped (never followed, opened, copied or recreated). Windows only:
446/// see [`MigrateOptions::skip_reparse_points`].
447#[derive(Debug, Clone, PartialEq, Eq)]
448#[non_exhaustive]
449pub struct SkippedReparsePoint {
450    /// Path relative to the migrated tree.
451    pub path: PathBuf,
452    pub kind: ReparseKind,
453    /// The reparse tag (`IO_REPARSE_TAG_*`).
454    pub tag: u32,
455    /// Why it was skipped.
456    pub reason: String,
457}
458
459/// An optional link that the destination could not hold and the caller's policy allowed to omit.
460#[derive(Debug, Clone, PartialEq, Eq)]
461pub struct OmittedLink {
462    /// Path of the link relative to the migrated tree.
463    pub path: PathBuf,
464    /// The link's target text in the old tree.
465    pub original_target: PathBuf,
466    /// The target that would have been written (absolute for a rewritten external link).
467    pub resolved_target: PathBuf,
468    /// Why the link could not be created.
469    pub reason: String,
470}
471
472type OptionalLinkPolicy = std::sync::Arc<dyn Fn(&Path) -> bool + Send + Sync>;
473
474/// Opt-in behaviour for [`AppRoot::migrate_from_with`]. The default is strict and identical to
475/// [`AppRoot::migrate_from`]: any symlink or special file in the source is refused.
476///
477/// The type is `#[non_exhaustive]`; build it with [`MigrateOptions::strict`] and the builders.
478#[derive(Clone, Default)]
479#[non_exhaustive]
480pub struct MigrateOptions {
481    links: bool,
482    ephemeral_specials: bool,
483    reparse_points: bool,
484    optional_link: Option<OptionalLinkPolicy>,
485    excluded: Vec<PathBuf>,
486    pub(crate) included: Option<Vec<PathBuf>>,
487}
488
489impl fmt::Debug for MigrateOptions {
490    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
491        f.debug_struct("MigrateOptions")
492            .field("links", &self.links)
493            .field("ephemeral_specials", &self.ephemeral_specials)
494            .field("reparse_points", &self.reparse_points)
495            .field("optional_link_policy", &self.optional_link.is_some())
496            .field("excluded", &self.excluded)
497            .field("included", &self.included)
498            .finish()
499    }
500}
501
502impl MigrateOptions {
503    /// Restrict migration to these relative roots, including each directory's descendants.
504    /// An empty list selects nothing; the default selects the whole tree.
505    /// Selected migrations always revalidate source & destination, retain previously selected
506    /// roots for retries of the same source, & use versioned scope-aware state without writing
507    /// a historical whole-tree completion marker.
508    pub fn include_roots(mut self, roots: Vec<PathBuf>) -> Result<Self, AppRootError> {
509        self.included = Some(normalize_roots(roots)?);
510        Ok(self)
511    }
512
513    fn selection(&self, rel: &Path) -> Result<bool, AppRootError> {
514        let Some(roots) = &self.included else {
515            return Ok(true);
516        };
517        for root in roots {
518            if rel.starts_with(root) || root.starts_with(rel) {
519                return Ok(true);
520            }
521            let a: Vec<_> = rel.components().collect();
522            let b: Vec<_> = root.components().collect();
523            if a.iter()
524                .zip(&b)
525                .all(|(x, y)| folded_component(*x) == folded_component(*y))
526            {
527                return Err(AppRootError::UnsupportedSource(rel.to_path_buf()));
528            }
529        }
530        Ok(false)
531    }
532
533    fn scope_state(&self, mut body: serde_json::Value) -> serde_json::Value {
534        if let Some(roots) = &self.included {
535            body["version"] = serde_json::json!(SELECTED_STATE_VERSION);
536            body["selected_roots"] = serde_json::json!(roots);
537        }
538        body
539    }
540
541    /// Refuse every symlink and special file (the default).
542    pub fn strict() -> Self {
543        Self::default()
544    }
545
546    /// Copy symlinks as link entries, never traversing them. A relative link that stays inside
547    /// the tree keeps its text; a relative link that leaves the tree becomes an absolute link to
548    /// its original target (resolved lexically against the link's old location); an absolute or
549    /// dangling link is copied as it is. If the destination cannot create a link the migration
550    /// fails with the retryable [`AppRootError::link_not_created`] error before any marker is written,
551    /// unless [`MigrateOptions::optional_links`] marks that link omittable.
552    pub fn preserve_links(mut self, on: bool) -> Self {
553        self.links = on;
554        self
555    }
556
557    /// Skip sockets and FIFOs (never opened) and report them. Device nodes and any other special
558    /// file are still refused. On Windows an `AF_UNIX` socket file (a reparse point) counts as a
559    /// socket.
560    pub fn skip_ephemeral_specials(mut self, on: bool) -> Self {
561        self.ephemeral_specials = on;
562        self
563    }
564
565    /// Windows reparse-point policy. A junction (mount point) or any other reparse point that is
566    /// neither a symlink nor an `AF_UNIX` socket is never followed, opened, copied or recreated:
567    /// the migration cannot tell whether it leads out of the root, and recreating a junction as a
568    /// symlink would change what it is. By default (and in strict mode) such an entry refuses the
569    /// migration with [`AppRootError::UnsupportedSource`], before anything is written. With this
570    /// option the entry is skipped and reported ([`MigrationReport::skipped_reparse_points`], and
571    /// in the state file), and the result is [`MigrationState::ReadyWithOmissions`], never `Ready`:
572    /// the caller has declared the entry disposable and the omission stays on record. A directory
573    /// reparse point is never recursed into. No effect on other platforms (they have none).
574    pub fn skip_reparse_points(mut self, on: bool) -> Self {
575        self.reparse_points = on;
576        self
577    }
578
579    /// Caller policy naming links that may be omitted when the destination cannot create them.
580    /// The predicate receives the link's path relative to the migrated tree. It only matters
581    /// together with [`MigrateOptions::preserve_links`], and only for a link whose creation
582    /// failed; every other link is migrated normally. Omissions are reported in the outcome and
583    /// in the marker, separately from required links.
584    pub fn optional_links(
585        mut self,
586        is_optional: impl Fn(&Path) -> bool + Send + Sync + 'static,
587    ) -> Self {
588        self.optional_link = Some(std::sync::Arc::new(is_optional));
589        self
590    }
591
592    /// Leave the entry at `rel` (a path relative to the migrated tree) and everything below it out
593    /// of the copy. For state a lifecycle participant moves by its own means (an encrypted
594    /// database and its key, moved by [`RootCutover`](crate::RootCutover) through the participant
595    /// that owns it): copying such files as plain bytes would race the owner's relocation and
596    /// collide with its destination. An excluded path is not planned, copied, verified or
597    /// recorded; it is neither an omission nor a reason for the state to be `ReadyWithOmissions`,
598    /// so the caller that excludes a path owns moving it. Exclusions match exact components;
599    /// differently spelled case aliases are refused rather than silently skipped. `rel` must be relative and made only of
600    /// normal components; anything else is ignored (it can match no entry).
601    pub fn exclude(mut self, rel: impl Into<PathBuf>) -> Self {
602        let rel = rel.into();
603        if rel.components().next().is_some()
604            && rel.components().all(|c| matches!(c, Component::Normal(_)))
605        {
606            self.excluded.push(rel);
607        }
608        self
609    }
610
611    pub fn links(&self) -> bool {
612        self.links
613    }
614
615    pub fn ephemeral_specials(&self) -> bool {
616        self.ephemeral_specials
617    }
618
619    pub fn reparse_points(&self) -> bool {
620        self.reparse_points
621    }
622
623    fn is_strict(&self) -> bool {
624        !self.links
625            && !self.ephemeral_specials
626            && !self.reparse_points
627            && self.optional_link.is_none()
628    }
629
630    fn is_optional(&self, rel: &Path) -> bool {
631        self.optional_link.as_ref().is_some_and(|f| f(rel))
632    }
633
634    /// Exact component exclusions preserve distinct names on case-sensitive volumes.
635    /// Differently spelled case aliases fail closed rather than silently omitting material.
636    fn is_excluded(&self, rel: &Path) -> Result<bool, AppRootError> {
637        for ex in &self.excluded {
638            if rel.starts_with(ex) {
639                return Ok(true);
640            }
641            let mut have = rel.components();
642            let ambiguous = ex.components().all(|want| match (want, have.next()) {
643                (Component::Normal(w), Some(Component::Normal(h))) => {
644                    w.to_string_lossy().to_lowercase() == h.to_string_lossy().to_lowercase()
645                }
646                _ => false,
647            });
648            if ambiguous {
649                return Err(AppRootError::UnsupportedSource(rel.to_path_buf()));
650            }
651        }
652        Ok(false)
653    }
654}
655
656pub(crate) fn normalize_roots(mut roots: Vec<PathBuf>) -> Result<Vec<PathBuf>, AppRootError> {
657    for root in &roots {
658        if root.as_os_str().is_empty()
659            || root.to_str().is_none()
660            || !root.components().all(|c| matches!(c, Component::Normal(_)))
661            || root
662                .components()
663                .any(|c| c.as_os_str().to_string_lossy().contains(['\\', ':', '\0']))
664            || root
665                .to_string_lossy()
666                .split(['/', '\\'])
667                .any(|part| part.is_empty() || part == "." || part == "..")
668            || [MIGRATION_STATE, MIGRATION_MARKER, WRITER_LOCK]
669                .iter()
670                .any(|reserved| roots_overlap(root, Path::new(reserved)))
671        {
672            return Err(AppRootError::PathEscape(root.display().to_string()));
673        }
674    }
675    roots.sort();
676    roots.dedup();
677    for (i, root) in roots.iter().enumerate() {
678        for other in &roots[..i] {
679            let a: Vec<_> = root.components().collect();
680            let b: Vec<_> = other.components().collect();
681            for (x, y) in a.iter().zip(&b) {
682                if x == y {
683                    continue;
684                }
685                if folded_component(*x) == folded_component(*y) {
686                    return Err(AppRootError::PathEscape(root.display().to_string()));
687                }
688                break;
689            }
690        }
691    }
692    let all = roots.clone();
693    roots.retain(|root| {
694        !all.iter()
695            .any(|other| root != other && root.starts_with(other))
696    });
697    Ok(roots)
698}
699
700fn folded_component(component: Component<'_>) -> String {
701    component.as_os_str().to_string_lossy().to_lowercase()
702}
703
704pub(crate) fn roots_overlap(a: &Path, b: &Path) -> bool {
705    a.components()
706        .zip(b.components())
707        .all(|(x, y)| folded_component(x) == folded_component(y))
708}
709
710/// `<Orthic Labs>/<App>`, created private.
711#[derive(Debug, Clone)]
712pub struct AppRoot {
713    name: String,
714    root: PathBuf,
715}
716
717impl Suite {
718    /// The per-app root for `app`, created private. See [`AppRoot`].
719    pub fn app_root(&self, app: &str) -> Result<AppRoot, AppRootError> {
720        AppRoot::new(self, app)
721    }
722}
723
724impl AppRoot {
725    pub fn new(suite: &Suite, app: &str) -> Result<AppRoot, AppRootError> {
726        if !valid_app_name(app) {
727            return Err(AppRootError::InvalidName(app.to_string()));
728        }
729        let parent = suite.app_parent();
730        if let Ok(rd) = fs::read_dir(&parent) {
731            for e in rd.flatten() {
732                let n = e.file_name().to_string_lossy().into_owned();
733                if n != app && n.eq_ignore_ascii_case(app) {
734                    return Err(AppRootError::CaseConflict {
735                        requested: app.to_string(),
736                        existing: n,
737                    });
738                }
739            }
740        }
741        let root = parent.join(app);
742        make_private_dir(&root)?;
743        Ok(AppRoot {
744            name: app.to_string(),
745            root,
746        })
747    }
748
749    pub fn name(&self) -> &str {
750        &self.name
751    }
752
753    pub fn root(&self) -> &Path {
754        &self.root
755    }
756
757    /// Child-side (or parent-side) check that two processes resolved the same data folder
758    /// (rightkit#989): `peer` is the folder the other process sent in its handshake, and it must
759    /// be this app root, by file identity (device and inode on Unix; the canonical path compared
760    /// without case on Windows), so a different spelling, a symlink or a case variant of the same
761    /// folder passes and another folder does not. A path that does not exist or cannot be
762    /// resolved is a mismatch. The refusal, [`AppRootError::PeerDirMismatch`], names neither
763    /// path. The handshake payload that carries `peer` stays app code.
764    pub fn verify_peer_dir(&self, peer: &Path) -> Result<(), AppRootError> {
765        let ours = fs::canonicalize(&self.root)?;
766        let theirs = fs::canonicalize(peer).map_err(|_| AppRootError::PeerDirMismatch)?;
767        if same_directory(&ours, &theirs) {
768            Ok(())
769        } else {
770            Err(AppRootError::PeerDirMismatch)
771        }
772    }
773
774    /// `<app root>/<rel>` without creating anything. `rel` must be relative and made only of
775    /// normal components (no `..`, `.`, root, prefix or empty); any existing ancestor that is a
776    /// symlink must still resolve inside the app root.
777    pub fn path(&self, rel: impl AsRef<Path>) -> Result<PathBuf, AppRootError> {
778        self.path_checked(rel.as_ref(), true)
779    }
780
781    /// [`AppRoot::path`]; with `follow_leaf` false only the ancestors are validated and the final
782    /// component may be a symlink (a link entry being placed) that is never resolved.
783    fn path_checked(&self, rel: &Path, follow_leaf: bool) -> Result<PathBuf, AppRootError> {
784        let bad = || AppRootError::PathEscape(rel.display().to_string());
785        let mut any = false;
786        for c in rel.components() {
787            match c {
788                Component::Normal(n) => {
789                    any = true;
790                    // `:` is a Windows stream/drive separator and a reserved char elsewhere.
791                    if n.to_string_lossy().contains(['\\', ':', '\0']) {
792                        return Err(bad());
793                    }
794                }
795                _ => return Err(bad()),
796            }
797        }
798        if !any {
799            return Err(bad());
800        }
801        let full = self.root.join(rel);
802        let canon_root = fs::canonicalize(&self.root)?;
803        let mut probe = if follow_leaf {
804            full.as_path()
805        } else {
806            full.parent().unwrap_or(&self.root)
807        };
808        loop {
809            match fs::canonicalize(probe) {
810                Ok(c) => {
811                    if !c.starts_with(&canon_root) {
812                        return Err(bad());
813                    }
814                    break;
815                }
816                Err(e) if e.kind() == io::ErrorKind::NotFound => {
817                    // A dangling symlink is NotFound for canonicalize but must not be followed.
818                    if fs::symlink_metadata(probe).is_ok() {
819                        return Err(bad());
820                    }
821                    match probe.parent() {
822                        Some(p) if p.starts_with(&self.root) => probe = p,
823                        _ => return Err(bad()),
824                    }
825                }
826                Err(e) => return Err(e.into()),
827            }
828        }
829        Ok(full)
830    }
831
832    /// A private directory at `<app root>/<rel>`, created if absent.
833    pub fn data_dir(&self, rel: impl AsRef<Path>) -> Result<PathBuf, AppRootError> {
834        let d = self.path(rel)?;
835        make_private_dir(&d)?;
836        // Re-check after creation: nothing created may resolve outside the root.
837        self.path(d.strip_prefix(&self.root).unwrap_or(&d))?;
838        Ok(d)
839    }
840
841    /// Take the single-writer lock for this app root. `Err(WriterBusy)` if another holder lives.
842    pub fn lock_writer(&self) -> Result<WriterLock, AppRootError> {
843        let p = self.root.join(WRITER_LOCK);
844        match try_lock_exclusive(&p)? {
845            Some(f) => Ok(WriterLock { _file: f }),
846            None => Err(AppRootError::WriterBusy(p)),
847        }
848    }
849
850    /// Copy the old root `src` into this app root, verify every byte, then commit. Restart-safe:
851    /// files land under a temporary name and are renamed only when fully written; a rerun after
852    /// any interruption re-copies missing or partial files, accepts files already identical, and
853    /// refuses (never overwrites) a differing destination file. Directories, including empty
854    /// ones, are migrated. The versioned state ([`MIGRATION_STATE`]) is `Incomplete` from before
855    /// the destination is first touched until the copy is verified; the historical marker
856    /// ([`MIGRATION_MARKER`]) is written last, and once present for this source the call is a
857    /// no-op. `src` is never changed or deleted. Symlinks, junctions and special files in `src` are
858    /// refused (strict); use [`AppRoot::migrate_from_with`] to opt in to links, ephemeral specials
859    /// and skipped reparse points. Takes the writer lock.
860    pub fn migrate_from(&self, src: &Path) -> Result<MigrationOutcome, AppRootError> {
861        Ok(self
862            .migrate_from_with(src, MigrateOptions::strict())?
863            .into_outcome())
864    }
865
866    /// [`AppRoot::migrate_from`] with opt-in behaviour. Lock, restart, collision handling and the
867    /// marker-last ordering are the same in every mode; `MigrateOptions::strict()` is exactly
868    /// `migrate_from`. See [`MigrateOptions`] for how links, ephemeral specials and reparse points
869    /// are handled. Returns a [`MigrationReport`] (its `outcome()` is what `migrate_from`
870    /// returns).
871    ///
872    /// A required link that the destination cannot create returns the retryable
873    /// [`AppRootError::link_not_created`] error and leaves the state `Incomplete`: the old tree is
874    /// untouched, no historical marker exists, and the incomplete destination is never treated as
875    /// migrated by this or an earlier version. A rerun finishes the copy. When the state is
876    /// `ReadyWithOmissions` a rerun retries only the omitted links; it neither re-verifies files
877    /// nor fails on files the app changed since.
878    pub fn migrate_from_with(
879        &self,
880        src: &Path,
881        opts: MigrateOptions,
882    ) -> Result<MigrationReport, AppRootError> {
883        self.migrate_impl(src, &opts, &make_link)
884    }
885
886    /// The completion state of the migration of `src` into this app root: `None` when no migration
887    /// of `src` has started here (or `src` does not exist), otherwise what the last run left.
888    /// `Incomplete` means the destination must not be used and the old root stays the authority. A
889    /// historical marker written by an earlier version counts as `Ready` (as `ReadyWithOmissions`
890    /// when it records `"complete": false`). A state file written by a newer version is an error
891    /// (`AppRootError::Io`, `InvalidData`). Malformed metadata also fails rather than falling
892    /// back to a historical marker. Takes no lock and changes nothing.
893    pub fn migration_state(&self, src: &Path) -> Result<Option<MigrationState>, AppRootError> {
894        Ok(self.migration_status(src)?.map(|status| status.state()))
895    }
896
897    /// Read persisted state & scope without taking a lock or changing files. Missing source or
898    /// metadata returns `None`; valid metadata for another source also returns `None`.
899    /// Malformed, unknown-version or invalid-scope metadata is an error, never evidence that a
900    /// destination is untracked. A historical marker is consulted only when state is absent.
901    pub fn migration_status(&self, src: &Path) -> Result<Option<MigrationStatus>, AppRootError> {
902        if !src.is_absolute() || src.components().any(|c| c == Component::ParentDir) {
903            return Err(AppRootError::PathEscape(src.display().to_string()));
904        }
905        let csrc = match fs::canonicalize(src) {
906            Ok(c) => c,
907            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
908            Err(e) => return Err(e.into()),
909        };
910        let invalid = |detail: &str| {
911            AppRootError::Io(io::Error::new(
912                io::ErrorKind::InvalidData,
913                detail.to_string(),
914            ))
915        };
916        let valid_source = |source: &str| {
917            let path = Path::new(source);
918            path.is_absolute() && !path.components().any(|part| part == Component::ParentDir)
919        };
920        let read = |path: &Path| -> Result<Option<serde_json::Value>, AppRootError> {
921            let text = match fs::read_to_string(path) {
922                Ok(text) => text,
923                Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
924                Err(error) => return Err(error.into()),
925            };
926            serde_json::from_str(&text)
927                .map(Some)
928                .map_err(|_| invalid("invalid migration metadata JSON"))
929        };
930        if let Some(value) = read(&self.root.join(MIGRATION_STATE))? {
931            let source = value
932                .get("source")
933                .and_then(serde_json::Value::as_str)
934                .ok_or_else(|| invalid("missing migration source"))?;
935            if !valid_source(source) {
936                return Err(invalid("invalid migration source"));
937            }
938            let state = value
939                .get("state")
940                .and_then(serde_json::Value::as_str)
941                .and_then(MigrationState::parse)
942                .ok_or_else(|| invalid("unknown migration state"))?;
943            let scope = match value.get("version").and_then(serde_json::Value::as_u64) {
944                Some(1) | Some(STATE_VERSION) if value.get("selected_roots").is_none() => {
945                    MigrationScope::All
946                }
947                Some(SELECTED_STATE_VERSION) => {
948                    let roots: Vec<PathBuf> = serde_json::from_value(
949                        value
950                            .get("selected_roots")
951                            .cloned()
952                            .ok_or_else(|| invalid("missing selected scope"))?,
953                    )
954                    .map_err(|_| invalid("invalid selected scope"))?;
955                    if normalize_roots(roots.clone())
956                        .map_err(|_| invalid("invalid selected scope"))?
957                        != roots
958                    {
959                        return Err(invalid("noncanonical selected scope"));
960                    }
961                    MigrationScope::Roots(roots)
962                }
963                _ => return Err(invalid("unknown migration version or scope")),
964            };
965            return Ok((Some(source) == csrc.to_str()).then_some(MigrationStatus { state, scope }));
966        }
967        let Some(marker) = read(&self.root.join(MIGRATION_MARKER))? else {
968            return Ok(None);
969        };
970        let source = marker
971            .get("source")
972            .and_then(serde_json::Value::as_str)
973            .ok_or_else(|| invalid("missing historical migration source"))?;
974        if !valid_source(source) {
975            return Err(invalid("invalid historical migration source"));
976        }
977        let complete = match marker.get("complete") {
978            None => true,
979            Some(value) => value
980                .as_bool()
981                .ok_or_else(|| invalid("invalid historical completion flag"))?,
982        };
983        Ok((Some(source) == csrc.to_str()).then_some(MigrationStatus {
984            state: if complete {
985                MigrationState::Ready
986            } else {
987                MigrationState::ReadyWithOmissions
988            },
989            scope: MigrationScope::All,
990        }))
991    }
992
993    /// The state file when it records `csrc` as its source. An unreadable or unversioned file is
994    /// not ours and counts as absent; a file of a newer version, or with a state this version does
995    /// not know, is refused rather than guessed at.
996    fn read_state(
997        &self,
998        csrc: &Path,
999    ) -> Result<Option<(MigrationState, serde_json::Value)>, AppRootError> {
1000        let path = self.root.join(MIGRATION_STATE);
1001        let text = match fs::read_to_string(&path) {
1002            Ok(t) => t,
1003            Err(e)
1004                if matches!(
1005                    e.kind(),
1006                    io::ErrorKind::NotFound | io::ErrorKind::InvalidData
1007                ) =>
1008            {
1009                return Ok(None)
1010            }
1011            Err(e) => return Err(e.into()),
1012        };
1013        let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
1014            return Ok(None);
1015        };
1016        let Some(version) = v.get("version").and_then(|n| n.as_u64()) else {
1017            return Ok(None);
1018        };
1019        if v.get("source").and_then(|s| s.as_str()) != csrc.to_str() {
1020            return Ok(None);
1021        }
1022        let state = v
1023            .get("state")
1024            .and_then(|s| s.as_str())
1025            .and_then(MigrationState::parse);
1026        match state {
1027            Some(state) if version <= SELECTED_STATE_VERSION => {
1028                if version < SELECTED_STATE_VERSION && v.get("selected_roots").is_some() {
1029                    return Err(AppRootError::PathEscape("legacy state cannot carry selected scope".into()));
1030                }
1031                if version == SELECTED_STATE_VERSION {
1032                    let roots: Vec<PathBuf> = serde_json::from_value(v.get("selected_roots").cloned().ok_or_else(|| AppRootError::PathEscape("missing selected scope".into()))?)
1033                        .map_err(|_| AppRootError::PathEscape("invalid selected scope".into()))?;
1034                    if normalize_roots(roots.clone())? != roots {
1035                        return Err(AppRootError::PathEscape("noncanonical selected scope".into()));
1036                    }
1037                }
1038                Ok(Some((state, v)))
1039            },
1040            _ => Err(AppRootError::Io(io::Error::new(
1041                io::ErrorKind::InvalidData,
1042                format!(
1043                    "{} was written by a newer version or holds an unknown state; refusing to guess",
1044                    path.display()
1045                ),
1046            ))),
1047        }
1048    }
1049
1050    /// `Some(complete)` when the historical marker records `csrc` as its source.
1051    fn legacy_marker(&self, csrc: &Path) -> Option<bool> {
1052        let text = fs::read_to_string(self.root.join(MIGRATION_MARKER)).ok()?;
1053        let v = serde_json::from_str::<serde_json::Value>(&text).ok()?;
1054        if v.get("source").and_then(|s| s.as_str()) != csrc.to_str() {
1055            return None;
1056        }
1057        Some(v.get("complete").and_then(|c| c.as_bool()).unwrap_or(true))
1058    }
1059
1060    fn write_state(&self, body: &serde_json::Value) -> Result<(), AppRootError> {
1061        write_json_atomic(&self.root.join(MIGRATION_STATE), body)?;
1062        Ok(())
1063    }
1064
1065    fn migrate_impl(
1066        &self,
1067        src: &Path,
1068        opts: &MigrateOptions,
1069        maker: LinkMaker<'_>,
1070    ) -> Result<MigrationReport, AppRootError> {
1071        self.migrate_current(src, opts, maker, false)
1072    }
1073
1074    /// Cutover must compare current authoritative bytes even if a previous copy was Ready.
1075    pub(crate) fn migrate_for_cutover(
1076        &self,
1077        src: &Path,
1078        opts: &MigrateOptions,
1079    ) -> Result<MigrationReport, AppRootError> {
1080        self.migrate_current(src, opts, &make_link, true)
1081    }
1082
1083    fn migrate_current(
1084        &self,
1085        src: &Path,
1086        opts: &MigrateOptions,
1087        maker: LinkMaker<'_>,
1088        revalidate: bool,
1089    ) -> Result<MigrationReport, AppRootError> {
1090        if !src.is_absolute() || src.components().any(|c| c == Component::ParentDir) {
1091            return Err(AppRootError::PathEscape(src.display().to_string()));
1092        }
1093        let md = match fs::symlink_metadata(src) {
1094            Ok(m) => m,
1095            Err(e) if e.kind() == io::ErrorKind::NotFound => {
1096                return Ok(MigrationReport::bare(
1097                    MigrationOutcome::NothingToMigrate,
1098                    MigrationState::Ready,
1099                ))
1100            }
1101            Err(e) => return Err(e.into()),
1102        };
1103        if !md.is_dir() {
1104            return Err(AppRootError::UnsupportedSource(src.to_path_buf()));
1105        }
1106        let csrc = fs::canonicalize(src)?;
1107        let cdst = fs::canonicalize(&self.root)?;
1108        if csrc.starts_with(&cdst) || cdst.starts_with(&csrc) {
1109            return Err(AppRootError::Overlap(src.to_path_buf()));
1110        }
1111        let _lock = self.lock_writer()?;
1112        let prior = self.read_state(&csrc)?;
1113        let prior_selected = prior
1114            .as_ref()
1115            .is_some_and(|(_, state)| state.get("selected_roots").is_some());
1116        let mut effective = opts.clone();
1117        if let (Some(roots), Some((_, state))) = (&mut effective.included, &prior) {
1118            if let Some(previous) = state.get("selected_roots") {
1119                let previous: Vec<PathBuf> = serde_json::from_value(previous.clone())
1120                    .map_err(|_| AppRootError::PathEscape("invalid selected scope".into()))?;
1121                roots.extend(previous);
1122                *roots = normalize_roots(std::mem::take(roots))?;
1123            }
1124        }
1125        let opts = &effective;
1126        let revalidate = revalidate || opts.included.is_some() || prior_selected;
1127        match prior {
1128            Some((MigrationState::Ready, state)) if !revalidate => {
1129                self.repair_marker(&csrc, &state);
1130                return Ok(MigrationReport::bare(
1131                    MigrationOutcome::AlreadyMigrated,
1132                    MigrationState::Ready,
1133                ));
1134            }
1135            Some((MigrationState::ReadyWithOmissions, state)) if !revalidate => {
1136                return self.retry_unresolved(&csrc, &state, maker);
1137            }
1138            // An earlier run did not finish: do the whole copy again.
1139            Some(_) => {}
1140            None => {
1141                if let Some(complete) = self.legacy_marker(&csrc).filter(|_| !revalidate) {
1142                    let state = if complete {
1143                        MigrationState::Ready
1144                    } else {
1145                        MigrationState::ReadyWithOmissions
1146                    };
1147                    return Ok(MigrationReport::bare(
1148                        MigrationOutcome::AlreadyMigrated,
1149                        state,
1150                    ));
1151                }
1152            }
1153        }
1154        // Plan first: a source this mode refuses fails here, before the destination is touched.
1155        let mut entries = Vec::new();
1156        collect(&csrc, Path::new(""), opts, &mut entries)?;
1157        if revalidate {
1158            // Old root remains authoritative until switch. A file removed there after an earlier
1159            // copy must not survive silently in the selected tree; preserve it and refuse cutover.
1160            let current: std::collections::BTreeSet<_> = entries.iter().map(Entry::rel).collect();
1161            let mut destination = Vec::new();
1162            let destination_opts = opts.clone().exclude(WRITER_LOCK);
1163            collect(&cdst, Path::new(""), &destination_opts, &mut destination)?;
1164            for entry in &destination {
1165                if !current.contains(entry.rel()) {
1166                    return Err(AppRootError::Collision(cdst.join(entry.rel())));
1167                }
1168            }
1169        }
1170        // Record the pending state before the first destination write, so that any interruption
1171        // (a crash included) leaves `Incomplete` on disk and never a state that reads as done.
1172        self.write_state(&opts.scope_state(state_json(
1173            MigrationState::Incomplete,
1174            csrc.to_str(),
1175            &Lists::default(),
1176            None,
1177        )))?;
1178        match self.copy_entries(&csrc, &entries, opts, maker) {
1179            Ok(done) => self.commit(&csrc, opts, done),
1180            Err(e) => {
1181                self.record_failure(&csrc, opts, &e);
1182                Err(e)
1183            }
1184        }
1185    }
1186
1187    /// Copy and verify every planned entry. Nothing here writes a marker or a completed state.
1188    fn copy_entries(
1189        &self,
1190        csrc: &Path,
1191        entries: &[Entry],
1192        opts: &MigrateOptions,
1193        maker: LinkMaker<'_>,
1194    ) -> Result<Lists, AppRootError> {
1195        let mut done = Lists::default();
1196        // (relative path, written target, is a directory link) for verification.
1197        let mut created: Vec<(PathBuf, PathBuf, bool)> = Vec::new();
1198        for entry in entries {
1199            match entry {
1200                Entry::Dir { rel } => {
1201                    // Validate the parent chain only: the leaf must be a real directory and is
1202                    // never followed if it is a link.
1203                    let to = self.path_checked(rel, false)?;
1204                    match fs::symlink_metadata(&to) {
1205                        Ok(m) if m.file_type().is_dir() => make_private_dir(&to)?,
1206                        Ok(_) => return Err(AppRootError::Collision(to)),
1207                        Err(e) if e.kind() == io::ErrorKind::NotFound => make_private_dir(&to)?,
1208                        Err(e) => return Err(e.into()),
1209                    }
1210                }
1211                Entry::File { rel, len } => {
1212                    done.files += 1;
1213                    done.bytes += len;
1214                    let from = csrc.join(rel);
1215                    let to = self.path(rel)?;
1216                    if let Some(parent) = to.parent() {
1217                        make_private_dir(parent)?;
1218                    }
1219                    match fs::symlink_metadata(&to) {
1220                        Ok(m) if regular_entry(&m) && same_content(&from, &to)? => {
1221                            continue;
1222                        }
1223                        Ok(_) => return Err(AppRootError::Collision(to)),
1224                        Err(e) if e.kind() == io::ErrorKind::NotFound => {}
1225                        Err(e) => return Err(e.into()),
1226                    }
1227                    let mut writer =
1228                        AtomicWriter::create_new_private(&to).map_err(io::Error::other)?;
1229                    let mut source =
1230                        rightkit_fs::open_read_nofollow(&from, &rightkit_fs::ReadOptions::new())
1231                            .map_err(io::Error::other)?;
1232                    io::copy(&mut source, writer.file_mut())?;
1233                    commit_migration_file(writer, &from, &to)?;
1234                }
1235                Entry::Link { rel, target, dir } => {
1236                    let (written, rewritten) = plan_link(csrc, rel, target);
1237                    // Validate the parent chain only: the leaf is a link and is never followed.
1238                    let to = self.path_checked(rel, false)?;
1239                    if let Some(parent) = to.parent() {
1240                        make_private_dir(parent)?;
1241                    }
1242                    match fs::symlink_metadata(&to) {
1243                        Ok(m) => {
1244                            if !(m.file_type().is_symlink()
1245                                && same_link_target(&fs::read_link(&to)?, &written))
1246                            {
1247                                return Err(AppRootError::Collision(to));
1248                            }
1249                        }
1250                        Err(e) if e.kind() == io::ErrorKind::NotFound => {
1251                            if let Err(e) = maker(&written, &to, *dir) {
1252                                if !link_unavailable(&e) {
1253                                    return Err(e.into());
1254                                }
1255                                if opts.is_optional(rel) {
1256                                    done.omitted.push(OmittedLink {
1257                                        path: rel.clone(),
1258                                        original_target: target.clone(),
1259                                        resolved_target: written,
1260                                        reason: e.to_string(),
1261                                    });
1262                                    continue;
1263                                }
1264                                return Err(AppRootError::Io(io::Error::other(LinkNotCreated {
1265                                    link: rel.clone(),
1266                                    target: target.clone(),
1267                                    resolved: written,
1268                                    reason: e.to_string(),
1269                                })));
1270                            }
1271                        }
1272                        Err(e) => return Err(e.into()),
1273                    }
1274                    created.push((rel.clone(), written.clone(), *dir));
1275                    done.links.push(MigratedLink {
1276                        path: rel.clone(),
1277                        original_target: target.clone(),
1278                        written_target: written,
1279                        rewritten,
1280                    });
1281                }
1282                Entry::Special { rel, kind } => done.specials.push(SkippedSpecial {
1283                    path: rel.clone(),
1284                    kind: *kind,
1285                }),
1286                Entry::Reparse { rel, kind, tag } => done.reparse.push(SkippedReparsePoint {
1287                    path: rel.clone(),
1288                    kind: *kind,
1289                    tag: *tag,
1290                    reason: reparse_reason(*kind, *tag),
1291                }),
1292            }
1293        }
1294        for entry in entries {
1295            match entry {
1296                Entry::File { rel, .. } => {
1297                    let to = self.path(rel)?;
1298                    if !same_content(&csrc.join(rel), &to)? {
1299                        return Err(AppRootError::VerifyFailed(to));
1300                    }
1301                }
1302                Entry::Dir { rel } => {
1303                    let to = self.path_checked(rel, false)?;
1304                    if !fs::symlink_metadata(&to).is_ok_and(|m| m.file_type().is_dir()) {
1305                        return Err(AppRootError::VerifyFailed(to));
1306                    }
1307                }
1308                _ => {}
1309            }
1310        }
1311        for (rel, written, dir) in &created {
1312            // Compare with read_link only; a link is never followed.
1313            let to = self.path_checked(rel, false)?;
1314            if !link_ok(&to, written, *dir) {
1315                return Err(AppRootError::VerifyFailed(to));
1316            }
1317        }
1318        Ok(done)
1319    }
1320
1321    /// The copy is verified: write the final state, then (only for `Ready`) the historical marker.
1322    /// The state goes first, so a crash between the two leaves a state new readers trust and no
1323    /// marker an old reader could mistake; the next call repairs the marker.
1324    fn commit(
1325        &self,
1326        csrc: &Path,
1327        opts: &MigrateOptions,
1328        done: Lists,
1329    ) -> Result<MigrationReport, AppRootError> {
1330        let state = done.state();
1331        self.write_state(&opts.scope_state(state_json(state, csrc.to_str(), &done, None)))?;
1332        if state == MigrationState::Ready && opts.included.is_none() {
1333            let body = marker_json(
1334                csrc.to_str(),
1335                done.files,
1336                done.bytes,
1337                opts,
1338                &done.links,
1339                &done.specials,
1340                &done.omitted,
1341            );
1342            write_json_atomic(&self.root.join(MIGRATION_MARKER), &body)?;
1343        }
1344        Ok(MigrationReport {
1345            outcome: MigrationOutcome::Migrated {
1346                files: done.files,
1347                bytes: done.bytes,
1348            },
1349            migrated_links: done.links,
1350            skipped_specials: done.specials,
1351            omitted_optional_links: done.omitted,
1352            skipped_reparse_points: done.reparse,
1353            state,
1354        })
1355    }
1356
1357    /// Best effort: leave `Incomplete` on disk with the reason, and the link that blocked it.
1358    fn record_failure(&self, csrc: &Path, opts: &MigrateOptions, e: &AppRootError) {
1359        let mut body = opts.scope_state(state_json(
1360            MigrationState::Incomplete,
1361            csrc.to_str(),
1362            &Lists::default(),
1363            Some(&e.to_string()),
1364        ));
1365        if let (Some(l), Some(obj)) = (e.link_not_created(), body.as_object_mut()) {
1366            obj.insert(
1367                "unresolved".into(),
1368                serde_json::json!([{"path": lossy(&l.link), "kind": "link", "reason": l.reason}]),
1369            );
1370        }
1371        let _ = self.write_state(&body);
1372    }
1373
1374    /// A `Ready` state without its historical marker (a crash between the two writes): write it.
1375    fn repair_marker(&self, csrc: &Path, state: &serde_json::Value) {
1376        let path = self.root.join(MIGRATION_MARKER);
1377        if path.exists() {
1378            return;
1379        }
1380        let l = Lists::from_state(state);
1381        let opts = MigrateOptions::strict().preserve_links(true);
1382        let body = marker_json(
1383            csrc.to_str(),
1384            l.files,
1385            l.bytes,
1386            &opts,
1387            &l.links,
1388            &l.specials,
1389            &l.omitted,
1390        );
1391        let _ = write_json_atomic(&path, &body);
1392    }
1393
1394    /// Rerun of a `ReadyWithOmissions` migration: retry the omitted links only. Files are not
1395    /// compared again (the app has been running on the destination and may have changed them), and
1396    /// a link that still cannot be created, or whose destination path is now occupied, stays
1397    /// omitted. Skipped reparse points stay skipped. When nothing is left unresolved the state
1398    /// becomes `Ready` and the historical marker is written.
1399    fn retry_unresolved(
1400        &self,
1401        csrc: &Path,
1402        state: &serde_json::Value,
1403        maker: LinkMaker<'_>,
1404    ) -> Result<MigrationReport, AppRootError> {
1405        let prior = Lists::from_state(state);
1406        let mut next = Lists {
1407            files: prior.files,
1408            bytes: prior.bytes,
1409            links: prior.links,
1410            specials: prior.specials,
1411            omitted: Vec::new(),
1412            reparse: prior.reparse,
1413        };
1414        let mut created = Vec::new();
1415        for o in prior.omitted {
1416            match self.retry_link(csrc, &o, maker)? {
1417                Ok(link) => {
1418                    created.push(link.clone());
1419                    next.links.push(link);
1420                }
1421                Err(reason) => next.omitted.push(OmittedLink { reason, ..o }),
1422            }
1423        }
1424        let now = next.state();
1425        if !created.is_empty() || now == MigrationState::Ready {
1426            self.write_state(&state_json(now, csrc.to_str(), &next, None))?;
1427            if now == MigrationState::Ready {
1428                let opts = MigrateOptions::strict().preserve_links(true);
1429                let body = marker_json(
1430                    csrc.to_str(),
1431                    next.files,
1432                    next.bytes,
1433                    &opts,
1434                    &next.links,
1435                    &next.specials,
1436                    &next.omitted,
1437                );
1438                write_json_atomic(&self.root.join(MIGRATION_MARKER), &body)?;
1439            }
1440        }
1441        Ok(MigrationReport {
1442            outcome: MigrationOutcome::AlreadyMigrated,
1443            migrated_links: created,
1444            skipped_specials: Vec::new(),
1445            omitted_optional_links: next.omitted,
1446            skipped_reparse_points: next.reparse,
1447            state: now,
1448        })
1449    }
1450
1451    /// Retry one omitted link. `Ok(Ok(link))` it exists now (created, or already identical),
1452    /// `Ok(Err(reason))` it stays omitted, `Err` a fault that is not about links being unavailable.
1453    fn retry_link(
1454        &self,
1455        csrc: &Path,
1456        o: &OmittedLink,
1457        maker: LinkMaker<'_>,
1458    ) -> Result<Result<MigratedLink, String>, AppRootError> {
1459        let from = csrc.join(&o.path);
1460        let md = match fs::symlink_metadata(&from) {
1461            Ok(m) if m.file_type().is_symlink() => m,
1462            Ok(_) => return Ok(Err("the source entry is no longer a link".to_string())),
1463            Err(e) if e.kind() == io::ErrorKind::NotFound => {
1464                return Ok(Err("the source link no longer exists".to_string()))
1465            }
1466            Err(e) => return Err(e.into()),
1467        };
1468        let target = fs::read_link(&from)?;
1469        let dir = link_is_dir(&md.file_type());
1470        let (written, rewritten) = plan_link(csrc, &o.path, &target);
1471        let to = self.path_checked(&o.path, false)?;
1472        match fs::symlink_metadata(&to) {
1473            Ok(m) => {
1474                let same = m.file_type().is_symlink()
1475                    && fs::read_link(&to).is_ok_and(|t| same_link_target(&t, &written));
1476                if !same {
1477                    return Ok(Err("the destination path is occupied".to_string()));
1478                }
1479            }
1480            Err(e) if e.kind() == io::ErrorKind::NotFound => {
1481                if let Some(parent) = to.parent() {
1482                    make_private_dir(parent)?;
1483                }
1484                if let Err(e) = maker(&written, &to, dir) {
1485                    if link_unavailable(&e) {
1486                        return Ok(Err(e.to_string()));
1487                    }
1488                    return Err(e.into());
1489                }
1490            }
1491            Err(e) => return Err(e.into()),
1492        }
1493        if !link_ok(&to, &written, dir) {
1494            return Err(AppRootError::VerifyFailed(to));
1495        }
1496        Ok(Ok(MigratedLink {
1497            path: o.path.clone(),
1498            original_target: target,
1499            written_target: written,
1500            rewritten,
1501        }))
1502    }
1503}
1504
1505type LinkMaker<'a> = &'a dyn Fn(&Path, &Path, bool) -> io::Result<()>;
1506
1507/// One thing found under the source tree.
1508enum Entry {
1509    /// A directory (recorded before its contents), so an empty one is migrated too.
1510    Dir {
1511        rel: PathBuf,
1512    },
1513    File {
1514        rel: PathBuf,
1515        len: u64,
1516    },
1517    /// A symlink, recorded without following it. `dir` is true for a Windows directory link.
1518    Link {
1519        rel: PathBuf,
1520        target: PathBuf,
1521        dir: bool,
1522    },
1523    Special {
1524        rel: PathBuf,
1525        kind: SpecialKind,
1526    },
1527    /// A Windows junction or other reparse point, recorded without following it. Only built on
1528    /// Windows.
1529    #[cfg_attr(not(windows), allow(dead_code))]
1530    Reparse {
1531        rel: PathBuf,
1532        kind: ReparseKind,
1533        tag: u32,
1534    },
1535}
1536
1537impl Entry {
1538    fn rel(&self) -> &Path {
1539        match self {
1540            Self::Dir { rel }
1541            | Self::File { rel, .. }
1542            | Self::Link { rel, .. }
1543            | Self::Special { rel, .. }
1544            | Self::Reparse { rel, .. } => rel,
1545        }
1546    }
1547}
1548
1549/// What a migration did or found, as it is recorded in the state file. `Default` is the empty
1550/// state of a run that has just started.
1551#[derive(Default)]
1552struct Lists {
1553    files: u64,
1554    bytes: u64,
1555    links: Vec<MigratedLink>,
1556    specials: Vec<SkippedSpecial>,
1557    omitted: Vec<OmittedLink>,
1558    reparse: Vec<SkippedReparsePoint>,
1559}
1560
1561impl Lists {
1562    /// `Ready` unless a link was omitted or a reparse point skipped. Skipped ephemeral specials
1563    /// are recorded but do not count.
1564    fn state(&self) -> MigrationState {
1565        if self.omitted.is_empty() && self.reparse.is_empty() {
1566            MigrationState::Ready
1567        } else {
1568            MigrationState::ReadyWithOmissions
1569        }
1570    }
1571
1572    /// Read the lists back from a state file (entries that do not parse are dropped).
1573    fn from_state(v: &serde_json::Value) -> Self {
1574        let text =
1575            |o: &serde_json::Value, k: &str| o.get(k).and_then(|s| s.as_str()).map(PathBuf::from);
1576        let items = |k: &str| {
1577            v.get(k)
1578                .and_then(|a| a.as_array())
1579                .cloned()
1580                .unwrap_or_default()
1581        };
1582        Self {
1583            files: v.get("files").and_then(|n| n.as_u64()).unwrap_or(0),
1584            bytes: v.get("bytes").and_then(|n| n.as_u64()).unwrap_or(0),
1585            links: items("links")
1586                .iter()
1587                .filter_map(|o| {
1588                    Some(MigratedLink {
1589                        path: text(o, "path")?,
1590                        original_target: text(o, "original_target")?,
1591                        written_target: text(o, "written_target")?,
1592                        rewritten: o
1593                            .get("rewritten")
1594                            .and_then(|b| b.as_bool())
1595                            .unwrap_or(false),
1596                    })
1597                })
1598                .collect(),
1599            specials: items("skipped_specials")
1600                .iter()
1601                .filter_map(|o| {
1602                    let kind = match o.get("kind").and_then(|k| k.as_str())? {
1603                        "socket" => SpecialKind::Socket,
1604                        "fifo" => SpecialKind::Fifo,
1605                        _ => return None,
1606                    };
1607                    Some(SkippedSpecial {
1608                        path: text(o, "path")?,
1609                        kind,
1610                    })
1611                })
1612                .collect(),
1613            omitted: items("omitted_optional_links")
1614                .iter()
1615                .filter_map(|o| {
1616                    Some(OmittedLink {
1617                        path: text(o, "path")?,
1618                        original_target: text(o, "original_target")?,
1619                        resolved_target: text(o, "resolved_target")?,
1620                        reason: o.get("reason").and_then(|r| r.as_str())?.to_string(),
1621                    })
1622                })
1623                .collect(),
1624            reparse: items("skipped_reparse_points")
1625                .iter()
1626                .filter_map(|o| {
1627                    let kind = match o.get("kind").and_then(|k| k.as_str())? {
1628                        "junction" => ReparseKind::Junction,
1629                        _ => ReparseKind::Other,
1630                    };
1631                    Some(SkippedReparsePoint {
1632                        path: text(o, "path")?,
1633                        kind,
1634                        tag: o.get("tag").and_then(|t| t.as_u64()).unwrap_or(0) as u32,
1635                        reason: o.get("reason").and_then(|r| r.as_str())?.to_string(),
1636                    })
1637                })
1638                .collect(),
1639        }
1640    }
1641}
1642
1643/// The names the migration itself owns at the root of the source: a marker or state file of an
1644/// earlier migration of that root must not be copied over this root's own.
1645fn is_reserved_root_name(name: &std::ffi::OsStr) -> bool {
1646    let n = name.to_string_lossy();
1647    n == MIGRATION_MARKER || n == MIGRATION_STATE
1648}
1649
1650/// Every directory, regular file, and under `opts` every symlink, ephemeral special and skipped
1651/// reparse point, below `base/rel`. Directories are entries too (before their contents), so an
1652/// empty directory is migrated. A symlink, junction or reparse point is never traversed. Without
1653/// the matching option a symlink, junction, reparse point or special file is refused. The
1654/// migration's own marker and state names are not entries at the root of the source.
1655fn collect(
1656    base: &Path,
1657    rel: &Path,
1658    opts: &MigrateOptions,
1659    out: &mut Vec<Entry>,
1660) -> Result<(), AppRootError> {
1661    let mut entries: Vec<_> = fs::read_dir(base.join(rel))?.collect::<Result<_, _>>()?;
1662    entries.sort_by_key(|e| e.file_name());
1663    for e in entries {
1664        let name = e.file_name();
1665        if rel.as_os_str().is_empty() && is_reserved_root_name(&name) {
1666            continue;
1667        }
1668        let r = rel.join(&name);
1669        if !opts.selection(&r)? || opts.is_excluded(&r)? {
1670            continue;
1671        }
1672        // Windows: classify a reparse point before anything else. `DirEntry::file_type` reports a
1673        // junction as a directory symlink and an `AF_UNIX` socket file as a plain file; neither
1674        // may be treated as what std calls it.
1675        #[cfg(windows)]
1676        {
1677            if let Some(kind) = reparse_of(&e, &base.join(&r))? {
1678                if opts
1679                    .included
1680                    .as_ref()
1681                    .is_some_and(|roots| !roots.iter().any(|root| r.starts_with(root)))
1682                {
1683                    return Err(AppRootError::UnsupportedSource(base.join(&r)));
1684                }
1685                match kind {
1686                    Reparse::Symlink => {}
1687                    Reparse::Socket => {
1688                        if opts.ephemeral_specials {
1689                            out.push(Entry::Special {
1690                                rel: r,
1691                                kind: SpecialKind::Socket,
1692                            });
1693                            continue;
1694                        }
1695                        return Err(AppRootError::UnsupportedSource(base.join(&r)));
1696                    }
1697                    Reparse::Junction | Reparse::Other(_) => {
1698                        if opts.reparse_points {
1699                            let (kind, tag) = match kind {
1700                                Reparse::Other(tag) => (ReparseKind::Other, tag),
1701                                _ => (ReparseKind::Junction, IO_REPARSE_TAG_MOUNT_POINT),
1702                            };
1703                            out.push(Entry::Reparse { rel: r, kind, tag });
1704                            continue;
1705                        }
1706                        return Err(AppRootError::UnsupportedSource(base.join(&r)));
1707                    }
1708                }
1709            }
1710        }
1711        // DirEntry::file_type does not follow symlinks.
1712        let ft = e.file_type()?;
1713        if opts
1714            .included
1715            .as_ref()
1716            .is_some_and(|roots| !roots.iter().any(|root| r.starts_with(root)))
1717            && !ft.is_dir()
1718        {
1719            return Err(AppRootError::UnsupportedSource(base.join(&r)));
1720        }
1721        if ft.is_symlink() {
1722            if !opts.links {
1723                return Err(AppRootError::UnsupportedSource(base.join(&r)));
1724            }
1725            let target = fs::read_link(base.join(&r))?;
1726            out.push(Entry::Link {
1727                dir: link_is_dir(&ft),
1728                rel: r,
1729                target,
1730            });
1731        } else if ft.is_dir() {
1732            out.push(Entry::Dir { rel: r.clone() });
1733            collect(base, &r, opts, out)?;
1734        } else if ft.is_file() {
1735            out.push(Entry::File {
1736                len: e.metadata()?.len(),
1737                rel: r,
1738            });
1739        } else {
1740            match special_kind(&ft) {
1741                // Never opened, never copied.
1742                Some(kind) if opts.ephemeral_specials => out.push(Entry::Special { rel: r, kind }),
1743                _ => return Err(AppRootError::UnsupportedSource(base.join(&r))),
1744            }
1745        }
1746    }
1747    Ok(())
1748}
1749
1750/// How a Windows entry that carries `FILE_ATTRIBUTE_REPARSE_POINT` is classified by its tag.
1751#[cfg(windows)]
1752#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1753enum Reparse {
1754    /// `IO_REPARSE_TAG_SYMLINK`: handled as a link (`preserve_links`) like any other symlink.
1755    Symlink,
1756    /// `IO_REPARSE_TAG_MOUNT_POINT`: a junction or volume mount point.
1757    Junction,
1758    /// `IO_REPARSE_TAG_AF_UNIX`: an `AF_UNIX` socket file.
1759    Socket,
1760    /// Any other tag.
1761    Other(u32),
1762}
1763
1764#[cfg(windows)]
1765const IO_REPARSE_TAG_SYMLINK: u32 = 0xA000_000C;
1766#[cfg(windows)]
1767const IO_REPARSE_TAG_MOUNT_POINT: u32 = 0xA000_0003;
1768#[cfg(windows)]
1769const IO_REPARSE_TAG_AF_UNIX: u32 = 0x8000_0023;
1770
1771#[cfg(windows)]
1772fn classify_reparse_tag(tag: u32) -> Reparse {
1773    match tag {
1774        IO_REPARSE_TAG_SYMLINK => Reparse::Symlink,
1775        IO_REPARSE_TAG_MOUNT_POINT => Reparse::Junction,
1776        IO_REPARSE_TAG_AF_UNIX => Reparse::Socket,
1777        other => Reparse::Other(other),
1778    }
1779}
1780
1781/// `None` when the entry is not a reparse point. `DirEntry::metadata` does not follow reparse
1782/// points, so the attribute is the entry's own; the tag is read without opening the entry.
1783#[cfg(windows)]
1784fn reparse_of(e: &fs::DirEntry, full: &Path) -> io::Result<Option<Reparse>> {
1785    use std::os::windows::fs::MetadataExt;
1786    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
1787    if e.metadata()?.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 {
1788        return Ok(None);
1789    }
1790    Ok(Some(classify_reparse_tag(reparse_tag(full)?)))
1791}
1792
1793/// The reparse tag of the entry at `path` (`WIN32_FIND_DATAW::dwReserved0` of `FindFirstFileW`,
1794/// which describes the entry itself and does not follow it).
1795#[cfg(windows)]
1796fn reparse_tag(path: &Path) -> io::Result<u32> {
1797    use std::os::windows::ffi::OsStrExt;
1798    use windows::core::PCWSTR;
1799    use windows::Win32::Storage::FileSystem::{FindClose, FindFirstFileW, WIN32_FIND_DATAW};
1800    let wide: Vec<u16> = path
1801        .as_os_str()
1802        .encode_wide()
1803        .chain(std::iter::once(0))
1804        .collect();
1805    let mut data = WIN32_FIND_DATAW::default();
1806    // SAFETY: `wide` is NUL-terminated and outlives the call, and `data` is a valid out pointer.
1807    let handle =
1808        unsafe { FindFirstFileW(PCWSTR(wide.as_ptr()), &mut data) }.map_err(io::Error::other)?;
1809    // SAFETY: `handle` came from `FindFirstFileW` above and is closed exactly once.
1810    let _ = unsafe { FindClose(handle) };
1811    Ok(data.dwReserved0)
1812}
1813
1814fn reparse_reason(kind: ReparseKind, tag: u32) -> String {
1815    match kind {
1816        ReparseKind::Junction => {
1817            "junction or volume mount point: never followed, copied or recreated".to_string()
1818        }
1819        ReparseKind::Other => {
1820            format!("reparse point (tag {tag:#010x}): never followed, opened, copied or recreated")
1821        }
1822    }
1823}
1824
1825#[cfg(unix)]
1826fn special_kind(ft: &fs::FileType) -> Option<SpecialKind> {
1827    use std::os::unix::fs::FileTypeExt;
1828    if ft.is_socket() {
1829        Some(SpecialKind::Socket)
1830    } else if ft.is_fifo() {
1831        Some(SpecialKind::Fifo)
1832    } else {
1833        None
1834    }
1835}
1836
1837/// Windows has no socket or FIFO file type that std can name; every special stays refused.
1838#[cfg(not(unix))]
1839fn special_kind(_ft: &fs::FileType) -> Option<SpecialKind> {
1840    None
1841}
1842
1843#[cfg(windows)]
1844fn link_is_dir(ft: &fs::FileType) -> bool {
1845    use std::os::windows::fs::FileTypeExt;
1846    ft.is_symlink_dir()
1847}
1848
1849#[cfg(not(windows))]
1850fn link_is_dir(_ft: &fs::FileType) -> bool {
1851    false
1852}
1853
1854#[cfg(windows)]
1855fn link_kind_matches(ft: &fs::FileType, dir: bool) -> bool {
1856    use std::os::windows::fs::FileTypeExt;
1857    ft.is_symlink_dir() == dir
1858}
1859
1860#[cfg(not(windows))]
1861fn link_kind_matches(_ft: &fs::FileType, _dir: bool) -> bool {
1862    true
1863}
1864
1865/// The text to write for the link at `rel` whose old target text is `target`, and whether it was
1866/// rewritten. A target with a root or prefix is absolute and copied as it is. A relative target
1867/// that never climbs above the tree root keeps its text. A relative target that does climb out is
1868/// resolved lexically against the link's old location (`csrc/<parent of rel>`) and written as an
1869/// absolute path, so it keeps pointing at the original target. A target that leaves the tree and
1870/// comes back (`../<tree name>/x`) counts as leaving: the same text would mean something else
1871/// under the new root.
1872fn plan_link(csrc: &Path, rel: &Path, target: &Path) -> (PathBuf, bool) {
1873    if target
1874        .components()
1875        .any(|c| matches!(c, Component::Prefix(_) | Component::RootDir))
1876    {
1877        return (target.to_path_buf(), false);
1878    }
1879    let parent_rel = rel.parent().unwrap_or_else(|| Path::new(""));
1880    let mut depth = parent_rel.components().count();
1881    let mut inside = true;
1882    let mut abs = csrc.to_path_buf();
1883    if depth > 0 {
1884        abs.push(parent_rel);
1885    }
1886    for c in target.components() {
1887        match c {
1888            Component::Normal(n) => {
1889                abs.push(n);
1890                depth += 1;
1891            }
1892            Component::ParentDir => {
1893                if depth == 0 {
1894                    inside = false;
1895                } else {
1896                    depth -= 1;
1897                }
1898                abs.pop();
1899            }
1900            _ => {}
1901        }
1902    }
1903    if inside {
1904        (target.to_path_buf(), false)
1905    } else {
1906        (strip_verbatim(abs), true)
1907    }
1908}
1909
1910/// `\\?\C:\x` becomes `C:\x` and `\\?\UNC\h\s` becomes `\\h\s`, so a rewritten link is a plain
1911/// path. Other platforms and other forms are returned unchanged.
1912#[cfg(windows)]
1913fn strip_verbatim(p: PathBuf) -> PathBuf {
1914    let s = p.to_string_lossy().into_owned();
1915    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
1916        PathBuf::from(format!(r"\\{rest}"))
1917    } else if let Some(rest) = s.strip_prefix(r"\\?\") {
1918        if rest.len() >= 2 && rest.as_bytes()[1] == b':' {
1919            return PathBuf::from(rest);
1920        }
1921        p
1922    } else {
1923        p
1924    }
1925}
1926
1927#[cfg(not(windows))]
1928fn strip_verbatim(p: PathBuf) -> PathBuf {
1929    p
1930}
1931
1932/// Whether two link target texts name the same target. Exact on Unix. On Windows the stored text
1933/// may come back with `/` as `\`, a verbatim prefix or another case, so those are normalised.
1934fn same_link_target(a: &Path, b: &Path) -> bool {
1935    if a == b {
1936        return true;
1937    }
1938    #[cfg(windows)]
1939    {
1940        fn norm(p: &Path) -> String {
1941            let s = p.to_string_lossy().replace('/', "\\");
1942            let s = s
1943                .strip_prefix(r"\\?\UNC\")
1944                .map(|r| format!(r"\\{r}"))
1945                .or_else(|| s.strip_prefix(r"\\?\").map(str::to_string))
1946                .or_else(|| s.strip_prefix(r"\??\").map(str::to_string))
1947                .unwrap_or(s);
1948            s.to_lowercase()
1949        }
1950        norm(a) == norm(b)
1951    }
1952    #[cfg(not(windows))]
1953    {
1954        false
1955    }
1956}
1957
1958/// Create a symlink at `to` pointing at `target`. `dir` selects `symlink_dir` on Windows.
1959fn make_link(target: &Path, to: &Path, dir: bool) -> io::Result<()> {
1960    #[cfg(unix)]
1961    {
1962        let _ = dir;
1963        std::os::unix::fs::symlink(target, to)
1964    }
1965    #[cfg(windows)]
1966    {
1967        if dir {
1968            std::os::windows::fs::symlink_dir(target, to)
1969        } else {
1970            std::os::windows::fs::symlink_file(target, to)
1971        }
1972    }
1973    #[cfg(not(any(unix, windows)))]
1974    {
1975        let _ = (target, to, dir);
1976        Err(io::Error::from(io::ErrorKind::Unsupported))
1977    }
1978}
1979
1980/// Whether a link-creation error means the destination cannot hold links (Windows without
1981/// SeCreateSymbolicLink or Developer Mode; a filesystem without symlinks) rather than a fault
1982/// that should surface as plain I/O. On Windows every error except `AlreadyExists` counts.
1983fn link_unavailable(e: &io::Error) -> bool {
1984    #[cfg(windows)]
1985    {
1986        e.kind() != io::ErrorKind::AlreadyExists
1987    }
1988    #[cfg(not(windows))]
1989    {
1990        matches!(
1991            e.kind(),
1992            io::ErrorKind::PermissionDenied | io::ErrorKind::Unsupported
1993        )
1994    }
1995}
1996
1997fn lossy(p: &Path) -> String {
1998    p.to_string_lossy().into_owned()
1999}
2000
2001fn links_json(links: &[MigratedLink]) -> serde_json::Value {
2002    serde_json::Value::Array(
2003        links
2004            .iter()
2005            .map(|l| {
2006                serde_json::json!({
2007                    "path": lossy(&l.path),
2008                    "original_target": lossy(&l.original_target),
2009                    "written_target": lossy(&l.written_target),
2010                    "rewritten": l.rewritten,
2011                })
2012            })
2013            .collect(),
2014    )
2015}
2016
2017fn specials_json(specials: &[SkippedSpecial]) -> serde_json::Value {
2018    serde_json::Value::Array(
2019        specials
2020            .iter()
2021            .map(|s| serde_json::json!({"path": lossy(&s.path), "kind": s.kind.as_str()}))
2022            .collect(),
2023    )
2024}
2025
2026fn omitted_json(omitted: &[OmittedLink]) -> serde_json::Value {
2027    serde_json::Value::Array(
2028        omitted
2029            .iter()
2030            .map(|o| {
2031                serde_json::json!({
2032                    "path": lossy(&o.path),
2033                    "original_target": lossy(&o.original_target),
2034                    "resolved_target": lossy(&o.resolved_target),
2035                    "reason": o.reason,
2036                })
2037            })
2038            .collect(),
2039    )
2040}
2041
2042fn reparse_json(reparse: &[SkippedReparsePoint]) -> serde_json::Value {
2043    serde_json::Value::Array(
2044        reparse
2045            .iter()
2046            .map(|r| {
2047                serde_json::json!({
2048                    "path": lossy(&r.path),
2049                    "kind": r.kind.as_str(),
2050                    "tag": r.tag,
2051                    "reason": r.reason,
2052                })
2053            })
2054            .collect(),
2055    )
2056}
2057
2058/// The historical commit marker body, written only for a `Ready` migration. A strict migration
2059/// writes exactly the historical shape (`source`, `files`, `bytes`); an opt-in one adds `links`,
2060/// `skipped_specials`, `omitted_optional_links` and `complete`. Readers of the old shape look only
2061/// at `source`, so both shapes stay readable by both versions. (`omitted_optional_links` is
2062/// empty and `complete` true whenever the real flow writes it; the completion state with
2063/// omissions lives in the state file.)
2064fn marker_json(
2065    source: Option<&str>,
2066    files: u64,
2067    bytes: u64,
2068    opts: &MigrateOptions,
2069    links: &[MigratedLink],
2070    specials: &[SkippedSpecial],
2071    omitted: &[OmittedLink],
2072) -> serde_json::Value {
2073    let mut body = serde_json::json!({
2074        "source": source,
2075        "files": files,
2076        "bytes": bytes,
2077    });
2078    if opts.is_strict() {
2079        return body;
2080    }
2081    let obj = body.as_object_mut().expect("object");
2082    obj.insert("links".into(), links_json(links));
2083    obj.insert("skipped_specials".into(), specials_json(specials));
2084    obj.insert("omitted_optional_links".into(), omitted_json(omitted));
2085    obj.insert(
2086        "complete".into(),
2087        serde_json::Value::Bool(omitted.is_empty()),
2088    );
2089    body
2090}
2091
2092/// The versioned state file body (see [`MIGRATION_STATE`]): `version`, `state`, `source`, the
2093/// counts, every list, and `unresolved` (the omitted links and skipped reparse points, with their
2094/// reasons). `error` is the reason an `Incomplete` run stopped, when it stopped on one.
2095fn state_json(
2096    state: MigrationState,
2097    source: Option<&str>,
2098    l: &Lists,
2099    error: Option<&str>,
2100) -> serde_json::Value {
2101    let mut unresolved: Vec<serde_json::Value> = l
2102        .omitted
2103        .iter()
2104        .map(|o| serde_json::json!({"path": lossy(&o.path), "kind": "link", "reason": o.reason}))
2105        .collect();
2106    unresolved.extend(l.reparse.iter().map(|r| {
2107        serde_json::json!({"path": lossy(&r.path), "kind": "reparse_point", "reason": r.reason})
2108    }));
2109    let mut body = serde_json::json!({
2110        "version": STATE_VERSION,
2111        "state": state.as_str(),
2112        "source": source,
2113        "files": l.files,
2114        "bytes": l.bytes,
2115        "links": links_json(&l.links),
2116        "skipped_specials": specials_json(&l.specials),
2117        "omitted_optional_links": omitted_json(&l.omitted),
2118        "skipped_reparse_points": reparse_json(&l.reparse),
2119        "unresolved": unresolved,
2120    });
2121    if let (Some(e), Some(obj)) = (error, body.as_object_mut()) {
2122        obj.insert("error".into(), e.into());
2123    }
2124    body
2125}
2126
2127/// Publish a copy without replacing a contender. An equal regular file permits a retry.
2128fn commit_migration_file(
2129    writer: AtomicWriter,
2130    source: &Path,
2131    target: &Path,
2132) -> Result<(), AppRootError> {
2133    match writer.commit() {
2134        Ok(()) => Ok(()),
2135        Err(FsError::AlreadyExists(_)) => {
2136            if regular_entry(&fs::symlink_metadata(target)?) && same_content(source, target)? {
2137                Ok(())
2138            } else {
2139                Err(AppRootError::Collision(target.to_path_buf()))
2140            }
2141        }
2142        Err(e) => Err(io::Error::other(e).into()),
2143    }
2144}
2145
2146fn regular_entry(metadata: &fs::Metadata) -> bool {
2147    #[cfg(windows)]
2148    {
2149        use std::os::windows::fs::MetadataExt;
2150        if metadata.file_attributes() & 0x400 != 0 {
2151            return false;
2152        }
2153    }
2154    metadata.file_type().is_file()
2155}
2156
2157/// The migration owns these regular metadata leaves, never a link or special file.
2158/// Returns whether a validated target already exists.
2159fn validate_metadata_target(path: &Path) -> io::Result<bool> {
2160    match fs::symlink_metadata(path) {
2161        Ok(m) if regular_entry(&m) => Ok(true),
2162        Ok(_) => Err(io::Error::new(
2163            io::ErrorKind::InvalidData,
2164            format!(
2165                "migration metadata is not a regular file: {}",
2166                path.display()
2167            ),
2168        )),
2169        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
2170        Err(e) => Err(e),
2171    }
2172}
2173
2174/// Write owned metadata through exclusive staging; never open a fixed temporary name.
2175pub(crate) fn write_json_atomic(path: &Path, body: &serde_json::Value) -> io::Result<()> {
2176    let replacing = validate_metadata_target(path)?;
2177    let mut writer = AtomicWriter::create_with(
2178        path,
2179        AtomicWriteOptions {
2180            private: true,
2181            replace: if replacing {
2182                ReplaceMode::Rename
2183            } else {
2184                ReplaceMode::NoReplace
2185            },
2186            ..Default::default()
2187        },
2188    )
2189    .map_err(io::Error::other)?;
2190    writer
2191        .write_all(body.to_string().as_bytes())
2192        .map_err(io::Error::other)?;
2193    writer
2194        .commit_checked(|_| validate_metadata_target(path).map(|_| ()))
2195        .map_err(io::Error::other)
2196}
2197
2198/// Whether `to` is a link (never followed) with the written target text and, on Windows, the
2199/// right file or directory kind.
2200fn link_ok(to: &Path, written: &Path, dir: bool) -> bool {
2201    match fs::symlink_metadata(to) {
2202        Ok(m) => {
2203            m.file_type().is_symlink()
2204                && fs::read_link(to).is_ok_and(|t| same_link_target(&t, written))
2205                && link_kind_matches(&m.file_type(), dir)
2206        }
2207        Err(_) => false,
2208    }
2209}
2210
2211/// Byte-for-byte equality (stronger than a digest compare).
2212pub(crate) fn same_content(a: &Path, b: &Path) -> Result<bool, AppRootError> {
2213    let mut fa = rightkit_fs::open_read_nofollow(a, &rightkit_fs::ReadOptions::new())
2214        .map_err(io::Error::other)?;
2215    let mut fb = rightkit_fs::open_read_nofollow(b, &rightkit_fs::ReadOptions::new())
2216        .map_err(io::Error::other)?;
2217    if fa.metadata()?.len() != fb.metadata()?.len() {
2218        return Ok(false);
2219    }
2220    let (mut ba, mut bb) = (vec![0u8; 64 * 1024], vec![0u8; 64 * 1024]);
2221    loop {
2222        let na = read_full(&mut fa, &mut ba)?;
2223        let nb = read_full(&mut fb, &mut bb)?;
2224        if na != nb || ba[..na] != bb[..nb] {
2225            return Ok(false);
2226        }
2227        if na == 0 {
2228            return Ok(true);
2229        }
2230    }
2231}
2232
2233fn read_full(f: &mut File, buf: &mut [u8]) -> io::Result<usize> {
2234    let mut n = 0;
2235    while n < buf.len() {
2236        match f.read(&mut buf[n..])? {
2237            0 => break,
2238            k => n += k,
2239        }
2240    }
2241    Ok(n)
2242}
2243
2244/// Do two canonical directory paths name one directory? Unix: the same device and inode.
2245/// Windows: the same path ignoring ASCII case (the canonical form resolves links and `..`).
2246fn same_directory(a: &Path, b: &Path) -> bool {
2247    #[cfg(unix)]
2248    {
2249        use std::os::unix::fs::MetadataExt;
2250        match (fs::metadata(a), fs::metadata(b)) {
2251            (Ok(x), Ok(y)) => x.dev() == y.dev() && x.ino() == y.ino(),
2252            _ => false,
2253        }
2254    }
2255    #[cfg(not(unix))]
2256    {
2257        a.to_string_lossy()
2258            .eq_ignore_ascii_case(&b.to_string_lossy())
2259    }
2260}