use std::fmt;
#[derive(Debug, Clone, Copy)]
pub struct AppVendorAsset {
pub app_label: &'static str,
pub url: &'static str,
pub target: &'static str,
pub sha256: &'static str,
}
inventory::collect!(AppVendorAsset);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VendorAssetError {
BadTargetPrefix(&'static str),
PathTraversal(&'static str),
NullByte(&'static str),
}
impl fmt::Display for VendorAssetError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BadTargetPrefix(t) => {
write!(f, "vendor asset target {:?} must start with \"vendor/\"", t)
}
Self::PathTraversal(t) => {
write!(
f,
"vendor asset target {:?} must not contain \"..\" segments",
t
)
}
Self::NullByte(t) => {
write!(f, "vendor asset target {:?} must not contain null byte", t)
}
}
}
}
impl std::error::Error for VendorAssetError {}
impl AppVendorAsset {
pub fn validate(&self) -> Result<(), VendorAssetError> {
if !self.target.starts_with("vendor/") {
return Err(VendorAssetError::BadTargetPrefix(self.target));
}
if self.target.split('/').any(|seg| seg == "..") {
return Err(VendorAssetError::PathTraversal(self.target));
}
if self.target.contains('\0') {
return Err(VendorAssetError::NullByte(self.target));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case::valid_simple("vendor/htmx.min.js")]
#[case::valid_nested("vendor/fonts/inter-400.woff2")]
fn validate_target_accepts_well_formed(#[case] target: &'static str) {
let asset = AppVendorAsset {
app_label: "blog",
url: "https://example.test/x.js",
target,
sha256: "",
};
let result = asset.validate();
assert!(
result.is_ok(),
"expected {:?} to be accepted, got {:?}",
target,
result
);
}
#[rstest]
#[case::missing_prefix("htmx.min.js", "must start with \"vendor/\"")]
#[case::parent_traversal("vendor/../etc/passwd", "must not contain")]
#[case::absolute_unix("/etc/passwd", "must start with \"vendor/\"")]
#[case::null_byte("vendor/foo\0.js", "must not contain null byte")]
#[case::empty("", "must start with \"vendor/\"")]
fn validate_target_rejects_bad(
#[case] target: &'static str,
#[case] expected_msg: &'static str,
) {
let asset = AppVendorAsset {
app_label: "blog",
url: "https://example.test/x.js",
target,
sha256: "",
};
let err = asset.validate().expect_err("validation should fail");
assert!(
err.to_string().contains(expected_msg),
"expected error to contain {:?}, got {:?}",
expected_msg,
err.to_string()
);
}
}