1use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::io::Write;
6use std::path::{Component, Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::error::{Result, TuffError};
12use crate::lockfile::ManagedHook;
13use crate::manifest::{self, CapabilityManifest, CapabilityType};
14
15pub const PACK_SCHEMA_VERSION: u8 = 1;
17pub const PACK_ARTIFACT_VERSION: u8 = 1;
19pub const PACK_MANIFEST_FILE: &str = "tuff-pack.toml";
21
22const ARTIFACT_MAGIC: &[u8] = b"TUFFPACK\x01";
23const MAX_METADATA_BYTES: usize = 16 * 1024 * 1024;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct PackManifest {
29 pub schema: u8,
30 pub name: String,
31 pub version: String,
32 pub description: String,
33 pub build: PackBuild,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub project: Option<ProjectPackSelection>,
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub capabilities: Vec<PackMember>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct PackBuild {
44 pub targets: Vec<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct ProjectPackSelection {
51 pub capabilities: Vec<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct PackMember {
58 pub path: String,
59}
60
61#[derive(Clone)]
63pub struct LoadedPackMember {
64 pub source_path: String,
65 pub manifest: CapabilityManifest,
66}
67
68#[derive(Clone)]
70pub struct LoadedPack {
71 pub root: PathBuf,
72 pub manifest: PackManifest,
73 pub members: Vec<LoadedPackMember>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct PackArtifactMetadata {
80 pub artifact_version: u8,
81 pub pack_schema: u8,
82 pub name: String,
83 pub version: String,
84 pub description: String,
85 pub capabilities: Vec<PackArtifactCapability>,
86 pub targets: Vec<PackArtifactTarget>,
87 pub files: Vec<PackArtifactFile>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct PackArtifactCapability {
94 pub id: String,
95 #[serde(rename = "type")]
96 pub capability_type: CapabilityType,
97 pub version: String,
98 pub description: String,
99 pub source_path: String,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct PackArtifactTarget {
105 pub id: String,
106 pub capabilities: Vec<PackArtifactTargetCapability>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct PackArtifactTargetCapability {
113 pub id: String,
114 pub installed_path: String,
115 pub sha256: String,
116 pub emitted_files: Vec<String>,
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub managed_hooks: Vec<ManagedHook>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct PackArtifactFile {
124 pub path: String,
125 pub sha256: String,
126 pub size: u64,
127}
128
129#[derive(Debug, Clone)]
130pub struct PackArtifactContent {
132 pub path: String,
133 pub bytes: Vec<u8>,
134}
135
136#[derive(Debug)]
137pub struct PackArtifact {
139 pub metadata: PackArtifactMetadata,
140 pub contents: Vec<PackArtifactContent>,
141 pub digest: String,
142}
143
144pub fn load_pack(path: &Path) -> Result<LoadedPack> {
151 let (root, manifest) = load_manifest(path)?;
152 if manifest.project.is_some() {
153 return Err(TuffError::new(
154 "project-backed pack manifests must be built from an initialized Tuff project",
155 ));
156 }
157 load_path_pack(root, manifest)
158}
159
160pub fn load_manifest(path: &Path) -> Result<(PathBuf, PackManifest)> {
168 let root = if path.is_file() {
169 if path.file_name().and_then(|name| name.to_str()) != Some(PACK_MANIFEST_FILE) {
170 return Err(TuffError::new(format!(
171 "pack source file must be named {PACK_MANIFEST_FILE}"
172 )));
173 }
174 path.parent()
175 .ok_or_else(|| TuffError::new("pack manifest has no parent directory"))?
176 .to_path_buf()
177 } else {
178 path.to_path_buf()
179 };
180 let root = root.canonicalize().map_err(|error| {
181 TuffError::new(format!(
182 "could not resolve pack root {}: {error}",
183 root.display()
184 ))
185 })?;
186 let manifest_path = root.join(PACK_MANIFEST_FILE);
187 let manifest: PackManifest =
188 toml::from_str(&fs::read_to_string(&manifest_path).map_err(|error| {
189 TuffError::new(format!(
190 "could not read pack manifest {}: {error}",
191 manifest_path.display()
192 ))
193 })?)
194 .map_err(|error| TuffError::new(format!("invalid pack manifest TOML: {error}")))?;
195
196 validate_pack_manifest(&manifest)?;
197 Ok((root, manifest))
198}
199
200fn load_path_pack(root: PathBuf, manifest: PackManifest) -> Result<LoadedPack> {
201 let mut members = Vec::with_capacity(manifest.capabilities.len());
202 let mut ids = BTreeSet::new();
203 for member in &manifest.capabilities {
204 let relative = validate_relative_path(Path::new(&member.path))?;
205 let member_dir = root.join(&relative);
206 reject_symlink_path(&root, &relative)?;
207 let canonical = member_dir.canonicalize().map_err(|error| {
208 TuffError::new(format!(
209 "could not resolve pack capability {}: {error}",
210 member_dir.display()
211 ))
212 })?;
213 if !canonical.starts_with(&root) || !canonical.is_dir() {
214 return Err(TuffError::new(format!(
215 "pack capability path must be a directory inside the pack root: {}",
216 member.path
217 )));
218 }
219 let capability = manifest::load_manifest(&canonical)?;
220 if !ids.insert(capability.id.clone()) {
221 return Err(TuffError::new(format!(
222 "duplicate capability id '{}' in pack",
223 capability.id
224 )));
225 }
226 members.push(LoadedPackMember {
227 source_path: normalize_path(&relative),
228 manifest: capability,
229 });
230 }
231 members.sort_by(|left, right| left.manifest.id.cmp(&right.manifest.id));
232 validate_workflow_closure(&members)?;
233
234 Ok(LoadedPack {
235 root,
236 manifest,
237 members,
238 })
239}
240
241pub fn write_manifest(path: &Path, manifest: &PackManifest) -> Result<()> {
247 fs::write(path, toml::to_string_pretty(manifest)?)?;
248 Ok(())
249}
250
251pub fn source_contents(pack: &LoadedPack) -> Result<Vec<PackArtifactContent>> {
257 let mut contents = BTreeMap::new();
258 for member in &pack.members {
259 let id = &member.manifest.id;
260 let manifest_path = member.manifest.root.join("tuff.toml");
261 insert_content(
262 &mut contents,
263 format!("sources/{id}/tuff.toml"),
264 read_safe_member_file(&pack.root, &manifest_path)?,
265 )?;
266 for source_file in member.manifest.source_files()? {
267 let relative = source_file
268 .strip_prefix(&member.manifest.root)
269 .map_err(|_| TuffError::new("capability source file escaped its manifest root"))?;
270 let relative = validate_relative_path(relative)?;
271 insert_content(
272 &mut contents,
273 format!("sources/{id}/{}", normalize_path(&relative)),
274 read_safe_member_file(&pack.root, &source_file)?,
275 )?;
276 }
277 }
278 Ok(contents
279 .into_iter()
280 .map(|(path, bytes)| PackArtifactContent { path, bytes })
281 .collect())
282}
283
284pub fn write_artifact(
291 output: &Path,
292 mut metadata: PackArtifactMetadata,
293 contents: Vec<PackArtifactContent>,
294) -> Result<String> {
295 if output.exists() {
296 return Err(TuffError::new(format!(
297 "refusing to overwrite existing pack artifact: {}",
298 output.display()
299 )));
300 }
301 let mut ordered = BTreeMap::new();
302 for content in contents {
303 validate_relative_path(Path::new(&content.path))?;
304 if ordered
305 .insert(content.path.clone(), content.bytes)
306 .is_some()
307 {
308 return Err(TuffError::new(format!(
309 "duplicate pack artifact path: {}",
310 content.path
311 )));
312 }
313 }
314 metadata.files = ordered
315 .iter()
316 .map(|(path, bytes)| PackArtifactFile {
317 path: path.clone(),
318 sha256: sha256(bytes),
319 size: bytes.len() as u64,
320 })
321 .collect();
322 let metadata_bytes = serde_json::to_vec(&metadata)?;
323 if metadata_bytes.len() > MAX_METADATA_BYTES {
324 return Err(TuffError::new("pack artifact metadata is too large"));
325 }
326
327 let mut artifact = Vec::with_capacity(
328 ARTIFACT_MAGIC.len()
329 + 8
330 + metadata_bytes.len()
331 + ordered.values().map(Vec::len).sum::<usize>(),
332 );
333 artifact.extend_from_slice(ARTIFACT_MAGIC);
334 artifact.extend_from_slice(&(metadata_bytes.len() as u64).to_be_bytes());
335 artifact.extend_from_slice(&metadata_bytes);
336 for bytes in ordered.values() {
337 artifact.extend_from_slice(bytes);
338 }
339 let digest = sha256(&artifact);
340
341 let parent = output.parent().unwrap_or_else(|| Path::new("."));
342 fs::create_dir_all(parent)?;
343 let mut temporary = tempfile::Builder::new()
344 .prefix("tuff-pack-")
345 .tempfile_in(parent)?;
346 temporary.write_all(&artifact)?;
347 temporary.flush()?;
348 temporary
349 .persist(output)
350 .map_err(|error| TuffError::new(error.error.to_string()))?;
351 Ok(digest)
352}
353
354pub fn read_artifact(path: &Path) -> Result<PackArtifact> {
361 let artifact = fs::read(path).map_err(|error| {
362 TuffError::new(format!(
363 "could not read pack artifact {}: {error}",
364 path.display()
365 ))
366 })?;
367 read_artifact_bytes(&artifact)
368}
369
370pub fn read_artifact_bytes(artifact: &[u8]) -> Result<PackArtifact> {
377 let header_len = ARTIFACT_MAGIC.len() + 8;
378 if artifact.len() < header_len || &artifact[..ARTIFACT_MAGIC.len()] != ARTIFACT_MAGIC {
379 return Err(TuffError::new("invalid pack artifact header"));
380 }
381 let length_bytes: [u8; 8] = artifact[ARTIFACT_MAGIC.len()..header_len]
382 .try_into()
383 .map_err(|_| TuffError::new("invalid pack artifact metadata length"))?;
384 let metadata_len = usize::try_from(u64::from_be_bytes(length_bytes))
385 .map_err(|_| TuffError::new("pack artifact metadata length is too large"))?;
386 if metadata_len > MAX_METADATA_BYTES || header_len + metadata_len > artifact.len() {
387 return Err(TuffError::new("invalid pack artifact metadata length"));
388 }
389 let metadata: PackArtifactMetadata =
390 serde_json::from_slice(&artifact[header_len..header_len + metadata_len])?;
391 validate_artifact_metadata(&metadata)?;
392 if serde_json::to_vec(&metadata)? != artifact[header_len..header_len + metadata_len] {
393 return Err(TuffError::new(
394 "pack artifact metadata is not canonically encoded",
395 ));
396 }
397
398 let mut cursor = header_len + metadata_len;
399 let mut contents = Vec::with_capacity(metadata.files.len());
400 for file in &metadata.files {
401 let size = usize::try_from(file.size)
402 .map_err(|_| TuffError::new("pack artifact file is too large"))?;
403 let end = cursor
404 .checked_add(size)
405 .filter(|end| *end <= artifact.len())
406 .ok_or_else(|| {
407 TuffError::new(format!("truncated pack artifact file: {}", file.path))
408 })?;
409 let bytes = artifact[cursor..end].to_vec();
410 if sha256(&bytes) != file.sha256 {
411 return Err(TuffError::new(format!(
412 "pack artifact hash mismatch for {}",
413 file.path
414 )));
415 }
416 contents.push(PackArtifactContent {
417 path: file.path.clone(),
418 bytes,
419 });
420 cursor = end;
421 }
422 if cursor != artifact.len() {
423 return Err(TuffError::new("pack artifact contains trailing data"));
424 }
425
426 Ok(PackArtifact {
427 metadata,
428 contents,
429 digest: sha256(artifact),
430 })
431}
432
433pub fn extract_prefix(artifact: &PackArtifact, prefix: &str, output: &Path) -> Result<usize> {
442 let prefix = format!("{}/", prefix.trim_end_matches('/'));
443 let selected = artifact
444 .contents
445 .iter()
446 .filter_map(|content| {
447 content
448 .path
449 .strip_prefix(&prefix)
450 .map(|relative| (relative, &content.bytes))
451 })
452 .collect::<Vec<_>>();
453 if selected.is_empty() {
454 return Err(TuffError::new(format!(
455 "pack artifact contains no files under {prefix}"
456 )));
457 }
458 if output.exists() && (!output.is_dir() || fs::read_dir(output)?.next().is_some()) {
459 return Err(TuffError::new(format!(
460 "pack extraction output must be missing or empty: {}",
461 output.display()
462 )));
463 }
464 let parent = output.parent().unwrap_or_else(|| Path::new("."));
465 fs::create_dir_all(parent)?;
466 let staging = tempfile::Builder::new()
467 .prefix("tuff-extract-")
468 .tempdir_in(parent)?;
469 for (relative, bytes) in &selected {
470 let relative = validate_relative_path(Path::new(relative))?;
471 let destination = staging.path().join(relative);
472 if let Some(parent) = destination.parent() {
473 fs::create_dir_all(parent)?;
474 }
475 fs::write(destination, bytes)?;
476 }
477 if output.exists() {
478 fs::remove_dir(output)?;
479 }
480 fs::rename(staging.keep(), output)?;
481 Ok(selected.len())
482}
483
484pub fn validate_relative_path(path: &Path) -> Result<PathBuf> {
490 if path.as_os_str().is_empty() || path.is_absolute() {
491 return Err(TuffError::new(format!(
492 "path must be a non-empty relative path: {}",
493 path.display()
494 )));
495 }
496 let mut clean = PathBuf::new();
497 for component in path.components() {
498 match component {
499 Component::Normal(value) => clean.push(value),
500 _ => {
501 return Err(TuffError::new(format!(
502 "path traversal is not allowed: {}",
503 path.display()
504 )));
505 }
506 }
507 }
508 Ok(clean)
509}
510
511fn validate_pack_manifest(manifest: &PackManifest) -> Result<()> {
512 if manifest.schema != PACK_SCHEMA_VERSION {
513 return Err(TuffError::new(format!(
514 "unsupported pack schema version: {}",
515 manifest.schema
516 )));
517 }
518 validate_non_empty("name", &manifest.name)?;
519 validate_non_empty("version", &manifest.version)?;
520 validate_non_empty("description", &manifest.description)?;
521 if manifest.name.chars().any(char::is_whitespace) {
522 return Err(TuffError::new("pack name must not contain whitespace"));
523 }
524 match (&manifest.project, manifest.capabilities.is_empty()) {
525 (None, true) => return Err(TuffError::new("pack must contain at least one capability")),
526 (Some(_), false) => {
527 return Err(TuffError::new(
528 "pack must use either [project] capabilities or [[capabilities]] paths, not both",
529 ));
530 }
531 _ => {}
532 }
533 if manifest.build.targets.is_empty() {
534 return Err(TuffError::new(
535 "pack build must contain at least one target",
536 ));
537 }
538 let mut targets = BTreeSet::new();
539 for target in &manifest.build.targets {
540 validate_non_empty("build.targets", target)?;
541 if !targets.insert(target) {
542 return Err(TuffError::new(format!(
543 "duplicate pack build target: {target}"
544 )));
545 }
546 }
547 let mut paths = BTreeSet::new();
548 for member in &manifest.capabilities {
549 let clean = validate_relative_path(Path::new(&member.path))?;
550 if !paths.insert(clean) {
551 return Err(TuffError::new(format!(
552 "duplicate pack capability path: {}",
553 member.path
554 )));
555 }
556 }
557 if let Some(project) = &manifest.project {
558 if project.capabilities.is_empty() {
559 return Err(TuffError::new(
560 "project pack must contain at least one capability id",
561 ));
562 }
563 let mut ids = BTreeSet::new();
564 for id in &project.capabilities {
565 validate_non_empty("project.capabilities", id)?;
566 if !ids.insert(id) {
567 return Err(TuffError::new(format!(
568 "duplicate project capability id: {id}"
569 )));
570 }
571 }
572 }
573 Ok(())
574}
575
576fn validate_workflow_closure(members: &[LoadedPackMember]) -> Result<()> {
577 let types = members
578 .iter()
579 .map(|member| (member.manifest.id.as_str(), member.manifest.capability_type))
580 .collect::<BTreeMap<_, _>>();
581 let workflows = members
582 .iter()
583 .filter(|member| member.manifest.capability_type == CapabilityType::Workflow)
584 .map(|member| (member.manifest.id.as_str(), &member.manifest))
585 .collect::<BTreeMap<_, _>>();
586
587 for member in members {
588 let Some(workflow) = member.manifest.workflow.as_ref() else {
589 continue;
590 };
591 for requirement in &workflow.requires {
592 let actual = types.get(requirement.id.as_str()).ok_or_else(|| {
593 TuffError::new(format!(
594 "workflow '{}' requires missing capability '{}' ({})",
595 member.manifest.id, requirement.id, requirement.capability_type
596 ))
597 })?;
598 if *actual != requirement.capability_type {
599 return Err(TuffError::new(format!(
600 "workflow '{}' requires '{}' as {}, but the pack member is {}",
601 member.manifest.id, requirement.id, requirement.capability_type, actual
602 )));
603 }
604 }
605 }
606
607 let mut visiting = BTreeSet::new();
608 let mut visited = BTreeSet::new();
609 for id in workflows.keys() {
610 visit_workflow(id, &workflows, &mut visiting, &mut visited)?;
611 }
612 Ok(())
613}
614
615fn visit_workflow<'a>(
616 id: &'a str,
617 workflows: &BTreeMap<&'a str, &'a CapabilityManifest>,
618 visiting: &mut BTreeSet<&'a str>,
619 visited: &mut BTreeSet<&'a str>,
620) -> Result<()> {
621 if visited.contains(id) {
622 return Ok(());
623 }
624 if !visiting.insert(id) {
625 return Err(TuffError::new(format!(
626 "workflow dependency cycle contains '{id}'"
627 )));
628 }
629 if let Some(workflow) = workflows.get(id).and_then(|item| item.workflow.as_ref()) {
630 for requirement in &workflow.requires {
631 if requirement.capability_type == CapabilityType::Workflow
632 && workflows.contains_key(requirement.id.as_str())
633 {
634 visit_workflow(requirement.id.as_str(), workflows, visiting, visited)?;
635 }
636 }
637 }
638 visiting.remove(id);
639 visited.insert(id);
640 Ok(())
641}
642
643fn validate_artifact_metadata(metadata: &PackArtifactMetadata) -> Result<()> {
644 if metadata.artifact_version != PACK_ARTIFACT_VERSION {
645 return Err(TuffError::new(format!(
646 "unsupported pack artifact version: {}",
647 metadata.artifact_version
648 )));
649 }
650 if metadata.pack_schema != PACK_SCHEMA_VERSION {
651 return Err(TuffError::new(format!(
652 "unsupported pack schema version: {}",
653 metadata.pack_schema
654 )));
655 }
656 validate_non_empty("name", &metadata.name)?;
657 validate_non_empty("version", &metadata.version)?;
658 if metadata.capabilities.is_empty() {
659 return Err(TuffError::new(
660 "pack artifact must contain at least one capability",
661 ));
662 }
663 if metadata.targets.is_empty() {
664 return Err(TuffError::new(
665 "pack artifact must contain at least one target",
666 ));
667 }
668 if !metadata
669 .capabilities
670 .windows(2)
671 .all(|window| window[0].id < window[1].id)
672 {
673 return Err(TuffError::new(
674 "pack artifact capabilities are not canonically ordered",
675 ));
676 }
677 if !metadata
678 .targets
679 .windows(2)
680 .all(|window| window[0].id < window[1].id)
681 {
682 return Err(TuffError::new(
683 "pack artifact targets are not canonically ordered",
684 ));
685 }
686 for capability in &metadata.capabilities {
687 validate_non_empty("capability.id", &capability.id)?;
688 validate_non_empty("capability.version", &capability.version)?;
689 validate_relative_path(Path::new(&capability.source_path))?;
690 if capability.capability_type == CapabilityType::Policy {
691 return Err(TuffError::new(
692 "policy capabilities are not supported in pack artifacts",
693 ));
694 }
695 }
696 let capability_ids = metadata
697 .capabilities
698 .iter()
699 .map(|capability| capability.id.as_str())
700 .collect::<Vec<_>>();
701 let artifact_paths = metadata
702 .files
703 .iter()
704 .map(|file| file.path.as_str())
705 .collect::<BTreeSet<_>>();
706 for capability in &metadata.capabilities {
707 let manifest_path = format!("sources/{}/tuff.toml", capability.id);
708 if !artifact_paths.contains(manifest_path.as_str()) {
709 return Err(TuffError::new(format!(
710 "pack artifact is missing source manifest for '{}'",
711 capability.id
712 )));
713 }
714 }
715 for target in &metadata.targets {
716 validate_non_empty("target.id", &target.id)?;
717 if !target
718 .capabilities
719 .windows(2)
720 .all(|window| window[0].id < window[1].id)
721 {
722 return Err(TuffError::new(format!(
723 "pack artifact target '{}' capabilities are not canonically ordered",
724 target.id
725 )));
726 }
727 let target_ids = target
728 .capabilities
729 .iter()
730 .map(|capability| capability.id.as_str())
731 .collect::<Vec<_>>();
732 if target_ids != capability_ids {
733 return Err(TuffError::new(format!(
734 "pack artifact target '{}' does not describe every capability",
735 target.id
736 )));
737 }
738 for capability in &target.capabilities {
739 validate_relative_path(Path::new(&capability.installed_path))?;
740 crate::cache::validate_hash(&capability.sha256)?;
741 for path in &capability.emitted_files {
742 validate_relative_path(Path::new(path))?;
743 let artifact_path = format!("targets/{}/{path}", target.id);
744 if !artifact_paths.contains(artifact_path.as_str()) {
745 return Err(TuffError::new(format!(
746 "pack artifact is missing emitted file '{}' for target '{}'",
747 path, target.id
748 )));
749 }
750 }
751 for hook in &capability.managed_hooks {
752 validate_relative_path(Path::new(&hook.settings_path))?;
753 crate::cache::validate_hash(&hook.baseline_hash)?;
754 let artifact_path = format!("targets/{}/{}", target.id, hook.settings_path);
755 if !artifact_paths.contains(artifact_path.as_str()) {
756 return Err(TuffError::new(format!(
757 "pack artifact is missing hook settings '{}' for target '{}'",
758 hook.settings_path, target.id
759 )));
760 }
761 }
762 }
763 }
764 let mut paths = BTreeSet::new();
765 for file in &metadata.files {
766 validate_relative_path(Path::new(&file.path))?;
767 if !file.path.starts_with("sources/") && !file.path.starts_with("targets/") {
768 return Err(TuffError::new(format!(
769 "unsupported pack artifact file path: {}",
770 file.path
771 )));
772 }
773 crate::cache::validate_hash(&file.sha256)?;
774 if !paths.insert(file.path.as_str()) {
775 return Err(TuffError::new(format!(
776 "duplicate pack artifact path: {}",
777 file.path
778 )));
779 }
780 }
781 if !metadata
782 .files
783 .windows(2)
784 .all(|window| window[0].path < window[1].path)
785 {
786 return Err(TuffError::new(
787 "pack artifact file metadata is not canonically ordered",
788 ));
789 }
790 Ok(())
791}
792
793fn insert_content(
794 contents: &mut BTreeMap<String, Vec<u8>>,
795 path: String,
796 bytes: Vec<u8>,
797) -> Result<()> {
798 if contents.insert(path.clone(), bytes).is_some() {
799 return Err(TuffError::new(format!(
800 "duplicate pack artifact path: {path}"
801 )));
802 }
803 Ok(())
804}
805
806fn read_safe_member_file(pack_root: &Path, path: &Path) -> Result<Vec<u8>> {
807 let relative = path
808 .strip_prefix(pack_root)
809 .map_err(|_| TuffError::new("pack member source escaped the pack root"))?;
810 reject_symlink_path(pack_root, relative)?;
811 let metadata = fs::symlink_metadata(path)?;
812 if metadata.file_type().is_symlink() || !metadata.is_file() {
813 return Err(TuffError::new(format!(
814 "pack member source must be a regular file: {}",
815 path.display()
816 )));
817 }
818 let canonical = path.canonicalize()?;
819 if !canonical.starts_with(pack_root) {
820 return Err(TuffError::new(format!(
821 "pack member source escaped the pack root: {}",
822 path.display()
823 )));
824 }
825 Ok(fs::read(canonical)?)
826}
827
828fn reject_symlink_path(root: &Path, relative: &Path) -> Result<()> {
829 let mut current = root.to_path_buf();
830 for component in relative.components() {
831 let Component::Normal(value) = component else {
832 return Err(TuffError::new("invalid pack member path"));
833 };
834 current.push(value);
835 if fs::symlink_metadata(¤t)?.file_type().is_symlink() {
836 return Err(TuffError::new(format!(
837 "symbolic links are not allowed in pack member paths: {}",
838 current.display()
839 )));
840 }
841 }
842 Ok(())
843}
844
845fn validate_non_empty(field: &str, value: &str) -> Result<()> {
846 if value.trim().is_empty() {
847 return Err(TuffError::new(format!(
848 "pack manifest field '{field}' must be a non-empty string"
849 )));
850 }
851 Ok(())
852}
853
854fn normalize_path(path: &Path) -> String {
855 path.to_string_lossy().replace('\\', "/")
856}
857
858fn sha256(bytes: &[u8]) -> String {
859 let mut hasher = Sha256::new();
860 hasher.update(bytes);
861 format!("{:x}", hasher.finalize())
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867
868 fn metadata() -> PackArtifactMetadata {
869 PackArtifactMetadata {
870 artifact_version: PACK_ARTIFACT_VERSION,
871 pack_schema: PACK_SCHEMA_VERSION,
872 name: "com.acme/demo".into(),
873 version: "1.0.0".into(),
874 description: "Demo pack".into(),
875 capabilities: vec![PackArtifactCapability {
876 id: "demo".into(),
877 capability_type: CapabilityType::Skill,
878 version: "1.0.0".into(),
879 description: "Demo capability".into(),
880 source_path: "capabilities/demo".into(),
881 }],
882 targets: vec![PackArtifactTarget {
883 id: "demo".into(),
884 capabilities: vec![PackArtifactTargetCapability {
885 id: "demo".into(),
886 installed_path: ".agents/skills/demo".into(),
887 sha256: sha256(b"tree"),
888 emitted_files: Vec::new(),
889 managed_hooks: Vec::new(),
890 }],
891 }],
892 files: Vec::new(),
893 }
894 }
895
896 #[test]
897 fn artifact_build_is_byte_for_byte_deterministic() {
898 let temp = tempfile::tempdir().unwrap();
899 let left = temp.path().join("left.tuffpack");
900 let right = temp.path().join("right.tuffpack");
901 let contents = || {
902 vec![
903 PackArtifactContent {
904 path: "targets/demo/b".into(),
905 bytes: b"b".to_vec(),
906 },
907 PackArtifactContent {
908 path: "targets/demo/a".into(),
909 bytes: b"a".to_vec(),
910 },
911 ]
912 };
913
914 write_artifact(&left, metadata(), contents()).unwrap();
915 write_artifact(&right, metadata(), contents()).unwrap();
916
917 assert_eq!(fs::read(left).unwrap(), fs::read(right).unwrap());
918 }
919
920 #[test]
921 fn artifact_read_rejects_tampered_payload() {
922 let temp = tempfile::tempdir().unwrap();
923 let path = temp.path().join("demo.tuffpack");
924 write_artifact(
925 &path,
926 metadata(),
927 vec![
928 PackArtifactContent {
929 path: "sources/demo/tuff.toml".into(),
930 bytes: b"manifest".to_vec(),
931 },
932 PackArtifactContent {
933 path: "targets/demo/file".into(),
934 bytes: b"safe".to_vec(),
935 },
936 ],
937 )
938 .unwrap();
939 let mut bytes = fs::read(&path).unwrap();
940 *bytes.last_mut().unwrap() ^= 1;
941 fs::write(&path, bytes).unwrap();
942
943 let error = read_artifact(&path).unwrap_err();
944
945 assert!(error.to_string().contains("hash mismatch"));
946 }
947
948 #[test]
949 fn relative_path_rejects_parent_traversal() {
950 let error = validate_relative_path(Path::new("../secret")).unwrap_err();
951
952 assert!(error.to_string().contains("path traversal"));
953 }
954}