treeboot-core 0.9.0

Reusable worktree bootstrap engine for the treeboot CLI.
Documentation
use std::path::{Component, Path, PathBuf};

pub(crate) fn canonicalize(path: &Path) -> std::io::Result<PathBuf> {
    dunce::canonicalize(path)
}

pub(crate) fn normalize_maybe_existing(path: &Path) -> std::io::Result<PathBuf> {
    match canonicalize(path) {
        Ok(path) => return Ok(path),
        Err(source) if source.kind() != std::io::ErrorKind::NotFound => {
            return Err(source);
        }
        Err(_) => {}
    }

    let mut ancestor = path;

    loop {
        match ancestor.try_exists() {
            Ok(true) => break,
            Ok(false) => {}
            Err(source) => return Err(source),
        }

        let Some(parent) = ancestor.parent() else {
            return Ok(normalize_lexical(path));
        };
        if parent == ancestor {
            return Ok(normalize_lexical(path));
        }
        ancestor = parent;
    }

    let suffix = path
        .strip_prefix(ancestor)
        .map_err(|source| std::io::Error::new(std::io::ErrorKind::InvalidInput, source))?;
    let mut normalized = canonicalize(ancestor)?;
    normalized.push(suffix);

    Ok(normalize_lexical(&normalized))
}

pub(crate) fn normalize_lexical(path: &Path) -> PathBuf {
    let mut normalized = PathBuf::new();

    for component in path.components() {
        match component {
            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
            Component::RootDir => normalized.push(component.as_os_str()),
            Component::CurDir => {}
            Component::ParentDir => {
                if !normalized.pop() && !normalized.has_root() {
                    normalized.push(component.as_os_str());
                }
            }
            Component::Normal(part) => normalized.push(part),
        }
    }

    normalized
}

pub(crate) fn resolve_path(base: &Path, path: &Path) -> Result<PathBuf, UnsupportedPath> {
    reject_unsupported_path(path)?;

    if path.is_absolute() {
        Ok(path.to_path_buf())
    } else {
        Ok(base.join(path))
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct UnsupportedPath {
    reason: &'static str,
}

impl UnsupportedPath {
    pub(crate) const fn reason(self) -> &'static str {
        self.reason
    }
}

fn reject_unsupported_path(path: &Path) -> Result<(), UnsupportedPath> {
    if let Some(reason) = unsupported_windows_path_reason(path) {
        return Err(UnsupportedPath { reason });
    }

    Ok(())
}

#[cfg(windows)]
fn unsupported_windows_path_reason(path: &Path) -> Option<&'static str> {
    let mut components = path.components();
    match components.next() {
        Some(Component::Prefix(_)) if !path.is_absolute() => {
            Some("drive-relative paths are not supported")
        }
        Some(Component::RootDir) => {
            Some("root-relative paths without a drive or share are not supported")
        }
        _ => None,
    }
}

#[cfg(not(windows))]
const fn unsupported_windows_path_reason(_path: &Path) -> Option<&'static str> {
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalize_lexical_should_resolve_parent_components() {
        assert_eq!(
            normalize_lexical(Path::new("/repo/worktree/../outside")),
            PathBuf::from("/repo/outside")
        );
    }

    #[test]
    fn normalize_maybe_existing_should_preserve_missing_parent_components() {
        let temp = tempfile::TempDir::new().expect("tempdir should be created");
        let base = temp.path().join("existing");
        std::fs::create_dir_all(&base).expect("existing ancestor should be created");
        let path = base.join("missing").join("..").join("target");

        let normalized =
            normalize_maybe_existing(&path).expect("path should normalize through ancestor");

        assert_eq!(
            normalized,
            canonicalize(&base)
                .expect("base should canonicalize")
                .join("target")
        );
    }

    #[cfg(windows)]
    #[test]
    fn resolve_path_should_reject_drive_relative_windows_paths() {
        let error = resolve_path(Path::new(r"C:\repo"), Path::new(r"C:relative"))
            .expect_err("drive-relative path should fail");

        assert_eq!(error.reason(), "drive-relative paths are not supported");
    }

    #[cfg(windows)]
    #[test]
    fn resolve_path_should_reject_root_relative_windows_paths() {
        let error = resolve_path(Path::new(r"C:\repo"), Path::new(r"\relative"))
            .expect_err("root-relative path should fail");

        assert_eq!(
            error.reason(),
            "root-relative paths without a drive or share are not supported"
        );
    }
}