use quick_xml::events::{BytesRef, BytesText};
use std::io::{self, ErrorKind, Read};
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant, SystemTime};
pub const MAX_DECODE_BYTES: usize = 32 * 1024 * 1024;
pub const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
pub const MAX_ARCHIVE_INFLATE: usize = 256 * 1024 * 1024;
pub const MAX_IMAGE_DIM: u32 = 20_000;
pub fn read_to_string_capped<R: Read>(reader: R, max: usize) -> Result<String, String> {
let mut buf = Vec::new();
reader
.take(max as u64 + 1)
.read_to_end(&mut buf)
.map_err(|e| e.to_string())?;
if buf.len() > max {
return Err(format!(
"input exceeds {} preview/parse limit",
human_size(max as u64)
));
}
String::from_utf8(buf).map_err(|_| "input is not valid UTF-8".to_string())
}
pub fn image_limits() -> image::Limits {
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_IMAGE_DIM);
limits.max_image_height = Some(MAX_IMAGE_DIM);
limits
}
pub fn open_image_reader(
path: &Path,
) -> io::Result<image::ImageReader<io::BufReader<std::fs::File>>> {
let mut reader = image::ImageReader::open(path)?.with_guessed_format()?;
reader.limits(image_limits());
Ok(reader)
}
pub fn image_dimensions(path: &Path) -> io::Result<(u32, u32)> {
open_image_reader(path)?
.into_dimensions()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
pub const SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
pub fn run_with_timeout(mut cmd: Command, timeout: Duration) -> io::Result<Output> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let out_h = std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut s) = stdout {
let _ = s.read_to_end(&mut buf);
}
buf
});
let err_h = std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut s) = stderr {
let _ = s.read_to_end(&mut buf);
}
buf
});
let deadline = Instant::now() + timeout;
let status = loop {
match child.try_wait()? {
Some(status) => break status,
None => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
let _ = out_h.join();
let _ = err_h.join();
return Err(io::Error::new(ErrorKind::TimedOut, "subprocess timed out"));
}
std::thread::sleep(Duration::from_millis(10));
}
}
};
let stdout = out_h.join().unwrap_or_default();
let stderr = err_h.join().unwrap_or_default();
Ok(Output {
status,
stdout,
stderr,
})
}
pub fn cmd_path_arg(path: &str) -> String {
if path.starts_with('/') || path.starts_with("./") {
path.to_string()
} else {
format!("./{path}")
}
}
pub fn open_in_native_app(path: &str) {
let arg = cmd_path_arg(path);
#[cfg(target_os = "macos")]
let _ = Command::new("open").arg(arg).spawn();
#[cfg(target_os = "linux")]
let _ = Command::new("xdg-open").arg(arg).spawn();
#[cfg(target_os = "windows")]
let _ = Command::new("rundll32")
.args(["url.dll,FileProtocolHandler", &arg])
.spawn();
}
pub fn is_safe_url(url: &str) -> bool {
if url.starts_with('-') {
return false;
}
let lower = url.to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("mailto:")
}
pub fn extract_ooxml_media(archive: &str, dir_prefix: &str) -> Vec<PathBuf> {
extract_zip_images(archive, |n| n.starts_with(dir_prefix) && is_raster_name(n))
}
pub fn extract_epub_media(archive: &str) -> Vec<PathBuf> {
extract_zip_images(archive, is_raster_name)
}
fn extract_zip_images(archive: &str, keep: impl Fn(&str) -> bool) -> Vec<PathBuf> {
let Ok(file) = std::fs::File::open(archive) else {
return Vec::new();
};
let Ok(mut zip) = zip::ZipArchive::new(file) else {
return Vec::new();
};
let mut names: Vec<String> = (0..zip.len())
.filter_map(|i| zip.by_index(i).ok().map(|f| f.name().to_string()))
.filter(|n| keep(n))
.collect();
names.sort();
if names.is_empty() {
return Vec::new();
}
let dir = std::env::temp_dir().join(format!("sucher-media-{}", std::process::id()));
if std::fs::create_dir_all(&dir).is_err() {
return Vec::new();
}
let mut out = Vec::new();
for name in names {
let Ok(mut f) = zip.by_name(&name) else {
continue;
};
let mut bytes = Vec::new();
if f.read_to_end(&mut bytes).is_err() {
continue;
}
let dest = dir.join(name.replace('/', "_"));
if std::fs::write(&dest, &bytes).is_ok() {
out.push(dest);
}
}
out
}
fn is_raster_name(name: &str) -> bool {
let n = name.to_lowercase();
[
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".tif", ".webp",
]
.iter()
.any(|e| n.ends_with(e))
}
pub fn xml_text(t: &BytesText) -> String {
t.decode().map(|c| c.into_owned()).unwrap_or_default()
}
pub fn xml_ref(r: &BytesRef) -> String {
let name = r.decode().map(|c| c.into_owned()).unwrap_or_default();
quick_xml::escape::unescape(&format!("&{name};"))
.map(|c| c.into_owned())
.unwrap_or_default()
}
pub fn human_size(n: u64) -> String {
const U: [&str; 5] = ["B", "K", "M", "G", "T"];
if n < 1024 {
return format!("{n} B");
}
let mut f = n as f64;
let mut i = 0;
while f >= 1024.0 && i < 4 {
f /= 1024.0;
i += 1;
}
format!("{f:.1}{}", U[i])
}
pub fn human_age(t: SystemTime, now: SystemTime) -> String {
let secs = now.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
const MIN: u64 = 60;
const HOUR: u64 = 60 * MIN;
const DAY: u64 = 24 * HOUR;
const WEEK: u64 = 7 * DAY;
const MONTH: u64 = 30 * DAY;
const YEAR: u64 = 365 * DAY;
match secs {
s if s < 5 => "now".into(),
s if s < MIN => format!("{s}s"),
s if s < HOUR => format!("{}m", s / MIN),
s if s < DAY => format!("{}h", s / HOUR),
s if s < WEEK => format!("{}d", s / DAY),
s if s < 2 * MONTH => format!("{}w", s / WEEK),
s if s < YEAR => format!("{}mo", s / MONTH),
s => format!("{}y", s / YEAR),
}
}
pub fn rel_time(t: SystemTime) -> String {
let secs = SystemTime::now()
.duration_since(t)
.map(|d| d.as_secs())
.unwrap_or(0);
match secs {
s if s < 60 => "just now".into(),
s if s < 3600 => format!("{}m ago", s / 60),
s if s < 86_400 => format!("{}h ago", s / 3600),
s if s < 86_400 * 30 => format!("{}d ago", s / 86_400),
s if s < 86_400 * 365 => format!("{}mo ago", s / (86_400 * 30)),
s => format!("{}y ago", s / (86_400 * 365)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn human_size_scales_units() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(512), "512 B");
assert_eq!(human_size(1024), "1.0K");
assert_eq!(human_size(1024 * 1024), "1.0M");
assert_eq!(human_size(3 * 1024 * 1024 * 1024), "3.0G");
}
#[test]
fn rel_time_buckets() {
let now = SystemTime::now();
assert_eq!(rel_time(now), "just now");
assert_eq!(rel_time(now - Duration::from_secs(120)), "2m ago");
assert_eq!(rel_time(now - Duration::from_secs(3 * 86_400)), "3d ago");
}
#[test]
fn human_age_across_the_ranges() {
let now = SystemTime::now();
let ago = |d: Duration| now - d;
assert_eq!(human_age(now, now), "now");
assert_eq!(human_age(ago(Duration::from_secs(3)), now), "now");
assert_eq!(human_age(ago(Duration::from_secs(30)), now), "30s");
assert_eq!(human_age(ago(Duration::from_secs(5 * 60)), now), "5m");
assert_eq!(human_age(ago(Duration::from_secs(3 * 3600)), now), "3h");
assert_eq!(human_age(ago(Duration::from_secs(2 * 86_400)), now), "2d");
assert_eq!(human_age(ago(Duration::from_secs(42 * 86_400)), now), "6w");
assert_eq!(
human_age(ago(Duration::from_secs(120 * 86_400)), now),
"4mo"
);
assert_eq!(
human_age(ago(Duration::from_secs(3 * 365 * 86_400)), now),
"3y"
);
assert_eq!(human_age(now + Duration::from_secs(60), now), "now");
}
#[test]
fn capped_reads_under_and_at_the_limit() {
let data = [b'a'; 10];
assert_eq!(read_to_string_capped(&data[..], 10).unwrap().len(), 10);
assert_eq!(read_to_string_capped(&data[..], 20).unwrap().len(), 10);
}
#[test]
fn capped_rejects_one_byte_over_the_limit() {
let data = [b'a'; 11];
assert!(read_to_string_capped(&data[..], 10).is_err());
}
#[test]
fn capped_rejects_invalid_utf8() {
let data = [0xff, 0xfe, 0x00];
assert!(read_to_string_capped(&data[..], 100).is_err());
}
#[test]
fn capped_stops_a_bomb_without_unbounded_allocation() {
let bomb = std::io::repeat(b'a').take(1 << 40);
let max = 1024;
assert!(
read_to_string_capped(bomb, max).is_err(),
"a source larger than the cap must be rejected, not truncated"
);
}
#[test]
fn cmd_path_arg_guards_leading_dash() {
assert_eq!(cmd_path_arg("/abs/x.pdf"), "/abs/x.pdf");
assert_eq!(cmd_path_arg("-x.pdf"), "./-x.pdf");
assert_eq!(cmd_path_arg("sub/f.pdf"), "./sub/f.pdf");
assert_eq!(cmd_path_arg("./already"), "./already");
assert_eq!(cmd_path_arg("-"), "./-");
}
#[test]
fn is_safe_url_allow_list() {
assert!(is_safe_url("http://example.com"));
assert!(is_safe_url("https://example.com/a?b=c"));
assert!(is_safe_url("mailto:a@b.com"));
assert!(is_safe_url("HTTPS://Example.com"));
assert!(!is_safe_url("file:///etc/passwd"));
assert!(!is_safe_url("javascript:alert(1)"));
assert!(!is_safe_url("custom:whatever"));
assert!(!is_safe_url("-x"));
assert!(!is_safe_url(""));
}
#[test]
fn run_with_timeout_captures_output_of_fast_command() {
let mut cmd = Command::new("printf");
cmd.arg("hello");
let out = run_with_timeout(cmd, Duration::from_secs(5)).unwrap();
assert!(out.status.success());
assert_eq!(out.stdout, b"hello");
}
#[test]
fn run_with_timeout_kills_a_hang() {
let mut cmd = Command::new("sleep");
cmd.arg("30");
let err = run_with_timeout(cmd, Duration::from_millis(100)).unwrap_err();
assert_eq!(err.kind(), ErrorKind::TimedOut);
}
#[test]
fn extracts_embedded_images_from_samples() {
let d = extract_ooxml_media("samples/sample.docx", "word/media/");
assert_eq!(d.len(), 1, "docx should have 1 image");
let p = extract_ooxml_media("samples/deck.pptx", "ppt/media/");
assert_eq!(p.len(), 2, "pptx should have 2 images");
assert!(p.iter().all(|x| x.exists()), "extracted files exist");
}
}