Files
convertis/src/identifier.rs
T
Elias Wendland f07f54d4c1
Release / check-release (push) Successful in 16s
Release / build (push) Skipped
Release / package (deb) (push) Skipped
Release / package (rpm) (push) Skipped
Release / publish (push) Skipped
CI / test (push) Successful in 1m58s
feat: implement protocol v2 for plugin system and add comprehensive tracing support
2026-07-17 17:46:14 +02:00

357 lines
13 KiB
Rust

// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{ArtifactKind, MediaKind};
use std::{fs, path::Path};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DetectedFormat {
pub format: String,
pub mime: String,
pub media_kind: MediaKind,
pub artifact_kind: ArtifactKind,
}
fn normalized(format: &str) -> String {
let normalized = match format.to_ascii_lowercase().as_str() {
"jpg" => "jpeg".to_owned(),
"htm" => "html".to_owned(),
"txt" | "ascii" => "text".to_owned(),
other => other.to_owned(),
};
tracing::trace!(
input = format,
output = normalized,
"normalized format name"
);
normalized
}
pub fn requested_format(path: Option<&Path>, explicit: Option<&str>) -> Option<String> {
tracing::trace!(path = ?path, explicit, "resolving requested format");
let requested = explicit.map(normalized).or_else(|| {
path.and_then(Path::extension)
.and_then(|value| value.to_str())
.map(normalized)
});
tracing::trace!(?requested, "resolved requested format");
requested
}
pub fn identify_path(path: &Path, explicit: Option<&str>) -> Result<DetectedFormat, String> {
tracing::debug!(path = %path.display(), explicit, is_directory = path.is_dir(), "starting path identification");
if path.is_dir() {
tracing::trace!(path = %path.display(), "identifying directory artifact");
if explicit.is_some_and(|format| normalized(format) != "frames") {
return Err("directory inputs currently support only the 'frames' format".to_owned());
}
let manifest = path.join(".convertis-frames.json");
tracing::trace!(manifest = %manifest.display(), exists = manifest.exists(), "checking frame manifest");
if manifest.exists() {
tracing::trace!(manifest = %manifest.display(), "reading frame manifest");
let bytes = fs::read(&manifest)
.map_err(|error| format!("could not read {}: {error}", manifest.display()))?;
tracing::trace!(manifest = %manifest.display(), byte_count = bytes.len(), "parsing frame manifest");
let value: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|error| format!("invalid frame manifest: {error}"))?;
let schema_version = value
.get("schema_version")
.and_then(serde_json::Value::as_u64);
tracing::trace!(?schema_version, "read frame manifest schema version");
if schema_version != Some(1) {
return Err("unsupported frame manifest schema".to_owned());
}
} else {
tracing::trace!(path = %path.display(), "scanning directory for recognizable frames");
let mut detected = None;
for entry in fs::read_dir(path).map_err(|error| error.to_string())? {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
tracing::trace!(%error, "could not inspect frame-directory entry");
continue;
}
};
tracing::trace!(entry = %entry.path().display(), "inspecting frame-directory entry");
if !entry.path().is_file() {
tracing::trace!(entry = %entry.path().display(), "skipping non-file frame-directory entry");
continue;
}
let bytes = fs::read(entry.path()).map_err(|error| error.to_string())?;
tracing::trace!(entry = %entry.path().display(), byte_count = bytes.len(), "read potential frame file");
let Some(format) = identify_bytes(&bytes) else {
tracing::trace!(entry = %entry.path().display(), "frame candidate was not recognizable");
continue;
};
if format.media_kind != MediaKind::Image {
tracing::trace!(entry = %entry.path().display(), media_kind = ?format.media_kind, "frame candidate was not a still image");
continue;
}
if detected
.as_ref()
.is_some_and(|current| current != &format.format)
{
return Err(
"frame directories without metadata must use one image format".to_owned(),
);
}
tracing::trace!(entry = %entry.path().display(), format = %format.format, "accepted frame candidate");
detected = Some(format.format);
}
if detected.is_none() {
return Err(
"directory has no frame manifest or recognizable image frames".to_owned(),
);
}
}
tracing::debug!(path = %path.display(), "identified frames directory");
return Ok(DetectedFormat {
format: "frames".to_owned(),
mime: "application/vnd.convertis.frames+json".to_owned(),
media_kind: MediaKind::Frames,
artifact_kind: ArtifactKind::Directory,
});
}
tracing::trace!(path = %path.display(), "reading file for content identification");
let bytes =
fs::read(path).map_err(|error| format!("could not read {}: {error}", path.display()))?;
tracing::trace!(path = %path.display(), byte_count = bytes.len(), "read file for identification");
if let Some(format) = explicit {
let format = normalized(format);
tracing::debug!(path = %path.display(), format, "using explicit input format");
return Ok(from_format(&format, &bytes));
}
let identified = identify_bytes(&bytes)
.ok_or_else(|| format!("could not identify {} from its contents", path.display()))?;
tracing::debug!(path = %path.display(), format = %identified.format, "identified file contents");
Ok(identified)
}
pub fn identify_bytes(bytes: &[u8]) -> Option<DetectedFormat> {
tracing::trace!(
byte_count = bytes.len(),
"starting content signature identification"
);
if bytes.starts_with(&[0, 0, 1, 0]) {
tracing::trace!(rule = "ico-header", "matched content signature");
return Some(from_format("ico", bytes));
}
tracing::trace!(
rule = "ico-header",
matched = false,
"checked content signature"
);
if bytes.starts_with(b"<!DOCTYPE html") || bytes.starts_with(b"<html") {
tracing::trace!(rule = "html-prefix", "matched content signature");
return Some(from_format("html", bytes));
}
tracing::trace!(
rule = "html-prefix",
matched = false,
"checked content signature"
);
if bytes.windows(4).any(|window| window == b"M4A ") {
tracing::trace!(rule = "m4a-brand", "matched content signature");
return Some(from_format("m4a", bytes));
}
tracing::trace!(
rule = "m4a-brand",
matched = false,
"checked content signature"
);
if bytes.starts_with(b"OggS") {
tracing::trace!(rule = "ogg-header", "matched content signature");
if bytes.windows(8).any(|window| window == b"OpusHead") {
tracing::trace!(rule = "opus-header", "matched Ogg subtype signature");
return Some(from_format("opus", bytes));
}
if bytes.windows(6).any(|window| window == b"theora") {
tracing::trace!(rule = "theora-header", "matched Ogg subtype signature");
return Some(from_format("ogv", bytes));
}
tracing::trace!("no Ogg subtype signature matched; using generic Ogg format");
return Some(from_format("ogg", bytes));
}
tracing::trace!(
rule = "ogg-header",
matched = false,
"checked content signature"
);
if let Some(kind) = infer::get(bytes) {
let format = normalized(kind.extension());
tracing::trace!(
infer_extension = kind.extension(),
mime = kind.mime_type(),
format,
"matched infer signature database"
);
return Some(from_format(&format, bytes));
}
tracing::trace!("infer signature database did not match");
let text = std::str::from_utf8(bytes).ok()?;
tracing::trace!(
utf8 = true,
character_count = text.chars().count(),
"content is valid UTF-8"
);
if !text.contains('\0') {
tracing::trace!(rule = "utf8-without-nul", "classified content as text");
return Some(from_format("text", bytes));
}
tracing::trace!("content did not match any supported identification rule");
None
}
fn from_format(format: &str, bytes: &[u8]) -> DetectedFormat {
tracing::trace!(
format,
byte_count = bytes.len(),
"deriving media details from format"
);
let animation = match format {
"webp" => bytes.len() > 20 && &bytes[12..16] == b"VP8X" && bytes[20] & 0x02 != 0,
"png" | "apng" => bytes.windows(4).any(|window| window == b"acTL"),
"gif" => gif_is_animated(bytes),
_ => false,
};
tracing::trace!(format, animation, "determined animation state");
let media_kind = if animation {
MediaKind::Animation
} else if matches!(
format,
"png" | "jpeg" | "gif" | "webp" | "bmp" | "tiff" | "ico" | "avif" | "heic" | "jxl" | "svg"
) {
MediaKind::Image
} else if matches!(
format,
"mp3" | "wav" | "flac" | "aac" | "m4a" | "opus" | "ogg" | "wma" | "aiff" | "au"
) {
MediaKind::Audio
} else if matches!(
format,
"mp4" | "webm" | "mkv" | "avi" | "mov" | "wmv" | "flv" | "m4v" | "mpeg" | "ogv"
) {
MediaKind::Video
} else if format == "text" {
MediaKind::Text
} else if format == "html" {
MediaKind::Document
} else {
MediaKind::Unknown
};
let mime = match format {
"jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"tiff" => "image/tiff",
"ico" => "image/x-icon",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"flac" => "audio/flac",
"mp4" => "video/mp4",
"webm" => "video/webm",
"text" => "text/plain; charset=utf-8",
"html" => "text/html; charset=utf-8",
_ => "application/octet-stream",
};
let format = if animation {
match format {
"png" | "apng" => "apng".to_owned(),
"gif" => "animated-gif".to_owned(),
"webp" => "animated-webp".to_owned(),
other => other.to_owned(),
}
} else {
format.to_owned()
};
tracing::trace!(format, mime, ?media_kind, "derived media details");
DetectedFormat {
format,
mime: mime.to_owned(),
media_kind,
artifact_kind: ArtifactKind::File,
}
}
fn gif_is_animated(bytes: &[u8]) -> bool {
tracing::trace!(byte_count = bytes.len(), "checking GIF animation state");
let mut options = gif::DecodeOptions::new();
options.set_color_output(gif::ColorOutput::Indexed);
let Ok(mut decoder) = options.read_info(std::io::Cursor::new(bytes)) else {
tracing::trace!("GIF decoder rejected content while checking animation");
return false;
};
let mut frames = 0;
while let Ok(Some(_)) = decoder.read_next_frame() {
frames += 1;
tracing::trace!(frames, "decoded GIF frame while checking animation");
if frames > 1 {
tracing::trace!(frames, animated = true, "GIF has multiple frames");
return true;
}
}
tracing::trace!(
frames,
animated = false,
"GIF has fewer than two decodable frames"
);
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn output_extension_is_only_target_intent() {
assert_eq!(
requested_format(Some(Path::new("output.JPG")), None),
Some("jpeg".into())
);
assert_eq!(
requested_format(Some(Path::new("output.jpg")), Some("png")),
Some("png".into())
);
}
#[test]
fn identifies_text_without_an_extension() {
assert_eq!(identify_bytes(b"hello\nworld").unwrap().format, "text");
}
#[test]
fn identifies_ico_magic() {
assert_eq!(identify_bytes(&[0, 0, 1, 0, 1, 0]).unwrap().format, "ico");
}
#[test]
fn content_wins_over_a_misleading_name() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("actually-an-image.txt");
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
png.resize(32, 0);
std::fs::write(&path, png).unwrap();
assert_eq!(identify_path(&path, None).unwrap().format, "png");
}
#[test]
fn distinguishes_animated_webp() {
let mut webp = b"RIFF\x16\0\0\0WEBPVP8X\x0a\0\0\0\x02".to_vec();
webp.resize(32, 0);
assert_eq!(identify_bytes(&webp).unwrap().format, "animated-webp");
}
}