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#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Sha256Manifest {
14 digest: [u8; 32],
15 filename: Option<String>,
16}
17
18impl Sha256Manifest {
19 pub fn digest(&self) -> &[u8; 32] {
21 &self.digest
22 }
23
24 pub fn filename(&self) -> Option<&str> {
26 self.filename.as_deref()
27 }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum IntegrityStatus {
33 Verified,
35 NotRequired,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct IntegrityResult {
42 member: MemberId,
43 digest: [u8; 32],
44 status: IntegrityStatus,
45}
46
47impl IntegrityResult {
48 pub fn member(&self) -> &MemberId {
50 &self.member
51 }
52
53 pub fn digest(&self) -> &[u8; 32] {
55 &self.digest
56 }
57
58 pub fn status(&self) -> IntegrityStatus {
60 self.status
61 }
62}
63
64pub 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
89pub 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
130pub 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#[derive(Debug)]
186pub struct VerifiedTransaction {
187 pub(crate) prepared: PreparedTransaction,
188 results: Vec<IntegrityResult>,
189}
190
191impl PreparedTransaction {
192 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 pub fn artifacts(&self) -> &crate::domain::ArtifactSet {
248 self.prepared.artifacts()
249 }
250
251 pub fn product(&self) -> &crate::domain::ProductId {
253 self.prepared.product()
254 }
255
256 pub fn release(&self) -> &crate::domain::ReleaseId {
258 self.prepared.release()
259 }
260
261 pub fn stage_root(&self) -> &Path {
263 self.prepared.stage_root()
264 }
265
266 pub fn staged_path(&self, member: &MemberId) -> Result<PathBuf> {
273 self.prepared.staged_path(member)
274 }
275
276 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}