Skip to main content

eredu_checkpoint/
artifact.rs

1//! Filesystem-backed artifact content fingerprinting.
2
3use sha2::{Digest as _, Sha256};
4use std::{
5    fs::File,
6    io::{Read, Seek, SeekFrom},
7    path::{Path, PathBuf},
8};
9
10/// One filesystem member and its location-independent logical artifact role.
11#[derive(Debug, Clone, Eq, PartialEq)]
12pub struct ArtifactFile {
13    logical_role: String,
14    path: PathBuf,
15}
16
17impl ArtifactFile {
18    /// Creates a logical artifact member.
19    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    /// Stable role used in the artifact layout.
27    pub fn logical_role(&self) -> &str {
28        &self.logical_role
29    }
30
31    /// Filesystem path supplying this member's bytes.
32    pub fn path(&self) -> &Path {
33        &self.path
34    }
35}
36
37/// Exact content facts for one logical artifact member.
38#[derive(Debug, Clone, Eq, PartialEq)]
39pub struct ArtifactMemberFingerprint {
40    logical_role: String,
41    length: u64,
42    digest: [u8; 32],
43}
44
45impl ArtifactMemberFingerprint {
46    /// Stable logical role of the member.
47    pub fn logical_role(&self) -> &str {
48        &self.logical_role
49    }
50
51    /// Exact admitted byte length.
52    pub const fn length(&self) -> u64 {
53        self.length
54    }
55
56    /// SHA-256 of the exact admitted bytes.
57    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/// Filesystem-backed artifact fingerprinting failure.
69#[derive(Debug, thiserror::Error)]
70pub enum ArtifactFingerprintError {
71    /// Opening, inspecting, or reading one member failed.
72    #[error("failed to {action} artifact file {path}: {source}", path = .path.display())]
73    Io {
74        /// Failed operation.
75        action: &'static str,
76        /// Affected filesystem member.
77        path: PathBuf,
78        /// Underlying I/O failure.
79        #[source]
80        source: std::io::Error,
81    },
82    /// The source changed while a stable content snapshot was being read.
83    #[error("artifact file changed while being fingerprinted: {path}", path = .path.display())]
84    Changed {
85        /// Affected filesystem member.
86        path: PathBuf,
87    },
88}
89
90/// Reads stable exact-content fingerprints for filesystem artifact members.
91///
92/// Every pinned file is read twice with metadata checks before, between, and
93/// after the passes. A result is returned only when both complete content
94/// digests and all observable file identity facts agree.
95pub 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            // The portable Metadata API exposes no ctime/change counter on
181            // non-Unix targets. Creation time is retained when available.
182            #[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}