Skip to main content

rpi_cli/
install_pi.rs

1//! Pi package installer (`rpi install-pi`).
2//!
3//! Pi packages are ordinary npm/git/local directories. npm and Git sources use
4//! Pi-compatible managed stores, while local directories are enabled in place.
5//! The resulting source is recorded in settings so static resources and JS/TS
6//! extensions can be loaded on startup.
7
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10
11use crate::npm::{NpmCommand, NpmManagerKind};
12
13#[derive(Debug, Clone)]
14struct Options {
15    spec: String,
16    global: bool,
17    force: bool,
18}
19
20#[derive(Debug)]
21struct InstallOutcome {
22    root: PathBuf,
23    settings_source: Option<String>,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum InstallSpecKind {
28    Npm,
29    Git,
30    Local,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34enum NpmExecutionMode {
35    Interactive,
36    StartupRemediation,
37}
38
39pub fn run(args: &[String]) -> i32 {
40    let options = match parse_args(args) {
41        Ok(options) => options,
42        Err(message) if message == "help" => {
43            print_help();
44            return 0;
45        }
46        Err(message) => {
47            eprintln!("error: {message}");
48            print_help();
49            return 2;
50        }
51    };
52    let cwd = match std::env::current_dir() {
53        Ok(path) => path,
54        Err(error) => {
55            eprintln!("error: could not determine current directory: {error}");
56            return 1;
57        }
58    };
59    let project_trusted = match project_trust_for_package_operation(&cwd, options.global) {
60        Ok(trusted) => trusted,
61        Err(error) => {
62            eprintln!("error: {error}");
63            return 1;
64        }
65    };
66    let spec_kind = classify_install_spec(&cwd, &options.spec);
67    let npm_command = match npm_command_for_install(&cwd, project_trusted, spec_kind) {
68        Ok(command) => command,
69        Err(error) => {
70            eprintln!("error: {error}");
71            return 1;
72        }
73    };
74    let mut settings = match load_settings_for_install(&cwd, options.global) {
75        Ok(settings) => settings,
76        Err(error) => {
77            eprintln!("error: refusing to install with unreadable settings: {error}");
78            return 1;
79        }
80    };
81    let outcome = match install_spec(&cwd, &options, spec_kind, npm_command.as_ref()) {
82        Ok(outcome) => outcome,
83        Err(error) => {
84            eprintln!("error: {error}");
85            return 1;
86        }
87    };
88
89    let package_root = normalize_config_path(
90        std::fs::canonicalize(&outcome.root).unwrap_or_else(|_| outcome.root.clone()),
91    );
92    let packages = settings.packages.get_or_insert_with(Vec::new);
93    let enabled = match outcome
94        .settings_source
95        .map(Ok)
96        .unwrap_or_else(|| settings_entry(&package_root, &cwd, options.global))
97    {
98        Ok(enabled) => enabled,
99        Err(error) => {
100            eprintln!("error: installed package but could not record its settings entry: {error}");
101            return 1;
102        }
103    };
104    if enable_settings_entry(&cwd, packages, enabled) {
105        if let Err(error) = save_settings_for_install(&cwd, options.global, &settings) {
106            eprintln!("error: installed package but could not save settings: {error}");
107            return 1;
108        }
109    }
110    println!("installed Pi package {}", package_root.display());
111    println!("JS/TS extensions and static resources will load on the next start.");
112    println!("warning: Pi extensions execute JavaScript with the current user's permissions.");
113    0
114}
115
116fn project_trust_for_package_operation(cwd: &Path, global: bool) -> Result<bool, String> {
117    match crate::config::project_trust_decision(cwd) {
118        Ok(Some(true)) => Ok(true),
119        Ok(_) if global => Ok(false),
120        Ok(_) => Err("project is not trusted; refusing to access project package storage".into()),
121        Err(_) if global => Ok(false),
122        Err(error) => Err(format!("could not read project trust decision: {error}")),
123    }
124}
125
126fn classify_install_spec(cwd: &Path, spec: &str) -> InstallSpecKind {
127    if is_git_install_spec(spec) {
128        return InstallSpecKind::Git;
129    }
130    let local_candidate = if Path::new(spec).is_absolute() {
131        PathBuf::from(spec)
132    } else {
133        cwd.join(spec)
134    };
135    if spec.starts_with("npm:") || (!local_candidate.exists() && looks_like_npm(spec)) {
136        InstallSpecKind::Npm
137    } else {
138        InstallSpecKind::Local
139    }
140}
141
142fn npm_command_for_install(
143    cwd: &Path,
144    project_trusted: bool,
145    kind: InstallSpecKind,
146) -> Result<Option<NpmCommand>, String> {
147    if kind == InstallSpecKind::Local {
148        Ok(None)
149    } else {
150        NpmCommand::resolve(cwd, project_trusted).map(Some)
151    }
152}
153
154fn parse_args(args: &[String]) -> Result<Options, String> {
155    let mut spec = None;
156    let mut global = false;
157    let mut force = false;
158    let mut i = 0;
159    while i < args.len() {
160        match args[i].as_str() {
161            "--help" | "-h" => return Err("help".into()),
162            "--global" | "-g" => global = true,
163            "--force" | "-f" => force = true,
164            value if value.starts_with('-') => {
165                return Err(format!("unknown install-pi option `{value}`"))
166            }
167            value => {
168                if spec.replace(value.to_string()).is_some() {
169                    return Err("install-pi accepts exactly one package spec".into());
170                }
171            }
172        }
173        i += 1;
174    }
175    Ok(Options {
176        spec: spec.ok_or_else(|| "missing npm, git, or local package spec".to_string())?,
177        global,
178        force,
179    })
180}
181
182/// Create a managed store one directory at a time and reject every symlink,
183/// junction, or other canonical redirection in its ancestry. This check runs
184/// before any package manager, clone, copy, or deletion touches the store.
185fn prepare_managed_destination(destination: &Path) -> Result<(), String> {
186    if !destination.is_absolute() {
187        return Err(format!(
188            "refusing non-absolute package destination {}",
189            destination.display()
190        ));
191    }
192    ensure_real_directory_tree(destination)
193}
194
195fn ensure_real_directory_tree(path: &Path) -> Result<(), String> {
196    if path.parent().is_some_and(|parent| parent != path) {
197        if !path.exists() {
198            let parent = path
199                .parent()
200                .ok_or_else(|| format!("directory has no parent: {}", path.display()))?;
201            ensure_real_directory_tree(parent)?;
202            match std::fs::create_dir(path) {
203                Ok(()) => {}
204                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
205                Err(error) => {
206                    return Err(format!(
207                        "could not create managed package directory {}: {error}",
208                        path.display()
209                    ));
210                }
211            }
212        }
213    }
214    let metadata = std::fs::symlink_metadata(path).map_err(|error| {
215        format!(
216            "could not inspect managed package directory {}: {error}",
217            path.display()
218        )
219    })?;
220    if !metadata.is_dir() || metadata.file_type().is_symlink() {
221        return Err(format!(
222            "refusing redirected package destination {}",
223            path.display()
224        ));
225    }
226    let canonical = std::fs::canonicalize(path)
227        .map(normalize_config_path)
228        .map_err(|error| {
229            format!(
230                "could not resolve managed package directory {}: {error}",
231                path.display()
232            )
233        })?;
234    if !paths_equal(&canonical, path) {
235        return Err(format!(
236            "refusing redirected package destination {}",
237            path.display()
238        ));
239    }
240    Ok(())
241}
242
243fn validate_install_target(destination: &Path, target: &Path) -> Result<(), String> {
244    ensure_real_directory_tree(destination)?;
245    if target.parent() != Some(destination) {
246        return Err(format!(
247            "refusing package target outside managed destination: {}",
248            target.display()
249        ));
250    }
251    let Some(leaf) = target.file_name().and_then(|name| name.to_str()) else {
252        return Err("package target must have a valid file name".to_string());
253    };
254    if matches!(leaf, "" | "." | "..") || leaf.starts_with('.') {
255        return Err(format!("refusing invalid package target name `{leaf}`"));
256    }
257    match std::fs::symlink_metadata(target) {
258        Ok(metadata) => {
259            if !metadata.is_dir() || metadata.file_type().is_symlink() {
260                return Err(format!(
261                    "refusing redirected package target {}",
262                    target.display()
263                ));
264            }
265            let canonical = std::fs::canonicalize(target)
266                .map(normalize_config_path)
267                .map_err(|error| format!("could not resolve {}: {error}", target.display()))?;
268            if !paths_equal(&canonical, target) {
269                return Err(format!(
270                    "refusing redirected package target {}",
271                    target.display()
272                ));
273            }
274        }
275        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
276        Err(error) => {
277            return Err(format!(
278                "could not inspect package target {}: {error}",
279                target.display()
280            ));
281        }
282    }
283    Ok(())
284}
285
286fn install_spec(
287    cwd: &Path,
288    options: &Options,
289    kind: InstallSpecKind,
290    npm_command: Option<&NpmCommand>,
291) -> Result<InstallOutcome, String> {
292    let spec = options.spec.as_str();
293    if kind == InstallSpecKind::Git {
294        let npm_command = npm_command.ok_or("missing package manager for git installation")?;
295        let root = install_git(cwd, options.global, spec, options.force, npm_command)?;
296        return Ok(InstallOutcome {
297            root,
298            settings_source: Some(spec.trim().to_string()),
299        });
300    }
301    if kind == InstallSpecKind::Npm {
302        let npm_command = npm_command.ok_or("missing package manager for npm installation")?;
303        let npm_spec = npm_settings_spec(spec);
304        let root = install_managed_npm(cwd, options.global, &npm_spec, options.force, npm_command)?;
305        return Ok(InstallOutcome {
306            root,
307            settings_source: Some(npm_spec),
308        });
309    }
310    let root = install_local(cwd, spec)?;
311    Ok(InstallOutcome {
312        settings_source: Some(settings_entry(&root, cwd, options.global)?),
313        root,
314    })
315}
316
317fn is_git_install_spec(spec: &str) -> bool {
318    let spec = spec.trim().to_ascii_lowercase();
319    spec.starts_with("git:")
320        || spec.starts_with("https://")
321        || spec.starts_with("http://")
322        || spec.starts_with("ssh://")
323        || spec.starts_with("git://")
324}
325
326fn npm_settings_spec(spec: &str) -> String {
327    format!("npm:{}", spec.strip_prefix("npm:").unwrap_or(spec).trim())
328}
329
330fn load_settings_for_install(
331    cwd: &Path,
332    global: bool,
333) -> Result<crate::settings::Settings, crate::config::ConfigError> {
334    if global {
335        crate::settings::load_settings()
336    } else {
337        crate::settings::load_project_settings_for_write(cwd)
338    }
339}
340
341fn save_settings_for_install(
342    cwd: &Path,
343    global: bool,
344    settings: &crate::settings::Settings,
345) -> Result<(), String> {
346    if global {
347        crate::settings::save_settings(settings)
348    } else {
349        crate::settings::save_project_settings(cwd, settings)
350    }
351}
352
353fn settings_entry(root: &Path, cwd: &Path, global: bool) -> Result<String, String> {
354    let base = if global {
355        crate::config::agent_dir().map_err(|error| error.to_string())?
356    } else {
357        cwd.join(".rpi")
358    };
359    let relative = relative_path_from(root, &base).ok_or_else(|| {
360        format!(
361            "package root {} cannot be represented from its settings directory {}",
362            root.display(),
363            base.display()
364        )
365    })?;
366    Ok(format!(
367        "file:{}",
368        relative.to_string_lossy().replace('\\', "/")
369    ))
370}
371
372fn relative_path_from(path: &Path, base: &Path) -> Option<PathBuf> {
373    use std::path::Component;
374
375    let path = normalize_config_path(path.to_path_buf());
376    let base = normalize_config_path(base.to_path_buf());
377    if !path.is_absolute() || !base.is_absolute() {
378        return None;
379    }
380    let path_components = path.components().collect::<Vec<_>>();
381    let base_components = base.components().collect::<Vec<_>>();
382    let mut common = 0;
383    while common < path_components.len()
384        && common < base_components.len()
385        && path_components_equal(path_components[common], base_components[common])
386    {
387        common += 1;
388    }
389    if common == 0 {
390        return None;
391    }
392
393    let mut relative = PathBuf::new();
394    for component in &base_components[common..] {
395        match component {
396            Component::Normal(_) => relative.push(".."),
397            Component::CurDir => {}
398            Component::ParentDir | Component::Prefix(_) | Component::RootDir => return None,
399        }
400    }
401    for component in &path_components[common..] {
402        match component {
403            Component::Prefix(_) | Component::RootDir => return None,
404            _ => relative.push(component.as_os_str()),
405        }
406    }
407    if relative.as_os_str().is_empty() {
408        relative.push(".");
409    }
410    Some(relative)
411}
412
413fn path_components_equal(left: std::path::Component<'_>, right: std::path::Component<'_>) -> bool {
414    if cfg!(windows) {
415        left.as_os_str()
416            .to_string_lossy()
417            .eq_ignore_ascii_case(&right.as_os_str().to_string_lossy())
418    } else {
419        left == right
420    }
421}
422
423fn enable_settings_entry(
424    cwd: &Path,
425    packages: &mut Vec<crate::settings::PackageSetting>,
426    enabled: String,
427) -> bool {
428    if let Some(existing) = packages
429        .iter_mut()
430        .find(|value| package_settings_sources_match(cwd, value.source(), &enabled))
431    {
432        if existing.source() == enabled {
433            return false;
434        }
435        match existing {
436            crate::settings::PackageSetting::Source(source) => *source = enabled,
437            crate::settings::PackageSetting::Filtered(filter) => filter.source = enabled,
438        }
439        return true;
440    }
441    packages.push(enabled.into());
442    true
443}
444
445fn package_name(spec: &str) -> String {
446    if let Some(parsed) = crate::packages::parse_npm_package_spec(spec) {
447        return parsed
448            .install_name
449            .trim_start_matches('@')
450            .replace('/', "__")
451            .replace('\\', "__");
452    }
453    let raw = spec.strip_prefix("npm:").unwrap_or(spec);
454    let raw = if raw.starts_with('@') {
455        raw.rfind('@')
456            .filter(|index| *index > 0)
457            .map(|index| &raw[..index])
458            .unwrap_or(raw)
459    } else {
460        raw.split('@').next().unwrap_or(raw)
461    };
462    raw.trim_start_matches('@')
463        .replace('/', "__")
464        .replace('\\', "__")
465}
466
467fn safe_name(spec: &str) -> String {
468    package_name(spec)
469        .chars()
470        .map(|ch| {
471            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
472                ch
473            } else {
474                '-'
475            }
476        })
477        .collect()
478}
479
480fn install_managed_npm(
481    cwd: &Path,
482    global: bool,
483    spec: &str,
484    force: bool,
485    npm_command: &NpmCommand,
486) -> Result<PathBuf, String> {
487    let parsed = npm_package_spec_checked(spec)?;
488    let name = PathBuf::from(&parsed.install_name);
489    let root = managed_npm_root(cwd, global)?;
490    validate_recognized_npm_store_root(&root, cwd, true)?;
491    let target = root.join("node_modules").join(&name);
492    if let Ok(metadata) = std::fs::symlink_metadata(&target) {
493        if !force {
494            return Err(format!(
495                "{} already exists; use --force to reinstall it",
496                target.display()
497            ));
498        }
499        if !metadata.is_dir() && !metadata.file_type().is_symlink() {
500            return Err(format!(
501                "refusing to replace non-directory npm target {}",
502                target.display()
503            ));
504        }
505    }
506    let source = spec.strip_prefix("npm:").unwrap_or(spec).trim();
507    update_npm_store_root(
508        &root,
509        &[(
510            name.to_string_lossy().replace('\\', "/"),
511            format!("npm:{source}"),
512        )],
513        npm_command,
514        cwd,
515        true,
516    )?;
517    validate_managed_npm_package(&root, &target, &name, &parsed.manifest_name)?;
518    Ok(target)
519}
520
521fn managed_npm_root(cwd: &Path, global: bool) -> Result<PathBuf, String> {
522    if global {
523        crate::config::agent_dir()
524            .map(|agent| agent.join("npm"))
525            .map_err(|error| error.to_string())
526    } else {
527        Ok(cwd.join(".pi/npm"))
528    }
529}
530
531fn validate_managed_npm_package(
532    install_root: &Path,
533    target: &Path,
534    relative_name: &Path,
535    manifest_name: &str,
536) -> Result<(), String> {
537    let canonical_root = std::fs::canonicalize(install_root)
538        .map(normalize_config_path)
539        .map_err(|error| {
540            format!(
541                "could not resolve npm root {}: {error}",
542                install_root.display()
543            )
544        })?;
545    let expected = install_root.join("node_modules").join(relative_name);
546    if !paths_equal(&expected, target) {
547        return Err(format!(
548            "refusing unexpected npm package target {}",
549            target.display()
550        ));
551    }
552    let canonical_target = std::fs::canonicalize(target)
553        .map(normalize_config_path)
554        .map_err(|error| format!("npm did not install {}: {error}", target.display()))?;
555    let manifest = target.join("package.json");
556    let canonical_manifest = std::fs::canonicalize(&manifest)
557        .map(normalize_config_path)
558        .map_err(|error| format!("npm did not install {}: {error}", manifest.display()))?;
559    if !path_is_within(&canonical_target, &canonical_root)
560        || !path_is_within(&canonical_manifest, &canonical_target)
561        || !manifest.is_file()
562    {
563        return Err(format!(
564            "npm installed an unsafe package target {}",
565            target.display()
566        ));
567    }
568    validate_npm_manifest_name(&manifest, manifest_name)?;
569    Ok(())
570}
571
572fn validate_npm_manifest_name(manifest: &Path, expected: &str) -> Result<(), String> {
573    let text = std::fs::read_to_string(manifest).map_err(|error| {
574        format!(
575            "could not read npm manifest {}: {error}",
576            manifest.display()
577        )
578    })?;
579    let value: serde_json::Value = serde_json::from_str(&text).map_err(|error| {
580        format!(
581            "could not parse npm manifest {}: {error}",
582            manifest.display()
583        )
584    })?;
585    let actual = value
586        .get("name")
587        .and_then(serde_json::Value::as_str)
588        .ok_or_else(|| format!("npm manifest {} has no package name", manifest.display()))?;
589    if actual != expected {
590        return Err(format!(
591            "npm installed manifest name `{actual}` does not match expected package `{expected}`"
592        ));
593    }
594    Ok(())
595}
596
597fn install_npm_at(
598    target: &Path,
599    install_spec: &str,
600    source_spec: &str,
601    force: bool,
602    npm_command: &NpmCommand,
603) -> Result<PathBuf, String> {
604    validate_standalone_npm_manager(npm_command)?;
605    install_npm_at_with(
606        target,
607        install_spec,
608        source_spec,
609        force,
610        npm_command,
611        |stage, npm_spec| {
612            let install_args = npm_command.install_args(&[npm_spec.to_string()], stage);
613            let mut command = Command::new(npm_command.program());
614            command
615                .args(npm_command.combined_args(&install_args))
616                .current_dir(stage)
617                .stdin(Stdio::inherit())
618                .stdout(Stdio::inherit())
619                .stderr(Stdio::inherit());
620            let status = command.status().map_err(|error| {
621                format!(
622                    "could not execute {} (install the configured package manager first): {error}",
623                    npm_command.program()
624                )
625            })?;
626            if !status.success() {
627                return Err(format!(
628                    "{} install exited with {status}",
629                    npm_command.program()
630                ));
631            }
632            Ok(())
633        },
634    )
635}
636
637fn install_npm_at_for_startup(
638    target: &Path,
639    install_spec: &str,
640    source_spec: &str,
641    npm_command: &NpmCommand,
642) -> Result<PathBuf, String> {
643    validate_standalone_npm_manager(npm_command)?;
644    install_npm_at_with_mode(
645        target,
646        install_spec,
647        source_spec,
648        true,
649        npm_command,
650        NpmExecutionMode::StartupRemediation,
651        |stage, npm_spec| {
652            let install_args = npm_command.install_args(&[npm_spec.to_string()], stage);
653            npm_command
654                .run_startup_remediation(&install_args, stage)
655                .map_err(|error| {
656                    format!(
657                        "could not restore npm package with {} during startup: {error}",
658                        npm_command.program()
659                    )
660                })
661        },
662    )
663}
664
665fn validate_standalone_npm_manager(npm_command: &NpmCommand) -> Result<(), String> {
666    if matches!(
667        npm_command.manager_kind(),
668        NpmManagerKind::Pnpm | NpmManagerKind::Bun
669    ) {
670        return Err(format!(
671            "{} cannot safely install an npm package into the standalone rpi package store; use a native Pi npm store entry instead",
672            match npm_command.manager_kind() {
673                NpmManagerKind::Pnpm => "pnpm",
674                NpmManagerKind::Bun => "bun",
675                _ => unreachable!(),
676            }
677        ));
678    }
679    Ok(())
680}
681
682fn install_npm_at_with(
683    target: &Path,
684    install_spec: &str,
685    source_spec: &str,
686    force: bool,
687    npm_command: &NpmCommand,
688    install: impl FnOnce(&Path, &str) -> Result<(), String>,
689) -> Result<PathBuf, String> {
690    install_npm_at_with_mode(
691        target,
692        install_spec,
693        source_spec,
694        force,
695        npm_command,
696        NpmExecutionMode::Interactive,
697        install,
698    )
699}
700
701fn install_npm_at_with_mode(
702    target: &Path,
703    install_spec: &str,
704    source_spec: &str,
705    force: bool,
706    npm_command: &NpmCommand,
707    npm_mode: NpmExecutionMode,
708    install: impl FnOnce(&Path, &str) -> Result<(), String>,
709) -> Result<PathBuf, String> {
710    let stage = tempfile::tempdir()
711        .map_err(|error| format!("could not create npm staging dir: {error}"))?;
712    let npm_spec = install_spec.strip_prefix("npm:").unwrap_or(install_spec);
713    install(stage.path(), npm_spec)?;
714    let parsed = npm_package_spec_checked(install_spec)?;
715    let package_name = PathBuf::from(&parsed.install_name);
716    let node_modules = stage.path().join("node_modules");
717    let installed = node_modules.join(&package_name);
718    let installed_metadata = std::fs::symlink_metadata(&installed).ok();
719    let installed_canonical = std::fs::canonicalize(&installed)
720        .ok()
721        .map(normalize_config_path);
722    let node_modules_canonical = std::fs::canonicalize(&node_modules)
723        .ok()
724        .map(normalize_config_path);
725    if !installed_metadata
726        .is_some_and(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
727        || !installed_canonical.as_ref().is_some_and(|path| {
728            node_modules_canonical
729                .as_ref()
730                .is_some_and(|root| path.starts_with(root))
731        })
732    {
733        return Err(format!(
734            "npm installed `{install_spec}` but no safe package directory was found"
735        ));
736    }
737    validate_npm_manifest_name(&installed.join("package.json"), &parsed.manifest_name)?;
738    replace_dir_prepared(&installed, target, force, |prepared| {
739        install_production_dependencies_with_mode(prepared, npm_command, npm_mode)?;
740        validate_npm_manifest_name(&prepared.join("package.json"), &parsed.manifest_name)?;
741        crate::packages::write_npm_source_marker(prepared, source_spec)
742    })?;
743    Ok(target.to_path_buf())
744}
745
746/// Refresh an installed npm package in-place. The package root is expected to
747/// be the safe-name directory created by `install-pi`; replacing that directory
748/// preserves the settings entry while updating its contents.
749pub(crate) fn update_npm_package(
750    root: &Path,
751    name: &str,
752    source_spec: &str,
753    npm_command: &NpmCommand,
754) -> Result<PathBuf, String> {
755    if !is_known_package_target(root) {
756        return Err(format!(
757            "refusing npm update outside a managed package store: {}",
758            root.display()
759        ));
760    }
761    let install_spec = npm_update_install_spec(name, source_spec);
762    install_npm_at(root, &install_spec, source_spec, true, npm_command)
763}
764
765/// Startup-only npm recovery. Unlike the interactive update command, every
766/// package-manager subprocess has a finite install budget and bounded output.
767pub(crate) fn update_npm_package_for_startup(
768    root: &Path,
769    name: &str,
770    source_spec: &str,
771    npm_command: &NpmCommand,
772) -> Result<PathBuf, String> {
773    if !is_known_package_target(root) {
774        return Err(format!(
775            "refusing npm update outside a managed package store: {}",
776            root.display()
777        ));
778    }
779    let install_spec = npm_update_install_spec(name, source_spec);
780    install_npm_at_for_startup(root, &install_spec, source_spec, npm_command)
781}
782
783/// Batch-update packages installed in one native Pi npm root. The caller must
784/// pass a root already recognized by package discovery; this function adds a
785/// root-level advisory lock, refuses direct symlinks, preserves an existing
786/// package manifest until the package manager runs, and never swaps an
787/// individual package leaf. The package manager owns its normal in-place
788/// `package.json`, lockfile, and `node_modules` changes, matching native Pi.
789pub(crate) fn update_npm_store_root(
790    root: &Path,
791    packages: &[(String, String)],
792    npm_command: &NpmCommand,
793    cwd: &Path,
794    project_trusted: bool,
795) -> Result<(), String> {
796    validate_recognized_npm_store_root(root, cwd, project_trusted)?;
797    update_npm_store_root_with(root, packages, npm_command, |root, command, args| {
798        let status = Command::new(command.program())
799            .args(command.combined_args(args))
800            .current_dir(root)
801            .stdin(Stdio::inherit())
802            .stdout(Stdio::inherit())
803            .stderr(Stdio::inherit())
804            .status()
805            .map_err(|error| {
806                format!(
807                    "could not execute {} for npm store update: {error}",
808                    command.program()
809                )
810            })?;
811        if status.success() {
812            Ok(())
813        } else {
814            Err(format!(
815                "{} npm store update exited with {status}",
816                command.program()
817            ))
818        }
819    })?;
820    validate_updated_npm_store_packages(root, packages)?;
821    Ok(())
822}
823
824/// Startup-only native npm-store recovery with a finite subprocess budget.
825pub(crate) fn update_npm_store_root_for_startup(
826    root: &Path,
827    packages: &[(String, String)],
828    npm_command: &NpmCommand,
829    cwd: &Path,
830    project_trusted: bool,
831) -> Result<(), String> {
832    validate_recognized_npm_store_root(root, cwd, project_trusted)?;
833    update_npm_store_root_with(root, packages, npm_command, |root, command, args| {
834        command
835            .run_startup_remediation(args, root)
836            .map_err(|error| {
837                format!(
838                    "could not restore npm store with {} during startup: {error}",
839                    command.program()
840                )
841            })
842    })?;
843    validate_updated_npm_store_packages(root, packages)?;
844    Ok(())
845}
846
847fn validate_updated_npm_store_packages(
848    root: &Path,
849    packages: &[(String, String)],
850) -> Result<(), String> {
851    for (name, source_spec) in packages {
852        let parsed = npm_package_spec_checked(source_spec)?;
853        if parsed.install_name != *name {
854            return Err(format!(
855                "npm update source `{source_spec}` does not match package `{name}`"
856            ));
857        }
858        let relative_name = PathBuf::from(name);
859        let target = root.join("node_modules").join(&relative_name);
860        validate_managed_npm_package(root, &target, &relative_name, &parsed.manifest_name)?;
861    }
862    Ok(())
863}
864
865/// Update a native Pi git checkout through a same-filesystem staging directory.
866/// The caller supplies the exact managed store root and source discovered from
867/// the trusted settings entry; the live checkout is not replaced until clone,
868/// dependency installation, and post-build validation have all succeeded.
869pub(crate) fn update_git_package(
870    root: &Path,
871    store_root: &Path,
872    source_spec: &str,
873    npm_command: &NpmCommand,
874) -> Result<(), String> {
875    update_git_package_with(
876        root,
877        store_root,
878        source_spec,
879        npm_command,
880        |root, origin, revision, npm_command| {
881            update_git_checkout_from_origin(root, store_root, origin, revision, npm_command)
882        },
883    )
884}
885
886fn update_git_package_with(
887    root: &Path,
888    store_root: &Path,
889    source_spec: &str,
890    npm_command: &NpmCommand,
891    update: impl FnOnce(&Path, &str, Option<&str>, &NpmCommand) -> Result<(), String>,
892) -> Result<(), String> {
893    if !root.is_absolute() || !store_root.is_absolute() {
894        return Err("refusing git update for a non-absolute managed path".to_string());
895    }
896    let source = crate::packages::parse_git_source(source_spec)
897        .ok_or_else(|| "refusing git update without a valid configured source".to_string())?;
898    validate_managed_git_root(root, store_root)?;
899
900    let _lock = PackageSwapLock::acquire(root)?;
901    // Re-resolve both paths after taking the advisory lock. A package entry
902    // can be replaced by a symlink/junction between discovery and update; in
903    // that case fail closed before invoking git.
904    validate_managed_git_root(root, store_root)?;
905    let origin = validated_git_origin(root, &source)?;
906    update(root, &origin, source.revision.as_deref(), npm_command)
907}
908
909fn update_git_checkout_from_origin(
910    root: &Path,
911    store_root: &Path,
912    origin: &str,
913    revision: Option<&str>,
914    npm_command: &NpmCommand,
915) -> Result<(), String> {
916    stage_git_checkout_update_with(
917        root,
918        store_root,
919        origin,
920        revision,
921        npm_command,
922        |prepared, origin, selected_revision| {
923            prepare_git_checkout_for_update(prepared, origin, revision, selected_revision)
924        },
925        |prepared, npm_command| {
926            install_git_dependencies(prepared, npm_command, NpmExecutionMode::Interactive)
927        },
928    )
929}
930
931fn prepare_git_checkout_for_update(
932    prepared: &Path,
933    origin: &str,
934    explicit_revision: Option<&str>,
935    selected_revision: Option<&str>,
936) -> Result<(), String> {
937    prepare_git_checkout_for_update_with(
938        prepared,
939        origin,
940        explicit_revision,
941        selected_revision,
942        clone_git_checkout,
943        fetch_git_revision,
944        checkout_git_revision,
945        validate_git_head,
946    )
947}
948
949fn prepare_git_checkout_for_update_with(
950    prepared: &Path,
951    origin: &str,
952    explicit_revision: Option<&str>,
953    selected_revision: Option<&str>,
954    clone: impl FnOnce(&str, Option<&str>, &Path) -> Result<(), String>,
955    fetch: impl FnOnce(&Path, &str, &str) -> Result<(), String>,
956    checkout: impl FnOnce(&Path, &str) -> Result<(), String>,
957    validate: impl FnOnce(&Path, Option<&str>) -> Result<(), String>,
958) -> Result<(), String> {
959    let Some(revision) = explicit_revision else {
960        return clone(origin, selected_revision, prepared);
961    };
962
963    // A full clone does not necessarily contain a commit that is only
964    // reachable through an unadvertised ref. Native Pi explicitly fetches a
965    // configured revision, so preserve that behavior in the staging checkout.
966    clone(origin, None, prepared)?;
967    fetch(prepared, origin, revision)?;
968    checkout(prepared, "FETCH_HEAD")?;
969    validate(prepared, Some("FETCH_HEAD"))
970}
971
972fn stage_git_checkout_update_with(
973    root: &Path,
974    store_root: &Path,
975    origin: &str,
976    revision: Option<&str>,
977    npm_command: &NpmCommand,
978    fetch: impl FnOnce(&Path, &str, Option<&str>) -> Result<(), String>,
979    build: impl FnOnce(&Path, &NpmCommand) -> Result<(), String>,
980) -> Result<(), String> {
981    let selected_revision = revision
982        .map(str::to_string)
983        .or_else(|| git_upstream_branch(root))
984        .or_else(|| git_origin_head_branch(root));
985    let expected_origin = crate::packages::parse_git_source(origin)
986        .filter(|source| source.revision.is_none())
987        .ok_or_else(|| "refusing to stage an invalid git origin".to_string())?;
988    let original_head = validated_git_head(root)?;
989    let update_marker = git_update_marker_path(root)?;
990    let had_update_marker = validate_git_update_marker(&update_marker)?;
991    let parent = root
992        .parent()
993        .ok_or_else(|| format!("git package has no parent: {}", root.display()))?;
994    let stage = tempfile::Builder::new()
995        .prefix(".rpi-git-update-")
996        .tempdir_in(parent)
997        .map_err(|error| format!("could not create git update staging directory: {error}"))?;
998    let prepared = stage.path().join("package");
999
1000    fetch(&prepared, origin, selected_revision.as_deref())?;
1001    validate_cloned_git_origin(&prepared, origin, &expected_origin)?;
1002    let prepared_head = validated_git_head(&prepared)?;
1003
1004    build(&prepared, npm_command)?;
1005    crate::packages::remove_package_source_marker(&prepared)?;
1006    validate_cloned_git_origin(&prepared, origin, &expected_origin)?;
1007    if validated_git_head(&prepared)? != prepared_head {
1008        return Err("refusing git update because the staged HEAD changed during build".to_string());
1009    }
1010    if !package_target_is_valid(&prepared) {
1011        return Err("staged git checkout does not contain a valid Pi package".to_string());
1012    }
1013
1014    // The network/build phase can take minutes. Repeat every live-target check
1015    // immediately before activation so an out-of-band replacement cannot be
1016    // overwritten merely because it passed the initial preflight.
1017    validate_managed_git_root(root, store_root)?;
1018    if validated_git_head(root)? != original_head {
1019        return Err(
1020            "refusing git update because the installed checkout changed during staging".to_string(),
1021        );
1022    }
1023    let current_origin = validated_git_origin(root, &expected_origin)?;
1024    if current_origin != origin {
1025        return Err("refusing git update because origin changed during staging".to_string());
1026    }
1027
1028    swap_prepared_dir(&prepared, root, true)?;
1029    if had_update_marker {
1030        if let Err(error) = std::fs::remove_file(&update_marker) {
1031            eprintln!(
1032                "warning: updated git package but could not remove stale marker {}: {error}",
1033                update_marker.display()
1034            );
1035        }
1036    }
1037    Ok(())
1038}
1039
1040fn validate_managed_git_root(root: &Path, store_root: &Path) -> Result<(), String> {
1041    let canonical_root = normalize_update_path(std::fs::canonicalize(root).map_err(|error| {
1042        format!(
1043            "could not resolve git package root {}: {error}",
1044            root.display()
1045        )
1046    })?);
1047    let canonical_store =
1048        normalize_update_path(std::fs::canonicalize(store_root).map_err(|error| {
1049            format!(
1050                "could not resolve git store root {}: {error}",
1051                store_root.display()
1052            )
1053        })?);
1054    if !paths_equal(&canonical_root, root)
1055        || !paths_equal(&canonical_store, store_root)
1056        || !path_is_within(&canonical_root, &canonical_store)
1057        || !is_real_directory(&canonical_root)
1058        || !is_real_git_metadata(&canonical_root.join(".git"))
1059    {
1060        return Err(format!(
1061            "refusing git update outside the managed store: {}",
1062            root.display()
1063        ));
1064    }
1065    let store_components = canonical_store.components().count();
1066    let mut relative = canonical_root.components().skip(store_components);
1067    if canonical_root.components().count() <= store_components
1068        || relative.any(|component| !matches!(component, std::path::Component::Normal(_)))
1069    {
1070        return Err(format!(
1071            "refusing invalid managed git package path: {}",
1072            root.display()
1073        ));
1074    }
1075    Ok(())
1076}
1077
1078const MAX_LOCAL_GIT_CONFIG_BYTES: usize = 64 * 1024;
1079
1080/// Return the one explicit fetch URL recorded in this checkout only when its
1081/// normalized repository identity matches the trusted settings source. Local
1082/// includes and command-capable configuration are rejected because `git
1083/// fetch` would otherwise evaluate them after this check.
1084fn validated_git_origin(
1085    root: &Path,
1086    expected: &crate::packages::GitSpec,
1087) -> Result<String, String> {
1088    let keys = local_git_config_output(root, &["--name-only", "--list"])?;
1089    if !keys.status.success() || keys.stdout.len() > MAX_LOCAL_GIT_CONFIG_BYTES {
1090        return Err("refusing git update with unreadable local configuration".to_string());
1091    }
1092    for key in parse_nul_config_values(&keys.stdout)? {
1093        if dangerous_git_fetch_config_key(key) {
1094            return Err("refusing git update with unsafe local configuration".to_string());
1095        }
1096    }
1097
1098    let origins = local_git_config_output(root, &["--get-all", "remote.origin.url"])?;
1099    if !origins.status.success() || origins.stdout.len() > MAX_LOCAL_GIT_CONFIG_BYTES {
1100        return Err("refusing git update without exactly one configured origin".to_string());
1101    }
1102    let origins = parse_nul_config_values(&origins.stdout)?;
1103    let [origin] = origins.as_slice() else {
1104        return Err("refusing git update without exactly one configured origin".to_string());
1105    };
1106    if !is_explicit_git_remote_url(origin) {
1107        return Err("refusing git update with a non-network or ambiguous origin".to_string());
1108    }
1109    let actual = crate::packages::parse_git_source(origin)
1110        .filter(|source| source.revision.is_none())
1111        .ok_or_else(|| "refusing git update with an invalid or dangerous origin".to_string())?;
1112    let actual_port = actual
1113        .port
1114        .unwrap_or_else(|| actual.transport.default_port());
1115    let expected_port = expected
1116        .port
1117        .unwrap_or_else(|| expected.transport.default_port());
1118    if actual.host != expected.host
1119        || actual.path != expected.path
1120        || actual.transport != expected.transport
1121        || actual_port != expected_port
1122        || actual.user_info != expected.user_info
1123    {
1124        return Err(
1125            "refusing git update because origin does not match package settings".to_string(),
1126        );
1127    }
1128    Ok((*origin).to_string())
1129}
1130
1131fn local_git_config_output(root: &Path, args: &[&str]) -> Result<std::process::Output, String> {
1132    let config = root.join(".git/config");
1133    let metadata = std::fs::symlink_metadata(&config).map_err(|error| {
1134        format!(
1135            "could not inspect git package configuration {}: {error}",
1136            config.display()
1137        )
1138    })?;
1139    let canonical_config =
1140        normalize_update_path(std::fs::canonicalize(&config).map_err(|error| {
1141            format!(
1142                "could not resolve git package configuration {}: {error}",
1143                config.display()
1144            )
1145        })?);
1146    let canonical_git = normalize_update_path(
1147        std::fs::canonicalize(root.join(".git"))
1148            .map_err(|error| format!("could not resolve git package metadata: {error}"))?,
1149    );
1150    if !metadata.is_file()
1151        || metadata.file_type().is_symlink()
1152        || metadata.len() > MAX_LOCAL_GIT_CONFIG_BYTES as u64
1153        || !paths_equal(&canonical_config, &config)
1154        || !path_is_within(&canonical_config, &canonical_git)
1155    {
1156        return Err("refusing git update with redirected package configuration".to_string());
1157    }
1158
1159    let mut command = Command::new("git");
1160    apply_hardened_git_environment(&mut command);
1161    command
1162        .args(["config", "--no-includes", "--null", "--file"])
1163        .arg(&config)
1164        .args(args)
1165        .current_dir(root)
1166        .stdin(Stdio::null())
1167        .stdout(Stdio::piped())
1168        .stderr(Stdio::null())
1169        .output()
1170        .map_err(|error| format!("could not inspect git package configuration: {error}"))
1171}
1172
1173fn parse_nul_config_values(output: &[u8]) -> Result<Vec<&str>, String> {
1174    if output.is_empty() {
1175        return Ok(Vec::new());
1176    }
1177    let Some(values) = output.strip_suffix(&[0]) else {
1178        return Err("refusing malformed git configuration output".to_string());
1179    };
1180    values
1181        .split(|byte| *byte == 0)
1182        .map(|value| {
1183            let value = std::str::from_utf8(value)
1184                .map_err(|_| "refusing non-UTF-8 git configuration".to_string())?;
1185            if value.is_empty() {
1186                Err("refusing empty git configuration value".to_string())
1187            } else {
1188                Ok(value)
1189            }
1190        })
1191        .collect()
1192}
1193
1194fn dangerous_git_fetch_config_key(key: &str) -> bool {
1195    let key = key.to_ascii_lowercase();
1196    matches!(
1197        key.as_str(),
1198        "core.askpass"
1199            | "core.attributesfile"
1200            | "core.fsmonitor"
1201            | "core.gitproxy"
1202            | "core.hookspath"
1203            | "core.sshcommand"
1204            | "core.worktree"
1205            | "extensions.worktreeconfig"
1206            | "fetch.bundleuri"
1207            | "http.curloptresolve"
1208            | "http.extraheader"
1209            | "http.proxy"
1210            | "http.sslcainfo"
1211            | "http.sslcapath"
1212            | "http.sslverify"
1213            | "include.path"
1214    ) || (key.starts_with("includeif.") && key.ends_with(".path"))
1215        || (key.starts_with("http.")
1216            && (key.ends_with(".curloptresolve")
1217                || key.ends_with(".extraheader")
1218                || key.ends_with(".proxy")
1219                || key.ends_with(".sslcainfo")
1220                || key.ends_with(".sslcapath")
1221                || key.ends_with(".sslverify")))
1222        || (key.starts_with("url.")
1223            && (key.ends_with(".insteadof") || key.ends_with(".pushinsteadof")))
1224        || (key.starts_with("credential.") && key.ends_with(".helper"))
1225        || (key.starts_with("remote.")
1226            && (key.ends_with(".proxy") || key.ends_with(".uploadpack") || key.ends_with(".vcs")))
1227        || (key.starts_with("filter.")
1228            && (key.ends_with(".clean") || key.ends_with(".process") || key.ends_with(".smudge")))
1229}
1230
1231fn is_explicit_git_remote_url(origin: &str) -> bool {
1232    if origin.is_empty()
1233        || origin != origin.trim()
1234        || origin
1235            .chars()
1236            .any(|character| character.is_control() || character.is_whitespace())
1237    {
1238        return false;
1239    }
1240    if origin.starts_with("git@") {
1241        return origin.contains(':');
1242    }
1243    let Some((scheme, _)) = origin.split_once("://") else {
1244        return false;
1245    };
1246    matches!(
1247        scheme.to_ascii_lowercase().as_str(),
1248        "http" | "https" | "ssh" | "git"
1249    )
1250}
1251
1252fn git_upstream_branch(root: &Path) -> Option<String> {
1253    let upstream = run_git_capture(
1254        root,
1255        ["rev-parse", "--abbrev-ref", "@{upstream}"].as_slice(),
1256    )
1257    .ok()?;
1258    let branch = upstream.trim().strip_prefix("origin/")?;
1259    is_safe_git_branch(branch).then(|| branch.to_string())
1260}
1261
1262fn git_origin_head_branch(root: &Path) -> Option<String> {
1263    let symbolic = run_git_capture(
1264        root,
1265        ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"].as_slice(),
1266    )
1267    .ok()?;
1268    let branch = symbolic.trim().strip_prefix("refs/remotes/origin/")?;
1269    is_safe_git_branch(branch).then(|| branch.to_string())
1270}
1271
1272fn git_update_marker_path(root: &Path) -> Result<PathBuf, String> {
1273    let parent = root
1274        .parent()
1275        .ok_or_else(|| format!("git package has no parent: {}", root.display()))?;
1276    let name = root
1277        .file_name()
1278        .and_then(|name| name.to_str())
1279        .ok_or_else(|| format!("git package has no valid name: {}", root.display()))?;
1280    Ok(parent.join(format!(".{name}.pi-update-incomplete")))
1281}
1282
1283fn validate_git_update_marker(marker: &Path) -> Result<bool, String> {
1284    match std::fs::symlink_metadata(marker) {
1285        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err(format!(
1286            "git update marker must be a regular file: {}",
1287            marker.display()
1288        )),
1289        Ok(_) => Ok(true),
1290        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1291        Err(error) => Err(format!(
1292            "could not inspect git update marker {}: {error}",
1293            marker.display()
1294        )),
1295    }
1296}
1297
1298fn is_safe_git_branch(branch: &str) -> bool {
1299    !branch.is_empty()
1300        && !branch.starts_with('-')
1301        && !branch.starts_with('/')
1302        && !branch.ends_with('/')
1303        && !branch.contains('\\')
1304        && !branch.contains('\0')
1305        && !branch.contains("..")
1306        && !branch.contains("@{")
1307        && !branch.chars().any(|ch| {
1308            ch.is_control() || ch.is_whitespace() || matches!(ch, '~' | '^' | ':' | '?' | '*' | '[')
1309        })
1310        && !branch
1311            .split('/')
1312            .any(|part| part.is_empty() || part == "." || part == "..")
1313}
1314
1315fn run_git_capture(root: &Path, args: &[&str]) -> Result<String, String> {
1316    let mut command = hardened_git_command();
1317    let output = command
1318        .args(args)
1319        .current_dir(root)
1320        .stdin(Stdio::null())
1321        .output()
1322        .map_err(|error| format!("could not execute hardened git: {error}"))?;
1323    if !output.status.success() {
1324        let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
1325        return if detail.is_empty() {
1326            Err(format!(
1327                "git {} exited with {}",
1328                args.join(" "),
1329                output.status
1330            ))
1331        } else {
1332            Err(format!(
1333                "git {} exited with {}: {detail}",
1334                args.join(" "),
1335                output.status
1336            ))
1337        };
1338    }
1339    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
1340}
1341
1342pub(crate) const HARDENED_GIT_ENV_REMOVE: &[&str] = &[
1343    "GIT_ALLOW_PROTOCOL",
1344    "GIT_ALTERNATE_OBJECT_DIRECTORIES",
1345    "GIT_ASKPASS",
1346    "GIT_ATTR_NOSYSTEM",
1347    "GIT_CEILING_DIRECTORIES",
1348    "GIT_COMMON_DIR",
1349    "GIT_CONFIG",
1350    "GIT_CONFIG_COUNT",
1351    "GIT_CONFIG_GLOBAL",
1352    "GIT_CONFIG_NOSYSTEM",
1353    "GIT_CONFIG_PARAMETERS",
1354    "GIT_CONFIG_SYSTEM",
1355    "GIT_DIR",
1356    "GIT_DISCOVERY_ACROSS_FILESYSTEM",
1357    "GIT_EXEC_PATH",
1358    "GIT_INDEX_FILE",
1359    "GIT_NAMESPACE",
1360    "GIT_OBJECT_DIRECTORY",
1361    "GIT_PROXY_COMMAND",
1362    "GIT_QUARANTINE_PATH",
1363    "GIT_SSH",
1364    "GIT_SSH_COMMAND",
1365    "GIT_SSH_VARIANT",
1366    "GIT_SSL_CAINFO",
1367    "GIT_SSL_CAPATH",
1368    "GIT_SSL_NO_VERIFY",
1369    "GIT_TEMPLATE_DIR",
1370    "GIT_WORK_TREE",
1371    "SSH_ASKPASS",
1372];
1373
1374pub(crate) fn hardened_git_null_config() -> &'static str {
1375    if cfg!(windows) {
1376        "NUL"
1377    } else {
1378        "/dev/null"
1379    }
1380}
1381
1382/// Build command-scope Git configuration without consulting command-capable
1383/// system, global, or environment configuration. A short allowlist preserves
1384/// the standard credential helpers shipped by common Git installations; SSH
1385/// agent and `~/.ssh/config` authentication continue to work independently.
1386pub(crate) fn hardened_git_network_config_args() -> Vec<String> {
1387    let mut args = Vec::new();
1388    for value in [
1389        "protocol.allow=never",
1390        "protocol.http.allow=always",
1391        "protocol.https.allow=always",
1392        "protocol.ssh.allow=always",
1393        "protocol.git.allow=always",
1394        "protocol.ext.allow=never",
1395        "protocol.file.allow=never",
1396        "credential.helper=",
1397        "credential.interactive=false",
1398        "gc.auto=0",
1399        "maintenance.auto=false",
1400        if cfg!(windows) {
1401            "core.hooksPath=NUL"
1402        } else {
1403            "core.hooksPath=/dev/null"
1404        },
1405    ] {
1406        args.push("-c".to_string());
1407        args.push(value.to_string());
1408    }
1409    for helper in safe_configured_credential_helpers() {
1410        args.push("-c".to_string());
1411        args.push(format!("credential.helper={helper}"));
1412    }
1413    args
1414}
1415
1416fn hardened_git_command() -> Command {
1417    let mut command = Command::new("git");
1418    command.args(hardened_git_network_config_args());
1419    apply_hardened_git_environment(&mut command);
1420    command
1421}
1422
1423fn apply_hardened_git_environment(command: &mut Command) {
1424    for name in HARDENED_GIT_ENV_REMOVE {
1425        command.env_remove(name);
1426    }
1427    command
1428        .env("GIT_CONFIG_NOSYSTEM", "1")
1429        .env("GIT_CONFIG_SYSTEM", hardened_git_null_config())
1430        .env("GIT_CONFIG_GLOBAL", hardened_git_null_config())
1431        .env("GIT_ATTR_NOSYSTEM", "1")
1432        .env("GIT_PROTOCOL_FROM_USER", "0")
1433        .env("GIT_TERMINAL_PROMPT", "0")
1434        .env("GCM_INTERACTIVE", "Never")
1435        .env("SSH_ASKPASS_REQUIRE", "never");
1436}
1437
1438fn safe_configured_credential_helpers() -> &'static [String] {
1439    static HELPERS: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
1440    HELPERS.get_or_init(|| {
1441        let mut helpers = Vec::new();
1442        for scope in ["--system", "--global"] {
1443            let mut command = Command::new("git");
1444            command.args([
1445                "config",
1446                scope,
1447                "--no-includes",
1448                "--null",
1449                "--get-all",
1450                "credential.helper",
1451            ]);
1452            for name in HARDENED_GIT_ENV_REMOVE {
1453                command.env_remove(name);
1454            }
1455            let Ok(output) = command
1456                .stdin(Stdio::null())
1457                .stdout(Stdio::piped())
1458                .stderr(Stdio::null())
1459                .output()
1460            else {
1461                continue;
1462            };
1463            if !output.status.success() || output.stdout.len() > MAX_LOCAL_GIT_CONFIG_BYTES {
1464                continue;
1465            }
1466            for value in output.stdout.split(|byte| *byte == 0) {
1467                let Ok(value) = std::str::from_utf8(value) else {
1468                    continue;
1469                };
1470                if is_safe_standard_credential_helper(value)
1471                    && !helpers.iter().any(|helper| helper == value)
1472                {
1473                    helpers.push(value.to_string());
1474                }
1475            }
1476        }
1477        helpers
1478    })
1479}
1480
1481fn is_safe_standard_credential_helper(value: &str) -> bool {
1482    matches!(
1483        value,
1484        "cache" | "libsecret" | "manager" | "manager-core" | "osxkeychain" | "store" | "wincred"
1485    )
1486}
1487
1488fn is_real_directory(path: &Path) -> bool {
1489    std::fs::symlink_metadata(path)
1490        .map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
1491        .unwrap_or(false)
1492}
1493
1494fn is_real_git_metadata(path: &Path) -> bool {
1495    std::fs::symlink_metadata(path)
1496        // A worktree's `.git` file points at another repository. Automatic
1497        // package updates only operate on self-contained checkouts, so reject
1498        // files (including `gitdir:` pointers) and symlinks outright.
1499        .map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
1500        .unwrap_or(false)
1501}
1502
1503fn normalize_update_path(path: PathBuf) -> PathBuf {
1504    normalize_config_path(path)
1505}
1506
1507fn update_npm_store_root_with(
1508    root: &Path,
1509    packages: &[(String, String)],
1510    npm_command: &NpmCommand,
1511    run: impl FnOnce(&Path, &NpmCommand, &[String]) -> Result<(), String>,
1512) -> Result<(), String> {
1513    if packages.is_empty() {
1514        return Ok(());
1515    }
1516    if !root.is_absolute() || root.file_name() != Some(std::ffi::OsStr::new("npm")) {
1517        return Err(format!(
1518            "refusing npm store update outside a recognized absolute npm root: {}",
1519            root.display()
1520        ));
1521    }
1522    let specs = packages
1523        .iter()
1524        .map(|(name, source_spec)| npm_root_update_spec(name, source_spec))
1525        .collect::<Result<Vec<_>, _>>()?;
1526    let parent = root
1527        .parent()
1528        .ok_or_else(|| format!("npm store root has no parent: {}", root.display()))?;
1529    ensure_real_directory_tree(parent)?;
1530    let _lock = PackageSwapLock::acquire(root)?;
1531    ensure_npm_store_root(root)?;
1532    for (name, _) in packages {
1533        let relative_name = npm_module_name_checked(name)?;
1534        validate_npm_target_before_mutation(root, &relative_name, true)?;
1535    }
1536    let args = npm_command.install_args(&specs, root);
1537    run(root, npm_command, &args)
1538}
1539
1540fn npm_root_update_spec(name: &str, source_spec: &str) -> Result<String, String> {
1541    let name = name.trim();
1542    let configured = source_spec
1543        .strip_prefix("npm:")
1544        .unwrap_or(source_spec)
1545        .trim();
1546    if name.is_empty() || configured.is_empty() {
1547        return Err("npm update source and package name must not be empty".to_string());
1548    }
1549    if npm_module_name(configured) != name {
1550        return Err(format!(
1551            "npm update source `{source_spec}` does not match package `{name}`"
1552        ));
1553    }
1554    Ok(if configured == name {
1555        format!("{name}@latest")
1556    } else {
1557        configured.to_string()
1558    })
1559}
1560
1561fn ensure_npm_store_root(root: &Path) -> Result<(), String> {
1562    ensure_real_directory_tree(root)?;
1563
1564    let manifest = root.join("package.json");
1565    match std::fs::symlink_metadata(&manifest) {
1566        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err(format!(
1567            "npm store package.json must be a regular file: {}",
1568            manifest.display()
1569        )),
1570        Ok(_) => Ok(()),
1571        Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::fs::write(
1572            &manifest,
1573            "{\n  \"name\": \"pi-extensions\",\n  \"private\": true\n}\n",
1574        )
1575        .map_err(|error| format!("could not create npm store package.json: {error}")),
1576        Err(error) => return Err(format!("could not inspect npm store package.json: {error}")),
1577    }?;
1578
1579    let ignore = root.join(".gitignore");
1580    match std::fs::symlink_metadata(&ignore) {
1581        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err(format!(
1582            "npm store .gitignore must be a regular file: {}",
1583            ignore.display()
1584        )),
1585        Ok(_) => Ok(()),
1586        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1587            std::fs::write(&ignore, "*\n!.gitignore\n")
1588                .map_err(|error| format!("could not create npm store .gitignore: {error}"))
1589        }
1590        Err(error) => Err(format!("could not inspect npm store .gitignore: {error}")),
1591    }
1592}
1593
1594fn validate_npm_target_before_mutation(
1595    root: &Path,
1596    relative_name: &Path,
1597    create_parents: bool,
1598) -> Result<(), String> {
1599    let canonical_root = std::fs::canonicalize(root)
1600        .map(normalize_config_path)
1601        .map_err(|error| format!("could not resolve npm root {}: {error}", root.display()))?;
1602    if !paths_equal(&canonical_root, root) {
1603        return Err(format!("refusing redirected npm root {}", root.display()));
1604    }
1605
1606    let node_modules = root.join("node_modules");
1607    match std::fs::symlink_metadata(&node_modules) {
1608        Ok(_) => ensure_real_directory_tree(&node_modules)?,
1609        Err(error) if error.kind() == std::io::ErrorKind::NotFound && create_parents => {
1610            ensure_real_directory_tree(&node_modules)?;
1611        }
1612        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1613        Err(error) => {
1614            return Err(format!(
1615                "could not inspect npm node_modules {}: {error}",
1616                node_modules.display()
1617            ));
1618        }
1619    }
1620
1621    if let Some(parent) = relative_name
1622        .parent()
1623        .filter(|parent| !parent.as_os_str().is_empty())
1624    {
1625        let scope = node_modules.join(parent);
1626        match std::fs::symlink_metadata(&scope) {
1627            Ok(_) => ensure_real_directory_tree(&scope)?,
1628            Err(error) if error.kind() == std::io::ErrorKind::NotFound && create_parents => {
1629                ensure_real_directory_tree(&scope)?;
1630            }
1631            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1632            Err(error) => {
1633                return Err(format!(
1634                    "could not inspect npm scope {}: {error}",
1635                    scope.display()
1636                ));
1637            }
1638        }
1639    }
1640
1641    let target = node_modules.join(relative_name);
1642    match std::fs::symlink_metadata(&target) {
1643        Ok(metadata) => {
1644            let canonical_target = std::fs::canonicalize(&target)
1645                .map(normalize_config_path)
1646                .map_err(|error| {
1647                    format!("could not resolve npm target {}: {error}", target.display())
1648                })?;
1649            if !metadata.is_dir() && !metadata.file_type().is_symlink() {
1650                return Err(format!(
1651                    "npm package target is not a directory: {}",
1652                    target.display()
1653                ));
1654            }
1655            if !path_is_within(&canonical_target, &canonical_root) {
1656                return Err(format!(
1657                    "refusing npm package target outside its managed root: {}",
1658                    target.display()
1659                ));
1660            }
1661        }
1662        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1663        Err(error) => {
1664            return Err(format!(
1665                "could not inspect npm target {}: {error}",
1666                target.display()
1667            ));
1668        }
1669    }
1670    Ok(())
1671}
1672
1673fn npm_update_install_spec(name: &str, source_spec: &str) -> String {
1674    let configured = source_spec
1675        .strip_prefix("npm:")
1676        .unwrap_or(source_spec)
1677        .trim();
1678    let install_spec = if configured == name {
1679        format!("npm:{name}@latest")
1680    } else {
1681        format!("npm:{configured}")
1682    };
1683    install_spec
1684}
1685
1686#[derive(Debug, Clone)]
1687struct UninstallOptions {
1688    spec: String,
1689    global: bool,
1690}
1691
1692/// Remove a Pi package installed by `rpi install-pi` and disable its settings
1693/// entry. Source directories outside rpi/pi package stores are never deleted.
1694pub fn uninstall(args: &[String]) -> i32 {
1695    let options = match parse_uninstall_args(args) {
1696        Ok(options) => options,
1697        Err(message) if message == "help" => {
1698            print_uninstall_help();
1699            return 0;
1700        }
1701        Err(message) => {
1702            eprintln!("error: {message}");
1703            print_uninstall_help();
1704            return 2;
1705        }
1706    };
1707    let cwd = match std::env::current_dir() {
1708        Ok(path) => path,
1709        Err(error) => {
1710            eprintln!("error: could not determine current directory: {error}");
1711            return 1;
1712        }
1713    };
1714    let project_trusted = match project_trust_for_package_operation(&cwd, options.global) {
1715        Ok(trusted) => trusted,
1716        Err(error) => {
1717            eprintln!("error: {error}");
1718            return 1;
1719        }
1720    };
1721    let is_npm = is_npm_uninstall_spec(&cwd, &options.spec);
1722    let is_git = is_git_install_spec(&options.spec);
1723    let root = find_installed_root(&cwd, &options);
1724    let (settings_after_removal, removed_settings) =
1725        match plan_settings_removal(&cwd, &options.spec, root.as_deref(), options.global) {
1726            Ok(plan) => plan,
1727            Err(error) => {
1728                eprintln!("error: refusing to uninstall with unreadable settings: {error}");
1729                return 1;
1730            }
1731        };
1732    let mut removed_files = false;
1733    if is_npm {
1734        let npm_command = match NpmCommand::resolve(&cwd, project_trusted) {
1735            Ok(command) => command,
1736            Err(error) => {
1737                eprintln!("error: {error}");
1738                return 1;
1739            }
1740        };
1741        match uninstall_managed_npm(&cwd, options.global, &options.spec, &npm_command) {
1742            Ok(Some(removed)) => {
1743                removed_files |= removed;
1744            }
1745            Ok(None) => {}
1746            Err(error) => {
1747                eprintln!("error: {error}");
1748                return 1;
1749            }
1750        }
1751    }
1752    if is_git {
1753        if let Some(root) = root.as_deref() {
1754            let managed_legacy = is_managed_package_root(&cwd, root);
1755            let managed_git = is_managed_git_package_root(&cwd, root, options.global);
1756            if root.exists() && (managed_legacy || managed_git) {
1757                let git_marker = if managed_git {
1758                    let marker = match git_update_marker_path(root) {
1759                        Ok(marker) => marker,
1760                        Err(error) => {
1761                            eprintln!("error: {error}");
1762                            return 1;
1763                        }
1764                    };
1765                    match validate_git_update_marker(&marker) {
1766                        Ok(exists) => Some((marker, exists)),
1767                        Err(error) => {
1768                            eprintln!("error: {error}");
1769                            return 1;
1770                        }
1771                    }
1772                } else {
1773                    None
1774                };
1775                match std::fs::remove_dir_all(root) {
1776                    Ok(()) => {
1777                        if let Some((marker, true)) = git_marker {
1778                            if let Err(error) = std::fs::remove_file(&marker) {
1779                                eprintln!(
1780                                    "error: removed git package but could not remove marker {}: {error}",
1781                                    marker.display()
1782                                );
1783                                return 1;
1784                            }
1785                        }
1786                        if managed_git {
1787                            prune_empty_git_parents(&cwd, root, options.global);
1788                        }
1789                        println!("removed Pi package {}", root.display());
1790                        removed_files = true;
1791                    }
1792                    Err(error) => {
1793                        eprintln!("error: could not remove {}: {error}", root.display());
1794                        return 1;
1795                    }
1796                }
1797            } else if root.exists() {
1798                println!(
1799                    "disabled Pi package at {}; source directory was left intact",
1800                    root.display()
1801                );
1802            }
1803        }
1804    } else if let Some(root) = root.as_deref().filter(|root| root.exists()) {
1805        println!(
1806            "disabled Pi package at {}; source directory was left intact",
1807            root.display()
1808        );
1809    }
1810    // Persist the settings change only after every filesystem/package-manager
1811    // operation succeeds. A failed uninstall therefore remains retryable.
1812    if removed_settings > 0 {
1813        if let Err(error) = save_settings_for_install(&cwd, options.global, &settings_after_removal)
1814        {
1815            eprintln!(
1816                "error: package files were removed but settings could not be updated: {error}"
1817            );
1818            return 1;
1819        }
1820    }
1821    if !removed_files && removed_settings == 0 {
1822        println!("Pi package is not installed: {}", options.spec);
1823    } else if removed_settings > 0 && !removed_files {
1824        println!("disabled Pi package {}", options.spec);
1825    }
1826    0
1827}
1828
1829fn is_npm_uninstall_spec(cwd: &Path, spec: &str) -> bool {
1830    if spec.starts_with("npm:") {
1831        return true;
1832    }
1833    if spec.starts_with("file:") || is_git_install_spec(spec) {
1834        return false;
1835    }
1836    let local = PathBuf::from(spec);
1837    let local = if local.is_absolute() {
1838        local
1839    } else {
1840        cwd.join(local)
1841    };
1842    !local.exists() && looks_like_npm(spec)
1843}
1844
1845fn uninstall_managed_npm(
1846    cwd: &Path,
1847    global: bool,
1848    spec: &str,
1849    npm_command: &NpmCommand,
1850) -> Result<Option<bool>, String> {
1851    let relative_name = npm_module_name_checked(spec)?;
1852    let package_name = relative_name.to_string_lossy().replace('\\', "/");
1853    let root = managed_npm_root(cwd, global)?;
1854    validate_recognized_npm_store_root(&root, cwd, true)?;
1855    if std::fs::symlink_metadata(&root)
1856        .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound)
1857    {
1858        return Ok(None);
1859    }
1860    validate_existing_npm_store_root(&root)?;
1861    let target = root.join("node_modules").join(&relative_name);
1862    let existed = match std::fs::symlink_metadata(&target) {
1863        Ok(_) => true,
1864        Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1865        Err(error) => {
1866            return Err(format!(
1867                "could not inspect npm package target {}: {error}",
1868                target.display()
1869            ));
1870        }
1871    };
1872    if !existed && !npm_store_manifest_tracks_package(&root, &package_name)? {
1873        return Ok(None);
1874    }
1875    let _lock = PackageSwapLock::acquire(&root)?;
1876    validate_existing_npm_store_root(&root)?;
1877    validate_npm_target_before_mutation(&root, &relative_name, false)?;
1878    let args = npm_command.uninstall_args(&package_name, &root);
1879    let status = Command::new(npm_command.program())
1880        .args(npm_command.combined_args(&args))
1881        .current_dir(&root)
1882        .stdin(Stdio::inherit())
1883        .stdout(Stdio::inherit())
1884        .stderr(Stdio::inherit())
1885        .status()
1886        .map_err(|error| {
1887            format!(
1888                "could not execute {} for npm package removal: {error}",
1889                npm_command.program()
1890            )
1891        })?;
1892    if !status.success() {
1893        return Err(format!(
1894            "{} npm package removal exited with {status}",
1895            npm_command.program()
1896        ));
1897    }
1898    Ok(Some(existed))
1899}
1900
1901fn npm_store_manifest_tracks_package(root: &Path, package_name: &str) -> Result<bool, String> {
1902    let manifest = root.join("package.json");
1903    let text = match std::fs::read_to_string(&manifest) {
1904        Ok(text) => text,
1905        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1906        Err(error) => {
1907            return Err(format!(
1908                "could not read npm store manifest {}: {error}",
1909                manifest.display()
1910            ));
1911        }
1912    };
1913    let value: serde_json::Value = serde_json::from_str(&text).map_err(|error| {
1914        format!(
1915            "could not parse npm store manifest {}: {error}",
1916            manifest.display()
1917        )
1918    })?;
1919    Ok(["dependencies", "devDependencies", "optionalDependencies"]
1920        .into_iter()
1921        .any(|key| {
1922            value
1923                .get(key)
1924                .and_then(serde_json::Value::as_object)
1925                .is_some_and(|entries| entries.contains_key(package_name))
1926        }))
1927}
1928
1929fn validate_existing_npm_store_root(root: &Path) -> Result<(), String> {
1930    ensure_real_directory_tree(root)?;
1931    for path in [root.join("package.json"), root.join("node_modules")] {
1932        match std::fs::symlink_metadata(&path) {
1933            Ok(metadata)
1934                if metadata.file_type().is_symlink()
1935                    || (path.file_name() == Some(std::ffi::OsStr::new("package.json"))
1936                        && !metadata.is_file())
1937                    || (path.file_name() == Some(std::ffi::OsStr::new("node_modules"))
1938                        && !metadata.is_dir()) =>
1939            {
1940                return Err(format!(
1941                    "refusing redirected npm store entry {}",
1942                    path.display()
1943                ));
1944            }
1945            Ok(_) if path.file_name() == Some(std::ffi::OsStr::new("node_modules")) => {
1946                let canonical = std::fs::canonicalize(&path)
1947                    .map(normalize_config_path)
1948                    .map_err(|error| format!("could not resolve {}: {error}", path.display()))?;
1949                if !paths_equal(&canonical, &path) {
1950                    return Err(format!(
1951                        "refusing redirected npm store entry {}",
1952                        path.display()
1953                    ));
1954                }
1955            }
1956            Ok(_) => {}
1957            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1958            Err(error) => {
1959                return Err(format!("could not inspect {}: {error}", path.display()));
1960            }
1961        }
1962    }
1963    Ok(())
1964}
1965
1966fn parse_uninstall_args(args: &[String]) -> Result<UninstallOptions, String> {
1967    let mut spec = None;
1968    let mut global = false;
1969    for arg in args {
1970        match arg.as_str() {
1971            "--help" | "-h" => return Err("help".into()),
1972            "--global" | "-g" => global = true,
1973            value if value.starts_with('-') => {
1974                return Err(format!("unknown uninstall-pi option `{value}`"));
1975            }
1976            value => {
1977                if spec.replace(value.to_string()).is_some() {
1978                    return Err("uninstall-pi accepts exactly one package spec".into());
1979                }
1980            }
1981        }
1982    }
1983    Ok(UninstallOptions {
1984        spec: spec.ok_or_else(|| "missing npm, git, or local package spec".to_string())?,
1985        global,
1986    })
1987}
1988
1989fn find_installed_root(cwd: &Path, options: &UninstallOptions) -> Option<PathBuf> {
1990    if is_git_install_spec(&options.spec) {
1991        return find_installed_git_root(cwd, options);
1992    }
1993    let raw = options.spec.strip_prefix("file:").unwrap_or(&options.spec);
1994    let package_key = package_dir_name(&options.spec);
1995    let mut candidates = Vec::new();
1996    let direct = PathBuf::from(raw);
1997    let is_local =
1998        !is_npm_uninstall_spec(cwd, &options.spec) && !is_git_install_spec(&options.spec);
1999    if is_local && !direct.is_absolute() {
2000        if options.global {
2001            if let Ok(agent) = crate::config::agent_dir() {
2002                candidates.push(agent.join(&direct));
2003            }
2004            if let Some(home) = dirs::home_dir() {
2005                candidates.push(home.join(".pi/agent").join(&direct));
2006            }
2007        } else {
2008            candidates.push(cwd.join(".rpi").join(&direct));
2009            candidates.push(cwd.join(".pi").join(&direct));
2010        }
2011    }
2012    if direct.is_absolute() || raw.starts_with('.') {
2013        candidates.push(if direct.is_absolute() {
2014            direct
2015        } else {
2016            cwd.join(direct)
2017        });
2018    }
2019    let add_store = |store: PathBuf, candidates: &mut Vec<PathBuf>| {
2020        candidates.push(store.join(&package_key));
2021    };
2022    if !options.global {
2023        if let Ok(name) = npm_module_name_checked(&options.spec) {
2024            if options.spec.starts_with("npm:") {
2025                candidates.push(cwd.join(".pi/npm/node_modules").join(name));
2026            }
2027        }
2028        add_store(cwd.join(".rpi/packages"), &mut candidates);
2029        add_store(cwd.join(".pi/packages"), &mut candidates);
2030    } else {
2031        if let Ok(agent) = crate::config::agent_dir() {
2032            if let Ok(name) = npm_module_name_checked(&options.spec) {
2033                if options.spec.starts_with("npm:") {
2034                    candidates.push(agent.join("npm/node_modules").join(name));
2035                }
2036            }
2037            add_store(agent.join("packages"), &mut candidates);
2038        }
2039        if let Some(home) = dirs::home_dir() {
2040            if let Ok(name) = npm_module_name_checked(&options.spec) {
2041                if options.spec.starts_with("npm:") {
2042                    candidates.push(home.join(".pi/agent/npm/node_modules").join(name));
2043                }
2044            }
2045            add_store(home.join(".pi/agent/packages"), &mut candidates);
2046        }
2047    }
2048    candidates
2049        .into_iter()
2050        .find_map(|path| std::fs::canonicalize(path).ok())
2051}
2052
2053fn find_installed_git_root(cwd: &Path, options: &UninstallOptions) -> Option<PathBuf> {
2054    let source = crate::packages::parse_git_source(&options.spec)?;
2055    let relative = Path::new(&source.host).join(&source.path);
2056    let mut native_targets = Vec::new();
2057    let mut legacy_targets = Vec::new();
2058    if options.global {
2059        if let Ok(agent) = crate::config::agent_dir() {
2060            native_targets.push(agent.join("git").join(&relative));
2061            legacy_targets.push(agent.join("packages").join(package_dir_name(&options.spec)));
2062        }
2063        if let Some(home) = dirs::home_dir() {
2064            native_targets.push(home.join(".pi/agent/git").join(&relative));
2065            legacy_targets.push(
2066                home.join(".pi/agent/packages")
2067                    .join(package_dir_name(&options.spec)),
2068            );
2069        }
2070    } else {
2071        native_targets.push(cwd.join(".pi/git").join(&relative));
2072        native_targets.push(cwd.join(".rpi/git").join(&relative));
2073        legacy_targets.push(
2074            cwd.join(".rpi/packages")
2075                .join(package_dir_name(&options.spec)),
2076        );
2077        legacy_targets.push(
2078            cwd.join(".pi/packages")
2079                .join(package_dir_name(&options.spec)),
2080        );
2081    }
2082
2083    for target in native_targets {
2084        let Ok(root) = std::fs::canonicalize(target).map(normalize_config_path) else {
2085            continue;
2086        };
2087        if is_managed_git_package_root(cwd, &root, options.global) {
2088            return Some(root);
2089        }
2090    }
2091    legacy_targets.into_iter().find_map(|target| {
2092        let root = normalize_config_path(std::fs::canonicalize(target).ok()?);
2093        legacy_git_root_matches_source(cwd, &root, options.global, &source).then_some(root)
2094    })
2095}
2096
2097fn legacy_git_root_matches_source(
2098    cwd: &Path,
2099    root: &Path,
2100    global: bool,
2101    expected: &crate::packages::GitSpec,
2102) -> bool {
2103    if !is_managed_package_root(cwd, root)
2104        || !is_real_git_metadata(&root.join(".git"))
2105        || (!global
2106            && !is_direct_package_below_store(root, cwd, Path::new(".rpi/packages"))
2107            && !is_direct_package_below_store(root, cwd, Path::new(".pi/packages")))
2108    {
2109        return false;
2110    }
2111    let Ok(origin) = run_git_capture(root, &["config", "--get", "remote.origin.url"]) else {
2112        return false;
2113    };
2114    crate::packages::parse_git_source(&origin)
2115        .is_some_and(|actual| actual.host == expected.host && actual.path == expected.path)
2116}
2117
2118fn package_dir_name(spec: &str) -> String {
2119    if let Some(git) = crate::packages::parse_git_source(spec) {
2120        return safe_name(
2121            git.path
2122                .trim_end_matches('/')
2123                .rsplit('/')
2124                .next()
2125                .unwrap_or("package"),
2126        );
2127    }
2128    let raw = spec.strip_prefix("git:").unwrap_or(spec);
2129    if raw.starts_with("http://") || raw.starts_with("https://") {
2130        return safe_name(
2131            raw.trim_end_matches('/')
2132                .rsplit('/')
2133                .next()
2134                .unwrap_or("package"),
2135        );
2136    }
2137    safe_name(raw)
2138}
2139
2140fn is_managed_git_package_root(cwd: &Path, root: &Path, global: bool) -> bool {
2141    let mut stores = Vec::new();
2142    if global {
2143        if let Ok(agent) = crate::config::agent_dir() {
2144            stores.push(agent.join("git"));
2145        }
2146        if let Some(home) = dirs::home_dir() {
2147            stores.push(home.join(".pi/agent/git"));
2148        }
2149    } else {
2150        stores.push(cwd.join(".pi/git"));
2151        stores.push(cwd.join(".rpi/git"));
2152    }
2153    let root = normalize_config_path(match std::fs::canonicalize(root) {
2154        Ok(root) => root,
2155        Err(_) => return false,
2156    });
2157    if !is_real_directory(&root) {
2158        return false;
2159    }
2160    stores.into_iter().any(|store| {
2161        let canonical_store = std::fs::canonicalize(&store)
2162            .ok()
2163            .map(normalize_config_path);
2164        canonical_store.as_ref().is_some_and(|canonical_store| {
2165            paths_equal(canonical_store, &store)
2166                && path_is_within(&root, canonical_store)
2167                && root.components().count() >= canonical_store.components().count() + 3
2168        })
2169    })
2170}
2171
2172fn prune_empty_git_parents(cwd: &Path, target: &Path, global: bool) {
2173    let mut stores = Vec::new();
2174    if global {
2175        if let Ok(agent) = crate::config::agent_dir() {
2176            stores.push(agent.join("git"));
2177        }
2178        if let Some(home) = dirs::home_dir() {
2179            stores.push(home.join(".pi/agent/git"));
2180        }
2181    } else {
2182        stores.push(cwd.join(".pi/git"));
2183        stores.push(cwd.join(".rpi/git"));
2184    }
2185    let Some(store) = stores.into_iter().find(|store| {
2186        std::fs::canonicalize(store)
2187            .map(normalize_config_path)
2188            .is_ok_and(|canonical| paths_equal(&canonical, store) && path_is_within(target, store))
2189    }) else {
2190        return;
2191    };
2192    let Some(mut current) = target.parent().map(Path::to_path_buf) else {
2193        return;
2194    };
2195    while !paths_equal(&current, &store) && path_is_within(&current, &store) {
2196        let is_empty = std::fs::read_dir(&current)
2197            .ok()
2198            .is_some_and(|mut entries| entries.next().is_none());
2199        if !is_empty || std::fs::remove_dir(&current).is_err() {
2200            break;
2201        }
2202        let Some(parent) = current.parent().map(Path::to_path_buf) else {
2203            break;
2204        };
2205        current = parent;
2206    }
2207}
2208
2209fn is_managed_package_root(cwd: &Path, root: &Path) -> bool {
2210    if is_direct_package_below_store(root, cwd, Path::new(".rpi/packages"))
2211        || is_direct_package_below_store(root, cwd, Path::new(".pi/packages"))
2212    {
2213        return true;
2214    }
2215    if let Ok(agent) = crate::config::agent_dir() {
2216        if is_direct_package_below_store(root, &agent, Path::new("packages")) {
2217            return true;
2218        }
2219    }
2220    if let Some(home) = dirs::home_dir() {
2221        if is_direct_package_below_store(root, &home, Path::new(".pi/agent/packages")) {
2222            return true;
2223        }
2224    }
2225    false
2226}
2227
2228fn is_direct_package_below_store(root: &Path, base: &Path, relative_store: &Path) -> bool {
2229    let Ok(root) = std::fs::canonicalize(root) else {
2230        return false;
2231    };
2232    let Some(store) = canonical_safe_store(base, relative_store) else {
2233        return false;
2234    };
2235    let Ok(relative) = root.strip_prefix(store) else {
2236        return false;
2237    };
2238    let mut components = relative.components();
2239    components.next().is_some() && components.next().is_none()
2240}
2241
2242fn canonical_safe_store(base: &Path, relative_store: &Path) -> Option<PathBuf> {
2243    let base = std::fs::canonicalize(base).ok()?;
2244    let store = std::fs::canonicalize(base.join(relative_store)).ok()?;
2245    (store == base.join(relative_store) && store.starts_with(&base)).then_some(store)
2246}
2247
2248fn canonical_managed_store_for_target(target: &Path) -> Option<PathBuf> {
2249    let parent = std::fs::canonicalize(target.parent()?).ok()?;
2250    if let Ok(agent) = crate::config::agent_dir() {
2251        if canonical_safe_store(&agent, Path::new("packages")).as_ref() == Some(&parent) {
2252            return Some(parent);
2253        }
2254    }
2255    if let Some(home) = dirs::home_dir() {
2256        if canonical_safe_store(&home, Path::new(".pi/agent/packages")).as_ref() == Some(&parent) {
2257            return Some(parent);
2258        }
2259    }
2260
2261    let project_config = parent.parent()?;
2262    let config_name = project_config.file_name()?;
2263    if config_name != ".rpi" && config_name != ".pi" {
2264        return None;
2265    }
2266    let project = std::fs::canonicalize(project_config.parent()?).ok()?;
2267    let expected = project.join(config_name).join("packages");
2268    (parent == expected).then_some(parent)
2269}
2270
2271fn target_is_missing_without_symlink(target: &Path) -> bool {
2272    std::fs::symlink_metadata(target)
2273        .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound)
2274}
2275
2276fn target_is_package_at_store(target: &Path, store: &Path) -> bool {
2277    let Some(target_parent) = target.parent() else {
2278        return false;
2279    };
2280    let Ok(parent) = std::fs::canonicalize(target_parent) else {
2281        return false;
2282    };
2283    let Some(leaf) = target.file_name().and_then(|name| name.to_str()) else {
2284        return false;
2285    };
2286    let valid_parent = (parent == store && !leaf.starts_with('@'))
2287        || (!leaf.starts_with('@')
2288            && parent
2289                .file_name()
2290                .and_then(|name| name.to_str())
2291                .is_some_and(|name| name.starts_with('@') && name.len() > 1)
2292            && parent.parent() == Some(store));
2293    if !valid_parent {
2294        return false;
2295    }
2296    match std::fs::canonicalize(target) {
2297        Ok(root) => root.parent() == Some(parent.as_path()),
2298        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2299            target_is_missing_without_symlink(target)
2300        }
2301        Err(_) => false,
2302    }
2303}
2304
2305fn is_known_npm_package_target(target: &Path) -> bool {
2306    if let Ok(agent) = crate::config::agent_dir() {
2307        if canonical_safe_store(&agent, Path::new("npm/node_modules"))
2308            .is_some_and(|store| target_is_package_at_store(target, &store))
2309        {
2310            return true;
2311        }
2312    }
2313    if let Some(home) = dirs::home_dir() {
2314        if canonical_safe_store(&home, Path::new(".pi/agent/npm/node_modules"))
2315            .is_some_and(|store| target_is_package_at_store(target, &store))
2316        {
2317            return true;
2318        }
2319    }
2320
2321    let Some(target_parent) = target.parent() else {
2322        return false;
2323    };
2324    let Ok(parent) = std::fs::canonicalize(target_parent) else {
2325        return false;
2326    };
2327    let store = if parent
2328        .file_name()
2329        .and_then(|name| name.to_str())
2330        .is_some_and(|name| name.starts_with('@') && name.len() > 1)
2331    {
2332        let Some(store) = parent.parent() else {
2333            return false;
2334        };
2335        store
2336    } else {
2337        parent.as_path()
2338    };
2339    if store.file_name() != Some(std::ffi::OsStr::new("node_modules"))
2340        || store.parent().and_then(Path::file_name) != Some(std::ffi::OsStr::new("npm"))
2341        || store
2342            .parent()
2343            .and_then(Path::parent)
2344            .and_then(Path::file_name)
2345            != Some(std::ffi::OsStr::new(".pi"))
2346    {
2347        return false;
2348    }
2349    let Some(project) = store.parent().and_then(Path::parent).and_then(Path::parent) else {
2350        return false;
2351    };
2352    let Ok(project) = std::fs::canonicalize(project) else {
2353        return false;
2354    };
2355    store == project.join(".pi/npm/node_modules") && target_is_package_at_store(target, store)
2356}
2357
2358fn is_known_git_package_target(target: &Path) -> bool {
2359    if !target.is_absolute() {
2360        return false;
2361    }
2362    if let Ok(agent) = crate::config::agent_dir() {
2363        if target_is_git_package_at_store(target, &agent.join("git")) {
2364            return true;
2365        }
2366    }
2367    if let Some(home) = dirs::home_dir() {
2368        if target_is_git_package_at_store(target, &home.join(".pi/agent/git")) {
2369            return true;
2370        }
2371    }
2372    target.ancestors().any(|store| {
2373        store.file_name() == Some(std::ffi::OsStr::new("git"))
2374            && store
2375                .parent()
2376                .and_then(Path::file_name)
2377                .is_some_and(|name| name == ".pi" || name == ".rpi")
2378            && target_is_git_package_at_store(target, store)
2379    })
2380}
2381
2382fn target_is_git_package_at_store(target: &Path, store: &Path) -> bool {
2383    let Ok(canonical_store) = std::fs::canonicalize(store).map(normalize_config_path) else {
2384        return false;
2385    };
2386    if !paths_equal(&canonical_store, store) {
2387        return false;
2388    }
2389    let Some(parent) = target.parent() else {
2390        return false;
2391    };
2392    let Ok(canonical_parent) = std::fs::canonicalize(parent).map(normalize_config_path) else {
2393        return false;
2394    };
2395    if !paths_equal(&canonical_parent, parent)
2396        || !path_is_within(&canonical_parent, &canonical_store)
2397    {
2398        return false;
2399    }
2400    let store_depth = store.components().count();
2401    let relative = target.components().skip(store_depth).collect::<Vec<_>>();
2402    if relative.len() < 3
2403        || relative
2404            .iter()
2405            .any(|component| !matches!(component, std::path::Component::Normal(_)))
2406    {
2407        return false;
2408    }
2409    match std::fs::symlink_metadata(target) {
2410        Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
2411            std::fs::canonicalize(target)
2412                .map(normalize_config_path)
2413                .is_ok_and(|canonical| paths_equal(&canonical, target))
2414        }
2415        Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
2416        _ => false,
2417    }
2418}
2419
2420fn is_known_package_target(target: &Path) -> bool {
2421    if let Some(store) = canonical_managed_store_for_target(target) {
2422        return match std::fs::canonicalize(target) {
2423            Ok(root) => root.parent() == Some(store.as_path()),
2424            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2425                target_is_missing_without_symlink(target)
2426            }
2427            Err(_) => false,
2428        };
2429    }
2430    is_known_npm_package_target(target) || is_known_git_package_target(target)
2431}
2432
2433fn plan_settings_removal(
2434    cwd: &Path,
2435    spec: &str,
2436    root: Option<&Path>,
2437    global: bool,
2438) -> Result<(crate::settings::Settings, usize), String> {
2439    let mut settings = load_settings_for_install(cwd, global).map_err(|error| error.to_string())?;
2440    let Some(packages) = settings.packages.as_mut() else {
2441        return Ok((settings, 0));
2442    };
2443    let removed = remove_matching_settings_entries(cwd, packages, spec, root, global);
2444    if packages.is_empty() {
2445        settings.packages = None;
2446    }
2447    Ok((settings, removed))
2448}
2449
2450fn remove_matching_settings_entries(
2451    cwd: &Path,
2452    packages: &mut Vec<crate::settings::PackageSetting>,
2453    spec: &str,
2454    root: Option<&Path>,
2455    global: bool,
2456) -> usize {
2457    let before = packages.len();
2458    packages.retain(|entry| {
2459        let source = entry.source();
2460        if package_settings_sources_match(cwd, source, spec) {
2461            return false;
2462        }
2463        let matches_root = root.is_some_and(|root| {
2464            resolve_settings_source_root(cwd, source, global)
2465                .is_some_and(|candidate| paths_equal(&candidate, root))
2466        });
2467        !matches_root
2468    });
2469    before - packages.len()
2470}
2471
2472fn package_settings_sources_match(cwd: &Path, existing: &str, input: &str) -> bool {
2473    let existing = existing.trim();
2474    let input = input.trim();
2475    if existing == input {
2476        return true;
2477    }
2478    if let Some(existing_npm) = existing.strip_prefix("npm:") {
2479        if is_npm_uninstall_spec(cwd, input) {
2480            let existing_name = npm_module_name(existing_npm);
2481            let input_name = npm_module_name(input);
2482            return !existing_name.is_empty()
2483                && !input_name.is_empty()
2484                && existing_name.eq_ignore_ascii_case(&input_name);
2485        }
2486        return false;
2487    }
2488    match (
2489        crate::packages::parse_git_source(existing),
2490        crate::packages::parse_git_source(input),
2491    ) {
2492        (Some(existing), Some(input)) => {
2493            return existing.host == input.host && existing.path == input.path;
2494        }
2495        (Some(_), None) | (None, Some(_)) => return false,
2496        (None, None) => {}
2497    }
2498    false
2499}
2500
2501fn resolve_settings_source_root(cwd: &Path, source: &str, global: bool) -> Option<PathBuf> {
2502    if source.starts_with("npm:") || is_git_install_spec(source) {
2503        return find_installed_root(
2504            cwd,
2505            &UninstallOptions {
2506                spec: source.to_string(),
2507                global,
2508            },
2509        );
2510    }
2511    let raw = source.strip_prefix("file:").unwrap_or(source).trim();
2512    if raw.is_empty() {
2513        return None;
2514    }
2515    let path = PathBuf::from(raw);
2516    let mut candidates = Vec::new();
2517    if path.is_absolute() {
2518        candidates.push(path);
2519    } else if global {
2520        if let Ok(agent) = crate::config::agent_dir() {
2521            candidates.push(agent.join(&path));
2522        }
2523        if let Some(home) = dirs::home_dir() {
2524            candidates.push(home.join(".pi/agent").join(&path));
2525        }
2526    } else {
2527        candidates.push(cwd.join(".rpi").join(&path));
2528        candidates.push(cwd.join(".pi").join(&path));
2529        candidates.push(cwd.join(&path));
2530    }
2531    candidates.into_iter().find_map(|candidate| {
2532        let candidate = if candidate.is_file()
2533            && candidate.file_name().and_then(|name| name.to_str()) == Some("package.json")
2534        {
2535            candidate.parent()?.to_path_buf()
2536        } else {
2537            candidate
2538        };
2539        std::fs::canonicalize(candidate)
2540            .ok()
2541            .map(normalize_config_path)
2542    })
2543}
2544
2545fn npm_module_name(spec: &str) -> String {
2546    crate::packages::parse_npm_package_spec(spec)
2547        .map(|parsed| parsed.install_name)
2548        .unwrap_or_default()
2549}
2550
2551fn install_git(
2552    cwd: &Path,
2553    global: bool,
2554    spec: &str,
2555    force: bool,
2556    npm_command: &NpmCommand,
2557) -> Result<PathBuf, String> {
2558    install_git_with_npm_mode(
2559        cwd,
2560        global,
2561        spec,
2562        force,
2563        npm_command,
2564        NpmExecutionMode::Interactive,
2565    )
2566}
2567
2568fn install_git_with_npm_mode(
2569    cwd: &Path,
2570    global: bool,
2571    spec: &str,
2572    force: bool,
2573    npm_command: &NpmCommand,
2574    npm_mode: NpmExecutionMode,
2575) -> Result<PathBuf, String> {
2576    let raw = match spec.strip_prefix("git:") {
2577        Some(rest) if !rest.starts_with("//") => rest.trim(),
2578        _ => spec.trim(),
2579    };
2580    let (repo, revision) = split_git_install_ref(raw);
2581    if revision
2582        .as_deref()
2583        .is_some_and(|value| !is_safe_git_revision(value))
2584    {
2585        return Err("refusing invalid git revision from package settings".to_string());
2586    }
2587    let url = normalize_git_clone_url(&repo)?;
2588    let parsed = crate::packages::parse_git_source(spec)
2589        .ok_or_else(|| format!("invalid git package source `{spec}`"))?;
2590    let install_root = if global {
2591        crate::config::agent_dir()
2592            .map(|agent| agent.join("git"))
2593            .map_err(|error| error.to_string())?
2594    } else {
2595        cwd.join(".pi/git")
2596    };
2597    let target = install_root.join(&parsed.host).join(&parsed.path);
2598    let destination = target
2599        .parent()
2600        .ok_or_else(|| format!("git package target has no parent: {}", target.display()))?;
2601    prepare_managed_destination(destination)?;
2602    ensure_managed_store_ignore(&install_root)?;
2603    validate_install_target(destination, &target)?;
2604    let update_marker = git_update_marker_path(&target)?;
2605    let had_update_marker = validate_git_update_marker(&update_marker)?;
2606    if target.exists() {
2607        if !force {
2608            return Err(format!(
2609                "{} already exists; use --force to replace it",
2610                target.display()
2611            ));
2612        }
2613    }
2614    let stage = tempfile::Builder::new()
2615        .prefix(".rpi-git-stage-")
2616        .tempdir_in(destination)
2617        .map_err(|error| format!("could not create git staging directory: {error}"))?;
2618    let prepared = stage.path().join("package");
2619    clone_git_checkout(&url, revision.as_deref(), &prepared)?;
2620    install_git_dependencies(&prepared, npm_command, npm_mode)?;
2621    crate::packages::remove_package_source_marker(&prepared)?;
2622    let _lock = PackageSwapLock::acquire(&target)?;
2623    validate_install_target(destination, &target)?;
2624    recover_interrupted_swap(&target)?;
2625    validate_install_target(destination, &target)?;
2626    swap_prepared_dir(&prepared, &target, force)?;
2627    if had_update_marker {
2628        std::fs::remove_file(&update_marker).map_err(|error| {
2629            format!(
2630                "installed git package but could not remove stale update marker {}: {error}",
2631                update_marker.display()
2632            )
2633        })?;
2634    }
2635    Ok(target)
2636}
2637
2638fn clone_git_checkout(url: &str, revision: Option<&str>, target: &Path) -> Result<(), String> {
2639    let expected = crate::packages::parse_git_source(url)
2640        .filter(|source| source.revision.is_none())
2641        .ok_or_else(|| "refusing to clone an invalid git source".to_string())?;
2642    let clone_cwd = target
2643        .parent()
2644        .ok_or_else(|| format!("git staging target has no parent: {}", target.display()))?;
2645    let mut command = hardened_git_command();
2646    let status = command
2647        // Native Pi performs a full clone. Besides matching that behavior, it
2648        // keeps non-default branches available for the checkout below.
2649        .args(["clone", "--no-recurse-submodules", "--"])
2650        .arg(url)
2651        .arg(target)
2652        // Never let an invoking project's repository config participate in a
2653        // managed clone. The staging parent is newly allocated by rpi.
2654        .current_dir(clone_cwd)
2655        .env("GIT_CEILING_DIRECTORIES", clone_cwd)
2656        .stdin(Stdio::inherit())
2657        .stdout(Stdio::inherit())
2658        .stderr(Stdio::inherit())
2659        .status()
2660        .map_err(|error| format!("could not execute hardened git clone: {error}"))?;
2661    if !status.success() {
2662        return Err(format!("git clone exited with {status}"));
2663    }
2664    validate_cloned_git_origin(target, url, &expected)?;
2665    let Some(revision) = revision else {
2666        validate_git_head(target, None)?;
2667        return Ok(());
2668    };
2669    checkout_git_revision(target, revision)?;
2670    validate_cloned_git_origin(target, url, &expected)?;
2671    validate_git_head(target, Some(revision))
2672}
2673
2674fn fetch_git_revision(root: &Path, origin: &str, revision: &str) -> Result<(), String> {
2675    if !is_safe_git_revision(revision) {
2676        return Err("refusing invalid git revision from package settings".to_string());
2677    }
2678    let mut command = hardened_git_command();
2679    let status = command
2680        .args(["fetch", "--depth", "1", "--no-recurse-submodules", "--"])
2681        .arg(origin)
2682        .arg(revision)
2683        .current_dir(root)
2684        .stdin(Stdio::inherit())
2685        .stdout(Stdio::inherit())
2686        .stderr(Stdio::inherit())
2687        .status()
2688        .map_err(|error| format!("could not execute hardened git fetch: {error}"))?;
2689    if !status.success() {
2690        return Err(format!("git fetch exited with {status}"));
2691    }
2692    Ok(())
2693}
2694
2695fn checkout_git_revision(root: &Path, revision: &str) -> Result<(), String> {
2696    let mut command = hardened_git_command();
2697    let status = command
2698        .args(["checkout", revision])
2699        .current_dir(root)
2700        .stdin(Stdio::inherit())
2701        .stdout(Stdio::inherit())
2702        .stderr(Stdio::inherit())
2703        .status()
2704        .map_err(|error| format!("could not execute hardened git checkout: {error}"))?;
2705    if !status.success() {
2706        return Err(format!("git checkout exited with {status}"));
2707    }
2708    Ok(())
2709}
2710
2711fn validate_cloned_git_origin(
2712    root: &Path,
2713    requested_url: &str,
2714    expected: &crate::packages::GitSpec,
2715) -> Result<(), String> {
2716    let canonical_root = std::fs::canonicalize(root)
2717        .map(normalize_config_path)
2718        .map_err(|error| format!("could not resolve cloned git package: {error}"))?;
2719    if !paths_equal(&canonical_root, root)
2720        || !is_real_directory(root)
2721        || !is_real_git_metadata(&root.join(".git"))
2722    {
2723        return Err("refusing a redirected or incomplete cloned git checkout".to_string());
2724    }
2725    let origin = validated_git_origin(root, expected)?;
2726    if origin != requested_url {
2727        return Err("refusing cloned git checkout whose origin changed during clone".to_string());
2728    }
2729    Ok(())
2730}
2731
2732fn validate_git_head(root: &Path, revision: Option<&str>) -> Result<(), String> {
2733    let head = validated_git_head(root)?;
2734    if let Some(revision) = revision {
2735        let commit_ref = format!("{revision}^{{commit}}");
2736        let selected = run_git_capture(root, &["rev-parse", "--verify", &commit_ref])?;
2737        let selected = normalized_git_commit(&selected)
2738            .ok_or_else(|| "selected git revision is not a commit".to_string())?;
2739        if selected != head {
2740            return Err("git checkout did not select the requested revision".to_string());
2741        }
2742    }
2743    Ok(())
2744}
2745
2746fn validated_git_head(root: &Path) -> Result<String, String> {
2747    let head = run_git_capture(root, &["rev-parse", "--verify", "HEAD^{commit}"])?;
2748    normalized_git_commit(&head).ok_or_else(|| "git checkout has an invalid HEAD".to_string())
2749}
2750
2751fn normalized_git_commit(value: &str) -> Option<String> {
2752    let value = value.trim();
2753    ((value.len() == 40 || value.len() == 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()))
2754        .then(|| value.to_ascii_lowercase())
2755}
2756
2757pub(crate) fn install_missing_git_package(
2758    cwd: &Path,
2759    global: bool,
2760    spec: &str,
2761    npm_command: &NpmCommand,
2762) -> Result<PathBuf, String> {
2763    install_git(cwd, global, spec, false, npm_command)
2764}
2765
2766pub(crate) fn install_missing_git_package_for_startup(
2767    cwd: &Path,
2768    global: bool,
2769    spec: &str,
2770    npm_command: &NpmCommand,
2771) -> Result<PathBuf, String> {
2772    install_git_with_npm_mode(
2773        cwd,
2774        global,
2775        spec,
2776        false,
2777        npm_command,
2778        NpmExecutionMode::StartupRemediation,
2779    )
2780}
2781
2782fn install_git_dependencies(
2783    root: &Path,
2784    npm_command: &NpmCommand,
2785    npm_mode: NpmExecutionMode,
2786) -> Result<(), String> {
2787    install_production_dependencies_with_mode(root, npm_command, npm_mode)
2788}
2789
2790fn ensure_managed_store_ignore(root: &Path) -> Result<(), String> {
2791    ensure_real_directory_tree(root)?;
2792    let ignore = root.join(".gitignore");
2793    match std::fs::symlink_metadata(&ignore) {
2794        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err(format!(
2795            "managed store .gitignore must be a regular file: {}",
2796            ignore.display()
2797        )),
2798        Ok(_) => Ok(()),
2799        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2800            std::fs::write(&ignore, "*\n!.gitignore\n")
2801                .map_err(|error| format!("could not create managed store .gitignore: {error}"))
2802        }
2803        Err(error) => Err(format!(
2804            "could not inspect managed store .gitignore: {error}"
2805        )),
2806    }
2807}
2808
2809fn split_git_install_ref(raw: &str) -> (String, Option<String>) {
2810    let path_start = if raw.starts_with("git@") {
2811        raw.find(':').map(|index| index + 1)
2812    } else if let Some(scheme_end) = raw.find("://") {
2813        let authority_start = scheme_end + 3;
2814        raw[authority_start..]
2815            .find('/')
2816            .map(|index| authority_start + index + 1)
2817    } else {
2818        raw.find('/').map(|index| index + 1)
2819    };
2820    let Some(path_start) = path_start else {
2821        return (raw.to_string(), None);
2822    };
2823    let Some(offset) = raw[path_start..].find('@') else {
2824        return (raw.to_string(), None);
2825    };
2826    let separator = path_start + offset;
2827    let repo = &raw[..separator];
2828    let revision = &raw[separator + 1..];
2829    if repo.is_empty() || revision.is_empty() {
2830        return (raw.to_string(), None);
2831    }
2832    (repo.to_string(), Some(revision.to_string()))
2833}
2834
2835fn normalize_git_clone_url(repo: &str) -> Result<String, String> {
2836    let repo = repo.trim();
2837    if repo.is_empty() {
2838        return Err("git repository URL must not be empty".to_string());
2839    }
2840    if repo.contains('\0')
2841        || repo.contains('\\')
2842        || repo.chars().any(|ch| ch.is_control() || ch.is_whitespace())
2843    {
2844        return Err(format!("invalid git repository source `{repo}`"));
2845    }
2846    if let Some(index) = repo.find("://") {
2847        let scheme = repo[..index].to_ascii_lowercase();
2848        if !matches!(scheme.as_str(), "http" | "https" | "ssh" | "git") {
2849            return Err(format!("unsupported git transport `{scheme}`"));
2850        }
2851        let authority_and_path = &repo[index + 3..];
2852        let Some((_, path)) = authority_and_path.split_once('/') else {
2853            return Err(format!("invalid git repository source `{repo}`"));
2854        };
2855        if path
2856            .split('/')
2857            .any(|part| part.is_empty() || part == "." || part == "..")
2858        {
2859            return Err(format!("invalid git repository source `{repo}`"));
2860        }
2861        return Ok(repo.to_string());
2862    }
2863    if repo.starts_with("git@") {
2864        let Some((_, path)) = repo.split_once(':') else {
2865            return Err(format!("invalid git repository source `{repo}`"));
2866        };
2867        if path
2868            .split('/')
2869            .any(|part| part.is_empty() || part == "." || part == "..")
2870        {
2871            return Err(format!("invalid git repository source `{repo}`"));
2872        }
2873        return Ok(repo.to_string());
2874    }
2875    // Pi accepts hosted shorthand only with the `git:` source prefix. The
2876    // clone command itself needs an explicit transport for that form.
2877    if repo.split('/').count() >= 3
2878        && !repo
2879            .split('/')
2880            .any(|part| part.is_empty() || part == "." || part == "..")
2881    {
2882        return Ok(format!("https://{repo}"));
2883    }
2884    Err(format!("invalid git repository source `{repo}`"))
2885}
2886
2887fn git_package_name(repo: &str) -> String {
2888    let leaf = repo
2889        .trim_end_matches('/')
2890        .rsplit(['/', ':'])
2891        .next()
2892        .unwrap_or("package");
2893    let leaf = leaf.strip_suffix(".git").unwrap_or(leaf);
2894    safe_name(leaf)
2895}
2896
2897fn is_safe_git_revision(value: &str) -> bool {
2898    !value.is_empty()
2899        && !value.starts_with('-')
2900        && !value.starts_with('/')
2901        && !value.ends_with('/')
2902        && !value.contains('\\')
2903        && !value.contains('\0')
2904        && !value.contains("..")
2905        && !value.contains("@{")
2906        && !value.chars().any(|ch| {
2907            ch.is_control() || ch.is_whitespace() || matches!(ch, '~' | '^' | ':' | '?' | '*' | '[')
2908        })
2909        && !value
2910            .split('/')
2911            .any(|part| part.is_empty() || part == "." || part == "..")
2912}
2913
2914fn install_local(cwd: &Path, spec: &str) -> Result<PathBuf, String> {
2915    let source = PathBuf::from(spec.strip_prefix("file:").unwrap_or(spec));
2916    let source = if source.is_absolute() {
2917        source
2918    } else {
2919        cwd.join(source)
2920    };
2921    let source = std::fs::canonicalize(&source)
2922        .map(normalize_config_path)
2923        .map_err(|error| {
2924            format!(
2925                "local package path was not found at {}: {error}",
2926                source.display()
2927            )
2928        })?;
2929    let root = if source.is_dir() {
2930        source
2931    } else if source.is_file() && source.file_name() == Some(std::ffi::OsStr::new("package.json")) {
2932        source
2933            .parent()
2934            .ok_or_else(|| format!("local package manifest has no parent: {}", source.display()))?
2935            .to_path_buf()
2936    } else {
2937        return Err(format!(
2938            "local package must be a directory or package.json: {}",
2939            source.display()
2940        ));
2941    };
2942    Ok(root)
2943}
2944
2945fn replace_dir_prepared(
2946    source: &Path,
2947    target: &Path,
2948    force: bool,
2949    prepare: impl FnOnce(&Path) -> Result<(), String>,
2950) -> Result<(), String> {
2951    if target.exists() {
2952        if !force {
2953            return Err(format!(
2954                "{} already exists; use --force to replace it",
2955                target.display()
2956            ));
2957        }
2958    }
2959    let parent = target
2960        .parent()
2961        .ok_or_else(|| format!("package target has no parent: {}", target.display()))?;
2962    std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
2963    let prepared = tempfile::Builder::new()
2964        .prefix(".rpi-package-stage-")
2965        .tempdir_in(parent)
2966        .map_err(|error| format!("could not create package staging directory: {error}"))?;
2967    copy_dir(source, prepared.path())
2968        .map_err(|error| format!("could not copy package: {error}"))?;
2969    prepare(prepared.path())?;
2970    let _lock = PackageSwapLock::acquire(target)?;
2971    recover_interrupted_swap(target)?;
2972    swap_prepared_dir(prepared.path(), target, force)
2973}
2974
2975struct PackageSwapLock {
2976    file: Option<std::fs::File>,
2977}
2978
2979impl PackageSwapLock {
2980    fn acquire(target: &Path) -> Result<Self, String> {
2981        let parent = target
2982            .parent()
2983            .ok_or_else(|| format!("package target has no parent: {}", target.display()))?;
2984        let leaf = target
2985            .file_name()
2986            .and_then(|name| name.to_str())
2987            .unwrap_or("package");
2988        let path = parent.join(format!(".{leaf}.rpi-update.lock"));
2989        let file = std::fs::OpenOptions::new()
2990            .read(true)
2991            .write(true)
2992            .create(true)
2993            .open(&path)
2994            .map_err(|error| {
2995                format!(
2996                    "could not open package lock for {}: {error}",
2997                    target.display()
2998                )
2999            })?;
3000        fs2::FileExt::try_lock_exclusive(&file).map_err(|error| {
3001            format!(
3002                "could not lock package {} (another update may be running): {error}",
3003                target.display()
3004            )
3005        })?;
3006        Ok(Self { file: Some(file) })
3007    }
3008}
3009
3010impl Drop for PackageSwapLock {
3011    fn drop(&mut self) {
3012        if let Some(file) = self.file.take() {
3013            let _ = fs2::FileExt::unlock(&file);
3014        }
3015    }
3016}
3017
3018/// Recover or clean up an interrupted update at a known managed/native npm
3019/// package target. A lock file is opened only when a matching backup exists.
3020pub(crate) fn recover_configured_package_root(target: &Path) -> Result<(), String> {
3021    if !is_known_package_target(target) || package_backup_dirs(target)?.is_empty() {
3022        return Ok(());
3023    }
3024    let _lock = PackageSwapLock::acquire(target)?;
3025    recover_interrupted_swap(target)
3026}
3027
3028fn recover_interrupted_swap(target: &Path) -> Result<(), String> {
3029    if target.exists() {
3030        if package_target_is_valid(target) {
3031            remove_stale_package_backups(target)?;
3032        }
3033        return Ok(());
3034    }
3035    let backups = package_backup_dirs(target)?;
3036    match backups.as_slice() {
3037        [] => Ok(()),
3038        [backup] => std::fs::rename(backup, target).map_err(|error| {
3039            format!(
3040                "could not restore interrupted package update from {}: {error}",
3041                backup.display()
3042            )
3043        }),
3044        _ => Err(format!(
3045            "could not recover {}: multiple interrupted update backups found",
3046            target.display()
3047        )),
3048    }
3049}
3050
3051fn package_backup_dirs(target: &Path) -> Result<Vec<PathBuf>, String> {
3052    let parent = target
3053        .parent()
3054        .ok_or_else(|| format!("package target has no parent: {}", target.display()))?;
3055    let leaf = target
3056        .file_name()
3057        .and_then(|name| name.to_str())
3058        .unwrap_or("package");
3059    let prefix = format!(".{leaf}.rpi-backup-");
3060    let mut backups = Vec::new();
3061    for entry in std::fs::read_dir(parent)
3062        .map_err(|error| format!("could not inspect package backups: {error}"))?
3063    {
3064        let entry = entry.map_err(|error| format!("could not inspect package backup: {error}"))?;
3065        let matches_name = entry
3066            .file_name()
3067            .to_str()
3068            .and_then(|name| name.strip_prefix(&prefix))
3069            .is_some_and(|suffix| {
3070                suffix.len() == 32 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit())
3071            });
3072        if matches_name
3073            && entry
3074                .file_type()
3075                .map_err(|error| format!("could not inspect package backup: {error}"))?
3076                .is_dir()
3077        {
3078            backups.push(entry.path());
3079        }
3080    }
3081    backups.sort();
3082    Ok(backups)
3083}
3084
3085fn package_target_is_valid(target: &Path) -> bool {
3086    if !target.is_dir() {
3087        return false;
3088    }
3089    let manifest_valid = std::fs::read_to_string(target.join("package.json"))
3090        .ok()
3091        .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
3092        .is_some_and(|value| value.is_object());
3093    manifest_valid
3094        || [
3095            "skills",
3096            "prompts",
3097            "themes",
3098            "extensions",
3099            "SYSTEM.md",
3100            "APPEND_SYSTEM.md",
3101        ]
3102        .iter()
3103        .any(|resource| target.join(resource).exists())
3104}
3105
3106fn remove_stale_package_backups(target: &Path) -> Result<(), String> {
3107    for backup in package_backup_dirs(target)? {
3108        std::fs::remove_dir_all(&backup).map_err(|error| {
3109            format!(
3110                "could not remove stale package backup {}: {error}",
3111                backup.display()
3112            )
3113        })?;
3114    }
3115    Ok(())
3116}
3117
3118fn swap_prepared_dir(prepared: &Path, target: &Path, force: bool) -> Result<(), String> {
3119    if !target.exists() {
3120        return std::fs::rename(prepared, target)
3121            .map_err(|error| format!("could not activate {}: {error}", target.display()));
3122    }
3123    if !force {
3124        return Err(format!(
3125            "{} already exists; use --force to replace it",
3126            target.display()
3127        ));
3128    }
3129    let parent = target
3130        .parent()
3131        .ok_or_else(|| format!("package target has no parent: {}", target.display()))?;
3132    let leaf = target
3133        .file_name()
3134        .and_then(|name| name.to_str())
3135        .unwrap_or("package");
3136    let backup = parent.join(format!(
3137        ".{leaf}.rpi-backup-{}",
3138        uuid::Uuid::new_v4().simple()
3139    ));
3140    std::fs::rename(target, &backup)
3141        .map_err(|error| format!("could not back up {}: {error}", target.display()))?;
3142    if let Err(error) = std::fs::rename(prepared, target) {
3143        return match std::fs::rename(&backup, target) {
3144            Ok(()) => Err(format!(
3145                "could not activate replacement for {}: {error}",
3146                target.display()
3147            )),
3148            Err(rollback) => Err(format!(
3149                "could not activate replacement for {}: {error}; rollback at {} also failed: {rollback}",
3150                target.display(),
3151                backup.display()
3152            )),
3153        };
3154    }
3155    if let Err(error) = std::fs::remove_dir_all(&backup) {
3156        eprintln!(
3157            "warning: updated package but could not remove backup {}: {error}",
3158            backup.display()
3159        );
3160    }
3161    Ok(())
3162}
3163
3164fn copy_dir(source: &Path, target: &Path) -> std::io::Result<()> {
3165    std::fs::create_dir_all(target)?;
3166    for entry in std::fs::read_dir(source)? {
3167        let entry = entry?;
3168        if matches!(entry.file_name().to_str(), Some("node_modules" | ".git")) {
3169            continue;
3170        }
3171        let from = entry.path();
3172        let to = target.join(entry.file_name());
3173        if from.is_dir() {
3174            copy_dir(&from, &to)?;
3175        } else {
3176            std::fs::copy(from, to)?;
3177        }
3178    }
3179    Ok(())
3180}
3181
3182fn install_production_dependencies_with_mode(
3183    root: &Path,
3184    npm_command: &NpmCommand,
3185    npm_mode: NpmExecutionMode,
3186) -> Result<(), String> {
3187    if !root.join("package.json").is_file() {
3188        return Ok(());
3189    }
3190    let install_args = if npm_command.is_configured() {
3191        vec!["install".to_string()]
3192    } else {
3193        ["install", "--omit=dev"].map(String::from).to_vec()
3194    };
3195    run_dependency_npm_command(root, npm_command, &install_args, npm_mode, false)?;
3196    let mut host_packages = Vec::new();
3197    if let Ok(text) = std::fs::read_to_string(root.join("package.json")) {
3198        if let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&text) {
3199            if let Some(peers) = manifest
3200                .get("peerDependencies")
3201                .and_then(|value| value.as_object())
3202            {
3203                for (name, version) in peers {
3204                    let version = version.as_str().unwrap_or("*");
3205                    host_packages.push(if version == "*" {
3206                        name.to_string()
3207                    } else {
3208                        format!("{name}@{version}")
3209                    });
3210                }
3211                if peers.contains_key("@earendil-works/pi-coding-agent") {
3212                    // Pi's coding-agent package imports this host runtime from
3213                    // its UI barrel, although it is not declared as a peer.
3214                    host_packages.push("@earendil-works/pi-server".to_string());
3215                }
3216            }
3217        }
3218    }
3219    if !host_packages.is_empty() && npm_command.manager_kind() == NpmManagerKind::Npm {
3220        let mut host_args = ["install", "--omit=dev", "--no-package-lock", "--no-save"]
3221            .map(String::from)
3222            .to_vec();
3223        host_args.extend(host_packages);
3224        run_dependency_npm_command(root, npm_command, &host_args, npm_mode, true)?;
3225    }
3226    Ok(())
3227}
3228
3229fn run_dependency_npm_command(
3230    root: &Path,
3231    npm_command: &NpmCommand,
3232    args: &[String],
3233    npm_mode: NpmExecutionMode,
3234    host_dependencies: bool,
3235) -> Result<(), String> {
3236    if npm_mode == NpmExecutionMode::StartupRemediation {
3237        return npm_command
3238            .run_startup_remediation(args, root)
3239            .map_err(|error| {
3240                format!(
3241                    "could not install {} during startup remediation: {error}",
3242                    if host_dependencies {
3243                        "Pi host dependencies"
3244                    } else {
3245                        "package dependencies"
3246                    }
3247                )
3248            });
3249    }
3250
3251    let status = Command::new(npm_command.program())
3252        .args(npm_command.combined_args(args))
3253        .current_dir(root)
3254        .stdin(Stdio::inherit())
3255        .stdout(Stdio::inherit())
3256        .stderr(Stdio::inherit())
3257        .status()
3258        .map_err(|error| {
3259            if host_dependencies {
3260                format!(
3261                    "could not install Pi host dependencies with {}: {error}",
3262                    npm_command.program()
3263                )
3264            } else {
3265                format!(
3266                    "could not execute {} for package dependencies: {error}",
3267                    npm_command.program()
3268                )
3269            }
3270        })?;
3271    if status.success() {
3272        Ok(())
3273    } else if host_dependencies {
3274        Err(format!(
3275            "{} host dependency install exited with {status}",
3276            npm_command.program()
3277        ))
3278    } else {
3279        Err(format!(
3280            "{} install exited with {status}",
3281            npm_command.program()
3282        ))
3283    }
3284}
3285
3286fn normalize_config_path(path: PathBuf) -> PathBuf {
3287    if cfg!(windows) {
3288        let text = path.to_string_lossy();
3289        if let Some(unc) = text.strip_prefix(r"\\?\UNC\") {
3290            return PathBuf::from(format!(r"\\{unc}"));
3291        }
3292        if let Some(local) = text.strip_prefix(r"\\?\") {
3293            return PathBuf::from(local);
3294        }
3295    }
3296    path
3297}
3298
3299fn paths_equal(left: &Path, right: &Path) -> bool {
3300    let left = normalize_config_path(left.to_path_buf());
3301    let right = normalize_config_path(right.to_path_buf());
3302    if cfg!(windows) {
3303        left.to_string_lossy()
3304            .replace('/', "\\")
3305            .eq_ignore_ascii_case(&right.to_string_lossy().replace('/', "\\"))
3306    } else {
3307        left == right
3308    }
3309}
3310
3311fn path_is_within(path: &Path, root: &Path) -> bool {
3312    let path = normalize_config_path(path.to_path_buf());
3313    let root = normalize_config_path(root.to_path_buf());
3314    let mut path_components = path.components();
3315    for root_component in root.components() {
3316        let Some(path_component) = path_components.next() else {
3317            return false;
3318        };
3319        if !path_components_equal(path_component, root_component) {
3320            return false;
3321        }
3322    }
3323    true
3324}
3325
3326fn validate_recognized_npm_store_root(
3327    root: &Path,
3328    cwd: &Path,
3329    project_trusted: bool,
3330) -> Result<(), String> {
3331    if !root.is_absolute() || root.file_name() != Some(std::ffi::OsStr::new("npm")) {
3332        return Err(format!(
3333            "refusing npm store update outside a recognized absolute npm root: {}",
3334            root.display()
3335        ));
3336    }
3337    let mut allowed = Vec::new();
3338    if let Ok(agent) = crate::config::agent_dir() {
3339        allowed.push(agent.join("npm"));
3340    }
3341    if let Some(home) = dirs::home_dir() {
3342        allowed.push(home.join(".pi/agent/npm"));
3343    }
3344    if project_trusted {
3345        allowed.push(cwd.join(".pi/npm"));
3346    }
3347    if !allowed.iter().any(|candidate| paths_equal(candidate, root)) {
3348        return Err(format!(
3349            "refusing npm store update outside a configured package store: {}",
3350            root.display()
3351        ));
3352    }
3353    Ok(())
3354}
3355
3356fn npm_module_name_checked(spec: &str) -> Result<PathBuf, String> {
3357    npm_package_spec_checked(spec).map(|parsed| PathBuf::from(parsed.install_name))
3358}
3359
3360fn npm_package_spec_checked(spec: &str) -> Result<crate::packages::ParsedNpmPackageSpec, String> {
3361    crate::packages::parse_npm_package_spec(spec)
3362        .ok_or_else(|| format!("refusing invalid npm package spec `{spec}`"))
3363}
3364
3365fn looks_like_npm(spec: &str) -> bool {
3366    !spec.contains(std::path::MAIN_SEPARATOR) && !spec.contains('/') && !spec.ends_with(".json")
3367}
3368
3369pub fn print_help() {
3370    println!("Usage: rpi install-pi [options] <spec>\n\nInstall a Pi npm/git/local package.\n\nSpecs:\n  npm:@scope/package@1.0.0\n  npm:alias@npm:@scope/package@^1\n  git:github.com/user/repo@v1\n  ./local-package\n\nOptions:\n  --global, -g  Use global agent settings and managed stores\n  --force, -f   Replace an existing package\n  --help, -h    Show this help");
3371}
3372
3373fn print_uninstall_help() {
3374    println!("Usage: rpi uninstall-pi [options] <spec>\n\nRemove an installed Pi npm/git/local package and disable it in settings.\n\nSpecs:\n  npm:@scope/package\n  git:github.com/user/repo\n  ./local-package\n\nOptions:\n  --global, -g  Use global agent settings and managed stores\n  --help, -h    Show this help\n\nAliases:\n  rpi uninstall pi <spec>");
3375}
3376
3377#[cfg(test)]
3378mod tests {
3379    use super::*;
3380
3381    fn failing_npm_command(script: &Path) -> NpmCommand {
3382        #[cfg(windows)]
3383        {
3384            std::fs::write(
3385                script,
3386                "[Console]::Error.Write('startup-bounded'); exit 7\n",
3387            )
3388            .unwrap();
3389            let program = PathBuf::from(std::env::var_os("SystemRoot").unwrap())
3390                .join("System32/WindowsPowerShell/v1.0/powershell.exe")
3391                .to_string_lossy()
3392                .into_owned();
3393            return NpmCommand::from_argv(Some(&[
3394                program,
3395                "-NoLogo".to_string(),
3396                "-NoProfile".to_string(),
3397                "-NonInteractive".to_string(),
3398                "-File".to_string(),
3399                script.to_string_lossy().into_owned(),
3400            ]))
3401            .unwrap();
3402        }
3403        #[cfg(unix)]
3404        {
3405            std::fs::write(script, "printf startup-bounded >&2\nexit 7\n").unwrap();
3406            NpmCommand::from_argv(Some(&[
3407                "/bin/sh".to_string(),
3408                script.to_string_lossy().into_owned(),
3409            ]))
3410            .unwrap()
3411        }
3412    }
3413
3414    fn run_test_git(cwd: &Path, args: &[&str]) -> String {
3415        let output = Command::new("git")
3416            .args(args)
3417            .current_dir(cwd)
3418            .output()
3419            .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
3420        assert!(
3421            output.status.success(),
3422            "git {:?} exited with {}: {}",
3423            args,
3424            output.status,
3425            String::from_utf8_lossy(&output.stderr)
3426        );
3427        String::from_utf8_lossy(&output.stdout).trim().to_string()
3428    }
3429
3430    fn initialize_test_git_package(root: &Path, origin: &str, state: &str) -> String {
3431        std::fs::create_dir_all(root).unwrap();
3432        run_test_git(root, &["init", "--initial-branch", "main"]);
3433        run_test_git(root, &["config", "user.email", "rpi-test@example.invalid"]);
3434        run_test_git(root, &["config", "user.name", "rpi-test"]);
3435        std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
3436        std::fs::write(root.join("state.txt"), format!("{state}\n")).unwrap();
3437        run_test_git(root, &["add", "package.json", "state.txt"]);
3438        run_test_git(root, &["commit", "-m", state]);
3439        run_test_git(root, &["remote", "add", "origin", origin]);
3440        run_test_git(root, &["rev-parse", "HEAD"])
3441    }
3442
3443    #[test]
3444    fn parses_install_specs_and_flags() {
3445        let options = parse_args(&["--global".into(), "npm:@scope/pkg@1.2.3".into()]).unwrap();
3446        assert!(options.global);
3447        assert_eq!(options.spec, "npm:@scope/pkg@1.2.3");
3448        assert_eq!(package_name(&options.spec), "scope__pkg");
3449        assert_eq!(npm_module_name(&options.spec), "@scope/pkg");
3450    }
3451
3452    #[test]
3453    fn startup_git_dependency_mode_returns_bounded_process_diagnostics() {
3454        let tmp = tempfile::tempdir().unwrap();
3455        let root = tmp.path().join("package");
3456        std::fs::create_dir_all(&root).unwrap();
3457        std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
3458        let script = tmp.path().join(if cfg!(windows) {
3459            "failing-npm.ps1"
3460        } else {
3461            "failing-npm.sh"
3462        });
3463        let command = failing_npm_command(&script);
3464
3465        let error = install_git_dependencies(&root, &command, NpmExecutionMode::StartupRemediation)
3466            .unwrap_err();
3467
3468        assert!(error.contains("startup remediation"), "{error}");
3469        assert!(error.contains("startup-bounded"), "{error}");
3470    }
3471
3472    #[test]
3473    fn install_git_sources_split_slash_refs_and_normalize_shorthand() {
3474        for spec in [
3475            "git:github.com/example/repo@feature/branch",
3476            "https://github.com/example/repo.git@feature/branch",
3477            "ssh://git@github.com/example/repo@feature/branch",
3478            "git://github.com/example/repo@feature/branch",
3479            "git:git@github.com:example/repo@feature/branch",
3480        ] {
3481            assert!(is_git_install_spec(spec), "spec={spec}");
3482            let raw = match spec.strip_prefix("git:") {
3483                Some(rest) if !rest.starts_with("//") => rest,
3484                _ => spec,
3485            };
3486            let (repo, revision) = split_git_install_ref(raw);
3487            assert_eq!(revision.as_deref(), Some("feature/branch"), "spec={spec}");
3488            let clone_url = normalize_git_clone_url(&repo).unwrap();
3489            assert!(clone_url.contains("github.com"), "spec={spec}");
3490            assert_eq!(git_package_name(&repo), "repo", "spec={spec}");
3491        }
3492    }
3493
3494    #[test]
3495    fn install_git_source_rejects_unsafe_revision() {
3496        let (repo, revision) = split_git_install_ref("github.com/example/repo@../escape");
3497        assert_eq!(repo, "github.com/example/repo");
3498        assert!(!is_safe_git_revision(revision.as_deref().unwrap()));
3499        assert!(normalize_git_clone_url(&repo).is_ok());
3500    }
3501
3502    #[test]
3503    fn settings_entries_are_relative_to_their_scope() {
3504        let tmp = tempfile::tempdir().unwrap();
3505        let root = tmp.path().join(".rpi/packages/package");
3506        assert_eq!(npm_settings_spec("demo"), "npm:demo");
3507        assert_eq!(
3508            npm_settings_spec("npm:@scope/demo@^1"),
3509            "npm:@scope/demo@^1"
3510        );
3511        assert_eq!(
3512            settings_entry(&root, tmp.path(), false).unwrap(),
3513            format!("file:{}", Path::new("packages/package").display())
3514        );
3515    }
3516
3517    #[test]
3518    fn local_install_references_the_source_without_copying_it() {
3519        let tmp = tempfile::tempdir().unwrap();
3520        let project = tmp.path().join("project");
3521        let source = tmp.path().join("local-package");
3522        std::fs::create_dir_all(&project).unwrap();
3523        std::fs::create_dir_all(&source).unwrap();
3524        std::fs::write(source.join("package.json"), r#"{"name":"local-package"}"#).unwrap();
3525
3526        let installed = install_local(&project, "../local-package").unwrap();
3527        assert!(paths_equal(
3528            &installed,
3529            &std::fs::canonicalize(&source).unwrap()
3530        ));
3531        assert!(!project.join(".rpi/packages/local-package").exists());
3532
3533        std::fs::create_dir_all(project.join(".rpi")).unwrap();
3534        let entry = settings_entry(&installed, &project, false).unwrap();
3535        let relative = Path::new(entry.strip_prefix("file:").unwrap());
3536        assert!(paths_equal(
3537            &std::fs::canonicalize(project.join(".rpi").join(relative)).unwrap(),
3538            &installed
3539        ));
3540    }
3541
3542    #[test]
3543    fn npm_names_cannot_be_interpreted_as_command_options() {
3544        for spec in ["npm:-demo", "npm:@-scope/demo", "npm:@scope/-demo"] {
3545            assert!(npm_module_name_checked(spec).is_err(), "spec={spec}");
3546        }
3547    }
3548
3549    #[test]
3550    fn interrupted_native_git_swap_is_recovered() {
3551        let tmp = tempfile::tempdir().unwrap();
3552        let target = tmp.path().join(".pi/git/github.com/example/repo");
3553        let backup = target
3554            .parent()
3555            .unwrap()
3556            .join(".repo.rpi-backup-00000000000000000000000000000001");
3557        std::fs::create_dir_all(&backup).unwrap();
3558        std::fs::write(backup.join("package.json"), r#"{"name":"repo"}"#).unwrap();
3559
3560        recover_configured_package_root(&target).unwrap();
3561        assert!(target.join("package.json").is_file());
3562        assert!(!backup.exists());
3563    }
3564
3565    #[test]
3566    fn package_source_matching_uses_npm_and_git_identity_without_cross_kind_collisions() {
3567        let cwd = Path::new(".");
3568        assert!(package_settings_sources_match(
3569            cwd,
3570            "npm:@scope/demo@^1",
3571            "npm:@scope/demo@beta"
3572        ));
3573        assert!(package_settings_sources_match(
3574            cwd,
3575            "npm:alias@npm:real@^1",
3576            "npm:alias@npm:other@beta"
3577        ));
3578        assert!(!package_settings_sources_match(
3579            cwd,
3580            "npm:alias@npm:real@^1",
3581            "npm:real@^1"
3582        ));
3583        assert!(package_settings_sources_match(
3584            cwd,
3585            "git:https://github.com/example/repo.git@main",
3586            "git:git@github.com:example/repo@release/v2"
3587        ));
3588        assert!(!package_settings_sources_match(
3589            cwd,
3590            "file:packages/demo",
3591            "npm:demo"
3592        ));
3593    }
3594
3595    #[test]
3596    fn npm_package_names_cannot_be_option_arguments() {
3597        assert!(npm_module_name_checked("npm:-rf").is_err());
3598        assert!(npm_module_name_checked("npm:--workspace").is_err());
3599        assert_eq!(
3600            npm_module_name_checked("npm:@scope/valid-name@^1").unwrap(),
3601            PathBuf::from("@scope/valid-name")
3602        );
3603        assert_eq!(
3604            npm_module_name_checked("npm:@scope/alias@npm:@target/real@^1").unwrap(),
3605            PathBuf::from("@scope/alias")
3606        );
3607        for spec in [
3608            "npm:alias@npm:real@npm:other",
3609            "npm:alias@file:../real",
3610            "npm:alias@npm:real@file:../other",
3611        ] {
3612            assert!(npm_module_name_checked(spec).is_err(), "spec={spec}");
3613        }
3614    }
3615
3616    #[test]
3617    fn global_relative_settings_entries_resolve_from_the_agent_directory() {
3618        struct RestoreEnv(Option<std::ffi::OsString>);
3619        impl Drop for RestoreEnv {
3620            fn drop(&mut self) {
3621                match self.0.take() {
3622                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3623                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3624                }
3625            }
3626        }
3627
3628        let _lock = crate::config::test_support::env_lock().lock().unwrap();
3629        let temp = tempfile::tempdir().unwrap();
3630        let agent = temp.path().join("agent");
3631        let package = agent.join("packages/demo");
3632        std::fs::create_dir_all(&package).unwrap();
3633        std::fs::write(package.join("package.json"), r#"{"name":"demo"}"#).unwrap();
3634        let _restore = RestoreEnv(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3635        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3636
3637        let resolved = resolve_settings_source_root(temp.path(), "file:packages/demo", true)
3638            .expect("global package should resolve from its settings owner");
3639        assert!(paths_equal(
3640            &resolved,
3641            &std::fs::canonicalize(package).unwrap()
3642        ));
3643    }
3644
3645    #[test]
3646    fn git_uninstall_lookup_uses_native_host_and_repository_path() {
3647        let temp = tempfile::tempdir().unwrap();
3648        let target = temp.path().join(".pi/git/github.com/example/repo");
3649        std::fs::create_dir_all(&target).unwrap();
3650        let options = UninstallOptions {
3651            spec: "git:github.com/example/repo@feature/branch".to_string(),
3652            global: false,
3653        };
3654        assert!(paths_equal(
3655            &find_installed_root(temp.path(), &options).unwrap(),
3656            &std::fs::canonicalize(target).unwrap()
3657        ));
3658    }
3659
3660    #[test]
3661    fn git_uninstall_never_falls_back_to_an_unverified_legacy_leaf() {
3662        fn git(cwd: &Path, args: &[&str]) {
3663            let status = Command::new("git")
3664                .args(args)
3665                .current_dir(cwd)
3666                .status()
3667                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
3668            assert!(status.success(), "git {:?} exited with {status}", args);
3669        }
3670
3671        let temp = tempfile::tempdir().unwrap();
3672        let root = temp.path().join(".rpi/packages/repo");
3673        std::fs::create_dir_all(&root).unwrap();
3674        std::fs::write(root.join("package.json"), r#"{"name":"repo"}"#).unwrap();
3675        let options = UninstallOptions {
3676            spec: "git:github.com/example/repo@main".to_string(),
3677            global: false,
3678        };
3679        assert_eq!(find_installed_root(temp.path(), &options), None);
3680
3681        git(&root, &["init"]);
3682        git(
3683            &root,
3684            &[
3685                "remote",
3686                "add",
3687                "origin",
3688                "https://github.com/other/repo.git",
3689            ],
3690        );
3691        assert_eq!(find_installed_root(temp.path(), &options), None);
3692
3693        git(
3694            &root,
3695            &[
3696                "remote",
3697                "set-url",
3698                "origin",
3699                "https://github.com/example/repo.git",
3700            ],
3701        );
3702        assert!(paths_equal(
3703            &find_installed_root(temp.path(), &options).unwrap(),
3704            &std::fs::canonicalize(root).unwrap(),
3705        ));
3706    }
3707
3708    #[test]
3709    fn npm_store_manifest_provenance_is_explicit() {
3710        let temp = tempfile::tempdir().unwrap();
3711        std::fs::write(
3712            temp.path().join("package.json"),
3713            r#"{"name":"pi-extensions","private":true,"dependencies":{"demo":"^1"}}"#,
3714        )
3715        .unwrap();
3716        assert!(npm_store_manifest_tracks_package(temp.path(), "demo").unwrap());
3717        assert!(!npm_store_manifest_tracks_package(temp.path(), "other").unwrap());
3718    }
3719
3720    #[test]
3721    fn npm_updates_preserve_ranges_and_tags() {
3722        assert_eq!(
3723            npm_update_install_spec("demo", "npm:demo"),
3724            "npm:demo@latest"
3725        );
3726        assert_eq!(
3727            npm_update_install_spec("demo", "npm:demo@^1"),
3728            "npm:demo@^1"
3729        );
3730        assert_eq!(
3731            npm_update_install_spec("demo", "npm:demo@beta"),
3732            "npm:demo@beta"
3733        );
3734        assert_eq!(
3735            npm_update_install_spec("alias", "npm:alias@npm:@scope/real@^1"),
3736            "npm:alias@npm:@scope/real@^1"
3737        );
3738        assert_eq!(
3739            npm_root_update_spec("demo", "npm:demo").unwrap(),
3740            "demo@latest"
3741        );
3742        assert_eq!(
3743            npm_root_update_spec("demo", "npm:demo@^1").unwrap(),
3744            "demo@^1"
3745        );
3746        assert_eq!(
3747            npm_root_update_spec("alias", "npm:alias@npm:real@beta").unwrap(),
3748            "alias@npm:real@beta"
3749        );
3750        assert!(npm_root_update_spec("demo", "npm:other@^1").is_err());
3751    }
3752
3753    #[test]
3754    fn managed_npm_alias_validates_the_target_manifest_name() {
3755        let tmp = tempfile::tempdir().unwrap();
3756        let install_root = tmp.path().join("npm");
3757        let relative = PathBuf::from("@scope/alias");
3758        let target = install_root.join("node_modules").join(&relative);
3759        std::fs::create_dir_all(&target).unwrap();
3760        std::fs::write(
3761            target.join("package.json"),
3762            r#"{"name":"@target/real","version":"1.0.0"}"#,
3763        )
3764        .unwrap();
3765
3766        validate_managed_npm_package(&install_root, &target, &relative, "@target/real").unwrap();
3767        assert!(
3768            validate_managed_npm_package(&install_root, &target, &relative, "@target/wrong",)
3769                .is_err()
3770        );
3771    }
3772
3773    #[test]
3774    fn native_store_update_does_not_overwrite_manifest_before_manager_runs() {
3775        let tmp = tempfile::tempdir().unwrap();
3776        let root = tmp.path().join("npm");
3777        std::fs::create_dir_all(&root).unwrap();
3778        let manifest = "{\"name\":\"custom-root\",\"private\":true}\n";
3779        std::fs::write(root.join("package.json"), manifest).unwrap();
3780        let argv = ["mise", "exec", "--", "pnpm"].map(String::from);
3781        let command = NpmCommand::from_argv(Some(&argv)).unwrap();
3782        let packages = vec![
3783            ("one".to_string(), "npm:one".to_string()),
3784            ("@scope/two".to_string(), "npm:@scope/two@^2".to_string()),
3785        ];
3786        let root_arg = root.to_string_lossy().into_owned();
3787
3788        update_npm_store_root_with(&root, &packages, &command, |actual_root, command, args| {
3789            assert_eq!(actual_root, root);
3790            assert_eq!(command.program(), "mise");
3791            assert_eq!(
3792                args,
3793                [
3794                    "install",
3795                    "one@latest",
3796                    "@scope/two@^2",
3797                    "--prefix",
3798                    &root_arg,
3799                    "--config.auto-install-peers=false",
3800                    "--config.strict-peer-dependencies=false",
3801                    "--config.strict-dep-builds=false",
3802                ]
3803                .map(String::from)
3804                .as_slice()
3805            );
3806            assert_eq!(
3807                std::fs::read_to_string(root.join("package.json")).unwrap(),
3808                manifest
3809            );
3810            Ok(())
3811        })
3812        .unwrap();
3813
3814        assert_eq!(
3815            std::fs::read_to_string(root.join("package.json")).unwrap(),
3816            manifest
3817        );
3818        assert!(tmp.path().join(".npm.rpi-update.lock").is_file());
3819    }
3820
3821    #[test]
3822    fn native_store_update_creates_only_the_missing_root_manifest() {
3823        let tmp = tempfile::tempdir().unwrap();
3824        let root = tmp.path().join("npm");
3825        let command = NpmCommand::from_argv(Some(&["bun".to_string()])).unwrap();
3826        let root_arg = root.to_string_lossy().into_owned();
3827        update_npm_store_root_with(
3828            &root,
3829            &[("demo".to_string(), "npm:demo@beta".to_string())],
3830            &command,
3831            |actual_root, _, args| {
3832                assert!(actual_root.join("package.json").is_file());
3833                assert_eq!(
3834                    args,
3835                    ["install", "demo@beta", "--cwd", &root_arg, "--omit=peer",]
3836                        .map(String::from)
3837                        .as_slice()
3838                );
3839                Ok(())
3840            },
3841        )
3842        .unwrap();
3843        assert_eq!(
3844            serde_json::from_str::<serde_json::Value>(
3845                &std::fs::read_to_string(root.join("package.json")).unwrap()
3846            )
3847            .unwrap()["name"],
3848            "pi-extensions"
3849        );
3850    }
3851
3852    #[test]
3853    fn pnpm_and_bun_refuse_standalone_leaf_copy_install() {
3854        let tmp = tempfile::tempdir().unwrap();
3855        for manager in ["pnpm", "bun"] {
3856            let command = NpmCommand::from_argv(Some(&[manager.to_string()])).unwrap();
3857            let error = install_npm_at(
3858                &tmp.path().join(manager),
3859                "npm:demo@latest",
3860                "npm:demo",
3861                true,
3862                &command,
3863            )
3864            .unwrap_err();
3865            assert!(error.contains("cannot safely install"), "{error}");
3866        }
3867    }
3868
3869    #[test]
3870    fn filtered_settings_entries_are_preserved_or_removed_by_source() {
3871        let filtered = crate::settings::PackageSetting::Filtered(crate::settings::PackageFilter {
3872            source: "npm:filtered".to_string(),
3873            autoload: Some(false),
3874            extensions: None,
3875            skills: None,
3876            prompts: None,
3877            themes: None,
3878            unknown: serde_json::Map::from_iter([(
3879                "futureFilter".to_string(),
3880                serde_json::json!({"enabled": true}),
3881            )]),
3882        });
3883        let preserved = filtered.clone();
3884        let mut packages = vec![filtered];
3885
3886        assert!(!enable_settings_entry(
3887            Path::new("."),
3888            &mut packages,
3889            "npm:filtered".to_string()
3890        ));
3891        assert_eq!(packages, vec![preserved.clone()]);
3892        assert!(enable_settings_entry(
3893            Path::new("."),
3894            &mut packages,
3895            "npm:filtered@beta".to_string()
3896        ));
3897        assert_eq!(packages.len(), 1);
3898        let crate::settings::PackageSetting::Filtered(updated) = &packages[0] else {
3899            panic!("filtered package entry was replaced instead of updated");
3900        };
3901        assert_eq!(updated.source, "npm:filtered@beta");
3902        assert_eq!(updated.autoload, Some(false));
3903        assert_eq!(
3904            updated.unknown.get("futureFilter"),
3905            Some(&serde_json::json!({"enabled": true}))
3906        );
3907
3908        assert!(enable_settings_entry(
3909            Path::new("."),
3910            &mut packages,
3911            "npm:second".to_string()
3912        ));
3913
3914        assert_eq!(
3915            remove_matching_settings_entries(
3916                Path::new("."),
3917                &mut packages,
3918                "npm:second",
3919                None,
3920                false,
3921            ),
3922            1
3923        );
3924        assert_eq!(packages.len(), 1);
3925        assert_eq!(
3926            remove_matching_settings_entries(
3927                Path::new("."),
3928                &mut packages,
3929                "npm:filtered",
3930                None,
3931                false,
3932            ),
3933            1
3934        );
3935        assert!(packages.is_empty());
3936    }
3937
3938    #[test]
3939    fn git_settings_ref_is_replaced_by_repository_identity() {
3940        let mut packages = vec![crate::settings::PackageSetting::Filtered(
3941            crate::settings::PackageFilter {
3942                source: "git:github.com/example/repo@main".to_string(),
3943                autoload: Some(false),
3944                extensions: Some(vec!["extensions/index.ts".to_string()]),
3945                skills: None,
3946                prompts: None,
3947                themes: None,
3948                unknown: serde_json::Map::new(),
3949            },
3950        )];
3951
3952        assert!(enable_settings_entry(
3953            Path::new("."),
3954            &mut packages,
3955            "git:github.com/example/repo@feature/branch".to_string(),
3956        ));
3957        assert_eq!(packages.len(), 1);
3958        let crate::settings::PackageSetting::Filtered(updated) = &packages[0] else {
3959            panic!("filtered package entry was replaced instead of updated");
3960        };
3961        assert_eq!(updated.source, "git:github.com/example/repo@feature/branch");
3962        assert_eq!(
3963            updated.extensions.as_deref(),
3964            Some(["extensions/index.ts".to_string()].as_slice())
3965        );
3966    }
3967
3968    #[test]
3969    fn project_package_operations_require_saved_trust() {
3970        struct RestoreEnv(Option<std::ffi::OsString>);
3971        impl Drop for RestoreEnv {
3972            fn drop(&mut self) {
3973                match self.0.take() {
3974                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3975                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3976                }
3977            }
3978        }
3979
3980        let _lock = crate::config::test_support::env_lock().lock().unwrap();
3981        let tmp = tempfile::tempdir().unwrap();
3982        let project = tmp.path().join("project");
3983        let agent = tmp.path().join("agent");
3984        std::fs::create_dir_all(&project).unwrap();
3985        std::fs::create_dir_all(&agent).unwrap();
3986        let _restore = RestoreEnv(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3987        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3988
3989        assert!(project_trust_for_package_operation(&project, false).is_err());
3990        assert!(!project_trust_for_package_operation(&project, true).unwrap());
3991        crate::config::set_project_trust(&project, Some(true)).unwrap();
3992        assert!(project_trust_for_package_operation(&project, false).unwrap());
3993    }
3994
3995    #[test]
3996    fn local_install_does_not_resolve_npm_command() {
3997        struct RestoreEnv(Option<std::ffi::OsString>);
3998        impl Drop for RestoreEnv {
3999            fn drop(&mut self) {
4000                match self.0.take() {
4001                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4002                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4003                }
4004            }
4005        }
4006
4007        let _lock = crate::config::test_support::env_lock().lock().unwrap();
4008        let tmp = tempfile::tempdir().unwrap();
4009        let agent = tmp.path().join("agent");
4010        std::fs::create_dir_all(&agent).unwrap();
4011        std::fs::write(agent.join("settings.json"), r#"{"npmCommand":[""]}"#).unwrap();
4012        let _restore = RestoreEnv(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4013        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4014
4015        assert!(
4016            npm_command_for_install(tmp.path(), true, InstallSpecKind::Local)
4017                .unwrap()
4018                .is_none()
4019        );
4020        assert!(npm_command_for_install(tmp.path(), true, InstallSpecKind::Npm).is_err());
4021    }
4022
4023    #[test]
4024    fn uninstall_lookup_does_not_read_untrusted_project_settings() {
4025        struct RestoreEnv {
4026            previous: Option<std::ffi::OsString>,
4027        }
4028        impl Drop for RestoreEnv {
4029            fn drop(&mut self) {
4030                match self.previous.take() {
4031                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4032                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4033                }
4034            }
4035        }
4036
4037        let _lock = crate::config::test_support::env_lock().lock().unwrap();
4038        let tmp = tempfile::tempdir().unwrap();
4039        let agent = tmp.path().join("agent");
4040        let project = tmp.path().join("project");
4041        let external = tmp.path().join("external-package");
4042        std::fs::create_dir_all(project.join(".rpi")).unwrap();
4043        std::fs::create_dir_all(&external).unwrap();
4044        std::fs::write(external.join("package.json"), r#"{"name":"external-name"}"#).unwrap();
4045        std::fs::write(
4046            project.join(".rpi/settings.json"),
4047            serde_json::to_vec(&serde_json::json!({
4048                "packages": [{
4049                    "source": format!("file:{}", external.display()),
4050                    "autoload": false
4051                }]
4052            }))
4053            .unwrap(),
4054        )
4055        .unwrap();
4056        let _restore = RestoreEnv {
4057            previous: std::env::var_os(crate::config::CONFIG_DIR_ENV),
4058        };
4059        std::env::set_var(crate::config::CONFIG_DIR_ENV, agent);
4060
4061        let options = UninstallOptions {
4062            spec: "external-name".to_string(),
4063            global: false,
4064        };
4065        assert_eq!(find_installed_root(&project, &options), None);
4066    }
4067
4068    #[cfg(windows)]
4069    #[test]
4070    fn normalizes_windows_verbatim_drive_path() {
4071        assert_eq!(
4072            normalize_config_path(PathBuf::from(r"\\?\C:\packages\demo")),
4073            PathBuf::from(r"C:\packages\demo")
4074        );
4075    }
4076
4077    #[cfg(windows)]
4078    #[test]
4079    fn normalizes_windows_verbatim_unc_path() {
4080        assert_eq!(
4081            normalize_config_path(PathBuf::from(r"\\?\UNC\server\share\demo")),
4082            PathBuf::from(r"\\server\share\demo")
4083        );
4084    }
4085
4086    #[test]
4087    fn rejects_missing_spec() {
4088        assert!(parse_args(&[]).is_err());
4089    }
4090
4091    #[test]
4092    fn parses_uninstall_specs_and_flags() {
4093        let options = parse_uninstall_args(&["--global".into(), "npm:@scope/pkg".into()]).unwrap();
4094        assert!(options.global);
4095        assert_eq!(options.spec, "npm:@scope/pkg");
4096    }
4097
4098    #[test]
4099    fn rejects_multiple_uninstall_specs() {
4100        assert!(parse_uninstall_args(&["a".into(), "b".into()]).is_err());
4101    }
4102
4103    #[test]
4104    fn scoped_npm_update_replaces_the_supplied_root_in_place() {
4105        let tmp = tempfile::tempdir().unwrap();
4106        let root = tmp.path().join(".pi/npm/node_modules/@scope/pkg");
4107        std::fs::create_dir_all(&root).unwrap();
4108        std::fs::write(root.join("old.txt"), "old").unwrap();
4109        assert!(is_known_npm_package_target(&root));
4110
4111        let result = install_npm_at_with(
4112            &root,
4113            "npm:@scope/pkg@latest",
4114            "npm:@scope/pkg",
4115            true,
4116            &NpmCommand::from_argv(None).unwrap(),
4117            |stage, spec| {
4118                assert_eq!(spec, "@scope/pkg@latest");
4119                let staged = stage.join("node_modules/@scope/pkg");
4120                std::fs::create_dir_all(&staged).unwrap();
4121                std::fs::write(
4122                    staged.join("package.json"),
4123                    r#"{"name":"@scope/pkg","version":"1.0.0"}"#,
4124                )
4125                .unwrap();
4126                std::fs::write(staged.join("new.txt"), "new").unwrap();
4127                Ok(())
4128            },
4129        )
4130        .unwrap();
4131
4132        assert_eq!(result, root);
4133        assert!(root.join("new.txt").is_file());
4134        assert!(!root.join("old.txt").exists());
4135        assert!(!tmp
4136            .path()
4137            .join(".pi/npm/node_modules/@scope/scope__pkg")
4138            .exists());
4139        let marker: serde_json::Value = serde_json::from_str(
4140            &std::fs::read_to_string(root.join(".rpi-package-source.json")).unwrap(),
4141        )
4142        .unwrap();
4143        assert_eq!(marker["kind"], "npm");
4144        assert_eq!(marker["spec"], "npm:@scope/pkg");
4145    }
4146
4147    #[test]
4148    fn preparation_failure_leaves_existing_package_untouched() {
4149        let tmp = tempfile::tempdir().unwrap();
4150        let source = tmp.path().join("source");
4151        let target = tmp.path().join(".rpi/packages/demo");
4152        std::fs::create_dir_all(&source).unwrap();
4153        std::fs::create_dir_all(&target).unwrap();
4154        std::fs::write(source.join("new.txt"), "new").unwrap();
4155        std::fs::write(target.join("old.txt"), "old").unwrap();
4156
4157        let error = replace_dir_prepared(&source, &target, true, |_prepared| {
4158            Err("dependency install failed".to_string())
4159        })
4160        .unwrap_err();
4161
4162        assert_eq!(error, "dependency install failed");
4163        assert_eq!(
4164            std::fs::read_to_string(target.join("old.txt")).unwrap(),
4165            "old"
4166        );
4167        assert!(!target.join("new.txt").exists());
4168    }
4169
4170    #[test]
4171    fn stale_lock_file_does_not_block_a_new_advisory_lock() {
4172        let tmp = tempfile::tempdir().unwrap();
4173        let target = tmp.path().join("packages/demo");
4174        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
4175        let lock_path = target.parent().unwrap().join(".demo.rpi-update.lock");
4176        std::fs::write(&lock_path, "left by interrupted process").unwrap();
4177
4178        let lock = PackageSwapLock::acquire(&target).unwrap();
4179        drop(lock);
4180        assert!(lock_path.is_file());
4181        drop(PackageSwapLock::acquire(&target).unwrap());
4182    }
4183
4184    #[test]
4185    fn interrupted_swap_recovers_missing_target_and_cleans_stale_backup() {
4186        let tmp = tempfile::tempdir().unwrap();
4187        let target = tmp.path().join("packages/demo");
4188        let backup = tmp
4189            .path()
4190            .join("packages/.demo.rpi-backup-00000000000000000000000000000001");
4191        std::fs::create_dir_all(&backup).unwrap();
4192        std::fs::write(backup.join("old.txt"), "old").unwrap();
4193        std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4194
4195        recover_interrupted_swap(&target).unwrap();
4196        assert_eq!(
4197            std::fs::read_to_string(target.join("old.txt")).unwrap(),
4198            "old"
4199        );
4200        assert!(!backup.exists());
4201
4202        let stale_backup = tmp
4203            .path()
4204            .join("packages/.demo.rpi-backup-00000000000000000000000000000002");
4205        std::fs::create_dir_all(&stale_backup).unwrap();
4206        std::fs::write(stale_backup.join("stale.txt"), "stale").unwrap();
4207        recover_interrupted_swap(&target).unwrap();
4208        assert!(target.join("old.txt").is_file());
4209        assert!(!stale_backup.exists());
4210
4211        for suffix in ["manual", "1234", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"] {
4212            let unrelated = tmp
4213                .path()
4214                .join(format!("packages/.demo.rpi-backup-{suffix}"));
4215            std::fs::create_dir_all(&unrelated).unwrap();
4216            recover_interrupted_swap(&target).unwrap();
4217            assert!(unrelated.is_dir(), "suffix={suffix}");
4218        }
4219
4220        let absent = tmp.path().join("packages/absent");
4221        let manual = tmp.path().join("packages/.absent.rpi-backup-manual");
4222        std::fs::create_dir_all(&manual).unwrap();
4223        recover_interrupted_swap(&absent).unwrap();
4224        assert!(!absent.exists());
4225        assert!(manual.is_dir());
4226    }
4227
4228    #[test]
4229    fn managed_uninstall_root_requires_a_canonical_direct_store_child() {
4230        let tmp = tempfile::tempdir().unwrap();
4231        let root = tmp.path().join(".rpi/packages/demo");
4232        let nested = root.join("nested");
4233        let external = tmp.path().join("node_modules/demo");
4234        std::fs::create_dir_all(&nested).unwrap();
4235        std::fs::create_dir_all(&external).unwrap();
4236
4237        assert!(is_direct_package_below_store(
4238            &root,
4239            tmp.path(),
4240            Path::new(".rpi/packages")
4241        ));
4242        assert!(!is_direct_package_below_store(
4243            &nested,
4244            tmp.path(),
4245            Path::new(".rpi/packages")
4246        ));
4247        assert!(!is_direct_package_below_store(
4248            &external,
4249            tmp.path(),
4250            Path::new(".rpi/packages")
4251        ));
4252    }
4253
4254    #[test]
4255    fn git_metadata_rejects_gitdir_pointer_files() {
4256        let tmp = tempfile::tempdir().unwrap();
4257        let git_file = tmp.path().join(".git");
4258        std::fs::write(&git_file, "gitdir: ../outside/.git\n").unwrap();
4259        assert!(!is_real_git_metadata(&git_file));
4260        std::fs::remove_file(&git_file).unwrap();
4261        std::fs::create_dir(&git_file).unwrap();
4262        assert!(is_real_git_metadata(&git_file));
4263    }
4264
4265    #[test]
4266    fn explicit_git_update_revision_uses_exact_fetch_and_fetch_head() {
4267        let tmp = tempfile::tempdir().unwrap();
4268        let prepared = tmp.path().join("prepared");
4269        let origin = "https://github.com/example/repo.git";
4270        let revision = "refs/pull/42/head";
4271        let calls = std::cell::RefCell::new(Vec::new());
4272
4273        prepare_git_checkout_for_update_with(
4274            &prepared,
4275            origin,
4276            Some(revision),
4277            Some(revision),
4278            |actual_origin, selected, actual_prepared| {
4279                assert_eq!(actual_origin, origin);
4280                assert_eq!(selected, None);
4281                assert_eq!(actual_prepared, prepared);
4282                calls.borrow_mut().push("clone".to_string());
4283                Ok(())
4284            },
4285            |actual_prepared, actual_origin, selected| {
4286                assert_eq!(actual_prepared, prepared);
4287                assert_eq!(actual_origin, origin);
4288                assert_eq!(selected, revision);
4289                calls.borrow_mut().push("fetch-exact".to_string());
4290                Ok(())
4291            },
4292            |actual_prepared, selected| {
4293                assert_eq!(actual_prepared, prepared);
4294                assert_eq!(selected, "FETCH_HEAD");
4295                calls.borrow_mut().push("checkout-fetch-head".to_string());
4296                Ok(())
4297            },
4298            |actual_prepared, selected| {
4299                assert_eq!(actual_prepared, prepared);
4300                assert_eq!(selected, Some("FETCH_HEAD"));
4301                calls.borrow_mut().push("validate-fetch-head".to_string());
4302                Ok(())
4303            },
4304        )
4305        .unwrap();
4306
4307        assert_eq!(
4308            calls.into_inner(),
4309            [
4310                "clone",
4311                "fetch-exact",
4312                "checkout-fetch-head",
4313                "validate-fetch-head"
4314            ]
4315        );
4316    }
4317
4318    #[test]
4319    fn unpinned_git_update_keeps_the_selected_branch_in_the_clone() {
4320        let tmp = tempfile::tempdir().unwrap();
4321        let prepared = tmp.path().join("prepared");
4322        let origin = "https://github.com/example/repo.git";
4323
4324        prepare_git_checkout_for_update_with(
4325            &prepared,
4326            origin,
4327            None,
4328            Some("release/v2"),
4329            |actual_origin, selected, actual_prepared| {
4330                assert_eq!(actual_origin, origin);
4331                assert_eq!(selected, Some("release/v2"));
4332                assert_eq!(actual_prepared, prepared);
4333                Ok(())
4334            },
4335            |_, _, _| panic!("unpinned update must not perform an exact fetch"),
4336            |_, _| panic!("unpinned update must not checkout FETCH_HEAD"),
4337            |_, _| panic!("clone validates its own selected revision"),
4338        )
4339        .unwrap();
4340    }
4341
4342    #[test]
4343    fn staged_git_update_build_failure_preserves_the_installed_checkout() {
4344        let tmp = tempfile::tempdir().unwrap();
4345        let store = tmp.path().join("store");
4346        let target = store.join("github.com/example/repo");
4347        let origin = "https://github.com/example/repo.git";
4348        let old_head = initialize_test_git_package(&target, origin, "old");
4349        std::fs::write(target.join("old-only.txt"), "keep\n").unwrap();
4350        let command = NpmCommand::from_argv(Some(&["unused".to_string()])).unwrap();
4351
4352        let error = stage_git_checkout_update_with(
4353            &target,
4354            &store,
4355            origin,
4356            None,
4357            &command,
4358            |prepared, expected_origin, revision| {
4359                assert_eq!(expected_origin, origin);
4360                assert_eq!(revision, None);
4361                initialize_test_git_package(prepared, origin, "new");
4362                Ok(())
4363            },
4364            |_prepared, _| Err("dependency build failed".to_string()),
4365        )
4366        .unwrap_err();
4367
4368        assert_eq!(error, "dependency build failed");
4369        assert_eq!(run_test_git(&target, &["rev-parse", "HEAD"]), old_head);
4370        assert_eq!(
4371            std::fs::read_to_string(target.join("state.txt"))
4372                .unwrap()
4373                .replace("\r\n", "\n"),
4374            "old\n"
4375        );
4376        assert!(target.join("old-only.txt").is_file());
4377        assert!(package_backup_dirs(&target).unwrap().is_empty());
4378        assert!(!target.parent().unwrap().read_dir().unwrap().any(|entry| {
4379            entry
4380                .ok()
4381                .and_then(|entry| entry.file_name().to_str().map(str::to_string))
4382                .is_some_and(|name| name.starts_with(".rpi-git-update-"))
4383        }));
4384    }
4385
4386    #[test]
4387    fn staged_git_update_swaps_only_after_validation_and_cleans_legacy_marker() {
4388        let tmp = tempfile::tempdir().unwrap();
4389        let store = tmp.path().join("store");
4390        let target = store.join("github.com/example/repo");
4391        let origin = "https://github.com/example/repo.git";
4392        let old_head = initialize_test_git_package(&target, origin, "old");
4393        std::fs::write(target.join("old-only.txt"), "remove\n").unwrap();
4394        let marker = git_update_marker_path(&target).unwrap();
4395        std::fs::write(&marker, "legacy in-place update\n").unwrap();
4396        let command = NpmCommand::from_argv(Some(&["unused".to_string()])).unwrap();
4397        let mut staged_head = None;
4398
4399        stage_git_checkout_update_with(
4400            &target,
4401            &store,
4402            origin,
4403            None,
4404            &command,
4405            |prepared, _, revision| {
4406                assert_eq!(revision, None);
4407                staged_head = Some(initialize_test_git_package(prepared, origin, "new"));
4408                Ok(())
4409            },
4410            |prepared, _| {
4411                std::fs::create_dir_all(prepared.join("node_modules/demo")).unwrap();
4412                Ok(())
4413            },
4414        )
4415        .unwrap();
4416
4417        assert_ne!(staged_head.as_deref(), Some(old_head.as_str()));
4418        assert_eq!(
4419            run_test_git(&target, &["rev-parse", "HEAD"]),
4420            staged_head.unwrap()
4421        );
4422        assert_eq!(
4423            std::fs::read_to_string(target.join("state.txt"))
4424                .unwrap()
4425                .replace("\r\n", "\n"),
4426            "new\n"
4427        );
4428        assert!(!target.join("old-only.txt").exists());
4429        assert!(target.join("node_modules/demo").is_dir());
4430        assert!(!marker.exists());
4431        assert!(package_backup_dirs(&target).unwrap().is_empty());
4432    }
4433
4434    #[test]
4435    fn git_swap_activation_failure_restores_the_previous_checkout() {
4436        let tmp = tempfile::tempdir().unwrap();
4437        let target = tmp.path().join("store/github.com/example/repo");
4438        let origin = "https://github.com/example/repo.git";
4439        let old_head = initialize_test_git_package(&target, origin, "old");
4440        let missing_prepared = target.parent().unwrap().join("missing-prepared");
4441
4442        let error = swap_prepared_dir(&missing_prepared, &target, true).unwrap_err();
4443
4444        assert!(error.contains("could not activate replacement"), "{error}");
4445        assert_eq!(run_test_git(&target, &["rev-parse", "HEAD"]), old_head);
4446        assert_eq!(
4447            std::fs::read_to_string(target.join("state.txt"))
4448                .unwrap()
4449                .replace("\r\n", "\n"),
4450            "old\n"
4451        );
4452        assert!(package_backup_dirs(&target).unwrap().is_empty());
4453    }
4454
4455    #[test]
4456    fn git_update_origin_gate_preserves_fixed_ref_for_a_matching_checkout() {
4457        fn git(cwd: &Path, args: &[&str]) {
4458            let status = Command::new("git")
4459                .args(args)
4460                .current_dir(cwd)
4461                .status()
4462                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4463            assert!(status.success(), "git {:?} exited with {status}", args);
4464        }
4465
4466        let tmp = tempfile::tempdir().unwrap();
4467        let store = tmp.path().join("store");
4468        let target = store.join("github.com/example/repo");
4469        std::fs::create_dir_all(&target).unwrap();
4470        git(&target, &["init", "--initial-branch", "main"]);
4471        git(
4472            &target,
4473            &[
4474                "remote",
4475                "add",
4476                "origin",
4477                "https://github.com/example/repo.git",
4478            ],
4479        );
4480
4481        let command = NpmCommand::from_argv(Some(&["unused".to_string()])).unwrap();
4482        let called = std::cell::Cell::new(false);
4483        update_git_package_with(
4484            &target,
4485            &store,
4486            "git:github.com/example/repo@release/v2",
4487            &command,
4488            |actual_root, origin, revision, _| {
4489                called.set(true);
4490                assert_eq!(actual_root, target);
4491                assert_eq!(origin, "https://github.com/example/repo.git");
4492                assert_eq!(revision, Some("release/v2"));
4493                Ok(())
4494            },
4495        )
4496        .unwrap();
4497        assert!(called.get());
4498    }
4499
4500    #[test]
4501    fn git_update_origin_mismatch_stops_before_network_or_checkout_changes() {
4502        fn git(cwd: &Path, args: &[&str]) -> String {
4503            let output = Command::new("git")
4504                .args(args)
4505                .current_dir(cwd)
4506                .output()
4507                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4508            assert!(
4509                output.status.success(),
4510                "git {:?} exited with {}",
4511                args,
4512                output.status
4513            );
4514            String::from_utf8_lossy(&output.stdout).trim().to_string()
4515        }
4516
4517        let tmp = tempfile::tempdir().unwrap();
4518        let store = tmp.path().join("store");
4519        let target = store.join("github.com/example/repo");
4520        std::fs::create_dir_all(&target).unwrap();
4521        git(&target, &["init", "--initial-branch", "main"]);
4522        git(
4523            &target,
4524            &["config", "user.email", "rpi-test@example.invalid"],
4525        );
4526        git(&target, &["config", "user.name", "rpi-test"]);
4527        std::fs::write(target.join("version.txt"), "unchanged\n").unwrap();
4528        git(&target, &["add", "version.txt"]);
4529        git(&target, &["commit", "-m", "initial"]);
4530        let head = git(&target, &["rev-parse", "HEAD"]);
4531
4532        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
4533        listener.set_nonblocking(true).unwrap();
4534        let origin = format!(
4535            "http://127.0.0.1:{}/attacker/repo.git",
4536            listener.local_addr().unwrap().port()
4537        );
4538        git(&target, &["remote", "add", "origin", &origin]);
4539
4540        let command = NpmCommand::from_argv(Some(&["unused".to_string()])).unwrap();
4541        let called = std::cell::Cell::new(false);
4542        let error = update_git_package_with(
4543            &target,
4544            &store,
4545            "git:github.com/example/repo@main",
4546            &command,
4547            |_, _, _, _| {
4548                called.set(true);
4549                Ok(())
4550            },
4551        )
4552        .unwrap_err();
4553
4554        assert!(error.contains("origin does not match"));
4555        assert!(!called.get());
4556        assert_eq!(git(&target, &["rev-parse", "HEAD"]), head);
4557        assert_eq!(
4558            std::fs::read_to_string(target.join("version.txt")).unwrap(),
4559            "unchanged\n"
4560        );
4561        assert_eq!(
4562            listener.accept().unwrap_err().kind(),
4563            std::io::ErrorKind::WouldBlock
4564        );
4565    }
4566
4567    #[test]
4568    fn git_update_origin_gate_rejects_missing_duplicate_and_dangerous_urls() {
4569        fn git(cwd: &Path, args: &[&str]) {
4570            let status = Command::new("git")
4571                .args(args)
4572                .current_dir(cwd)
4573                .status()
4574                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4575            assert!(status.success(), "git {:?} exited with {status}", args);
4576        }
4577
4578        let tmp = tempfile::tempdir().unwrap();
4579        let checkout = tmp.path().join("checkout");
4580        std::fs::create_dir_all(&checkout).unwrap();
4581        git(&checkout, &["init", "--initial-branch", "main"]);
4582        let expected = crate::packages::parse_git_source("git:github.com/example/repo").unwrap();
4583
4584        assert!(validated_git_origin(&checkout, &expected).is_err());
4585
4586        git(
4587            &checkout,
4588            &[
4589                "config",
4590                "--add",
4591                "remote.origin.url",
4592                "https://github.com/example/repo.git",
4593            ],
4594        );
4595        git(
4596            &checkout,
4597            &[
4598                "config",
4599                "--add",
4600                "remote.origin.url",
4601                "git@github.com:example/repo.git",
4602            ],
4603        );
4604        assert!(validated_git_origin(&checkout, &expected).is_err());
4605
4606        git(&checkout, &["config", "--unset-all", "remote.origin.url"]);
4607        git(
4608            &checkout,
4609            &["config", "remote.origin.url", "ext::sh -c 'echo unsafe'"],
4610        );
4611        assert!(validated_git_origin(&checkout, &expected).is_err());
4612
4613        git(
4614            &checkout,
4615            &[
4616                "config",
4617                "remote.origin.url",
4618                "https://github.com/example/repo.git ",
4619            ],
4620        );
4621        assert!(validated_git_origin(&checkout, &expected).is_err());
4622
4623        for mismatched in [
4624            "http://github.com/example/repo.git",
4625            "https://github.com:444/example/repo.git",
4626            "https://other-user@github.com/example/repo.git",
4627        ] {
4628            git(&checkout, &["config", "remote.origin.url", mismatched]);
4629            let error = validated_git_origin(&checkout, &expected).unwrap_err();
4630            assert!(
4631                error.contains("origin does not match"),
4632                "origin={mismatched}"
4633            );
4634        }
4635
4636        git(
4637            &checkout,
4638            &[
4639                "config",
4640                "remote.origin.url",
4641                "https://github.com:443/example/repo.git",
4642            ],
4643        );
4644        assert_eq!(
4645            validated_git_origin(&checkout, &expected).unwrap(),
4646            "https://github.com:443/example/repo.git"
4647        );
4648    }
4649
4650    #[test]
4651    fn git_update_rejects_external_core_worktree_before_fetch_reset_or_npm() {
4652        fn git(cwd: &Path, args: &[&str]) {
4653            let status = Command::new("git")
4654                .args(args)
4655                .current_dir(cwd)
4656                .status()
4657                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4658            assert!(status.success(), "git {:?} exited with {status}", args);
4659        }
4660
4661        let tmp = tempfile::tempdir().unwrap();
4662        let store = tmp.path().join("store");
4663        let target = store.join("github.com/example/repo");
4664        let outside = tmp.path().join("outside-worktree");
4665        std::fs::create_dir_all(&target).unwrap();
4666        std::fs::create_dir_all(&outside).unwrap();
4667        let sentinel = outside.join("must-survive.txt");
4668        std::fs::write(&sentinel, "unchanged\n").unwrap();
4669        git(&target, &["init", "--initial-branch", "main"]);
4670        git(
4671            &target,
4672            &[
4673                "remote",
4674                "add",
4675                "origin",
4676                "https://github.com/example/repo.git",
4677            ],
4678        );
4679        git(
4680            &target,
4681            &["config", "core.worktree", outside.to_str().unwrap()],
4682        );
4683
4684        let command = NpmCommand::from_argv(Some(&["must-not-run".to_string()])).unwrap();
4685        let called = std::cell::Cell::new(false);
4686        let error = update_git_package_with(
4687            &target,
4688            &store,
4689            "git:github.com/example/repo",
4690            &command,
4691            |_, _, _, _| {
4692                called.set(true);
4693                Ok(())
4694            },
4695        )
4696        .unwrap_err();
4697
4698        assert!(error.contains("unsafe local configuration"), "{error}");
4699        assert!(!called.get());
4700        assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "unchanged\n");
4701    }
4702
4703    #[test]
4704    fn git_update_origin_gate_rejects_command_capable_local_config() {
4705        fn git(cwd: &Path, args: &[&str]) {
4706            let status = Command::new("git")
4707                .args(args)
4708                .current_dir(cwd)
4709                .status()
4710                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4711            assert!(status.success(), "git {:?} exited with {status}", args);
4712        }
4713
4714        let expected = crate::packages::parse_git_source("git:github.com/example/repo").unwrap();
4715        for (key, value) in [
4716            ("url.https://evil.invalid/.insteadOf", "https://github.com/"),
4717            ("core.sshCommand", "malicious-ssh"),
4718            ("credential.helper", "!malicious-helper"),
4719            ("http.https://github.com.proxy", "http://evil.invalid"),
4720            ("http.curloptResolve", "github.com:443:127.0.0.1"),
4721            (
4722                "http.https://github.com.curloptResolve",
4723                "github.com:443:127.0.0.1",
4724            ),
4725            ("fetch.bundleURI", "file:///outside/repository.bundle"),
4726            ("include.path", "../untrusted-config"),
4727            ("filter.package.process", "malicious-filter"),
4728        ] {
4729            let tmp = tempfile::tempdir().unwrap();
4730            let checkout = tmp.path().join("checkout");
4731            std::fs::create_dir_all(&checkout).unwrap();
4732            git(&checkout, &["init", "--initial-branch", "main"]);
4733            git(
4734                &checkout,
4735                &[
4736                    "remote",
4737                    "add",
4738                    "origin",
4739                    "https://github.com/example/repo.git",
4740                ],
4741            );
4742            git(&checkout, &["config", key, value]);
4743
4744            let error = validated_git_origin(&checkout, &expected).unwrap_err();
4745            assert!(error.contains("unsafe local configuration"), "key={key}");
4746        }
4747    }
4748
4749    #[test]
4750    fn hardened_git_command_drops_environment_config_and_command_injection() {
4751        let tmp = tempfile::tempdir().unwrap();
4752        let status = Command::new("git")
4753            .args(["init", "--initial-branch", "main"])
4754            .current_dir(tmp.path())
4755            .status()
4756            .unwrap();
4757        assert!(status.success());
4758
4759        let injected_key = "url.https://attacker.invalid/.insteadOf";
4760        let mut command = Command::new("git");
4761        command
4762            .env("GIT_CONFIG_COUNT", "1")
4763            .env("GIT_CONFIG_KEY_0", injected_key)
4764            .env("GIT_CONFIG_VALUE_0", "https://github.com/")
4765            .env("GIT_SSH_COMMAND", "malicious-ssh");
4766        apply_hardened_git_environment(&mut command);
4767        let ssh_command = command
4768            .get_envs()
4769            .find(|(name, _)| *name == std::ffi::OsStr::new("GIT_SSH_COMMAND"));
4770        assert!(ssh_command.is_some_and(|(_, value)| value.is_none()));
4771
4772        let output = command
4773            .args(hardened_git_network_config_args())
4774            .args(["config", "--get-all", injected_key])
4775            .current_dir(tmp.path())
4776            .output()
4777            .unwrap();
4778        assert!(!output.status.success());
4779        assert!(output.stdout.is_empty());
4780    }
4781
4782    #[test]
4783    fn hardened_git_config_allows_only_known_credential_helpers() {
4784        for helper in [
4785            "cache",
4786            "libsecret",
4787            "manager",
4788            "manager-core",
4789            "osxkeychain",
4790            "store",
4791            "wincred",
4792        ] {
4793            assert!(is_safe_standard_credential_helper(helper));
4794        }
4795        for helper in [
4796            "!malicious-helper",
4797            "manager --arg",
4798            "/tmp/helper",
4799            "custom",
4800            "",
4801        ] {
4802            assert!(!is_safe_standard_credential_helper(helper));
4803        }
4804
4805        let args = hardened_git_network_config_args();
4806        let values = args
4807            .chunks_exact(2)
4808            .map(|pair| pair[1].as_str())
4809            .collect::<Vec<_>>();
4810        assert!(values.contains(&"protocol.allow=never"));
4811        assert!(values.contains(&"protocol.ext.allow=never"));
4812        assert!(values.contains(&"protocol.file.allow=never"));
4813        assert!(values.contains(&"credential.helper="));
4814        assert!(values.iter().all(|value| {
4815            value
4816                .strip_prefix("credential.helper=")
4817                .map_or(true, |helper| {
4818                    helper.is_empty() || is_safe_standard_credential_helper(helper)
4819                })
4820        }));
4821    }
4822
4823    #[test]
4824    fn cloned_checkout_validation_requires_exact_origin_and_valid_head() {
4825        fn git(cwd: &Path, args: &[&str]) {
4826            let status = Command::new("git")
4827                .args(args)
4828                .current_dir(cwd)
4829                .status()
4830                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4831            assert!(status.success(), "git {:?} exited with {status}", args);
4832        }
4833
4834        let tmp = tempfile::tempdir().unwrap();
4835        let checkout = tmp.path().join("checkout");
4836        std::fs::create_dir_all(&checkout).unwrap();
4837        git(&checkout, &["init", "--initial-branch", "main"]);
4838        git(
4839            &checkout,
4840            &["config", "user.email", "rpi-test@example.invalid"],
4841        );
4842        git(&checkout, &["config", "user.name", "rpi-test"]);
4843        std::fs::write(checkout.join("version.txt"), "one\n").unwrap();
4844        git(&checkout, &["add", "version.txt"]);
4845        git(&checkout, &["commit", "-m", "one"]);
4846        let requested = "https://github.com/example/repo.git";
4847        git(&checkout, &["remote", "add", "origin", requested]);
4848        let expected = crate::packages::parse_git_source(requested).unwrap();
4849
4850        validate_cloned_git_origin(&checkout, requested, &expected).unwrap();
4851        validate_git_head(&checkout, None).unwrap();
4852
4853        git(
4854            &checkout,
4855            &[
4856                "remote",
4857                "set-url",
4858                "origin",
4859                "ssh://git@github.com/example/repo.git",
4860            ],
4861        );
4862        let error = validate_cloned_git_origin(&checkout, requested, &expected).unwrap_err();
4863        assert!(error.contains("origin does not match"), "{error}");
4864    }
4865
4866    #[test]
4867    fn full_clone_can_checkout_a_non_default_branch() {
4868        fn git(cwd: &Path, args: &[&str]) {
4869            let status = Command::new("git")
4870                .args(args)
4871                .current_dir(cwd)
4872                .status()
4873                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4874            assert!(status.success(), "git {:?} exited with {status}", args);
4875        }
4876
4877        let temp = tempfile::tempdir().unwrap();
4878        let source = temp.path().join("source");
4879        let checkout = temp.path().join("checkout");
4880        std::fs::create_dir_all(&source).unwrap();
4881        git(&source, &["init", "--initial-branch", "main"]);
4882        git(
4883            &source,
4884            &["config", "user.email", "rpi-test@example.invalid"],
4885        );
4886        git(&source, &["config", "user.name", "rpi-test"]);
4887        std::fs::write(source.join("version.txt"), "main\n").unwrap();
4888        git(&source, &["add", "version.txt"]);
4889        git(&source, &["commit", "-m", "main"]);
4890        git(&source, &["checkout", "-b", "feature/branch"]);
4891        std::fs::write(source.join("version.txt"), "feature\n").unwrap();
4892        git(&source, &["add", "version.txt"]);
4893        git(&source, &["commit", "-m", "feature"]);
4894        git(&source, &["checkout", "main"]);
4895
4896        git(
4897            temp.path(),
4898            &[
4899                "-c",
4900                "core.autocrlf=false",
4901                "clone",
4902                source.to_str().unwrap(),
4903                checkout.to_str().unwrap(),
4904            ],
4905        );
4906        checkout_git_revision(&checkout, "feature/branch").unwrap();
4907        validate_git_head(&checkout, Some("feature/branch")).unwrap();
4908        assert_eq!(
4909            std::fs::read_to_string(checkout.join("version.txt"))
4910                .unwrap()
4911                .replace("\r\n", "\n"),
4912            "feature\n"
4913        );
4914        assert_eq!(
4915            run_git_capture(&checkout, &["branch", "--show-current"]).unwrap(),
4916            "feature/branch"
4917        );
4918    }
4919
4920    #[cfg(unix)]
4921    #[test]
4922    fn managed_uninstall_root_rejects_symlinked_store_escape() {
4923        let tmp = tempfile::tempdir().unwrap();
4924        let external_store = tmp.path().join("node_modules");
4925        let external_package = external_store.join("demo");
4926        std::fs::create_dir_all(&external_package).unwrap();
4927        std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
4928        std::os::unix::fs::symlink(&external_store, tmp.path().join(".rpi/packages")).unwrap();
4929
4930        assert!(!is_managed_package_root(tmp.path(), &external_package));
4931    }
4932
4933    #[cfg(unix)]
4934    #[test]
4935    fn npm_mutation_rejects_symlinked_target_escape() {
4936        let tmp = tempfile::tempdir().unwrap();
4937        let root = tmp.path().join("npm");
4938        let external = tmp.path().join("external/demo");
4939        std::fs::create_dir_all(root.join("node_modules")).unwrap();
4940        std::fs::create_dir_all(&external).unwrap();
4941        std::os::unix::fs::symlink(&external, root.join("node_modules/demo")).unwrap();
4942
4943        let error =
4944            validate_npm_target_before_mutation(&root, Path::new("demo"), false).unwrap_err();
4945        assert!(error.contains("outside its managed root"), "{error}");
4946    }
4947
4948    #[cfg(windows)]
4949    #[test]
4950    fn managed_uninstall_root_rejects_symlinked_store_escape() {
4951        let tmp = tempfile::tempdir().unwrap();
4952        let external_store = tmp.path().join("node_modules");
4953        let external_package = external_store.join("demo");
4954        std::fs::create_dir_all(&external_package).unwrap();
4955        std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
4956        if std::os::windows::fs::symlink_dir(&external_store, tmp.path().join(".rpi/packages"))
4957            .is_err()
4958        {
4959            return;
4960        }
4961
4962        assert!(!is_managed_package_root(tmp.path(), &external_package));
4963    }
4964
4965    #[cfg(windows)]
4966    #[test]
4967    fn npm_mutation_rejects_symlinked_target_escape() {
4968        let tmp = tempfile::tempdir().unwrap();
4969        let root = tmp.path().join("npm");
4970        let external = tmp.path().join("external/demo");
4971        std::fs::create_dir_all(root.join("node_modules")).unwrap();
4972        std::fs::create_dir_all(&external).unwrap();
4973        if std::os::windows::fs::symlink_dir(&external, root.join("node_modules/demo")).is_err() {
4974            return;
4975        }
4976
4977        let error =
4978            validate_npm_target_before_mutation(&root, Path::new("demo"), false).unwrap_err();
4979        assert!(error.contains("outside its managed root"), "{error}");
4980    }
4981}