1use super::errors::PackageError;
2use super::*;
3
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5pub(crate) struct LockFile {
6 pub(crate) version: u32,
7 #[serde(default = "current_generator_version")]
10 pub(crate) generator_version: String,
11 #[serde(default = "current_protocol_artifact_version")]
16 pub(crate) protocol_artifact_version: String,
17 #[serde(default, rename = "package")]
18 pub(crate) packages: Vec<LockEntry>,
19}
20
21impl Default for LockFile {
22 fn default() -> Self {
23 Self {
24 version: LOCK_FILE_VERSION,
25 generator_version: current_generator_version(),
26 protocol_artifact_version: current_protocol_artifact_version(),
27 packages: Vec::new(),
28 }
29 }
30}
31
32pub(crate) fn current_generator_version() -> String {
33 env!("CARGO_PKG_VERSION").to_string()
34}
35
36pub(crate) fn current_protocol_artifact_version() -> String {
37 env!("CARGO_PKG_VERSION").to_string()
38}
39
40#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41pub(crate) struct LockEntry {
42 pub(crate) name: String,
43 pub(crate) source: String,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub(crate) tag: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub(crate) rev_request: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub(crate) commit: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub(crate) content_hash: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub(crate) package_version: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub(crate) harn_compat: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub(crate) provenance: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub(crate) manifest_digest: Option<String>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub(crate) registry: Option<RegistryProvenance>,
76 #[serde(default, skip_serializing_if = "PackageLockExports::is_empty")]
77 pub(crate) exports: PackageLockExports,
78 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 pub(crate) permissions: Vec<String>,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
81 pub(crate) host_requirements: Vec<String>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub(crate) struct RegistryProvenance {
86 pub(crate) source: String,
87 pub(crate) name: String,
88 pub(crate) version: String,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub(crate) provenance_url: Option<String>,
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
94pub struct PackageLockExports {
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub modules: Vec<PackageLockExport>,
97 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 pub tools: Vec<PackageLockExport>,
99 #[serde(default, skip_serializing_if = "Vec::is_empty")]
100 pub skills: Vec<PackageLockExport>,
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 pub personas: Vec<String>,
103}
104
105impl PackageLockExports {
106 pub(crate) fn is_empty(&self) -> bool {
107 self.modules.is_empty()
108 && self.tools.is_empty()
109 && self.skills.is_empty()
110 && self.personas.is_empty()
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct PackageLockExport {
116 pub name: String,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub path: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub symbol: Option<String>,
121}
122
123impl LockFile {
124 pub(crate) fn load(path: &Path) -> Result<Option<Self>, PackageError> {
125 let content = match fs::read_to_string(path) {
126 Ok(s) => s,
127 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
128 Err(error) => return Err(format!("failed to read {}: {error}", path.display()).into()),
129 };
130
131 let raw_version = toml::from_str::<RawVersionedFile>(&content)
135 .ok()
136 .map(|raw| raw.version);
137
138 match raw_version {
139 Some(LOCK_FILE_VERSION) => {
140 let mut lock: Self = toml::from_str(&content)
141 .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
142 lock.sort_entries();
143 Ok(Some(lock))
144 }
145 Some(1..=3) => {
146 let mut lock: Self = toml::from_str(&content)
150 .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
151 lock.version = LOCK_FILE_VERSION;
152 lock.sort_entries();
153 Ok(Some(lock))
154 }
155 Some(other) => Err(format!(
156 "unsupported {} version {} (expected {})",
157 path.display(),
158 other,
159 LOCK_FILE_VERSION
160 )
161 .into()),
162 None => {
163 let legacy = toml::from_str::<LegacyLockFile>(&content)
164 .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
165 let mut lock = Self {
166 version: LOCK_FILE_VERSION,
167 generator_version: current_generator_version(),
168 protocol_artifact_version: current_protocol_artifact_version(),
169 packages: legacy
170 .packages
171 .into_iter()
172 .map(|entry| LockEntry {
173 name: entry.name,
174 source: entry
175 .path
176 .map(|path| format!("path+{path}"))
177 .or_else(|| entry.git.map(|git| format!("git+{git}")))
178 .unwrap_or_default(),
179 tag: entry.tag.clone(),
180 rev_request: entry.rev_request.or(entry.tag),
181 commit: entry.commit,
182 content_hash: None,
183 package_version: None,
184 harn_compat: None,
185 provenance: None,
186 manifest_digest: None,
187 registry: None,
188 exports: PackageLockExports::default(),
189 permissions: Vec::new(),
190 host_requirements: Vec::new(),
191 })
192 .collect(),
193 };
194 lock.sort_entries();
195 Ok(Some(lock))
196 }
197 }
198 }
199
200 fn save(&self, path: &Path) -> Result<(), PackageError> {
201 let mut normalized = self.clone();
202 normalized.version = LOCK_FILE_VERSION;
203 normalized.generator_version = current_generator_version();
204 normalized.protocol_artifact_version = current_protocol_artifact_version();
205 normalized.sort_entries();
206 let body = toml::to_string_pretty(&normalized)
207 .map_err(|error| format!("failed to encode {}: {error}", path.display()))?;
208 let mut out = String::from("# This file is auto-generated by Harn. Do not edit.\n\n");
209 out.push_str(&body);
210 harn_vm::atomic_io::atomic_write(path, out.as_bytes()).map_err(|error| {
211 PackageError::Lockfile(format!("failed to write {}: {error}", path.display()))
212 })
213 }
214
215 pub(crate) fn sort_entries(&mut self) {
216 self.packages
217 .sort_by(|left, right| left.name.cmp(&right.name));
218 }
219
220 pub(crate) fn find(&self, name: &str) -> Option<&LockEntry> {
221 self.packages.iter().find(|entry| entry.name == name)
222 }
223
224 fn replace(&mut self, entry: LockEntry) {
225 if let Some(existing) = self.packages.iter_mut().find(|pkg| pkg.name == entry.name) {
226 *existing = entry;
227 } else {
228 self.packages.push(entry);
229 }
230 self.sort_entries();
231 }
232
233 fn remove(&mut self, name: &str) {
234 self.packages.retain(|entry| entry.name != name);
235 }
236}
237
238#[derive(Debug, Deserialize)]
239struct RawVersionedFile {
240 version: u32,
241}
242
243#[derive(Debug, Deserialize)]
244pub(crate) struct LegacyLockFile {
245 #[serde(default, rename = "package")]
246 packages: Vec<LegacyLockEntry>,
247}
248
249#[derive(Debug, Deserialize)]
250pub(crate) struct LegacyLockEntry {
251 pub(crate) name: String,
252 #[serde(default)]
253 git: Option<String>,
254 #[serde(default)]
255 tag: Option<String>,
256 #[serde(default)]
257 pub(crate) rev_request: Option<String>,
258 #[serde(default)]
259 pub(crate) commit: Option<String>,
260 #[serde(default)]
261 path: Option<String>,
262}
263
264pub(crate) fn compatible_locked_entry(
265 workspace: &PackageWorkspace,
266 alias: &str,
267 dependency: &Dependency,
268 lock: &LockEntry,
269 manifest_dir: &Path,
270) -> Result<bool, PackageError> {
271 if lock.name != alias {
272 return Ok(false);
273 }
274 if let Some(path) = dependency.local_path() {
275 let source = path_source_uri(&resolve_path_dependency_source(manifest_dir, path)?)?;
276 return Ok(lock.source == source);
277 }
278 if let Some(requirement) = dependency.version() {
279 let Dependency::Table(table) = dependency else {
280 return Ok(false);
281 };
282 let Some(registry) = lock.registry.as_ref() else {
283 return Ok(false);
284 };
285 let registry_name = table.registry_name.as_deref().unwrap_or(alias);
286 if registry.name != registry_name {
287 return Ok(false);
288 }
289 let expected_source = workspace.resolve_registry_source(table.registry.as_deref())?;
290 if registry.source != expected_source {
291 return Ok(false);
292 }
293 let version = parse_registry_semver(®istry.version)?;
294 let req = parse_registry_version_req(requirement)?;
295 return Ok(req.matches(&version)
296 && lock.source.starts_with("git+")
297 && lock.commit.is_some()
298 && lock.content_hash.is_some());
299 }
300 if let Some(url) = dependency.git_url() {
301 let source = format!("git+{}", normalize_git_url(url)?);
302 let requested = dependency_git_request(dependency).map(str::to_string);
303 return Ok(lock.source == source
304 && lock.rev_request == requested
305 && lock.tag == dependency.tag().map(str::to_string)
306 && lock.commit.is_some()
307 && lock.content_hash.is_some());
308 }
309 Ok(false)
310}
311
312#[derive(Debug, Clone)]
313pub(crate) struct PendingDependency {
314 alias: String,
315 dependency: Dependency,
316 manifest_dir: PathBuf,
317 parent: Option<String>,
318 parent_is_git: bool,
319}
320
321pub(crate) fn git_rev_request(
322 alias: &str,
323 dependency: &Dependency,
324) -> Result<String, PackageError> {
325 dependency_git_request(dependency)
326 .map(str::to_string)
327 .ok_or_else(|| {
328 PackageError::Lockfile(format!(
329 "git dependency {alias} must specify `tag`, `rev`, or `branch`; use `harn add <url>@<tag-or-sha>` or add `tag = \"...\"` to {MANIFEST}"
330 ))
331 })
332}
333
334pub(crate) fn dependency_git_request(dependency: &Dependency) -> Option<&str> {
335 dependency
336 .branch()
337 .or_else(|| dependency.rev())
338 .or_else(|| dependency.tag())
339}
340
341pub(crate) fn dependency_manifest_dir(source: &Path) -> Option<PathBuf> {
342 if source.is_dir() {
343 return Some(source.to_path_buf());
344 }
345 source.parent().map(Path::to_path_buf)
346}
347
348pub(crate) fn read_package_manifest_from_dir(dir: &Path) -> Result<Option<Manifest>, PackageError> {
349 let manifest_path = dir.join(MANIFEST);
350 if !manifest_path.exists() {
351 return Ok(None);
352 }
353 read_manifest_from_path(&manifest_path).map(Some)
354}
355
356#[derive(Debug, Clone, Default)]
359pub(crate) struct LockEntryProvenance {
360 pub(crate) package_version: Option<String>,
361 pub(crate) harn_compat: Option<String>,
362 pub(crate) provenance: Option<String>,
363 pub(crate) manifest_digest: Option<String>,
364 pub(crate) exports: PackageLockExports,
365 pub(crate) permissions: Vec<String>,
366 pub(crate) host_requirements: Vec<String>,
367}
368
369pub(crate) fn read_lock_entry_provenance(
370 package_dir: &Path,
371) -> Result<LockEntryProvenance, PackageError> {
372 let manifest_path = package_dir.join(MANIFEST);
373 if !manifest_path.exists() {
374 return Ok(LockEntryProvenance::default());
375 }
376 let bytes = fs::read(&manifest_path)
377 .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?;
378 let digest = format!("sha256:{}", sha256_hex(&bytes));
379 let manifest = read_manifest_from_path(&manifest_path)?;
380 let (package_version, harn_compat, provenance, permissions, host_requirements) = manifest
381 .package
382 .as_ref()
383 .map(|info| {
384 (
385 info.version.clone(),
386 info.harn.clone(),
387 info.provenance.clone(),
388 info.permissions.clone(),
389 info.host_requirements.clone(),
390 )
391 })
392 .unwrap_or((None, None, None, Vec::new(), Vec::new()));
393 Ok(LockEntryProvenance {
394 package_version,
395 harn_compat,
396 provenance,
397 manifest_digest: Some(digest),
398 exports: package_lock_exports_from_manifest(&manifest),
399 permissions: normalized_requirements(&permissions),
400 host_requirements: normalized_requirements(&host_requirements),
401 })
402}
403
404fn fill_provenance(entry: &mut LockEntry, provenance: LockEntryProvenance) {
405 entry.package_version = provenance.package_version;
406 entry.harn_compat = provenance.harn_compat;
407 entry.provenance = provenance.provenance;
408 entry.manifest_digest = provenance.manifest_digest;
409 entry.exports = provenance.exports;
410 entry.permissions = provenance.permissions;
411 entry.host_requirements = provenance.host_requirements;
412}
413
414pub(crate) fn package_lock_exports_from_manifest(manifest: &Manifest) -> PackageLockExports {
415 let mut modules: Vec<PackageLockExport> = manifest
416 .exports
417 .iter()
418 .map(|(name, path)| PackageLockExport {
419 name: name.clone(),
420 path: Some(path.clone()),
421 symbol: None,
422 })
423 .collect();
424 modules.sort_by(|left, right| left.name.cmp(&right.name));
425
426 let (mut tools, mut skills) = manifest
427 .package
428 .as_ref()
429 .map(|package| {
430 let tools = package
431 .tools
432 .iter()
433 .map(|tool| PackageLockExport {
434 name: tool.name.clone(),
435 path: Some(tool.module.clone()),
436 symbol: Some(tool.symbol.clone()),
437 })
438 .collect::<Vec<_>>();
439 let skills = package
440 .skills
441 .iter()
442 .map(|skill| PackageLockExport {
443 name: skill.name.clone(),
444 path: Some(skill.path.clone()),
445 symbol: None,
446 })
447 .collect::<Vec<_>>();
448 (tools, skills)
449 })
450 .unwrap_or_default();
451 tools.sort_by(|left, right| left.name.cmp(&right.name));
452 skills.sort_by(|left, right| left.name.cmp(&right.name));
453
454 let mut personas: Vec<String> = manifest
455 .personas
456 .iter()
457 .filter_map(|persona| persona.name.clone())
458 .collect();
459 personas.sort();
460 personas.dedup();
461
462 PackageLockExports {
463 modules,
464 tools,
465 skills,
466 personas,
467 }
468}
469
470pub(crate) fn normalized_requirements(values: &[String]) -> Vec<String> {
471 let mut out: Vec<String> = values
472 .iter()
473 .map(|value| value.trim())
474 .filter(|value| !value.is_empty())
475 .map(str::to_string)
476 .collect();
477 out.sort();
478 out.dedup();
479 out
480}
481
482pub(crate) fn dependency_conflict_message(
483 existing: &LockEntry,
484 candidate: &LockEntry,
485) -> PackageError {
486 PackageError::Lockfile(format!(
487 "dependency alias '{}' resolves to multiple packages ({} and {}); use distinct aliases in {MANIFEST}",
488 candidate.name, existing.source, candidate.source
489 ))
490}
491
492pub(crate) fn replace_lock_entry(
493 lock: &mut LockFile,
494 candidate: LockEntry,
495) -> Result<bool, PackageError> {
496 validate_package_alias(&candidate.name)?;
497 if let Some(existing) = lock.find(&candidate.name) {
498 if existing == &candidate {
499 return Ok(false);
500 }
501 return Err(dependency_conflict_message(existing, &candidate));
502 }
503 lock.replace(candidate);
504 Ok(true)
505}
506
507pub(crate) fn enqueue_manifest_dependencies(
508 pending: &mut Vec<PendingDependency>,
509 manifest: Manifest,
510 manifest_dir: PathBuf,
511 parent: String,
512 parent_is_git: bool,
513) {
514 let mut aliases: Vec<String> = manifest.dependencies.keys().cloned().collect();
515 aliases.sort();
516 for alias in aliases.into_iter().rev() {
517 if let Some(dependency) = manifest.dependencies.get(&alias).cloned() {
518 pending.push(PendingDependency {
519 alias,
520 dependency,
521 manifest_dir: manifest_dir.clone(),
522 parent: Some(parent.clone()),
523 parent_is_git,
524 });
525 }
526 }
527}
528
529fn resolve_registry_version_dependency(
530 workspace: &PackageWorkspace,
531 alias: &str,
532 dependency: Dependency,
533) -> Result<Dependency, PackageError> {
534 let Dependency::Table(table) = &dependency else {
535 return Ok(dependency);
536 };
537 if table.version.is_none() {
538 return Ok(dependency);
539 }
540 if table.git.is_some()
541 || table.path.is_some()
542 || table.rev.is_some()
543 || table.tag.is_some()
544 || table.branch.is_some()
545 {
546 return Err(format!(
547 "dependency {alias} uses `version`; do not combine registry version constraints with git, path, tag, rev, or branch"
548 )
549 .into());
550 }
551 registry_dependency_from_manifest_constraint_in(workspace, alias, table)
552}
553
554pub(crate) fn build_lockfile(
555 workspace: &PackageWorkspace,
556 ctx: &ManifestContext,
557 existing: Option<&LockFile>,
558 refresh_alias: Option<&str>,
559 refresh_all: bool,
560 allow_resolve: bool,
561 offline: bool,
562) -> Result<LockFile, PackageError> {
563 if manifest_has_git_dependencies(&ctx.manifest) {
564 ensure_git_available()?;
565 }
566
567 let mut lock = LockFile::default();
568 let mut pending: Vec<PendingDependency> = Vec::new();
569 let mut aliases: Vec<String> = ctx.manifest.dependencies.keys().cloned().collect();
570 aliases.sort();
571 for alias in aliases.into_iter().rev() {
572 let dependency = ctx
573 .manifest
574 .dependencies
575 .get(&alias)
576 .ok_or_else(|| format!("dependency {alias} disappeared while locking"))?
577 .clone();
578 pending.push(PendingDependency {
579 alias,
580 dependency,
581 manifest_dir: ctx.dir.clone(),
582 parent: None,
583 parent_is_git: false,
584 });
585 }
586
587 while let Some(next) = pending.pop() {
588 let alias = next.alias;
589 validate_package_alias(&alias)?;
590 let dependency = next.dependency;
591 if dependency.local_path().is_some() && next.parent_is_git {
592 let parent = next.parent.as_deref().unwrap_or("a git package");
593 return Err(format!(
594 "package {parent} declares local path dependency {alias}, but path dependencies are not supported inside git-installed packages; publish {alias} as a git dependency with `tag`, `rev`, or `version`"
595 ).into());
596 }
597 if dependency.requires_git() {
598 ensure_git_available()?;
599 if dependency.git_url().is_some() {
600 git_rev_request(&alias, &dependency)?;
601 }
602 }
603 let refresh = refresh_all || refresh_alias == Some(alias.as_str());
604 if let Some(existing_lock) = existing.and_then(|lock| lock.find(&alias)) {
605 if !refresh
606 && compatible_locked_entry(
607 workspace,
608 &alias,
609 &dependency,
610 existing_lock,
611 &next.manifest_dir,
612 )?
613 {
614 let mut entry = existing_lock.clone();
615 if entry.source.starts_with("git+") && entry.content_hash.is_none() {
616 let url = entry.source.trim_start_matches("git+");
617 let commit = entry
618 .commit
619 .as_deref()
620 .ok_or_else(|| format!("missing locked commit for {alias}"))?;
621 entry.content_hash = Some(ensure_git_cache_populated_in(
622 workspace,
623 url,
624 &entry.source,
625 commit,
626 None,
627 false,
628 offline,
629 )?);
630 }
631 if entry.source.starts_with("git+") {
632 let url = entry.source.trim_start_matches("git+");
633 let commit = entry
634 .commit
635 .as_deref()
636 .ok_or_else(|| format!("missing locked commit for {alias}"))?;
637 let expected_hash = entry
638 .content_hash
639 .as_deref()
640 .ok_or_else(|| format!("missing content hash for {alias}"))?;
641 ensure_git_cache_populated_in(
642 workspace,
643 url,
644 &entry.source,
645 commit,
646 Some(expected_hash),
647 false,
648 offline,
649 )?;
650 let cache_dir = git_cache_dir_in(workspace, &entry.source, commit)?;
651 if entry.manifest_digest.is_none()
652 || entry.package_version.is_none()
653 || entry.provenance.is_none()
654 {
655 fill_provenance(&mut entry, read_lock_entry_provenance(&cache_dir)?);
656 }
657 if entry.registry.is_none() {
658 entry.registry = dependency.registry_provenance();
659 }
660 let inserted = replace_lock_entry(&mut lock, entry.clone())?;
661 if inserted {
662 if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
663 enqueue_manifest_dependencies(
664 &mut pending,
665 manifest,
666 cache_dir,
667 alias,
668 true,
669 );
670 }
671 }
672 } else if entry.source.starts_with("path+") {
673 let source = path_from_source_uri(&entry.source)?;
674 let manifest_dir = dependency_manifest_dir(&source);
675 if entry.manifest_digest.is_none()
676 || entry.package_version.is_none()
677 || entry.provenance.is_none()
678 {
679 if let Some(dir) = manifest_dir.as_deref() {
680 fill_provenance(&mut entry, read_lock_entry_provenance(dir)?);
681 }
682 }
683 let inserted = replace_lock_entry(&mut lock, entry.clone())?;
684 if inserted {
685 if let Some(manifest_dir) = manifest_dir {
686 if let Some(manifest) = read_package_manifest_from_dir(&manifest_dir)? {
687 enqueue_manifest_dependencies(
688 &mut pending,
689 manifest,
690 manifest_dir,
691 alias,
692 false,
693 );
694 }
695 }
696 }
697 } else {
698 replace_lock_entry(&mut lock, entry)?;
699 }
700 continue;
701 }
702 }
703
704 if !allow_resolve {
705 return Err(format!("{} would need to change", ctx.lock_path().display()).into());
706 }
707
708 let dependency = resolve_registry_version_dependency(workspace, &alias, dependency)?;
709
710 if let Some(path) = dependency.local_path() {
711 let source = resolve_path_dependency_source(&next.manifest_dir, path)?;
712 let package_alias = alias.clone();
713 let manifest_dir = dependency_manifest_dir(&source);
714 let provenance = manifest_dir
715 .as_deref()
716 .map(read_lock_entry_provenance)
717 .transpose()?
718 .unwrap_or_default();
719 let mut entry = LockEntry {
720 name: alias.clone(),
721 source: path_source_uri(&source)?,
722 tag: None,
723 rev_request: None,
724 commit: None,
725 content_hash: None,
726 package_version: None,
727 harn_compat: None,
728 provenance: None,
729 manifest_digest: None,
730 registry: None,
731 exports: PackageLockExports::default(),
732 permissions: Vec::new(),
733 host_requirements: Vec::new(),
734 };
735 fill_provenance(&mut entry, provenance);
736 let inserted = replace_lock_entry(&mut lock, entry)?;
737 if inserted {
738 if let Some(manifest_dir) = manifest_dir {
739 if let Some(manifest) = read_package_manifest_from_dir(&manifest_dir)? {
740 enqueue_manifest_dependencies(
741 &mut pending,
742 manifest,
743 manifest_dir,
744 package_alias,
745 false,
746 );
747 }
748 }
749 }
750 continue;
751 }
752
753 if let Some(url) = dependency.git_url() {
754 let rev_request = git_rev_request(&alias, &dependency)?;
755 let normalized_url = normalize_git_url(url)?;
756 let source = format!("git+{normalized_url}");
757 let commit = resolve_git_commit(
758 &normalized_url,
759 dependency.rev(),
760 dependency.tag(),
761 dependency.branch(),
762 )?;
763 let content_hash = ensure_git_cache_populated_in(
764 workspace,
765 &normalized_url,
766 &source,
767 &commit,
768 None,
769 false,
770 offline,
771 )?;
772 let cache_dir = git_cache_dir_in(workspace, &source, &commit)?;
773 let provenance = read_lock_entry_provenance(&cache_dir)?;
774 let mut entry = LockEntry {
775 name: alias.clone(),
776 source: source.clone(),
777 tag: dependency.tag().map(str::to_string),
778 rev_request: Some(rev_request),
779 commit: Some(commit.clone()),
780 content_hash: Some(content_hash),
781 package_version: None,
782 harn_compat: None,
783 provenance: None,
784 manifest_digest: None,
785 registry: dependency.registry_provenance(),
786 exports: PackageLockExports::default(),
787 permissions: Vec::new(),
788 host_requirements: Vec::new(),
789 };
790 fill_provenance(&mut entry, provenance);
791 let inserted = replace_lock_entry(&mut lock, entry)?;
792 if inserted {
793 if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
794 enqueue_manifest_dependencies(&mut pending, manifest, cache_dir, alias, true);
795 }
796 }
797 continue;
798 }
799
800 return Err(format!("dependency {alias} is missing a git or path source").into());
801 }
802 Ok(lock)
803}
804
805pub(crate) fn materialize_dependencies_from_lock(
806 workspace: &PackageWorkspace,
807 ctx: &ManifestContext,
808 lock: &LockFile,
809 refetch: Option<&str>,
810 offline: bool,
811) -> Result<usize, PackageError> {
812 let packages_dir = ctx.packages_dir();
813 fs::create_dir_all(&packages_dir)
814 .map_err(|error| format!("failed to create {}: {error}", packages_dir.display()))?;
815
816 let mut installed = 0usize;
817 for entry in &lock.packages {
818 let alias = &entry.name;
819 validate_package_alias(alias)?;
820 if entry.source.starts_with("path+") {
821 let source = path_from_source_uri(&entry.source)?;
822 materialize_path_dependency(&source, &packages_dir, alias)?;
823 installed += 1;
824 continue;
825 }
826
827 let commit = entry
828 .commit
829 .as_deref()
830 .ok_or_else(|| format!("missing locked commit for {alias}"))?;
831 let expected_hash = entry
832 .content_hash
833 .as_deref()
834 .ok_or_else(|| format!("missing content hash for {alias}"))?;
835 let source = entry.source.clone();
836 let url = source.trim_start_matches("git+");
837 let refetch_this = refetch == Some("all") || refetch == Some(alias.as_str());
838 ensure_git_cache_populated_in(
839 workspace,
840 url,
841 &source,
842 commit,
843 Some(expected_hash),
844 refetch_this,
845 offline,
846 )?;
847 let cache_dir = git_cache_dir_in(workspace, &source, commit)?;
848 let dest_dir = packages_dir.join(alias);
849 if !dest_dir.exists() || !materialized_hash_matches(&dest_dir, expected_hash) {
850 remove_materialized_package(&packages_dir, alias)?;
851 copy_dir_recursive(&cache_dir, &dest_dir)?;
852 write_cached_content_hash(&dest_dir, expected_hash)?;
853 }
854 installed += 1;
855 }
856 Ok(installed)
857}
858
859pub(crate) fn validate_lock_matches_manifest(
860 workspace: &PackageWorkspace,
861 ctx: &ManifestContext,
862 lock: &LockFile,
863) -> Result<(), PackageError> {
864 for (alias, dependency) in &ctx.manifest.dependencies {
865 validate_package_alias(alias)?;
866 let entry = lock.find(alias).ok_or_else(|| {
867 format!(
868 "{} is missing an entry for {alias}",
869 ctx.lock_path().display()
870 )
871 })?;
872 if !compatible_locked_entry(workspace, alias, dependency, entry, &ctx.dir)? {
873 return Err(format!(
874 "{} is out of date for {alias}; run `harn install`",
875 ctx.lock_path().display()
876 )
877 .into());
878 }
879 }
880 Ok(())
881}
882
883pub fn ensure_dependencies_materialized(anchor: &Path) -> Result<(), PackageError> {
884 let Some((manifest, dir)) = find_nearest_manifest(anchor) else {
885 return Ok(());
886 };
887 if manifest.dependencies.is_empty() {
888 return Ok(());
889 }
890 let ctx = ManifestContext { manifest, dir };
891 let lock = LockFile::load(&ctx.lock_path())?.ok_or_else(|| {
892 format!(
893 "{} is missing; run `harn install`",
894 ctx.lock_path().display()
895 )
896 })?;
897 let workspace = PackageWorkspace::from_current_dir()?;
898 validate_lock_matches_manifest(&workspace, &ctx, &lock)?;
899 materialize_dependencies_from_lock(&workspace, &ctx, &lock, None, false)?;
900 Ok(())
901}
902
903pub(crate) fn dependency_section_bounds(lines: &[String]) -> Option<(usize, usize)> {
904 let start = lines
905 .iter()
906 .position(|line| line.trim() == "[dependencies]")?;
907 let end = lines
908 .iter()
909 .enumerate()
910 .skip(start + 1)
911 .find(|(_, line)| line.trim_start().starts_with('['))
912 .map(|(index, _)| index)
913 .unwrap_or(lines.len());
914 Some((start, end))
915}
916
917pub(crate) fn render_dependency_line(
918 alias: &str,
919 dependency: &Dependency,
920) -> Result<String, PackageError> {
921 validate_package_alias(alias)?;
922 match dependency {
923 Dependency::Path(path) => Ok(format!(
924 "{alias} = {{ path = {} }}",
925 toml_string_literal(path)?
926 )),
927 Dependency::Table(table) => {
928 let mut fields = Vec::new();
929 if let Some(path) = table.path.as_deref() {
930 fields.push(format!("path = {}", toml_string_literal(path)?));
931 }
932 if let Some(git) = table.git.as_deref() {
933 fields.push(format!("git = {}", toml_string_literal(git)?));
934 }
935 if let Some(branch) = table.branch.as_deref() {
936 fields.push(format!("branch = {}", toml_string_literal(branch)?));
937 } else if let Some(tag) = table.tag.as_deref() {
938 fields.push(format!("tag = {}", toml_string_literal(tag)?));
939 } else if let Some(rev) = table.rev.as_deref() {
940 fields.push(format!("rev = {}", toml_string_literal(rev)?));
941 }
942 if let Some(version) = table.version.as_deref() {
943 fields.push(format!("version = {}", toml_string_literal(version)?));
944 }
945 if let Some(package) = table.package.as_deref() {
946 fields.push(format!("package = {}", toml_string_literal(package)?));
947 }
948 if let Some(registry) = table.registry.as_deref() {
949 fields.push(format!("registry = {}", toml_string_literal(registry)?));
950 }
951 if let Some(name) = table.registry_name.as_deref() {
952 fields.push(format!("registry_name = {}", toml_string_literal(name)?));
953 }
954 if let Some(version) = table.registry_version.as_deref() {
955 fields.push(format!(
956 "registry_version = {}",
957 toml_string_literal(version)?
958 ));
959 }
960 Ok(format!("{alias} = {{ {} }}", fields.join(", ")))
961 }
962 }
963}
964
965pub(crate) fn ensure_manifest_exists(manifest_path: &Path) -> Result<String, PackageError> {
966 if manifest_path.exists() {
967 return fs::read_to_string(manifest_path).map_err(|error| {
968 PackageError::Lockfile(format!(
969 "failed to read {}: {error}",
970 manifest_path.display()
971 ))
972 });
973 }
974 Ok("[package]\nname = \"my-project\"\nversion = \"0.1.0\"\n".to_string())
975}
976
977pub(crate) fn upsert_dependency_in_manifest(
978 manifest_path: &Path,
979 alias: &str,
980 dependency: &Dependency,
981) -> Result<(), PackageError> {
982 let content = ensure_manifest_exists(manifest_path)?;
983 let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
984 if dependency_section_bounds(&lines).is_none() {
985 if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) {
986 lines.push(String::new());
987 }
988 lines.push("[dependencies]".to_string());
989 }
990 let (start, end) = dependency_section_bounds(&lines).ok_or_else(|| {
991 format!(
992 "failed to locate [dependencies] in {}",
993 manifest_path.display()
994 )
995 })?;
996 let rendered = render_dependency_line(alias, dependency)?;
997 if let Some((index, _)) = lines
998 .iter()
999 .enumerate()
1000 .skip(start + 1)
1001 .take(end - start - 1)
1002 .find(|(_, line)| {
1003 line.split('=')
1004 .next()
1005 .is_some_and(|key| key.trim() == alias)
1006 })
1007 {
1008 lines[index] = rendered;
1009 } else {
1010 lines.insert(end, rendered);
1011 }
1012 write_manifest_content(manifest_path, &(lines.join("\n") + "\n"))
1013}
1014
1015pub(crate) fn remove_dependency_from_manifest(
1016 manifest_path: &Path,
1017 alias: &str,
1018) -> Result<bool, PackageError> {
1019 let content = fs::read_to_string(manifest_path)
1020 .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?;
1021 let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
1022 let Some((start, end)) = dependency_section_bounds(&lines) else {
1023 return Ok(false);
1024 };
1025 let mut removed = false;
1026 lines = lines
1027 .into_iter()
1028 .enumerate()
1029 .filter_map(|(index, line)| {
1030 if index <= start || index >= end {
1031 return Some(line);
1032 }
1033 let matches = line
1034 .split('=')
1035 .next()
1036 .is_some_and(|key| key.trim() == alias);
1037 if matches {
1038 removed = true;
1039 None
1040 } else {
1041 Some(line)
1042 }
1043 })
1044 .collect();
1045 if removed {
1046 write_manifest_content(manifest_path, &(lines.join("\n") + "\n"))?;
1047 }
1048 Ok(removed)
1049}
1050
1051pub(crate) fn install_packages_impl(
1052 frozen: bool,
1053 refetch: Option<&str>,
1054 offline: bool,
1055) -> Result<usize, PackageError> {
1056 install_packages_in(
1057 &PackageWorkspace::from_current_dir()?,
1058 frozen,
1059 refetch,
1060 offline,
1061 )
1062}
1063
1064pub(crate) fn install_packages_in(
1065 workspace: &PackageWorkspace,
1066 frozen: bool,
1067 refetch: Option<&str>,
1068 offline: bool,
1069) -> Result<usize, PackageError> {
1070 let ctx = workspace.load_manifest_context()?;
1071 let existing = LockFile::load(&ctx.lock_path())?;
1072 if ctx.manifest.dependencies.is_empty() {
1073 if !frozen {
1074 LockFile::default().save(&ctx.lock_path())?;
1075 }
1076 return Ok(0);
1077 }
1078
1079 if (frozen || offline) && existing.is_none() {
1080 return Err(format!("{} is missing", ctx.lock_path().display()).into());
1081 }
1082
1083 let desired = build_lockfile(
1084 workspace,
1085 &ctx,
1086 existing.as_ref(),
1087 None,
1088 false,
1089 !frozen && !offline,
1090 offline,
1091 )?;
1092 if frozen || offline {
1093 if existing.as_ref() != Some(&desired) {
1094 return Err(format!("{} would need to change", ctx.lock_path().display()).into());
1095 }
1096 } else {
1097 desired.save(&ctx.lock_path())?;
1098 }
1099 materialize_dependencies_from_lock(workspace, &ctx, &desired, refetch, offline)
1100}
1101
1102pub fn install_packages(frozen: bool, refetch: Option<&str>, offline: bool, json: bool) {
1103 match install_packages_impl(frozen, refetch, offline) {
1104 Ok(installed) if json => {
1105 print_install_summary_json("install", installed, frozen, offline);
1106 }
1107 Ok(0) => println!("No dependencies to install."),
1108 Ok(installed) => println!("Installed {installed} package(s) to {PKG_DIR}/"),
1109 Err(error) if json => {
1110 print_install_error_json("install", &error);
1111 process::exit(1);
1112 }
1113 Err(error) => {
1114 eprintln!("error: {error}");
1115 process::exit(1);
1116 }
1117 }
1118}
1119
1120fn print_install_summary_json(action: &str, installed: usize, frozen: bool, offline: bool) {
1121 let body = serde_json::json!({
1122 "action": action,
1123 "ok": true,
1124 "installed": installed,
1125 "frozen": frozen,
1126 "offline": offline,
1127 "lock_file": LOCK_FILE,
1128 "packages_dir": PKG_DIR,
1129 });
1130 println!(
1131 "{}",
1132 serde_json::to_string_pretty(&body).unwrap_or_default()
1133 );
1134}
1135
1136fn print_install_error_json(action: &str, error: &PackageError) {
1137 let body = serde_json::json!({
1138 "action": action,
1139 "ok": false,
1140 "error": error.to_string(),
1141 });
1142 println!(
1143 "{}",
1144 serde_json::to_string_pretty(&body).unwrap_or_default()
1145 );
1146}
1147
1148pub fn lock_packages() {
1149 let result = (|| -> Result<usize, PackageError> {
1150 let workspace = PackageWorkspace::from_current_dir()?;
1151 let ctx = workspace.load_manifest_context()?;
1152 let existing = LockFile::load(&ctx.lock_path())?;
1153 let lock = build_lockfile(&workspace, &ctx, existing.as_ref(), None, true, true, false)?;
1154 lock.save(&ctx.lock_path())?;
1155 Ok(lock.packages.len())
1156 })();
1157
1158 match result {
1159 Ok(count) => println!("Wrote {} with {count} package(s).", LOCK_FILE),
1160 Err(error) => {
1161 eprintln!("error: {error}");
1162 process::exit(1);
1163 }
1164 }
1165}
1166
1167pub fn update_packages(alias: Option<&str>, all: bool, json: bool) {
1168 let result = PackageWorkspace::from_current_dir()
1169 .and_then(|workspace| update_packages_in(&workspace, alias, all));
1170 print_update_packages_result(result, json);
1171}
1172
1173pub(crate) fn update_packages_in(
1174 workspace: &PackageWorkspace,
1175 alias: Option<&str>,
1176 all: bool,
1177) -> Result<usize, PackageError> {
1178 if !all && alias.is_none() {
1179 return Err("specify a dependency alias or pass --all"
1180 .to_string()
1181 .into());
1182 }
1183
1184 let ctx = workspace.load_manifest_context()?;
1185 if let Some(alias) = alias {
1186 validate_package_alias(alias)?;
1187 if !ctx.manifest.dependencies.contains_key(alias) {
1188 return Err(format!("{alias} is not present in [dependencies]").into());
1189 }
1190 }
1191 let existing = LockFile::load(&ctx.lock_path())?;
1192 let lock = build_lockfile(workspace, &ctx, existing.as_ref(), alias, all, true, false)?;
1193 lock.save(&ctx.lock_path())?;
1194 materialize_dependencies_from_lock(workspace, &ctx, &lock, None, false)
1195}
1196
1197fn print_update_packages_result(result: Result<usize, PackageError>, json: bool) {
1198 match result {
1199 Ok(installed) if json => print_install_summary_json("update", installed, false, false),
1200 Ok(installed) => println!("Updated {installed} package(s)."),
1201 Err(error) if json => {
1202 print_install_error_json("update", &error);
1203 process::exit(1);
1204 }
1205 Err(error) => {
1206 eprintln!("error: {error}");
1207 process::exit(1);
1208 }
1209 }
1210}
1211
1212pub fn remove_package(alias: &str) {
1213 let result = PackageWorkspace::from_current_dir()
1214 .and_then(|workspace| remove_package_in(&workspace, alias));
1215 print_remove_package_result(alias, result);
1216}
1217
1218pub(crate) fn remove_package_in(
1219 workspace: &PackageWorkspace,
1220 alias: &str,
1221) -> Result<bool, PackageError> {
1222 validate_package_alias(alias)?;
1223 let ctx = workspace.load_manifest_context()?;
1224 let removed = remove_dependency_from_manifest(&ctx.manifest_path(), alias)?;
1225 if !removed {
1226 return Ok(false);
1227 }
1228 let mut lock = LockFile::load(&ctx.lock_path())?.unwrap_or_default();
1229 lock.remove(alias);
1230 lock.save(&ctx.lock_path())?;
1231 remove_materialized_package(&ctx.packages_dir(), alias)?;
1232 Ok(true)
1233}
1234
1235fn print_remove_package_result(alias: &str, result: Result<bool, PackageError>) {
1236 match result {
1237 Ok(true) => println!("Removed {alias} from {MANIFEST} and {LOCK_FILE}."),
1238 Ok(false) => {
1239 eprintln!("error: {alias} is not present in [dependencies]");
1240 process::exit(1);
1241 }
1242 Err(error) => {
1243 eprintln!("error: {error}");
1244 process::exit(1);
1245 }
1246 }
1247}
1248
1249#[derive(Clone, Copy, Debug)]
1250pub(crate) struct AddPackageRequest<'a> {
1251 name_or_spec: &'a str,
1252 alias: Option<&'a str>,
1253 git_url: Option<&'a str>,
1254 tag: Option<&'a str>,
1255 rev: Option<&'a str>,
1256 branch: Option<&'a str>,
1257 local_path: Option<&'a str>,
1258 registry: Option<&'a str>,
1259}
1260
1261#[cfg(test)]
1262#[allow(clippy::too_many_arguments)]
1263pub(crate) fn normalize_add_request(
1264 name_or_spec: &str,
1265 alias: Option<&str>,
1266 git_url: Option<&str>,
1267 tag: Option<&str>,
1268 rev: Option<&str>,
1269 branch: Option<&str>,
1270 local_path: Option<&str>,
1271 registry: Option<&str>,
1272) -> Result<(String, Dependency), PackageError> {
1273 normalize_add_request_in(
1274 &PackageWorkspace::from_current_dir()?,
1275 AddPackageRequest {
1276 name_or_spec,
1277 alias,
1278 git_url,
1279 tag,
1280 rev,
1281 branch,
1282 local_path,
1283 registry,
1284 },
1285 )
1286}
1287
1288pub(crate) fn normalize_add_request_in(
1289 workspace: &PackageWorkspace,
1290 request: AddPackageRequest<'_>,
1291) -> Result<(String, Dependency), PackageError> {
1292 let AddPackageRequest {
1293 name_or_spec,
1294 alias,
1295 git_url,
1296 tag,
1297 rev,
1298 branch,
1299 local_path,
1300 registry,
1301 } = request;
1302
1303 if local_path.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
1304 return Err("path dependencies do not accept --rev, --tag, or --branch"
1305 .to_string()
1306 .into());
1307 }
1308 if git_url.is_none()
1309 && local_path.is_none()
1310 && rev.is_none()
1311 && tag.is_none()
1312 && branch.is_none()
1313 {
1314 if let Some(path) = existing_local_path_spec(name_or_spec) {
1315 let alias = alias
1316 .map(str::to_string)
1317 .map(Ok)
1318 .unwrap_or_else(|| derive_package_alias_from_path(&path))?;
1319 validate_package_alias(&alias)?;
1320 return Ok((
1321 alias,
1322 Dependency::Table(Box::new(DepTable {
1323 path: Some(name_or_spec.to_string()),
1324 ..DepTable::default()
1325 })),
1326 ));
1327 }
1328 if parse_registry_package_spec(name_or_spec).is_some() {
1329 return registry_dependency_from_spec_in(workspace, name_or_spec, alias, registry);
1330 }
1331 }
1332 if git_url.is_some() || local_path.is_some() {
1333 if let Some(path) = local_path {
1334 let alias = alias
1335 .map(str::to_string)
1336 .unwrap_or_else(|| name_or_spec.to_string());
1337 validate_package_alias(&alias)?;
1338 return Ok((
1339 alias,
1340 Dependency::Table(Box::new(DepTable {
1341 path: Some(path.to_string()),
1342 ..DepTable::default()
1343 })),
1344 ));
1345 }
1346 let alias = alias.unwrap_or(name_or_spec).to_string();
1347 validate_package_alias(&alias)?;
1348 if rev.is_some() && tag.is_some() {
1349 return Err("use only one of --rev or --tag".to_string().into());
1350 }
1351 if rev.is_none() && tag.is_none() && branch.is_none() {
1352 return Err(format!(
1353 "git dependency {alias} must specify `tag`, `rev`, or `branch`; use `harn add <url>@<tag-or-sha>` or pass `--tag`/`--rev`/`--branch`"
1354 ).into());
1355 }
1356 let git = normalize_git_url(git_url.ok_or_else(|| "missing --git URL".to_string())?)?;
1357 let package_name = derive_repo_name_from_source(&git)?;
1358 return Ok((
1359 alias.clone(),
1360 Dependency::Table(Box::new(DepTable {
1361 git: Some(git),
1362 tag: tag.map(str::to_string),
1363 rev: rev.map(str::to_string),
1364 branch: branch.map(str::to_string),
1365 package: (alias != package_name).then_some(package_name),
1366 ..DepTable::default()
1367 })),
1368 ));
1369 }
1370
1371 if rev.is_some() && tag.is_some() {
1372 return Err("use only one of --rev or --tag".to_string().into());
1373 }
1374 let (raw_source, inline_ref) = parse_positional_git_spec(name_or_spec);
1375 if inline_ref.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
1376 return Err(
1377 "specify the git ref either inline as @ref or via --tag/--rev/--branch"
1378 .to_string()
1379 .into(),
1380 );
1381 }
1382 let git = normalize_git_url(raw_source)?;
1383 let package_name = derive_repo_name_from_source(&git)?;
1384 let alias = alias.unwrap_or(package_name.as_str()).to_string();
1385 validate_package_alias(&alias)?;
1386 if inline_ref.is_none() && rev.is_none() && tag.is_none() && branch.is_none() {
1387 return Err(format!(
1388 "git dependency {alias} must specify `tag`, `rev`, or `branch`; use `harn add {raw_source}@<tag-or-sha>` or pass `--tag`/`--rev`/`--branch`"
1389 ).into());
1390 }
1391 Ok((
1392 alias.clone(),
1393 Dependency::Table(Box::new(DepTable {
1394 git: Some(git),
1395 tag: tag.map(str::to_string),
1396 rev: inline_ref.or(rev).map(str::to_string),
1397 branch: branch.map(str::to_string),
1398 package: (alias != package_name).then_some(package_name),
1399 ..DepTable::default()
1400 })),
1401 ))
1402}
1403
1404#[cfg(test)]
1405pub fn add_package(
1406 name_or_spec: &str,
1407 alias: Option<&str>,
1408 git_url: Option<&str>,
1409 tag: Option<&str>,
1410 rev: Option<&str>,
1411 branch: Option<&str>,
1412 local_path: Option<&str>,
1413) {
1414 add_package_with_registry(
1415 name_or_spec,
1416 alias,
1417 git_url,
1418 tag,
1419 rev,
1420 branch,
1421 local_path,
1422 None,
1423 )
1424}
1425
1426pub fn add_package_with_registry(
1427 name_or_spec: &str,
1428 alias: Option<&str>,
1429 git_url: Option<&str>,
1430 tag: Option<&str>,
1431 rev: Option<&str>,
1432 branch: Option<&str>,
1433 local_path: Option<&str>,
1434 registry: Option<&str>,
1435) {
1436 let result = PackageWorkspace::from_current_dir().and_then(|workspace| {
1437 add_package_to(
1438 &workspace,
1439 name_or_spec,
1440 alias,
1441 git_url,
1442 tag,
1443 rev,
1444 branch,
1445 local_path,
1446 registry,
1447 )
1448 });
1449
1450 match result {
1451 Ok((alias, installed)) => {
1452 println!("Added {alias} to {MANIFEST}.");
1453 println!("Installed {installed} package(s).");
1454 }
1455 Err(error) => {
1456 eprintln!("error: {error}");
1457 process::exit(1);
1458 }
1459 }
1460}
1461
1462#[allow(clippy::too_many_arguments)]
1463pub(crate) fn add_package_to(
1464 workspace: &PackageWorkspace,
1465 name_or_spec: &str,
1466 alias: Option<&str>,
1467 git_url: Option<&str>,
1468 tag: Option<&str>,
1469 rev: Option<&str>,
1470 branch: Option<&str>,
1471 local_path: Option<&str>,
1472 registry: Option<&str>,
1473) -> Result<(String, usize), PackageError> {
1474 let manifest_path = workspace.manifest_dir().join(MANIFEST);
1475 let (alias, dependency) = normalize_add_request_in(
1476 workspace,
1477 AddPackageRequest {
1478 name_or_spec,
1479 alias,
1480 git_url,
1481 tag,
1482 rev,
1483 branch,
1484 local_path,
1485 registry,
1486 },
1487 )?;
1488 upsert_dependency_in_manifest(&manifest_path, &alias, &dependency)?;
1489 let installed = install_packages_in(workspace, false, None, false)?;
1490 Ok((alias, installed))
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495 use super::*;
1496 use crate::package::test_support::*;
1497
1498 #[test]
1499 fn lock_file_round_trips_typed_schema() {
1500 let tmp = tempfile::tempdir().unwrap();
1501 let path = tmp.path().join(LOCK_FILE);
1502 let lock = LockFile {
1503 version: LOCK_FILE_VERSION,
1504 generator_version: current_generator_version(),
1505 protocol_artifact_version: current_protocol_artifact_version(),
1506 packages: vec![LockEntry {
1507 name: "acme-lib".to_string(),
1508 source: "git+https://github.com/acme/acme-lib".to_string(),
1509 tag: Some("v1.0.0".to_string()),
1510 rev_request: Some("v1.0.0".to_string()),
1511 commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
1512 content_hash: Some("sha256:deadbeef".to_string()),
1513 package_version: Some("1.0.0".to_string()),
1514 harn_compat: Some(">=0.8,<0.9".to_string()),
1515 provenance: Some(
1516 "https://github.com/acme/acme-lib/releases/tag/v1.0.0".to_string(),
1517 ),
1518 manifest_digest: Some("sha256:cafebabe".to_string()),
1519 registry: None,
1520 exports: PackageLockExports {
1521 modules: vec![PackageLockExport {
1522 name: "lib".to_string(),
1523 path: Some("lib/main.harn".to_string()),
1524 symbol: None,
1525 }],
1526 tools: vec![PackageLockExport {
1527 name: "echo".to_string(),
1528 path: Some("lib/tools.harn".to_string()),
1529 symbol: Some("tools".to_string()),
1530 }],
1531 skills: Vec::new(),
1532 personas: Vec::new(),
1533 },
1534 permissions: vec!["tool:read_only".to_string()],
1535 host_requirements: vec!["workspace.read_text".to_string()],
1536 }],
1537 };
1538 lock.save(&path).unwrap();
1539 let loaded = LockFile::load(&path).unwrap().unwrap();
1540 assert_eq!(loaded, lock);
1541 }
1542
1543 #[test]
1544 fn add_and_remove_git_dependency_round_trip() {
1545 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1546 let project_tmp = tempfile::tempdir().unwrap();
1547 let root = project_tmp.path();
1548 let workspace = TestWorkspace::new(root);
1549 fs::create_dir_all(root.join(".git")).unwrap();
1550 fs::write(
1551 root.join(MANIFEST),
1552 r#"
1553 [package]
1554 name = "workspace"
1555 version = "0.1.0"
1556 "#,
1557 )
1558 .unwrap();
1559
1560 let spec = format!("{}@v1.0.0", repo.display());
1561 add_package_to(
1562 workspace.env(),
1563 &spec,
1564 None,
1565 None,
1566 None,
1567 None,
1568 None,
1569 None,
1570 None,
1571 )
1572 .unwrap();
1573
1574 let alias = "acme-lib";
1575 let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1576 assert!(manifest.contains("acme-lib"));
1577 assert!(manifest.contains("rev = \"v1.0.0\""));
1578
1579 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1580 let entry = lock.find(alias).unwrap();
1581 assert_eq!(lock.version, LOCK_FILE_VERSION);
1582 assert!(entry.source.starts_with("git+file://"));
1583 assert!(entry.commit.as_deref().is_some_and(is_full_git_sha));
1584 assert!(entry
1585 .content_hash
1586 .as_deref()
1587 .is_some_and(|hash| hash.starts_with("sha256:")));
1588 assert!(root.join(PKG_DIR).join(alias).join("lib.harn").is_file());
1589
1590 remove_package_in(workspace.env(), alias).unwrap();
1591 let updated_manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1592 assert!(!updated_manifest.contains("acme-lib ="));
1593 let updated_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1594 assert!(updated_lock.find(alias).is_none());
1595 assert!(!root.join(PKG_DIR).join(alias).exists());
1596 }
1597
1598 #[test]
1599 fn install_resolves_git_tag_dependency_and_records_tag() {
1600 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1601 let project_tmp = tempfile::tempdir().unwrap();
1602 let root = project_tmp.path();
1603 let workspace = TestWorkspace::new(root);
1604 fs::create_dir_all(root.join(".git")).unwrap();
1605 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1606 fs::write(
1607 root.join(MANIFEST),
1608 format!(
1609 r#"
1610 [package]
1611 name = "workspace"
1612 version = "0.1.0"
1613
1614 [dependencies]
1615 acme-lib = {{ git = "{git}", tag = "v1.0.0" }}
1616 "#
1617 ),
1618 )
1619 .unwrap();
1620
1621 let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
1622
1623 assert_eq!(installed, 1);
1624 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1625 let entry = lock.find("acme-lib").unwrap();
1626 assert_eq!(entry.tag.as_deref(), Some("v1.0.0"));
1627 assert_eq!(entry.rev_request.as_deref(), Some("v1.0.0"));
1628 assert!(entry.commit.as_deref().is_some_and(is_full_git_sha));
1629 assert!(entry.content_hash.as_deref().is_some());
1630 assert!(root
1631 .join(PKG_DIR)
1632 .join("acme-lib")
1633 .join("lib.harn")
1634 .is_file());
1635 }
1636
1637 #[test]
1638 fn install_resolves_registry_version_range_to_highest_matching_tag() {
1639 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1640 fs::write(
1641 repo.join("lib.harn"),
1642 "pub fn value() -> string { return \"v0.1.1\" }\n",
1643 )
1644 .unwrap();
1645 run_git(&repo, &["add", "."]);
1646 run_git(&repo, &["commit", "-m", "v0.1.1"]);
1647 run_git(&repo, &["tag", "v0.1.1"]);
1648 fs::write(
1649 repo.join("lib.harn"),
1650 "pub fn value() -> string { return \"v0.2.0\" }\n",
1651 )
1652 .unwrap();
1653 run_git(&repo, &["add", "."]);
1654 run_git(&repo, &["commit", "-m", "v0.2.0"]);
1655 run_git(&repo, &["tag", "v0.2.0"]);
1656
1657 let project_tmp = tempfile::tempdir().unwrap();
1658 let root = project_tmp.path();
1659 let registry_path = root.join("index.toml");
1660 let workspace =
1661 TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
1662 fs::create_dir_all(root.join(".git")).unwrap();
1663 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1664 fs::write(
1665 ®istry_path,
1666 format!(
1667 r#"
1668version = 1
1669
1670[[package]]
1671name = "acme-lib"
1672repository = "{git}"
1673
1674[[package.version]]
1675version = "0.1.0"
1676git = "{git}"
1677tag = "v1.0.0"
1678
1679[[package.version]]
1680version = "0.1.1"
1681git = "{git}"
1682tag = "v0.1.1"
1683
1684[[package.version]]
1685version = "0.2.0"
1686git = "{git}"
1687tag = "v0.2.0"
1688"#
1689 ),
1690 )
1691 .unwrap();
1692 fs::write(
1693 root.join(MANIFEST),
1694 r#"
1695 [package]
1696 name = "workspace"
1697 version = "0.1.0"
1698
1699 [dependencies]
1700 acme-lib = { version = ">=0.1,<0.2" }
1701 "#,
1702 )
1703 .unwrap();
1704
1705 let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
1706
1707 assert_eq!(installed, 1);
1708 let lock_path = root.join(LOCK_FILE);
1709 let lock = LockFile::load(&lock_path).unwrap().unwrap();
1710 let entry = lock.find("acme-lib").unwrap();
1711 assert_eq!(entry.tag.as_deref(), Some("v0.1.1"));
1712 assert_eq!(entry.rev_request.as_deref(), Some("v0.1.1"));
1713 assert_eq!(
1714 entry
1715 .registry
1716 .as_ref()
1717 .map(|registry| registry.version.as_str()),
1718 Some("0.1.1")
1719 );
1720 let source =
1721 fs::read_to_string(root.join(PKG_DIR).join("acme-lib").join("lib.harn")).unwrap();
1722 assert!(source.contains("v0.1.1"), "{source}");
1723
1724 let original_lock = fs::read_to_string(&lock_path).unwrap();
1725 fs::remove_dir_all(root.join(PKG_DIR)).unwrap();
1726 fs::remove_dir_all(&repo).unwrap();
1727 fs::remove_file(®istry_path).unwrap();
1728
1729 let reinstalled = install_packages_in(workspace.env(), true, None, true).unwrap();
1730 assert_eq!(reinstalled, 1);
1731 assert_eq!(fs::read_to_string(&lock_path).unwrap(), original_lock);
1732 assert!(root
1733 .join(PKG_DIR)
1734 .join("acme-lib")
1735 .join("lib.harn")
1736 .is_file());
1737 }
1738
1739 #[test]
1740 fn update_branch_dependency_refreshes_locked_commit() {
1741 let (_repo_tmp, repo, branch) = create_git_package_repo();
1742 let project_tmp = tempfile::tempdir().unwrap();
1743 let root = project_tmp.path();
1744 let workspace = TestWorkspace::new(root);
1745 fs::create_dir_all(root.join(".git")).unwrap();
1746 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1747 fs::write(
1748 root.join(MANIFEST),
1749 format!(
1750 r#"
1751 [package]
1752 name = "workspace"
1753 version = "0.1.0"
1754
1755 [dependencies]
1756 acme-lib = {{ git = "{git}", branch = "{branch}" }}
1757 "#
1758 ),
1759 )
1760 .unwrap();
1761
1762 let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
1763 assert_eq!(installed, 1);
1764 let first_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1765 let first_commit = first_lock
1766 .find("acme-lib")
1767 .and_then(|entry| entry.commit.clone())
1768 .unwrap();
1769
1770 fs::write(
1771 repo.join("lib.harn"),
1772 "pub fn value() -> string { return \"v2\" }\n",
1773 )
1774 .unwrap();
1775 run_git(&repo, &["add", "."]);
1776 run_git(&repo, &["commit", "-m", "update"]);
1777
1778 update_packages_in(workspace.env(), Some("acme-lib"), false).unwrap();
1779 let second_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1780 let second_commit = second_lock
1781 .find("acme-lib")
1782 .and_then(|entry| entry.commit.clone())
1783 .unwrap();
1784 assert_ne!(first_commit, second_commit);
1785 }
1786
1787 #[test]
1788 fn add_positional_local_path_dependency_uses_manifest_name_and_live_link() {
1789 let dependency_tmp = tempfile::tempdir().unwrap();
1790 let dependency_root = dependency_tmp.path().join("harn-openapi");
1791 fs::create_dir_all(&dependency_root).unwrap();
1792 fs::write(
1793 dependency_root.join(MANIFEST),
1794 r#"
1795 [package]
1796 name = "openapi"
1797 version = "0.1.0"
1798 "#,
1799 )
1800 .unwrap();
1801 fs::write(
1802 dependency_root.join("lib.harn"),
1803 "pub fn version() -> string { return \"v1\" }\n",
1804 )
1805 .unwrap();
1806
1807 let project_tmp = tempfile::tempdir().unwrap();
1808 let root = project_tmp.path();
1809 let workspace = TestWorkspace::new(root);
1810 fs::create_dir_all(root.join(".git")).unwrap();
1811 fs::write(
1812 root.join(MANIFEST),
1813 r#"
1814 [package]
1815 name = "workspace"
1816 version = "0.1.0"
1817 "#,
1818 )
1819 .unwrap();
1820
1821 add_package_to(
1822 workspace.env(),
1823 dependency_root.to_string_lossy().as_ref(),
1824 None,
1825 None,
1826 None,
1827 None,
1828 None,
1829 None,
1830 None,
1831 )
1832 .unwrap();
1833
1834 let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1835 assert!(
1836 manifest.contains("openapi = { path = "),
1837 "manifest should use package.name as alias: {manifest}"
1838 );
1839 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1840 let entry = lock.find("openapi").expect("openapi lock entry");
1841 assert!(entry.source.starts_with("path+file://"));
1842 let materialized = root.join(PKG_DIR).join("openapi");
1843 assert!(materialized.join("lib.harn").is_file());
1844
1845 #[cfg(unix)]
1846 assert!(
1847 fs::symlink_metadata(&materialized)
1848 .unwrap()
1849 .file_type()
1850 .is_symlink(),
1851 "path dependencies should be live-linked on Unix"
1852 );
1853
1854 #[cfg(windows)]
1855 let materialized_is_link = fs::symlink_metadata(&materialized)
1856 .unwrap()
1857 .file_type()
1858 .is_symlink();
1859
1860 fs::write(
1861 dependency_root.join("lib.harn"),
1862 "pub fn version() -> string { return \"v2\" }\n",
1863 )
1864 .unwrap();
1865 #[cfg(unix)]
1866 {
1867 let live_source = fs::read_to_string(materialized.join("lib.harn")).unwrap();
1868 assert!(
1869 live_source.contains("v2"),
1870 "materialized path dependency should reflect sibling repo edits"
1871 );
1872 }
1873 #[cfg(windows)]
1874 {
1875 let materialized_source = fs::read_to_string(materialized.join("lib.harn")).unwrap();
1876 if materialized_is_link {
1877 assert!(
1878 materialized_source.contains("v2"),
1879 "Windows path dependency symlink should reflect sibling repo edits"
1880 );
1881 } else {
1882 assert!(
1883 materialized_source.contains("v1"),
1884 "Windows path dependency copy fallback should keep the copied contents"
1885 );
1886 }
1887 }
1888
1889 remove_package_in(workspace.env(), "openapi").unwrap();
1890 assert!(!materialized.exists());
1891 assert!(dependency_root.join("lib.harn").exists());
1892 }
1893
1894 #[test]
1895 fn frozen_install_errors_when_lockfile_is_missing() {
1896 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1897 let project_tmp = tempfile::tempdir().unwrap();
1898 let root = project_tmp.path();
1899 let workspace = TestWorkspace::new(root);
1900 fs::create_dir_all(root.join(".git")).unwrap();
1901 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1902 fs::write(
1903 root.join(MANIFEST),
1904 format!(
1905 r#"
1906 [package]
1907 name = "workspace"
1908 version = "0.1.0"
1909
1910 [dependencies]
1911 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1912 "#
1913 ),
1914 )
1915 .unwrap();
1916
1917 let error = install_packages_in(workspace.env(), true, None, false).unwrap_err();
1918 assert!(error.to_string().contains(LOCK_FILE));
1919 }
1920
1921 #[test]
1922 fn offline_locked_install_materializes_from_cache_without_source_repo() {
1923 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1924 let project_tmp = tempfile::tempdir().unwrap();
1925 let root = project_tmp.path();
1926 let workspace = TestWorkspace::new(root);
1927 fs::create_dir_all(root.join(".git")).unwrap();
1928 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1929 fs::write(
1930 root.join(MANIFEST),
1931 format!(
1932 r#"
1933 [package]
1934 name = "workspace"
1935 version = "0.1.0"
1936
1937 [dependencies]
1938 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1939 "#
1940 ),
1941 )
1942 .unwrap();
1943
1944 let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
1945 assert_eq!(installed, 1);
1946 fs::remove_dir_all(root.join(PKG_DIR)).unwrap();
1947 fs::remove_dir_all(&repo).unwrap();
1948
1949 let installed = install_packages_in(workspace.env(), true, None, true).unwrap();
1950 assert_eq!(installed, 1);
1951 assert!(root
1952 .join(PKG_DIR)
1953 .join("acme-lib")
1954 .join("lib.harn")
1955 .is_file());
1956 }
1957
1958 #[test]
1959 fn offline_locked_install_fails_when_cache_is_missing() {
1960 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1961 let project_tmp = tempfile::tempdir().unwrap();
1962 let root = project_tmp.path();
1963 let workspace = TestWorkspace::new(root);
1964 let cache_dir = workspace.cache_dir();
1965 fs::create_dir_all(root.join(".git")).unwrap();
1966 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1967 fs::write(
1968 root.join(MANIFEST),
1969 format!(
1970 r#"
1971 [package]
1972 name = "workspace"
1973 version = "0.1.0"
1974
1975 [dependencies]
1976 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1977 "#
1978 ),
1979 )
1980 .unwrap();
1981
1982 install_packages_in(workspace.env(), false, None, false).unwrap();
1983 fs::remove_dir_all(cache_dir.join("git")).unwrap();
1984 let error = install_packages_in(workspace.env(), true, None, true).unwrap_err();
1985 assert!(error.to_string().contains("offline mode"));
1986 }
1987
1988 #[test]
1989 fn add_github_shorthand_requires_version_or_ref() {
1990 let error = normalize_add_request(
1991 "github.com/burin-labs/harn-openapi",
1992 None,
1993 None,
1994 None,
1995 None,
1996 None,
1997 None,
1998 None,
1999 )
2000 .unwrap_err();
2001 assert!(error
2002 .to_string()
2003 .contains("must specify `tag`, `rev`, or `branch`"));
2004 }
2005
2006 #[test]
2007 fn add_github_shorthand_with_ref_writes_git_dependency() {
2008 let (alias, dependency) = normalize_add_request(
2009 "github.com/burin-labs/harn-openapi@v1.2.3",
2010 None,
2011 None,
2012 None,
2013 None,
2014 None,
2015 None,
2016 None,
2017 )
2018 .unwrap();
2019 assert_eq!(alias, "harn-openapi");
2020 assert_eq!(
2021 render_dependency_line(&alias, &dependency).unwrap(),
2022 "harn-openapi = { git = \"https://github.com/burin-labs/harn-openapi\", rev = \"v1.2.3\" }"
2023 );
2024 }
2025 #[test]
2026 fn install_resolves_transitive_git_dependencies_from_clean_cache() {
2027 let (_sdk_tmp, sdk_repo, _branch) = create_git_package_repo_with(
2028 "notion-sdk-harn",
2029 "",
2030 "pub fn sdk_value() -> string { return \"sdk\" }\n",
2031 );
2032 let sdk_git = normalize_git_url(sdk_repo.to_string_lossy().as_ref()).unwrap();
2033 let connector_tail = format!(
2034 r#"
2035
2036 [dependencies]
2037 notion-sdk-harn = {{ git = "{sdk_git}", rev = "v1.0.0" }}
2038 "#
2039 );
2040 let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
2041 "notion-connector-harn",
2042 &connector_tail,
2043 r#"
2044 import "notion-sdk-harn"
2045
2046 pub fn connector_value() -> string {
2047 return "connector"
2048 }
2049 "#,
2050 );
2051
2052 let project_tmp = tempfile::tempdir().unwrap();
2053 let root = project_tmp.path();
2054 let workspace = TestWorkspace::new(root);
2055 fs::create_dir_all(root.join(".git")).unwrap();
2056 let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
2057 fs::write(
2058 root.join(MANIFEST),
2059 format!(
2060 r#"
2061 [package]
2062 name = "workspace"
2063 version = "0.1.0"
2064
2065 [dependencies]
2066 notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
2067 "#
2068 ),
2069 )
2070 .unwrap();
2071
2072 let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
2073 assert_eq!(installed, 2);
2074
2075 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2076 assert!(lock.find("notion-connector-harn").is_some());
2077 assert!(lock.find("notion-sdk-harn").is_some());
2078 assert!(root
2079 .join(PKG_DIR)
2080 .join("notion-connector-harn")
2081 .join("lib.harn")
2082 .is_file());
2083 assert!(root
2084 .join(PKG_DIR)
2085 .join("notion-sdk-harn")
2086 .join("lib.harn")
2087 .is_file());
2088
2089 let mut vm = test_vm();
2090 let exports = futures::executor::block_on(
2091 vm.load_module_exports(
2092 &root
2093 .join(PKG_DIR)
2094 .join("notion-connector-harn")
2095 .join("lib.harn"),
2096 ),
2097 )
2098 .expect("transitive import should load from the workspace package root");
2099 assert!(exports.contains_key("connector_value"));
2100 }
2101
2102 #[test]
2103 fn git_packages_reject_transitive_path_dependencies() {
2104 let connector_tail = r#"
2105
2106 [dependencies]
2107 local-helper = { path = "../helper" }
2108 "#;
2109 let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
2110 "notion-connector-harn",
2111 connector_tail,
2112 "pub fn connector_value() -> string { return \"connector\" }\n",
2113 );
2114
2115 let project_tmp = tempfile::tempdir().unwrap();
2116 let root = project_tmp.path();
2117 let workspace = TestWorkspace::new(root);
2118 fs::create_dir_all(root.join(".git")).unwrap();
2119 let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
2120 fs::write(
2121 root.join(MANIFEST),
2122 format!(
2123 r#"
2124 [package]
2125 name = "workspace"
2126 version = "0.1.0"
2127
2128 [dependencies]
2129 notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
2130 "#
2131 ),
2132 )
2133 .unwrap();
2134
2135 let error = install_packages_in(workspace.env(), false, None, false).unwrap_err();
2136 assert!(error
2137 .to_string()
2138 .contains("path dependencies are not supported inside git-installed packages"));
2139 }
2140
2141 #[test]
2142 fn package_alias_validation_rejects_path_traversal_names() {
2143 for alias in [
2144 "../evil",
2145 "nested/evil",
2146 "nested\\evil",
2147 ".",
2148 "..",
2149 "bad alias",
2150 ] {
2151 assert!(
2152 validate_package_alias(alias).is_err(),
2153 "{alias:?} should be rejected"
2154 );
2155 }
2156 validate_package_alias("acme-lib_1.2").expect("ordinary alias should be accepted");
2157 }
2158
2159 #[test]
2160 fn add_package_rejects_aliases_that_escape_packages_dir() {
2161 let error = normalize_add_request(
2162 "ignored",
2163 Some("../evil"),
2164 None,
2165 None,
2166 None,
2167 None,
2168 Some("./dep"),
2169 None,
2170 )
2171 .unwrap_err();
2172 assert!(error.to_string().contains("invalid dependency alias"));
2173 }
2174
2175 #[test]
2176 fn rendered_dependency_values_are_toml_escaped() {
2177 let path = "dep\" \nmalicious = true";
2178 let line = render_dependency_line(
2179 "safe",
2180 &Dependency::Table(Box::new(DepTable {
2181 path: Some(path.to_string()),
2182 ..DepTable::default()
2183 })),
2184 )
2185 .expect("dependency line");
2186 let parsed: Manifest = toml::from_str(&format!("[dependencies]\n{line}\n")).unwrap();
2187 assert_eq!(parsed.dependencies.len(), 1);
2188 assert_eq!(
2189 parsed
2190 .dependencies
2191 .get("safe")
2192 .and_then(Dependency::local_path),
2193 Some(path)
2194 );
2195 }
2196
2197 #[test]
2198 fn materialization_rejects_lock_alias_path_traversal_before_removing_paths() {
2199 let tmp = tempfile::tempdir().unwrap();
2200 let dep = tmp.path().join("dep");
2201 fs::create_dir_all(&dep).unwrap();
2202 fs::write(dep.join("lib.harn"), "pub fn dep() { 1 }\n").unwrap();
2203 let victim = tmp.path().join("victim");
2204 fs::create_dir_all(&victim).unwrap();
2205 fs::write(victim.join("keep.txt"), "keep").unwrap();
2206
2207 let manifest: Manifest = toml::from_str("[package]\nname = \"root\"\n").unwrap();
2208 let ctx = ManifestContext {
2209 manifest,
2210 dir: tmp.path().to_path_buf(),
2211 };
2212 let workspace = TestWorkspace::new(tmp.path());
2213 let lock = LockFile {
2214 packages: vec![LockEntry {
2215 name: "../victim".to_string(),
2216 source: path_source_uri(&dep).unwrap(),
2217 ..LockEntry::default()
2218 }],
2219 ..LockFile::default()
2220 };
2221
2222 let error = materialize_dependencies_from_lock(workspace.env(), &ctx, &lock, None, false)
2223 .unwrap_err();
2224 assert!(error.to_string().contains("invalid dependency alias"));
2225 assert!(
2226 victim.join("keep.txt").exists(),
2227 "malicious alias should not remove paths outside .harn/packages"
2228 );
2229 }
2230}