omne-cli 0.2.1

CLI for managing omne volumes: init, upgrade, and validate kernel and distro releases
Documentation
//! `omne upgrade [kernel|distro]` — replace kernel or distro with latest release.
//!
//! Walks up from cwd to find `.omne/`, reads `kernel-source` or
//! `distro-source` from MANIFEST.md frontmatter, fetches the latest
//! release, removes the old directory (with symlink safety checks),
//! and extracts the new tarball.

// Test-seam function is called from lib.rs (integration tests).
#![allow(dead_code)]

use std::path::Path;

use clap::{Args as ClapArgs, ValueEnum};

use crate::error::CliError;
use crate::fetch;
use crate::github::GithubClient;
use crate::manifest;
use crate::volume;

/// What to upgrade. Default is `kernel`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Target {
    Kernel,
    Distro,
}

/// Arguments for `omne upgrade`.
#[derive(Debug, ClapArgs)]
pub struct Args {
    /// What to upgrade: `kernel` or `distro`. Defaults to `kernel`.
    #[arg(value_enum, default_value_t = Target::Kernel)]
    pub target: Target,
}

pub fn run(args: &Args) -> Result<(), CliError> {
    let cwd = std::env::current_dir()
        .map_err(|e| CliError::Io(format!("cannot determine current directory: {e}")))?;
    let root = volume::find_omne_root(&cwd).ok_or(CliError::NotAVolume)?;
    let github = GithubClient::from_env("https://api.github.com", "omne-cli");
    upgrade_with_client(args.target, &root, &github)
}

/// Test seam: same logic as `run` but with injected root and client.
pub fn upgrade_with_client(
    target: Target,
    root: &Path,
    github: &GithubClient,
) -> Result<(), CliError> {
    let omne = root.join(".omne");

    // Read `.omne/omne.md` frontmatter (v2 demoted MANIFEST.md → omne.md README).
    let readme_path = omne.join("omne.md");
    let readme_content = std::fs::read_to_string(&readme_path).map_err(|e| {
        CliError::Io(format!(
            "cannot read {}: {e} — is this an omne volume?",
            readme_path.display()
        ))
    })?;
    let frontmatter = manifest::parse_frontmatter(&readme_content)?;

    // Determine source and target directory
    let (org, repo, target_dir, label, expected_top_level) = match target {
        Target::Kernel => {
            let (org, repo) = parse_source(&frontmatter.kernel_source)?;
            let target_dir = omne.join("core");
            (org, repo, target_dir, "kernel".to_string(), "core")
        }
        Target::Distro => {
            let (org, repo) = parse_source(&frontmatter.distro_source)?;
            let target_dir = omne.join("dist");
            (
                org,
                repo.clone(),
                target_dir,
                format!("distro ({repo})"),
                "dist",
            )
        }
    };

    // Symlink safety pre-check — bail before any network I/O.
    if target_dir.exists() {
        verify_no_symlinks(&target_dir, &omne)?;
    }

    // Fetch latest release tag
    let tag = github.latest_release_tag(&org, &repo)?;
    eprintln!("Upgrading {label} to {tag}...");

    // Re-verify symlink safety after network I/O to close the TOCTOU
    // window: a symlink could have been planted during the fetch.
    if target_dir.exists() {
        verify_no_symlinks(&target_dir, &omne)?;
        std::fs::remove_dir_all(&target_dir)?;
    }

    // Download and extract new release
    fetch::download_and_extract(github, &org, &repo, &tag, &omne, expected_top_level)?;

    eprintln!("\x1b[32m✓\x1b[0m Upgrade complete ({label}).");
    Ok(())
}

/// Parse a `"org/repo"` source string.
fn parse_source(source: &str) -> Result<(String, String), CliError> {
    let (org, repo) =
        source
            .split_once('/')
            .ok_or_else(|| crate::manifest::Error::InvalidSourceFormat {
                value: source.to_string(),
            })?;
    Ok((org.to_string(), repo.to_string()))
}

/// Verify that `target_dir` and all ancestors up to `boundary` are real
/// directories, not symlinks. Returns `Err(UnsafeTarget)` if any symlink
/// is found.
fn verify_no_symlinks(target_dir: &Path, boundary: &Path) -> Result<(), CliError> {
    // Check target itself
    if target_dir.symlink_metadata()?.file_type().is_symlink() {
        return Err(CliError::UnsafeTarget {
            path: target_dir.to_path_buf(),
        });
    }

    // Walk ancestors from target up to and including boundary
    let mut current = target_dir.to_path_buf();
    while let Some(parent) = current.parent() {
        if parent.symlink_metadata()?.file_type().is_symlink() {
            return Err(CliError::UnsafeTarget {
                path: parent.to_path_buf(),
            });
        }
        if parent == boundary {
            break;
        }
        current = parent.to_path_buf();
    }

    Ok(())
}