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, or duplicate capability ids.
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
239    Ok(LoadedPack {
240        root,
241        manifest,
242        members,
243    })
244}
245
246/// Writes a source pack manifest as deterministic TOML.
247///
248/// # Errors
249///
250/// Returns an error when serialization or filesystem writing fails.
251pub fn write_manifest(path: &Path, manifest: &PackManifest) -> Result<()> {
252    fs::write(path, toml::to_string_pretty(manifest)?)?;
253    Ok(())
254}
255
256/// Collects the canonical manifest and declared source files for every pack member.
257///
258/// # Errors
259///
260/// Returns an error when a source is missing, unsafe, linked, outside the pack, or duplicated.
261pub 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
291/// Writes a deterministic pack artifact and returns its hexadecimal SHA-256 digest.
292///
293/// # Errors
294///
295/// Returns an error for unsafe or duplicate artifact paths, oversized metadata, an existing
296/// output path, serialization failure, or filesystem failure.
297pub 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
361/// Reads and verifies a complete pack artifact.
362///
363/// # Errors
364///
365/// Returns an error for unsupported versions, non-canonical metadata, unsafe paths, truncated or
366/// trailing data, invalid hashes, or inconsistent member and target metadata.
367pub 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
377/// Parses and verifies a complete pack artifact already loaded in memory.
378///
379/// # Errors
380///
381/// Returns an error for unsupported versions, non-canonical metadata, unsafe paths, truncated or
382/// trailing data, invalid hashes, or inconsistent member and target metadata.
383pub 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
440/// Extracts files below an artifact prefix into a missing or empty directory.
441///
442/// Returns the number of extracted files.
443///
444/// # Errors
445///
446/// Returns an error when the prefix is absent, a path is unsafe, the destination is non-empty, or
447/// a filesystem operation fails.
448pub 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
491/// Normalizes a non-empty relative path and rejects traversal or platform prefixes.
492///
493/// # Errors
494///
495/// Returns an error for empty, absolute, parent, current-directory, or prefixed paths.
496pub 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        // An artifact comes from a registry, and each id becomes the
633        // directory the member installs into and the name recorded in the
634        // lockfile, so it gets the same rule as a manifest id.
635        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(&current)?.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        // The artifact comes from a registry; its member ids become install
904        // directories and lockfile names.
905        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}