1use regex::Regex;
3use serde_json::Value;
4use std::fs;
5use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
6use std::path::Path;
7use url::Url;
8
9pub fn update_urls(value: &mut Value, new_url: &str) {
12 let re = Regex::new(r"https?://[^/]+").unwrap();
13 update_urls_inner(value, new_url, &re);
14}
15
16fn update_urls_inner(value: &mut Value, new_url: &str, re: &Regex) {
17 match value {
18 Value::Object(map) => {
19 if let Some(resolved) = map.get_mut("resolved") {
20 if let Some(old_url) = resolved.as_str() {
21 let updated_url = re.replace(old_url, new_url).into_owned();
23 *resolved = Value::String(updated_url);
24 }
25 }
26 for v in map.values_mut() {
28 update_urls_inner(v, new_url, re);
29 }
30 }
31 Value::Array(arr) => {
32 for item in arr.iter_mut() {
33 update_urls_inner(item, new_url, re);
34 }
35 }
36 _ => {}
37 }
38}
39
40pub fn rewrite_lockfile(lockfile: &Path, new_url: &str) -> Result<(), Box<dyn std::error::Error>> {
43 if !lockfile.exists() {
44 return Err(format!("lockfile not found: {}", lockfile.display()).into());
45 }
46
47 let file_content = fs::read_to_string(lockfile)?;
49 let mut json_content: Value = serde_json::from_str(&file_content)?;
50
51 update_urls(&mut json_content, new_url);
53
54 let updated_content = serde_json::to_string_pretty(&json_content)?;
56 fs::write(lockfile, updated_content)?;
57 Ok(())
58}
59
60pub fn update_urls_from_config(
63 config: &Path,
64 lockfile: &Path,
65 arg: &str,
66) -> Result<(), Box<dyn std::error::Error>> {
67 if !config.exists() {
69 return Err("pkg.config.json not found".into());
70 }
71 if !lockfile.exists() {
72 return Err("package-lock.json not found".into());
73 }
74
75 let file_content = fs::read_to_string(lockfile)?;
77 let mut json_content: Value = serde_json::from_str(&file_content)?;
78
79 let config_content = fs::read_to_string(config)?;
81 let config_json: Value = serde_json::from_str(&config_content)?;
82
83 let new_url = if arg == "--local" {
85 config_json["local"]
86 .as_str()
87 .ok_or("Local URL not found in pkg.config.json")?
88 } else if arg == "--remote" {
89 config_json["remote"]
90 .as_str()
91 .ok_or("Remote URL not found in pkg.config.json")?
92 } else {
93 return Err("Invalid argument. Use --local or --remote.".into());
94 };
95
96 update_urls(&mut json_content, new_url);
97 let updated_content = serde_json::to_string_pretty(&json_content)?;
98 fs::write(lockfile, updated_content)?;
99 Ok(())
100}
101
102pub fn update_urls_in_package_lock(arg: &str) -> Result<(), Box<dyn std::error::Error>> {
106 update_urls_from_config(
107 Path::new("pkg.config.json"),
108 Path::new("package-lock.json"),
109 arg,
110 )
111}
112
113fn is_local_host(host: &str) -> bool {
130 let stripped = host.strip_prefix('[').and_then(|s| s.strip_suffix(']'));
132 let ip_candidate = stripped.unwrap_or(host);
133 if let Ok(ip) = ip_candidate.parse::<IpAddr>() {
134 return is_local_ip(ip);
135 }
136
137 let lower = host.to_ascii_lowercase();
140 let lower = lower.strip_suffix('.').unwrap_or(&lower);
141
142 if lower == "localhost" {
143 return true;
144 }
145
146 for suffix in [".test", ".local", ".lan"] {
148 if lower.ends_with(suffix) && lower.len() > suffix.len() {
149 return true;
150 }
151 }
152
153 false
154}
155
156fn is_local_ip(ip: IpAddr) -> bool {
157 match ip {
158 IpAddr::V4(v4) => is_local_ipv4(v4),
159 IpAddr::V6(v6) => is_local_ipv6(v6),
160 }
161}
162
163fn is_local_ipv4(ip: Ipv4Addr) -> bool {
164 let [a, b, _, _] = ip.octets();
165 if a == 127 {
167 return true;
168 }
169 if a == 10 {
171 return true;
172 }
173 if a == 172 && (16..=31).contains(&b) {
175 return true;
176 }
177 if a == 192 && b == 168 {
179 return true;
180 }
181 false
182}
183
184fn is_local_ipv6(ip: Ipv6Addr) -> bool {
185 ip == Ipv6Addr::LOCALHOST
187}
188
189enum RewriteDecision {
191 Skip,
193 ReplaceSchemeAuthority(String),
196}
197
198fn walk_resolved_urls<F>(value: &mut Value, decide: &F) -> usize
201where
202 F: Fn(&Url) -> RewriteDecision,
203{
204 let mut count = 0;
205 match value {
206 Value::Object(map) => {
207 if let Some(resolved) = map.get_mut("resolved") {
208 if let Some(old_url_str) = resolved.as_str() {
209 if let Ok(parsed) = Url::parse(old_url_str) {
210 if let RewriteDecision::ReplaceSchemeAuthority(new_prefix) = decide(&parsed)
211 {
212 let suffix = &old_url_str[scheme_authority_len(old_url_str, &parsed)..];
213 let new_url = format!("{}{}", new_prefix, suffix);
214 if new_url != old_url_str {
215 *resolved = Value::String(new_url);
216 count += 1;
217 }
218 }
219 }
220 }
221 }
222 for v in map.values_mut() {
223 count += walk_resolved_urls(v, decide);
224 }
225 }
226 Value::Array(arr) => {
227 for item in arr.iter_mut() {
228 count += walk_resolved_urls(item, decide);
229 }
230 }
231 _ => {}
232 }
233 count
234}
235
236fn scheme_authority_len(raw: &str, parsed: &Url) -> usize {
240 let scheme_len = parsed.scheme().len();
241 let after_scheme = scheme_len + 3;
243 let tail = &raw[after_scheme..];
246 let end = tail.find(['/', '?', '#']).unwrap_or(tail.len());
247 after_scheme + end
248}
249
250pub fn rewrite_lockfile_to_public(lockfile: &Path) -> Result<usize, Box<dyn std::error::Error>> {
254 if !lockfile.exists() {
255 return Err(format!("lockfile not found: {}", lockfile.display()).into());
256 }
257
258 let file_content = fs::read_to_string(lockfile)?;
259 let mut json_content: Value = serde_json::from_str(&file_content)?;
260
261 let decide = |parsed: &Url| -> RewriteDecision {
262 match parsed.host_str() {
263 Some(host) if is_local_host(host) => {
264 RewriteDecision::ReplaceSchemeAuthority("https://registry.npmjs.org".to_string())
265 }
266 _ => RewriteDecision::Skip,
267 }
268 };
269
270 let count = walk_resolved_urls(&mut json_content, &decide);
271
272 let updated_content = serde_json::to_string_pretty(&json_content)?;
274 fs::write(lockfile, updated_content)?;
275 Ok(count)
276}
277
278fn normalize_local_url(local_url: &str) -> Result<String, Box<dyn std::error::Error>> {
290 let parsed = Url::parse(local_url)
291 .map_err(|e| format!("invalid --to-local URL '{}': {}", local_url, e))?;
292
293 let scheme = parsed.scheme();
294 if scheme != "http" && scheme != "https" {
295 return Err(format!(
296 "invalid --to-local URL '{}': scheme must be http or https",
297 local_url
298 )
299 .into());
300 }
301 match parsed.host_str() {
302 None => return Err(format!("invalid --to-local URL '{}': missing host", local_url).into()),
303 Some("") => {
304 return Err(format!("invalid --to-local URL '{}': missing host", local_url).into())
305 }
306 _ => {}
307 }
308 if parsed.query().is_some() || parsed.fragment().is_some() {
309 return Err(format!(
310 "invalid --to-local URL '{}': must not have query or fragment",
311 local_url
312 )
313 .into());
314 }
315 if !parsed.username().is_empty() || parsed.password().is_some() {
316 return Err(format!(
317 "invalid --to-local URL '{}': must not embed credentials (use .npmrc _authToken)",
318 local_url
319 )
320 .into());
321 }
322
323 let auth_end = scheme_authority_len(local_url, &parsed);
329 let path = &local_url[auth_end..];
330 let path = path.strip_suffix('/').unwrap_or(path);
331 Ok(format!("{}{}", &local_url[..auth_end], path))
332}
333
334pub fn npmrc_registry(npmrc: &Path) -> Option<String> {
346 let content = fs::read_to_string(npmrc).ok()?;
347 let mut found: Option<String> = None;
348 for raw_line in content.lines() {
349 let line = raw_line.trim_start_matches('\u{feff}').trim();
352 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
353 continue;
354 }
355 let Some((key, value)) = line.split_once('=') else {
356 continue;
357 };
358 if !key.trim().eq_ignore_ascii_case("registry") {
361 continue;
362 }
363 let value = value.trim();
364 let value = match value.find([' ', '\t']) {
368 Some(i)
369 if matches!(
370 value[i..].trim_start().chars().next(),
371 Some('#') | Some(';')
372 ) =>
373 {
374 value[..i].trim_end()
375 }
376 _ => value,
377 };
378 let value = value
380 .strip_prefix('"')
381 .and_then(|s| s.strip_suffix('"'))
382 .or_else(|| value.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
383 .unwrap_or(value);
384 if !value.is_empty() {
385 found = Some(value.to_string());
386 }
387 }
388 found
389}
390
391pub fn rewrite_lockfile_to_local(
395 lockfile: &Path,
396 local_url: &str,
397) -> Result<usize, Box<dyn std::error::Error>> {
398 let local_prefix = normalize_local_url(local_url)?;
399
400 if !lockfile.exists() {
401 return Err(format!("lockfile not found: {}", lockfile.display()).into());
402 }
403
404 let file_content = fs::read_to_string(lockfile)?;
405 let mut json_content: Value = serde_json::from_str(&file_content)?;
406
407 let decide = |parsed: &Url| -> RewriteDecision {
408 if parsed.host_str() == Some("registry.npmjs.org") {
413 RewriteDecision::ReplaceSchemeAuthority(local_prefix.clone())
414 } else {
415 RewriteDecision::Skip
416 }
417 };
418
419 let count = walk_resolved_urls(&mut json_content, &decide);
420
421 let updated_content = serde_json::to_string_pretty(&json_content)?;
422 fs::write(lockfile, updated_content)?;
423 Ok(count)
424}
425
426#[derive(Debug)]
434pub enum InstallHookResult {
435 Installed,
437 AlreadyExists,
439}
440
441const PRE_COMMIT_HOOK: &str = "\
443#!/bin/sh
444# Auto-rewrite local registry URLs in package-lock.json before commit.
445# Installed by `pkglock install-hook`. Safe to delete or edit by hand.
446
447set -e
448cd \"$(git rev-parse --show-toplevel)\"
449
450if ! git diff --cached --name-only --diff-filter=ACMR | grep -q '^package-lock\\.json$'; then
451 exit 0
452fi
453
454if ! command -v pkglock >/dev/null 2>&1; then
455 echo \"pkglock: command not found on PATH — install pkglock or commit with --no-verify\" >&2
456 exit 1
457fi
458
459pkglock --to-public
460git add package-lock.json
461echo \"pkglock: rewrote local URLs in package-lock.json before commit\"
462";
463
464pub fn install_pre_commit_hook(
472 repo_root: &Path,
473) -> Result<InstallHookResult, Box<dyn std::error::Error>> {
474 let git_dir = repo_root.join(".git");
475 let meta = fs::metadata(&git_dir).map_err(|_| -> Box<dyn std::error::Error> {
479 "pkglock: must run install-hook from the git repo root (.git not found in cwd)".into()
480 })?;
481 if !meta.is_dir() {
482 return Err("pkglock: .git is not a directory (git worktrees not supported)".into());
483 }
484
485 let hooks_dir = git_dir.join("hooks");
486 if !hooks_dir.exists() {
487 fs::create_dir_all(&hooks_dir)?;
488 } else if !hooks_dir.is_dir() {
489 return Err(format!(
490 "pkglock: {} exists but is not a directory",
491 hooks_dir.display()
492 )
493 .into());
494 }
495
496 let hook_path = hooks_dir.join("pre-commit");
497 if hook_path.exists() {
498 return Ok(InstallHookResult::AlreadyExists);
499 }
500
501 fs::write(&hook_path, PRE_COMMIT_HOOK)?;
502
503 #[cfg(unix)]
504 {
505 use std::os::unix::fs::PermissionsExt;
506 let mut perms = fs::metadata(&hook_path)?.permissions();
507 perms.set_mode(0o755);
508 fs::set_permissions(&hook_path, perms)?;
509 }
510
511 Ok(InstallHookResult::Installed)
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use serde_json::json;
518 use std::fs;
519
520 #[test]
521 fn test_update_urls_simple() {
522 let mut json = json!({
523 "resolved": "https://registry.npmjs.org/package/-/package-1.0.0.tgz"
524 });
525 update_urls(&mut json, "http://localhost:4873");
526 assert_eq!(
527 json["resolved"],
528 "http://localhost:4873/package/-/package-1.0.0.tgz"
529 );
530 }
531
532 #[test]
533 fn test_update_urls_nested() {
534 let mut json = json!({
535 "dependencies": {
536 "package": {
537 "resolved": "https://registry.npmjs.org/package/-/package-1.0.0.tgz"
538 }
539 }
540 });
541 update_urls(&mut json, "http://localhost:4873");
542 assert_eq!(
543 json["dependencies"]["package"]["resolved"],
544 "http://localhost:4873/package/-/package-1.0.0.tgz"
545 );
546 }
547
548 #[test]
549 fn test_update_urls_no_resolved_field() {
550 let mut json = json!({
551 "name": "test-package",
552 "version": "1.0.0"
553 });
554 update_urls(&mut json, "http://localhost:4873");
555 assert_eq!(json["name"], "test-package");
556 assert_eq!(json["version"], "1.0.0");
557 }
558
559 #[test]
560 fn test_update_urls_array_recursion() {
561 let mut top_array = json!([
563 { "resolved": "https://registry.npmjs.org/a/-/a-1.0.0.tgz" },
564 { "resolved": "https://registry.npmjs.org/b/-/b-2.0.0.tgz" }
565 ]);
566 update_urls(&mut top_array, "http://localhost:4873");
567 assert_eq!(
568 top_array[0]["resolved"],
569 "http://localhost:4873/a/-/a-1.0.0.tgz"
570 );
571 assert_eq!(
572 top_array[1]["resolved"],
573 "http://localhost:4873/b/-/b-2.0.0.tgz"
574 );
575
576 let mut nested = json!({
578 "packages": [
579 { "resolved": "https://registry.npmjs.org/c/-/c-3.0.0.tgz" },
580 {
581 "nested": {
582 "resolved": "https://registry.npmjs.org/d/-/d-4.0.0.tgz"
583 }
584 }
585 ]
586 });
587 update_urls(&mut nested, "http://localhost:4873");
588 assert_eq!(
589 nested["packages"][0]["resolved"],
590 "http://localhost:4873/c/-/c-3.0.0.tgz"
591 );
592 assert_eq!(
593 nested["packages"][1]["nested"]["resolved"],
594 "http://localhost:4873/d/-/d-4.0.0.tgz"
595 );
596 }
597
598 #[test]
599 fn test_update_urls_mixed_array() {
600 let mut mixed = json!([
601 "string",
602 42,
603 null,
604 { "resolved": "https://registry.npmjs.org/x/-/x-1.0.0.tgz" }
605 ]);
606 update_urls(&mut mixed, "http://localhost:4873");
607 assert_eq!(mixed[0], "string");
608 assert_eq!(mixed[1], 42);
609 assert!(mixed[2].is_null());
610 assert_eq!(
611 mixed[3]["resolved"],
612 "http://localhost:4873/x/-/x-1.0.0.tgz"
613 );
614 }
615
616 #[test]
617 fn test_rewrite_lockfile_explicit_path() {
618 let dir = tempfile::tempdir().unwrap();
620 let lockfile = dir.path().join("package-lock.json");
621
622 let package_lock = r#"{
623 "dependencies": {
624 "package-a": {
625 "resolved": "https://registry.npmjs.org/package-a/-/package-a-1.0.0.tgz"
626 }
627 }
628 }"#;
629 fs::write(&lockfile, package_lock).unwrap();
630
631 rewrite_lockfile(&lockfile, "http://localhost:4873").unwrap();
632
633 let updated_content = fs::read_to_string(&lockfile).unwrap();
634 assert!(updated_content.contains("http://localhost:4873"));
635 assert!(!updated_content.contains("https://registry.npmjs.org"));
636
637 let missing = dir.path().join("does-not-exist.json");
639 let err = rewrite_lockfile(&missing, "http://localhost:4873").unwrap_err();
640 let msg = err.to_string();
641 assert!(
642 msg.contains("lockfile not found"),
643 "unexpected error message: {msg}"
644 );
645 assert!(
646 msg.contains(&missing.display().to_string()),
647 "error message did not include path: {msg}"
648 );
649 }
650
651 #[test]
652 fn test_is_local_host_positives() {
653 for host in [
654 "localhost",
655 "LOCALHOST",
656 "myhost.test",
657 "myhost.local",
658 "myhost.lan",
659 "a.b.test",
660 "Foo.Local",
661 "127.0.0.1",
662 "127.255.255.254",
663 "10.0.0.1",
664 "10.255.255.255",
665 "172.16.0.1",
666 "172.16.0.0",
667 "172.31.255.255",
668 "192.168.1.1",
669 "::1",
670 ] {
671 assert!(is_local_host(host), "expected {host} to be local");
672 }
673 }
674
675 #[test]
676 fn test_is_local_host_negatives() {
677 for host in [
678 "registry.npmjs.org",
679 "example.com",
680 "notlocalhost",
681 "localhost.example.com",
682 "mytest.com",
683 "test",
684 "local",
685 "lan",
686 ".test",
687 ".local",
688 ".lan",
689 "172.15.0.1",
690 "172.32.0.1",
691 "11.0.0.1",
692 "192.169.0.1",
693 "8.8.8.8",
694 "2001:db8::1",
695 ] {
696 assert!(!is_local_host(host), "expected {host} to NOT be local");
697 }
698 }
699
700 #[test]
701 fn test_is_local_host_bracketed_ipv6() {
702 assert!(is_local_host("[::1]"));
704 assert!(!is_local_host("[2001:db8::1]"));
705 }
706
707 #[test]
708 fn test_rewrite_lockfile_to_public_mixed() {
709 let dir = tempfile::tempdir().unwrap();
710 let lockfile = dir.path().join("package-lock.json");
711
712 let package_lock = r#"{
713 "dependencies": {
714 "keep-me": {
715 "resolved": "https://registry.npmjs.org/keep-me/-/keep-me-1.0.0.tgz"
716 },
717 "from-localhost": {
718 "resolved": "http://localhost:4873/from-localhost/-/from-localhost-1.0.0.tgz"
719 },
720 "from-private-ip": {
721 "resolved": "http://192.168.1.10:4873/from-private-ip/-/from-private-ip-2.0.0.tgz"
722 },
723 "from-test-tld": {
724 "resolved": "http://myhost.test/from-test-tld/-/from-test-tld-3.0.0.tgz"
725 },
726 "from-external": {
727 "resolved": "https://example.com/from-external/-/from-external-4.0.0.tgz"
728 }
729 }
730 }"#;
731 fs::write(&lockfile, package_lock).unwrap();
732
733 let count = rewrite_lockfile_to_public(&lockfile).unwrap();
734 assert_eq!(count, 3, "expected 3 URLs to be rewritten");
735
736 let updated: Value = serde_json::from_str(&fs::read_to_string(&lockfile).unwrap()).unwrap();
737
738 assert_eq!(
739 updated["dependencies"]["keep-me"]["resolved"],
740 "https://registry.npmjs.org/keep-me/-/keep-me-1.0.0.tgz"
741 );
742 assert_eq!(
743 updated["dependencies"]["from-localhost"]["resolved"],
744 "https://registry.npmjs.org/from-localhost/-/from-localhost-1.0.0.tgz"
745 );
746 assert_eq!(
747 updated["dependencies"]["from-private-ip"]["resolved"],
748 "https://registry.npmjs.org/from-private-ip/-/from-private-ip-2.0.0.tgz"
749 );
750 assert_eq!(
751 updated["dependencies"]["from-test-tld"]["resolved"],
752 "https://registry.npmjs.org/from-test-tld/-/from-test-tld-3.0.0.tgz"
753 );
754 assert_eq!(
755 updated["dependencies"]["from-external"]["resolved"],
756 "https://example.com/from-external/-/from-external-4.0.0.tgz"
757 );
758 }
759
760 #[test]
761 fn test_rewrite_lockfile_to_public_no_matches() {
762 let dir = tempfile::tempdir().unwrap();
763 let lockfile = dir.path().join("package-lock.json");
764
765 let package_lock = r#"{
766 "dependencies": {
767 "a": { "resolved": "https://registry.npmjs.org/a/-/a-1.0.0.tgz" }
768 }
769 }"#;
770 fs::write(&lockfile, package_lock).unwrap();
771
772 let count = rewrite_lockfile_to_public(&lockfile).unwrap();
773 assert_eq!(count, 0);
774 }
775
776 #[test]
777 fn test_rewrite_lockfile_to_public_missing_file() {
778 let dir = tempfile::tempdir().unwrap();
779 let missing = dir.path().join("nope.json");
780 let err = rewrite_lockfile_to_public(&missing).unwrap_err();
781 let msg = err.to_string();
782 assert!(msg.contains("lockfile not found"), "unexpected: {msg}");
783 }
784
785 #[test]
786 fn test_is_local_host_trailing_dot_fqdn() {
787 for host in ["foo.local.", "foo.test.", "foo.lan.", "localhost."] {
788 assert!(is_local_host(host), "expected {host} to be local");
789 }
790 }
791
792 #[test]
793 fn test_rewrite_lockfile_to_public_preserves_query_only_no_path() {
794 let dir = tempfile::tempdir().unwrap();
795 let lockfile = dir.path().join("package-lock.json");
796 let package_lock = r#"{
797 "resolved": "http://localhost?token=abc"
798 }"#;
799 fs::write(&lockfile, package_lock).unwrap();
800 let count = rewrite_lockfile_to_public(&lockfile).unwrap();
801 assert_eq!(count, 1);
802 let updated: Value = serde_json::from_str(&fs::read_to_string(&lockfile).unwrap()).unwrap();
803 assert_eq!(updated["resolved"], "https://registry.npmjs.org?token=abc");
804 }
805
806 #[test]
807 fn test_rewrite_lockfile_to_public_preserves_fragment_only_no_path() {
808 let dir = tempfile::tempdir().unwrap();
809 let lockfile = dir.path().join("package-lock.json");
810 let package_lock = r#"{
811 "resolved": "http://localhost#sha"
812 }"#;
813 fs::write(&lockfile, package_lock).unwrap();
814 let count = rewrite_lockfile_to_public(&lockfile).unwrap();
815 assert_eq!(count, 1);
816 let updated: Value = serde_json::from_str(&fs::read_to_string(&lockfile).unwrap()).unwrap();
817 assert_eq!(updated["resolved"], "https://registry.npmjs.org#sha");
818 }
819
820 #[test]
821 fn test_rewrite_lockfile_to_public_preserves_query_and_fragment_no_path() {
822 let dir = tempfile::tempdir().unwrap();
823 let lockfile = dir.path().join("package-lock.json");
824 let package_lock = r#"{
825 "resolved": "http://localhost?q=1#sha"
826 }"#;
827 fs::write(&lockfile, package_lock).unwrap();
828 let count = rewrite_lockfile_to_public(&lockfile).unwrap();
829 assert_eq!(count, 1);
830 let updated: Value = serde_json::from_str(&fs::read_to_string(&lockfile).unwrap()).unwrap();
831 assert_eq!(updated["resolved"], "https://registry.npmjs.org?q=1#sha");
832 }
833
834 #[test]
835 fn test_rewrite_lockfile_to_public_preserves_query_and_fragment() {
836 let dir = tempfile::tempdir().unwrap();
837 let lockfile = dir.path().join("package-lock.json");
838 let package_lock = r#"{
839 "resolved": "http://localhost:4873/pkg/-/pkg-1.0.0.tgz?token=abc#sha"
840 }"#;
841 fs::write(&lockfile, package_lock).unwrap();
842 let count = rewrite_lockfile_to_public(&lockfile).unwrap();
843 assert_eq!(count, 1);
844 let updated: Value = serde_json::from_str(&fs::read_to_string(&lockfile).unwrap()).unwrap();
845 assert_eq!(
846 updated["resolved"],
847 "https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz?token=abc#sha"
848 );
849 }
850
851 #[test]
856 fn test_npmrc_registry_basic() {
857 let dir = tempfile::tempdir().unwrap();
858 let npmrc = dir.path().join(".npmrc");
859 fs::write(&npmrc, "registry=https://registry.npmjs.org/\n").unwrap();
860 assert_eq!(
861 npmrc_registry(&npmrc),
862 Some("https://registry.npmjs.org/".to_string())
863 );
864 }
865
866 #[test]
867 fn test_npmrc_registry_whitespace_and_case() {
868 let dir = tempfile::tempdir().unwrap();
869 let npmrc = dir.path().join(".npmrc");
870 fs::write(&npmrc, " Registry = http://localhost:4873 \n").unwrap();
871 assert_eq!(
872 npmrc_registry(&npmrc),
873 Some("http://localhost:4873".to_string())
874 );
875 }
876
877 #[test]
878 fn test_npmrc_registry_comments_and_blanks_ignored() {
879 let dir = tempfile::tempdir().unwrap();
880 let npmrc = dir.path().join(".npmrc");
881 let body = "\
882# a comment
883; another comment
884
885registry=http://verdaccio.lan:4873
886";
887 fs::write(&npmrc, body).unwrap();
888 assert_eq!(
889 npmrc_registry(&npmrc),
890 Some("http://verdaccio.lan:4873".to_string())
891 );
892 }
893
894 #[test]
895 fn test_npmrc_registry_scoped_ignored() {
896 let dir = tempfile::tempdir().unwrap();
897 let npmrc = dir.path().join(".npmrc");
898 let body = "\
899@my-org:registry=https://scoped.example.com/
900";
901 fs::write(&npmrc, body).unwrap();
902 assert_eq!(npmrc_registry(&npmrc), None);
903 }
904
905 #[test]
906 fn test_npmrc_registry_scoped_does_not_shadow_bare() {
907 let dir = tempfile::tempdir().unwrap();
908 let npmrc = dir.path().join(".npmrc");
909 let body = "\
910@my-org:registry=https://scoped.example.com/
911registry=http://localhost:4873
912";
913 fs::write(&npmrc, body).unwrap();
914 assert_eq!(
915 npmrc_registry(&npmrc),
916 Some("http://localhost:4873".to_string())
917 );
918 }
919
920 #[test]
921 fn test_npmrc_registry_no_entry() {
922 let dir = tempfile::tempdir().unwrap();
923 let npmrc = dir.path().join(".npmrc");
924 fs::write(&npmrc, "save-exact=true\n").unwrap();
925 assert_eq!(npmrc_registry(&npmrc), None);
926 }
927
928 #[test]
929 fn test_npmrc_registry_empty_file() {
930 let dir = tempfile::tempdir().unwrap();
931 let npmrc = dir.path().join(".npmrc");
932 fs::write(&npmrc, "").unwrap();
933 assert_eq!(npmrc_registry(&npmrc), None);
934 }
935
936 #[test]
937 fn test_npmrc_registry_missing_file() {
938 let dir = tempfile::tempdir().unwrap();
939 let npmrc = dir.path().join("does-not-exist");
940 assert_eq!(npmrc_registry(&npmrc), None);
941 }
942
943 #[test]
944 fn test_rewrite_lockfile_to_local_mixed() {
945 let dir = tempfile::tempdir().unwrap();
946 let lockfile = dir.path().join("package-lock.json");
947 let package_lock = r#"{
948 "dependencies": {
949 "from-npmjs": {
950 "resolved": "https://registry.npmjs.org/from-npmjs/-/from-npmjs-1.0.0.tgz"
951 },
952 "from-external": {
953 "resolved": "https://example.com/from-external/-/from-external-4.0.0.tgz"
954 },
955 "from-local": {
956 "resolved": "http://localhost:4873/from-local/-/from-local-1.0.0.tgz"
957 }
958 }
959 }"#;
960 fs::write(&lockfile, package_lock).unwrap();
961
962 let count = rewrite_lockfile_to_local(&lockfile, "http://localhost:4873").unwrap();
963 assert_eq!(count, 1, "only the npmjs URL should be rewritten");
964
965 let updated: Value = serde_json::from_str(&fs::read_to_string(&lockfile).unwrap()).unwrap();
966 assert_eq!(
967 updated["dependencies"]["from-npmjs"]["resolved"],
968 "http://localhost:4873/from-npmjs/-/from-npmjs-1.0.0.tgz"
969 );
970 assert_eq!(
971 updated["dependencies"]["from-external"]["resolved"],
972 "https://example.com/from-external/-/from-external-4.0.0.tgz"
973 );
974 assert_eq!(
975 updated["dependencies"]["from-local"]["resolved"],
976 "http://localhost:4873/from-local/-/from-local-1.0.0.tgz"
977 );
978 }
979
980 #[test]
981 fn test_rewrite_lockfile_to_local_path_bearing_url() {
982 let dir = tempfile::tempdir().unwrap();
983 let lockfile = dir.path().join("package-lock.json");
984 let package_lock = r#"{
985 "resolved": "https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz"
986 }"#;
987 fs::write(&lockfile, package_lock).unwrap();
988 let count = rewrite_lockfile_to_local(&lockfile, "https://verdaccio.lan/repo").unwrap();
989 assert_eq!(count, 1);
990 let updated: Value = serde_json::from_str(&fs::read_to_string(&lockfile).unwrap()).unwrap();
991 assert_eq!(
992 updated["resolved"],
993 "https://verdaccio.lan/repo/pkg/-/pkg-1.0.0.tgz"
994 );
995 }
996
997 #[test]
998 fn test_rewrite_lockfile_to_local_trailing_slash() {
999 let dir = tempfile::tempdir().unwrap();
1000 let lockfile = dir.path().join("package-lock.json");
1001 let package_lock = r#"{
1002 "resolved": "https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz"
1003 }"#;
1004 fs::write(&lockfile, package_lock).unwrap();
1005 let count = rewrite_lockfile_to_local(&lockfile, "http://localhost:4873/").unwrap();
1006 assert_eq!(count, 1);
1007 let updated: Value = serde_json::from_str(&fs::read_to_string(&lockfile).unwrap()).unwrap();
1008 assert_eq!(
1009 updated["resolved"],
1010 "http://localhost:4873/pkg/-/pkg-1.0.0.tgz"
1011 );
1012 }
1013
1014 #[test]
1015 fn test_rewrite_lockfile_to_local_path_bearing_url_trailing_slash() {
1016 let dir = tempfile::tempdir().unwrap();
1017 let lockfile = dir.path().join("package-lock.json");
1018 let package_lock = r#"{
1019 "resolved": "https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz"
1020 }"#;
1021 fs::write(&lockfile, package_lock).unwrap();
1022 let count = rewrite_lockfile_to_local(&lockfile, "https://verdaccio.lan/repo/").unwrap();
1023 assert_eq!(count, 1);
1024 let updated: Value = serde_json::from_str(&fs::read_to_string(&lockfile).unwrap()).unwrap();
1025 assert_eq!(
1026 updated["resolved"],
1027 "https://verdaccio.lan/repo/pkg/-/pkg-1.0.0.tgz"
1028 );
1029 }
1030
1031 #[test]
1032 fn test_rewrite_lockfile_to_local_preserves_query_and_fragment() {
1033 let dir = tempfile::tempdir().unwrap();
1034 let lockfile = dir.path().join("package-lock.json");
1035 let package_lock = r#"{
1036 "resolved": "https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz?token=abc#sha"
1037 }"#;
1038 fs::write(&lockfile, package_lock).unwrap();
1039 let count = rewrite_lockfile_to_local(&lockfile, "http://localhost:4873").unwrap();
1040 assert_eq!(count, 1);
1041 let updated: Value = serde_json::from_str(&fs::read_to_string(&lockfile).unwrap()).unwrap();
1042 assert_eq!(
1043 updated["resolved"],
1044 "http://localhost:4873/pkg/-/pkg-1.0.0.tgz?token=abc#sha"
1045 );
1046 }
1047
1048 #[test]
1049 fn test_rewrite_lockfile_to_local_no_matches() {
1050 let dir = tempfile::tempdir().unwrap();
1051 let lockfile = dir.path().join("package-lock.json");
1052 let original = r#"{
1053 "dependencies": {
1054 "a": {
1055 "resolved": "https://example.com/a/-/a-1.0.0.tgz"
1056 }
1057 }
1058}"#;
1059 fs::write(&lockfile, original).unwrap();
1060 let count = rewrite_lockfile_to_local(&lockfile, "http://localhost:4873").unwrap();
1061 assert_eq!(count, 0);
1062 let after: Value = serde_json::from_str(&fs::read_to_string(&lockfile).unwrap()).unwrap();
1064 let before: Value = serde_json::from_str(original).unwrap();
1065 assert_eq!(after, before);
1066 }
1067
1068 #[test]
1069 fn test_rewrite_lockfile_to_local_invalid_url() {
1070 let dir = tempfile::tempdir().unwrap();
1071 let lockfile = dir.path().join("package-lock.json");
1072 fs::write(&lockfile, r#"{"resolved":"https://registry.npmjs.org/x"}"#).unwrap();
1073 assert!(rewrite_lockfile_to_local(&lockfile, "not a url").is_err());
1075 assert!(rewrite_lockfile_to_local(&lockfile, "ftp://localhost:4873").is_err());
1077 assert!(rewrite_lockfile_to_local(&lockfile, "http://").is_err());
1079 assert!(rewrite_lockfile_to_local(&lockfile, "http://localhost?x=1").is_err());
1081 assert!(rewrite_lockfile_to_local(&lockfile, "http://localhost#frag").is_err());
1082 }
1083
1084 #[test]
1085 fn test_rewrite_lockfile_to_local_missing_file() {
1086 let dir = tempfile::tempdir().unwrap();
1087 let missing = dir.path().join("nope.json");
1088 let err = rewrite_lockfile_to_local(&missing, "http://localhost:4873").unwrap_err();
1089 let msg = err.to_string();
1090 assert!(msg.contains("lockfile not found"), "unexpected: {msg}");
1091 assert!(
1092 msg.contains(&missing.display().to_string()),
1093 "error message did not include path: {msg}"
1094 );
1095 }
1096
1097 #[test]
1098 fn test_rewrite_lockfile_to_local_rejects_userinfo() {
1099 let dir = tempfile::tempdir().unwrap();
1100 let lockfile = dir.path().join("package-lock.json");
1101 fs::write(&lockfile, r#"{"resolved":"https://registry.npmjs.org/x"}"#).unwrap();
1102 let err = rewrite_lockfile_to_local(&lockfile, "http://u:p@localhost:4873").unwrap_err();
1103 let msg = err.to_string();
1104 assert!(
1105 msg.contains("must not embed credentials"),
1106 "unexpected: {msg}"
1107 );
1108 let err = rewrite_lockfile_to_local(&lockfile, "http://user@localhost:4873").unwrap_err();
1110 assert!(
1111 err.to_string().contains("must not embed credentials"),
1112 "unexpected: {err}"
1113 );
1114 }
1115
1116 #[test]
1117 fn test_npmrc_registry_last_wins() {
1118 let dir = tempfile::tempdir().unwrap();
1119 let npmrc = dir.path().join(".npmrc");
1120 let body = "\
1121registry=https://registry.npmjs.org/
1122# overridden for this checkout
1123registry=http://verdaccio.lan
1124";
1125 fs::write(&npmrc, body).unwrap();
1126 assert_eq!(
1127 npmrc_registry(&npmrc),
1128 Some("http://verdaccio.lan".to_string())
1129 );
1130 }
1131
1132 #[test]
1133 fn test_npmrc_registry_bom_prefixed() {
1134 let dir = tempfile::tempdir().unwrap();
1135 let npmrc = dir.path().join(".npmrc");
1136 let body = "\u{feff}registry=http://localhost:4873\n";
1137 fs::write(&npmrc, body).unwrap();
1138 assert_eq!(
1139 npmrc_registry(&npmrc),
1140 Some("http://localhost:4873".to_string())
1141 );
1142 }
1143
1144 #[test]
1145 fn test_npmrc_registry_inline_comment_hash() {
1146 let dir = tempfile::tempdir().unwrap();
1147 let npmrc = dir.path().join(".npmrc");
1148 fs::write(&npmrc, "registry=http://localhost:4873 # local mirror\n").unwrap();
1149 assert_eq!(
1150 npmrc_registry(&npmrc),
1151 Some("http://localhost:4873".to_string())
1152 );
1153 }
1154
1155 #[test]
1156 fn test_npmrc_registry_inline_comment_semicolon() {
1157 let dir = tempfile::tempdir().unwrap();
1158 let npmrc = dir.path().join(".npmrc");
1159 fs::write(&npmrc, "registry=http://localhost:4873\t; trailing\n").unwrap();
1160 assert_eq!(
1161 npmrc_registry(&npmrc),
1162 Some("http://localhost:4873".to_string())
1163 );
1164 }
1165
1166 #[test]
1167 fn test_npmrc_registry_double_quoted() {
1168 let dir = tempfile::tempdir().unwrap();
1169 let npmrc = dir.path().join(".npmrc");
1170 fs::write(&npmrc, "registry=\"https://registry.npmjs.org/\"\n").unwrap();
1171 assert_eq!(
1172 npmrc_registry(&npmrc),
1173 Some("https://registry.npmjs.org/".to_string())
1174 );
1175 }
1176
1177 #[test]
1178 fn test_npmrc_registry_single_quoted() {
1179 let dir = tempfile::tempdir().unwrap();
1180 let npmrc = dir.path().join(".npmrc");
1181 fs::write(&npmrc, "registry='http://localhost:4873'\n").unwrap();
1182 assert_eq!(
1183 npmrc_registry(&npmrc),
1184 Some("http://localhost:4873".to_string())
1185 );
1186 }
1187
1188 #[test]
1189 fn test_npmrc_registry_hash_in_value_without_whitespace_preserved() {
1190 let dir = tempfile::tempdir().unwrap();
1192 let npmrc = dir.path().join(".npmrc");
1193 fs::write(&npmrc, "registry=http://localhost:4873/path#anchor\n").unwrap();
1194 assert_eq!(
1195 npmrc_registry(&npmrc),
1196 Some("http://localhost:4873/path#anchor".to_string())
1197 );
1198 }
1199
1200 #[test]
1201 fn test_rewrite_lockfile_to_local_exact_host_match() {
1202 let dir = tempfile::tempdir().unwrap();
1205 let lockfile = dir.path().join("package-lock.json");
1206 let package_lock = r#"{
1207 "dependencies": {
1208 "wrong-tld": {
1209 "resolved": "https://registry.npmjs.com/a/-/a-1.0.0.tgz"
1210 },
1211 "subdomain": {
1212 "resolved": "https://foo.registry.npmjs.org/a/-/a-1.0.0.tgz"
1213 },
1214 "yes": {
1215 "resolved": "https://registry.npmjs.org/a/-/a-1.0.0.tgz"
1216 }
1217 }
1218 }"#;
1219 fs::write(&lockfile, package_lock).unwrap();
1220 let count = rewrite_lockfile_to_local(&lockfile, "http://localhost:4873").unwrap();
1221 assert_eq!(count, 1);
1222 let updated: Value = serde_json::from_str(&fs::read_to_string(&lockfile).unwrap()).unwrap();
1223 assert_eq!(
1224 updated["dependencies"]["wrong-tld"]["resolved"],
1225 "https://registry.npmjs.com/a/-/a-1.0.0.tgz"
1226 );
1227 assert_eq!(
1228 updated["dependencies"]["subdomain"]["resolved"],
1229 "https://foo.registry.npmjs.org/a/-/a-1.0.0.tgz"
1230 );
1231 assert_eq!(
1232 updated["dependencies"]["yes"]["resolved"],
1233 "http://localhost:4873/a/-/a-1.0.0.tgz"
1234 );
1235 }
1236
1237 #[test]
1242 fn test_install_pre_commit_hook_fresh_repo() {
1243 let dir = tempfile::tempdir().unwrap();
1244 fs::create_dir(dir.path().join(".git")).unwrap();
1245
1246 let result = install_pre_commit_hook(dir.path()).unwrap();
1247 assert!(matches!(result, InstallHookResult::Installed));
1248
1249 let hook_path = dir.path().join(".git/hooks/pre-commit");
1250 assert!(hook_path.exists(), "hook file should exist");
1251 let body = fs::read_to_string(&hook_path).unwrap();
1252 assert!(body.starts_with("#!/bin/sh"), "missing shebang: {body}");
1253 assert!(
1254 body.contains("pkglock --to-public"),
1255 "missing marker string: {body}"
1256 );
1257
1258 #[cfg(unix)]
1259 {
1260 use std::os::unix::fs::PermissionsExt;
1261 let mode = fs::metadata(&hook_path).unwrap().permissions().mode();
1262 assert!(
1263 mode & 0o111 != 0,
1264 "expected executable bit set, got mode {mode:o}"
1265 );
1266 }
1267 }
1268
1269 #[test]
1270 fn test_install_pre_commit_hook_already_exists() {
1271 let dir = tempfile::tempdir().unwrap();
1272 fs::create_dir_all(dir.path().join(".git/hooks")).unwrap();
1273 let hook_path = dir.path().join(".git/hooks/pre-commit");
1274 let existing = "#!/bin/sh\necho 'my own hook'\n";
1275 fs::write(&hook_path, existing).unwrap();
1276
1277 let result = install_pre_commit_hook(dir.path()).unwrap();
1278 assert!(matches!(result, InstallHookResult::AlreadyExists));
1279
1280 let after = fs::read_to_string(&hook_path).unwrap();
1281 assert_eq!(after, existing, "existing hook must not be modified");
1282 }
1283
1284 #[test]
1285 fn test_install_pre_commit_hook_missing_git() {
1286 let dir = tempfile::tempdir().unwrap();
1287 let err = install_pre_commit_hook(dir.path()).unwrap_err();
1288 let msg = err.to_string();
1289 assert!(msg.contains(".git"), "expected .git in error: {msg}");
1290 }
1291
1292 #[test]
1293 fn test_install_pre_commit_hook_git_is_file() {
1294 let dir = tempfile::tempdir().unwrap();
1295 fs::write(dir.path().join(".git"), "gitdir: /elsewhere\n").unwrap();
1296 let err = install_pre_commit_hook(dir.path()).unwrap_err();
1297 let msg = err.to_string();
1298 assert!(
1299 msg.contains("not a directory") || msg.contains("worktree"),
1300 "unexpected: {msg}"
1301 );
1302 }
1303
1304 #[cfg(unix)]
1305 #[test]
1306 fn test_install_pre_commit_hook_symlinked_git() {
1307 use std::os::unix::fs::symlink;
1308 let dir = tempfile::tempdir().unwrap();
1309 let real_git = dir.path().join("real-git");
1310 fs::create_dir(&real_git).unwrap();
1311 symlink(&real_git, dir.path().join(".git")).unwrap();
1312 let result = install_pre_commit_hook(dir.path()).unwrap();
1313 assert!(matches!(result, InstallHookResult::Installed));
1314 assert!(dir.path().join(".git/hooks/pre-commit").exists());
1315 }
1316
1317 #[cfg(unix)]
1318 #[test]
1319 fn test_pre_commit_hook_script_is_valid_posix_sh() {
1320 use std::process::Command;
1321 let dir = tempfile::tempdir().unwrap();
1322 fs::create_dir(dir.path().join(".git")).unwrap();
1323 install_pre_commit_hook(dir.path()).unwrap();
1324 let hook = dir.path().join(".git/hooks/pre-commit");
1325 let output = Command::new("sh")
1326 .arg("-n")
1327 .arg(&hook)
1328 .output()
1329 .expect("failed to invoke sh -n");
1330 assert!(
1331 output.status.success(),
1332 "hook script failed syntax check: stderr={}",
1333 String::from_utf8_lossy(&output.stderr)
1334 );
1335 }
1336
1337 #[test]
1338 fn test_install_pre_commit_hook_hooks_is_file() {
1339 let dir = tempfile::tempdir().unwrap();
1340 fs::create_dir(dir.path().join(".git")).unwrap();
1341 fs::write(dir.path().join(".git/hooks"), "not a dir").unwrap();
1342 let err = install_pre_commit_hook(dir.path()).unwrap_err();
1343 let msg = err.to_string();
1344 assert!(
1345 msg.contains("not a directory") || msg.contains("hooks"),
1346 "unexpected: {msg}"
1347 );
1348 }
1349}