1use 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
21pub const MAX_APP_NAME_LEN: usize = 64;
23pub 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
33pub 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 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 PathEscape(String),
66 CaseConflict {
68 requested: String,
69 existing: String,
70 },
71 WriterBusy(PathBuf),
73 UnsupportedSource(PathBuf),
75 Collision(PathBuf),
77 Overlap(PathBuf),
79 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
125pub struct WriterLock {
128 _file: File,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum MigrationOutcome {
134 Migrated { files: u64, bytes: u64 },
136 AlreadyMigrated,
138 NothingToMigrate,
140}
141
142#[derive(Debug, Clone)]
144pub struct AppRoot {
145 name: String,
146 root: PathBuf,
147}
148
149impl Suite {
150 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 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 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 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 pub fn data_dir(&self, rel: impl AsRef<Path>) -> Result<PathBuf, AppRootError> {
240 let d = self.path(rel)?;
241 make_private_dir(&d)?;
242 self.path(d.strip_prefix(&self.root).unwrap_or(&d))?;
244 Ok(d)
245 }
246
247 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 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
344fn 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
369fn 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 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 #[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 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}