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.
12
13use std::fmt;
14use std::fs::{self, File};
15use std::io::{self, Read, Write};
16use std::path::{Component, Path, PathBuf};
17
18use crate::discovery::try_lock_exclusive;
19use crate::suite::{make_private_dir, Suite, SuiteError};
20
21/// The longest app name accepted, in bytes.
22pub const MAX_APP_NAME_LEN: usize = 64;
23/// The commit marker written at the app root once a migration is verified.
24pub const MIGRATION_MARKER: &str = ".rightkit-migrated.json";
25const WRITER_LOCK: &str = ".writer.lock";
26const TMP_SUFFIX: &str = ".rk-migrating";
27
28const WINDOWS_RESERVED: [&str; 22] = [
29    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
30    "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
31];
32
33/// Whether `s` is an app name: 1..=[`MAX_APP_NAME_LEN`] bytes of ASCII letters, digits, `-`, `_`
34/// and `.`; starts and ends with a letter or digit (so no `.`, `..`, hidden or trailing-dot
35/// names); no separators; not a Windows reserved device name (with or without an extension).
36/// Case is preserved on disk but is significant for identity on every platform: two names that
37/// differ only by case are refused as conflicting by [`AppRoot::new`] when one already exists.
38pub fn valid_app_name(s: &str) -> bool {
39    let b = s.as_bytes();
40    if b.is_empty() || b.len() > MAX_APP_NAME_LEN {
41        return false;
42    }
43    if !b
44        .iter()
45        .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.'))
46    {
47        return false;
48    }
49    if !b[0].is_ascii_alphanumeric() || !b[b.len() - 1].is_ascii_alphanumeric() || s.contains("..")
50    {
51        return false;
52    }
53    // `shared` is the suite's shared-resources directory, a sibling of every app root.
54    if s.eq_ignore_ascii_case("shared") {
55        return false;
56    }
57    let stem = s.split('.').next().unwrap_or(s).to_ascii_uppercase();
58    !WINDOWS_RESERVED.contains(&stem.as_str())
59}
60
61#[derive(Debug)]
62pub enum AppRootError {
63    InvalidName(String),
64    /// A subpath was absolute, had `..`, `.`, a prefix, was empty, or resolved outside the root.
65    PathEscape(String),
66    /// An existing sibling differs from the requested app name only by case.
67    CaseConflict {
68        requested: String,
69        existing: String,
70    },
71    /// Another process holds the single-writer lock.
72    WriterBusy(PathBuf),
73    /// The source tree holds a symlink or special file; refused rather than followed.
74    UnsupportedSource(PathBuf),
75    /// A destination file exists with different content; nothing is overwritten.
76    Collision(PathBuf),
77    /// Source and destination are the same tree or nested in each other.
78    Overlap(PathBuf),
79    /// Post-copy verification found a difference.
80    VerifyFailed(PathBuf),
81    Suite(SuiteError),
82    Io(io::Error),
83}
84
85impl fmt::Display for AppRootError {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            Self::InvalidName(n) => write!(f, "invalid app name: {n:?}"),
89            Self::PathEscape(p) => write!(f, "path escapes the app root: {p}"),
90            Self::CaseConflict {
91                requested,
92                existing,
93            } => write!(
94                f,
95                "app root {requested:?} conflicts with existing {existing:?} (differs only by case)"
96            ),
97            Self::WriterBusy(p) => write!(f, "another writer holds {}", p.display()),
98            Self::UnsupportedSource(p) => {
99                write!(
100                    f,
101                    "refusing symlink or special file in source: {}",
102                    p.display()
103                )
104            }
105            Self::Collision(p) => write!(f, "destination differs from source: {}", p.display()),
106            Self::Overlap(p) => write!(f, "source and destination overlap: {}", p.display()),
107            Self::VerifyFailed(p) => write!(f, "verification failed: {}", p.display()),
108            Self::Suite(e) => write!(f, "{e}"),
109            Self::Io(e) => write!(f, "{e}"),
110        }
111    }
112}
113impl std::error::Error for AppRootError {}
114impl From<io::Error> for AppRootError {
115    fn from(e: io::Error) -> Self {
116        Self::Io(e)
117    }
118}
119impl From<SuiteError> for AppRootError {
120    fn from(e: SuiteError) -> Self {
121        Self::Suite(e)
122    }
123}
124
125/// Held while this process is the single writer of an app root. Released on drop or process
126/// death (OS lock).
127pub struct WriterLock {
128    _file: File,
129}
130
131/// What [`AppRoot::migrate_from`] did.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum MigrationOutcome {
134    /// Copied and verified; the marker was committed.
135    Migrated { files: u64, bytes: u64 },
136    /// The marker already records this source; nothing was touched.
137    AlreadyMigrated,
138    /// The source does not exist; nothing to do and no marker written.
139    NothingToMigrate,
140}
141
142/// `<Orthic Labs>/<App>`, created private.
143#[derive(Debug, Clone)]
144pub struct AppRoot {
145    name: String,
146    root: PathBuf,
147}
148
149impl Suite {
150    /// The per-app root for `app`, created private. See [`AppRoot`].
151    pub fn app_root(&self, app: &str) -> Result<AppRoot, AppRootError> {
152        AppRoot::new(self, app)
153    }
154}
155
156impl AppRoot {
157    pub fn new(suite: &Suite, app: &str) -> Result<AppRoot, AppRootError> {
158        if !valid_app_name(app) {
159            return Err(AppRootError::InvalidName(app.to_string()));
160        }
161        let parent = suite.app_parent();
162        if let Ok(rd) = fs::read_dir(&parent) {
163            for e in rd.flatten() {
164                let n = e.file_name().to_string_lossy().into_owned();
165                if n != app && n.eq_ignore_ascii_case(app) {
166                    return Err(AppRootError::CaseConflict {
167                        requested: app.to_string(),
168                        existing: n,
169                    });
170                }
171            }
172        }
173        let root = parent.join(app);
174        make_private_dir(&root)?;
175        Ok(AppRoot {
176            name: app.to_string(),
177            root,
178        })
179    }
180
181    pub fn name(&self) -> &str {
182        &self.name
183    }
184
185    pub fn root(&self) -> &Path {
186        &self.root
187    }
188
189    /// `<app root>/<rel>` without creating anything. `rel` must be relative and made only of
190    /// normal components (no `..`, `.`, root, prefix or empty); any existing ancestor that is a
191    /// symlink must still resolve inside the app root.
192    pub fn path(&self, rel: impl AsRef<Path>) -> Result<PathBuf, AppRootError> {
193        let rel = rel.as_ref();
194        let bad = || AppRootError::PathEscape(rel.display().to_string());
195        let mut any = false;
196        for c in rel.components() {
197            match c {
198                Component::Normal(n) => {
199                    any = true;
200                    // `:` is a Windows stream/drive separator and a reserved char elsewhere.
201                    if n.to_string_lossy().contains(['\\', ':', '\0']) {
202                        return Err(bad());
203                    }
204                }
205                _ => return Err(bad()),
206            }
207        }
208        if !any {
209            return Err(bad());
210        }
211        let full = self.root.join(rel);
212        let canon_root = fs::canonicalize(&self.root)?;
213        let mut probe = full.as_path();
214        loop {
215            match fs::canonicalize(probe) {
216                Ok(c) => {
217                    if !c.starts_with(&canon_root) {
218                        return Err(bad());
219                    }
220                    break;
221                }
222                Err(e) if e.kind() == io::ErrorKind::NotFound => {
223                    // A dangling symlink is NotFound for canonicalize but must not be followed.
224                    if fs::symlink_metadata(probe).is_ok() {
225                        return Err(bad());
226                    }
227                    match probe.parent() {
228                        Some(p) if p.starts_with(&self.root) => probe = p,
229                        _ => return Err(bad()),
230                    }
231                }
232                Err(e) => return Err(e.into()),
233            }
234        }
235        Ok(full)
236    }
237
238    /// A private directory at `<app root>/<rel>`, created if absent.
239    pub fn data_dir(&self, rel: impl AsRef<Path>) -> Result<PathBuf, AppRootError> {
240        let d = self.path(rel)?;
241        make_private_dir(&d)?;
242        // Re-check after creation: nothing created may resolve outside the root.
243        self.path(d.strip_prefix(&self.root).unwrap_or(&d))?;
244        Ok(d)
245    }
246
247    /// Take the single-writer lock for this app root. `Err(WriterBusy)` if another holder lives.
248    pub fn lock_writer(&self) -> Result<WriterLock, AppRootError> {
249        let p = self.root.join(WRITER_LOCK);
250        match try_lock_exclusive(&p)? {
251            Some(f) => Ok(WriterLock { _file: f }),
252            None => Err(AppRootError::WriterBusy(p)),
253        }
254    }
255
256    /// Copy the old root `src` into this app root, verify every byte, then commit an atomic
257    /// marker. Restart-safe: files land under a temporary name and are renamed only when fully
258    /// written; a rerun after any interruption re-copies missing or partial files, accepts files
259    /// already identical, and refuses (never overwrites) a differing destination file. The marker
260    /// is written last; once present for this source the call is a no-op. `src` is never changed
261    /// or deleted. Symlinks and special files in `src` are refused. Takes the writer lock.
262    pub fn migrate_from(&self, src: &Path) -> Result<MigrationOutcome, AppRootError> {
263        if !src.is_absolute() || src.components().any(|c| c == Component::ParentDir) {
264            return Err(AppRootError::PathEscape(src.display().to_string()));
265        }
266        let md = match fs::symlink_metadata(src) {
267            Ok(m) => m,
268            Err(e) if e.kind() == io::ErrorKind::NotFound => {
269                return Ok(MigrationOutcome::NothingToMigrate)
270            }
271            Err(e) => return Err(e.into()),
272        };
273        if !md.is_dir() {
274            return Err(AppRootError::UnsupportedSource(src.to_path_buf()));
275        }
276        let csrc = fs::canonicalize(src)?;
277        let cdst = fs::canonicalize(&self.root)?;
278        if csrc.starts_with(&cdst) || cdst.starts_with(&csrc) {
279            return Err(AppRootError::Overlap(src.to_path_buf()));
280        }
281        let _lock = self.lock_writer()?;
282        let marker = self.root.join(MIGRATION_MARKER);
283        if let Ok(text) = fs::read_to_string(&marker) {
284            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
285                if v.get("source").and_then(|s| s.as_str()) == csrc.to_str() {
286                    return Ok(MigrationOutcome::AlreadyMigrated);
287                }
288            }
289        }
290        let mut files = Vec::new();
291        collect(&csrc, Path::new(""), &mut files)?;
292        let mut total = 0u64;
293        for (rel, len) in &files {
294            total += len;
295            let from = csrc.join(rel);
296            let to = self.path(rel)?;
297            if let Some(parent) = to.parent() {
298                make_private_dir(parent)?;
299            }
300            if to.exists() {
301                if same_content(&from, &to)? {
302                    continue;
303                }
304                return Err(AppRootError::Collision(to));
305            }
306            let mut tmp = to.clone().into_os_string();
307            tmp.push(TMP_SUFFIX);
308            let tmp = PathBuf::from(tmp);
309            {
310                let mut r = File::open(&from)?;
311                let mut w = File::create(&tmp)?;
312                io::copy(&mut r, &mut w)?;
313                w.sync_all()?;
314            }
315            fs::rename(&tmp, &to)?;
316        }
317        for (rel, _) in &files {
318            let to = self.path(rel)?;
319            if !same_content(&csrc.join(rel), &to)? {
320                return Err(AppRootError::VerifyFailed(to));
321            }
322        }
323        let body = serde_json::json!({
324            "source": csrc.to_str(),
325            "files": files.len(),
326            "bytes": total,
327        });
328        let mut tmp = marker.clone().into_os_string();
329        tmp.push(TMP_SUFFIX);
330        let tmp = PathBuf::from(tmp);
331        {
332            let mut w = File::create(&tmp)?;
333            w.write_all(body.to_string().as_bytes())?;
334            w.sync_all()?;
335        }
336        fs::rename(&tmp, &marker)?;
337        Ok(MigrationOutcome::Migrated {
338            files: files.len() as u64,
339            bytes: total,
340        })
341    }
342}
343
344/// Relative paths and sizes of every regular file under `base/rel`; directories recorded as
345/// recursion only (empty directories are not migrated).
346fn collect(base: &Path, rel: &Path, out: &mut Vec<(PathBuf, u64)>) -> Result<(), AppRootError> {
347    let mut entries: Vec<_> = fs::read_dir(base.join(rel))?.collect::<Result<_, _>>()?;
348    entries.sort_by_key(|e| e.file_name());
349    for e in entries {
350        let name = e.file_name();
351        if name.to_string_lossy().ends_with(TMP_SUFFIX) {
352            continue;
353        }
354        let r = rel.join(&name);
355        let ft = e.file_type()?;
356        if ft.is_symlink() {
357            return Err(AppRootError::UnsupportedSource(base.join(&r)));
358        } else if ft.is_dir() {
359            collect(base, &r, out)?;
360        } else if ft.is_file() {
361            out.push((r, e.metadata()?.len()));
362        } else {
363            return Err(AppRootError::UnsupportedSource(base.join(&r)));
364        }
365    }
366    Ok(())
367}
368
369/// Byte-for-byte equality (stronger than a digest compare).
370fn same_content(a: &Path, b: &Path) -> Result<bool, AppRootError> {
371    let (ma, mb) = (fs::metadata(a)?, fs::metadata(b)?);
372    if !mb.is_file() || ma.len() != mb.len() {
373        return Ok(false);
374    }
375    let (mut fa, mut fb) = (File::open(a)?, File::open(b)?);
376    let (mut ba, mut bb) = (vec![0u8; 64 * 1024], vec![0u8; 64 * 1024]);
377    loop {
378        let na = read_full(&mut fa, &mut ba)?;
379        let nb = read_full(&mut fb, &mut bb)?;
380        if na != nb || ba[..na] != bb[..nb] {
381            return Ok(false);
382        }
383        if na == 0 {
384            return Ok(true);
385        }
386    }
387}
388
389fn read_full(f: &mut File, buf: &mut [u8]) -> io::Result<usize> {
390    let mut n = 0;
391    while n < buf.len() {
392        match f.read(&mut buf[n..])? {
393            0 => break,
394            k => n += k,
395        }
396    }
397    Ok(n)
398}
399
400#[cfg(test)]
401mod tests {
402    /// flock (Unix `File::try_lock`) belongs to the open file description, which a child forked by another test in
403    /// this binary shares until it execs (child-process spawns with pre_exec take the fork path on macOS). A lock
404    /// released by drop can therefore read as held for that instant: re-acquire with a short bound. Not a release bug:
405    /// the guard's drop closes our descriptor; the lock ends when the last shared copy closes (#788).
406    fn eventually<T, E>(mut f: impl FnMut() -> Result<T, E>) -> Result<T, E> {
407        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
408        loop {
409            match f() {
410                Ok(v) => return Ok(v),
411                Err(e) if std::time::Instant::now() >= deadline => return Err(e),
412                Err(_) => std::thread::sleep(std::time::Duration::from_millis(20)),
413            }
414        }
415    }
416
417    use super::*;
418
419    fn suite() -> (tempfile::TempDir, Suite) {
420        let d = tempfile::tempdir().unwrap();
421        let s = Suite::at(d.path().join("root")).unwrap();
422        (d, s)
423    }
424
425    #[test]
426    fn names_are_validated() {
427        for ok in ["HeardRight", "app.orthiclabs.genright", "a", "Cut-Right_2"] {
428            assert!(valid_app_name(ok), "{ok}");
429        }
430        let long = "a".repeat(MAX_APP_NAME_LEN + 1);
431        for bad in [
432            "", ".", "..", "a/b", "a\\b", "../x", "x..y", ".hidden", "trail.", "con", "NUL.txt",
433            "com1", "sp ace", "a:b", "é", "shared", "Shared", &long,
434        ] {
435            assert!(!valid_app_name(bad), "{bad:?}");
436        }
437        assert!(valid_app_name(&"a".repeat(MAX_APP_NAME_LEN)));
438        let (_d, s) = suite();
439        assert!(matches!(
440            s.app_root("../x"),
441            Err(AppRootError::InvalidName(_))
442        ));
443    }
444
445    #[test]
446    fn canonical_suite_gives_sibling_of_shared_and_override_stays_inside() {
447        let d = tempfile::tempdir().unwrap();
448        let canon = Suite::at(d.path().join("Orthic Labs").join("shared")).unwrap();
449        let a = canon.app_root("HeardRight").unwrap();
450        assert_eq!(a.root(), d.path().join("Orthic Labs").join("HeardRight"));
451        let (_d2, s) = suite();
452        let a = s.app_root("HeardRight").unwrap();
453        assert!(a.root().starts_with(s.root()));
454        assert!(a.root().is_dir());
455        #[cfg(unix)]
456        {
457            use std::os::unix::fs::PermissionsExt;
458            assert_eq!(
459                fs::metadata(a.root()).unwrap().permissions().mode() & 0o077,
460                0
461            );
462        }
463    }
464
465    #[test]
466    fn case_conflicts_are_refused() {
467        let (_d, s) = suite();
468        s.app_root("HeardRight").unwrap();
469        assert!(matches!(
470            s.app_root("heardright"),
471            Err(AppRootError::CaseConflict { .. })
472        ));
473        s.app_root("HeardRight").unwrap();
474    }
475
476    #[test]
477    fn subpath_traversal_is_refused() {
478        let (_d, s) = suite();
479        let a = s.app_root("App").unwrap();
480        for bad in ["", ".", "..", "../x", "a/../../x", "/etc", "a:b"] {
481            assert!(
482                matches!(a.path(bad), Err(AppRootError::PathEscape(_))),
483                "{bad:?}"
484            );
485        }
486        // On Windows `\\` is a separator, so `a\\b` is the ordinary subpath `a/b`.
487        #[cfg(unix)]
488        assert!(matches!(a.path("a\\b"), Err(AppRootError::PathEscape(_))));
489        let d = a.data_dir("logs/today").unwrap();
490        assert!(d.is_dir() && d.starts_with(a.root()));
491    }
492
493    #[cfg(unix)]
494    #[test]
495    fn symlink_escape_is_refused() {
496        let (d, s) = suite();
497        let a = s.app_root("App").unwrap();
498        let outside = d.path().join("outside");
499        fs::create_dir(&outside).unwrap();
500        std::os::unix::fs::symlink(&outside, a.root().join("link")).unwrap();
501        assert!(matches!(a.path("link/x"), Err(AppRootError::PathEscape(_))));
502        assert!(matches!(
503            a.data_dir("link/x"),
504            Err(AppRootError::PathEscape(_))
505        ));
506        std::os::unix::fs::symlink(d.path().join("nowhere"), a.root().join("dangling")).unwrap();
507        assert!(matches!(
508            a.path("dangling/x"),
509            Err(AppRootError::PathEscape(_))
510        ));
511        assert!(!outside.join("x").exists());
512    }
513
514    #[test]
515    fn writer_lock_is_single_holder() {
516        let (_d, s) = suite();
517        let a = s.app_root("App").unwrap();
518        let l = a.lock_writer().unwrap();
519        assert!(matches!(a.lock_writer(), Err(AppRootError::WriterBusy(_))));
520        drop(l);
521        a.lock_writer().unwrap();
522    }
523
524    fn old_tree(d: &Path) -> PathBuf {
525        let old = d.join("old");
526        fs::create_dir_all(old.join("sub")).unwrap();
527        fs::write(old.join("a.txt"), b"alpha").unwrap();
528        fs::write(old.join("sub/b.bin"), vec![7u8; 200_000]).unwrap();
529        old
530    }
531
532    #[test]
533    fn migration_copies_verifies_commits_and_is_idempotent() {
534        let (d, s) = suite();
535        let a = s.app_root("App").unwrap();
536        let old = old_tree(d.path());
537        assert_eq!(
538            a.migrate_from(&old).unwrap(),
539            MigrationOutcome::Migrated {
540                files: 2,
541                bytes: 200_005
542            }
543        );
544        assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"alpha");
545        assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
546        assert!(a.root().join(MIGRATION_MARKER).is_file());
547        assert!(old.join("a.txt").is_file(), "old root is left in place");
548        assert_eq!(
549            a.migrate_from(&old).unwrap(),
550            MigrationOutcome::AlreadyMigrated
551        );
552        assert_eq!(
553            a.migrate_from(&d.path().join("missing")).unwrap(),
554            MigrationOutcome::NothingToMigrate
555        );
556    }
557
558    #[test]
559    fn interrupted_migration_resumes_without_marker() {
560        let (d, s) = suite();
561        let a = s.app_root("App").unwrap();
562        let old = old_tree(d.path());
563        // Simulate a crash: a complete copy of one file and a partial temp of another.
564        fs::copy(old.join("a.txt"), a.root().join("a.txt")).unwrap();
565        fs::create_dir_all(a.root().join("sub")).unwrap();
566        fs::write(a.root().join(format!("sub/b.bin{TMP_SUFFIX}")), b"par").unwrap();
567        assert!(!a.root().join(MIGRATION_MARKER).exists());
568        assert!(matches!(
569            a.migrate_from(&old).unwrap(),
570            MigrationOutcome::Migrated { files: 2, .. }
571        ));
572        assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
573    }
574
575    #[test]
576    fn migration_refuses_collision_overlap_symlink_and_busy() {
577        let (d, s) = suite();
578        let a = s.app_root("App").unwrap();
579        let old = old_tree(d.path());
580        fs::write(a.root().join("a.txt"), b"different").unwrap();
581        assert!(matches!(
582            a.migrate_from(&old),
583            Err(AppRootError::Collision(_))
584        ));
585        assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"different");
586        assert!(!a.root().join(MIGRATION_MARKER).exists());
587        assert!(matches!(
588            a.migrate_from(a.root()),
589            Err(AppRootError::Overlap(_))
590        ));
591        assert!(matches!(
592            a.migrate_from(Path::new("rel")),
593            Err(AppRootError::PathEscape(_))
594        ));
595        let _held = eventually(|| a.lock_writer()).unwrap();
596        assert!(matches!(
597            a.migrate_from(&old),
598            Err(AppRootError::WriterBusy(_))
599        ));
600        #[cfg(unix)]
601        {
602            let b = s.app_root("Other").unwrap();
603            let old2 = d.path().join("old2");
604            fs::create_dir(&old2).unwrap();
605            std::os::unix::fs::symlink("/etc/hosts", old2.join("l")).unwrap();
606            assert!(matches!(
607                b.migrate_from(&old2),
608                Err(AppRootError::UnsupportedSource(_))
609            ));
610            assert!(!b.root().join(MIGRATION_MARKER).exists());
611        }
612    }
613}