Skip to main content

tuff_core/
pack.rs

1//! Pack manifests and deterministic `.tuffpack` artifacts.
2
3use 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
15/// Source-manifest schema understood by this Tuff release.
16pub const PACK_SCHEMA_VERSION: u8 = 1;
17/// Binary artifact format understood by this Tuff release.
18pub const PACK_ARTIFACT_VERSION: u8 = 1;
19/// Canonical source-pack manifest filename.
20pub 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)]
27/// Declarative source definition for a versioned capability pack.
28pub 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)]
42/// Adapter targets rendered while building a pack.
43pub struct PackBuild {
44    pub targets: Vec<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49/// Capability IDs selected from the current project's `tuff.lock`.
50pub struct ProjectPackSelection {
51    pub capabilities: Vec<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56/// A local manifest-backed capability included in a pack.
57pub struct PackMember {
58    pub path: String,
59}
60
61/// A validated pack member and its loaded capability manifest.
62#[derive(Clone)]
63pub struct LoadedPackMember {
64    pub source_path: String,
65    pub manifest: CapabilityManifest,
66}
67
68/// A validated source pack rooted at its canonical filesystem directory.
69#[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")]
78/// Canonical metadata stored at the beginning of a pack artifact.
79pub 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")]
92/// Identity and release metadata for one artifact member.
93pub 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)]
103/// Pre-rendered adapter metadata stored in an artifact.
104pub struct PackArtifactTarget {
105    pub id: String,
106    pub capabilities: Vec<PackArtifactTargetCapability>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111/// Lifecycle metadata for one capability rendered to one target.
112pub 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)]
122/// Hash and byte length for one stored artifact file.
123pub struct PackArtifactFile {
124    pub path: String,
125    pub sha256: String,
126    pub size: u64,
127}
128
129#[derive(Debug, Clone)]
130/// An artifact-relative path paired with its file bytes.
131pub struct PackArtifactContent {
132    pub path: String,
133    pub bytes: Vec<u8>,
134}
135
136#[derive(Debug)]
137/// A fully parsed and verified pack artifact.
138pub struct PackArtifact {
139    pub metadata: PackArtifactMetadata,
140    pub contents: Vec<PackArtifactContent>,
141    pub digest: String,
142}
143
144/// Loads and validates a source pack from a directory or `tuff-pack.toml` path.
145///
146/// # Errors
147///
148/// Returns an error for malformed manifests, unsafe or duplicate paths, invalid capability
149/// manifests, incomplete workflow dependencies, or workflow cycles.
150pub 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
160/// Loads and validates only the source-pack manifest and returns its canonical root.
161///
162/// Project-backed manifests require CLI project context before their members can be resolved.
163///
164/// # Errors
165///
166/// Returns an error for an invalid path, unreadable TOML, or invalid source-pack metadata.
167pub 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
247/// Writes a source pack manifest as deterministic TOML.
248///
249/// # Errors
250///
251/// Returns an error when serialization or filesystem writing fails.
252pub fn write_manifest(path: &Path, manifest: &PackManifest) -> Result<()> {
253    fs::write(path, toml::to_string_pretty(manifest)?)?;
254    Ok(())
255}
256
257/// Collects the canonical manifest and declared source files for every pack member.
258///
259/// # Errors
260///
261/// Returns an error when a source is missing, unsafe, linked, outside the pack, or duplicated.
262pub 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
292/// Writes a deterministic pack artifact and returns its hexadecimal SHA-256 digest.
293///
294/// # Errors
295///
296/// Returns an error for unsafe or duplicate artifact paths, oversized metadata, an existing
297/// output path, serialization failure, or filesystem failure.
298pub 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
362/// Reads and verifies a complete pack artifact.
363///
364/// # Errors
365///
366/// Returns an error for unsupported versions, non-canonical metadata, unsafe paths, truncated or
367/// trailing data, invalid hashes, or inconsistent member and target metadata.
368pub 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
378/// Parses and verifies a complete pack artifact already loaded in memory.
379///
380/// # Errors
381///
382/// Returns an error for unsupported versions, non-canonical metadata, unsafe paths, truncated or
383/// trailing data, invalid hashes, or inconsistent member and target metadata.
384pub 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
441/// Extracts files below an artifact prefix into a missing or empty directory.
442///
443/// Returns the number of extracted files.
444///
445/// # Errors
446///
447/// Returns an error when the prefix is absent, a path is unsafe, the destination is non-empty, or
448/// a filesystem operation fails.
449pub 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
492/// Normalizes a non-empty relative path and rejects traversal or platform prefixes.
493///
494/// # Errors
495///
496/// Returns an error for empty, absolute, parent, current-directory, or prefixed paths.
497pub 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(&current)?.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}