use crate::{Error, PackageIndex};
use sha1::Sha1;
use sha2::{Digest as _, Sha256, Sha384, Sha512};
use std::cell::RefCell;
thread_local! {
static SHA512_HASHER: RefCell<Sha512> = RefCell::new(Sha512::new());
}
pub const SHA512_INTEGRITY_PREFIX: &str = "sha512-";
const SRI_PREFIXES: &[(&str, IntegrityAlgo)] = &[
("sha512-", IntegrityAlgo::Sha512),
("sha384-", IntegrityAlgo::Sha384),
("sha256-", IntegrityAlgo::Sha256),
("sha1-", IntegrityAlgo::Sha1),
];
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum IntegrityAlgo {
Sha1,
Sha256,
Sha384,
Sha512,
}
impl IntegrityAlgo {
fn prefix(self) -> &'static str {
match self {
Self::Sha1 => "sha1-",
Self::Sha256 => "sha256-",
Self::Sha384 => "sha384-",
Self::Sha512 => "sha512-",
}
}
}
fn parse_sri(expected: &str) -> Option<(IntegrityAlgo, &str)> {
SRI_PREFIXES
.iter()
.find_map(|(prefix, algo)| expected.strip_prefix(prefix).map(|rest| (*algo, rest)))
}
pub fn validate_and_encode_name(name: &str) -> Option<String> {
if name.is_empty() || name.len() > 214 {
return None;
}
let (scope, bare) = match name.strip_prefix('@') {
Some(rest) => {
let (s, b) = rest.split_once('/')?;
(Some(s), b)
}
None => (None, name),
};
let ok_component = |s: &str| -> bool {
if s.is_empty() || s == "." || s == ".." {
return false;
}
s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
};
if let Some(s) = scope
&& !ok_component(s)
{
return None;
}
if !ok_component(bare) {
return None;
}
Some(name.replace('/', "__"))
}
pub fn validate_version(version: &str) -> bool {
if version.is_empty() || version.len() > 256 {
return false;
}
if version
.bytes()
.any(|b| b.is_ascii_control() || matches!(b, b'/' | b'\\' | b'\0'))
{
return false;
}
if version == "." || version == ".." {
return false;
}
true
}
pub fn verify_integrity(data: &[u8], expected: &str) -> Result<(), Error> {
let Some((algo, expected_b64)) = parse_sri(expected) else {
return Err(Error::Integrity(format!(
"unsupported integrity format (expected sha1/sha256/sha384/sha512-...): {expected}"
)));
};
let mut actual_buf = [0u8; 64];
let actual_len = match algo {
IntegrityAlgo::Sha1 => {
let d = Sha1::digest(data);
actual_buf[..d.len()].copy_from_slice(&d);
d.len()
}
IntegrityAlgo::Sha256 => {
let d = Sha256::digest(data);
actual_buf[..d.len()].copy_from_slice(&d);
d.len()
}
IntegrityAlgo::Sha384 => {
let d = Sha384::digest(data);
actual_buf[..d.len()].copy_from_slice(&d);
d.len()
}
IntegrityAlgo::Sha512 => SHA512_HASHER.with(|cell| {
let mut hasher = cell.borrow_mut();
hasher.reset();
hasher.update(data);
let d = hasher.finalize_reset();
actual_buf[..d.len()].copy_from_slice(&d);
d.len()
}),
};
let actual = &actual_buf[..actual_len];
use base64::Engine;
let engine = base64::engine::general_purpose::STANDARD;
let mut expected_digest = [0u8; 64];
let matched = engine
.decode_slice(expected_b64, &mut expected_digest)
.map(|n| n == actual_len && expected_digest[..n] == actual[..])
.unwrap_or(false);
if matched {
Ok(())
} else {
let actual_b64 = engine.encode(actual);
Err(Error::Integrity(format!(
"integrity mismatch: expected {expected}, got {prefix}{actual_b64}",
prefix = algo.prefix(),
)))
}
}
pub fn sha512_integrity(data: &[u8]) -> String {
let digest = SHA512_HASHER.with(|cell| {
let mut hasher = cell.borrow_mut();
hasher.reset();
hasher.update(data);
hasher.finalize_reset()
});
let mut digest_buf = [0u8; 64];
digest_buf.copy_from_slice(&digest);
sha512_integrity_from_digest(&digest_buf)
}
pub fn sha512_integrity_from_digest(digest: &[u8; 64]) -> String {
use base64::Engine;
format!(
"{SHA512_INTEGRITY_PREFIX}{}",
base64::engine::general_purpose::STANDARD.encode(digest)
)
}
pub fn verify_precomputed_sha512(actual: &[u8; 64], expected: &str) -> Result<bool, Error> {
let Some((algo, expected_b64)) = parse_sri(expected) else {
return Err(Error::Integrity(format!(
"unsupported integrity format (expected sha1/sha256/sha384/sha512-...): {expected}"
)));
};
if !matches!(algo, IntegrityAlgo::Sha512) {
return Ok(false);
}
use base64::Engine;
let engine = base64::engine::general_purpose::STANDARD;
let mut expected_digest = [0u8; 64];
let decoded_len = match engine.decode_slice(expected_b64, &mut expected_digest) {
Ok(n) => n,
Err(e) => {
return Err(Error::Integrity(format!(
"integrity field has malformed base64: {expected} ({e})"
)));
}
};
if decoded_len != 64 {
return Err(Error::Integrity(format!(
"integrity field decoded to {decoded_len} bytes, expected 64 for sha512: {expected}"
)));
}
if expected_digest[..decoded_len] == actual[..] {
Ok(true)
} else {
let actual_b64 = engine.encode(actual);
Err(Error::Integrity(format!(
"integrity mismatch: expected {expected}, got sha512-{actual_b64}",
)))
}
}
pub fn validate_pkg_content(
index: &PackageIndex,
expected_name: &str,
expected_version: &str,
) -> Result<(), Error> {
let stored = index
.get("package.json")
.ok_or_else(|| Error::Tar("package.json missing from tarball".to_string()))?;
let bytes =
std::fs::read(&stored.store_path).map_err(|e| Error::Io(stored.store_path.clone(), e))?;
let v: serde_json::Value = sonic_rs::from_slice(&bytes)
.or_else(|_| serde_json::from_slice(&bytes))
.map_err(|e| Error::Tar(format!("invalid package.json: {e}")))?;
let actual_name = v.get("name").and_then(|n| n.as_str()).unwrap_or("");
let actual_version = v.get("version").and_then(|v| v.as_str()).unwrap_or("");
let actual_version_normalized = actual_version
.strip_prefix('v')
.filter(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
.unwrap_or(actual_version);
let actual_version_without_build = actual_version_normalized
.split_once('+')
.map(|(base, _)| base);
let expected_is_url_or_ref = expected_version.contains("://")
|| expected_version.starts_with("git+")
|| expected_version.starts_with("file:");
let version_matches = expected_is_url_or_ref
|| actual_version_normalized == expected_version
|| actual_version_without_build == Some(expected_version);
if actual_name != expected_name || !version_matches {
return Err(Error::PkgContentMismatch {
actual: format!("{actual_name}@{actual_version}"),
});
}
Ok(())
}
pub fn integrity_to_hex(integrity: &str) -> Option<String> {
let (_, b64) = parse_sri(integrity)?;
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
Some(hex::encode(bytes))
}
pub fn shasum_to_sri(shasum: &str) -> Option<String> {
let shasum = shasum.trim();
if shasum.len() != 40 || !shasum.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let bytes = hex::decode(shasum).ok()?;
use base64::Engine;
Some(format!(
"sha1-{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shasum_to_sri_matches_npm_classic_encoding() {
assert_eq!(
shasum_to_sri("dc96d6d3268bbc55103ce55cd4608d43d3b7ff72").as_deref(),
Some("sha1-3JbW0yaLvFUQPOVc1GCNQ9O3/3I=")
);
}
#[test]
fn shasum_to_sri_round_trips_through_integrity_to_hex() {
let hex = "dc96d6d3268bbc55103ce55cd4608d43d3b7ff72";
let sri = shasum_to_sri(hex).unwrap();
assert_eq!(integrity_to_hex(&sri).as_deref(), Some(hex));
}
#[test]
fn shasum_to_sri_is_lenient_about_surrounding_whitespace() {
assert!(shasum_to_sri(" dc96d6d3268bbc55103ce55cd4608d43d3b7ff72\n").is_some());
}
#[test]
fn shasum_to_sri_rejects_malformed_input() {
assert_eq!(shasum_to_sri(&"a".repeat(64)), None);
assert_eq!(
shasum_to_sri("dc96d6d3268bbc55103ce55cd4608d43d3b7ffzz"),
None
);
assert_eq!(shasum_to_sri(""), None);
}
}