1use std::collections::HashSet;
2
3use crate::{
4 framed_tuple_payload, hash_canonical_payload, hash_raw_bytes, require_profile, ArtifactFile,
5 CanonicalizationProfile, HashError, HashId, Kind, TupleField,
6};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ArtifactEntryKind {
10 File,
11 Symlink,
12}
13
14#[derive(Debug, Clone, Copy)]
15pub struct ArtifactTreeEntry<'a> {
16 pub path: &'a str,
17 pub bytes: &'a [u8],
18 pub kind: ArtifactEntryKind,
19}
20
21impl<'a> ArtifactTreeEntry<'a> {
22 pub const fn file(path: &'a str, bytes: &'a [u8]) -> Self {
23 Self {
24 path,
25 bytes,
26 kind: ArtifactEntryKind::File,
27 }
28 }
29
30 pub const fn symlink(path: &'a str) -> Self {
31 Self {
32 path,
33 bytes: &[],
34 kind: ArtifactEntryKind::Symlink,
35 }
36 }
37}
38
39pub fn validate_artifact_path(path: &str) -> Result<(), HashError> {
40 let invalid = |reason| HashError::InvalidArtifactPath {
41 path: path.to_string(),
42 reason,
43 };
44
45 if path.is_empty() {
46 return Err(invalid("path must contain at least one segment"));
47 }
48 if path.starts_with('/') || path.ends_with('/') {
49 return Err(invalid("leading and trailing slashes are forbidden"));
50 }
51 if path.contains("//") {
52 return Err(invalid("repeated slashes are forbidden"));
53 }
54 if path.contains('\\') {
55 return Err(invalid("backslashes are forbidden"));
56 }
57 if path.contains('\0') {
58 return Err(invalid("NUL bytes are forbidden"));
59 }
60 if path
61 .split('/')
62 .any(|segment| segment.is_empty() || segment == "." || segment == "..")
63 {
64 return Err(invalid("empty, '.' and '..' segments are forbidden"));
65 }
66 Ok(())
67}
68
69pub fn artifact_tree_payload(entries: &[ArtifactTreeEntry<'_>]) -> Result<Vec<u8>, HashError> {
70 let mut entries = entries.to_vec();
71 for entry in &entries {
72 validate_artifact_path(entry.path)?;
73 if entry.kind == ArtifactEntryKind::Symlink {
74 return Err(HashError::SymlinkArtifact(entry.path.to_string()));
75 }
76 }
77 entries.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
78
79 let mut seen = HashSet::with_capacity(entries.len());
80 let mut payload = Vec::new();
81 payload.extend_from_slice(&(entries.len() as u64).to_be_bytes());
82 for entry in entries {
83 if !seen.insert(entry.path) {
84 return Err(HashError::DuplicateArtifactPath(entry.path.to_string()));
85 }
86 let file_hash = hash_raw_bytes::<ArtifactFile>(entry.bytes)?.to_string();
87 let leaf = framed_tuple_payload(&[
88 TupleField::new("path", entry.path.as_bytes()),
89 TupleField::new("fileHash", file_hash.as_bytes()),
90 ])?;
91 payload.extend_from_slice(&leaf);
92 }
93 Ok(payload)
94}
95
96pub fn hash_artifact_tree<K: Kind>(
97 entries: &[ArtifactTreeEntry<'_>],
98) -> Result<HashId<K>, HashError> {
99 require_profile::<K>(CanonicalizationProfile::ArtifactTreeV1)?;
100 let payload = artifact_tree_payload(entries)?;
101 Ok(hash_canonical_payload(&payload))
102}