1use sha2::{Digest as _, Sha256};
4use std::{
5 fs::File,
6 io::{Read, Seek, SeekFrom},
7 path::{Path, PathBuf},
8};
9
10#[derive(Debug, Clone, Eq, PartialEq)]
12pub struct ArtifactFile {
13 logical_role: String,
14 path: PathBuf,
15}
16
17impl ArtifactFile {
18 pub fn new(logical_role: impl Into<String>, path: impl Into<PathBuf>) -> Self {
20 Self {
21 logical_role: logical_role.into(),
22 path: path.into(),
23 }
24 }
25
26 pub fn logical_role(&self) -> &str {
28 &self.logical_role
29 }
30
31 pub fn path(&self) -> &Path {
33 &self.path
34 }
35}
36
37#[derive(Debug, Clone, Eq, PartialEq)]
39pub struct ArtifactMemberFingerprint {
40 logical_role: String,
41 length: u64,
42 digest: [u8; 32],
43}
44
45impl ArtifactMemberFingerprint {
46 pub fn logical_role(&self) -> &str {
48 &self.logical_role
49 }
50
51 pub const fn length(&self) -> u64 {
53 self.length
54 }
55
56 pub const fn digest(&self) -> [u8; 32] {
58 self.digest
59 }
60}
61
62#[derive(Debug, Clone, Copy, Eq, PartialEq)]
63pub(crate) struct FileContentFingerprint {
64 pub(crate) length: u64,
65 pub(crate) digest: [u8; 32],
66}
67
68#[derive(Debug, thiserror::Error)]
70pub enum ArtifactFingerprintError {
71 #[error("failed to {action} artifact file {path}: {source}", path = .path.display())]
73 Io {
74 action: &'static str,
76 path: PathBuf,
78 #[source]
80 source: std::io::Error,
81 },
82 #[error("artifact file changed while being fingerprinted: {path}", path = .path.display())]
84 Changed {
85 path: PathBuf,
87 },
88}
89
90pub fn fingerprint_artifact_files(
96 files: impl IntoIterator<Item = ArtifactFile>,
97) -> Result<Vec<ArtifactMemberFingerprint>, ArtifactFingerprintError> {
98 files
99 .into_iter()
100 .map(|member| {
101 let mut file = File::open(member.path())
102 .map_err(|source| io_error("open", &member.path, source))?;
103 let fingerprint = fingerprint_open_file(member.path(), &mut file)?;
104 Ok(ArtifactMemberFingerprint {
105 logical_role: member.logical_role,
106 length: fingerprint.length,
107 digest: fingerprint.digest,
108 })
109 })
110 .collect()
111}
112
113pub(crate) fn fingerprint_open_file(
114 path: &Path,
115 file: &mut File,
116) -> Result<FileContentFingerprint, ArtifactFingerprintError> {
117 fingerprint_open_file_with_hook(path, file, || {})
118}
119
120fn fingerprint_open_file_with_hook(
121 path: &Path,
122 file: &mut File,
123 after_first_pass: impl FnOnce(),
124) -> Result<FileContentFingerprint, ArtifactFingerprintError> {
125 let before = StableFileMetadata::read(path, file)?;
126 let first = digest_pass(path, file)?;
127 after_first_pass();
128 let between = StableFileMetadata::read(path, file)?;
129 let second = digest_pass(path, file)?;
130 let after = StableFileMetadata::read(path, file)?;
131 if before != between || between != after || first != second || first.length != before.length {
132 return Err(ArtifactFingerprintError::Changed {
133 path: path.to_path_buf(),
134 });
135 }
136 Ok(first)
137}
138
139#[derive(Debug, Clone, Copy, Eq, PartialEq)]
140struct StableFileMetadata {
141 length: u64,
142 modified: std::time::SystemTime,
143 #[cfg(unix)]
144 device: u64,
145 #[cfg(unix)]
146 inode: u64,
147 #[cfg(unix)]
148 change_time_seconds: i64,
149 #[cfg(unix)]
150 change_time_nanoseconds: i64,
151 #[cfg(not(unix))]
152 created: Option<std::time::SystemTime>,
153 #[cfg(windows)]
154 file_attributes: u32,
155 #[cfg(windows)]
156 creation_time: u64,
157}
158
159impl StableFileMetadata {
160 fn read(path: &Path, file: &File) -> Result<Self, ArtifactFingerprintError> {
161 #[cfg(unix)]
162 use std::os::unix::fs::MetadataExt as _;
163
164 let metadata = file
165 .metadata()
166 .map_err(|source| io_error("inspect", path, source))?;
167 Ok(Self {
168 length: metadata.len(),
169 modified: metadata
170 .modified()
171 .map_err(|source| io_error("inspect", path, source))?,
172 #[cfg(unix)]
173 device: metadata.dev(),
174 #[cfg(unix)]
175 inode: metadata.ino(),
176 #[cfg(unix)]
177 change_time_seconds: metadata.ctime(),
178 #[cfg(unix)]
179 change_time_nanoseconds: metadata.ctime_nsec(),
180 #[cfg(not(unix))]
183 created: metadata.created().ok(),
184 #[cfg(windows)]
185 file_attributes: {
186 use std::os::windows::fs::MetadataExt as _;
187 metadata.file_attributes()
188 },
189 #[cfg(windows)]
190 creation_time: {
191 use std::os::windows::fs::MetadataExt as _;
192 metadata.creation_time()
193 },
194 })
195 }
196}
197
198fn digest_pass(
199 path: &Path,
200 file: &mut File,
201) -> Result<FileContentFingerprint, ArtifactFingerprintError> {
202 file.seek(SeekFrom::Start(0))
203 .map_err(|source| io_error("seek", path, source))?;
204 let mut hasher = Sha256::new();
205 let mut length = 0_u64;
206 let mut buffer = vec![0_u8; 1024 * 1024];
207 loop {
208 let read = file
209 .read(&mut buffer)
210 .map_err(|source| io_error("read", path, source))?;
211 if read == 0 {
212 break;
213 }
214 hasher.update(&buffer[..read]);
215 length =
216 length
217 .checked_add(read as u64)
218 .ok_or_else(|| ArtifactFingerprintError::Changed {
219 path: path.to_path_buf(),
220 })?;
221 }
222 Ok(FileContentFingerprint {
223 length,
224 digest: hasher.finalize().into(),
225 })
226}
227
228fn io_error(action: &'static str, path: &Path, source: std::io::Error) -> ArtifactFingerprintError {
229 ArtifactFingerprintError::Io {
230 action,
231 path: path.to_path_buf(),
232 source,
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use std::io::Write as _;
240
241 #[test]
242 fn double_pass_rejects_same_length_change_with_restored_metadata() {
243 let directory = tempfile::tempdir().unwrap();
244 let path = directory.path().join("member");
245 std::fs::write(&path, b"first-content").unwrap();
246 let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
247 let mut admitted = File::open(&path).unwrap();
248 let result = fingerprint_open_file_with_hook(&path, &mut admitted, || {
249 let mut attacker = File::options()
250 .write(true)
251 .truncate(true)
252 .open(&path)
253 .unwrap();
254 attacker.write_all(b"other-content").unwrap();
255 attacker
256 .set_times(std::fs::FileTimes::new().set_modified(modified))
257 .unwrap();
258 });
259 assert!(matches!(
260 result,
261 Err(ArtifactFingerprintError::Changed { .. })
262 ));
263 }
264}