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::usage(
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::usage(format!(
171 "pack source file must be named {PACK_MANIFEST_FILE}"
172 )));
173 }
174 path.parent()
175 .ok_or_else(|| TuffError::usage("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::of(
182 crate::error::ErrorKind::Io,
183 format!("could not resolve pack root {}: {error}", 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::of(
190 crate::error::ErrorKind::Io,
191 format!(
192 "could not read pack manifest {}: {error}",
193 manifest_path.display()
194 ),
195 )
196 })?)
197 .map_err(|error| TuffError::corrupt(format!("invalid pack manifest TOML: {error}")))?;
198
199 validate_pack_manifest(&manifest)?;
200 Ok((root, manifest))
201}
202
203fn load_path_pack(root: PathBuf, manifest: PackManifest) -> Result<LoadedPack> {
204 let mut members = Vec::with_capacity(manifest.capabilities.len());
205 let mut ids = BTreeSet::new();
206 for member in &manifest.capabilities {
207 let relative = validate_relative_path(Path::new(&member.path))?;
208 let member_dir = root.join(&relative);
209 reject_symlink_path(&root, &relative)?;
210 let canonical = member_dir.canonicalize().map_err(|error| {
211 TuffError::of(
212 crate::error::ErrorKind::Io,
213 format!(
214 "could not resolve pack capability {}: {error}",
215 member_dir.display()
216 ),
217 )
218 })?;
219 if !canonical.starts_with(&root) || !canonical.is_dir() {
220 return Err(TuffError::usage(format!(
221 "pack capability path must be a directory inside the pack root: {}",
222 member.path
223 )));
224 }
225 let capability = manifest::load_manifest(&canonical)?;
226 if !ids.insert(capability.id.clone()) {
227 return Err(TuffError::usage(format!(
228 "duplicate capability id '{}' in pack",
229 capability.id
230 )));
231 }
232 members.push(LoadedPackMember {
233 source_path: normalize_path(&relative),
234 manifest: capability,
235 });
236 }
237 members.sort_by(|left, right| left.manifest.id.cmp(&right.manifest.id));
238 validate_workflow_closure(&members)?;
239
240 Ok(LoadedPack {
241 root,
242 manifest,
243 members,
244 })
245}
246
247pub fn write_manifest(path: &Path, manifest: &PackManifest) -> Result<()> {
253 fs::write(path, toml::to_string_pretty(manifest)?)?;
254 Ok(())
255}
256
257pub fn source_contents(pack: &LoadedPack) -> Result<Vec<PackArtifactContent>> {
263 let mut contents = BTreeMap::new();
264 for member in &pack.members {
265 let id = &member.manifest.id;
266 let manifest_path = member.manifest.root.join("tuff.toml");
267 insert_content(
268 &mut contents,
269 format!("sources/{id}/tuff.toml"),
270 read_safe_member_file(&pack.root, &manifest_path)?,
271 )?;
272 for source_file in member.manifest.source_files()? {
273 let relative = source_file
274 .strip_prefix(&member.manifest.root)
275 .map_err(|_| {
276 TuffError::refused("capability source file escaped its manifest root")
277 })?;
278 let relative = validate_relative_path(relative)?;
279 insert_content(
280 &mut contents,
281 format!("sources/{id}/{}", normalize_path(&relative)),
282 read_safe_member_file(&pack.root, &source_file)?,
283 )?;
284 }
285 }
286 Ok(contents
287 .into_iter()
288 .map(|(path, bytes)| PackArtifactContent { path, bytes })
289 .collect())
290}
291
292pub fn write_artifact(
299 output: &Path,
300 mut metadata: PackArtifactMetadata,
301 contents: Vec<PackArtifactContent>,
302) -> Result<String> {
303 if output.exists() {
304 return Err(TuffError::refused(format!(
305 "refusing to overwrite existing pack artifact: {}",
306 output.display()
307 )));
308 }
309 let mut ordered = BTreeMap::new();
310 for content in contents {
311 validate_relative_path(Path::new(&content.path))?;
312 if ordered
313 .insert(content.path.clone(), content.bytes)
314 .is_some()
315 {
316 return Err(TuffError::usage(format!(
317 "duplicate pack artifact path: {}",
318 content.path
319 )));
320 }
321 }
322 metadata.files = ordered
323 .iter()
324 .map(|(path, bytes)| PackArtifactFile {
325 path: path.clone(),
326 sha256: sha256(bytes),
327 size: bytes.len() as u64,
328 })
329 .collect();
330 let metadata_bytes = serde_json::to_vec(&metadata)?;
331 if metadata_bytes.len() > MAX_METADATA_BYTES {
332 return Err(TuffError::usage("pack artifact metadata is too large"));
333 }
334
335 let mut artifact = Vec::with_capacity(
336 ARTIFACT_MAGIC.len()
337 + 8
338 + metadata_bytes.len()
339 + ordered.values().map(Vec::len).sum::<usize>(),
340 );
341 artifact.extend_from_slice(ARTIFACT_MAGIC);
342 artifact.extend_from_slice(&(metadata_bytes.len() as u64).to_be_bytes());
343 artifact.extend_from_slice(&metadata_bytes);
344 for bytes in ordered.values() {
345 artifact.extend_from_slice(bytes);
346 }
347 let digest = sha256(&artifact);
348
349 let parent = output.parent().unwrap_or_else(|| Path::new("."));
350 fs::create_dir_all(parent)?;
351 let mut temporary = tempfile::Builder::new()
352 .prefix("tuff-pack-")
353 .tempfile_in(parent)?;
354 temporary.write_all(&artifact)?;
355 temporary.flush()?;
356 temporary
357 .persist(output)
358 .map_err(|error| TuffError::of(crate::error::ErrorKind::Io, error.error.to_string()))?;
359 Ok(digest)
360}
361
362pub fn read_artifact(path: &Path) -> Result<PackArtifact> {
369 let artifact = fs::read(path).map_err(|error| {
370 TuffError::of(
371 crate::error::ErrorKind::Io,
372 format!("could not read pack artifact {}: {error}", path.display()),
373 )
374 })?;
375 read_artifact_bytes(&artifact)
376}
377
378pub fn read_artifact_bytes(artifact: &[u8]) -> Result<PackArtifact> {
385 let header_len = ARTIFACT_MAGIC.len() + 8;
386 if artifact.len() < header_len || &artifact[..ARTIFACT_MAGIC.len()] != ARTIFACT_MAGIC {
387 return Err(TuffError::corrupt("invalid pack artifact header"));
388 }
389 let length_bytes: [u8; 8] = artifact[ARTIFACT_MAGIC.len()..header_len]
390 .try_into()
391 .map_err(|_| TuffError::corrupt("invalid pack artifact metadata length"))?;
392 let metadata_len = usize::try_from(u64::from_be_bytes(length_bytes))
393 .map_err(|_| TuffError::corrupt("pack artifact metadata length is too large"))?;
394 if metadata_len > MAX_METADATA_BYTES || header_len + metadata_len > artifact.len() {
395 return Err(TuffError::corrupt("invalid pack artifact metadata length"));
396 }
397 let metadata: PackArtifactMetadata =
398 serde_json::from_slice(&artifact[header_len..header_len + metadata_len])?;
399 validate_artifact_metadata(&metadata)?;
400 if serde_json::to_vec(&metadata)? != artifact[header_len..header_len + metadata_len] {
401 return Err(TuffError::corrupt(
402 "pack artifact metadata is not canonically encoded",
403 ));
404 }
405
406 let mut cursor = header_len + metadata_len;
407 let mut contents = Vec::with_capacity(metadata.files.len());
408 for file in &metadata.files {
409 let size = usize::try_from(file.size)
410 .map_err(|_| TuffError::corrupt("pack artifact file is too large"))?;
411 let end = cursor
412 .checked_add(size)
413 .filter(|end| *end <= artifact.len())
414 .ok_or_else(|| {
415 TuffError::corrupt(format!("truncated pack artifact file: {}", file.path))
416 })?;
417 let bytes = artifact[cursor..end].to_vec();
418 if sha256(&bytes) != file.sha256 {
419 return Err(TuffError::corrupt(format!(
420 "pack artifact hash mismatch for {}",
421 file.path
422 )));
423 }
424 contents.push(PackArtifactContent {
425 path: file.path.clone(),
426 bytes,
427 });
428 cursor = end;
429 }
430 if cursor != artifact.len() {
431 return Err(TuffError::corrupt("pack artifact contains trailing data"));
432 }
433
434 Ok(PackArtifact {
435 metadata,
436 contents,
437 digest: sha256(artifact),
438 })
439}
440
441pub fn extract_prefix(artifact: &PackArtifact, prefix: &str, output: &Path) -> Result<usize> {
450 let prefix = format!("{}/", prefix.trim_end_matches('/'));
451 let selected = artifact
452 .contents
453 .iter()
454 .filter_map(|content| {
455 content
456 .path
457 .strip_prefix(&prefix)
458 .map(|relative| (relative, &content.bytes))
459 })
460 .collect::<Vec<_>>();
461 if selected.is_empty() {
462 return Err(TuffError::not_found(format!(
463 "pack artifact contains no files under {prefix}"
464 )));
465 }
466 if output.exists() && (!output.is_dir() || fs::read_dir(output)?.next().is_some()) {
467 return Err(TuffError::refused(format!(
468 "pack extraction output must be missing or empty: {}",
469 output.display()
470 )));
471 }
472 let parent = output.parent().unwrap_or_else(|| Path::new("."));
473 fs::create_dir_all(parent)?;
474 let staging = tempfile::Builder::new()
475 .prefix("tuff-extract-")
476 .tempdir_in(parent)?;
477 for (relative, bytes) in &selected {
478 let relative = validate_relative_path(Path::new(relative))?;
479 let destination = staging.path().join(relative);
480 if let Some(parent) = destination.parent() {
481 fs::create_dir_all(parent)?;
482 }
483 fs::write(destination, bytes)?;
484 }
485 if output.exists() {
486 fs::remove_dir(output)?;
487 }
488 fs::rename(staging.keep(), output)?;
489 Ok(selected.len())
490}
491
492pub fn validate_relative_path(path: &Path) -> Result<PathBuf> {
498 if path.as_os_str().is_empty() || path.is_absolute() {
499 return Err(TuffError::usage(format!(
500 "path must be a non-empty relative path: {}",
501 path.display()
502 )));
503 }
504 let mut clean = PathBuf::new();
505 for component in path.components() {
506 match component {
507 Component::Normal(value) => clean.push(value),
508 _ => {
509 return Err(TuffError::refused(format!(
510 "path traversal is not allowed: {}",
511 path.display()
512 )));
513 }
514 }
515 }
516 Ok(clean)
517}
518
519fn validate_pack_manifest(manifest: &PackManifest) -> Result<()> {
520 if manifest.schema != PACK_SCHEMA_VERSION {
521 return Err(TuffError::unsupported(format!(
522 "unsupported pack schema version: {}",
523 manifest.schema
524 )));
525 }
526 validate_non_empty("name", &manifest.name)?;
527 validate_non_empty("version", &manifest.version)?;
528 validate_non_empty("description", &manifest.description)?;
529 if manifest.name.chars().any(char::is_whitespace) {
530 return Err(TuffError::usage("pack name must not contain whitespace"));
531 }
532 match (&manifest.project, manifest.capabilities.is_empty()) {
533 (None, true) => {
534 return Err(TuffError::usage(
535 "pack must contain at least one capability",
536 ));
537 }
538 (Some(_), false) => {
539 return Err(TuffError::usage(
540 "pack must use either [project] capabilities or [[capabilities]] paths, not both",
541 ));
542 }
543 _ => {}
544 }
545 if manifest.build.targets.is_empty() {
546 return Err(TuffError::usage(
547 "pack build must contain at least one target",
548 ));
549 }
550 let mut targets = BTreeSet::new();
551 for target in &manifest.build.targets {
552 validate_non_empty("build.targets", target)?;
553 if !targets.insert(target) {
554 return Err(TuffError::usage(format!(
555 "duplicate pack build target: {target}"
556 )));
557 }
558 }
559 let mut paths = BTreeSet::new();
560 for member in &manifest.capabilities {
561 let clean = validate_relative_path(Path::new(&member.path))?;
562 if !paths.insert(clean) {
563 return Err(TuffError::usage(format!(
564 "duplicate pack capability path: {}",
565 member.path
566 )));
567 }
568 }
569 if let Some(project) = &manifest.project {
570 if project.capabilities.is_empty() {
571 return Err(TuffError::usage(
572 "project pack must contain at least one capability id",
573 ));
574 }
575 let mut ids = BTreeSet::new();
576 for id in &project.capabilities {
577 validate_non_empty("project.capabilities", id)?;
578 if !ids.insert(id) {
579 return Err(TuffError::usage(format!(
580 "duplicate project capability id: {id}"
581 )));
582 }
583 }
584 }
585 Ok(())
586}
587
588fn validate_workflow_closure(members: &[LoadedPackMember]) -> Result<()> {
589 let types = members
590 .iter()
591 .map(|member| (member.manifest.id.as_str(), member.manifest.capability_type))
592 .collect::<BTreeMap<_, _>>();
593 let workflows = members
594 .iter()
595 .filter(|member| member.manifest.capability_type == CapabilityType::Workflow)
596 .map(|member| (member.manifest.id.as_str(), &member.manifest))
597 .collect::<BTreeMap<_, _>>();
598
599 for member in members {
600 let Some(workflow) = member.manifest.workflow.as_ref() else {
601 continue;
602 };
603 for requirement in &workflow.requires {
604 let actual = types.get(requirement.id.as_str()).ok_or_else(|| {
605 TuffError::usage(format!(
606 "workflow '{}' requires missing capability '{}' ({})",
607 member.manifest.id, requirement.id, requirement.capability_type
608 ))
609 })?;
610 if *actual != requirement.capability_type {
611 return Err(TuffError::usage(format!(
612 "workflow '{}' requires '{}' as {}, but the pack member is {}",
613 member.manifest.id, requirement.id, requirement.capability_type, actual
614 )));
615 }
616 }
617 }
618
619 let mut visiting = BTreeSet::new();
620 let mut visited = BTreeSet::new();
621 for id in workflows.keys() {
622 visit_workflow(id, &workflows, &mut visiting, &mut visited)?;
623 }
624 Ok(())
625}
626
627fn visit_workflow<'a>(
628 id: &'a str,
629 workflows: &BTreeMap<&'a str, &'a CapabilityManifest>,
630 visiting: &mut BTreeSet<&'a str>,
631 visited: &mut BTreeSet<&'a str>,
632) -> Result<()> {
633 if visited.contains(id) {
634 return Ok(());
635 }
636 if !visiting.insert(id) {
637 return Err(TuffError::usage(format!(
638 "workflow dependency cycle contains '{id}'"
639 )));
640 }
641 if let Some(workflow) = workflows.get(id).and_then(|item| item.workflow.as_ref()) {
642 for requirement in &workflow.requires {
643 if requirement.capability_type == CapabilityType::Workflow
644 && workflows.contains_key(requirement.id.as_str())
645 {
646 visit_workflow(requirement.id.as_str(), workflows, visiting, visited)?;
647 }
648 }
649 }
650 visiting.remove(id);
651 visited.insert(id);
652 Ok(())
653}
654
655fn validate_artifact_metadata(metadata: &PackArtifactMetadata) -> Result<()> {
656 if metadata.artifact_version != PACK_ARTIFACT_VERSION {
657 return Err(TuffError::unsupported(format!(
658 "unsupported pack artifact version: {}",
659 metadata.artifact_version
660 )));
661 }
662 if metadata.pack_schema != PACK_SCHEMA_VERSION {
663 return Err(TuffError::unsupported(format!(
664 "unsupported pack schema version: {}",
665 metadata.pack_schema
666 )));
667 }
668 validate_non_empty("name", &metadata.name)?;
669 validate_non_empty("version", &metadata.version)?;
670 if metadata.capabilities.is_empty() {
671 return Err(TuffError::corrupt(
672 "pack artifact must contain at least one capability",
673 ));
674 }
675 if metadata.targets.is_empty() {
676 return Err(TuffError::corrupt(
677 "pack artifact must contain at least one target",
678 ));
679 }
680 if !metadata
681 .capabilities
682 .windows(2)
683 .all(|window| window[0].id < window[1].id)
684 {
685 return Err(TuffError::corrupt(
686 "pack artifact capabilities are not canonically ordered",
687 ));
688 }
689 if !metadata
690 .targets
691 .windows(2)
692 .all(|window| window[0].id < window[1].id)
693 {
694 return Err(TuffError::corrupt(
695 "pack artifact targets are not canonically ordered",
696 ));
697 }
698 for capability in &metadata.capabilities {
699 validate_non_empty("capability.id", &capability.id)?;
700 validate_non_empty("capability.version", &capability.version)?;
701 validate_relative_path(Path::new(&capability.source_path))?;
702 if capability.capability_type == CapabilityType::Policy {
703 return Err(TuffError::unsupported(
704 "policy capabilities are not supported in pack artifacts",
705 ));
706 }
707 }
708 let capability_ids = metadata
709 .capabilities
710 .iter()
711 .map(|capability| capability.id.as_str())
712 .collect::<Vec<_>>();
713 let artifact_paths = metadata
714 .files
715 .iter()
716 .map(|file| file.path.as_str())
717 .collect::<BTreeSet<_>>();
718 for capability in &metadata.capabilities {
719 let manifest_path = format!("sources/{}/tuff.toml", capability.id);
720 if !artifact_paths.contains(manifest_path.as_str()) {
721 return Err(TuffError::corrupt(format!(
722 "pack artifact is missing source manifest for '{}'",
723 capability.id
724 )));
725 }
726 }
727 for target in &metadata.targets {
728 validate_non_empty("target.id", &target.id)?;
729 if !target
730 .capabilities
731 .windows(2)
732 .all(|window| window[0].id < window[1].id)
733 {
734 return Err(TuffError::corrupt(format!(
735 "pack artifact target '{}' capabilities are not canonically ordered",
736 target.id
737 )));
738 }
739 let target_ids = target
740 .capabilities
741 .iter()
742 .map(|capability| capability.id.as_str())
743 .collect::<Vec<_>>();
744 if target_ids != capability_ids {
745 return Err(TuffError::corrupt(format!(
746 "pack artifact target '{}' does not describe every capability",
747 target.id
748 )));
749 }
750 for capability in &target.capabilities {
751 validate_relative_path(Path::new(&capability.installed_path))?;
752 crate::cache::validate_hash(&capability.sha256)?;
753 for path in &capability.emitted_files {
754 validate_relative_path(Path::new(path))?;
755 let artifact_path = format!("targets/{}/{path}", target.id);
756 if !artifact_paths.contains(artifact_path.as_str()) {
757 return Err(TuffError::corrupt(format!(
758 "pack artifact is missing emitted file '{}' for target '{}'",
759 path, target.id
760 )));
761 }
762 }
763 for hook in &capability.managed_hooks {
764 validate_relative_path(Path::new(&hook.settings_path))?;
765 crate::cache::validate_hash(&hook.baseline_hash)?;
766 let artifact_path = format!("targets/{}/{}", target.id, hook.settings_path);
767 if !artifact_paths.contains(artifact_path.as_str()) {
768 return Err(TuffError::corrupt(format!(
769 "pack artifact is missing hook settings '{}' for target '{}'",
770 hook.settings_path, target.id
771 )));
772 }
773 }
774 }
775 }
776 let mut paths = BTreeSet::new();
777 for file in &metadata.files {
778 validate_relative_path(Path::new(&file.path))?;
779 if !file.path.starts_with("sources/") && !file.path.starts_with("targets/") {
780 return Err(TuffError::corrupt(format!(
781 "unsupported pack artifact file path: {}",
782 file.path
783 )));
784 }
785 crate::cache::validate_hash(&file.sha256)?;
786 if !paths.insert(file.path.as_str()) {
787 return Err(TuffError::corrupt(format!(
788 "duplicate pack artifact path: {}",
789 file.path
790 )));
791 }
792 }
793 if !metadata
794 .files
795 .windows(2)
796 .all(|window| window[0].path < window[1].path)
797 {
798 return Err(TuffError::corrupt(
799 "pack artifact file metadata is not canonically ordered",
800 ));
801 }
802 Ok(())
803}
804
805fn insert_content(
806 contents: &mut BTreeMap<String, Vec<u8>>,
807 path: String,
808 bytes: Vec<u8>,
809) -> Result<()> {
810 if contents.insert(path.clone(), bytes).is_some() {
811 return Err(TuffError::corrupt(format!(
812 "duplicate pack artifact path: {path}"
813 )));
814 }
815 Ok(())
816}
817
818fn read_safe_member_file(pack_root: &Path, path: &Path) -> Result<Vec<u8>> {
819 let relative = path
820 .strip_prefix(pack_root)
821 .map_err(|_| TuffError::refused("pack member source escaped the pack root"))?;
822 reject_symlink_path(pack_root, relative)?;
823 let metadata = fs::symlink_metadata(path)?;
824 if metadata.file_type().is_symlink() || !metadata.is_file() {
825 return Err(TuffError::refused(format!(
826 "pack member source must be a regular file: {}",
827 path.display()
828 )));
829 }
830 let canonical = path.canonicalize()?;
831 if !canonical.starts_with(pack_root) {
832 return Err(TuffError::refused(format!(
833 "pack member source escaped the pack root: {}",
834 path.display()
835 )));
836 }
837 Ok(fs::read(canonical)?)
838}
839
840fn reject_symlink_path(root: &Path, relative: &Path) -> Result<()> {
841 let mut current = root.to_path_buf();
842 for component in relative.components() {
843 let Component::Normal(value) = component else {
844 return Err(TuffError::corrupt("invalid pack member path"));
845 };
846 current.push(value);
847 if fs::symlink_metadata(¤t)?.file_type().is_symlink() {
848 return Err(TuffError::refused(format!(
849 "symbolic links are not allowed in pack member paths: {}",
850 current.display()
851 )));
852 }
853 }
854 Ok(())
855}
856
857fn validate_non_empty(field: &str, value: &str) -> Result<()> {
858 if value.trim().is_empty() {
859 return Err(TuffError::usage(format!(
860 "pack manifest field '{field}' must be a non-empty string"
861 )));
862 }
863 Ok(())
864}
865
866fn normalize_path(path: &Path) -> String {
867 path.to_string_lossy().replace('\\', "/")
868}
869
870fn sha256(bytes: &[u8]) -> String {
871 let mut hasher = Sha256::new();
872 hasher.update(bytes);
873 format!("{:x}", hasher.finalize())
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879
880 fn metadata() -> PackArtifactMetadata {
881 PackArtifactMetadata {
882 artifact_version: PACK_ARTIFACT_VERSION,
883 pack_schema: PACK_SCHEMA_VERSION,
884 name: "com.acme/demo".into(),
885 version: "1.0.0".into(),
886 description: "Demo pack".into(),
887 capabilities: vec![PackArtifactCapability {
888 id: "demo".into(),
889 capability_type: CapabilityType::Skill,
890 version: "1.0.0".into(),
891 description: "Demo capability".into(),
892 source_path: "capabilities/demo".into(),
893 }],
894 targets: vec![PackArtifactTarget {
895 id: "demo".into(),
896 capabilities: vec![PackArtifactTargetCapability {
897 id: "demo".into(),
898 installed_path: ".agents/skills/demo".into(),
899 sha256: sha256(b"tree"),
900 emitted_files: Vec::new(),
901 managed_hooks: Vec::new(),
902 }],
903 }],
904 files: Vec::new(),
905 }
906 }
907
908 #[test]
909 fn artifact_build_is_byte_for_byte_deterministic() {
910 let temp = tempfile::tempdir().unwrap();
911 let left = temp.path().join("left.tuffpack");
912 let right = temp.path().join("right.tuffpack");
913 let contents = || {
914 vec![
915 PackArtifactContent {
916 path: "targets/demo/b".into(),
917 bytes: b"b".to_vec(),
918 },
919 PackArtifactContent {
920 path: "targets/demo/a".into(),
921 bytes: b"a".to_vec(),
922 },
923 ]
924 };
925
926 write_artifact(&left, metadata(), contents()).unwrap();
927 write_artifact(&right, metadata(), contents()).unwrap();
928
929 assert_eq!(fs::read(left).unwrap(), fs::read(right).unwrap());
930 }
931
932 #[test]
933 fn artifact_read_rejects_tampered_payload() {
934 let temp = tempfile::tempdir().unwrap();
935 let path = temp.path().join("demo.tuffpack");
936 write_artifact(
937 &path,
938 metadata(),
939 vec![
940 PackArtifactContent {
941 path: "sources/demo/tuff.toml".into(),
942 bytes: b"manifest".to_vec(),
943 },
944 PackArtifactContent {
945 path: "targets/demo/file".into(),
946 bytes: b"safe".to_vec(),
947 },
948 ],
949 )
950 .unwrap();
951 let mut bytes = fs::read(&path).unwrap();
952 *bytes.last_mut().unwrap() ^= 1;
953 fs::write(&path, bytes).unwrap();
954
955 let error = read_artifact(&path).unwrap_err();
956
957 assert!(error.to_string().contains("hash mismatch"));
958 }
959
960 #[test]
961 fn relative_path_rejects_parent_traversal() {
962 let error = validate_relative_path(Path::new("../secret")).unwrap_err();
963
964 assert!(error.to_string().contains("path traversal"));
965 }
966}