1use std::fmt;
35use std::fs::{self, File};
36use std::io::{self, Read};
37use std::path::{Component, Path, PathBuf};
38
39use rightkit_fs::{AtomicWriteOptions, AtomicWriter, FsError, ReplaceMode};
40
41use crate::discovery::{try_lock_exclusive, LockedFile};
42use crate::suite::{make_private_dir, Suite, SuiteError};
43
44pub const MAX_APP_NAME_LEN: usize = 64;
46pub const MIGRATION_MARKER: &str = ".rightkit-migrated.json";
49pub const MIGRATION_STATE: &str = ".rightkit-migration-state.json";
53const 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
65pub 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 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 PathEscape(String),
98 CaseConflict {
100 requested: String,
101 existing: String,
102 },
103 WriterBusy(PathBuf),
105 UnsupportedSource(PathBuf),
110 Collision(PathBuf),
112 Overlap(PathBuf),
114 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 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 pub fn is_retryable(&self) -> bool {
165 self.link_not_created().is_some() || matches!(self, Self::WriterBusy(_))
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
176#[non_exhaustive]
177pub struct LinkNotCreated {
178 pub link: PathBuf,
180 pub target: PathBuf,
182 pub resolved: PathBuf,
184 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
212pub struct WriterLock {
215 _file: LockedFile,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
220pub enum MigrationOutcome {
221 Migrated { files: u64, bytes: u64 },
223 AlreadyMigrated,
225 NothingToMigrate,
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233#[non_exhaustive]
234pub enum MigrationState {
235 Ready,
238 ReadyWithOmissions,
243 Incomplete,
247}
248
249impl MigrationState {
250 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 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#[derive(Debug, Clone, PartialEq, Eq)]
277#[non_exhaustive]
278pub enum MigrationScope {
279 All,
281 Roots(Vec<PathBuf>),
283}
284
285#[derive(Debug, Clone, PartialEq, Eq)]
287#[non_exhaustive]
288pub struct MigrationStatus {
289 state: MigrationState,
290 scope: MigrationScope,
291}
292
293impl MigrationStatus {
294 pub fn state(&self) -> MigrationState {
296 self.state
297 }
298 pub fn scope(&self) -> &MigrationScope {
300 &self.scope
301 }
302}
303
304#[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 pub fn state(&self) -> MigrationState {
336 self.state
337 }
338
339 pub fn skipped_reparse_points(&self) -> &[SkippedReparsePoint] {
342 &self.skipped_reparse_points
343 }
344
345 pub fn outcome(&self) -> &MigrationOutcome {
347 &self.outcome
348 }
349
350 pub fn into_outcome(self) -> MigrationOutcome {
351 self.outcome
352 }
353
354 pub fn migrated_links(&self) -> &[MigratedLink] {
356 &self.migrated_links
357 }
358
359 pub fn skipped_specials(&self) -> &[SkippedSpecial] {
361 &self.skipped_specials
362 }
363
364 pub fn omitted_optional_links(&self) -> &[OmittedLink] {
367 &self.omitted_optional_links
368 }
369
370 pub fn complete(&self) -> bool {
374 self.state == MigrationState::Ready
375 }
376}
377
378#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct MigratedLink {
381 pub path: PathBuf,
383 pub original_target: PathBuf,
385 pub written_target: PathBuf,
387 pub rewritten: bool,
390}
391
392#[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#[derive(Debug, Clone, PartialEq, Eq)]
410pub struct SkippedSpecial {
411 pub path: PathBuf,
413 pub kind: SpecialKind,
414}
415
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
418#[non_exhaustive]
419pub enum ReparseKind {
420 Junction,
423 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#[derive(Debug, Clone, PartialEq, Eq)]
440#[non_exhaustive]
441pub struct SkippedReparsePoint {
442 pub path: PathBuf,
444 pub kind: ReparseKind,
445 pub tag: u32,
447 pub reason: String,
449}
450
451#[derive(Debug, Clone, PartialEq, Eq)]
453pub struct OmittedLink {
454 pub path: PathBuf,
456 pub original_target: PathBuf,
458 pub resolved_target: PathBuf,
460 pub reason: String,
462}
463
464type OptionalLinkPolicy = std::sync::Arc<dyn Fn(&Path) -> bool + Send + Sync>;
465
466#[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 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 pub fn strict() -> Self {
535 Self::default()
536 }
537
538 pub fn preserve_links(mut self, on: bool) -> Self {
545 self.links = on;
546 self
547 }
548
549 pub fn skip_ephemeral_specials(mut self, on: bool) -> Self {
553 self.ephemeral_specials = on;
554 self
555 }
556
557 pub fn skip_reparse_points(mut self, on: bool) -> Self {
567 self.reparse_points = on;
568 self
569 }
570
571 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 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 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#[derive(Debug, Clone)]
704pub struct AppRoot {
705 name: String,
706 root: PathBuf,
707}
708
709impl Suite {
710 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 pub fn path(&self, rel: impl AsRef<Path>) -> Result<PathBuf, AppRootError> {
753 self.path_checked(rel.as_ref(), true)
754 }
755
756 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 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 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 pub fn data_dir(&self, rel: impl AsRef<Path>) -> Result<PathBuf, AppRootError> {
809 let d = self.path(rel)?;
810 make_private_dir(&d)?;
811 self.path(d.strip_prefix(&self.root).unwrap_or(&d))?;
813 Ok(d)
814 }
815
816 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 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 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 pub fn migration_state(&self, src: &Path) -> Result<Option<MigrationState>, AppRootError> {
869 Ok(self.migration_status(src)?.map(|status| status.state()))
870 }
871
872 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 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 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 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 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 let mut entries = Vec::new();
1131 collect(&csrc, Path::new(""), opts, &mut entries)?;
1132 if revalidate {
1133 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 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 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 let mut created: Vec<(PathBuf, PathBuf, bool)> = Vec::new();
1173 for entry in entries {
1174 match entry {
1175 Entry::Dir { rel } => {
1176 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 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 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 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 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 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 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 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
1482enum Entry {
1484 Dir {
1486 rel: PathBuf,
1487 },
1488 File {
1489 rel: PathBuf,
1490 len: u64,
1491 },
1492 Link {
1494 rel: PathBuf,
1495 target: PathBuf,
1496 dir: bool,
1497 },
1498 Special {
1499 rel: PathBuf,
1500 kind: SpecialKind,
1501 },
1502 #[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#[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 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 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
1618fn 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
1625fn 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 #[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 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 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#[cfg(windows)]
1727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1728enum Reparse {
1729 Symlink,
1731 Junction,
1733 Socket,
1735 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#[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#[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 let handle =
1783 unsafe { FindFirstFileW(PCWSTR(wide.as_ptr()), &mut data) }.map_err(io::Error::other)?;
1784 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#[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
1840fn 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#[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
1907fn 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
1933fn 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
1955fn 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
2033fn 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
2067fn 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
2102fn 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
2132fn 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
2149pub(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
2173fn 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
2186pub(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 use super::*;
2222
2223 fn suite() -> (tempfile::TempDir, Suite) {
2224 let d = tempfile::tempdir().unwrap();
2225 let s = Suite::at(d.path().join("root")).unwrap();
2226 (d, s)
2227 }
2228
2229 #[test]
2230 fn names_are_validated() {
2231 for ok in ["HeardRight", "app.orthiclabs.genright", "a", "Cut-Right_2"] {
2232 assert!(valid_app_name(ok), "{ok}");
2233 }
2234 let long = "a".repeat(MAX_APP_NAME_LEN + 1);
2235 for bad in [
2236 "", ".", "..", "a/b", "a\\b", "../x", "x..y", ".hidden", "trail.", "con", "NUL.txt",
2237 "com1", "sp ace", "a:b", "é", "shared", "Shared", &long,
2238 ] {
2239 assert!(!valid_app_name(bad), "{bad:?}");
2240 }
2241 assert!(valid_app_name(&"a".repeat(MAX_APP_NAME_LEN)));
2242 let (_d, s) = suite();
2243 assert!(matches!(
2244 s.app_root("../x"),
2245 Err(AppRootError::InvalidName(_))
2246 ));
2247 }
2248
2249 #[test]
2250 fn canonical_suite_gives_sibling_of_shared_and_override_stays_inside() {
2251 let d = tempfile::tempdir().unwrap();
2252 let canon = Suite::at(d.path().join("Orthic Labs").join("shared")).unwrap();
2253 let a = canon.app_root("HeardRight").unwrap();
2254 assert_eq!(a.root(), d.path().join("Orthic Labs").join("HeardRight"));
2255 let (_d2, s) = suite();
2256 let a = s.app_root("HeardRight").unwrap();
2257 assert!(a.root().starts_with(s.root()));
2258 assert!(a.root().is_dir());
2259 #[cfg(unix)]
2260 {
2261 use std::os::unix::fs::PermissionsExt;
2262 assert_eq!(
2263 fs::metadata(a.root()).unwrap().permissions().mode() & 0o077,
2264 0
2265 );
2266 }
2267 }
2268
2269 #[test]
2270 fn case_conflicts_are_refused() {
2271 let (_d, s) = suite();
2272 s.app_root("HeardRight").unwrap();
2273 assert!(matches!(
2274 s.app_root("heardright"),
2275 Err(AppRootError::CaseConflict { .. })
2276 ));
2277 s.app_root("HeardRight").unwrap();
2278 }
2279
2280 #[test]
2281 fn subpath_traversal_is_refused() {
2282 let (_d, s) = suite();
2283 let a = s.app_root("App").unwrap();
2284 for bad in ["", ".", "..", "../x", "a/../../x", "/etc", "a:b"] {
2285 assert!(
2286 matches!(a.path(bad), Err(AppRootError::PathEscape(_))),
2287 "{bad:?}"
2288 );
2289 }
2290 #[cfg(unix)]
2292 assert!(matches!(a.path("a\\b"), Err(AppRootError::PathEscape(_))));
2293 let d = a.data_dir("logs/today").unwrap();
2294 assert!(d.is_dir() && d.starts_with(a.root()));
2295 }
2296
2297 #[cfg(unix)]
2298 #[test]
2299 fn symlink_escape_is_refused() {
2300 let (d, s) = suite();
2301 let a = s.app_root("App").unwrap();
2302 let outside = d.path().join("outside");
2303 fs::create_dir(&outside).unwrap();
2304 std::os::unix::fs::symlink(&outside, a.root().join("link")).unwrap();
2305 assert!(matches!(a.path("link/x"), Err(AppRootError::PathEscape(_))));
2306 assert!(matches!(
2307 a.data_dir("link/x"),
2308 Err(AppRootError::PathEscape(_))
2309 ));
2310 std::os::unix::fs::symlink(d.path().join("nowhere"), a.root().join("dangling")).unwrap();
2311 assert!(matches!(
2312 a.path("dangling/x"),
2313 Err(AppRootError::PathEscape(_))
2314 ));
2315 assert!(!outside.join("x").exists());
2316 }
2317
2318 #[test]
2319 fn writer_lock_is_single_holder() {
2320 let (_d, s) = suite();
2321 let a = s.app_root("App").unwrap();
2322 let l = a.lock_writer().unwrap();
2323 assert!(matches!(a.lock_writer(), Err(AppRootError::WriterBusy(_))));
2324 drop(l);
2325 a.lock_writer().unwrap();
2326 }
2327
2328 fn old_tree(d: &Path) -> PathBuf {
2329 let old = d.join("old");
2330 fs::create_dir_all(old.join("sub")).unwrap();
2331 fs::write(old.join("a.txt"), b"alpha").unwrap();
2332 fs::write(old.join("sub/b.bin"), vec![7u8; 200_000]).unwrap();
2333 old
2334 }
2335
2336 #[test]
2337 fn migration_copies_verifies_commits_and_is_idempotent() {
2338 let (d, s) = suite();
2339 let a = s.app_root("App").unwrap();
2340 let old = old_tree(d.path());
2341 assert_eq!(
2342 a.migrate_from(&old).unwrap(),
2343 MigrationOutcome::Migrated {
2344 files: 2,
2345 bytes: 200_005
2346 }
2347 );
2348 assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"alpha");
2349 assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
2350 assert!(a.root().join(MIGRATION_MARKER).is_file());
2351 assert!(old.join("a.txt").is_file(), "old root is left in place");
2352 assert_eq!(
2353 a.migrate_from(&old).unwrap(),
2354 MigrationOutcome::AlreadyMigrated
2355 );
2356 assert_eq!(
2357 a.migrate_from(&d.path().join("missing")).unwrap(),
2358 MigrationOutcome::NothingToMigrate
2359 );
2360 }
2361
2362 #[test]
2363 fn interrupted_migration_resumes_without_marker() {
2364 let (d, s) = suite();
2365 let a = s.app_root("App").unwrap();
2366 let old = old_tree(d.path());
2367 fs::copy(old.join("a.txt"), a.root().join("a.txt")).unwrap();
2369 fs::create_dir_all(a.root().join("sub")).unwrap();
2370 fs::write(a.root().join(format!("sub/b.bin{TMP_SUFFIX}")), b"par").unwrap();
2371 assert!(!a.root().join(MIGRATION_MARKER).exists());
2372 assert!(matches!(
2373 a.migrate_from(&old).unwrap(),
2374 MigrationOutcome::Migrated { files: 2, .. }
2375 ));
2376 assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
2377 }
2378
2379 #[test]
2380 fn migration_never_opens_legacy_temps_hardlinked_to_source() {
2381 let (d, s) = suite();
2382 let a = s.app_root("App").unwrap();
2383 let old = old_tree(d.path());
2384 for name in ["a.txt", MIGRATION_STATE, MIGRATION_MARKER] {
2385 fs::hard_link(
2386 old.join("a.txt"),
2387 a.root().join(format!("{name}{TMP_SUFFIX}")),
2388 )
2389 .unwrap();
2390 }
2391 a.migrate_from(&old).unwrap();
2392 assert_eq!(fs::read(old.join("a.txt")).unwrap(), b"alpha");
2393 assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"alpha");
2394 for name in ["a.txt", MIGRATION_STATE, MIGRATION_MARKER] {
2395 assert_eq!(
2396 fs::read(a.root().join(format!("{name}{TMP_SUFFIX}"))).unwrap(),
2397 b"alpha"
2398 );
2399 }
2400 }
2401
2402 #[cfg(unix)]
2403 #[test]
2404 fn migration_never_follows_legacy_temp_symlinks_into_source() {
2405 let (d, s) = suite();
2406 let a = s.app_root("App").unwrap();
2407 let old = old_tree(d.path());
2408 for name in ["a.txt", MIGRATION_STATE, MIGRATION_MARKER] {
2409 std::os::unix::fs::symlink(
2410 old.join("a.txt"),
2411 a.root().join(format!("{name}{TMP_SUFFIX}")),
2412 )
2413 .unwrap();
2414 }
2415 a.migrate_from(&old).unwrap();
2416 assert_eq!(fs::read(old.join("a.txt")).unwrap(), b"alpha");
2417 assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"alpha");
2418 for name in ["a.txt", MIGRATION_STATE, MIGRATION_MARKER] {
2419 assert!(
2420 fs::symlink_metadata(a.root().join(format!("{name}{TMP_SUFFIX}")))
2421 .unwrap()
2422 .file_type()
2423 .is_symlink()
2424 );
2425 }
2426 }
2427
2428 #[test]
2429 fn migration_publication_preserves_a_destination_contender() {
2430 let (d, s) = suite();
2431 let a = s.app_root("App").unwrap();
2432 let old = old_tree(d.path());
2433 let source = old.join("a.txt");
2434 let target = a.root().join("a.txt");
2435 for contender in [b"other".as_slice(), b"alpha".as_slice()] {
2436 let mut writer = AtomicWriter::create_new_private(&target).unwrap();
2437 writer.write_all(b"alpha").unwrap();
2438 let temp = writer.temp_path().to_path_buf();
2439 fs::write(&target, contender).unwrap();
2441 let result = commit_migration_file(writer, &source, &target);
2442 if contender == b"alpha" {
2443 result.unwrap();
2444 } else {
2445 assert!(matches!(result, Err(AppRootError::Collision(p)) if p == target));
2446 }
2447 assert_eq!(fs::read(&target).unwrap(), contender);
2448 assert_eq!(fs::read(&source).unwrap(), b"alpha");
2449 assert!(!temp.exists());
2450 fs::remove_file(&target).unwrap();
2451 }
2452 }
2453
2454 #[test]
2455 fn migration_preserves_native_filenames_that_resemble_fs_temps() {
2456 let (d, s) = suite();
2457 let a = s.app_root("App").unwrap();
2458 let old = old_tree(d.path());
2459 let rel = Path::new("sub/.record.rktmp-123-1-0");
2460 fs::write(old.join(rel), b"user data, not a migration temp").unwrap();
2461 assert!(matches!(
2462 a.migrate_from(&old).unwrap(),
2463 MigrationOutcome::Migrated { files: 3, .. }
2464 ));
2465 assert_eq!(
2466 fs::read(a.root().join(rel)).unwrap(),
2467 b"user data, not a migration temp"
2468 );
2469 assert_eq!(
2470 fs::read(old.join(rel)).unwrap(),
2471 b"user data, not a migration temp"
2472 );
2473 assert_eq!(
2474 a.migrate_from(&old).unwrap(),
2475 MigrationOutcome::AlreadyMigrated
2476 );
2477 }
2478
2479 #[test]
2480 fn migration_preserves_legacy_temp_shaped_files_and_directories() {
2481 let (d, s) = suite();
2482 let a = s.app_root("App").unwrap();
2483 let old = old_tree(d.path());
2484 let file = Path::new("sub/user.rk-migrating");
2485 let directory = Path::new("archive.rk-migrating");
2486 fs::write(old.join(file), b"user file").unwrap();
2487 fs::create_dir(old.join(directory)).unwrap();
2488 fs::write(old.join(directory).join("entry"), b"user entry").unwrap();
2489 assert!(matches!(
2490 a.migrate_from(&old).unwrap(),
2491 MigrationOutcome::Migrated { files: 4, .. }
2492 ));
2493 for root in [old.as_path(), a.root()] {
2494 assert_eq!(fs::read(root.join(file)).unwrap(), b"user file");
2495 assert_eq!(
2496 fs::read(root.join(directory).join("entry")).unwrap(),
2497 b"user entry"
2498 );
2499 }
2500 }
2501
2502 #[cfg(unix)]
2503 #[test]
2504 fn metadata_publication_refuses_symlink_leaves_without_touching_source() {
2505 let (d, s) = suite();
2506 let a = s.app_root("App").unwrap();
2507 let old = old_tree(d.path());
2508 for name in [MIGRATION_STATE, MIGRATION_MARKER] {
2509 let target = a.root().join(name);
2510 std::os::unix::fs::symlink(old.join("a.txt"), &target).unwrap();
2511 let error =
2512 write_json_atomic(&target, &serde_json::json!({"source": "old"})).unwrap_err();
2513 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
2514 assert!(fs::symlink_metadata(&target)
2515 .unwrap()
2516 .file_type()
2517 .is_symlink());
2518 assert_eq!(fs::read(old.join("a.txt")).unwrap(), b"alpha");
2519 }
2520 }
2521
2522 #[test]
2523 fn migration_refuses_collision_overlap_symlink_and_busy() {
2524 let (d, s) = suite();
2525 let a = s.app_root("App").unwrap();
2526 let old = old_tree(d.path());
2527 fs::write(a.root().join("a.txt"), b"different").unwrap();
2528 assert!(matches!(
2529 a.migrate_from(&old),
2530 Err(AppRootError::Collision(_))
2531 ));
2532 assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"different");
2533 assert!(!a.root().join(MIGRATION_MARKER).exists());
2534 assert!(matches!(
2535 a.migrate_from(a.root()),
2536 Err(AppRootError::Overlap(_))
2537 ));
2538 assert!(matches!(
2539 a.migrate_from(Path::new("rel")),
2540 Err(AppRootError::PathEscape(_))
2541 ));
2542 let _held = a.lock_writer().unwrap();
2543 assert!(matches!(
2544 a.migrate_from(&old),
2545 Err(AppRootError::WriterBusy(_))
2546 ));
2547 #[cfg(unix)]
2548 {
2549 let b = s.app_root("Other").unwrap();
2550 let old2 = d.path().join("old2");
2551 fs::create_dir(&old2).unwrap();
2552 std::os::unix::fs::symlink("/etc/hosts", old2.join("l")).unwrap();
2553 assert!(matches!(
2554 b.migrate_from(&old2),
2555 Err(AppRootError::UnsupportedSource(_))
2556 ));
2557 assert!(!b.root().join(MIGRATION_MARKER).exists());
2558 }
2559 }
2560
2561 fn snapshot(root: &Path) -> Vec<(String, Vec<u8>)> {
2564 fn walk(base: &Path, rel: &Path, out: &mut Vec<(String, Vec<u8>)>) {
2565 let mut names: Vec<_> = fs::read_dir(base.join(rel))
2566 .unwrap()
2567 .map(|e| e.unwrap().file_name())
2568 .collect();
2569 names.sort();
2570 for n in names {
2571 let r = rel.join(&n);
2572 let full = base.join(&r);
2573 let md = fs::symlink_metadata(&full).unwrap();
2574 let key = r.display().to_string();
2575 if md.file_type().is_symlink() {
2576 let t = fs::read_link(&full).unwrap();
2577 out.push((
2578 format!("L {key}"),
2579 t.to_string_lossy().into_owned().into_bytes(),
2580 ));
2581 } else if md.is_dir() {
2582 out.push((format!("D {key}"), Vec::new()));
2583 walk(base, &r, out);
2584 } else if md.is_file() {
2585 out.push((format!("F {key}"), fs::read(&full).unwrap()));
2586 } else {
2587 out.push((format!("O {key}"), Vec::new()));
2588 }
2589 }
2590 }
2591 let mut out = Vec::new();
2592 walk(root, Path::new(""), &mut out);
2593 out
2594 }
2595
2596 fn links_on() -> MigrateOptions {
2597 MigrateOptions::strict().preserve_links(true)
2598 }
2599
2600 fn marker_value(a: &AppRoot) -> serde_json::Value {
2601 serde_json::from_str(&fs::read_to_string(a.root().join(MIGRATION_MARKER)).unwrap()).unwrap()
2602 }
2603
2604 fn state_value(a: &AppRoot) -> serde_json::Value {
2605 serde_json::from_str(&fs::read_to_string(a.root().join(MIGRATION_STATE)).unwrap()).unwrap()
2606 }
2607
2608 fn old_reader_sees_migrated(a: &AppRoot, old: &Path) -> bool {
2611 let Ok(text) = fs::read_to_string(a.root().join(MIGRATION_MARKER)) else {
2612 return false;
2613 };
2614 let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
2615 return false;
2616 };
2617 v.get("source").and_then(|s| s.as_str()) == fs::canonicalize(old).unwrap().to_str()
2618 }
2619
2620 fn entry_count(dir: &Path) -> usize {
2621 fs::read_dir(dir).unwrap().count()
2622 }
2623
2624 #[test]
2625 fn empty_directories_are_migrated_in_every_mode() {
2626 let (d, s) = suite();
2627 let old = old_tree(d.path());
2628 fs::create_dir(old.join("empty")).unwrap();
2629 fs::create_dir_all(old.join("sub/deep/emptier")).unwrap();
2630 let before = snapshot(&old);
2631 for (name, opts) in [
2632 ("Strict", MigrateOptions::strict()),
2633 ("Links", links_on()),
2634 (
2635 "Everything",
2636 links_on()
2637 .skip_ephemeral_specials(true)
2638 .skip_reparse_points(true),
2639 ),
2640 ] {
2641 let a = s.app_root(name).unwrap();
2642 let r = a.migrate_from_with(&old, opts).unwrap();
2643 assert_eq!(
2644 r.outcome(),
2645 &MigrationOutcome::Migrated {
2646 files: 2,
2647 bytes: 200_005
2648 },
2649 "{name}: directories are not counted as files"
2650 );
2651 assert_eq!(r.state(), MigrationState::Ready, "{name}");
2652 for dir in ["empty", "sub/deep", "sub/deep/emptier"] {
2653 let p = a.root().join(dir);
2654 assert!(
2655 fs::symlink_metadata(&p).unwrap().file_type().is_dir(),
2656 "{name}: {dir}"
2657 );
2658 }
2659 assert_eq!(entry_count(&a.root().join("empty")), 0, "{name}");
2660 assert_eq!(entry_count(&a.root().join("sub/deep/emptier")), 0, "{name}");
2661 assert_eq!(
2662 a.migrate_from_with(&old, MigrateOptions::strict())
2663 .unwrap()
2664 .outcome(),
2665 &MigrationOutcome::AlreadyMigrated
2666 );
2667 }
2668 assert_eq!(snapshot(&old), before);
2669 }
2670
2671 #[test]
2672 fn a_directory_collision_is_refused_and_leaves_the_move_incomplete_until_resolved() {
2673 let (d, s) = suite();
2674 let a = s.app_root("App").unwrap();
2675 let old = old_tree(d.path());
2676 fs::create_dir(old.join("empty")).unwrap();
2677 fs::write(a.root().join("empty"), b"in the way").unwrap();
2679 assert!(matches!(
2680 a.migrate_from(&old),
2681 Err(AppRootError::Collision(_))
2682 ));
2683 assert_eq!(fs::read(a.root().join("empty")).unwrap(), b"in the way");
2684 assert!(!a.root().join(MIGRATION_MARKER).exists());
2686 assert!(!old_reader_sees_migrated(&a, &old));
2687 assert_eq!(
2688 a.migration_state(&old).unwrap(),
2689 Some(MigrationState::Incomplete)
2690 );
2691 assert_eq!(state_value(&a)["state"], serde_json::json!("incomplete"));
2692 assert!(state_value(&a)["error"]
2693 .as_str()
2694 .unwrap()
2695 .contains("differs from source"));
2696 fs::remove_file(a.root().join("empty")).unwrap();
2698 assert!(matches!(
2699 a.migrate_from(&old).unwrap(),
2700 MigrationOutcome::Migrated { files: 2, .. }
2701 ));
2702 assert!(a.root().join("empty").is_dir());
2703 assert_eq!(
2704 a.migration_state(&old).unwrap(),
2705 Some(MigrationState::Ready)
2706 );
2707 assert!(old_reader_sees_migrated(&a, &old));
2708 assert_eq!(state_value(&a)["state"], serde_json::json!("ready"));
2709 }
2710
2711 #[test]
2712 fn an_interrupted_run_leaves_incomplete_and_the_rerun_retries_everything() {
2713 let (d, s) = suite();
2714 let a = s.app_root("App").unwrap();
2715 let old = old_tree(d.path());
2716 fs::create_dir(old.join("empty")).unwrap();
2717 a.write_state(&state_json(
2719 MigrationState::Incomplete,
2720 fs::canonicalize(&old).unwrap().to_str(),
2721 &Lists::default(),
2722 None,
2723 ))
2724 .unwrap();
2725 fs::copy(old.join("a.txt"), a.root().join("a.txt")).unwrap();
2726 fs::create_dir_all(a.root().join("sub")).unwrap();
2727 fs::write(a.root().join(format!("sub/b.bin{TMP_SUFFIX}")), b"par").unwrap();
2728 assert_eq!(
2729 a.migration_state(&old).unwrap(),
2730 Some(MigrationState::Incomplete)
2731 );
2732 assert!(!old_reader_sees_migrated(&a, &old));
2733 let r = a.migrate_from_with(&old, links_on()).unwrap();
2734 assert!(matches!(
2735 r.outcome(),
2736 MigrationOutcome::Migrated { files: 2, .. }
2737 ));
2738 assert_eq!(r.state(), MigrationState::Ready);
2739 assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
2740 assert!(a.root().join("empty").is_dir());
2741 assert!(old_reader_sees_migrated(&a, &old));
2742 assert_eq!(state_value(&a)["state"], serde_json::json!("ready"));
2743 }
2744
2745 #[test]
2746 fn a_ready_state_without_its_marker_is_repaired_by_the_next_call() {
2747 let (d, s) = suite();
2748 let a = s.app_root("App").unwrap();
2749 let old = old_tree(d.path());
2750 a.migrate_from(&old).unwrap();
2751 fs::remove_file(a.root().join(MIGRATION_MARKER)).unwrap();
2753 assert!(!old_reader_sees_migrated(&a, &old));
2754 assert_eq!(
2755 a.migrate_from(&old).unwrap(),
2756 MigrationOutcome::AlreadyMigrated
2757 );
2758 assert!(old_reader_sees_migrated(&a, &old));
2759 }
2760
2761 #[test]
2762 fn readback_refuses_ambiguous_state_while_copy_retains_legacy_absent_rules() {
2763 let (d, s) = suite();
2764 let a = s.app_root("App").unwrap();
2765 let old = old_tree(d.path());
2766 let src = fs::canonicalize(&old).unwrap();
2767 let state = a.root().join(MIGRATION_STATE);
2768 let write = |v: serde_json::Value| fs::write(&state, v.to_string()).unwrap();
2769 write(
2771 serde_json::json!({"version": 2, "state": "ready", "source": src.with_file_name("elsewhere")}),
2772 );
2773 assert_eq!(a.migration_state(&old).unwrap(), None);
2774 write(
2775 serde_json::json!({"version": 9, "state": "ready", "source": src.with_file_name("elsewhere")}),
2776 );
2777 assert!(a.migration_state(&old).is_err());
2778 assert!(
2779 a.read_state(&src).unwrap().is_none(),
2780 "copy parser remains source-scoped"
2781 );
2782 for v in [
2784 serde_json::json!({"version": 99, "state": "ready", "source": src.to_str()}),
2785 serde_json::json!({"version": 2, "state": "teleported", "source": src.to_str()}),
2786 ] {
2787 write(v);
2788 for r in [
2789 a.migration_state(&old).map(|_| ()),
2790 a.migrate_from(&old).map(|_| ()),
2791 ] {
2792 match r {
2793 Err(AppRootError::Io(e)) => {
2794 assert_eq!(e.kind(), io::ErrorKind::InvalidData)
2795 }
2796 other => panic!("expected InvalidData, got {other:?}"),
2797 }
2798 }
2799 assert!(!a.root().join("a.txt").exists(), "nothing was copied");
2800 }
2801 for text in ["not json {", "{\"source\": \"x\"}"] {
2803 fs::write(&state, text).unwrap();
2804 assert!(a.migration_state(&old).is_err());
2805 assert!(a.read_state(&src).unwrap().is_none());
2806 }
2807 assert!(matches!(
2808 a.migrate_from(&old).unwrap(),
2809 MigrationOutcome::Migrated { files: 2, .. }
2810 ));
2811 }
2812
2813 #[test]
2814 fn the_migrations_own_marker_names_in_the_source_are_not_copied() {
2815 let (d, s) = suite();
2816 let a = s.app_root("App").unwrap();
2817 let old = old_tree(d.path());
2818 fs::write(old.join(MIGRATION_MARKER), br#"{"source":"/earlier"}"#).unwrap();
2820 fs::write(old.join(MIGRATION_STATE), b"not ours").unwrap();
2821 fs::write(old.join("sub").join(MIGRATION_MARKER), b"data").unwrap();
2823 let r = a.migrate_from(&old).unwrap();
2824 assert!(matches!(r, MigrationOutcome::Migrated { files: 3, .. }));
2825 assert_eq!(
2826 marker_value(&a)["source"].as_str(),
2827 fs::canonicalize(&old).unwrap().to_str()
2828 );
2829 assert_eq!(state_value(&a)["state"], serde_json::json!("ready"));
2830 assert_eq!(
2831 fs::read(a.root().join("sub").join(MIGRATION_MARKER)).unwrap(),
2832 b"data"
2833 );
2834 }
2835
2836 #[test]
2837 fn strict_marker_keeps_the_historical_shape_and_old_markers_still_count() {
2838 let (d, s) = suite();
2839 let a = s.app_root("App").unwrap();
2840 let old = old_tree(d.path());
2841 a.migrate_from(&old).unwrap();
2842 let v = marker_value(&a);
2843 let mut keys: Vec<_> = v.as_object().unwrap().keys().cloned().collect();
2844 keys.sort();
2845 assert_eq!(keys, ["bytes", "files", "source"]);
2846 assert_eq!(
2848 a.migrate_from_with(&old, links_on().skip_ephemeral_specials(true))
2849 .unwrap()
2850 .outcome(),
2851 &MigrationOutcome::AlreadyMigrated
2852 );
2853 let b = s.app_root("Other").unwrap();
2855 let out = b.migrate_from_with(&old, links_on()).unwrap();
2856 assert_eq!(
2857 out.outcome(),
2858 &MigrationOutcome::Migrated {
2859 files: 2,
2860 bytes: 200_005
2861 }
2862 );
2863 assert!(out.migrated_links().is_empty());
2864 assert!(out.skipped_specials().is_empty());
2865 assert!(out.omitted_optional_links().is_empty());
2866 assert!(out.complete());
2867 let v = marker_value(&b);
2868 assert_eq!(v["links"], serde_json::json!([]));
2869 assert_eq!(v["skipped_specials"], serde_json::json!([]));
2870 assert_eq!(v["omitted_optional_links"], serde_json::json!([]));
2871 assert_eq!(v["complete"], serde_json::json!(true));
2872 assert_eq!(v["files"], serde_json::json!(2));
2873 }
2874
2875 #[test]
2876 fn marker_json_reports_omitted_links_as_incomplete() {
2877 let omitted = [OmittedLink {
2878 path: PathBuf::from("skills/x"),
2879 original_target: PathBuf::from("../../x"),
2880 resolved_target: PathBuf::from("/abs/x"),
2881 reason: "denied".into(),
2882 }];
2883 let v = marker_json(Some("/old"), 1, 2, &links_on(), &[], &[], &omitted);
2884 assert_eq!(v["complete"], serde_json::json!(false));
2885 assert_eq!(v["omitted_optional_links"][0]["path"], "skills/x");
2886 assert_eq!(v["omitted_optional_links"][0]["original_target"], "../../x");
2887 assert_eq!(v["omitted_optional_links"][0]["resolved_target"], "/abs/x");
2888 }
2889
2890 #[test]
2891 fn link_creation_errors_are_classified() {
2892 let denied = io::Error::from(io::ErrorKind::PermissionDenied);
2893 assert!(link_unavailable(&denied));
2894 assert!(link_unavailable(&io::Error::from(
2895 io::ErrorKind::Unsupported
2896 )));
2897 assert!(!link_unavailable(&io::Error::from(
2898 io::ErrorKind::AlreadyExists
2899 )));
2900 let typed = LinkNotCreated {
2901 link: "a".into(),
2902 target: "b".into(),
2903 resolved: "b".into(),
2904 reason: "denied".into(),
2905 };
2906 let e = AppRootError::Io(io::Error::other(typed.clone()));
2907 assert_eq!(e.link_not_created(), Some(&typed));
2908 assert!(e.is_retryable());
2909 assert!(e.to_string().contains("cannot create symlink a -> b"));
2910 assert!(AppRootError::WriterBusy("x".into()).is_retryable());
2911 assert!(!AppRootError::Collision("x".into()).is_retryable());
2912 let plain = AppRootError::Io(io::Error::from(io::ErrorKind::PermissionDenied));
2914 assert!(plain.link_not_created().is_none() && !plain.is_retryable());
2915 }
2916
2917 #[test]
2918 fn plan_link_keeps_inside_text_and_resolves_outside_lexically() {
2919 let csrc = Path::new(if cfg!(windows) {
2920 r"C:\data\old"
2921 } else {
2922 "/data/old"
2923 });
2924 let rel = |s: &str| PathBuf::from(s);
2925 for (at, t) in [
2927 ("l", "a.txt"),
2928 ("sub/l", "../a.txt"),
2929 ("sub/l", "."),
2930 ("sub/l", ".."),
2931 ("l", "./x/../y"),
2932 ] {
2933 assert_eq!(
2934 plan_link(csrc, &rel(at), Path::new(t)),
2935 (PathBuf::from(t), false),
2936 "{at} {t}"
2937 );
2938 }
2939 let (w, rewritten) = plan_link(csrc, &rel("sub/l"), Path::new("../../out/f"));
2941 assert!(rewritten);
2942 assert_eq!(w, csrc.parent().unwrap().join("out").join("f"));
2943 let (w, rewritten) = plan_link(csrc, &rel("l"), Path::new("../old/a.txt"));
2944 assert!(rewritten);
2945 assert_eq!(w, csrc.join("a.txt"));
2946 }
2947
2948 #[cfg(unix)]
2949 mod unix_links {
2950 use super::*;
2951 use std::os::unix::fs::symlink;
2952
2953 fn migrated(
2954 r: MigrationReport,
2955 ) -> (
2956 u64,
2957 Vec<MigratedLink>,
2958 Vec<SkippedSpecial>,
2959 Vec<OmittedLink>,
2960 ) {
2961 let files = match r.outcome() {
2962 MigrationOutcome::Migrated { files, .. } => *files,
2963 other => panic!("not migrated: {other:?}"),
2964 };
2965 (
2966 files,
2967 r.migrated_links().to_vec(),
2968 r.skipped_specials().to_vec(),
2969 r.omitted_optional_links().to_vec(),
2970 )
2971 }
2972
2973 fn read_link_text(p: PathBuf) -> PathBuf {
2974 fs::read_link(p).unwrap()
2975 }
2976
2977 #[test]
2978 fn internal_relative_link_keeps_its_text_and_meaning() {
2979 let (d, s) = suite();
2980 let a = s.app_root("App").unwrap();
2981 let old = old_tree(d.path());
2982 symlink("../a.txt", old.join("sub/to_a")).unwrap();
2983 symlink("sub", old.join("top")).unwrap();
2984 symlink("sub/b.bin", old.join("to_b")).unwrap();
2985 let before = snapshot(&old);
2986 let (files, links, specials, omitted) =
2987 migrated(a.migrate_from_with(&old, links_on()).unwrap());
2988 assert_eq!(files, 2);
2989 assert_eq!(links.len(), 3);
2990 assert!(specials.is_empty() && omitted.is_empty());
2991 assert!(links
2992 .iter()
2993 .all(|l| !l.rewritten && l.original_target == l.written_target));
2994 assert_eq!(
2995 read_link_text(a.root().join("sub/to_a")),
2996 Path::new("../a.txt")
2997 );
2998 assert_eq!(fs::read(a.root().join("sub/to_a")).unwrap(), b"alpha");
2999 assert_eq!(read_link_text(a.root().join("top")), Path::new("sub"));
3000 assert_eq!(fs::read(a.root().join("top/b.bin")).unwrap().len(), 200_000);
3001 assert_eq!(
3002 read_link_text(a.root().join("to_b")),
3003 Path::new("sub/b.bin")
3004 );
3005 let v = marker_value(&a);
3006 assert_eq!(v["links"].as_array().unwrap().len(), 3);
3007 assert_eq!(v["complete"], serde_json::json!(true));
3008 assert_eq!(snapshot(&old), before, "old tree is byte-identical");
3009 }
3010
3011 #[test]
3012 fn external_relative_link_is_rewritten_to_its_original_target() {
3013 let (d, s) = suite();
3014 let a = s.app_root("App").unwrap();
3015 let old = old_tree(d.path());
3016 let outside = d.path().join("outside");
3017 fs::create_dir(&outside).unwrap();
3018 fs::write(outside.join("file"), b"out").unwrap();
3019 symlink("../../outside/file", old.join("sub/ext")).unwrap();
3020 symlink("../old/a.txt", old.join("reenter")).unwrap();
3022 let before = snapshot(&old);
3023 let (_, links, _, _) = migrated(a.migrate_from_with(&old, links_on()).unwrap());
3024 let canon_d = fs::canonicalize(d.path()).unwrap();
3025 let want_ext = canon_d.join("outside").join("file");
3026 let want_re = fs::canonicalize(&old).unwrap().join("a.txt");
3027 assert_eq!(read_link_text(a.root().join("sub/ext")), want_ext);
3028 assert_eq!(read_link_text(a.root().join("reenter")), want_re);
3029 assert_eq!(fs::read(a.root().join("sub/ext")).unwrap(), b"out");
3030 assert_eq!(fs::read(a.root().join("reenter")).unwrap(), b"alpha");
3031 let ext = links
3032 .iter()
3033 .find(|l| l.path == Path::new("sub/ext"))
3034 .unwrap();
3035 assert!(ext.rewritten);
3036 assert_eq!(ext.original_target, Path::new("../../outside/file"));
3037 assert_eq!(ext.written_target, want_ext);
3038 assert_eq!(snapshot(&old), before);
3039 }
3040
3041 #[test]
3042 fn absolute_link_is_copied_verbatim() {
3043 let (d, s) = suite();
3044 let a = s.app_root("App").unwrap();
3045 let old = old_tree(d.path());
3046 let outside = d.path().join("outside");
3047 fs::create_dir(&outside).unwrap();
3048 fs::write(outside.join("file"), b"out").unwrap();
3049 let target = outside.join("file");
3050 symlink(&target, old.join("abs")).unwrap();
3051 let (_, links, _, _) = migrated(a.migrate_from_with(&old, links_on()).unwrap());
3052 assert_eq!(read_link_text(a.root().join("abs")), target);
3053 assert!(!links[0].rewritten && links[0].written_target == target);
3054 }
3055
3056 #[test]
3057 fn dangling_links_stay_links() {
3058 let (d, s) = suite();
3059 let a = s.app_root("App").unwrap();
3060 let old = old_tree(d.path());
3061 symlink("missing/nowhere", old.join("dang")).unwrap();
3062 symlink("/nonexistent-rk-test/xyz", old.join("dang_abs")).unwrap();
3063 let before = snapshot(&old);
3064 let (_, links, _, _) = migrated(a.migrate_from_with(&old, links_on()).unwrap());
3065 assert_eq!(links.len(), 2);
3066 for (name, text) in [
3067 ("dang", "missing/nowhere"),
3068 ("dang_abs", "/nonexistent-rk-test/xyz"),
3069 ] {
3070 let p = a.root().join(name);
3071 assert!(fs::symlink_metadata(&p).unwrap().file_type().is_symlink());
3072 assert!(!p.exists(), "still dangling");
3073 assert_eq!(read_link_text(p), Path::new(text));
3074 }
3075 assert_eq!(snapshot(&old), before);
3076 }
3077
3078 #[test]
3079 fn link_cycles_are_not_traversed() {
3080 let (d, s) = suite();
3081 let a = s.app_root("App").unwrap();
3082 let old = old_tree(d.path());
3083 symlink(".", old.join("loop")).unwrap();
3084 symlink("b", old.join("ca")).unwrap();
3085 symlink("ca", old.join("b")).unwrap();
3086 symlink("..", old.join("sub/up")).unwrap();
3087 let before = snapshot(&old);
3088 let (files, links, _, _) = migrated(a.migrate_from_with(&old, links_on()).unwrap());
3089 assert_eq!(files, 2, "no file was reached through a link");
3090 assert_eq!(links.len(), 4);
3091 assert_eq!(read_link_text(a.root().join("loop")), Path::new("."));
3092 assert_eq!(read_link_text(a.root().join("sub/up")), Path::new(".."));
3093 assert_eq!(read_link_text(a.root().join("ca")), Path::new("b"));
3094 assert_eq!(read_link_text(a.root().join("b")), Path::new("ca"));
3095 assert_eq!(snapshot(&old), before);
3096 }
3097
3098 #[test]
3099 fn collisions_behave_as_before_with_links_enabled() {
3100 let (d, s) = suite();
3101 let old = old_tree(d.path());
3102 symlink("../a.txt", old.join("sub/to_a")).unwrap();
3103 let a = s.app_root("A").unwrap();
3105 fs::write(a.root().join("a.txt"), b"different").unwrap();
3106 assert!(matches!(
3107 a.migrate_from_with(&old, links_on()),
3108 Err(AppRootError::Collision(_))
3109 ));
3110 assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"different");
3111 assert!(!a.root().join(MIGRATION_MARKER).exists());
3112 let b = s.app_root("B").unwrap();
3114 fs::create_dir_all(b.root().join("sub")).unwrap();
3115 symlink("elsewhere", b.root().join("sub/to_a")).unwrap();
3116 assert!(matches!(
3117 b.migrate_from_with(&old, links_on()),
3118 Err(AppRootError::Collision(_))
3119 ));
3120 assert_eq!(
3121 read_link_text(b.root().join("sub/to_a")),
3122 Path::new("elsewhere")
3123 );
3124 assert!(!b.root().join(MIGRATION_MARKER).exists());
3125 let c = s.app_root("C").unwrap();
3126 fs::create_dir_all(c.root().join("sub")).unwrap();
3127 fs::write(c.root().join("sub/to_a"), b"file").unwrap();
3128 assert!(matches!(
3129 c.migrate_from_with(&old, links_on()),
3130 Err(AppRootError::Collision(_))
3131 ));
3132 assert_eq!(fs::read(c.root().join("sub/to_a")).unwrap(), b"file");
3133 let e = s.app_root("E").unwrap();
3135 fs::create_dir_all(e.root().join("sub")).unwrap();
3136 symlink("../a.txt", e.root().join("sub/to_a")).unwrap();
3137 migrated(e.migrate_from_with(&old, links_on()).unwrap());
3138 }
3139
3140 #[test]
3141 fn interrupted_link_migration_restarts_cleanly() {
3142 let (d, s) = suite();
3143 let a = s.app_root("App").unwrap();
3144 let old = old_tree(d.path());
3145 symlink("../a.txt", old.join("sub/to_a")).unwrap();
3146 symlink("sub", old.join("top")).unwrap();
3147 fs::copy(old.join("a.txt"), a.root().join("a.txt")).unwrap();
3149 fs::create_dir_all(a.root().join("sub")).unwrap();
3150 symlink("../a.txt", a.root().join("sub/to_a")).unwrap();
3151 fs::write(a.root().join(format!("sub/b.bin{TMP_SUFFIX}")), b"par").unwrap();
3152 assert!(!a.root().join(MIGRATION_MARKER).exists());
3153 let (files, links, _, _) = migrated(a.migrate_from_with(&old, links_on()).unwrap());
3154 assert_eq!((files, links.len()), (2, 2));
3155 assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
3156 assert_eq!(read_link_text(a.root().join("top")), Path::new("sub"));
3157 assert_eq!(
3158 a.migrate_from_with(&old, links_on()).unwrap().outcome(),
3159 &MigrationOutcome::AlreadyMigrated
3160 );
3161 }
3162
3163 #[test]
3164 fn sockets_and_fifos_are_skipped_and_reported_never_opened() {
3165 let (d, s) = suite();
3166 let a = s.app_root("App").unwrap();
3167 let old = old_tree(d.path());
3168 let _sock = std::os::unix::net::UnixListener::bind(old.join("s")).unwrap();
3169 let st = std::process::Command::new("mkfifo")
3170 .arg(old.join("f"))
3171 .status()
3172 .unwrap();
3173 assert!(st.success());
3174 let before = snapshot(&old);
3175 for o in [MigrateOptions::strict(), links_on()] {
3177 assert!(matches!(
3178 a.migrate_from_with(&old, o.clone()),
3179 Err(AppRootError::UnsupportedSource(_))
3180 ));
3181 }
3182 assert!(!a.root().join(MIGRATION_MARKER).exists());
3183 let o = MigrateOptions::strict().skip_ephemeral_specials(true);
3184 let (files, links, specials, _) =
3185 migrated(a.migrate_from_with(&old, o.clone()).unwrap());
3186 assert_eq!((files, links.len()), (2, 0));
3187 assert_eq!(
3188 specials,
3189 vec![
3190 SkippedSpecial {
3191 path: "f".into(),
3192 kind: SpecialKind::Fifo
3193 },
3194 SkippedSpecial {
3195 path: "s".into(),
3196 kind: SpecialKind::Socket
3197 },
3198 ]
3199 );
3200 assert!(fs::symlink_metadata(a.root().join("f")).is_err());
3201 assert!(fs::symlink_metadata(a.root().join("s")).is_err());
3202 let v = marker_value(&a);
3203 assert_eq!(
3204 v["skipped_specials"][0],
3205 serde_json::json!({"path": "f", "kind": "fifo"})
3206 );
3207 assert_eq!(
3208 v["skipped_specials"][1],
3209 serde_json::json!({"path": "s", "kind": "socket"})
3210 );
3211 assert_eq!(snapshot(&old), before);
3212 }
3213
3214 #[test]
3215 fn strict_default_still_refuses_links_and_specials() {
3216 let (d, s) = suite();
3217 let old = old_tree(d.path());
3218 symlink("a.txt", old.join("l")).unwrap();
3219 let before = snapshot(&old);
3220 for (i, o) in [
3221 MigrateOptions::default(),
3222 MigrateOptions::strict(),
3223 MigrateOptions::strict().skip_ephemeral_specials(true),
3224 MigrateOptions::strict().optional_links(|_| true),
3225 ]
3226 .into_iter()
3227 .enumerate()
3228 {
3229 let a = s.app_root(&format!("App{i}")).unwrap();
3230 assert!(matches!(
3231 a.migrate_from_with(&old, o.clone()),
3232 Err(AppRootError::UnsupportedSource(_))
3233 ));
3234 assert!(!a.root().join(MIGRATION_MARKER).exists());
3235 }
3236 let a = s.app_root("Plain").unwrap();
3237 assert!(matches!(
3238 a.migrate_from(&old),
3239 Err(AppRootError::UnsupportedSource(_))
3240 ));
3241 assert_eq!(snapshot(&old), before);
3242 }
3243
3244 fn denied(_: &Path, _: &Path, _: bool) -> io::Result<()> {
3245 Err(io::Error::from(io::ErrorKind::PermissionDenied))
3246 }
3247
3248 #[test]
3249 fn required_link_failure_is_typed_retryable_and_writes_no_marker() {
3250 let (d, s) = suite();
3251 let a = s.app_root("App").unwrap();
3252 let old = old_tree(d.path());
3253 symlink("../a.txt", old.join("sub/to_a")).unwrap();
3254 symlink("../../ext", old.join("sub/ext")).unwrap();
3255 let before = snapshot(&old);
3256 let err = a.migrate_impl(&old, &links_on(), &denied).unwrap_err();
3257 assert!(err.is_retryable());
3258 let typed = err.link_not_created().expect("typed link failure");
3259 assert_eq!(typed.link, Path::new("sub/ext"));
3260 assert_eq!(typed.target, Path::new("../../ext"));
3261 assert_eq!(
3262 typed.resolved,
3263 fs::canonicalize(d.path()).unwrap().join("ext")
3264 );
3265 assert!(
3266 matches!(err, AppRootError::Io(_)),
3267 "carried by the existing Io variant"
3268 );
3269 assert!(
3270 !a.root().join(MIGRATION_MARKER).exists(),
3271 "no completed marker"
3272 );
3273 assert_eq!(snapshot(&old), before);
3274 let (files, links, _, omitted) =
3276 migrated(a.migrate_from_with(&old, links_on()).unwrap());
3277 assert_eq!((files, links.len(), omitted.len()), (2, 2, 0));
3278 assert!(a.root().join(MIGRATION_MARKER).is_file());
3279 assert_eq!(snapshot(&old), before);
3280 }
3281
3282 #[test]
3283 fn optional_link_failure_is_omitted_and_reported_separately() {
3284 let (d, s) = suite();
3285 let a = s.app_root("App").unwrap();
3286 let old = old_tree(d.path());
3287 fs::create_dir(old.join("skills")).unwrap();
3288 symlink("../../shared/tool", old.join("skills/tool")).unwrap();
3289 let before = snapshot(&old);
3290 let other = links_on().optional_links(|p| p.starts_with("worktrees"));
3292 assert!(a
3293 .migrate_impl(&old, &other, &denied)
3294 .unwrap_err()
3295 .link_not_created()
3296 .is_some());
3297 assert!(!a.root().join(MIGRATION_MARKER).exists());
3298 let opts = links_on().optional_links(|p| p.starts_with("skills"));
3299 let report = a.migrate_impl(&old, &opts, &denied).unwrap();
3300 assert!(!report.complete());
3301 let (files, links, _, omitted) = migrated(report);
3302 assert_eq!((files, links.len()), (2, 0));
3303 assert_eq!(omitted.len(), 1);
3304 assert_eq!(omitted[0].path, Path::new("skills/tool"));
3305 assert_eq!(omitted[0].original_target, Path::new("../../shared/tool"));
3306 assert_eq!(
3307 omitted[0].resolved_target,
3308 fs::canonicalize(d.path()).unwrap().join("shared/tool")
3309 );
3310 assert!(fs::symlink_metadata(a.root().join("skills/tool")).is_err());
3311 assert_eq!(
3314 a.migration_state(&old).unwrap(),
3315 Some(MigrationState::ReadyWithOmissions)
3316 );
3317 assert!(!a.root().join(MIGRATION_MARKER).exists());
3318 assert!(!old_reader_sees_migrated(&a, &old));
3319 let v = state_value(&a);
3320 assert_eq!(v["state"], serde_json::json!("ready_with_omissions"));
3321 assert_eq!(v["omitted_optional_links"].as_array().unwrap().len(), 1);
3322 assert_eq!(v["unresolved"][0]["path"], serde_json::json!("skills/tool"));
3323 assert_eq!(v["links"], serde_json::json!([]));
3324 assert_eq!(snapshot(&old), before);
3325 }
3326
3327 #[test]
3328 fn omitted_optional_links_are_retried_and_the_state_becomes_ready() {
3329 let (d, s) = suite();
3330 let a = s.app_root("App").unwrap();
3331 let old = old_tree(d.path());
3332 fs::create_dir(old.join("skills")).unwrap();
3333 symlink("../../shared/tool", old.join("skills/tool")).unwrap();
3334 let before = snapshot(&old);
3335 let opts = || links_on().optional_links(|p| p.starts_with("skills"));
3336 let first = a.migrate_impl(&old, &opts(), &denied).unwrap();
3337 assert_eq!(first.state(), MigrationState::ReadyWithOmissions);
3338 let again = a.migrate_impl(&old, &opts(), &denied).unwrap();
3340 assert_eq!(again.outcome(), &MigrationOutcome::AlreadyMigrated);
3341 assert_eq!(again.state(), MigrationState::ReadyWithOmissions);
3342 assert_eq!(again.omitted_optional_links().len(), 1);
3343 assert!(again.migrated_links().is_empty());
3344 assert!(!a.root().join(MIGRATION_MARKER).exists());
3345 fs::write(a.root().join("a.txt"), b"edited by the app").unwrap();
3348 let done = a.migrate_from_with(&old, opts()).unwrap();
3349 assert_eq!(done.outcome(), &MigrationOutcome::AlreadyMigrated);
3350 assert_eq!(done.state(), MigrationState::Ready);
3351 assert!(done.complete() && done.omitted_optional_links().is_empty());
3352 assert_eq!(done.migrated_links().len(), 1);
3353 assert_eq!(done.migrated_links()[0].path, Path::new("skills/tool"));
3354 assert!(done.migrated_links()[0].rewritten);
3355 assert_eq!(
3356 read_link_text(a.root().join("skills/tool")),
3357 fs::canonicalize(d.path()).unwrap().join("shared/tool")
3358 );
3359 assert_eq!(
3360 fs::read(a.root().join("a.txt")).unwrap(),
3361 b"edited by the app"
3362 );
3363 assert_eq!(state_value(&a)["state"], serde_json::json!("ready"));
3364 assert_eq!(state_value(&a)["links"].as_array().unwrap().len(), 1);
3365 assert_eq!(state_value(&a)["unresolved"], serde_json::json!([]));
3366 assert!(old_reader_sees_migrated(&a, &old), "now a complete move");
3367 let last = a.migrate_from_with(&old, opts()).unwrap();
3369 assert_eq!(last.outcome(), &MigrationOutcome::AlreadyMigrated);
3370 assert!(last.complete() && last.migrated_links().is_empty());
3371 assert_eq!(snapshot(&old), before);
3372 }
3373
3374 #[test]
3375 fn a_retry_never_overwrites_an_occupied_destination_path() {
3376 let (d, s) = suite();
3377 let a = s.app_root("App").unwrap();
3378 let old = old_tree(d.path());
3379 fs::create_dir(old.join("skills")).unwrap();
3380 symlink("tool-a", old.join("skills/tool")).unwrap();
3381 let opts = || links_on().optional_links(|p| p.starts_with("skills"));
3382 let first = a.migrate_impl(&old, &opts(), &denied).unwrap();
3383 assert_eq!(first.state(), MigrationState::ReadyWithOmissions);
3384 fs::write(a.root().join("skills/tool"), b"the app's own file").unwrap();
3386 let r = a.migrate_from_with(&old, opts()).unwrap();
3387 assert_eq!(r.state(), MigrationState::ReadyWithOmissions);
3388 assert_eq!(r.omitted_optional_links().len(), 1);
3389 assert!(r.omitted_optional_links()[0].reason.contains("occupied"));
3390 assert_eq!(
3391 fs::read(a.root().join("skills/tool")).unwrap(),
3392 b"the app's own file"
3393 );
3394 assert!(!a.root().join(MIGRATION_MARKER).exists());
3395 }
3396
3397 #[test]
3398 fn a_link_to_an_empty_directory_does_not_dangle_in_the_new_tree() {
3399 let (d, s) = suite();
3400 let a = s.app_root("App").unwrap();
3401 let old = old_tree(d.path());
3402 fs::create_dir(old.join("empty")).unwrap();
3403 symlink("empty", old.join("to_empty")).unwrap();
3404 let before = snapshot(&old);
3405 let r = a.migrate_from_with(&old, links_on()).unwrap();
3406 assert_eq!(r.state(), MigrationState::Ready);
3407 assert!(a.root().join("empty").is_dir());
3408 assert!(
3409 a.root().join("to_empty").is_dir(),
3410 "the link resolves to the migrated empty directory"
3411 );
3412 assert_eq!(
3413 read_link_text(a.root().join("to_empty")),
3414 Path::new("empty")
3415 );
3416 assert_eq!(snapshot(&old), before);
3417 }
3418
3419 #[test]
3420 fn a_failed_required_link_leaves_incomplete_and_the_rerun_reaches_ready() {
3421 let (d, s) = suite();
3422 let a = s.app_root("App").unwrap();
3423 let old = old_tree(d.path());
3424 symlink("../a.txt", old.join("sub/to_a")).unwrap();
3425 symlink("../../ext", old.join("sub/ext")).unwrap();
3426 assert_eq!(a.migration_state(&old).unwrap(), None);
3427 let err = a.migrate_impl(&old, &links_on(), &denied).unwrap_err();
3428 assert!(err.is_retryable());
3429 assert!(!a.root().join(MIGRATION_MARKER).exists());
3432 assert!(!old_reader_sees_migrated(&a, &old));
3433 assert_eq!(
3434 a.migration_state(&old).unwrap(),
3435 Some(MigrationState::Incomplete)
3436 );
3437 assert!(!a.migration_state(&old).unwrap().unwrap().is_usable());
3438 let v = state_value(&a);
3439 assert_eq!(v["state"], serde_json::json!("incomplete"));
3440 assert_eq!(v["version"], serde_json::json!(2));
3441 assert_eq!(v["unresolved"][0]["path"], serde_json::json!("sub/ext"));
3442 assert_eq!(v["unresolved"][0]["kind"], serde_json::json!("link"));
3443 assert!(v["error"]
3444 .as_str()
3445 .unwrap()
3446 .contains("cannot create symlink"));
3447 assert!(a.migrate_impl(&old, &links_on(), &denied).is_err());
3449 assert_eq!(
3450 a.migration_state(&old).unwrap(),
3451 Some(MigrationState::Incomplete)
3452 );
3453 let r = a.migrate_from_with(&old, links_on()).unwrap();
3455 assert_eq!(r.state(), MigrationState::Ready);
3456 assert_eq!(r.migrated_links().len(), 2);
3457 assert_eq!(
3458 a.migration_state(&old).unwrap(),
3459 Some(MigrationState::Ready)
3460 );
3461 assert!(a.root().join(MIGRATION_MARKER).is_file());
3462 assert!(old_reader_sees_migrated(&a, &old));
3463 assert_eq!(state_value(&a)["state"], serde_json::json!("ready"));
3464 }
3465
3466 #[test]
3467 fn socket_entry_is_skipped_and_recorded_without_making_the_move_incomplete() {
3468 let (d, s) = suite();
3469 let a = s.app_root("App").unwrap();
3470 let old = old_tree(d.path());
3471 let _sock = std::os::unix::net::UnixListener::bind(old.join("s")).unwrap();
3472 let before = snapshot(&old);
3473 let o = MigrateOptions::strict().skip_ephemeral_specials(true);
3474 let r = a.migrate_from_with(&old, o).unwrap();
3475 assert_eq!(r.state(), MigrationState::Ready);
3478 assert!(r.complete());
3479 assert_eq!(r.skipped_specials().len(), 1);
3480 assert_eq!(r.skipped_specials()[0].kind, SpecialKind::Socket);
3481 assert!(r.skipped_reparse_points().is_empty());
3482 assert!(fs::symlink_metadata(a.root().join("s")).is_err());
3483 let v = state_value(&a);
3484 assert_eq!(
3485 v["skipped_specials"][0],
3486 serde_json::json!({"path": "s", "kind": "socket"})
3487 );
3488 assert_eq!(v["state"], serde_json::json!("ready"));
3489 assert_eq!(snapshot(&old), before);
3490 }
3491 }
3492
3493 #[cfg(windows)]
3494 mod windows_links {
3495 use super::*;
3496 use std::os::windows::fs::{symlink_dir, symlink_file, FileTypeExt};
3497
3498 fn old_with_links(d: &Path) -> Option<PathBuf> {
3501 let old = old_tree(d);
3502 if symlink_file("a.txt", old.join("la")).is_err()
3503 || symlink_dir("sub", old.join("ld")).is_err()
3504 {
3505 eprintln!("skipped: symbolic links cannot be created (no privilege)");
3506 return None;
3507 }
3508 Some(old)
3509 }
3510
3511 #[test]
3512 fn links_migrate_with_their_kind_or_report_the_privilege_failure() {
3513 let (d, s) = suite();
3514 let a = s.app_root("App").unwrap();
3515 let Some(old) = old_with_links(d.path()) else {
3516 return;
3517 };
3518 let before = snapshot(&old);
3519 match a.migrate_from_with(&old, links_on()) {
3520 Ok(report) => {
3521 assert_eq!(report.migrated_links().len(), 2);
3522 let la = fs::symlink_metadata(a.root().join("la"))
3523 .unwrap()
3524 .file_type();
3525 let ld = fs::symlink_metadata(a.root().join("ld"))
3526 .unwrap()
3527 .file_type();
3528 assert!(la.is_symlink_file() && ld.is_symlink_dir());
3529 assert!(marker_value(&a)["links"].as_array().unwrap().len() == 2);
3530 }
3531 Err(e) => {
3532 let typed = e.link_not_created().expect("typed link failure");
3533 assert!(typed.link == Path::new("la") || typed.link == Path::new("ld"));
3534 assert!(typed.target == Path::new("a.txt") || typed.target == Path::new("sub"));
3535 assert!(e.is_retryable());
3536 assert!(!a.root().join(MIGRATION_MARKER).exists());
3537 }
3538 }
3539 assert_eq!(snapshot(&old), before);
3540 }
3541
3542 #[test]
3543 fn optional_links_are_migrated_or_omitted_never_dropped_silently() {
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 opts = links_on().optional_links(|_| true);
3550 let r = a.migrate_from_with(&old, opts).unwrap();
3551 assert!(matches!(r.outcome(), MigrationOutcome::Migrated { .. }));
3552 assert_eq!(
3553 r.migrated_links().len() + r.omitted_optional_links().len(),
3554 2
3555 );
3556 assert_eq!(r.complete(), r.omitted_optional_links().is_empty());
3557 let want = if r.complete() {
3558 "ready"
3559 } else {
3560 "ready_with_omissions"
3561 };
3562 assert_eq!(state_value(&a)["state"], serde_json::json!(want));
3563 assert_eq!(
3565 a.root().join(MIGRATION_MARKER).exists(),
3566 r.complete(),
3567 "an earlier reader sees a finished move only for a complete one"
3568 );
3569 }
3570
3571 fn junction(link: &Path, target: &Path) -> bool {
3573 std::process::Command::new("cmd")
3574 .args(["/C", "mklink", "/J"])
3575 .arg(link)
3576 .arg(target)
3577 .output()
3578 .is_ok_and(|o| o.status.success())
3579 }
3580
3581 #[test]
3582 fn reparse_tags_are_classified() {
3583 assert_eq!(classify_reparse_tag(0xA000_000C), Reparse::Symlink);
3584 assert_eq!(classify_reparse_tag(0xA000_0003), Reparse::Junction);
3585 assert_eq!(classify_reparse_tag(0x8000_0023), Reparse::Socket);
3586 assert_eq!(
3588 classify_reparse_tag(0x9000_001A),
3589 Reparse::Other(0x9000_001A)
3590 );
3591 assert_eq!(
3592 classify_reparse_tag(0xA000_001D),
3593 Reparse::Other(0xA000_001D)
3594 );
3595 }
3596
3597 #[test]
3598 fn a_junction_is_never_followed_copied_or_recreated() {
3599 let (d, s) = suite();
3600 let outside = d.path().join("outside");
3601 fs::create_dir(&outside).unwrap();
3602 fs::write(outside.join("secret.txt"), b"not part of the app root").unwrap();
3603 let old = old_tree(d.path());
3604 if !junction(&old.join("junc"), &outside) {
3605 eprintln!("skipped: cannot create a junction here");
3606 return;
3607 }
3608 let before = snapshot(&old);
3609 for (i, o) in [
3611 MigrateOptions::strict(),
3612 links_on(),
3613 MigrateOptions::strict().skip_ephemeral_specials(true),
3614 ]
3615 .into_iter()
3616 .enumerate()
3617 {
3618 let a = s.app_root(&format!("Refuse{i}")).unwrap();
3619 assert!(matches!(
3620 a.migrate_from_with(&old, o),
3621 Err(AppRootError::UnsupportedSource(_))
3622 ));
3623 assert!(!a.root().join(MIGRATION_MARKER).exists());
3624 assert!(!a.root().join(MIGRATION_STATE).exists());
3625 assert!(!a.root().join("a.txt").exists(), "nothing was copied");
3626 assert_eq!(a.migration_state(&old).unwrap(), None);
3627 }
3628 let a = s.app_root("Skip").unwrap();
3630 let o = links_on().skip_reparse_points(true);
3631 let r = a.migrate_from_with(&old, o.clone()).unwrap();
3632 assert_eq!(r.state(), MigrationState::ReadyWithOmissions);
3633 assert!(!r.complete());
3634 assert_eq!(
3635 r.outcome(),
3636 &MigrationOutcome::Migrated {
3637 files: 2,
3638 bytes: 200_005
3639 }
3640 );
3641 assert_eq!(r.skipped_reparse_points().len(), 1);
3642 let skipped = &r.skipped_reparse_points()[0];
3643 assert_eq!(skipped.path, Path::new("junc"));
3644 assert_eq!(skipped.kind, ReparseKind::Junction);
3645 assert!(skipped.reason.contains("never followed"));
3646 assert!(r.migrated_links().is_empty(), "not recreated as a symlink");
3647 assert!(fs::symlink_metadata(a.root().join("junc")).is_err());
3648 assert!(
3649 snapshot(a.root())
3650 .iter()
3651 .all(|(k, _)| !k.contains("secret") && !k.contains("junc")),
3652 "nothing behind the junction was copied"
3653 );
3654 assert!(!a.root().join(MIGRATION_MARKER).exists());
3657 assert!(!old_reader_sees_migrated(&a, &old));
3658 let v = state_value(&a);
3659 assert_eq!(v["state"], serde_json::json!("ready_with_omissions"));
3660 assert_eq!(
3661 v["skipped_reparse_points"][0]["path"],
3662 serde_json::json!("junc")
3663 );
3664 assert_eq!(
3665 v["skipped_reparse_points"][0]["kind"],
3666 serde_json::json!("junction")
3667 );
3668 assert_eq!(
3669 v["unresolved"][0]["kind"],
3670 serde_json::json!("reparse_point")
3671 );
3672 let again = a.migrate_from_with(&old, o).unwrap();
3674 assert_eq!(again.outcome(), &MigrationOutcome::AlreadyMigrated);
3675 assert_eq!(again.state(), MigrationState::ReadyWithOmissions);
3676 assert_eq!(again.skipped_reparse_points().len(), 1);
3677 assert_eq!(
3678 a.migration_state(&old).unwrap(),
3679 Some(MigrationState::ReadyWithOmissions)
3680 );
3681 assert_eq!(snapshot(&old), before);
3683 assert_eq!(
3684 fs::read(outside.join("secret.txt")).unwrap(),
3685 b"not part of the app root"
3686 );
3687 }
3688 }
3689}