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