use std::fmt;
use std::fs::{self, File};
use std::io::{self, Read};
use std::path::{Component, Path, PathBuf};
use rightkit_fs::{AtomicWriteOptions, AtomicWriter, FsError, ReplaceMode};
use crate::discovery::try_lock_exclusive;
use crate::suite::{make_private_dir, Suite, SuiteError};
pub const MAX_APP_NAME_LEN: usize = 64;
pub const MIGRATION_MARKER: &str = ".rightkit-migrated.json";
pub const MIGRATION_STATE: &str = ".rightkit-migration-state.json";
const STATE_VERSION: u64 = 2;
const SELECTED_STATE_VERSION: u64 = 3;
const WRITER_LOCK: &str = ".writer.lock";
#[cfg(test)]
const TMP_SUFFIX: &str = ".rk-migrating";
const WINDOWS_RESERVED: [&str; 22] = [
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
pub fn valid_app_name(s: &str) -> bool {
let b = s.as_bytes();
if b.is_empty() || b.len() > MAX_APP_NAME_LEN {
return false;
}
if !b
.iter()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.'))
{
return false;
}
if !b[0].is_ascii_alphanumeric() || !b[b.len() - 1].is_ascii_alphanumeric() || s.contains("..")
{
return false;
}
if s.eq_ignore_ascii_case("shared") {
return false;
}
let stem = s.split('.').next().unwrap_or(s).to_ascii_uppercase();
!WINDOWS_RESERVED.contains(&stem.as_str())
}
#[derive(Debug)]
pub enum AppRootError {
InvalidName(String),
PathEscape(String),
CaseConflict {
requested: String,
existing: String,
},
WriterBusy(PathBuf),
UnsupportedSource(PathBuf),
Collision(PathBuf),
Overlap(PathBuf),
VerifyFailed(PathBuf),
Suite(SuiteError),
Io(io::Error),
}
impl fmt::Display for AppRootError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidName(n) => write!(f, "invalid app name: {n:?}"),
Self::PathEscape(p) => write!(f, "path escapes the app root: {p}"),
Self::CaseConflict {
requested,
existing,
} => write!(
f,
"app root {requested:?} conflicts with existing {existing:?} (differs only by case)"
),
Self::WriterBusy(p) => write!(f, "another writer holds {}", p.display()),
Self::UnsupportedSource(p) => {
write!(
f,
"refusing symlink or special file in source: {}",
p.display()
)
}
Self::Collision(p) => write!(f, "destination differs from source: {}", p.display()),
Self::Overlap(p) => write!(f, "source and destination overlap: {}", p.display()),
Self::VerifyFailed(p) => write!(f, "verification failed: {}", p.display()),
Self::Suite(e) => write!(f, "{e}"),
Self::Io(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for AppRootError {}
impl AppRootError {
pub fn link_not_created(&self) -> Option<&LinkNotCreated> {
match self {
Self::Io(e) => e.get_ref()?.downcast_ref::<LinkNotCreated>(),
_ => None,
}
}
pub fn is_retryable(&self) -> bool {
self.link_not_created().is_some() || matches!(self, Self::WriterBusy(_))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct LinkNotCreated {
pub link: PathBuf,
pub target: PathBuf,
pub resolved: PathBuf,
pub reason: String,
}
impl fmt::Display for LinkNotCreated {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"cannot create symlink {} -> {} in the app root: {}",
self.link.display(),
self.target.display(),
self.reason
)
}
}
impl std::error::Error for LinkNotCreated {}
impl From<io::Error> for AppRootError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
impl From<SuiteError> for AppRootError {
fn from(e: SuiteError) -> Self {
Self::Suite(e)
}
}
pub struct WriterLock {
_file: File,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MigrationOutcome {
Migrated { files: u64, bytes: u64 },
AlreadyMigrated,
NothingToMigrate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MigrationState {
Ready,
ReadyWithOmissions,
Incomplete,
}
impl MigrationState {
pub fn as_str(self) -> &'static str {
match self {
Self::Ready => "ready",
Self::ReadyWithOmissions => "ready_with_omissions",
Self::Incomplete => "incomplete",
}
}
pub fn is_usable(self) -> bool {
!matches!(self, Self::Incomplete)
}
fn parse(s: &str) -> Option<Self> {
match s {
"ready" => Some(Self::Ready),
"ready_with_omissions" => Some(Self::ReadyWithOmissions),
"incomplete" => Some(Self::Incomplete),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum MigrationScope {
All,
Roots(Vec<PathBuf>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct MigrationStatus {
state: MigrationState,
scope: MigrationScope,
}
impl MigrationStatus {
pub fn state(&self) -> MigrationState {
self.state
}
pub fn scope(&self) -> &MigrationScope {
&self.scope
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct MigrationReport {
outcome: MigrationOutcome,
migrated_links: Vec<MigratedLink>,
skipped_specials: Vec<SkippedSpecial>,
omitted_optional_links: Vec<OmittedLink>,
skipped_reparse_points: Vec<SkippedReparsePoint>,
state: MigrationState,
}
impl MigrationReport {
fn bare(outcome: MigrationOutcome, state: MigrationState) -> Self {
Self {
outcome,
migrated_links: Vec::new(),
skipped_specials: Vec::new(),
omitted_optional_links: Vec::new(),
skipped_reparse_points: Vec::new(),
state,
}
}
pub fn state(&self) -> MigrationState {
self.state
}
pub fn skipped_reparse_points(&self) -> &[SkippedReparsePoint] {
&self.skipped_reparse_points
}
pub fn outcome(&self) -> &MigrationOutcome {
&self.outcome
}
pub fn into_outcome(self) -> MigrationOutcome {
self.outcome
}
pub fn migrated_links(&self) -> &[MigratedLink] {
&self.migrated_links
}
pub fn skipped_specials(&self) -> &[SkippedSpecial] {
&self.skipped_specials
}
pub fn omitted_optional_links(&self) -> &[OmittedLink] {
&self.omitted_optional_links
}
pub fn complete(&self) -> bool {
self.state == MigrationState::Ready
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigratedLink {
pub path: PathBuf,
pub original_target: PathBuf,
pub written_target: PathBuf,
pub rewritten: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpecialKind {
Socket,
Fifo,
}
impl SpecialKind {
fn as_str(self) -> &'static str {
match self {
Self::Socket => "socket",
Self::Fifo => "fifo",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkippedSpecial {
pub path: PathBuf,
pub kind: SpecialKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReparseKind {
Junction,
Other,
}
impl ReparseKind {
fn as_str(self) -> &'static str {
match self {
Self::Junction => "junction",
Self::Other => "other",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SkippedReparsePoint {
pub path: PathBuf,
pub kind: ReparseKind,
pub tag: u32,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OmittedLink {
pub path: PathBuf,
pub original_target: PathBuf,
pub resolved_target: PathBuf,
pub reason: String,
}
type OptionalLinkPolicy = std::sync::Arc<dyn Fn(&Path) -> bool + Send + Sync>;
#[derive(Clone, Default)]
#[non_exhaustive]
pub struct MigrateOptions {
links: bool,
ephemeral_specials: bool,
reparse_points: bool,
optional_link: Option<OptionalLinkPolicy>,
excluded: Vec<PathBuf>,
pub(crate) included: Option<Vec<PathBuf>>,
}
impl fmt::Debug for MigrateOptions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MigrateOptions")
.field("links", &self.links)
.field("ephemeral_specials", &self.ephemeral_specials)
.field("reparse_points", &self.reparse_points)
.field("optional_link_policy", &self.optional_link.is_some())
.field("excluded", &self.excluded)
.field("included", &self.included)
.finish()
}
}
impl MigrateOptions {
pub fn include_roots(mut self, roots: Vec<PathBuf>) -> Result<Self, AppRootError> {
self.included = Some(normalize_roots(roots)?);
Ok(self)
}
fn selection(&self, rel: &Path) -> Result<bool, AppRootError> {
let Some(roots) = &self.included else {
return Ok(true);
};
for root in roots {
if rel.starts_with(root) || root.starts_with(rel) {
return Ok(true);
}
let a: Vec<_> = rel.components().collect();
let b: Vec<_> = root.components().collect();
if a.iter()
.zip(&b)
.all(|(x, y)| folded_component(*x) == folded_component(*y))
{
return Err(AppRootError::UnsupportedSource(rel.to_path_buf()));
}
}
Ok(false)
}
fn scope_state(&self, mut body: serde_json::Value) -> serde_json::Value {
if let Some(roots) = &self.included {
body["version"] = serde_json::json!(SELECTED_STATE_VERSION);
body["selected_roots"] = serde_json::json!(roots);
}
body
}
pub fn strict() -> Self {
Self::default()
}
pub fn preserve_links(mut self, on: bool) -> Self {
self.links = on;
self
}
pub fn skip_ephemeral_specials(mut self, on: bool) -> Self {
self.ephemeral_specials = on;
self
}
pub fn skip_reparse_points(mut self, on: bool) -> Self {
self.reparse_points = on;
self
}
pub fn optional_links(
mut self,
is_optional: impl Fn(&Path) -> bool + Send + Sync + 'static,
) -> Self {
self.optional_link = Some(std::sync::Arc::new(is_optional));
self
}
pub fn exclude(mut self, rel: impl Into<PathBuf>) -> Self {
let rel = rel.into();
if rel.components().next().is_some()
&& rel.components().all(|c| matches!(c, Component::Normal(_)))
{
self.excluded.push(rel);
}
self
}
pub fn links(&self) -> bool {
self.links
}
pub fn ephemeral_specials(&self) -> bool {
self.ephemeral_specials
}
pub fn reparse_points(&self) -> bool {
self.reparse_points
}
fn is_strict(&self) -> bool {
!self.links
&& !self.ephemeral_specials
&& !self.reparse_points
&& self.optional_link.is_none()
}
fn is_optional(&self, rel: &Path) -> bool {
self.optional_link.as_ref().is_some_and(|f| f(rel))
}
fn is_excluded(&self, rel: &Path) -> Result<bool, AppRootError> {
for ex in &self.excluded {
if rel.starts_with(ex) {
return Ok(true);
}
let mut have = rel.components();
let ambiguous = ex.components().all(|want| match (want, have.next()) {
(Component::Normal(w), Some(Component::Normal(h))) => {
w.to_string_lossy().to_lowercase() == h.to_string_lossy().to_lowercase()
}
_ => false,
});
if ambiguous {
return Err(AppRootError::UnsupportedSource(rel.to_path_buf()));
}
}
Ok(false)
}
}
pub(crate) fn normalize_roots(mut roots: Vec<PathBuf>) -> Result<Vec<PathBuf>, AppRootError> {
for root in &roots {
if root.as_os_str().is_empty()
|| root.to_str().is_none()
|| !root.components().all(|c| matches!(c, Component::Normal(_)))
|| root
.components()
.any(|c| c.as_os_str().to_string_lossy().contains(['\\', ':', '\0']))
|| root
.to_string_lossy()
.split(['/', '\\'])
.any(|part| part.is_empty() || part == "." || part == "..")
|| [MIGRATION_STATE, MIGRATION_MARKER, WRITER_LOCK]
.iter()
.any(|reserved| roots_overlap(root, Path::new(reserved)))
{
return Err(AppRootError::PathEscape(root.display().to_string()));
}
}
roots.sort();
roots.dedup();
for (i, root) in roots.iter().enumerate() {
for other in &roots[..i] {
let a: Vec<_> = root.components().collect();
let b: Vec<_> = other.components().collect();
for (x, y) in a.iter().zip(&b) {
if x == y {
continue;
}
if folded_component(*x) == folded_component(*y) {
return Err(AppRootError::PathEscape(root.display().to_string()));
}
break;
}
}
}
let all = roots.clone();
roots.retain(|root| {
!all.iter()
.any(|other| root != other && root.starts_with(other))
});
Ok(roots)
}
fn folded_component(component: Component<'_>) -> String {
component.as_os_str().to_string_lossy().to_lowercase()
}
pub(crate) fn roots_overlap(a: &Path, b: &Path) -> bool {
a.components()
.zip(b.components())
.all(|(x, y)| folded_component(x) == folded_component(y))
}
#[derive(Debug, Clone)]
pub struct AppRoot {
name: String,
root: PathBuf,
}
impl Suite {
pub fn app_root(&self, app: &str) -> Result<AppRoot, AppRootError> {
AppRoot::new(self, app)
}
}
impl AppRoot {
pub fn new(suite: &Suite, app: &str) -> Result<AppRoot, AppRootError> {
if !valid_app_name(app) {
return Err(AppRootError::InvalidName(app.to_string()));
}
let parent = suite.app_parent();
if let Ok(rd) = fs::read_dir(&parent) {
for e in rd.flatten() {
let n = e.file_name().to_string_lossy().into_owned();
if n != app && n.eq_ignore_ascii_case(app) {
return Err(AppRootError::CaseConflict {
requested: app.to_string(),
existing: n,
});
}
}
}
let root = parent.join(app);
make_private_dir(&root)?;
Ok(AppRoot {
name: app.to_string(),
root,
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn path(&self, rel: impl AsRef<Path>) -> Result<PathBuf, AppRootError> {
self.path_checked(rel.as_ref(), true)
}
fn path_checked(&self, rel: &Path, follow_leaf: bool) -> Result<PathBuf, AppRootError> {
let bad = || AppRootError::PathEscape(rel.display().to_string());
let mut any = false;
for c in rel.components() {
match c {
Component::Normal(n) => {
any = true;
if n.to_string_lossy().contains(['\\', ':', '\0']) {
return Err(bad());
}
}
_ => return Err(bad()),
}
}
if !any {
return Err(bad());
}
let full = self.root.join(rel);
let canon_root = fs::canonicalize(&self.root)?;
let mut probe = if follow_leaf {
full.as_path()
} else {
full.parent().unwrap_or(&self.root)
};
loop {
match fs::canonicalize(probe) {
Ok(c) => {
if !c.starts_with(&canon_root) {
return Err(bad());
}
break;
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
if fs::symlink_metadata(probe).is_ok() {
return Err(bad());
}
match probe.parent() {
Some(p) if p.starts_with(&self.root) => probe = p,
_ => return Err(bad()),
}
}
Err(e) => return Err(e.into()),
}
}
Ok(full)
}
pub fn data_dir(&self, rel: impl AsRef<Path>) -> Result<PathBuf, AppRootError> {
let d = self.path(rel)?;
make_private_dir(&d)?;
self.path(d.strip_prefix(&self.root).unwrap_or(&d))?;
Ok(d)
}
pub fn lock_writer(&self) -> Result<WriterLock, AppRootError> {
let p = self.root.join(WRITER_LOCK);
match try_lock_exclusive(&p)? {
Some(f) => Ok(WriterLock { _file: f }),
None => Err(AppRootError::WriterBusy(p)),
}
}
pub fn migrate_from(&self, src: &Path) -> Result<MigrationOutcome, AppRootError> {
Ok(self
.migrate_from_with(src, MigrateOptions::strict())?
.into_outcome())
}
pub fn migrate_from_with(
&self,
src: &Path,
opts: MigrateOptions,
) -> Result<MigrationReport, AppRootError> {
self.migrate_impl(src, &opts, &make_link)
}
pub fn migration_state(&self, src: &Path) -> Result<Option<MigrationState>, AppRootError> {
Ok(self.migration_status(src)?.map(|status| status.state()))
}
pub fn migration_status(&self, src: &Path) -> Result<Option<MigrationStatus>, AppRootError> {
if !src.is_absolute() || src.components().any(|c| c == Component::ParentDir) {
return Err(AppRootError::PathEscape(src.display().to_string()));
}
let csrc = match fs::canonicalize(src) {
Ok(c) => c,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
let invalid = |detail: &str| {
AppRootError::Io(io::Error::new(
io::ErrorKind::InvalidData,
detail.to_string(),
))
};
let valid_source = |source: &str| {
let path = Path::new(source);
path.is_absolute() && !path.components().any(|part| part == Component::ParentDir)
};
let read = |path: &Path| -> Result<Option<serde_json::Value>, AppRootError> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
serde_json::from_str(&text)
.map(Some)
.map_err(|_| invalid("invalid migration metadata JSON"))
};
if let Some(value) = read(&self.root.join(MIGRATION_STATE))? {
let source = value
.get("source")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| invalid("missing migration source"))?;
if !valid_source(source) {
return Err(invalid("invalid migration source"));
}
let state = value
.get("state")
.and_then(serde_json::Value::as_str)
.and_then(MigrationState::parse)
.ok_or_else(|| invalid("unknown migration state"))?;
let scope = match value.get("version").and_then(serde_json::Value::as_u64) {
Some(1) | Some(STATE_VERSION) if value.get("selected_roots").is_none() => {
MigrationScope::All
}
Some(SELECTED_STATE_VERSION) => {
let roots: Vec<PathBuf> = serde_json::from_value(
value
.get("selected_roots")
.cloned()
.ok_or_else(|| invalid("missing selected scope"))?,
)
.map_err(|_| invalid("invalid selected scope"))?;
if normalize_roots(roots.clone())
.map_err(|_| invalid("invalid selected scope"))?
!= roots
{
return Err(invalid("noncanonical selected scope"));
}
MigrationScope::Roots(roots)
}
_ => return Err(invalid("unknown migration version or scope")),
};
return Ok((Some(source) == csrc.to_str()).then_some(MigrationStatus { state, scope }));
}
let Some(marker) = read(&self.root.join(MIGRATION_MARKER))? else {
return Ok(None);
};
let source = marker
.get("source")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| invalid("missing historical migration source"))?;
if !valid_source(source) {
return Err(invalid("invalid historical migration source"));
}
let complete = match marker.get("complete") {
None => true,
Some(value) => value
.as_bool()
.ok_or_else(|| invalid("invalid historical completion flag"))?,
};
Ok((Some(source) == csrc.to_str()).then_some(MigrationStatus {
state: if complete {
MigrationState::Ready
} else {
MigrationState::ReadyWithOmissions
},
scope: MigrationScope::All,
}))
}
fn read_state(
&self,
csrc: &Path,
) -> Result<Option<(MigrationState, serde_json::Value)>, AppRootError> {
let path = self.root.join(MIGRATION_STATE);
let text = match fs::read_to_string(&path) {
Ok(t) => t,
Err(e)
if matches!(
e.kind(),
io::ErrorKind::NotFound | io::ErrorKind::InvalidData
) =>
{
return Ok(None)
}
Err(e) => return Err(e.into()),
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
return Ok(None);
};
let Some(version) = v.get("version").and_then(|n| n.as_u64()) else {
return Ok(None);
};
if v.get("source").and_then(|s| s.as_str()) != csrc.to_str() {
return Ok(None);
}
let state = v
.get("state")
.and_then(|s| s.as_str())
.and_then(MigrationState::parse);
match state {
Some(state) if version <= SELECTED_STATE_VERSION => {
if version < SELECTED_STATE_VERSION && v.get("selected_roots").is_some() {
return Err(AppRootError::PathEscape("legacy state cannot carry selected scope".into()));
}
if version == SELECTED_STATE_VERSION {
let roots: Vec<PathBuf> = serde_json::from_value(v.get("selected_roots").cloned().ok_or_else(|| AppRootError::PathEscape("missing selected scope".into()))?)
.map_err(|_| AppRootError::PathEscape("invalid selected scope".into()))?;
if normalize_roots(roots.clone())? != roots {
return Err(AppRootError::PathEscape("noncanonical selected scope".into()));
}
}
Ok(Some((state, v)))
},
_ => Err(AppRootError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{} was written by a newer version or holds an unknown state; refusing to guess",
path.display()
),
))),
}
}
fn legacy_marker(&self, csrc: &Path) -> Option<bool> {
let text = fs::read_to_string(self.root.join(MIGRATION_MARKER)).ok()?;
let v = serde_json::from_str::<serde_json::Value>(&text).ok()?;
if v.get("source").and_then(|s| s.as_str()) != csrc.to_str() {
return None;
}
Some(v.get("complete").and_then(|c| c.as_bool()).unwrap_or(true))
}
fn write_state(&self, body: &serde_json::Value) -> Result<(), AppRootError> {
write_json_atomic(&self.root.join(MIGRATION_STATE), body)?;
Ok(())
}
fn migrate_impl(
&self,
src: &Path,
opts: &MigrateOptions,
maker: LinkMaker<'_>,
) -> Result<MigrationReport, AppRootError> {
self.migrate_current(src, opts, maker, false)
}
pub(crate) fn migrate_for_cutover(
&self,
src: &Path,
opts: &MigrateOptions,
) -> Result<MigrationReport, AppRootError> {
self.migrate_current(src, opts, &make_link, true)
}
fn migrate_current(
&self,
src: &Path,
opts: &MigrateOptions,
maker: LinkMaker<'_>,
revalidate: bool,
) -> Result<MigrationReport, AppRootError> {
if !src.is_absolute() || src.components().any(|c| c == Component::ParentDir) {
return Err(AppRootError::PathEscape(src.display().to_string()));
}
let md = match fs::symlink_metadata(src) {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Ok(MigrationReport::bare(
MigrationOutcome::NothingToMigrate,
MigrationState::Ready,
))
}
Err(e) => return Err(e.into()),
};
if !md.is_dir() {
return Err(AppRootError::UnsupportedSource(src.to_path_buf()));
}
let csrc = fs::canonicalize(src)?;
let cdst = fs::canonicalize(&self.root)?;
if csrc.starts_with(&cdst) || cdst.starts_with(&csrc) {
return Err(AppRootError::Overlap(src.to_path_buf()));
}
let _lock = self.lock_writer()?;
let prior = self.read_state(&csrc)?;
let prior_selected = prior
.as_ref()
.is_some_and(|(_, state)| state.get("selected_roots").is_some());
let mut effective = opts.clone();
if let (Some(roots), Some((_, state))) = (&mut effective.included, &prior) {
if let Some(previous) = state.get("selected_roots") {
let previous: Vec<PathBuf> = serde_json::from_value(previous.clone())
.map_err(|_| AppRootError::PathEscape("invalid selected scope".into()))?;
roots.extend(previous);
*roots = normalize_roots(std::mem::take(roots))?;
}
}
let opts = &effective;
let revalidate = revalidate || opts.included.is_some() || prior_selected;
match prior {
Some((MigrationState::Ready, state)) if !revalidate => {
self.repair_marker(&csrc, &state);
return Ok(MigrationReport::bare(
MigrationOutcome::AlreadyMigrated,
MigrationState::Ready,
));
}
Some((MigrationState::ReadyWithOmissions, state)) if !revalidate => {
return self.retry_unresolved(&csrc, &state, maker);
}
Some(_) => {}
None => {
if let Some(complete) = self.legacy_marker(&csrc).filter(|_| !revalidate) {
let state = if complete {
MigrationState::Ready
} else {
MigrationState::ReadyWithOmissions
};
return Ok(MigrationReport::bare(
MigrationOutcome::AlreadyMigrated,
state,
));
}
}
}
let mut entries = Vec::new();
collect(&csrc, Path::new(""), opts, &mut entries)?;
if revalidate {
let current: std::collections::BTreeSet<_> = entries.iter().map(Entry::rel).collect();
let mut destination = Vec::new();
let destination_opts = opts.clone().exclude(WRITER_LOCK);
collect(&cdst, Path::new(""), &destination_opts, &mut destination)?;
for entry in &destination {
if !current.contains(entry.rel()) {
return Err(AppRootError::Collision(cdst.join(entry.rel())));
}
}
}
self.write_state(&opts.scope_state(state_json(
MigrationState::Incomplete,
csrc.to_str(),
&Lists::default(),
None,
)))?;
match self.copy_entries(&csrc, &entries, opts, maker) {
Ok(done) => self.commit(&csrc, opts, done),
Err(e) => {
self.record_failure(&csrc, opts, &e);
Err(e)
}
}
}
fn copy_entries(
&self,
csrc: &Path,
entries: &[Entry],
opts: &MigrateOptions,
maker: LinkMaker<'_>,
) -> Result<Lists, AppRootError> {
let mut done = Lists::default();
let mut created: Vec<(PathBuf, PathBuf, bool)> = Vec::new();
for entry in entries {
match entry {
Entry::Dir { rel } => {
let to = self.path_checked(rel, false)?;
match fs::symlink_metadata(&to) {
Ok(m) if m.file_type().is_dir() => make_private_dir(&to)?,
Ok(_) => return Err(AppRootError::Collision(to)),
Err(e) if e.kind() == io::ErrorKind::NotFound => make_private_dir(&to)?,
Err(e) => return Err(e.into()),
}
}
Entry::File { rel, len } => {
done.files += 1;
done.bytes += len;
let from = csrc.join(rel);
let to = self.path(rel)?;
if let Some(parent) = to.parent() {
make_private_dir(parent)?;
}
match fs::symlink_metadata(&to) {
Ok(m) if regular_entry(&m) && same_content(&from, &to)? => {
continue;
}
Ok(_) => return Err(AppRootError::Collision(to)),
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
let mut writer =
AtomicWriter::create_new_private(&to).map_err(io::Error::other)?;
let mut source =
rightkit_fs::open_read_nofollow(&from, &rightkit_fs::ReadOptions::new())
.map_err(io::Error::other)?;
io::copy(&mut source, writer.file_mut())?;
commit_migration_file(writer, &from, &to)?;
}
Entry::Link { rel, target, dir } => {
let (written, rewritten) = plan_link(csrc, rel, target);
let to = self.path_checked(rel, false)?;
if let Some(parent) = to.parent() {
make_private_dir(parent)?;
}
match fs::symlink_metadata(&to) {
Ok(m) => {
if !(m.file_type().is_symlink()
&& same_link_target(&fs::read_link(&to)?, &written))
{
return Err(AppRootError::Collision(to));
}
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
if let Err(e) = maker(&written, &to, *dir) {
if !link_unavailable(&e) {
return Err(e.into());
}
if opts.is_optional(rel) {
done.omitted.push(OmittedLink {
path: rel.clone(),
original_target: target.clone(),
resolved_target: written,
reason: e.to_string(),
});
continue;
}
return Err(AppRootError::Io(io::Error::other(LinkNotCreated {
link: rel.clone(),
target: target.clone(),
resolved: written,
reason: e.to_string(),
})));
}
}
Err(e) => return Err(e.into()),
}
created.push((rel.clone(), written.clone(), *dir));
done.links.push(MigratedLink {
path: rel.clone(),
original_target: target.clone(),
written_target: written,
rewritten,
});
}
Entry::Special { rel, kind } => done.specials.push(SkippedSpecial {
path: rel.clone(),
kind: *kind,
}),
Entry::Reparse { rel, kind, tag } => done.reparse.push(SkippedReparsePoint {
path: rel.clone(),
kind: *kind,
tag: *tag,
reason: reparse_reason(*kind, *tag),
}),
}
}
for entry in entries {
match entry {
Entry::File { rel, .. } => {
let to = self.path(rel)?;
if !same_content(&csrc.join(rel), &to)? {
return Err(AppRootError::VerifyFailed(to));
}
}
Entry::Dir { rel } => {
let to = self.path_checked(rel, false)?;
if !fs::symlink_metadata(&to).is_ok_and(|m| m.file_type().is_dir()) {
return Err(AppRootError::VerifyFailed(to));
}
}
_ => {}
}
}
for (rel, written, dir) in &created {
let to = self.path_checked(rel, false)?;
if !link_ok(&to, written, *dir) {
return Err(AppRootError::VerifyFailed(to));
}
}
Ok(done)
}
fn commit(
&self,
csrc: &Path,
opts: &MigrateOptions,
done: Lists,
) -> Result<MigrationReport, AppRootError> {
let state = done.state();
self.write_state(&opts.scope_state(state_json(state, csrc.to_str(), &done, None)))?;
if state == MigrationState::Ready && opts.included.is_none() {
let body = marker_json(
csrc.to_str(),
done.files,
done.bytes,
opts,
&done.links,
&done.specials,
&done.omitted,
);
write_json_atomic(&self.root.join(MIGRATION_MARKER), &body)?;
}
Ok(MigrationReport {
outcome: MigrationOutcome::Migrated {
files: done.files,
bytes: done.bytes,
},
migrated_links: done.links,
skipped_specials: done.specials,
omitted_optional_links: done.omitted,
skipped_reparse_points: done.reparse,
state,
})
}
fn record_failure(&self, csrc: &Path, opts: &MigrateOptions, e: &AppRootError) {
let mut body = opts.scope_state(state_json(
MigrationState::Incomplete,
csrc.to_str(),
&Lists::default(),
Some(&e.to_string()),
));
if let (Some(l), Some(obj)) = (e.link_not_created(), body.as_object_mut()) {
obj.insert(
"unresolved".into(),
serde_json::json!([{"path": lossy(&l.link), "kind": "link", "reason": l.reason}]),
);
}
let _ = self.write_state(&body);
}
fn repair_marker(&self, csrc: &Path, state: &serde_json::Value) {
let path = self.root.join(MIGRATION_MARKER);
if path.exists() {
return;
}
let l = Lists::from_state(state);
let opts = MigrateOptions::strict().preserve_links(true);
let body = marker_json(
csrc.to_str(),
l.files,
l.bytes,
&opts,
&l.links,
&l.specials,
&l.omitted,
);
let _ = write_json_atomic(&path, &body);
}
fn retry_unresolved(
&self,
csrc: &Path,
state: &serde_json::Value,
maker: LinkMaker<'_>,
) -> Result<MigrationReport, AppRootError> {
let prior = Lists::from_state(state);
let mut next = Lists {
files: prior.files,
bytes: prior.bytes,
links: prior.links,
specials: prior.specials,
omitted: Vec::new(),
reparse: prior.reparse,
};
let mut created = Vec::new();
for o in prior.omitted {
match self.retry_link(csrc, &o, maker)? {
Ok(link) => {
created.push(link.clone());
next.links.push(link);
}
Err(reason) => next.omitted.push(OmittedLink { reason, ..o }),
}
}
let now = next.state();
if !created.is_empty() || now == MigrationState::Ready {
self.write_state(&state_json(now, csrc.to_str(), &next, None))?;
if now == MigrationState::Ready {
let opts = MigrateOptions::strict().preserve_links(true);
let body = marker_json(
csrc.to_str(),
next.files,
next.bytes,
&opts,
&next.links,
&next.specials,
&next.omitted,
);
write_json_atomic(&self.root.join(MIGRATION_MARKER), &body)?;
}
}
Ok(MigrationReport {
outcome: MigrationOutcome::AlreadyMigrated,
migrated_links: created,
skipped_specials: Vec::new(),
omitted_optional_links: next.omitted,
skipped_reparse_points: next.reparse,
state: now,
})
}
fn retry_link(
&self,
csrc: &Path,
o: &OmittedLink,
maker: LinkMaker<'_>,
) -> Result<Result<MigratedLink, String>, AppRootError> {
let from = csrc.join(&o.path);
let md = match fs::symlink_metadata(&from) {
Ok(m) if m.file_type().is_symlink() => m,
Ok(_) => return Ok(Err("the source entry is no longer a link".to_string())),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Ok(Err("the source link no longer exists".to_string()))
}
Err(e) => return Err(e.into()),
};
let target = fs::read_link(&from)?;
let dir = link_is_dir(&md.file_type());
let (written, rewritten) = plan_link(csrc, &o.path, &target);
let to = self.path_checked(&o.path, false)?;
match fs::symlink_metadata(&to) {
Ok(m) => {
let same = m.file_type().is_symlink()
&& fs::read_link(&to).is_ok_and(|t| same_link_target(&t, &written));
if !same {
return Ok(Err("the destination path is occupied".to_string()));
}
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
if let Some(parent) = to.parent() {
make_private_dir(parent)?;
}
if let Err(e) = maker(&written, &to, dir) {
if link_unavailable(&e) {
return Ok(Err(e.to_string()));
}
return Err(e.into());
}
}
Err(e) => return Err(e.into()),
}
if !link_ok(&to, &written, dir) {
return Err(AppRootError::VerifyFailed(to));
}
Ok(Ok(MigratedLink {
path: o.path.clone(),
original_target: target,
written_target: written,
rewritten,
}))
}
}
type LinkMaker<'a> = &'a dyn Fn(&Path, &Path, bool) -> io::Result<()>;
enum Entry {
Dir {
rel: PathBuf,
},
File {
rel: PathBuf,
len: u64,
},
Link {
rel: PathBuf,
target: PathBuf,
dir: bool,
},
Special {
rel: PathBuf,
kind: SpecialKind,
},
#[cfg_attr(not(windows), allow(dead_code))]
Reparse {
rel: PathBuf,
kind: ReparseKind,
tag: u32,
},
}
impl Entry {
fn rel(&self) -> &Path {
match self {
Self::Dir { rel }
| Self::File { rel, .. }
| Self::Link { rel, .. }
| Self::Special { rel, .. }
| Self::Reparse { rel, .. } => rel,
}
}
}
#[derive(Default)]
struct Lists {
files: u64,
bytes: u64,
links: Vec<MigratedLink>,
specials: Vec<SkippedSpecial>,
omitted: Vec<OmittedLink>,
reparse: Vec<SkippedReparsePoint>,
}
impl Lists {
fn state(&self) -> MigrationState {
if self.omitted.is_empty() && self.reparse.is_empty() {
MigrationState::Ready
} else {
MigrationState::ReadyWithOmissions
}
}
fn from_state(v: &serde_json::Value) -> Self {
let text =
|o: &serde_json::Value, k: &str| o.get(k).and_then(|s| s.as_str()).map(PathBuf::from);
let items = |k: &str| {
v.get(k)
.and_then(|a| a.as_array())
.cloned()
.unwrap_or_default()
};
Self {
files: v.get("files").and_then(|n| n.as_u64()).unwrap_or(0),
bytes: v.get("bytes").and_then(|n| n.as_u64()).unwrap_or(0),
links: items("links")
.iter()
.filter_map(|o| {
Some(MigratedLink {
path: text(o, "path")?,
original_target: text(o, "original_target")?,
written_target: text(o, "written_target")?,
rewritten: o
.get("rewritten")
.and_then(|b| b.as_bool())
.unwrap_or(false),
})
})
.collect(),
specials: items("skipped_specials")
.iter()
.filter_map(|o| {
let kind = match o.get("kind").and_then(|k| k.as_str())? {
"socket" => SpecialKind::Socket,
"fifo" => SpecialKind::Fifo,
_ => return None,
};
Some(SkippedSpecial {
path: text(o, "path")?,
kind,
})
})
.collect(),
omitted: items("omitted_optional_links")
.iter()
.filter_map(|o| {
Some(OmittedLink {
path: text(o, "path")?,
original_target: text(o, "original_target")?,
resolved_target: text(o, "resolved_target")?,
reason: o.get("reason").and_then(|r| r.as_str())?.to_string(),
})
})
.collect(),
reparse: items("skipped_reparse_points")
.iter()
.filter_map(|o| {
let kind = match o.get("kind").and_then(|k| k.as_str())? {
"junction" => ReparseKind::Junction,
_ => ReparseKind::Other,
};
Some(SkippedReparsePoint {
path: text(o, "path")?,
kind,
tag: o.get("tag").and_then(|t| t.as_u64()).unwrap_or(0) as u32,
reason: o.get("reason").and_then(|r| r.as_str())?.to_string(),
})
})
.collect(),
}
}
}
fn is_reserved_root_name(name: &std::ffi::OsStr) -> bool {
let n = name.to_string_lossy();
n == MIGRATION_MARKER || n == MIGRATION_STATE
}
fn collect(
base: &Path,
rel: &Path,
opts: &MigrateOptions,
out: &mut Vec<Entry>,
) -> Result<(), AppRootError> {
let mut entries: Vec<_> = fs::read_dir(base.join(rel))?.collect::<Result<_, _>>()?;
entries.sort_by_key(|e| e.file_name());
for e in entries {
let name = e.file_name();
if rel.as_os_str().is_empty() && is_reserved_root_name(&name) {
continue;
}
let r = rel.join(&name);
if !opts.selection(&r)? || opts.is_excluded(&r)? {
continue;
}
#[cfg(windows)]
{
if let Some(kind) = reparse_of(&e, &base.join(&r))? {
if opts
.included
.as_ref()
.is_some_and(|roots| !roots.iter().any(|root| r.starts_with(root)))
{
return Err(AppRootError::UnsupportedSource(base.join(&r)));
}
match kind {
Reparse::Symlink => {}
Reparse::Socket => {
if opts.ephemeral_specials {
out.push(Entry::Special {
rel: r,
kind: SpecialKind::Socket,
});
continue;
}
return Err(AppRootError::UnsupportedSource(base.join(&r)));
}
Reparse::Junction | Reparse::Other(_) => {
if opts.reparse_points {
let (kind, tag) = match kind {
Reparse::Other(tag) => (ReparseKind::Other, tag),
_ => (ReparseKind::Junction, IO_REPARSE_TAG_MOUNT_POINT),
};
out.push(Entry::Reparse { rel: r, kind, tag });
continue;
}
return Err(AppRootError::UnsupportedSource(base.join(&r)));
}
}
}
}
let ft = e.file_type()?;
if opts
.included
.as_ref()
.is_some_and(|roots| !roots.iter().any(|root| r.starts_with(root)))
&& !ft.is_dir()
{
return Err(AppRootError::UnsupportedSource(base.join(&r)));
}
if ft.is_symlink() {
if !opts.links {
return Err(AppRootError::UnsupportedSource(base.join(&r)));
}
let target = fs::read_link(base.join(&r))?;
out.push(Entry::Link {
dir: link_is_dir(&ft),
rel: r,
target,
});
} else if ft.is_dir() {
out.push(Entry::Dir { rel: r.clone() });
collect(base, &r, opts, out)?;
} else if ft.is_file() {
out.push(Entry::File {
len: e.metadata()?.len(),
rel: r,
});
} else {
match special_kind(&ft) {
Some(kind) if opts.ephemeral_specials => out.push(Entry::Special { rel: r, kind }),
_ => return Err(AppRootError::UnsupportedSource(base.join(&r))),
}
}
}
Ok(())
}
#[cfg(windows)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Reparse {
Symlink,
Junction,
Socket,
Other(u32),
}
#[cfg(windows)]
const IO_REPARSE_TAG_SYMLINK: u32 = 0xA000_000C;
#[cfg(windows)]
const IO_REPARSE_TAG_MOUNT_POINT: u32 = 0xA000_0003;
#[cfg(windows)]
const IO_REPARSE_TAG_AF_UNIX: u32 = 0x8000_0023;
#[cfg(windows)]
fn classify_reparse_tag(tag: u32) -> Reparse {
match tag {
IO_REPARSE_TAG_SYMLINK => Reparse::Symlink,
IO_REPARSE_TAG_MOUNT_POINT => Reparse::Junction,
IO_REPARSE_TAG_AF_UNIX => Reparse::Socket,
other => Reparse::Other(other),
}
}
#[cfg(windows)]
fn reparse_of(e: &fs::DirEntry, full: &Path) -> io::Result<Option<Reparse>> {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
if e.metadata()?.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 {
return Ok(None);
}
Ok(Some(classify_reparse_tag(reparse_tag(full)?)))
}
#[cfg(windows)]
fn reparse_tag(path: &Path) -> io::Result<u32> {
use std::os::windows::ffi::OsStrExt;
use windows::core::PCWSTR;
use windows::Win32::Storage::FileSystem::{FindClose, FindFirstFileW, WIN32_FIND_DATAW};
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut data = WIN32_FIND_DATAW::default();
let handle =
unsafe { FindFirstFileW(PCWSTR(wide.as_ptr()), &mut data) }.map_err(io::Error::other)?;
let _ = unsafe { FindClose(handle) };
Ok(data.dwReserved0)
}
fn reparse_reason(kind: ReparseKind, tag: u32) -> String {
match kind {
ReparseKind::Junction => {
"junction or volume mount point: never followed, copied or recreated".to_string()
}
ReparseKind::Other => {
format!("reparse point (tag {tag:#010x}): never followed, opened, copied or recreated")
}
}
}
#[cfg(unix)]
fn special_kind(ft: &fs::FileType) -> Option<SpecialKind> {
use std::os::unix::fs::FileTypeExt;
if ft.is_socket() {
Some(SpecialKind::Socket)
} else if ft.is_fifo() {
Some(SpecialKind::Fifo)
} else {
None
}
}
#[cfg(not(unix))]
fn special_kind(_ft: &fs::FileType) -> Option<SpecialKind> {
None
}
#[cfg(windows)]
fn link_is_dir(ft: &fs::FileType) -> bool {
use std::os::windows::fs::FileTypeExt;
ft.is_symlink_dir()
}
#[cfg(not(windows))]
fn link_is_dir(_ft: &fs::FileType) -> bool {
false
}
#[cfg(windows)]
fn link_kind_matches(ft: &fs::FileType, dir: bool) -> bool {
use std::os::windows::fs::FileTypeExt;
ft.is_symlink_dir() == dir
}
#[cfg(not(windows))]
fn link_kind_matches(_ft: &fs::FileType, _dir: bool) -> bool {
true
}
fn plan_link(csrc: &Path, rel: &Path, target: &Path) -> (PathBuf, bool) {
if target
.components()
.any(|c| matches!(c, Component::Prefix(_) | Component::RootDir))
{
return (target.to_path_buf(), false);
}
let parent_rel = rel.parent().unwrap_or_else(|| Path::new(""));
let mut depth = parent_rel.components().count();
let mut inside = true;
let mut abs = csrc.to_path_buf();
if depth > 0 {
abs.push(parent_rel);
}
for c in target.components() {
match c {
Component::Normal(n) => {
abs.push(n);
depth += 1;
}
Component::ParentDir => {
if depth == 0 {
inside = false;
} else {
depth -= 1;
}
abs.pop();
}
_ => {}
}
}
if inside {
(target.to_path_buf(), false)
} else {
(strip_verbatim(abs), true)
}
}
#[cfg(windows)]
fn strip_verbatim(p: PathBuf) -> PathBuf {
let s = p.to_string_lossy().into_owned();
if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
PathBuf::from(format!(r"\\{rest}"))
} else if let Some(rest) = s.strip_prefix(r"\\?\") {
if rest.len() >= 2 && rest.as_bytes()[1] == b':' {
return PathBuf::from(rest);
}
p
} else {
p
}
}
#[cfg(not(windows))]
fn strip_verbatim(p: PathBuf) -> PathBuf {
p
}
fn same_link_target(a: &Path, b: &Path) -> bool {
if a == b {
return true;
}
#[cfg(windows)]
{
fn norm(p: &Path) -> String {
let s = p.to_string_lossy().replace('/', "\\");
let s = s
.strip_prefix(r"\\?\UNC\")
.map(|r| format!(r"\\{r}"))
.or_else(|| s.strip_prefix(r"\\?\").map(str::to_string))
.or_else(|| s.strip_prefix(r"\??\").map(str::to_string))
.unwrap_or(s);
s.to_lowercase()
}
norm(a) == norm(b)
}
#[cfg(not(windows))]
{
false
}
}
fn make_link(target: &Path, to: &Path, dir: bool) -> io::Result<()> {
#[cfg(unix)]
{
let _ = dir;
std::os::unix::fs::symlink(target, to)
}
#[cfg(windows)]
{
if dir {
std::os::windows::fs::symlink_dir(target, to)
} else {
std::os::windows::fs::symlink_file(target, to)
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = (target, to, dir);
Err(io::Error::from(io::ErrorKind::Unsupported))
}
}
fn link_unavailable(e: &io::Error) -> bool {
#[cfg(windows)]
{
e.kind() != io::ErrorKind::AlreadyExists
}
#[cfg(not(windows))]
{
matches!(
e.kind(),
io::ErrorKind::PermissionDenied | io::ErrorKind::Unsupported
)
}
}
fn lossy(p: &Path) -> String {
p.to_string_lossy().into_owned()
}
fn links_json(links: &[MigratedLink]) -> serde_json::Value {
serde_json::Value::Array(
links
.iter()
.map(|l| {
serde_json::json!({
"path": lossy(&l.path),
"original_target": lossy(&l.original_target),
"written_target": lossy(&l.written_target),
"rewritten": l.rewritten,
})
})
.collect(),
)
}
fn specials_json(specials: &[SkippedSpecial]) -> serde_json::Value {
serde_json::Value::Array(
specials
.iter()
.map(|s| serde_json::json!({"path": lossy(&s.path), "kind": s.kind.as_str()}))
.collect(),
)
}
fn omitted_json(omitted: &[OmittedLink]) -> serde_json::Value {
serde_json::Value::Array(
omitted
.iter()
.map(|o| {
serde_json::json!({
"path": lossy(&o.path),
"original_target": lossy(&o.original_target),
"resolved_target": lossy(&o.resolved_target),
"reason": o.reason,
})
})
.collect(),
)
}
fn reparse_json(reparse: &[SkippedReparsePoint]) -> serde_json::Value {
serde_json::Value::Array(
reparse
.iter()
.map(|r| {
serde_json::json!({
"path": lossy(&r.path),
"kind": r.kind.as_str(),
"tag": r.tag,
"reason": r.reason,
})
})
.collect(),
)
}
fn marker_json(
source: Option<&str>,
files: u64,
bytes: u64,
opts: &MigrateOptions,
links: &[MigratedLink],
specials: &[SkippedSpecial],
omitted: &[OmittedLink],
) -> serde_json::Value {
let mut body = serde_json::json!({
"source": source,
"files": files,
"bytes": bytes,
});
if opts.is_strict() {
return body;
}
let obj = body.as_object_mut().expect("object");
obj.insert("links".into(), links_json(links));
obj.insert("skipped_specials".into(), specials_json(specials));
obj.insert("omitted_optional_links".into(), omitted_json(omitted));
obj.insert(
"complete".into(),
serde_json::Value::Bool(omitted.is_empty()),
);
body
}
fn state_json(
state: MigrationState,
source: Option<&str>,
l: &Lists,
error: Option<&str>,
) -> serde_json::Value {
let mut unresolved: Vec<serde_json::Value> = l
.omitted
.iter()
.map(|o| serde_json::json!({"path": lossy(&o.path), "kind": "link", "reason": o.reason}))
.collect();
unresolved.extend(l.reparse.iter().map(|r| {
serde_json::json!({"path": lossy(&r.path), "kind": "reparse_point", "reason": r.reason})
}));
let mut body = serde_json::json!({
"version": STATE_VERSION,
"state": state.as_str(),
"source": source,
"files": l.files,
"bytes": l.bytes,
"links": links_json(&l.links),
"skipped_specials": specials_json(&l.specials),
"omitted_optional_links": omitted_json(&l.omitted),
"skipped_reparse_points": reparse_json(&l.reparse),
"unresolved": unresolved,
});
if let (Some(e), Some(obj)) = (error, body.as_object_mut()) {
obj.insert("error".into(), e.into());
}
body
}
fn commit_migration_file(
writer: AtomicWriter,
source: &Path,
target: &Path,
) -> Result<(), AppRootError> {
match writer.commit() {
Ok(()) => Ok(()),
Err(FsError::AlreadyExists(_)) => {
if regular_entry(&fs::symlink_metadata(target)?) && same_content(source, target)? {
Ok(())
} else {
Err(AppRootError::Collision(target.to_path_buf()))
}
}
Err(e) => Err(io::Error::other(e).into()),
}
}
fn regular_entry(metadata: &fs::Metadata) -> bool {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
if metadata.file_attributes() & 0x400 != 0 {
return false;
}
}
metadata.file_type().is_file()
}
fn validate_metadata_target(path: &Path) -> io::Result<bool> {
match fs::symlink_metadata(path) {
Ok(m) if regular_entry(&m) => Ok(true),
Ok(_) => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"migration metadata is not a regular file: {}",
path.display()
),
)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e),
}
}
pub(crate) fn write_json_atomic(path: &Path, body: &serde_json::Value) -> io::Result<()> {
let replacing = validate_metadata_target(path)?;
let mut writer = AtomicWriter::create_with(
path,
AtomicWriteOptions {
private: true,
replace: if replacing {
ReplaceMode::Rename
} else {
ReplaceMode::NoReplace
},
..Default::default()
},
)
.map_err(io::Error::other)?;
writer
.write_all(body.to_string().as_bytes())
.map_err(io::Error::other)?;
writer
.commit_checked(|_| validate_metadata_target(path).map(|_| ()))
.map_err(io::Error::other)
}
fn link_ok(to: &Path, written: &Path, dir: bool) -> bool {
match fs::symlink_metadata(to) {
Ok(m) => {
m.file_type().is_symlink()
&& fs::read_link(to).is_ok_and(|t| same_link_target(&t, written))
&& link_kind_matches(&m.file_type(), dir)
}
Err(_) => false,
}
}
pub(crate) fn same_content(a: &Path, b: &Path) -> Result<bool, AppRootError> {
let mut fa = rightkit_fs::open_read_nofollow(a, &rightkit_fs::ReadOptions::new())
.map_err(io::Error::other)?;
let mut fb = rightkit_fs::open_read_nofollow(b, &rightkit_fs::ReadOptions::new())
.map_err(io::Error::other)?;
if fa.metadata()?.len() != fb.metadata()?.len() {
return Ok(false);
}
let (mut ba, mut bb) = (vec![0u8; 64 * 1024], vec![0u8; 64 * 1024]);
loop {
let na = read_full(&mut fa, &mut ba)?;
let nb = read_full(&mut fb, &mut bb)?;
if na != nb || ba[..na] != bb[..nb] {
return Ok(false);
}
if na == 0 {
return Ok(true);
}
}
}
fn read_full(f: &mut File, buf: &mut [u8]) -> io::Result<usize> {
let mut n = 0;
while n < buf.len() {
match f.read(&mut buf[n..])? {
0 => break,
k => n += k,
}
}
Ok(n)
}
#[cfg(test)]
mod tests {
fn eventually<T, E>(mut f: impl FnMut() -> Result<T, E>) -> Result<T, E> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
match f() {
Ok(v) => return Ok(v),
Err(e) if std::time::Instant::now() >= deadline => return Err(e),
Err(_) => std::thread::sleep(std::time::Duration::from_millis(20)),
}
}
}
fn settled<T>(mut f: impl FnMut() -> Result<T, AppRootError>) -> Result<T, AppRootError> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
match f() {
Err(AppRootError::WriterBusy(_)) if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(20))
}
other => return other,
}
}
}
use super::*;
fn suite() -> (tempfile::TempDir, Suite) {
let d = tempfile::tempdir().unwrap();
let s = Suite::at(d.path().join("root")).unwrap();
(d, s)
}
#[test]
fn names_are_validated() {
for ok in ["HeardRight", "app.orthiclabs.genright", "a", "Cut-Right_2"] {
assert!(valid_app_name(ok), "{ok}");
}
let long = "a".repeat(MAX_APP_NAME_LEN + 1);
for bad in [
"", ".", "..", "a/b", "a\\b", "../x", "x..y", ".hidden", "trail.", "con", "NUL.txt",
"com1", "sp ace", "a:b", "é", "shared", "Shared", &long,
] {
assert!(!valid_app_name(bad), "{bad:?}");
}
assert!(valid_app_name(&"a".repeat(MAX_APP_NAME_LEN)));
let (_d, s) = suite();
assert!(matches!(
s.app_root("../x"),
Err(AppRootError::InvalidName(_))
));
}
#[test]
fn canonical_suite_gives_sibling_of_shared_and_override_stays_inside() {
let d = tempfile::tempdir().unwrap();
let canon = Suite::at(d.path().join("Orthic Labs").join("shared")).unwrap();
let a = canon.app_root("HeardRight").unwrap();
assert_eq!(a.root(), d.path().join("Orthic Labs").join("HeardRight"));
let (_d2, s) = suite();
let a = s.app_root("HeardRight").unwrap();
assert!(a.root().starts_with(s.root()));
assert!(a.root().is_dir());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(a.root()).unwrap().permissions().mode() & 0o077,
0
);
}
}
#[test]
fn case_conflicts_are_refused() {
let (_d, s) = suite();
s.app_root("HeardRight").unwrap();
assert!(matches!(
s.app_root("heardright"),
Err(AppRootError::CaseConflict { .. })
));
s.app_root("HeardRight").unwrap();
}
#[test]
fn subpath_traversal_is_refused() {
let (_d, s) = suite();
let a = s.app_root("App").unwrap();
for bad in ["", ".", "..", "../x", "a/../../x", "/etc", "a:b"] {
assert!(
matches!(a.path(bad), Err(AppRootError::PathEscape(_))),
"{bad:?}"
);
}
#[cfg(unix)]
assert!(matches!(a.path("a\\b"), Err(AppRootError::PathEscape(_))));
let d = a.data_dir("logs/today").unwrap();
assert!(d.is_dir() && d.starts_with(a.root()));
}
#[cfg(unix)]
#[test]
fn symlink_escape_is_refused() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let outside = d.path().join("outside");
fs::create_dir(&outside).unwrap();
std::os::unix::fs::symlink(&outside, a.root().join("link")).unwrap();
assert!(matches!(a.path("link/x"), Err(AppRootError::PathEscape(_))));
assert!(matches!(
a.data_dir("link/x"),
Err(AppRootError::PathEscape(_))
));
std::os::unix::fs::symlink(d.path().join("nowhere"), a.root().join("dangling")).unwrap();
assert!(matches!(
a.path("dangling/x"),
Err(AppRootError::PathEscape(_))
));
assert!(!outside.join("x").exists());
}
#[test]
fn writer_lock_is_single_holder() {
let (_d, s) = suite();
let a = s.app_root("App").unwrap();
let l = a.lock_writer().unwrap();
assert!(matches!(a.lock_writer(), Err(AppRootError::WriterBusy(_))));
drop(l);
eventually(|| a.lock_writer()).unwrap();
}
fn old_tree(d: &Path) -> PathBuf {
let old = d.join("old");
fs::create_dir_all(old.join("sub")).unwrap();
fs::write(old.join("a.txt"), b"alpha").unwrap();
fs::write(old.join("sub/b.bin"), vec![7u8; 200_000]).unwrap();
old
}
#[test]
fn migration_copies_verifies_commits_and_is_idempotent() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
assert_eq!(
settled(|| a.migrate_from(&old)).unwrap(),
MigrationOutcome::Migrated {
files: 2,
bytes: 200_005
}
);
assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"alpha");
assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
assert!(a.root().join(MIGRATION_MARKER).is_file());
assert!(old.join("a.txt").is_file(), "old root is left in place");
assert_eq!(
settled(|| a.migrate_from(&old)).unwrap(),
MigrationOutcome::AlreadyMigrated
);
assert_eq!(
a.migrate_from(&d.path().join("missing")).unwrap(),
MigrationOutcome::NothingToMigrate
);
}
#[test]
fn interrupted_migration_resumes_without_marker() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
fs::copy(old.join("a.txt"), a.root().join("a.txt")).unwrap();
fs::create_dir_all(a.root().join("sub")).unwrap();
fs::write(a.root().join(format!("sub/b.bin{TMP_SUFFIX}")), b"par").unwrap();
assert!(!a.root().join(MIGRATION_MARKER).exists());
assert!(matches!(
settled(|| a.migrate_from(&old)).unwrap(),
MigrationOutcome::Migrated { files: 2, .. }
));
assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
}
#[test]
fn migration_never_opens_legacy_temps_hardlinked_to_source() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
for name in ["a.txt", MIGRATION_STATE, MIGRATION_MARKER] {
fs::hard_link(
old.join("a.txt"),
a.root().join(format!("{name}{TMP_SUFFIX}")),
)
.unwrap();
}
settled(|| a.migrate_from(&old)).unwrap();
assert_eq!(fs::read(old.join("a.txt")).unwrap(), b"alpha");
assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"alpha");
for name in ["a.txt", MIGRATION_STATE, MIGRATION_MARKER] {
assert_eq!(
fs::read(a.root().join(format!("{name}{TMP_SUFFIX}"))).unwrap(),
b"alpha"
);
}
}
#[cfg(unix)]
#[test]
fn migration_never_follows_legacy_temp_symlinks_into_source() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
for name in ["a.txt", MIGRATION_STATE, MIGRATION_MARKER] {
std::os::unix::fs::symlink(
old.join("a.txt"),
a.root().join(format!("{name}{TMP_SUFFIX}")),
)
.unwrap();
}
settled(|| a.migrate_from(&old)).unwrap();
assert_eq!(fs::read(old.join("a.txt")).unwrap(), b"alpha");
assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"alpha");
for name in ["a.txt", MIGRATION_STATE, MIGRATION_MARKER] {
assert!(
fs::symlink_metadata(a.root().join(format!("{name}{TMP_SUFFIX}")))
.unwrap()
.file_type()
.is_symlink()
);
}
}
#[test]
fn migration_publication_preserves_a_destination_contender() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
let source = old.join("a.txt");
let target = a.root().join("a.txt");
for contender in [b"other".as_slice(), b"alpha".as_slice()] {
let mut writer = AtomicWriter::create_new_private(&target).unwrap();
writer.write_all(b"alpha").unwrap();
let temp = writer.temp_path().to_path_buf();
fs::write(&target, contender).unwrap();
let result = commit_migration_file(writer, &source, &target);
if contender == b"alpha" {
result.unwrap();
} else {
assert!(matches!(result, Err(AppRootError::Collision(p)) if p == target));
}
assert_eq!(fs::read(&target).unwrap(), contender);
assert_eq!(fs::read(&source).unwrap(), b"alpha");
assert!(!temp.exists());
fs::remove_file(&target).unwrap();
}
}
#[test]
fn migration_preserves_native_filenames_that_resemble_fs_temps() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
let rel = Path::new("sub/.record.rktmp-123-1-0");
fs::write(old.join(rel), b"user data, not a migration temp").unwrap();
assert!(matches!(
settled(|| a.migrate_from(&old)).unwrap(),
MigrationOutcome::Migrated { files: 3, .. }
));
assert_eq!(
fs::read(a.root().join(rel)).unwrap(),
b"user data, not a migration temp"
);
assert_eq!(
fs::read(old.join(rel)).unwrap(),
b"user data, not a migration temp"
);
assert_eq!(
settled(|| a.migrate_from(&old)).unwrap(),
MigrationOutcome::AlreadyMigrated
);
}
#[test]
fn migration_preserves_legacy_temp_shaped_files_and_directories() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
let file = Path::new("sub/user.rk-migrating");
let directory = Path::new("archive.rk-migrating");
fs::write(old.join(file), b"user file").unwrap();
fs::create_dir(old.join(directory)).unwrap();
fs::write(old.join(directory).join("entry"), b"user entry").unwrap();
assert!(matches!(
settled(|| a.migrate_from(&old)).unwrap(),
MigrationOutcome::Migrated { files: 4, .. }
));
for root in [old.as_path(), a.root()] {
assert_eq!(fs::read(root.join(file)).unwrap(), b"user file");
assert_eq!(
fs::read(root.join(directory).join("entry")).unwrap(),
b"user entry"
);
}
}
#[cfg(unix)]
#[test]
fn metadata_publication_refuses_symlink_leaves_without_touching_source() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
for name in [MIGRATION_STATE, MIGRATION_MARKER] {
let target = a.root().join(name);
std::os::unix::fs::symlink(old.join("a.txt"), &target).unwrap();
let error =
write_json_atomic(&target, &serde_json::json!({"source": "old"})).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(fs::symlink_metadata(&target)
.unwrap()
.file_type()
.is_symlink());
assert_eq!(fs::read(old.join("a.txt")).unwrap(), b"alpha");
}
}
#[test]
fn migration_refuses_collision_overlap_symlink_and_busy() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
fs::write(a.root().join("a.txt"), b"different").unwrap();
assert!(matches!(
a.migrate_from(&old),
Err(AppRootError::Collision(_))
));
assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"different");
assert!(!a.root().join(MIGRATION_MARKER).exists());
assert!(matches!(
a.migrate_from(a.root()),
Err(AppRootError::Overlap(_))
));
assert!(matches!(
a.migrate_from(Path::new("rel")),
Err(AppRootError::PathEscape(_))
));
let _held = eventually(|| a.lock_writer()).unwrap();
assert!(matches!(
a.migrate_from(&old),
Err(AppRootError::WriterBusy(_))
));
#[cfg(unix)]
{
let b = s.app_root("Other").unwrap();
let old2 = d.path().join("old2");
fs::create_dir(&old2).unwrap();
std::os::unix::fs::symlink("/etc/hosts", old2.join("l")).unwrap();
assert!(matches!(
b.migrate_from(&old2),
Err(AppRootError::UnsupportedSource(_))
));
assert!(!b.root().join(MIGRATION_MARKER).exists());
}
}
fn snapshot(root: &Path) -> Vec<(String, Vec<u8>)> {
fn walk(base: &Path, rel: &Path, out: &mut Vec<(String, Vec<u8>)>) {
let mut names: Vec<_> = fs::read_dir(base.join(rel))
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
names.sort();
for n in names {
let r = rel.join(&n);
let full = base.join(&r);
let md = fs::symlink_metadata(&full).unwrap();
let key = r.display().to_string();
if md.file_type().is_symlink() {
let t = fs::read_link(&full).unwrap();
out.push((
format!("L {key}"),
t.to_string_lossy().into_owned().into_bytes(),
));
} else if md.is_dir() {
out.push((format!("D {key}"), Vec::new()));
walk(base, &r, out);
} else if md.is_file() {
out.push((format!("F {key}"), fs::read(&full).unwrap()));
} else {
out.push((format!("O {key}"), Vec::new()));
}
}
}
let mut out = Vec::new();
walk(root, Path::new(""), &mut out);
out
}
fn links_on() -> MigrateOptions {
MigrateOptions::strict().preserve_links(true)
}
fn marker_value(a: &AppRoot) -> serde_json::Value {
serde_json::from_str(&fs::read_to_string(a.root().join(MIGRATION_MARKER)).unwrap()).unwrap()
}
fn state_value(a: &AppRoot) -> serde_json::Value {
serde_json::from_str(&fs::read_to_string(a.root().join(MIGRATION_STATE)).unwrap()).unwrap()
}
fn old_reader_sees_migrated(a: &AppRoot, old: &Path) -> bool {
let Ok(text) = fs::read_to_string(a.root().join(MIGRATION_MARKER)) else {
return false;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
return false;
};
v.get("source").and_then(|s| s.as_str()) == fs::canonicalize(old).unwrap().to_str()
}
fn entry_count(dir: &Path) -> usize {
fs::read_dir(dir).unwrap().count()
}
#[test]
fn empty_directories_are_migrated_in_every_mode() {
let (d, s) = suite();
let old = old_tree(d.path());
fs::create_dir(old.join("empty")).unwrap();
fs::create_dir_all(old.join("sub/deep/emptier")).unwrap();
let before = snapshot(&old);
for (name, opts) in [
("Strict", MigrateOptions::strict()),
("Links", links_on()),
(
"Everything",
links_on()
.skip_ephemeral_specials(true)
.skip_reparse_points(true),
),
] {
let a = s.app_root(name).unwrap();
let r = a.migrate_from_with(&old, opts).unwrap();
assert_eq!(
r.outcome(),
&MigrationOutcome::Migrated {
files: 2,
bytes: 200_005
},
"{name}: directories are not counted as files"
);
assert_eq!(r.state(), MigrationState::Ready, "{name}");
for dir in ["empty", "sub/deep", "sub/deep/emptier"] {
let p = a.root().join(dir);
assert!(
fs::symlink_metadata(&p).unwrap().file_type().is_dir(),
"{name}: {dir}"
);
}
assert_eq!(entry_count(&a.root().join("empty")), 0, "{name}");
assert_eq!(entry_count(&a.root().join("sub/deep/emptier")), 0, "{name}");
assert_eq!(
settled(|| a.migrate_from_with(&old, MigrateOptions::strict()))
.unwrap()
.outcome(),
&MigrationOutcome::AlreadyMigrated
);
}
assert_eq!(snapshot(&old), before);
}
#[test]
fn a_directory_collision_is_refused_and_leaves_the_move_incomplete_until_resolved() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
fs::create_dir(old.join("empty")).unwrap();
fs::write(a.root().join("empty"), b"in the way").unwrap();
assert!(matches!(
a.migrate_from(&old),
Err(AppRootError::Collision(_))
));
assert_eq!(fs::read(a.root().join("empty")).unwrap(), b"in the way");
assert!(!a.root().join(MIGRATION_MARKER).exists());
assert!(!old_reader_sees_migrated(&a, &old));
assert_eq!(
a.migration_state(&old).unwrap(),
Some(MigrationState::Incomplete)
);
assert_eq!(state_value(&a)["state"], serde_json::json!("incomplete"));
assert!(state_value(&a)["error"]
.as_str()
.unwrap()
.contains("differs from source"));
fs::remove_file(a.root().join("empty")).unwrap();
assert!(matches!(
settled(|| a.migrate_from(&old)).unwrap(),
MigrationOutcome::Migrated { files: 2, .. }
));
assert!(a.root().join("empty").is_dir());
assert_eq!(
a.migration_state(&old).unwrap(),
Some(MigrationState::Ready)
);
assert!(old_reader_sees_migrated(&a, &old));
assert_eq!(state_value(&a)["state"], serde_json::json!("ready"));
}
#[test]
fn an_interrupted_run_leaves_incomplete_and_the_rerun_retries_everything() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
fs::create_dir(old.join("empty")).unwrap();
a.write_state(&state_json(
MigrationState::Incomplete,
fs::canonicalize(&old).unwrap().to_str(),
&Lists::default(),
None,
))
.unwrap();
fs::copy(old.join("a.txt"), a.root().join("a.txt")).unwrap();
fs::create_dir_all(a.root().join("sub")).unwrap();
fs::write(a.root().join(format!("sub/b.bin{TMP_SUFFIX}")), b"par").unwrap();
assert_eq!(
a.migration_state(&old).unwrap(),
Some(MigrationState::Incomplete)
);
assert!(!old_reader_sees_migrated(&a, &old));
let r = settled(|| a.migrate_from_with(&old, links_on())).unwrap();
assert!(matches!(
r.outcome(),
MigrationOutcome::Migrated { files: 2, .. }
));
assert_eq!(r.state(), MigrationState::Ready);
assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
assert!(a.root().join("empty").is_dir());
assert!(old_reader_sees_migrated(&a, &old));
assert_eq!(state_value(&a)["state"], serde_json::json!("ready"));
}
#[test]
fn a_ready_state_without_its_marker_is_repaired_by_the_next_call() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
a.migrate_from(&old).unwrap();
fs::remove_file(a.root().join(MIGRATION_MARKER)).unwrap();
assert!(!old_reader_sees_migrated(&a, &old));
assert_eq!(
settled(|| a.migrate_from(&old)).unwrap(),
MigrationOutcome::AlreadyMigrated
);
assert!(old_reader_sees_migrated(&a, &old));
}
#[test]
fn readback_refuses_ambiguous_state_while_copy_retains_legacy_absent_rules() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
let src = fs::canonicalize(&old).unwrap();
let state = a.root().join(MIGRATION_STATE);
let write = |v: serde_json::Value| fs::write(&state, v.to_string()).unwrap();
write(
serde_json::json!({"version": 2, "state": "ready", "source": src.with_file_name("elsewhere")}),
);
assert_eq!(a.migration_state(&old).unwrap(), None);
write(
serde_json::json!({"version": 9, "state": "ready", "source": src.with_file_name("elsewhere")}),
);
assert!(a.migration_state(&old).is_err());
assert!(
a.read_state(&src).unwrap().is_none(),
"copy parser remains source-scoped"
);
for v in [
serde_json::json!({"version": 99, "state": "ready", "source": src.to_str()}),
serde_json::json!({"version": 2, "state": "teleported", "source": src.to_str()}),
] {
write(v);
for r in [
a.migration_state(&old).map(|_| ()),
settled(|| a.migrate_from(&old)).map(|_| ()),
] {
match r {
Err(AppRootError::Io(e)) => {
assert_eq!(e.kind(), io::ErrorKind::InvalidData)
}
other => panic!("expected InvalidData, got {other:?}"),
}
}
assert!(!a.root().join("a.txt").exists(), "nothing was copied");
}
for text in ["not json {", "{\"source\": \"x\"}"] {
fs::write(&state, text).unwrap();
assert!(a.migration_state(&old).is_err());
assert!(a.read_state(&src).unwrap().is_none());
}
assert!(matches!(
settled(|| a.migrate_from(&old)).unwrap(),
MigrationOutcome::Migrated { files: 2, .. }
));
}
#[test]
fn the_migrations_own_marker_names_in_the_source_are_not_copied() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
fs::write(old.join(MIGRATION_MARKER), br#"{"source":"/earlier"}"#).unwrap();
fs::write(old.join(MIGRATION_STATE), b"not ours").unwrap();
fs::write(old.join("sub").join(MIGRATION_MARKER), b"data").unwrap();
let r = a.migrate_from(&old).unwrap();
assert!(matches!(r, MigrationOutcome::Migrated { files: 3, .. }));
assert_eq!(
marker_value(&a)["source"].as_str(),
fs::canonicalize(&old).unwrap().to_str()
);
assert_eq!(state_value(&a)["state"], serde_json::json!("ready"));
assert_eq!(
fs::read(a.root().join("sub").join(MIGRATION_MARKER)).unwrap(),
b"data"
);
}
#[test]
fn strict_marker_keeps_the_historical_shape_and_old_markers_still_count() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
a.migrate_from(&old).unwrap();
let v = marker_value(&a);
let mut keys: Vec<_> = v.as_object().unwrap().keys().cloned().collect();
keys.sort();
assert_eq!(keys, ["bytes", "files", "source"]);
assert_eq!(
settled(|| a.migrate_from_with(&old, links_on().skip_ephemeral_specials(true)))
.unwrap()
.outcome(),
&MigrationOutcome::AlreadyMigrated
);
let b = s.app_root("Other").unwrap();
let out = b.migrate_from_with(&old, links_on()).unwrap();
assert_eq!(
out.outcome(),
&MigrationOutcome::Migrated {
files: 2,
bytes: 200_005
}
);
assert!(out.migrated_links().is_empty());
assert!(out.skipped_specials().is_empty());
assert!(out.omitted_optional_links().is_empty());
assert!(out.complete());
let v = marker_value(&b);
assert_eq!(v["links"], serde_json::json!([]));
assert_eq!(v["skipped_specials"], serde_json::json!([]));
assert_eq!(v["omitted_optional_links"], serde_json::json!([]));
assert_eq!(v["complete"], serde_json::json!(true));
assert_eq!(v["files"], serde_json::json!(2));
}
#[test]
fn marker_json_reports_omitted_links_as_incomplete() {
let omitted = [OmittedLink {
path: PathBuf::from("skills/x"),
original_target: PathBuf::from("../../x"),
resolved_target: PathBuf::from("/abs/x"),
reason: "denied".into(),
}];
let v = marker_json(Some("/old"), 1, 2, &links_on(), &[], &[], &omitted);
assert_eq!(v["complete"], serde_json::json!(false));
assert_eq!(v["omitted_optional_links"][0]["path"], "skills/x");
assert_eq!(v["omitted_optional_links"][0]["original_target"], "../../x");
assert_eq!(v["omitted_optional_links"][0]["resolved_target"], "/abs/x");
}
#[test]
fn link_creation_errors_are_classified() {
let denied = io::Error::from(io::ErrorKind::PermissionDenied);
assert!(link_unavailable(&denied));
assert!(link_unavailable(&io::Error::from(
io::ErrorKind::Unsupported
)));
assert!(!link_unavailable(&io::Error::from(
io::ErrorKind::AlreadyExists
)));
let typed = LinkNotCreated {
link: "a".into(),
target: "b".into(),
resolved: "b".into(),
reason: "denied".into(),
};
let e = AppRootError::Io(io::Error::other(typed.clone()));
assert_eq!(e.link_not_created(), Some(&typed));
assert!(e.is_retryable());
assert!(e.to_string().contains("cannot create symlink a -> b"));
assert!(AppRootError::WriterBusy("x".into()).is_retryable());
assert!(!AppRootError::Collision("x".into()).is_retryable());
let plain = AppRootError::Io(io::Error::from(io::ErrorKind::PermissionDenied));
assert!(plain.link_not_created().is_none() && !plain.is_retryable());
}
#[test]
fn plan_link_keeps_inside_text_and_resolves_outside_lexically() {
let csrc = Path::new(if cfg!(windows) {
r"C:\data\old"
} else {
"/data/old"
});
let rel = |s: &str| PathBuf::from(s);
for (at, t) in [
("l", "a.txt"),
("sub/l", "../a.txt"),
("sub/l", "."),
("sub/l", ".."),
("l", "./x/../y"),
] {
assert_eq!(
plan_link(csrc, &rel(at), Path::new(t)),
(PathBuf::from(t), false),
"{at} {t}"
);
}
let (w, rewritten) = plan_link(csrc, &rel("sub/l"), Path::new("../../out/f"));
assert!(rewritten);
assert_eq!(w, csrc.parent().unwrap().join("out").join("f"));
let (w, rewritten) = plan_link(csrc, &rel("l"), Path::new("../old/a.txt"));
assert!(rewritten);
assert_eq!(w, csrc.join("a.txt"));
}
#[cfg(unix)]
mod unix_links {
use super::*;
use std::os::unix::fs::symlink;
fn migrated(
r: MigrationReport,
) -> (
u64,
Vec<MigratedLink>,
Vec<SkippedSpecial>,
Vec<OmittedLink>,
) {
let files = match r.outcome() {
MigrationOutcome::Migrated { files, .. } => *files,
other => panic!("not migrated: {other:?}"),
};
(
files,
r.migrated_links().to_vec(),
r.skipped_specials().to_vec(),
r.omitted_optional_links().to_vec(),
)
}
fn read_link_text(p: PathBuf) -> PathBuf {
fs::read_link(p).unwrap()
}
#[test]
fn internal_relative_link_keeps_its_text_and_meaning() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
symlink("../a.txt", old.join("sub/to_a")).unwrap();
symlink("sub", old.join("top")).unwrap();
symlink("sub/b.bin", old.join("to_b")).unwrap();
let before = snapshot(&old);
let (files, links, specials, omitted) =
migrated(a.migrate_from_with(&old, links_on()).unwrap());
assert_eq!(files, 2);
assert_eq!(links.len(), 3);
assert!(specials.is_empty() && omitted.is_empty());
assert!(links
.iter()
.all(|l| !l.rewritten && l.original_target == l.written_target));
assert_eq!(
read_link_text(a.root().join("sub/to_a")),
Path::new("../a.txt")
);
assert_eq!(fs::read(a.root().join("sub/to_a")).unwrap(), b"alpha");
assert_eq!(read_link_text(a.root().join("top")), Path::new("sub"));
assert_eq!(fs::read(a.root().join("top/b.bin")).unwrap().len(), 200_000);
assert_eq!(
read_link_text(a.root().join("to_b")),
Path::new("sub/b.bin")
);
let v = marker_value(&a);
assert_eq!(v["links"].as_array().unwrap().len(), 3);
assert_eq!(v["complete"], serde_json::json!(true));
assert_eq!(snapshot(&old), before, "old tree is byte-identical");
}
#[test]
fn external_relative_link_is_rewritten_to_its_original_target() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
let outside = d.path().join("outside");
fs::create_dir(&outside).unwrap();
fs::write(outside.join("file"), b"out").unwrap();
symlink("../../outside/file", old.join("sub/ext")).unwrap();
symlink("../old/a.txt", old.join("reenter")).unwrap();
let before = snapshot(&old);
let (_, links, _, _) = migrated(a.migrate_from_with(&old, links_on()).unwrap());
let canon_d = fs::canonicalize(d.path()).unwrap();
let want_ext = canon_d.join("outside").join("file");
let want_re = fs::canonicalize(&old).unwrap().join("a.txt");
assert_eq!(read_link_text(a.root().join("sub/ext")), want_ext);
assert_eq!(read_link_text(a.root().join("reenter")), want_re);
assert_eq!(fs::read(a.root().join("sub/ext")).unwrap(), b"out");
assert_eq!(fs::read(a.root().join("reenter")).unwrap(), b"alpha");
let ext = links
.iter()
.find(|l| l.path == Path::new("sub/ext"))
.unwrap();
assert!(ext.rewritten);
assert_eq!(ext.original_target, Path::new("../../outside/file"));
assert_eq!(ext.written_target, want_ext);
assert_eq!(snapshot(&old), before);
}
#[test]
fn absolute_link_is_copied_verbatim() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
let outside = d.path().join("outside");
fs::create_dir(&outside).unwrap();
fs::write(outside.join("file"), b"out").unwrap();
let target = outside.join("file");
symlink(&target, old.join("abs")).unwrap();
let (_, links, _, _) = migrated(a.migrate_from_with(&old, links_on()).unwrap());
assert_eq!(read_link_text(a.root().join("abs")), target);
assert!(!links[0].rewritten && links[0].written_target == target);
}
#[test]
fn dangling_links_stay_links() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
symlink("missing/nowhere", old.join("dang")).unwrap();
symlink("/nonexistent-rk-test/xyz", old.join("dang_abs")).unwrap();
let before = snapshot(&old);
let (_, links, _, _) = migrated(a.migrate_from_with(&old, links_on()).unwrap());
assert_eq!(links.len(), 2);
for (name, text) in [
("dang", "missing/nowhere"),
("dang_abs", "/nonexistent-rk-test/xyz"),
] {
let p = a.root().join(name);
assert!(fs::symlink_metadata(&p).unwrap().file_type().is_symlink());
assert!(!p.exists(), "still dangling");
assert_eq!(read_link_text(p), Path::new(text));
}
assert_eq!(snapshot(&old), before);
}
#[test]
fn link_cycles_are_not_traversed() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
symlink(".", old.join("loop")).unwrap();
symlink("b", old.join("ca")).unwrap();
symlink("ca", old.join("b")).unwrap();
symlink("..", old.join("sub/up")).unwrap();
let before = snapshot(&old);
let (files, links, _, _) = migrated(a.migrate_from_with(&old, links_on()).unwrap());
assert_eq!(files, 2, "no file was reached through a link");
assert_eq!(links.len(), 4);
assert_eq!(read_link_text(a.root().join("loop")), Path::new("."));
assert_eq!(read_link_text(a.root().join("sub/up")), Path::new(".."));
assert_eq!(read_link_text(a.root().join("ca")), Path::new("b"));
assert_eq!(read_link_text(a.root().join("b")), Path::new("ca"));
assert_eq!(snapshot(&old), before);
}
#[test]
fn collisions_behave_as_before_with_links_enabled() {
let (d, s) = suite();
let old = old_tree(d.path());
symlink("../a.txt", old.join("sub/to_a")).unwrap();
let a = s.app_root("A").unwrap();
fs::write(a.root().join("a.txt"), b"different").unwrap();
assert!(matches!(
a.migrate_from_with(&old, links_on()),
Err(AppRootError::Collision(_))
));
assert_eq!(fs::read(a.root().join("a.txt")).unwrap(), b"different");
assert!(!a.root().join(MIGRATION_MARKER).exists());
let b = s.app_root("B").unwrap();
fs::create_dir_all(b.root().join("sub")).unwrap();
symlink("elsewhere", b.root().join("sub/to_a")).unwrap();
assert!(matches!(
b.migrate_from_with(&old, links_on()),
Err(AppRootError::Collision(_))
));
assert_eq!(
read_link_text(b.root().join("sub/to_a")),
Path::new("elsewhere")
);
assert!(!b.root().join(MIGRATION_MARKER).exists());
let c = s.app_root("C").unwrap();
fs::create_dir_all(c.root().join("sub")).unwrap();
fs::write(c.root().join("sub/to_a"), b"file").unwrap();
assert!(matches!(
c.migrate_from_with(&old, links_on()),
Err(AppRootError::Collision(_))
));
assert_eq!(fs::read(c.root().join("sub/to_a")).unwrap(), b"file");
let e = s.app_root("E").unwrap();
fs::create_dir_all(e.root().join("sub")).unwrap();
symlink("../a.txt", e.root().join("sub/to_a")).unwrap();
migrated(e.migrate_from_with(&old, links_on()).unwrap());
}
#[test]
fn interrupted_link_migration_restarts_cleanly() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
symlink("../a.txt", old.join("sub/to_a")).unwrap();
symlink("sub", old.join("top")).unwrap();
fs::copy(old.join("a.txt"), a.root().join("a.txt")).unwrap();
fs::create_dir_all(a.root().join("sub")).unwrap();
symlink("../a.txt", a.root().join("sub/to_a")).unwrap();
fs::write(a.root().join(format!("sub/b.bin{TMP_SUFFIX}")), b"par").unwrap();
assert!(!a.root().join(MIGRATION_MARKER).exists());
let (files, links, _, _) = migrated(a.migrate_from_with(&old, links_on()).unwrap());
assert_eq!((files, links.len()), (2, 2));
assert_eq!(fs::read(a.root().join("sub/b.bin")).unwrap().len(), 200_000);
assert_eq!(read_link_text(a.root().join("top")), Path::new("sub"));
assert_eq!(
settled(|| a.migrate_from_with(&old, links_on()))
.unwrap()
.outcome(),
&MigrationOutcome::AlreadyMigrated
);
}
#[test]
fn sockets_and_fifos_are_skipped_and_reported_never_opened() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
let _sock = std::os::unix::net::UnixListener::bind(old.join("s")).unwrap();
let st = std::process::Command::new("mkfifo")
.arg(old.join("f"))
.status()
.unwrap();
assert!(st.success());
let before = snapshot(&old);
for o in [MigrateOptions::strict(), links_on()] {
assert!(matches!(
settled(|| a.migrate_from_with(&old, o.clone())),
Err(AppRootError::UnsupportedSource(_))
));
}
assert!(!a.root().join(MIGRATION_MARKER).exists());
let o = MigrateOptions::strict().skip_ephemeral_specials(true);
let (files, links, specials, _) =
migrated(settled(|| a.migrate_from_with(&old, o.clone())).unwrap());
assert_eq!((files, links.len()), (2, 0));
assert_eq!(
specials,
vec![
SkippedSpecial {
path: "f".into(),
kind: SpecialKind::Fifo
},
SkippedSpecial {
path: "s".into(),
kind: SpecialKind::Socket
},
]
);
assert!(fs::symlink_metadata(a.root().join("f")).is_err());
assert!(fs::symlink_metadata(a.root().join("s")).is_err());
let v = marker_value(&a);
assert_eq!(
v["skipped_specials"][0],
serde_json::json!({"path": "f", "kind": "fifo"})
);
assert_eq!(
v["skipped_specials"][1],
serde_json::json!({"path": "s", "kind": "socket"})
);
assert_eq!(snapshot(&old), before);
}
#[test]
fn strict_default_still_refuses_links_and_specials() {
let (d, s) = suite();
let old = old_tree(d.path());
symlink("a.txt", old.join("l")).unwrap();
let before = snapshot(&old);
for (i, o) in [
MigrateOptions::default(),
MigrateOptions::strict(),
MigrateOptions::strict().skip_ephemeral_specials(true),
MigrateOptions::strict().optional_links(|_| true),
]
.into_iter()
.enumerate()
{
let a = s.app_root(&format!("App{i}")).unwrap();
assert!(matches!(
settled(|| a.migrate_from_with(&old, o.clone())),
Err(AppRootError::UnsupportedSource(_))
));
assert!(!a.root().join(MIGRATION_MARKER).exists());
}
let a = s.app_root("Plain").unwrap();
assert!(matches!(
a.migrate_from(&old),
Err(AppRootError::UnsupportedSource(_))
));
assert_eq!(snapshot(&old), before);
}
fn denied(_: &Path, _: &Path, _: bool) -> io::Result<()> {
Err(io::Error::from(io::ErrorKind::PermissionDenied))
}
#[test]
fn required_link_failure_is_typed_retryable_and_writes_no_marker() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
symlink("../a.txt", old.join("sub/to_a")).unwrap();
symlink("../../ext", old.join("sub/ext")).unwrap();
let before = snapshot(&old);
let err = a.migrate_impl(&old, &links_on(), &denied).unwrap_err();
assert!(err.is_retryable());
let typed = err.link_not_created().expect("typed link failure");
assert_eq!(typed.link, Path::new("sub/ext"));
assert_eq!(typed.target, Path::new("../../ext"));
assert_eq!(
typed.resolved,
fs::canonicalize(d.path()).unwrap().join("ext")
);
assert!(
matches!(err, AppRootError::Io(_)),
"carried by the existing Io variant"
);
assert!(
!a.root().join(MIGRATION_MARKER).exists(),
"no completed marker"
);
assert_eq!(snapshot(&old), before);
let (files, links, _, omitted) =
migrated(settled(|| a.migrate_from_with(&old, links_on())).unwrap());
assert_eq!((files, links.len(), omitted.len()), (2, 2, 0));
assert!(a.root().join(MIGRATION_MARKER).is_file());
assert_eq!(snapshot(&old), before);
}
#[test]
fn optional_link_failure_is_omitted_and_reported_separately() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
fs::create_dir(old.join("skills")).unwrap();
symlink("../../shared/tool", old.join("skills/tool")).unwrap();
let before = snapshot(&old);
let other = links_on().optional_links(|p| p.starts_with("worktrees"));
assert!(a
.migrate_impl(&old, &other, &denied)
.unwrap_err()
.link_not_created()
.is_some());
assert!(!a.root().join(MIGRATION_MARKER).exists());
let opts = links_on().optional_links(|p| p.starts_with("skills"));
let report = settled(|| a.migrate_impl(&old, &opts, &denied)).unwrap();
assert!(!report.complete());
let (files, links, _, omitted) = migrated(report);
assert_eq!((files, links.len()), (2, 0));
assert_eq!(omitted.len(), 1);
assert_eq!(omitted[0].path, Path::new("skills/tool"));
assert_eq!(omitted[0].original_target, Path::new("../../shared/tool"));
assert_eq!(
omitted[0].resolved_target,
fs::canonicalize(d.path()).unwrap().join("shared/tool")
);
assert!(fs::symlink_metadata(a.root().join("skills/tool")).is_err());
assert_eq!(
a.migration_state(&old).unwrap(),
Some(MigrationState::ReadyWithOmissions)
);
assert!(!a.root().join(MIGRATION_MARKER).exists());
assert!(!old_reader_sees_migrated(&a, &old));
let v = state_value(&a);
assert_eq!(v["state"], serde_json::json!("ready_with_omissions"));
assert_eq!(v["omitted_optional_links"].as_array().unwrap().len(), 1);
assert_eq!(v["unresolved"][0]["path"], serde_json::json!("skills/tool"));
assert_eq!(v["links"], serde_json::json!([]));
assert_eq!(snapshot(&old), before);
}
#[test]
fn omitted_optional_links_are_retried_and_the_state_becomes_ready() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
fs::create_dir(old.join("skills")).unwrap();
symlink("../../shared/tool", old.join("skills/tool")).unwrap();
let before = snapshot(&old);
let opts = || links_on().optional_links(|p| p.starts_with("skills"));
let first = a.migrate_impl(&old, &opts(), &denied).unwrap();
assert_eq!(first.state(), MigrationState::ReadyWithOmissions);
let again = settled(|| a.migrate_impl(&old, &opts(), &denied)).unwrap();
assert_eq!(again.outcome(), &MigrationOutcome::AlreadyMigrated);
assert_eq!(again.state(), MigrationState::ReadyWithOmissions);
assert_eq!(again.omitted_optional_links().len(), 1);
assert!(again.migrated_links().is_empty());
assert!(!a.root().join(MIGRATION_MARKER).exists());
fs::write(a.root().join("a.txt"), b"edited by the app").unwrap();
let done = settled(|| a.migrate_from_with(&old, opts())).unwrap();
assert_eq!(done.outcome(), &MigrationOutcome::AlreadyMigrated);
assert_eq!(done.state(), MigrationState::Ready);
assert!(done.complete() && done.omitted_optional_links().is_empty());
assert_eq!(done.migrated_links().len(), 1);
assert_eq!(done.migrated_links()[0].path, Path::new("skills/tool"));
assert!(done.migrated_links()[0].rewritten);
assert_eq!(
read_link_text(a.root().join("skills/tool")),
fs::canonicalize(d.path()).unwrap().join("shared/tool")
);
assert_eq!(
fs::read(a.root().join("a.txt")).unwrap(),
b"edited by the app"
);
assert_eq!(state_value(&a)["state"], serde_json::json!("ready"));
assert_eq!(state_value(&a)["links"].as_array().unwrap().len(), 1);
assert_eq!(state_value(&a)["unresolved"], serde_json::json!([]));
assert!(old_reader_sees_migrated(&a, &old), "now a complete move");
let last = settled(|| a.migrate_from_with(&old, opts())).unwrap();
assert_eq!(last.outcome(), &MigrationOutcome::AlreadyMigrated);
assert!(last.complete() && last.migrated_links().is_empty());
assert_eq!(snapshot(&old), before);
}
#[test]
fn a_retry_never_overwrites_an_occupied_destination_path() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
fs::create_dir(old.join("skills")).unwrap();
symlink("tool-a", old.join("skills/tool")).unwrap();
let opts = || links_on().optional_links(|p| p.starts_with("skills"));
let first = a.migrate_impl(&old, &opts(), &denied).unwrap();
assert_eq!(first.state(), MigrationState::ReadyWithOmissions);
fs::write(a.root().join("skills/tool"), b"the app's own file").unwrap();
let r = settled(|| a.migrate_from_with(&old, opts())).unwrap();
assert_eq!(r.state(), MigrationState::ReadyWithOmissions);
assert_eq!(r.omitted_optional_links().len(), 1);
assert!(r.omitted_optional_links()[0].reason.contains("occupied"));
assert_eq!(
fs::read(a.root().join("skills/tool")).unwrap(),
b"the app's own file"
);
assert!(!a.root().join(MIGRATION_MARKER).exists());
}
#[test]
fn a_link_to_an_empty_directory_does_not_dangle_in_the_new_tree() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
fs::create_dir(old.join("empty")).unwrap();
symlink("empty", old.join("to_empty")).unwrap();
let before = snapshot(&old);
let r = settled(|| a.migrate_from_with(&old, links_on())).unwrap();
assert_eq!(r.state(), MigrationState::Ready);
assert!(a.root().join("empty").is_dir());
assert!(
a.root().join("to_empty").is_dir(),
"the link resolves to the migrated empty directory"
);
assert_eq!(
read_link_text(a.root().join("to_empty")),
Path::new("empty")
);
assert_eq!(snapshot(&old), before);
}
#[test]
fn a_failed_required_link_leaves_incomplete_and_the_rerun_reaches_ready() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
symlink("../a.txt", old.join("sub/to_a")).unwrap();
symlink("../../ext", old.join("sub/ext")).unwrap();
assert_eq!(a.migration_state(&old).unwrap(), None);
let err = a.migrate_impl(&old, &links_on(), &denied).unwrap_err();
assert!(err.is_retryable());
assert!(!a.root().join(MIGRATION_MARKER).exists());
assert!(!old_reader_sees_migrated(&a, &old));
assert_eq!(
a.migration_state(&old).unwrap(),
Some(MigrationState::Incomplete)
);
assert!(!a.migration_state(&old).unwrap().unwrap().is_usable());
let v = state_value(&a);
assert_eq!(v["state"], serde_json::json!("incomplete"));
assert_eq!(v["version"], serde_json::json!(2));
assert_eq!(v["unresolved"][0]["path"], serde_json::json!("sub/ext"));
assert_eq!(v["unresolved"][0]["kind"], serde_json::json!("link"));
assert!(v["error"]
.as_str()
.unwrap()
.contains("cannot create symlink"));
assert!(settled(|| a.migrate_impl(&old, &links_on(), &denied)).is_err());
assert_eq!(
a.migration_state(&old).unwrap(),
Some(MigrationState::Incomplete)
);
let r = settled(|| a.migrate_from_with(&old, links_on())).unwrap();
assert_eq!(r.state(), MigrationState::Ready);
assert_eq!(r.migrated_links().len(), 2);
assert_eq!(
a.migration_state(&old).unwrap(),
Some(MigrationState::Ready)
);
assert!(a.root().join(MIGRATION_MARKER).is_file());
assert!(old_reader_sees_migrated(&a, &old));
assert_eq!(state_value(&a)["state"], serde_json::json!("ready"));
}
#[test]
fn socket_entry_is_skipped_and_recorded_without_making_the_move_incomplete() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let old = old_tree(d.path());
let _sock = std::os::unix::net::UnixListener::bind(old.join("s")).unwrap();
let before = snapshot(&old);
let o = MigrateOptions::strict().skip_ephemeral_specials(true);
let r = a.migrate_from_with(&old, o).unwrap();
assert_eq!(r.state(), MigrationState::Ready);
assert!(r.complete());
assert_eq!(r.skipped_specials().len(), 1);
assert_eq!(r.skipped_specials()[0].kind, SpecialKind::Socket);
assert!(r.skipped_reparse_points().is_empty());
assert!(fs::symlink_metadata(a.root().join("s")).is_err());
let v = state_value(&a);
assert_eq!(
v["skipped_specials"][0],
serde_json::json!({"path": "s", "kind": "socket"})
);
assert_eq!(v["state"], serde_json::json!("ready"));
assert_eq!(snapshot(&old), before);
}
}
#[cfg(windows)]
mod windows_links {
use super::*;
use std::os::windows::fs::{symlink_dir, symlink_file, FileTypeExt};
fn old_with_links(d: &Path) -> Option<PathBuf> {
let old = old_tree(d);
if symlink_file("a.txt", old.join("la")).is_err()
|| symlink_dir("sub", old.join("ld")).is_err()
{
eprintln!("skipped: symbolic links cannot be created (no privilege)");
return None;
}
Some(old)
}
#[test]
fn links_migrate_with_their_kind_or_report_the_privilege_failure() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let Some(old) = old_with_links(d.path()) else {
return;
};
let before = snapshot(&old);
match a.migrate_from_with(&old, links_on()) {
Ok(report) => {
assert_eq!(report.migrated_links().len(), 2);
let la = fs::symlink_metadata(a.root().join("la"))
.unwrap()
.file_type();
let ld = fs::symlink_metadata(a.root().join("ld"))
.unwrap()
.file_type();
assert!(la.is_symlink_file() && ld.is_symlink_dir());
assert!(marker_value(&a)["links"].as_array().unwrap().len() == 2);
}
Err(e) => {
let typed = e.link_not_created().expect("typed link failure");
assert!(typed.link == Path::new("la") || typed.link == Path::new("ld"));
assert!(typed.target == Path::new("a.txt") || typed.target == Path::new("sub"));
assert!(e.is_retryable());
assert!(!a.root().join(MIGRATION_MARKER).exists());
}
}
assert_eq!(snapshot(&old), before);
}
#[test]
fn optional_links_are_migrated_or_omitted_never_dropped_silently() {
let (d, s) = suite();
let a = s.app_root("App").unwrap();
let Some(old) = old_with_links(d.path()) else {
return;
};
let opts = links_on().optional_links(|_| true);
let r = a.migrate_from_with(&old, opts).unwrap();
assert!(matches!(r.outcome(), MigrationOutcome::Migrated { .. }));
assert_eq!(
r.migrated_links().len() + r.omitted_optional_links().len(),
2
);
assert_eq!(r.complete(), r.omitted_optional_links().is_empty());
let want = if r.complete() {
"ready"
} else {
"ready_with_omissions"
};
assert_eq!(state_value(&a)["state"], serde_json::json!(want));
assert_eq!(
a.root().join(MIGRATION_MARKER).exists(),
r.complete(),
"an earlier reader sees a finished move only for a complete one"
);
}
fn junction(link: &Path, target: &Path) -> bool {
std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(link)
.arg(target)
.output()
.is_ok_and(|o| o.status.success())
}
#[test]
fn reparse_tags_are_classified() {
assert_eq!(classify_reparse_tag(0xA000_000C), Reparse::Symlink);
assert_eq!(classify_reparse_tag(0xA000_0003), Reparse::Junction);
assert_eq!(classify_reparse_tag(0x8000_0023), Reparse::Socket);
assert_eq!(
classify_reparse_tag(0x9000_001A),
Reparse::Other(0x9000_001A)
);
assert_eq!(
classify_reparse_tag(0xA000_001D),
Reparse::Other(0xA000_001D)
);
}
#[test]
fn a_junction_is_never_followed_copied_or_recreated() {
let (d, s) = suite();
let outside = d.path().join("outside");
fs::create_dir(&outside).unwrap();
fs::write(outside.join("secret.txt"), b"not part of the app root").unwrap();
let old = old_tree(d.path());
if !junction(&old.join("junc"), &outside) {
eprintln!("skipped: cannot create a junction here");
return;
}
let before = snapshot(&old);
for (i, o) in [
MigrateOptions::strict(),
links_on(),
MigrateOptions::strict().skip_ephemeral_specials(true),
]
.into_iter()
.enumerate()
{
let a = s.app_root(&format!("Refuse{i}")).unwrap();
assert!(matches!(
a.migrate_from_with(&old, o),
Err(AppRootError::UnsupportedSource(_))
));
assert!(!a.root().join(MIGRATION_MARKER).exists());
assert!(!a.root().join(MIGRATION_STATE).exists());
assert!(!a.root().join("a.txt").exists(), "nothing was copied");
assert_eq!(a.migration_state(&old).unwrap(), None);
}
let a = s.app_root("Skip").unwrap();
let o = links_on().skip_reparse_points(true);
let r = a.migrate_from_with(&old, o.clone()).unwrap();
assert_eq!(r.state(), MigrationState::ReadyWithOmissions);
assert!(!r.complete());
assert_eq!(
r.outcome(),
&MigrationOutcome::Migrated {
files: 2,
bytes: 200_005
}
);
assert_eq!(r.skipped_reparse_points().len(), 1);
let skipped = &r.skipped_reparse_points()[0];
assert_eq!(skipped.path, Path::new("junc"));
assert_eq!(skipped.kind, ReparseKind::Junction);
assert!(skipped.reason.contains("never followed"));
assert!(r.migrated_links().is_empty(), "not recreated as a symlink");
assert!(fs::symlink_metadata(a.root().join("junc")).is_err());
assert!(
snapshot(a.root())
.iter()
.all(|(k, _)| !k.contains("secret") && !k.contains("junc")),
"nothing behind the junction was copied"
);
assert!(!a.root().join(MIGRATION_MARKER).exists());
assert!(!old_reader_sees_migrated(&a, &old));
let v = state_value(&a);
assert_eq!(v["state"], serde_json::json!("ready_with_omissions"));
assert_eq!(
v["skipped_reparse_points"][0]["path"],
serde_json::json!("junc")
);
assert_eq!(
v["skipped_reparse_points"][0]["kind"],
serde_json::json!("junction")
);
assert_eq!(
v["unresolved"][0]["kind"],
serde_json::json!("reparse_point")
);
let again = a.migrate_from_with(&old, o).unwrap();
assert_eq!(again.outcome(), &MigrationOutcome::AlreadyMigrated);
assert_eq!(again.state(), MigrationState::ReadyWithOmissions);
assert_eq!(again.skipped_reparse_points().len(), 1);
assert_eq!(
a.migration_state(&old).unwrap(),
Some(MigrationState::ReadyWithOmissions)
);
assert_eq!(snapshot(&old), before);
assert_eq!(
fs::read(outside.join("secret.txt")).unwrap(),
b"not part of the app root"
);
}
}
}