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    if global {
118        return Ok(false);
119    }
120    crate::config::project_trust_decision(cwd)
121        .map(|decision| decision.unwrap_or(true))
122        .map_err(|error| format!("could not read project trust decision: {error}"))
123}
124
125fn classify_install_spec(cwd: &Path, spec: &str) -> InstallSpecKind {
126    if is_git_install_spec(spec) {
127        return InstallSpecKind::Git;
128    }
129    let local_candidate = if Path::new(spec).is_absolute() {
130        PathBuf::from(spec)
131    } else {
132        cwd.join(spec)
133    };
134    if spec.starts_with("npm:") || (!local_candidate.exists() && looks_like_npm(spec)) {
135        InstallSpecKind::Npm
136    } else {
137        InstallSpecKind::Local
138    }
139}
140
141fn npm_command_for_install(
142    cwd: &Path,
143    project_trusted: bool,
144    kind: InstallSpecKind,
145) -> Result<Option<NpmCommand>, String> {
146    if kind == InstallSpecKind::Local {
147        Ok(None)
148    } else {
149        NpmCommand::resolve(cwd, project_trusted).map(Some)
150    }
151}
152
153fn parse_args(args: &[String]) -> Result<Options, String> {
154    let mut spec = None;
155    let mut global = false;
156    let mut force = false;
157    let mut i = 0;
158    while i < args.len() {
159        match args[i].as_str() {
160            "--help" | "-h" => return Err("help".into()),
161            "--global" | "-g" => global = true,
162            "--force" | "-f" => force = true,
163            value if value.starts_with('-') => {
164                return Err(format!("unknown install-pi option `{value}`"))
165            }
166            value => {
167                if spec.replace(value.to_string()).is_some() {
168                    return Err("install-pi accepts exactly one package spec".into());
169                }
170            }
171        }
172        i += 1;
173    }
174    Ok(Options {
175        spec: spec.ok_or_else(|| "missing npm, git, or local package spec".to_string())?,
176        global,
177        force,
178    })
179}
180
181/// Create a managed store one directory at a time and reject every symlink,
182/// junction, or other canonical redirection in its ancestry. This check runs
183/// before any package manager, clone, copy, or deletion touches the store.
184fn prepare_managed_destination(destination: &Path) -> Result<(), String> {
185    if !destination.is_absolute() {
186        return Err(format!(
187            "refusing non-absolute package destination {}",
188            destination.display()
189        ));
190    }
191    ensure_real_directory_tree(destination)
192}
193
194fn ensure_real_directory_tree(path: &Path) -> Result<(), String> {
195    if path.parent().is_some_and(|parent| parent != path) {
196        if !path.exists() {
197            let parent = path
198                .parent()
199                .ok_or_else(|| format!("directory has no parent: {}", path.display()))?;
200            ensure_real_directory_tree(parent)?;
201            match std::fs::create_dir(path) {
202                Ok(()) => {}
203                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
204                Err(error) => {
205                    return Err(format!(
206                        "could not create managed package directory {}: {error}",
207                        path.display()
208                    ));
209                }
210            }
211        }
212    }
213    let metadata = std::fs::symlink_metadata(path).map_err(|error| {
214        format!(
215            "could not inspect managed package directory {}: {error}",
216            path.display()
217        )
218    })?;
219    if !metadata.is_dir() || metadata.file_type().is_symlink() {
220        return Err(format!(
221            "refusing redirected package destination {}",
222            path.display()
223        ));
224    }
225    let canonical = std::fs::canonicalize(path)
226        .map(normalize_config_path)
227        .map_err(|error| {
228            format!(
229                "could not resolve managed package directory {}: {error}",
230                path.display()
231            )
232        })?;
233    if !paths_equal(&canonical, path) {
234        return Err(format!(
235            "refusing redirected package destination {}",
236            path.display()
237        ));
238    }
239    Ok(())
240}
241
242fn validate_install_target(destination: &Path, target: &Path) -> Result<(), String> {
243    ensure_real_directory_tree(destination)?;
244    if target.parent() != Some(destination) {
245        return Err(format!(
246            "refusing package target outside managed destination: {}",
247            target.display()
248        ));
249    }
250    let Some(leaf) = target.file_name().and_then(|name| name.to_str()) else {
251        return Err("package target must have a valid file name".to_string());
252    };
253    if matches!(leaf, "" | "." | "..") || leaf.starts_with('.') {
254        return Err(format!("refusing invalid package target name `{leaf}`"));
255    }
256    match std::fs::symlink_metadata(target) {
257        Ok(metadata) => {
258            if !metadata.is_dir() || metadata.file_type().is_symlink() {
259                return Err(format!(
260                    "refusing redirected package target {}",
261                    target.display()
262                ));
263            }
264            let canonical = std::fs::canonicalize(target)
265                .map(normalize_config_path)
266                .map_err(|error| format!("could not resolve {}: {error}", target.display()))?;
267            if !paths_equal(&canonical, target) {
268                return Err(format!(
269                    "refusing redirected package target {}",
270                    target.display()
271                ));
272            }
273        }
274        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
275        Err(error) => {
276            return Err(format!(
277                "could not inspect package target {}: {error}",
278                target.display()
279            ));
280        }
281    }
282    Ok(())
283}
284
285fn install_spec(
286    cwd: &Path,
287    options: &Options,
288    kind: InstallSpecKind,
289    npm_command: Option<&NpmCommand>,
290) -> Result<InstallOutcome, String> {
291    let spec = options.spec.as_str();
292    if kind == InstallSpecKind::Git {
293        let npm_command = npm_command.ok_or("missing package manager for git installation")?;
294        let root = install_git(cwd, options.global, spec, options.force, npm_command)?;
295        return Ok(InstallOutcome {
296            root,
297            settings_source: Some(spec.trim().to_string()),
298        });
299    }
300    if kind == InstallSpecKind::Npm {
301        let npm_command = npm_command.ok_or("missing package manager for npm installation")?;
302        let npm_spec = npm_settings_spec(spec);
303        let root = install_managed_npm(cwd, options.global, &npm_spec, options.force, npm_command)?;
304        return Ok(InstallOutcome {
305            root,
306            settings_source: Some(npm_spec),
307        });
308    }
309    let root = install_local(cwd, spec)?;
310    Ok(InstallOutcome {
311        settings_source: Some(settings_entry(&root, cwd, options.global)?),
312        root,
313    })
314}
315
316fn is_git_install_spec(spec: &str) -> bool {
317    let spec = spec.trim().to_ascii_lowercase();
318    spec.starts_with("git:")
319        || spec.starts_with("https://")
320        || spec.starts_with("http://")
321        || spec.starts_with("ssh://")
322        || spec.starts_with("git://")
323}
324
325fn npm_settings_spec(spec: &str) -> String {
326    format!("npm:{}", spec.strip_prefix("npm:").unwrap_or(spec).trim())
327}
328
329fn load_settings_for_install(
330    cwd: &Path,
331    global: bool,
332) -> Result<crate::settings::Settings, crate::config::ConfigError> {
333    if global {
334        crate::settings::load_settings()
335    } else {
336        crate::settings::load_project_settings_for_write(cwd)
337    }
338}
339
340fn save_settings_for_install(
341    cwd: &Path,
342    global: bool,
343    settings: &crate::settings::Settings,
344) -> Result<(), String> {
345    if global {
346        crate::settings::save_settings(settings)
347    } else {
348        crate::settings::save_project_settings(cwd, settings)
349    }
350}
351
352fn settings_entry(root: &Path, cwd: &Path, global: bool) -> Result<String, String> {
353    let base = if global {
354        crate::config::agent_dir().map_err(|error| error.to_string())?
355    } else {
356        cwd.join(".rpi")
357    };
358    let relative = relative_path_from(root, &base).ok_or_else(|| {
359        format!(
360            "package root {} cannot be represented from its settings directory {}",
361            root.display(),
362            base.display()
363        )
364    })?;
365    Ok(format!(
366        "file:{}",
367        relative.to_string_lossy().replace('\\', "/")
368    ))
369}
370
371fn relative_path_from(path: &Path, base: &Path) -> Option<PathBuf> {
372    use std::path::Component;
373
374    let path = normalize_config_path(path.to_path_buf());
375    let base = normalize_config_path(base.to_path_buf());
376    if !path.is_absolute() || !base.is_absolute() {
377        return None;
378    }
379    let path_components = path.components().collect::<Vec<_>>();
380    let base_components = base.components().collect::<Vec<_>>();
381    let mut common = 0;
382    while common < path_components.len()
383        && common < base_components.len()
384        && path_components_equal(path_components[common], base_components[common])
385    {
386        common += 1;
387    }
388    if common == 0 {
389        return None;
390    }
391
392    let mut relative = PathBuf::new();
393    for component in &base_components[common..] {
394        match component {
395            Component::Normal(_) => relative.push(".."),
396            Component::CurDir => {}
397            Component::ParentDir | Component::Prefix(_) | Component::RootDir => return None,
398        }
399    }
400    for component in &path_components[common..] {
401        match component {
402            Component::Prefix(_) | Component::RootDir => return None,
403            _ => relative.push(component.as_os_str()),
404        }
405    }
406    if relative.as_os_str().is_empty() {
407        relative.push(".");
408    }
409    Some(relative)
410}
411
412fn path_components_equal(left: std::path::Component<'_>, right: std::path::Component<'_>) -> bool {
413    if cfg!(windows) {
414        left.as_os_str()
415            .to_string_lossy()
416            .eq_ignore_ascii_case(&right.as_os_str().to_string_lossy())
417    } else {
418        left == right
419    }
420}
421
422fn enable_settings_entry(
423    cwd: &Path,
424    packages: &mut Vec<crate::settings::PackageSetting>,
425    enabled: String,
426) -> bool {
427    if let Some(existing) = packages
428        .iter_mut()
429        .find(|value| package_settings_sources_match(cwd, value.source(), &enabled))
430    {
431        if existing.source() == enabled {
432            return false;
433        }
434        match existing {
435            crate::settings::PackageSetting::Source(source) => *source = enabled,
436            crate::settings::PackageSetting::Filtered(filter) => filter.source = enabled,
437        }
438        return true;
439    }
440    packages.push(enabled.into());
441    true
442}
443
444fn package_name(spec: &str) -> String {
445    if let Some(parsed) = crate::packages::parse_npm_package_spec(spec) {
446        return parsed
447            .install_name
448            .trim_start_matches('@')
449            .replace('/', "__")
450            .replace('\\', "__");
451    }
452    let raw = spec.strip_prefix("npm:").unwrap_or(spec);
453    let raw = if raw.starts_with('@') {
454        raw.rfind('@')
455            .filter(|index| *index > 0)
456            .map(|index| &raw[..index])
457            .unwrap_or(raw)
458    } else {
459        raw.split('@').next().unwrap_or(raw)
460    };
461    raw.trim_start_matches('@')
462        .replace('/', "__")
463        .replace('\\', "__")
464}
465
466fn safe_name(spec: &str) -> String {
467    package_name(spec)
468        .chars()
469        .map(|ch| {
470            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
471                ch
472            } else {
473                '-'
474            }
475        })
476        .collect()
477}
478
479fn install_managed_npm(
480    cwd: &Path,
481    global: bool,
482    spec: &str,
483    force: bool,
484    npm_command: &NpmCommand,
485) -> Result<PathBuf, String> {
486    let parsed = npm_package_spec_checked(spec)?;
487    let name = PathBuf::from(&parsed.install_name);
488    let root = managed_npm_root(cwd, global)?;
489    validate_recognized_npm_store_root(&root, cwd, true)?;
490    let target = root.join("node_modules").join(&name);
491    if let Ok(metadata) = std::fs::symlink_metadata(&target) {
492        if !force {
493            return Err(format!(
494                "{} already exists; use --force to reinstall it",
495                target.display()
496            ));
497        }
498        if !metadata.is_dir() && !metadata.file_type().is_symlink() {
499            return Err(format!(
500                "refusing to replace non-directory npm target {}",
501                target.display()
502            ));
503        }
504    }
505    let source = spec.strip_prefix("npm:").unwrap_or(spec).trim();
506    update_npm_store_root(
507        &root,
508        &[(
509            name.to_string_lossy().replace('\\', "/"),
510            format!("npm:{source}"),
511        )],
512        npm_command,
513        cwd,
514        true,
515    )?;
516    validate_managed_npm_package(&root, &target, &name, &parsed.manifest_name)?;
517    Ok(target)
518}
519
520fn managed_npm_root(cwd: &Path, global: bool) -> Result<PathBuf, String> {
521    if global {
522        crate::config::agent_dir()
523            .map(|agent| agent.join("npm"))
524            .map_err(|error| error.to_string())
525    } else {
526        Ok(cwd.join(".pi/npm"))
527    }
528}
529
530fn validate_managed_npm_package(
531    install_root: &Path,
532    target: &Path,
533    relative_name: &Path,
534    manifest_name: &str,
535) -> Result<(), String> {
536    let canonical_root = std::fs::canonicalize(install_root)
537        .map(normalize_config_path)
538        .map_err(|error| {
539            format!(
540                "could not resolve npm root {}: {error}",
541                install_root.display()
542            )
543        })?;
544    let expected = install_root.join("node_modules").join(relative_name);
545    if !paths_equal(&expected, target) {
546        return Err(format!(
547            "refusing unexpected npm package target {}",
548            target.display()
549        ));
550    }
551    let canonical_target = std::fs::canonicalize(target)
552        .map(normalize_config_path)
553        .map_err(|error| format!("npm did not install {}: {error}", target.display()))?;
554    let manifest = target.join("package.json");
555    let canonical_manifest = std::fs::canonicalize(&manifest)
556        .map(normalize_config_path)
557        .map_err(|error| format!("npm did not install {}: {error}", manifest.display()))?;
558    if !path_is_within(&canonical_target, &canonical_root)
559        || !path_is_within(&canonical_manifest, &canonical_target)
560        || !manifest.is_file()
561    {
562        return Err(format!(
563            "npm installed an unsafe package target {}",
564            target.display()
565        ));
566    }
567    validate_npm_manifest_name(&manifest, manifest_name)?;
568    Ok(())
569}
570
571fn validate_npm_manifest_name(manifest: &Path, expected: &str) -> Result<(), String> {
572    let text = std::fs::read_to_string(manifest).map_err(|error| {
573        format!(
574            "could not read npm manifest {}: {error}",
575            manifest.display()
576        )
577    })?;
578    let value: serde_json::Value = serde_json::from_str(&text).map_err(|error| {
579        format!(
580            "could not parse npm manifest {}: {error}",
581            manifest.display()
582        )
583    })?;
584    let actual = value
585        .get("name")
586        .and_then(serde_json::Value::as_str)
587        .ok_or_else(|| format!("npm manifest {} has no package name", manifest.display()))?;
588    if actual != expected {
589        return Err(format!(
590            "npm installed manifest name `{actual}` does not match expected package `{expected}`"
591        ));
592    }
593    Ok(())
594}
595
596fn install_npm_at(
597    target: &Path,
598    install_spec: &str,
599    source_spec: &str,
600    force: bool,
601    npm_command: &NpmCommand,
602) -> Result<PathBuf, String> {
603    validate_standalone_npm_manager(npm_command)?;
604    install_npm_at_with(
605        target,
606        install_spec,
607        source_spec,
608        force,
609        npm_command,
610        |stage, npm_spec| {
611            let install_args = npm_command.install_args(&[npm_spec.to_string()], stage);
612            let mut command = Command::new(npm_command.program());
613            command
614                .args(npm_command.combined_args(&install_args))
615                .current_dir(stage)
616                .stdin(Stdio::inherit())
617                .stdout(Stdio::inherit())
618                .stderr(Stdio::inherit());
619            let status = command.status().map_err(|error| {
620                format!(
621                    "could not execute {} (install the configured package manager first): {error}",
622                    npm_command.program()
623                )
624            })?;
625            if !status.success() {
626                return Err(format!(
627                    "{} install exited with {status}",
628                    npm_command.program()
629                ));
630            }
631            Ok(())
632        },
633    )
634}
635
636fn install_npm_at_for_startup(
637    target: &Path,
638    install_spec: &str,
639    source_spec: &str,
640    npm_command: &NpmCommand,
641) -> Result<PathBuf, String> {
642    validate_standalone_npm_manager(npm_command)?;
643    install_npm_at_with_mode(
644        target,
645        install_spec,
646        source_spec,
647        true,
648        npm_command,
649        NpmExecutionMode::StartupRemediation,
650        |stage, npm_spec| {
651            let install_args = npm_command.install_args(&[npm_spec.to_string()], stage);
652            npm_command
653                .run_startup_remediation(&install_args, stage)
654                .map_err(|error| {
655                    format!(
656                        "could not restore npm package with {} during startup: {error}",
657                        npm_command.program()
658                    )
659                })
660        },
661    )
662}
663
664fn validate_standalone_npm_manager(npm_command: &NpmCommand) -> Result<(), String> {
665    if matches!(
666        npm_command.manager_kind(),
667        NpmManagerKind::Pnpm | NpmManagerKind::Bun
668    ) {
669        return Err(format!(
670            "{} cannot safely install an npm package into the standalone rpi package store; use a native Pi npm store entry instead",
671            match npm_command.manager_kind() {
672                NpmManagerKind::Pnpm => "pnpm",
673                NpmManagerKind::Bun => "bun",
674                _ => unreachable!(),
675            }
676        ));
677    }
678    Ok(())
679}
680
681fn install_npm_at_with(
682    target: &Path,
683    install_spec: &str,
684    source_spec: &str,
685    force: bool,
686    npm_command: &NpmCommand,
687    install: impl FnOnce(&Path, &str) -> Result<(), String>,
688) -> Result<PathBuf, String> {
689    install_npm_at_with_mode(
690        target,
691        install_spec,
692        source_spec,
693        force,
694        npm_command,
695        NpmExecutionMode::Interactive,
696        install,
697    )
698}
699
700fn install_npm_at_with_mode(
701    target: &Path,
702    install_spec: &str,
703    source_spec: &str,
704    force: bool,
705    npm_command: &NpmCommand,
706    npm_mode: NpmExecutionMode,
707    install: impl FnOnce(&Path, &str) -> Result<(), String>,
708) -> Result<PathBuf, String> {
709    let stage = tempfile::tempdir()
710        .map_err(|error| format!("could not create npm staging dir: {error}"))?;
711    let npm_spec = install_spec.strip_prefix("npm:").unwrap_or(install_spec);
712    install(stage.path(), npm_spec)?;
713    let parsed = npm_package_spec_checked(install_spec)?;
714    let package_name = PathBuf::from(&parsed.install_name);
715    let node_modules = stage.path().join("node_modules");
716    let installed = node_modules.join(&package_name);
717    let installed_metadata = std::fs::symlink_metadata(&installed).ok();
718    let installed_canonical = std::fs::canonicalize(&installed)
719        .ok()
720        .map(normalize_config_path);
721    let node_modules_canonical = std::fs::canonicalize(&node_modules)
722        .ok()
723        .map(normalize_config_path);
724    if !installed_metadata
725        .is_some_and(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
726        || !installed_canonical.as_ref().is_some_and(|path| {
727            node_modules_canonical
728                .as_ref()
729                .is_some_and(|root| path.starts_with(root))
730        })
731    {
732        return Err(format!(
733            "npm installed `{install_spec}` but no safe package directory was found"
734        ));
735    }
736    validate_npm_manifest_name(&installed.join("package.json"), &parsed.manifest_name)?;
737    replace_dir_prepared(&installed, target, force, |prepared| {
738        install_production_dependencies_with_mode(prepared, npm_command, npm_mode)?;
739        validate_npm_manifest_name(&prepared.join("package.json"), &parsed.manifest_name)?;
740        crate::packages::write_npm_source_marker(prepared, source_spec)
741    })?;
742    Ok(target.to_path_buf())
743}
744
745/// Refresh an installed npm package in-place. The package root is expected to
746/// be the safe-name directory created by `install-pi`; replacing that directory
747/// preserves the settings entry while updating its contents.
748pub(crate) fn update_npm_package(
749    root: &Path,
750    name: &str,
751    source_spec: &str,
752    npm_command: &NpmCommand,
753) -> Result<PathBuf, String> {
754    if !is_known_package_target(root) {
755        return Err(format!(
756            "refusing npm update outside a managed package store: {}",
757            root.display()
758        ));
759    }
760    let install_spec = npm_update_install_spec(name, source_spec);
761    install_npm_at(root, &install_spec, source_spec, true, npm_command)
762}
763
764/// Startup-only npm recovery. Unlike the interactive update command, every
765/// package-manager subprocess has a finite install budget and bounded output.
766pub(crate) fn update_npm_package_for_startup(
767    root: &Path,
768    name: &str,
769    source_spec: &str,
770    npm_command: &NpmCommand,
771) -> Result<PathBuf, String> {
772    if !is_known_package_target(root) {
773        return Err(format!(
774            "refusing npm update outside a managed package store: {}",
775            root.display()
776        ));
777    }
778    let install_spec = npm_update_install_spec(name, source_spec);
779    install_npm_at_for_startup(root, &install_spec, source_spec, npm_command)
780}
781
782/// Batch-update packages installed in one native Pi npm root. The caller must
783/// pass a root already recognized by package discovery; this function adds a
784/// root-level advisory lock, refuses direct symlinks, preserves an existing
785/// package manifest until the package manager runs, and never swaps an
786/// individual package leaf. The package manager owns its normal in-place
787/// `package.json`, lockfile, and `node_modules` changes, matching native Pi.
788pub(crate) fn update_npm_store_root(
789    root: &Path,
790    packages: &[(String, String)],
791    npm_command: &NpmCommand,
792    cwd: &Path,
793    project_trusted: bool,
794) -> Result<(), String> {
795    validate_recognized_npm_store_root(root, cwd, project_trusted)?;
796    update_npm_store_root_with(root, packages, npm_command, |root, command, args| {
797        let status = Command::new(command.program())
798            .args(command.combined_args(args))
799            .current_dir(root)
800            .stdin(Stdio::inherit())
801            .stdout(Stdio::inherit())
802            .stderr(Stdio::inherit())
803            .status()
804            .map_err(|error| {
805                format!(
806                    "could not execute {} for npm store update: {error}",
807                    command.program()
808                )
809            })?;
810        if status.success() {
811            Ok(())
812        } else {
813            Err(format!(
814                "{} npm store update exited with {status}",
815                command.program()
816            ))
817        }
818    })?;
819    validate_updated_npm_store_packages(root, packages)?;
820    Ok(())
821}
822
823/// Startup-only native npm-store recovery with a finite subprocess budget.
824pub(crate) fn update_npm_store_root_for_startup(
825    root: &Path,
826    packages: &[(String, String)],
827    npm_command: &NpmCommand,
828    cwd: &Path,
829    project_trusted: bool,
830) -> Result<(), String> {
831    validate_recognized_npm_store_root(root, cwd, project_trusted)?;
832    update_npm_store_root_with(root, packages, npm_command, |root, command, args| {
833        command
834            .run_startup_remediation(args, root)
835            .map_err(|error| {
836                format!(
837                    "could not restore npm store with {} during startup: {error}",
838                    command.program()
839                )
840            })
841    })?;
842    validate_updated_npm_store_packages(root, packages)?;
843    Ok(())
844}
845
846fn validate_updated_npm_store_packages(
847    root: &Path,
848    packages: &[(String, String)],
849) -> Result<(), String> {
850    for (name, source_spec) in packages {
851        let parsed = npm_package_spec_checked(source_spec)?;
852        if parsed.install_name != *name {
853            return Err(format!(
854                "npm update source `{source_spec}` does not match package `{name}`"
855            ));
856        }
857        let relative_name = PathBuf::from(name);
858        let target = root.join("node_modules").join(&relative_name);
859        validate_managed_npm_package(root, &target, &relative_name, &parsed.manifest_name)?;
860    }
861    Ok(())
862}
863
864/// Update a native Pi git checkout through a same-filesystem staging directory.
865/// The caller supplies the exact managed store root and source discovered from
866/// the trusted settings entry; the live checkout is not replaced until clone,
867/// dependency installation, and post-build validation have all succeeded.
868pub(crate) fn update_git_package(
869    root: &Path,
870    store_root: &Path,
871    source_spec: &str,
872    npm_command: &NpmCommand,
873) -> Result<(), String> {
874    update_git_package_with(
875        root,
876        store_root,
877        source_spec,
878        npm_command,
879        |root, origin, revision, npm_command| {
880            update_git_checkout_from_origin(root, store_root, origin, revision, npm_command)
881        },
882    )
883}
884
885fn update_git_package_with(
886    root: &Path,
887    store_root: &Path,
888    source_spec: &str,
889    npm_command: &NpmCommand,
890    update: impl FnOnce(&Path, &str, Option<&str>, &NpmCommand) -> Result<(), String>,
891) -> Result<(), String> {
892    if !root.is_absolute() || !store_root.is_absolute() {
893        return Err("refusing git update for a non-absolute managed path".to_string());
894    }
895    let source = crate::packages::parse_git_source(source_spec)
896        .ok_or_else(|| "refusing git update without a valid configured source".to_string())?;
897    validate_managed_git_root(root, store_root)?;
898
899    let _lock = PackageSwapLock::acquire(root)?;
900    // Re-resolve both paths after taking the advisory lock. A package entry
901    // can be replaced by a symlink/junction between discovery and update; in
902    // that case fail closed before invoking git.
903    validate_managed_git_root(root, store_root)?;
904    let origin = validated_git_origin(root, &source)?;
905    update(root, &origin, source.revision.as_deref(), npm_command)
906}
907
908fn update_git_checkout_from_origin(
909    root: &Path,
910    store_root: &Path,
911    origin: &str,
912    revision: Option<&str>,
913    npm_command: &NpmCommand,
914) -> Result<(), String> {
915    stage_git_checkout_update_with(
916        root,
917        store_root,
918        origin,
919        revision,
920        npm_command,
921        |prepared, origin, selected_revision| {
922            prepare_git_checkout_for_update(prepared, origin, revision, selected_revision)
923        },
924        |prepared, npm_command| {
925            install_git_dependencies(prepared, npm_command, NpmExecutionMode::Interactive)
926        },
927    )
928}
929
930fn prepare_git_checkout_for_update(
931    prepared: &Path,
932    origin: &str,
933    explicit_revision: Option<&str>,
934    selected_revision: Option<&str>,
935) -> Result<(), String> {
936    prepare_git_checkout_for_update_with(
937        prepared,
938        origin,
939        explicit_revision,
940        selected_revision,
941        clone_git_checkout,
942        fetch_git_revision,
943        checkout_git_revision,
944        validate_git_head,
945    )
946}
947
948fn prepare_git_checkout_for_update_with(
949    prepared: &Path,
950    origin: &str,
951    explicit_revision: Option<&str>,
952    selected_revision: Option<&str>,
953    clone: impl FnOnce(&str, Option<&str>, &Path) -> Result<(), String>,
954    fetch: impl FnOnce(&Path, &str, &str) -> Result<(), String>,
955    checkout: impl FnOnce(&Path, &str) -> Result<(), String>,
956    validate: impl FnOnce(&Path, Option<&str>) -> Result<(), String>,
957) -> Result<(), String> {
958    let Some(revision) = explicit_revision else {
959        return clone(origin, selected_revision, prepared);
960    };
961
962    // A full clone does not necessarily contain a commit that is only
963    // reachable through an unadvertised ref. Native Pi explicitly fetches a
964    // configured revision, so preserve that behavior in the staging checkout.
965    clone(origin, None, prepared)?;
966    fetch(prepared, origin, revision)?;
967    checkout(prepared, "FETCH_HEAD")?;
968    validate(prepared, Some("FETCH_HEAD"))
969}
970
971fn stage_git_checkout_update_with(
972    root: &Path,
973    store_root: &Path,
974    origin: &str,
975    revision: Option<&str>,
976    npm_command: &NpmCommand,
977    fetch: impl FnOnce(&Path, &str, Option<&str>) -> Result<(), String>,
978    build: impl FnOnce(&Path, &NpmCommand) -> Result<(), String>,
979) -> Result<(), String> {
980    let selected_revision = revision
981        .map(str::to_string)
982        .or_else(|| git_upstream_branch(root))
983        .or_else(|| git_origin_head_branch(root));
984    let expected_origin = crate::packages::parse_git_source(origin)
985        .filter(|source| source.revision.is_none())
986        .ok_or_else(|| "refusing to stage an invalid git origin".to_string())?;
987    let original_head = validated_git_head(root)?;
988    let update_marker = git_update_marker_path(root)?;
989    let had_update_marker = validate_git_update_marker(&update_marker)?;
990    let parent = root
991        .parent()
992        .ok_or_else(|| format!("git package has no parent: {}", root.display()))?;
993    let stage = tempfile::Builder::new()
994        .prefix(".rpi-git-update-")
995        .tempdir_in(parent)
996        .map_err(|error| format!("could not create git update staging directory: {error}"))?;
997    let prepared = stage.path().join("package");
998
999    fetch(&prepared, origin, selected_revision.as_deref())?;
1000    validate_cloned_git_origin(&prepared, origin, &expected_origin)?;
1001    let prepared_head = validated_git_head(&prepared)?;
1002
1003    build(&prepared, npm_command)?;
1004    crate::packages::remove_package_source_marker(&prepared)?;
1005    validate_cloned_git_origin(&prepared, origin, &expected_origin)?;
1006    if validated_git_head(&prepared)? != prepared_head {
1007        return Err("refusing git update because the staged HEAD changed during build".to_string());
1008    }
1009    if !package_target_is_valid(&prepared) {
1010        return Err("staged git checkout does not contain a valid Pi package".to_string());
1011    }
1012
1013    // The network/build phase can take minutes. Repeat every live-target check
1014    // immediately before activation so an out-of-band replacement cannot be
1015    // overwritten merely because it passed the initial preflight.
1016    validate_managed_git_root(root, store_root)?;
1017    if validated_git_head(root)? != original_head {
1018        return Err(
1019            "refusing git update because the installed checkout changed during staging".to_string(),
1020        );
1021    }
1022    let current_origin = validated_git_origin(root, &expected_origin)?;
1023    if current_origin != origin {
1024        return Err("refusing git update because origin changed during staging".to_string());
1025    }
1026
1027    swap_prepared_dir(&prepared, root, true)?;
1028    if had_update_marker {
1029        if let Err(error) = std::fs::remove_file(&update_marker) {
1030            eprintln!(
1031                "warning: updated git package but could not remove stale marker {}: {error}",
1032                update_marker.display()
1033            );
1034        }
1035    }
1036    Ok(())
1037}
1038
1039fn validate_managed_git_root(root: &Path, store_root: &Path) -> Result<(), String> {
1040    let canonical_root = normalize_update_path(std::fs::canonicalize(root).map_err(|error| {
1041        format!(
1042            "could not resolve git package root {}: {error}",
1043            root.display()
1044        )
1045    })?);
1046    let canonical_store =
1047        normalize_update_path(std::fs::canonicalize(store_root).map_err(|error| {
1048            format!(
1049                "could not resolve git store root {}: {error}",
1050                store_root.display()
1051            )
1052        })?);
1053    if !paths_equal(&canonical_root, root)
1054        || !paths_equal(&canonical_store, store_root)
1055        || !path_is_within(&canonical_root, &canonical_store)
1056        || !is_real_directory(&canonical_root)
1057        || !is_real_git_metadata(&canonical_root.join(".git"))
1058    {
1059        return Err(format!(
1060            "refusing git update outside the managed store: {}",
1061            root.display()
1062        ));
1063    }
1064    let store_components = canonical_store.components().count();
1065    let mut relative = canonical_root.components().skip(store_components);
1066    if canonical_root.components().count() <= store_components
1067        || relative.any(|component| !matches!(component, std::path::Component::Normal(_)))
1068    {
1069        return Err(format!(
1070            "refusing invalid managed git package path: {}",
1071            root.display()
1072        ));
1073    }
1074    Ok(())
1075}
1076
1077const MAX_LOCAL_GIT_CONFIG_BYTES: usize = 64 * 1024;
1078
1079/// Return the one explicit fetch URL recorded in this checkout only when its
1080/// normalized repository identity matches the trusted settings source. Local
1081/// includes and command-capable configuration are rejected because `git
1082/// fetch` would otherwise evaluate them after this check.
1083fn validated_git_origin(
1084    root: &Path,
1085    expected: &crate::packages::GitSpec,
1086) -> Result<String, String> {
1087    let keys = local_git_config_output(root, &["--name-only", "--list"])?;
1088    if !keys.status.success() || keys.stdout.len() > MAX_LOCAL_GIT_CONFIG_BYTES {
1089        return Err("refusing git update with unreadable local configuration".to_string());
1090    }
1091    for key in parse_nul_config_values(&keys.stdout)? {
1092        if dangerous_git_fetch_config_key(key) {
1093            return Err("refusing git update with unsafe local configuration".to_string());
1094        }
1095    }
1096
1097    let origins = local_git_config_output(root, &["--get-all", "remote.origin.url"])?;
1098    if !origins.status.success() || origins.stdout.len() > MAX_LOCAL_GIT_CONFIG_BYTES {
1099        return Err("refusing git update without exactly one configured origin".to_string());
1100    }
1101    let origins = parse_nul_config_values(&origins.stdout)?;
1102    let [origin] = origins.as_slice() else {
1103        return Err("refusing git update without exactly one configured origin".to_string());
1104    };
1105    if !is_explicit_git_remote_url(origin) {
1106        return Err("refusing git update with a non-network or ambiguous origin".to_string());
1107    }
1108    let actual = crate::packages::parse_git_source(origin)
1109        .filter(|source| source.revision.is_none())
1110        .ok_or_else(|| "refusing git update with an invalid or dangerous origin".to_string())?;
1111    let actual_port = actual
1112        .port
1113        .unwrap_or_else(|| actual.transport.default_port());
1114    let expected_port = expected
1115        .port
1116        .unwrap_or_else(|| expected.transport.default_port());
1117    if actual.host != expected.host
1118        || actual.path != expected.path
1119        || actual.transport != expected.transport
1120        || actual_port != expected_port
1121        || actual.user_info != expected.user_info
1122    {
1123        return Err(
1124            "refusing git update because origin does not match package settings".to_string(),
1125        );
1126    }
1127    Ok((*origin).to_string())
1128}
1129
1130fn local_git_config_output(root: &Path, args: &[&str]) -> Result<std::process::Output, String> {
1131    let config = root.join(".git/config");
1132    let metadata = std::fs::symlink_metadata(&config).map_err(|error| {
1133        format!(
1134            "could not inspect git package configuration {}: {error}",
1135            config.display()
1136        )
1137    })?;
1138    let canonical_config =
1139        normalize_update_path(std::fs::canonicalize(&config).map_err(|error| {
1140            format!(
1141                "could not resolve git package configuration {}: {error}",
1142                config.display()
1143            )
1144        })?);
1145    let canonical_git = normalize_update_path(
1146        std::fs::canonicalize(root.join(".git"))
1147            .map_err(|error| format!("could not resolve git package metadata: {error}"))?,
1148    );
1149    if !metadata.is_file()
1150        || metadata.file_type().is_symlink()
1151        || metadata.len() > MAX_LOCAL_GIT_CONFIG_BYTES as u64
1152        || !paths_equal(&canonical_config, &config)
1153        || !path_is_within(&canonical_config, &canonical_git)
1154    {
1155        return Err("refusing git update with redirected package configuration".to_string());
1156    }
1157
1158    let mut command = Command::new("git");
1159    apply_hardened_git_environment(&mut command);
1160    command
1161        .args(["config", "--no-includes", "--null", "--file"])
1162        .arg(&config)
1163        .args(args)
1164        .current_dir(root)
1165        .stdin(Stdio::null())
1166        .stdout(Stdio::piped())
1167        .stderr(Stdio::null())
1168        .output()
1169        .map_err(|error| format!("could not inspect git package configuration: {error}"))
1170}
1171
1172fn parse_nul_config_values(output: &[u8]) -> Result<Vec<&str>, String> {
1173    if output.is_empty() {
1174        return Ok(Vec::new());
1175    }
1176    let Some(values) = output.strip_suffix(&[0]) else {
1177        return Err("refusing malformed git configuration output".to_string());
1178    };
1179    values
1180        .split(|byte| *byte == 0)
1181        .map(|value| {
1182            let value = std::str::from_utf8(value)
1183                .map_err(|_| "refusing non-UTF-8 git configuration".to_string())?;
1184            if value.is_empty() {
1185                Err("refusing empty git configuration value".to_string())
1186            } else {
1187                Ok(value)
1188            }
1189        })
1190        .collect()
1191}
1192
1193fn dangerous_git_fetch_config_key(key: &str) -> bool {
1194    let key = key.to_ascii_lowercase();
1195    matches!(
1196        key.as_str(),
1197        "core.askpass"
1198            | "core.attributesfile"
1199            | "core.fsmonitor"
1200            | "core.gitproxy"
1201            | "core.hookspath"
1202            | "core.sshcommand"
1203            | "core.worktree"
1204            | "extensions.worktreeconfig"
1205            | "fetch.bundleuri"
1206            | "http.curloptresolve"
1207            | "http.extraheader"
1208            | "http.proxy"
1209            | "http.sslcainfo"
1210            | "http.sslcapath"
1211            | "http.sslverify"
1212            | "include.path"
1213    ) || (key.starts_with("includeif.") && key.ends_with(".path"))
1214        || (key.starts_with("http.")
1215            && (key.ends_with(".curloptresolve")
1216                || key.ends_with(".extraheader")
1217                || key.ends_with(".proxy")
1218                || key.ends_with(".sslcainfo")
1219                || key.ends_with(".sslcapath")
1220                || key.ends_with(".sslverify")))
1221        || (key.starts_with("url.")
1222            && (key.ends_with(".insteadof") || key.ends_with(".pushinsteadof")))
1223        || (key.starts_with("credential.") && key.ends_with(".helper"))
1224        || (key.starts_with("remote.")
1225            && (key.ends_with(".proxy") || key.ends_with(".uploadpack") || key.ends_with(".vcs")))
1226        || (key.starts_with("filter.")
1227            && (key.ends_with(".clean") || key.ends_with(".process") || key.ends_with(".smudge")))
1228}
1229
1230fn is_explicit_git_remote_url(origin: &str) -> bool {
1231    if origin.is_empty()
1232        || origin != origin.trim()
1233        || origin
1234            .chars()
1235            .any(|character| character.is_control() || character.is_whitespace())
1236    {
1237        return false;
1238    }
1239    if origin.starts_with("git@") {
1240        return origin.contains(':');
1241    }
1242    let Some((scheme, _)) = origin.split_once("://") else {
1243        return false;
1244    };
1245    matches!(
1246        scheme.to_ascii_lowercase().as_str(),
1247        "http" | "https" | "ssh" | "git"
1248    )
1249}
1250
1251fn git_upstream_branch(root: &Path) -> Option<String> {
1252    let upstream = run_git_capture(
1253        root,
1254        ["rev-parse", "--abbrev-ref", "@{upstream}"].as_slice(),
1255    )
1256    .ok()?;
1257    let branch = upstream.trim().strip_prefix("origin/")?;
1258    is_safe_git_branch(branch).then(|| branch.to_string())
1259}
1260
1261fn git_origin_head_branch(root: &Path) -> Option<String> {
1262    let symbolic = run_git_capture(
1263        root,
1264        ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"].as_slice(),
1265    )
1266    .ok()?;
1267    let branch = symbolic.trim().strip_prefix("refs/remotes/origin/")?;
1268    is_safe_git_branch(branch).then(|| branch.to_string())
1269}
1270
1271fn git_update_marker_path(root: &Path) -> Result<PathBuf, String> {
1272    let parent = root
1273        .parent()
1274        .ok_or_else(|| format!("git package has no parent: {}", root.display()))?;
1275    let name = root
1276        .file_name()
1277        .and_then(|name| name.to_str())
1278        .ok_or_else(|| format!("git package has no valid name: {}", root.display()))?;
1279    Ok(parent.join(format!(".{name}.pi-update-incomplete")))
1280}
1281
1282fn validate_git_update_marker(marker: &Path) -> Result<bool, String> {
1283    match std::fs::symlink_metadata(marker) {
1284        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err(format!(
1285            "git update marker must be a regular file: {}",
1286            marker.display()
1287        )),
1288        Ok(_) => Ok(true),
1289        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1290        Err(error) => Err(format!(
1291            "could not inspect git update marker {}: {error}",
1292            marker.display()
1293        )),
1294    }
1295}
1296
1297fn is_safe_git_branch(branch: &str) -> bool {
1298    !branch.is_empty()
1299        && !branch.starts_with('-')
1300        && !branch.starts_with('/')
1301        && !branch.ends_with('/')
1302        && !branch.contains('\\')
1303        && !branch.contains('\0')
1304        && !branch.contains("..")
1305        && !branch.contains("@{")
1306        && !branch.chars().any(|ch| {
1307            ch.is_control() || ch.is_whitespace() || matches!(ch, '~' | '^' | ':' | '?' | '*' | '[')
1308        })
1309        && !branch
1310            .split('/')
1311            .any(|part| part.is_empty() || part == "." || part == "..")
1312}
1313
1314fn run_git_capture(root: &Path, args: &[&str]) -> Result<String, String> {
1315    let mut command = hardened_git_command();
1316    let output = command
1317        .args(args)
1318        .current_dir(root)
1319        .stdin(Stdio::null())
1320        .output()
1321        .map_err(|error| format!("could not execute hardened git: {error}"))?;
1322    if !output.status.success() {
1323        let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
1324        return if detail.is_empty() {
1325            Err(format!(
1326                "git {} exited with {}",
1327                args.join(" "),
1328                output.status
1329            ))
1330        } else {
1331            Err(format!(
1332                "git {} exited with {}: {detail}",
1333                args.join(" "),
1334                output.status
1335            ))
1336        };
1337    }
1338    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
1339}
1340
1341pub(crate) const HARDENED_GIT_ENV_REMOVE: &[&str] = &[
1342    "GIT_ALLOW_PROTOCOL",
1343    "GIT_ALTERNATE_OBJECT_DIRECTORIES",
1344    "GIT_ASKPASS",
1345    "GIT_ATTR_NOSYSTEM",
1346    "GIT_CEILING_DIRECTORIES",
1347    "GIT_COMMON_DIR",
1348    "GIT_CONFIG",
1349    "GIT_CONFIG_COUNT",
1350    "GIT_CONFIG_GLOBAL",
1351    "GIT_CONFIG_NOSYSTEM",
1352    "GIT_CONFIG_PARAMETERS",
1353    "GIT_CONFIG_SYSTEM",
1354    "GIT_DIR",
1355    "GIT_DISCOVERY_ACROSS_FILESYSTEM",
1356    "GIT_EXEC_PATH",
1357    "GIT_INDEX_FILE",
1358    "GIT_NAMESPACE",
1359    "GIT_OBJECT_DIRECTORY",
1360    "GIT_PROXY_COMMAND",
1361    "GIT_QUARANTINE_PATH",
1362    "GIT_SSH",
1363    "GIT_SSH_COMMAND",
1364    "GIT_SSH_VARIANT",
1365    "GIT_SSL_CAINFO",
1366    "GIT_SSL_CAPATH",
1367    "GIT_SSL_NO_VERIFY",
1368    "GIT_TEMPLATE_DIR",
1369    "GIT_WORK_TREE",
1370    "SSH_ASKPASS",
1371];
1372
1373pub(crate) fn hardened_git_null_config() -> &'static str {
1374    if cfg!(windows) {
1375        "NUL"
1376    } else {
1377        "/dev/null"
1378    }
1379}
1380
1381/// Build command-scope Git configuration without consulting command-capable
1382/// system, global, or environment configuration. A short allowlist preserves
1383/// the standard credential helpers shipped by common Git installations; SSH
1384/// agent and `~/.ssh/config` authentication continue to work independently.
1385pub(crate) fn hardened_git_network_config_args() -> Vec<String> {
1386    let mut args = Vec::new();
1387    for value in [
1388        "protocol.allow=never",
1389        "protocol.http.allow=always",
1390        "protocol.https.allow=always",
1391        "protocol.ssh.allow=always",
1392        "protocol.git.allow=always",
1393        "protocol.ext.allow=never",
1394        "protocol.file.allow=never",
1395        "credential.helper=",
1396        "credential.interactive=false",
1397        "gc.auto=0",
1398        "maintenance.auto=false",
1399        if cfg!(windows) {
1400            "core.hooksPath=NUL"
1401        } else {
1402            "core.hooksPath=/dev/null"
1403        },
1404    ] {
1405        args.push("-c".to_string());
1406        args.push(value.to_string());
1407    }
1408    for helper in safe_configured_credential_helpers() {
1409        args.push("-c".to_string());
1410        args.push(format!("credential.helper={helper}"));
1411    }
1412    args
1413}
1414
1415fn hardened_git_command() -> Command {
1416    let mut command = Command::new("git");
1417    command.args(hardened_git_network_config_args());
1418    apply_hardened_git_environment(&mut command);
1419    command
1420}
1421
1422fn apply_hardened_git_environment(command: &mut Command) {
1423    for name in HARDENED_GIT_ENV_REMOVE {
1424        command.env_remove(name);
1425    }
1426    command
1427        .env("GIT_CONFIG_NOSYSTEM", "1")
1428        .env("GIT_CONFIG_SYSTEM", hardened_git_null_config())
1429        .env("GIT_CONFIG_GLOBAL", hardened_git_null_config())
1430        .env("GIT_ATTR_NOSYSTEM", "1")
1431        .env("GIT_PROTOCOL_FROM_USER", "0")
1432        .env("GIT_TERMINAL_PROMPT", "0")
1433        .env("GCM_INTERACTIVE", "Never")
1434        .env("SSH_ASKPASS_REQUIRE", "never");
1435}
1436
1437fn safe_configured_credential_helpers() -> &'static [String] {
1438    static HELPERS: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
1439    HELPERS.get_or_init(|| {
1440        let mut helpers = Vec::new();
1441        for scope in ["--system", "--global"] {
1442            let mut command = Command::new("git");
1443            command.args([
1444                "config",
1445                scope,
1446                "--no-includes",
1447                "--null",
1448                "--get-all",
1449                "credential.helper",
1450            ]);
1451            for name in HARDENED_GIT_ENV_REMOVE {
1452                command.env_remove(name);
1453            }
1454            let Ok(output) = command
1455                .stdin(Stdio::null())
1456                .stdout(Stdio::piped())
1457                .stderr(Stdio::null())
1458                .output()
1459            else {
1460                continue;
1461            };
1462            if !output.status.success() || output.stdout.len() > MAX_LOCAL_GIT_CONFIG_BYTES {
1463                continue;
1464            }
1465            for value in output.stdout.split(|byte| *byte == 0) {
1466                let Ok(value) = std::str::from_utf8(value) else {
1467                    continue;
1468                };
1469                if is_safe_standard_credential_helper(value)
1470                    && !helpers.iter().any(|helper| helper == value)
1471                {
1472                    helpers.push(value.to_string());
1473                }
1474            }
1475        }
1476        helpers
1477    })
1478}
1479
1480fn is_safe_standard_credential_helper(value: &str) -> bool {
1481    matches!(
1482        value,
1483        "cache" | "libsecret" | "manager" | "manager-core" | "osxkeychain" | "store" | "wincred"
1484    )
1485}
1486
1487fn is_real_directory(path: &Path) -> bool {
1488    std::fs::symlink_metadata(path)
1489        .map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
1490        .unwrap_or(false)
1491}
1492
1493fn is_real_git_metadata(path: &Path) -> bool {
1494    std::fs::symlink_metadata(path)
1495        // A worktree's `.git` file points at another repository. Automatic
1496        // package updates only operate on self-contained checkouts, so reject
1497        // files (including `gitdir:` pointers) and symlinks outright.
1498        .map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
1499        .unwrap_or(false)
1500}
1501
1502fn normalize_update_path(path: PathBuf) -> PathBuf {
1503    normalize_config_path(path)
1504}
1505
1506fn update_npm_store_root_with(
1507    root: &Path,
1508    packages: &[(String, String)],
1509    npm_command: &NpmCommand,
1510    run: impl FnOnce(&Path, &NpmCommand, &[String]) -> Result<(), String>,
1511) -> Result<(), String> {
1512    if packages.is_empty() {
1513        return Ok(());
1514    }
1515    if !root.is_absolute() || root.file_name() != Some(std::ffi::OsStr::new("npm")) {
1516        return Err(format!(
1517            "refusing npm store update outside a recognized absolute npm root: {}",
1518            root.display()
1519        ));
1520    }
1521    let specs = packages
1522        .iter()
1523        .map(|(name, source_spec)| npm_root_update_spec(name, source_spec))
1524        .collect::<Result<Vec<_>, _>>()?;
1525    let parent = root
1526        .parent()
1527        .ok_or_else(|| format!("npm store root has no parent: {}", root.display()))?;
1528    ensure_real_directory_tree(parent)?;
1529    let _lock = PackageSwapLock::acquire(root)?;
1530    ensure_npm_store_root(root)?;
1531    for (name, _) in packages {
1532        let relative_name = npm_module_name_checked(name)?;
1533        validate_npm_target_before_mutation(root, &relative_name, true)?;
1534    }
1535    let args = npm_command.install_args(&specs, root);
1536    run(root, npm_command, &args)
1537}
1538
1539fn npm_root_update_spec(name: &str, source_spec: &str) -> Result<String, String> {
1540    let name = name.trim();
1541    let configured = source_spec
1542        .strip_prefix("npm:")
1543        .unwrap_or(source_spec)
1544        .trim();
1545    if name.is_empty() || configured.is_empty() {
1546        return Err("npm update source and package name must not be empty".to_string());
1547    }
1548    if npm_module_name(configured) != name {
1549        return Err(format!(
1550            "npm update source `{source_spec}` does not match package `{name}`"
1551        ));
1552    }
1553    Ok(if configured == name {
1554        format!("{name}@latest")
1555    } else {
1556        configured.to_string()
1557    })
1558}
1559
1560fn ensure_npm_store_root(root: &Path) -> Result<(), String> {
1561    ensure_real_directory_tree(root)?;
1562
1563    let manifest = root.join("package.json");
1564    match std::fs::symlink_metadata(&manifest) {
1565        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err(format!(
1566            "npm store package.json must be a regular file: {}",
1567            manifest.display()
1568        )),
1569        Ok(_) => Ok(()),
1570        Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::fs::write(
1571            &manifest,
1572            "{\n  \"name\": \"pi-extensions\",\n  \"private\": true\n}\n",
1573        )
1574        .map_err(|error| format!("could not create npm store package.json: {error}")),
1575        Err(error) => return Err(format!("could not inspect npm store package.json: {error}")),
1576    }?;
1577
1578    let ignore = root.join(".gitignore");
1579    match std::fs::symlink_metadata(&ignore) {
1580        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err(format!(
1581            "npm store .gitignore must be a regular file: {}",
1582            ignore.display()
1583        )),
1584        Ok(_) => Ok(()),
1585        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1586            std::fs::write(&ignore, "*\n!.gitignore\n")
1587                .map_err(|error| format!("could not create npm store .gitignore: {error}"))
1588        }
1589        Err(error) => Err(format!("could not inspect npm store .gitignore: {error}")),
1590    }
1591}
1592
1593fn validate_npm_target_before_mutation(
1594    root: &Path,
1595    relative_name: &Path,
1596    create_parents: bool,
1597) -> Result<(), String> {
1598    let canonical_root = std::fs::canonicalize(root)
1599        .map(normalize_config_path)
1600        .map_err(|error| format!("could not resolve npm root {}: {error}", root.display()))?;
1601    if !paths_equal(&canonical_root, root) {
1602        return Err(format!("refusing redirected npm root {}", root.display()));
1603    }
1604
1605    let node_modules = root.join("node_modules");
1606    match std::fs::symlink_metadata(&node_modules) {
1607        Ok(_) => ensure_real_directory_tree(&node_modules)?,
1608        Err(error) if error.kind() == std::io::ErrorKind::NotFound && create_parents => {
1609            ensure_real_directory_tree(&node_modules)?;
1610        }
1611        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1612        Err(error) => {
1613            return Err(format!(
1614                "could not inspect npm node_modules {}: {error}",
1615                node_modules.display()
1616            ));
1617        }
1618    }
1619
1620    if let Some(parent) = relative_name
1621        .parent()
1622        .filter(|parent| !parent.as_os_str().is_empty())
1623    {
1624        let scope = node_modules.join(parent);
1625        match std::fs::symlink_metadata(&scope) {
1626            Ok(_) => ensure_real_directory_tree(&scope)?,
1627            Err(error) if error.kind() == std::io::ErrorKind::NotFound && create_parents => {
1628                ensure_real_directory_tree(&scope)?;
1629            }
1630            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1631            Err(error) => {
1632                return Err(format!(
1633                    "could not inspect npm scope {}: {error}",
1634                    scope.display()
1635                ));
1636            }
1637        }
1638    }
1639
1640    let target = node_modules.join(relative_name);
1641    match std::fs::symlink_metadata(&target) {
1642        Ok(metadata) => {
1643            let canonical_target = std::fs::canonicalize(&target)
1644                .map(normalize_config_path)
1645                .map_err(|error| {
1646                    format!("could not resolve npm target {}: {error}", target.display())
1647                })?;
1648            if !metadata.is_dir() && !metadata.file_type().is_symlink() {
1649                return Err(format!(
1650                    "npm package target is not a directory: {}",
1651                    target.display()
1652                ));
1653            }
1654            if !path_is_within(&canonical_target, &canonical_root) {
1655                return Err(format!(
1656                    "refusing npm package target outside its managed root: {}",
1657                    target.display()
1658                ));
1659            }
1660        }
1661        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1662        Err(error) => {
1663            return Err(format!(
1664                "could not inspect npm target {}: {error}",
1665                target.display()
1666            ));
1667        }
1668    }
1669    Ok(())
1670}
1671
1672fn npm_update_install_spec(name: &str, source_spec: &str) -> String {
1673    let configured = source_spec
1674        .strip_prefix("npm:")
1675        .unwrap_or(source_spec)
1676        .trim();
1677    let install_spec = if configured == name {
1678        format!("npm:{name}@latest")
1679    } else {
1680        format!("npm:{configured}")
1681    };
1682    install_spec
1683}
1684
1685#[derive(Debug, Clone)]
1686struct UninstallOptions {
1687    spec: String,
1688    global: bool,
1689}
1690
1691/// Remove a Pi package installed by `rpi install-pi` and disable its settings
1692/// entry. Source directories outside rpi/pi package stores are never deleted.
1693pub fn uninstall(args: &[String]) -> i32 {
1694    let options = match parse_uninstall_args(args) {
1695        Ok(options) => options,
1696        Err(message) if message == "help" => {
1697            print_uninstall_help();
1698            return 0;
1699        }
1700        Err(message) => {
1701            eprintln!("error: {message}");
1702            print_uninstall_help();
1703            return 2;
1704        }
1705    };
1706    let cwd = match std::env::current_dir() {
1707        Ok(path) => path,
1708        Err(error) => {
1709            eprintln!("error: could not determine current directory: {error}");
1710            return 1;
1711        }
1712    };
1713    let project_trusted = match project_trust_for_package_operation(&cwd, options.global) {
1714        Ok(trusted) => trusted,
1715        Err(error) => {
1716            eprintln!("error: {error}");
1717            return 1;
1718        }
1719    };
1720    let is_npm = is_npm_uninstall_spec(&cwd, &options.spec);
1721    let is_git = is_git_install_spec(&options.spec);
1722    let root = find_installed_root(&cwd, &options);
1723    let (settings_after_removal, removed_settings) =
1724        match plan_settings_removal(&cwd, &options.spec, root.as_deref(), options.global) {
1725            Ok(plan) => plan,
1726            Err(error) => {
1727                eprintln!("error: refusing to uninstall with unreadable settings: {error}");
1728                return 1;
1729            }
1730        };
1731    let mut removed_files = false;
1732    if is_npm {
1733        let npm_command = match NpmCommand::resolve(&cwd, project_trusted) {
1734            Ok(command) => command,
1735            Err(error) => {
1736                eprintln!("error: {error}");
1737                return 1;
1738            }
1739        };
1740        match uninstall_managed_npm(&cwd, options.global, &options.spec, &npm_command) {
1741            Ok(Some(removed)) => {
1742                removed_files |= removed;
1743            }
1744            Ok(None) => {}
1745            Err(error) => {
1746                eprintln!("error: {error}");
1747                return 1;
1748            }
1749        }
1750    }
1751    if is_git {
1752        if let Some(root) = root.as_deref() {
1753            let managed_legacy = is_managed_package_root(&cwd, root);
1754            let managed_git = is_managed_git_package_root(&cwd, root, options.global);
1755            if root.exists() && (managed_legacy || managed_git) {
1756                let git_marker = if managed_git {
1757                    let marker = match git_update_marker_path(root) {
1758                        Ok(marker) => marker,
1759                        Err(error) => {
1760                            eprintln!("error: {error}");
1761                            return 1;
1762                        }
1763                    };
1764                    match validate_git_update_marker(&marker) {
1765                        Ok(exists) => Some((marker, exists)),
1766                        Err(error) => {
1767                            eprintln!("error: {error}");
1768                            return 1;
1769                        }
1770                    }
1771                } else {
1772                    None
1773                };
1774                match std::fs::remove_dir_all(root) {
1775                    Ok(()) => {
1776                        if let Some((marker, true)) = git_marker {
1777                            if let Err(error) = std::fs::remove_file(&marker) {
1778                                eprintln!(
1779                                    "error: removed git package but could not remove marker {}: {error}",
1780                                    marker.display()
1781                                );
1782                                return 1;
1783                            }
1784                        }
1785                        if managed_git {
1786                            prune_empty_git_parents(&cwd, root, options.global);
1787                        }
1788                        println!("removed Pi package {}", root.display());
1789                        removed_files = true;
1790                    }
1791                    Err(error) => {
1792                        eprintln!("error: could not remove {}: {error}", root.display());
1793                        return 1;
1794                    }
1795                }
1796            } else if root.exists() {
1797                println!(
1798                    "disabled Pi package at {}; source directory was left intact",
1799                    root.display()
1800                );
1801            }
1802        }
1803    } else if let Some(root) = root.as_deref().filter(|root| root.exists()) {
1804        println!(
1805            "disabled Pi package at {}; source directory was left intact",
1806            root.display()
1807        );
1808    }
1809    // Persist the settings change only after every filesystem/package-manager
1810    // operation succeeds. A failed uninstall therefore remains retryable.
1811    if removed_settings > 0 {
1812        if let Err(error) = save_settings_for_install(&cwd, options.global, &settings_after_removal)
1813        {
1814            eprintln!(
1815                "error: package files were removed but settings could not be updated: {error}"
1816            );
1817            return 1;
1818        }
1819    }
1820    if !removed_files && removed_settings == 0 {
1821        println!("Pi package is not installed: {}", options.spec);
1822    } else if removed_settings > 0 && !removed_files {
1823        println!("disabled Pi package {}", options.spec);
1824    }
1825    0
1826}
1827
1828fn is_npm_uninstall_spec(cwd: &Path, spec: &str) -> bool {
1829    if spec.starts_with("npm:") {
1830        return true;
1831    }
1832    if spec.starts_with("file:") || is_git_install_spec(spec) {
1833        return false;
1834    }
1835    let local = PathBuf::from(spec);
1836    let local = if local.is_absolute() {
1837        local
1838    } else {
1839        cwd.join(local)
1840    };
1841    !local.exists() && looks_like_npm(spec)
1842}
1843
1844fn uninstall_managed_npm(
1845    cwd: &Path,
1846    global: bool,
1847    spec: &str,
1848    npm_command: &NpmCommand,
1849) -> Result<Option<bool>, String> {
1850    let relative_name = npm_module_name_checked(spec)?;
1851    let package_name = relative_name.to_string_lossy().replace('\\', "/");
1852    let root = managed_npm_root(cwd, global)?;
1853    validate_recognized_npm_store_root(&root, cwd, true)?;
1854    if std::fs::symlink_metadata(&root)
1855        .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound)
1856    {
1857        return Ok(None);
1858    }
1859    validate_existing_npm_store_root(&root)?;
1860    let target = root.join("node_modules").join(&relative_name);
1861    let existed = match std::fs::symlink_metadata(&target) {
1862        Ok(_) => true,
1863        Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1864        Err(error) => {
1865            return Err(format!(
1866                "could not inspect npm package target {}: {error}",
1867                target.display()
1868            ));
1869        }
1870    };
1871    if !existed && !npm_store_manifest_tracks_package(&root, &package_name)? {
1872        return Ok(None);
1873    }
1874    let _lock = PackageSwapLock::acquire(&root)?;
1875    validate_existing_npm_store_root(&root)?;
1876    validate_npm_target_before_mutation(&root, &relative_name, false)?;
1877    let args = npm_command.uninstall_args(&package_name, &root);
1878    let status = Command::new(npm_command.program())
1879        .args(npm_command.combined_args(&args))
1880        .current_dir(&root)
1881        .stdin(Stdio::inherit())
1882        .stdout(Stdio::inherit())
1883        .stderr(Stdio::inherit())
1884        .status()
1885        .map_err(|error| {
1886            format!(
1887                "could not execute {} for npm package removal: {error}",
1888                npm_command.program()
1889            )
1890        })?;
1891    if !status.success() {
1892        return Err(format!(
1893            "{} npm package removal exited with {status}",
1894            npm_command.program()
1895        ));
1896    }
1897    Ok(Some(existed))
1898}
1899
1900fn npm_store_manifest_tracks_package(root: &Path, package_name: &str) -> Result<bool, String> {
1901    let manifest = root.join("package.json");
1902    let text = match std::fs::read_to_string(&manifest) {
1903        Ok(text) => text,
1904        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1905        Err(error) => {
1906            return Err(format!(
1907                "could not read npm store manifest {}: {error}",
1908                manifest.display()
1909            ));
1910        }
1911    };
1912    let value: serde_json::Value = serde_json::from_str(&text).map_err(|error| {
1913        format!(
1914            "could not parse npm store manifest {}: {error}",
1915            manifest.display()
1916        )
1917    })?;
1918    Ok(["dependencies", "devDependencies", "optionalDependencies"]
1919        .into_iter()
1920        .any(|key| {
1921            value
1922                .get(key)
1923                .and_then(serde_json::Value::as_object)
1924                .is_some_and(|entries| entries.contains_key(package_name))
1925        }))
1926}
1927
1928fn validate_existing_npm_store_root(root: &Path) -> Result<(), String> {
1929    ensure_real_directory_tree(root)?;
1930    for path in [root.join("package.json"), root.join("node_modules")] {
1931        match std::fs::symlink_metadata(&path) {
1932            Ok(metadata)
1933                if metadata.file_type().is_symlink()
1934                    || (path.file_name() == Some(std::ffi::OsStr::new("package.json"))
1935                        && !metadata.is_file())
1936                    || (path.file_name() == Some(std::ffi::OsStr::new("node_modules"))
1937                        && !metadata.is_dir()) =>
1938            {
1939                return Err(format!(
1940                    "refusing redirected npm store entry {}",
1941                    path.display()
1942                ));
1943            }
1944            Ok(_) if path.file_name() == Some(std::ffi::OsStr::new("node_modules")) => {
1945                let canonical = std::fs::canonicalize(&path)
1946                    .map(normalize_config_path)
1947                    .map_err(|error| format!("could not resolve {}: {error}", path.display()))?;
1948                if !paths_equal(&canonical, &path) {
1949                    return Err(format!(
1950                        "refusing redirected npm store entry {}",
1951                        path.display()
1952                    ));
1953                }
1954            }
1955            Ok(_) => {}
1956            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1957            Err(error) => {
1958                return Err(format!("could not inspect {}: {error}", path.display()));
1959            }
1960        }
1961    }
1962    Ok(())
1963}
1964
1965fn parse_uninstall_args(args: &[String]) -> Result<UninstallOptions, String> {
1966    let mut spec = None;
1967    let mut global = false;
1968    for arg in args {
1969        match arg.as_str() {
1970            "--help" | "-h" => return Err("help".into()),
1971            "--global" | "-g" => global = true,
1972            value if value.starts_with('-') => {
1973                return Err(format!("unknown uninstall-pi option `{value}`"));
1974            }
1975            value => {
1976                if spec.replace(value.to_string()).is_some() {
1977                    return Err("uninstall-pi accepts exactly one package spec".into());
1978                }
1979            }
1980        }
1981    }
1982    Ok(UninstallOptions {
1983        spec: spec.ok_or_else(|| "missing npm, git, or local package spec".to_string())?,
1984        global,
1985    })
1986}
1987
1988fn find_installed_root(cwd: &Path, options: &UninstallOptions) -> Option<PathBuf> {
1989    if is_git_install_spec(&options.spec) {
1990        return find_installed_git_root(cwd, options);
1991    }
1992    let raw = options.spec.strip_prefix("file:").unwrap_or(&options.spec);
1993    let package_key = package_dir_name(&options.spec);
1994    let mut candidates = Vec::new();
1995    let direct = PathBuf::from(raw);
1996    let is_local =
1997        !is_npm_uninstall_spec(cwd, &options.spec) && !is_git_install_spec(&options.spec);
1998    if is_local && !direct.is_absolute() {
1999        if options.global {
2000            if let Ok(agent) = crate::config::agent_dir() {
2001                candidates.push(agent.join(&direct));
2002            }
2003            if let Some(home) = dirs::home_dir() {
2004                candidates.push(home.join(".pi/agent").join(&direct));
2005            }
2006        } else {
2007            candidates.push(cwd.join(".rpi").join(&direct));
2008            candidates.push(cwd.join(".pi").join(&direct));
2009        }
2010    }
2011    if direct.is_absolute() || raw.starts_with('.') {
2012        candidates.push(if direct.is_absolute() {
2013            direct
2014        } else {
2015            cwd.join(direct)
2016        });
2017    }
2018    let add_store = |store: PathBuf, candidates: &mut Vec<PathBuf>| {
2019        candidates.push(store.join(&package_key));
2020    };
2021    if !options.global {
2022        if let Ok(name) = npm_module_name_checked(&options.spec) {
2023            if options.spec.starts_with("npm:") {
2024                candidates.push(cwd.join(".pi/npm/node_modules").join(name));
2025            }
2026        }
2027        add_store(cwd.join(".rpi/packages"), &mut candidates);
2028        add_store(cwd.join(".pi/packages"), &mut candidates);
2029    } else {
2030        if let Ok(agent) = crate::config::agent_dir() {
2031            if let Ok(name) = npm_module_name_checked(&options.spec) {
2032                if options.spec.starts_with("npm:") {
2033                    candidates.push(agent.join("npm/node_modules").join(name));
2034                }
2035            }
2036            add_store(agent.join("packages"), &mut candidates);
2037        }
2038        if let Some(home) = dirs::home_dir() {
2039            if let Ok(name) = npm_module_name_checked(&options.spec) {
2040                if options.spec.starts_with("npm:") {
2041                    candidates.push(home.join(".pi/agent/npm/node_modules").join(name));
2042                }
2043            }
2044            add_store(home.join(".pi/agent/packages"), &mut candidates);
2045        }
2046    }
2047    candidates
2048        .into_iter()
2049        .find_map(|path| std::fs::canonicalize(path).ok())
2050}
2051
2052fn find_installed_git_root(cwd: &Path, options: &UninstallOptions) -> Option<PathBuf> {
2053    let source = crate::packages::parse_git_source(&options.spec)?;
2054    let relative = Path::new(&source.host).join(&source.path);
2055    let mut native_targets = Vec::new();
2056    let mut legacy_targets = Vec::new();
2057    if options.global {
2058        if let Ok(agent) = crate::config::agent_dir() {
2059            native_targets.push(agent.join("git").join(&relative));
2060            legacy_targets.push(agent.join("packages").join(package_dir_name(&options.spec)));
2061        }
2062        if let Some(home) = dirs::home_dir() {
2063            native_targets.push(home.join(".pi/agent/git").join(&relative));
2064            legacy_targets.push(
2065                home.join(".pi/agent/packages")
2066                    .join(package_dir_name(&options.spec)),
2067            );
2068        }
2069    } else {
2070        native_targets.push(cwd.join(".pi/git").join(&relative));
2071        native_targets.push(cwd.join(".rpi/git").join(&relative));
2072        legacy_targets.push(
2073            cwd.join(".rpi/packages")
2074                .join(package_dir_name(&options.spec)),
2075        );
2076        legacy_targets.push(
2077            cwd.join(".pi/packages")
2078                .join(package_dir_name(&options.spec)),
2079        );
2080    }
2081
2082    for target in native_targets {
2083        let Ok(root) = std::fs::canonicalize(target).map(normalize_config_path) else {
2084            continue;
2085        };
2086        if is_managed_git_package_root(cwd, &root, options.global) {
2087            return Some(root);
2088        }
2089    }
2090    legacy_targets.into_iter().find_map(|target| {
2091        let root = normalize_config_path(std::fs::canonicalize(target).ok()?);
2092        legacy_git_root_matches_source(cwd, &root, options.global, &source).then_some(root)
2093    })
2094}
2095
2096fn legacy_git_root_matches_source(
2097    cwd: &Path,
2098    root: &Path,
2099    global: bool,
2100    expected: &crate::packages::GitSpec,
2101) -> bool {
2102    if !is_managed_package_root(cwd, root)
2103        || !is_real_git_metadata(&root.join(".git"))
2104        || (!global
2105            && !is_direct_package_below_store(root, cwd, Path::new(".rpi/packages"))
2106            && !is_direct_package_below_store(root, cwd, Path::new(".pi/packages")))
2107    {
2108        return false;
2109    }
2110    let Ok(origin) = run_git_capture(root, &["config", "--get", "remote.origin.url"]) else {
2111        return false;
2112    };
2113    crate::packages::parse_git_source(&origin)
2114        .is_some_and(|actual| actual.host == expected.host && actual.path == expected.path)
2115}
2116
2117fn package_dir_name(spec: &str) -> String {
2118    if let Some(git) = crate::packages::parse_git_source(spec) {
2119        return safe_name(
2120            git.path
2121                .trim_end_matches('/')
2122                .rsplit('/')
2123                .next()
2124                .unwrap_or("package"),
2125        );
2126    }
2127    let raw = spec.strip_prefix("git:").unwrap_or(spec);
2128    if raw.starts_with("http://") || raw.starts_with("https://") {
2129        return safe_name(
2130            raw.trim_end_matches('/')
2131                .rsplit('/')
2132                .next()
2133                .unwrap_or("package"),
2134        );
2135    }
2136    safe_name(raw)
2137}
2138
2139fn is_managed_git_package_root(cwd: &Path, root: &Path, global: bool) -> bool {
2140    let mut stores = Vec::new();
2141    if global {
2142        if let Ok(agent) = crate::config::agent_dir() {
2143            stores.push(agent.join("git"));
2144        }
2145        if let Some(home) = dirs::home_dir() {
2146            stores.push(home.join(".pi/agent/git"));
2147        }
2148    } else {
2149        stores.push(cwd.join(".pi/git"));
2150        stores.push(cwd.join(".rpi/git"));
2151    }
2152    let root = normalize_config_path(match std::fs::canonicalize(root) {
2153        Ok(root) => root,
2154        Err(_) => return false,
2155    });
2156    if !is_real_directory(&root) {
2157        return false;
2158    }
2159    stores.into_iter().any(|store| {
2160        let canonical_store = std::fs::canonicalize(&store)
2161            .ok()
2162            .map(normalize_config_path);
2163        canonical_store.as_ref().is_some_and(|canonical_store| {
2164            paths_equal(canonical_store, &store)
2165                && path_is_within(&root, canonical_store)
2166                && root.components().count() >= canonical_store.components().count() + 3
2167        })
2168    })
2169}
2170
2171fn prune_empty_git_parents(cwd: &Path, target: &Path, global: bool) {
2172    let mut stores = Vec::new();
2173    if global {
2174        if let Ok(agent) = crate::config::agent_dir() {
2175            stores.push(agent.join("git"));
2176        }
2177        if let Some(home) = dirs::home_dir() {
2178            stores.push(home.join(".pi/agent/git"));
2179        }
2180    } else {
2181        stores.push(cwd.join(".pi/git"));
2182        stores.push(cwd.join(".rpi/git"));
2183    }
2184    let Some(store) = stores.into_iter().find(|store| {
2185        std::fs::canonicalize(store)
2186            .map(normalize_config_path)
2187            .is_ok_and(|canonical| paths_equal(&canonical, store) && path_is_within(target, store))
2188    }) else {
2189        return;
2190    };
2191    let Some(mut current) = target.parent().map(Path::to_path_buf) else {
2192        return;
2193    };
2194    while !paths_equal(&current, &store) && path_is_within(&current, &store) {
2195        let is_empty = std::fs::read_dir(&current)
2196            .ok()
2197            .is_some_and(|mut entries| entries.next().is_none());
2198        if !is_empty || std::fs::remove_dir(&current).is_err() {
2199            break;
2200        }
2201        let Some(parent) = current.parent().map(Path::to_path_buf) else {
2202            break;
2203        };
2204        current = parent;
2205    }
2206}
2207
2208fn is_managed_package_root(cwd: &Path, root: &Path) -> bool {
2209    if is_direct_package_below_store(root, cwd, Path::new(".rpi/packages"))
2210        || is_direct_package_below_store(root, cwd, Path::new(".pi/packages"))
2211    {
2212        return true;
2213    }
2214    if let Ok(agent) = crate::config::agent_dir() {
2215        if is_direct_package_below_store(root, &agent, Path::new("packages")) {
2216            return true;
2217        }
2218    }
2219    if let Some(home) = dirs::home_dir() {
2220        if is_direct_package_below_store(root, &home, Path::new(".pi/agent/packages")) {
2221            return true;
2222        }
2223    }
2224    false
2225}
2226
2227fn is_direct_package_below_store(root: &Path, base: &Path, relative_store: &Path) -> bool {
2228    let Ok(root) = std::fs::canonicalize(root) else {
2229        return false;
2230    };
2231    let Some(store) = canonical_safe_store(base, relative_store) else {
2232        return false;
2233    };
2234    let Ok(relative) = root.strip_prefix(store) else {
2235        return false;
2236    };
2237    let mut components = relative.components();
2238    components.next().is_some() && components.next().is_none()
2239}
2240
2241fn canonical_safe_store(base: &Path, relative_store: &Path) -> Option<PathBuf> {
2242    let base = std::fs::canonicalize(base).ok()?;
2243    let store = std::fs::canonicalize(base.join(relative_store)).ok()?;
2244    (store == base.join(relative_store) && store.starts_with(&base)).then_some(store)
2245}
2246
2247fn canonical_managed_store_for_target(target: &Path) -> Option<PathBuf> {
2248    let parent = std::fs::canonicalize(target.parent()?).ok()?;
2249    if let Ok(agent) = crate::config::agent_dir() {
2250        if canonical_safe_store(&agent, Path::new("packages")).as_ref() == Some(&parent) {
2251            return Some(parent);
2252        }
2253    }
2254    if let Some(home) = dirs::home_dir() {
2255        if canonical_safe_store(&home, Path::new(".pi/agent/packages")).as_ref() == Some(&parent) {
2256            return Some(parent);
2257        }
2258    }
2259
2260    let project_config = parent.parent()?;
2261    let config_name = project_config.file_name()?;
2262    if config_name != ".rpi" && config_name != ".pi" {
2263        return None;
2264    }
2265    let project = std::fs::canonicalize(project_config.parent()?).ok()?;
2266    let expected = project.join(config_name).join("packages");
2267    (parent == expected).then_some(parent)
2268}
2269
2270fn target_is_missing_without_symlink(target: &Path) -> bool {
2271    std::fs::symlink_metadata(target)
2272        .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound)
2273}
2274
2275fn target_is_package_at_store(target: &Path, store: &Path) -> bool {
2276    let Some(target_parent) = target.parent() else {
2277        return false;
2278    };
2279    let Ok(parent) = std::fs::canonicalize(target_parent) else {
2280        return false;
2281    };
2282    let Some(leaf) = target.file_name().and_then(|name| name.to_str()) else {
2283        return false;
2284    };
2285    let valid_parent = (parent == store && !leaf.starts_with('@'))
2286        || (!leaf.starts_with('@')
2287            && parent
2288                .file_name()
2289                .and_then(|name| name.to_str())
2290                .is_some_and(|name| name.starts_with('@') && name.len() > 1)
2291            && parent.parent() == Some(store));
2292    if !valid_parent {
2293        return false;
2294    }
2295    match std::fs::canonicalize(target) {
2296        Ok(root) => root.parent() == Some(parent.as_path()),
2297        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2298            target_is_missing_without_symlink(target)
2299        }
2300        Err(_) => false,
2301    }
2302}
2303
2304fn is_known_npm_package_target(target: &Path) -> bool {
2305    if let Ok(agent) = crate::config::agent_dir() {
2306        if canonical_safe_store(&agent, Path::new("npm/node_modules"))
2307            .is_some_and(|store| target_is_package_at_store(target, &store))
2308        {
2309            return true;
2310        }
2311    }
2312    if let Some(home) = dirs::home_dir() {
2313        if canonical_safe_store(&home, Path::new(".pi/agent/npm/node_modules"))
2314            .is_some_and(|store| target_is_package_at_store(target, &store))
2315        {
2316            return true;
2317        }
2318    }
2319
2320    let Some(target_parent) = target.parent() else {
2321        return false;
2322    };
2323    let Ok(parent) = std::fs::canonicalize(target_parent) else {
2324        return false;
2325    };
2326    let store = if parent
2327        .file_name()
2328        .and_then(|name| name.to_str())
2329        .is_some_and(|name| name.starts_with('@') && name.len() > 1)
2330    {
2331        let Some(store) = parent.parent() else {
2332            return false;
2333        };
2334        store
2335    } else {
2336        parent.as_path()
2337    };
2338    if store.file_name() != Some(std::ffi::OsStr::new("node_modules"))
2339        || store.parent().and_then(Path::file_name) != Some(std::ffi::OsStr::new("npm"))
2340        || store
2341            .parent()
2342            .and_then(Path::parent)
2343            .and_then(Path::file_name)
2344            != Some(std::ffi::OsStr::new(".pi"))
2345    {
2346        return false;
2347    }
2348    let Some(project) = store.parent().and_then(Path::parent).and_then(Path::parent) else {
2349        return false;
2350    };
2351    let Ok(project) = std::fs::canonicalize(project) else {
2352        return false;
2353    };
2354    store == project.join(".pi/npm/node_modules") && target_is_package_at_store(target, store)
2355}
2356
2357fn is_known_git_package_target(target: &Path) -> bool {
2358    if !target.is_absolute() {
2359        return false;
2360    }
2361    if let Ok(agent) = crate::config::agent_dir() {
2362        if target_is_git_package_at_store(target, &agent.join("git")) {
2363            return true;
2364        }
2365    }
2366    if let Some(home) = dirs::home_dir() {
2367        if target_is_git_package_at_store(target, &home.join(".pi/agent/git")) {
2368            return true;
2369        }
2370    }
2371    target.ancestors().any(|store| {
2372        store.file_name() == Some(std::ffi::OsStr::new("git"))
2373            && store
2374                .parent()
2375                .and_then(Path::file_name)
2376                .is_some_and(|name| name == ".pi" || name == ".rpi")
2377            && target_is_git_package_at_store(target, store)
2378    })
2379}
2380
2381fn target_is_git_package_at_store(target: &Path, store: &Path) -> bool {
2382    let Ok(canonical_store) = std::fs::canonicalize(store).map(normalize_config_path) else {
2383        return false;
2384    };
2385    if !paths_equal(&canonical_store, store) {
2386        return false;
2387    }
2388    let Some(parent) = target.parent() else {
2389        return false;
2390    };
2391    let Ok(canonical_parent) = std::fs::canonicalize(parent).map(normalize_config_path) else {
2392        return false;
2393    };
2394    if !paths_equal(&canonical_parent, parent)
2395        || !path_is_within(&canonical_parent, &canonical_store)
2396    {
2397        return false;
2398    }
2399    let store_depth = store.components().count();
2400    let relative = target.components().skip(store_depth).collect::<Vec<_>>();
2401    if relative.len() < 3
2402        || relative
2403            .iter()
2404            .any(|component| !matches!(component, std::path::Component::Normal(_)))
2405    {
2406        return false;
2407    }
2408    match std::fs::symlink_metadata(target) {
2409        Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
2410            std::fs::canonicalize(target)
2411                .map(normalize_config_path)
2412                .is_ok_and(|canonical| paths_equal(&canonical, target))
2413        }
2414        Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
2415        _ => false,
2416    }
2417}
2418
2419fn is_known_package_target(target: &Path) -> bool {
2420    if let Some(store) = canonical_managed_store_for_target(target) {
2421        return match std::fs::canonicalize(target) {
2422            Ok(root) => root.parent() == Some(store.as_path()),
2423            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2424                target_is_missing_without_symlink(target)
2425            }
2426            Err(_) => false,
2427        };
2428    }
2429    is_known_npm_package_target(target) || is_known_git_package_target(target)
2430}
2431
2432fn plan_settings_removal(
2433    cwd: &Path,
2434    spec: &str,
2435    root: Option<&Path>,
2436    global: bool,
2437) -> Result<(crate::settings::Settings, usize), String> {
2438    let mut settings = load_settings_for_install(cwd, global).map_err(|error| error.to_string())?;
2439    let Some(packages) = settings.packages.as_mut() else {
2440        return Ok((settings, 0));
2441    };
2442    let removed = remove_matching_settings_entries(cwd, packages, spec, root, global);
2443    if packages.is_empty() {
2444        settings.packages = None;
2445    }
2446    Ok((settings, removed))
2447}
2448
2449fn remove_matching_settings_entries(
2450    cwd: &Path,
2451    packages: &mut Vec<crate::settings::PackageSetting>,
2452    spec: &str,
2453    root: Option<&Path>,
2454    global: bool,
2455) -> usize {
2456    let before = packages.len();
2457    packages.retain(|entry| {
2458        let source = entry.source();
2459        if package_settings_sources_match(cwd, source, spec) {
2460            return false;
2461        }
2462        let matches_root = root.is_some_and(|root| {
2463            resolve_settings_source_root(cwd, source, global)
2464                .is_some_and(|candidate| paths_equal(&candidate, root))
2465        });
2466        !matches_root
2467    });
2468    before - packages.len()
2469}
2470
2471fn package_settings_sources_match(cwd: &Path, existing: &str, input: &str) -> bool {
2472    let existing = existing.trim();
2473    let input = input.trim();
2474    if existing == input {
2475        return true;
2476    }
2477    if let Some(existing_npm) = existing.strip_prefix("npm:") {
2478        if is_npm_uninstall_spec(cwd, input) {
2479            let existing_name = npm_module_name(existing_npm);
2480            let input_name = npm_module_name(input);
2481            return !existing_name.is_empty()
2482                && !input_name.is_empty()
2483                && existing_name.eq_ignore_ascii_case(&input_name);
2484        }
2485        return false;
2486    }
2487    match (
2488        crate::packages::parse_git_source(existing),
2489        crate::packages::parse_git_source(input),
2490    ) {
2491        (Some(existing), Some(input)) => {
2492            return existing.host == input.host && existing.path == input.path;
2493        }
2494        (Some(_), None) | (None, Some(_)) => return false,
2495        (None, None) => {}
2496    }
2497    false
2498}
2499
2500fn resolve_settings_source_root(cwd: &Path, source: &str, global: bool) -> Option<PathBuf> {
2501    if source.starts_with("npm:") || is_git_install_spec(source) {
2502        return find_installed_root(
2503            cwd,
2504            &UninstallOptions {
2505                spec: source.to_string(),
2506                global,
2507            },
2508        );
2509    }
2510    let raw = source.strip_prefix("file:").unwrap_or(source).trim();
2511    if raw.is_empty() {
2512        return None;
2513    }
2514    let path = PathBuf::from(raw);
2515    let mut candidates = Vec::new();
2516    if path.is_absolute() {
2517        candidates.push(path);
2518    } else if global {
2519        if let Ok(agent) = crate::config::agent_dir() {
2520            candidates.push(agent.join(&path));
2521        }
2522        if let Some(home) = dirs::home_dir() {
2523            candidates.push(home.join(".pi/agent").join(&path));
2524        }
2525    } else {
2526        candidates.push(cwd.join(".rpi").join(&path));
2527        candidates.push(cwd.join(".pi").join(&path));
2528        candidates.push(cwd.join(&path));
2529    }
2530    candidates.into_iter().find_map(|candidate| {
2531        let candidate = if candidate.is_file()
2532            && candidate.file_name().and_then(|name| name.to_str()) == Some("package.json")
2533        {
2534            candidate.parent()?.to_path_buf()
2535        } else {
2536            candidate
2537        };
2538        std::fs::canonicalize(candidate)
2539            .ok()
2540            .map(normalize_config_path)
2541    })
2542}
2543
2544fn npm_module_name(spec: &str) -> String {
2545    crate::packages::parse_npm_package_spec(spec)
2546        .map(|parsed| parsed.install_name)
2547        .unwrap_or_default()
2548}
2549
2550fn install_git(
2551    cwd: &Path,
2552    global: bool,
2553    spec: &str,
2554    force: bool,
2555    npm_command: &NpmCommand,
2556) -> Result<PathBuf, String> {
2557    install_git_with_npm_mode(
2558        cwd,
2559        global,
2560        spec,
2561        force,
2562        npm_command,
2563        NpmExecutionMode::Interactive,
2564    )
2565}
2566
2567fn install_git_with_npm_mode(
2568    cwd: &Path,
2569    global: bool,
2570    spec: &str,
2571    force: bool,
2572    npm_command: &NpmCommand,
2573    npm_mode: NpmExecutionMode,
2574) -> Result<PathBuf, String> {
2575    let raw = match spec.strip_prefix("git:") {
2576        Some(rest) if !rest.starts_with("//") => rest.trim(),
2577        _ => spec.trim(),
2578    };
2579    let (repo, revision) = split_git_install_ref(raw);
2580    if revision
2581        .as_deref()
2582        .is_some_and(|value| !is_safe_git_revision(value))
2583    {
2584        return Err("refusing invalid git revision from package settings".to_string());
2585    }
2586    let url = normalize_git_clone_url(&repo)?;
2587    let parsed = crate::packages::parse_git_source(spec)
2588        .ok_or_else(|| format!("invalid git package source `{spec}`"))?;
2589    let install_root = if global {
2590        crate::config::agent_dir()
2591            .map(|agent| agent.join("git"))
2592            .map_err(|error| error.to_string())?
2593    } else {
2594        cwd.join(".pi/git")
2595    };
2596    let target = install_root.join(&parsed.host).join(&parsed.path);
2597    let destination = target
2598        .parent()
2599        .ok_or_else(|| format!("git package target has no parent: {}", target.display()))?;
2600    prepare_managed_destination(destination)?;
2601    ensure_managed_store_ignore(&install_root)?;
2602    validate_install_target(destination, &target)?;
2603    let update_marker = git_update_marker_path(&target)?;
2604    let had_update_marker = validate_git_update_marker(&update_marker)?;
2605    if target.exists() {
2606        if !force {
2607            return Err(format!(
2608                "{} already exists; use --force to replace it",
2609                target.display()
2610            ));
2611        }
2612    }
2613    let stage = tempfile::Builder::new()
2614        .prefix(".rpi-git-stage-")
2615        .tempdir_in(destination)
2616        .map_err(|error| format!("could not create git staging directory: {error}"))?;
2617    let prepared = stage.path().join("package");
2618    clone_git_checkout(&url, revision.as_deref(), &prepared)?;
2619    install_git_dependencies(&prepared, npm_command, npm_mode)?;
2620    crate::packages::remove_package_source_marker(&prepared)?;
2621    let _lock = PackageSwapLock::acquire(&target)?;
2622    validate_install_target(destination, &target)?;
2623    recover_interrupted_swap(&target)?;
2624    validate_install_target(destination, &target)?;
2625    swap_prepared_dir(&prepared, &target, force)?;
2626    if had_update_marker {
2627        std::fs::remove_file(&update_marker).map_err(|error| {
2628            format!(
2629                "installed git package but could not remove stale update marker {}: {error}",
2630                update_marker.display()
2631            )
2632        })?;
2633    }
2634    Ok(target)
2635}
2636
2637fn clone_git_checkout(url: &str, revision: Option<&str>, target: &Path) -> Result<(), String> {
2638    let expected = crate::packages::parse_git_source(url)
2639        .filter(|source| source.revision.is_none())
2640        .ok_or_else(|| "refusing to clone an invalid git source".to_string())?;
2641    let clone_cwd = target
2642        .parent()
2643        .ok_or_else(|| format!("git staging target has no parent: {}", target.display()))?;
2644    let mut command = hardened_git_command();
2645    let status = command
2646        // Native Pi performs a full clone. Besides matching that behavior, it
2647        // keeps non-default branches available for the checkout below.
2648        .args(["clone", "--no-recurse-submodules", "--"])
2649        .arg(url)
2650        .arg(target)
2651        // Never let an invoking project's repository config participate in a
2652        // managed clone. The staging parent is newly allocated by rpi.
2653        .current_dir(clone_cwd)
2654        .env("GIT_CEILING_DIRECTORIES", clone_cwd)
2655        .stdin(Stdio::inherit())
2656        .stdout(Stdio::inherit())
2657        .stderr(Stdio::inherit())
2658        .status()
2659        .map_err(|error| format!("could not execute hardened git clone: {error}"))?;
2660    if !status.success() {
2661        return Err(format!("git clone exited with {status}"));
2662    }
2663    validate_cloned_git_origin(target, url, &expected)?;
2664    let Some(revision) = revision else {
2665        validate_git_head(target, None)?;
2666        return Ok(());
2667    };
2668    checkout_git_revision(target, revision)?;
2669    validate_cloned_git_origin(target, url, &expected)?;
2670    validate_git_head(target, Some(revision))
2671}
2672
2673fn fetch_git_revision(root: &Path, origin: &str, revision: &str) -> Result<(), String> {
2674    if !is_safe_git_revision(revision) {
2675        return Err("refusing invalid git revision from package settings".to_string());
2676    }
2677    let mut command = hardened_git_command();
2678    let status = command
2679        .args(["fetch", "--depth", "1", "--no-recurse-submodules", "--"])
2680        .arg(origin)
2681        .arg(revision)
2682        .current_dir(root)
2683        .stdin(Stdio::inherit())
2684        .stdout(Stdio::inherit())
2685        .stderr(Stdio::inherit())
2686        .status()
2687        .map_err(|error| format!("could not execute hardened git fetch: {error}"))?;
2688    if !status.success() {
2689        return Err(format!("git fetch exited with {status}"));
2690    }
2691    Ok(())
2692}
2693
2694fn checkout_git_revision(root: &Path, revision: &str) -> Result<(), String> {
2695    let mut command = hardened_git_command();
2696    let status = command
2697        .args(["checkout", revision])
2698        .current_dir(root)
2699        .stdin(Stdio::inherit())
2700        .stdout(Stdio::inherit())
2701        .stderr(Stdio::inherit())
2702        .status()
2703        .map_err(|error| format!("could not execute hardened git checkout: {error}"))?;
2704    if !status.success() {
2705        return Err(format!("git checkout exited with {status}"));
2706    }
2707    Ok(())
2708}
2709
2710fn validate_cloned_git_origin(
2711    root: &Path,
2712    requested_url: &str,
2713    expected: &crate::packages::GitSpec,
2714) -> Result<(), String> {
2715    let canonical_root = std::fs::canonicalize(root)
2716        .map(normalize_config_path)
2717        .map_err(|error| format!("could not resolve cloned git package: {error}"))?;
2718    if !paths_equal(&canonical_root, root)
2719        || !is_real_directory(root)
2720        || !is_real_git_metadata(&root.join(".git"))
2721    {
2722        return Err("refusing a redirected or incomplete cloned git checkout".to_string());
2723    }
2724    let origin = validated_git_origin(root, expected)?;
2725    if origin != requested_url {
2726        return Err("refusing cloned git checkout whose origin changed during clone".to_string());
2727    }
2728    Ok(())
2729}
2730
2731fn validate_git_head(root: &Path, revision: Option<&str>) -> Result<(), String> {
2732    let head = validated_git_head(root)?;
2733    if let Some(revision) = revision {
2734        let commit_ref = format!("{revision}^{{commit}}");
2735        let selected = run_git_capture(root, &["rev-parse", "--verify", &commit_ref])?;
2736        let selected = normalized_git_commit(&selected)
2737            .ok_or_else(|| "selected git revision is not a commit".to_string())?;
2738        if selected != head {
2739            return Err("git checkout did not select the requested revision".to_string());
2740        }
2741    }
2742    Ok(())
2743}
2744
2745fn validated_git_head(root: &Path) -> Result<String, String> {
2746    let head = run_git_capture(root, &["rev-parse", "--verify", "HEAD^{commit}"])?;
2747    normalized_git_commit(&head).ok_or_else(|| "git checkout has an invalid HEAD".to_string())
2748}
2749
2750fn normalized_git_commit(value: &str) -> Option<String> {
2751    let value = value.trim();
2752    ((value.len() == 40 || value.len() == 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()))
2753        .then(|| value.to_ascii_lowercase())
2754}
2755
2756pub(crate) fn install_missing_git_package(
2757    cwd: &Path,
2758    global: bool,
2759    spec: &str,
2760    npm_command: &NpmCommand,
2761) -> Result<PathBuf, String> {
2762    install_git(cwd, global, spec, false, npm_command)
2763}
2764
2765pub(crate) fn install_missing_git_package_for_startup(
2766    cwd: &Path,
2767    global: bool,
2768    spec: &str,
2769    npm_command: &NpmCommand,
2770) -> Result<PathBuf, String> {
2771    install_git_with_npm_mode(
2772        cwd,
2773        global,
2774        spec,
2775        false,
2776        npm_command,
2777        NpmExecutionMode::StartupRemediation,
2778    )
2779}
2780
2781fn install_git_dependencies(
2782    root: &Path,
2783    npm_command: &NpmCommand,
2784    npm_mode: NpmExecutionMode,
2785) -> Result<(), String> {
2786    install_production_dependencies_with_mode(root, npm_command, npm_mode)
2787}
2788
2789fn ensure_managed_store_ignore(root: &Path) -> Result<(), String> {
2790    ensure_real_directory_tree(root)?;
2791    let ignore = root.join(".gitignore");
2792    match std::fs::symlink_metadata(&ignore) {
2793        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err(format!(
2794            "managed store .gitignore must be a regular file: {}",
2795            ignore.display()
2796        )),
2797        Ok(_) => Ok(()),
2798        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2799            std::fs::write(&ignore, "*\n!.gitignore\n")
2800                .map_err(|error| format!("could not create managed store .gitignore: {error}"))
2801        }
2802        Err(error) => Err(format!(
2803            "could not inspect managed store .gitignore: {error}"
2804        )),
2805    }
2806}
2807
2808fn split_git_install_ref(raw: &str) -> (String, Option<String>) {
2809    let path_start = if raw.starts_with("git@") {
2810        raw.find(':').map(|index| index + 1)
2811    } else if let Some(scheme_end) = raw.find("://") {
2812        let authority_start = scheme_end + 3;
2813        raw[authority_start..]
2814            .find('/')
2815            .map(|index| authority_start + index + 1)
2816    } else {
2817        raw.find('/').map(|index| index + 1)
2818    };
2819    let Some(path_start) = path_start else {
2820        return (raw.to_string(), None);
2821    };
2822    let Some(offset) = raw[path_start..].find('@') else {
2823        return (raw.to_string(), None);
2824    };
2825    let separator = path_start + offset;
2826    let repo = &raw[..separator];
2827    let revision = &raw[separator + 1..];
2828    if repo.is_empty() || revision.is_empty() {
2829        return (raw.to_string(), None);
2830    }
2831    (repo.to_string(), Some(revision.to_string()))
2832}
2833
2834fn normalize_git_clone_url(repo: &str) -> Result<String, String> {
2835    let repo = repo.trim();
2836    if repo.is_empty() {
2837        return Err("git repository URL must not be empty".to_string());
2838    }
2839    if repo.contains('\0')
2840        || repo.contains('\\')
2841        || repo.chars().any(|ch| ch.is_control() || ch.is_whitespace())
2842    {
2843        return Err(format!("invalid git repository source `{repo}`"));
2844    }
2845    if let Some(index) = repo.find("://") {
2846        let scheme = repo[..index].to_ascii_lowercase();
2847        if !matches!(scheme.as_str(), "http" | "https" | "ssh" | "git") {
2848            return Err(format!("unsupported git transport `{scheme}`"));
2849        }
2850        let authority_and_path = &repo[index + 3..];
2851        let Some((_, path)) = authority_and_path.split_once('/') else {
2852            return Err(format!("invalid git repository source `{repo}`"));
2853        };
2854        if path
2855            .split('/')
2856            .any(|part| part.is_empty() || part == "." || part == "..")
2857        {
2858            return Err(format!("invalid git repository source `{repo}`"));
2859        }
2860        return Ok(repo.to_string());
2861    }
2862    if repo.starts_with("git@") {
2863        let Some((_, path)) = repo.split_once(':') else {
2864            return Err(format!("invalid git repository source `{repo}`"));
2865        };
2866        if path
2867            .split('/')
2868            .any(|part| part.is_empty() || part == "." || part == "..")
2869        {
2870            return Err(format!("invalid git repository source `{repo}`"));
2871        }
2872        return Ok(repo.to_string());
2873    }
2874    // Pi accepts hosted shorthand only with the `git:` source prefix. The
2875    // clone command itself needs an explicit transport for that form.
2876    if repo.split('/').count() >= 3
2877        && !repo
2878            .split('/')
2879            .any(|part| part.is_empty() || part == "." || part == "..")
2880    {
2881        return Ok(format!("https://{repo}"));
2882    }
2883    Err(format!("invalid git repository source `{repo}`"))
2884}
2885
2886#[cfg(test)]
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_are_enabled_by_default() {
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).unwrap());
3990        assert!(!project_trust_for_package_operation(&project, true).unwrap());
3991        crate::config::set_project_trust(&project, Some(false)).unwrap();
3992        assert!(!project_trust_for_package_operation(&project, false).unwrap());
3993        crate::config::set_project_trust(&project, Some(true)).unwrap();
3994        assert!(project_trust_for_package_operation(&project, false).unwrap());
3995    }
3996
3997    #[test]
3998    fn local_install_does_not_resolve_npm_command() {
3999        struct RestoreEnv(Option<std::ffi::OsString>);
4000        impl Drop for RestoreEnv {
4001            fn drop(&mut self) {
4002                match self.0.take() {
4003                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4004                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4005                }
4006            }
4007        }
4008
4009        let _lock = crate::config::test_support::env_lock().lock().unwrap();
4010        let tmp = tempfile::tempdir().unwrap();
4011        let agent = tmp.path().join("agent");
4012        std::fs::create_dir_all(&agent).unwrap();
4013        std::fs::write(agent.join("settings.json"), r#"{"npmCommand":[""]}"#).unwrap();
4014        let _restore = RestoreEnv(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4015        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4016
4017        assert!(
4018            npm_command_for_install(tmp.path(), true, InstallSpecKind::Local)
4019                .unwrap()
4020                .is_none()
4021        );
4022        assert!(npm_command_for_install(tmp.path(), true, InstallSpecKind::Npm).is_err());
4023    }
4024
4025    #[test]
4026    fn uninstall_lookup_does_not_read_untrusted_project_settings() {
4027        struct RestoreEnv {
4028            previous: Option<std::ffi::OsString>,
4029        }
4030        impl Drop for RestoreEnv {
4031            fn drop(&mut self) {
4032                match self.previous.take() {
4033                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4034                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4035                }
4036            }
4037        }
4038
4039        let _lock = crate::config::test_support::env_lock().lock().unwrap();
4040        let tmp = tempfile::tempdir().unwrap();
4041        let agent = tmp.path().join("agent");
4042        let project = tmp.path().join("project");
4043        let external = tmp.path().join("external-package");
4044        std::fs::create_dir_all(project.join(".rpi")).unwrap();
4045        std::fs::create_dir_all(&external).unwrap();
4046        std::fs::write(external.join("package.json"), r#"{"name":"external-name"}"#).unwrap();
4047        std::fs::write(
4048            project.join(".rpi/settings.json"),
4049            serde_json::to_vec(&serde_json::json!({
4050                "packages": [{
4051                    "source": format!("file:{}", external.display()),
4052                    "autoload": false
4053                }]
4054            }))
4055            .unwrap(),
4056        )
4057        .unwrap();
4058        let _restore = RestoreEnv {
4059            previous: std::env::var_os(crate::config::CONFIG_DIR_ENV),
4060        };
4061        std::env::set_var(crate::config::CONFIG_DIR_ENV, agent);
4062
4063        let options = UninstallOptions {
4064            spec: "external-name".to_string(),
4065            global: false,
4066        };
4067        assert_eq!(find_installed_root(&project, &options), None);
4068    }
4069
4070    #[cfg(windows)]
4071    #[test]
4072    fn normalizes_windows_verbatim_drive_path() {
4073        assert_eq!(
4074            normalize_config_path(PathBuf::from(r"\\?\C:\packages\demo")),
4075            PathBuf::from(r"C:\packages\demo")
4076        );
4077    }
4078
4079    #[cfg(windows)]
4080    #[test]
4081    fn normalizes_windows_verbatim_unc_path() {
4082        assert_eq!(
4083            normalize_config_path(PathBuf::from(r"\\?\UNC\server\share\demo")),
4084            PathBuf::from(r"\\server\share\demo")
4085        );
4086    }
4087
4088    #[test]
4089    fn rejects_missing_spec() {
4090        assert!(parse_args(&[]).is_err());
4091    }
4092
4093    #[test]
4094    fn parses_uninstall_specs_and_flags() {
4095        let options = parse_uninstall_args(&["--global".into(), "npm:@scope/pkg".into()]).unwrap();
4096        assert!(options.global);
4097        assert_eq!(options.spec, "npm:@scope/pkg");
4098    }
4099
4100    #[test]
4101    fn rejects_multiple_uninstall_specs() {
4102        assert!(parse_uninstall_args(&["a".into(), "b".into()]).is_err());
4103    }
4104
4105    #[test]
4106    fn scoped_npm_update_replaces_the_supplied_root_in_place() {
4107        let tmp = tempfile::tempdir().unwrap();
4108        let root = tmp.path().join(".pi/npm/node_modules/@scope/pkg");
4109        std::fs::create_dir_all(&root).unwrap();
4110        std::fs::write(root.join("old.txt"), "old").unwrap();
4111        assert!(is_known_npm_package_target(&root));
4112
4113        let result = install_npm_at_with(
4114            &root,
4115            "npm:@scope/pkg@latest",
4116            "npm:@scope/pkg",
4117            true,
4118            &NpmCommand::from_argv(None).unwrap(),
4119            |stage, spec| {
4120                assert_eq!(spec, "@scope/pkg@latest");
4121                let staged = stage.join("node_modules/@scope/pkg");
4122                std::fs::create_dir_all(&staged).unwrap();
4123                std::fs::write(
4124                    staged.join("package.json"),
4125                    r#"{"name":"@scope/pkg","version":"1.0.0"}"#,
4126                )
4127                .unwrap();
4128                std::fs::write(staged.join("new.txt"), "new").unwrap();
4129                Ok(())
4130            },
4131        )
4132        .unwrap();
4133
4134        assert_eq!(result, root);
4135        assert!(root.join("new.txt").is_file());
4136        assert!(!root.join("old.txt").exists());
4137        assert!(!tmp
4138            .path()
4139            .join(".pi/npm/node_modules/@scope/scope__pkg")
4140            .exists());
4141        let marker: serde_json::Value = serde_json::from_str(
4142            &std::fs::read_to_string(root.join(".rpi-package-source.json")).unwrap(),
4143        )
4144        .unwrap();
4145        assert_eq!(marker["kind"], "npm");
4146        assert_eq!(marker["spec"], "npm:@scope/pkg");
4147    }
4148
4149    #[test]
4150    fn preparation_failure_leaves_existing_package_untouched() {
4151        let tmp = tempfile::tempdir().unwrap();
4152        let source = tmp.path().join("source");
4153        let target = tmp.path().join(".rpi/packages/demo");
4154        std::fs::create_dir_all(&source).unwrap();
4155        std::fs::create_dir_all(&target).unwrap();
4156        std::fs::write(source.join("new.txt"), "new").unwrap();
4157        std::fs::write(target.join("old.txt"), "old").unwrap();
4158
4159        let error = replace_dir_prepared(&source, &target, true, |_prepared| {
4160            Err("dependency install failed".to_string())
4161        })
4162        .unwrap_err();
4163
4164        assert_eq!(error, "dependency install failed");
4165        assert_eq!(
4166            std::fs::read_to_string(target.join("old.txt")).unwrap(),
4167            "old"
4168        );
4169        assert!(!target.join("new.txt").exists());
4170    }
4171
4172    #[test]
4173    fn stale_lock_file_does_not_block_a_new_advisory_lock() {
4174        let tmp = tempfile::tempdir().unwrap();
4175        let target = tmp.path().join("packages/demo");
4176        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
4177        let lock_path = target.parent().unwrap().join(".demo.rpi-update.lock");
4178        std::fs::write(&lock_path, "left by interrupted process").unwrap();
4179
4180        let lock = PackageSwapLock::acquire(&target).unwrap();
4181        drop(lock);
4182        assert!(lock_path.is_file());
4183        drop(PackageSwapLock::acquire(&target).unwrap());
4184    }
4185
4186    #[test]
4187    fn interrupted_swap_recovers_missing_target_and_cleans_stale_backup() {
4188        let tmp = tempfile::tempdir().unwrap();
4189        let target = tmp.path().join("packages/demo");
4190        let backup = tmp
4191            .path()
4192            .join("packages/.demo.rpi-backup-00000000000000000000000000000001");
4193        std::fs::create_dir_all(&backup).unwrap();
4194        std::fs::write(backup.join("old.txt"), "old").unwrap();
4195        std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4196
4197        recover_interrupted_swap(&target).unwrap();
4198        assert_eq!(
4199            std::fs::read_to_string(target.join("old.txt")).unwrap(),
4200            "old"
4201        );
4202        assert!(!backup.exists());
4203
4204        let stale_backup = tmp
4205            .path()
4206            .join("packages/.demo.rpi-backup-00000000000000000000000000000002");
4207        std::fs::create_dir_all(&stale_backup).unwrap();
4208        std::fs::write(stale_backup.join("stale.txt"), "stale").unwrap();
4209        recover_interrupted_swap(&target).unwrap();
4210        assert!(target.join("old.txt").is_file());
4211        assert!(!stale_backup.exists());
4212
4213        for suffix in ["manual", "1234", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"] {
4214            let unrelated = tmp
4215                .path()
4216                .join(format!("packages/.demo.rpi-backup-{suffix}"));
4217            std::fs::create_dir_all(&unrelated).unwrap();
4218            recover_interrupted_swap(&target).unwrap();
4219            assert!(unrelated.is_dir(), "suffix={suffix}");
4220        }
4221
4222        let absent = tmp.path().join("packages/absent");
4223        let manual = tmp.path().join("packages/.absent.rpi-backup-manual");
4224        std::fs::create_dir_all(&manual).unwrap();
4225        recover_interrupted_swap(&absent).unwrap();
4226        assert!(!absent.exists());
4227        assert!(manual.is_dir());
4228    }
4229
4230    #[test]
4231    fn managed_uninstall_root_requires_a_canonical_direct_store_child() {
4232        let tmp = tempfile::tempdir().unwrap();
4233        let root = tmp.path().join(".rpi/packages/demo");
4234        let nested = root.join("nested");
4235        let external = tmp.path().join("node_modules/demo");
4236        std::fs::create_dir_all(&nested).unwrap();
4237        std::fs::create_dir_all(&external).unwrap();
4238
4239        assert!(is_direct_package_below_store(
4240            &root,
4241            tmp.path(),
4242            Path::new(".rpi/packages")
4243        ));
4244        assert!(!is_direct_package_below_store(
4245            &nested,
4246            tmp.path(),
4247            Path::new(".rpi/packages")
4248        ));
4249        assert!(!is_direct_package_below_store(
4250            &external,
4251            tmp.path(),
4252            Path::new(".rpi/packages")
4253        ));
4254    }
4255
4256    #[test]
4257    fn git_metadata_rejects_gitdir_pointer_files() {
4258        let tmp = tempfile::tempdir().unwrap();
4259        let git_file = tmp.path().join(".git");
4260        std::fs::write(&git_file, "gitdir: ../outside/.git\n").unwrap();
4261        assert!(!is_real_git_metadata(&git_file));
4262        std::fs::remove_file(&git_file).unwrap();
4263        std::fs::create_dir(&git_file).unwrap();
4264        assert!(is_real_git_metadata(&git_file));
4265    }
4266
4267    #[test]
4268    fn explicit_git_update_revision_uses_exact_fetch_and_fetch_head() {
4269        let tmp = tempfile::tempdir().unwrap();
4270        let prepared = tmp.path().join("prepared");
4271        let origin = "https://github.com/example/repo.git";
4272        let revision = "refs/pull/42/head";
4273        let calls = std::cell::RefCell::new(Vec::new());
4274
4275        prepare_git_checkout_for_update_with(
4276            &prepared,
4277            origin,
4278            Some(revision),
4279            Some(revision),
4280            |actual_origin, selected, actual_prepared| {
4281                assert_eq!(actual_origin, origin);
4282                assert_eq!(selected, None);
4283                assert_eq!(actual_prepared, prepared);
4284                calls.borrow_mut().push("clone".to_string());
4285                Ok(())
4286            },
4287            |actual_prepared, actual_origin, selected| {
4288                assert_eq!(actual_prepared, prepared);
4289                assert_eq!(actual_origin, origin);
4290                assert_eq!(selected, revision);
4291                calls.borrow_mut().push("fetch-exact".to_string());
4292                Ok(())
4293            },
4294            |actual_prepared, selected| {
4295                assert_eq!(actual_prepared, prepared);
4296                assert_eq!(selected, "FETCH_HEAD");
4297                calls.borrow_mut().push("checkout-fetch-head".to_string());
4298                Ok(())
4299            },
4300            |actual_prepared, selected| {
4301                assert_eq!(actual_prepared, prepared);
4302                assert_eq!(selected, Some("FETCH_HEAD"));
4303                calls.borrow_mut().push("validate-fetch-head".to_string());
4304                Ok(())
4305            },
4306        )
4307        .unwrap();
4308
4309        assert_eq!(
4310            calls.into_inner(),
4311            [
4312                "clone",
4313                "fetch-exact",
4314                "checkout-fetch-head",
4315                "validate-fetch-head"
4316            ]
4317        );
4318    }
4319
4320    #[test]
4321    fn unpinned_git_update_keeps_the_selected_branch_in_the_clone() {
4322        let tmp = tempfile::tempdir().unwrap();
4323        let prepared = tmp.path().join("prepared");
4324        let origin = "https://github.com/example/repo.git";
4325
4326        prepare_git_checkout_for_update_with(
4327            &prepared,
4328            origin,
4329            None,
4330            Some("release/v2"),
4331            |actual_origin, selected, actual_prepared| {
4332                assert_eq!(actual_origin, origin);
4333                assert_eq!(selected, Some("release/v2"));
4334                assert_eq!(actual_prepared, prepared);
4335                Ok(())
4336            },
4337            |_, _, _| panic!("unpinned update must not perform an exact fetch"),
4338            |_, _| panic!("unpinned update must not checkout FETCH_HEAD"),
4339            |_, _| panic!("clone validates its own selected revision"),
4340        )
4341        .unwrap();
4342    }
4343
4344    #[test]
4345    fn staged_git_update_build_failure_preserves_the_installed_checkout() {
4346        let tmp = tempfile::tempdir().unwrap();
4347        let store = tmp.path().join("store");
4348        let target = store.join("github.com/example/repo");
4349        let origin = "https://github.com/example/repo.git";
4350        let old_head = initialize_test_git_package(&target, origin, "old");
4351        std::fs::write(target.join("old-only.txt"), "keep\n").unwrap();
4352        let command = NpmCommand::from_argv(Some(&["unused".to_string()])).unwrap();
4353
4354        let error = stage_git_checkout_update_with(
4355            &target,
4356            &store,
4357            origin,
4358            None,
4359            &command,
4360            |prepared, expected_origin, revision| {
4361                assert_eq!(expected_origin, origin);
4362                assert_eq!(revision, None);
4363                initialize_test_git_package(prepared, origin, "new");
4364                Ok(())
4365            },
4366            |_prepared, _| Err("dependency build failed".to_string()),
4367        )
4368        .unwrap_err();
4369
4370        assert_eq!(error, "dependency build failed");
4371        assert_eq!(run_test_git(&target, &["rev-parse", "HEAD"]), old_head);
4372        assert_eq!(
4373            std::fs::read_to_string(target.join("state.txt"))
4374                .unwrap()
4375                .replace("\r\n", "\n"),
4376            "old\n"
4377        );
4378        assert!(target.join("old-only.txt").is_file());
4379        assert!(package_backup_dirs(&target).unwrap().is_empty());
4380        assert!(!target.parent().unwrap().read_dir().unwrap().any(|entry| {
4381            entry
4382                .ok()
4383                .and_then(|entry| entry.file_name().to_str().map(str::to_string))
4384                .is_some_and(|name| name.starts_with(".rpi-git-update-"))
4385        }));
4386    }
4387
4388    #[test]
4389    fn staged_git_update_swaps_only_after_validation_and_cleans_legacy_marker() {
4390        let tmp = tempfile::tempdir().unwrap();
4391        let store = tmp.path().join("store");
4392        let target = store.join("github.com/example/repo");
4393        let origin = "https://github.com/example/repo.git";
4394        let old_head = initialize_test_git_package(&target, origin, "old");
4395        std::fs::write(target.join("old-only.txt"), "remove\n").unwrap();
4396        let marker = git_update_marker_path(&target).unwrap();
4397        std::fs::write(&marker, "legacy in-place update\n").unwrap();
4398        let command = NpmCommand::from_argv(Some(&["unused".to_string()])).unwrap();
4399        let mut staged_head = None;
4400
4401        stage_git_checkout_update_with(
4402            &target,
4403            &store,
4404            origin,
4405            None,
4406            &command,
4407            |prepared, _, revision| {
4408                assert_eq!(revision, None);
4409                staged_head = Some(initialize_test_git_package(prepared, origin, "new"));
4410                Ok(())
4411            },
4412            |prepared, _| {
4413                std::fs::create_dir_all(prepared.join("node_modules/demo")).unwrap();
4414                Ok(())
4415            },
4416        )
4417        .unwrap();
4418
4419        assert_ne!(staged_head.as_deref(), Some(old_head.as_str()));
4420        assert_eq!(
4421            run_test_git(&target, &["rev-parse", "HEAD"]),
4422            staged_head.unwrap()
4423        );
4424        assert_eq!(
4425            std::fs::read_to_string(target.join("state.txt"))
4426                .unwrap()
4427                .replace("\r\n", "\n"),
4428            "new\n"
4429        );
4430        assert!(!target.join("old-only.txt").exists());
4431        assert!(target.join("node_modules/demo").is_dir());
4432        assert!(!marker.exists());
4433        assert!(package_backup_dirs(&target).unwrap().is_empty());
4434    }
4435
4436    #[test]
4437    fn git_swap_activation_failure_restores_the_previous_checkout() {
4438        let tmp = tempfile::tempdir().unwrap();
4439        let target = tmp.path().join("store/github.com/example/repo");
4440        let origin = "https://github.com/example/repo.git";
4441        let old_head = initialize_test_git_package(&target, origin, "old");
4442        let missing_prepared = target.parent().unwrap().join("missing-prepared");
4443
4444        let error = swap_prepared_dir(&missing_prepared, &target, true).unwrap_err();
4445
4446        assert!(error.contains("could not activate replacement"), "{error}");
4447        assert_eq!(run_test_git(&target, &["rev-parse", "HEAD"]), old_head);
4448        assert_eq!(
4449            std::fs::read_to_string(target.join("state.txt"))
4450                .unwrap()
4451                .replace("\r\n", "\n"),
4452            "old\n"
4453        );
4454        assert!(package_backup_dirs(&target).unwrap().is_empty());
4455    }
4456
4457    #[test]
4458    fn git_update_origin_gate_preserves_fixed_ref_for_a_matching_checkout() {
4459        fn git(cwd: &Path, args: &[&str]) {
4460            let status = Command::new("git")
4461                .args(args)
4462                .current_dir(cwd)
4463                .status()
4464                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4465            assert!(status.success(), "git {:?} exited with {status}", args);
4466        }
4467
4468        let tmp = tempfile::tempdir().unwrap();
4469        let store = tmp.path().join("store");
4470        let target = store.join("github.com/example/repo");
4471        std::fs::create_dir_all(&target).unwrap();
4472        git(&target, &["init", "--initial-branch", "main"]);
4473        git(
4474            &target,
4475            &[
4476                "remote",
4477                "add",
4478                "origin",
4479                "https://github.com/example/repo.git",
4480            ],
4481        );
4482
4483        let command = NpmCommand::from_argv(Some(&["unused".to_string()])).unwrap();
4484        let called = std::cell::Cell::new(false);
4485        update_git_package_with(
4486            &target,
4487            &store,
4488            "git:github.com/example/repo@release/v2",
4489            &command,
4490            |actual_root, origin, revision, _| {
4491                called.set(true);
4492                assert_eq!(actual_root, target);
4493                assert_eq!(origin, "https://github.com/example/repo.git");
4494                assert_eq!(revision, Some("release/v2"));
4495                Ok(())
4496            },
4497        )
4498        .unwrap();
4499        assert!(called.get());
4500    }
4501
4502    #[test]
4503    fn git_update_origin_mismatch_stops_before_network_or_checkout_changes() {
4504        fn git(cwd: &Path, args: &[&str]) -> String {
4505            let output = Command::new("git")
4506                .args(args)
4507                .current_dir(cwd)
4508                .output()
4509                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4510            assert!(
4511                output.status.success(),
4512                "git {:?} exited with {}",
4513                args,
4514                output.status
4515            );
4516            String::from_utf8_lossy(&output.stdout).trim().to_string()
4517        }
4518
4519        let tmp = tempfile::tempdir().unwrap();
4520        let store = tmp.path().join("store");
4521        let target = store.join("github.com/example/repo");
4522        std::fs::create_dir_all(&target).unwrap();
4523        git(&target, &["init", "--initial-branch", "main"]);
4524        git(
4525            &target,
4526            &["config", "user.email", "rpi-test@example.invalid"],
4527        );
4528        git(&target, &["config", "user.name", "rpi-test"]);
4529        std::fs::write(target.join("version.txt"), "unchanged\n").unwrap();
4530        git(&target, &["add", "version.txt"]);
4531        git(&target, &["commit", "-m", "initial"]);
4532        let head = git(&target, &["rev-parse", "HEAD"]);
4533
4534        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
4535        listener.set_nonblocking(true).unwrap();
4536        let origin = format!(
4537            "http://127.0.0.1:{}/attacker/repo.git",
4538            listener.local_addr().unwrap().port()
4539        );
4540        git(&target, &["remote", "add", "origin", &origin]);
4541
4542        let command = NpmCommand::from_argv(Some(&["unused".to_string()])).unwrap();
4543        let called = std::cell::Cell::new(false);
4544        let error = update_git_package_with(
4545            &target,
4546            &store,
4547            "git:github.com/example/repo@main",
4548            &command,
4549            |_, _, _, _| {
4550                called.set(true);
4551                Ok(())
4552            },
4553        )
4554        .unwrap_err();
4555
4556        assert!(error.contains("origin does not match"));
4557        assert!(!called.get());
4558        assert_eq!(git(&target, &["rev-parse", "HEAD"]), head);
4559        assert_eq!(
4560            std::fs::read_to_string(target.join("version.txt")).unwrap(),
4561            "unchanged\n"
4562        );
4563        assert_eq!(
4564            listener.accept().unwrap_err().kind(),
4565            std::io::ErrorKind::WouldBlock
4566        );
4567    }
4568
4569    #[test]
4570    fn git_update_origin_gate_rejects_missing_duplicate_and_dangerous_urls() {
4571        fn git(cwd: &Path, args: &[&str]) {
4572            let status = Command::new("git")
4573                .args(args)
4574                .current_dir(cwd)
4575                .status()
4576                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4577            assert!(status.success(), "git {:?} exited with {status}", args);
4578        }
4579
4580        let tmp = tempfile::tempdir().unwrap();
4581        let checkout = tmp.path().join("checkout");
4582        std::fs::create_dir_all(&checkout).unwrap();
4583        git(&checkout, &["init", "--initial-branch", "main"]);
4584        let expected = crate::packages::parse_git_source("git:github.com/example/repo").unwrap();
4585
4586        assert!(validated_git_origin(&checkout, &expected).is_err());
4587
4588        git(
4589            &checkout,
4590            &[
4591                "config",
4592                "--add",
4593                "remote.origin.url",
4594                "https://github.com/example/repo.git",
4595            ],
4596        );
4597        git(
4598            &checkout,
4599            &[
4600                "config",
4601                "--add",
4602                "remote.origin.url",
4603                "git@github.com:example/repo.git",
4604            ],
4605        );
4606        assert!(validated_git_origin(&checkout, &expected).is_err());
4607
4608        git(&checkout, &["config", "--unset-all", "remote.origin.url"]);
4609        git(
4610            &checkout,
4611            &["config", "remote.origin.url", "ext::sh -c 'echo unsafe'"],
4612        );
4613        assert!(validated_git_origin(&checkout, &expected).is_err());
4614
4615        git(
4616            &checkout,
4617            &[
4618                "config",
4619                "remote.origin.url",
4620                "https://github.com/example/repo.git ",
4621            ],
4622        );
4623        assert!(validated_git_origin(&checkout, &expected).is_err());
4624
4625        for mismatched in [
4626            "http://github.com/example/repo.git",
4627            "https://github.com:444/example/repo.git",
4628            "https://other-user@github.com/example/repo.git",
4629        ] {
4630            git(&checkout, &["config", "remote.origin.url", mismatched]);
4631            let error = validated_git_origin(&checkout, &expected).unwrap_err();
4632            assert!(
4633                error.contains("origin does not match"),
4634                "origin={mismatched}"
4635            );
4636        }
4637
4638        git(
4639            &checkout,
4640            &[
4641                "config",
4642                "remote.origin.url",
4643                "https://github.com:443/example/repo.git",
4644            ],
4645        );
4646        assert_eq!(
4647            validated_git_origin(&checkout, &expected).unwrap(),
4648            "https://github.com:443/example/repo.git"
4649        );
4650    }
4651
4652    #[test]
4653    fn git_update_rejects_external_core_worktree_before_fetch_reset_or_npm() {
4654        fn git(cwd: &Path, args: &[&str]) {
4655            let status = Command::new("git")
4656                .args(args)
4657                .current_dir(cwd)
4658                .status()
4659                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4660            assert!(status.success(), "git {:?} exited with {status}", args);
4661        }
4662
4663        let tmp = tempfile::tempdir().unwrap();
4664        let store = tmp.path().join("store");
4665        let target = store.join("github.com/example/repo");
4666        let outside = tmp.path().join("outside-worktree");
4667        std::fs::create_dir_all(&target).unwrap();
4668        std::fs::create_dir_all(&outside).unwrap();
4669        let sentinel = outside.join("must-survive.txt");
4670        std::fs::write(&sentinel, "unchanged\n").unwrap();
4671        git(&target, &["init", "--initial-branch", "main"]);
4672        git(
4673            &target,
4674            &[
4675                "remote",
4676                "add",
4677                "origin",
4678                "https://github.com/example/repo.git",
4679            ],
4680        );
4681        git(
4682            &target,
4683            &["config", "core.worktree", outside.to_str().unwrap()],
4684        );
4685
4686        let command = NpmCommand::from_argv(Some(&["must-not-run".to_string()])).unwrap();
4687        let called = std::cell::Cell::new(false);
4688        let error = update_git_package_with(
4689            &target,
4690            &store,
4691            "git:github.com/example/repo",
4692            &command,
4693            |_, _, _, _| {
4694                called.set(true);
4695                Ok(())
4696            },
4697        )
4698        .unwrap_err();
4699
4700        assert!(error.contains("unsafe local configuration"), "{error}");
4701        assert!(!called.get());
4702        assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "unchanged\n");
4703    }
4704
4705    #[test]
4706    fn git_update_origin_gate_rejects_command_capable_local_config() {
4707        fn git(cwd: &Path, args: &[&str]) {
4708            let status = Command::new("git")
4709                .args(args)
4710                .current_dir(cwd)
4711                .status()
4712                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4713            assert!(status.success(), "git {:?} exited with {status}", args);
4714        }
4715
4716        let expected = crate::packages::parse_git_source("git:github.com/example/repo").unwrap();
4717        for (key, value) in [
4718            ("url.https://evil.invalid/.insteadOf", "https://github.com/"),
4719            ("core.sshCommand", "malicious-ssh"),
4720            ("credential.helper", "!malicious-helper"),
4721            ("http.https://github.com.proxy", "http://evil.invalid"),
4722            ("http.curloptResolve", "github.com:443:127.0.0.1"),
4723            (
4724                "http.https://github.com.curloptResolve",
4725                "github.com:443:127.0.0.1",
4726            ),
4727            ("fetch.bundleURI", "file:///outside/repository.bundle"),
4728            ("include.path", "../untrusted-config"),
4729            ("filter.package.process", "malicious-filter"),
4730        ] {
4731            let tmp = tempfile::tempdir().unwrap();
4732            let checkout = tmp.path().join("checkout");
4733            std::fs::create_dir_all(&checkout).unwrap();
4734            git(&checkout, &["init", "--initial-branch", "main"]);
4735            git(
4736                &checkout,
4737                &[
4738                    "remote",
4739                    "add",
4740                    "origin",
4741                    "https://github.com/example/repo.git",
4742                ],
4743            );
4744            git(&checkout, &["config", key, value]);
4745
4746            let error = validated_git_origin(&checkout, &expected).unwrap_err();
4747            assert!(error.contains("unsafe local configuration"), "key={key}");
4748        }
4749    }
4750
4751    #[test]
4752    fn hardened_git_command_drops_environment_config_and_command_injection() {
4753        let tmp = tempfile::tempdir().unwrap();
4754        let status = Command::new("git")
4755            .args(["init", "--initial-branch", "main"])
4756            .current_dir(tmp.path())
4757            .status()
4758            .unwrap();
4759        assert!(status.success());
4760
4761        let injected_key = "url.https://attacker.invalid/.insteadOf";
4762        let mut command = Command::new("git");
4763        command
4764            .env("GIT_CONFIG_COUNT", "1")
4765            .env("GIT_CONFIG_KEY_0", injected_key)
4766            .env("GIT_CONFIG_VALUE_0", "https://github.com/")
4767            .env("GIT_SSH_COMMAND", "malicious-ssh");
4768        apply_hardened_git_environment(&mut command);
4769        let ssh_command = command
4770            .get_envs()
4771            .find(|(name, _)| *name == std::ffi::OsStr::new("GIT_SSH_COMMAND"));
4772        assert!(ssh_command.is_some_and(|(_, value)| value.is_none()));
4773
4774        let output = command
4775            .args(hardened_git_network_config_args())
4776            .args(["config", "--get-all", injected_key])
4777            .current_dir(tmp.path())
4778            .output()
4779            .unwrap();
4780        assert!(!output.status.success());
4781        assert!(output.stdout.is_empty());
4782    }
4783
4784    #[test]
4785    fn hardened_git_config_allows_only_known_credential_helpers() {
4786        for helper in [
4787            "cache",
4788            "libsecret",
4789            "manager",
4790            "manager-core",
4791            "osxkeychain",
4792            "store",
4793            "wincred",
4794        ] {
4795            assert!(is_safe_standard_credential_helper(helper));
4796        }
4797        for helper in [
4798            "!malicious-helper",
4799            "manager --arg",
4800            "/tmp/helper",
4801            "custom",
4802            "",
4803        ] {
4804            assert!(!is_safe_standard_credential_helper(helper));
4805        }
4806
4807        let args = hardened_git_network_config_args();
4808        let values = args
4809            .chunks_exact(2)
4810            .map(|pair| pair[1].as_str())
4811            .collect::<Vec<_>>();
4812        assert!(values.contains(&"protocol.allow=never"));
4813        assert!(values.contains(&"protocol.ext.allow=never"));
4814        assert!(values.contains(&"protocol.file.allow=never"));
4815        assert!(values.contains(&"credential.helper="));
4816        assert!(values.iter().all(|value| {
4817            value
4818                .strip_prefix("credential.helper=")
4819                .map_or(true, |helper| {
4820                    helper.is_empty() || is_safe_standard_credential_helper(helper)
4821                })
4822        }));
4823    }
4824
4825    #[test]
4826    fn cloned_checkout_validation_requires_exact_origin_and_valid_head() {
4827        fn git(cwd: &Path, args: &[&str]) {
4828            let status = Command::new("git")
4829                .args(args)
4830                .current_dir(cwd)
4831                .status()
4832                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4833            assert!(status.success(), "git {:?} exited with {status}", args);
4834        }
4835
4836        let tmp = tempfile::tempdir().unwrap();
4837        let checkout = tmp.path().join("checkout");
4838        std::fs::create_dir_all(&checkout).unwrap();
4839        git(&checkout, &["init", "--initial-branch", "main"]);
4840        git(
4841            &checkout,
4842            &["config", "user.email", "rpi-test@example.invalid"],
4843        );
4844        git(&checkout, &["config", "user.name", "rpi-test"]);
4845        std::fs::write(checkout.join("version.txt"), "one\n").unwrap();
4846        git(&checkout, &["add", "version.txt"]);
4847        git(&checkout, &["commit", "-m", "one"]);
4848        let requested = "https://github.com/example/repo.git";
4849        git(&checkout, &["remote", "add", "origin", requested]);
4850        let expected = crate::packages::parse_git_source(requested).unwrap();
4851
4852        validate_cloned_git_origin(&checkout, requested, &expected).unwrap();
4853        validate_git_head(&checkout, None).unwrap();
4854
4855        git(
4856            &checkout,
4857            &[
4858                "remote",
4859                "set-url",
4860                "origin",
4861                "ssh://git@github.com/example/repo.git",
4862            ],
4863        );
4864        let error = validate_cloned_git_origin(&checkout, requested, &expected).unwrap_err();
4865        assert!(error.contains("origin does not match"), "{error}");
4866    }
4867
4868    #[test]
4869    fn full_clone_can_checkout_a_non_default_branch() {
4870        fn git(cwd: &Path, args: &[&str]) {
4871            let status = Command::new("git")
4872                .args(args)
4873                .current_dir(cwd)
4874                .status()
4875                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4876            assert!(status.success(), "git {:?} exited with {status}", args);
4877        }
4878
4879        let temp = tempfile::tempdir().unwrap();
4880        let source = temp.path().join("source");
4881        let checkout = temp.path().join("checkout");
4882        std::fs::create_dir_all(&source).unwrap();
4883        git(&source, &["init", "--initial-branch", "main"]);
4884        git(
4885            &source,
4886            &["config", "user.email", "rpi-test@example.invalid"],
4887        );
4888        git(&source, &["config", "user.name", "rpi-test"]);
4889        std::fs::write(source.join("version.txt"), "main\n").unwrap();
4890        git(&source, &["add", "version.txt"]);
4891        git(&source, &["commit", "-m", "main"]);
4892        git(&source, &["checkout", "-b", "feature/branch"]);
4893        std::fs::write(source.join("version.txt"), "feature\n").unwrap();
4894        git(&source, &["add", "version.txt"]);
4895        git(&source, &["commit", "-m", "feature"]);
4896        git(&source, &["checkout", "main"]);
4897
4898        git(
4899            temp.path(),
4900            &[
4901                "-c",
4902                "core.autocrlf=false",
4903                "clone",
4904                source.to_str().unwrap(),
4905                checkout.to_str().unwrap(),
4906            ],
4907        );
4908        checkout_git_revision(&checkout, "feature/branch").unwrap();
4909        validate_git_head(&checkout, Some("feature/branch")).unwrap();
4910        assert_eq!(
4911            std::fs::read_to_string(checkout.join("version.txt"))
4912                .unwrap()
4913                .replace("\r\n", "\n"),
4914            "feature\n"
4915        );
4916        assert_eq!(
4917            run_git_capture(&checkout, &["branch", "--show-current"]).unwrap(),
4918            "feature/branch"
4919        );
4920    }
4921
4922    #[cfg(unix)]
4923    #[test]
4924    fn managed_uninstall_root_rejects_symlinked_store_escape() {
4925        let tmp = tempfile::tempdir().unwrap();
4926        let external_store = tmp.path().join("node_modules");
4927        let external_package = external_store.join("demo");
4928        std::fs::create_dir_all(&external_package).unwrap();
4929        std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
4930        std::os::unix::fs::symlink(&external_store, tmp.path().join(".rpi/packages")).unwrap();
4931
4932        assert!(!is_managed_package_root(tmp.path(), &external_package));
4933    }
4934
4935    #[cfg(unix)]
4936    #[test]
4937    fn npm_mutation_rejects_symlinked_target_escape() {
4938        let tmp = tempfile::tempdir().unwrap();
4939        let root = tmp.path().join("npm");
4940        let external = tmp.path().join("external/demo");
4941        std::fs::create_dir_all(root.join("node_modules")).unwrap();
4942        std::fs::create_dir_all(&external).unwrap();
4943        std::os::unix::fs::symlink(&external, root.join("node_modules/demo")).unwrap();
4944
4945        let error =
4946            validate_npm_target_before_mutation(&root, Path::new("demo"), false).unwrap_err();
4947        assert!(error.contains("outside its managed root"), "{error}");
4948    }
4949
4950    #[cfg(windows)]
4951    #[test]
4952    fn managed_uninstall_root_rejects_symlinked_store_escape() {
4953        let tmp = tempfile::tempdir().unwrap();
4954        let external_store = tmp.path().join("node_modules");
4955        let external_package = external_store.join("demo");
4956        std::fs::create_dir_all(&external_package).unwrap();
4957        std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
4958        if std::os::windows::fs::symlink_dir(&external_store, tmp.path().join(".rpi/packages"))
4959            .is_err()
4960        {
4961            return;
4962        }
4963
4964        assert!(!is_managed_package_root(tmp.path(), &external_package));
4965    }
4966
4967    #[cfg(windows)]
4968    #[test]
4969    fn npm_mutation_rejects_symlinked_target_escape() {
4970        let tmp = tempfile::tempdir().unwrap();
4971        let root = tmp.path().join("npm");
4972        let external = tmp.path().join("external/demo");
4973        std::fs::create_dir_all(root.join("node_modules")).unwrap();
4974        std::fs::create_dir_all(&external).unwrap();
4975        if std::os::windows::fs::symlink_dir(&external, root.join("node_modules/demo")).is_err() {
4976            return;
4977        }
4978
4979        let error =
4980            validate_npm_target_before_mutation(&root, Path::new("demo"), false).unwrap_err();
4981        assert!(error.contains("outside its managed root"), "{error}");
4982    }
4983}