Skip to main content

arete_hash/
tree.rs

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}