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, LockedFile};
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";
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),
PeerDirMismatch,
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::PeerDirMismatch => {
write!(
f,
"the peer process uses a different data folder than this one"
)
}
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: LockedFile,
}
#[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 verify_peer_dir(&self, peer: &Path) -> Result<(), AppRootError> {
let ours = fs::canonicalize(&self.root)?;
let theirs = fs::canonicalize(peer).map_err(|_| AppRootError::PeerDirMismatch)?;
if same_directory(&ours, &theirs) {
Ok(())
} else {
Err(AppRootError::PeerDirMismatch)
}
}
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)
}
fn same_directory(a: &Path, b: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
match (fs::metadata(a), fs::metadata(b)) {
(Ok(x), Ok(y)) => x.dev() == y.dev() && x.ino() == y.ino(),
_ => false,
}
}
#[cfg(not(unix))]
{
a.to_string_lossy()
.eq_ignore_ascii_case(&b.to_string_lossy())
}
}