Skip to main content

omne_cli/commands/
upgrade.rs

1//! `omne upgrade [kernel|distro]` — replace kernel or distro with latest release.
2//!
3//! Walks up from cwd to find `.omne/`, reads `kernel-source` or
4//! `distro-source` from MANIFEST.md frontmatter, fetches the latest
5//! release, removes the old directory (with symlink safety checks),
6//! and extracts the new tarball.
7
8// Test-seam function is called from lib.rs (integration tests).
9#![allow(dead_code)]
10
11use std::path::Path;
12
13use clap::{Args as ClapArgs, ValueEnum};
14
15use crate::error::CliError;
16use crate::fetch;
17use crate::github::GithubClient;
18use crate::manifest;
19use crate::volume;
20
21/// What to upgrade. Default is `kernel`.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
23pub enum Target {
24    Kernel,
25    Distro,
26}
27
28/// Arguments for `omne upgrade`.
29#[derive(Debug, ClapArgs)]
30pub struct Args {
31    /// What to upgrade: `kernel` or `distro`. Defaults to `kernel`.
32    #[arg(value_enum, default_value_t = Target::Kernel)]
33    pub target: Target,
34}
35
36pub fn run(args: &Args) -> Result<(), CliError> {
37    let cwd = std::env::current_dir()
38        .map_err(|e| CliError::Io(format!("cannot determine current directory: {e}")))?;
39    let root = volume::find_omne_root(&cwd).ok_or(CliError::NotAVolume)?;
40    let github = GithubClient::from_env("https://api.github.com", "omne-cli");
41    upgrade_with_client(args.target, &root, &github)
42}
43
44/// Test seam: same logic as `run` but with injected root and client.
45pub fn upgrade_with_client(
46    target: Target,
47    root: &Path,
48    github: &GithubClient,
49) -> Result<(), CliError> {
50    let omne = root.join(".omne");
51
52    // Read `.omne/omne.md` frontmatter (v2 demoted MANIFEST.md → omne.md README).
53    let readme_path = omne.join("omne.md");
54    let readme_content = std::fs::read_to_string(&readme_path).map_err(|e| {
55        CliError::Io(format!(
56            "cannot read {}: {e} — is this an omne volume?",
57            readme_path.display()
58        ))
59    })?;
60    let frontmatter = manifest::parse_frontmatter(&readme_content)?;
61
62    // Determine source and target directory
63    let (org, repo, target_dir, label, expected_top_level) = match target {
64        Target::Kernel => {
65            let (org, repo) = parse_source(&frontmatter.kernel_source)?;
66            let target_dir = omne.join("core");
67            (org, repo, target_dir, "kernel".to_string(), "core")
68        }
69        Target::Distro => {
70            let (org, repo) = parse_source(&frontmatter.distro_source)?;
71            let target_dir = omne.join("dist");
72            (
73                org,
74                repo.clone(),
75                target_dir,
76                format!("distro ({repo})"),
77                "dist",
78            )
79        }
80    };
81
82    // Symlink safety pre-check — bail before any network I/O.
83    if target_dir.exists() {
84        verify_no_symlinks(&target_dir, &omne)?;
85    }
86
87    // Fetch latest release tag
88    let tag = github.latest_release_tag(&org, &repo)?;
89    eprintln!("Upgrading {label} to {tag}...");
90
91    // Re-verify symlink safety after network I/O to close the TOCTOU
92    // window: a symlink could have been planted during the fetch.
93    if target_dir.exists() {
94        verify_no_symlinks(&target_dir, &omne)?;
95        std::fs::remove_dir_all(&target_dir)?;
96    }
97
98    // Download and extract new release
99    fetch::download_and_extract(github, &org, &repo, &tag, &omne, expected_top_level)?;
100
101    eprintln!("\x1b[32m✓\x1b[0m Upgrade complete ({label}).");
102    Ok(())
103}
104
105/// Parse a `"org/repo"` source string.
106fn parse_source(source: &str) -> Result<(String, String), CliError> {
107    let (org, repo) =
108        source
109            .split_once('/')
110            .ok_or_else(|| crate::manifest::Error::InvalidSourceFormat {
111                value: source.to_string(),
112            })?;
113    Ok((org.to_string(), repo.to_string()))
114}
115
116/// Verify that `target_dir` and all ancestors up to `boundary` are real
117/// directories, not symlinks. Returns `Err(UnsafeTarget)` if any symlink
118/// is found.
119fn verify_no_symlinks(target_dir: &Path, boundary: &Path) -> Result<(), CliError> {
120    // Check target itself
121    if target_dir.symlink_metadata()?.file_type().is_symlink() {
122        return Err(CliError::UnsafeTarget {
123            path: target_dir.to_path_buf(),
124        });
125    }
126
127    // Walk ancestors from target up to and including boundary
128    let mut current = target_dir.to_path_buf();
129    while let Some(parent) = current.parent() {
130        if parent.symlink_metadata()?.file_type().is_symlink() {
131            return Err(CliError::UnsafeTarget {
132                path: parent.to_path_buf(),
133            });
134        }
135        if parent == boundary {
136            break;
137        }
138        current = parent.to_path_buf();
139    }
140
141    Ok(())
142}