1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
//! Hash utilities for stdlib path filters.
//!
//! Streams SHA-256 and SHA-512 digests via cap-std handles,
//! enables SHA-1 and MD5 behind the `legacy-digests` feature,
//! and always returns lowercase hexadecimal output.
use std::io::Read;
use camino::Utf8Path;
use digest::Digest;
#[cfg(feature = "legacy-digests")]
use md5::Md5;
use minijinja::{Error, ErrorKind};
#[cfg(feature = "legacy-digests")]
use sha1::Sha1;
use sha2::{Sha256, Sha512};
use super::fs_utils;
use crate::hex::to_lower_hex;
use crate::localization::{self, keys};
use crate::stdlib::io_helpers::io_to_error;
pub(super) fn compute_hash(path: &Utf8Path, alg: &str) -> Result<String, Error> {
if alg.eq_ignore_ascii_case("sha256") {
hash_stream::<Sha256>(path)
} else if alg.eq_ignore_ascii_case("sha512") {
hash_stream::<Sha512>(path)
} else if alg.eq_ignore_ascii_case("sha1") {
#[cfg(feature = "legacy-digests")]
{
hash_stream::<Sha1>(path)
}
#[cfg(not(feature = "legacy-digests"))]
{
Err(Error::new(
ErrorKind::InvalidOperation,
localization::message(keys::STDLIB_PATH_HASH_UNSUPPORTED_ALGORITHM_LEGACY)
.with_arg("algorithm", "sha1")
.with_arg("feature", "legacy-digests")
.to_string(),
))
}
} else if alg.eq_ignore_ascii_case("md5") {
#[cfg(feature = "legacy-digests")]
{
hash_stream::<Md5>(path)
}
#[cfg(not(feature = "legacy-digests"))]
{
Err(Error::new(
ErrorKind::InvalidOperation,
localization::message(keys::STDLIB_PATH_HASH_UNSUPPORTED_ALGORITHM_LEGACY)
.with_arg("algorithm", "md5")
.with_arg("feature", "legacy-digests")
.to_string(),
))
}
} else {
Err(Error::new(
ErrorKind::InvalidOperation,
localization::message(keys::STDLIB_PATH_HASH_UNSUPPORTED_ALGORITHM)
.with_arg("algorithm", alg)
.to_string(),
))
}
}
pub(super) fn compute_digest(path: &Utf8Path, len: usize, alg: &str) -> Result<String, Error> {
let mut hash = compute_hash(path, alg)?;
if len < hash.len() {
hash.truncate(len);
}
Ok(hash)
}
fn hash_stream<H>(path: &Utf8Path) -> Result<String, Error>
where
H: Digest,
{
let mut file = fs_utils::open_file(path)?;
let mut hasher = H::new();
let mut buffer = [0_u8; 8192];
loop {
let read = file.read(&mut buffer).map_err(|err| {
io_to_error(
path,
&localization::message(keys::STDLIB_PATH_ACTION_READ),
err,
)
})?;
if read == 0 {
break;
}
// A well-behaved Read never reports more bytes than the buffer holds;
// clamp to the full buffer rather than panicking on a misbehaving
// implementation, but surface the anomaly for diagnosis.
let chunk = if let Some(chunk) = buffer.get(..read) {
chunk
} else {
tracing::debug!(
read,
capacity = buffer.len(),
"Read reported more bytes than the buffer holds; clamping to full buffer"
);
&buffer
};
hasher.update(chunk);
}
Ok(to_lower_hex(&hasher.finalize()))
}
#[cfg(test)]
mod tests {
//! Tests for the chunked digest-streaming loop.
//!
//! `hash_stream` feeds the hasher from a fixed 8192-byte buffer because the
//! `RustCrypto` 0.11 hashers no longer implement `io::Write`, so `io::copy`
//! is unavailable. The loop is the part that could silently drop, reorder,
//! or double-count a chunk, and only inputs larger than the buffer exercise
//! more than one iteration of it.
use anyhow::{Result, anyhow, ensure};
use camino::Utf8PathBuf;
use cap_std::{ambient_authority, fs_utf8::Dir};
use rstest::rstest;
use sha2::{Digest, Sha256};
use tempfile::TempDir;
use super::{compute_hash, to_lower_hex};
/// Name of the fixture file staged inside the temporary directory.
const FIXTURE_NAME: &str = "payload";
/// Write `payload` to a temporary file and return the directory guard
/// alongside the file's path.
///
/// The write goes through a `cap_std` directory capability rather than
/// ambient `std::fs`, matching the convention the sibling test modules
/// follow. The guard must outlive the returned path: dropping it removes
/// the file.
fn fixture(payload: &[u8]) -> Result<(TempDir, Utf8PathBuf)> {
let dir = tempfile::tempdir()?;
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
.map_err(|path| anyhow!("temporary path is not valid UTF-8: {path:?}"))?;
let handle = Dir::open_ambient_dir(&root, ambient_authority())?;
handle.write(FIXTURE_NAME, payload)?;
Ok((dir, root.join(FIXTURE_NAME)))
}
/// A repeating, non-constant byte pattern of `size` bytes.
///
/// Cycling `0..=u8::MAX` means a chunk dropped, reordered, or counted twice
/// changes the digest. A constant fill would not.
fn patterned(size: usize) -> Vec<u8> {
(0..=u8::MAX).cycle().take(size).collect()
}
#[rstest]
#[case::empty(0)]
#[case::single_read(4)]
#[case::exactly_one_buffer(8192)]
#[case::spans_two_reads(8193)]
#[case::spans_several_reads(70_000)]
fn streamed_digest_matches_a_one_shot_digest(#[case] size: usize) -> Result<()> {
let payload = patterned(size);
let (_dir, file) = fixture(&payload)?;
let streamed = compute_hash(&file, "sha256")?;
let one_shot = to_lower_hex(&Sha256::digest(&payload));
ensure!(
streamed == one_shot,
"streamed digest of {size} bytes was {streamed} but a one-shot digest is {one_shot}"
);
Ok(())
}
/// Anchor the streaming path to a published vector, so the cross-check
/// above cannot pass by agreeing on a wrong digest.
#[rstest]
fn streamed_digest_matches_the_known_vector_for_abc() -> Result<()> {
let (_dir, file) = fixture(b"abc")?;
let expected = concat!(
"ba7816bf8f01cfea414140de5dae2223",
"b00361a396177a9cb410ff61f20015ad",
);
let digest = compute_hash(&file, "sha256")?;
ensure!(
digest == expected,
"expected the published digest {expected} but streamed {digest}"
);
Ok(())
}
mod properties {
//! Property tests for the chunked streaming loop.
//!
//! The cases above pin sizes chosen to straddle the 8192-byte read
//! buffer. They cannot cover how the loop behaves at an arbitrary
//! offset within a chunk, which is where a partial final read or a
//! mishandled boundary would hide. Generating the length instead lets
//! the partition vary freely, including the awkward remainders either
//! side of a buffer boundary.
use proptest::prelude::*;
use sha2::{Digest, Sha256};
use super::{compute_hash, fixture, to_lower_hex};
proptest! {
/// Streaming a payload of any length agrees with a one-shot digest.
///
/// The range spans several buffer fills so the generated lengths
/// exercise both exact multiples and partial trailing reads.
#[test]
fn streamed_digest_matches_a_one_shot_digest_for_any_length(
payload in prop::collection::vec(any::<u8>(), 0..20_000),
) {
let (_dir, file) = fixture(&payload).expect("stage the payload");
let streamed = compute_hash(&file, "sha256").expect("hash the payload");
let one_shot = to_lower_hex(&Sha256::digest(&payload));
prop_assert_eq!(streamed, one_shot);
}
}
}
}