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