Skip to main content

harn_cli/package/
registry.rs

1use super::errors::PackageError;
2use super::*;
3use semver::{Version, VersionReq};
4
5const PRESERVED_GIT_ENV: &[&str] = &[
6    "PATH",
7    "TMPDIR",
8    "TEMP",
9    "TMP",
10    "SystemRoot",
11    "WINDIR",
12    "COMSPEC",
13    "PATHEXT",
14];
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub(crate) struct PackageCacheMetadata {
18    version: u32,
19    source: String,
20    commit: String,
21    content_hash: String,
22    cached_at_unix_ms: u128,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub(crate) struct PackageRegistryIndex {
27    version: u32,
28    #[serde(default, rename = "package")]
29    packages: Vec<RegistryPackage>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub(crate) struct RegistryPackage {
34    name: String,
35    #[serde(default)]
36    description: Option<String>,
37    repository: String,
38    #[serde(default)]
39    license: Option<String>,
40    #[serde(default, alias = "harn_version", alias = "harn_version_range")]
41    harn: Option<String>,
42    #[serde(default)]
43    exports: Vec<String>,
44    #[serde(default, alias = "connector-contract")]
45    connector_contract: Option<String>,
46    #[serde(default)]
47    docs_url: Option<String>,
48    #[serde(default)]
49    checksum: Option<String>,
50    #[serde(default)]
51    provenance: Option<String>,
52    #[serde(default, rename = "version")]
53    versions: Vec<RegistryPackageVersion>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub(crate) struct RegistryPackageVersion {
58    version: String,
59    git: String,
60    #[serde(default)]
61    tag: Option<String>,
62    #[serde(default)]
63    rev: Option<String>,
64    #[serde(default)]
65    sha: Option<String>,
66    #[serde(default)]
67    branch: Option<String>,
68    #[serde(default)]
69    package: Option<String>,
70    #[serde(default)]
71    checksum: Option<String>,
72    #[serde(default)]
73    provenance: Option<String>,
74    #[serde(default)]
75    yanked: bool,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79pub(crate) struct RegistryPackageInfo {
80    package: RegistryPackage,
81    selected_version: Option<RegistryPackageVersion>,
82}
83
84pub(crate) fn manifest_has_git_dependencies(manifest: &Manifest) -> bool {
85    manifest.dependencies.values().any(Dependency::requires_git)
86}
87
88pub(crate) fn ensure_git_available() -> Result<(), PackageError> {
89    process::Command::new("git")
90        .arg("--version")
91        .env_remove("GIT_DIR")
92        .env_remove("GIT_WORK_TREE")
93        .env_remove("GIT_INDEX_FILE")
94        .output()
95        .map(|_| ())
96        .map_err(|_| {
97            PackageError::Registry(
98                "git is required for git dependencies but was not found in PATH".to_string(),
99            )
100        })
101}
102
103pub(crate) fn cache_root() -> Result<PathBuf, PackageError> {
104    PackageWorkspace::from_current_dir()?.cache_root()
105}
106
107pub(crate) fn sha256_hex(bytes: impl AsRef<[u8]>) -> String {
108    hex_bytes(Sha256::digest(bytes.as_ref()))
109}
110
111pub(crate) fn hex_bytes(bytes: impl AsRef<[u8]>) -> String {
112    const HEX: &[u8; 16] = b"0123456789abcdef";
113    let bytes = bytes.as_ref();
114    let mut out = String::with_capacity(bytes.len() * 2);
115    for &byte in bytes {
116        out.push(HEX[(byte >> 4) as usize] as char);
117        out.push(HEX[(byte & 0x0f) as usize] as char);
118    }
119    out
120}
121
122pub(crate) fn git_cache_dir_in(
123    workspace: &PackageWorkspace,
124    source: &str,
125    commit: &str,
126) -> Result<PathBuf, PackageError> {
127    Ok(workspace
128        .cache_root()?
129        .join("git")
130        .join(sha256_hex(source))
131        .join(commit))
132}
133
134pub(crate) fn git_cache_lock_path_in(
135    workspace: &PackageWorkspace,
136    source: &str,
137    commit: &str,
138) -> Result<PathBuf, PackageError> {
139    Ok(workspace
140        .cache_root()?
141        .join("locks")
142        .join(format!("{}-{commit}.lock", sha256_hex(source))))
143}
144
145pub(crate) fn acquire_git_cache_lock_in(
146    workspace: &PackageWorkspace,
147    source: &str,
148    commit: &str,
149) -> Result<File, PackageError> {
150    let path = git_cache_lock_path_in(workspace, source, commit)?;
151    if let Some(parent) = path.parent() {
152        fs::create_dir_all(parent)
153            .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
154    }
155    let file = File::create(&path)
156        .map_err(|error| format!("failed to open {}: {error}", path.display()))?;
157    file.lock_exclusive()
158        .map_err(|error| format!("failed to lock {}: {error}", path.display()))?;
159    Ok(file)
160}
161
162pub(crate) fn read_cached_content_hash(dir: &Path) -> Result<Option<String>, PackageError> {
163    let path = dir.join(CONTENT_HASH_FILE);
164    match fs::read_to_string(&path) {
165        Ok(value) => Ok(Some(value.trim().to_string())),
166        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
167        Err(error) => Err(format!("failed to read {}: {error}", path.display()).into()),
168    }
169}
170
171pub(crate) fn write_cached_content_hash(dir: &Path, hash: &str) -> Result<(), PackageError> {
172    let path = dir.join(CONTENT_HASH_FILE);
173    harn_vm::atomic_io::atomic_write(&path, format!("{hash}\n").as_bytes()).map_err(|error| {
174        PackageError::Registry(format!("failed to write {}: {error}", path.display()))
175    })
176}
177
178pub(crate) fn read_cache_metadata(
179    dir: &Path,
180) -> Result<Option<PackageCacheMetadata>, PackageError> {
181    let path = dir.join(CACHE_METADATA_FILE);
182    let content = match fs::read_to_string(&path) {
183        Ok(content) => content,
184        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
185        Err(error) => return Err(format!("failed to read {}: {error}", path.display()).into()),
186    };
187    let metadata = toml::from_str::<PackageCacheMetadata>(&content)
188        .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
189    if metadata.version != CACHE_METADATA_VERSION {
190        return Err(format!(
191            "unsupported {} version {} (expected {})",
192            path.display(),
193            metadata.version,
194            CACHE_METADATA_VERSION
195        )
196        .into());
197    }
198    Ok(Some(metadata))
199}
200
201pub(crate) fn write_cache_metadata(
202    dir: &Path,
203    source: &str,
204    commit: &str,
205    content_hash: &str,
206) -> Result<(), PackageError> {
207    let cached_at_unix_ms = SystemTime::now()
208        .duration_since(UNIX_EPOCH)
209        .map_err(|error| format!("system clock error: {error}"))?
210        .as_millis();
211    let metadata = PackageCacheMetadata {
212        version: CACHE_METADATA_VERSION,
213        source: source.to_string(),
214        commit: commit.to_string(),
215        content_hash: content_hash.to_string(),
216        cached_at_unix_ms,
217    };
218    let body = toml::to_string_pretty(&metadata)
219        .map_err(|error| format!("failed to encode cache metadata: {error}"))?;
220    let path = dir.join(CACHE_METADATA_FILE);
221    harn_vm::atomic_io::atomic_write(&path, body.as_bytes()).map_err(|error| {
222        PackageError::Registry(format!("failed to write {}: {error}", path.display()))
223    })
224}
225
226pub(crate) fn normalized_relative_path(path: &Path) -> String {
227    path.components()
228        .map(|component| component.as_os_str().to_string_lossy())
229        .collect::<Vec<_>>()
230        .join("/")
231}
232
233pub(crate) fn collect_hashable_files(
234    root: &Path,
235    cursor: &Path,
236    out: &mut Vec<PathBuf>,
237) -> Result<(), PackageError> {
238    for entry in fs::read_dir(cursor)
239        .map_err(|error| format!("failed to read {}: {error}", cursor.display()))?
240    {
241        let entry =
242            entry.map_err(|error| format!("failed to read {} entry: {error}", cursor.display()))?;
243        let path = entry.path();
244        let file_type = entry
245            .file_type()
246            .map_err(|error| format!("failed to stat {}: {error}", path.display()))?;
247        let name = entry.file_name();
248        if name == OsStr::new(".git")
249            || name == OsStr::new(".gitignore")
250            || name == OsStr::new(CONTENT_HASH_FILE)
251            || name == OsStr::new(CACHE_METADATA_FILE)
252        {
253            continue;
254        }
255        if file_type.is_dir() {
256            collect_hashable_files(root, &path, out)?;
257        } else if file_type.is_file() {
258            let relative = path
259                .strip_prefix(root)
260                .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?;
261            out.push(relative.to_path_buf());
262        }
263    }
264    Ok(())
265}
266
267pub(crate) fn compute_content_hash(dir: &Path) -> Result<String, PackageError> {
268    let mut files = Vec::new();
269    collect_hashable_files(dir, dir, &mut files)?;
270    files.sort();
271    let mut hasher = Sha256::new();
272    for relative in files {
273        let normalized = normalized_relative_path(&relative);
274        let contents = fs::read(dir.join(&relative)).map_err(|error| {
275            format!("failed to read {}: {error}", dir.join(&relative).display())
276        })?;
277        hasher.update(normalized.as_bytes());
278        hasher.update([0]);
279        hasher.update(sha256_hex(contents).as_bytes());
280    }
281    Ok(format!("sha256:{}", hex_bytes(hasher.finalize())))
282}
283
284pub(crate) fn verify_content_hash_or_compute(
285    dir: &Path,
286    expected: &str,
287) -> Result<(), PackageError> {
288    let actual = compute_content_hash(dir)?;
289    if actual != expected {
290        return Err(format!(
291            "content hash mismatch for {}: expected {}, got {}",
292            dir.display(),
293            expected,
294            actual
295        )
296        .into());
297    }
298    if read_cached_content_hash(dir)?.as_deref() != Some(expected) {
299        write_cached_content_hash(dir, expected)?;
300    }
301    Ok(())
302}
303
304pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), PackageError> {
305    fs::create_dir_all(dst)
306        .map_err(|error| format!("failed to create {}: {error}", dst.display()))?;
307    for entry in
308        fs::read_dir(src).map_err(|error| format!("failed to read {}: {error}", src.display()))?
309    {
310        let entry =
311            entry.map_err(|error| format!("failed to read {} entry: {error}", src.display()))?;
312        let ty = entry
313            .file_type()
314            .map_err(|error| format!("failed to stat {}: {error}", entry.path().display()))?;
315        let name = entry.file_name();
316        if name == OsStr::new(".git")
317            || name == OsStr::new(CONTENT_HASH_FILE)
318            || name == OsStr::new(CACHE_METADATA_FILE)
319        {
320            continue;
321        }
322        let dest_path = dst.join(entry.file_name());
323        if ty.is_dir() {
324            copy_dir_recursive(&entry.path(), &dest_path)?;
325        } else if ty.is_file() {
326            if let Some(parent) = dest_path.parent() {
327                fs::create_dir_all(parent)
328                    .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
329            }
330            fs::copy(entry.path(), &dest_path).map_err(|error| {
331                format!(
332                    "failed to copy {} to {}: {error}",
333                    entry.path().display(),
334                    dest_path.display()
335                )
336            })?;
337        }
338    }
339    Ok(())
340}
341
342pub(crate) fn remove_materialized_package(
343    packages_dir: &Path,
344    alias: &str,
345) -> Result<(), PackageError> {
346    remove_materialized_path(&packages_dir.join(alias))?;
347    remove_materialized_path(&packages_dir.join(format!("{alias}.harn")))?;
348    Ok(())
349}
350
351fn remove_materialized_path(path: &Path) -> Result<(), PackageError> {
352    match fs::symlink_metadata(path) {
353        Ok(metadata) if is_link_like(&metadata) => remove_link_like_path(path)
354            .map_err(|error| format!("failed to remove {}: {error}", path.display()).into()),
355        Ok(metadata) if metadata.is_file() => fs::remove_file(path)
356            .map_err(|error| format!("failed to remove {}: {error}", path.display()).into()),
357        Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path)
358            .map_err(|error| format!("failed to remove {}: {error}", path.display()).into()),
359        Ok(_) => Ok(()),
360        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
361        Err(error) => Err(format!("failed to stat {}: {error}", path.display()).into()),
362    }
363}
364
365fn is_link_like(metadata: &fs::Metadata) -> bool {
366    metadata.file_type().is_symlink() || is_windows_reparse_point(metadata)
367}
368
369#[cfg(windows)]
370fn is_windows_reparse_point(metadata: &fs::Metadata) -> bool {
371    use std::os::windows::fs::MetadataExt;
372
373    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
374    metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
375}
376
377#[cfg(not(windows))]
378fn is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
379    false
380}
381
382fn remove_link_like_path(path: &Path) -> std::io::Result<()> {
383    match fs::remove_file(path) {
384        Ok(()) => Ok(()),
385        Err(file_error) => match fs::remove_dir(path) {
386            Ok(()) => Ok(()),
387            Err(_) => Err(file_error),
388        },
389    }
390}
391
392#[cfg(unix)]
393pub(crate) fn symlink_path_dependency(source: &Path, dest: &Path) -> Result<(), PackageError> {
394    std::os::unix::fs::symlink(source, dest).map_err(|error| {
395        PackageError::Registry(format!(
396            "failed to symlink {} to {}: {error}",
397            source.display(),
398            dest.display()
399        ))
400    })
401}
402
403#[cfg(windows)]
404pub(crate) fn symlink_path_dependency(source: &Path, dest: &Path) -> Result<(), PackageError> {
405    if source.is_dir() {
406        std::os::windows::fs::symlink_dir(source, dest)
407    } else {
408        std::os::windows::fs::symlink_file(source, dest)
409    }
410    .map_err(|error| {
411        PackageError::Registry(format!(
412            "failed to symlink {} to {}: {error}",
413            source.display(),
414            dest.display()
415        ))
416    })
417}
418
419#[cfg(not(any(unix, windows)))]
420pub(crate) fn symlink_path_dependency(_source: &Path, _dest: &Path) -> Result<(), PackageError> {
421    Err("symlinks are not supported on this platform"
422        .to_string()
423        .into())
424}
425
426pub(crate) fn materialize_path_dependency(
427    source: &Path,
428    dest_root: &Path,
429    alias: &str,
430) -> Result<(), PackageError> {
431    remove_materialized_package(dest_root, alias)?;
432    if source.is_dir() {
433        let dest = dest_root.join(alias);
434        match symlink_path_dependency(source, &dest) {
435            Ok(()) => Ok(()),
436            Err(_) => copy_dir_recursive(source, &dest),
437        }
438    } else {
439        let dest = dest_root.join(format!("{alias}.harn"));
440        if let Some(parent) = dest.parent() {
441            fs::create_dir_all(parent)
442                .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
443        }
444        match symlink_path_dependency(source, &dest) {
445            Ok(()) => Ok(()),
446            Err(_) => {
447                fs::copy(source, &dest).map_err(|error| {
448                    format!(
449                        "failed to copy {} to {}: {error}",
450                        source.display(),
451                        dest.display()
452                    )
453                })?;
454                Ok(())
455            }
456        }
457    }
458}
459
460pub(crate) fn materialized_hash_matches(dir: &Path, expected: &str) -> bool {
461    verify_content_hash_or_compute(dir, expected).is_ok()
462}
463
464pub(crate) fn resolve_path_dependency_source(
465    manifest_dir: &Path,
466    raw: &str,
467) -> Result<PathBuf, PackageError> {
468    let source = {
469        let candidate = PathBuf::from(raw);
470        if candidate.is_absolute() {
471            candidate
472        } else {
473            manifest_dir.join(candidate)
474        }
475    };
476    if source.exists() {
477        return source.canonicalize().map_err(|error| {
478            PackageError::Registry(format!(
479                "failed to canonicalize {}: {error}",
480                source.display()
481            ))
482        });
483    }
484    if source.extension().is_none() {
485        let with_ext = source.with_extension("harn");
486        if with_ext.exists() {
487            return with_ext.canonicalize().map_err(|error| {
488                PackageError::Registry(format!(
489                    "failed to canonicalize {}: {error}",
490                    with_ext.display()
491                ))
492            });
493        }
494    }
495    Err(format!("package source not found: {}", source.display()).into())
496}
497
498pub(crate) fn path_source_uri(path: &Path) -> Result<String, PackageError> {
499    let url = Url::from_file_path(path)
500        .map_err(|_| format!("failed to convert {} to file:// URL", path.display()))?;
501    Ok(format!("path+{url}"))
502}
503
504pub(crate) fn path_from_source_uri(source: &str) -> Result<PathBuf, PackageError> {
505    let raw = source
506        .strip_prefix("path+")
507        .ok_or_else(|| format!("invalid path source: {source}"))?;
508    if let Ok(url) = Url::parse(raw) {
509        return url
510            .to_file_path()
511            .map_err(|_| PackageError::Registry(format!("invalid file:// path source: {source}")));
512    }
513    Ok(PathBuf::from(raw))
514}
515
516pub(crate) fn registry_file_url_or_path(raw: &str) -> Result<Option<PathBuf>, PackageError> {
517    if let Ok(url) = Url::parse(raw) {
518        if url.scheme() == "file" {
519            return url.to_file_path().map(Some).map_err(|_| {
520                PackageError::Registry(format!("invalid file:// registry URL: {raw}"))
521            });
522        }
523        return Ok(None);
524    }
525    Ok(Some(PathBuf::from(raw)))
526}
527
528pub(crate) fn read_registry_source(source: &str) -> Result<String, PackageError> {
529    if let Some(path) = registry_file_url_or_path(source)? {
530        return fs::read_to_string(&path).map_err(|error| {
531            PackageError::Registry(format!(
532                "failed to read package registry {}: {error}",
533                path.display()
534            ))
535        });
536    }
537
538    let url = Url::parse(source)
539        .map_err(|error| format!("invalid package registry URL {source:?}: {error}"))?;
540    match url.scheme() {
541        "http" | "https" => {}
542        other => return Err(format!("unsupported package registry URL scheme: {other}").into()),
543    }
544    // `reqwest::blocking` builds its own current-thread tokio runtime and
545    // panics if dropped from inside an already-running tokio runtime — which
546    // is exactly what `harn add` / `harn install` do today. Hop onto a fresh
547    // OS thread so the blocking client's lifetime is fully outside any
548    // ambient runtime.
549    let source_owned = source.to_string();
550    std::thread::scope(|scope| {
551        scope
552            .spawn(move || fetch_registry_blocking(url, &source_owned))
553            .join()
554            .map_err(|_| PackageError::Registry("registry fetch thread panicked".to_string()))?
555    })
556}
557
558fn fetch_registry_blocking(url: Url, source: &str) -> Result<String, PackageError> {
559    let response = reqwest::blocking::Client::builder()
560        .timeout(Duration::from_secs(20))
561        .build()
562        .map_err(|error| format!("failed to build package registry client: {error}"))?
563        .get(url)
564        .send()
565        .map_err(|error| format!("failed to fetch package registry {source}: {error}"))?;
566    let status = response.status();
567    if !status.is_success() {
568        return Err(format!("GET {source} returned HTTP {status}").into());
569    }
570    response.text().map_err(|error| {
571        PackageError::Registry(format!("failed to read package registry response: {error}"))
572    })
573}
574
575pub(crate) fn is_valid_registry_segment(segment: &str) -> bool {
576    let mut chars = segment.chars();
577    let Some(first) = chars.next() else {
578        return false;
579    };
580    first.is_ascii_alphanumeric()
581        && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
582}
583
584pub(crate) fn is_valid_registry_package_name(name: &str) -> bool {
585    let trimmed = name.trim();
586    if trimmed != name || trimmed.is_empty() || trimmed.contains("://") || trimmed.ends_with('/') {
587        return false;
588    }
589    if let Some(scoped) = trimmed.strip_prefix('@') {
590        let Some((scope, package)) = scoped.split_once('/') else {
591            return false;
592        };
593        return !package.contains('/')
594            && is_valid_registry_segment(scope)
595            && is_valid_registry_segment(package);
596    }
597    !trimmed.contains('/') && is_valid_registry_segment(trimmed)
598}
599
600pub(crate) fn parse_registry_package_spec(spec: &str) -> Option<(&str, Option<&str>)> {
601    let trimmed = spec.trim();
602    if !trimmed.starts_with('@') {
603        if let Some((name, version)) = trimmed.rsplit_once('@') {
604            if is_valid_registry_package_name(name) && !version.trim().is_empty() {
605                return Some((name, Some(version)));
606            }
607        }
608        if is_valid_registry_package_name(trimmed) {
609            return Some((trimmed, None));
610        }
611        return None;
612    }
613
614    if let Some((name, version)) = trimmed.rsplit_once('@') {
615        if !name.is_empty()
616            && name != trimmed
617            && is_valid_registry_package_name(name)
618            && !version.trim().is_empty()
619        {
620            return Some((name, Some(version)));
621        }
622    }
623    if is_valid_registry_package_name(trimmed) {
624        return Some((trimmed, None));
625    }
626    None
627}
628
629pub(crate) fn parse_package_registry_index(
630    source: &str,
631    content: &str,
632) -> Result<PackageRegistryIndex, PackageError> {
633    let mut index = toml::from_str::<PackageRegistryIndex>(content)
634        .map_err(|error| format!("failed to parse package registry {source}: {error}"))?;
635    if index.version != REGISTRY_INDEX_VERSION {
636        return Err(format!(
637            "unsupported package registry {source} version {} (expected {})",
638            index.version, REGISTRY_INDEX_VERSION
639        )
640        .into());
641    }
642    validate_package_registry_index(source, &mut index)?;
643    Ok(index)
644}
645
646pub(crate) fn validate_package_registry_index(
647    source: &str,
648    index: &mut PackageRegistryIndex,
649) -> Result<(), PackageError> {
650    let mut names = HashSet::new();
651    for package in &mut index.packages {
652        if !is_valid_registry_package_name(&package.name) {
653            return Err(format!(
654                "package registry {source} has invalid package name '{}'",
655                package.name
656            )
657            .into());
658        }
659        if !names.insert(package.name.clone()) {
660            return Err(format!(
661                "package registry {source} declares '{}' more than once",
662                package.name
663            )
664            .into());
665        }
666        normalize_git_url(&package.repository).map_err(|error| {
667            format!(
668                "package registry {source} has invalid repository for '{}': {error}",
669                package.name
670            )
671        })?;
672        let mut versions = HashSet::new();
673        for version in &package.versions {
674            if version.version.trim().is_empty() {
675                return Err(format!(
676                    "package registry {source} has empty version for '{}'",
677                    package.name
678                )
679                .into());
680            }
681            if !versions.insert(version.version.clone()) {
682                return Err(format!(
683                    "package registry {source} declares '{}@{}' more than once",
684                    package.name, version.version
685                )
686                .into());
687            }
688            if selected_git_ref_count(version) != 1 {
689                return Err(format!(
690                    "package registry {source} entry '{}@{}' must specify tag, rev, or branch; rev may accompany tag as a resolved commit pin",
691                    package.name, version.version
692                )
693                .into());
694            }
695            parse_registry_semver(&version.version).map_err(|error| {
696                format!(
697                    "package registry {source} has invalid semver for '{}@{}': {error}",
698                    package.name, version.version
699                )
700            })?;
701            normalize_git_url(&version.git).map_err(|error| {
702                format!(
703                    "package registry {source} has invalid git source for '{}@{}': {error}",
704                    package.name, version.version
705                )
706            })?;
707        }
708    }
709    index
710        .packages
711        .sort_by(|left, right| left.name.cmp(&right.name));
712    Ok(())
713}
714
715fn selected_git_ref_count(version: &RegistryPackageVersion) -> usize {
716    usize::from(version.tag.is_some())
717        + usize::from(version.tag.is_none() && version.rev.is_some())
718        + usize::from(version.branch.is_some())
719}
720
721pub(crate) fn load_package_registry_in(
722    workspace: &PackageWorkspace,
723    explicit: Option<&str>,
724) -> Result<(String, PackageRegistryIndex), PackageError> {
725    let source = workspace.resolve_registry_source(explicit)?;
726    let content = read_registry_source(&source)?;
727    let index = parse_package_registry_index(&source, &content)?;
728    Ok((source, index))
729}
730
731pub(crate) fn registry_package_matches(package: &RegistryPackage, query: &str) -> bool {
732    if query.trim().is_empty() {
733        return true;
734    }
735    let query = query.to_ascii_lowercase();
736    package.name.to_ascii_lowercase().contains(&query)
737        || package
738            .description
739            .as_deref()
740            .is_some_and(|value| value.to_ascii_lowercase().contains(&query))
741        || package.repository.to_ascii_lowercase().contains(&query)
742        || package
743            .exports
744            .iter()
745            .any(|export| export.to_ascii_lowercase().contains(&query))
746}
747
748pub(crate) fn latest_registry_version(
749    package: &RegistryPackage,
750) -> Option<&RegistryPackageVersion> {
751    package
752        .versions
753        .iter()
754        .filter(|version| !version.yanked)
755        .filter_map(|version| {
756            parse_registry_semver(&version.version)
757                .ok()
758                .map(|semver| (semver, version))
759        })
760        .max_by(|(left, _), (right, _)| left.cmp(right))
761        .map(|(_, version)| version)
762}
763
764impl PackageRegistryIndex {
765    pub(crate) fn latest_unyanked_version(&self, name: &str) -> Option<&str> {
766        self.packages
767            .iter()
768            .find(|package| package.name == name)
769            .and_then(latest_registry_version)
770            .map(|version| version.version.as_str())
771    }
772
773    pub(crate) fn is_version_yanked(&self, name: &str, version: &str) -> bool {
774        self.packages
775            .iter()
776            .find(|package| package.name == name)
777            .into_iter()
778            .flat_map(|package| package.versions.iter())
779            .any(|entry| entry.version == version && entry.yanked)
780    }
781}
782
783pub(crate) fn parse_registry_semver(raw: &str) -> Result<Version, PackageError> {
784    Version::parse(raw.trim().trim_start_matches('v'))
785        .map_err(|error| PackageError::Registry(error.to_string()))
786}
787
788pub(crate) fn parse_registry_version_req(raw: &str) -> Result<VersionReq, PackageError> {
789    VersionReq::parse(&normalize_registry_version_req(raw)).map_err(|error| {
790        PackageError::Registry(format!("invalid version requirement {raw:?}: {error}"))
791    })
792}
793
794fn normalize_registry_version_req(raw: &str) -> String {
795    raw.split(',')
796        .map(|part| normalize_version_req_part(part.trim()))
797        .collect::<Vec<_>>()
798        .join(",")
799}
800
801fn normalize_version_req_part(part: &str) -> String {
802    for op in ["<=", ">=", "!=", "=", "<", ">", "^", "~"] {
803        if let Some(rest) = part.strip_prefix(op) {
804            return format!("{op}{}", normalize_partial_version(rest.trim()));
805        }
806    }
807    normalize_partial_version(part)
808}
809
810fn normalize_partial_version(raw: &str) -> String {
811    let trimmed = raw.trim().trim_start_matches('v');
812    if trimmed == "*" || trimmed.eq_ignore_ascii_case("x") {
813        return trimmed.to_string();
814    }
815    let (core, suffix) = trimmed
816        .find(['-', '+'])
817        .map(|index| (&trimmed[..index], &trimmed[index..]))
818        .unwrap_or((trimmed, ""));
819    let mut parts = core.split('.').collect::<Vec<_>>();
820    if (1..=2).contains(&parts.len())
821        && parts
822            .iter()
823            .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
824    {
825        while parts.len() < 3 {
826            parts.push("0");
827        }
828        return format!("{}{}", parts.join("."), suffix);
829    }
830    trimmed.to_string()
831}
832
833/// Look up a registry package by either its scoped registry name
834/// (`@burin/notion-sdk`) or any `[[package.version]].package` alias
835/// (`notion-sdk-harn`). Bare-name lookup falls back to the alias so
836/// `harn add notion-sdk-harn@0.1.0` works the same as the scoped form.
837fn lookup_registry_package<'a>(
838    index: &'a PackageRegistryIndex,
839    name: &str,
840) -> Result<&'a RegistryPackage, PackageError> {
841    if let Some(package) = index.packages.iter().find(|package| package.name == name) {
842        return Ok(package);
843    }
844    let matches: Vec<&RegistryPackage> = index
845        .packages
846        .iter()
847        .filter(|package| {
848            package
849                .versions
850                .iter()
851                .any(|entry| entry.package.as_deref() == Some(name))
852        })
853        .collect();
854    match matches.as_slice() {
855        [package] => Ok(package),
856        [] => Err(format!("package registry does not contain {name}").into()),
857        many => Err(format!(
858            "package alias {name} is ambiguous in the registry — found {} packages; use the scoped name (e.g. {})",
859            many.len(),
860            many[0].name,
861        )
862        .into()),
863    }
864}
865
866pub(crate) fn find_registry_package_version(
867    index: &PackageRegistryIndex,
868    name: &str,
869    version: Option<&str>,
870) -> Result<RegistryPackageInfo, PackageError> {
871    let package = lookup_registry_package(index, name)?;
872    let selected_version = match version {
873        Some(version) => Some(
874            package
875                .versions
876                .iter()
877                .find(|entry| entry.version == version)
878                .ok_or_else(|| format!("package registry does not contain {name}@{version}"))?
879                .clone(),
880        ),
881        None => latest_registry_version(package).cloned(),
882    };
883    Ok(RegistryPackageInfo {
884        package: package.clone(),
885        selected_version,
886    })
887}
888
889pub(crate) fn find_registry_package_version_matching(
890    index: &PackageRegistryIndex,
891    name: &str,
892    requirement: &str,
893) -> Result<RegistryPackageInfo, PackageError> {
894    let package = lookup_registry_package(index, name)?;
895    let req = parse_registry_version_req(requirement)?;
896    let selected_version = package
897        .versions
898        .iter()
899        .filter(|entry| !entry.yanked)
900        .filter_map(|entry| {
901            parse_registry_semver(&entry.version)
902                .ok()
903                .filter(|version| req.matches(version))
904                .map(|version| (version, entry.clone()))
905        })
906        .max_by(|(left, _), (right, _)| left.cmp(right))
907        .map(|(_, entry)| entry)
908        .ok_or_else(|| {
909            format!("package registry does not contain {name} matching {requirement}")
910        })?;
911    Ok(RegistryPackageInfo {
912        package: package.clone(),
913        selected_version: Some(selected_version),
914    })
915}
916
917pub(crate) fn search_package_registry_impl(
918    query: Option<&str>,
919    registry: Option<&str>,
920) -> Result<Vec<RegistryPackage>, PackageError> {
921    search_package_registry_in(&PackageWorkspace::from_current_dir()?, query, registry)
922}
923
924pub(crate) fn search_package_registry_in(
925    workspace: &PackageWorkspace,
926    query: Option<&str>,
927    registry: Option<&str>,
928) -> Result<Vec<RegistryPackage>, PackageError> {
929    let (_, index) = load_package_registry_in(workspace, registry)?;
930    Ok(index
931        .packages
932        .into_iter()
933        .filter(|package| registry_package_matches(package, query.unwrap_or("")))
934        .collect())
935}
936
937pub(crate) fn package_registry_info_impl(
938    spec: &str,
939    registry: Option<&str>,
940) -> Result<RegistryPackageInfo, PackageError> {
941    package_registry_info_in(&PackageWorkspace::from_current_dir()?, spec, registry)
942}
943
944pub(crate) fn package_registry_info_in(
945    workspace: &PackageWorkspace,
946    spec: &str,
947    registry: Option<&str>,
948) -> Result<RegistryPackageInfo, PackageError> {
949    let Some((name, version)) = parse_registry_package_spec(spec) else {
950        return Err(format!(
951            "invalid registry package name '{spec}'; use names like @burin/notion-sdk or acme-lib"
952        )
953        .into());
954    };
955    let (_, index) = load_package_registry_in(workspace, registry)?;
956    find_registry_package_version(&index, name, version)
957}
958
959pub(crate) fn registry_dependency_from_spec_in(
960    workspace: &PackageWorkspace,
961    spec: &str,
962    alias: Option<&str>,
963    registry: Option<&str>,
964) -> Result<(String, Dependency), PackageError> {
965    let Some((name, Some(version))) = parse_registry_package_spec(spec) else {
966        return Err(format!(
967            "registry dependency '{spec}' must include a version, for example {spec}@1.2.3"
968        )
969        .into());
970    };
971    let registry_source = workspace.resolve_registry_source(registry)?;
972    let (_, index) = load_package_registry_in(workspace, registry)?;
973    // Accept both exact versions (`@1.2.3`) and semver constraints
974    // (`@^0.1`, `@~1.4`, `@>=1,<2`). The latter resolve to the highest
975    // matching unyanked entry.
976    let info = if is_exact_semver(version) {
977        find_registry_package_version(&index, name, Some(version))?
978    } else {
979        find_registry_package_version_matching(&index, name, version)?
980    };
981    let selected = info
982        .selected_version
983        .ok_or_else(|| format!("package registry does not contain {name}@{version}"))?;
984    if selected.yanked {
985        return Err(format!("{name}@{version} is yanked in the package registry").into());
986    }
987    let git = normalize_git_url(&selected.git)?;
988    let package_name = selected
989        .package
990        .clone()
991        .map(Ok)
992        .unwrap_or_else(|| derive_repo_name_from_source(&git))?;
993    let alias = alias.unwrap_or(package_name.as_str()).to_string();
994    let tag = selected.tag;
995    let rev = if tag.is_some() { None } else { selected.rev };
996    let resolved_version = selected.version.clone();
997    Ok((
998        alias.clone(),
999        Dependency::Table(Box::new(DepTable {
1000            git: Some(git),
1001            tag,
1002            rev,
1003            branch: selected.branch,
1004            package: (alias != package_name).then_some(package_name),
1005            registry: Some(registry_source),
1006            // Store the canonical scoped registry name (e.g. `@burin/notion-sdk`)
1007            // even when the user typed the bare alias (`notion-sdk-harn`) so
1008            // re-resolves stay anchored to the same registry row.
1009            registry_name: Some(info.package.name),
1010            registry_version: Some(resolved_version),
1011            ..DepTable::default()
1012        })),
1013    ))
1014}
1015
1016fn is_exact_semver(spec: &str) -> bool {
1017    parse_registry_semver(spec).is_ok()
1018}
1019
1020pub(crate) fn registry_dependency_from_manifest_constraint_in(
1021    workspace: &PackageWorkspace,
1022    alias: &str,
1023    table: &DepTable,
1024) -> Result<Dependency, PackageError> {
1025    let requirement = table
1026        .version
1027        .as_deref()
1028        .ok_or_else(|| format!("dependency {alias} is missing `version`"))?;
1029    let registry_source = workspace.resolve_registry_source(table.registry.as_deref())?;
1030    let registry_name = table.registry_name.as_deref().unwrap_or(alias);
1031    let (_, index) = load_package_registry_in(workspace, Some(&registry_source))?;
1032    let info = find_registry_package_version_matching(&index, registry_name, requirement)?;
1033    let selected = info.selected_version.ok_or_else(|| {
1034        format!("package registry does not contain {registry_name} matching {requirement}")
1035    })?;
1036    let git = normalize_git_url(&selected.git)?;
1037    let tag = selected.tag;
1038    let rev = if tag.is_some() { None } else { selected.rev };
1039    Ok(Dependency::Table(Box::new(DepTable {
1040        git: Some(git),
1041        tag,
1042        rev,
1043        branch: selected.branch,
1044        package: selected.package.or_else(|| table.package.clone()),
1045        registry: Some(registry_source),
1046        registry_name: Some(registry_name.to_string()),
1047        registry_version: Some(selected.version),
1048        ..DepTable::default()
1049    })))
1050}
1051
1052pub(crate) fn is_probable_shorthand_git_url(raw: &str) -> bool {
1053    !raw.contains("://")
1054        && !raw.starts_with("git@")
1055        && raw.contains('/')
1056        && raw
1057            .split('/')
1058            .next()
1059            .is_some_and(|segment| segment.contains('.'))
1060}
1061
1062pub(crate) fn normalize_git_url(raw: &str) -> Result<String, PackageError> {
1063    let trimmed = raw.trim();
1064    if trimmed.is_empty() {
1065        return Err("git URL cannot be empty".to_string().into());
1066    }
1067
1068    let candidate_path = PathBuf::from(trimmed);
1069    if candidate_path.exists() {
1070        let canonical = candidate_path
1071            .canonicalize()
1072            .map_err(|error| format!("failed to canonicalize {trimmed}: {error}"))?;
1073        let url = Url::from_file_path(canonical)
1074            .map_err(|_| format!("failed to convert {trimmed} to file:// URL"))?;
1075        return Ok(url.to_string().trim_end_matches('/').to_string());
1076    }
1077
1078    if let Some(rest) = trimmed.strip_prefix("git@") {
1079        if let Some((host, path)) = rest.split_once(':') {
1080            return Ok(format!(
1081                "ssh://git@{}/{}",
1082                host,
1083                path.trim_start_matches('/').trim_end_matches('/')
1084            ));
1085        }
1086    }
1087
1088    let with_scheme = if is_probable_shorthand_git_url(trimmed) {
1089        format!("https://{trimmed}")
1090    } else {
1091        trimmed.to_string()
1092    };
1093    let parsed =
1094        Url::parse(&with_scheme).map_err(|error| format!("invalid git URL {trimmed}: {error}"))?;
1095    let mut normalized = parsed.to_string();
1096    while normalized.ends_with('/') {
1097        normalized.pop();
1098    }
1099    if parsed.scheme() != "file" && normalized.ends_with(".git") {
1100        normalized.truncate(normalized.len() - 4);
1101    }
1102    Ok(normalized)
1103}
1104
1105pub(crate) fn derive_repo_name_from_source(source: &str) -> Result<String, PackageError> {
1106    let url = Url::parse(source).map_err(|error| format!("invalid git URL {source}: {error}"))?;
1107    let segment = url
1108        .path_segments()
1109        .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty()))
1110        .ok_or_else(|| format!("failed to derive package name from {source}"))?;
1111    Ok(segment.trim_end_matches(".git").to_string())
1112}
1113
1114pub(crate) fn parse_positional_git_spec(spec: &str) -> (&str, Option<&str>) {
1115    if let Some((source, candidate_ref)) = spec.rsplit_once('@') {
1116        if !candidate_ref.is_empty()
1117            && !candidate_ref.contains('/')
1118            && !candidate_ref.contains(':')
1119            && !source.ends_with("://")
1120        {
1121            return (source, Some(candidate_ref));
1122        }
1123    }
1124    (spec, None)
1125}
1126
1127pub(crate) fn existing_local_path_spec(spec: &str) -> Option<PathBuf> {
1128    if spec.trim().is_empty() || spec.contains("://") || spec.starts_with("git@") {
1129        return None;
1130    }
1131    let candidate = PathBuf::from(spec);
1132    if candidate.exists() {
1133        return Some(candidate);
1134    }
1135    if candidate.extension().is_none() {
1136        let with_ext = candidate.with_extension("harn");
1137        if with_ext.exists() {
1138            return Some(with_ext);
1139        }
1140    }
1141    if is_probable_shorthand_git_url(spec) {
1142        return None;
1143    }
1144    None
1145}
1146
1147pub(crate) fn package_manifest_name(path: &Path) -> Option<String> {
1148    let manifest_path = if path.is_dir() {
1149        path.join(MANIFEST)
1150    } else {
1151        path.parent()?.join(MANIFEST)
1152    };
1153    let manifest = read_manifest_from_path(&manifest_path).ok()?;
1154    manifest
1155        .package
1156        .and_then(|pkg| pkg.name)
1157        .map(|name| name.trim().to_string())
1158        .filter(|name| !name.is_empty())
1159}
1160
1161pub(crate) fn derive_package_alias_from_path(path: &Path) -> Result<String, PackageError> {
1162    if let Some(name) = package_manifest_name(path) {
1163        return Ok(name);
1164    }
1165    let fallback = if path.is_dir() {
1166        path.file_name()
1167    } else {
1168        path.file_stem()
1169    };
1170    fallback
1171        .and_then(|name| name.to_str())
1172        .map(str::trim)
1173        .filter(|name| !name.is_empty())
1174        .map(str::to_string)
1175        .ok_or_else(|| {
1176            PackageError::Registry(format!(
1177                "failed to derive package alias from {}",
1178                path.display()
1179            ))
1180        })
1181}
1182
1183pub(crate) fn is_full_git_sha(value: &str) -> bool {
1184    value.len() == 40 && value.as_bytes().iter().all(|byte| byte.is_ascii_hexdigit())
1185}
1186
1187struct HardenedGitEnv {
1188    _temp_dir: tempfile::TempDir,
1189    home: PathBuf,
1190    config_home: PathBuf,
1191    global_config: PathBuf,
1192    system_config: PathBuf,
1193}
1194
1195impl HardenedGitEnv {
1196    fn new() -> Result<Self, PackageError> {
1197        let temp_dir = tempfile::Builder::new()
1198            .prefix("harn-git-env-")
1199            .tempdir()
1200            .map_err(|error| {
1201                PackageError::Registry(format!("failed to create isolated git env: {error}"))
1202            })?;
1203        let home = temp_dir.path().join("home");
1204        let config_home = temp_dir.path().join("xdg-config");
1205        fs::create_dir_all(&home)
1206            .map_err(|error| format!("failed to create {}: {error}", home.display()))?;
1207        fs::create_dir_all(&config_home)
1208            .map_err(|error| format!("failed to create {}: {error}", config_home.display()))?;
1209        let global_config = home.join(".gitconfig");
1210        let system_config = temp_dir.path().join("gitconfig-system");
1211        Ok(Self {
1212            _temp_dir: temp_dir,
1213            home,
1214            config_home,
1215            global_config,
1216            system_config,
1217        })
1218    }
1219
1220    fn apply_to(&self, command: &mut process::Command, cwd: Option<&Path>) {
1221        if let Some(dir) = cwd {
1222            command.current_dir(dir);
1223        }
1224        // Registry git URLs are untrusted input, so fetches must not inherit
1225        // user Git config, credential helpers, SSH agents, or askpass hooks.
1226        command.env_clear();
1227        for name in PRESERVED_GIT_ENV {
1228            if let Some(value) = std::env::var_os(name) {
1229                command.env(name, value);
1230            }
1231        }
1232        command
1233            .env("HOME", &self.home)
1234            .env("XDG_CONFIG_HOME", &self.config_home)
1235            .env("GIT_CONFIG_GLOBAL", &self.global_config)
1236            .env("GIT_CONFIG_SYSTEM", &self.system_config)
1237            .env("GIT_CONFIG_NOSYSTEM", "1")
1238            .env("GIT_TERMINAL_PROMPT", "0");
1239    }
1240}
1241
1242pub(crate) fn git_output<I, S>(
1243    args: I,
1244    cwd: Option<&Path>,
1245) -> Result<std::process::Output, PackageError>
1246where
1247    I: IntoIterator<Item = S>,
1248    S: AsRef<OsStr>,
1249{
1250    let git_env = HardenedGitEnv::new()?;
1251    let mut command = process::Command::new("git");
1252    git_env.apply_to(&mut command, cwd);
1253    command.args(args);
1254    command
1255        .output()
1256        .map_err(|error| PackageError::Registry(format!("failed to run git: {error}")))
1257}
1258
1259pub(crate) fn resolve_git_commit(
1260    url: &str,
1261    rev: Option<&str>,
1262    tag: Option<&str>,
1263    branch: Option<&str>,
1264) -> Result<String, PackageError> {
1265    let requested = branch.or(rev).or(tag).unwrap_or("HEAD");
1266    if branch.is_none() && tag.is_none() && is_full_git_sha(requested) {
1267        return Ok(requested.to_string());
1268    }
1269
1270    let refs = if let Some(branch) = branch {
1271        vec![format!("refs/heads/{branch}")]
1272    } else if let Some(tag) = tag {
1273        vec![format!("refs/tags/{tag}^{{}}"), format!("refs/tags/{tag}")]
1274    } else if requested == "HEAD" {
1275        vec!["HEAD".to_string()]
1276    } else {
1277        vec![
1278            requested.to_string(),
1279            format!("refs/tags/{requested}^{{}}"),
1280            format!("refs/tags/{requested}"),
1281            format!("refs/heads/{requested}"),
1282        ]
1283    };
1284
1285    let output = git_output(
1286        std::iter::once("ls-remote".to_string())
1287            .chain(std::iter::once(url.to_string()))
1288            .chain(refs),
1289        None,
1290    )?;
1291    if !output.status.success() {
1292        return Err(format!(
1293            "failed to resolve git ref from {url}: {}",
1294            String::from_utf8_lossy(&output.stderr).trim()
1295        )
1296        .into());
1297    }
1298    let stdout = String::from_utf8_lossy(&output.stdout);
1299    pick_ls_remote_commit(&stdout)
1300        .map(str::to_string)
1301        .ok_or_else(|| format!("could not resolve {requested} from {url}").into())
1302}
1303
1304/// Pick the commit SHA from `git ls-remote` output.
1305///
1306/// Annotated tags surface as two refs: `refs/tags/X` (the tag object) and
1307/// `refs/tags/X^{}` (the commit the tag points at). Prefer the peeled form so
1308/// the lockfile records the commit SHA, not the tag-object SHA — checking out
1309/// the tag object still recovers the commit, but the SHA recorded in the lock
1310/// is less surprising and round-trips through normal git commands.
1311fn pick_ls_remote_commit(stdout: &str) -> Option<&str> {
1312    let parsed: Vec<(&str, &str)> = stdout
1313        .lines()
1314        .filter_map(|line| {
1315            let mut parts = line.split_whitespace();
1316            let sha = parts.next()?;
1317            let refname = parts.next().unwrap_or("");
1318            is_full_git_sha(sha).then_some((sha, refname))
1319        })
1320        .collect();
1321    parsed
1322        .iter()
1323        .find_map(|(sha, refname)| refname.ends_with("^{}").then_some(*sha))
1324        .or_else(|| parsed.first().map(|(sha, _)| *sha))
1325}
1326
1327pub(crate) fn clone_git_commit_to(
1328    url: &str,
1329    commit: &str,
1330    dest: &Path,
1331) -> Result<(), PackageError> {
1332    if dest.exists() {
1333        fs::remove_dir_all(dest)
1334            .map_err(|error| format!("failed to reset {}: {error}", dest.display()))?;
1335    }
1336    fs::create_dir_all(dest)
1337        .map_err(|error| format!("failed to create {}: {error}", dest.display()))?;
1338
1339    let init = git_output(["init", "--quiet"], Some(dest))?;
1340    if !init.status.success() {
1341        return Err(format!(
1342            "failed to initialize git repo in {}: {}",
1343            dest.display(),
1344            String::from_utf8_lossy(&init.stderr).trim()
1345        )
1346        .into());
1347    }
1348
1349    let remote = git_output(["remote", "add", "origin", url], Some(dest))?;
1350    if !remote.status.success() {
1351        return Err(format!(
1352            "failed to add git remote {url}: {}",
1353            String::from_utf8_lossy(&remote.stderr).trim()
1354        )
1355        .into());
1356    }
1357
1358    let fetch = git_output(["fetch", "--depth", "1", "origin", commit], Some(dest))?;
1359    if !fetch.status.success() {
1360        let fallback_dir = dest.with_extension("full-clone");
1361        if fallback_dir.exists() {
1362            fs::remove_dir_all(&fallback_dir)
1363                .map_err(|error| format!("failed to remove {}: {error}", fallback_dir.display()))?;
1364        }
1365        let clone = git_output(
1366            ["clone", url, fallback_dir.to_string_lossy().as_ref()],
1367            None,
1368        )?;
1369        if !clone.status.success() {
1370            return Err(format!(
1371                "failed to fetch {commit} from {url}: {}",
1372                String::from_utf8_lossy(&fetch.stderr).trim()
1373            )
1374            .into());
1375        }
1376        let checkout = git_output(["checkout", commit], Some(&fallback_dir))?;
1377        if !checkout.status.success() {
1378            return Err(format!(
1379                "failed to checkout {commit} in {}: {}",
1380                fallback_dir.display(),
1381                String::from_utf8_lossy(&checkout.stderr).trim()
1382            )
1383            .into());
1384        }
1385        fs::remove_dir_all(dest)
1386            .map_err(|error| format!("failed to remove {}: {error}", dest.display()))?;
1387        fs::rename(&fallback_dir, dest).map_err(|error| {
1388            format!(
1389                "failed to move {} to {}: {error}",
1390                fallback_dir.display(),
1391                dest.display()
1392            )
1393        })?;
1394    } else {
1395        let checkout = git_output(["checkout", "--detach", "FETCH_HEAD"], Some(dest))?;
1396        if !checkout.status.success() {
1397            return Err(format!(
1398                "failed to checkout FETCH_HEAD in {}: {}",
1399                dest.display(),
1400                String::from_utf8_lossy(&checkout.stderr).trim()
1401            )
1402            .into());
1403        }
1404    }
1405
1406    let git_dir = dest.join(".git");
1407    if git_dir.exists() {
1408        fs::remove_dir_all(&git_dir)
1409            .map_err(|error| format!("failed to remove {}: {error}", git_dir.display()))?;
1410    }
1411    Ok(())
1412}
1413
1414pub(crate) fn unique_temp_dir(base: &Path, label: &str) -> Result<PathBuf, PackageError> {
1415    for _ in 0..16 {
1416        let suffix = uuid::Uuid::now_v7();
1417        let candidate = base.join(format!("{label}-{suffix}"));
1418        if !candidate.exists() {
1419            return Ok(candidate);
1420        }
1421    }
1422    Err(format!(
1423        "failed to allocate a unique temporary directory under {}",
1424        base.display()
1425    )
1426    .into())
1427}
1428
1429pub(crate) fn ensure_git_cache_populated_in(
1430    workspace: &PackageWorkspace,
1431    url: &str,
1432    source: &str,
1433    commit: &str,
1434    expected_hash: Option<&str>,
1435    refetch: bool,
1436    offline: bool,
1437) -> Result<String, PackageError> {
1438    let cache_dir = git_cache_dir_in(workspace, source, commit)?;
1439    let _lock = acquire_git_cache_lock_in(workspace, source, commit)?;
1440    if refetch && cache_dir.exists() {
1441        fs::remove_dir_all(&cache_dir)
1442            .map_err(|error| format!("failed to remove {}: {error}", cache_dir.display()))?;
1443    }
1444    if cache_dir.exists() {
1445        if let Some(expected) = expected_hash {
1446            verify_content_hash_or_compute(&cache_dir, expected)?;
1447            write_cache_metadata(&cache_dir, source, commit, expected)?;
1448            return Ok(expected.to_string());
1449        }
1450        let hash = compute_content_hash(&cache_dir)?;
1451        write_cached_content_hash(&cache_dir, &hash)?;
1452        write_cache_metadata(&cache_dir, source, commit, &hash)?;
1453        return Ok(hash);
1454    }
1455
1456    if offline {
1457        return Err(format!(
1458            "package cache entry for {source} at {commit} is missing; cannot fetch in offline mode"
1459        )
1460        .into());
1461    }
1462
1463    let parent = cache_dir
1464        .parent()
1465        .ok_or_else(|| format!("invalid cache path {}", cache_dir.display()))?;
1466    fs::create_dir_all(parent)
1467        .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1468    let temp_dir = unique_temp_dir(parent, "tmp")?;
1469    let populated = (|| -> Result<String, PackageError> {
1470        clone_git_commit_to(url, commit, &temp_dir)?;
1471        let hash = compute_content_hash(&temp_dir)?;
1472        if let Some(expected) = expected_hash {
1473            if hash != expected {
1474                return Err(format!(
1475                    "content hash mismatch for {source} at {commit}: expected {expected}, got {hash}"
1476                )
1477                .into());
1478            }
1479        }
1480        write_cached_content_hash(&temp_dir, &hash)?;
1481        write_cache_metadata(&temp_dir, source, commit, &hash)?;
1482        fs::rename(&temp_dir, &cache_dir).map_err(|error| {
1483            format!(
1484                "failed to move {} to {}: {error}",
1485                temp_dir.display(),
1486                cache_dir.display()
1487            )
1488        })?;
1489        Ok(hash)
1490    })();
1491    let hash = match populated {
1492        Ok(hash) => hash,
1493        Err(error) => {
1494            let _ = fs::remove_dir_all(&temp_dir);
1495            return Err(error);
1496        }
1497    };
1498    Ok(hash)
1499}
1500
1501#[derive(Debug, Clone)]
1502pub(crate) struct PackageCacheEntry {
1503    path: PathBuf,
1504    source_hash: String,
1505    commit: String,
1506    metadata: Option<PackageCacheMetadata>,
1507}
1508
1509pub(crate) fn git_cache_root_in(workspace: &PackageWorkspace) -> Result<PathBuf, PackageError> {
1510    Ok(workspace.cache_root()?.join("git"))
1511}
1512
1513pub(crate) fn discover_git_cache_entries() -> Result<Vec<PackageCacheEntry>, PackageError> {
1514    discover_git_cache_entries_in(&PackageWorkspace::from_current_dir()?)
1515}
1516
1517pub(crate) fn discover_git_cache_entries_in(
1518    workspace: &PackageWorkspace,
1519) -> Result<Vec<PackageCacheEntry>, PackageError> {
1520    let root = git_cache_root_in(workspace)?;
1521    let mut entries = Vec::new();
1522    let source_dirs = match fs::read_dir(&root) {
1523        Ok(source_dirs) => source_dirs,
1524        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
1525        Err(error) => return Err(format!("failed to read {}: {error}", root.display()).into()),
1526    };
1527    for source_dir in source_dirs {
1528        let source_dir = source_dir
1529            .map_err(|error| format!("failed to read {} entry: {error}", root.display()))?;
1530        let source_type = source_dir
1531            .file_type()
1532            .map_err(|error| format!("failed to stat {}: {error}", source_dir.path().display()))?;
1533        if !source_type.is_dir() {
1534            continue;
1535        }
1536        let source_hash = source_dir.file_name().to_string_lossy().to_string();
1537        let commit_dirs = fs::read_dir(source_dir.path())
1538            .map_err(|error| format!("failed to read {}: {error}", source_dir.path().display()))?;
1539        for commit_dir in commit_dirs {
1540            let commit_dir = commit_dir.map_err(|error| {
1541                format!(
1542                    "failed to read {} entry: {error}",
1543                    source_dir.path().display()
1544                )
1545            })?;
1546            let commit_type = commit_dir.file_type().map_err(|error| {
1547                format!("failed to stat {}: {error}", commit_dir.path().display())
1548            })?;
1549            if !commit_type.is_dir() {
1550                continue;
1551            }
1552            let commit = commit_dir.file_name().to_string_lossy().to_string();
1553            if commit.starts_with("tmp-") || commit.ends_with(".full-clone") {
1554                continue;
1555            }
1556            let metadata = read_cache_metadata(&commit_dir.path())?;
1557            entries.push(PackageCacheEntry {
1558                path: commit_dir.path(),
1559                source_hash: source_hash.clone(),
1560                commit,
1561                metadata,
1562            });
1563        }
1564    }
1565    entries.sort_by(|left, right| {
1566        left.source_hash
1567            .cmp(&right.source_hash)
1568            .then_with(|| left.commit.cmp(&right.commit))
1569    });
1570    Ok(entries)
1571}
1572
1573pub(crate) fn locked_git_cache_paths_in(
1574    workspace: &PackageWorkspace,
1575    lock: &LockFile,
1576) -> Result<HashSet<PathBuf>, PackageError> {
1577    let mut keep = HashSet::new();
1578    for entry in &lock.packages {
1579        validate_package_alias(&entry.name)?;
1580        if !entry.source.starts_with("git+") {
1581            continue;
1582        }
1583        let commit = entry
1584            .commit
1585            .as_deref()
1586            .ok_or_else(|| format!("missing locked commit for {}", entry.name))?;
1587        keep.insert(git_cache_dir_in(workspace, &entry.source, commit)?);
1588    }
1589    Ok(keep)
1590}
1591
1592pub(crate) fn verify_lock_entry_cache_in(
1593    workspace: &PackageWorkspace,
1594    entry: &LockEntry,
1595) -> Result<bool, PackageError> {
1596    validate_package_alias(&entry.name)?;
1597    if !entry.source.starts_with("git+") {
1598        if entry.source.starts_with("path+") {
1599            let path = path_from_source_uri(&entry.source)?;
1600            if !path.exists() {
1601                return Err(format!(
1602                    "path dependency {} source is missing: {}",
1603                    entry.name,
1604                    path.display()
1605                )
1606                .into());
1607            }
1608        }
1609        return Ok(false);
1610    }
1611    let commit = entry
1612        .commit
1613        .as_deref()
1614        .ok_or_else(|| format!("missing locked commit for {}", entry.name))?;
1615    let expected_hash = entry
1616        .content_hash
1617        .as_deref()
1618        .ok_or_else(|| format!("missing content hash for {}", entry.name))?;
1619    let cache_dir = git_cache_dir_in(workspace, &entry.source, commit)?;
1620    if !cache_dir.is_dir() {
1621        return Err(format!(
1622            "package cache entry for {} is missing: {}",
1623            entry.name,
1624            cache_dir.display()
1625        )
1626        .into());
1627    }
1628    verify_content_hash_or_compute(&cache_dir, expected_hash)?;
1629    match read_cache_metadata(&cache_dir)? {
1630        Some(metadata)
1631            if metadata.source == entry.source
1632                && metadata.commit == commit
1633                && metadata.content_hash == expected_hash => {}
1634        Some(metadata) => {
1635            return Err(format!(
1636                "package cache metadata mismatch for {}: expected {} {} {}, got {} {} {}",
1637                entry.name,
1638                entry.source,
1639                commit,
1640                expected_hash,
1641                metadata.source,
1642                metadata.commit,
1643                metadata.content_hash
1644            )
1645            .into());
1646        }
1647        None => write_cache_metadata(&cache_dir, &entry.source, commit, expected_hash)?,
1648    }
1649    Ok(true)
1650}
1651
1652pub(crate) fn verify_materialized_lock_entry(
1653    ctx: &ManifestContext,
1654    entry: &LockEntry,
1655) -> Result<bool, PackageError> {
1656    validate_package_alias(&entry.name)?;
1657    let packages_dir = ctx.packages_dir();
1658    if entry.source.starts_with("path+") {
1659        let dir = packages_dir.join(&entry.name);
1660        let file = packages_dir.join(format!("{}.harn", entry.name));
1661        if !dir.exists() && !file.exists() {
1662            return Err(format!(
1663                "materialized path dependency {} is missing under {}",
1664                entry.name,
1665                packages_dir.display()
1666            )
1667            .into());
1668        }
1669        return Ok(true);
1670    }
1671    if !entry.source.starts_with("git+") {
1672        return Ok(false);
1673    }
1674    let expected_hash = entry
1675        .content_hash
1676        .as_deref()
1677        .ok_or_else(|| format!("missing content hash for {}", entry.name))?;
1678    let dest_dir = packages_dir.join(&entry.name);
1679    if !dest_dir.is_dir() {
1680        return Err(format!(
1681            "materialized package {} is missing: {}",
1682            entry.name,
1683            dest_dir.display()
1684        )
1685        .into());
1686    }
1687    verify_content_hash_or_compute(&dest_dir, expected_hash)?;
1688    Ok(true)
1689}
1690
1691pub(crate) fn verify_package_cache_impl(materialized: bool) -> Result<usize, PackageError> {
1692    verify_package_cache_in(&PackageWorkspace::from_current_dir()?, materialized)
1693}
1694
1695pub(crate) fn verify_package_cache_in(
1696    workspace: &PackageWorkspace,
1697    materialized: bool,
1698) -> Result<usize, PackageError> {
1699    let ctx = workspace.load_manifest_context()?;
1700    let lock = LockFile::load(&ctx.lock_path())?
1701        .ok_or_else(|| format!("{} is missing", ctx.lock_path().display()))?;
1702    validate_lock_matches_manifest(workspace, &ctx, &lock)?;
1703    let mut verified = 0usize;
1704    for entry in &lock.packages {
1705        if verify_lock_entry_cache_in(workspace, entry)? {
1706            verified += 1;
1707        }
1708        if materialized && verify_materialized_lock_entry(&ctx, entry)? {
1709            verified += 1;
1710        }
1711    }
1712    Ok(verified)
1713}
1714
1715pub(crate) fn clean_package_cache_impl(all: bool) -> Result<usize, PackageError> {
1716    clean_package_cache_in(&PackageWorkspace::from_current_dir()?, all)
1717}
1718
1719pub(crate) fn clean_package_cache_in(
1720    workspace: &PackageWorkspace,
1721    all: bool,
1722) -> Result<usize, PackageError> {
1723    let entries = discover_git_cache_entries_in(workspace)?;
1724    if entries.is_empty() {
1725        return Ok(0);
1726    }
1727    if all {
1728        let root = workspace.cache_root()?;
1729        for child in ["git", "locks"] {
1730            let path = root.join(child);
1731            if path.exists() {
1732                fs::remove_dir_all(&path)
1733                    .map_err(|error| format!("failed to remove {}: {error}", path.display()))?;
1734            }
1735        }
1736        return Ok(entries.len());
1737    }
1738
1739    let ctx = workspace.load_manifest_context()?;
1740    let lock = LockFile::load(&ctx.lock_path())?
1741        .ok_or_else(|| format!("{LOCK_FILE} is missing; pass --all to clean every cache entry"))?;
1742    validate_lock_matches_manifest(workspace, &ctx, &lock)?;
1743    let keep = locked_git_cache_paths_in(workspace, &lock)?;
1744    let mut removed = 0usize;
1745    for entry in entries {
1746        if keep.contains(&entry.path) {
1747            continue;
1748        }
1749        fs::remove_dir_all(&entry.path)
1750            .map_err(|error| format!("failed to remove {}: {error}", entry.path.display()))?;
1751        removed += 1;
1752        if let Some(parent) = entry.path.parent() {
1753            let is_empty = fs::read_dir(parent)
1754                .map(|mut children| children.next().is_none())
1755                .unwrap_or(false);
1756            if is_empty {
1757                fs::remove_dir(parent)
1758                    .map_err(|error| format!("failed to remove {}: {error}", parent.display()))?;
1759            }
1760        }
1761    }
1762    Ok(removed)
1763}
1764
1765pub fn list_package_cache() {
1766    let result = (|| -> Result<(PathBuf, Vec<PackageCacheEntry>), PackageError> {
1767        Ok((cache_root()?, discover_git_cache_entries()?))
1768    })();
1769
1770    match result {
1771        Ok((root, entries)) => {
1772            println!("Cache root: {}", root.display());
1773            if entries.is_empty() {
1774                println!("No cached git packages.");
1775                return;
1776            }
1777            println!("commit\tcontent_hash\tsource\tpath");
1778            for entry in entries {
1779                let (source, content_hash) = entry
1780                    .metadata
1781                    .as_ref()
1782                    .map(|metadata| (metadata.source.as_str(), metadata.content_hash.as_str()))
1783                    .unwrap_or(("(unknown)", "(unknown)"));
1784                println!(
1785                    "{}\t{}\t{}\t{}",
1786                    entry.commit,
1787                    content_hash,
1788                    source,
1789                    entry.path.display()
1790                );
1791            }
1792        }
1793        Err(error) => {
1794            eprintln!("error: {error}");
1795            process::exit(1);
1796        }
1797    }
1798}
1799
1800pub fn clean_package_cache(all: bool) {
1801    match clean_package_cache_impl(all) {
1802        Ok(removed) => println!("Removed {removed} cached package entries."),
1803        Err(error) => {
1804            eprintln!("error: {error}");
1805            process::exit(1);
1806        }
1807    }
1808}
1809
1810pub fn verify_package_cache(materialized: bool) {
1811    match verify_package_cache_impl(materialized) {
1812        Ok(verified) => println!("Verified {verified} package cache entries."),
1813        Err(error) => {
1814            eprintln!("error: {error}");
1815            process::exit(1);
1816        }
1817    }
1818}
1819
1820pub fn search_package_registry(query: Option<&str>, registry: Option<&str>, json: bool) {
1821    match search_package_registry_impl(query, registry) {
1822        Ok(packages) if json => {
1823            println!(
1824                "{}",
1825                serde_json::to_string_pretty(&packages)
1826                    .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
1827            );
1828        }
1829        Ok(packages) => {
1830            if packages.is_empty() {
1831                println!("No packages found.");
1832                return;
1833            }
1834            println!("name\tlatest\tharn\tcontract\tdescription");
1835            for package in packages {
1836                let latest = latest_registry_version(&package)
1837                    .map(|version| version.version.as_str())
1838                    .unwrap_or("-");
1839                println!(
1840                    "{}\t{}\t{}\t{}\t{}",
1841                    package.name,
1842                    latest,
1843                    package.harn.as_deref().unwrap_or("-"),
1844                    package.connector_contract.as_deref().unwrap_or("-"),
1845                    package.description.as_deref().unwrap_or("")
1846                );
1847            }
1848        }
1849        Err(error) => {
1850            eprintln!("error: {error}");
1851            process::exit(1);
1852        }
1853    }
1854}
1855
1856pub fn show_package_registry_info(spec: &str, registry: Option<&str>, json: bool) {
1857    match package_registry_info_impl(spec, registry) {
1858        Ok(info) if json => {
1859            println!(
1860                "{}",
1861                serde_json::to_string_pretty(&info)
1862                    .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
1863            );
1864        }
1865        Ok(info) => {
1866            let package = info.package;
1867            println!("{}", package.name);
1868            if let Some(description) = package.description.as_deref() {
1869                println!("description: {description}");
1870            }
1871            println!("repository: {}", package.repository);
1872            if let Some(license) = package.license.as_deref() {
1873                println!("license: {license}");
1874            }
1875            if let Some(harn) = package.harn.as_deref() {
1876                println!("harn: {harn}");
1877            }
1878            if let Some(contract) = package.connector_contract.as_deref() {
1879                println!("connector_contract: {contract}");
1880            }
1881            if let Some(docs) = package.docs_url.as_deref() {
1882                println!("docs: {docs}");
1883            }
1884            if let Some(checksum) = package.checksum.as_deref() {
1885                println!("checksum: {checksum}");
1886            }
1887            if let Some(provenance) = package.provenance.as_deref() {
1888                println!("provenance: {provenance}");
1889            }
1890            if !package.exports.is_empty() {
1891                println!("exports: {}", package.exports.join(", "));
1892            }
1893            if let Some(version) = info.selected_version {
1894                println!("selected: {}", version.version);
1895                println!("git: {}", version.git);
1896                if let Some(rev) = version.rev.as_deref() {
1897                    println!("rev: {rev}");
1898                }
1899                if let Some(branch) = version.branch.as_deref() {
1900                    println!("branch: {branch}");
1901                }
1902                if let Some(package_name) = version.package.as_deref() {
1903                    println!("package: {package_name}");
1904                }
1905            }
1906            if !package.versions.is_empty() {
1907                let versions = package
1908                    .versions
1909                    .iter()
1910                    .map(|version| {
1911                        if version.yanked {
1912                            format!("{} (yanked)", version.version)
1913                        } else {
1914                            version.version.clone()
1915                        }
1916                    })
1917                    .collect::<Vec<_>>()
1918                    .join(", ");
1919                println!("versions: {versions}");
1920            }
1921        }
1922        Err(error) => {
1923            eprintln!("error: {error}");
1924            process::exit(1);
1925        }
1926    }
1927}
1928
1929#[cfg(test)]
1930mod tests {
1931    use super::*;
1932    use crate::package::test_support::*;
1933
1934    #[test]
1935    fn pick_ls_remote_commit_prefers_peeled_tag_over_tag_object() {
1936        // Real-world example from notion-sdk-harn v0.1.0: the tag is
1937        // annotated, so ls-remote returns both the tag-object SHA and the
1938        // commit it points at.
1939        let output = "\
1940963b6e8acfdf030a9b922bc5a73e010758ff47da\trefs/tags/v0.1.0\n\
1941bad580c5fbe8ede612b2748ad98606642ce2fc02\trefs/tags/v0.1.0^{}\n";
1942        assert_eq!(
1943            pick_ls_remote_commit(output),
1944            Some("bad580c5fbe8ede612b2748ad98606642ce2fc02"),
1945        );
1946    }
1947
1948    #[test]
1949    fn pick_ls_remote_commit_falls_back_to_first_match_for_lightweight_tags() {
1950        let output = "\
1951abc123abc123abc123abc123abc123abc1234567\trefs/tags/v0.0.1\n";
1952        assert_eq!(
1953            pick_ls_remote_commit(output),
1954            Some("abc123abc123abc123abc123abc123abc1234567"),
1955        );
1956    }
1957
1958    #[test]
1959    fn pick_ls_remote_commit_returns_none_on_empty_output() {
1960        assert_eq!(pick_ls_remote_commit(""), None);
1961    }
1962
1963    #[cfg(unix)]
1964    #[test]
1965    fn hardened_git_env_scrubs_ambient_git_credentials_and_config() {
1966        let git_env = HardenedGitEnv::new().unwrap();
1967        let mut command = process::Command::new("/usr/bin/env");
1968        command
1969            .env("HOME", "/sensitive/home")
1970            .env("XDG_CONFIG_HOME", "/sensitive/config")
1971            .env("GIT_ASKPASS", "/sensitive/askpass")
1972            .env("GIT_SSH_COMMAND", "ssh -i /sensitive/key")
1973            .env("SSH_AUTH_SOCK", "/sensitive/agent.sock")
1974            .env("GIT_CONFIG_COUNT", "1")
1975            .env(
1976                "GIT_CONFIG_KEY_0",
1977                "http.https://attacker.example/.extraheader",
1978            )
1979            .env("GIT_CONFIG_VALUE_0", "Authorization: bearer secret");
1980        git_env.apply_to(&mut command, None);
1981
1982        let output = command.output().unwrap();
1983        assert!(
1984            output.status.success(),
1985            "env probe failed: {}",
1986            String::from_utf8_lossy(&output.stderr)
1987        );
1988        let stdout = String::from_utf8(output.stdout).unwrap();
1989        let vars: std::collections::BTreeMap<_, _> = stdout
1990            .lines()
1991            .filter_map(|line| line.split_once('='))
1992            .map(|(name, value)| (name.to_string(), value.to_string()))
1993            .collect();
1994
1995        assert_eq!(Path::new(&vars["HOME"]), git_env.home);
1996        assert_eq!(Path::new(&vars["XDG_CONFIG_HOME"]), git_env.config_home);
1997        assert_eq!(Path::new(&vars["GIT_CONFIG_GLOBAL"]), git_env.global_config);
1998        assert_eq!(Path::new(&vars["GIT_CONFIG_SYSTEM"]), git_env.system_config);
1999        assert_eq!(vars["GIT_CONFIG_NOSYSTEM"], "1");
2000        assert_eq!(vars["GIT_TERMINAL_PROMPT"], "0");
2001        assert!(!vars.contains_key("GIT_ASKPASS"));
2002        assert!(!vars.contains_key("GIT_SSH_COMMAND"));
2003        assert!(!vars.contains_key("SSH_AUTH_SOCK"));
2004        assert!(!vars.contains_key("GIT_CONFIG_COUNT"));
2005        assert!(!vars.contains_key("GIT_CONFIG_KEY_0"));
2006        assert!(!vars.contains_key("GIT_CONFIG_VALUE_0"));
2007    }
2008
2009    #[test]
2010    fn compute_content_hash_ignores_git_and_hash_marker() {
2011        let tmp = tempfile::tempdir().unwrap();
2012        let root = tmp.path();
2013        fs::create_dir_all(root.join(".git")).unwrap();
2014        fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
2015        fs::write(root.join(".gitignore"), "ignored\n").unwrap();
2016        fs::write(root.join(CONTENT_HASH_FILE), "stale\n").unwrap();
2017        fs::write(
2018            root.join("lib.harn"),
2019            "pub fn value() -> number { return 1 }\n",
2020        )
2021        .unwrap();
2022        let first = compute_content_hash(root).unwrap();
2023        fs::write(root.join(".git/HEAD"), "changed\n").unwrap();
2024        fs::write(root.join(".gitignore"), "changed\n").unwrap();
2025        fs::write(root.join(CONTENT_HASH_FILE), "changed\n").unwrap();
2026        let second = compute_content_hash(root).unwrap();
2027        assert_eq!(first, second);
2028    }
2029
2030    #[cfg(unix)]
2031    #[test]
2032    fn remove_materialized_package_unlinks_directory_symlink_without_touching_source() {
2033        let tmp = tempfile::tempdir().unwrap();
2034        let source = tmp.path().join("source");
2035        let packages = tmp.path().join(".harn/packages");
2036        fs::create_dir_all(&source).unwrap();
2037        fs::create_dir_all(&packages).unwrap();
2038        fs::write(
2039            source.join("lib.harn"),
2040            "pub fn value() -> number { return 1 }\n",
2041        )
2042        .unwrap();
2043
2044        let materialized = packages.join("acme");
2045        std::os::unix::fs::symlink(&source, &materialized).unwrap();
2046
2047        remove_materialized_package(&packages, "acme").unwrap();
2048
2049        assert!(!materialized.exists());
2050        assert!(source.join("lib.harn").is_file());
2051    }
2052
2053    #[test]
2054    fn package_cache_verify_detects_tampering_even_with_stale_marker() {
2055        let (_repo_tmp, repo, _branch) = create_git_package_repo();
2056        let project_tmp = tempfile::tempdir().unwrap();
2057        let root = project_tmp.path();
2058        let workspace = TestWorkspace::new(root);
2059        fs::create_dir_all(root.join(".git")).unwrap();
2060        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2061        fs::write(
2062            root.join(MANIFEST),
2063            format!(
2064                r#"
2065    [package]
2066    name = "workspace"
2067    version = "0.1.0"
2068
2069    [dependencies]
2070    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2071    "#
2072            ),
2073        )
2074        .unwrap();
2075
2076        install_packages_in(workspace.env(), false, None, false).unwrap();
2077        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2078        let entry = lock.find("acme-lib").unwrap();
2079        let cache_dir = git_cache_dir_in(
2080            workspace.env(),
2081            &entry.source,
2082            entry.commit.as_deref().unwrap(),
2083        )
2084        .unwrap();
2085        fs::write(
2086            cache_dir.join("lib.harn"),
2087            "pub fn value() { return \"pwned\" }\n",
2088        )
2089        .unwrap();
2090
2091        let error = verify_package_cache_in(workspace.env(), false).unwrap_err();
2092        assert!(error.to_string().contains("content hash mismatch"));
2093    }
2094
2095    #[test]
2096    fn package_cache_clean_all_removes_cached_git_entries() {
2097        let (_repo_tmp, repo, _branch) = create_git_package_repo();
2098        let project_tmp = tempfile::tempdir().unwrap();
2099        let root = project_tmp.path();
2100        let workspace = TestWorkspace::new(root);
2101        fs::create_dir_all(root.join(".git")).unwrap();
2102        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2103        fs::write(
2104            root.join(MANIFEST),
2105            format!(
2106                r#"
2107    [package]
2108    name = "workspace"
2109    version = "0.1.0"
2110
2111    [dependencies]
2112    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2113    "#
2114            ),
2115        )
2116        .unwrap();
2117
2118        install_packages_in(workspace.env(), false, None, false).unwrap();
2119        assert_eq!(
2120            discover_git_cache_entries_in(workspace.env())
2121                .unwrap()
2122                .len(),
2123            1
2124        );
2125
2126        let removed = clean_package_cache_in(workspace.env(), true).unwrap();
2127        assert_eq!(removed, 1);
2128        assert!(discover_git_cache_entries_in(workspace.env())
2129            .unwrap()
2130            .is_empty());
2131    }
2132
2133    #[test]
2134    fn registry_index_search_and_info_use_local_file_without_network() {
2135        let (_repo_tmp, repo, _branch) = create_git_package_repo();
2136        let project_tmp = tempfile::tempdir().unwrap();
2137        let root = project_tmp.path();
2138        let workspace = TestWorkspace::new(root);
2139        let registry_path = root.join("index.toml");
2140        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2141        write_package_registry_index(&registry_path, "@burin/acme-lib", &git, "acme-lib");
2142        fs::create_dir_all(root.join(".git")).unwrap();
2143        fs::write(
2144            root.join(MANIFEST),
2145            r#"
2146    [package]
2147    name = "workspace"
2148    version = "0.1.0"
2149    "#,
2150        )
2151        .unwrap();
2152
2153        let matches = search_package_registry_in(
2154            workspace.env(),
2155            Some("acme"),
2156            Some(registry_path.to_string_lossy().as_ref()),
2157        )
2158        .unwrap();
2159        assert_eq!(matches.len(), 1);
2160        assert_eq!(matches[0].name, "@burin/acme-lib");
2161        assert_eq!(
2162            matches[0].harn.as_deref(),
2163            Some(crate::package::current_harn_range_example().as_str())
2164        );
2165        assert_eq!(matches[0].connector_contract.as_deref(), Some("v1"));
2166        assert_eq!(matches[0].exports, vec!["lib"]);
2167
2168        let info = package_registry_info_in(
2169            workspace.env(),
2170            "@burin/acme-lib@1.0.0",
2171            Some(registry_path.to_string_lossy().as_ref()),
2172        )
2173        .unwrap();
2174        assert_eq!(info.package.license.as_deref(), Some("MIT OR Apache-2.0"));
2175        assert_eq!(
2176            info.selected_version
2177                .as_ref()
2178                .map(|version| version.git.as_str()),
2179            Some(git.as_str())
2180        );
2181    }
2182
2183    #[test]
2184    fn add_registry_dependency_preserves_provenance_in_manifest_and_lock() {
2185        let (_repo_tmp, repo, _branch) = create_git_package_repo();
2186        let project_tmp = tempfile::tempdir().unwrap();
2187        let root = project_tmp.path();
2188        let registry_path = root.join("index.toml");
2189        let workspace =
2190            TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
2191        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2192        write_package_registry_index(&registry_path, "@burin/acme-lib", &git, "acme-lib");
2193        fs::create_dir_all(root.join(".git")).unwrap();
2194        fs::write(
2195            root.join(MANIFEST),
2196            r#"
2197    [package]
2198    name = "workspace"
2199    version = "0.1.0"
2200    "#,
2201        )
2202        .unwrap();
2203
2204        add_package_to(
2205            workspace.env(),
2206            "@burin/acme-lib@1.0.0",
2207            None,
2208            None,
2209            None,
2210            None,
2211            None,
2212            None,
2213            None,
2214        )
2215        .unwrap();
2216
2217        let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
2218        assert!(
2219            manifest.contains(&format!("git = \"{git}\"")),
2220            "registry install must record the resolved git URL: {manifest}"
2221        );
2222        assert!(
2223            manifest.contains("tag = \"v1.0.0\""),
2224            "registry install must pin the resolved tag: {manifest}"
2225        );
2226        assert!(
2227            manifest.contains("registry_name = \"@burin/acme-lib\""),
2228            "registry install must preserve the registry-side package name: {manifest}"
2229        );
2230        assert!(
2231            manifest.contains("registry_version = \"1.0.0\""),
2232            "registry install must preserve the requested registry version: {manifest}"
2233        );
2234        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2235        let entry = lock.find("acme-lib").unwrap();
2236        assert_eq!(entry.source, format!("git+{git}"));
2237        let registry = entry
2238            .registry
2239            .as_ref()
2240            .expect("registry-added entry should carry registry provenance");
2241        assert_eq!(registry.name, "@burin/acme-lib");
2242        assert_eq!(registry.version, "1.0.0");
2243        assert!(root
2244            .join(PKG_DIR)
2245            .join("acme-lib")
2246            .join("lib.harn")
2247            .is_file());
2248    }
2249
2250    #[test]
2251    fn add_registry_dependency_accepts_bare_alias_and_semver_range() {
2252        // Covers the literal acceptance from the free-tier package-manager
2253        // epic (harn#2157): `harn add notion-sdk-harn@^0.1` should resolve
2254        // even though the registry-side name is `@burin/notion-sdk`.
2255        let (_repo_tmp, repo, _branch) = create_git_package_repo();
2256        let project_tmp = tempfile::tempdir().unwrap();
2257        let root = project_tmp.path();
2258        let registry_path = root.join("index.toml");
2259        let workspace =
2260            TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
2261        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2262        write_package_registry_index(&registry_path, "@burin/acme-lib", &git, "acme-lib");
2263        fs::create_dir_all(root.join(".git")).unwrap();
2264        fs::write(
2265            root.join(MANIFEST),
2266            r#"
2267    [package]
2268    name = "workspace"
2269    version = "0.1.0"
2270    "#,
2271        )
2272        .unwrap();
2273
2274        // Bare alias + semver range. Highest matching unyanked version wins.
2275        add_package_to(
2276            workspace.env(),
2277            "acme-lib@^1",
2278            None,
2279            None,
2280            None,
2281            None,
2282            None,
2283            None,
2284            None,
2285        )
2286        .unwrap();
2287
2288        let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
2289        assert!(
2290            manifest.contains("registry_name = \"@burin/acme-lib\""),
2291            "bare-alias add must record the canonical scoped registry name: {manifest}"
2292        );
2293        assert!(
2294            manifest.contains("registry_version = \"1.0.0\""),
2295            "semver range must resolve to the highest matching exact version: {manifest}"
2296        );
2297    }
2298
2299    #[test]
2300    fn registry_index_rejects_invalid_names_and_duplicate_versions() {
2301        let content = r#"
2302    version = 1
2303
2304    [[package]]
2305    name = "@bad/"
2306    repository = "https://github.com/acme/acme-lib"
2307
2308    [[package.version]]
2309    version = "1.0.0"
2310    git = "https://github.com/acme/acme-lib"
2311    rev = "v1.0.0"
2312    "#;
2313        let error = parse_package_registry_index("fixture", content).unwrap_err();
2314        assert!(error.to_string().contains("invalid package name"));
2315
2316        let content = r#"
2317    version = 1
2318
2319    [[package]]
2320    name = "@burin/acme-lib"
2321    repository = "https://github.com/acme/acme-lib"
2322
2323    [[package.version]]
2324    version = "1.0.0"
2325    git = "https://github.com/acme/acme-lib"
2326    rev = "v1.0.0"
2327
2328    [[package.version]]
2329    version = "1.0.0"
2330    git = "https://github.com/acme/acme-lib"
2331    rev = "v1.0.0"
2332    "#;
2333        let error = parse_package_registry_index("fixture", content).unwrap_err();
2334        assert!(error.to_string().contains("more than once"));
2335    }
2336}