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]> {
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
82pub 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
116pub 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#[derive(Debug)]
164pub struct VerifiedTransaction {
165 pub(crate) prepared: PreparedTransaction,
166 results: Vec<IntegrityResult>,
167}
168
169impl PreparedTransaction {
170 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 pub fn artifacts(&self) -> &crate::domain::ArtifactSet {
213 self.prepared.artifacts()
214 }
215
216 pub fn product(&self) -> &crate::domain::ProductId {
218 self.prepared.product()
219 }
220
221 pub fn release(&self) -> &crate::domain::ReleaseId {
223 self.prepared.release()
224 }
225
226 pub fn stage_root(&self) -> &Path {
228 self.prepared.stage_root()
229 }
230
231 pub fn staged_path(&self, member: &MemberId) -> Result<PathBuf> {
233 self.prepared.staged_path(member)
234 }
235
236 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}