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.
65pub fn hash_file(path: &Path) -> Result<[u8; 32]> {
66    let mut file =
67        File::open(path).map_err(|source| Error::io("opening file for hashing", source))?;
68    let mut hasher = Sha256::new();
69    let mut buffer = [0_u8; 64 * 1024];
70    loop {
71        let count = file
72            .read(&mut buffer)
73            .map_err(|source| Error::io("reading file for hashing", source))?;
74        if count == 0 {
75            break;
76        }
77        hasher.update(&buffer[..count]);
78    }
79    Ok(hasher.finalize().into())
80}
81
82/// Parses exactly one strict SHA-256 sidecar line.
83pub fn parse_sha256_sidecar(input: &str) -> Result<Sha256Manifest> {
84    let lines = input
85        .lines()
86        .map(str::trim)
87        .filter(|line| !line.is_empty())
88        .collect::<Vec<_>>();
89    if lines.len() != 1 {
90        return Err(Error::VerificationFailed(
91            "SHA-256 sidecar must contain exactly one non-empty entry".into(),
92        ));
93    }
94    let mut fields = lines[0].splitn(2, char::is_whitespace);
95    let digest_text = fields.next().unwrap_or_default();
96    let digest = decode_digest(digest_text)?;
97    let filename = fields
98        .next()
99        .map(str::trim)
100        .filter(|value| !value.is_empty());
101    let filename = filename.map(|value| value.strip_prefix('*').unwrap_or(value).to_owned());
102    if filename.as_deref().is_some_and(|value| {
103        value.is_empty()
104            || value.chars().any(char::is_control)
105            || Path::new(value)
106                .file_name()
107                .is_none_or(|name| name != value)
108    }) {
109        return Err(Error::VerificationFailed(
110            "sidecar filename must be one exact non-control filename".into(),
111        ));
112    }
113    Ok(Sha256Manifest { digest, filename })
114}
115
116/// Verifies a local file against a parsed SHA-256 declaration.
117pub fn verify_file(path: &Path, manifest: &Sha256Manifest) -> Result<[u8; 32]> {
118    if let Some(expected_name) = manifest.filename() {
119        let actual_name = path
120            .file_name()
121            .and_then(|name| name.to_str())
122            .ok_or_else(|| Error::VerificationFailed("candidate has no valid filename".into()))?;
123        if actual_name != expected_name {
124            return Err(Error::VerificationFailed(format!(
125                "sidecar filename {expected_name:?} does not bind to {actual_name:?}"
126            )));
127        }
128    }
129    let digest = hash_file(path)?;
130    if &digest != manifest.digest() {
131        return Err(Error::VerificationFailed(format!(
132            "SHA-256 mismatch for {}",
133            path.display()
134        )));
135    }
136    Ok(digest)
137}
138
139fn decode_digest(text: &str) -> Result<[u8; 32]> {
140    if text.len() != 64 || !text.bytes().all(|byte| byte.is_ascii_hexdigit()) {
141        return Err(Error::VerificationFailed(
142            "SHA-256 digest must contain exactly 64 hexadecimal characters".into(),
143        ));
144    }
145    let mut digest = [0_u8; 32];
146    for (index, slot) in digest.iter_mut().enumerate() {
147        let offset = index * 2;
148        *slot = (hex_value(text.as_bytes()[offset]) << 4) | hex_value(text.as_bytes()[offset + 1]);
149    }
150    Ok(digest)
151}
152
153fn hex_value(byte: u8) -> u8 {
154    match byte {
155        b'0'..=b'9' => byte - b'0',
156        b'a'..=b'f' => byte - b'a' + 10,
157        b'A'..=b'F' => byte - b'A' + 10,
158        _ => unreachable!("validated hexadecimal input"),
159    }
160}
161
162/// A prepared transaction whose declared integrity requirements have passed.
163#[derive(Debug)]
164pub struct VerifiedTransaction {
165    pub(crate) prepared: PreparedTransaction,
166    results: Vec<IntegrityResult>,
167}
168
169impl PreparedTransaction {
170    /// Applies every member's declared integrity requirement before candidate validation.
171    pub fn verify_integrity(self) -> Result<VerifiedTransaction> {
172        let members = self.artifacts().iter().cloned().collect::<Vec<_>>();
173        let mut results = Vec::with_capacity(members.len());
174        for member in members {
175            let path = self.staged_path(member.id())?;
176            let metadata = fs::symlink_metadata(&path)
177                .map_err(|source| Error::io("reading staged candidate", source))?;
178            if !metadata.is_file() {
179                return Err(Error::VerificationFailed(format!(
180                    "staged member {} is not a regular file",
181                    member.id()
182                )));
183            }
184            let digest = hash_file(&path)?;
185            let status = match member.integrity() {
186                IntegrityRequirement::None => IntegrityStatus::NotRequired,
187                IntegrityRequirement::Sha256(expected) if expected == digest => {
188                    IntegrityStatus::Verified
189                }
190                IntegrityRequirement::Sha256(_) => {
191                    return Err(Error::VerificationFailed(format!(
192                        "SHA-256 mismatch for member {}",
193                        member.id()
194                    )));
195                }
196            };
197            results.push(IntegrityResult {
198                member: member.id().clone(),
199                digest,
200                status,
201            });
202        }
203        Ok(VerifiedTransaction {
204            prepared: self,
205            results,
206        })
207    }
208}
209
210impl VerifiedTransaction {
211    /// Returns the validated artifact set.
212    pub fn artifacts(&self) -> &crate::domain::ArtifactSet {
213        self.prepared.artifacts()
214    }
215
216    /// Returns the product identity.
217    pub fn product(&self) -> &crate::domain::ProductId {
218        self.prepared.product()
219    }
220
221    /// Returns the release identity.
222    pub fn release(&self) -> &crate::domain::ReleaseId {
223        self.prepared.release()
224    }
225
226    /// Returns the private stage root for bounded candidate execution.
227    pub fn stage_root(&self) -> &Path {
228        self.prepared.stage_root()
229    }
230
231    /// Returns a verified member's private staged path.
232    pub fn staged_path(&self, member: &MemberId) -> Result<PathBuf> {
233        self.prepared.staged_path(member)
234    }
235
236    /// Returns the integrity result for one member.
237    pub fn integrity(&self, member: &MemberId) -> Option<&IntegrityResult> {
238        self.results.iter().find(|result| result.member() == member)
239    }
240
241    pub(crate) fn verified_digests(&self) -> std::collections::HashMap<MemberId, [u8; 32]> {
242        self.results
243            .iter()
244            .filter(|r| r.status() == IntegrityStatus::Verified)
245            .map(|r| (r.member().clone(), *r.digest()))
246            .collect()
247    }
248}