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
2886fn git_package_name(repo: &str) -> String {
2887    let leaf = repo
2888        .trim_end_matches('/')
2889        .rsplit(['/', ':'])
2890        .next()
2891        .unwrap_or("package");
2892    let leaf = leaf.strip_suffix(".git").unwrap_or(leaf);
2893    safe_name(leaf)
2894}
2895
2896fn is_safe_git_revision(value: &str) -> bool {
2897    !value.is_empty()
2898        && !value.starts_with('-')
2899        && !value.starts_with('/')
2900        && !value.ends_with('/')
2901        && !value.contains('\\')
2902        && !value.contains('\0')
2903        && !value.contains("..")
2904        && !value.contains("@{")
2905        && !value.chars().any(|ch| {
2906            ch.is_control() || ch.is_whitespace() || matches!(ch, '~' | '^' | ':' | '?' | '*' | '[')
2907        })
2908        && !value
2909            .split('/')
2910            .any(|part| part.is_empty() || part == "." || part == "..")
2911}
2912
2913fn install_local(cwd: &Path, spec: &str) -> Result<PathBuf, String> {
2914    let source = PathBuf::from(spec.strip_prefix("file:").unwrap_or(spec));
2915    let source = if source.is_absolute() {
2916        source
2917    } else {
2918        cwd.join(source)
2919    };
2920    let source = std::fs::canonicalize(&source)
2921        .map(normalize_config_path)
2922        .map_err(|error| {
2923            format!(
2924                "local package path was not found at {}: {error}",
2925                source.display()
2926            )
2927        })?;
2928    let root = if source.is_dir() {
2929        source
2930    } else if source.is_file() && source.file_name() == Some(std::ffi::OsStr::new("package.json")) {
2931        source
2932            .parent()
2933            .ok_or_else(|| format!("local package manifest has no parent: {}", source.display()))?
2934            .to_path_buf()
2935    } else {
2936        return Err(format!(
2937            "local package must be a directory or package.json: {}",
2938            source.display()
2939        ));
2940    };
2941    Ok(root)
2942}
2943
2944fn replace_dir_prepared(
2945    source: &Path,
2946    target: &Path,
2947    force: bool,
2948    prepare: impl FnOnce(&Path) -> Result<(), String>,
2949) -> Result<(), String> {
2950    if target.exists() {
2951        if !force {
2952            return Err(format!(
2953                "{} already exists; use --force to replace it",
2954                target.display()
2955            ));
2956        }
2957    }
2958    let parent = target
2959        .parent()
2960        .ok_or_else(|| format!("package target has no parent: {}", target.display()))?;
2961    std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
2962    let prepared = tempfile::Builder::new()
2963        .prefix(".rpi-package-stage-")
2964        .tempdir_in(parent)
2965        .map_err(|error| format!("could not create package staging directory: {error}"))?;
2966    copy_dir(source, prepared.path())
2967        .map_err(|error| format!("could not copy package: {error}"))?;
2968    prepare(prepared.path())?;
2969    let _lock = PackageSwapLock::acquire(target)?;
2970    recover_interrupted_swap(target)?;
2971    swap_prepared_dir(prepared.path(), target, force)
2972}
2973
2974struct PackageSwapLock {
2975    file: Option<std::fs::File>,
2976}
2977
2978impl PackageSwapLock {
2979    fn acquire(target: &Path) -> Result<Self, String> {
2980        let parent = target
2981            .parent()
2982            .ok_or_else(|| format!("package target has no parent: {}", target.display()))?;
2983        let leaf = target
2984            .file_name()
2985            .and_then(|name| name.to_str())
2986            .unwrap_or("package");
2987        let path = parent.join(format!(".{leaf}.rpi-update.lock"));
2988        let file = std::fs::OpenOptions::new()
2989            .read(true)
2990            .write(true)
2991            .create(true)
2992            .open(&path)
2993            .map_err(|error| {
2994                format!(
2995                    "could not open package lock for {}: {error}",
2996                    target.display()
2997                )
2998            })?;
2999        fs2::FileExt::try_lock_exclusive(&file).map_err(|error| {
3000            format!(
3001                "could not lock package {} (another update may be running): {error}",
3002                target.display()
3003            )
3004        })?;
3005        Ok(Self { file: Some(file) })
3006    }
3007}
3008
3009impl Drop for PackageSwapLock {
3010    fn drop(&mut self) {
3011        if let Some(file) = self.file.take() {
3012            let _ = fs2::FileExt::unlock(&file);
3013        }
3014    }
3015}
3016
3017/// Recover or clean up an interrupted update at a known managed/native npm
3018/// package target. A lock file is opened only when a matching backup exists.
3019pub(crate) fn recover_configured_package_root(target: &Path) -> Result<(), String> {
3020    if !is_known_package_target(target) || package_backup_dirs(target)?.is_empty() {
3021        return Ok(());
3022    }
3023    let _lock = PackageSwapLock::acquire(target)?;
3024    recover_interrupted_swap(target)
3025}
3026
3027fn recover_interrupted_swap(target: &Path) -> Result<(), String> {
3028    if target.exists() {
3029        if package_target_is_valid(target) {
3030            remove_stale_package_backups(target)?;
3031        }
3032        return Ok(());
3033    }
3034    let backups = package_backup_dirs(target)?;
3035    match backups.as_slice() {
3036        [] => Ok(()),
3037        [backup] => std::fs::rename(backup, target).map_err(|error| {
3038            format!(
3039                "could not restore interrupted package update from {}: {error}",
3040                backup.display()
3041            )
3042        }),
3043        _ => Err(format!(
3044            "could not recover {}: multiple interrupted update backups found",
3045            target.display()
3046        )),
3047    }
3048}
3049
3050fn package_backup_dirs(target: &Path) -> Result<Vec<PathBuf>, String> {
3051    let parent = target
3052        .parent()
3053        .ok_or_else(|| format!("package target has no parent: {}", target.display()))?;
3054    let leaf = target
3055        .file_name()
3056        .and_then(|name| name.to_str())
3057        .unwrap_or("package");
3058    let prefix = format!(".{leaf}.rpi-backup-");
3059    let mut backups = Vec::new();
3060    for entry in std::fs::read_dir(parent)
3061        .map_err(|error| format!("could not inspect package backups: {error}"))?
3062    {
3063        let entry = entry.map_err(|error| format!("could not inspect package backup: {error}"))?;
3064        let matches_name = entry
3065            .file_name()
3066            .to_str()
3067            .and_then(|name| name.strip_prefix(&prefix))
3068            .is_some_and(|suffix| {
3069                suffix.len() == 32 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit())
3070            });
3071        if matches_name
3072            && entry
3073                .file_type()
3074                .map_err(|error| format!("could not inspect package backup: {error}"))?
3075                .is_dir()
3076        {
3077            backups.push(entry.path());
3078        }
3079    }
3080    backups.sort();
3081    Ok(backups)
3082}
3083
3084fn package_target_is_valid(target: &Path) -> bool {
3085    if !target.is_dir() {
3086        return false;
3087    }
3088    let manifest_valid = std::fs::read_to_string(target.join("package.json"))
3089        .ok()
3090        .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
3091        .is_some_and(|value| value.is_object());
3092    manifest_valid
3093        || [
3094            "skills",
3095            "prompts",
3096            "themes",
3097            "extensions",
3098            "SYSTEM.md",
3099            "APPEND_SYSTEM.md",
3100        ]
3101        .iter()
3102        .any(|resource| target.join(resource).exists())
3103}
3104
3105fn remove_stale_package_backups(target: &Path) -> Result<(), String> {
3106    for backup in package_backup_dirs(target)? {
3107        std::fs::remove_dir_all(&backup).map_err(|error| {
3108            format!(
3109                "could not remove stale package backup {}: {error}",
3110                backup.display()
3111            )
3112        })?;
3113    }
3114    Ok(())
3115}
3116
3117fn swap_prepared_dir(prepared: &Path, target: &Path, force: bool) -> Result<(), String> {
3118    if !target.exists() {
3119        return std::fs::rename(prepared, target)
3120            .map_err(|error| format!("could not activate {}: {error}", target.display()));
3121    }
3122    if !force {
3123        return Err(format!(
3124            "{} already exists; use --force to replace it",
3125            target.display()
3126        ));
3127    }
3128    let parent = target
3129        .parent()
3130        .ok_or_else(|| format!("package target has no parent: {}", target.display()))?;
3131    let leaf = target
3132        .file_name()
3133        .and_then(|name| name.to_str())
3134        .unwrap_or("package");
3135    let backup = parent.join(format!(
3136        ".{leaf}.rpi-backup-{}",
3137        uuid::Uuid::new_v4().simple()
3138    ));
3139    std::fs::rename(target, &backup)
3140        .map_err(|error| format!("could not back up {}: {error}", target.display()))?;
3141    if let Err(error) = std::fs::rename(prepared, target) {
3142        return match std::fs::rename(&backup, target) {
3143            Ok(()) => Err(format!(
3144                "could not activate replacement for {}: {error}",
3145                target.display()
3146            )),
3147            Err(rollback) => Err(format!(
3148                "could not activate replacement for {}: {error}; rollback at {} also failed: {rollback}",
3149                target.display(),
3150                backup.display()
3151            )),
3152        };
3153    }
3154    if let Err(error) = std::fs::remove_dir_all(&backup) {
3155        eprintln!(
3156            "warning: updated package but could not remove backup {}: {error}",
3157            backup.display()
3158        );
3159    }
3160    Ok(())
3161}
3162
3163fn copy_dir(source: &Path, target: &Path) -> std::io::Result<()> {
3164    std::fs::create_dir_all(target)?;
3165    for entry in std::fs::read_dir(source)? {
3166        let entry = entry?;
3167        if matches!(entry.file_name().to_str(), Some("node_modules" | ".git")) {
3168            continue;
3169        }
3170        let from = entry.path();
3171        let to = target.join(entry.file_name());
3172        if from.is_dir() {
3173            copy_dir(&from, &to)?;
3174        } else {
3175            std::fs::copy(from, to)?;
3176        }
3177    }
3178    Ok(())
3179}
3180
3181fn install_production_dependencies_with_mode(
3182    root: &Path,
3183    npm_command: &NpmCommand,
3184    npm_mode: NpmExecutionMode,
3185) -> Result<(), String> {
3186    if !root.join("package.json").is_file() {
3187        return Ok(());
3188    }
3189    let install_args = if npm_command.is_configured() {
3190        vec!["install".to_string()]
3191    } else {
3192        ["install", "--omit=dev"].map(String::from).to_vec()
3193    };
3194    run_dependency_npm_command(root, npm_command, &install_args, npm_mode, false)?;
3195    let mut host_packages = Vec::new();
3196    if let Ok(text) = std::fs::read_to_string(root.join("package.json")) {
3197        if let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&text) {
3198            if let Some(peers) = manifest
3199                .get("peerDependencies")
3200                .and_then(|value| value.as_object())
3201            {
3202                for (name, version) in peers {
3203                    let version = version.as_str().unwrap_or("*");
3204                    host_packages.push(if version == "*" {
3205                        name.to_string()
3206                    } else {
3207                        format!("{name}@{version}")
3208                    });
3209                }
3210                if peers.contains_key("@earendil-works/pi-coding-agent") {
3211                    // Pi's coding-agent package imports this host runtime from
3212                    // its UI barrel, although it is not declared as a peer.
3213                    host_packages.push("@earendil-works/pi-server".to_string());
3214                }
3215            }
3216        }
3217    }
3218    if !host_packages.is_empty() && npm_command.manager_kind() == NpmManagerKind::Npm {
3219        let mut host_args = ["install", "--omit=dev", "--no-package-lock", "--no-save"]
3220            .map(String::from)
3221            .to_vec();
3222        host_args.extend(host_packages);
3223        run_dependency_npm_command(root, npm_command, &host_args, npm_mode, true)?;
3224    }
3225    Ok(())
3226}
3227
3228fn run_dependency_npm_command(
3229    root: &Path,
3230    npm_command: &NpmCommand,
3231    args: &[String],
3232    npm_mode: NpmExecutionMode,
3233    host_dependencies: bool,
3234) -> Result<(), String> {
3235    if npm_mode == NpmExecutionMode::StartupRemediation {
3236        return npm_command
3237            .run_startup_remediation(args, root)
3238            .map_err(|error| {
3239                format!(
3240                    "could not install {} during startup remediation: {error}",
3241                    if host_dependencies {
3242                        "Pi host dependencies"
3243                    } else {
3244                        "package dependencies"
3245                    }
3246                )
3247            });
3248    }
3249
3250    let status = Command::new(npm_command.program())
3251        .args(npm_command.combined_args(args))
3252        .current_dir(root)
3253        .stdin(Stdio::inherit())
3254        .stdout(Stdio::inherit())
3255        .stderr(Stdio::inherit())
3256        .status()
3257        .map_err(|error| {
3258            if host_dependencies {
3259                format!(
3260                    "could not install Pi host dependencies with {}: {error}",
3261                    npm_command.program()
3262                )
3263            } else {
3264                format!(
3265                    "could not execute {} for package dependencies: {error}",
3266                    npm_command.program()
3267                )
3268            }
3269        })?;
3270    if status.success() {
3271        Ok(())
3272    } else if host_dependencies {
3273        Err(format!(
3274            "{} host dependency install exited with {status}",
3275            npm_command.program()
3276        ))
3277    } else {
3278        Err(format!(
3279            "{} install exited with {status}",
3280            npm_command.program()
3281        ))
3282    }
3283}
3284
3285fn normalize_config_path(path: PathBuf) -> PathBuf {
3286    if cfg!(windows) {
3287        let text = path.to_string_lossy();
3288        if let Some(unc) = text.strip_prefix(r"\\?\UNC\") {
3289            return PathBuf::from(format!(r"\\{unc}"));
3290        }
3291        if let Some(local) = text.strip_prefix(r"\\?\") {
3292            return PathBuf::from(local);
3293        }
3294    }
3295    path
3296}
3297
3298fn paths_equal(left: &Path, right: &Path) -> bool {
3299    let left = normalize_config_path(left.to_path_buf());
3300    let right = normalize_config_path(right.to_path_buf());
3301    if cfg!(windows) {
3302        left.to_string_lossy()
3303            .replace('/', "\\")
3304            .eq_ignore_ascii_case(&right.to_string_lossy().replace('/', "\\"))
3305    } else {
3306        left == right
3307    }
3308}
3309
3310fn path_is_within(path: &Path, root: &Path) -> bool {
3311    let path = normalize_config_path(path.to_path_buf());
3312    let root = normalize_config_path(root.to_path_buf());
3313    let mut path_components = path.components();
3314    for root_component in root.components() {
3315        let Some(path_component) = path_components.next() else {
3316            return false;
3317        };
3318        if !path_components_equal(path_component, root_component) {
3319            return false;
3320        }
3321    }
3322    true
3323}
3324
3325fn validate_recognized_npm_store_root(
3326    root: &Path,
3327    cwd: &Path,
3328    project_trusted: bool,
3329) -> Result<(), String> {
3330    if !root.is_absolute() || root.file_name() != Some(std::ffi::OsStr::new("npm")) {
3331        return Err(format!(
3332            "refusing npm store update outside a recognized absolute npm root: {}",
3333            root.display()
3334        ));
3335    }
3336    let mut allowed = Vec::new();
3337    if let Ok(agent) = crate::config::agent_dir() {
3338        allowed.push(agent.join("npm"));
3339    }
3340    if let Some(home) = dirs::home_dir() {
3341        allowed.push(home.join(".pi/agent/npm"));
3342    }
3343    if project_trusted {
3344        allowed.push(cwd.join(".pi/npm"));
3345    }
3346    if !allowed.iter().any(|candidate| paths_equal(candidate, root)) {
3347        return Err(format!(
3348            "refusing npm store update outside a configured package store: {}",
3349            root.display()
3350        ));
3351    }
3352    Ok(())
3353}
3354
3355fn npm_module_name_checked(spec: &str) -> Result<PathBuf, String> {
3356    npm_package_spec_checked(spec).map(|parsed| PathBuf::from(parsed.install_name))
3357}
3358
3359fn npm_package_spec_checked(spec: &str) -> Result<crate::packages::ParsedNpmPackageSpec, String> {
3360    crate::packages::parse_npm_package_spec(spec)
3361        .ok_or_else(|| format!("refusing invalid npm package spec `{spec}`"))
3362}
3363
3364fn looks_like_npm(spec: &str) -> bool {
3365    !spec.contains(std::path::MAIN_SEPARATOR) && !spec.contains('/') && !spec.ends_with(".json")
3366}
3367
3368pub fn print_help() {
3369    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");
3370}
3371
3372fn print_uninstall_help() {
3373    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>");
3374}
3375
3376#[cfg(test)]
3377mod tests {
3378    use super::*;
3379
3380    fn failing_npm_command(script: &Path) -> NpmCommand {
3381        #[cfg(windows)]
3382        {
3383            std::fs::write(
3384                script,
3385                "[Console]::Error.Write('startup-bounded'); exit 7\n",
3386            )
3387            .unwrap();
3388            let program = PathBuf::from(std::env::var_os("SystemRoot").unwrap())
3389                .join("System32/WindowsPowerShell/v1.0/powershell.exe")
3390                .to_string_lossy()
3391                .into_owned();
3392            return NpmCommand::from_argv(Some(&[
3393                program,
3394                "-NoLogo".to_string(),
3395                "-NoProfile".to_string(),
3396                "-NonInteractive".to_string(),
3397                "-File".to_string(),
3398                script.to_string_lossy().into_owned(),
3399            ]))
3400            .unwrap();
3401        }
3402        #[cfg(unix)]
3403        {
3404            std::fs::write(script, "printf startup-bounded >&2\nexit 7\n").unwrap();
3405            NpmCommand::from_argv(Some(&[
3406                "/bin/sh".to_string(),
3407                script.to_string_lossy().into_owned(),
3408            ]))
3409            .unwrap()
3410        }
3411    }
3412
3413    fn run_test_git(cwd: &Path, args: &[&str]) -> String {
3414        let output = Command::new("git")
3415            .args(args)
3416            .current_dir(cwd)
3417            .output()
3418            .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
3419        assert!(
3420            output.status.success(),
3421            "git {:?} exited with {}: {}",
3422            args,
3423            output.status,
3424            String::from_utf8_lossy(&output.stderr)
3425        );
3426        String::from_utf8_lossy(&output.stdout).trim().to_string()
3427    }
3428
3429    fn initialize_test_git_package(root: &Path, origin: &str, state: &str) -> String {
3430        std::fs::create_dir_all(root).unwrap();
3431        run_test_git(root, &["init", "--initial-branch", "main"]);
3432        run_test_git(root, &["config", "user.email", "rpi-test@example.invalid"]);
3433        run_test_git(root, &["config", "user.name", "rpi-test"]);
3434        std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
3435        std::fs::write(root.join("state.txt"), format!("{state}\n")).unwrap();
3436        run_test_git(root, &["add", "package.json", "state.txt"]);
3437        run_test_git(root, &["commit", "-m", state]);
3438        run_test_git(root, &["remote", "add", "origin", origin]);
3439        run_test_git(root, &["rev-parse", "HEAD"])
3440    }
3441
3442    #[test]
3443    fn parses_install_specs_and_flags() {
3444        let options = parse_args(&["--global".into(), "npm:@scope/pkg@1.2.3".into()]).unwrap();
3445        assert!(options.global);
3446        assert_eq!(options.spec, "npm:@scope/pkg@1.2.3");
3447        assert_eq!(package_name(&options.spec), "scope__pkg");
3448        assert_eq!(npm_module_name(&options.spec), "@scope/pkg");
3449    }
3450
3451    #[test]
3452    fn startup_git_dependency_mode_returns_bounded_process_diagnostics() {
3453        let tmp = tempfile::tempdir().unwrap();
3454        let root = tmp.path().join("package");
3455        std::fs::create_dir_all(&root).unwrap();
3456        std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
3457        let script = tmp.path().join(if cfg!(windows) {
3458            "failing-npm.ps1"
3459        } else {
3460            "failing-npm.sh"
3461        });
3462        let command = failing_npm_command(&script);
3463
3464        let error = install_git_dependencies(&root, &command, NpmExecutionMode::StartupRemediation)
3465            .unwrap_err();
3466
3467        assert!(error.contains("startup remediation"), "{error}");
3468        assert!(error.contains("startup-bounded"), "{error}");
3469    }
3470
3471    #[test]
3472    fn install_git_sources_split_slash_refs_and_normalize_shorthand() {
3473        for spec in [
3474            "git:github.com/example/repo@feature/branch",
3475            "https://github.com/example/repo.git@feature/branch",
3476            "ssh://git@github.com/example/repo@feature/branch",
3477            "git://github.com/example/repo@feature/branch",
3478            "git:git@github.com:example/repo@feature/branch",
3479        ] {
3480            assert!(is_git_install_spec(spec), "spec={spec}");
3481            let raw = match spec.strip_prefix("git:") {
3482                Some(rest) if !rest.starts_with("//") => rest,
3483                _ => spec,
3484            };
3485            let (repo, revision) = split_git_install_ref(raw);
3486            assert_eq!(revision.as_deref(), Some("feature/branch"), "spec={spec}");
3487            let clone_url = normalize_git_clone_url(&repo).unwrap();
3488            assert!(clone_url.contains("github.com"), "spec={spec}");
3489            assert_eq!(git_package_name(&repo), "repo", "spec={spec}");
3490        }
3491    }
3492
3493    #[test]
3494    fn install_git_source_rejects_unsafe_revision() {
3495        let (repo, revision) = split_git_install_ref("github.com/example/repo@../escape");
3496        assert_eq!(repo, "github.com/example/repo");
3497        assert!(!is_safe_git_revision(revision.as_deref().unwrap()));
3498        assert!(normalize_git_clone_url(&repo).is_ok());
3499    }
3500
3501    #[test]
3502    fn settings_entries_are_relative_to_their_scope() {
3503        let tmp = tempfile::tempdir().unwrap();
3504        let root = tmp.path().join(".rpi/packages/package");
3505        assert_eq!(npm_settings_spec("demo"), "npm:demo");
3506        assert_eq!(
3507            npm_settings_spec("npm:@scope/demo@^1"),
3508            "npm:@scope/demo@^1"
3509        );
3510        assert_eq!(
3511            settings_entry(&root, tmp.path(), false).unwrap(),
3512            format!("file:{}", Path::new("packages/package").display())
3513        );
3514    }
3515
3516    #[test]
3517    fn local_install_references_the_source_without_copying_it() {
3518        let tmp = tempfile::tempdir().unwrap();
3519        let project = tmp.path().join("project");
3520        let source = tmp.path().join("local-package");
3521        std::fs::create_dir_all(&project).unwrap();
3522        std::fs::create_dir_all(&source).unwrap();
3523        std::fs::write(source.join("package.json"), r#"{"name":"local-package"}"#).unwrap();
3524
3525        let installed = install_local(&project, "../local-package").unwrap();
3526        assert!(paths_equal(
3527            &installed,
3528            &std::fs::canonicalize(&source).unwrap()
3529        ));
3530        assert!(!project.join(".rpi/packages/local-package").exists());
3531
3532        std::fs::create_dir_all(project.join(".rpi")).unwrap();
3533        let entry = settings_entry(&installed, &project, false).unwrap();
3534        let relative = Path::new(entry.strip_prefix("file:").unwrap());
3535        assert!(paths_equal(
3536            &std::fs::canonicalize(project.join(".rpi").join(relative)).unwrap(),
3537            &installed
3538        ));
3539    }
3540
3541    #[test]
3542    fn npm_names_cannot_be_interpreted_as_command_options() {
3543        for spec in ["npm:-demo", "npm:@-scope/demo", "npm:@scope/-demo"] {
3544            assert!(npm_module_name_checked(spec).is_err(), "spec={spec}");
3545        }
3546    }
3547
3548    #[test]
3549    fn interrupted_native_git_swap_is_recovered() {
3550        let tmp = tempfile::tempdir().unwrap();
3551        let target = tmp.path().join(".pi/git/github.com/example/repo");
3552        let backup = target
3553            .parent()
3554            .unwrap()
3555            .join(".repo.rpi-backup-00000000000000000000000000000001");
3556        std::fs::create_dir_all(&backup).unwrap();
3557        std::fs::write(backup.join("package.json"), r#"{"name":"repo"}"#).unwrap();
3558
3559        recover_configured_package_root(&target).unwrap();
3560        assert!(target.join("package.json").is_file());
3561        assert!(!backup.exists());
3562    }
3563
3564    #[test]
3565    fn package_source_matching_uses_npm_and_git_identity_without_cross_kind_collisions() {
3566        let cwd = Path::new(".");
3567        assert!(package_settings_sources_match(
3568            cwd,
3569            "npm:@scope/demo@^1",
3570            "npm:@scope/demo@beta"
3571        ));
3572        assert!(package_settings_sources_match(
3573            cwd,
3574            "npm:alias@npm:real@^1",
3575            "npm:alias@npm:other@beta"
3576        ));
3577        assert!(!package_settings_sources_match(
3578            cwd,
3579            "npm:alias@npm:real@^1",
3580            "npm:real@^1"
3581        ));
3582        assert!(package_settings_sources_match(
3583            cwd,
3584            "git:https://github.com/example/repo.git@main",
3585            "git:git@github.com:example/repo@release/v2"
3586        ));
3587        assert!(!package_settings_sources_match(
3588            cwd,
3589            "file:packages/demo",
3590            "npm:demo"
3591        ));
3592    }
3593
3594    #[test]
3595    fn npm_package_names_cannot_be_option_arguments() {
3596        assert!(npm_module_name_checked("npm:-rf").is_err());
3597        assert!(npm_module_name_checked("npm:--workspace").is_err());
3598        assert_eq!(
3599            npm_module_name_checked("npm:@scope/valid-name@^1").unwrap(),
3600            PathBuf::from("@scope/valid-name")
3601        );
3602        assert_eq!(
3603            npm_module_name_checked("npm:@scope/alias@npm:@target/real@^1").unwrap(),
3604            PathBuf::from("@scope/alias")
3605        );
3606        for spec in [
3607            "npm:alias@npm:real@npm:other",
3608            "npm:alias@file:../real",
3609            "npm:alias@npm:real@file:../other",
3610        ] {
3611            assert!(npm_module_name_checked(spec).is_err(), "spec={spec}");
3612        }
3613    }
3614
3615    #[test]
3616    fn global_relative_settings_entries_resolve_from_the_agent_directory() {
3617        struct RestoreEnv(Option<std::ffi::OsString>);
3618        impl Drop for RestoreEnv {
3619            fn drop(&mut self) {
3620                match self.0.take() {
3621                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3622                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3623                }
3624            }
3625        }
3626
3627        let _lock = crate::config::test_support::env_lock().lock().unwrap();
3628        let temp = tempfile::tempdir().unwrap();
3629        let agent = temp.path().join("agent");
3630        let package = agent.join("packages/demo");
3631        std::fs::create_dir_all(&package).unwrap();
3632        std::fs::write(package.join("package.json"), r#"{"name":"demo"}"#).unwrap();
3633        let _restore = RestoreEnv(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3634        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3635
3636        let resolved = resolve_settings_source_root(temp.path(), "file:packages/demo", true)
3637            .expect("global package should resolve from its settings owner");
3638        assert!(paths_equal(
3639            &resolved,
3640            &std::fs::canonicalize(package).unwrap()
3641        ));
3642    }
3643
3644    #[test]
3645    fn git_uninstall_lookup_uses_native_host_and_repository_path() {
3646        let temp = tempfile::tempdir().unwrap();
3647        let target = temp.path().join(".pi/git/github.com/example/repo");
3648        std::fs::create_dir_all(&target).unwrap();
3649        let options = UninstallOptions {
3650            spec: "git:github.com/example/repo@feature/branch".to_string(),
3651            global: false,
3652        };
3653        assert!(paths_equal(
3654            &find_installed_root(temp.path(), &options).unwrap(),
3655            &std::fs::canonicalize(target).unwrap()
3656        ));
3657    }
3658
3659    #[test]
3660    fn git_uninstall_never_falls_back_to_an_unverified_legacy_leaf() {
3661        fn git(cwd: &Path, args: &[&str]) {
3662            let status = Command::new("git")
3663                .args(args)
3664                .current_dir(cwd)
3665                .status()
3666                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
3667            assert!(status.success(), "git {:?} exited with {status}", args);
3668        }
3669
3670        let temp = tempfile::tempdir().unwrap();
3671        let root = temp.path().join(".rpi/packages/repo");
3672        std::fs::create_dir_all(&root).unwrap();
3673        std::fs::write(root.join("package.json"), r#"{"name":"repo"}"#).unwrap();
3674        let options = UninstallOptions {
3675            spec: "git:github.com/example/repo@main".to_string(),
3676            global: false,
3677        };
3678        assert_eq!(find_installed_root(temp.path(), &options), None);
3679
3680        git(&root, &["init"]);
3681        git(
3682            &root,
3683            &[
3684                "remote",
3685                "add",
3686                "origin",
3687                "https://github.com/other/repo.git",
3688            ],
3689        );
3690        assert_eq!(find_installed_root(temp.path(), &options), None);
3691
3692        git(
3693            &root,
3694            &[
3695                "remote",
3696                "set-url",
3697                "origin",
3698                "https://github.com/example/repo.git",
3699            ],
3700        );
3701        assert!(paths_equal(
3702            &find_installed_root(temp.path(), &options).unwrap(),
3703            &std::fs::canonicalize(root).unwrap(),
3704        ));
3705    }
3706
3707    #[test]
3708    fn npm_store_manifest_provenance_is_explicit() {
3709        let temp = tempfile::tempdir().unwrap();
3710        std::fs::write(
3711            temp.path().join("package.json"),
3712            r#"{"name":"pi-extensions","private":true,"dependencies":{"demo":"^1"}}"#,
3713        )
3714        .unwrap();
3715        assert!(npm_store_manifest_tracks_package(temp.path(), "demo").unwrap());
3716        assert!(!npm_store_manifest_tracks_package(temp.path(), "other").unwrap());
3717    }
3718
3719    #[test]
3720    fn npm_updates_preserve_ranges_and_tags() {
3721        assert_eq!(
3722            npm_update_install_spec("demo", "npm:demo"),
3723            "npm:demo@latest"
3724        );
3725        assert_eq!(
3726            npm_update_install_spec("demo", "npm:demo@^1"),
3727            "npm:demo@^1"
3728        );
3729        assert_eq!(
3730            npm_update_install_spec("demo", "npm:demo@beta"),
3731            "npm:demo@beta"
3732        );
3733        assert_eq!(
3734            npm_update_install_spec("alias", "npm:alias@npm:@scope/real@^1"),
3735            "npm:alias@npm:@scope/real@^1"
3736        );
3737        assert_eq!(
3738            npm_root_update_spec("demo", "npm:demo").unwrap(),
3739            "demo@latest"
3740        );
3741        assert_eq!(
3742            npm_root_update_spec("demo", "npm:demo@^1").unwrap(),
3743            "demo@^1"
3744        );
3745        assert_eq!(
3746            npm_root_update_spec("alias", "npm:alias@npm:real@beta").unwrap(),
3747            "alias@npm:real@beta"
3748        );
3749        assert!(npm_root_update_spec("demo", "npm:other@^1").is_err());
3750    }
3751
3752    #[test]
3753    fn managed_npm_alias_validates_the_target_manifest_name() {
3754        let tmp = tempfile::tempdir().unwrap();
3755        let install_root = tmp.path().join("npm");
3756        let relative = PathBuf::from("@scope/alias");
3757        let target = install_root.join("node_modules").join(&relative);
3758        std::fs::create_dir_all(&target).unwrap();
3759        std::fs::write(
3760            target.join("package.json"),
3761            r#"{"name":"@target/real","version":"1.0.0"}"#,
3762        )
3763        .unwrap();
3764
3765        validate_managed_npm_package(&install_root, &target, &relative, "@target/real").unwrap();
3766        assert!(
3767            validate_managed_npm_package(&install_root, &target, &relative, "@target/wrong",)
3768                .is_err()
3769        );
3770    }
3771
3772    #[test]
3773    fn native_store_update_does_not_overwrite_manifest_before_manager_runs() {
3774        let tmp = tempfile::tempdir().unwrap();
3775        let root = tmp.path().join("npm");
3776        std::fs::create_dir_all(&root).unwrap();
3777        let manifest = "{\"name\":\"custom-root\",\"private\":true}\n";
3778        std::fs::write(root.join("package.json"), manifest).unwrap();
3779        let argv = ["mise", "exec", "--", "pnpm"].map(String::from);
3780        let command = NpmCommand::from_argv(Some(&argv)).unwrap();
3781        let packages = vec![
3782            ("one".to_string(), "npm:one".to_string()),
3783            ("@scope/two".to_string(), "npm:@scope/two@^2".to_string()),
3784        ];
3785        let root_arg = root.to_string_lossy().into_owned();
3786
3787        update_npm_store_root_with(&root, &packages, &command, |actual_root, command, args| {
3788            assert_eq!(actual_root, root);
3789            assert_eq!(command.program(), "mise");
3790            assert_eq!(
3791                args,
3792                [
3793                    "install",
3794                    "one@latest",
3795                    "@scope/two@^2",
3796                    "--prefix",
3797                    &root_arg,
3798                    "--config.auto-install-peers=false",
3799                    "--config.strict-peer-dependencies=false",
3800                    "--config.strict-dep-builds=false",
3801                ]
3802                .map(String::from)
3803                .as_slice()
3804            );
3805            assert_eq!(
3806                std::fs::read_to_string(root.join("package.json")).unwrap(),
3807                manifest
3808            );
3809            Ok(())
3810        })
3811        .unwrap();
3812
3813        assert_eq!(
3814            std::fs::read_to_string(root.join("package.json")).unwrap(),
3815            manifest
3816        );
3817        assert!(tmp.path().join(".npm.rpi-update.lock").is_file());
3818    }
3819
3820    #[test]
3821    fn native_store_update_creates_only_the_missing_root_manifest() {
3822        let tmp = tempfile::tempdir().unwrap();
3823        let root = tmp.path().join("npm");
3824        let command = NpmCommand::from_argv(Some(&["bun".to_string()])).unwrap();
3825        let root_arg = root.to_string_lossy().into_owned();
3826        update_npm_store_root_with(
3827            &root,
3828            &[("demo".to_string(), "npm:demo@beta".to_string())],
3829            &command,
3830            |actual_root, _, args| {
3831                assert!(actual_root.join("package.json").is_file());
3832                assert_eq!(
3833                    args,
3834                    ["install", "demo@beta", "--cwd", &root_arg, "--omit=peer",]
3835                        .map(String::from)
3836                        .as_slice()
3837                );
3838                Ok(())
3839            },
3840        )
3841        .unwrap();
3842        assert_eq!(
3843            serde_json::from_str::<serde_json::Value>(
3844                &std::fs::read_to_string(root.join("package.json")).unwrap()
3845            )
3846            .unwrap()["name"],
3847            "pi-extensions"
3848        );
3849    }
3850
3851    #[test]
3852    fn pnpm_and_bun_refuse_standalone_leaf_copy_install() {
3853        let tmp = tempfile::tempdir().unwrap();
3854        for manager in ["pnpm", "bun"] {
3855            let command = NpmCommand::from_argv(Some(&[manager.to_string()])).unwrap();
3856            let error = install_npm_at(
3857                &tmp.path().join(manager),
3858                "npm:demo@latest",
3859                "npm:demo",
3860                true,
3861                &command,
3862            )
3863            .unwrap_err();
3864            assert!(error.contains("cannot safely install"), "{error}");
3865        }
3866    }
3867
3868    #[test]
3869    fn filtered_settings_entries_are_preserved_or_removed_by_source() {
3870        let filtered = crate::settings::PackageSetting::Filtered(crate::settings::PackageFilter {
3871            source: "npm:filtered".to_string(),
3872            autoload: Some(false),
3873            extensions: None,
3874            skills: None,
3875            prompts: None,
3876            themes: None,
3877            unknown: serde_json::Map::from_iter([(
3878                "futureFilter".to_string(),
3879                serde_json::json!({"enabled": true}),
3880            )]),
3881        });
3882        let preserved = filtered.clone();
3883        let mut packages = vec![filtered];
3884
3885        assert!(!enable_settings_entry(
3886            Path::new("."),
3887            &mut packages,
3888            "npm:filtered".to_string()
3889        ));
3890        assert_eq!(packages, vec![preserved.clone()]);
3891        assert!(enable_settings_entry(
3892            Path::new("."),
3893            &mut packages,
3894            "npm:filtered@beta".to_string()
3895        ));
3896        assert_eq!(packages.len(), 1);
3897        let crate::settings::PackageSetting::Filtered(updated) = &packages[0] else {
3898            panic!("filtered package entry was replaced instead of updated");
3899        };
3900        assert_eq!(updated.source, "npm:filtered@beta");
3901        assert_eq!(updated.autoload, Some(false));
3902        assert_eq!(
3903            updated.unknown.get("futureFilter"),
3904            Some(&serde_json::json!({"enabled": true}))
3905        );
3906
3907        assert!(enable_settings_entry(
3908            Path::new("."),
3909            &mut packages,
3910            "npm:second".to_string()
3911        ));
3912
3913        assert_eq!(
3914            remove_matching_settings_entries(
3915                Path::new("."),
3916                &mut packages,
3917                "npm:second",
3918                None,
3919                false,
3920            ),
3921            1
3922        );
3923        assert_eq!(packages.len(), 1);
3924        assert_eq!(
3925            remove_matching_settings_entries(
3926                Path::new("."),
3927                &mut packages,
3928                "npm:filtered",
3929                None,
3930                false,
3931            ),
3932            1
3933        );
3934        assert!(packages.is_empty());
3935    }
3936
3937    #[test]
3938    fn git_settings_ref_is_replaced_by_repository_identity() {
3939        let mut packages = vec![crate::settings::PackageSetting::Filtered(
3940            crate::settings::PackageFilter {
3941                source: "git:github.com/example/repo@main".to_string(),
3942                autoload: Some(false),
3943                extensions: Some(vec!["extensions/index.ts".to_string()]),
3944                skills: None,
3945                prompts: None,
3946                themes: None,
3947                unknown: serde_json::Map::new(),
3948            },
3949        )];
3950
3951        assert!(enable_settings_entry(
3952            Path::new("."),
3953            &mut packages,
3954            "git:github.com/example/repo@feature/branch".to_string(),
3955        ));
3956        assert_eq!(packages.len(), 1);
3957        let crate::settings::PackageSetting::Filtered(updated) = &packages[0] else {
3958            panic!("filtered package entry was replaced instead of updated");
3959        };
3960        assert_eq!(updated.source, "git:github.com/example/repo@feature/branch");
3961        assert_eq!(
3962            updated.extensions.as_deref(),
3963            Some(["extensions/index.ts".to_string()].as_slice())
3964        );
3965    }
3966
3967    #[test]
3968    fn project_package_operations_are_enabled_by_default() {
3969        struct RestoreEnv(Option<std::ffi::OsString>);
3970        impl Drop for RestoreEnv {
3971            fn drop(&mut self) {
3972                match self.0.take() {
3973                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3974                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3975                }
3976            }
3977        }
3978
3979        let _lock = crate::config::test_support::env_lock().lock().unwrap();
3980        let tmp = tempfile::tempdir().unwrap();
3981        let project = tmp.path().join("project");
3982        let agent = tmp.path().join("agent");
3983        std::fs::create_dir_all(&project).unwrap();
3984        std::fs::create_dir_all(&agent).unwrap();
3985        let _restore = RestoreEnv(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3986        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3987
3988        assert!(project_trust_for_package_operation(&project, false).unwrap());
3989        assert!(!project_trust_for_package_operation(&project, true).unwrap());
3990        crate::config::set_project_trust(&project, Some(false)).unwrap();
3991        assert!(!project_trust_for_package_operation(&project, false).unwrap());
3992        crate::config::set_project_trust(&project, Some(true)).unwrap();
3993        assert!(project_trust_for_package_operation(&project, false).unwrap());
3994    }
3995
3996    #[test]
3997    fn local_install_does_not_resolve_npm_command() {
3998        struct RestoreEnv(Option<std::ffi::OsString>);
3999        impl Drop for RestoreEnv {
4000            fn drop(&mut self) {
4001                match self.0.take() {
4002                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4003                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4004                }
4005            }
4006        }
4007
4008        let _lock = crate::config::test_support::env_lock().lock().unwrap();
4009        let tmp = tempfile::tempdir().unwrap();
4010        let agent = tmp.path().join("agent");
4011        std::fs::create_dir_all(&agent).unwrap();
4012        std::fs::write(agent.join("settings.json"), r#"{"npmCommand":[""]}"#).unwrap();
4013        let _restore = RestoreEnv(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4014        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4015
4016        assert!(
4017            npm_command_for_install(tmp.path(), true, InstallSpecKind::Local)
4018                .unwrap()
4019                .is_none()
4020        );
4021        assert!(npm_command_for_install(tmp.path(), true, InstallSpecKind::Npm).is_err());
4022    }
4023
4024    #[test]
4025    fn uninstall_lookup_does_not_read_untrusted_project_settings() {
4026        struct RestoreEnv {
4027            previous: Option<std::ffi::OsString>,
4028        }
4029        impl Drop for RestoreEnv {
4030            fn drop(&mut self) {
4031                match self.previous.take() {
4032                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4033                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4034                }
4035            }
4036        }
4037
4038        let _lock = crate::config::test_support::env_lock().lock().unwrap();
4039        let tmp = tempfile::tempdir().unwrap();
4040        let agent = tmp.path().join("agent");
4041        let project = tmp.path().join("project");
4042        let external = tmp.path().join("external-package");
4043        std::fs::create_dir_all(project.join(".rpi")).unwrap();
4044        std::fs::create_dir_all(&external).unwrap();
4045        std::fs::write(external.join("package.json"), r#"{"name":"external-name"}"#).unwrap();
4046        std::fs::write(
4047            project.join(".rpi/settings.json"),
4048            serde_json::to_vec(&serde_json::json!({
4049                "packages": [{
4050                    "source": format!("file:{}", external.display()),
4051                    "autoload": false
4052                }]
4053            }))
4054            .unwrap(),
4055        )
4056        .unwrap();
4057        let _restore = RestoreEnv {
4058            previous: std::env::var_os(crate::config::CONFIG_DIR_ENV),
4059        };
4060        std::env::set_var(crate::config::CONFIG_DIR_ENV, agent);
4061
4062        let options = UninstallOptions {
4063            spec: "external-name".to_string(),
4064            global: false,
4065        };
4066        assert_eq!(find_installed_root(&project, &options), None);
4067    }
4068
4069    #[cfg(windows)]
4070    #[test]
4071    fn normalizes_windows_verbatim_drive_path() {
4072        assert_eq!(
4073            normalize_config_path(PathBuf::from(r"\\?\C:\packages\demo")),
4074            PathBuf::from(r"C:\packages\demo")
4075        );
4076    }
4077
4078    #[cfg(windows)]
4079    #[test]
4080    fn normalizes_windows_verbatim_unc_path() {
4081        assert_eq!(
4082            normalize_config_path(PathBuf::from(r"\\?\UNC\server\share\demo")),
4083            PathBuf::from(r"\\server\share\demo")
4084        );
4085    }
4086
4087    #[test]
4088    fn rejects_missing_spec() {
4089        assert!(parse_args(&[]).is_err());
4090    }
4091
4092    #[test]
4093    fn parses_uninstall_specs_and_flags() {
4094        let options = parse_uninstall_args(&["--global".into(), "npm:@scope/pkg".into()]).unwrap();
4095        assert!(options.global);
4096        assert_eq!(options.spec, "npm:@scope/pkg");
4097    }
4098
4099    #[test]
4100    fn rejects_multiple_uninstall_specs() {
4101        assert!(parse_uninstall_args(&["a".into(), "b".into()]).is_err());
4102    }
4103
4104    #[test]
4105    fn scoped_npm_update_replaces_the_supplied_root_in_place() {
4106        let tmp = tempfile::tempdir().unwrap();
4107        let root = tmp.path().join(".pi/npm/node_modules/@scope/pkg");
4108        std::fs::create_dir_all(&root).unwrap();
4109        std::fs::write(root.join("old.txt"), "old").unwrap();
4110        assert!(is_known_npm_package_target(&root));
4111
4112        let result = install_npm_at_with(
4113            &root,
4114            "npm:@scope/pkg@latest",
4115            "npm:@scope/pkg",
4116            true,
4117            &NpmCommand::from_argv(None).unwrap(),
4118            |stage, spec| {
4119                assert_eq!(spec, "@scope/pkg@latest");
4120                let staged = stage.join("node_modules/@scope/pkg");
4121                std::fs::create_dir_all(&staged).unwrap();
4122                std::fs::write(
4123                    staged.join("package.json"),
4124                    r#"{"name":"@scope/pkg","version":"1.0.0"}"#,
4125                )
4126                .unwrap();
4127                std::fs::write(staged.join("new.txt"), "new").unwrap();
4128                Ok(())
4129            },
4130        )
4131        .unwrap();
4132
4133        assert_eq!(result, root);
4134        assert!(root.join("new.txt").is_file());
4135        assert!(!root.join("old.txt").exists());
4136        assert!(!tmp
4137            .path()
4138            .join(".pi/npm/node_modules/@scope/scope__pkg")
4139            .exists());
4140        let marker: serde_json::Value = serde_json::from_str(
4141            &std::fs::read_to_string(root.join(".rpi-package-source.json")).unwrap(),
4142        )
4143        .unwrap();
4144        assert_eq!(marker["kind"], "npm");
4145        assert_eq!(marker["spec"], "npm:@scope/pkg");
4146    }
4147
4148    #[test]
4149    fn preparation_failure_leaves_existing_package_untouched() {
4150        let tmp = tempfile::tempdir().unwrap();
4151        let source = tmp.path().join("source");
4152        let target = tmp.path().join(".rpi/packages/demo");
4153        std::fs::create_dir_all(&source).unwrap();
4154        std::fs::create_dir_all(&target).unwrap();
4155        std::fs::write(source.join("new.txt"), "new").unwrap();
4156        std::fs::write(target.join("old.txt"), "old").unwrap();
4157
4158        let error = replace_dir_prepared(&source, &target, true, |_prepared| {
4159            Err("dependency install failed".to_string())
4160        })
4161        .unwrap_err();
4162
4163        assert_eq!(error, "dependency install failed");
4164        assert_eq!(
4165            std::fs::read_to_string(target.join("old.txt")).unwrap(),
4166            "old"
4167        );
4168        assert!(!target.join("new.txt").exists());
4169    }
4170
4171    #[test]
4172    fn stale_lock_file_does_not_block_a_new_advisory_lock() {
4173        let tmp = tempfile::tempdir().unwrap();
4174        let target = tmp.path().join("packages/demo");
4175        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
4176        let lock_path = target.parent().unwrap().join(".demo.rpi-update.lock");
4177        std::fs::write(&lock_path, "left by interrupted process").unwrap();
4178
4179        let lock = PackageSwapLock::acquire(&target).unwrap();
4180        drop(lock);
4181        assert!(lock_path.is_file());
4182        drop(PackageSwapLock::acquire(&target).unwrap());
4183    }
4184
4185    #[test]
4186    fn interrupted_swap_recovers_missing_target_and_cleans_stale_backup() {
4187        let tmp = tempfile::tempdir().unwrap();
4188        let target = tmp.path().join("packages/demo");
4189        let backup = tmp
4190            .path()
4191            .join("packages/.demo.rpi-backup-00000000000000000000000000000001");
4192        std::fs::create_dir_all(&backup).unwrap();
4193        std::fs::write(backup.join("old.txt"), "old").unwrap();
4194        std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4195
4196        recover_interrupted_swap(&target).unwrap();
4197        assert_eq!(
4198            std::fs::read_to_string(target.join("old.txt")).unwrap(),
4199            "old"
4200        );
4201        assert!(!backup.exists());
4202
4203        let stale_backup = tmp
4204            .path()
4205            .join("packages/.demo.rpi-backup-00000000000000000000000000000002");
4206        std::fs::create_dir_all(&stale_backup).unwrap();
4207        std::fs::write(stale_backup.join("stale.txt"), "stale").unwrap();
4208        recover_interrupted_swap(&target).unwrap();
4209        assert!(target.join("old.txt").is_file());
4210        assert!(!stale_backup.exists());
4211
4212        for suffix in ["manual", "1234", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"] {
4213            let unrelated = tmp
4214                .path()
4215                .join(format!("packages/.demo.rpi-backup-{suffix}"));
4216            std::fs::create_dir_all(&unrelated).unwrap();
4217            recover_interrupted_swap(&target).unwrap();
4218            assert!(unrelated.is_dir(), "suffix={suffix}");
4219        }
4220
4221        let absent = tmp.path().join("packages/absent");
4222        let manual = tmp.path().join("packages/.absent.rpi-backup-manual");
4223        std::fs::create_dir_all(&manual).unwrap();
4224        recover_interrupted_swap(&absent).unwrap();
4225        assert!(!absent.exists());
4226        assert!(manual.is_dir());
4227    }
4228
4229    #[test]
4230    fn managed_uninstall_root_requires_a_canonical_direct_store_child() {
4231        let tmp = tempfile::tempdir().unwrap();
4232        let root = tmp.path().join(".rpi/packages/demo");
4233        let nested = root.join("nested");
4234        let external = tmp.path().join("node_modules/demo");
4235        std::fs::create_dir_all(&nested).unwrap();
4236        std::fs::create_dir_all(&external).unwrap();
4237
4238        assert!(is_direct_package_below_store(
4239            &root,
4240            tmp.path(),
4241            Path::new(".rpi/packages")
4242        ));
4243        assert!(!is_direct_package_below_store(
4244            &nested,
4245            tmp.path(),
4246            Path::new(".rpi/packages")
4247        ));
4248        assert!(!is_direct_package_below_store(
4249            &external,
4250            tmp.path(),
4251            Path::new(".rpi/packages")
4252        ));
4253    }
4254
4255    #[test]
4256    fn git_metadata_rejects_gitdir_pointer_files() {
4257        let tmp = tempfile::tempdir().unwrap();
4258        let git_file = tmp.path().join(".git");
4259        std::fs::write(&git_file, "gitdir: ../outside/.git\n").unwrap();
4260        assert!(!is_real_git_metadata(&git_file));
4261        std::fs::remove_file(&git_file).unwrap();
4262        std::fs::create_dir(&git_file).unwrap();
4263        assert!(is_real_git_metadata(&git_file));
4264    }
4265
4266    #[test]
4267    fn explicit_git_update_revision_uses_exact_fetch_and_fetch_head() {
4268        let tmp = tempfile::tempdir().unwrap();
4269        let prepared = tmp.path().join("prepared");
4270        let origin = "https://github.com/example/repo.git";
4271        let revision = "refs/pull/42/head";
4272        let calls = std::cell::RefCell::new(Vec::new());
4273
4274        prepare_git_checkout_for_update_with(
4275            &prepared,
4276            origin,
4277            Some(revision),
4278            Some(revision),
4279            |actual_origin, selected, actual_prepared| {
4280                assert_eq!(actual_origin, origin);
4281                assert_eq!(selected, None);
4282                assert_eq!(actual_prepared, prepared);
4283                calls.borrow_mut().push("clone".to_string());
4284                Ok(())
4285            },
4286            |actual_prepared, actual_origin, selected| {
4287                assert_eq!(actual_prepared, prepared);
4288                assert_eq!(actual_origin, origin);
4289                assert_eq!(selected, revision);
4290                calls.borrow_mut().push("fetch-exact".to_string());
4291                Ok(())
4292            },
4293            |actual_prepared, selected| {
4294                assert_eq!(actual_prepared, prepared);
4295                assert_eq!(selected, "FETCH_HEAD");
4296                calls.borrow_mut().push("checkout-fetch-head".to_string());
4297                Ok(())
4298            },
4299            |actual_prepared, selected| {
4300                assert_eq!(actual_prepared, prepared);
4301                assert_eq!(selected, Some("FETCH_HEAD"));
4302                calls.borrow_mut().push("validate-fetch-head".to_string());
4303                Ok(())
4304            },
4305        )
4306        .unwrap();
4307
4308        assert_eq!(
4309            calls.into_inner(),
4310            [
4311                "clone",
4312                "fetch-exact",
4313                "checkout-fetch-head",
4314                "validate-fetch-head"
4315            ]
4316        );
4317    }
4318
4319    #[test]
4320    fn unpinned_git_update_keeps_the_selected_branch_in_the_clone() {
4321        let tmp = tempfile::tempdir().unwrap();
4322        let prepared = tmp.path().join("prepared");
4323        let origin = "https://github.com/example/repo.git";
4324
4325        prepare_git_checkout_for_update_with(
4326            &prepared,
4327            origin,
4328            None,
4329            Some("release/v2"),
4330            |actual_origin, selected, actual_prepared| {
4331                assert_eq!(actual_origin, origin);
4332                assert_eq!(selected, Some("release/v2"));
4333                assert_eq!(actual_prepared, prepared);
4334                Ok(())
4335            },
4336            |_, _, _| panic!("unpinned update must not perform an exact fetch"),
4337            |_, _| panic!("unpinned update must not checkout FETCH_HEAD"),
4338            |_, _| panic!("clone validates its own selected revision"),
4339        )
4340        .unwrap();
4341    }
4342
4343    #[test]
4344    fn staged_git_update_build_failure_preserves_the_installed_checkout() {
4345        let tmp = tempfile::tempdir().unwrap();
4346        let store = tmp.path().join("store");
4347        let target = store.join("github.com/example/repo");
4348        let origin = "https://github.com/example/repo.git";
4349        let old_head = initialize_test_git_package(&target, origin, "old");
4350        std::fs::write(target.join("old-only.txt"), "keep\n").unwrap();
4351        let command = NpmCommand::from_argv(Some(&["unused".to_string()])).unwrap();
4352
4353        let error = stage_git_checkout_update_with(
4354            &target,
4355            &store,
4356            origin,
4357            None,
4358            &command,
4359            |prepared, expected_origin, revision| {
4360                assert_eq!(expected_origin, origin);
4361                assert_eq!(revision, None);
4362                initialize_test_git_package(prepared, origin, "new");
4363                Ok(())
4364            },
4365            |_prepared, _| Err("dependency build failed".to_string()),
4366        )
4367        .unwrap_err();
4368
4369        assert_eq!(error, "dependency build failed");
4370        assert_eq!(run_test_git(&target, &["rev-parse", "HEAD"]), old_head);
4371        assert_eq!(
4372            std::fs::read_to_string(target.join("state.txt"))
4373                .unwrap()
4374                .replace("\r\n", "\n"),
4375            "old\n"
4376        );
4377        assert!(target.join("old-only.txt").is_file());
4378        assert!(package_backup_dirs(&target).unwrap().is_empty());
4379        assert!(!target.parent().unwrap().read_dir().unwrap().any(|entry| {
4380            entry
4381                .ok()
4382                .and_then(|entry| entry.file_name().to_str().map(str::to_string))
4383                .is_some_and(|name| name.starts_with(".rpi-git-update-"))
4384        }));
4385    }
4386
4387    #[test]
4388    fn staged_git_update_swaps_only_after_validation_and_cleans_legacy_marker() {
4389        let tmp = tempfile::tempdir().unwrap();
4390        let store = tmp.path().join("store");
4391        let target = store.join("github.com/example/repo");
4392        let origin = "https://github.com/example/repo.git";
4393        let old_head = initialize_test_git_package(&target, origin, "old");
4394        std::fs::write(target.join("old-only.txt"), "remove\n").unwrap();
4395        let marker = git_update_marker_path(&target).unwrap();
4396        std::fs::write(&marker, "legacy in-place update\n").unwrap();
4397        let command = NpmCommand::from_argv(Some(&["unused".to_string()])).unwrap();
4398        let mut staged_head = None;
4399
4400        stage_git_checkout_update_with(
4401            &target,
4402            &store,
4403            origin,
4404            None,
4405            &command,
4406            |prepared, _, revision| {
4407                assert_eq!(revision, None);
4408                staged_head = Some(initialize_test_git_package(prepared, origin, "new"));
4409                Ok(())
4410            },
4411            |prepared, _| {
4412                std::fs::create_dir_all(prepared.join("node_modules/demo")).unwrap();
4413                Ok(())
4414            },
4415        )
4416        .unwrap();
4417
4418        assert_ne!(staged_head.as_deref(), Some(old_head.as_str()));
4419        assert_eq!(
4420            run_test_git(&target, &["rev-parse", "HEAD"]),
4421            staged_head.unwrap()
4422        );
4423        assert_eq!(
4424            std::fs::read_to_string(target.join("state.txt"))
4425                .unwrap()
4426                .replace("\r\n", "\n"),
4427            "new\n"
4428        );
4429        assert!(!target.join("old-only.txt").exists());
4430        assert!(target.join("node_modules/demo").is_dir());
4431        assert!(!marker.exists());
4432        assert!(package_backup_dirs(&target).unwrap().is_empty());
4433    }
4434
4435    #[test]
4436    fn git_swap_activation_failure_restores_the_previous_checkout() {
4437        let tmp = tempfile::tempdir().unwrap();
4438        let target = tmp.path().join("store/github.com/example/repo");
4439        let origin = "https://github.com/example/repo.git";
4440        let old_head = initialize_test_git_package(&target, origin, "old");
4441        let missing_prepared = target.parent().unwrap().join("missing-prepared");
4442
4443        let error = swap_prepared_dir(&missing_prepared, &target, true).unwrap_err();
4444
4445        assert!(error.contains("could not activate replacement"), "{error}");
4446        assert_eq!(run_test_git(&target, &["rev-parse", "HEAD"]), old_head);
4447        assert_eq!(
4448            std::fs::read_to_string(target.join("state.txt"))
4449                .unwrap()
4450                .replace("\r\n", "\n"),
4451            "old\n"
4452        );
4453        assert!(package_backup_dirs(&target).unwrap().is_empty());
4454    }
4455
4456    #[test]
4457    fn git_update_origin_gate_preserves_fixed_ref_for_a_matching_checkout() {
4458        fn git(cwd: &Path, args: &[&str]) {
4459            let status = Command::new("git")
4460                .args(args)
4461                .current_dir(cwd)
4462                .status()
4463                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4464            assert!(status.success(), "git {:?} exited with {status}", args);
4465        }
4466
4467        let tmp = tempfile::tempdir().unwrap();
4468        let store = tmp.path().join("store");
4469        let target = store.join("github.com/example/repo");
4470        std::fs::create_dir_all(&target).unwrap();
4471        git(&target, &["init", "--initial-branch", "main"]);
4472        git(
4473            &target,
4474            &[
4475                "remote",
4476                "add",
4477                "origin",
4478                "https://github.com/example/repo.git",
4479            ],
4480        );
4481
4482        let command = NpmCommand::from_argv(Some(&["unused".to_string()])).unwrap();
4483        let called = std::cell::Cell::new(false);
4484        update_git_package_with(
4485            &target,
4486            &store,
4487            "git:github.com/example/repo@release/v2",
4488            &command,
4489            |actual_root, origin, revision, _| {
4490                called.set(true);
4491                assert_eq!(actual_root, target);
4492                assert_eq!(origin, "https://github.com/example/repo.git");
4493                assert_eq!(revision, Some("release/v2"));
4494                Ok(())
4495            },
4496        )
4497        .unwrap();
4498        assert!(called.get());
4499    }
4500
4501    #[test]
4502    fn git_update_origin_mismatch_stops_before_network_or_checkout_changes() {
4503        fn git(cwd: &Path, args: &[&str]) -> String {
4504            let output = Command::new("git")
4505                .args(args)
4506                .current_dir(cwd)
4507                .output()
4508                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4509            assert!(
4510                output.status.success(),
4511                "git {:?} exited with {}",
4512                args,
4513                output.status
4514            );
4515            String::from_utf8_lossy(&output.stdout).trim().to_string()
4516        }
4517
4518        let tmp = tempfile::tempdir().unwrap();
4519        let store = tmp.path().join("store");
4520        let target = store.join("github.com/example/repo");
4521        std::fs::create_dir_all(&target).unwrap();
4522        git(&target, &["init", "--initial-branch", "main"]);
4523        git(
4524            &target,
4525            &["config", "user.email", "rpi-test@example.invalid"],
4526        );
4527        git(&target, &["config", "user.name", "rpi-test"]);
4528        std::fs::write(target.join("version.txt"), "unchanged\n").unwrap();
4529        git(&target, &["add", "version.txt"]);
4530        git(&target, &["commit", "-m", "initial"]);
4531        let head = git(&target, &["rev-parse", "HEAD"]);
4532
4533        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
4534        listener.set_nonblocking(true).unwrap();
4535        let origin = format!(
4536            "http://127.0.0.1:{}/attacker/repo.git",
4537            listener.local_addr().unwrap().port()
4538        );
4539        git(&target, &["remote", "add", "origin", &origin]);
4540
4541        let command = NpmCommand::from_argv(Some(&["unused".to_string()])).unwrap();
4542        let called = std::cell::Cell::new(false);
4543        let error = update_git_package_with(
4544            &target,
4545            &store,
4546            "git:github.com/example/repo@main",
4547            &command,
4548            |_, _, _, _| {
4549                called.set(true);
4550                Ok(())
4551            },
4552        )
4553        .unwrap_err();
4554
4555        assert!(error.contains("origin does not match"));
4556        assert!(!called.get());
4557        assert_eq!(git(&target, &["rev-parse", "HEAD"]), head);
4558        assert_eq!(
4559            std::fs::read_to_string(target.join("version.txt")).unwrap(),
4560            "unchanged\n"
4561        );
4562        assert_eq!(
4563            listener.accept().unwrap_err().kind(),
4564            std::io::ErrorKind::WouldBlock
4565        );
4566    }
4567
4568    #[test]
4569    fn git_update_origin_gate_rejects_missing_duplicate_and_dangerous_urls() {
4570        fn git(cwd: &Path, args: &[&str]) {
4571            let status = Command::new("git")
4572                .args(args)
4573                .current_dir(cwd)
4574                .status()
4575                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4576            assert!(status.success(), "git {:?} exited with {status}", args);
4577        }
4578
4579        let tmp = tempfile::tempdir().unwrap();
4580        let checkout = tmp.path().join("checkout");
4581        std::fs::create_dir_all(&checkout).unwrap();
4582        git(&checkout, &["init", "--initial-branch", "main"]);
4583        let expected = crate::packages::parse_git_source("git:github.com/example/repo").unwrap();
4584
4585        assert!(validated_git_origin(&checkout, &expected).is_err());
4586
4587        git(
4588            &checkout,
4589            &[
4590                "config",
4591                "--add",
4592                "remote.origin.url",
4593                "https://github.com/example/repo.git",
4594            ],
4595        );
4596        git(
4597            &checkout,
4598            &[
4599                "config",
4600                "--add",
4601                "remote.origin.url",
4602                "git@github.com:example/repo.git",
4603            ],
4604        );
4605        assert!(validated_git_origin(&checkout, &expected).is_err());
4606
4607        git(&checkout, &["config", "--unset-all", "remote.origin.url"]);
4608        git(
4609            &checkout,
4610            &["config", "remote.origin.url", "ext::sh -c 'echo unsafe'"],
4611        );
4612        assert!(validated_git_origin(&checkout, &expected).is_err());
4613
4614        git(
4615            &checkout,
4616            &[
4617                "config",
4618                "remote.origin.url",
4619                "https://github.com/example/repo.git ",
4620            ],
4621        );
4622        assert!(validated_git_origin(&checkout, &expected).is_err());
4623
4624        for mismatched in [
4625            "http://github.com/example/repo.git",
4626            "https://github.com:444/example/repo.git",
4627            "https://other-user@github.com/example/repo.git",
4628        ] {
4629            git(&checkout, &["config", "remote.origin.url", mismatched]);
4630            let error = validated_git_origin(&checkout, &expected).unwrap_err();
4631            assert!(
4632                error.contains("origin does not match"),
4633                "origin={mismatched}"
4634            );
4635        }
4636
4637        git(
4638            &checkout,
4639            &[
4640                "config",
4641                "remote.origin.url",
4642                "https://github.com:443/example/repo.git",
4643            ],
4644        );
4645        assert_eq!(
4646            validated_git_origin(&checkout, &expected).unwrap(),
4647            "https://github.com:443/example/repo.git"
4648        );
4649    }
4650
4651    #[test]
4652    fn git_update_rejects_external_core_worktree_before_fetch_reset_or_npm() {
4653        fn git(cwd: &Path, args: &[&str]) {
4654            let status = Command::new("git")
4655                .args(args)
4656                .current_dir(cwd)
4657                .status()
4658                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4659            assert!(status.success(), "git {:?} exited with {status}", args);
4660        }
4661
4662        let tmp = tempfile::tempdir().unwrap();
4663        let store = tmp.path().join("store");
4664        let target = store.join("github.com/example/repo");
4665        let outside = tmp.path().join("outside-worktree");
4666        std::fs::create_dir_all(&target).unwrap();
4667        std::fs::create_dir_all(&outside).unwrap();
4668        let sentinel = outside.join("must-survive.txt");
4669        std::fs::write(&sentinel, "unchanged\n").unwrap();
4670        git(&target, &["init", "--initial-branch", "main"]);
4671        git(
4672            &target,
4673            &[
4674                "remote",
4675                "add",
4676                "origin",
4677                "https://github.com/example/repo.git",
4678            ],
4679        );
4680        git(
4681            &target,
4682            &["config", "core.worktree", outside.to_str().unwrap()],
4683        );
4684
4685        let command = NpmCommand::from_argv(Some(&["must-not-run".to_string()])).unwrap();
4686        let called = std::cell::Cell::new(false);
4687        let error = update_git_package_with(
4688            &target,
4689            &store,
4690            "git:github.com/example/repo",
4691            &command,
4692            |_, _, _, _| {
4693                called.set(true);
4694                Ok(())
4695            },
4696        )
4697        .unwrap_err();
4698
4699        assert!(error.contains("unsafe local configuration"), "{error}");
4700        assert!(!called.get());
4701        assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "unchanged\n");
4702    }
4703
4704    #[test]
4705    fn git_update_origin_gate_rejects_command_capable_local_config() {
4706        fn git(cwd: &Path, args: &[&str]) {
4707            let status = Command::new("git")
4708                .args(args)
4709                .current_dir(cwd)
4710                .status()
4711                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4712            assert!(status.success(), "git {:?} exited with {status}", args);
4713        }
4714
4715        let expected = crate::packages::parse_git_source("git:github.com/example/repo").unwrap();
4716        for (key, value) in [
4717            ("url.https://evil.invalid/.insteadOf", "https://github.com/"),
4718            ("core.sshCommand", "malicious-ssh"),
4719            ("credential.helper", "!malicious-helper"),
4720            ("http.https://github.com.proxy", "http://evil.invalid"),
4721            ("http.curloptResolve", "github.com:443:127.0.0.1"),
4722            (
4723                "http.https://github.com.curloptResolve",
4724                "github.com:443:127.0.0.1",
4725            ),
4726            ("fetch.bundleURI", "file:///outside/repository.bundle"),
4727            ("include.path", "../untrusted-config"),
4728            ("filter.package.process", "malicious-filter"),
4729        ] {
4730            let tmp = tempfile::tempdir().unwrap();
4731            let checkout = tmp.path().join("checkout");
4732            std::fs::create_dir_all(&checkout).unwrap();
4733            git(&checkout, &["init", "--initial-branch", "main"]);
4734            git(
4735                &checkout,
4736                &[
4737                    "remote",
4738                    "add",
4739                    "origin",
4740                    "https://github.com/example/repo.git",
4741                ],
4742            );
4743            git(&checkout, &["config", key, value]);
4744
4745            let error = validated_git_origin(&checkout, &expected).unwrap_err();
4746            assert!(error.contains("unsafe local configuration"), "key={key}");
4747        }
4748    }
4749
4750    #[test]
4751    fn hardened_git_command_drops_environment_config_and_command_injection() {
4752        let tmp = tempfile::tempdir().unwrap();
4753        let status = Command::new("git")
4754            .args(["init", "--initial-branch", "main"])
4755            .current_dir(tmp.path())
4756            .status()
4757            .unwrap();
4758        assert!(status.success());
4759
4760        let injected_key = "url.https://attacker.invalid/.insteadOf";
4761        let mut command = Command::new("git");
4762        command
4763            .env("GIT_CONFIG_COUNT", "1")
4764            .env("GIT_CONFIG_KEY_0", injected_key)
4765            .env("GIT_CONFIG_VALUE_0", "https://github.com/")
4766            .env("GIT_SSH_COMMAND", "malicious-ssh");
4767        apply_hardened_git_environment(&mut command);
4768        let ssh_command = command
4769            .get_envs()
4770            .find(|(name, _)| *name == std::ffi::OsStr::new("GIT_SSH_COMMAND"));
4771        assert!(ssh_command.is_some_and(|(_, value)| value.is_none()));
4772
4773        let output = command
4774            .args(hardened_git_network_config_args())
4775            .args(["config", "--get-all", injected_key])
4776            .current_dir(tmp.path())
4777            .output()
4778            .unwrap();
4779        assert!(!output.status.success());
4780        assert!(output.stdout.is_empty());
4781    }
4782
4783    #[test]
4784    fn hardened_git_config_allows_only_known_credential_helpers() {
4785        for helper in [
4786            "cache",
4787            "libsecret",
4788            "manager",
4789            "manager-core",
4790            "osxkeychain",
4791            "store",
4792            "wincred",
4793        ] {
4794            assert!(is_safe_standard_credential_helper(helper));
4795        }
4796        for helper in [
4797            "!malicious-helper",
4798            "manager --arg",
4799            "/tmp/helper",
4800            "custom",
4801            "",
4802        ] {
4803            assert!(!is_safe_standard_credential_helper(helper));
4804        }
4805
4806        let args = hardened_git_network_config_args();
4807        let values = args
4808            .chunks_exact(2)
4809            .map(|pair| pair[1].as_str())
4810            .collect::<Vec<_>>();
4811        assert!(values.contains(&"protocol.allow=never"));
4812        assert!(values.contains(&"protocol.ext.allow=never"));
4813        assert!(values.contains(&"protocol.file.allow=never"));
4814        assert!(values.contains(&"credential.helper="));
4815        assert!(values.iter().all(|value| {
4816            value
4817                .strip_prefix("credential.helper=")
4818                .map_or(true, |helper| {
4819                    helper.is_empty() || is_safe_standard_credential_helper(helper)
4820                })
4821        }));
4822    }
4823
4824    #[test]
4825    fn cloned_checkout_validation_requires_exact_origin_and_valid_head() {
4826        fn git(cwd: &Path, args: &[&str]) {
4827            let status = Command::new("git")
4828                .args(args)
4829                .current_dir(cwd)
4830                .status()
4831                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4832            assert!(status.success(), "git {:?} exited with {status}", args);
4833        }
4834
4835        let tmp = tempfile::tempdir().unwrap();
4836        let checkout = tmp.path().join("checkout");
4837        std::fs::create_dir_all(&checkout).unwrap();
4838        git(&checkout, &["init", "--initial-branch", "main"]);
4839        git(
4840            &checkout,
4841            &["config", "user.email", "rpi-test@example.invalid"],
4842        );
4843        git(&checkout, &["config", "user.name", "rpi-test"]);
4844        std::fs::write(checkout.join("version.txt"), "one\n").unwrap();
4845        git(&checkout, &["add", "version.txt"]);
4846        git(&checkout, &["commit", "-m", "one"]);
4847        let requested = "https://github.com/example/repo.git";
4848        git(&checkout, &["remote", "add", "origin", requested]);
4849        let expected = crate::packages::parse_git_source(requested).unwrap();
4850
4851        validate_cloned_git_origin(&checkout, requested, &expected).unwrap();
4852        validate_git_head(&checkout, None).unwrap();
4853
4854        git(
4855            &checkout,
4856            &[
4857                "remote",
4858                "set-url",
4859                "origin",
4860                "ssh://git@github.com/example/repo.git",
4861            ],
4862        );
4863        let error = validate_cloned_git_origin(&checkout, requested, &expected).unwrap_err();
4864        assert!(error.contains("origin does not match"), "{error}");
4865    }
4866
4867    #[test]
4868    fn full_clone_can_checkout_a_non_default_branch() {
4869        fn git(cwd: &Path, args: &[&str]) {
4870            let status = Command::new("git")
4871                .args(args)
4872                .current_dir(cwd)
4873                .status()
4874                .unwrap_or_else(|error| panic!("git {:?}: {error}", args));
4875            assert!(status.success(), "git {:?} exited with {status}", args);
4876        }
4877
4878        let temp = tempfile::tempdir().unwrap();
4879        let source = temp.path().join("source");
4880        let checkout = temp.path().join("checkout");
4881        std::fs::create_dir_all(&source).unwrap();
4882        git(&source, &["init", "--initial-branch", "main"]);
4883        git(
4884            &source,
4885            &["config", "user.email", "rpi-test@example.invalid"],
4886        );
4887        git(&source, &["config", "user.name", "rpi-test"]);
4888        std::fs::write(source.join("version.txt"), "main\n").unwrap();
4889        git(&source, &["add", "version.txt"]);
4890        git(&source, &["commit", "-m", "main"]);
4891        git(&source, &["checkout", "-b", "feature/branch"]);
4892        std::fs::write(source.join("version.txt"), "feature\n").unwrap();
4893        git(&source, &["add", "version.txt"]);
4894        git(&source, &["commit", "-m", "feature"]);
4895        git(&source, &["checkout", "main"]);
4896
4897        git(
4898            temp.path(),
4899            &[
4900                "-c",
4901                "core.autocrlf=false",
4902                "clone",
4903                source.to_str().unwrap(),
4904                checkout.to_str().unwrap(),
4905            ],
4906        );
4907        checkout_git_revision(&checkout, "feature/branch").unwrap();
4908        validate_git_head(&checkout, Some("feature/branch")).unwrap();
4909        assert_eq!(
4910            std::fs::read_to_string(checkout.join("version.txt"))
4911                .unwrap()
4912                .replace("\r\n", "\n"),
4913            "feature\n"
4914        );
4915        assert_eq!(
4916            run_git_capture(&checkout, &["branch", "--show-current"]).unwrap(),
4917            "feature/branch"
4918        );
4919    }
4920
4921    #[cfg(unix)]
4922    #[test]
4923    fn managed_uninstall_root_rejects_symlinked_store_escape() {
4924        let tmp = tempfile::tempdir().unwrap();
4925        let external_store = tmp.path().join("node_modules");
4926        let external_package = external_store.join("demo");
4927        std::fs::create_dir_all(&external_package).unwrap();
4928        std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
4929        std::os::unix::fs::symlink(&external_store, tmp.path().join(".rpi/packages")).unwrap();
4930
4931        assert!(!is_managed_package_root(tmp.path(), &external_package));
4932    }
4933
4934    #[cfg(unix)]
4935    #[test]
4936    fn npm_mutation_rejects_symlinked_target_escape() {
4937        let tmp = tempfile::tempdir().unwrap();
4938        let root = tmp.path().join("npm");
4939        let external = tmp.path().join("external/demo");
4940        std::fs::create_dir_all(root.join("node_modules")).unwrap();
4941        std::fs::create_dir_all(&external).unwrap();
4942        std::os::unix::fs::symlink(&external, root.join("node_modules/demo")).unwrap();
4943
4944        let error =
4945            validate_npm_target_before_mutation(&root, Path::new("demo"), false).unwrap_err();
4946        assert!(error.contains("outside its managed root"), "{error}");
4947    }
4948
4949    #[cfg(windows)]
4950    #[test]
4951    fn managed_uninstall_root_rejects_symlinked_store_escape() {
4952        let tmp = tempfile::tempdir().unwrap();
4953        let external_store = tmp.path().join("node_modules");
4954        let external_package = external_store.join("demo");
4955        std::fs::create_dir_all(&external_package).unwrap();
4956        std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
4957        if std::os::windows::fs::symlink_dir(&external_store, tmp.path().join(".rpi/packages"))
4958            .is_err()
4959        {
4960            return;
4961        }
4962
4963        assert!(!is_managed_package_root(tmp.path(), &external_package));
4964    }
4965
4966    #[cfg(windows)]
4967    #[test]
4968    fn npm_mutation_rejects_symlinked_target_escape() {
4969        let tmp = tempfile::tempdir().unwrap();
4970        let root = tmp.path().join("npm");
4971        let external = tmp.path().join("external/demo");
4972        std::fs::create_dir_all(root.join("node_modules")).unwrap();
4973        std::fs::create_dir_all(&external).unwrap();
4974        if std::os::windows::fs::symlink_dir(&external, root.join("node_modules/demo")).is_err() {
4975            return;
4976        }
4977
4978        let error =
4979            validate_npm_target_before_mutation(&root, Path::new("demo"), false).unwrap_err();
4980        assert!(error.contains("outside its managed root"), "{error}");
4981    }
4982}