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