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, rename = "package")]
8 pub(crate) packages: Vec<LockEntry>,
9}
10
11impl Default for LockFile {
12 fn default() -> Self {
13 Self {
14 version: LOCK_FILE_VERSION,
15 packages: Vec::new(),
16 }
17 }
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub(crate) struct LockEntry {
22 pub(crate) name: String,
23 pub(crate) source: String,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub(crate) rev_request: Option<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub(crate) commit: Option<String>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub(crate) content_hash: Option<String>,
30}
31
32impl LockFile {
33 pub(crate) fn load(path: &Path) -> Result<Option<Self>, PackageError> {
34 let content = match fs::read_to_string(path) {
35 Ok(s) => s,
36 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
37 Err(error) => return Err(format!("failed to read {}: {error}", path.display()).into()),
38 };
39
40 match toml::from_str::<Self>(&content) {
41 Ok(mut lock) => {
42 if lock.version != LOCK_FILE_VERSION {
43 return Err(format!(
44 "unsupported {} version {} (expected {})",
45 path.display(),
46 lock.version,
47 LOCK_FILE_VERSION
48 )
49 .into());
50 }
51 lock.sort_entries();
52 Ok(Some(lock))
53 }
54 Err(_) => {
55 let legacy = toml::from_str::<LegacyLockFile>(&content)
56 .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
57 let mut lock = Self {
58 version: LOCK_FILE_VERSION,
59 packages: legacy
60 .packages
61 .into_iter()
62 .map(|entry| LockEntry {
63 name: entry.name,
64 source: entry
65 .path
66 .map(|path| format!("path+{path}"))
67 .or_else(|| entry.git.map(|git| format!("git+{git}")))
68 .unwrap_or_default(),
69 rev_request: entry.rev_request.or(entry.tag),
70 commit: entry.commit,
71 content_hash: None,
72 })
73 .collect(),
74 };
75 lock.sort_entries();
76 Ok(Some(lock))
77 }
78 }
79 }
80
81 fn save(&self, path: &Path) -> Result<(), PackageError> {
82 let mut normalized = self.clone();
83 normalized.version = LOCK_FILE_VERSION;
84 normalized.sort_entries();
85 let body = toml::to_string_pretty(&normalized)
86 .map_err(|error| format!("failed to encode {}: {error}", path.display()))?;
87 let mut out = String::from("# This file is auto-generated by Harn. Do not edit.\n\n");
88 out.push_str(&body);
89 harn_vm::atomic_io::atomic_write(path, out.as_bytes()).map_err(|error| {
90 PackageError::Lockfile(format!("failed to write {}: {error}", path.display()))
91 })
92 }
93
94 pub(crate) fn sort_entries(&mut self) {
95 self.packages
96 .sort_by(|left, right| left.name.cmp(&right.name));
97 }
98
99 pub(crate) fn find(&self, name: &str) -> Option<&LockEntry> {
100 self.packages.iter().find(|entry| entry.name == name)
101 }
102
103 fn replace(&mut self, entry: LockEntry) {
104 if let Some(existing) = self.packages.iter_mut().find(|pkg| pkg.name == entry.name) {
105 *existing = entry;
106 } else {
107 self.packages.push(entry);
108 }
109 self.sort_entries();
110 }
111
112 fn remove(&mut self, name: &str) {
113 self.packages.retain(|entry| entry.name != name);
114 }
115}
116
117#[derive(Debug, Deserialize)]
118pub(crate) struct LegacyLockFile {
119 #[serde(default, rename = "package")]
120 packages: Vec<LegacyLockEntry>,
121}
122
123#[derive(Debug, Deserialize)]
124pub(crate) struct LegacyLockEntry {
125 pub(crate) name: String,
126 #[serde(default)]
127 git: Option<String>,
128 #[serde(default)]
129 tag: Option<String>,
130 #[serde(default)]
131 pub(crate) rev_request: Option<String>,
132 #[serde(default)]
133 pub(crate) commit: Option<String>,
134 #[serde(default)]
135 path: Option<String>,
136}
137
138pub(crate) fn compatible_locked_entry(
139 alias: &str,
140 dependency: &Dependency,
141 lock: &LockEntry,
142 manifest_dir: &Path,
143) -> Result<bool, PackageError> {
144 if lock.name != alias {
145 return Ok(false);
146 }
147 if let Some(path) = dependency.local_path() {
148 let source = path_source_uri(&resolve_path_dependency_source(manifest_dir, path)?)?;
149 return Ok(lock.source == source);
150 }
151 if let Some(url) = dependency.git_url() {
152 let source = format!("git+{}", normalize_git_url(url)?);
153 let requested = dependency
154 .branch()
155 .map(str::to_string)
156 .or_else(|| dependency.rev().map(str::to_string));
157 return Ok(lock.source == source
158 && lock.rev_request == requested
159 && lock.commit.is_some()
160 && lock.content_hash.is_some());
161 }
162 Ok(false)
163}
164
165#[derive(Debug, Clone)]
166pub(crate) struct PendingDependency {
167 alias: String,
168 dependency: Dependency,
169 manifest_dir: PathBuf,
170 parent: Option<String>,
171 parent_is_git: bool,
172}
173
174pub(crate) fn git_rev_request(
175 alias: &str,
176 dependency: &Dependency,
177) -> Result<String, PackageError> {
178 dependency
179 .branch()
180 .or_else(|| dependency.rev())
181 .map(str::to_string)
182 .ok_or_else(|| {
183 PackageError::Lockfile(format!(
184 "git dependency {alias} must specify `rev` or `branch`; use `harn add <url>@<tag-or-sha>` or add `rev = \"...\"` to {MANIFEST}"
185 ))
186 })
187}
188
189pub(crate) fn dependency_manifest_dir(source: &Path) -> Option<PathBuf> {
190 if source.is_dir() {
191 return Some(source.to_path_buf());
192 }
193 source.parent().map(Path::to_path_buf)
194}
195
196pub(crate) fn read_package_manifest_from_dir(dir: &Path) -> Result<Option<Manifest>, PackageError> {
197 let manifest_path = dir.join(MANIFEST);
198 if !manifest_path.exists() {
199 return Ok(None);
200 }
201 read_manifest_from_path(&manifest_path).map(Some)
202}
203
204pub(crate) fn dependency_conflict_message(
205 existing: &LockEntry,
206 candidate: &LockEntry,
207) -> PackageError {
208 PackageError::Lockfile(format!(
209 "dependency alias '{}' resolves to multiple packages ({} and {}); use distinct aliases in {MANIFEST}",
210 candidate.name, existing.source, candidate.source
211 ))
212}
213
214pub(crate) fn replace_lock_entry(
215 lock: &mut LockFile,
216 candidate: LockEntry,
217) -> Result<bool, PackageError> {
218 validate_package_alias(&candidate.name)?;
219 if let Some(existing) = lock.find(&candidate.name) {
220 if existing == &candidate {
221 return Ok(false);
222 }
223 return Err(dependency_conflict_message(existing, &candidate));
224 }
225 lock.replace(candidate);
226 Ok(true)
227}
228
229pub(crate) fn enqueue_manifest_dependencies(
230 pending: &mut Vec<PendingDependency>,
231 manifest: Manifest,
232 manifest_dir: PathBuf,
233 parent: String,
234 parent_is_git: bool,
235) {
236 let mut aliases: Vec<String> = manifest.dependencies.keys().cloned().collect();
237 aliases.sort();
238 for alias in aliases.into_iter().rev() {
239 if let Some(dependency) = manifest.dependencies.get(&alias).cloned() {
240 pending.push(PendingDependency {
241 alias,
242 dependency,
243 manifest_dir: manifest_dir.clone(),
244 parent: Some(parent.clone()),
245 parent_is_git,
246 });
247 }
248 }
249}
250
251pub(crate) fn build_lockfile(
252 ctx: &ManifestContext,
253 existing: Option<&LockFile>,
254 refresh_alias: Option<&str>,
255 refresh_all: bool,
256 allow_resolve: bool,
257 offline: bool,
258) -> Result<LockFile, PackageError> {
259 if manifest_has_git_dependencies(&ctx.manifest) {
260 ensure_git_available()?;
261 }
262
263 let mut lock = LockFile::default();
264 let mut pending: Vec<PendingDependency> = Vec::new();
265 let mut aliases: Vec<String> = ctx.manifest.dependencies.keys().cloned().collect();
266 aliases.sort();
267 for alias in aliases.into_iter().rev() {
268 let dependency = ctx
269 .manifest
270 .dependencies
271 .get(&alias)
272 .ok_or_else(|| format!("dependency {alias} disappeared while locking"))?
273 .clone();
274 pending.push(PendingDependency {
275 alias,
276 dependency,
277 manifest_dir: ctx.dir.clone(),
278 parent: None,
279 parent_is_git: false,
280 });
281 }
282
283 while let Some(next) = pending.pop() {
284 let alias = next.alias;
285 validate_package_alias(&alias)?;
286 let dependency = next.dependency;
287 if dependency.local_path().is_some() && next.parent_is_git {
288 let parent = next.parent.as_deref().unwrap_or("a git package");
289 return Err(format!(
290 "package {parent} declares local path dependency {alias}, but path dependencies are not supported inside git-installed packages; publish {alias} as a git dependency with `rev` or `branch`"
291 ).into());
292 }
293 if dependency.git_url().is_some() {
294 ensure_git_available()?;
295 git_rev_request(&alias, &dependency)?;
296 }
297 let refresh = refresh_all || refresh_alias == Some(alias.as_str());
298 if let Some(existing_lock) = existing.and_then(|lock| lock.find(&alias)) {
299 if !refresh
300 && compatible_locked_entry(&alias, &dependency, existing_lock, &next.manifest_dir)?
301 {
302 let mut entry = existing_lock.clone();
303 if entry.source.starts_with("git+") && entry.content_hash.is_none() {
304 let url = entry.source.trim_start_matches("git+");
305 let commit = entry
306 .commit
307 .as_deref()
308 .ok_or_else(|| format!("missing locked commit for {alias}"))?;
309 entry.content_hash = Some(ensure_git_cache_populated(
310 url,
311 &entry.source,
312 commit,
313 None,
314 false,
315 offline,
316 )?);
317 }
318 let inserted = replace_lock_entry(&mut lock, entry.clone())?;
319 if entry.source.starts_with("git+") {
320 let url = entry.source.trim_start_matches("git+");
321 let commit = entry
322 .commit
323 .as_deref()
324 .ok_or_else(|| format!("missing locked commit for {alias}"))?;
325 let expected_hash = entry
326 .content_hash
327 .as_deref()
328 .ok_or_else(|| format!("missing content hash for {alias}"))?;
329 ensure_git_cache_populated(
330 url,
331 &entry.source,
332 commit,
333 Some(expected_hash),
334 false,
335 offline,
336 )?;
337 if inserted {
338 let cache_dir = git_cache_dir(&entry.source, commit)?;
339 if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
340 enqueue_manifest_dependencies(
341 &mut pending,
342 manifest,
343 cache_dir,
344 alias,
345 true,
346 );
347 }
348 }
349 } else if inserted && entry.source.starts_with("path+") {
350 let source = path_from_source_uri(&entry.source)?;
351 if let Some(manifest_dir) = dependency_manifest_dir(&source) {
352 if let Some(manifest) = read_package_manifest_from_dir(&manifest_dir)? {
353 enqueue_manifest_dependencies(
354 &mut pending,
355 manifest,
356 manifest_dir,
357 alias,
358 false,
359 );
360 }
361 }
362 }
363 continue;
364 }
365 }
366
367 if !allow_resolve {
368 return Err(format!("{} would need to change", ctx.lock_path().display()).into());
369 }
370
371 if let Some(path) = dependency.local_path() {
372 let source = resolve_path_dependency_source(&next.manifest_dir, path)?;
373 let package_alias = alias.clone();
374 let entry = LockEntry {
375 name: alias.clone(),
376 source: path_source_uri(&source)?,
377 rev_request: None,
378 commit: None,
379 content_hash: None,
380 };
381 let inserted = replace_lock_entry(&mut lock, entry)?;
382 if inserted {
383 if let Some(manifest_dir) = dependency_manifest_dir(&source) {
384 if let Some(manifest) = read_package_manifest_from_dir(&manifest_dir)? {
385 enqueue_manifest_dependencies(
386 &mut pending,
387 manifest,
388 manifest_dir,
389 package_alias,
390 false,
391 );
392 }
393 }
394 }
395 continue;
396 }
397
398 if let Some(url) = dependency.git_url() {
399 let rev_request = git_rev_request(&alias, &dependency)?;
400 let normalized_url = normalize_git_url(url)?;
401 let source = format!("git+{normalized_url}");
402 let commit =
403 resolve_git_commit(&normalized_url, dependency.rev(), dependency.branch())?;
404 let content_hash = ensure_git_cache_populated(
405 &normalized_url,
406 &source,
407 &commit,
408 None,
409 false,
410 offline,
411 )?;
412 let entry = LockEntry {
413 name: alias.clone(),
414 source: source.clone(),
415 rev_request: Some(rev_request),
416 commit: Some(commit.clone()),
417 content_hash: Some(content_hash),
418 };
419 let inserted = replace_lock_entry(&mut lock, entry)?;
420 if inserted {
421 let cache_dir = git_cache_dir(&source, &commit)?;
422 if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
423 enqueue_manifest_dependencies(&mut pending, manifest, cache_dir, alias, true);
424 }
425 }
426 continue;
427 }
428
429 return Err(format!("dependency {alias} is missing a git or path source").into());
430 }
431 Ok(lock)
432}
433
434pub(crate) fn materialize_dependencies_from_lock(
435 ctx: &ManifestContext,
436 lock: &LockFile,
437 refetch: Option<&str>,
438 offline: bool,
439) -> Result<usize, PackageError> {
440 let packages_dir = ctx.packages_dir();
441 fs::create_dir_all(&packages_dir)
442 .map_err(|error| format!("failed to create {}: {error}", packages_dir.display()))?;
443
444 let mut installed = 0usize;
445 for entry in &lock.packages {
446 let alias = &entry.name;
447 validate_package_alias(alias)?;
448 if entry.source.starts_with("path+") {
449 let source = path_from_source_uri(&entry.source)?;
450 materialize_path_dependency(&source, &packages_dir, alias)?;
451 installed += 1;
452 continue;
453 }
454
455 let commit = entry
456 .commit
457 .as_deref()
458 .ok_or_else(|| format!("missing locked commit for {alias}"))?;
459 let expected_hash = entry
460 .content_hash
461 .as_deref()
462 .ok_or_else(|| format!("missing content hash for {alias}"))?;
463 let source = entry.source.clone();
464 let url = source.trim_start_matches("git+");
465 let refetch_this = refetch == Some("all") || refetch == Some(alias.as_str());
466 ensure_git_cache_populated(
467 url,
468 &source,
469 commit,
470 Some(expected_hash),
471 refetch_this,
472 offline,
473 )?;
474 let cache_dir = git_cache_dir(&source, commit)?;
475 let dest_dir = packages_dir.join(alias);
476 if !dest_dir.exists() || !materialized_hash_matches(&dest_dir, expected_hash) {
477 remove_materialized_package(&packages_dir, alias)?;
478 copy_dir_recursive(&cache_dir, &dest_dir)?;
479 write_cached_content_hash(&dest_dir, expected_hash)?;
480 }
481 installed += 1;
482 }
483 Ok(installed)
484}
485
486pub(crate) fn validate_lock_matches_manifest(
487 ctx: &ManifestContext,
488 lock: &LockFile,
489) -> Result<(), PackageError> {
490 for (alias, dependency) in &ctx.manifest.dependencies {
491 validate_package_alias(alias)?;
492 let entry = lock.find(alias).ok_or_else(|| {
493 format!(
494 "{} is missing an entry for {alias}",
495 ctx.lock_path().display()
496 )
497 })?;
498 if !compatible_locked_entry(alias, dependency, entry, &ctx.dir)? {
499 return Err(format!(
500 "{} is out of date for {alias}; run `harn install`",
501 ctx.lock_path().display()
502 )
503 .into());
504 }
505 }
506 Ok(())
507}
508
509pub fn ensure_dependencies_materialized(anchor: &Path) -> Result<(), PackageError> {
510 let Some((manifest, dir)) = find_nearest_manifest(anchor) else {
511 return Ok(());
512 };
513 if manifest.dependencies.is_empty() {
514 return Ok(());
515 }
516 let ctx = ManifestContext { manifest, dir };
517 let lock = LockFile::load(&ctx.lock_path())?.ok_or_else(|| {
518 format!(
519 "{} is missing; run `harn install`",
520 ctx.lock_path().display()
521 )
522 })?;
523 validate_lock_matches_manifest(&ctx, &lock)?;
524 materialize_dependencies_from_lock(&ctx, &lock, None, false)?;
525 Ok(())
526}
527
528pub(crate) fn dependency_section_bounds(lines: &[String]) -> Option<(usize, usize)> {
529 let start = lines
530 .iter()
531 .position(|line| line.trim() == "[dependencies]")?;
532 let end = lines
533 .iter()
534 .enumerate()
535 .skip(start + 1)
536 .find(|(_, line)| line.trim_start().starts_with('['))
537 .map(|(index, _)| index)
538 .unwrap_or(lines.len());
539 Some((start, end))
540}
541
542pub(crate) fn render_dependency_line(
543 alias: &str,
544 dependency: &Dependency,
545) -> Result<String, PackageError> {
546 validate_package_alias(alias)?;
547 match dependency {
548 Dependency::Path(path) => Ok(format!(
549 "{alias} = {{ path = {} }}",
550 toml_string_literal(path)?
551 )),
552 Dependency::Table(table) => {
553 let mut fields = Vec::new();
554 if let Some(path) = table.path.as_deref() {
555 fields.push(format!("path = {}", toml_string_literal(path)?));
556 }
557 if let Some(git) = table.git.as_deref() {
558 fields.push(format!("git = {}", toml_string_literal(git)?));
559 }
560 if let Some(branch) = table.branch.as_deref() {
561 fields.push(format!("branch = {}", toml_string_literal(branch)?));
562 } else if let Some(rev) = table.rev.as_deref().or(table.tag.as_deref()) {
563 fields.push(format!("rev = {}", toml_string_literal(rev)?));
564 }
565 if let Some(package) = table.package.as_deref() {
566 fields.push(format!("package = {}", toml_string_literal(package)?));
567 }
568 Ok(format!("{alias} = {{ {} }}", fields.join(", ")))
569 }
570 }
571}
572
573pub(crate) fn ensure_manifest_exists(manifest_path: &Path) -> Result<String, PackageError> {
574 if manifest_path.exists() {
575 return fs::read_to_string(manifest_path).map_err(|error| {
576 PackageError::Lockfile(format!(
577 "failed to read {}: {error}",
578 manifest_path.display()
579 ))
580 });
581 }
582 Ok("[package]\nname = \"my-project\"\nversion = \"0.1.0\"\n".to_string())
583}
584
585pub(crate) fn upsert_dependency_in_manifest(
586 manifest_path: &Path,
587 alias: &str,
588 dependency: &Dependency,
589) -> Result<(), PackageError> {
590 let content = ensure_manifest_exists(manifest_path)?;
591 let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
592 if dependency_section_bounds(&lines).is_none() {
593 if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) {
594 lines.push(String::new());
595 }
596 lines.push("[dependencies]".to_string());
597 }
598 let (start, end) = dependency_section_bounds(&lines).ok_or_else(|| {
599 format!(
600 "failed to locate [dependencies] in {}",
601 manifest_path.display()
602 )
603 })?;
604 let rendered = render_dependency_line(alias, dependency)?;
605 if let Some((index, _)) = lines
606 .iter()
607 .enumerate()
608 .skip(start + 1)
609 .take(end - start - 1)
610 .find(|(_, line)| {
611 line.split('=')
612 .next()
613 .is_some_and(|key| key.trim() == alias)
614 })
615 {
616 lines[index] = rendered;
617 } else {
618 lines.insert(end, rendered);
619 }
620 write_manifest_content(manifest_path, &(lines.join("\n") + "\n"))
621}
622
623pub(crate) fn remove_dependency_from_manifest(
624 manifest_path: &Path,
625 alias: &str,
626) -> Result<bool, PackageError> {
627 let content = fs::read_to_string(manifest_path)
628 .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?;
629 let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
630 let Some((start, end)) = dependency_section_bounds(&lines) else {
631 return Ok(false);
632 };
633 let mut removed = false;
634 lines = lines
635 .into_iter()
636 .enumerate()
637 .filter_map(|(index, line)| {
638 if index <= start || index >= end {
639 return Some(line);
640 }
641 let matches = line
642 .split('=')
643 .next()
644 .is_some_and(|key| key.trim() == alias);
645 if matches {
646 removed = true;
647 None
648 } else {
649 Some(line)
650 }
651 })
652 .collect();
653 if removed {
654 write_manifest_content(manifest_path, &(lines.join("\n") + "\n"))?;
655 }
656 Ok(removed)
657}
658
659pub(crate) fn install_packages_impl(
660 frozen: bool,
661 refetch: Option<&str>,
662 offline: bool,
663) -> Result<usize, PackageError> {
664 let ctx = load_current_manifest_context()?;
665 let existing = LockFile::load(&ctx.lock_path())?;
666 if ctx.manifest.dependencies.is_empty() {
667 if !frozen {
668 LockFile::default().save(&ctx.lock_path())?;
669 }
670 return Ok(0);
671 }
672
673 if (frozen || offline) && existing.is_none() {
674 return Err(format!("{} is missing", ctx.lock_path().display()).into());
675 }
676
677 let desired = build_lockfile(
678 &ctx,
679 existing.as_ref(),
680 None,
681 false,
682 !frozen && !offline,
683 offline,
684 )?;
685 if frozen || offline {
686 if existing.as_ref() != Some(&desired) {
687 return Err(format!("{} would need to change", ctx.lock_path().display()).into());
688 }
689 } else {
690 desired.save(&ctx.lock_path())?;
691 }
692 materialize_dependencies_from_lock(&ctx, &desired, refetch, offline)
693}
694
695pub fn install_packages(frozen: bool, refetch: Option<&str>, offline: bool) {
696 match install_packages_impl(frozen, refetch, offline) {
697 Ok(0) => println!("No dependencies to install."),
698 Ok(installed) => println!("Installed {installed} package(s) to {PKG_DIR}/"),
699 Err(error) => {
700 eprintln!("error: {error}");
701 process::exit(1);
702 }
703 }
704}
705
706pub fn lock_packages() {
707 let result = (|| -> Result<usize, PackageError> {
708 let ctx = load_current_manifest_context()?;
709 let existing = LockFile::load(&ctx.lock_path())?;
710 let lock = build_lockfile(&ctx, existing.as_ref(), None, true, true, false)?;
711 lock.save(&ctx.lock_path())?;
712 Ok(lock.packages.len())
713 })();
714
715 match result {
716 Ok(count) => println!("Wrote {} with {count} package(s).", LOCK_FILE),
717 Err(error) => {
718 eprintln!("error: {error}");
719 process::exit(1);
720 }
721 }
722}
723
724pub fn update_packages(alias: Option<&str>, all: bool) {
725 if !all && alias.is_none() {
726 eprintln!("error: specify a dependency alias or pass --all");
727 process::exit(1);
728 }
729
730 let result = (|| -> Result<usize, PackageError> {
731 let ctx = load_current_manifest_context()?;
732 if let Some(alias) = alias {
733 validate_package_alias(alias)?;
734 if !ctx.manifest.dependencies.contains_key(alias) {
735 return Err(format!("{alias} is not present in [dependencies]").into());
736 }
737 }
738 let existing = LockFile::load(&ctx.lock_path())?;
739 let lock = build_lockfile(&ctx, existing.as_ref(), alias, all, true, false)?;
740 lock.save(&ctx.lock_path())?;
741 materialize_dependencies_from_lock(&ctx, &lock, None, false)
742 })();
743
744 match result {
745 Ok(installed) => println!("Updated {installed} package(s)."),
746 Err(error) => {
747 eprintln!("error: {error}");
748 process::exit(1);
749 }
750 }
751}
752
753pub fn remove_package(alias: &str) {
754 let result = (|| -> Result<bool, PackageError> {
755 validate_package_alias(alias)?;
756 let ctx = load_current_manifest_context()?;
757 let removed = remove_dependency_from_manifest(&ctx.manifest_path(), alias)?;
758 if !removed {
759 return Ok(false);
760 }
761 let mut lock = LockFile::load(&ctx.lock_path())?.unwrap_or_default();
762 lock.remove(alias);
763 lock.save(&ctx.lock_path())?;
764 remove_materialized_package(&ctx.packages_dir(), alias)?;
765 Ok(true)
766 })();
767
768 match result {
769 Ok(true) => println!("Removed {alias} from {MANIFEST} and {LOCK_FILE}."),
770 Ok(false) => {
771 eprintln!("error: {alias} is not present in [dependencies]");
772 process::exit(1);
773 }
774 Err(error) => {
775 eprintln!("error: {error}");
776 process::exit(1);
777 }
778 }
779}
780
781pub(crate) fn normalize_add_request(
782 name_or_spec: &str,
783 alias: Option<&str>,
784 git_url: Option<&str>,
785 tag: Option<&str>,
786 rev: Option<&str>,
787 branch: Option<&str>,
788 local_path: Option<&str>,
789 registry: Option<&str>,
790) -> Result<(String, Dependency), PackageError> {
791 if local_path.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
792 return Err("path dependencies do not accept --rev, --tag, or --branch"
793 .to_string()
794 .into());
795 }
796 if git_url.is_none()
797 && local_path.is_none()
798 && rev.is_none()
799 && tag.is_none()
800 && branch.is_none()
801 {
802 if let Some(path) = existing_local_path_spec(name_or_spec) {
803 let alias = alias
804 .map(str::to_string)
805 .map(Ok)
806 .unwrap_or_else(|| derive_package_alias_from_path(&path))?;
807 validate_package_alias(&alias)?;
808 return Ok((
809 alias,
810 Dependency::Table(DepTable {
811 git: None,
812 tag: None,
813 rev: None,
814 branch: None,
815 path: Some(name_or_spec.to_string()),
816 package: None,
817 }),
818 ));
819 }
820 if parse_registry_package_spec(name_or_spec).is_some() {
821 return registry_dependency_from_spec(name_or_spec, alias, registry);
822 }
823 }
824 if git_url.is_some() || local_path.is_some() {
825 if let Some(path) = local_path {
826 let alias = alias
827 .map(str::to_string)
828 .unwrap_or_else(|| name_or_spec.to_string());
829 validate_package_alias(&alias)?;
830 return Ok((
831 alias,
832 Dependency::Table(DepTable {
833 git: None,
834 tag: None,
835 rev: None,
836 branch: None,
837 path: Some(path.to_string()),
838 package: None,
839 }),
840 ));
841 }
842 let alias = alias.unwrap_or(name_or_spec).to_string();
843 validate_package_alias(&alias)?;
844 if rev.is_none() && tag.is_none() && branch.is_none() {
845 return Err(format!(
846 "git dependency {alias} must specify `rev` or `branch`; use `harn add <url>@<tag-or-sha>` or pass `--rev`/`--branch`"
847 ).into());
848 }
849 let git = normalize_git_url(git_url.ok_or_else(|| "missing --git URL".to_string())?)?;
850 let package_name = derive_repo_name_from_source(&git)?;
851 return Ok((
852 alias.clone(),
853 Dependency::Table(DepTable {
854 git: Some(git),
855 tag: None,
856 rev: rev.or(tag).map(str::to_string),
857 branch: branch.map(str::to_string),
858 path: None,
859 package: (alias != package_name).then_some(package_name),
860 }),
861 ));
862 }
863
864 if rev.is_some() && tag.is_some() {
865 return Err("use only one of --rev or --tag".to_string().into());
866 }
867 let (raw_source, inline_ref) = parse_positional_git_spec(name_or_spec);
868 if inline_ref.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
869 return Err(
870 "specify the git ref either inline as @ref or via --rev/--branch"
871 .to_string()
872 .into(),
873 );
874 }
875 let git = normalize_git_url(raw_source)?;
876 let package_name = derive_repo_name_from_source(&git)?;
877 let alias = alias.unwrap_or(package_name.as_str()).to_string();
878 validate_package_alias(&alias)?;
879 if inline_ref.is_none() && rev.is_none() && tag.is_none() && branch.is_none() {
880 return Err(format!(
881 "git dependency {alias} must specify `rev` or `branch`; use `harn add {raw_source}@<tag-or-sha>` or pass `--rev`/`--branch`"
882 ).into());
883 }
884 Ok((
885 alias.clone(),
886 Dependency::Table(DepTable {
887 git: Some(git),
888 tag: None,
889 rev: inline_ref.or(rev).or(tag).map(str::to_string),
890 branch: branch.map(str::to_string),
891 path: None,
892 package: (alias != package_name).then_some(package_name),
893 }),
894 ))
895}
896
897#[cfg(test)]
898pub fn add_package(
899 name_or_spec: &str,
900 alias: Option<&str>,
901 git_url: Option<&str>,
902 tag: Option<&str>,
903 rev: Option<&str>,
904 branch: Option<&str>,
905 local_path: Option<&str>,
906) {
907 add_package_with_registry(
908 name_or_spec,
909 alias,
910 git_url,
911 tag,
912 rev,
913 branch,
914 local_path,
915 None,
916 )
917}
918
919pub fn add_package_with_registry(
920 name_or_spec: &str,
921 alias: Option<&str>,
922 git_url: Option<&str>,
923 tag: Option<&str>,
924 rev: Option<&str>,
925 branch: Option<&str>,
926 local_path: Option<&str>,
927 registry: Option<&str>,
928) {
929 let result = (|| -> Result<(String, usize), PackageError> {
930 let manifest_path = std::env::current_dir()
931 .map_err(|error| format!("failed to read cwd: {error}"))?
932 .join(MANIFEST);
933 let (alias, dependency) = normalize_add_request(
934 name_or_spec,
935 alias,
936 git_url,
937 tag,
938 rev,
939 branch,
940 local_path,
941 registry,
942 )?;
943 upsert_dependency_in_manifest(&manifest_path, &alias, &dependency)?;
944 let installed = install_packages_impl(false, None, false)?;
945 Ok((alias, installed))
946 })();
947
948 match result {
949 Ok((alias, installed)) => {
950 println!("Added {alias} to {MANIFEST}.");
951 println!("Installed {installed} package(s).");
952 }
953 Err(error) => {
954 eprintln!("error: {error}");
955 process::exit(1);
956 }
957 }
958}
959
960#[cfg(test)]
961mod tests {
962 use super::*;
963 use crate::package::test_support::*;
964
965 #[test]
966 fn lock_file_round_trips_typed_schema() {
967 let tmp = tempfile::tempdir().unwrap();
968 let path = tmp.path().join(LOCK_FILE);
969 let lock = LockFile {
970 version: LOCK_FILE_VERSION,
971 packages: vec![LockEntry {
972 name: "acme-lib".to_string(),
973 source: "git+https://github.com/acme/acme-lib".to_string(),
974 rev_request: Some("v1.0.0".to_string()),
975 commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
976 content_hash: Some("sha256:deadbeef".to_string()),
977 }],
978 };
979 lock.save(&path).unwrap();
980 let loaded = LockFile::load(&path).unwrap().unwrap();
981 assert_eq!(loaded, lock);
982 }
983
984 #[test]
985 fn add_and_remove_git_dependency_round_trip() {
986 let (_repo_tmp, repo, _branch) = create_git_package_repo();
987 let project_tmp = tempfile::tempdir().unwrap();
988 let root = project_tmp.path();
989 let cache_dir = root.join(".cache");
990 fs::create_dir_all(root.join(".git")).unwrap();
991 fs::write(
992 root.join(MANIFEST),
993 r#"
994 [package]
995 name = "workspace"
996 version = "0.1.0"
997 "#,
998 )
999 .unwrap();
1000
1001 with_test_env(root, &cache_dir, || {
1002 let spec = format!("{}@v1.0.0", repo.display());
1003 add_package(&spec, None, None, None, None, None, None);
1004
1005 let alias = "acme-lib";
1006 let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1007 assert!(manifest.contains("acme-lib"));
1008 assert!(manifest.contains("rev = \"v1.0.0\""));
1009
1010 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1011 let entry = lock.find(alias).unwrap();
1012 assert_eq!(lock.version, LOCK_FILE_VERSION);
1013 assert!(entry.source.starts_with("git+file://"));
1014 assert!(entry.commit.as_deref().is_some_and(is_full_git_sha));
1015 assert!(entry
1016 .content_hash
1017 .as_deref()
1018 .is_some_and(|hash| hash.starts_with("sha256:")));
1019 assert!(root.join(PKG_DIR).join(alias).join("lib.harn").is_file());
1020
1021 remove_package(alias);
1022 let updated_manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1023 assert!(!updated_manifest.contains("acme-lib ="));
1024 let updated_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1025 assert!(updated_lock.find(alias).is_none());
1026 assert!(!root.join(PKG_DIR).join(alias).exists());
1027 });
1028 }
1029
1030 #[test]
1031 fn update_branch_dependency_refreshes_locked_commit() {
1032 let (_repo_tmp, repo, branch) = create_git_package_repo();
1033 let project_tmp = tempfile::tempdir().unwrap();
1034 let root = project_tmp.path();
1035 let cache_dir = root.join(".cache");
1036 fs::create_dir_all(root.join(".git")).unwrap();
1037 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1038 fs::write(
1039 root.join(MANIFEST),
1040 format!(
1041 r#"
1042 [package]
1043 name = "workspace"
1044 version = "0.1.0"
1045
1046 [dependencies]
1047 acme-lib = {{ git = "{git}", branch = "{branch}" }}
1048 "#
1049 ),
1050 )
1051 .unwrap();
1052
1053 with_test_env(root, &cache_dir, || {
1054 let installed = install_packages_impl(false, None, false).unwrap();
1055 assert_eq!(installed, 1);
1056 let first_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1057 let first_commit = first_lock
1058 .find("acme-lib")
1059 .and_then(|entry| entry.commit.clone())
1060 .unwrap();
1061
1062 fs::write(
1063 repo.join("lib.harn"),
1064 "pub fn value() -> string { return \"v2\" }\n",
1065 )
1066 .unwrap();
1067 run_git(&repo, &["add", "."]);
1068 run_git(&repo, &["commit", "-m", "update"]);
1069
1070 update_packages(Some("acme-lib"), false);
1071 let second_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1072 let second_commit = second_lock
1073 .find("acme-lib")
1074 .and_then(|entry| entry.commit.clone())
1075 .unwrap();
1076 assert_ne!(first_commit, second_commit);
1077 });
1078 }
1079
1080 #[test]
1081 fn add_positional_local_path_dependency_uses_manifest_name_and_live_link() {
1082 let dependency_tmp = tempfile::tempdir().unwrap();
1083 let dependency_root = dependency_tmp.path().join("harn-openapi");
1084 fs::create_dir_all(&dependency_root).unwrap();
1085 fs::write(
1086 dependency_root.join(MANIFEST),
1087 r#"
1088 [package]
1089 name = "openapi"
1090 version = "0.1.0"
1091 "#,
1092 )
1093 .unwrap();
1094 fs::write(
1095 dependency_root.join("lib.harn"),
1096 "pub fn version() -> string { return \"v1\" }\n",
1097 )
1098 .unwrap();
1099
1100 let project_tmp = tempfile::tempdir().unwrap();
1101 let root = project_tmp.path();
1102 let cache_dir = root.join(".cache");
1103 fs::create_dir_all(root.join(".git")).unwrap();
1104 fs::write(
1105 root.join(MANIFEST),
1106 r#"
1107 [package]
1108 name = "workspace"
1109 version = "0.1.0"
1110 "#,
1111 )
1112 .unwrap();
1113
1114 with_test_env(root, &cache_dir, || {
1115 add_package(
1116 dependency_root.to_string_lossy().as_ref(),
1117 None,
1118 None,
1119 None,
1120 None,
1121 None,
1122 None,
1123 );
1124
1125 let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1126 assert!(
1127 manifest.contains("openapi = { path = "),
1128 "manifest should use package.name as alias: {manifest}"
1129 );
1130 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1131 let entry = lock.find("openapi").expect("openapi lock entry");
1132 assert!(entry.source.starts_with("path+file://"));
1133 let materialized = root.join(PKG_DIR).join("openapi");
1134 assert!(materialized.join("lib.harn").is_file());
1135
1136 #[cfg(unix)]
1137 assert!(
1138 fs::symlink_metadata(&materialized)
1139 .unwrap()
1140 .file_type()
1141 .is_symlink(),
1142 "path dependencies should be live-linked on Unix"
1143 );
1144
1145 #[cfg(windows)]
1146 let materialized_is_link = fs::symlink_metadata(&materialized)
1147 .unwrap()
1148 .file_type()
1149 .is_symlink();
1150
1151 fs::write(
1152 dependency_root.join("lib.harn"),
1153 "pub fn version() -> string { return \"v2\" }\n",
1154 )
1155 .unwrap();
1156 #[cfg(unix)]
1157 {
1158 let live_source = fs::read_to_string(materialized.join("lib.harn")).unwrap();
1159 assert!(
1160 live_source.contains("v2"),
1161 "materialized path dependency should reflect sibling repo edits"
1162 );
1163 }
1164 #[cfg(windows)]
1165 {
1166 let materialized_source =
1167 fs::read_to_string(materialized.join("lib.harn")).unwrap();
1168 if materialized_is_link {
1169 assert!(
1170 materialized_source.contains("v2"),
1171 "Windows path dependency symlink should reflect sibling repo edits"
1172 );
1173 } else {
1174 assert!(
1175 materialized_source.contains("v1"),
1176 "Windows path dependency copy fallback should keep the copied contents"
1177 );
1178 }
1179 }
1180
1181 remove_package("openapi");
1182 assert!(!materialized.exists());
1183 assert!(dependency_root.join("lib.harn").exists());
1184 });
1185 }
1186
1187 #[test]
1188 fn frozen_install_errors_when_lockfile_is_missing() {
1189 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1190 let project_tmp = tempfile::tempdir().unwrap();
1191 let root = project_tmp.path();
1192 let cache_dir = root.join(".cache");
1193 fs::create_dir_all(root.join(".git")).unwrap();
1194 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1195 fs::write(
1196 root.join(MANIFEST),
1197 format!(
1198 r#"
1199 [package]
1200 name = "workspace"
1201 version = "0.1.0"
1202
1203 [dependencies]
1204 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1205 "#
1206 ),
1207 )
1208 .unwrap();
1209
1210 with_test_env(root, &cache_dir, || {
1211 let error = install_packages_impl(true, None, false).unwrap_err();
1212 assert!(error.to_string().contains(LOCK_FILE));
1213 });
1214 }
1215
1216 #[test]
1217 fn offline_locked_install_materializes_from_cache_without_source_repo() {
1218 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1219 let project_tmp = tempfile::tempdir().unwrap();
1220 let root = project_tmp.path();
1221 let cache_dir = root.join(".cache");
1222 fs::create_dir_all(root.join(".git")).unwrap();
1223 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1224 fs::write(
1225 root.join(MANIFEST),
1226 format!(
1227 r#"
1228 [package]
1229 name = "workspace"
1230 version = "0.1.0"
1231
1232 [dependencies]
1233 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1234 "#
1235 ),
1236 )
1237 .unwrap();
1238
1239 with_test_env(root, &cache_dir, || {
1240 let installed = install_packages_impl(false, None, false).unwrap();
1241 assert_eq!(installed, 1);
1242 fs::remove_dir_all(root.join(PKG_DIR)).unwrap();
1243 fs::remove_dir_all(&repo).unwrap();
1244
1245 let installed = install_packages_impl(true, None, true).unwrap();
1246 assert_eq!(installed, 1);
1247 assert!(root
1248 .join(PKG_DIR)
1249 .join("acme-lib")
1250 .join("lib.harn")
1251 .is_file());
1252 });
1253 }
1254
1255 #[test]
1256 fn offline_locked_install_fails_when_cache_is_missing() {
1257 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1258 let project_tmp = tempfile::tempdir().unwrap();
1259 let root = project_tmp.path();
1260 let cache_dir = root.join(".cache");
1261 fs::create_dir_all(root.join(".git")).unwrap();
1262 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1263 fs::write(
1264 root.join(MANIFEST),
1265 format!(
1266 r#"
1267 [package]
1268 name = "workspace"
1269 version = "0.1.0"
1270
1271 [dependencies]
1272 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1273 "#
1274 ),
1275 )
1276 .unwrap();
1277
1278 with_test_env(root, &cache_dir, || {
1279 install_packages_impl(false, None, false).unwrap();
1280 fs::remove_dir_all(cache_dir.join("git")).unwrap();
1281 let error = install_packages_impl(true, None, true).unwrap_err();
1282 assert!(error.to_string().contains("offline mode"));
1283 });
1284 }
1285
1286 #[test]
1287 fn add_github_shorthand_requires_version_or_ref() {
1288 let error = normalize_add_request(
1289 "github.com/burin-labs/harn-openapi",
1290 None,
1291 None,
1292 None,
1293 None,
1294 None,
1295 None,
1296 None,
1297 )
1298 .unwrap_err();
1299 assert!(error.to_string().contains("must specify `rev` or `branch`"));
1300 }
1301
1302 #[test]
1303 fn add_github_shorthand_with_ref_writes_git_dependency() {
1304 let (alias, dependency) = normalize_add_request(
1305 "github.com/burin-labs/harn-openapi@v1.2.3",
1306 None,
1307 None,
1308 None,
1309 None,
1310 None,
1311 None,
1312 None,
1313 )
1314 .unwrap();
1315 assert_eq!(alias, "harn-openapi");
1316 assert_eq!(
1317 render_dependency_line(&alias, &dependency).unwrap(),
1318 "harn-openapi = { git = \"https://github.com/burin-labs/harn-openapi\", rev = \"v1.2.3\" }"
1319 );
1320 }
1321 #[test]
1322 fn install_resolves_transitive_git_dependencies_from_clean_cache() {
1323 let (_sdk_tmp, sdk_repo, _branch) = create_git_package_repo_with(
1324 "notion-sdk-harn",
1325 "",
1326 "pub fn sdk_value() -> string { return \"sdk\" }\n",
1327 );
1328 let sdk_git = normalize_git_url(sdk_repo.to_string_lossy().as_ref()).unwrap();
1329 let connector_tail = format!(
1330 r#"
1331
1332 [dependencies]
1333 notion-sdk-harn = {{ git = "{sdk_git}", rev = "v1.0.0" }}
1334 "#
1335 );
1336 let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
1337 "notion-connector-harn",
1338 &connector_tail,
1339 r#"
1340 import "notion-sdk-harn"
1341
1342 pub fn connector_value() -> string {
1343 return "connector"
1344 }
1345 "#,
1346 );
1347
1348 let project_tmp = tempfile::tempdir().unwrap();
1349 let root = project_tmp.path();
1350 let cache_dir = root.join(".cache");
1351 fs::create_dir_all(root.join(".git")).unwrap();
1352 let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
1353 fs::write(
1354 root.join(MANIFEST),
1355 format!(
1356 r#"
1357 [package]
1358 name = "workspace"
1359 version = "0.1.0"
1360
1361 [dependencies]
1362 notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
1363 "#
1364 ),
1365 )
1366 .unwrap();
1367
1368 with_test_env(root, &cache_dir, || {
1369 let installed = install_packages_impl(false, None, false).unwrap();
1370 assert_eq!(installed, 2);
1371
1372 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1373 assert!(lock.find("notion-connector-harn").is_some());
1374 assert!(lock.find("notion-sdk-harn").is_some());
1375 assert!(root
1376 .join(PKG_DIR)
1377 .join("notion-connector-harn")
1378 .join("lib.harn")
1379 .is_file());
1380 assert!(root
1381 .join(PKG_DIR)
1382 .join("notion-sdk-harn")
1383 .join("lib.harn")
1384 .is_file());
1385
1386 let mut vm = test_vm();
1387 let exports = futures::executor::block_on(
1388 vm.load_module_exports(
1389 &root
1390 .join(PKG_DIR)
1391 .join("notion-connector-harn")
1392 .join("lib.harn"),
1393 ),
1394 )
1395 .expect("transitive import should load from the workspace package root");
1396 assert!(exports.contains_key("connector_value"));
1397 });
1398 }
1399
1400 #[test]
1401 fn git_packages_reject_transitive_path_dependencies() {
1402 let connector_tail = r#"
1403
1404 [dependencies]
1405 local-helper = { path = "../helper" }
1406 "#;
1407 let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
1408 "notion-connector-harn",
1409 connector_tail,
1410 "pub fn connector_value() -> string { return \"connector\" }\n",
1411 );
1412
1413 let project_tmp = tempfile::tempdir().unwrap();
1414 let root = project_tmp.path();
1415 let cache_dir = root.join(".cache");
1416 fs::create_dir_all(root.join(".git")).unwrap();
1417 let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
1418 fs::write(
1419 root.join(MANIFEST),
1420 format!(
1421 r#"
1422 [package]
1423 name = "workspace"
1424 version = "0.1.0"
1425
1426 [dependencies]
1427 notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
1428 "#
1429 ),
1430 )
1431 .unwrap();
1432
1433 with_test_env(root, &cache_dir, || {
1434 let error = install_packages_impl(false, None, false).unwrap_err();
1435 assert!(error
1436 .to_string()
1437 .contains("path dependencies are not supported inside git-installed packages"));
1438 });
1439 }
1440
1441 #[test]
1442 fn package_alias_validation_rejects_path_traversal_names() {
1443 for alias in [
1444 "../evil",
1445 "nested/evil",
1446 "nested\\evil",
1447 ".",
1448 "..",
1449 "bad alias",
1450 ] {
1451 assert!(
1452 validate_package_alias(alias).is_err(),
1453 "{alias:?} should be rejected"
1454 );
1455 }
1456 validate_package_alias("acme-lib_1.2").expect("ordinary alias should be accepted");
1457 }
1458
1459 #[test]
1460 fn add_package_rejects_aliases_that_escape_packages_dir() {
1461 let error = normalize_add_request(
1462 "ignored",
1463 Some("../evil"),
1464 None,
1465 None,
1466 None,
1467 None,
1468 Some("./dep"),
1469 None,
1470 )
1471 .unwrap_err();
1472 assert!(error.to_string().contains("invalid dependency alias"));
1473 }
1474
1475 #[test]
1476 fn rendered_dependency_values_are_toml_escaped() {
1477 let path = "dep\" \nmalicious = true";
1478 let line = render_dependency_line(
1479 "safe",
1480 &Dependency::Table(DepTable {
1481 git: None,
1482 tag: None,
1483 rev: None,
1484 branch: None,
1485 path: Some(path.to_string()),
1486 package: None,
1487 }),
1488 )
1489 .expect("dependency line");
1490 let parsed: Manifest = toml::from_str(&format!("[dependencies]\n{line}\n")).unwrap();
1491 assert_eq!(parsed.dependencies.len(), 1);
1492 assert_eq!(
1493 parsed
1494 .dependencies
1495 .get("safe")
1496 .and_then(Dependency::local_path),
1497 Some(path)
1498 );
1499 }
1500
1501 #[test]
1502 fn materialization_rejects_lock_alias_path_traversal_before_removing_paths() {
1503 let tmp = tempfile::tempdir().unwrap();
1504 let dep = tmp.path().join("dep");
1505 fs::create_dir_all(&dep).unwrap();
1506 fs::write(dep.join("lib.harn"), "pub fn dep() { 1 }\n").unwrap();
1507 let victim = tmp.path().join("victim");
1508 fs::create_dir_all(&victim).unwrap();
1509 fs::write(victim.join("keep.txt"), "keep").unwrap();
1510
1511 let manifest: Manifest = toml::from_str("[package]\nname = \"root\"\n").unwrap();
1512 let ctx = ManifestContext {
1513 manifest,
1514 dir: tmp.path().to_path_buf(),
1515 };
1516 let lock = LockFile {
1517 version: LOCK_FILE_VERSION,
1518 packages: vec![LockEntry {
1519 name: "../victim".to_string(),
1520 source: path_source_uri(&dep).unwrap(),
1521 rev_request: None,
1522 commit: None,
1523 content_hash: None,
1524 }],
1525 };
1526
1527 let error = materialize_dependencies_from_lock(&ctx, &lock, None, false).unwrap_err();
1528 assert!(error.to_string().contains("invalid dependency alias"));
1529 assert!(
1530 victim.join("keep.txt").exists(),
1531 "malicious alias should not remove paths outside .harn/packages"
1532 );
1533 }
1534}