use std::fs::{self, DirBuilder, OpenOptions};
use std::io::Write;
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Image,
Video,
Text,
Other,
}
const TEXT_EXTENSIONS: &[&str] = &[
"txt", "md", "markdown", "json", "toml", "yaml", "yml", "xml", "csv", "log", "ini", "conf",
"rs", "py", "c", "h", "cpp", "hpp", "go", "js", "ts", "java", "sh", "lua", "nix", "sql", "tex",
"diff", "patch",
];
pub fn kind(mime: &str, name: &str) -> Kind {
let extension = name.rsplit_once('.').map_or("", |(_, e)| e).to_lowercase();
let primary = mime.split('/').next().unwrap_or("");
match primary {
"image" => Kind::Image,
"video" | "audio" => Kind::Video,
"text" => Kind::Text,
_ if TEXT_EXTENSIONS.contains(&extension.as_str()) => Kind::Text,
_ => Kind::Other,
}
}
pub fn links(text: &str) -> Vec<String> {
let chars: Vec<char> = text.chars().collect();
let mut found: Vec<String> = Vec::new();
let mut i = 0;
while i < chars.len() {
let starts = chars[i] == 'h' && (i == 0 || !chars[i - 1].is_alphanumeric());
let len = if starts {
crate::markdown::url_len(&chars[i..])
} else {
0
};
if len > 0 {
let url: String = chars[i..i + len].iter().collect();
if !found.contains(&url) {
found.push(url);
}
i += len;
} else {
i += 1;
}
}
found
}
pub fn safe_name(name: &str) -> String {
let last = name.rsplit(['/', '\\']).next().unwrap_or("");
let clean: String = last.chars().filter(|c| !c.is_control()).collect();
match clean.trim_matches('.') {
"" => "file".into(),
_ => clean,
}
}
pub fn human_size(bytes: i64) -> String {
let bytes = bytes.max(0) as f64;
match bytes {
b if b < 1024.0 => format!("{b:.0} B"),
b if b < 1024.0 * 1024.0 => format!("{:.1} KB", b / 1024.0),
b if b < 1024.0 * 1024.0 * 1024.0 => format!("{:.1} MB", b / 1024.0 / 1024.0),
b => format!("{:.1} GB", b / 1024.0 / 1024.0 / 1024.0),
}
}
#[derive(Debug)]
pub struct Files {
base: PathBuf,
}
static FOLDERS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
impl Files {
pub fn new() -> Self {
Self {
base: std::env::temp_dir().join(format!(
"mm-{}-{}",
std::process::id(),
FOLDERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
)),
}
}
pub fn base(&self) -> &Path {
&self.base
}
}
impl Drop for Files {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.base);
}
}
pub fn save(base: &Path, id: &str, name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
let folder = base.join(safe_name(id));
DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&folder)?;
let path = folder.join(safe_name(name));
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)?;
file.write_all(bytes)?;
Ok(path)
}
pub fn start(
command: &str,
target: &str,
done: impl FnOnce(Option<String>) + Send + 'static,
) -> std::io::Result<()> {
let mut child = Command::new("sh")
.args(["-c", "exec $0 \"$1\"", command, target])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0)
.spawn()?;
let command = command.to_owned();
std::thread::spawn(move || {
let outcome = match child.wait() {
Ok(status) if status.success() => None,
Ok(status) => Some(format!("{command} exited: {status}")),
Err(e) => Some(e.to_string()),
};
done(outcome);
});
Ok(())
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::PermissionsExt;
use std::sync::mpsc::channel;
use std::time::Duration;
use super::*;
#[test]
fn files_are_sorted_by_type_then_by_extension() {
assert_eq!(kind("image/png", "a.png"), Kind::Image);
assert_eq!(kind("video/mp4", "a.mp4"), Kind::Video);
assert_eq!(kind("audio/ogg", "a.ogg"), Kind::Video);
assert_eq!(kind("text/plain", "a.txt"), Kind::Text);
assert_eq!(kind("application/octet-stream", "main.RS"), Kind::Text);
assert_eq!(kind("application/json", "data.json"), Kind::Text);
assert_eq!(kind("application/pdf", "report.pdf"), Kind::Other);
assert_eq!(kind("", "noextension"), Kind::Other);
}
#[test]
fn links_are_found_in_order_without_punctuation_or_duplicates() {
let text = "see https://a.org/x, then (https://b.org/y_(z)) and [t](http://c.org/q?a=1). again https://a.org/x!";
assert_eq!(
links(text),
[
"https://a.org/x",
"https://b.org/y_(z)",
"http://c.org/q?a=1"
]
);
assert!(links("nothing, not even xhttps://x.org").is_empty());
assert!(links("http://").is_empty());
}
#[test]
fn names_cannot_escape_their_folder_and_sizes_are_readable() {
assert_eq!(safe_name("../../etc/passwd"), "passwd");
assert_eq!(safe_name("a\\b.txt"), "b.txt");
assert_eq!(safe_name(".."), "file");
assert_eq!(safe_name(""), "file");
assert_eq!(safe_name("ok name.png"), "ok name.png");
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(1023), "1023 B");
assert_eq!(human_size(1536), "1.5 KB");
assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB");
assert_eq!(human_size(-4), "0 B");
}
#[test]
fn saved_files_are_private_and_the_folder_goes_with_the_session() {
let base = {
let files = Files::new();
let path = save(files.base(), "id1", "../x.png", b"abc").unwrap();
assert_eq!(path, files.base().join("id1").join("x.png"));
assert_eq!(fs::read(&path).unwrap(), b"abc");
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
let folder = fs::metadata(path.parent().unwrap()).unwrap();
assert_eq!(folder.permissions().mode() & 0o777, 0o700);
files.base().to_owned()
};
assert!(!base.exists());
}
#[test]
fn a_program_is_started_detached_and_its_failure_is_reported() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("p.sh");
fs::write(&script, "#!/bin/sh\nprintf '%s' \"$1\" > \"$1.out\"\n").unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let target = dir.path().join("t").to_string_lossy().into_owned();
let (tx, rx) = channel();
start(&format!("sh {}", script.display()), &target, move |o| {
let _ = tx.send(o);
})
.unwrap();
assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), None);
assert_eq!(fs::read_to_string(format!("{target}.out")).unwrap(), target);
let (tx, rx) = channel();
start("definitely-not-a-program", "x", move |o| {
let _ = tx.send(o);
})
.unwrap();
assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_some());
}
}