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