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;
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#[cfg(test)]
58const TMP_SUFFIX: &str = ".rk-migrating";
59
60const WINDOWS_RESERVED: [&str; 22] = [
61    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
62    "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
63];
64
65/// Whether `s` is an app name: 1..=[`MAX_APP_NAME_LEN`] bytes of ASCII letters, digits, `-`, `_`
66/// and `.`; starts and ends with a letter or digit (so no `.`, `..`, hidden or trailing-dot
67/// names); no separators; not a Windows reserved device name (with or without an extension).
68/// Case is preserved on disk but is significant for identity on every platform: two names that
69/// differ only by case are refused as conflicting by [`AppRoot::new`] when one already exists.
70pub fn valid_app_name(s: &str) -> bool {
71    let b = s.as_bytes();
72    if b.is_empty() || b.len() > MAX_APP_NAME_LEN {
73        return false;
74    }
75    if !b
76        .iter()
77        .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.'))
78    {
79        return false;
80    }
81    if !b[0].is_ascii_alphanumeric() || !b[b.len() - 1].is_ascii_alphanumeric() || s.contains("..")
82    {
83        return false;
84    }
85    // `shared` is the suite's shared-resources directory, a sibling of every app root.
86    if s.eq_ignore_ascii_case("shared") {
87        return false;
88    }
89    let stem = s.split('.').next().unwrap_or(s).to_ascii_uppercase();
90    !WINDOWS_RESERVED.contains(&stem.as_str())
91}
92
93#[derive(Debug)]
94pub enum AppRootError {
95    InvalidName(String),
96    /// A subpath was absolute, had `..`, `.`, a prefix, was empty, or resolved outside the root.
97    PathEscape(String),
98    /// An existing sibling differs from the requested app name only by case.
99    CaseConflict {
100        requested: String,
101        existing: String,
102    },
103    /// Another process holds the single-writer lock.
104    WriterBusy(PathBuf),
105    /// The source tree holds a symlink, junction or other reparse point, or special file that this
106    /// migration mode refuses (strict mode refuses every one; an opt-in mode still refuses
107    /// devices and other specials, and a junction or reparse point unless
108    /// [`MigrateOptions::skip_reparse_points`] is on).
109    UnsupportedSource(PathBuf),
110    /// A destination file exists with different content; nothing is overwritten.
111    Collision(PathBuf),
112    /// Source and destination are the same tree or nested in each other.
113    Overlap(PathBuf),
114    /// Post-copy verification found a difference.
115    VerifyFailed(PathBuf),
116    Suite(SuiteError),
117    Io(io::Error),
118}
119
120impl fmt::Display for AppRootError {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        match self {
123            Self::InvalidName(n) => write!(f, "invalid app name: {n:?}"),
124            Self::PathEscape(p) => write!(f, "path escapes the app root: {p}"),
125            Self::CaseConflict {
126                requested,
127                existing,
128            } => write!(
129                f,
130                "app root {requested:?} conflicts with existing {existing:?} (differs only by case)"
131            ),
132            Self::WriterBusy(p) => write!(f, "another writer holds {}", p.display()),
133            Self::UnsupportedSource(p) => {
134                write!(
135                    f,
136                    "refusing symlink or special file in source: {}",
137                    p.display()
138                )
139            }
140            Self::Collision(p) => write!(f, "destination differs from source: {}", p.display()),
141            Self::Overlap(p) => write!(f, "source and destination overlap: {}", p.display()),
142            Self::VerifyFailed(p) => write!(f, "verification failed: {}", p.display()),
143            Self::Suite(e) => write!(f, "{e}"),
144            Self::Io(e) => write!(f, "{e}"),
145        }
146    }
147}
148impl std::error::Error for AppRootError {}
149
150impl AppRootError {
151    /// The typed link failure carried by an [`AppRootError::Io`], if that is what this is: a
152    /// symlink the destination could not create (Windows without the symlink privilege or
153    /// Developer Mode, or a filesystem without links) during an opt-in migration. Read the link,
154    /// its original target and the target that would have been written from [`LinkNotCreated`].
155    pub fn link_not_created(&self) -> Option<&LinkNotCreated> {
156        match self {
157            Self::Io(e) => e.get_ref()?.downcast_ref::<LinkNotCreated>(),
158            _ => None,
159        }
160    }
161
162    /// True when trying again can succeed without changing the request: a link the destination
163    /// could not create yet ([`AppRootError::link_not_created`]) or a busy writer lock.
164    pub fn is_retryable(&self) -> bool {
165        self.link_not_created().is_some() || matches!(self, Self::WriterBusy(_))
166    }
167}
168
169/// A required symlink that the destination could not create. Carried inside
170/// [`AppRootError::Io`]; read it with [`AppRootError::link_not_created`].
171///
172/// Retryable: it is returned before the completion marker is written, so the destination is not
173/// treated as migrated, the old tree is untouched and still usable, and a later call (after the
174/// privilege is granted) resumes and completes.
175#[derive(Debug, Clone, PartialEq, Eq)]
176#[non_exhaustive]
177pub struct LinkNotCreated {
178    /// Path of the link relative to the migrated tree.
179    pub link: PathBuf,
180    /// The link's target text in the old tree.
181    pub target: PathBuf,
182    /// The target that would have been written (absolute for a rewritten external link).
183    pub resolved: PathBuf,
184    /// The operating system's reason.
185    pub reason: String,
186}
187
188impl fmt::Display for LinkNotCreated {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        write!(
191            f,
192            "cannot create symlink {} -> {} in the app root: {}",
193            self.link.display(),
194            self.target.display(),
195            self.reason
196        )
197    }
198}
199impl std::error::Error for LinkNotCreated {}
200
201impl From<io::Error> for AppRootError {
202    fn from(e: io::Error) -> Self {
203        Self::Io(e)
204    }
205}
206impl From<SuiteError> for AppRootError {
207    fn from(e: SuiteError) -> Self {
208        Self::Suite(e)
209    }
210}
211
212/// Held while this process is the single writer of an app root. Released on drop or process
213/// death (OS lock).
214pub struct WriterLock {
215    _file: File,
216}
217
218/// What [`AppRoot::migrate_from`] did.
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub enum MigrationOutcome {
221    /// Copied and verified; the marker was committed.
222    Migrated { files: u64, bytes: u64 },
223    /// The marker already records this source; nothing was touched.
224    AlreadyMigrated,
225    /// The source does not exist; nothing to do and no marker written.
226    NothingToMigrate,
227}
228
229/// How far a migration into an app root got. Recorded in the versioned state file
230/// ([`MIGRATION_STATE`]); read it with [`AppRoot::migration_state`] or
231/// [`MigrationReport::state`].
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233#[non_exhaustive]
234pub enum MigrationState {
235    /// Everything in the recorded scope was migrated & verified. Whole-tree scope also writes
236    /// the historical marker [`MIGRATION_MARKER`].
237    Ready,
238    /// Every required entry is in place. Only entries the caller declared disposable are missing,
239    /// and each is recorded (optional links that could not be created, reparse points skipped on
240    /// request). The destination is usable. The historical marker is not written, so a reader of
241    /// an earlier version does not take it for a complete move; a rerun retries the omitted links.
242    ReadyWithOmissions,
243    /// The copy was started and not finished (interrupted, or stopped by a failure such as a
244    /// required link the destination cannot create). The destination is not usable: the old root
245    /// stays the authority. A rerun verifies and finishes the copy. No historical marker exists.
246    Incomplete,
247}
248
249impl MigrationState {
250    /// The name written in the state file: `ready`, `ready_with_omissions` or `incomplete`.
251    pub fn as_str(self) -> &'static str {
252        match self {
253            Self::Ready => "ready",
254            Self::ReadyWithOmissions => "ready_with_omissions",
255            Self::Incomplete => "incomplete",
256        }
257    }
258
259    /// True when the destination may be used as the app's data root (`Ready` or
260    /// `ReadyWithOmissions`).
261    pub fn is_usable(self) -> bool {
262        !matches!(self, Self::Incomplete)
263    }
264
265    fn parse(s: &str) -> Option<Self> {
266        match s {
267            "ready" => Some(Self::Ready),
268            "ready_with_omissions" => Some(Self::ReadyWithOmissions),
269            "incomplete" => Some(Self::Incomplete),
270            _ => None,
271        }
272    }
273}
274
275/// Persisted scope of a migration; selected roots include directory descendants.
276#[derive(Debug, Clone, PartialEq, Eq)]
277#[non_exhaustive]
278pub enum MigrationScope {
279    /// Historical whole-tree migration.
280    All,
281    /// Canonical relative roots, including an explicit empty selection.
282    Roots(Vec<PathBuf>),
283}
284
285/// Read-only migration state & its persisted scope for one canonical source.
286#[derive(Debug, Clone, PartialEq, Eq)]
287#[non_exhaustive]
288pub struct MigrationStatus {
289    state: MigrationState,
290    scope: MigrationScope,
291}
292
293impl MigrationStatus {
294    /// Completion state recorded for this scope.
295    pub fn state(&self) -> MigrationState {
296        self.state
297    }
298    /// Exact persisted scope; observation alone does not prove another scope complete.
299    pub fn scope(&self) -> &MigrationScope {
300        &self.scope
301    }
302}
303
304/// What [`AppRoot::migrate_from_with`] did: the [`MigrationOutcome`] plus what an opt-in
305/// migration did with links, special files and reparse points. The lists describe this call's
306/// work only (they are empty when the outcome is `NothingToMigrate`; the state file holds the
307/// record of an earlier call). The one exception is a rerun that retries the omissions of a
308/// `ReadyWithOmissions` migration: its `migrated_links` are the links created now, and
309/// `omitted_optional_links` and `skipped_reparse_points` are what is still unresolved.
310#[derive(Debug, Clone, PartialEq, Eq)]
311#[non_exhaustive]
312pub struct MigrationReport {
313    outcome: MigrationOutcome,
314    migrated_links: Vec<MigratedLink>,
315    skipped_specials: Vec<SkippedSpecial>,
316    omitted_optional_links: Vec<OmittedLink>,
317    skipped_reparse_points: Vec<SkippedReparsePoint>,
318    state: MigrationState,
319}
320
321impl MigrationReport {
322    fn bare(outcome: MigrationOutcome, state: MigrationState) -> Self {
323        Self {
324            outcome,
325            migrated_links: Vec::new(),
326            skipped_specials: Vec::new(),
327            omitted_optional_links: Vec::new(),
328            skipped_reparse_points: Vec::new(),
329            state,
330        }
331    }
332
333    /// How far the migration got. A returned report is `Ready` or `ReadyWithOmissions`; an
334    /// incomplete migration is an error, and its state is read with [`AppRoot::migration_state`].
335    pub fn state(&self) -> MigrationState {
336        self.state
337    }
338
339    /// Junctions and other Windows reparse points skipped on request (see
340    /// [`MigrateOptions::skip_reparse_points`]). None is ever followed, copied or recreated.
341    pub fn skipped_reparse_points(&self) -> &[SkippedReparsePoint] {
342        &self.skipped_reparse_points
343    }
344
345    /// The same value `migrate_from` returns.
346    pub fn outcome(&self) -> &MigrationOutcome {
347        &self.outcome
348    }
349
350    pub fn into_outcome(self) -> MigrationOutcome {
351        self.outcome
352    }
353
354    /// Symlinks recreated in the app root (as link entries, never followed).
355    pub fn migrated_links(&self) -> &[MigratedLink] {
356        &self.migrated_links
357    }
358
359    /// Sockets and FIFOs skipped (see [`MigrateOptions::skip_ephemeral_specials`]).
360    pub fn skipped_specials(&self) -> &[SkippedSpecial] {
361        &self.skipped_specials
362    }
363
364    /// Links the caller's policy marked optional and that could not be created. A required link
365    /// that cannot be created is an error instead ([`AppRootError::link_not_created`]).
366    pub fn omitted_optional_links(&self) -> &[OmittedLink] {
367        &self.omitted_optional_links
368    }
369
370    /// True only for [`MigrationState::Ready`]: false when an optional link was omitted or a
371    /// reparse point was skipped. Skipped ephemeral specials (sockets, FIFOs) do not make a
372    /// migration incomplete. For `AlreadyMigrated` this is the value the earlier state recorded.
373    pub fn complete(&self) -> bool {
374        self.state == MigrationState::Ready
375    }
376}
377
378/// A symlink recreated by a link-preserving migration.
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct MigratedLink {
381    /// Path of the link relative to the migrated tree.
382    pub path: PathBuf,
383    /// The link's target text in the old tree, exactly as `read_link` returned it.
384    pub original_target: PathBuf,
385    /// The target text written to the new link.
386    pub written_target: PathBuf,
387    /// True when a relative link leaving the tree was rewritten to an absolute path to its
388    /// original target.
389    pub rewritten: bool,
390}
391
392/// The kind of ephemeral special file skipped by a migration.
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394pub enum SpecialKind {
395    Socket,
396    Fifo,
397}
398
399impl SpecialKind {
400    fn as_str(self) -> &'static str {
401        match self {
402            Self::Socket => "socket",
403            Self::Fifo => "fifo",
404        }
405    }
406}
407
408/// A socket or FIFO that was skipped (not opened, not copied).
409#[derive(Debug, Clone, PartialEq, Eq)]
410pub struct SkippedSpecial {
411    /// Path relative to the migrated tree.
412    pub path: PathBuf,
413    pub kind: SpecialKind,
414}
415
416/// The kind of Windows reparse point skipped by a migration.
417#[derive(Debug, Clone, Copy, PartialEq, Eq)]
418#[non_exhaustive]
419pub enum ReparseKind {
420    /// A junction (mount point) or volume mount point. Std reports it as a directory symlink, but
421    /// it is not one: it is never recreated as a symlink, because that would change what it is.
422    Junction,
423    /// Any other reparse point that is not a symlink or an `AF_UNIX` socket (cloud placeholders,
424    /// WSL links, app execution aliases, deduplicated files, ...).
425    Other,
426}
427
428impl ReparseKind {
429    fn as_str(self) -> &'static str {
430        match self {
431            Self::Junction => "junction",
432            Self::Other => "other",
433        }
434    }
435}
436
437/// A reparse point that was skipped (never followed, opened, copied or recreated). Windows only:
438/// see [`MigrateOptions::skip_reparse_points`].
439#[derive(Debug, Clone, PartialEq, Eq)]
440#[non_exhaustive]
441pub struct SkippedReparsePoint {
442    /// Path relative to the migrated tree.
443    pub path: PathBuf,
444    pub kind: ReparseKind,
445    /// The reparse tag (`IO_REPARSE_TAG_*`).
446    pub tag: u32,
447    /// Why it was skipped.
448    pub reason: String,
449}
450
451/// An optional link that the destination could not hold and the caller's policy allowed to omit.
452#[derive(Debug, Clone, PartialEq, Eq)]
453pub struct OmittedLink {
454    /// Path of the link relative to the migrated tree.
455    pub path: PathBuf,
456    /// The link's target text in the old tree.
457    pub original_target: PathBuf,
458    /// The target that would have been written (absolute for a rewritten external link).
459    pub resolved_target: PathBuf,
460    /// Why the link could not be created.
461    pub reason: String,
462}
463
464type OptionalLinkPolicy = std::sync::Arc<dyn Fn(&Path) -> bool + Send + Sync>;
465
466/// Opt-in behaviour for [`AppRoot::migrate_from_with`]. The default is strict and identical to
467/// [`AppRoot::migrate_from`]: any symlink or special file in the source is refused.
468///
469/// The type is `#[non_exhaustive]`; build it with [`MigrateOptions::strict`] and the builders.
470#[derive(Clone, Default)]
471#[non_exhaustive]
472pub struct MigrateOptions {
473    links: bool,
474    ephemeral_specials: bool,
475    reparse_points: bool,
476    optional_link: Option<OptionalLinkPolicy>,
477    excluded: Vec<PathBuf>,
478    pub(crate) included: Option<Vec<PathBuf>>,
479}
480
481impl fmt::Debug for MigrateOptions {
482    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
483        f.debug_struct("MigrateOptions")
484            .field("links", &self.links)
485            .field("ephemeral_specials", &self.ephemeral_specials)
486            .field("reparse_points", &self.reparse_points)
487            .field("optional_link_policy", &self.optional_link.is_some())
488            .field("excluded", &self.excluded)
489            .field("included", &self.included)
490            .finish()
491    }
492}
493
494impl MigrateOptions {
495    /// Restrict migration to these relative roots, including each directory's descendants.
496    /// An empty list selects nothing; the default selects the whole tree.
497    /// Selected migrations always revalidate source & destination, retain previously selected
498    /// roots for retries of the same source, & use versioned scope-aware state without writing
499    /// a historical whole-tree completion marker.
500    pub fn include_roots(mut self, roots: Vec<PathBuf>) -> Result<Self, AppRootError> {
501        self.included = Some(normalize_roots(roots)?);
502        Ok(self)
503    }
504
505    fn selection(&self, rel: &Path) -> Result<bool, AppRootError> {
506        let Some(roots) = &self.included else {
507            return Ok(true);
508        };
509        for root in roots {
510            if rel.starts_with(root) || root.starts_with(rel) {
511                return Ok(true);
512            }
513            let a: Vec<_> = rel.components().collect();
514            let b: Vec<_> = root.components().collect();
515            if a.iter()
516                .zip(&b)
517                .all(|(x, y)| folded_component(*x) == folded_component(*y))
518            {
519                return Err(AppRootError::UnsupportedSource(rel.to_path_buf()));
520            }
521        }
522        Ok(false)
523    }
524
525    fn scope_state(&self, mut body: serde_json::Value) -> serde_json::Value {
526        if let Some(roots) = &self.included {
527            body["version"] = serde_json::json!(SELECTED_STATE_VERSION);
528            body["selected_roots"] = serde_json::json!(roots);
529        }
530        body
531    }
532
533    /// Refuse every symlink and special file (the default).
534    pub fn strict() -> Self {
535        Self::default()
536    }
537
538    /// Copy symlinks as link entries, never traversing them. A relative link that stays inside
539    /// the tree keeps its text; a relative link that leaves the tree becomes an absolute link to
540    /// its original target (resolved lexically against the link's old location); an absolute or
541    /// dangling link is copied as it is. If the destination cannot create a link the migration
542    /// fails with the retryable [`AppRootError::link_not_created`] error before any marker is written,
543    /// unless [`MigrateOptions::optional_links`] marks that link omittable.
544    pub fn preserve_links(mut self, on: bool) -> Self {
545        self.links = on;
546        self
547    }
548
549    /// Skip sockets and FIFOs (never opened) and report them. Device nodes and any other special
550    /// file are still refused. On Windows an `AF_UNIX` socket file (a reparse point) counts as a
551    /// socket.
552    pub fn skip_ephemeral_specials(mut self, on: bool) -> Self {
553        self.ephemeral_specials = on;
554        self
555    }
556
557    /// Windows reparse-point policy. A junction (mount point) or any other reparse point that is
558    /// neither a symlink nor an `AF_UNIX` socket is never followed, opened, copied or recreated:
559    /// the migration cannot tell whether it leads out of the root, and recreating a junction as a
560    /// symlink would change what it is. By default (and in strict mode) such an entry refuses the
561    /// migration with [`AppRootError::UnsupportedSource`], before anything is written. With this
562    /// option the entry is skipped and reported ([`MigrationReport::skipped_reparse_points`], and
563    /// in the state file), and the result is [`MigrationState::ReadyWithOmissions`], never `Ready`:
564    /// the caller has declared the entry disposable and the omission stays on record. A directory
565    /// reparse point is never recursed into. No effect on other platforms (they have none).
566    pub fn skip_reparse_points(mut self, on: bool) -> Self {
567        self.reparse_points = on;
568        self
569    }
570
571    /// Caller policy naming links that may be omitted when the destination cannot create them.
572    /// The predicate receives the link's path relative to the migrated tree. It only matters
573    /// together with [`MigrateOptions::preserve_links`], and only for a link whose creation
574    /// failed; every other link is migrated normally. Omissions are reported in the outcome and
575    /// in the marker, separately from required links.
576    pub fn optional_links(
577        mut self,
578        is_optional: impl Fn(&Path) -> bool + Send + Sync + 'static,
579    ) -> Self {
580        self.optional_link = Some(std::sync::Arc::new(is_optional));
581        self
582    }
583
584    /// Leave the entry at `rel` (a path relative to the migrated tree) and everything below it out
585    /// of the copy. For state a lifecycle participant moves by its own means (an encrypted
586    /// database and its key, moved by [`RootCutover`](crate::RootCutover) through the participant
587    /// that owns it): copying such files as plain bytes would race the owner's relocation and
588    /// collide with its destination. An excluded path is not planned, copied, verified or
589    /// recorded; it is neither an omission nor a reason for the state to be `ReadyWithOmissions`,
590    /// so the caller that excludes a path owns moving it. Exclusions match exact components;
591    /// differently spelled case aliases are refused rather than silently skipped. `rel` must be relative and made only of
592    /// normal components; anything else is ignored (it can match no entry).
593    pub fn exclude(mut self, rel: impl Into<PathBuf>) -> Self {
594        let rel = rel.into();
595        if rel.components().next().is_some()
596            && rel.components().all(|c| matches!(c, Component::Normal(_)))
597        {
598            self.excluded.push(rel);
599        }
600        self
601    }
602
603    pub fn links(&self) -> bool {
604        self.links
605    }
606
607    pub fn ephemeral_specials(&self) -> bool {
608        self.ephemeral_specials
609    }
610
611    pub fn reparse_points(&self) -> bool {
612        self.reparse_points
613    }
614
615    fn is_strict(&self) -> bool {
616        !self.links
617            && !self.ephemeral_specials
618            && !self.reparse_points
619            && self.optional_link.is_none()
620    }
621
622    fn is_optional(&self, rel: &Path) -> bool {
623        self.optional_link.as_ref().is_some_and(|f| f(rel))
624    }
625
626    /// Exact component exclusions preserve distinct names on case-sensitive volumes.
627    /// Differently spelled case aliases fail closed rather than silently omitting material.
628    fn is_excluded(&self, rel: &Path) -> Result<bool, AppRootError> {
629        for ex in &self.excluded {
630            if rel.starts_with(ex) {
631                return Ok(true);
632            }
633            let mut have = rel.components();
634            let ambiguous = ex.components().all(|want| match (want, have.next()) {
635                (Component::Normal(w), Some(Component::Normal(h))) => {
636                    w.to_string_lossy().to_lowercase() == h.to_string_lossy().to_lowercase()
637                }
638                _ => false,
639            });
640            if ambiguous {
641                return Err(AppRootError::UnsupportedSource(rel.to_path_buf()));
642            }
643        }
644        Ok(false)
645    }
646}
647
648pub(crate) fn normalize_roots(mut roots: Vec<PathBuf>) -> Result<Vec<PathBuf>, AppRootError> {
649    for root in &roots {
650        if root.as_os_str().is_empty()
651            || root.to_str().is_none()
652            || !root.components().all(|c| matches!(c, Component::Normal(_)))
653            || root
654                .components()
655                .any(|c| c.as_os_str().to_string_lossy().contains(['\\', ':', '\0']))
656            || root
657                .to_string_lossy()
658                .split(['/', '\\'])
659                .any(|part| part.is_empty() || part == "." || part == "..")
660            || [MIGRATION_STATE, MIGRATION_MARKER, WRITER_LOCK]
661                .iter()
662                .any(|reserved| roots_overlap(root, Path::new(reserved)))
663        {
664            return Err(AppRootError::PathEscape(root.display().to_string()));
665        }
666    }
667    roots.sort();
668    roots.dedup();
669    for (i, root) in roots.iter().enumerate() {
670        for other in &roots[..i] {
671            let a: Vec<_> = root.components().collect();
672            let b: Vec<_> = other.components().collect();
673            for (x, y) in a.iter().zip(&b) {
674                if x == y {
675                    continue;
676                }
677                if folded_component(*x) == folded_component(*y) {
678                    return Err(AppRootError::PathEscape(root.display().to_string()));
679                }
680                break;
681            }
682        }
683    }
684    let all = roots.clone();
685    roots.retain(|root| {
686        !all.iter()
687            .any(|other| root != other && root.starts_with(other))
688    });
689    Ok(roots)
690}
691
692fn folded_component(component: Component<'_>) -> String {
693    component.as_os_str().to_string_lossy().to_lowercase()
694}
695
696pub(crate) fn roots_overlap(a: &Path, b: &Path) -> bool {
697    a.components()
698        .zip(b.components())
699        .all(|(x, y)| folded_component(x) == folded_component(y))
700}
701
702/// `<Orthic Labs>/<App>`, created private.
703#[derive(Debug, Clone)]
704pub struct AppRoot {
705    name: String,
706    root: PathBuf,
707}
708
709impl Suite {
710    /// The per-app root for `app`, created private. See [`AppRoot`].
711    pub fn app_root(&self, app: &str) -> Result<AppRoot, AppRootError> {
712        AppRoot::new(self, app)
713    }
714}
715
716impl AppRoot {
717    pub fn new(suite: &Suite, app: &str) -> Result<AppRoot, AppRootError> {
718        if !valid_app_name(app) {
719            return Err(AppRootError::InvalidName(app.to_string()));
720        }
721        let parent = suite.app_parent();
722        if let Ok(rd) = fs::read_dir(&parent) {
723            for e in rd.flatten() {
724                let n = e.file_name().to_string_lossy().into_owned();
725                if n != app && n.eq_ignore_ascii_case(app) {
726                    return Err(AppRootError::CaseConflict {
727                        requested: app.to_string(),
728                        existing: n,
729                    });
730                }
731            }
732        }
733        let root = parent.join(app);
734        make_private_dir(&root)?;
735        Ok(AppRoot {
736            name: app.to_string(),
737            root,
738        })
739    }
740
741    pub fn name(&self) -> &str {
742        &self.name
743    }
744
745    pub fn root(&self) -> &Path {
746        &self.root
747    }
748
749    /// `<app root>/<rel>` without creating anything. `rel` must be relative and made only of
750    /// normal components (no `..`, `.`, root, prefix or empty); any existing ancestor that is a
751    /// symlink must still resolve inside the app root.
752    pub fn path(&self, rel: impl AsRef<Path>) -> Result<PathBuf, AppRootError> {
753        self.path_checked(rel.as_ref(), true)
754    }
755
756    /// [`AppRoot::path`]; with `follow_leaf` false only the ancestors are validated and the final
757    /// component may be a symlink (a link entry being placed) that is never resolved.
758    fn path_checked(&self, rel: &Path, follow_leaf: bool) -> Result<PathBuf, AppRootError> {
759        let bad = || AppRootError::PathEscape(rel.display().to_string());
760        let mut any = false;
761        for c in rel.components() {
762            match c {
763                Component::Normal(n) => {
764                    any = true;
765                    // `:` is a Windows stream/drive separator and a reserved char elsewhere.
766                    if n.to_string_lossy().contains(['\\', ':', '\0']) {
767                        return Err(bad());
768                    }
769                }
770                _ => return Err(bad()),
771            }
772        }
773        if !any {
774            return Err(bad());
775        }
776        let full = self.root.join(rel);
777        let canon_root = fs::canonicalize(&self.root)?;
778        let mut probe = if follow_leaf {
779            full.as_path()
780        } else {
781            full.parent().unwrap_or(&self.root)
782        };
783        loop {
784            match fs::canonicalize(probe) {
785                Ok(c) => {
786                    if !c.starts_with(&canon_root) {
787                        return Err(bad());
788                    }
789                    break;
790                }
791                Err(e) if e.kind() == io::ErrorKind::NotFound => {
792                    // A dangling symlink is NotFound for canonicalize but must not be followed.
793                    if fs::symlink_metadata(probe).is_ok() {
794                        return Err(bad());
795                    }
796                    match probe.parent() {
797                        Some(p) if p.starts_with(&self.root) => probe = p,
798                        _ => return Err(bad()),
799                    }
800                }
801                Err(e) => return Err(e.into()),
802            }
803        }
804        Ok(full)
805    }
806
807    /// A private directory at `<app root>/<rel>`, created if absent.
808    pub fn data_dir(&self, rel: impl AsRef<Path>) -> Result<PathBuf, AppRootError> {
809        let d = self.path(rel)?;
810        make_private_dir(&d)?;
811        // Re-check after creation: nothing created may resolve outside the root.
812        self.path(d.strip_prefix(&self.root).unwrap_or(&d))?;
813        Ok(d)
814    }
815
816    /// Take the single-writer lock for this app root. `Err(WriterBusy)` if another holder lives.
817    pub fn lock_writer(&self) -> Result<WriterLock, AppRootError> {
818        let p = self.root.join(WRITER_LOCK);
819        match try_lock_exclusive(&p)? {
820            Some(f) => Ok(WriterLock { _file: f }),
821            None => Err(AppRootError::WriterBusy(p)),
822        }
823    }
824
825    /// Copy the old root `src` into this app root, verify every byte, then commit. Restart-safe:
826    /// files land under a temporary name and are renamed only when fully written; a rerun after
827    /// any interruption re-copies missing or partial files, accepts files already identical, and
828    /// refuses (never overwrites) a differing destination file. Directories, including empty
829    /// ones, are migrated. The versioned state ([`MIGRATION_STATE`]) is `Incomplete` from before
830    /// the destination is first touched until the copy is verified; the historical marker
831    /// ([`MIGRATION_MARKER`]) is written last, and once present for this source the call is a
832    /// no-op. `src` is never changed or deleted. Symlinks, junctions and special files in `src` are
833    /// refused (strict); use [`AppRoot::migrate_from_with`] to opt in to links, ephemeral specials
834    /// and skipped reparse points. Takes the writer lock.
835    pub fn migrate_from(&self, src: &Path) -> Result<MigrationOutcome, AppRootError> {
836        Ok(self
837            .migrate_from_with(src, MigrateOptions::strict())?
838            .into_outcome())
839    }
840
841    /// [`AppRoot::migrate_from`] with opt-in behaviour. Lock, restart, collision handling and the
842    /// marker-last ordering are the same in every mode; `MigrateOptions::strict()` is exactly
843    /// `migrate_from`. See [`MigrateOptions`] for how links, ephemeral specials and reparse points
844    /// are handled. Returns a [`MigrationReport`] (its `outcome()` is what `migrate_from`
845    /// returns).
846    ///
847    /// A required link that the destination cannot create returns the retryable
848    /// [`AppRootError::link_not_created`] error and leaves the state `Incomplete`: the old tree is
849    /// untouched, no historical marker exists, and the incomplete destination is never treated as
850    /// migrated by this or an earlier version. A rerun finishes the copy. When the state is
851    /// `ReadyWithOmissions` a rerun retries only the omitted links; it neither re-verifies files
852    /// nor fails on files the app changed since.
853    pub fn migrate_from_with(
854        &self,
855        src: &Path,
856        opts: MigrateOptions,
857    ) -> Result<MigrationReport, AppRootError> {
858        self.migrate_impl(src, &opts, &make_link)
859    }
860
861    /// The completion state of the migration of `src` into this app root: `None` when no migration
862    /// of `src` has started here (or `src` does not exist), otherwise what the last run left.
863    /// `Incomplete` means the destination must not be used and the old root stays the authority. A
864    /// historical marker written by an earlier version counts as `Ready` (as `ReadyWithOmissions`
865    /// when it records `"complete": false`). A state file written by a newer version is an error
866    /// (`AppRootError::Io`, `InvalidData`). Malformed metadata also fails rather than falling
867    /// back to a historical marker. Takes no lock and changes nothing.
868    pub fn migration_state(&self, src: &Path) -> Result<Option<MigrationState>, AppRootError> {
869        Ok(self.migration_status(src)?.map(|status| status.state()))
870    }
871
872    /// Read persisted state & scope without taking a lock or changing files. Missing source or
873    /// metadata returns `None`; valid metadata for another source also returns `None`.
874    /// Malformed, unknown-version or invalid-scope metadata is an error, never evidence that a
875    /// destination is untracked. A historical marker is consulted only when state is absent.
876    pub fn migration_status(&self, src: &Path) -> Result<Option<MigrationStatus>, AppRootError> {
877        if !src.is_absolute() || src.components().any(|c| c == Component::ParentDir) {
878            return Err(AppRootError::PathEscape(src.display().to_string()));
879        }
880        let csrc = match fs::canonicalize(src) {
881            Ok(c) => c,
882            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
883            Err(e) => return Err(e.into()),
884        };
885        let invalid = |detail: &str| {
886            AppRootError::Io(io::Error::new(
887                io::ErrorKind::InvalidData,
888                detail.to_string(),
889            ))
890        };
891        let valid_source = |source: &str| {
892            let path = Path::new(source);
893            path.is_absolute() && !path.components().any(|part| part == Component::ParentDir)
894        };
895        let read = |path: &Path| -> Result<Option<serde_json::Value>, AppRootError> {
896            let text = match fs::read_to_string(path) {
897                Ok(text) => text,
898                Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
899                Err(error) => return Err(error.into()),
900            };
901            serde_json::from_str(&text)
902                .map(Some)
903                .map_err(|_| invalid("invalid migration metadata JSON"))
904        };
905        if let Some(value) = read(&self.root.join(MIGRATION_STATE))? {
906            let source = value
907                .get("source")
908                .and_then(serde_json::Value::as_str)
909                .ok_or_else(|| invalid("missing migration source"))?;
910            if !valid_source(source) {
911                return Err(invalid("invalid migration source"));
912            }
913            let state = value
914                .get("state")
915                .and_then(serde_json::Value::as_str)
916                .and_then(MigrationState::parse)
917                .ok_or_else(|| invalid("unknown migration state"))?;
918            let scope = match value.get("version").and_then(serde_json::Value::as_u64) {
919                Some(1) | Some(STATE_VERSION) if value.get("selected_roots").is_none() => {
920                    MigrationScope::All
921                }
922                Some(SELECTED_STATE_VERSION) => {
923                    let roots: Vec<PathBuf> = serde_json::from_value(
924                        value
925                            .get("selected_roots")
926                            .cloned()
927                            .ok_or_else(|| invalid("missing selected scope"))?,
928                    )
929                    .map_err(|_| invalid("invalid selected scope"))?;
930                    if normalize_roots(roots.clone())
931                        .map_err(|_| invalid("invalid selected scope"))?
932                        != roots
933                    {
934                        return Err(invalid("noncanonical selected scope"));
935                    }
936                    MigrationScope::Roots(roots)
937                }
938                _ => return Err(invalid("unknown migration version or scope")),
939            };
940            return Ok((Some(source) == csrc.to_str()).then_some(MigrationStatus { state, scope }));
941        }
942        let Some(marker) = read(&self.root.join(MIGRATION_MARKER))? else {
943            return Ok(None);
944        };
945        let source = marker
946            .get("source")
947            .and_then(serde_json::Value::as_str)
948            .ok_or_else(|| invalid("missing historical migration source"))?;
949        if !valid_source(source) {
950            return Err(invalid("invalid historical migration source"));
951        }
952        let complete = match marker.get("complete") {
953            None => true,
954            Some(value) => value
955                .as_bool()
956                .ok_or_else(|| invalid("invalid historical completion flag"))?,
957        };
958        Ok((Some(source) == csrc.to_str()).then_some(MigrationStatus {
959            state: if complete {
960                MigrationState::Ready
961            } else {
962                MigrationState::ReadyWithOmissions
963            },
964            scope: MigrationScope::All,
965        }))
966    }
967
968    /// The state file when it records `csrc` as its source. An unreadable or unversioned file is
969    /// not ours and counts as absent; a file of a newer version, or with a state this version does
970    /// not know, is refused rather than guessed at.
971    fn read_state(
972        &self,
973        csrc: &Path,
974    ) -> Result<Option<(MigrationState, serde_json::Value)>, AppRootError> {
975        let path = self.root.join(MIGRATION_STATE);
976        let text = match fs::read_to_string(&path) {
977            Ok(t) => t,
978            Err(e)
979                if matches!(
980                    e.kind(),
981                    io::ErrorKind::NotFound | io::ErrorKind::InvalidData
982                ) =>
983            {
984                return Ok(None)
985            }
986            Err(e) => return Err(e.into()),
987        };
988        let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
989            return Ok(None);
990        };
991        let Some(version) = v.get("version").and_then(|n| n.as_u64()) else {
992            return Ok(None);
993        };
994        if v.get("source").and_then(|s| s.as_str()) != csrc.to_str() {
995            return Ok(None);
996        }
997        let state = v
998            .get("state")
999            .and_then(|s| s.as_str())
1000            .and_then(MigrationState::parse);
1001        match state {
1002            Some(state) if version <= SELECTED_STATE_VERSION => {
1003                if version < SELECTED_STATE_VERSION && v.get("selected_roots").is_some() {
1004                    return Err(AppRootError::PathEscape("legacy state cannot carry selected scope".into()));
1005                }
1006                if version == SELECTED_STATE_VERSION {
1007                    let roots: Vec<PathBuf> = serde_json::from_value(v.get("selected_roots").cloned().ok_or_else(|| AppRootError::PathEscape("missing selected scope".into()))?)
1008                        .map_err(|_| AppRootError::PathEscape("invalid selected scope".into()))?;
1009                    if normalize_roots(roots.clone())? != roots {
1010                        return Err(AppRootError::PathEscape("noncanonical selected scope".into()));
1011                    }
1012                }
1013                Ok(Some((state, v)))
1014            },
1015            _ => Err(AppRootError::Io(io::Error::new(
1016                io::ErrorKind::InvalidData,
1017                format!(
1018                    "{} was written by a newer version or holds an unknown state; refusing to guess",
1019                    path.display()
1020                ),
1021            ))),
1022        }
1023    }
1024
1025    /// `Some(complete)` when the historical marker records `csrc` as its source.
1026    fn legacy_marker(&self, csrc: &Path) -> Option<bool> {
1027        let text = fs::read_to_string(self.root.join(MIGRATION_MARKER)).ok()?;
1028        let v = serde_json::from_str::<serde_json::Value>(&text).ok()?;
1029        if v.get("source").and_then(|s| s.as_str()) != csrc.to_str() {
1030            return None;
1031        }
1032        Some(v.get("complete").and_then(|c| c.as_bool()).unwrap_or(true))
1033    }
1034
1035    fn write_state(&self, body: &serde_json::Value) -> Result<(), AppRootError> {
1036        write_json_atomic(&self.root.join(MIGRATION_STATE), body)?;
1037        Ok(())
1038    }
1039
1040    fn migrate_impl(
1041        &self,
1042        src: &Path,
1043        opts: &MigrateOptions,
1044        maker: LinkMaker<'_>,
1045    ) -> Result<MigrationReport, AppRootError> {
1046        self.migrate_current(src, opts, maker, false)
1047    }
1048
1049    /// Cutover must compare current authoritative bytes even if a previous copy was Ready.
1050    pub(crate) fn migrate_for_cutover(
1051        &self,
1052        src: &Path,
1053        opts: &MigrateOptions,
1054    ) -> Result<MigrationReport, AppRootError> {
1055        self.migrate_current(src, opts, &make_link, true)
1056    }
1057
1058    fn migrate_current(
1059        &self,
1060        src: &Path,
1061        opts: &MigrateOptions,
1062        maker: LinkMaker<'_>,
1063        revalidate: bool,
1064    ) -> Result<MigrationReport, AppRootError> {
1065        if !src.is_absolute() || src.components().any(|c| c == Component::ParentDir) {
1066            return Err(AppRootError::PathEscape(src.display().to_string()));
1067        }
1068        let md = match fs::symlink_metadata(src) {
1069            Ok(m) => m,
1070            Err(e) if e.kind() == io::ErrorKind::NotFound => {
1071                return Ok(MigrationReport::bare(
1072                    MigrationOutcome::NothingToMigrate,
1073                    MigrationState::Ready,
1074                ))
1075            }
1076            Err(e) => return Err(e.into()),
1077        };
1078        if !md.is_dir() {
1079            return Err(AppRootError::UnsupportedSource(src.to_path_buf()));
1080        }
1081        let csrc = fs::canonicalize(src)?;
1082        let cdst = fs::canonicalize(&self.root)?;
1083        if csrc.starts_with(&cdst) || cdst.starts_with(&csrc) {
1084            return Err(AppRootError::Overlap(src.to_path_buf()));
1085        }
1086        let _lock = self.lock_writer()?;
1087        let prior = self.read_state(&csrc)?;
1088        let prior_selected = prior
1089            .as_ref()
1090            .is_some_and(|(_, state)| state.get("selected_roots").is_some());
1091        let mut effective = opts.clone();
1092        if let (Some(roots), Some((_, state))) = (&mut effective.included, &prior) {
1093            if let Some(previous) = state.get("selected_roots") {
1094                let previous: Vec<PathBuf> = serde_json::from_value(previous.clone())
1095                    .map_err(|_| AppRootError::PathEscape("invalid selected scope".into()))?;
1096                roots.extend(previous);
1097                *roots = normalize_roots(std::mem::take(roots))?;
1098            }
1099        }
1100        let opts = &effective;
1101        let revalidate = revalidate || opts.included.is_some() || prior_selected;
1102        match prior {
1103            Some((MigrationState::Ready, state)) if !revalidate => {
1104                self.repair_marker(&csrc, &state);
1105                return Ok(MigrationReport::bare(
1106                    MigrationOutcome::AlreadyMigrated,
1107                    MigrationState::Ready,
1108                ));
1109            }
1110            Some((MigrationState::ReadyWithOmissions, state)) if !revalidate => {
1111                return self.retry_unresolved(&csrc, &state, maker);
1112            }
1113            // An earlier run did not finish: do the whole copy again.
1114            Some(_) => {}
1115            None => {
1116                if let Some(complete) = self.legacy_marker(&csrc).filter(|_| !revalidate) {
1117                    let state = if complete {
1118                        MigrationState::Ready
1119                    } else {
1120                        MigrationState::ReadyWithOmissions
1121                    };
1122                    return Ok(MigrationReport::bare(
1123                        MigrationOutcome::AlreadyMigrated,
1124                        state,
1125                    ));
1126                }
1127            }
1128        }
1129        // Plan first: a source this mode refuses fails here, before the destination is touched.
1130        let mut entries = Vec::new();
1131        collect(&csrc, Path::new(""), opts, &mut entries)?;
1132        if revalidate {
1133            // Old root remains authoritative until switch. A file removed there after an earlier
1134            // copy must not survive silently in the selected tree; preserve it and refuse cutover.
1135            let current: std::collections::BTreeSet<_> = entries.iter().map(Entry::rel).collect();
1136            let mut destination = Vec::new();
1137            let destination_opts = opts.clone().exclude(WRITER_LOCK);
1138            collect(&cdst, Path::new(""), &destination_opts, &mut destination)?;
1139            for entry in &destination {
1140                if !current.contains(entry.rel()) {
1141                    return Err(AppRootError::Collision(cdst.join(entry.rel())));
1142                }
1143            }
1144        }
1145        // Record the pending state before the first destination write, so that any interruption
1146        // (a crash included) leaves `Incomplete` on disk and never a state that reads as done.
1147        self.write_state(&opts.scope_state(state_json(
1148            MigrationState::Incomplete,
1149            csrc.to_str(),
1150            &Lists::default(),
1151            None,
1152        )))?;
1153        match self.copy_entries(&csrc, &entries, opts, maker) {
1154            Ok(done) => self.commit(&csrc, opts, done),
1155            Err(e) => {
1156                self.record_failure(&csrc, opts, &e);
1157                Err(e)
1158            }
1159        }
1160    }
1161
1162    /// Copy and verify every planned entry. Nothing here writes a marker or a completed state.
1163    fn copy_entries(
1164        &self,
1165        csrc: &Path,
1166        entries: &[Entry],
1167        opts: &MigrateOptions,
1168        maker: LinkMaker<'_>,
1169    ) -> Result<Lists, AppRootError> {
1170        let mut done = Lists::default();
1171        // (relative path, written target, is a directory link) for verification.
1172        let mut created: Vec<(PathBuf, PathBuf, bool)> = Vec::new();
1173        for entry in entries {
1174            match entry {
1175                Entry::Dir { rel } => {
1176                    // Validate the parent chain only: the leaf must be a real directory and is
1177                    // never followed if it is a link.
1178                    let to = self.path_checked(rel, false)?;
1179                    match fs::symlink_metadata(&to) {
1180                        Ok(m) if m.file_type().is_dir() => make_private_dir(&to)?,
1181                        Ok(_) => return Err(AppRootError::Collision(to)),
1182                        Err(e) if e.kind() == io::ErrorKind::NotFound => make_private_dir(&to)?,
1183                        Err(e) => return Err(e.into()),
1184                    }
1185                }
1186                Entry::File { rel, len } => {
1187                    done.files += 1;
1188                    done.bytes += len;
1189                    let from = csrc.join(rel);
1190                    let to = self.path(rel)?;
1191                    if let Some(parent) = to.parent() {
1192                        make_private_dir(parent)?;
1193                    }
1194                    match fs::symlink_metadata(&to) {
1195                        Ok(m) if regular_entry(&m) && same_content(&from, &to)? => {
1196                            continue;
1197                        }
1198                        Ok(_) => return Err(AppRootError::Collision(to)),
1199                        Err(e) if e.kind() == io::ErrorKind::NotFound => {}
1200                        Err(e) => return Err(e.into()),
1201                    }
1202                    let mut writer =
1203                        AtomicWriter::create_new_private(&to).map_err(io::Error::other)?;
1204                    let mut source =
1205                        rightkit_fs::open_read_nofollow(&from, &rightkit_fs::ReadOptions::new())
1206                            .map_err(io::Error::other)?;
1207                    io::copy(&mut source, writer.file_mut())?;
1208                    commit_migration_file(writer, &from, &to)?;
1209                }
1210                Entry::Link { rel, target, dir } => {
1211                    let (written, rewritten) = plan_link(csrc, rel, target);
1212                    // Validate the parent chain only: the leaf is a link and is never followed.
1213                    let to = self.path_checked(rel, false)?;
1214                    if let Some(parent) = to.parent() {
1215                        make_private_dir(parent)?;
1216                    }
1217                    match fs::symlink_metadata(&to) {
1218                        Ok(m) => {
1219                            if !(m.file_type().is_symlink()
1220                                && same_link_target(&fs::read_link(&to)?, &written))
1221                            {
1222                                return Err(AppRootError::Collision(to));
1223                            }
1224                        }
1225                        Err(e) if e.kind() == io::ErrorKind::NotFound => {
1226                            if let Err(e) = maker(&written, &to, *dir) {
1227                                if !link_unavailable(&e) {
1228                                    return Err(e.into());
1229                                }
1230                                if opts.is_optional(rel) {
1231                                    done.omitted.push(OmittedLink {
1232                                        path: rel.clone(),
1233                                        original_target: target.clone(),
1234                                        resolved_target: written,
1235                                        reason: e.to_string(),
1236                                    });
1237                                    continue;
1238                                }
1239                                return Err(AppRootError::Io(io::Error::other(LinkNotCreated {
1240                                    link: rel.clone(),
1241                                    target: target.clone(),
1242                                    resolved: written,
1243                                    reason: e.to_string(),
1244                                })));
1245                            }
1246                        }
1247                        Err(e) => return Err(e.into()),
1248                    }
1249                    created.push((rel.clone(), written.clone(), *dir));
1250                    done.links.push(MigratedLink {
1251                        path: rel.clone(),
1252                        original_target: target.clone(),
1253                        written_target: written,
1254                        rewritten,
1255                    });
1256                }
1257                Entry::Special { rel, kind } => done.specials.push(SkippedSpecial {
1258                    path: rel.clone(),
1259                    kind: *kind,
1260                }),
1261                Entry::Reparse { rel, kind, tag } => done.reparse.push(SkippedReparsePoint {
1262                    path: rel.clone(),
1263                    kind: *kind,
1264                    tag: *tag,
1265                    reason: reparse_reason(*kind, *tag),
1266                }),
1267            }
1268        }
1269        for entry in entries {
1270            match entry {
1271                Entry::File { rel, .. } => {
1272                    let to = self.path(rel)?;
1273                    if !same_content(&csrc.join(rel), &to)? {
1274                        return Err(AppRootError::VerifyFailed(to));
1275                    }
1276                }
1277                Entry::Dir { rel } => {
1278                    let to = self.path_checked(rel, false)?;
1279                    if !fs::symlink_metadata(&to).is_ok_and(|m| m.file_type().is_dir()) {
1280                        return Err(AppRootError::VerifyFailed(to));
1281                    }
1282                }
1283                _ => {}
1284            }
1285        }
1286        for (rel, written, dir) in &created {
1287            // Compare with read_link only; a link is never followed.
1288            let to = self.path_checked(rel, false)?;
1289            if !link_ok(&to, written, *dir) {
1290                return Err(AppRootError::VerifyFailed(to));
1291            }
1292        }
1293        Ok(done)
1294    }
1295
1296    /// The copy is verified: write the final state, then (only for `Ready`) the historical marker.
1297    /// The state goes first, so a crash between the two leaves a state new readers trust and no
1298    /// marker an old reader could mistake; the next call repairs the marker.
1299    fn commit(
1300        &self,
1301        csrc: &Path,
1302        opts: &MigrateOptions,
1303        done: Lists,
1304    ) -> Result<MigrationReport, AppRootError> {
1305        let state = done.state();
1306        self.write_state(&opts.scope_state(state_json(state, csrc.to_str(), &done, None)))?;
1307        if state == MigrationState::Ready && opts.included.is_none() {
1308            let body = marker_json(
1309                csrc.to_str(),
1310                done.files,
1311                done.bytes,
1312                opts,
1313                &done.links,
1314                &done.specials,
1315                &done.omitted,
1316            );
1317            write_json_atomic(&self.root.join(MIGRATION_MARKER), &body)?;
1318        }
1319        Ok(MigrationReport {
1320            outcome: MigrationOutcome::Migrated {
1321                files: done.files,
1322                bytes: done.bytes,
1323            },
1324            migrated_links: done.links,
1325            skipped_specials: done.specials,
1326            omitted_optional_links: done.omitted,
1327            skipped_reparse_points: done.reparse,
1328            state,
1329        })
1330    }
1331
1332    /// Best effort: leave `Incomplete` on disk with the reason, and the link that blocked it.
1333    fn record_failure(&self, csrc: &Path, opts: &MigrateOptions, e: &AppRootError) {
1334        let mut body = opts.scope_state(state_json(
1335            MigrationState::Incomplete,
1336            csrc.to_str(),
1337            &Lists::default(),
1338            Some(&e.to_string()),
1339        ));
1340        if let (Some(l), Some(obj)) = (e.link_not_created(), body.as_object_mut()) {
1341            obj.insert(
1342                "unresolved".into(),
1343                serde_json::json!([{"path": lossy(&l.link), "kind": "link", "reason": l.reason}]),
1344            );
1345        }
1346        let _ = self.write_state(&body);
1347    }
1348
1349    /// A `Ready` state without its historical marker (a crash between the two writes): write it.
1350    fn repair_marker(&self, csrc: &Path, state: &serde_json::Value) {
1351        let path = self.root.join(MIGRATION_MARKER);
1352        if path.exists() {
1353            return;
1354        }
1355        let l = Lists::from_state(state);
1356        let opts = MigrateOptions::strict().preserve_links(true);
1357        let body = marker_json(
1358            csrc.to_str(),
1359            l.files,
1360            l.bytes,
1361            &opts,
1362            &l.links,
1363            &l.specials,
1364            &l.omitted,
1365        );
1366        let _ = write_json_atomic(&path, &body);
1367    }
1368
1369    /// Rerun of a `ReadyWithOmissions` migration: retry the omitted links only. Files are not
1370    /// compared again (the app has been running on the destination and may have changed them), and
1371    /// a link that still cannot be created, or whose destination path is now occupied, stays
1372    /// omitted. Skipped reparse points stay skipped. When nothing is left unresolved the state
1373    /// becomes `Ready` and the historical marker is written.
1374    fn retry_unresolved(
1375        &self,
1376        csrc: &Path,
1377        state: &serde_json::Value,
1378        maker: LinkMaker<'_>,
1379    ) -> Result<MigrationReport, AppRootError> {
1380        let prior = Lists::from_state(state);
1381        let mut next = Lists {
1382            files: prior.files,
1383            bytes: prior.bytes,
1384            links: prior.links,
1385            specials: prior.specials,
1386            omitted: Vec::new(),
1387            reparse: prior.reparse,
1388        };
1389        let mut created = Vec::new();
1390        for o in prior.omitted {
1391            match self.retry_link(csrc, &o, maker)? {
1392                Ok(link) => {
1393                    created.push(link.clone());
1394                    next.links.push(link);
1395                }
1396                Err(reason) => next.omitted.push(OmittedLink { reason, ..o }),
1397            }
1398        }
1399        let now = next.state();
1400        if !created.is_empty() || now == MigrationState::Ready {
1401            self.write_state(&state_json(now, csrc.to_str(), &next, None))?;
1402            if now == MigrationState::Ready {
1403                let opts = MigrateOptions::strict().preserve_links(true);
1404                let body = marker_json(
1405                    csrc.to_str(),
1406                    next.files,
1407                    next.bytes,
1408                    &opts,
1409                    &next.links,
1410                    &next.specials,
1411                    &next.omitted,
1412                );
1413                write_json_atomic(&self.root.join(MIGRATION_MARKER), &body)?;
1414            }
1415        }
1416        Ok(MigrationReport {
1417            outcome: MigrationOutcome::AlreadyMigrated,
1418            migrated_links: created,
1419            skipped_specials: Vec::new(),
1420            omitted_optional_links: next.omitted,
1421            skipped_reparse_points: next.reparse,
1422            state: now,
1423        })
1424    }
1425
1426    /// Retry one omitted link. `Ok(Ok(link))` it exists now (created, or already identical),
1427    /// `Ok(Err(reason))` it stays omitted, `Err` a fault that is not about links being unavailable.
1428    fn retry_link(
1429        &self,
1430        csrc: &Path,
1431        o: &OmittedLink,
1432        maker: LinkMaker<'_>,
1433    ) -> Result<Result<MigratedLink, String>, AppRootError> {
1434        let from = csrc.join(&o.path);
1435        let md = match fs::symlink_metadata(&from) {
1436            Ok(m) if m.file_type().is_symlink() => m,
1437            Ok(_) => return Ok(Err("the source entry is no longer a link".to_string())),
1438            Err(e) if e.kind() == io::ErrorKind::NotFound => {
1439                return Ok(Err("the source link no longer exists".to_string()))
1440            }
1441            Err(e) => return Err(e.into()),
1442        };
1443        let target = fs::read_link(&from)?;
1444        let dir = link_is_dir(&md.file_type());
1445        let (written, rewritten) = plan_link(csrc, &o.path, &target);
1446        let to = self.path_checked(&o.path, false)?;
1447        match fs::symlink_metadata(&to) {
1448            Ok(m) => {
1449                let same = m.file_type().is_symlink()
1450                    && fs::read_link(&to).is_ok_and(|t| same_link_target(&t, &written));
1451                if !same {
1452                    return Ok(Err("the destination path is occupied".to_string()));
1453                }
1454            }
1455            Err(e) if e.kind() == io::ErrorKind::NotFound => {
1456                if let Some(parent) = to.parent() {
1457                    make_private_dir(parent)?;
1458                }
1459                if let Err(e) = maker(&written, &to, dir) {
1460                    if link_unavailable(&e) {
1461                        return Ok(Err(e.to_string()));
1462                    }
1463                    return Err(e.into());
1464                }
1465            }
1466            Err(e) => return Err(e.into()),
1467        }
1468        if !link_ok(&to, &written, dir) {
1469            return Err(AppRootError::VerifyFailed(to));
1470        }
1471        Ok(Ok(MigratedLink {
1472            path: o.path.clone(),
1473            original_target: target,
1474            written_target: written,
1475            rewritten,
1476        }))
1477    }
1478}
1479
1480type LinkMaker<'a> = &'a dyn Fn(&Path, &Path, bool) -> io::Result<()>;
1481
1482/// One thing found under the source tree.
1483enum Entry {
1484    /// A directory (recorded before its contents), so an empty one is migrated too.
1485    Dir {
1486        rel: PathBuf,
1487    },
1488    File {
1489        rel: PathBuf,
1490        len: u64,
1491    },
1492    /// A symlink, recorded without following it. `dir` is true for a Windows directory link.
1493    Link {
1494        rel: PathBuf,
1495        target: PathBuf,
1496        dir: bool,
1497    },
1498    Special {
1499        rel: PathBuf,
1500        kind: SpecialKind,
1501    },
1502    /// A Windows junction or other reparse point, recorded without following it. Only built on
1503    /// Windows.
1504    #[cfg_attr(not(windows), allow(dead_code))]
1505    Reparse {
1506        rel: PathBuf,
1507        kind: ReparseKind,
1508        tag: u32,
1509    },
1510}
1511
1512impl Entry {
1513    fn rel(&self) -> &Path {
1514        match self {
1515            Self::Dir { rel }
1516            | Self::File { rel, .. }
1517            | Self::Link { rel, .. }
1518            | Self::Special { rel, .. }
1519            | Self::Reparse { rel, .. } => rel,
1520        }
1521    }
1522}
1523
1524/// What a migration did or found, as it is recorded in the state file. `Default` is the empty
1525/// state of a run that has just started.
1526#[derive(Default)]
1527struct Lists {
1528    files: u64,
1529    bytes: u64,
1530    links: Vec<MigratedLink>,
1531    specials: Vec<SkippedSpecial>,
1532    omitted: Vec<OmittedLink>,
1533    reparse: Vec<SkippedReparsePoint>,
1534}
1535
1536impl Lists {
1537    /// `Ready` unless a link was omitted or a reparse point skipped. Skipped ephemeral specials
1538    /// are recorded but do not count.
1539    fn state(&self) -> MigrationState {
1540        if self.omitted.is_empty() && self.reparse.is_empty() {
1541            MigrationState::Ready
1542        } else {
1543            MigrationState::ReadyWithOmissions
1544        }
1545    }
1546
1547    /// Read the lists back from a state file (entries that do not parse are dropped).
1548    fn from_state(v: &serde_json::Value) -> Self {
1549        let text =
1550            |o: &serde_json::Value, k: &str| o.get(k).and_then(|s| s.as_str()).map(PathBuf::from);
1551        let items = |k: &str| {
1552            v.get(k)
1553                .and_then(|a| a.as_array())
1554                .cloned()
1555                .unwrap_or_default()
1556        };
1557        Self {
1558            files: v.get("files").and_then(|n| n.as_u64()).unwrap_or(0),
1559            bytes: v.get("bytes").and_then(|n| n.as_u64()).unwrap_or(0),
1560            links: items("links")
1561                .iter()
1562                .filter_map(|o| {
1563                    Some(MigratedLink {
1564                        path: text(o, "path")?,
1565                        original_target: text(o, "original_target")?,
1566                        written_target: text(o, "written_target")?,
1567                        rewritten: o
1568                            .get("rewritten")
1569                            .and_then(|b| b.as_bool())
1570                            .unwrap_or(false),
1571                    })
1572                })
1573                .collect(),
1574            specials: items("skipped_specials")
1575                .iter()
1576                .filter_map(|o| {
1577                    let kind = match o.get("kind").and_then(|k| k.as_str())? {
1578                        "socket" => SpecialKind::Socket,
1579                        "fifo" => SpecialKind::Fifo,
1580                        _ => return None,
1581                    };
1582                    Some(SkippedSpecial {
1583                        path: text(o, "path")?,
1584                        kind,
1585                    })
1586                })
1587                .collect(),
1588            omitted: items("omitted_optional_links")
1589                .iter()
1590                .filter_map(|o| {
1591                    Some(OmittedLink {
1592                        path: text(o, "path")?,
1593                        original_target: text(o, "original_target")?,
1594                        resolved_target: text(o, "resolved_target")?,
1595                        reason: o.get("reason").and_then(|r| r.as_str())?.to_string(),
1596                    })
1597                })
1598                .collect(),
1599            reparse: items("skipped_reparse_points")
1600                .iter()
1601                .filter_map(|o| {
1602                    let kind = match o.get("kind").and_then(|k| k.as_str())? {
1603                        "junction" => ReparseKind::Junction,
1604                        _ => ReparseKind::Other,
1605                    };
1606                    Some(SkippedReparsePoint {
1607                        path: text(o, "path")?,
1608                        kind,
1609                        tag: o.get("tag").and_then(|t| t.as_u64()).unwrap_or(0) as u32,
1610                        reason: o.get("reason").and_then(|r| r.as_str())?.to_string(),
1611                    })
1612                })
1613                .collect(),
1614        }
1615    }
1616}
1617
1618/// The names the migration itself owns at the root of the source: a marker or state file of an
1619/// earlier migration of that root must not be copied over this root's own.
1620fn is_reserved_root_name(name: &std::ffi::OsStr) -> bool {
1621    let n = name.to_string_lossy();
1622    n == MIGRATION_MARKER || n == MIGRATION_STATE
1623}
1624
1625/// Every directory, regular file, and under `opts` every symlink, ephemeral special and skipped
1626/// reparse point, below `base/rel`. Directories are entries too (before their contents), so an
1627/// empty directory is migrated. A symlink, junction or reparse point is never traversed. Without
1628/// the matching option a symlink, junction, reparse point or special file is refused. The
1629/// migration's own marker and state names are not entries at the root of the source.
1630fn collect(
1631    base: &Path,
1632    rel: &Path,
1633    opts: &MigrateOptions,
1634    out: &mut Vec<Entry>,
1635) -> Result<(), AppRootError> {
1636    let mut entries: Vec<_> = fs::read_dir(base.join(rel))?.collect::<Result<_, _>>()?;
1637    entries.sort_by_key(|e| e.file_name());
1638    for e in entries {
1639        let name = e.file_name();
1640        if rel.as_os_str().is_empty() && is_reserved_root_name(&name) {
1641            continue;
1642        }
1643        let r = rel.join(&name);
1644        if !opts.selection(&r)? || opts.is_excluded(&r)? {
1645            continue;
1646        }
1647        // Windows: classify a reparse point before anything else. `DirEntry::file_type` reports a
1648        // junction as a directory symlink and an `AF_UNIX` socket file as a plain file; neither
1649        // may be treated as what std calls it.
1650        #[cfg(windows)]
1651        {
1652            if let Some(kind) = reparse_of(&e, &base.join(&r))? {
1653                if opts
1654                    .included
1655                    .as_ref()
1656                    .is_some_and(|roots| !roots.iter().any(|root| r.starts_with(root)))
1657                {
1658                    return Err(AppRootError::UnsupportedSource(base.join(&r)));
1659                }
1660                match kind {
1661                    Reparse::Symlink => {}
1662                    Reparse::Socket => {
1663                        if opts.ephemeral_specials {
1664                            out.push(Entry::Special {
1665                                rel: r,
1666                                kind: SpecialKind::Socket,
1667                            });
1668                            continue;
1669                        }
1670                        return Err(AppRootError::UnsupportedSource(base.join(&r)));
1671                    }
1672                    Reparse::Junction | Reparse::Other(_) => {
1673                        if opts.reparse_points {
1674                            let (kind, tag) = match kind {
1675                                Reparse::Other(tag) => (ReparseKind::Other, tag),
1676                                _ => (ReparseKind::Junction, IO_REPARSE_TAG_MOUNT_POINT),
1677                            };
1678                            out.push(Entry::Reparse { rel: r, kind, tag });
1679                            continue;
1680                        }
1681                        return Err(AppRootError::UnsupportedSource(base.join(&r)));
1682                    }
1683                }
1684            }
1685        }
1686        // DirEntry::file_type does not follow symlinks.
1687        let ft = e.file_type()?;
1688        if opts
1689            .included
1690            .as_ref()
1691            .is_some_and(|roots| !roots.iter().any(|root| r.starts_with(root)))
1692            && !ft.is_dir()
1693        {
1694            return Err(AppRootError::UnsupportedSource(base.join(&r)));
1695        }
1696        if ft.is_symlink() {
1697            if !opts.links {
1698                return Err(AppRootError::UnsupportedSource(base.join(&r)));
1699            }
1700            let target = fs::read_link(base.join(&r))?;
1701            out.push(Entry::Link {
1702                dir: link_is_dir(&ft),
1703                rel: r,
1704                target,
1705            });
1706        } else if ft.is_dir() {
1707            out.push(Entry::Dir { rel: r.clone() });
1708            collect(base, &r, opts, out)?;
1709        } else if ft.is_file() {
1710            out.push(Entry::File {
1711                len: e.metadata()?.len(),
1712                rel: r,
1713            });
1714        } else {
1715            match special_kind(&ft) {
1716                // Never opened, never copied.
1717                Some(kind) if opts.ephemeral_specials => out.push(Entry::Special { rel: r, kind }),
1718                _ => return Err(AppRootError::UnsupportedSource(base.join(&r))),
1719            }
1720        }
1721    }
1722    Ok(())
1723}
1724
1725/// How a Windows entry that carries `FILE_ATTRIBUTE_REPARSE_POINT` is classified by its tag.
1726#[cfg(windows)]
1727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1728enum Reparse {
1729    /// `IO_REPARSE_TAG_SYMLINK`: handled as a link (`preserve_links`) like any other symlink.
1730    Symlink,
1731    /// `IO_REPARSE_TAG_MOUNT_POINT`: a junction or volume mount point.
1732    Junction,
1733    /// `IO_REPARSE_TAG_AF_UNIX`: an `AF_UNIX` socket file.
1734    Socket,
1735    /// Any other tag.
1736    Other(u32),
1737}
1738
1739#[cfg(windows)]
1740const IO_REPARSE_TAG_SYMLINK: u32 = 0xA000_000C;
1741#[cfg(windows)]
1742const IO_REPARSE_TAG_MOUNT_POINT: u32 = 0xA000_0003;
1743#[cfg(windows)]
1744const IO_REPARSE_TAG_AF_UNIX: u32 = 0x8000_0023;
1745
1746#[cfg(windows)]
1747fn classify_reparse_tag(tag: u32) -> Reparse {
1748    match tag {
1749        IO_REPARSE_TAG_SYMLINK => Reparse::Symlink,
1750        IO_REPARSE_TAG_MOUNT_POINT => Reparse::Junction,
1751        IO_REPARSE_TAG_AF_UNIX => Reparse::Socket,
1752        other => Reparse::Other(other),
1753    }
1754}
1755
1756/// `None` when the entry is not a reparse point. `DirEntry::metadata` does not follow reparse
1757/// points, so the attribute is the entry's own; the tag is read without opening the entry.
1758#[cfg(windows)]
1759fn reparse_of(e: &fs::DirEntry, full: &Path) -> io::Result<Option<Reparse>> {
1760    use std::os::windows::fs::MetadataExt;
1761    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
1762    if e.metadata()?.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 {
1763        return Ok(None);
1764    }
1765    Ok(Some(classify_reparse_tag(reparse_tag(full)?)))
1766}
1767
1768/// The reparse tag of the entry at `path` (`WIN32_FIND_DATAW::dwReserved0` of `FindFirstFileW`,
1769/// which describes the entry itself and does not follow it).
1770#[cfg(windows)]
1771fn reparse_tag(path: &Path) -> io::Result<u32> {
1772    use std::os::windows::ffi::OsStrExt;
1773    use windows::core::PCWSTR;
1774    use windows::Win32::Storage::FileSystem::{FindClose, FindFirstFileW, WIN32_FIND_DATAW};
1775    let wide: Vec<u16> = path
1776        .as_os_str()
1777        .encode_wide()
1778        .chain(std::iter::once(0))
1779        .collect();
1780    let mut data = WIN32_FIND_DATAW::default();
1781    // SAFETY: `wide` is NUL-terminated and outlives the call, and `data` is a valid out pointer.
1782    let handle =
1783        unsafe { FindFirstFileW(PCWSTR(wide.as_ptr()), &mut data) }.map_err(io::Error::other)?;
1784    // SAFETY: `handle` came from `FindFirstFileW` above and is closed exactly once.
1785    let _ = unsafe { FindClose(handle) };
1786    Ok(data.dwReserved0)
1787}
1788
1789fn reparse_reason(kind: ReparseKind, tag: u32) -> String {
1790    match kind {
1791        ReparseKind::Junction => {
1792            "junction or volume mount point: never followed, copied or recreated".to_string()
1793        }
1794        ReparseKind::Other => {
1795            format!("reparse point (tag {tag:#010x}): never followed, opened, copied or recreated")
1796        }
1797    }
1798}
1799
1800#[cfg(unix)]
1801fn special_kind(ft: &fs::FileType) -> Option<SpecialKind> {
1802    use std::os::unix::fs::FileTypeExt;
1803    if ft.is_socket() {
1804        Some(SpecialKind::Socket)
1805    } else if ft.is_fifo() {
1806        Some(SpecialKind::Fifo)
1807    } else {
1808        None
1809    }
1810}
1811
1812/// Windows has no socket or FIFO file type that std can name; every special stays refused.
1813#[cfg(not(unix))]
1814fn special_kind(_ft: &fs::FileType) -> Option<SpecialKind> {
1815    None
1816}
1817
1818#[cfg(windows)]
1819fn link_is_dir(ft: &fs::FileType) -> bool {
1820    use std::os::windows::fs::FileTypeExt;
1821    ft.is_symlink_dir()
1822}
1823
1824#[cfg(not(windows))]
1825fn link_is_dir(_ft: &fs::FileType) -> bool {
1826    false
1827}
1828
1829#[cfg(windows)]
1830fn link_kind_matches(ft: &fs::FileType, dir: bool) -> bool {
1831    use std::os::windows::fs::FileTypeExt;
1832    ft.is_symlink_dir() == dir
1833}
1834
1835#[cfg(not(windows))]
1836fn link_kind_matches(_ft: &fs::FileType, _dir: bool) -> bool {
1837    true
1838}
1839
1840/// The text to write for the link at `rel` whose old target text is `target`, and whether it was
1841/// rewritten. A target with a root or prefix is absolute and copied as it is. A relative target
1842/// that never climbs above the tree root keeps its text. A relative target that does climb out is
1843/// resolved lexically against the link's old location (`csrc/<parent of rel>`) and written as an
1844/// absolute path, so it keeps pointing at the original target. A target that leaves the tree and
1845/// comes back (`../<tree name>/x`) counts as leaving: the same text would mean something else
1846/// under the new root.
1847fn plan_link(csrc: &Path, rel: &Path, target: &Path) -> (PathBuf, bool) {
1848    if target
1849        .components()
1850        .any(|c| matches!(c, Component::Prefix(_) | Component::RootDir))
1851    {
1852        return (target.to_path_buf(), false);
1853    }
1854    let parent_rel = rel.parent().unwrap_or_else(|| Path::new(""));
1855    let mut depth = parent_rel.components().count();
1856    let mut inside = true;
1857    let mut abs = csrc.to_path_buf();
1858    if depth > 0 {
1859        abs.push(parent_rel);
1860    }
1861    for c in target.components() {
1862        match c {
1863            Component::Normal(n) => {
1864                abs.push(n);
1865                depth += 1;
1866            }
1867            Component::ParentDir => {
1868                if depth == 0 {
1869                    inside = false;
1870                } else {
1871                    depth -= 1;
1872                }
1873                abs.pop();
1874            }
1875            _ => {}
1876        }
1877    }
1878    if inside {
1879        (target.to_path_buf(), false)
1880    } else {
1881        (strip_verbatim(abs), true)
1882    }
1883}
1884
1885/// `\\?\C:\x` becomes `C:\x` and `\\?\UNC\h\s` becomes `\\h\s`, so a rewritten link is a plain
1886/// path. Other platforms and other forms are returned unchanged.
1887#[cfg(windows)]
1888fn strip_verbatim(p: PathBuf) -> PathBuf {
1889    let s = p.to_string_lossy().into_owned();
1890    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
1891        PathBuf::from(format!(r"\\{rest}"))
1892    } else if let Some(rest) = s.strip_prefix(r"\\?\") {
1893        if rest.len() >= 2 && rest.as_bytes()[1] == b':' {
1894            return PathBuf::from(rest);
1895        }
1896        p
1897    } else {
1898        p
1899    }
1900}
1901
1902#[cfg(not(windows))]
1903fn strip_verbatim(p: PathBuf) -> PathBuf {
1904    p
1905}
1906
1907/// Whether two link target texts name the same target. Exact on Unix. On Windows the stored text
1908/// may come back with `/` as `\`, a verbatim prefix or another case, so those are normalised.
1909fn same_link_target(a: &Path, b: &Path) -> bool {
1910    if a == b {
1911        return true;
1912    }
1913    #[cfg(windows)]
1914    {
1915        fn norm(p: &Path) -> String {
1916            let s = p.to_string_lossy().replace('/', "\\");
1917            let s = s
1918                .strip_prefix(r"\\?\UNC\")
1919                .map(|r| format!(r"\\{r}"))
1920                .or_else(|| s.strip_prefix(r"\\?\").map(str::to_string))
1921                .or_else(|| s.strip_prefix(r"\??\").map(str::to_string))
1922                .unwrap_or(s);
1923            s.to_lowercase()
1924        }
1925        norm(a) == norm(b)
1926    }
1927    #[cfg(not(windows))]
1928    {
1929        false
1930    }
1931}
1932
1933/// Create a symlink at `to` pointing at `target`. `dir` selects `symlink_dir` on Windows.
1934fn make_link(target: &Path, to: &Path, dir: bool) -> io::Result<()> {
1935    #[cfg(unix)]
1936    {
1937        let _ = dir;
1938        std::os::unix::fs::symlink(target, to)
1939    }
1940    #[cfg(windows)]
1941    {
1942        if dir {
1943            std::os::windows::fs::symlink_dir(target, to)
1944        } else {
1945            std::os::windows::fs::symlink_file(target, to)
1946        }
1947    }
1948    #[cfg(not(any(unix, windows)))]
1949    {
1950        let _ = (target, to, dir);
1951        Err(io::Error::from(io::ErrorKind::Unsupported))
1952    }
1953}
1954
1955/// Whether a link-creation error means the destination cannot hold links (Windows without
1956/// SeCreateSymbolicLink or Developer Mode; a filesystem without symlinks) rather than a fault
1957/// that should surface as plain I/O. On Windows every error except `AlreadyExists` counts.
1958fn link_unavailable(e: &io::Error) -> bool {
1959    #[cfg(windows)]
1960    {
1961        e.kind() != io::ErrorKind::AlreadyExists
1962    }
1963    #[cfg(not(windows))]
1964    {
1965        matches!(
1966            e.kind(),
1967            io::ErrorKind::PermissionDenied | io::ErrorKind::Unsupported
1968        )
1969    }
1970}
1971
1972fn lossy(p: &Path) -> String {
1973    p.to_string_lossy().into_owned()
1974}
1975
1976fn links_json(links: &[MigratedLink]) -> serde_json::Value {
1977    serde_json::Value::Array(
1978        links
1979            .iter()
1980            .map(|l| {
1981                serde_json::json!({
1982                    "path": lossy(&l.path),
1983                    "original_target": lossy(&l.original_target),
1984                    "written_target": lossy(&l.written_target),
1985                    "rewritten": l.rewritten,
1986                })
1987            })
1988            .collect(),
1989    )
1990}
1991
1992fn specials_json(specials: &[SkippedSpecial]) -> serde_json::Value {
1993    serde_json::Value::Array(
1994        specials
1995            .iter()
1996            .map(|s| serde_json::json!({"path": lossy(&s.path), "kind": s.kind.as_str()}))
1997            .collect(),
1998    )
1999}
2000
2001fn omitted_json(omitted: &[OmittedLink]) -> serde_json::Value {
2002    serde_json::Value::Array(
2003        omitted
2004            .iter()
2005            .map(|o| {
2006                serde_json::json!({
2007                    "path": lossy(&o.path),
2008                    "original_target": lossy(&o.original_target),
2009                    "resolved_target": lossy(&o.resolved_target),
2010                    "reason": o.reason,
2011                })
2012            })
2013            .collect(),
2014    )
2015}
2016
2017fn reparse_json(reparse: &[SkippedReparsePoint]) -> serde_json::Value {
2018    serde_json::Value::Array(
2019        reparse
2020            .iter()
2021            .map(|r| {
2022                serde_json::json!({
2023                    "path": lossy(&r.path),
2024                    "kind": r.kind.as_str(),
2025                    "tag": r.tag,
2026                    "reason": r.reason,
2027                })
2028            })
2029            .collect(),
2030    )
2031}
2032
2033/// The historical commit marker body, written only for a `Ready` migration. A strict migration
2034/// writes exactly the historical shape (`source`, `files`, `bytes`); an opt-in one adds `links`,
2035/// `skipped_specials`, `omitted_optional_links` and `complete`. Readers of the old shape look only
2036/// at `source`, so both shapes stay readable by both versions. (`omitted_optional_links` is
2037/// empty and `complete` true whenever the real flow writes it; the completion state with
2038/// omissions lives in the state file.)
2039fn marker_json(
2040    source: Option<&str>,
2041    files: u64,
2042    bytes: u64,
2043    opts: &MigrateOptions,
2044    links: &[MigratedLink],
2045    specials: &[SkippedSpecial],
2046    omitted: &[OmittedLink],
2047) -> serde_json::Value {
2048    let mut body = serde_json::json!({
2049        "source": source,
2050        "files": files,
2051        "bytes": bytes,
2052    });
2053    if opts.is_strict() {
2054        return body;
2055    }
2056    let obj = body.as_object_mut().expect("object");
2057    obj.insert("links".into(), links_json(links));
2058    obj.insert("skipped_specials".into(), specials_json(specials));
2059    obj.insert("omitted_optional_links".into(), omitted_json(omitted));
2060    obj.insert(
2061        "complete".into(),
2062        serde_json::Value::Bool(omitted.is_empty()),
2063    );
2064    body
2065}
2066
2067/// The versioned state file body (see [`MIGRATION_STATE`]): `version`, `state`, `source`, the
2068/// counts, every list, and `unresolved` (the omitted links and skipped reparse points, with their
2069/// reasons). `error` is the reason an `Incomplete` run stopped, when it stopped on one.
2070fn state_json(
2071    state: MigrationState,
2072    source: Option<&str>,
2073    l: &Lists,
2074    error: Option<&str>,
2075) -> serde_json::Value {
2076    let mut unresolved: Vec<serde_json::Value> = l
2077        .omitted
2078        .iter()
2079        .map(|o| serde_json::json!({"path": lossy(&o.path), "kind": "link", "reason": o.reason}))
2080        .collect();
2081    unresolved.extend(l.reparse.iter().map(|r| {
2082        serde_json::json!({"path": lossy(&r.path), "kind": "reparse_point", "reason": r.reason})
2083    }));
2084    let mut body = serde_json::json!({
2085        "version": STATE_VERSION,
2086        "state": state.as_str(),
2087        "source": source,
2088        "files": l.files,
2089        "bytes": l.bytes,
2090        "links": links_json(&l.links),
2091        "skipped_specials": specials_json(&l.specials),
2092        "omitted_optional_links": omitted_json(&l.omitted),
2093        "skipped_reparse_points": reparse_json(&l.reparse),
2094        "unresolved": unresolved,
2095    });
2096    if let (Some(e), Some(obj)) = (error, body.as_object_mut()) {
2097        obj.insert("error".into(), e.into());
2098    }
2099    body
2100}
2101
2102/// Publish a copy without replacing a contender. An equal regular file permits a retry.
2103fn commit_migration_file(
2104    writer: AtomicWriter,
2105    source: &Path,
2106    target: &Path,
2107) -> Result<(), AppRootError> {
2108    match writer.commit() {
2109        Ok(()) => Ok(()),
2110        Err(FsError::AlreadyExists(_)) => {
2111            if regular_entry(&fs::symlink_metadata(target)?) && same_content(source, target)? {
2112                Ok(())
2113            } else {
2114                Err(AppRootError::Collision(target.to_path_buf()))
2115            }
2116        }
2117        Err(e) => Err(io::Error::other(e).into()),
2118    }
2119}
2120
2121fn regular_entry(metadata: &fs::Metadata) -> bool {
2122    #[cfg(windows)]
2123    {
2124        use std::os::windows::fs::MetadataExt;
2125        if metadata.file_attributes() & 0x400 != 0 {
2126            return false;
2127        }
2128    }
2129    metadata.file_type().is_file()
2130}
2131
2132/// The migration owns these regular metadata leaves, never a link or special file.
2133/// Returns whether a validated target already exists.
2134fn validate_metadata_target(path: &Path) -> io::Result<bool> {
2135    match fs::symlink_metadata(path) {
2136        Ok(m) if regular_entry(&m) => Ok(true),
2137        Ok(_) => Err(io::Error::new(
2138            io::ErrorKind::InvalidData,
2139            format!(
2140                "migration metadata is not a regular file: {}",
2141                path.display()
2142            ),
2143        )),
2144        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
2145        Err(e) => Err(e),
2146    }
2147}
2148
2149/// Write owned metadata through exclusive staging; never open a fixed temporary name.
2150pub(crate) fn write_json_atomic(path: &Path, body: &serde_json::Value) -> io::Result<()> {
2151    let replacing = validate_metadata_target(path)?;
2152    let mut writer = AtomicWriter::create_with(
2153        path,
2154        AtomicWriteOptions {
2155            private: true,
2156            replace: if replacing {
2157                ReplaceMode::Rename
2158            } else {
2159                ReplaceMode::NoReplace
2160            },
2161            ..Default::default()
2162        },
2163    )
2164    .map_err(io::Error::other)?;
2165    writer
2166        .write_all(body.to_string().as_bytes())
2167        .map_err(io::Error::other)?;
2168    writer
2169        .commit_checked(|_| validate_metadata_target(path).map(|_| ()))
2170        .map_err(io::Error::other)
2171}
2172
2173/// Whether `to` is a link (never followed) with the written target text and, on Windows, the
2174/// right file or directory kind.
2175fn link_ok(to: &Path, written: &Path, dir: bool) -> bool {
2176    match fs::symlink_metadata(to) {
2177        Ok(m) => {
2178            m.file_type().is_symlink()
2179                && fs::read_link(to).is_ok_and(|t| same_link_target(&t, written))
2180                && link_kind_matches(&m.file_type(), dir)
2181        }
2182        Err(_) => false,
2183    }
2184}
2185
2186/// Byte-for-byte equality (stronger than a digest compare).
2187pub(crate) fn same_content(a: &Path, b: &Path) -> Result<bool, AppRootError> {
2188    let mut fa = rightkit_fs::open_read_nofollow(a, &rightkit_fs::ReadOptions::new())
2189        .map_err(io::Error::other)?;
2190    let mut fb = rightkit_fs::open_read_nofollow(b, &rightkit_fs::ReadOptions::new())
2191        .map_err(io::Error::other)?;
2192    if fa.metadata()?.len() != fb.metadata()?.len() {
2193        return Ok(false);
2194    }
2195    let (mut ba, mut bb) = (vec![0u8; 64 * 1024], vec![0u8; 64 * 1024]);
2196    loop {
2197        let na = read_full(&mut fa, &mut ba)?;
2198        let nb = read_full(&mut fb, &mut bb)?;
2199        if na != nb || ba[..na] != bb[..nb] {
2200            return Ok(false);
2201        }
2202        if na == 0 {
2203            return Ok(true);
2204        }
2205    }
2206}
2207
2208fn read_full(f: &mut File, buf: &mut [u8]) -> io::Result<usize> {
2209    let mut n = 0;
2210    while n < buf.len() {
2211        match f.read(&mut buf[n..])? {
2212            0 => break,
2213            k => n += k,
2214        }
2215    }
2216    Ok(n)
2217}
2218
2219#[cfg(test)]
2220mod tests {
2221    /// flock (Unix `File::try_lock`) belongs to the open file description, which a child forked by another test in
2222    /// this binary shares until it execs (child-process spawns with pre_exec take the fork path on macOS). A lock
2223    /// released by drop can therefore read as held for that instant: re-acquire with a short bound. Not a release bug:
2224    /// the guard's drop closes our descriptor; the lock ends when the last shared copy closes (#788).
2225    fn eventually<T, E>(mut f: impl FnMut() -> Result<T, E>) -> Result<T, E> {
2226        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2227        loop {
2228            match f() {
2229                Ok(v) => return Ok(v),
2230                Err(e) if std::time::Instant::now() >= deadline => return Err(e),
2231                Err(_) => std::thread::sleep(std::time::Duration::from_millis(20)),
2232            }
2233        }
2234    }
2235
2236    /// [`eventually`] for calls that take the writer lock themselves (the migrations): retries only `WriterBusy`, so
2237    /// every other outcome, including the errors a test expects, is returned at once.
2238    fn settled<T>(mut f: impl FnMut() -> Result<T, AppRootError>) -> Result<T, AppRootError> {
2239        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2240        loop {
2241            match f() {
2242                Err(AppRootError::WriterBusy(_)) if std::time::Instant::now() < deadline => {
2243                    std::thread::sleep(std::time::Duration::from_millis(20))
2244                }
2245                other => return other,
2246            }
2247        }
2248    }
2249
2250    use super::*;
2251
2252    fn suite() -> (tempfile::TempDir, Suite) {
2253        let d = tempfile::tempdir().unwrap();
2254        let s = Suite::at(d.path().join("root")).unwrap();
2255        (d, s)
2256    }
2257
2258    #[test]
2259    fn names_are_validated() {
2260        for ok in ["HeardRight", "app.orthiclabs.genright", "a", "Cut-Right_2"] {
2261            assert!(valid_app_name(ok), "{ok}");
2262        }
2263        let long = "a".repeat(MAX_APP_NAME_LEN + 1);
2264        for bad in [
2265            "", ".", "..", "a/b", "a\\b", "../x", "x..y", ".hidden", "trail.", "con", "NUL.txt",
2266            "com1", "sp ace", "a:b", "é", "shared", "Shared", &long,
2267        ] {
2268            assert!(!valid_app_name(bad), "{bad:?}");
2269        }
2270        assert!(valid_app_name(&"a".repeat(MAX_APP_NAME_LEN)));
2271        let (_d, s) = suite();
2272        assert!(matches!(
2273            s.app_root("../x"),
2274            Err(AppRootError::InvalidName(_))
2275        ));
2276    }
2277
2278    #[test]
2279    fn canonical_suite_gives_sibling_of_shared_and_override_stays_inside() {
2280        let d = tempfile::tempdir().unwrap();
2281        let canon = Suite::at(d.path().join("Orthic Labs").join("shared")).unwrap();
2282        let a = canon.app_root("HeardRight").unwrap();
2283        assert_eq!(a.root(), d.path().join("Orthic Labs").join("HeardRight"));
2284        let (_d2, s) = suite();
2285        let a = s.app_root("HeardRight").unwrap();
2286        assert!(a.root().starts_with(s.root()));
2287        assert!(a.root().is_dir());
2288        #[cfg(unix)]
2289        {
2290            use std::os::unix::fs::PermissionsExt;
2291            assert_eq!(
2292                fs::metadata(a.root()).unwrap().permissions().mode() & 0o077,
2293                0
2294            );
2295        }
2296    }
2297
2298    #[test]
2299    fn case_conflicts_are_refused() {
2300        let (_d, s) = suite();
2301        s.app_root("HeardRight").unwrap();
2302        assert!(matches!(
2303            s.app_root("heardright"),
2304            Err(AppRootError::CaseConflict { .. })
2305        ));
2306        s.app_root("HeardRight").unwrap();
2307    }
2308
2309    #[test]
2310    fn subpath_traversal_is_refused() {
2311        let (_d, s) = suite();
2312        let a = s.app_root("App").unwrap();
2313        for bad in ["", ".", "..", "../x", "a/../../x", "/etc", "a:b"] {
2314            assert!(
2315                matches!(a.path(bad), Err(AppRootError::PathEscape(_))),
2316                "{bad:?}"
2317            );
2318        }
2319        // On Windows `\\` is a separator, so `a\\b` is the ordinary subpath `a/b`.
2320        #[cfg(unix)]
2321        assert!(matches!(a.path("a\\b"), Err(AppRootError::PathEscape(_))));
2322        let d = a.data_dir("logs/today").unwrap();
2323        assert!(d.is_dir() && d.starts_with(a.root()));
2324    }
2325
2326    #[cfg(unix)]
2327    #[test]
2328    fn symlink_escape_is_refused() {
2329        let (d, s) = suite();
2330        let a = s.app_root("App").unwrap();
2331        let outside = d.path().join("outside");
2332        fs::create_dir(&outside).unwrap();
2333        std::os::unix::fs::symlink(&outside, a.root().join("link")).unwrap();
2334        assert!(matches!(a.path("link/x"), Err(AppRootError::PathEscape(_))));
2335        assert!(matches!(
2336            a.data_dir("link/x"),
2337            Err(AppRootError::PathEscape(_))
2338        ));
2339        std::os::unix::fs::symlink(d.path().join("nowhere"), a.root().join("dangling")).unwrap();
2340        assert!(matches!(
2341            a.path("dangling/x"),
2342            Err(AppRootError::PathEscape(_))
2343        ));
2344        assert!(!outside.join("x").exists());
2345    }
2346
2347    #[test]
2348    fn writer_lock_is_single_holder() {
2349        let (_d, s) = suite();
2350        let a = s.app_root("App").unwrap();
2351        let l = a.lock_writer().unwrap();
2352        assert!(matches!(a.lock_writer(), Err(AppRootError::WriterBusy(_))));
2353        drop(l);
2354        eventually(|| a.lock_writer()).unwrap();
2355    }
2356
2357    fn old_tree(d: &Path) -> PathBuf {
2358        let old = d.join("old");
2359        fs::create_dir_all(old.join("sub")).unwrap();
2360        fs::write(old.join("a.txt"), b"alpha").unwrap();
2361        fs::write(old.join("sub/b.bin"), vec![7u8; 200_000]).unwrap();
2362        old
2363    }
2364
2365    #[test]
2366    fn migration_copies_verifies_commits_and_is_idempotent() {
2367        let (d, s) = suite();
2368        let a = s.app_root("App").unwrap();
2369        let old = old_tree(d.path());
2370        assert_eq!(
2371            settled(|| a.migrate_from(&old)).unwrap(),
2372            MigrationOutcome::Migrated {
2373                files: 2,
2374                bytes: 200_005
2375            }
2376        );
2377        assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"alpha");
2378        assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
2379        assert!(a.root().join(MIGRATION_MARKER).is_file());
2380        assert!(old.join("a.txt").is_file(), "old root is left in place");
2381        assert_eq!(
2382            settled(|| a.migrate_from(&old)).unwrap(),
2383            MigrationOutcome::AlreadyMigrated
2384        );
2385        assert_eq!(
2386            a.migrate_from(&d.path().join("missing")).unwrap(),
2387            MigrationOutcome::NothingToMigrate
2388        );
2389    }
2390
2391    #[test]
2392    fn interrupted_migration_resumes_without_marker() {
2393        let (d, s) = suite();
2394        let a = s.app_root("App").unwrap();
2395        let old = old_tree(d.path());
2396        // Simulate a crash: a complete copy of one file and a partial temp of another.
2397        fs::copy(old.join("a.txt"), a.root().join("a.txt")).unwrap();
2398        fs::create_dir_all(a.root().join("sub")).unwrap();
2399        fs::write(a.root().join(format!("sub/b.bin{TMP_SUFFIX}")), b"par").unwrap();
2400        assert!(!a.root().join(MIGRATION_MARKER).exists());
2401        assert!(matches!(
2402            settled(|| a.migrate_from(&old)).unwrap(),
2403            MigrationOutcome::Migrated { files: 2, .. }
2404        ));
2405        assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
2406    }
2407
2408    #[test]
2409    fn migration_never_opens_legacy_temps_hardlinked_to_source() {
2410        let (d, s) = suite();
2411        let a = s.app_root("App").unwrap();
2412        let old = old_tree(d.path());
2413        for name in ["a.txt", MIGRATION_STATE, MIGRATION_MARKER] {
2414            fs::hard_link(
2415                old.join("a.txt"),
2416                a.root().join(format!("{name}{TMP_SUFFIX}")),
2417            )
2418            .unwrap();
2419        }
2420        settled(|| a.migrate_from(&old)).unwrap();
2421        assert_eq!(fs::read(old.join("a.txt")).unwrap(), b"alpha");
2422        assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"alpha");
2423        for name in ["a.txt", MIGRATION_STATE, MIGRATION_MARKER] {
2424            assert_eq!(
2425                fs::read(a.root().join(format!("{name}{TMP_SUFFIX}"))).unwrap(),
2426                b"alpha"
2427            );
2428        }
2429    }
2430
2431    #[cfg(unix)]
2432    #[test]
2433    fn migration_never_follows_legacy_temp_symlinks_into_source() {
2434        let (d, s) = suite();
2435        let a = s.app_root("App").unwrap();
2436        let old = old_tree(d.path());
2437        for name in ["a.txt", MIGRATION_STATE, MIGRATION_MARKER] {
2438            std::os::unix::fs::symlink(
2439                old.join("a.txt"),
2440                a.root().join(format!("{name}{TMP_SUFFIX}")),
2441            )
2442            .unwrap();
2443        }
2444        settled(|| a.migrate_from(&old)).unwrap();
2445        assert_eq!(fs::read(old.join("a.txt")).unwrap(), b"alpha");
2446        assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"alpha");
2447        for name in ["a.txt", MIGRATION_STATE, MIGRATION_MARKER] {
2448            assert!(
2449                fs::symlink_metadata(a.root().join(format!("{name}{TMP_SUFFIX}")))
2450                    .unwrap()
2451                    .file_type()
2452                    .is_symlink()
2453            );
2454        }
2455    }
2456
2457    #[test]
2458    fn migration_publication_preserves_a_destination_contender() {
2459        let (d, s) = suite();
2460        let a = s.app_root("App").unwrap();
2461        let old = old_tree(d.path());
2462        let source = old.join("a.txt");
2463        let target = a.root().join("a.txt");
2464        for contender in [b"other".as_slice(), b"alpha".as_slice()] {
2465            let mut writer = AtomicWriter::create_new_private(&target).unwrap();
2466            writer.write_all(b"alpha").unwrap();
2467            let temp = writer.temp_path().to_path_buf();
2468            // A contender arrives after staging, before the real production commit helper.
2469            fs::write(&target, contender).unwrap();
2470            let result = commit_migration_file(writer, &source, &target);
2471            if contender == b"alpha" {
2472                result.unwrap();
2473            } else {
2474                assert!(matches!(result, Err(AppRootError::Collision(p)) if p == target));
2475            }
2476            assert_eq!(fs::read(&target).unwrap(), contender);
2477            assert_eq!(fs::read(&source).unwrap(), b"alpha");
2478            assert!(!temp.exists());
2479            fs::remove_file(&target).unwrap();
2480        }
2481    }
2482
2483    #[test]
2484    fn migration_preserves_native_filenames_that_resemble_fs_temps() {
2485        let (d, s) = suite();
2486        let a = s.app_root("App").unwrap();
2487        let old = old_tree(d.path());
2488        let rel = Path::new("sub/.record.rktmp-123-1-0");
2489        fs::write(old.join(rel), b"user data, not a migration temp").unwrap();
2490        assert!(matches!(
2491            settled(|| a.migrate_from(&old)).unwrap(),
2492            MigrationOutcome::Migrated { files: 3, .. }
2493        ));
2494        assert_eq!(
2495            fs::read(a.root().join(rel)).unwrap(),
2496            b"user data, not a migration temp"
2497        );
2498        assert_eq!(
2499            fs::read(old.join(rel)).unwrap(),
2500            b"user data, not a migration temp"
2501        );
2502        assert_eq!(
2503            settled(|| a.migrate_from(&old)).unwrap(),
2504            MigrationOutcome::AlreadyMigrated
2505        );
2506    }
2507
2508    #[test]
2509    fn migration_preserves_legacy_temp_shaped_files_and_directories() {
2510        let (d, s) = suite();
2511        let a = s.app_root("App").unwrap();
2512        let old = old_tree(d.path());
2513        let file = Path::new("sub/user.rk-migrating");
2514        let directory = Path::new("archive.rk-migrating");
2515        fs::write(old.join(file), b"user file").unwrap();
2516        fs::create_dir(old.join(directory)).unwrap();
2517        fs::write(old.join(directory).join("entry"), b"user entry").unwrap();
2518        assert!(matches!(
2519            settled(|| a.migrate_from(&old)).unwrap(),
2520            MigrationOutcome::Migrated { files: 4, .. }
2521        ));
2522        for root in [old.as_path(), a.root()] {
2523            assert_eq!(fs::read(root.join(file)).unwrap(), b"user file");
2524            assert_eq!(
2525                fs::read(root.join(directory).join("entry")).unwrap(),
2526                b"user entry"
2527            );
2528        }
2529    }
2530
2531    #[cfg(unix)]
2532    #[test]
2533    fn metadata_publication_refuses_symlink_leaves_without_touching_source() {
2534        let (d, s) = suite();
2535        let a = s.app_root("App").unwrap();
2536        let old = old_tree(d.path());
2537        for name in [MIGRATION_STATE, MIGRATION_MARKER] {
2538            let target = a.root().join(name);
2539            std::os::unix::fs::symlink(old.join("a.txt"), &target).unwrap();
2540            let error =
2541                write_json_atomic(&target, &serde_json::json!({"source": "old"})).unwrap_err();
2542            assert_eq!(error.kind(), io::ErrorKind::InvalidData);
2543            assert!(fs::symlink_metadata(&target)
2544                .unwrap()
2545                .file_type()
2546                .is_symlink());
2547            assert_eq!(fs::read(old.join("a.txt")).unwrap(), b"alpha");
2548        }
2549    }
2550
2551    #[test]
2552    fn migration_refuses_collision_overlap_symlink_and_busy() {
2553        let (d, s) = suite();
2554        let a = s.app_root("App").unwrap();
2555        let old = old_tree(d.path());
2556        fs::write(a.root().join("a.txt"), b"different").unwrap();
2557        assert!(matches!(
2558            a.migrate_from(&old),
2559            Err(AppRootError::Collision(_))
2560        ));
2561        assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"different");
2562        assert!(!a.root().join(MIGRATION_MARKER).exists());
2563        assert!(matches!(
2564            a.migrate_from(a.root()),
2565            Err(AppRootError::Overlap(_))
2566        ));
2567        assert!(matches!(
2568            a.migrate_from(Path::new("rel")),
2569            Err(AppRootError::PathEscape(_))
2570        ));
2571        let _held = eventually(|| a.lock_writer()).unwrap();
2572        assert!(matches!(
2573            a.migrate_from(&old),
2574            Err(AppRootError::WriterBusy(_))
2575        ));
2576        #[cfg(unix)]
2577        {
2578            let b = s.app_root("Other").unwrap();
2579            let old2 = d.path().join("old2");
2580            fs::create_dir(&old2).unwrap();
2581            std::os::unix::fs::symlink("/etc/hosts", old2.join("l")).unwrap();
2582            assert!(matches!(
2583                b.migrate_from(&old2),
2584                Err(AppRootError::UnsupportedSource(_))
2585            ));
2586            assert!(!b.root().join(MIGRATION_MARKER).exists());
2587        }
2588    }
2589
2590    /// Every entry under `root` without following links: (kind + relative path, payload), where the
2591    /// payload is file bytes or the link's target text.
2592    fn snapshot(root: &Path) -> Vec<(String, Vec<u8>)> {
2593        fn walk(base: &Path, rel: &Path, out: &mut Vec<(String, Vec<u8>)>) {
2594            let mut names: Vec<_> = fs::read_dir(base.join(rel))
2595                .unwrap()
2596                .map(|e| e.unwrap().file_name())
2597                .collect();
2598            names.sort();
2599            for n in names {
2600                let r = rel.join(&n);
2601                let full = base.join(&r);
2602                let md = fs::symlink_metadata(&full).unwrap();
2603                let key = r.display().to_string();
2604                if md.file_type().is_symlink() {
2605                    let t = fs::read_link(&full).unwrap();
2606                    out.push((
2607                        format!("L {key}"),
2608                        t.to_string_lossy().into_owned().into_bytes(),
2609                    ));
2610                } else if md.is_dir() {
2611                    out.push((format!("D {key}"), Vec::new()));
2612                    walk(base, &r, out);
2613                } else if md.is_file() {
2614                    out.push((format!("F {key}"), fs::read(&full).unwrap()));
2615                } else {
2616                    out.push((format!("O {key}"), Vec::new()));
2617                }
2618            }
2619        }
2620        let mut out = Vec::new();
2621        walk(root, Path::new(""), &mut out);
2622        out
2623    }
2624
2625    fn links_on() -> MigrateOptions {
2626        MigrateOptions::strict().preserve_links(true)
2627    }
2628
2629    fn marker_value(a: &AppRoot) -> serde_json::Value {
2630        serde_json::from_str(&fs::read_to_string(a.root().join(MIGRATION_MARKER)).unwrap()).unwrap()
2631    }
2632
2633    fn state_value(a: &AppRoot) -> serde_json::Value {
2634        serde_json::from_str(&fs::read_to_string(a.root().join(MIGRATION_STATE)).unwrap()).unwrap()
2635    }
2636
2637    /// What a reader of 0.3.0 does: it looks only at the historical marker, and a marker that
2638    /// records this source means the move is done.
2639    fn old_reader_sees_migrated(a: &AppRoot, old: &Path) -> bool {
2640        let Ok(text) = fs::read_to_string(a.root().join(MIGRATION_MARKER)) else {
2641            return false;
2642        };
2643        let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
2644            return false;
2645        };
2646        v.get("source").and_then(|s| s.as_str()) == fs::canonicalize(old).unwrap().to_str()
2647    }
2648
2649    fn entry_count(dir: &Path) -> usize {
2650        fs::read_dir(dir).unwrap().count()
2651    }
2652
2653    #[test]
2654    fn empty_directories_are_migrated_in_every_mode() {
2655        let (d, s) = suite();
2656        let old = old_tree(d.path());
2657        fs::create_dir(old.join("empty")).unwrap();
2658        fs::create_dir_all(old.join("sub/deep/emptier")).unwrap();
2659        let before = snapshot(&old);
2660        for (name, opts) in [
2661            ("Strict", MigrateOptions::strict()),
2662            ("Links", links_on()),
2663            (
2664                "Everything",
2665                links_on()
2666                    .skip_ephemeral_specials(true)
2667                    .skip_reparse_points(true),
2668            ),
2669        ] {
2670            let a = s.app_root(name).unwrap();
2671            let r = a.migrate_from_with(&old, opts).unwrap();
2672            assert_eq!(
2673                r.outcome(),
2674                &MigrationOutcome::Migrated {
2675                    files: 2,
2676                    bytes: 200_005
2677                },
2678                "{name}: directories are not counted as files"
2679            );
2680            assert_eq!(r.state(), MigrationState::Ready, "{name}");
2681            for dir in ["empty", "sub/deep", "sub/deep/emptier"] {
2682                let p = a.root().join(dir);
2683                assert!(
2684                    fs::symlink_metadata(&p).unwrap().file_type().is_dir(),
2685                    "{name}: {dir}"
2686                );
2687            }
2688            assert_eq!(entry_count(&a.root().join("empty")), 0, "{name}");
2689            assert_eq!(entry_count(&a.root().join("sub/deep/emptier")), 0, "{name}");
2690            assert_eq!(
2691                settled(|| a.migrate_from_with(&old, MigrateOptions::strict()))
2692                    .unwrap()
2693                    .outcome(),
2694                &MigrationOutcome::AlreadyMigrated
2695            );
2696        }
2697        assert_eq!(snapshot(&old), before);
2698    }
2699
2700    #[test]
2701    fn a_directory_collision_is_refused_and_leaves_the_move_incomplete_until_resolved() {
2702        let (d, s) = suite();
2703        let a = s.app_root("App").unwrap();
2704        let old = old_tree(d.path());
2705        fs::create_dir(old.join("empty")).unwrap();
2706        // A file where the source has a directory.
2707        fs::write(a.root().join("empty"), b"in the way").unwrap();
2708        assert!(matches!(
2709            a.migrate_from(&old),
2710            Err(AppRootError::Collision(_))
2711        ));
2712        assert_eq!(fs::read(a.root().join("empty")).unwrap(), b"in the way");
2713        // Incomplete, and an earlier reader cannot take it for a finished move.
2714        assert!(!a.root().join(MIGRATION_MARKER).exists());
2715        assert!(!old_reader_sees_migrated(&a, &old));
2716        assert_eq!(
2717            a.migration_state(&old).unwrap(),
2718            Some(MigrationState::Incomplete)
2719        );
2720        assert_eq!(state_value(&a)["state"], serde_json::json!("incomplete"));
2721        assert!(state_value(&a)["error"]
2722            .as_str()
2723            .unwrap()
2724            .contains("differs from source"));
2725        // Resolve it and rerun: the same call finishes and writes the marker last.
2726        fs::remove_file(a.root().join("empty")).unwrap();
2727        assert!(matches!(
2728            settled(|| a.migrate_from(&old)).unwrap(),
2729            MigrationOutcome::Migrated { files: 2, .. }
2730        ));
2731        assert!(a.root().join("empty").is_dir());
2732        assert_eq!(
2733            a.migration_state(&old).unwrap(),
2734            Some(MigrationState::Ready)
2735        );
2736        assert!(old_reader_sees_migrated(&a, &old));
2737        assert_eq!(state_value(&a)["state"], serde_json::json!("ready"));
2738    }
2739
2740    #[test]
2741    fn an_interrupted_run_leaves_incomplete_and_the_rerun_retries_everything() {
2742        let (d, s) = suite();
2743        let a = s.app_root("App").unwrap();
2744        let old = old_tree(d.path());
2745        fs::create_dir(old.join("empty")).unwrap();
2746        // The state a killed process leaves: pending state, one finished file, one partial temp.
2747        a.write_state(&state_json(
2748            MigrationState::Incomplete,
2749            fs::canonicalize(&old).unwrap().to_str(),
2750            &Lists::default(),
2751            None,
2752        ))
2753        .unwrap();
2754        fs::copy(old.join("a.txt"), a.root().join("a.txt")).unwrap();
2755        fs::create_dir_all(a.root().join("sub")).unwrap();
2756        fs::write(a.root().join(format!("sub/b.bin{TMP_SUFFIX}")), b"par").unwrap();
2757        assert_eq!(
2758            a.migration_state(&old).unwrap(),
2759            Some(MigrationState::Incomplete)
2760        );
2761        assert!(!old_reader_sees_migrated(&a, &old));
2762        let r = settled(|| a.migrate_from_with(&old, links_on())).unwrap();
2763        assert!(matches!(
2764            r.outcome(),
2765            MigrationOutcome::Migrated { files: 2, .. }
2766        ));
2767        assert_eq!(r.state(), MigrationState::Ready);
2768        assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
2769        assert!(a.root().join("empty").is_dir());
2770        assert!(old_reader_sees_migrated(&a, &old));
2771        assert_eq!(state_value(&a)["state"], serde_json::json!("ready"));
2772    }
2773
2774    #[test]
2775    fn a_ready_state_without_its_marker_is_repaired_by_the_next_call() {
2776        let (d, s) = suite();
2777        let a = s.app_root("App").unwrap();
2778        let old = old_tree(d.path());
2779        a.migrate_from(&old).unwrap();
2780        // A crash between the state write and the marker write.
2781        fs::remove_file(a.root().join(MIGRATION_MARKER)).unwrap();
2782        assert!(!old_reader_sees_migrated(&a, &old));
2783        assert_eq!(
2784            settled(|| a.migrate_from(&old)).unwrap(),
2785            MigrationOutcome::AlreadyMigrated
2786        );
2787        assert!(old_reader_sees_migrated(&a, &old));
2788    }
2789
2790    #[test]
2791    fn readback_refuses_ambiguous_state_while_copy_retains_legacy_absent_rules() {
2792        let (d, s) = suite();
2793        let a = s.app_root("App").unwrap();
2794        let old = old_tree(d.path());
2795        let src = fs::canonicalize(&old).unwrap();
2796        let state = a.root().join(MIGRATION_STATE);
2797        let write = |v: serde_json::Value| fs::write(&state, v.to_string()).unwrap();
2798        // Valid other-source state has no authority here. Unknown metadata cannot prove that.
2799        write(
2800            serde_json::json!({"version": 2, "state": "ready", "source": src.with_file_name("elsewhere")}),
2801        );
2802        assert_eq!(a.migration_state(&old).unwrap(), None);
2803        write(
2804            serde_json::json!({"version": 9, "state": "ready", "source": src.with_file_name("elsewhere")}),
2805        );
2806        assert!(a.migration_state(&old).is_err());
2807        assert!(
2808            a.read_state(&src).unwrap().is_none(),
2809            "copy parser remains source-scoped"
2810        );
2811        // A newer version, and a state this version does not know, are refused, not guessed at.
2812        for v in [
2813            serde_json::json!({"version": 99, "state": "ready", "source": src.to_str()}),
2814            serde_json::json!({"version": 2, "state": "teleported", "source": src.to_str()}),
2815        ] {
2816            write(v);
2817            for r in [
2818                a.migration_state(&old).map(|_| ()),
2819                settled(|| a.migrate_from(&old)).map(|_| ()),
2820            ] {
2821                match r {
2822                    Err(AppRootError::Io(e)) => {
2823                        assert_eq!(e.kind(), io::ErrorKind::InvalidData)
2824                    }
2825                    other => panic!("expected InvalidData, got {other:?}"),
2826                }
2827            }
2828            assert!(!a.root().join("a.txt").exists(), "nothing was copied");
2829        }
2830        // Strict observation refuses ambiguous metadata. Copy retains its existing absent rule.
2831        for text in ["not json {", "{\"source\": \"x\"}"] {
2832            fs::write(&state, text).unwrap();
2833            assert!(a.migration_state(&old).is_err());
2834            assert!(a.read_state(&src).unwrap().is_none());
2835        }
2836        assert!(matches!(
2837            settled(|| a.migrate_from(&old)).unwrap(),
2838            MigrationOutcome::Migrated { files: 2, .. }
2839        ));
2840    }
2841
2842    #[test]
2843    fn the_migrations_own_marker_names_in_the_source_are_not_copied() {
2844        let (d, s) = suite();
2845        let a = s.app_root("App").unwrap();
2846        let old = old_tree(d.path());
2847        // The old root was itself the destination of an earlier migration.
2848        fs::write(old.join(MIGRATION_MARKER), br#"{"source":"/earlier"}"#).unwrap();
2849        fs::write(old.join(MIGRATION_STATE), b"not ours").unwrap();
2850        // Only the root of the source is reserved: a nested file of the same name is data.
2851        fs::write(old.join("sub").join(MIGRATION_MARKER), b"data").unwrap();
2852        let r = a.migrate_from(&old).unwrap();
2853        assert!(matches!(r, MigrationOutcome::Migrated { files: 3, .. }));
2854        assert_eq!(
2855            marker_value(&a)["source"].as_str(),
2856            fs::canonicalize(&old).unwrap().to_str()
2857        );
2858        assert_eq!(state_value(&a)["state"], serde_json::json!("ready"));
2859        assert_eq!(
2860            fs::read(a.root().join("sub").join(MIGRATION_MARKER)).unwrap(),
2861            b"data"
2862        );
2863    }
2864
2865    #[test]
2866    fn strict_marker_keeps_the_historical_shape_and_old_markers_still_count() {
2867        let (d, s) = suite();
2868        let a = s.app_root("App").unwrap();
2869        let old = old_tree(d.path());
2870        a.migrate_from(&old).unwrap();
2871        let v = marker_value(&a);
2872        let mut keys: Vec<_> = v.as_object().unwrap().keys().cloned().collect();
2873        keys.sort();
2874        assert_eq!(keys, ["bytes", "files", "source"]);
2875        // An opt-in call honours a marker written by a strict (old-format) migration.
2876        assert_eq!(
2877            settled(|| a.migrate_from_with(&old, links_on().skip_ephemeral_specials(true)))
2878                .unwrap()
2879                .outcome(),
2880            &MigrationOutcome::AlreadyMigrated
2881        );
2882        // An opt-in migration of a plain tree adds the new keys, all empty, and is complete.
2883        let b = s.app_root("Other").unwrap();
2884        let out = b.migrate_from_with(&old, links_on()).unwrap();
2885        assert_eq!(
2886            out.outcome(),
2887            &MigrationOutcome::Migrated {
2888                files: 2,
2889                bytes: 200_005
2890            }
2891        );
2892        assert!(out.migrated_links().is_empty());
2893        assert!(out.skipped_specials().is_empty());
2894        assert!(out.omitted_optional_links().is_empty());
2895        assert!(out.complete());
2896        let v = marker_value(&b);
2897        assert_eq!(v["links"], serde_json::json!([]));
2898        assert_eq!(v["skipped_specials"], serde_json::json!([]));
2899        assert_eq!(v["omitted_optional_links"], serde_json::json!([]));
2900        assert_eq!(v["complete"], serde_json::json!(true));
2901        assert_eq!(v["files"], serde_json::json!(2));
2902    }
2903
2904    #[test]
2905    fn marker_json_reports_omitted_links_as_incomplete() {
2906        let omitted = [OmittedLink {
2907            path: PathBuf::from("skills/x"),
2908            original_target: PathBuf::from("../../x"),
2909            resolved_target: PathBuf::from("/abs/x"),
2910            reason: "denied".into(),
2911        }];
2912        let v = marker_json(Some("/old"), 1, 2, &links_on(), &[], &[], &omitted);
2913        assert_eq!(v["complete"], serde_json::json!(false));
2914        assert_eq!(v["omitted_optional_links"][0]["path"], "skills/x");
2915        assert_eq!(v["omitted_optional_links"][0]["original_target"], "../../x");
2916        assert_eq!(v["omitted_optional_links"][0]["resolved_target"], "/abs/x");
2917    }
2918
2919    #[test]
2920    fn link_creation_errors_are_classified() {
2921        let denied = io::Error::from(io::ErrorKind::PermissionDenied);
2922        assert!(link_unavailable(&denied));
2923        assert!(link_unavailable(&io::Error::from(
2924            io::ErrorKind::Unsupported
2925        )));
2926        assert!(!link_unavailable(&io::Error::from(
2927            io::ErrorKind::AlreadyExists
2928        )));
2929        let typed = LinkNotCreated {
2930            link: "a".into(),
2931            target: "b".into(),
2932            resolved: "b".into(),
2933            reason: "denied".into(),
2934        };
2935        let e = AppRootError::Io(io::Error::other(typed.clone()));
2936        assert_eq!(e.link_not_created(), Some(&typed));
2937        assert!(e.is_retryable());
2938        assert!(e.to_string().contains("cannot create symlink a -> b"));
2939        assert!(AppRootError::WriterBusy("x".into()).is_retryable());
2940        assert!(!AppRootError::Collision("x".into()).is_retryable());
2941        // A plain I/O error is not the typed failure and is not retryable.
2942        let plain = AppRootError::Io(io::Error::from(io::ErrorKind::PermissionDenied));
2943        assert!(plain.link_not_created().is_none() && !plain.is_retryable());
2944    }
2945
2946    #[test]
2947    fn plan_link_keeps_inside_text_and_resolves_outside_lexically() {
2948        let csrc = Path::new(if cfg!(windows) {
2949            r"C:\data\old"
2950        } else {
2951            "/data/old"
2952        });
2953        let rel = |s: &str| PathBuf::from(s);
2954        // Stays inside: same text.
2955        for (at, t) in [
2956            ("l", "a.txt"),
2957            ("sub/l", "../a.txt"),
2958            ("sub/l", "."),
2959            ("sub/l", ".."),
2960            ("l", "./x/../y"),
2961        ] {
2962            assert_eq!(
2963                plan_link(csrc, &rel(at), Path::new(t)),
2964                (PathBuf::from(t), false),
2965                "{at} {t}"
2966            );
2967        }
2968        // Leaves the tree, including leaving and re-entering.
2969        let (w, rewritten) = plan_link(csrc, &rel("sub/l"), Path::new("../../out/f"));
2970        assert!(rewritten);
2971        assert_eq!(w, csrc.parent().unwrap().join("out").join("f"));
2972        let (w, rewritten) = plan_link(csrc, &rel("l"), Path::new("../old/a.txt"));
2973        assert!(rewritten);
2974        assert_eq!(w, csrc.join("a.txt"));
2975    }
2976
2977    #[cfg(unix)]
2978    mod unix_links {
2979        use super::*;
2980        use std::os::unix::fs::symlink;
2981
2982        fn migrated(
2983            r: MigrationReport,
2984        ) -> (
2985            u64,
2986            Vec<MigratedLink>,
2987            Vec<SkippedSpecial>,
2988            Vec<OmittedLink>,
2989        ) {
2990            let files = match r.outcome() {
2991                MigrationOutcome::Migrated { files, .. } => *files,
2992                other => panic!("not migrated: {other:?}"),
2993            };
2994            (
2995                files,
2996                r.migrated_links().to_vec(),
2997                r.skipped_specials().to_vec(),
2998                r.omitted_optional_links().to_vec(),
2999            )
3000        }
3001
3002        fn read_link_text(p: PathBuf) -> PathBuf {
3003            fs::read_link(p).unwrap()
3004        }
3005
3006        #[test]
3007        fn internal_relative_link_keeps_its_text_and_meaning() {
3008            let (d, s) = suite();
3009            let a = s.app_root("App").unwrap();
3010            let old = old_tree(d.path());
3011            symlink("../a.txt", old.join("sub/to_a")).unwrap();
3012            symlink("sub", old.join("top")).unwrap();
3013            symlink("sub/b.bin", old.join("to_b")).unwrap();
3014            let before = snapshot(&old);
3015            let (files, links, specials, omitted) =
3016                migrated(a.migrate_from_with(&old, links_on()).unwrap());
3017            assert_eq!(files, 2);
3018            assert_eq!(links.len(), 3);
3019            assert!(specials.is_empty() && omitted.is_empty());
3020            assert!(links
3021                .iter()
3022                .all(|l| !l.rewritten && l.original_target == l.written_target));
3023            assert_eq!(
3024                read_link_text(a.root().join("sub/to_a")),
3025                Path::new("../a.txt")
3026            );
3027            assert_eq!(fs::read(a.root().join("sub/to_a")).unwrap(), b"alpha");
3028            assert_eq!(read_link_text(a.root().join("top")), Path::new("sub"));
3029            assert_eq!(fs::read(a.root().join("top/b.bin")).unwrap().len(), 200_000);
3030            assert_eq!(
3031                read_link_text(a.root().join("to_b")),
3032                Path::new("sub/b.bin")
3033            );
3034            let v = marker_value(&a);
3035            assert_eq!(v["links"].as_array().unwrap().len(), 3);
3036            assert_eq!(v["complete"], serde_json::json!(true));
3037            assert_eq!(snapshot(&old), before, "old tree is byte-identical");
3038        }
3039
3040        #[test]
3041        fn external_relative_link_is_rewritten_to_its_original_target() {
3042            let (d, s) = suite();
3043            let a = s.app_root("App").unwrap();
3044            let old = old_tree(d.path());
3045            let outside = d.path().join("outside");
3046            fs::create_dir(&outside).unwrap();
3047            fs::write(outside.join("file"), b"out").unwrap();
3048            symlink("../../outside/file", old.join("sub/ext")).unwrap();
3049            // Leaves the tree and comes back: the same text would mean something else.
3050            symlink("../old/a.txt", old.join("reenter")).unwrap();
3051            let before = snapshot(&old);
3052            let (_, links, _, _) = migrated(a.migrate_from_with(&old, links_on()).unwrap());
3053            let canon_d = fs::canonicalize(d.path()).unwrap();
3054            let want_ext = canon_d.join("outside").join("file");
3055            let want_re = fs::canonicalize(&old).unwrap().join("a.txt");
3056            assert_eq!(read_link_text(a.root().join("sub/ext")), want_ext);
3057            assert_eq!(read_link_text(a.root().join("reenter")), want_re);
3058            assert_eq!(fs::read(a.root().join("sub/ext")).unwrap(), b"out");
3059            assert_eq!(fs::read(a.root().join("reenter")).unwrap(), b"alpha");
3060            let ext = links
3061                .iter()
3062                .find(|l| l.path == Path::new("sub/ext"))
3063                .unwrap();
3064            assert!(ext.rewritten);
3065            assert_eq!(ext.original_target, Path::new("../../outside/file"));
3066            assert_eq!(ext.written_target, want_ext);
3067            assert_eq!(snapshot(&old), before);
3068        }
3069
3070        #[test]
3071        fn absolute_link_is_copied_verbatim() {
3072            let (d, s) = suite();
3073            let a = s.app_root("App").unwrap();
3074            let old = old_tree(d.path());
3075            let outside = d.path().join("outside");
3076            fs::create_dir(&outside).unwrap();
3077            fs::write(outside.join("file"), b"out").unwrap();
3078            let target = outside.join("file");
3079            symlink(&target, old.join("abs")).unwrap();
3080            let (_, links, _, _) = migrated(a.migrate_from_with(&old, links_on()).unwrap());
3081            assert_eq!(read_link_text(a.root().join("abs")), target);
3082            assert!(!links[0].rewritten && links[0].written_target == target);
3083        }
3084
3085        #[test]
3086        fn dangling_links_stay_links() {
3087            let (d, s) = suite();
3088            let a = s.app_root("App").unwrap();
3089            let old = old_tree(d.path());
3090            symlink("missing/nowhere", old.join("dang")).unwrap();
3091            symlink("/nonexistent-rk-test/xyz", old.join("dang_abs")).unwrap();
3092            let before = snapshot(&old);
3093            let (_, links, _, _) = migrated(a.migrate_from_with(&old, links_on()).unwrap());
3094            assert_eq!(links.len(), 2);
3095            for (name, text) in [
3096                ("dang", "missing/nowhere"),
3097                ("dang_abs", "/nonexistent-rk-test/xyz"),
3098            ] {
3099                let p = a.root().join(name);
3100                assert!(fs::symlink_metadata(&p).unwrap().file_type().is_symlink());
3101                assert!(!p.exists(), "still dangling");
3102                assert_eq!(read_link_text(p), Path::new(text));
3103            }
3104            assert_eq!(snapshot(&old), before);
3105        }
3106
3107        #[test]
3108        fn link_cycles_are_not_traversed() {
3109            let (d, s) = suite();
3110            let a = s.app_root("App").unwrap();
3111            let old = old_tree(d.path());
3112            symlink(".", old.join("loop")).unwrap();
3113            symlink("b", old.join("ca")).unwrap();
3114            symlink("ca", old.join("b")).unwrap();
3115            symlink("..", old.join("sub/up")).unwrap();
3116            let before = snapshot(&old);
3117            let (files, links, _, _) = migrated(a.migrate_from_with(&old, links_on()).unwrap());
3118            assert_eq!(files, 2, "no file was reached through a link");
3119            assert_eq!(links.len(), 4);
3120            assert_eq!(read_link_text(a.root().join("loop")), Path::new("."));
3121            assert_eq!(read_link_text(a.root().join("sub/up")), Path::new(".."));
3122            assert_eq!(read_link_text(a.root().join("ca")), Path::new("b"));
3123            assert_eq!(read_link_text(a.root().join("b")), Path::new("ca"));
3124            assert_eq!(snapshot(&old), before);
3125        }
3126
3127        #[test]
3128        fn collisions_behave_as_before_with_links_enabled() {
3129            let (d, s) = suite();
3130            let old = old_tree(d.path());
3131            symlink("../a.txt", old.join("sub/to_a")).unwrap();
3132            // A differing file.
3133            let a = s.app_root("A").unwrap();
3134            fs::write(a.root().join("a.txt"), b"different").unwrap();
3135            assert!(matches!(
3136                a.migrate_from_with(&old, links_on()),
3137                Err(AppRootError::Collision(_))
3138            ));
3139            assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"different");
3140            assert!(!a.root().join(MIGRATION_MARKER).exists());
3141            // A link with another target, and a regular file where a link belongs.
3142            let b = s.app_root("B").unwrap();
3143            fs::create_dir_all(b.root().join("sub")).unwrap();
3144            symlink("elsewhere", b.root().join("sub/to_a")).unwrap();
3145            assert!(matches!(
3146                b.migrate_from_with(&old, links_on()),
3147                Err(AppRootError::Collision(_))
3148            ));
3149            assert_eq!(
3150                read_link_text(b.root().join("sub/to_a")),
3151                Path::new("elsewhere")
3152            );
3153            assert!(!b.root().join(MIGRATION_MARKER).exists());
3154            let c = s.app_root("C").unwrap();
3155            fs::create_dir_all(c.root().join("sub")).unwrap();
3156            fs::write(c.root().join("sub/to_a"), b"file").unwrap();
3157            assert!(matches!(
3158                c.migrate_from_with(&old, links_on()),
3159                Err(AppRootError::Collision(_))
3160            ));
3161            assert_eq!(fs::read(c.root().join("sub/to_a")).unwrap(), b"file");
3162            // An identical link already there is accepted.
3163            let e = s.app_root("E").unwrap();
3164            fs::create_dir_all(e.root().join("sub")).unwrap();
3165            symlink("../a.txt", e.root().join("sub/to_a")).unwrap();
3166            migrated(e.migrate_from_with(&old, links_on()).unwrap());
3167        }
3168
3169        #[test]
3170        fn interrupted_link_migration_restarts_cleanly() {
3171            let (d, s) = suite();
3172            let a = s.app_root("App").unwrap();
3173            let old = old_tree(d.path());
3174            symlink("../a.txt", old.join("sub/to_a")).unwrap();
3175            symlink("sub", old.join("top")).unwrap();
3176            // A crash after one file, one link and a partial temp file.
3177            fs::copy(old.join("a.txt"), a.root().join("a.txt")).unwrap();
3178            fs::create_dir_all(a.root().join("sub")).unwrap();
3179            symlink("../a.txt", a.root().join("sub/to_a")).unwrap();
3180            fs::write(a.root().join(format!("sub/b.bin{TMP_SUFFIX}")), b"par").unwrap();
3181            assert!(!a.root().join(MIGRATION_MARKER).exists());
3182            let (files, links, _, _) = migrated(a.migrate_from_with(&old, links_on()).unwrap());
3183            assert_eq!((files, links.len()), (2, 2));
3184            assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
3185            assert_eq!(read_link_text(a.root().join("top")), Path::new("sub"));
3186            assert_eq!(
3187                settled(|| a.migrate_from_with(&old, links_on()))
3188                    .unwrap()
3189                    .outcome(),
3190                &MigrationOutcome::AlreadyMigrated
3191            );
3192        }
3193
3194        #[test]
3195        fn sockets_and_fifos_are_skipped_and_reported_never_opened() {
3196            let (d, s) = suite();
3197            let a = s.app_root("App").unwrap();
3198            let old = old_tree(d.path());
3199            let _sock = std::os::unix::net::UnixListener::bind(old.join("s")).unwrap();
3200            let st = std::process::Command::new("mkfifo")
3201                .arg(old.join("f"))
3202                .status()
3203                .unwrap();
3204            assert!(st.success());
3205            let before = snapshot(&old);
3206            // Strict, and links alone, still refuse.
3207            for o in [MigrateOptions::strict(), links_on()] {
3208                assert!(matches!(
3209                    settled(|| a.migrate_from_with(&old, o.clone())),
3210                    Err(AppRootError::UnsupportedSource(_))
3211                ));
3212            }
3213            assert!(!a.root().join(MIGRATION_MARKER).exists());
3214            let o = MigrateOptions::strict().skip_ephemeral_specials(true);
3215            let (files, links, specials, _) =
3216                migrated(settled(|| a.migrate_from_with(&old, o.clone())).unwrap());
3217            assert_eq!((files, links.len()), (2, 0));
3218            assert_eq!(
3219                specials,
3220                vec![
3221                    SkippedSpecial {
3222                        path: "f".into(),
3223                        kind: SpecialKind::Fifo
3224                    },
3225                    SkippedSpecial {
3226                        path: "s".into(),
3227                        kind: SpecialKind::Socket
3228                    },
3229                ]
3230            );
3231            assert!(fs::symlink_metadata(a.root().join("f")).is_err());
3232            assert!(fs::symlink_metadata(a.root().join("s")).is_err());
3233            let v = marker_value(&a);
3234            assert_eq!(
3235                v["skipped_specials"][0],
3236                serde_json::json!({"path": "f", "kind": "fifo"})
3237            );
3238            assert_eq!(
3239                v["skipped_specials"][1],
3240                serde_json::json!({"path": "s", "kind": "socket"})
3241            );
3242            assert_eq!(snapshot(&old), before);
3243        }
3244
3245        #[test]
3246        fn strict_default_still_refuses_links_and_specials() {
3247            let (d, s) = suite();
3248            let old = old_tree(d.path());
3249            symlink("a.txt", old.join("l")).unwrap();
3250            let before = snapshot(&old);
3251            for (i, o) in [
3252                MigrateOptions::default(),
3253                MigrateOptions::strict(),
3254                MigrateOptions::strict().skip_ephemeral_specials(true),
3255                MigrateOptions::strict().optional_links(|_| true),
3256            ]
3257            .into_iter()
3258            .enumerate()
3259            {
3260                let a = s.app_root(&format!("App{i}")).unwrap();
3261                assert!(matches!(
3262                    settled(|| a.migrate_from_with(&old, o.clone())),
3263                    Err(AppRootError::UnsupportedSource(_))
3264                ));
3265                assert!(!a.root().join(MIGRATION_MARKER).exists());
3266            }
3267            let a = s.app_root("Plain").unwrap();
3268            assert!(matches!(
3269                a.migrate_from(&old),
3270                Err(AppRootError::UnsupportedSource(_))
3271            ));
3272            assert_eq!(snapshot(&old), before);
3273        }
3274
3275        fn denied(_: &Path, _: &Path, _: bool) -> io::Result<()> {
3276            Err(io::Error::from(io::ErrorKind::PermissionDenied))
3277        }
3278
3279        #[test]
3280        fn required_link_failure_is_typed_retryable_and_writes_no_marker() {
3281            let (d, s) = suite();
3282            let a = s.app_root("App").unwrap();
3283            let old = old_tree(d.path());
3284            symlink("../a.txt", old.join("sub/to_a")).unwrap();
3285            symlink("../../ext", old.join("sub/ext")).unwrap();
3286            let before = snapshot(&old);
3287            let err = a.migrate_impl(&old, &links_on(), &denied).unwrap_err();
3288            assert!(err.is_retryable());
3289            let typed = err.link_not_created().expect("typed link failure");
3290            assert_eq!(typed.link, Path::new("sub/ext"));
3291            assert_eq!(typed.target, Path::new("../../ext"));
3292            assert_eq!(
3293                typed.resolved,
3294                fs::canonicalize(d.path()).unwrap().join("ext")
3295            );
3296            assert!(
3297                matches!(err, AppRootError::Io(_)),
3298                "carried by the existing Io variant"
3299            );
3300            assert!(
3301                !a.root().join(MIGRATION_MARKER).exists(),
3302                "no completed marker"
3303            );
3304            assert_eq!(snapshot(&old), before);
3305            // Retry once links work: the same call completes and writes the marker.
3306            let (files, links, _, omitted) =
3307                migrated(settled(|| a.migrate_from_with(&old, links_on())).unwrap());
3308            assert_eq!((files, links.len(), omitted.len()), (2, 2, 0));
3309            assert!(a.root().join(MIGRATION_MARKER).is_file());
3310            assert_eq!(snapshot(&old), before);
3311        }
3312
3313        #[test]
3314        fn optional_link_failure_is_omitted_and_reported_separately() {
3315            let (d, s) = suite();
3316            let a = s.app_root("App").unwrap();
3317            let old = old_tree(d.path());
3318            fs::create_dir(old.join("skills")).unwrap();
3319            symlink("../../shared/tool", old.join("skills/tool")).unwrap();
3320            let before = snapshot(&old);
3321            // A policy that does not name the link leaves it required.
3322            let other = links_on().optional_links(|p| p.starts_with("worktrees"));
3323            assert!(a
3324                .migrate_impl(&old, &other, &denied)
3325                .unwrap_err()
3326                .link_not_created()
3327                .is_some());
3328            assert!(!a.root().join(MIGRATION_MARKER).exists());
3329            let opts = links_on().optional_links(|p| p.starts_with("skills"));
3330            let report = settled(|| a.migrate_impl(&old, &opts, &denied)).unwrap();
3331            assert!(!report.complete());
3332            let (files, links, _, omitted) = migrated(report);
3333            assert_eq!((files, links.len()), (2, 0));
3334            assert_eq!(omitted.len(), 1);
3335            assert_eq!(omitted[0].path, Path::new("skills/tool"));
3336            assert_eq!(omitted[0].original_target, Path::new("../../shared/tool"));
3337            assert_eq!(
3338                omitted[0].resolved_target,
3339                fs::canonicalize(d.path()).unwrap().join("shared/tool")
3340            );
3341            assert!(fs::symlink_metadata(a.root().join("skills/tool")).is_err());
3342            // Ready with omissions: usable, recorded in the state file, and no historical marker
3343            // for an earlier version to take it for a complete move.
3344            assert_eq!(
3345                a.migration_state(&old).unwrap(),
3346                Some(MigrationState::ReadyWithOmissions)
3347            );
3348            assert!(!a.root().join(MIGRATION_MARKER).exists());
3349            assert!(!old_reader_sees_migrated(&a, &old));
3350            let v = state_value(&a);
3351            assert_eq!(v["state"], serde_json::json!("ready_with_omissions"));
3352            assert_eq!(v["omitted_optional_links"].as_array().unwrap().len(), 1);
3353            assert_eq!(v["unresolved"][0]["path"], serde_json::json!("skills/tool"));
3354            assert_eq!(v["links"], serde_json::json!([]));
3355            assert_eq!(snapshot(&old), before);
3356        }
3357
3358        #[test]
3359        fn omitted_optional_links_are_retried_and_the_state_becomes_ready() {
3360            let (d, s) = suite();
3361            let a = s.app_root("App").unwrap();
3362            let old = old_tree(d.path());
3363            fs::create_dir(old.join("skills")).unwrap();
3364            symlink("../../shared/tool", old.join("skills/tool")).unwrap();
3365            let before = snapshot(&old);
3366            let opts = || links_on().optional_links(|p| p.starts_with("skills"));
3367            let first = a.migrate_impl(&old, &opts(), &denied).unwrap();
3368            assert_eq!(first.state(), MigrationState::ReadyWithOmissions);
3369            // Still denied: the rerun retries, finds the link still omitted and changes nothing.
3370            let again = settled(|| a.migrate_impl(&old, &opts(), &denied)).unwrap();
3371            assert_eq!(again.outcome(), &MigrationOutcome::AlreadyMigrated);
3372            assert_eq!(again.state(), MigrationState::ReadyWithOmissions);
3373            assert_eq!(again.omitted_optional_links().len(), 1);
3374            assert!(again.migrated_links().is_empty());
3375            assert!(!a.root().join(MIGRATION_MARKER).exists());
3376            // The app has been running on the destination: a file it changed must not turn the
3377            // retry into a collision, because files are not compared again.
3378            fs::write(a.root().join("a.txt"), b"edited by the app").unwrap();
3379            let done = settled(|| a.migrate_from_with(&old, opts())).unwrap();
3380            assert_eq!(done.outcome(), &MigrationOutcome::AlreadyMigrated);
3381            assert_eq!(done.state(), MigrationState::Ready);
3382            assert!(done.complete() && done.omitted_optional_links().is_empty());
3383            assert_eq!(done.migrated_links().len(), 1);
3384            assert_eq!(done.migrated_links()[0].path, Path::new("skills/tool"));
3385            assert!(done.migrated_links()[0].rewritten);
3386            assert_eq!(
3387                read_link_text(a.root().join("skills/tool")),
3388                fs::canonicalize(d.path()).unwrap().join("shared/tool")
3389            );
3390            assert_eq!(
3391                fs::read(a.root().join("a.txt")).unwrap(),
3392                b"edited by the app"
3393            );
3394            assert_eq!(state_value(&a)["state"], serde_json::json!("ready"));
3395            assert_eq!(state_value(&a)["links"].as_array().unwrap().len(), 1);
3396            assert_eq!(state_value(&a)["unresolved"], serde_json::json!([]));
3397            assert!(old_reader_sees_migrated(&a, &old), "now a complete move");
3398            // Ready is final.
3399            let last = settled(|| a.migrate_from_with(&old, opts())).unwrap();
3400            assert_eq!(last.outcome(), &MigrationOutcome::AlreadyMigrated);
3401            assert!(last.complete() && last.migrated_links().is_empty());
3402            assert_eq!(snapshot(&old), before);
3403        }
3404
3405        #[test]
3406        fn a_retry_never_overwrites_an_occupied_destination_path() {
3407            let (d, s) = suite();
3408            let a = s.app_root("App").unwrap();
3409            let old = old_tree(d.path());
3410            fs::create_dir(old.join("skills")).unwrap();
3411            symlink("tool-a", old.join("skills/tool")).unwrap();
3412            let opts = || links_on().optional_links(|p| p.starts_with("skills"));
3413            let first = a.migrate_impl(&old, &opts(), &denied).unwrap();
3414            assert_eq!(first.state(), MigrationState::ReadyWithOmissions);
3415            // The app put its own file where the omitted link belongs.
3416            fs::write(a.root().join("skills/tool"), b"the app's own file").unwrap();
3417            let r = settled(|| a.migrate_from_with(&old, opts())).unwrap();
3418            assert_eq!(r.state(), MigrationState::ReadyWithOmissions);
3419            assert_eq!(r.omitted_optional_links().len(), 1);
3420            assert!(r.omitted_optional_links()[0].reason.contains("occupied"));
3421            assert_eq!(
3422                fs::read(a.root().join("skills/tool")).unwrap(),
3423                b"the app's own file"
3424            );
3425            assert!(!a.root().join(MIGRATION_MARKER).exists());
3426        }
3427
3428        #[test]
3429        fn a_link_to_an_empty_directory_does_not_dangle_in_the_new_tree() {
3430            let (d, s) = suite();
3431            let a = s.app_root("App").unwrap();
3432            let old = old_tree(d.path());
3433            fs::create_dir(old.join("empty")).unwrap();
3434            symlink("empty", old.join("to_empty")).unwrap();
3435            let before = snapshot(&old);
3436            let r = settled(|| a.migrate_from_with(&old, links_on())).unwrap();
3437            assert_eq!(r.state(), MigrationState::Ready);
3438            assert!(a.root().join("empty").is_dir());
3439            assert!(
3440                a.root().join("to_empty").is_dir(),
3441                "the link resolves to the migrated empty directory"
3442            );
3443            assert_eq!(
3444                read_link_text(a.root().join("to_empty")),
3445                Path::new("empty")
3446            );
3447            assert_eq!(snapshot(&old), before);
3448        }
3449
3450        #[test]
3451        fn a_failed_required_link_leaves_incomplete_and_the_rerun_reaches_ready() {
3452            let (d, s) = suite();
3453            let a = s.app_root("App").unwrap();
3454            let old = old_tree(d.path());
3455            symlink("../a.txt", old.join("sub/to_a")).unwrap();
3456            symlink("../../ext", old.join("sub/ext")).unwrap();
3457            assert_eq!(a.migration_state(&old).unwrap(), None);
3458            let err = a.migrate_impl(&old, &links_on(), &denied).unwrap_err();
3459            assert!(err.is_retryable());
3460            // The old root stays the authority: no historical marker, an old reader (which looks
3461            // only at the marker) does not see a finished move, and the state says why.
3462            assert!(!a.root().join(MIGRATION_MARKER).exists());
3463            assert!(!old_reader_sees_migrated(&a, &old));
3464            assert_eq!(
3465                a.migration_state(&old).unwrap(),
3466                Some(MigrationState::Incomplete)
3467            );
3468            assert!(!a.migration_state(&old).unwrap().unwrap().is_usable());
3469            let v = state_value(&a);
3470            assert_eq!(v["state"], serde_json::json!("incomplete"));
3471            assert_eq!(v["version"], serde_json::json!(2));
3472            assert_eq!(v["unresolved"][0]["path"], serde_json::json!("sub/ext"));
3473            assert_eq!(v["unresolved"][0]["kind"], serde_json::json!("link"));
3474            assert!(v["error"]
3475                .as_str()
3476                .unwrap()
3477                .contains("cannot create symlink"));
3478            // A second denied attempt is the same error and the same state, not a success.
3479            assert!(settled(|| a.migrate_impl(&old, &links_on(), &denied)).is_err());
3480            assert_eq!(
3481                a.migration_state(&old).unwrap(),
3482                Some(MigrationState::Incomplete)
3483            );
3484            // Links work now: the rerun finishes the whole copy.
3485            let r = settled(|| a.migrate_from_with(&old, links_on())).unwrap();
3486            assert_eq!(r.state(), MigrationState::Ready);
3487            assert_eq!(r.migrated_links().len(), 2);
3488            assert_eq!(
3489                a.migration_state(&old).unwrap(),
3490                Some(MigrationState::Ready)
3491            );
3492            assert!(a.root().join(MIGRATION_MARKER).is_file());
3493            assert!(old_reader_sees_migrated(&a, &old));
3494            assert_eq!(state_value(&a)["state"], serde_json::json!("ready"));
3495        }
3496
3497        #[test]
3498        fn socket_entry_is_skipped_and_recorded_without_making_the_move_incomplete() {
3499            let (d, s) = suite();
3500            let a = s.app_root("App").unwrap();
3501            let old = old_tree(d.path());
3502            let _sock = std::os::unix::net::UnixListener::bind(old.join("s")).unwrap();
3503            let before = snapshot(&old);
3504            let o = MigrateOptions::strict().skip_ephemeral_specials(true);
3505            let r = a.migrate_from_with(&old, o).unwrap();
3506            // A socket is ephemeral by nature: recorded, never opened or copied, and not an
3507            // omission that holds the migration back.
3508            assert_eq!(r.state(), MigrationState::Ready);
3509            assert!(r.complete());
3510            assert_eq!(r.skipped_specials().len(), 1);
3511            assert_eq!(r.skipped_specials()[0].kind, SpecialKind::Socket);
3512            assert!(r.skipped_reparse_points().is_empty());
3513            assert!(fs::symlink_metadata(a.root().join("s")).is_err());
3514            let v = state_value(&a);
3515            assert_eq!(
3516                v["skipped_specials"][0],
3517                serde_json::json!({"path": "s", "kind": "socket"})
3518            );
3519            assert_eq!(v["state"], serde_json::json!("ready"));
3520            assert_eq!(snapshot(&old), before);
3521        }
3522    }
3523
3524    #[cfg(windows)]
3525    mod windows_links {
3526        use super::*;
3527        use std::os::windows::fs::{symlink_dir, symlink_file, FileTypeExt};
3528
3529        /// Creating the source links needs the same privilege as the migration; without it there
3530        /// is nothing to migrate and the test says so.
3531        fn old_with_links(d: &Path) -> Option<PathBuf> {
3532            let old = old_tree(d);
3533            if symlink_file("a.txt", old.join("la")).is_err()
3534                || symlink_dir("sub", old.join("ld")).is_err()
3535            {
3536                eprintln!("skipped: symbolic links cannot be created (no privilege)");
3537                return None;
3538            }
3539            Some(old)
3540        }
3541
3542        #[test]
3543        fn links_migrate_with_their_kind_or_report_the_privilege_failure() {
3544            let (d, s) = suite();
3545            let a = s.app_root("App").unwrap();
3546            let Some(old) = old_with_links(d.path()) else {
3547                return;
3548            };
3549            let before = snapshot(&old);
3550            match a.migrate_from_with(&old, links_on()) {
3551                Ok(report) => {
3552                    assert_eq!(report.migrated_links().len(), 2);
3553                    let la = fs::symlink_metadata(a.root().join("la"))
3554                        .unwrap()
3555                        .file_type();
3556                    let ld = fs::symlink_metadata(a.root().join("ld"))
3557                        .unwrap()
3558                        .file_type();
3559                    assert!(la.is_symlink_file() && ld.is_symlink_dir());
3560                    assert!(marker_value(&a)["links"].as_array().unwrap().len() == 2);
3561                }
3562                Err(e) => {
3563                    let typed = e.link_not_created().expect("typed link failure");
3564                    assert!(typed.link == Path::new("la") || typed.link == Path::new("ld"));
3565                    assert!(typed.target == Path::new("a.txt") || typed.target == Path::new("sub"));
3566                    assert!(e.is_retryable());
3567                    assert!(!a.root().join(MIGRATION_MARKER).exists());
3568                }
3569            }
3570            assert_eq!(snapshot(&old), before);
3571        }
3572
3573        #[test]
3574        fn optional_links_are_migrated_or_omitted_never_dropped_silently() {
3575            let (d, s) = suite();
3576            let a = s.app_root("App").unwrap();
3577            let Some(old) = old_with_links(d.path()) else {
3578                return;
3579            };
3580            let opts = links_on().optional_links(|_| true);
3581            let r = a.migrate_from_with(&old, opts).unwrap();
3582            assert!(matches!(r.outcome(), MigrationOutcome::Migrated { .. }));
3583            assert_eq!(
3584                r.migrated_links().len() + r.omitted_optional_links().len(),
3585                2
3586            );
3587            assert_eq!(r.complete(), r.omitted_optional_links().is_empty());
3588            let want = if r.complete() {
3589                "ready"
3590            } else {
3591                "ready_with_omissions"
3592            };
3593            assert_eq!(state_value(&a)["state"], serde_json::json!(want));
3594            // The historical marker exists exactly when nothing was omitted.
3595            assert_eq!(
3596                a.root().join(MIGRATION_MARKER).exists(),
3597                r.complete(),
3598                "an earlier reader sees a finished move only for a complete one"
3599            );
3600        }
3601
3602        /// A junction needs no privilege (`mklink /J`). Without it there is nothing to test.
3603        fn junction(link: &Path, target: &Path) -> bool {
3604            std::process::Command::new("cmd")
3605                .args(["/C", "mklink", "/J"])
3606                .arg(link)
3607                .arg(target)
3608                .output()
3609                .is_ok_and(|o| o.status.success())
3610        }
3611
3612        #[test]
3613        fn reparse_tags_are_classified() {
3614            assert_eq!(classify_reparse_tag(0xA000_000C), Reparse::Symlink);
3615            assert_eq!(classify_reparse_tag(0xA000_0003), Reparse::Junction);
3616            assert_eq!(classify_reparse_tag(0x8000_0023), Reparse::Socket);
3617            // OneDrive placeholder and WSL symlink tags are neither: never followed, never copied.
3618            assert_eq!(
3619                classify_reparse_tag(0x9000_001A),
3620                Reparse::Other(0x9000_001A)
3621            );
3622            assert_eq!(
3623                classify_reparse_tag(0xA000_001D),
3624                Reparse::Other(0xA000_001D)
3625            );
3626        }
3627
3628        #[test]
3629        fn a_junction_is_never_followed_copied_or_recreated() {
3630            let (d, s) = suite();
3631            let outside = d.path().join("outside");
3632            fs::create_dir(&outside).unwrap();
3633            fs::write(outside.join("secret.txt"), b"not part of the app root").unwrap();
3634            let old = old_tree(d.path());
3635            if !junction(&old.join("junc"), &outside) {
3636                eprintln!("skipped: cannot create a junction here");
3637                return;
3638            }
3639            let before = snapshot(&old);
3640            // Strict, links alone and specials alone all refuse, before anything is written.
3641            for (i, o) in [
3642                MigrateOptions::strict(),
3643                links_on(),
3644                MigrateOptions::strict().skip_ephemeral_specials(true),
3645            ]
3646            .into_iter()
3647            .enumerate()
3648            {
3649                let a = s.app_root(&format!("Refuse{i}")).unwrap();
3650                assert!(matches!(
3651                    a.migrate_from_with(&old, o),
3652                    Err(AppRootError::UnsupportedSource(_))
3653                ));
3654                assert!(!a.root().join(MIGRATION_MARKER).exists());
3655                assert!(!a.root().join(MIGRATION_STATE).exists());
3656                assert!(!a.root().join("a.txt").exists(), "nothing was copied");
3657                assert_eq!(a.migration_state(&old).unwrap(), None);
3658            }
3659            // Opt-in: skipped and reported with a reason, never followed or linked.
3660            let a = s.app_root("Skip").unwrap();
3661            let o = links_on().skip_reparse_points(true);
3662            let r = a.migrate_from_with(&old, o.clone()).unwrap();
3663            assert_eq!(r.state(), MigrationState::ReadyWithOmissions);
3664            assert!(!r.complete());
3665            assert_eq!(
3666                r.outcome(),
3667                &MigrationOutcome::Migrated {
3668                    files: 2,
3669                    bytes: 200_005
3670                }
3671            );
3672            assert_eq!(r.skipped_reparse_points().len(), 1);
3673            let skipped = &r.skipped_reparse_points()[0];
3674            assert_eq!(skipped.path, Path::new("junc"));
3675            assert_eq!(skipped.kind, ReparseKind::Junction);
3676            assert!(skipped.reason.contains("never followed"));
3677            assert!(r.migrated_links().is_empty(), "not recreated as a symlink");
3678            assert!(fs::symlink_metadata(a.root().join("junc")).is_err());
3679            assert!(
3680                snapshot(a.root())
3681                    .iter()
3682                    .all(|(k, _)| !k.contains("secret") && !k.contains("junc")),
3683                "nothing behind the junction was copied"
3684            );
3685            // Recorded as unresolved, and no historical marker: an earlier reader does not see a
3686            // finished move.
3687            assert!(!a.root().join(MIGRATION_MARKER).exists());
3688            assert!(!old_reader_sees_migrated(&a, &old));
3689            let v = state_value(&a);
3690            assert_eq!(v["state"], serde_json::json!("ready_with_omissions"));
3691            assert_eq!(
3692                v["skipped_reparse_points"][0]["path"],
3693                serde_json::json!("junc")
3694            );
3695            assert_eq!(
3696                v["skipped_reparse_points"][0]["kind"],
3697                serde_json::json!("junction")
3698            );
3699            assert_eq!(
3700                v["unresolved"][0]["kind"],
3701                serde_json::json!("reparse_point")
3702            );
3703            // A rerun keeps reporting it and stays ready with omissions.
3704            let again = a.migrate_from_with(&old, o).unwrap();
3705            assert_eq!(again.outcome(), &MigrationOutcome::AlreadyMigrated);
3706            assert_eq!(again.state(), MigrationState::ReadyWithOmissions);
3707            assert_eq!(again.skipped_reparse_points().len(), 1);
3708            assert_eq!(
3709                a.migration_state(&old).unwrap(),
3710                Some(MigrationState::ReadyWithOmissions)
3711            );
3712            // The old tree and the target behind the junction are untouched.
3713            assert_eq!(snapshot(&old), before);
3714            assert_eq!(
3715                fs::read(outside.join("secret.txt")).unwrap(),
3716                b"not part of the app root"
3717            );
3718        }
3719    }
3720}