From 3363ec5e3dcffcea3ffd08362aec4bd97595199f Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Sun, 16 Aug 2026 12:36:08 -0700 Subject: [PATCH 1/8] feat(setup): enhance loader with tool installer and config isolation --- AGENTS.md | 1 + assets/nushell-loader/loader.nu | 49 ++-- src/cmd/mod.rs | 1 + src/cmd/setup.rs | 454 +++++++++++++++++++++++++++++++- src/cmd/setup_tools.rs | 447 +++++++++++++++++++++++++++++++ tests/setup_test.rs | 148 ++++++++++- 6 files changed, 1073 insertions(+), 27 deletions(-) create mode 100644 src/cmd/setup_tools.rs diff --git a/AGENTS.md b/AGENTS.md index af0ff3c4..12890712 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ src/ nupm.rs — `numan nupm status|inspect|import|diff`: nupm discovery + import + drift (Phase 6.1–6.3) completions.rs — `numan completions `: install by default (mkdir+write); `--print` for stdout (Phase 7.3) setup.rs — `numan setup nu [VERSION]|remove|path|use ` + `setup loader`: Nushell bootstrap + nushell-loader install + setup_tools.rs — CLI shell tool presets + GitHub release binary installer (starship, zoxide, carapace, atuin, mise, direnv, oh-my-posh) try_cmd.rs — `numan try [--no-activate]`: attempt a package for current Nu; explain compatible managed Nu versions if incompatible use_cmd.rs — `numan use |latest|list`: activates a previously installed managed Nu version (no auto-download); cross-minor leave/teardown (modules then plugins) + restore (plugins then modules) via activation profiles; same-target is restore-only; writes the active-version marker after a PreMutation snapshot under the root mutation lock activation_switch.rs — shared leave/restore orchestration for `numan use` (lower-level lifecycle, no profile-sync wrappers) diff --git a/assets/nushell-loader/loader.nu b/assets/nushell-loader/loader.nu index eab72090..caea7f00 100644 --- a/assets/nushell-loader/loader.nu +++ b/assets/nushell-loader/loader.nu @@ -4,40 +4,55 @@ # Installed by `numan setup loader`. Re-run with --force to update. let autoload_dir: path = $nu.data-dir | path join "vendor/autoload" - mkdir $autoload_dir -# Place init commands here, in the following format: -# let aidnem_loader_configs = [ -# {name: 'starship', command: "starship init nu" } -# {name: 'zoxide', command: "zoxide init nushell" } -# {name: 'carapace', command: "carapace _carapace nushell"} -# ] -let aidnem_loader_configs: list = [] +# Configuration is loaded from `loader-config.nu` in the same directory as loader.nu +let loader_config_file = ($nu.config-path | path dirname | path join 'loader-config.nu') + +let aidnem_loader_configs: list = if ($loader_config_file | path exists) { + source $loader_config_file + $aidnem_loader_configs +} else { + [] +} -def _aidnem_loader_get_file_from_name [name] { +def _aidnem_loader_get_file_from_name [name: string] { { parent: $autoload_dir, stem: $name, extension: 'nu' } | path join } for item in $aidnem_loader_configs { let target = _aidnem_loader_get_file_from_name $item.name if not ($target | path exists) { - print $"[Aidnem Loader] File not found for ($item.name), generating it now." - print $"[Aidnem Loader] Running `($item.command) | save ($target)`" - nu -n -c $item.command | save $target + print $"[Aidnem Loader] Generating cache for ($item.name)..." + try { + let res = (nu -n -c $item.command | complete) + if $res.exit_code == 0 and not ($res.stdout | is-empty) { + $res.stdout | save -f $target + print $"[Aidnem Loader] Successfully cached ($item.name) -> ($target)" + } else { + print -e $"[Aidnem Loader] Warning: Failed to generate ($item.name) (exit code ($res.exit_code))" + if not ($res.stderr | is-empty) { + print -e $"[Aidnem Loader] ($res.stderr)" + } + } + } catch { |err| + print -e $"[Aidnem Loader] Error generating ($item.name): ($err.msg)" + } } } -def _aidnem_loader_completer [context: string, position: int]: nothing -> list { - $aidnem_loader_configs | get name +def _aidnem_loader_completer [context: string, position: int]: nothing -> list { + $aidnem_loader_configs | get -i name | default [] } # Remove a cached init file so that it will be regenerated on next startup. -# Configs are listed in $aidnem_loader_configs +# Configs are listed in $aidnem_loader_configs (from loader-config.nu) def aidnem_loader_remove_file [...names: string@_aidnem_loader_completer]: nothing -> nothing { for name in $names { let target = _aidnem_loader_get_file_from_name $name - print $"[Aidnem Loader] Removing ($target)" - rm $target + if ($target | path exists) { + print $"[Aidnem Loader] Removing ($target)" + rm -f $target + } } } diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 133cfcd5..e497be8d 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -16,6 +16,7 @@ pub mod remove; pub mod search; pub mod self_update; pub mod setup; +pub mod setup_tools; pub mod snapshot; pub mod try_cmd; pub mod update; diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs index e27ae5f3..8f87e86a 100644 --- a/src/cmd/setup.rs +++ b/src/cmd/setup.rs @@ -193,7 +193,7 @@ impl NuSetupArgs { } } -#[derive(Debug, Args)] +#[derive(Debug, Args, Clone, Default)] pub struct LoaderArgs { /// Overwrite an existing loader.nu without prompting #[arg(long)] @@ -206,6 +206,34 @@ pub struct LoaderArgs { /// Skip confirmation prompts #[arg(long)] pub yes: bool, + + /// Display current status of loader, configured tools, and cache files + #[arg(long)] + pub status: bool, + + /// Scan PATH for known CLI tools (starship, zoxide, carapace, etc.) and configure them + #[arg(long)] + pub detect: bool, + + /// Add a tool preset (e.g. starship, zoxide) or custom name=command pair + #[arg(long, value_name = "TOOL")] + pub add: Option, + + /// Remove a tool from loader configuration and delete its cached autoload file + #[arg(long, value_name = "TOOL")] + pub remove: Option, + + /// Invalidate and remove cached tool init files + #[arg(long)] + pub clean: bool, + + /// Automatically download and install missing tools from GitHub + #[arg(long)] + pub install: bool, + + /// Automatically download and install missing tools when detected or added + #[arg(long)] + pub install_missing: bool, } pub fn execute(cmd: SetupCommands, root: &Path) -> Result<()> { @@ -708,7 +736,7 @@ pub fn execute_loader(args: &LoaderArgs, root: &Path) -> Result<()> { // numan use). The probe helper below stays unlocked so unit tests can // inject a fake config path without contending on the advisory lock. setup_subcommand_lock(root, "nushell-loader install", || { - execute_loader_with_probe(args, || { + execute_loader_with_probe_and_root(args, Some(root), || { let nu_exe = find_nu_executable_with_root(root)?; probe_nu_config_path(&nu_exe) }) @@ -720,6 +748,18 @@ pub fn execute_loader(args: &LoaderArgs, root: &Path) -> Result<()> { /// Unlocked test seam — production callers must go through [`execute_loader`], /// which acquires [`setup_subcommand_lock`]. pub fn execute_loader_with_probe(args: &LoaderArgs, probe: F) -> Result<()> +where + F: FnOnce() -> Result, +{ + execute_loader_with_probe_and_root(args, None, probe) +} + +/// Unlocked test seam supporting root injection. +pub fn execute_loader_with_probe_and_root( + args: &LoaderArgs, + root: Option<&Path>, + probe: F, +) -> Result<()> where F: FnOnce() -> Result, { @@ -728,6 +768,7 @@ where .parent() .context("Nu config path has no parent directory")?; let loader_path = config_dir.join("loader.nu"); + let loader_config_path = config_dir.join("loader-config.nu"); std::fs::create_dir_all(config_dir).with_context(|| { format!( @@ -736,15 +777,389 @@ where ) })?; + // 1. Status action + if args.status { + return execute_loader_status(&loader_path, &loader_config_path, &config_path, root); + } + + // 2. Clean action + if args.clean { + return execute_loader_clean(&loader_config_path, root); + } + + // 3. Remove action + if let Some(tool_name) = &args.remove { + return execute_loader_remove(&loader_config_path, tool_name, root); + } + + // Install/update loader engine file install_loader_file(&loader_path, args)?; + // Ensure loader-config.nu exists + if !loader_config_path.exists() { + write_loader_config(&loader_config_path, &[])?; + } + + let should_install = args.install || args.install_missing; + + // 4. Add action + if let Some(tool_spec) = &args.add { + execute_loader_add( + &loader_config_path, + tool_spec, + root, + should_install, + args.yes, + )?; + } + + // 5. Detect action + if args.detect { + execute_loader_detect(&loader_config_path, root, should_install, args.yes)?; + } + + // Configure config.nu if requested if args.configure { configure_config_nu(&config_path, args)?; - } else { + } else if args.add.is_none() && !args.detect { print_manual_snippet(&config_path); } - print_next_steps(&loader_path, args.configure); + if args.add.is_none() && !args.detect { + print_next_steps(&loader_path, &loader_config_path, args.configure); + } + + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoaderConfigEntry { + pub name: String, + pub command: String, +} + +pub fn read_loader_config(config_path: &Path) -> Result> { + if !config_path.is_file() { + return Ok(Vec::new()); + } + let content = std::fs::read_to_string(config_path) + .with_context(|| format!("Failed to read '{}'", config_path.display()))?; + Ok(parse_loader_config(&content)) +} + +pub fn parse_loader_config(content: &str) -> Vec { + let mut entries = Vec::new(); + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('#') || trimmed.starts_with("//") { + continue; + } + if let Some(entry) = parse_loader_record_line(trimmed) { + entries.push(entry); + } + } + entries +} + +fn parse_loader_record_line(line: &str) -> Option { + let name_idx = line.find("name:")?; + let after_name = &line[name_idx + 5..].trim_start(); + let name_quote = after_name.chars().next()?; + if name_quote != '\'' && name_quote != '"' { + return None; + } + let name_end = after_name[1..].find(name_quote)?; + let name = &after_name[1..1 + name_end]; + + let cmd_idx = line.find("command:")?; + let after_cmd = &line[cmd_idx + 8..].trim_start(); + let cmd_quote = after_cmd.chars().next()?; + if cmd_quote != '\'' && cmd_quote != '"' { + return None; + } + let cmd_end = after_cmd[1..].find(cmd_quote)?; + let command = &after_cmd[1..1 + cmd_end]; + + Some(LoaderConfigEntry { + name: name.to_string(), + command: command.to_string(), + }) +} + +pub fn render_loader_config(entries: &[LoaderConfigEntry]) -> String { + let mut out = String::from( + "# Generated by Numan. Tool configurations for nushell-loader.\n# Manage via `numan setup loader --add ` or `numan setup loader --remove `.\n\nlet aidnem_loader_configs: list = [\n", + ); + for e in entries { + out.push_str(&format!( + " {{ name: '{}', command: \"{}\" }}\n", + e.name, + e.command.replace('"', "\\\"") + )); + } + out.push_str("]\n"); + out +} + +pub fn write_loader_config(config_path: &Path, entries: &[LoaderConfigEntry]) -> Result<()> { + let rendered = render_loader_config(entries); + write_bytes_atomic(config_path, rendered.as_bytes()) + .with_context(|| format!("Failed to write '{}'", config_path.display()))?; + Ok(()) +} + +fn resolve_vendor_autoload_dir(root: Option<&Path>) -> Option { + if let Some(r) = root { + if let Ok(paths) = crate::nu::paths::NuPaths::load(r) { + if let Some(v) = paths.vendor_autoload_dir { + return Some(PathBuf::from(v)); + } + if let Some(data) = paths.data_dir { + return Some(Path::new(&data).join("vendor").join("autoload")); + } + } + } + dirs::data_dir().map(|d| d.join("nushell").join("vendor").join("autoload")) +} + +fn execute_loader_status( + loader_path: &Path, + loader_config_path: &Path, + config_path: &Path, + root: Option<&Path>, +) -> Result<()> { + println!("Nushell Loader Status:"); + println!(" Engine script: {}", loader_path.display()); + if loader_path.is_file() { + println!(" Status: installed"); + } else { + println!(" Status: not installed (run 'numan setup loader')"); + } + + println!(" Config file: {}", loader_config_path.display()); + let entries = read_loader_config(loader_config_path).unwrap_or_default(); + println!(" Configured tools ({}):", entries.len()); + + let autoload_dir = resolve_vendor_autoload_dir(root); + + for e in &entries { + let bin_found = crate::cmd::setup_tools::find_binary_on_path(&e.name, root); + let bin_status = match bin_found { + Some(p) => format!("found at {}", p.display()), + None => "missing from PATH".to_string(), + }; + + let cache_status = if let Some(ref ad) = autoload_dir { + let cache_file = ad.join(format!("{}.nu", e.name)); + if cache_file.is_file() { + "cached in vendor/autoload" + } else { + "not yet cached (will generate on startup)" + } + } else { + "unknown vendor autoload dir" + }; + + println!(" • {}:", e.name); + println!(" Command: {}", e.command); + println!(" Binary: {}", bin_status); + println!(" Cache: {}", cache_status); + } + + if config_path.is_file() { + let content = std::fs::read_to_string(config_path).unwrap_or_default(); + let sourced = config_already_sources_loader(&content); + println!( + " Sourced in config.nu: {}", + if sourced { + "yes" + } else { + "no (run 'numan setup loader --configure')" + } + ); + } + + Ok(()) +} + +fn execute_loader_clean(loader_config_path: &Path, root: Option<&Path>) -> Result<()> { + let entries = read_loader_config(loader_config_path).unwrap_or_default(); + let Some(autoload_dir) = resolve_vendor_autoload_dir(root) else { + println!("Could not determine vendor/autoload directory to clean."); + return Ok(()); + }; + + if !autoload_dir.is_dir() { + println!( + "Vendor autoload directory '{}' does not exist.", + autoload_dir.display() + ); + return Ok(()); + } + + let mut removed = 0; + for e in &entries { + let target = autoload_dir.join(format!("{}.nu", e.name)); + if target.is_file() { + let _ = std::fs::remove_file(&target); + println!("Removed cache '{}'", target.display()); + removed += 1; + } + } + + println!("Cleaned {} cached loader file(s).", removed); + Ok(()) +} + +fn execute_loader_remove( + loader_config_path: &Path, + tool_name: &str, + root: Option<&Path>, +) -> Result<()> { + let mut entries = read_loader_config(loader_config_path)?; + let initial_len = entries.len(); + entries.retain(|e| e.name != tool_name); + + if entries.len() == initial_len { + println!("Tool '{}' is not registered in loader config.", tool_name); + return Ok(()); + } + + write_loader_config(loader_config_path, &entries)?; + println!( + "Removed '{}' from '{}'.", + tool_name, + loader_config_path.display() + ); + + if let Some(autoload_dir) = resolve_vendor_autoload_dir(root) { + let target = autoload_dir.join(format!("{tool_name}.nu")); + if target.is_file() { + let _ = std::fs::remove_file(&target); + println!("Removed cached autoload file '{}'.", target.display()); + } + } + + Ok(()) +} + +fn execute_loader_add( + loader_config_path: &Path, + tool_spec: &str, + root: Option<&Path>, + should_install: bool, + yes: bool, +) -> Result<()> { + let (name, command) = if let Some(preset) = crate::cmd::setup_tools::find_preset(tool_spec) { + (preset.name.to_string(), preset.init_command.to_string()) + } else if let Some((n, cmd)) = tool_spec.split_once('=') { + (n.trim().to_string(), cmd.trim().to_string()) + } else { + bail!( + "Unknown tool preset '{}'. Use a known preset (starship, zoxide, carapace, atuin, mise, direnv, oh-my-posh) or specify 'name=command'.", + tool_spec + ); + }; + + let bin_found = crate::cmd::setup_tools::find_binary_on_path(&name, root); + if bin_found.is_none() { + if let Some(preset) = crate::cmd::setup_tools::find_preset(&name) { + if should_install { + if let Some(r) = root { + crate::cmd::setup_tools::download_and_install_tool( + preset, + r, + &Platform::detect(), + )?; + } else { + println!("Cannot install tool without a Numan root directory."); + } + } else if !yes { + println!( + "Notice: '{}' is not currently on your PATH. Pass '--install' to download it automatically.", + name + ); + } + } else { + println!("Notice: '{}' binary was not found on PATH.", name); + } + } + + let mut entries = read_loader_config(loader_config_path)?; + if let Some(existing) = entries.iter_mut().find(|e| e.name == name) { + existing.command = command.clone(); + println!("Updated '{}' command in loader config.", name); + } else { + entries.push(LoaderConfigEntry { + name: name.clone(), + command: command.clone(), + }); + println!( + "Added '{}' (command: \"{}\") to loader config.", + name, command + ); + } + + write_loader_config(loader_config_path, &entries)?; + Ok(()) +} + +fn execute_loader_detect( + loader_config_path: &Path, + root: Option<&Path>, + should_install: bool, + _yes: bool, +) -> Result<()> { + let mut entries = read_loader_config(loader_config_path)?; + let mut added_count = 0; + + for preset in crate::cmd::setup_tools::KNOWN_TOOLS { + let is_configured = entries.iter().any(|e| e.name == preset.name); + let bin_found = crate::cmd::setup_tools::find_binary_on_path(preset.binary_name, root); + + if bin_found.is_some() { + if !is_configured { + entries.push(LoaderConfigEntry { + name: preset.name.to_string(), + command: preset.init_command.to_string(), + }); + println!( + "Detected '{}' on PATH -> added to loader config (command: \"{}\").", + preset.display_name, preset.init_command + ); + added_count += 1; + } + } else if should_install && !is_configured { + if let Some(r) = root { + println!("Installing missing preset '{}'…", preset.display_name); + if crate::cmd::setup_tools::download_and_install_tool( + preset, + r, + &Platform::detect(), + ) + .is_ok() + { + entries.push(LoaderConfigEntry { + name: preset.name.to_string(), + command: preset.init_command.to_string(), + }); + added_count += 1; + } + } + } + } + + if added_count > 0 { + write_loader_config(loader_config_path, &entries)?; + println!( + "Updated '{}' with {} tool(s).", + loader_config_path.display(), + added_count + ); + } else { + println!("No new tools detected to add."); + } + Ok(()) } @@ -854,15 +1269,13 @@ fn print_manual_snippet(config_path: &Path) { println!("{CONFIG_SNIPPET}"); } -fn print_next_steps(loader_path: &Path, configured: bool) { +fn print_next_steps(_loader_path: &Path, loader_config_path: &Path, configured: bool) { println!(); println!("Next steps:"); println!( - " 1. Edit '{}' and add entries to aidnem_loader_configs.", - loader_path.display() + " 1. Configure tools with 'numan setup loader --detect' or 'numan setup loader --add '.\n (Configurations are stored in '{}')", + loader_config_path.display() ); - println!(" Example:"); - println!(" {{name: 'starship', command: \"starship init nu\"}}"); if !configured { println!(" 2. Source loader.nu from config.nu (see snippet above)."); println!(" 3. Restart Nu. First startup generates caches; later startups are faster."); @@ -917,6 +1330,7 @@ mod tests { force: false, configure: false, yes: true, + ..Default::default() }; install_loader_file(&loader_path, &args).unwrap(); @@ -934,6 +1348,7 @@ mod tests { force: false, configure: false, yes: true, + ..Default::default() }; install_loader_file(&loader_path, &args).unwrap(); assert_eq!( @@ -957,6 +1372,7 @@ mod tests { force: false, configure: true, yes: true, + ..Default::default() }; let err = configure_config_nu(&config_path, &args).unwrap_err(); assert!(err.to_string().contains("symlink")); @@ -971,6 +1387,7 @@ mod tests { force: false, configure: true, yes: true, + ..Default::default() }; configure_config_nu(&config_path, &args).unwrap(); @@ -989,6 +1406,7 @@ mod tests { force: false, configure: true, yes: true, + ..Default::default() }; execute_loader_with_probe(&args, || Ok(config_path.clone())).unwrap(); @@ -997,6 +1415,24 @@ mod tests { assert!(config_already_sources_loader(&config)); } + #[test] + fn loader_config_parsing_and_rendering_roundtrips() { + let entries = vec![ + LoaderConfigEntry { + name: "starship".to_string(), + command: "starship init nu".to_string(), + }, + LoaderConfigEntry { + name: "zoxide".to_string(), + command: "zoxide init nushell".to_string(), + }, + ]; + + let rendered = render_loader_config(&entries); + let parsed = parse_loader_config(&rendered); + assert_eq!(entries, parsed); + } + #[test] fn remove_managed_nu_removes_directory() { let dir = TempDir::new().unwrap(); diff --git a/src/cmd/setup_tools.rs b/src/cmd/setup_tools.rs new file mode 100644 index 00000000..6a359185 --- /dev/null +++ b/src/cmd/setup_tools.rs @@ -0,0 +1,447 @@ +//! Tool presets and GitHub release binary installer for CLI shell integrations. + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +use crate::core::platform::{Arch, Os, Platform}; +use crate::install::download::download_file; +use crate::install::extract::{extract_archive, ArchiveFormat, ExtractConfig}; +use crate::nu::bootstrap::{persist_path_dir, prepend_process_path}; + +const USER_AGENT: &str = "numan-cli (https://github.com/tonythethompson/numan)"; + +#[derive(Debug, Clone)] +pub struct ToolPreset { + pub name: &'static str, + pub display_name: &'static str, + pub description: &'static str, + pub init_command: &'static str, + pub binary_name: &'static str, + pub github_repo: &'static str, + pub is_direct_binary: bool, +} + +pub const KNOWN_TOOLS: &[ToolPreset] = &[ + ToolPreset { + name: "starship", + display_name: "Starship", + description: "The minimal, blazing-fast, and infinitely customizable prompt for any shell!", + init_command: "starship init nu", + binary_name: "starship", + github_repo: "starship/starship", + is_direct_binary: false, + }, + ToolPreset { + name: "zoxide", + display_name: "Zoxide", + description: "A smarter cd command for your terminal", + init_command: "zoxide init nushell", + binary_name: "zoxide", + github_repo: "ajeetdsouza/zoxide", + is_direct_binary: false, + }, + ToolPreset { + name: "carapace", + display_name: "Carapace", + description: "Multi-shell multi-command completion generator", + init_command: "carapace _carapace nushell", + binary_name: "carapace", + github_repo: "carapace-sh/carapace-bin", + is_direct_binary: false, + }, + ToolPreset { + name: "atuin", + display_name: "Atuin", + description: "Magical shell history across terminals and machines", + init_command: "atuin init nu", + binary_name: "atuin", + github_repo: "atuinsh/atuin", + is_direct_binary: false, + }, + ToolPreset { + name: "mise", + display_name: "Mise", + description: "Polyglot dev tool manager, environment variables, and task runner", + init_command: "mise activate nu", + binary_name: "mise", + github_repo: "jdx/mise", + is_direct_binary: false, + }, + ToolPreset { + name: "direnv", + display_name: "Direnv", + description: "Unclutter your .profile and load directory environment variables", + init_command: "direnv hook nu", + binary_name: "direnv", + github_repo: "direnv/direnv", + is_direct_binary: true, + }, + ToolPreset { + name: "oh-my-posh", + display_name: "Oh My Posh", + description: "A prompt theme engine for any shell", + init_command: "oh-my-posh init nu", + binary_name: "oh-my-posh", + github_repo: "JanDeDobbeleer/oh-my-posh", + is_direct_binary: true, + }, +]; + +pub fn find_preset(name: &str) -> Option<&'static ToolPreset> { + let lower = name.to_ascii_lowercase(); + KNOWN_TOOLS.iter().find(|t| t.name == lower) +} + +pub fn tools_bin_dir(root: &Path) -> PathBuf { + root.join("tools").join("bin") +} + +pub fn binary_file_name(base: &str) -> String { + if cfg!(windows) && !base.ends_with(".exe") { + format!("{base}.exe") + } else { + base.to_string() + } +} + +/// Search PATH and `$NUMAN_ROOT/tools/bin` for an executable binary. +pub fn find_binary_on_path(base_name: &str, root: Option<&Path>) -> Option { + let target = binary_file_name(base_name); + + if let Some(r) = root { + let in_tools = tools_bin_dir(r).join(&target); + if in_tools.is_file() { + return Some(in_tools); + } + } + + let path_var = std::env::var("PATH").unwrap_or_default(); + #[cfg(windows)] + let separator = ';'; + #[cfg(not(windows))] + let separator = ':'; + + for dir in path_var.split(separator) { + let trimmed = dir.trim(); + if trimmed.is_empty() { + continue; + } + let candidate = Path::new(trimmed).join(&target); + if candidate.is_file() { + return Some(candidate); + } + } + + None +} + +#[derive(Debug, Deserialize)] +struct GitHubRelease { + tag_name: String, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, + #[serde(default)] + #[allow(dead_code)] + size: u64, +} + +#[allow(unreachable_patterns)] +fn matches_tool_asset(tool: &ToolPreset, asset_name: &str, platform: &Platform) -> bool { + let name = asset_name.to_ascii_lowercase(); + + match tool.name { + "starship" => match (platform.os, platform.arch) { + (Os::Windows, Arch::X86_64) => name.contains("x86_64-pc-windows-msvc.zip"), + (Os::Windows, Arch::Aarch64) => name.contains("aarch64-pc-windows-msvc.zip"), + (Os::Linux, Arch::X86_64) => { + name.contains("x86_64-unknown-linux") && name.ends_with(".tar.gz") + } + (Os::Linux, Arch::Aarch64) => { + name.contains("aarch64-unknown-linux") && name.ends_with(".tar.gz") + } + (Os::Macos, Arch::X86_64) => name.contains("x86_64-apple-darwin.tar.gz"), + (Os::Macos, Arch::Aarch64) => name.contains("aarch64-apple-darwin.tar.gz"), + _ => false, + }, + "zoxide" => match (platform.os, platform.arch) { + (Os::Windows, Arch::X86_64) => name.contains("x86_64-pc-windows-msvc.zip"), + (Os::Linux, Arch::X86_64) => { + name.contains("x86_64-unknown-linux") && name.ends_with(".tar.gz") + } + (Os::Linux, Arch::Aarch64) => { + name.contains("aarch64-unknown-linux") && name.ends_with(".tar.gz") + } + (Os::Macos, Arch::X86_64) => name.contains("x86_64-apple-darwin.tar.gz"), + (Os::Macos, Arch::Aarch64) => name.contains("aarch64-apple-darwin.tar.gz"), + _ => false, + }, + "carapace" => match (platform.os, platform.arch) { + (Os::Windows, Arch::X86_64) => name.contains("windows_amd64.zip"), + (Os::Windows, Arch::Aarch64) => name.contains("windows_arm64.zip"), + (Os::Linux, Arch::X86_64) => name.contains("linux_amd64.tar.gz"), + (Os::Linux, Arch::Aarch64) => name.contains("linux_arm64.tar.gz"), + (Os::Macos, Arch::X86_64) => name.contains("darwin_amd64.tar.gz"), + (Os::Macos, Arch::Aarch64) => name.contains("darwin_arm64.tar.gz"), + _ => false, + }, + "atuin" => match (platform.os, platform.arch) { + (Os::Windows, Arch::X86_64) => name.contains("x86_64-pc-windows-msvc.zip"), + (Os::Linux, Arch::X86_64) => { + name.contains("x86_64-unknown-linux") && name.ends_with(".tar.gz") + } + (Os::Linux, Arch::Aarch64) => { + name.contains("aarch64-unknown-linux") && name.ends_with(".tar.gz") + } + (Os::Macos, Arch::X86_64) => name.contains("x86_64-apple-darwin.tar.gz"), + (Os::Macos, Arch::Aarch64) => name.contains("aarch64-apple-darwin.tar.gz"), + _ => false, + }, + "mise" => match (platform.os, platform.arch) { + (Os::Windows, Arch::X86_64) => name.contains("win-x64.zip"), + (Os::Windows, Arch::Aarch64) => name.contains("win-arm64.zip"), + (Os::Linux, Arch::X86_64) => name.contains("linux-x64.tar.gz"), + (Os::Linux, Arch::Aarch64) => name.contains("linux-arm64.tar.gz"), + (Os::Macos, Arch::X86_64) => name.contains("macos-x64.tar.gz"), + (Os::Macos, Arch::Aarch64) => name.contains("macos-arm64.tar.gz"), + _ => false, + }, + "direnv" => match (platform.os, platform.arch) { + (Os::Windows, Arch::X86_64) => name.contains("windows-amd64"), + (Os::Windows, Arch::Aarch64) => name.contains("windows-arm64"), + (Os::Linux, Arch::X86_64) => name == "direnv.linux-amd64", + (Os::Linux, Arch::Aarch64) => name == "direnv.linux-arm64", + (Os::Macos, Arch::X86_64) => name == "direnv.darwin-amd64", + (Os::Macos, Arch::Aarch64) => name == "direnv.darwin-arm64", + _ => false, + }, + "oh-my-posh" => match (platform.os, platform.arch) { + (Os::Windows, Arch::X86_64) => name == "posh-windows-amd64.exe", + (Os::Windows, Arch::Aarch64) => name == "posh-windows-arm64.exe", + (Os::Linux, Arch::X86_64) => name == "posh-linux-amd64", + (Os::Linux, Arch::Aarch64) => name == "posh-linux-arm64", + (Os::Macos, Arch::X86_64) => name == "posh-darwin-amd64", + (Os::Macos, Arch::Aarch64) => name == "posh-darwin-arm64", + _ => false, + }, + _ => false, + } +} + +fn fetch_latest_release(repo: &str) -> Result { + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .user_agent(USER_AGENT) + .build() + .context("Failed to build HTTP client for tool download")?; + + let url = format!("https://api.github.com/repos/{repo}/releases/latest"); + let response = client + .get(&url) + .send() + .with_context(|| format!("Failed to fetch release metadata for {repo}"))?; + + if !response.status().is_success() { + bail!( + "Failed to fetch release for {repo}: HTTP {}", + response.status() + ); + } + + let text = response.text()?; + serde_json::from_str::(&text) + .with_context(|| format!("Failed to parse release JSON for {repo}")) +} + +#[cfg(unix)] +fn make_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(path) + .with_context(|| format!("Failed to read metadata for '{}'", path.display()))? + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(path, perms) + .with_context(|| format!("Failed to mark '{}' executable", path.display()))?; + Ok(()) +} + +#[cfg(not(unix))] +fn make_executable(_path: &Path) -> Result<()> { + Ok(()) +} + +fn find_extracted_binary(extract_dir: &Path, expected_name: &str) -> Result { + let bin_target = binary_file_name(expected_name); + let direct = extract_dir.join(&bin_target); + if direct.is_file() { + return Ok(direct); + } + + // Search 2 levels deep + for entry in std::fs::read_dir(extract_dir)? { + let entry = entry?; + let p = entry.path(); + if p.is_file() && p.file_name().and_then(|n| n.to_str()) == Some(&bin_target) { + return Ok(p); + } + if p.is_dir() { + for sub in std::fs::read_dir(&p)? { + let sub = sub?; + let sub_p = sub.path(); + if sub_p.is_file() + && sub_p.file_name().and_then(|n| n.to_str()) == Some(&bin_target) + { + return Ok(sub_p); + } + } + } + } + + bail!( + "Could not find '{}' in extracted archive at '{}'", + bin_target, + extract_dir.display() + ) +} + +/// Download and install a tool preset from GitHub into `$NUMAN_ROOT/tools/bin`. +pub fn download_and_install_tool( + tool: &ToolPreset, + root: &Path, + platform: &Platform, +) -> Result { + let release = fetch_latest_release(tool.github_repo)?; + let asset = release + .assets + .iter() + .find(|a| matches_tool_asset(tool, &a.name, platform)) + .with_context(|| { + format!( + "No release asset found for {} ({}) on platform {}", + tool.display_name, release.tag_name, platform.triple + ) + })?; + + let cache_dir = root.join("tools").join(".cache"); + std::fs::create_dir_all(&cache_dir)?; + let download_dest = cache_dir.join(&asset.name); + + println!( + "Downloading {} {} ({})…", + tool.display_name, release.tag_name, asset.name + ); + download_file(&asset.browser_download_url, &download_dest)?; + + let bin_dir = tools_bin_dir(root); + std::fs::create_dir_all(&bin_dir)?; + let final_dest = bin_dir.join(binary_file_name(tool.binary_name)); + + if tool.is_direct_binary { + std::fs::copy(&download_dest, &final_dest).with_context(|| { + format!( + "Failed to copy {} to {}", + download_dest.display(), + final_dest.display() + ) + })?; + } else { + let extract_dir = cache_dir.join(format!(".extract-{}", tool.name)); + if extract_dir.exists() { + let _ = std::fs::remove_dir_all(&extract_dir); + } + std::fs::create_dir_all(&extract_dir)?; + + let format = ArchiveFormat::from_url(&asset.name).with_context(|| { + format!("Unsupported archive format for tool asset '{}'", asset.name) + })?; + + extract_archive( + &download_dest, + &extract_dir, + &ExtractConfig::default(), + format, + )?; + + let extracted_bin = find_extracted_binary(&extract_dir, tool.binary_name)?; + std::fs::copy(&extracted_bin, &final_dest).with_context(|| { + format!( + "Failed to copy extracted binary from '{}' to '{}'", + extracted_bin.display(), + final_dest.display() + ) + })?; + + let _ = std::fs::remove_dir_all(&extract_dir); + } + + make_executable(&final_dest)?; + + // Add tools bin to PATH + prepend_process_path(&bin_dir)?; + let _ = persist_path_dir(&bin_dir); + + println!( + "Installed {} {} to '{}'.", + tool.display_name, + release.tag_name, + final_dest.display() + ); + + Ok(final_dest) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_preset() { + assert!(find_preset("starship").is_some()); + assert!(find_preset("STARSHIP").is_some()); + assert!(find_preset("zoxide").is_some()); + assert!(find_preset("carapace").is_some()); + assert!(find_preset("atuin").is_some()); + assert!(find_preset("mise").is_some()); + assert!(find_preset("direnv").is_some()); + assert!(find_preset("oh-my-posh").is_some()); + assert!(find_preset("unknown-tool").is_none()); + } + + #[test] + fn test_asset_matching_starship() { + let linux_x64 = Platform { + triple: "x86_64-unknown-linux-gnu".to_string(), + os: Os::Linux, + arch: Arch::X86_64, + env: crate::core::platform::Env::Gnu, + }; + let win_x64 = Platform { + triple: "x86_64-pc-windows-msvc".to_string(), + os: Os::Windows, + arch: Arch::X86_64, + env: crate::core::platform::Env::Msvc, + }; + let starship = find_preset("starship").unwrap(); + + assert!(matches_tool_asset( + starship, + "starship-x86_64-unknown-linux-gnu.tar.gz", + &linux_x64 + )); + assert!(matches_tool_asset( + starship, + "starship-x86_64-pc-windows-msvc.zip", + &win_x64 + )); + assert!(!matches_tool_asset( + starship, + "starship-x86_64-apple-darwin.tar.gz", + &win_x64 + )); + } +} diff --git a/tests/setup_test.rs b/tests/setup_test.rs index 42f3627d..84bdd82c 100644 --- a/tests/setup_test.rs +++ b/tests/setup_test.rs @@ -1,6 +1,9 @@ //! `numan setup loader` integration tests. -use numan_cli::cmd::setup::{config_already_sources_loader, execute_loader_with_probe, LoaderArgs}; +use numan_cli::cmd::setup::{ + config_already_sources_loader, execute_loader_with_probe, execute_loader_with_probe_and_root, + read_loader_config, LoaderArgs, +}; #[test] fn setup_loader_install_and_configure_without_live_nu() { @@ -12,6 +15,7 @@ fn setup_loader_install_and_configure_without_live_nu() { force: false, configure: true, yes: true, + ..Default::default() }; execute_loader_with_probe(&args, || Ok(config_path.clone())).unwrap(); @@ -22,6 +26,148 @@ fn setup_loader_install_and_configure_without_live_nu() { assert!(loader.contains("aidnem_loader_configs")); assert!(loader.contains("github.com/aidnem/nushell-loader")); + let loader_config_path = dir.path().join("loader-config.nu"); + assert!(loader_config_path.is_file()); + let config = std::fs::read_to_string(&config_path).unwrap(); assert!(config_already_sources_loader(&config)); } + +#[test] +fn setup_loader_add_and_remove_tool() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.nu"); + std::fs::write(&config_path, "# user config\n").unwrap(); + + // 1. Initial setup + let args = LoaderArgs { + configure: true, + yes: true, + ..Default::default() + }; + execute_loader_with_probe(&args, || Ok(config_path.clone())).unwrap(); + + // 2. Add preset starship + let add_args = LoaderArgs { + add: Some("starship".to_string()), + yes: true, + ..Default::default() + }; + execute_loader_with_probe(&add_args, || Ok(config_path.clone())).unwrap(); + + let loader_config_path = dir.path().join("loader-config.nu"); + let configs = read_loader_config(&loader_config_path).unwrap(); + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].name, "starship"); + assert_eq!(configs[0].command, "starship init nu"); + + // 3. Add custom tool + let add_custom = LoaderArgs { + add: Some("custom=echo custom_init".to_string()), + yes: true, + ..Default::default() + }; + execute_loader_with_probe(&add_custom, || Ok(config_path.clone())).unwrap(); + + let configs = read_loader_config(&loader_config_path).unwrap(); + assert_eq!(configs.len(), 2); + assert_eq!(configs[1].name, "custom"); + assert_eq!(configs[1].command, "echo custom_init"); + + // 4. Remove starship + let remove_args = LoaderArgs { + remove: Some("starship".to_string()), + yes: true, + ..Default::default() + }; + execute_loader_with_probe(&remove_args, || Ok(config_path.clone())).unwrap(); + + let configs = read_loader_config(&loader_config_path).unwrap(); + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].name, "custom"); +} + +#[test] +fn setup_loader_config_isolation_preserves_user_entries_on_force() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.nu"); + std::fs::write(&config_path, "# user config\n").unwrap(); + + // Add tool + let add_args = LoaderArgs { + add: Some("zoxide".to_string()), + yes: true, + ..Default::default() + }; + execute_loader_with_probe(&add_args, || Ok(config_path.clone())).unwrap(); + + let loader_config_path = dir.path().join("loader-config.nu"); + let configs = read_loader_config(&loader_config_path).unwrap(); + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].name, "zoxide"); + + // Force re-install loader.nu engine + let force_args = LoaderArgs { + force: true, + yes: true, + ..Default::default() + }; + execute_loader_with_probe(&force_args, || Ok(config_path.clone())).unwrap(); + + // User configs must remain intact! + let configs_after = read_loader_config(&loader_config_path).unwrap(); + assert_eq!(configs_after.len(), 1); + assert_eq!(configs_after[0].name, "zoxide"); +} + +#[test] +fn setup_loader_status_runs_cleanly() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.nu"); + std::fs::write(&config_path, "# user config\n").unwrap(); + + let setup_args = LoaderArgs { + add: Some("starship".to_string()), + configure: true, + yes: true, + ..Default::default() + }; + execute_loader_with_probe(&setup_args, || Ok(config_path.clone())).unwrap(); + + let status_args = LoaderArgs { + status: true, + ..Default::default() + }; + execute_loader_with_probe(&status_args, || Ok(config_path.clone())).unwrap(); +} + +#[test] +fn setup_loader_detect_discovers_installed_tool() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("numan-root"); + let config_path = dir.path().join("config.nu"); + std::fs::write(&config_path, "# user config\n").unwrap(); + + // Plant a fake binary in root/tools/bin + let tools_bin = root.join("tools").join("bin"); + std::fs::create_dir_all(&tools_bin).unwrap(); + let fake_starship = tools_bin.join(if cfg!(windows) { + "starship.exe" + } else { + "starship" + }); + std::fs::write(&fake_starship, b"fake").unwrap(); + + let detect_args = LoaderArgs { + detect: true, + yes: true, + ..Default::default() + }; + + execute_loader_with_probe_and_root(&detect_args, Some(&root), || Ok(config_path.clone())) + .unwrap(); + + let loader_config_path = dir.path().join("loader-config.nu"); + let configs = read_loader_config(&loader_config_path).unwrap(); + assert!(configs.iter().any(|e| e.name == "starship")); +} From d84b267fe82531425bef39f992794e08694fdccf Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Sun, 16 Aug 2026 17:52:02 -0700 Subject: [PATCH 2/8] docs: update README, CHANGELOG, and CLI help for setup loader --- CHANGELOG.md | 7 +++++++ README.md | 31 +++++++++++++++++++++++++++++-- src/cli.rs | 2 +- src/cmd/setup.rs | 2 +- 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 063b59a7..7debe402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Enhanced `numan setup loader`**: + - Configuration isolation: separates user tool configurations into `loader-config.nu` so that updating the loader engine (`numan setup loader --force`) preserves custom tool definitions. + - Direct GitHub release binary installer: downloads prebuilt verified binaries for CLI tools (Starship, Zoxide, Carapace, Atuin, Mise, Direnv, Oh-My-Posh) into `$NUMAN_ROOT/tools/bin/` with `--install` / `--install-missing` and persists them to the user's `PATH`. + - Tool management flags: `--status` (inspect health, config, PATH status, and cached autoload files), `--detect` (discover PATH tools), `--add ` (preset or custom), `--remove ` (with cache purge), and `--clean` (invalidate cached init files). + ## [0.2.2] - 2026-08-16 ### Added diff --git a/README.md b/README.md index 2ec94067..2fc8256f 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,6 @@ These workflows are covered by unit tests, hermetic integration tests, and real- ## Features -- **Registry-backed installs**: Search packages, inspect available versions, and install `owner/name` or `owner/name@version`. -- **Official registry**: `numan init` configures the `official` registry automatically. Its production trust root is built into numan, and `numan registry sync` verifies every signed index. - **Registry-backed installs**: Search packages, inspect available versions, and install `owner/name` or `owner/name@version`. - **Official registry**: `numan init` configures the `official` registry automatically. Its production trust root is built into numan, and `numan registry sync` verifies every signed index. - **Package types**: Plugins and modules support activation. Scripts and completion packages are currently install-only while their activation contracts are finalized. @@ -49,6 +47,7 @@ These workflows are covered by unit tests, hermetic integration tests, and real- - **Scoped activation**: Plugins remain active only while the Nushell executable hash, Nushell version, and plugin registry path match the recorded activation state. - **Module autoloads**: numan writes managed vendor autoload files with ownership markers and validates candidate files before promotion. - **Lifecycle management**: Update, remove, and garbage collection operations recover safely through lifecycle journals. +- **Shell tool integration & loader**: `numan setup loader` configures cached initialization for third-party shell CLI tools (Starship, Zoxide, Carapace, Atuin, Mise, Direnv, Oh-My-Posh), detects installed tools, isolates configurations in `loader-config.nu`, and downloads missing prebuilt binaries into `$NUMAN_ROOT/tools/bin/`. - **nupm interoperability**: Use `numan nupm status`, `inspect`, `import`, and `diff` to inspect, migrate, and detect drift in existing [nupm](https://github.com/nushell/nupm) installations. - **Health checks**: `numan doctor` diagnoses installation health and applies safe repairs by default. Use `--scan` for report-only mode. - **Shell completions**: Install completions for Bash, Fish, Zsh, PowerShell, and Nushell with `numan completions` (use `--print` to emit the script). @@ -353,6 +352,7 @@ Global flag: `--root ` — override the numan root directory (all commands | `numan setup nu remove` | Remove the managed Nushell install and fall back to PATH Nu | | `numan setup nu path` | Use the Nushell already on PATH (removes managed install) | | `numan setup nu use ` | Register a specific existing Nushell binary | +| `numan setup loader` | Setup nushell-loader integration and third-party shell CLI tools | | `numan use ` | Switch the active managed Nu to a pinned version (no auto-install; errors with a hint to run `numan setup nu ` if missing). Cross-minor switches deactivate Numan-active plugins/modules for the leaving Nu and restore that minor's remembered set when you switch back. | | `numan use latest` | Switch the active managed Nu to the latest installed version (same leave/restore behavior as `use `) | | `numan use list` | List installed managed Nu versions and mark the active one | @@ -380,11 +380,38 @@ Global flag: `--root ` — override the numan root directory (all commands | `nupm import` | `--as owner/name` (single import); `--manifest ` (batch); `--nupm-home `; `--yes` skip consent | | `doctor` | `--scan` report only; `--json` machine output; `--nupm-home ` (repairs by default) | | `setup nu` | `--force` re-download; `--skip-path` don't update PATH; `--yes` skip prompt | +| `setup loader` | `--status` check health/cache; `--detect` scan PATH tools; `--add ` add preset/custom; `--remove ` remove tool; `--clean` purge caches; `--install` / `--install-missing` download binaries; `--force` overwrite engine; `--configure` append to `config.nu` | Run `numan --help` for full flag documentation. --- +## Shell tool integration (nushell-loader) + +Numan includes a high-performance loader integration based on [nushell-loader](https://github.com/aidnem/nushell-loader). It caches initialization scripts for external tools (Starship, Zoxide, Carapace, Atuin, Mise, Direnv, Oh-My-Posh) in `$nu.data-dir/vendor/autoload/`, speeding up Nushell startup. + +```bash +# 1. Install loader.nu and append source snippet to config.nu +numan setup loader --configure + +# 2. Detect shell tools on your PATH and register them +numan setup loader --detect + +# 3. Add a tool preset (with optional binary download if missing from PATH) +numan setup loader --add starship --install +numan setup loader --add "custom=echo 'source ~/.custom.nu'" + +# 4. Check status and cache files +numan setup loader --status + +# 5. Clean / invalidate cached initialization scripts +numan setup loader --clean +``` + +Tool definitions are isolated in `loader-config.nu`, ensuring that updating the loader engine (`numan setup loader --force`) preserves your custom configuration. Binaries downloaded via `--install` or `--install-missing` are placed in `$NUMAN_ROOT/tools/bin/` and persisted to your `PATH`. + +--- + ## nupm migration numan can discover and import compatible packages from an existing [nupm](https://github.com/nushell/nupm) installation without modifying nupm state. diff --git a/src/cli.rs b/src/cli.rs index 8c9168d9..f1c18aae 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -56,7 +56,7 @@ pub enum Commands { Completions(cmd::completions::CompletionsArgs), /// Diagnose Numan root health and apply safe repairs (use `--scan` for report-only) Doctor(cmd::doctor::DoctorArgs), - /// Install optional Nushell integration helpers + /// Manage Nushell installations and external shell CLI tool integrations #[command(subcommand)] Setup(cmd::setup::SetupCommands), /// Try a package against your current Nu and platform, with compatibility guidance diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs index 8f87e86a..a5de0ffa 100644 --- a/src/cmd/setup.rs +++ b/src/cmd/setup.rs @@ -55,7 +55,7 @@ source ($nu.config-path | path dirname | path join 'loader.nu') pub enum SetupCommands { /// Download and install the official Nushell release under the Numan root Nu(NuSetupArgs), - /// Install the vendored nushell-loader script and print a config.nu snippet + /// Setup nushell-loader integration and manage external shell CLI tools Loader(LoaderArgs), } From 8869455c435297102c62b2c76865917fb2916a40 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 17 Aug 2026 02:25:37 +0000 Subject: [PATCH 3/8] Fixed loader security, parsing, error handling Co-authored-by: tonythethompson --- AGENTS.md | 2 +- assets/nushell-loader/loader.nu | 12 +- src/cmd/setup.rs | 210 ++++++++++++++++++++++++-------- src/cmd/setup_tools.rs | 190 +++++++++++++++++++++++++++-- tests/setup_test.rs | 83 ++++++++++++- 5 files changed, 424 insertions(+), 73 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 12890712..2ba3817c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ src/ gc.rs — `numan gc [--dry-run]`: delete orphaned payload directories (Phase 5) nupm.rs — `numan nupm status|inspect|import|diff`: nupm discovery + import + drift (Phase 6.1–6.3) completions.rs — `numan completions `: install by default (mkdir+write); `--print` for stdout (Phase 7.3) - setup.rs — `numan setup nu [VERSION]|remove|path|use ` + `setup loader`: Nushell bootstrap + nushell-loader install + setup.rs — `numan setup nu [VERSION]|remove|path|use ` + `setup loader [--status|--detect|--add|--remove|--clean|--install]`: Nushell bootstrap + nushell-loader install with loader-config.nu isolation setup_tools.rs — CLI shell tool presets + GitHub release binary installer (starship, zoxide, carapace, atuin, mise, direnv, oh-my-posh) try_cmd.rs — `numan try [--no-activate]`: attempt a package for current Nu; explain compatible managed Nu versions if incompatible use_cmd.rs — `numan use |latest|list`: activates a previously installed managed Nu version (no auto-download); cross-minor leave/teardown (modules then plugins) + restore (plugins then modules) via activation profiles; same-target is restore-only; writes the active-version marker after a PreMutation snapshot under the root mutation lock diff --git a/assets/nushell-loader/loader.nu b/assets/nushell-loader/loader.nu index caea7f00..56616a41 100644 --- a/assets/nushell-loader/loader.nu +++ b/assets/nushell-loader/loader.nu @@ -6,17 +6,21 @@ let autoload_dir: path = $nu.data-dir | path join "vendor/autoload" mkdir $autoload_dir -# Configuration is loaded from `loader-config.nu` in the same directory as loader.nu +# Load tool configuration from loader-config.nu in the same directory as loader.nu. +# Uses `open` at runtime instead of `source` so the path does not need to be a +# parse-time constant and a missing config file is handled gracefully. let loader_config_file = ($nu.config-path | path dirname | path join 'loader-config.nu') let aidnem_loader_configs: list = if ($loader_config_file | path exists) { - source $loader_config_file - $aidnem_loader_configs + (open $loader_config_file) } else { [] } def _aidnem_loader_get_file_from_name [name: string] { + if not ($name =~ '^[A-Za-z0-9][A-Za-z0-9_-]*$') { + error make { msg: $"Invalid loader name: '($name)'. Names must match [A-Za-z0-9][A-Za-z0-9_-]*" } + } { parent: $autoload_dir, stem: $name, extension: 'nu' } | path join } @@ -26,7 +30,7 @@ for item in $aidnem_loader_configs { print $"[Aidnem Loader] Generating cache for ($item.name)..." try { let res = (nu -n -c $item.command | complete) - if $res.exit_code == 0 and not ($res.stdout | is-empty) { + if $res.exit_code == 0 and ($res.stderr | is-empty) and not ($res.stdout | is-empty) { $res.stdout | save -f $target print $"[Aidnem Loader] Successfully cached ($item.name) -> ($target)" } else { diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs index a5de0ffa..6696820c 100644 --- a/src/cmd/setup.rs +++ b/src/cmd/setup.rs @@ -228,12 +228,8 @@ pub struct LoaderArgs { pub clean: bool, /// Automatically download and install missing tools from GitHub - #[arg(long)] + #[arg(long, alias = "install-missing")] pub install: bool, - - /// Automatically download and install missing tools when detected or added - #[arg(long)] - pub install_missing: bool, } pub fn execute(cmd: SetupCommands, root: &Path) -> Result<()> { @@ -777,17 +773,14 @@ where ) })?; - // 1. Status action if args.status { return execute_loader_status(&loader_path, &loader_config_path, &config_path, root); } - // 2. Clean action if args.clean { return execute_loader_clean(&loader_config_path, root); } - // 3. Remove action if let Some(tool_name) = &args.remove { return execute_loader_remove(&loader_config_path, tool_name, root); } @@ -800,9 +793,8 @@ where write_loader_config(&loader_config_path, &[])?; } - let should_install = args.install || args.install_missing; + let should_install = args.install; - // 4. Add action if let Some(tool_spec) = &args.add { execute_loader_add( &loader_config_path, @@ -813,7 +805,6 @@ where )?; } - // 5. Detect action if args.detect { execute_loader_detect(&loader_config_path, root, should_install, args.yes)?; } @@ -847,6 +838,23 @@ pub fn read_loader_config(config_path: &Path) -> Result> Ok(parse_loader_config(&content)) } +/// Validate a tool name for use in loader config and autoload file paths. +fn validate_tool_name(name: &str) -> Result<()> { + if name.is_empty() || name.len() > 64 { + bail!("Tool name '{name}' must be 1-64 characters."); + } + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + bail!("Tool name '{name}' may only contain ASCII letters, digits, '-' and '_'."); + } + Ok(()) +} + +/// Reserved tool name that must not be overwritten by loader add/remove. +const RESERVED_LOADER_NAMES: &[&str] = &["numan"]; + pub fn parse_loader_config(content: &str) -> Vec { let mut entries = Vec::new(); for line in content.lines() { @@ -868,33 +876,92 @@ fn parse_loader_record_line(line: &str) -> Option { if name_quote != '\'' && name_quote != '"' { return None; } - let name_end = after_name[1..].find(name_quote)?; - let name = &after_name[1..1 + name_end]; - let cmd_idx = line.find("command:")?; - let after_cmd = &line[cmd_idx + 8..].trim_start(); + let mut name = String::new(); + let mut rest = &after_name[name_quote.len_utf8()..]; + loop { + match rest.chars().next()? { + '\\' if name_quote == '"' => { + rest = &rest[1..]; + match rest.chars().next()? { + '\\' => { + name.push('\\'); + rest = &rest[1..]; + } + '"' => { + name.push('"'); + rest = &rest[1..]; + } + c => { + name.push('\\'); + name.push(c); + rest = &rest[c.len_utf8()..]; + } + } + } + c if c == name_quote => { + rest = &rest[name_quote.len_utf8()..]; + break; + } + c => { + name.push(c); + rest = &rest[c.len_utf8()..]; + } + } + } + + let cmd_idx = rest.find("command:")?; + let after_cmd = &rest[cmd_idx + 8..].trim_start(); let cmd_quote = after_cmd.chars().next()?; if cmd_quote != '\'' && cmd_quote != '"' { return None; } - let cmd_end = after_cmd[1..].find(cmd_quote)?; - let command = &after_cmd[1..1 + cmd_end]; - Some(LoaderConfigEntry { - name: name.to_string(), - command: command.to_string(), - }) + let mut command = String::new(); + let mut cmd_rest = &after_cmd[cmd_quote.len_utf8()..]; + loop { + match cmd_rest.chars().next()? { + '\\' if cmd_quote == '"' => { + cmd_rest = &cmd_rest[1..]; + match cmd_rest.chars().next()? { + '\\' => { + command.push('\\'); + cmd_rest = &cmd_rest[1..]; + } + '"' => { + command.push('"'); + cmd_rest = &cmd_rest[1..]; + } + c => { + command.push('\\'); + command.push(c); + cmd_rest = &cmd_rest[c.len_utf8()..]; + } + } + } + c if c == cmd_quote => { + break; + } + c => { + command.push(c); + cmd_rest = &cmd_rest[c.len_utf8()..]; + } + } + } + + Some(LoaderConfigEntry { name, command }) } pub fn render_loader_config(entries: &[LoaderConfigEntry]) -> String { let mut out = String::from( - "# Generated by Numan. Tool configurations for nushell-loader.\n# Manage via `numan setup loader --add ` or `numan setup loader --remove `.\n\nlet aidnem_loader_configs: list = [\n", + "# Generated by Numan. Tool configurations for nushell-loader.\n# Manage via `numan setup loader --add ` or `numan setup loader --remove `.\n\n[\n", ); for e in entries { + let escaped_name = e.name.replace('\\', "\\\\").replace('\'', "\\'"); + let escaped_cmd = e.command.replace('\\', "\\\\").replace('"', "\\\""); out.push_str(&format!( " {{ name: '{}', command: \"{}\" }}\n", - e.name, - e.command.replace('"', "\\\"") + escaped_name, escaped_cmd )); } out.push_str("]\n"); @@ -909,17 +976,9 @@ pub fn write_loader_config(config_path: &Path, entries: &[LoaderConfigEntry]) -> } fn resolve_vendor_autoload_dir(root: Option<&Path>) -> Option { - if let Some(r) = root { - if let Ok(paths) = crate::nu::paths::NuPaths::load(r) { - if let Some(v) = paths.vendor_autoload_dir { - return Some(PathBuf::from(v)); - } - if let Some(data) = paths.data_dir { - return Some(Path::new(&data).join("vendor").join("autoload")); - } - } - } - dirs::data_dir().map(|d| d.join("nushell").join("vendor").join("autoload")) + let r = root?; + let paths = crate::nu::paths::NuPaths::load(r).ok()?; + paths.vendor_autoload_dir.map(PathBuf::from) } fn execute_loader_status( @@ -937,7 +996,8 @@ fn execute_loader_status( } println!(" Config file: {}", loader_config_path.display()); - let entries = read_loader_config(loader_config_path).unwrap_or_default(); + let entries = read_loader_config(loader_config_path) + .with_context(|| format!("Failed to parse '{}'", loader_config_path.display()))?; println!(" Configured tools ({}):", entries.len()); let autoload_dir = resolve_vendor_autoload_dir(root); @@ -983,7 +1043,8 @@ fn execute_loader_status( } fn execute_loader_clean(loader_config_path: &Path, root: Option<&Path>) -> Result<()> { - let entries = read_loader_config(loader_config_path).unwrap_or_default(); + let entries = read_loader_config(loader_config_path) + .with_context(|| format!("Failed to parse '{}'", loader_config_path.display()))?; let Some(autoload_dir) = resolve_vendor_autoload_dir(root) else { println!("Could not determine vendor/autoload directory to clean."); return Ok(()); @@ -1001,9 +1062,18 @@ fn execute_loader_clean(loader_config_path: &Path, root: Option<&Path>) -> Resul for e in &entries { let target = autoload_dir.join(format!("{}.nu", e.name)); if target.is_file() { - let _ = std::fs::remove_file(&target); - println!("Removed cache '{}'", target.display()); - removed += 1; + match std::fs::remove_file(&target) { + Ok(()) => { + println!("Removed cache '{}'", target.display()); + removed += 1; + } + Err(err) => { + eprintln!( + "Warning: failed to remove cache '{}': {err:#}", + target.display() + ); + } + } } } @@ -1016,6 +1086,8 @@ fn execute_loader_remove( tool_name: &str, root: Option<&Path>, ) -> Result<()> { + validate_tool_name(tool_name).context("Invalid tool name in --remove")?; + let mut entries = read_loader_config(loader_config_path)?; let initial_len = entries.len(); entries.retain(|e| e.name != tool_name); @@ -1035,8 +1107,17 @@ fn execute_loader_remove( if let Some(autoload_dir) = resolve_vendor_autoload_dir(root) { let target = autoload_dir.join(format!("{tool_name}.nu")); if target.is_file() { - let _ = std::fs::remove_file(&target); - println!("Removed cached autoload file '{}'.", target.display()); + match std::fs::remove_file(&target) { + Ok(()) => { + println!("Removed cached autoload file '{}'.", target.display()); + } + Err(err) => { + eprintln!( + "Warning: failed to remove cached autoload file '{}': {err:#}", + target.display() + ); + } + } } } @@ -1061,6 +1142,14 @@ fn execute_loader_add( ); }; + validate_tool_name(&name).context("Invalid tool name in --add")?; + if RESERVED_LOADER_NAMES.contains(&name.as_str()) { + bail!( + "Tool name '{}' is reserved and cannot be added via the loader.", + name + ); + } + let bin_found = crate::cmd::setup_tools::find_binary_on_path(&name, root); if bin_found.is_none() { if let Some(preset) = crate::cmd::setup_tools::find_preset(&name) { @@ -1132,19 +1221,30 @@ fn execute_loader_detect( } else if should_install && !is_configured { if let Some(r) = root { println!("Installing missing preset '{}'…", preset.display_name); - if crate::cmd::setup_tools::download_and_install_tool( + match crate::cmd::setup_tools::download_and_install_tool( preset, r, &Platform::detect(), - ) - .is_ok() - { - entries.push(LoaderConfigEntry { - name: preset.name.to_string(), - command: preset.init_command.to_string(), - }); - added_count += 1; + ) { + Ok(_) => { + entries.push(LoaderConfigEntry { + name: preset.name.to_string(), + command: preset.init_command.to_string(), + }); + added_count += 1; + } + Err(err) => { + eprintln!( + "Warning: failed to install '{}': {err:#}", + preset.display_name + ); + } } + } else { + eprintln!( + "Warning: cannot install '{}' without a Numan root directory.", + preset.display_name + ); } } } @@ -1426,6 +1526,14 @@ mod tests { name: "zoxide".to_string(), command: "zoxide init nushell".to_string(), }, + LoaderConfigEntry { + name: "mytool".to_string(), + command: r#"echo "hello world""#.to_string(), + }, + LoaderConfigEntry { + name: "escaped".to_string(), + command: r#"run "C:\path\to\app""#.to_string(), + }, ]; let rendered = render_loader_config(&entries); diff --git a/src/cmd/setup_tools.rs b/src/cmd/setup_tools.rs index 6a359185..84596348 100644 --- a/src/cmd/setup_tools.rs +++ b/src/cmd/setup_tools.rs @@ -4,7 +4,7 @@ use anyhow::{bail, Context, Result}; use serde::Deserialize; use std::path::{Path, PathBuf}; -use crate::core::platform::{Arch, Os, Platform}; +use crate::core::platform::{Arch, Env, Os, Platform}; use crate::install::download::download_file; use crate::install::extract::{extract_archive, ArchiveFormat, ExtractConfig}; use crate::nu::bootstrap::{persist_path_dir, prepend_process_path}; @@ -160,10 +160,18 @@ fn matches_tool_asset(tool: &ToolPreset, asset_name: &str, platform: &Platform) (Os::Windows, Arch::X86_64) => name.contains("x86_64-pc-windows-msvc.zip"), (Os::Windows, Arch::Aarch64) => name.contains("aarch64-pc-windows-msvc.zip"), (Os::Linux, Arch::X86_64) => { - name.contains("x86_64-unknown-linux") && name.ends_with(".tar.gz") + let libc = match platform.env { + Env::Musl => "musl", + _ => "gnu", + }; + name.contains(&format!("x86_64-unknown-linux-{libc}")) && name.ends_with(".tar.gz") } (Os::Linux, Arch::Aarch64) => { - name.contains("aarch64-unknown-linux") && name.ends_with(".tar.gz") + let libc = match platform.env { + Env::Musl => "musl", + _ => "gnu", + }; + name.contains(&format!("aarch64-unknown-linux-{libc}")) && name.ends_with(".tar.gz") } (Os::Macos, Arch::X86_64) => name.contains("x86_64-apple-darwin.tar.gz"), (Os::Macos, Arch::Aarch64) => name.contains("aarch64-apple-darwin.tar.gz"), @@ -172,10 +180,18 @@ fn matches_tool_asset(tool: &ToolPreset, asset_name: &str, platform: &Platform) "zoxide" => match (platform.os, platform.arch) { (Os::Windows, Arch::X86_64) => name.contains("x86_64-pc-windows-msvc.zip"), (Os::Linux, Arch::X86_64) => { - name.contains("x86_64-unknown-linux") && name.ends_with(".tar.gz") + let libc = match platform.env { + Env::Musl => "musl", + _ => "gnu", + }; + name.contains(&format!("x86_64-unknown-linux-{libc}")) && name.ends_with(".tar.gz") } (Os::Linux, Arch::Aarch64) => { - name.contains("aarch64-unknown-linux") && name.ends_with(".tar.gz") + let libc = match platform.env { + Env::Musl => "musl", + _ => "gnu", + }; + name.contains(&format!("aarch64-unknown-linux-{libc}")) && name.ends_with(".tar.gz") } (Os::Macos, Arch::X86_64) => name.contains("x86_64-apple-darwin.tar.gz"), (Os::Macos, Arch::Aarch64) => name.contains("aarch64-apple-darwin.tar.gz"), @@ -193,10 +209,18 @@ fn matches_tool_asset(tool: &ToolPreset, asset_name: &str, platform: &Platform) "atuin" => match (platform.os, platform.arch) { (Os::Windows, Arch::X86_64) => name.contains("x86_64-pc-windows-msvc.zip"), (Os::Linux, Arch::X86_64) => { - name.contains("x86_64-unknown-linux") && name.ends_with(".tar.gz") + let libc = match platform.env { + Env::Musl => "musl", + _ => "gnu", + }; + name.contains(&format!("x86_64-unknown-linux-{libc}")) && name.ends_with(".tar.gz") } (Os::Linux, Arch::Aarch64) => { - name.contains("aarch64-unknown-linux") && name.ends_with(".tar.gz") + let libc = match platform.env { + Env::Musl => "musl", + _ => "gnu", + }; + name.contains(&format!("aarch64-unknown-linux-{libc}")) && name.ends_with(".tar.gz") } (Os::Macos, Arch::X86_64) => name.contains("x86_64-apple-darwin.tar.gz"), (Os::Macos, Arch::Aarch64) => name.contains("aarch64-apple-darwin.tar.gz"), @@ -241,11 +265,28 @@ fn fetch_latest_release(repo: &str) -> Result { .context("Failed to build HTTP client for tool download")?; let url = format!("https://api.github.com/repos/{repo}/releases/latest"); - let response = client + let mut request = client .get(&url) + .header("Accept", "application/vnd.github+json"); + + // Authenticate when a GitHub token is available for higher rate limits. + if let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN")) { + if !token.is_empty() { + request = request.bearer_auth(&token); + } + } + + let response = request .send() .with_context(|| format!("Failed to fetch release metadata for {repo}"))?; + if response.status().as_u16() == 403 || response.status().as_u16() == 429 { + bail!( + "GitHub API rate limit exceeded while fetching releases for {repo}. \ + Set GITHUB_TOKEN or GH_TOKEN to authenticate and increase the limit." + ); + } + if !response.status().is_success() { bail!( "Failed to fetch release for {repo}: HTTP {}", @@ -329,7 +370,18 @@ pub fn download_and_install_tool( let cache_dir = root.join("tools").join(".cache"); std::fs::create_dir_all(&cache_dir)?; - let download_dest = cache_dir.join(&asset.name); + let sanitized_name = asset + .name + .chars() + .filter(|c| *c != '/' && *c != '\\' && *c != ':') + .collect::(); + if sanitized_name.is_empty() { + bail!( + "Release asset name for {} is empty after sanitization", + tool.display_name + ); + } + let download_dest = cache_dir.join(&sanitized_name); println!( "Downloading {} {} ({})…", @@ -337,6 +389,22 @@ pub fn download_and_install_tool( ); download_file(&asset.browser_download_url, &download_dest)?; + // Validate downloaded file size when the release metadata provides one. + if asset.size > 0 { + let actual = std::fs::metadata(&download_dest) + .with_context(|| format!("Failed to stat downloaded '{}'", download_dest.display()))? + .len(); + if actual != asset.size { + bail!( + "Downloaded {} asset '{}' has {} bytes but release metadata reports {} bytes", + tool.display_name, + asset.name, + actual, + asset.size + ); + } + } + let bin_dir = tools_bin_dir(root); std::fs::create_dir_all(&bin_dir)?; let final_dest = bin_dir.join(binary_file_name(tool.binary_name)); @@ -383,7 +451,14 @@ pub fn download_and_install_tool( // Add tools bin to PATH prepend_process_path(&bin_dir)?; - let _ = persist_path_dir(&bin_dir); + if let Err(err) = persist_path_dir(&bin_dir) { + eprintln!( + "Warning: failed to persist '{}' on PATH: {err:#}. \ + Add it manually to keep {} available in new shells.", + bin_dir.display(), + tool.binary_name + ); + } println!( "Installed {} {} to '{}'.", @@ -414,12 +489,18 @@ mod tests { #[test] fn test_asset_matching_starship() { - let linux_x64 = Platform { + let linux_x64_gnu = Platform { triple: "x86_64-unknown-linux-gnu".to_string(), os: Os::Linux, arch: Arch::X86_64, env: crate::core::platform::Env::Gnu, }; + let linux_x64_musl = Platform { + triple: "x86_64-unknown-linux-musl".to_string(), + os: Os::Linux, + arch: Arch::X86_64, + env: crate::core::platform::Env::Musl, + }; let win_x64 = Platform { triple: "x86_64-pc-windows-msvc".to_string(), os: Os::Windows, @@ -428,10 +509,29 @@ mod tests { }; let starship = find_preset("starship").unwrap(); + // GNU Linux matches gnu asset assert!(matches_tool_asset( starship, "starship-x86_64-unknown-linux-gnu.tar.gz", - &linux_x64 + &linux_x64_gnu + )); + // GNU Linux rejects musl asset + assert!(!matches_tool_asset( + starship, + "starship-x86_64-unknown-linux-musl.tar.gz", + &linux_x64_gnu + )); + // Musl Linux matches musl asset + assert!(matches_tool_asset( + starship, + "starship-x86_64-unknown-linux-musl.tar.gz", + &linux_x64_musl + )); + // Musl Linux rejects gnu asset + assert!(!matches_tool_asset( + starship, + "starship-x86_64-unknown-linux-gnu.tar.gz", + &linux_x64_musl )); assert!(matches_tool_asset( starship, @@ -444,4 +544,70 @@ mod tests { &win_x64 )); } + + #[test] + fn test_asset_matching_direnv_exact_names() { + let linux_x64 = Platform { + triple: "x86_64-unknown-linux-gnu".to_string(), + os: Os::Linux, + arch: Arch::X86_64, + env: crate::core::platform::Env::Gnu, + }; + let darwin_arm64 = Platform { + triple: "aarch64-apple-darwin".to_string(), + os: Os::Macos, + arch: Arch::Aarch64, + env: crate::core::platform::Env::Darwin, + }; + let direnv = find_preset("direnv").unwrap(); + + assert!(matches_tool_asset(direnv, "direnv.linux-amd64", &linux_x64)); + assert!(!matches_tool_asset( + direnv, + "direnv.linux-amd64.tar.gz", + &linux_x64 + )); + assert!(matches_tool_asset( + direnv, + "direnv.darwin-arm64", + &darwin_arm64 + )); + } + + #[test] + fn test_asset_matching_oh_my_posh_exact_names() { + let linux_x64 = Platform { + triple: "x86_64-unknown-linux-gnu".to_string(), + os: Os::Linux, + arch: Arch::X86_64, + env: crate::core::platform::Env::Gnu, + }; + let win_x64 = Platform { + triple: "x86_64-pc-windows-msvc".to_string(), + os: Os::Windows, + arch: Arch::X86_64, + env: crate::core::platform::Env::Msvc, + }; + let omp = find_preset("oh-my-posh").unwrap(); + + assert!(matches_tool_asset(omp, "posh-linux-amd64", &linux_x64)); + assert!(!matches_tool_asset(omp, "posh-linux-amd64.exe", &linux_x64)); + assert!(matches_tool_asset(omp, "posh-windows-amd64.exe", &win_x64)); + } + + #[test] + fn test_asset_matching_empty_assets_yields_no_match() { + let linux_x64 = Platform { + triple: "x86_64-unknown-linux-gnu".to_string(), + os: Os::Linux, + arch: Arch::X86_64, + env: crate::core::platform::Env::Gnu, + }; + let starship = find_preset("starship").unwrap(); + let empty: Vec = vec![]; + assert!(empty + .iter() + .find(|a| matches_tool_asset(starship, a, &linux_x64)) + .is_none()); + } } diff --git a/tests/setup_test.rs b/tests/setup_test.rs index 84bdd82c..85deb72a 100644 --- a/tests/setup_test.rs +++ b/tests/setup_test.rs @@ -2,7 +2,7 @@ use numan_cli::cmd::setup::{ config_already_sources_loader, execute_loader_with_probe, execute_loader_with_probe_and_root, - read_loader_config, LoaderArgs, + parse_loader_config, read_loader_config, render_loader_config, LoaderArgs, LoaderConfigEntry, }; #[test] @@ -87,13 +87,40 @@ fn setup_loader_add_and_remove_tool() { assert_eq!(configs[0].name, "custom"); } +#[test] +fn setup_loader_add_rejects_traversal_names() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.nu"); + std::fs::write(&config_path, "# user config\n").unwrap(); + + let args = LoaderArgs { + yes: true, + ..Default::default() + }; + execute_loader_with_probe(&args, || Ok(config_path.clone())).unwrap(); + + // Attempt to add a tool with path traversal in the name + let add_args = LoaderArgs { + add: Some("../../../escape=echo bad".to_string()), + yes: true, + ..Default::default() + }; + let result = execute_loader_with_probe(&add_args, || Ok(config_path.clone())); + assert!(result.is_err(), "path traversal name should be rejected"); + let msg = format!("{:#}", result.unwrap_err()); + assert!( + msg.contains("may only contain") || msg.contains("must be 1-64"), + "expected name validation error, got: {msg}" + ); +} + #[test] fn setup_loader_config_isolation_preserves_user_entries_on_force() { let dir = tempfile::tempdir().unwrap(); let config_path = dir.path().join("config.nu"); std::fs::write(&config_path, "# user config\n").unwrap(); - // Add tool + // Add preset tool let add_args = LoaderArgs { add: Some("zoxide".to_string()), yes: true, @@ -101,10 +128,20 @@ fn setup_loader_config_isolation_preserves_user_entries_on_force() { }; execute_loader_with_probe(&add_args, || Ok(config_path.clone())).unwrap(); + // Add a custom entry + let add_custom = LoaderArgs { + add: Some("mytool=some_command".to_string()), + yes: true, + ..Default::default() + }; + execute_loader_with_probe(&add_custom, || Ok(config_path.clone())).unwrap(); + let loader_config_path = dir.path().join("loader-config.nu"); let configs = read_loader_config(&loader_config_path).unwrap(); - assert_eq!(configs.len(), 1); + assert_eq!(configs.len(), 2); assert_eq!(configs[0].name, "zoxide"); + assert_eq!(configs[1].name, "mytool"); + assert_eq!(configs[1].command, "some_command"); // Force re-install loader.nu engine let force_args = LoaderArgs { @@ -114,10 +151,12 @@ fn setup_loader_config_isolation_preserves_user_entries_on_force() { }; execute_loader_with_probe(&force_args, || Ok(config_path.clone())).unwrap(); - // User configs must remain intact! + // Both entries must remain intact let configs_after = read_loader_config(&loader_config_path).unwrap(); - assert_eq!(configs_after.len(), 1); + assert_eq!(configs_after.len(), 2); assert_eq!(configs_after[0].name, "zoxide"); + assert_eq!(configs_after[1].name, "mytool"); + assert_eq!(configs_after[1].command, "some_command"); } #[test] @@ -157,6 +196,18 @@ fn setup_loader_detect_discovers_installed_tool() { "starship" }); std::fs::write(&fake_starship, b"fake").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&fake_starship, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + // Add a non-executable file that detection must skip (Unix only) + #[cfg(unix)] + { + let non_exec = tools_bin.join("nonexec"); + std::fs::write(&non_exec, b"fake").unwrap(); + } let detect_args = LoaderArgs { detect: true, @@ -171,3 +222,25 @@ fn setup_loader_detect_discovers_installed_tool() { let configs = read_loader_config(&loader_config_path).unwrap(); assert!(configs.iter().any(|e| e.name == "starship")); } + +#[test] +fn loader_config_roundtrip_with_escaping() { + let entries = vec![ + LoaderConfigEntry { + name: "starship".to_string(), + command: "starship init nu".to_string(), + }, + LoaderConfigEntry { + name: "mytool".to_string(), + command: r#"echo "hello world""#.to_string(), + }, + LoaderConfigEntry { + name: "escaped".to_string(), + command: r#"run "C:\path\to\app""#.to_string(), + }, + ]; + + let rendered = render_loader_config(&entries); + let parsed = parse_loader_config(&rendered); + assert_eq!(entries, parsed); +} From bb00c2312cd38cc41b89f81a55621cf15e134708 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Sun, 16 Aug 2026 19:32:19 -0700 Subject: [PATCH 4/8] fix(setup): address PR review comments for loader and tool installer --- assets/nushell-loader/loader.nu | 11 +- src/cmd/setup.rs | 6 +- src/cmd/setup_tools.rs | 286 ++++++++++++++++++++++++++++---- tests/setup_test.rs | 9 +- 4 files changed, 269 insertions(+), 43 deletions(-) diff --git a/assets/nushell-loader/loader.nu b/assets/nushell-loader/loader.nu index 56616a41..0b036c03 100644 --- a/assets/nushell-loader/loader.nu +++ b/assets/nushell-loader/loader.nu @@ -12,7 +12,16 @@ mkdir $autoload_dir let loader_config_file = ($nu.config-path | path dirname | path join 'loader-config.nu') let aidnem_loader_configs: list = if ($loader_config_file | path exists) { - (open $loader_config_file) + try { + let cfg = (open $loader_config_file) + if ($cfg | describe | str starts-with "list") { + $cfg + } else { + [] + } + } catch { + [] + } } else { [] } diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs index 6696820c..ecd98483 100644 --- a/src/cmd/setup.rs +++ b/src/cmd/setup.rs @@ -806,7 +806,7 @@ where } if args.detect { - execute_loader_detect(&loader_config_path, root, should_install, args.yes)?; + execute_loader_detect(&loader_config_path, root, should_install)?; } // Configure config.nu if requested @@ -1087,6 +1087,9 @@ fn execute_loader_remove( root: Option<&Path>, ) -> Result<()> { validate_tool_name(tool_name).context("Invalid tool name in --remove")?; + if RESERVED_LOADER_NAMES.contains(&tool_name) { + bail!("Tool name '{tool_name}' is reserved and cannot be removed via the loader."); + } let mut entries = read_loader_config(loader_config_path)?; let initial_len = entries.len(); @@ -1197,7 +1200,6 @@ fn execute_loader_detect( loader_config_path: &Path, root: Option<&Path>, should_install: bool, - _yes: bool, ) -> Result<()> { let mut entries = read_loader_config(loader_config_path)?; let mut added_count = 0; diff --git a/src/cmd/setup_tools.rs b/src/cmd/setup_tools.rs index 84596348..3247ed0a 100644 --- a/src/cmd/setup_tools.rs +++ b/src/cmd/setup_tools.rs @@ -105,13 +105,31 @@ pub fn binary_file_name(base: &str) -> String { } } +fn is_executable(path: &Path) -> bool { + if !path.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = path.metadata() { + return meta.permissions().mode() & 0o111 != 0; + } + false + } + #[cfg(not(unix))] + { + true + } +} + /// Search PATH and `$NUMAN_ROOT/tools/bin` for an executable binary. pub fn find_binary_on_path(base_name: &str, root: Option<&Path>) -> Option { let target = binary_file_name(base_name); if let Some(r) = root { let in_tools = tools_bin_dir(r).join(&target); - if in_tools.is_file() { + if is_executable(&in_tools) { return Some(in_tools); } } @@ -128,7 +146,7 @@ pub fn find_binary_on_path(base_name: &str, root: Option<&Path>) -> Option, } #[allow(unreachable_patterns)] @@ -167,11 +186,9 @@ fn matches_tool_asset(tool: &ToolPreset, asset_name: &str, platform: &Platform) name.contains(&format!("x86_64-unknown-linux-{libc}")) && name.ends_with(".tar.gz") } (Os::Linux, Arch::Aarch64) => { - let libc = match platform.env { - Env::Musl => "musl", - _ => "gnu", - }; - name.contains(&format!("aarch64-unknown-linux-{libc}")) && name.ends_with(".tar.gz") + (name.contains("aarch64-unknown-linux-musl.tar.gz") + || name.contains("aarch64-unknown-linux")) + && name.ends_with(".tar.gz") } (Os::Macos, Arch::X86_64) => name.contains("x86_64-apple-darwin.tar.gz"), (Os::Macos, Arch::Aarch64) => name.contains("aarch64-apple-darwin.tar.gz"), @@ -179,19 +196,12 @@ fn matches_tool_asset(tool: &ToolPreset, asset_name: &str, platform: &Platform) }, "zoxide" => match (platform.os, platform.arch) { (Os::Windows, Arch::X86_64) => name.contains("x86_64-pc-windows-msvc.zip"), + (Os::Windows, Arch::Aarch64) => name.contains("aarch64-pc-windows-msvc.zip"), (Os::Linux, Arch::X86_64) => { - let libc = match platform.env { - Env::Musl => "musl", - _ => "gnu", - }; - name.contains(&format!("x86_64-unknown-linux-{libc}")) && name.ends_with(".tar.gz") + name.contains("x86_64-unknown-linux") && name.ends_with(".tar.gz") } (Os::Linux, Arch::Aarch64) => { - let libc = match platform.env { - Env::Musl => "musl", - _ => "gnu", - }; - name.contains(&format!("aarch64-unknown-linux-{libc}")) && name.ends_with(".tar.gz") + name.contains("aarch64-unknown-linux") && name.ends_with(".tar.gz") } (Os::Macos, Arch::X86_64) => name.contains("x86_64-apple-darwin.tar.gz"), (Os::Macos, Arch::Aarch64) => name.contains("aarch64-apple-darwin.tar.gz"), @@ -207,30 +217,50 @@ fn matches_tool_asset(tool: &ToolPreset, asset_name: &str, platform: &Platform) _ => false, }, "atuin" => match (platform.os, platform.arch) { - (Os::Windows, Arch::X86_64) => name.contains("x86_64-pc-windows-msvc.zip"), + (Os::Windows, Arch::X86_64) => { + name.contains("x86_64-pc-windows-msvc.zip") && !name.contains("server") + } (Os::Linux, Arch::X86_64) => { let libc = match platform.env { Env::Musl => "musl", _ => "gnu", }; - name.contains(&format!("x86_64-unknown-linux-{libc}")) && name.ends_with(".tar.gz") + name.contains(&format!("x86_64-unknown-linux-{libc}")) + && name.ends_with(".tar.gz") + && !name.contains("server") } (Os::Linux, Arch::Aarch64) => { let libc = match platform.env { Env::Musl => "musl", _ => "gnu", }; - name.contains(&format!("aarch64-unknown-linux-{libc}")) && name.ends_with(".tar.gz") + name.contains(&format!("aarch64-unknown-linux-{libc}")) + && name.ends_with(".tar.gz") + && !name.contains("server") + } + (Os::Macos, Arch::X86_64) => { + name.contains("x86_64-apple-darwin.tar.gz") && !name.contains("server") + } + (Os::Macos, Arch::Aarch64) => { + name.contains("aarch64-apple-darwin.tar.gz") && !name.contains("server") } - (Os::Macos, Arch::X86_64) => name.contains("x86_64-apple-darwin.tar.gz"), - (Os::Macos, Arch::Aarch64) => name.contains("aarch64-apple-darwin.tar.gz"), _ => false, }, "mise" => match (platform.os, platform.arch) { - (Os::Windows, Arch::X86_64) => name.contains("win-x64.zip"), - (Os::Windows, Arch::Aarch64) => name.contains("win-arm64.zip"), - (Os::Linux, Arch::X86_64) => name.contains("linux-x64.tar.gz"), - (Os::Linux, Arch::Aarch64) => name.contains("linux-arm64.tar.gz"), + (Os::Windows, Arch::X86_64) => { + name.contains("windows-x64.zip") || name.contains("win-x64.zip") + } + (Os::Windows, Arch::Aarch64) => { + name.contains("windows-arm64.zip") || name.contains("win-arm64.zip") + } + (Os::Linux, Arch::X86_64) => match platform.env { + Env::Musl => name.contains("linux-x64-musl.tar.gz"), + _ => name.contains("linux-x64.tar.gz") && !name.contains("musl"), + }, + (Os::Linux, Arch::Aarch64) => match platform.env { + Env::Musl => name.contains("linux-arm64-musl.tar.gz"), + _ => name.contains("linux-arm64.tar.gz") && !name.contains("musl"), + }, (Os::Macos, Arch::X86_64) => name.contains("macos-x64.tar.gz"), (Os::Macos, Arch::Aarch64) => name.contains("macos-arm64.tar.gz"), _ => false, @@ -299,6 +329,79 @@ fn fetch_latest_release(repo: &str) -> Result { .with_context(|| format!("Failed to parse release JSON for {repo}")) } +pub fn parse_checksum_from_text(text: &str, asset_name: &str) -> Option { + let trimmed = text.trim(); + if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(trimmed.to_ascii_lowercase()); + } + + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let hash_candidate = parts[0].trim_matches('*'); + let file_candidate = parts[1].trim_start_matches('*'); + if hash_candidate.len() == 64 + && hash_candidate.chars().all(|c| c.is_ascii_hexdigit()) + && (file_candidate == asset_name || file_candidate.ends_with(asset_name)) + { + return Some(hash_candidate.to_ascii_lowercase()); + } + + let file_cand2 = parts[0].trim_end_matches(':'); + let hash_cand2 = parts[1]; + if hash_cand2.len() == 64 + && hash_cand2.chars().all(|c| c.is_ascii_hexdigit()) + && (file_cand2 == asset_name || file_cand2.ends_with(asset_name)) + { + return Some(hash_cand2.to_ascii_lowercase()); + } + } + } + None +} + +fn fetch_checksum_text(url: &str) -> Result { + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .user_agent(USER_AGENT) + .build() + .context("Failed to build HTTP client for checksum download")?; + let resp = client + .get(url) + .header("User-Agent", USER_AGENT) + .send() + .with_context(|| format!("Failed to fetch checksum from {url}"))?; + if !resp.status().is_success() { + bail!("Failed to fetch checksum: HTTP {}", resp.status()); + } + resp.text().context("Failed to read checksum text") +} + +fn find_checksum_asset<'a>(assets: &'a [GitHubAsset], asset_name: &str) -> Option<&'a GitHubAsset> { + let exact_sha256 = format!("{asset_name}.sha256"); + let exact_sha256sum = format!("{asset_name}.sha256sum"); + if let Some(a) = assets + .iter() + .find(|a| a.name == exact_sha256 || a.name == exact_sha256sum) + { + return Some(a); + } + + assets.iter().find(|a| { + let n = a.name.to_ascii_lowercase(); + n == "checksums.txt" + || n == "shasums256.txt" + || n == "sha256.sum" + || n == "sha256sums" + || n == "sha256sums.txt" + || (n.contains("checksums") && n.ends_with(".txt")) + }) +} + #[cfg(unix)] fn make_executable(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; @@ -370,14 +473,16 @@ pub fn download_and_install_tool( let cache_dir = root.join("tools").join(".cache"); std::fs::create_dir_all(&cache_dir)?; - let sanitized_name = asset - .name + let sanitized_name = Path::new(&asset.name) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&asset.name) .chars() - .filter(|c| *c != '/' && *c != '\\' && *c != ':') + .filter(|c| !matches!(*c, '/' | '\\' | ':' | '\0')) .collect::(); - if sanitized_name.is_empty() { + if sanitized_name.is_empty() || sanitized_name == "." || sanitized_name == ".." { bail!( - "Release asset name for {} is empty after sanitization", + "Release asset name for {} is invalid after sanitization", tool.display_name ); } @@ -405,6 +510,28 @@ pub fn download_and_install_tool( } } + // Verify SHA-256 integrity when available from release metadata or accompanying checksum asset. + let expected_sha = if let Some(ref d) = asset.digest { + if let Some(hex) = d.strip_prefix("sha256:") { + Some(hex.to_ascii_lowercase()) + } else { + Some(d.to_ascii_lowercase()) + } + } else if let Some(checksum_asset) = find_checksum_asset(&release.assets, &asset.name) { + if let Ok(text) = fetch_checksum_text(&checksum_asset.browser_download_url) { + parse_checksum_from_text(&text, &asset.name) + } else { + None + } + } else { + None + }; + + if let Some(sha) = expected_sha { + crate::core::integrity::verify_and_report(&download_dest, &sha, tool.display_name)?; + println!("Verified SHA-256 integrity: sha256:{sha}"); + } + let bin_dir = tools_bin_dir(root); std::fs::create_dir_all(&bin_dir)?; let final_dest = bin_dir.join(binary_file_name(tool.binary_name)); @@ -610,4 +737,99 @@ mod tests { .find(|a| matches_tool_asset(starship, a, &linux_x64)) .is_none()); } + + #[test] + fn test_asset_matching_zoxide_windows_and_linux() { + let win_arm64 = Platform { + triple: "aarch64-pc-windows-msvc".to_string(), + os: Os::Windows, + arch: Arch::Aarch64, + env: crate::core::platform::Env::Msvc, + }; + let linux_x64 = Platform { + triple: "x86_64-unknown-linux-gnu".to_string(), + os: Os::Linux, + arch: Arch::X86_64, + env: crate::core::platform::Env::Gnu, + }; + let zoxide = find_preset("zoxide").unwrap(); + + assert!(matches_tool_asset( + zoxide, + "zoxide-0.10.0-aarch64-pc-windows-msvc.zip", + &win_arm64 + )); + assert!(matches_tool_asset( + zoxide, + "zoxide-0.10.0-x86_64-unknown-linux-musl.tar.gz", + &linux_x64 + )); + } + + #[test] + fn test_asset_matching_atuin_excludes_server() { + let win_x64 = Platform { + triple: "x86_64-pc-windows-msvc".to_string(), + os: Os::Windows, + arch: Arch::X86_64, + env: crate::core::platform::Env::Msvc, + }; + let atuin = find_preset("atuin").unwrap(); + + assert!(matches_tool_asset( + atuin, + "atuin-x86_64-pc-windows-msvc.zip", + &win_x64 + )); + assert!(!matches_tool_asset( + atuin, + "atuin-server-x86_64-pc-windows-msvc.zip", + &win_x64 + )); + } + + #[test] + fn test_parse_checksum_from_text() { + let hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + // Single line hash + assert_eq!( + parse_checksum_from_text(hash, "any-file.tar.gz"), + Some(hash.to_string()) + ); + + // Multi-line sha256sums file + let multi = format!( + "# Checksums\n\ + 1111111111111111111111111111111111111111111111111111111111111111 other-file.tar.gz\n\ + {hash} target-asset.zip\n\ + 2222222222222222222222222222222222222222222222222222222222222222 *another.zip\n" + ); + assert_eq!( + parse_checksum_from_text(&multi, "target-asset.zip"), + Some(hash.to_string()) + ); + assert_eq!( + parse_checksum_from_text(&multi, "another.zip"), + Some("2222222222222222222222222222222222222222222222222222222222222222".to_string()) + ); + assert_eq!(parse_checksum_from_text(&multi, "missing.zip"), None); + } + + #[test] + #[cfg(unix)] + fn test_non_executable_binary_is_skipped() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let tools_bin = dir.path().join("tools").join("bin"); + std::fs::create_dir_all(&tools_bin).unwrap(); + let bin = tools_bin.join("starship"); + std::fs::write(&bin, b"fake").unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o644)).unwrap(); + + assert_eq!(find_binary_on_path("starship", Some(dir.path())), None); + + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(find_binary_on_path("starship", Some(dir.path())), Some(bin)); + } } diff --git a/tests/setup_test.rs b/tests/setup_test.rs index 85deb72a..6d8cd4a6 100644 --- a/tests/setup_test.rs +++ b/tests/setup_test.rs @@ -195,20 +195,13 @@ fn setup_loader_detect_discovers_installed_tool() { } else { "starship" }); - std::fs::write(&fake_starship, b"fake").unwrap(); + // Ensure starship is executable so Unix systems detect it #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&fake_starship, std::fs::Permissions::from_mode(0o755)).unwrap(); } - // Add a non-executable file that detection must skip (Unix only) - #[cfg(unix)] - { - let non_exec = tools_bin.join("nonexec"); - std::fs::write(&non_exec, b"fake").unwrap(); - } - let detect_args = LoaderArgs { detect: true, yes: true, From 2f45c38e5042d811df7b9e50c821036596d61687 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Sun, 16 Aug 2026 19:34:03 -0700 Subject: [PATCH 5/8] fix(setup): align tool name validation with leading alphanumeric and clean up loader config rendering --- src/cmd/setup.rs | 7 +++++-- tests/setup_test.rs | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs index ecd98483..0b1560a9 100644 --- a/src/cmd/setup.rs +++ b/src/cmd/setup.rs @@ -843,6 +843,10 @@ fn validate_tool_name(name: &str) -> Result<()> { if name.is_empty() || name.len() > 64 { bail!("Tool name '{name}' must be 1-64 characters."); } + let first = name.chars().next().unwrap(); + if !first.is_ascii_alphanumeric() { + bail!("Tool name '{name}' must start with an ASCII letter or digit."); + } if !name .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') @@ -957,11 +961,10 @@ pub fn render_loader_config(entries: &[LoaderConfigEntry]) -> String { "# Generated by Numan. Tool configurations for nushell-loader.\n# Manage via `numan setup loader --add ` or `numan setup loader --remove `.\n\n[\n", ); for e in entries { - let escaped_name = e.name.replace('\\', "\\\\").replace('\'', "\\'"); let escaped_cmd = e.command.replace('\\', "\\\\").replace('"', "\\\""); out.push_str(&format!( " {{ name: '{}', command: \"{}\" }}\n", - escaped_name, escaped_cmd + e.name, escaped_cmd )); } out.push_str("]\n"); diff --git a/tests/setup_test.rs b/tests/setup_test.rs index 6d8cd4a6..538eb819 100644 --- a/tests/setup_test.rs +++ b/tests/setup_test.rs @@ -109,7 +109,9 @@ fn setup_loader_add_rejects_traversal_names() { assert!(result.is_err(), "path traversal name should be rejected"); let msg = format!("{:#}", result.unwrap_err()); assert!( - msg.contains("may only contain") || msg.contains("must be 1-64"), + msg.contains("may only contain") + || msg.contains("must be 1-64") + || msg.contains("must start with"), "expected name validation error, got: {msg}" ); } @@ -195,6 +197,7 @@ fn setup_loader_detect_discovers_installed_tool() { } else { "starship" }); + std::fs::write(&fake_starship, b"fake").unwrap(); // Ensure starship is executable so Unix systems detect it #[cfg(unix)] { From 374029fd9e015458fdc5d2a9cdb39ce8786bc2a2 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Sun, 16 Aug 2026 21:08:32 -0700 Subject: [PATCH 6/8] fix(setup_tools): prevent false suffix match in checksum filename parsing --- src/cmd/setup_tools.rs | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/cmd/setup_tools.rs b/src/cmd/setup_tools.rs index 3247ed0a..b11e0fb4 100644 --- a/src/cmd/setup_tools.rs +++ b/src/cmd/setup_tools.rs @@ -329,6 +329,27 @@ fn fetch_latest_release(repo: &str) -> Result { .with_context(|| format!("Failed to parse release JSON for {repo}")) } +fn matches_checksum_filename(candidate: &str, asset_name: &str) -> bool { + let clean = candidate + .trim_start_matches('*') + .trim_matches('\'') + .trim_matches('"'); + if clean == asset_name { + return true; + } + if let Some(rest) = clean.strip_prefix("./") { + if rest == asset_name { + return true; + } + } + if let Some(rest) = clean.strip_prefix(".\\") { + if rest == asset_name { + return true; + } + } + clean.ends_with(&format!("/{asset_name}")) || clean.ends_with(&format!("\\{asset_name}")) +} + pub fn parse_checksum_from_text(text: &str, asset_name: &str) -> Option { let trimmed = text.trim(); if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) { @@ -343,10 +364,10 @@ pub fn parse_checksum_from_text(text: &str, asset_name: &str) -> Option let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() >= 2 { let hash_candidate = parts[0].trim_matches('*'); - let file_candidate = parts[1].trim_start_matches('*'); + let file_candidate = parts[1]; if hash_candidate.len() == 64 && hash_candidate.chars().all(|c| c.is_ascii_hexdigit()) - && (file_candidate == asset_name || file_candidate.ends_with(asset_name)) + && matches_checksum_filename(file_candidate, asset_name) { return Some(hash_candidate.to_ascii_lowercase()); } @@ -355,7 +376,7 @@ pub fn parse_checksum_from_text(text: &str, asset_name: &str) -> Option let hash_cand2 = parts[1]; if hash_cand2.len() == 64 && hash_cand2.chars().all(|c| c.is_ascii_hexdigit()) - && (file_cand2 == asset_name || file_cand2.ends_with(asset_name)) + && matches_checksum_filename(file_cand2, asset_name) { return Some(hash_cand2.to_ascii_lowercase()); } @@ -798,17 +819,27 @@ mod tests { Some(hash.to_string()) ); - // Multi-line sha256sums file + // Multi-line sha256sums file with paths and potential suffix collisions let multi = format!( "# Checksums\n\ 1111111111111111111111111111111111111111111111111111111111111111 other-file.tar.gz\n\ + 3333333333333333333333333333333333333333333333333333333333333333 prefix-target-asset.zip\n\ {hash} target-asset.zip\n\ + 4444444444444444444444444444444444444444444444444444444444444444 ./dist/path-asset.tar.gz\n\ 2222222222222222222222222222222222222222222222222222222222222222 *another.zip\n" ); assert_eq!( parse_checksum_from_text(&multi, "target-asset.zip"), Some(hash.to_string()) ); + assert_eq!( + parse_checksum_from_text(&multi, "prefix-target-asset.zip"), + Some("3333333333333333333333333333333333333333333333333333333333333333".to_string()) + ); + assert_eq!( + parse_checksum_from_text(&multi, "path-asset.tar.gz"), + Some("4444444444444444444444444444444444444444444444444444444444444444".to_string()) + ); assert_eq!( parse_checksum_from_text(&multi, "another.zip"), Some("2222222222222222222222222222222222222222222222222222222222222222".to_string()) From 11908e6be64731f90bce6aef5237c40039de4755 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Sun, 16 Aug 2026 21:09:45 -0700 Subject: [PATCH 7/8] fix(setup_tools): enforce musl libc requirements on musl linux platforms --- src/cmd/setup_tools.rs | 74 ++++++++++++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/src/cmd/setup_tools.rs b/src/cmd/setup_tools.rs index b11e0fb4..78cf257c 100644 --- a/src/cmd/setup_tools.rs +++ b/src/cmd/setup_tools.rs @@ -178,18 +178,22 @@ fn matches_tool_asset(tool: &ToolPreset, asset_name: &str, platform: &Platform) "starship" => match (platform.os, platform.arch) { (Os::Windows, Arch::X86_64) => name.contains("x86_64-pc-windows-msvc.zip"), (Os::Windows, Arch::Aarch64) => name.contains("aarch64-pc-windows-msvc.zip"), - (Os::Linux, Arch::X86_64) => { - let libc = match platform.env { - Env::Musl => "musl", - _ => "gnu", - }; - name.contains(&format!("x86_64-unknown-linux-{libc}")) && name.ends_with(".tar.gz") - } - (Os::Linux, Arch::Aarch64) => { - (name.contains("aarch64-unknown-linux-musl.tar.gz") - || name.contains("aarch64-unknown-linux")) - && name.ends_with(".tar.gz") - } + (Os::Linux, Arch::X86_64) => match platform.env { + Env::Musl => { + name.contains("x86_64-unknown-linux-musl") && name.ends_with(".tar.gz") + } + _ => name.contains("x86_64-unknown-linux-gnu") && name.ends_with(".tar.gz"), + }, + (Os::Linux, Arch::Aarch64) => match platform.env { + Env::Musl => { + name.contains("aarch64-unknown-linux-musl") && name.ends_with(".tar.gz") + } + _ => { + (name.contains("aarch64-unknown-linux-gnu") + || name.contains("aarch64-unknown-linux-musl")) + && name.ends_with(".tar.gz") + } + }, (Os::Macos, Arch::X86_64) => name.contains("x86_64-apple-darwin.tar.gz"), (Os::Macos, Arch::Aarch64) => name.contains("aarch64-apple-darwin.tar.gz"), _ => false, @@ -197,12 +201,26 @@ fn matches_tool_asset(tool: &ToolPreset, asset_name: &str, platform: &Platform) "zoxide" => match (platform.os, platform.arch) { (Os::Windows, Arch::X86_64) => name.contains("x86_64-pc-windows-msvc.zip"), (Os::Windows, Arch::Aarch64) => name.contains("aarch64-pc-windows-msvc.zip"), - (Os::Linux, Arch::X86_64) => { - name.contains("x86_64-unknown-linux") && name.ends_with(".tar.gz") - } - (Os::Linux, Arch::Aarch64) => { - name.contains("aarch64-unknown-linux") && name.ends_with(".tar.gz") - } + (Os::Linux, Arch::X86_64) => match platform.env { + Env::Musl => { + name.contains("x86_64-unknown-linux-musl") && name.ends_with(".tar.gz") + } + _ => { + (name.contains("x86_64-unknown-linux-gnu") + || name.contains("x86_64-unknown-linux-musl")) + && name.ends_with(".tar.gz") + } + }, + (Os::Linux, Arch::Aarch64) => match platform.env { + Env::Musl => { + name.contains("aarch64-unknown-linux-musl") && name.ends_with(".tar.gz") + } + _ => { + (name.contains("aarch64-unknown-linux-gnu") + || name.contains("aarch64-unknown-linux-musl")) + && name.ends_with(".tar.gz") + } + }, (Os::Macos, Arch::X86_64) => name.contains("x86_64-apple-darwin.tar.gz"), (Os::Macos, Arch::Aarch64) => name.contains("aarch64-apple-darwin.tar.gz"), _ => false, @@ -767,12 +785,18 @@ mod tests { arch: Arch::Aarch64, env: crate::core::platform::Env::Msvc, }; - let linux_x64 = Platform { + let linux_x64_gnu = Platform { triple: "x86_64-unknown-linux-gnu".to_string(), os: Os::Linux, arch: Arch::X86_64, env: crate::core::platform::Env::Gnu, }; + let linux_x64_musl = Platform { + triple: "x86_64-unknown-linux-musl".to_string(), + os: Os::Linux, + arch: Arch::X86_64, + env: crate::core::platform::Env::Musl, + }; let zoxide = find_preset("zoxide").unwrap(); assert!(matches_tool_asset( @@ -783,7 +807,17 @@ mod tests { assert!(matches_tool_asset( zoxide, "zoxide-0.10.0-x86_64-unknown-linux-musl.tar.gz", - &linux_x64 + &linux_x64_gnu + )); + assert!(matches_tool_asset( + zoxide, + "zoxide-0.10.0-x86_64-unknown-linux-musl.tar.gz", + &linux_x64_musl + )); + assert!(!matches_tool_asset( + zoxide, + "zoxide-0.10.0-x86_64-unknown-linux-gnu.tar.gz", + &linux_x64_musl )); } From 099d4a983c9c28dc2c9b86e10cb2bc0295ea79f1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:20:49 +0000 Subject: [PATCH 8/8] Fixes verified: loader nuon parsing, path-traversal guards, Mise install, tests green. Co-authored-by: tonythethompson --- CHANGELOG.md | 2 +- assets/nushell-loader/loader.nu | 3 +- src/cmd/setup.rs | 341 ++++++++++++++++++++++++-------- src/cmd/setup_tools.rs | 161 ++++++++++----- tests/setup_test.rs | 113 ++++++++++- 5 files changed, 493 insertions(+), 127 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3096757b..f13f50d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Enhanced `numan setup loader`**: - Configuration isolation: separates user tool configurations into `loader-config.nu` so that updating the loader engine (`numan setup loader --force`) preserves custom tool definitions. - - Direct GitHub release binary installer: downloads prebuilt verified binaries for CLI tools (Starship, Zoxide, Carapace, Atuin, Mise, Direnv, Oh-My-Posh) into `$NUMAN_ROOT/tools/bin/` with `--install` / `--install-missing` and persists them to the user's `PATH`. + - Direct GitHub release binary installer: downloads prebuilt binaries for CLI tools (Starship, Zoxide, Carapace, Atuin, Mise, Direnv, Oh-My-Posh) into `$NUMAN_ROOT/tools/bin/`, verifying SHA-256 integrity when release metadata or a checksum asset is available, with `--install` / `--install-missing` and persistence to the user's `PATH`. - Tool management flags: `--status` (inspect health, config, PATH status, and cached autoload files), `--detect` (discover PATH tools), `--add ` (preset or custom), `--remove ` (with cache purge), and `--clean` (invalidate cached init files). ## [0.2.2] - 2026-08-16 diff --git a/assets/nushell-loader/loader.nu b/assets/nushell-loader/loader.nu index 0b036c03..fec81abf 100644 --- a/assets/nushell-loader/loader.nu +++ b/assets/nushell-loader/loader.nu @@ -13,7 +13,8 @@ let loader_config_file = ($nu.config-path | path dirname | path join 'loader-con let aidnem_loader_configs: list = if ($loader_config_file | path exists) { try { - let cfg = (open $loader_config_file) + # The config is stored as nuon data in a .nu file, so read it raw and parse it. + let cfg = (open --raw $loader_config_file | from nuon) if ($cfg | describe | str starts-with "list") { $cfg } else { diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs index 0b1560a9..a31c0df9 100644 --- a/src/cmd/setup.rs +++ b/src/cmd/setup.rs @@ -785,6 +785,22 @@ where return execute_loader_remove(&loader_config_path, tool_name, root); } + // Migrate inline configs that older loader.nu versions stored directly in + // the engine script, so upgrading to loader-config.nu isolation preserves them. + if loader_path.is_file() && !loader_config_path.exists() { + if let Ok(existing) = std::fs::read_to_string(&loader_path) { + let inline = extract_inline_loader_configs(&existing); + if !inline.is_empty() { + write_loader_config(&loader_config_path, &inline)?; + println!( + "Migrated {} inline tool config(s) from the existing loader.nu to '{}'.", + inline.len(), + loader_config_path.display() + ); + } + } + } + // Install/update loader engine file install_loader_file(&loader_path, args)?; @@ -835,7 +851,31 @@ pub fn read_loader_config(config_path: &Path) -> Result> } let content = std::fs::read_to_string(config_path) .with_context(|| format!("Failed to read '{}'", config_path.display()))?; - Ok(parse_loader_config(&content)) + parse_loader_config(&content) +} + +/// Extract `aidnem_loader_configs` entries that older loader.nu versions +/// stored inline (before the loader-config.nu isolation), best-effort. +fn extract_inline_loader_configs(loader_content: &str) -> Vec { + let mut line_idx = None; + for (idx, line) in loader_content.lines().enumerate() { + if line.contains("aidnem_loader_configs") && !line.trim_start().starts_with('#') { + line_idx = Some(idx); + break; + } + } + let Some(idx) = line_idx else { + return Vec::new(); + }; + let rest = loader_content + .lines() + .skip(idx) + .collect::>() + .join("\n"); + let Some(open) = rest.find('[') else { + return Vec::new(); + }; + parse_loader_config(&rest[open..]).unwrap_or_default() } /// Validate a tool name for use in loader config and autoload file paths. @@ -859,101 +899,159 @@ fn validate_tool_name(name: &str) -> Result<()> { /// Reserved tool name that must not be overwritten by loader add/remove. const RESERVED_LOADER_NAMES: &[&str] = &["numan"]; -pub fn parse_loader_config(content: &str) -> Vec { +pub fn parse_loader_config(content: &str) -> Result> { + let chars: Vec = content.chars().collect(); let mut entries = Vec::new(); - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.starts_with('#') || trimmed.starts_with("//") { - continue; + let mut i = 0usize; + while i < chars.len() { + match chars[i] { + '[' | ']' | ',' => { + i += 1; + } + '#' => skip_line(&chars, &mut i), + '/' if chars.get(i + 1) == Some(&'/') => skip_line(&chars, &mut i), + c if c.is_whitespace() => { + i += 1; + } + '{' => match parse_record(&chars, &mut i) { + Some(entry) => entries.push(entry), + None => { + bail!("Malformed loader config record at character {i}."); + } + }, + c => { + bail!( + "Unexpected content in loader config at character {i}: '{c}'. \ + Expected a nuon list of {{ name: '...', command: \"...\" }} records." + ); + } } - if let Some(entry) = parse_loader_record_line(trimmed) { - entries.push(entry); + } + Ok(entries) +} + +/// Scan forward past a `#` / `//` line comment. +fn skip_line(chars: &[char], i: &mut usize) { + while let Some(c) = chars.get(*i) { + if *c == '\n' { + *i += 1; + break; } + *i += 1; } - entries } -fn parse_loader_record_line(line: &str) -> Option { - let name_idx = line.find("name:")?; - let after_name = &line[name_idx + 5..].trim_start(); - let name_quote = after_name.chars().next()?; - if name_quote != '\'' && name_quote != '"' { - return None; +/// Skip whitespace, comments, and record separators. +fn skip_trivia(chars: &[char], i: &mut usize) { + loop { + match chars.get(*i) { + Some(c) if c.is_whitespace() || *c == ',' => *i += 1, + Some('#') => skip_line(chars, i), + Some('/') if chars.get(*i + 1) == Some(&'/') => skip_line(chars, i), + _ => break, + } } +} - let mut name = String::new(); - let mut rest = &after_name[name_quote.len_utf8()..]; +/// Parse a `{ name: , command: }` record that may span multiple lines. +fn parse_record(chars: &[char], i: &mut usize) -> Option { + debug_assert_eq!(chars.get(*i), Some(&'{')); + *i += 1; + let mut name = None; + let mut command = None; loop { - match rest.chars().next()? { - '\\' if name_quote == '"' => { - rest = &rest[1..]; - match rest.chars().next()? { - '\\' => { - name.push('\\'); - rest = &rest[1..]; - } - '"' => { - name.push('"'); - rest = &rest[1..]; - } - c => { - name.push('\\'); - name.push(c); - rest = &rest[c.len_utf8()..]; - } - } - } - c if c == name_quote => { - rest = &rest[name_quote.len_utf8()..]; + skip_trivia(chars, i); + match chars.get(*i) { + None => return None, + Some('}') => { + *i += 1; break; } - c => { - name.push(c); - rest = &rest[c.len_utf8()..]; - } + Some(_) => {} + } + let key = read_ident(chars, i)?; + skip_trivia(chars, i); + if chars.get(*i) != Some(&':') { + return None; + } + *i += 1; + skip_trivia(chars, i); + let value = read_string(chars, i)?; + match key.as_str() { + "name" => name = Some(value), + "command" => command = Some(value), + _ => {} } } + Some(LoaderConfigEntry { + name: name?, + command: command?, + }) +} - let cmd_idx = rest.find("command:")?; - let after_cmd = &rest[cmd_idx + 8..].trim_start(); - let cmd_quote = after_cmd.chars().next()?; - if cmd_quote != '\'' && cmd_quote != '"' { - return None; +/// Read an ASCII identifier (nuon record key). +fn read_ident(chars: &[char], i: &mut usize) -> Option { + let start = *i; + while let Some(c) = chars.get(*i) { + if c.is_ascii_alphanumeric() || *c == '_' { + *i += 1; + } else { + break; + } + } + if *i == start { + None + } else { + Some(chars[start..*i].iter().collect()) } +} - let mut command = String::new(); - let mut cmd_rest = &after_cmd[cmd_quote.len_utf8()..]; +/// Read a single- or double-quoted string, honoring escapes in double quotes. +fn read_string(chars: &[char], i: &mut usize) -> Option { + let quote = *chars.get(*i)?; + if quote != '\'' && quote != '"' { + return None; + } + *i += 1; + let mut out = String::new(); loop { - match cmd_rest.chars().next()? { - '\\' if cmd_quote == '"' => { - cmd_rest = &cmd_rest[1..]; - match cmd_rest.chars().next()? { - '\\' => { - command.push('\\'); - cmd_rest = &cmd_rest[1..]; - } - '"' => { - command.push('"'); - cmd_rest = &cmd_rest[1..]; - } - c => { - command.push('\\'); - command.push(c); - cmd_rest = &cmd_rest[c.len_utf8()..]; - } + let c = *chars.get(*i)?; + if c == quote { + *i += 1; + break; + } + if c == '\\' && quote == '"' { + *i += 1; + let esc = *chars.get(*i)?; + match esc { + '\\' => { + out.push('\\'); + *i += 1; + } + '"' => { + out.push('"'); + *i += 1; + } + 'n' => { + out.push('\n'); + *i += 1; + } + 't' => { + out.push('\t'); + *i += 1; + } + _ => { + out.push('\\'); + out.push(esc); + *i += 1; } } - c if c == cmd_quote => { - break; - } - c => { - command.push(c); - cmd_rest = &cmd_rest[c.len_utf8()..]; - } + } else { + out.push(c); + *i += 1; } } - - Some(LoaderConfigEntry { name, command }) + Some(out) } pub fn render_loader_config(entries: &[LoaderConfigEntry]) -> String { @@ -1063,6 +1161,22 @@ fn execute_loader_clean(loader_config_path: &Path, root: Option<&Path>) -> Resul let mut removed = 0; for e in &entries { + // Names come from a user-editable file; validate before joining to the + // cache path so a crafted entry cannot escape vendor/autoload. + if let Err(err) = validate_tool_name(&e.name) { + eprintln!( + "Warning: skipping invalid tool name '{}' in loader config: {err:#}", + e.name + ); + continue; + } + if RESERVED_LOADER_NAMES.contains(&e.name.as_str()) { + eprintln!( + "Warning: skipping reserved tool name '{}' (Numan-managed file).", + e.name + ); + continue; + } let target = autoload_dir.join(format!("{}.nu", e.name)); if target.is_file() { match std::fs::remove_file(&target) { @@ -1182,8 +1296,29 @@ fn execute_loader_add( let mut entries = read_loader_config(loader_config_path)?; if let Some(existing) = entries.iter_mut().find(|e| e.name == name) { - existing.command = command.clone(); - println!("Updated '{}' command in loader config.", name); + if existing.command != command { + // The cached autoload file encodes the old command; purge it so the + // loader regenerates it from the updated configuration. + if let Some(autoload_dir) = resolve_vendor_autoload_dir(root) { + let target = autoload_dir.join(format!("{name}.nu")); + if target.is_file() { + match std::fs::remove_file(&target) { + Ok(()) => println!( + "Removed stale cache '{}' (command changed).", + target.display() + ), + Err(err) => eprintln!( + "Warning: failed to remove stale cache '{}': {err:#}", + target.display() + ), + } + } + } + existing.command = command.clone(); + println!("Updated '{}' command in loader config.", name); + } else { + println!("'{}' is already configured with this command.", name); + } } else { entries.push(LoaderConfigEntry { name: name.clone(), @@ -1542,10 +1677,60 @@ mod tests { ]; let rendered = render_loader_config(&entries); - let parsed = parse_loader_config(&rendered); + let parsed = parse_loader_config(&rendered).unwrap(); assert_eq!(entries, parsed); } + #[test] + fn loader_config_parses_multiline_records() { + let content = "\ +# header comment +[ + { + name: 'starship', + command: 'starship init nu', // inline comment + }, + { + name: 'custom', + command: \"echo \\\"quoted\\\"\", + }, +] +"; + let parsed = parse_loader_config(content).unwrap(); + assert_eq!( + parsed, + vec![ + LoaderConfigEntry { + name: "starship".to_string(), + command: "starship init nu".to_string(), + }, + LoaderConfigEntry { + name: "custom".to_string(), + command: "echo \"quoted\"".to_string(), + }, + ] + ); + } + + #[test] + fn loader_config_rejects_malformed_content() { + let err = parse_loader_config("this is not a list") + .unwrap_err() + .to_string(); + assert!( + err.contains("Unexpected content") || err.contains("Malformed"), + "got: {err}" + ); + + let err = parse_loader_config("[ { name: 'only-name' } ]") + .unwrap_err() + .to_string(); + assert!( + err.contains("Malformed"), + "record without command must fail, got: {err}" + ); + } + #[test] fn remove_managed_nu_removes_directory() { let dir = TempDir::new().unwrap(); diff --git a/src/cmd/setup_tools.rs b/src/cmd/setup_tools.rs index 78cf257c..063136b8 100644 --- a/src/cmd/setup_tools.rs +++ b/src/cmd/setup_tools.rs @@ -8,6 +8,7 @@ use crate::core::platform::{Arch, Env, Os, Platform}; use crate::install::download::download_file; use crate::install::extract::{extract_archive, ArchiveFormat, ExtractConfig}; use crate::nu::bootstrap::{persist_path_dir, prepend_process_path}; +use crate::util::fs_safety::assert_not_symlink; const USER_AGENT: &str = "numan-cli (https://github.com/tonythethompson/numan)"; @@ -188,11 +189,7 @@ fn matches_tool_asset(tool: &ToolPreset, asset_name: &str, platform: &Platform) Env::Musl => { name.contains("aarch64-unknown-linux-musl") && name.ends_with(".tar.gz") } - _ => { - (name.contains("aarch64-unknown-linux-gnu") - || name.contains("aarch64-unknown-linux-musl")) - && name.ends_with(".tar.gz") - } + _ => name.contains("aarch64-unknown-linux-gnu") && name.ends_with(".tar.gz"), }, (Os::Macos, Arch::X86_64) => name.contains("x86_64-apple-darwin.tar.gz"), (Os::Macos, Arch::Aarch64) => name.contains("aarch64-apple-darwin.tar.gz"), @@ -205,21 +202,13 @@ fn matches_tool_asset(tool: &ToolPreset, asset_name: &str, platform: &Platform) Env::Musl => { name.contains("x86_64-unknown-linux-musl") && name.ends_with(".tar.gz") } - _ => { - (name.contains("x86_64-unknown-linux-gnu") - || name.contains("x86_64-unknown-linux-musl")) - && name.ends_with(".tar.gz") - } + _ => name.contains("x86_64-unknown-linux-gnu") && name.ends_with(".tar.gz"), }, (Os::Linux, Arch::Aarch64) => match platform.env { Env::Musl => { name.contains("aarch64-unknown-linux-musl") && name.ends_with(".tar.gz") } - _ => { - (name.contains("aarch64-unknown-linux-gnu") - || name.contains("aarch64-unknown-linux-musl")) - && name.ends_with(".tar.gz") - } + _ => name.contains("aarch64-unknown-linux-gnu") && name.ends_with(".tar.gz"), }, (Os::Macos, Arch::X86_64) => name.contains("x86_64-apple-darwin.tar.gz"), (Os::Macos, Arch::Aarch64) => name.contains("aarch64-apple-darwin.tar.gz"), @@ -458,38 +447,44 @@ fn make_executable(_path: &Path) -> Result<()> { Ok(()) } +/// Depth limit for locating a binary inside an extracted archive. +const EXTRACTED_BINARY_MAX_DEPTH: usize = 4; + fn find_extracted_binary(extract_dir: &Path, expected_name: &str) -> Result { let bin_target = binary_file_name(expected_name); - let direct = extract_dir.join(&bin_target); - if direct.is_file() { - return Ok(direct); - } + let mut found = None; + walk_for_binary(extract_dir, &bin_target, 0, &mut found)?; + found.ok_or_else(|| { + anyhow::anyhow!( + "Could not find '{}' in extracted archive at '{}'", + bin_target, + extract_dir.display() + ) + }) +} - // Search 2 levels deep - for entry in std::fs::read_dir(extract_dir)? { +fn walk_for_binary( + dir: &Path, + target: &str, + depth: usize, + found: &mut Option, +) -> Result<()> { + if found.is_some() { + return Ok(()); + } + for entry in std::fs::read_dir(dir)? { let entry = entry?; let p = entry.path(); - if p.is_file() && p.file_name().and_then(|n| n.to_str()) == Some(&bin_target) { - return Ok(p); - } - if p.is_dir() { - for sub in std::fs::read_dir(&p)? { - let sub = sub?; - let sub_p = sub.path(); - if sub_p.is_file() - && sub_p.file_name().and_then(|n| n.to_str()) == Some(&bin_target) - { - return Ok(sub_p); - } + if p.is_file() { + if p.file_name().and_then(|n| n.to_str()) == Some(target) { + *found = Some(p); + return Ok(()); } + } else if p.is_dir() && depth < EXTRACTED_BINARY_MAX_DEPTH { + walk_for_binary(&p, target, depth + 1, found)?; } } - - bail!( - "Could not find '{}' in extracted archive at '{}'", - bin_target, - extract_dir.display() - ) + Ok(()) } /// Download and install a tool preset from GitHub into `$NUMAN_ROOT/tools/bin`. @@ -498,6 +493,14 @@ pub fn download_and_install_tool( root: &Path, platform: &Platform, ) -> Result { + // Resolve to an absolute root so relative roots never produce relative + // PATH entries that resolve against a future shell's working directory. + std::fs::create_dir_all(root) + .with_context(|| format!("Failed to create Numan root directory '{}'", root.display()))?; + let root = root + .canonicalize() + .with_context(|| format!("Failed to resolve Numan root '{}'", root.display()))?; + let release = fetch_latest_release(tool.github_repo)?; let asset = release .assets @@ -512,6 +515,7 @@ pub fn download_and_install_tool( let cache_dir = root.join("tools").join(".cache"); std::fs::create_dir_all(&cache_dir)?; + assert_not_symlink(&cache_dir, "tool cache directory")?; let sanitized_name = Path::new(&asset.name) .file_name() .and_then(|n| n.to_str()) @@ -526,12 +530,14 @@ pub fn download_and_install_tool( ); } let download_dest = cache_dir.join(&sanitized_name); + assert_not_symlink(&download_dest, "tool download target")?; println!( "Downloading {} {} ({})…", tool.display_name, release.tag_name, asset.name ); download_file(&asset.browser_download_url, &download_dest)?; + assert_not_symlink(&download_dest, "tool download target")?; // Validate downloaded file size when the release metadata provides one. if asset.size > 0 { @@ -569,11 +575,18 @@ pub fn download_and_install_tool( if let Some(sha) = expected_sha { crate::core::integrity::verify_and_report(&download_dest, &sha, tool.display_name)?; println!("Verified SHA-256 integrity: sha256:{sha}"); + } else { + println!( + "No SHA-256 digest available for {}; skipping integrity verification.", + tool.display_name + ); } - let bin_dir = tools_bin_dir(root); + let bin_dir = tools_bin_dir(&root); std::fs::create_dir_all(&bin_dir)?; + assert_not_symlink(&bin_dir, "tool bin directory")?; let final_dest = bin_dir.join(binary_file_name(tool.binary_name)); + assert_not_symlink(&final_dest, "tool binary destination")?; if tool.is_direct_binary { std::fs::copy(&download_dest, &final_dest).with_context(|| { @@ -594,12 +607,13 @@ pub fn download_and_install_tool( format!("Unsupported archive format for tool asset '{}'", asset.name) })?; - extract_archive( - &download_dest, - &extract_dir, - &ExtractConfig::default(), - format, - )?; + let mut config = ExtractConfig::default(); + // Mise releases exceed the default 100 MiB extraction cap. + if tool.name == "mise" { + config.max_uncompressed_bytes = Some(512 * 1024 * 1024); + } + + extract_archive(&download_dest, &extract_dir, &config, format)?; let extracted_bin = find_extracted_binary(&extract_dir, tool.binary_name)?; std::fs::copy(&extracted_bin, &final_dest).with_context(|| { @@ -805,6 +819,11 @@ mod tests { &win_arm64 )); assert!(matches_tool_asset( + zoxide, + "zoxide-0.10.0-x86_64-unknown-linux-gnu.tar.gz", + &linux_x64_gnu + )); + assert!(!matches_tool_asset( zoxide, "zoxide-0.10.0-x86_64-unknown-linux-musl.tar.gz", &linux_x64_gnu @@ -821,6 +840,56 @@ mod tests { )); } + #[test] + fn test_asset_matching_starship_aarch64_libc() { + let linux_arm64_gnu = Platform { + triple: "aarch64-unknown-linux-gnu".to_string(), + os: Os::Linux, + arch: Arch::Aarch64, + env: crate::core::platform::Env::Gnu, + }; + let linux_arm64_musl = Platform { + triple: "aarch64-unknown-linux-musl".to_string(), + os: Os::Linux, + arch: Arch::Aarch64, + env: crate::core::platform::Env::Musl, + }; + let starship = find_preset("starship").unwrap(); + + assert!(matches_tool_asset( + starship, + "starship-aarch64-unknown-linux-gnu.tar.gz", + &linux_arm64_gnu + )); + assert!(!matches_tool_asset( + starship, + "starship-aarch64-unknown-linux-musl.tar.gz", + &linux_arm64_gnu + )); + assert!(matches_tool_asset( + starship, + "starship-aarch64-unknown-linux-musl.tar.gz", + &linux_arm64_musl + )); + assert!(!matches_tool_asset( + starship, + "starship-aarch64-unknown-linux-gnu.tar.gz", + &linux_arm64_musl + )); + } + + #[test] + fn test_find_extracted_binary_deep_path() { + let dir = tempfile::tempdir().unwrap(); + // Replicates mise's `mise/bin/mise` layout (3 levels deep). + let nested = dir.path().join("mise").join("bin"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(nested.join("mise"), b"fake").unwrap(); + + let found = find_extracted_binary(dir.path(), "mise").unwrap(); + assert_eq!(found, nested.join("mise")); + } + #[test] fn test_asset_matching_atuin_excludes_server() { let win_x64 = Platform { diff --git a/tests/setup_test.rs b/tests/setup_test.rs index 538eb819..a71144b5 100644 --- a/tests/setup_test.rs +++ b/tests/setup_test.rs @@ -5,6 +5,20 @@ use numan_cli::cmd::setup::{ parse_loader_config, read_loader_config, render_loader_config, LoaderArgs, LoaderConfigEntry, }; +/// Write a minimal `nu_state/paths.json` so loader flows can resolve the +/// vendor autoload directory without probing a real Nu binary. +fn write_paths_json(root: &std::path::Path, autoload: &std::path::Path) { + let nu_state = root.join("nu_state"); + std::fs::create_dir_all(&nu_state).unwrap(); + let json = format!( + r#"{{"nu_executable":"/usr/bin/nu","nu_version":"0.113.1","plugin_registry_path":"/tmp/p.json","nu_executable_hash":"abc","platform":"x86_64-unknown-linux-gnu","data_dir":"{}","vendor_autoload_dirs":["{}"],"vendor_autoload_dir":"{}"}}"#, + autoload.display(), + autoload.display(), + autoload.display() + ); + std::fs::write(nu_state.join("paths.json"), json).unwrap(); +} + #[test] fn setup_loader_install_and_configure_without_live_nu() { let dir = tempfile::tempdir().unwrap(); @@ -237,6 +251,103 @@ fn loader_config_roundtrip_with_escaping() { ]; let rendered = render_loader_config(&entries); - let parsed = parse_loader_config(&rendered); + let parsed = parse_loader_config(&rendered).unwrap(); assert_eq!(entries, parsed); } + +#[test] +fn setup_loader_clean_skips_invalid_and_reserved_names() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.nu"); + std::fs::write(&config_path, "# user config\n").unwrap(); + let root = dir.path().join("numan-root"); + let autoload = dir.path().join("autoload"); + std::fs::create_dir_all(&autoload).unwrap(); + write_paths_json(&root, &autoload); + + let loader_config_path = dir.path().join("loader-config.nu"); + let entries = vec![ + LoaderConfigEntry { + name: "starship".to_string(), + command: "starship init nu".to_string(), + }, + // Crafted entry that would escape vendor/autoload if not validated. + LoaderConfigEntry { + name: "../victim".to_string(), + command: "echo bad".to_string(), + }, + // Reserved Numan-managed name must never be deleted by --clean. + LoaderConfigEntry { + name: "numan".to_string(), + command: "echo numan".to_string(), + }, + ]; + std::fs::write(&loader_config_path, render_loader_config(&entries)).unwrap(); + + std::fs::write(autoload.join("starship.nu"), b"cache").unwrap(); + std::fs::write(autoload.join("numan.nu"), b"managed").unwrap(); + let victim = dir.path().join("victim.nu"); + std::fs::write(&victim, b"outside").unwrap(); + + let clean_args = LoaderArgs { + clean: true, + yes: true, + ..Default::default() + }; + execute_loader_with_probe_and_root(&clean_args, Some(&root), || Ok(config_path.clone())) + .unwrap(); + + assert!( + !autoload.join("starship.nu").exists(), + "valid entry cache should be removed" + ); + assert!( + autoload.join("numan.nu").exists(), + "reserved numan.nu must not be deleted" + ); + assert!( + victim.exists(), + "path-traversal name must not delete a file outside vendor/autoload" + ); +} + +#[test] +fn setup_loader_add_purges_stale_cache_when_command_changes() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.nu"); + std::fs::write(&config_path, "# user config\n").unwrap(); + let root = dir.path().join("numan-root"); + let autoload = dir.path().join("autoload"); + std::fs::create_dir_all(&autoload).unwrap(); + write_paths_json(&root, &autoload); + + let add_first = LoaderArgs { + add: Some("mytool=echo one".to_string()), + yes: true, + ..Default::default() + }; + execute_loader_with_probe_and_root(&add_first, Some(&root), || Ok(config_path.clone())) + .unwrap(); + + // Simulate a cached autoload file generated from the old command. + let cache = autoload.join("mytool.nu"); + std::fs::write(&cache, b"stale").unwrap(); + + let add_second = LoaderArgs { + add: Some("mytool=echo two".to_string()), + yes: true, + ..Default::default() + }; + execute_loader_with_probe_and_root(&add_second, Some(&root), || Ok(config_path.clone())) + .unwrap(); + + let loader_config_path = dir.path().join("loader-config.nu"); + let configs = read_loader_config(&loader_config_path).unwrap(); + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].name, "mytool"); + assert_eq!(configs[0].command, "echo two"); + assert!( + !cache.exists(), + "stale cache must be purged so the loader regenerates it" + ); +}