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::new(
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::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
241/// Writes a source pack manifest as deterministic TOML.
242///
243/// # Errors
244///
245/// Returns an error when serialization or filesystem writing fails.
246pub fn write_manifest(path: &Path, manifest: &PackManifest) -> Result<()> {
247    fs::write(path, toml::to_string_pretty(manifest)?)?;
248    Ok(())
249}
250
251/// Collects the canonical manifest and declared source files for every pack member.
252///
253/// # Errors
254///
255/// Returns an error when a source is missing, unsafe, linked, outside the pack, or duplicated.
256pub 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
284/// Writes a deterministic pack artifact and returns its hexadecimal SHA-256 digest.
285///
286/// # Errors
287///
288/// Returns an error for unsafe or duplicate artifact paths, oversized metadata, an existing
289/// output path, serialization failure, or filesystem failure.
290pub 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
354/// Reads and verifies a complete pack artifact.
355///
356/// # Errors
357///
358/// Returns an error for unsupported versions, non-canonical metadata, unsafe paths, truncated or
359/// trailing data, invalid hashes, or inconsistent member and target metadata.
360pub 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
370/// Parses and verifies a complete pack artifact already loaded in memory.
371///
372/// # Errors
373///
374/// Returns an error for unsupported versions, non-canonical metadata, unsafe paths, truncated or
375/// trailing data, invalid hashes, or inconsistent member and target metadata.
376pub 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
433/// Extracts files below an artifact prefix into a missing or empty directory.
434///
435/// Returns the number of extracted files.
436///
437/// # Errors
438///
439/// Returns an error when the prefix is absent, a path is unsafe, the destination is non-empty, or
440/// a filesystem operation fails.
441pub 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
484/// Normalizes a non-empty relative path and rejects traversal or platform prefixes.
485///
486/// # Errors
487///
488/// Returns an error for empty, absolute, parent, current-directory, or prefixed paths.
489pub 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(&current)?.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}