manifest decode & profile validate optimization

This commit is contained in:
2026-02-25 11:16:02 +08:00
parent 2a6a963ecd
commit 1cc3351bef
35 changed files with 4418 additions and 225 deletions

View File

@@ -4,8 +4,6 @@ use crate::data_model::rc::ResourceCertificate;
use crate::data_model::signed_object::{
RpkiSignedObject, RpkiSignedObjectParsed, SignedObjectParseError, SignedObjectValidateError,
};
use der_parser::ber::BerObjectContent;
use der_parser::der::{DerObject, Tag, parse_der};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestObject {
@@ -28,7 +26,10 @@ pub struct ManifestEContent {
pub this_update: UtcTime,
pub next_update: UtcTime,
pub file_hash_alg: String,
pub files: Vec<FileAndHash>,
/// DER-encoded content bytes of `Manifest.fileList` (SEQUENCE OF FileAndHash).
pub file_list_der: Vec<u8>,
/// Count of FileAndHash entries in `fileList`.
pub file_count: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -39,7 +40,7 @@ pub struct ManifestEContentParsed {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileAndHash {
pub file_name: String,
pub hash_bytes: Vec<u8>,
pub hash_bytes: [u8; 32],
}
#[derive(Debug, thiserror::Error)]
@@ -246,7 +247,8 @@ impl ManifestObject {
impl ManifestEContent {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<ManifestEContentParsed, ManifestParseError> {
let (rem, _obj) = parse_der(der).map_err(|e| ManifestParseError::Parse(e.to_string()))?;
let (_tag, _value, rem) =
der_take_tlv(der).map_err(|e| ManifestParseError::Parse(e))?;
if !rem.is_empty() {
return Err(ManifestParseError::TrailingBytes(rem.len()));
}
@@ -265,6 +267,18 @@ impl ManifestEContent {
pub fn decode_der(der: &[u8]) -> Result<Self, ManifestDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
/// Parse and return the manifest fileList.
///
/// Note: `ManifestEContent` is profile-validated when produced via `decode_der()`, so this
/// should only fail due to internal inconsistencies (or if constructed manually).
pub fn parse_files(&self) -> Result<Vec<FileAndHash>, ManifestProfileError> {
parse_file_list_sha256_fast(&self.file_list_der)
}
pub fn file_count(&self) -> usize {
self.file_count
}
}
impl ManifestObjectParsed {
@@ -292,111 +306,328 @@ impl ManifestObjectParsed {
impl ManifestEContentParsed {
pub fn validate_profile(self) -> Result<ManifestEContent, ManifestProfileError> {
let (_rem, obj) =
parse_der(&self.der).map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?;
let seq = obj
.as_sequence()
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?;
if seq.len() != 5 && seq.len() != 6 {
return Err(ManifestProfileError::InvalidManifestSequenceLen(seq.len()));
}
let mut idx = 0;
let mut version: u32 = 0;
if seq.len() == 6 {
let v_obj = &seq[0];
if v_obj.class() != der_parser::ber::Class::ContextSpecific || v_obj.tag() != Tag(0) {
return Err(ManifestProfileError::ProfileDecode(
"Manifest.version must be [0] EXPLICIT INTEGER".into(),
));
}
let inner_der = v_obj
.as_slice()
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?;
let (rem, inner) = parse_der(inner_der)
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?;
if !rem.is_empty() {
return Err(ManifestProfileError::ProfileDecode(
"trailing bytes inside Manifest.version".into(),
));
}
let v = inner
.as_u64()
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?;
if v != 0 {
return Err(ManifestProfileError::InvalidManifestVersion(v));
}
version = 0;
idx = 1;
}
let manifest_number = parse_manifest_number(&seq[idx])?;
idx += 1;
let this_update =
parse_generalized_time(&seq[idx], ManifestProfileError::InvalidThisUpdate)?;
idx += 1;
let next_update =
parse_generalized_time(&seq[idx], ManifestProfileError::InvalidNextUpdate)?;
idx += 1;
if next_update <= this_update {
return Err(ManifestProfileError::NextUpdateNotLater);
}
let file_hash_alg = oid_to_string(&seq[idx])?;
idx += 1;
if file_hash_alg != OID_SHA256 {
return Err(ManifestProfileError::InvalidFileHashAlg(file_hash_alg));
}
let files = parse_file_list_sha256(&seq[idx])?;
Ok(ManifestEContent {
version,
manifest_number,
this_update,
next_update,
file_hash_alg: OID_SHA256.to_string(),
files,
})
decode_manifest_econtent_fast(&self.der)
}
}
fn parse_manifest_number(obj: &DerObject<'_>) -> Result<BigUnsigned, ManifestProfileError> {
let n = obj
.as_biguint()
.map_err(|_e| ManifestProfileError::InvalidManifestNumber)?;
let out = BigUnsigned::from_biguint(&n);
if out.bytes_be.len() > 20 {
return Err(ManifestProfileError::ManifestNumberTooLong);
}
Ok(out)
}
fn validate_file_name_bytes(bytes: &[u8]) -> Result<(), ManifestProfileError> {
// RFC 9286 §4.2.2:
// 1+ chars from a-zA-Z0-9-_ , then '.', then 3-letter extension.
if bytes.len() < 5 {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
};
fn parse_generalized_time(
obj: &DerObject<'_>,
err: ManifestProfileError,
) -> Result<UtcTime, ManifestProfileError> {
match &obj.content {
BerObjectContent::GeneralizedTime(dt) => dt
.to_datetime()
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string())),
_ => Err(err),
// "followed by a single . (DOT), followed by a three letter extension"
// -> the dot must be exactly 4 bytes from the end.
let dot_pos = bytes.len() - 4;
if bytes[dot_pos] != b'.' {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
}
}
fn parse_file_list_sha256(obj: &DerObject<'_>) -> Result<Vec<FileAndHash>, ManifestProfileError> {
let seq = obj
.as_sequence()
.map_err(|_e| ManifestProfileError::InvalidFileList)?;
let mut out = Vec::with_capacity(seq.len());
for entry in seq {
let (file_name, hash_bytes) = parse_file_and_hash(entry)?;
validate_file_name(&file_name)?;
if hash_bytes.len() != 32 {
return Err(ManifestProfileError::InvalidHashLength(hash_bytes.len()));
#[inline(always)]
fn valid_base_char(b: u8) -> bool {
// RFC 9286 allowed set: a-zA-Z0-9-_
(b'0'..=b'9').contains(&b)
|| (b'a'..=b'z').contains(&b)
|| (b'A'..=b'Z').contains(&b)
|| b == b'-'
|| b == b'_'
}
for &b in &bytes[..dot_pos] {
if (b & 0x80) != 0 || !valid_base_char(b) {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
}
}
let e0 = bytes[dot_pos + 1];
let e1 = bytes[dot_pos + 2];
let e2 = bytes[dot_pos + 3];
if (e0 & 0x80) != 0 || (e1 & 0x80) != 0 || (e2 & 0x80) != 0 {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
}
#[inline(always)]
fn lower_if_alpha(b: u8) -> Option<u8> {
match b {
b'a'..=b'z' => Some(b),
b'A'..=b'Z' => Some(b + 32),
_ => None,
}
}
let Some(l0) = lower_if_alpha(e0) else {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
};
let Some(l1) = lower_if_alpha(e1) else {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
};
let Some(l2) = lower_if_alpha(e2) else {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
};
match [l0, l1, l2] {
// Full IANA list (see `common.rs`).
[b'a', b's', b'a']
| [b'c', b'e', b'r']
| [b'c', b'r', b'l']
| [b'g', b'b', b'r']
| [b'm', b'f', b't']
| [b'r', b'o', b'a']
| [b's', b'i', b'g']
| [b't', b'a', b'k'] => Ok(()),
_ => Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
)),
}
}
fn decode_manifest_econtent_fast(der: &[u8]) -> Result<ManifestEContent, ManifestProfileError> {
let (tag, mut seq_content, rem) = der_take_tlv(der)
.map_err(|e| ManifestProfileError::ProfileDecode(format!("DER decode error: {e}")))?;
if !rem.is_empty() {
return Err(ManifestProfileError::ProfileDecode(format!(
"trailing bytes after DER object: {} bytes",
rem.len()
)));
}
if tag != 0x30 {
return Err(ManifestProfileError::ProfileDecode(
"Manifest eContent must be SEQUENCE".into(),
));
}
let seq_len = der_count_elements(seq_content)
.map_err(|e| ManifestProfileError::ProfileDecode(e))?;
if seq_len != 5 && seq_len != 6 {
return Err(ManifestProfileError::InvalidManifestSequenceLen(seq_len));
}
let mut version: u32 = 0;
if seq_len == 6 {
let Some(&first_tag) = seq_content.first() else {
return Err(ManifestProfileError::InvalidManifestSequenceLen(0));
};
if first_tag != 0xA0 {
return Err(ManifestProfileError::ProfileDecode(
"Manifest.version must be [0] EXPLICIT INTEGER".into(),
));
}
let (_cs_tag, cs_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.version decode error: {e}"))
})?;
seq_content = after;
let (inner_tag, inner_value, inner_rem) = der_take_tlv(cs_value).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.version inner decode error: {e}"))
})?;
if !inner_rem.is_empty() {
return Err(ManifestProfileError::ProfileDecode(
"trailing bytes inside Manifest.version".into(),
));
}
if inner_tag != 0x02 {
return Err(ManifestProfileError::ProfileDecode(
"Manifest.version must be [0] EXPLICIT INTEGER".into(),
));
}
let v = der_integer_to_u64(inner_value).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.version decode error: {e}"))
})?;
if v != 0 {
return Err(ManifestProfileError::InvalidManifestVersion(v));
}
version = 0;
}
let (mn_tag, mn_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.manifestNumber decode error: {e}"))
})?;
seq_content = after;
if mn_tag != 0x02 {
return Err(ManifestProfileError::InvalidManifestNumber);
}
let manifest_number = der_integer_to_bigunsigned(mn_value)?;
let (tu_tag, tu_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.thisUpdate decode error: {e}"))
})?;
seq_content = after;
if tu_tag != 0x18 {
return Err(ManifestProfileError::InvalidThisUpdate);
}
let this_update = parse_generalized_time_bytes(tu_value)
.map_err(|e| ManifestProfileError::ProfileDecode(e))?;
let (nu_tag, nu_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.nextUpdate decode error: {e}"))
})?;
seq_content = after;
if nu_tag != 0x18 {
return Err(ManifestProfileError::InvalidNextUpdate);
}
let next_update = parse_generalized_time_bytes(nu_value)
.map_err(|e| ManifestProfileError::ProfileDecode(e))?;
if next_update <= this_update {
return Err(ManifestProfileError::NextUpdateNotLater);
}
let (oid_tag, oid_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.fileHashAlg decode error: {e}"))
})?;
seq_content = after;
if oid_tag != 0x06 {
return Err(ManifestProfileError::ProfileDecode(
"Manifest.fileHashAlg must be OBJECT IDENTIFIER".into(),
));
}
if !oid_content_is_sha256(oid_value) {
return Err(ManifestProfileError::InvalidFileHashAlg(
oid_content_to_string(oid_value),
));
}
let (fl_tag, fl_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.fileList decode error: {e}"))
})?;
seq_content = after;
if fl_tag != 0x30 {
return Err(ManifestProfileError::InvalidFileList);
}
let file_count = validate_file_list_sha256_fast(fl_value)?;
let file_list_der = fl_value.to_vec();
if !seq_content.is_empty() {
return Err(ManifestProfileError::InvalidManifestSequenceLen(seq_len));
}
Ok(ManifestEContent {
version,
manifest_number,
this_update,
next_update,
file_hash_alg: OID_SHA256.to_string(),
file_list_der,
file_count,
})
}
fn validate_file_list_sha256_fast(content: &[u8]) -> Result<usize, ManifestProfileError> {
let mut cur = content;
let mut count: usize = 0;
while !cur.is_empty() {
let (tag, value, rem) = der_take_tlv(cur).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("fileList entry decode error: {e}"))
})?;
cur = rem;
if tag != 0x30 {
return Err(ManifestProfileError::InvalidFileAndHash);
}
let mut entry = value;
let (fn_tag, fn_value, entry_rem) = der_take_tlv(entry).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("fileList fileName decode error: {e}"))
})?;
entry = entry_rem;
if fn_tag != 0x16 {
return Err(ManifestProfileError::InvalidFileAndHash);
}
if entry.is_empty() {
return Err(ManifestProfileError::InvalidFileAndHash);
}
validate_file_name_bytes(fn_value)?;
let (hash_tag, hash_value, entry_rem) = der_take_tlv(entry).map_err(|_e| {
// Missing second element should map to "SEQUENCE of 2" shape error.
ManifestProfileError::InvalidFileAndHash
})?;
entry = entry_rem;
if !entry.is_empty() {
return Err(ManifestProfileError::InvalidFileAndHash);
}
if hash_tag != 0x03 {
return Err(ManifestProfileError::InvalidHashType);
}
if hash_value.is_empty() {
return Err(ManifestProfileError::InvalidHashLength(0));
}
let unused_bits = hash_value[0];
if unused_bits != 0 {
return Err(ManifestProfileError::HashNotOctetAligned);
}
let bits = &hash_value[1..];
if bits.len() != 32 {
return Err(ManifestProfileError::InvalidHashLength(bits.len()));
}
count += 1;
}
Ok(count)
}
fn parse_file_list_sha256_fast(content: &[u8]) -> Result<Vec<FileAndHash>, ManifestProfileError> {
// Heuristic initial capacity (avoid a full pre-scan, which is expensive for xlarge manifests).
// Each FileAndHash entry is typically tens of bytes; 80 is a conservative average.
let est = (content.len() / 80).clamp(16, 4096);
let mut cur = content;
let mut out: Vec<FileAndHash> = Vec::with_capacity(est);
while !cur.is_empty() {
let (tag, value, rem) = der_take_tlv(cur)
.map_err(|e| ManifestProfileError::ProfileDecode(format!("fileList entry decode error: {e}")))?;
cur = rem;
if tag != 0x30 {
return Err(ManifestProfileError::InvalidFileAndHash);
}
let mut entry = value;
let (fn_tag, fn_value, entry_rem) = der_take_tlv(entry)
.map_err(|e| ManifestProfileError::ProfileDecode(format!("fileList fileName decode error: {e}")))?;
entry = entry_rem;
if fn_tag != 0x16 {
return Err(ManifestProfileError::InvalidFileAndHash);
}
if entry.is_empty() {
return Err(ManifestProfileError::InvalidFileAndHash);
}
let file_name = validate_and_copy_file_name(fn_value)?;
let (hash_tag, hash_value, entry_rem) = der_take_tlv(entry).map_err(|_e| {
// Missing second element should map to "SEQUENCE of 2" shape error.
ManifestProfileError::InvalidFileAndHash
})?;
entry = entry_rem;
if !entry.is_empty() {
return Err(ManifestProfileError::InvalidFileAndHash);
}
if hash_tag != 0x03 {
return Err(ManifestProfileError::InvalidHashType);
}
if hash_value.is_empty() {
return Err(ManifestProfileError::InvalidHashLength(0));
}
let unused_bits = hash_value[0];
if unused_bits != 0 {
return Err(ManifestProfileError::HashNotOctetAligned);
}
let bits = &hash_value[1..];
if bits.len() != 32 {
return Err(ManifestProfileError::InvalidHashLength(bits.len()));
}
let mut hash_bytes = [0u8; 32];
hash_bytes.copy_from_slice(bits);
out.push(FileAndHash {
file_name,
hash_bytes,
@@ -405,58 +636,346 @@ fn parse_file_list_sha256(obj: &DerObject<'_>) -> Result<Vec<FileAndHash>, Manif
Ok(out)
}
fn parse_file_and_hash(obj: &DerObject<'_>) -> Result<(String, Vec<u8>), ManifestProfileError> {
let seq = obj
.as_sequence()
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?;
if seq.len() != 2 {
return Err(ManifestProfileError::InvalidFileAndHash);
}
let file_name = seq[0]
.as_str()
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?
.to_string();
let (unused_bits, bits) = match &seq[1].content {
BerObjectContent::BitString(unused, bso) => (*unused, bso.data.to_vec()),
_ => return Err(ManifestProfileError::InvalidHashType),
};
if unused_bits != 0 {
return Err(ManifestProfileError::HashNotOctetAligned);
}
Ok((file_name, bits))
fn validate_and_copy_file_name(bytes: &[u8]) -> Result<String, ManifestProfileError> {
validate_file_name_bytes(bytes)?;
Ok(unsafe { String::from_utf8_unchecked(bytes.to_vec()) })
}
fn validate_file_name(name: &str) -> Result<(), ManifestProfileError> {
// RFC 9286 §4.2.2:
// 1+ chars from a-zA-Z0-9-_ , then '.', then 3-letter extension.
let Some((base, ext)) = name.rsplit_once('.') else {
return Err(ManifestProfileError::InvalidFileName(name.to_string()));
};
if base.is_empty() || ext.len() != 3 {
return Err(ManifestProfileError::InvalidFileName(name.to_string()));
fn der_count_elements(mut input: &[u8]) -> Result<usize, String> {
let mut count: usize = 0;
while !input.is_empty() {
let (_tag, _value, rem) = der_take_tlv(input)?;
input = rem;
count += 1;
}
if !base
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err(ManifestProfileError::InvalidFileName(name.to_string()));
}
if !ext.bytes().all(|b| b.is_ascii_alphabetic()) {
return Err(ManifestProfileError::InvalidFileName(name.to_string()));
}
let ext_lower = ext.to_ascii_lowercase();
if !crate::data_model::common::IANA_RPKI_REPOSITORY_FILENAME_EXTENSIONS
.iter()
.any(|&e| e == ext_lower)
{
return Err(ManifestProfileError::InvalidFileName(name.to_string()));
}
Ok(())
Ok(count)
}
fn oid_to_string(obj: &DerObject<'_>) -> Result<String, ManifestProfileError> {
let oid = obj
.as_oid()
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?;
Ok(oid.to_id_string())
fn der_integer_to_u64(bytes: &[u8]) -> Result<u64, String> {
if bytes.is_empty() {
return Err("INTEGER empty".into());
}
// Reject negative (two's complement).
if bytes[0] & 0x80 != 0 {
return Err("INTEGER is negative".into());
}
if bytes.len() > 8 {
return Err("INTEGER too large".into());
}
let mut v: u64 = 0;
for &b in bytes {
v = (v << 8) | (b as u64);
}
Ok(v)
}
fn der_integer_to_bigunsigned(bytes: &[u8]) -> Result<BigUnsigned, ManifestProfileError> {
if bytes.is_empty() {
return Err(ManifestProfileError::InvalidManifestNumber);
}
// Two's complement: for non-negative values, a leading 0x00 may be present.
if bytes[0] & 0x80 != 0 {
return Err(ManifestProfileError::InvalidManifestNumber);
}
let mut start = 0usize;
while start + 1 < bytes.len() && bytes[start] == 0 {
start += 1;
}
let mut minimal = bytes[start..].to_vec();
if minimal.is_empty() {
minimal.push(0);
}
if minimal.len() > 20 {
return Err(ManifestProfileError::ManifestNumberTooLong);
}
Ok(BigUnsigned { bytes_be: minimal })
}
fn parse_generalized_time_bytes(bytes: &[u8]) -> Result<UtcTime, String> {
// Accept "YYYYMMDDHHMMSSZ" and also allow optional fractional seconds (".fff...Z").
if !bytes.is_ascii() {
return Err("GeneralizedTime not ASCII".into());
}
let s = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
if !s.ends_with('Z') {
return Err("GeneralizedTime must end with 'Z'".into());
}
let core = &s[..s.len() - 1];
let (main, frac) = core.split_once('.').map_or((core, None), |(a, b)| (a, Some(b)));
if main.len() != 14 || !main.bytes().all(|b| b.is_ascii_digit()) {
return Err("GeneralizedTime must be YYYYMMDDHHMMSS[.fff]Z".into());
}
let year: i32 = main[0..4].parse().map_err(|_| "bad year")?;
let month: u8 = main[4..6].parse().map_err(|_| "bad month")?;
let day: u8 = main[6..8].parse().map_err(|_| "bad day")?;
let hour: u8 = main[8..10].parse().map_err(|_| "bad hour")?;
let minute: u8 = main[10..12].parse().map_err(|_| "bad minute")?;
let second: u8 = main[12..14].parse().map_err(|_| "bad second")?;
let nanosecond: u32 = if let Some(frac) = frac {
if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
return Err("bad fractional seconds".into());
}
let mut ns: u32 = 0;
let mut scale: u32 = 1_000_000_000;
for (i, ch) in frac.bytes().enumerate() {
if i >= 9 {
break;
}
scale /= 10;
ns += ((ch - b'0') as u32) * scale;
}
ns
} else {
0
};
let date = time::Date::from_calendar_date(year, time::Month::try_from(month).map_err(|_| "bad month")?, day)
.map_err(|e| e.to_string())?;
let t = time::Time::from_hms_nano(hour, minute, second, nanosecond).map_err(|e| e.to_string())?;
Ok(date.with_time(t).assume_utc())
}
fn oid_content_is_sha256(bytes: &[u8]) -> bool {
// 2.16.840.1.101.3.4.2.1
let mut arcs = oid_content_iter(bytes);
const EXPECTED: &[u64] = &[2, 16, 840, 1, 101, 3, 4, 2, 1];
for &e in EXPECTED {
match arcs.next() {
Some(v) if v == e => {}
_ => return false,
}
}
arcs.next().is_none()
}
fn oid_content_to_string(bytes: &[u8]) -> String {
let arcs: Vec<u64> = oid_content_iter(bytes).collect();
if arcs.is_empty() {
return "<invalid oid>".to_string();
}
let mut s = String::new();
for (i, a) in arcs.iter().enumerate() {
if i > 0 {
s.push('.');
}
s.push_str(&a.to_string());
}
s
}
fn oid_content_iter(bytes: &[u8]) -> impl Iterator<Item = u64> + '_ {
struct It<'a> {
bytes: &'a [u8],
pos: usize,
first_done: bool,
first_a0: u64,
first_a1: u64,
emit_first_idx: u8,
}
impl<'a> Iterator for It<'a> {
type Item = u64;
fn next(&mut self) -> Option<u64> {
if !self.first_done {
if self.bytes.is_empty() {
self.first_done = true;
return None;
}
let first = self.bytes[0] as u64;
self.first_a0 = first / 40;
self.first_a1 = first % 40;
self.pos = 1;
self.first_done = true;
self.emit_first_idx = 0;
}
if self.emit_first_idx == 0 {
self.emit_first_idx = 1;
return Some(self.first_a0);
}
if self.emit_first_idx == 1 {
self.emit_first_idx = 2;
return Some(self.first_a1);
}
if self.pos >= self.bytes.len() {
return None;
}
let mut v: u64 = 0;
while self.pos < self.bytes.len() {
let b = self.bytes[self.pos];
self.pos += 1;
v = (v << 7) | ((b & 0x7F) as u64);
if b & 0x80 == 0 {
return Some(v);
}
}
None
}
}
It {
bytes,
pos: 0,
first_done: false,
first_a0: 0,
first_a1: 0,
emit_first_idx: 0,
}
}
fn der_take_tlv(input: &[u8]) -> Result<(u8, &[u8], &[u8]), String> {
if input.len() < 2 {
return Err("truncated DER (need tag+len)".into());
}
let tag = input[0];
if (tag & 0x1F) == 0x1F {
return Err("high-tag-number form not supported".into());
}
let len0 = input[1];
if len0 == 0x80 {
return Err("indefinite length not allowed in DER".into());
}
let (len, hdr_len) = if len0 & 0x80 == 0 {
(len0 as usize, 2usize)
} else {
let n = (len0 & 0x7F) as usize;
if n == 0 || n > 8 {
return Err("invalid DER length".into());
}
if input.len() < 2 + n {
return Err("truncated DER (length bytes)".into());
}
let mut l: usize = 0;
for &b in &input[2..2 + n] {
l = (l << 8) | (b as usize);
}
(l, 2 + n)
};
if input.len() < hdr_len + len {
return Err("truncated DER (value bytes)".into());
}
let value = &input[hdr_len..hdr_len + len];
let rem = &input[hdr_len + len..];
Ok((tag, value, rem))
}
#[cfg(test)]
mod tests {
use super::*;
fn tlv(tag: u8, value: &[u8]) -> Vec<u8> {
assert!(value.len() < 128);
let mut out = Vec::with_capacity(2 + value.len());
out.push(tag);
out.push(value.len() as u8);
out.extend_from_slice(value);
out
}
fn tlv_long_len(tag: u8, len_bytes: &[u8], value: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(2 + len_bytes.len() + value.len());
out.push(tag);
out.push(0x80 | (len_bytes.len() as u8));
out.extend_from_slice(len_bytes);
out.extend_from_slice(value);
out
}
#[test]
fn der_take_tlv_supports_short_and_long_form_lengths_and_errors() {
let v = b"abc";
let der = tlv(0x04, v);
let (tag, val, rem) = der_take_tlv(&der).expect("short len");
assert_eq!(tag, 0x04);
assert_eq!(val, v);
assert!(rem.is_empty());
// Long-form length with 1 length byte (130).
let v = vec![b'x'; 130];
let der = tlv_long_len(0x04, &[0x82], &v);
let (tag, val, rem) = der_take_tlv(&der).expect("long len 1");
assert_eq!(tag, 0x04);
assert_eq!(val.len(), 130);
assert!(rem.is_empty());
// Long-form length with 2 length bytes (256).
let v = vec![b'y'; 256];
let der = tlv_long_len(0x04, &[0x01, 0x00], &v);
let (tag, val, rem) = der_take_tlv(&der).expect("long len 2");
assert_eq!(tag, 0x04);
assert_eq!(val.len(), 256);
assert!(rem.is_empty());
assert!(der_take_tlv(&[]).is_err());
assert!(der_take_tlv(&[0x04]).is_err());
// High-tag-number form not supported.
assert!(der_take_tlv(&[0x1F, 0x01, 0x00]).is_err());
// Indefinite length is not allowed in DER.
assert!(der_take_tlv(&[0x04, 0x80]).is_err());
// Invalid long-form length encoding.
assert!(der_take_tlv(&[0x04, 0x81]).is_err());
assert!(der_take_tlv(&[0x04, 0x89]).is_err());
}
#[test]
fn parse_generalized_time_bytes_accepts_fraction_and_rejects_invalid() {
let t = parse_generalized_time_bytes(b"20260101000000Z").expect("basic time");
assert_eq!(t.year(), 2026);
let t = parse_generalized_time_bytes(b"20260101000000.1Z").expect("fractional");
assert_eq!(t.nanosecond(), 100_000_000);
assert!(parse_generalized_time_bytes(b"20260101000000").is_err());
assert!(parse_generalized_time_bytes(b"20260101000000+00").is_err());
assert!(parse_generalized_time_bytes(b"2026010100000Z").is_err());
assert!(parse_generalized_time_bytes(b"20261301000000Z").is_err());
assert!(parse_generalized_time_bytes(b"20260132000000Z").is_err());
assert!(parse_generalized_time_bytes(&[0xFF]).is_err());
}
#[test]
fn oid_helpers_accept_sha256_and_format_invalid() {
// 2.16.840.1.101.3.4.2.1
let sha256_oid_content = [0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01];
assert!(oid_content_is_sha256(&sha256_oid_content));
assert!(!oid_content_is_sha256(&[0x55, 0x04, 0x03])); // 2.5.4.3
assert_eq!(oid_content_to_string(&[]), "<invalid oid>".to_string());
}
#[test]
fn validate_file_list_sha256_fast_counts_and_rejects_bad_hash() {
fn file_and_hash(file: &str, digest: u8) -> Vec<u8> {
let mut hash = vec![0u8; 33];
hash[0] = 0; // unused bits
for b in &mut hash[1..] {
*b = digest;
}
let ia5 = tlv(0x16, file.as_bytes());
let bit = tlv(0x03, &hash);
let mut entry = Vec::new();
entry.extend_from_slice(&ia5);
entry.extend_from_slice(&bit);
tlv(0x30, &entry)
}
let mut list = Vec::new();
list.extend_from_slice(&file_and_hash("A.cer", 0xAA));
list.extend_from_slice(&file_and_hash("B.roa", 0xBB));
assert_eq!(validate_file_list_sha256_fast(&list).expect("count"), 2);
// Wrong hash length.
let mut bad = Vec::new();
let ia5 = tlv(0x16, b"A.cer");
let bit = tlv(0x03, &[0u8; 2]); // too short
let mut entry = Vec::new();
entry.extend_from_slice(&ia5);
entry.extend_from_slice(&bit);
bad.extend_from_slice(&tlv(0x30, &entry));
assert!(matches!(
validate_file_list_sha256_fast(&bad),
Err(ManifestProfileError::InvalidHashLength(_))
));
}
}

View File

@@ -7,7 +7,7 @@ use crate::data_model::oid::{
use crate::data_model::rc::{ResourceCertificate, SubjectInfoAccess};
use der_parser::ber::Class;
use der_parser::der::{DerObject, Tag, parse_der};
use sha2::{Digest, Sha256};
use ring::digest;
use x509_parser::prelude::FromDer;
use x509_parser::public_key::PublicKey;
use x509_parser::x509::SubjectPublicKeyInfo;
@@ -812,8 +812,8 @@ fn validate_signed_data_profile(
});
}
let computed = Sha256::digest(&encap_content_info.econtent).to_vec();
if computed != signed_attrs.message_digest {
let computed = digest::digest(&digest::SHA256, &encap_content_info.econtent);
if computed.as_ref() != signed_attrs.message_digest.as_slice() {
return Err(SignedObjectValidateError::MessageDigestMismatch);
}

View File

@@ -62,3 +62,52 @@ impl Fetcher for BlockingHttpFetcher {
self.fetch_bytes(uri)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
fn spawn_one_shot_http_server(status_line: &'static str, body: &'static [u8]) -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind");
let addr = listener.local_addr().expect("addr");
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let hdr = format!(
"{status_line}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(hdr.as_bytes()).expect("write hdr");
stream.write_all(body).expect("write body");
});
format!("http://{}/", addr)
}
#[test]
fn fetch_bytes_returns_body_on_success() {
let url = spawn_one_shot_http_server("HTTP/1.1 200 OK", b"hello");
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(2),
..HttpFetcherConfig::default()
})
.expect("http");
let got = http.fetch_bytes(&url).expect("fetch");
assert_eq!(got, b"hello");
}
#[test]
fn fetch_bytes_rejects_non_success_status() {
let url = spawn_one_shot_http_server("HTTP/1.1 404 Not Found", b"");
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(2),
..HttpFetcherConfig::default()
})
.expect("http");
let err = http.fetch_bytes(&url).unwrap_err();
assert!(err.contains("http status"), "{err}");
}
}

View File

@@ -80,3 +80,39 @@ fn normalize_rsync_base_uri(s: &str) -> String {
format!("{s}/")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_dir_rsync_fetcher_collects_files_and_normalizes_base_uri() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join("nested")).expect("mkdir");
std::fs::write(tmp.path().join("a.mft"), b"a").expect("write");
std::fs::write(tmp.path().join("nested").join("b.roa"), b"b").expect("write");
let f = LocalDirRsyncFetcher::new(tmp.path());
let mut objects = f
.fetch_objects("rsync://example.net/repo")
.expect("fetch_objects");
objects.sort_by(|(a, _), (b, _)| a.cmp(b));
assert_eq!(objects.len(), 2);
assert_eq!(objects[0].0, "rsync://example.net/repo/a.mft");
assert_eq!(objects[0].1, b"a");
assert_eq!(objects[1].0, "rsync://example.net/repo/nested/b.roa");
assert_eq!(objects[1].1, b"b");
}
#[test]
fn local_dir_rsync_fetcher_reports_read_dir_errors() {
let tmp = tempfile::tempdir().expect("tempdir");
let missing = tmp.path().join("missing");
let f = LocalDirRsyncFetcher::new(missing);
let err = f.fetch_objects("rsync://example.net/repo").unwrap_err();
match err {
RsyncFetchError::Fetch(msg) => assert!(!msg.is_empty()),
}
}
}

View File

@@ -142,3 +142,67 @@ fn walk_dir_collect(
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_rsync_base_uri_appends_slash_when_missing() {
assert_eq!(
normalize_rsync_base_uri("rsync://example.net/repo"),
"rsync://example.net/repo/".to_string()
);
assert_eq!(
normalize_rsync_base_uri("rsync://example.net/repo/"),
"rsync://example.net/repo/".to_string()
);
}
#[test]
fn walk_dir_collect_collects_files_and_normalizes_backslashes_in_uri() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
std::fs::create_dir_all(root.join("sub")).expect("mkdir");
std::fs::write(root.join("sub").join("a.cer"), b"x").expect("write");
std::fs::write(root.join("b\\c.mft"), b"y").expect("write backslash file");
let mut out: Vec<(String, Vec<u8>)> = Vec::new();
walk_dir_collect(root, root, "rsync://example.net/repo/", &mut out).expect("walk");
out.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(out.len(), 2);
assert_eq!(out[0].0, "rsync://example.net/repo/b/c.mft");
assert_eq!(out[0].1, b"y");
assert_eq!(out[1].0, "rsync://example.net/repo/sub/a.cer");
assert_eq!(out[1].1, b"x");
}
#[test]
fn system_rsync_fetcher_reports_spawn_and_exit_errors() {
let dst = tempfile::tempdir().expect("tempdir");
// 1) Spawn error.
let f = SystemRsyncFetcher::new(SystemRsyncConfig {
rsync_bin: PathBuf::from("/this/does/not/exist/rsync"),
timeout: Duration::from_secs(1),
extra_args: Vec::new(),
});
let e = f
.run_rsync("rsync://example.net/repo/", dst.path())
.expect_err("spawn must fail");
assert!(e.contains("rsync spawn failed:"), "{e}");
// 2) Non-zero exit status.
let f = SystemRsyncFetcher::new(SystemRsyncConfig {
rsync_bin: PathBuf::from("false"),
timeout: Duration::from_secs(1),
extra_args: Vec::new(),
});
let e = f
.run_rsync("rsync://example.net/repo/", dst.path())
.expect_err("false must fail");
assert!(e.contains("rsync failed:"), "{e}");
assert!(e.contains("status="), "{e}");
}
}

View File

@@ -1,9 +1,18 @@
pub mod audit;
pub mod cli;
pub mod data_model;
#[cfg(feature = "full")]
pub mod audit;
#[cfg(feature = "full")]
pub mod cli;
#[cfg(feature = "full")]
pub mod fetch;
#[cfg(feature = "full")]
pub mod policy;
#[cfg(feature = "full")]
pub mod report;
#[cfg(feature = "full")]
pub mod storage;
#[cfg(feature = "full")]
pub mod sync;
#[cfg(feature = "full")]
pub mod validation;

View File

@@ -288,3 +288,197 @@ fn collect_element_text(node: &roxmltree::Node<'_, '_>) -> Option<String> {
fn strip_all_ascii_whitespace(s: &str) -> String {
s.chars().filter(|c| !c.is_ascii_whitespace()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
struct MapFetcher {
map: HashMap<String, Vec<u8>>,
}
impl Fetcher for MapFetcher {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
self.map
.get(uri)
.cloned()
.ok_or_else(|| format!("not found: {uri}"))
}
}
fn notification_xml(session_id: &str, serial: u64, snapshot_uri: &str, snapshot_hash: &str) -> Vec<u8> {
format!(
r#"<notification xmlns="{RRDP_XMLNS}" version="1" session_id="{session_id}" serial="{serial}"><snapshot uri="{snapshot_uri}" hash="{snapshot_hash}"/></notification>"#
)
.into_bytes()
}
fn snapshot_xml(session_id: &str, serial: u64, published: &[(&str, &[u8])]) -> Vec<u8> {
let mut out = format!(
r#"<snapshot xmlns="{RRDP_XMLNS}" version="1" session_id="{session_id}" serial="{serial}">"#
);
for (uri, bytes) in published {
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
out.push_str(&format!(r#"<publish uri="{uri}">{b64}</publish>"#));
}
out.push_str("</snapshot>");
out.into_bytes()
}
#[test]
fn parse_notification_snapshot_rejects_non_ascii() {
let mut xml = b"<notification/>".to_vec();
xml.push(0x80);
let err = parse_notification_snapshot(&xml).unwrap_err();
assert!(matches!(err, RrdpError::NotAscii));
}
#[test]
fn parse_notification_snapshot_parses_valid_minimal_notification() {
let sid = "550e8400-e29b-41d4-a716-446655440000";
let snapshot_uri = "https://example.net/snapshot.xml";
let hash = "00".repeat(32);
let xml = notification_xml(sid, 7, snapshot_uri, &hash);
let n = parse_notification_snapshot(&xml).expect("parse");
assert_eq!(n.session_id, Uuid::parse_str(sid).unwrap());
assert_eq!(n.serial, 7);
assert_eq!(n.snapshot_uri, snapshot_uri);
assert_eq!(hex::encode(n.snapshot_hash_sha256), hash);
}
#[test]
fn sync_from_notification_snapshot_applies_snapshot_and_stores_state() {
let tmp = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(tmp.path()).expect("open rocksdb");
let sid = "550e8400-e29b-41d4-a716-446655440000";
let serial = 9u64;
let notif_uri = "https://example.net/notification.xml";
let snapshot_uri = "https://example.net/snapshot.xml";
let snapshot = snapshot_xml(
sid,
serial,
&[
("rsync://example.net/repo/a.mft", b"mft-bytes"),
("rsync://example.net/repo/b.roa", b"roa-bytes"),
],
);
let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot));
let notif = notification_xml(sid, serial, snapshot_uri, &snapshot_hash);
let fetcher = MapFetcher {
map: HashMap::from([(snapshot_uri.to_string(), snapshot.clone())]),
};
let published = sync_from_notification_snapshot(&store, notif_uri, &notif, &fetcher)
.expect("sync");
assert_eq!(published, 2);
let a = store
.get_raw("rsync://example.net/repo/a.mft")
.expect("get_raw")
.expect("a present");
assert_eq!(a, b"mft-bytes");
let b = store
.get_raw("rsync://example.net/repo/b.roa")
.expect("get_raw")
.expect("b present");
assert_eq!(b, b"roa-bytes");
let state_bytes = store
.get_rrdp_state(notif_uri)
.expect("get_rrdp_state")
.expect("state present");
let state = RrdpState::decode(&state_bytes).expect("decode state");
assert_eq!(state.session_id, sid);
assert_eq!(state.serial, serial);
}
#[test]
fn sync_from_notification_snapshot_rejects_snapshot_hash_mismatch() {
let tmp = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(tmp.path()).expect("open rocksdb");
let sid = "550e8400-e29b-41d4-a716-446655440000";
let serial = 1u64;
let notif_uri = "https://example.net/notification.xml";
let snapshot_uri = "https://example.net/snapshot.xml";
let snapshot = snapshot_xml(sid, serial, &[("rsync://example.net/repo/a.mft", b"x")]);
let notif = notification_xml(sid, serial, snapshot_uri, &"00".repeat(32));
let fetcher = MapFetcher {
map: HashMap::from([(snapshot_uri.to_string(), snapshot)]),
};
let err = sync_from_notification_snapshot(&store, notif_uri, &notif, &fetcher).unwrap_err();
assert!(matches!(err, RrdpSyncError::Rrdp(RrdpError::SnapshotHashMismatch)));
}
#[test]
fn apply_snapshot_rejects_session_id_and_serial_mismatch() {
let tmp = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(tmp.path()).expect("open rocksdb");
let expected_sid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let got_sid = "550e8400-e29b-41d4-a716-446655440001";
let snapshot = snapshot_xml(got_sid, 2, &[("rsync://example.net/repo/a.mft", b"x")]);
let err = apply_snapshot(&store, &snapshot, expected_sid, 2).unwrap_err();
assert!(matches!(
err,
RrdpSyncError::Rrdp(RrdpError::SnapshotSessionIdMismatch { .. })
));
let snapshot = snapshot_xml(expected_sid.to_string().as_str(), 3, &[("rsync://example.net/repo/a.mft", b"x")]);
let err = apply_snapshot(&store, &snapshot, expected_sid, 2).unwrap_err();
assert!(matches!(
err,
RrdpSyncError::Rrdp(RrdpError::SnapshotSerialMismatch { .. })
));
}
#[test]
fn strip_all_ascii_whitespace_removes_newlines_and_spaces() {
assert_eq!(strip_all_ascii_whitespace(" a \n b\tc "), "abc");
}
#[test]
fn apply_snapshot_reports_publish_errors() {
let tmp = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(tmp.path()).expect("open rocksdb");
let sid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
// Missing publish/@uri
let xml = format!(
r#"<snapshot xmlns="{RRDP_XMLNS}" version="1" session_id="{sid}" serial="1"><publish>AA==</publish></snapshot>"#
)
.into_bytes();
let err = apply_snapshot(&store, &xml, sid, 1).unwrap_err();
assert!(matches!(err, RrdpSyncError::Rrdp(RrdpError::PublishUriMissing)));
// Missing base64 content (no text nodes).
let xml = format!(
r#"<snapshot xmlns="{RRDP_XMLNS}" version="1" session_id="{sid}" serial="1"><publish uri="rsync://example.net/repo/a.cer"></publish></snapshot>"#
)
.into_bytes();
let err = apply_snapshot(&store, &xml, sid, 1).unwrap_err();
assert!(matches!(
err,
RrdpSyncError::Rrdp(RrdpError::PublishContentMissing)
));
// Invalid base64 content.
let xml = format!(
r#"<snapshot xmlns="{RRDP_XMLNS}" version="1" session_id="{sid}" serial="1"><publish uri="rsync://example.net/repo/a.cer">!!!</publish></snapshot>"#
)
.into_bytes();
let err = apply_snapshot(&store, &xml, sid, 1).unwrap_err();
assert!(matches!(
err,
RrdpSyncError::Rrdp(RrdpError::PublishBase64(_))
));
}
}

View File

@@ -260,13 +260,17 @@ fn revalidate_cached_pack_with_current_time(
.map(|f| (f.rsync_uri.as_str(), f))
.collect();
for entry in &manifest.manifest.files {
let entries = manifest
.manifest
.parse_files()
.map_err(|e| ManifestFreshError::Decode(ManifestDecodeError::Validate(e)))?;
for entry in &entries {
let rsync_uri =
join_rsync_dir_and_file(&pack.publication_point_rsync_uri, &entry.file_name);
join_rsync_dir_and_file(&pack.publication_point_rsync_uri, entry.file_name.as_str());
let Some(file) = by_uri.get(rsync_uri.as_str()) else {
return Err(ManifestCachedError::CachedMissingFile { rsync_uri });
};
if file.sha256.as_slice() != entry.hash_bytes.as_slice() {
if file.sha256.as_slice() != entry.hash_bytes.as_ref() {
return Err(ManifestCachedError::CachedHashMismatch { rsync_uri });
}
}
@@ -388,9 +392,11 @@ fn try_build_fresh_pack(
}
}
let mut files = Vec::with_capacity(manifest.manifest.files.len());
for entry in &manifest.manifest.files {
let rsync_uri = join_rsync_dir_and_file(publication_point_rsync_uri, &entry.file_name);
let entries = manifest.manifest.parse_files().map_err(ManifestDecodeError::Validate)?;
let mut files = Vec::with_capacity(manifest.manifest.file_count());
for entry in &entries {
let rsync_uri =
join_rsync_dir_and_file(publication_point_rsync_uri, entry.file_name.as_str());
let bytes = store
.get_raw(&rsync_uri)
.map_err(|_e| ManifestFreshError::MissingFile {
@@ -401,7 +407,7 @@ fn try_build_fresh_pack(
})?;
let computed = sha2::Sha256::digest(&bytes);
if computed.as_slice() != entry.hash_bytes.as_slice() {
if computed.as_slice() != entry.hash_bytes.as_ref() {
return Err(ManifestFreshError::HashMismatch { rsync_uri });
}