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