Skip to main content

pkglock_lib/
lib.rs

1// Library entry point: reads, parses, and updates package-lock.json, plus URL update functionality.
2use regex::Regex;
3use serde_json::Value;
4use std::fs;
5use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
6use std::path::Path;
7use url::Url;
8
9// Function to update URLs in a JSON value recursively.
10// Compiles the URL regex once and delegates to an inner helper for recursion.
11pub 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                    // Replace the matched part of the URL with the new URL
22                    let updated_url = re.replace(old_url, new_url).into_owned();
23                    *resolved = Value::String(updated_url);
24                }
25            }
26            // Recursively update nested objects
27            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
40// Core primitive: read the given lockfile, rewrite its URLs to `new_url`, and write it back.
41// This function knows nothing about pkg.config.json or --local/--remote.
42pub 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    // Read and parse the lockfile
48    let file_content = fs::read_to_string(lockfile)?;
49    let mut json_content: Value = serde_json::from_str(&file_content)?;
50
51    // Update URLs using the update_urls function within this module
52    update_urls(&mut json_content, new_url);
53
54    // Write the updated JSON back to the lockfile
55    let updated_content = serde_json::to_string_pretty(&json_content)?;
56    fs::write(lockfile, updated_content)?;
57    Ok(())
58}
59
60// Resolves the URL to use from `config` based on `arg` (--local or --remote), then
61// delegates to `rewrite_lockfile` against the given lockfile path.
62pub fn update_urls_from_config(
63    config: &Path,
64    lockfile: &Path,
65    arg: &str,
66) -> Result<(), Box<dyn std::error::Error>> {
67    // Check that the required files exist (preserve historical error messages)
68    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    // Read+parse lockfile FIRST (matches historical ordering for malformed-JSON precedence).
76    let file_content = fs::read_to_string(lockfile)?;
77    let mut json_content: Value = serde_json::from_str(&file_content)?;
78
79    // Then read+parse pkg.config.json.
80    let config_content = fs::read_to_string(config)?;
81    let config_json: Value = serde_json::from_str(&config_content)?;
82
83    // Determine new URL based on argument
84    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
102// Back-compat wrapper that resolves cwd-relative `pkg.config.json` and `package-lock.json`,
103// picks the URL based on `--local` or `--remote`, then delegates to `rewrite_lockfile`.
104// This is the single site where cwd-coupling is encoded.
105pub 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
113// ---------------------------------------------------------------------------
114// Smart-mode rewriters (conditional, host-aware).
115//
116// These do not share code with `update_urls`/`update_urls_inner` above — that
117// path is unconditional scheme+authority replacement driven by pkg.config.json.
118// The smart-mode path needs to *inspect* each resolved URL and decide whether
119// to rewrite it based on the host.
120//
121// The core is `walk_resolved_urls`, a predicate-based JSON walker. The
122// `--to-public` entry point composes it with a local-host predicate; future
123// flags (`--to-local <URL>`) will reuse the same walker with a different
124// predicate.
125// ---------------------------------------------------------------------------
126
127/// Returns true if `host` should be treated as a "local" hostname for the
128/// purposes of `--to-public` rewriting. See the task spec for the exact rules.
129fn is_local_host(host: &str) -> bool {
130    // IP literal? (handles IPv4, IPv6, and bracketed IPv6 from URL host_str.)
131    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    // Hostname matching is case-insensitive. Also strip a single trailing '.'
138    // so FQDN forms like "foo.local." behave the same as "foo.local".
139    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    // Dot-prefixed suffix match: must be a multi-label hostname.
147    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    // 127.0.0.0/8
166    if a == 127 {
167        return true;
168    }
169    // 10.0.0.0/8
170    if a == 10 {
171        return true;
172    }
173    // 172.16.0.0/12  -> 172.16.0.0 through 172.31.255.255
174    if a == 172 && (16..=31).contains(&b) {
175        return true;
176    }
177    // 192.168.0.0/16
178    if a == 192 && b == 168 {
179        return true;
180    }
181    false
182}
183
184fn is_local_ipv6(ip: Ipv6Addr) -> bool {
185    // Per spec: only the loopback literal `::1`.
186    ip == Ipv6Addr::LOCALHOST
187}
188
189/// Decision returned by a URL-rewrite predicate.
190enum RewriteDecision {
191    /// Leave the URL untouched.
192    Skip,
193    /// Replace the `scheme://authority` portion of the URL with this string,
194    /// preserving the path, query, and fragment.
195    ReplaceSchemeAuthority(String),
196}
197
198/// Walks every `resolved` URL in the JSON tree, asks `decide` what to do with
199/// it, and rewrites in place. Returns the number of URLs actually changed.
200fn 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
236/// Compute the byte length of the `scheme://authority` prefix in `raw`, given
237/// its parsed form. We can't easily ask `url::Url` for this directly, so we
238/// locate the first `/` after the `scheme://` portion in the original string.
239fn scheme_authority_len(raw: &str, parsed: &Url) -> usize {
240    let scheme_len = parsed.scheme().len();
241    // raw starts with "<scheme>://"
242    let after_scheme = scheme_len + 3;
243    // The authority ends at the first '/', '?', or '#' — whichever comes first.
244    // If none are present, the whole string is scheme+authority.
245    let tail = &raw[after_scheme..];
246    let end = tail.find(['/', '?', '#']).unwrap_or(tail.len());
247    after_scheme + end
248}
249
250/// Read the lockfile, rewrite any `resolved` URL whose host is local (see
251/// `is_local_host`) to point at `https://registry.npmjs.org`, write it back,
252/// and return the number of URLs changed.
253pub 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    // Always write back. Cheap, and keeps behavior predictable.
273    let updated_content = serde_json::to_string_pretty(&json_content)?;
274    fs::write(lockfile, updated_content)?;
275    Ok(count)
276}
277
278/// Validate a user-supplied local registry URL and return the normalized
279/// `scheme://authority[/base-path]` prefix that should replace
280/// `scheme://registry.npmjs.org` in resolved URLs.
281///
282/// Rules:
283/// - Must parse as a URL.
284/// - Scheme must be `http` or `https`.
285/// - Must have a host.
286/// - Must not have a query or fragment (registry base URLs don't carry those).
287/// - Any trailing `/` on the path is trimmed so the splice doesn't produce
288///   double slashes when the original npmjs URL's path is appended.
289fn 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    // Slice scheme://authority[/path] out of the original input by reusing
324    // scheme_authority_len. Query/fragment are rejected above, so everything
325    // after auth_end is path. Trim a single trailing '/' so the splice with
326    // the original npmjs path (which starts with '/') doesn't double-slash;
327    // a bare '/' path collapses to empty.
328    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
334/// Parse `registry=...` from a .npmrc-style file. Supports:
335/// - Comments starting with `#` or `;` (line start or after whitespace).
336/// - Surrounding double or single quotes on the value.
337/// - Case-insensitive `registry` key.
338/// - Last-write-wins for repeated keys (matches npm semantics).
339/// - UTF-8 BOM at the start of the file.
340///
341/// Scoped `@scope:registry=...` overrides are ignored.
342/// Does NOT expand environment variable references like `${VAR}`.
343/// Returns the raw value (no URL validation here), or `None` for missing
344/// files or files without a bare `registry=` entry.
345pub 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        // Strip a leading UTF-8 BOM (only meaningful on the first line, but
350        // cheap to attempt unconditionally).
351        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        // Only the bare `registry` key — skip scoped overrides like
359        // `@my-org:registry`.
360        if !key.trim().eq_ignore_ascii_case("registry") {
361            continue;
362        }
363        let value = value.trim();
364        // Strip an inline whitespace-prefixed comment: " # ..." or " ; ...".
365        // Only treat `#`/`;` as a comment when preceded by whitespace — npm
366        // permits a literal `#` in the value when not preceded by space.
367        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        // Strip matching surrounding quotes ("..." or '...').
379        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
391/// Read the lockfile, rewrite any `resolved` URL whose host is exactly
392/// `registry.npmjs.org` to point at `local_url`, write it back, and return the
393/// number of URLs changed.
394pub 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        // host_str() returns the parsed host's canonical form: ASCII-lowercased,
409        // IDN punycoded. The constant "registry.npmjs.org" is already canonical,
410        // so exact == is correct. If this constant ever changes to a host with
411        // uppercase or non-ASCII, switch to a normalized comparison.
412        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// ---------------------------------------------------------------------------
427// install-hook: write a git pre-commit hook that runs `pkglock --to-public`
428// on staged package-lock.json files.
429// ---------------------------------------------------------------------------
430
431/// Outcome of `install_pre_commit_hook`. Both variants are "no error" — the
432/// caller decides whether `AlreadyExists` triggers a non-zero exit.
433#[derive(Debug)]
434pub enum InstallHookResult {
435    /// The hook did not exist; we wrote it.
436    Installed,
437    /// A `pre-commit` hook already exists; we left it alone.
438    AlreadyExists,
439}
440
441/// Embedded POSIX-sh hook script. Kept inline because it's short.
442const 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
464/// Install a `pre-commit` git hook under `repo_root/.git/hooks/`.
465///
466/// `repo_root` must contain a `.git` *directory* (worktrees, whose `.git` is a
467/// file, are not supported in v0.3). The `.git/hooks/` directory is created if
468/// missing. If a `pre-commit` hook is already present it is left untouched and
469/// `AlreadyExists` is returned — the caller is expected to surface that to the
470/// user and exit non-zero.
471pub 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    // Use fs::metadata (follows symlinks) so a `.git` symlink to a directory
476    // is accepted. Worktrees, where `.git` is a regular *file* pointing to the
477    // real gitdir, are still rejected — they need different hook resolution.
478    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        // Top-level array of objects with `resolved`
562        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        // Nested: object holding an array of objects with `resolved`
577        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        // TempDir cleans up via Drop, so panics mid-test no longer leak directories.
619        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        // Missing-file error path
638        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        // url::Url::host_str() returns bracketed form for IPv6.
703        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    // ------------------------------------------------------------------
852    // --to-local tests
853    // ------------------------------------------------------------------
854
855    #[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        // Content-equivalent (we re-serialize, so we compare parsed values).
1063        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        // Not parseable.
1074        assert!(rewrite_lockfile_to_local(&lockfile, "not a url").is_err());
1075        // Wrong scheme.
1076        assert!(rewrite_lockfile_to_local(&lockfile, "ftp://localhost:4873").is_err());
1077        // Missing host (parser rejects bare scheme://).
1078        assert!(rewrite_lockfile_to_local(&lockfile, "http://").is_err());
1079        // Has query/fragment (rejected up front).
1080        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        // Username-only (no password) should also be rejected.
1109        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        // npm allows a literal `#` in the value when not preceded by whitespace.
1191        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        // Only `registry.npmjs.org` should be rewritten — not `.com` variants
1203        // or subdomains.
1204        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    // ------------------------------------------------------------------
1238    // install-hook tests
1239    // ------------------------------------------------------------------
1240
1241    #[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}