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 use super::*;
403
404 fn suite() -> (tempfile::TempDir, Suite) {
405 let d = tempfile::tempdir().unwrap();
406 let s = Suite::at(d.path().join("root")).unwrap();
407 (d, s)
408 }
409
410 #[test]
411 fn names_are_validated() {
412 for ok in ["HeardRight", "app.orthiclabs.genright", "a", "Cut-Right_2"] {
413 assert!(valid_app_name(ok), "{ok}");
414 }
415 let long = "a".repeat(MAX_APP_NAME_LEN + 1);
416 for bad in [
417 "", ".", "..", "a/b", "a\\b", "../x", "x..y", ".hidden", "trail.", "con", "NUL.txt",
418 "com1", "sp ace", "a:b", "é", "shared", "Shared", &long,
419 ] {
420 assert!(!valid_app_name(bad), "{bad:?}");
421 }
422 assert!(valid_app_name(&"a".repeat(MAX_APP_NAME_LEN)));
423 let (_d, s) = suite();
424 assert!(matches!(
425 s.app_root("../x"),
426 Err(AppRootError::InvalidName(_))
427 ));
428 }
429
430 #[test]
431 fn canonical_suite_gives_sibling_of_shared_and_override_stays_inside() {
432 let d = tempfile::tempdir().unwrap();
433 let canon = Suite::at(d.path().join("Orthic Labs").join("shared")).unwrap();
434 let a = canon.app_root("HeardRight").unwrap();
435 assert_eq!(a.root(), d.path().join("Orthic Labs").join("HeardRight"));
436 let (_d2, s) = suite();
437 let a = s.app_root("HeardRight").unwrap();
438 assert!(a.root().starts_with(s.root()));
439 assert!(a.root().is_dir());
440 #[cfg(unix)]
441 {
442 use std::os::unix::fs::PermissionsExt;
443 assert_eq!(
444 fs::metadata(a.root()).unwrap().permissions().mode() & 0o077,
445 0
446 );
447 }
448 }
449
450 #[test]
451 fn case_conflicts_are_refused() {
452 let (_d, s) = suite();
453 s.app_root("HeardRight").unwrap();
454 assert!(matches!(
455 s.app_root("heardright"),
456 Err(AppRootError::CaseConflict { .. })
457 ));
458 s.app_root("HeardRight").unwrap();
459 }
460
461 #[test]
462 fn subpath_traversal_is_refused() {
463 let (_d, s) = suite();
464 let a = s.app_root("App").unwrap();
465 for bad in ["", ".", "..", "../x", "a/../../x", "/etc", "a:b"] {
466 assert!(
467 matches!(a.path(bad), Err(AppRootError::PathEscape(_))),
468 "{bad:?}"
469 );
470 }
471 #[cfg(unix)]
473 assert!(matches!(a.path("a\\b"), Err(AppRootError::PathEscape(_))));
474 let d = a.data_dir("logs/today").unwrap();
475 assert!(d.is_dir() && d.starts_with(a.root()));
476 }
477
478 #[cfg(unix)]
479 #[test]
480 fn symlink_escape_is_refused() {
481 let (d, s) = suite();
482 let a = s.app_root("App").unwrap();
483 let outside = d.path().join("outside");
484 fs::create_dir(&outside).unwrap();
485 std::os::unix::fs::symlink(&outside, a.root().join("link")).unwrap();
486 assert!(matches!(a.path("link/x"), Err(AppRootError::PathEscape(_))));
487 assert!(matches!(
488 a.data_dir("link/x"),
489 Err(AppRootError::PathEscape(_))
490 ));
491 std::os::unix::fs::symlink(d.path().join("nowhere"), a.root().join("dangling")).unwrap();
492 assert!(matches!(
493 a.path("dangling/x"),
494 Err(AppRootError::PathEscape(_))
495 ));
496 assert!(!outside.join("x").exists());
497 }
498
499 #[test]
500 fn writer_lock_is_single_holder() {
501 let (_d, s) = suite();
502 let a = s.app_root("App").unwrap();
503 let l = a.lock_writer().unwrap();
504 assert!(matches!(a.lock_writer(), Err(AppRootError::WriterBusy(_))));
505 drop(l);
506 a.lock_writer().unwrap();
507 }
508
509 fn old_tree(d: &Path) -> PathBuf {
510 let old = d.join("old");
511 fs::create_dir_all(old.join("sub")).unwrap();
512 fs::write(old.join("a.txt"), b"alpha").unwrap();
513 fs::write(old.join("sub/b.bin"), vec![7u8; 200_000]).unwrap();
514 old
515 }
516
517 #[test]
518 fn migration_copies_verifies_commits_and_is_idempotent() {
519 let (d, s) = suite();
520 let a = s.app_root("App").unwrap();
521 let old = old_tree(d.path());
522 assert_eq!(
523 a.migrate_from(&old).unwrap(),
524 MigrationOutcome::Migrated {
525 files: 2,
526 bytes: 200_005
527 }
528 );
529 assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"alpha");
530 assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
531 assert!(a.root().join(MIGRATION_MARKER).is_file());
532 assert!(old.join("a.txt").is_file(), "old root is left in place");
533 assert_eq!(
534 a.migrate_from(&old).unwrap(),
535 MigrationOutcome::AlreadyMigrated
536 );
537 assert_eq!(
538 a.migrate_from(&d.path().join("missing")).unwrap(),
539 MigrationOutcome::NothingToMigrate
540 );
541 }
542
543 #[test]
544 fn interrupted_migration_resumes_without_marker() {
545 let (d, s) = suite();
546 let a = s.app_root("App").unwrap();
547 let old = old_tree(d.path());
548 fs::copy(old.join("a.txt"), a.root().join("a.txt")).unwrap();
550 fs::create_dir_all(a.root().join("sub")).unwrap();
551 fs::write(a.root().join(format!("sub/b.bin{TMP_SUFFIX}")), b"par").unwrap();
552 assert!(!a.root().join(MIGRATION_MARKER).exists());
553 assert!(matches!(
554 a.migrate_from(&old).unwrap(),
555 MigrationOutcome::Migrated { files: 2, .. }
556 ));
557 assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
558 }
559
560 #[test]
561 fn migration_refuses_collision_overlap_symlink_and_busy() {
562 let (d, s) = suite();
563 let a = s.app_root("App").unwrap();
564 let old = old_tree(d.path());
565 fs::write(a.root().join("a.txt"), b"different").unwrap();
566 assert!(matches!(
567 a.migrate_from(&old),
568 Err(AppRootError::Collision(_))
569 ));
570 assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"different");
571 assert!(!a.root().join(MIGRATION_MARKER).exists());
572 assert!(matches!(
573 a.migrate_from(a.root()),
574 Err(AppRootError::Overlap(_))
575 ));
576 assert!(matches!(
577 a.migrate_from(Path::new("rel")),
578 Err(AppRootError::PathEscape(_))
579 ));
580 let _held = a.lock_writer().unwrap();
581 assert!(matches!(
582 a.migrate_from(&old),
583 Err(AppRootError::WriterBusy(_))
584 ));
585 #[cfg(unix)]
586 {
587 let b = s.app_root("Other").unwrap();
588 let old2 = d.path().join("old2");
589 fs::create_dir(&old2).unwrap();
590 std::os::unix::fs::symlink("/etc/hosts", old2.join("l")).unwrap();
591 assert!(matches!(
592 b.migrate_from(&old2),
593 Err(AppRootError::UnsupportedSource(_))
594 ));
595 assert!(!b.root().join(MIGRATION_MARKER).exists());
596 }
597 }
598}