Skip to content

Commit 10c559b

Browse files
committed
Add release.Digests.validate method
Validates a file path against one or more SHA digests. Uses the `sha1` and `sha2` crates, which are part of the [Rust Crypto] project. Uses the `constant_time_eq` crate to compare the values. A new `Digest` error variant reports digest mismatches, displaying them has hex strings. Disable automatic line-ending conversion in JSON files to prevent test failures on Windows. [Rust Crypto]: https://github.com/RustCrypto
1 parent bf6db96 commit 10c559b

File tree

7 files changed

+318
-3
lines changed

7 files changed

+318
-3
lines changed

.gitattributes

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Disable line-ending conversion of JSON files, so that the hash digest
2+
# validation tests pass on Windows.
3+
*.json -text

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ All notable changes to this project will be documented in this file. It uses the
1515
pgxn_meta.
1616
* Changed the errors returned by all the APIs from boxed errors [error
1717
module] errors.
18+
* Added `release.Digests.validate` method to validate a file against one or
19+
more digests.
1820

1921
### 📔 Notes
2022

Cargo.lock

+86
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+5-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ exclude = [ ".github", ".vscode", ".gitignore", ".ci", ".pre-*.yaml"]
1818
base64 = "0.22"
1919
boon = "0.6"
2020
chrono = { version = "0.4.38", features = ["serde"] }
21+
constant_time_eq = "0.3"
22+
digest = "0.10"
2123
email_address = "0.2.9"
24+
hex = "0.4"
2225
json-patch = "3.0"
2326
lexopt = "0.3.0"
2427
rand = "0.8.5"
@@ -27,6 +30,8 @@ semver = { version = "1.0", features = ["std", "serde"] }
2730
serde = { version = "1", features = ["derive"] }
2831
serde_json = "1.0"
2932
serde_with = { version = "3.9.0", features = ["hex"] }
33+
sha1 = "0.10"
34+
sha2 = "0.10"
3035
spdx = "0.10.6"
3136
thiserror = "1.0"
3237
wax = "0.6.0"
@@ -37,5 +42,4 @@ serde_json = "1.0"
3742

3843
[dev-dependencies]
3944
assert-json-diff = "2.0.2"
40-
hex = "0.4.3"
4145
tempfile = "3.12.0"

src/error/mod.rs

+4
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ pub enum Error {
5151
/// Missing property value.
5252
#[error("{0} property missing")]
5353
Missing(&'static str),
54+
55+
/// Hash digest mismatch.
56+
#[error("{0} digest {1} does not match {2}")]
57+
Digest(&'static str, String, String),
5458
}
5559

5660
impl<'s, 'v> From<boon::ValidationError<'s, 'v>> for Error {

src/release/mod.rs

+58-1
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ It supports both the [v1] and [v2] specs.
1919

2020
use crate::{dist::*, error::Error, util};
2121
use chrono::{DateTime, Utc};
22+
use hex;
2223
use serde::{de, Deserialize, Deserializer, Serialize};
2324
use serde_json::Value;
24-
use std::{borrow::Borrow, collections::HashMap, fs::File, path::Path};
25+
use std::{borrow::Borrow, collections::HashMap, fs::File, io, path::Path};
2526

2627
mod v1;
2728
mod v2;
@@ -57,6 +58,62 @@ impl Digests {
5758
pub fn sha512(&self) -> Option<&[u8; 64]> {
5859
self.sha512.as_ref()
5960
}
61+
62+
/// Validates `path` against one or more of the digests. Returns an error
63+
/// on validation failure.
64+
pub fn validate<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
65+
self._validate(File::open(path)?)
66+
}
67+
68+
/// Validates `file` against one or more of the digests. Returns an error
69+
/// on validation failure.
70+
fn _validate<P: io::Read + io::Seek>(&self, mut file: P) -> Result<(), Error> {
71+
use sha1::Sha1;
72+
use sha2::{Digest, Sha256, Sha512};
73+
let mut ok = false;
74+
75+
// Prefer SHA-512.
76+
if let Some(digest) = self.sha512() {
77+
compare(&mut file, digest, Sha512::new(), "SHA-512")?;
78+
ok = true;
79+
}
80+
81+
// Allow SHA-256.
82+
if let Some(digest) = self.sha256() {
83+
compare(&mut file, digest, Sha256::new(), "SHA-256")?;
84+
ok = true;
85+
}
86+
87+
// Fall back on SHA-1 for PGXN v1 distributions.
88+
if let Some(digest) = self.sha1() {
89+
compare(&mut file, digest, Sha1::new(), "SHA-1")?;
90+
ok = true;
91+
}
92+
93+
if ok {
94+
return Ok(());
95+
}
96+
97+
// This should not happen, since the validator ensures there's a digest.
98+
Err(Error::Missing("digests"))
99+
}
100+
}
101+
102+
/// Use `hasher` to hash the contents of `file` and compare the result to
103+
/// `digest`. Returns an error on digest failure.
104+
fn compare<P, D>(mut file: P, digest: &[u8], mut hasher: D, alg: &'static str) -> Result<(), Error>
105+
where
106+
P: io::Read + io::Seek,
107+
D: digest::Digest + io::Write,
108+
{
109+
// Rewind the file, as it may be read multiple times.
110+
file.rewind()?;
111+
io::copy(&mut file, &mut hasher)?;
112+
let hash = hasher.finalize();
113+
if constant_time_eq::constant_time_eq(hash.as_slice(), digest) {
114+
return Ok(());
115+
}
116+
Err(Error::Digest(alg, hex::encode(hash), hex::encode(digest)))
60117
}
61118

62119
/// ReleasePayload represents release metadata populated by PGXN.

0 commit comments

Comments
 (0)