Skip to main content

eggup_core/
integrity.rs

1use std::fs::{self, File};
2use std::io::Read;
3use std::path::{Path, PathBuf};
4
5use sha2::{Digest, Sha256};
6
7use crate::domain::{IntegrityRequirement, MemberId};
8use crate::error::{Error, Result};
9use crate::stage::PreparedTransaction;
10
11/// A parsed single-entry SHA-256 sidecar or manifest line.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Sha256Manifest {
14    digest: [u8; 32],
15    filename: Option<String>,
16}
17
18impl Sha256Manifest {
19    /// Returns the declared digest bytes.
20    pub fn digest(&self) -> &[u8; 32] {
21        &self.digest
22    }
23
24    /// Returns the exact optional filename binding.
25    pub fn filename(&self) -> Option<&str> {
26        self.filename.as_deref()
27    }
28}
29
30/// The result of applying a member's integrity declaration to staged bytes.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum IntegrityStatus {
33    /// The declared SHA-256 digest matched the staged bytes.
34    Verified,
35    /// The member explicitly declared that no integrity evidence is required.
36    NotRequired,
37}
38
39/// One member's bounded integrity result.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct IntegrityResult {
42    member: MemberId,
43    digest: [u8; 32],
44    status: IntegrityStatus,
45}
46
47impl IntegrityResult {
48    /// Returns the member identity.
49    pub fn member(&self) -> &MemberId {
50        &self.member
51    }
52
53    /// Returns the computed staged-file digest.
54    pub fn digest(&self) -> &[u8; 32] {
55        &self.digest
56    }
57
58    /// Returns whether the declared integrity requirement passed or was absent.
59    pub fn status(&self) -> IntegrityStatus {
60        self.status
61    }
62}
63
64/// Streams a local file through SHA-256 without buffering the complete artifact.
65///
66/// # Errors
67///
68/// Returns [`Error::Io`] when the file cannot be opened or read.
69///
70/// The digest is integrity evidence only: it is not a publisher-trust or
71/// signature claim, and this crate contains no authenticity verifier.
72pub fn hash_file(path: &Path) -> Result<[u8; 32]> {
73    let mut file =
74        File::open(path).map_err(|source| Error::io("opening file for hashing", source))?;
75    let mut hasher = Sha256::new();
76    let mut buffer = [0_u8; 64 * 1024];
77    loop {
78        let count = file
79            .read(&mut buffer)
80            .map_err(|source| Error::io("reading file for hashing", source))?;
81        if count == 0 {
82            break;
83        }
84        hasher.update(&buffer[..count]);
85    }
86    Ok(hasher.finalize().into())
87}
88
89/// Parses exactly one strict SHA-256 sidecar line.
90///
91/// # Errors
92///
93/// Returns [`Error::VerificationFailed`] when the sidecar does not contain
94/// exactly one non-empty entry, when the digest is not exactly 64
95/// hexadecimal characters, or when the filename is not one exact non-control
96/// filename.
97pub fn parse_sha256_sidecar(input: &str) -> Result<Sha256Manifest> {
98    let lines = input
99        .lines()
100        .map(str::trim)
101        .filter(|line| !line.is_empty())
102        .collect::<Vec<_>>();
103    if lines.len() != 1 {
104        return Err(Error::VerificationFailed(
105            "SHA-256 sidecar must contain exactly one non-empty entry".into(),
106        ));
107    }
108    let mut fields = lines[0].splitn(2, char::is_whitespace);
109    let digest_text = fields.next().unwrap_or_default();
110    let digest = decode_digest(digest_text)?;
111    let filename = fields
112        .next()
113        .map(str::trim)
114        .filter(|value| !value.is_empty());
115    let filename = filename.map(|value| value.strip_prefix('*').unwrap_or(value).to_owned());
116    if filename.as_deref().is_some_and(|value| {
117        value.is_empty()
118            || value.chars().any(char::is_control)
119            || Path::new(value)
120                .file_name()
121                .is_none_or(|name| name != value)
122    }) {
123        return Err(Error::VerificationFailed(
124            "sidecar filename must be one exact non-control filename".into(),
125        ));
126    }
127    Ok(Sha256Manifest { digest, filename })
128}
129
130/// Verifies a local file against a parsed SHA-256 declaration.
131///
132/// # Errors
133///
134/// Returns [`Error::VerificationFailed`] when the manifest's filename does
135/// not bind to `path`, and [`Error::Io`] when the file cannot be read.
136///
137/// A matching digest is integrity evidence only; it is never a publisher-trust
138/// or signature claim.
139pub fn verify_file(path: &Path, manifest: &Sha256Manifest) -> Result<[u8; 32]> {
140    if let Some(expected_name) = manifest.filename() {
141        let actual_name = path
142            .file_name()
143            .and_then(|name| name.to_str())
144            .ok_or_else(|| Error::VerificationFailed("candidate has no valid filename".into()))?;
145        if actual_name != expected_name {
146            return Err(Error::VerificationFailed(format!(
147                "sidecar filename {expected_name:?} does not bind to {actual_name:?}"
148            )));
149        }
150    }
151    let digest = hash_file(path)?;
152    if &digest != manifest.digest() {
153        return Err(Error::VerificationFailed(format!(
154            "SHA-256 mismatch for {}",
155            path.display()
156        )));
157    }
158    Ok(digest)
159}
160
161fn decode_digest(text: &str) -> Result<[u8; 32]> {
162    if text.len() != 64 || !text.bytes().all(|byte| byte.is_ascii_hexdigit()) {
163        return Err(Error::VerificationFailed(
164            "SHA-256 digest must contain exactly 64 hexadecimal characters".into(),
165        ));
166    }
167    let mut digest = [0_u8; 32];
168    for (index, slot) in digest.iter_mut().enumerate() {
169        let offset = index * 2;
170        *slot = (hex_value(text.as_bytes()[offset]) << 4) | hex_value(text.as_bytes()[offset + 1]);
171    }
172    Ok(digest)
173}
174
175fn hex_value(byte: u8) -> u8 {
176    match byte {
177        b'0'..=b'9' => byte - b'0',
178        b'a'..=b'f' => byte - b'a' + 10,
179        b'A'..=b'F' => byte - b'A' + 10,
180        _ => unreachable!("validated hexadecimal input"),
181    }
182}
183
184/// A prepared transaction whose declared integrity requirements have passed.
185#[derive(Debug)]
186pub struct VerifiedTransaction {
187    pub(crate) prepared: PreparedTransaction,
188    results: Vec<IntegrityResult>,
189}
190
191impl PreparedTransaction {
192    /// Applies every member's declared integrity requirement before candidate validation.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`Error::VerificationFailed`] when a staged member is not a
197    /// regular file or when its bytes do not match the member's declared
198    /// SHA-256 digest, and [`Error::Io`] when a staged member cannot be read
199    /// or hashed.
200    ///
201    /// A member declared
202    /// [`IntegrityRequirement::None`](crate::IntegrityRequirement::None) is
203    /// observable here as `NotRequired` and is rejected later: validation
204    /// refuses it and the final staged-digest revalidation requires a verified
205    /// digest for every member. There is no escape hatch.
206    pub fn verify_integrity(self) -> Result<VerifiedTransaction> {
207        let members = self.artifacts().iter().cloned().collect::<Vec<_>>();
208        let mut results = Vec::with_capacity(members.len());
209        for member in members {
210            let path = self.staged_path(member.id())?;
211            let metadata = fs::symlink_metadata(&path)
212                .map_err(|source| Error::io("reading staged candidate", source))?;
213            if !metadata.is_file() {
214                return Err(Error::VerificationFailed(format!(
215                    "staged member {} is not a regular file",
216                    member.id()
217                )));
218            }
219            let digest = hash_file(&path)?;
220            let status = match member.integrity() {
221                IntegrityRequirement::None => IntegrityStatus::NotRequired,
222                IntegrityRequirement::Sha256(expected) if expected == digest => {
223                    IntegrityStatus::Verified
224                }
225                IntegrityRequirement::Sha256(_) => {
226                    return Err(Error::VerificationFailed(format!(
227                        "SHA-256 mismatch for member {}",
228                        member.id()
229                    )));
230                }
231            };
232            results.push(IntegrityResult {
233                member: member.id().clone(),
234                digest,
235                status,
236            });
237        }
238        Ok(VerifiedTransaction {
239            prepared: self,
240            results,
241        })
242    }
243}
244
245impl VerifiedTransaction {
246    /// Returns the validated artifact set.
247    pub fn artifacts(&self) -> &crate::domain::ArtifactSet {
248        self.prepared.artifacts()
249    }
250
251    /// Returns the product identity.
252    pub fn product(&self) -> &crate::domain::ProductId {
253        self.prepared.product()
254    }
255
256    /// Returns the release identity.
257    pub fn release(&self) -> &crate::domain::ReleaseId {
258        self.prepared.release()
259    }
260
261    /// Returns the private stage root for bounded candidate execution.
262    pub fn stage_root(&self) -> &Path {
263        self.prepared.stage_root()
264    }
265
266    /// Returns a verified member's private staged path.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`Error::UnknownMember`] when `member` is not part of this
271    /// transaction's artifact set.
272    pub fn staged_path(&self, member: &MemberId) -> Result<PathBuf> {
273        self.prepared.staged_path(member)
274    }
275
276    /// Returns the integrity result for one member.
277    pub fn integrity(&self, member: &MemberId) -> Option<&IntegrityResult> {
278        self.results.iter().find(|result| result.member() == member)
279    }
280
281    pub(crate) fn verified_digests(&self) -> std::collections::HashMap<MemberId, [u8; 32]> {
282        self.results
283            .iter()
284            .filter(|r| r.status() == IntegrityStatus::Verified)
285            .map(|r| (r.member().clone(), *r.digest()))
286            .collect()
287    }
288}