use crate::pipeline::PipelineError;
use crate::pipeline::nodes::shared::limits::choice;
pub const ACCEPT_CODE: &str = "FW_NODE_FS_FILE_PUT_ACCEPT";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Accept {
Image,
Pdf,
Csv,
Json,
Glb,
Audio,
Video,
Archive,
}
pub const ACCEPT_WORDS: &[&str] = &["image", "pdf", "csv", "json", "glb", "audio", "video", "archive"];
impl Accept {
fn parse(word: &str) -> Result<Self, PipelineError> {
Ok(match choice(word, ACCEPT_WORDS, "", "--accept", ACCEPT_CODE)? {
"image" => Self::Image,
"pdf" => Self::Pdf,
"csv" => Self::Csv,
"json" => Self::Json,
"glb" => Self::Glb,
"audio" => Self::Audio,
"video" => Self::Video,
"archive" => Self::Archive,
_ => return Err(PipelineError::new(ACCEPT_CODE, "--accept needs a word")),
})
}
pub fn word(self) -> &'static str {
match self {
Self::Image => "image",
Self::Pdf => "pdf",
Self::Csv => "csv",
Self::Json => "json",
Self::Glb => "glb",
Self::Audio => "audio",
Self::Video => "video",
Self::Archive => "archive",
}
}
fn mime_types(self) -> &'static [&'static str] {
match self {
Self::Image => &[
"image/jpeg", "image/png", "image/webp", "image/gif", "image/bmp", "image/tiff",
"image/svg+xml", "image/avif", "image/heic", "image/heif",
],
Self::Pdf => &["application/pdf"],
Self::Csv => &["text/csv", "text/plain"],
Self::Json => &["application/json", "text/json"],
Self::Glb => &["model/gltf-binary"],
Self::Audio => &["audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4"],
Self::Video => &["video/mp4", "video/webm", "video/ogg", "video/x-m4v"],
Self::Archive => &[
"application/zip", "application/gzip", "application/x-tar", "application/x-bzip2",
"application/x-7z-compressed", "application/x-rar-compressed",
"application/vnd.android.package-archive", "application/java-archive",
],
}
}
}
pub fn parse_accept(words: &[String]) -> Result<Vec<Accept>, PipelineError> {
let mut kinds = Vec::new();
for word in words.iter().flat_map(|w| w.split(',')).map(str::trim).filter(|w| !w.is_empty()) {
let kind = Accept::parse(word)?;
if !kinds.contains(&kind) {
kinds.push(kind);
}
}
if kinds.is_empty() {
kinds.push(Accept::Image);
}
Ok(kinds)
}
pub fn effective_mime(bytes: &[u8], claimed: &str, accept: &[Accept]) -> Result<String, PipelineError> {
let detected = detect_binary_mime(bytes).map(normalized_mime);
let mime = match detected.as_deref() {
Some(mime) => mime.to_string(),
None => {
let claimed_mime = normalized_mime(claimed);
const TEXT: &[&str] = &["image/svg+xml", "application/json", "text/json", "text/csv", "text/plain"];
if !TEXT.contains(&claimed_mime.as_str()) {
return Err(PipelineError::new(
ACCEPT_CODE,
format!("the file's type could not be read from its content (it claims '{claimed}')"),
));
}
claimed_mime
}
};
if !accept.iter().any(|kind| kind.mime_types().iter().any(|m| normalized_mime(m) == mime)) {
let words: Vec<&str> = accept.iter().map(|kind| kind.word()).collect();
return Err(PipelineError::new(
ACCEPT_CODE,
format!("the file is '{mime}', which --accept {} does not allow", words.join(",")),
));
}
if let Some(detected) = detected.as_deref()
&& !claim_matches_detected(claimed, detected)
{
return Err(PipelineError::new(
ACCEPT_CODE,
format!("the file claims '{claimed}' but its content is '{detected}'"),
));
}
Ok(mime)
}
pub fn extensions(mime: &str) -> &'static [&'static str] {
match normalized_mime(mime).as_str() {
"image/jpeg" => &["jpg", "jpeg"],
"image/png" => &["png"],
"image/webp" => &["webp"],
"image/gif" => &["gif"],
"image/bmp" => &["bmp"],
"image/tiff" => &["tif", "tiff"],
"image/svg+xml" => &["svg"],
"image/avif" => &["avif"],
"image/heic" => &["heic"],
"image/heif" => &["heif", "heic"],
"application/pdf" => &["pdf"],
"text/plain" => &["txt", "csv", "text"],
"text/csv" => &["csv"],
"application/json" | "text/json" => &["json", "geojson"],
"application/zip" => &["zip", "apk", "jar"],
"video/mp4" => &["mp4", "m4v"],
"video/webm" => &["webm"],
"video/ogg" => &["ogv", "ogg"],
"video/x-m4v" => &["m4v"],
"audio/mpeg" => &["mp3"],
"audio/wav" => &["wav"],
"audio/ogg" => &["ogg", "oga", "opus"],
"audio/mp4" => &["m4a", "mp4"],
"model/gltf-binary" => &["glb"],
"application/gzip" | "application/x-gzip" => &["gz", "tgz"],
"application/x-tar" => &["tar"],
"application/x-bzip2" => &["bz2"],
"application/x-7z-compressed" => &["7z"],
"application/x-rar-compressed" => &["rar"],
"application/vnd.android.package-archive" => &["apk"],
"application/java-archive" => &["jar"],
_ => &[],
}
}
pub fn stored_extension(original_name: &str, mime: &str) -> &'static str {
let allowed = extensions(mime);
let original = extension_of(original_name);
allowed
.iter()
.find(|ext| Some(**ext) == original.as_deref())
.or_else(|| allowed.first())
.copied()
.unwrap_or("")
}
pub fn extension_agrees(ext: &str, mime: &str) -> bool {
let allowed = extensions(mime);
allowed.is_empty() || allowed.contains(&ext.to_ascii_lowercase().as_str())
}
pub fn extension_of(name: &str) -> Option<String> {
let ext = std::path::Path::new(name.trim()).extension()?.to_str()?;
let ext: String = ext.chars().filter(|c| c.is_ascii_alphanumeric()).take(10).collect::<String>().to_ascii_lowercase();
(!ext.is_empty()).then_some(ext)
}
pub fn normalized_mime(mime: &str) -> String {
let raw = mime.split(';').next().unwrap_or("").trim().to_lowercase();
match raw.as_str() {
"image/jpg" => "image/jpeg".to_string(),
"audio/x-wav" => "audio/wav".to_string(),
"audio/m4a" => "audio/mp4".to_string(),
"application/x-gzip" => "application/gzip".to_string(),
"application/geo+json" => "application/json".to_string(),
_ => raw,
}
}
pub fn detect_binary_mime(bytes: &[u8]) -> Option<&'static str> {
if is_glb(bytes) {
return Some("model/gltf-binary");
}
if is_ogg_theora(bytes) {
return Some("video/ogg");
}
infer::get(bytes).map(|kind| kind.mime_type())
}
fn is_glb(bytes: &[u8]) -> bool {
if bytes.len() < 12 || &bytes[0..4] != b"glTF" {
return false;
}
let version = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let declared_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
(version == 1 || version == 2) && declared_len >= 12 && declared_len <= bytes.len()
}
fn is_ogg_theora(bytes: &[u8]) -> bool {
if bytes.len() < 35 || &bytes[0..4] != b"OggS" {
return false;
}
let page_segments = bytes[26] as usize;
let payload_start = 27 + page_segments;
bytes.get(payload_start..payload_start + 7) == Some(&b"\x80theora"[..])
}
fn claim_matches_detected(claimed: &str, detected: &str) -> bool {
let claimed = normalized_mime(claimed);
let detected = normalized_mime(detected);
if claimed.is_empty() || claimed == "application/octet-stream" || claimed == "binary/octet-stream" || claimed == detected {
return true;
}
detected == "application/zip"
&& ["application/vnd.android.package-archive", "application/java-archive"].contains(&claimed.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01\x08\x02\0\0\0";
#[test]
fn accept_words_are_singular_and_closed() {
assert_eq!(parse_accept(&[]).unwrap(), vec![Accept::Image]);
assert_eq!(parse_accept(&["image".into(), "PDF".into()]).unwrap(), vec![Accept::Image, Accept::Pdf]);
assert_eq!(parse_accept(&["image,pdf".into()]).unwrap(), vec![Accept::Image, Accept::Pdf]);
for bad in ["images", "documents", "jpg"] {
let err = parse_accept(&[bad.into()]).unwrap_err();
assert_eq!(err.code, ACCEPT_CODE);
assert!(err.message.contains("must be one of image, pdf"), "{}", err.message);
}
}
#[test]
fn audio_aliases_are_accepted() {
assert!(effective_mime(b"", "audio/x-wav", &[Accept::Audio]).is_err(), "no signature, not text");
assert_eq!(normalized_mime("audio/x-wav"), "audio/wav");
assert_eq!(normalized_mime("audio/m4a"), "audio/mp4");
assert!(Accept::Audio.mime_types().contains(&"audio/wav"));
}
#[test]
fn geojson_is_json_under_its_own_extension() {
let geo = br#"{"type":"FeatureCollection","features":[]}"#;
assert_eq!(effective_mime(geo, "application/geo+json", &[Accept::Json]).unwrap(), "application/json");
assert_eq!(stored_extension("places.geojson", "application/json"), "geojson");
assert_eq!(stored_extension("places", "application/json"), "json");
}
#[test]
fn detects_glb_by_header() {
let mut bytes = b"glTF".to_vec();
bytes.extend_from_slice(&2u32.to_le_bytes());
bytes.extend_from_slice(&(12u32).to_le_bytes());
assert_eq!(detect_binary_mime(&bytes), Some("model/gltf-binary"));
}
#[test]
fn detects_ogg_theora_as_video() {
let mut bytes = vec![0; 35];
bytes[0..4].copy_from_slice(b"OggS");
bytes[26] = 1;
bytes[27] = 7;
bytes[28..35].copy_from_slice(b"\x80theora");
assert_eq!(detect_binary_mime(&bytes), Some("video/ogg"));
}
#[test]
fn the_content_decides_and_the_claim_must_agree() {
assert_eq!(effective_mime(PNG, "image/png", &[Accept::Image]).unwrap(), "image/png");
assert_eq!(effective_mime(PNG, "application/octet-stream", &[Accept::Image]).unwrap(), "image/png");
assert!(effective_mime(PNG, "image/jpeg", &[Accept::Image]).unwrap_err().message.contains("claims"));
assert!(effective_mime(PNG, "image/png", &[Accept::Pdf]).unwrap_err().message.contains("--accept pdf"));
assert_eq!(effective_mime(b"a,b\n1,2\n", "text/csv", &[Accept::Csv]).unwrap(), "text/csv");
assert!(effective_mime(b"a,b\n", "image/png", &[Accept::Image]).is_err());
}
#[test]
fn the_extension_follows_the_detected_type() {
assert_eq!(stored_extension("upload", "model/gltf-binary"), "glb");
assert_eq!(stored_extension("photo.jpeg", "image/jpeg"), "jpeg");
assert_eq!(stored_extension("photo.exe", "image/jpeg"), "jpg");
assert_eq!(stored_extension("upload", "audio/x-wav"), "wav");
assert_eq!(stored_extension("upload", "audio/m4a"), "m4a");
assert_eq!(stored_extension("app.apk", "application/zip"), "apk");
assert_eq!(stored_extension("upload", "application/x-unknown"), "");
assert!(extension_agrees("JPG", "image/jpeg"));
assert!(!extension_agrees("png", "image/jpeg"));
}
}