use std::io::Read;
use std::path::{Component, Path, PathBuf};
use anyhow::{Result, bail};
pub const ICON_MAX_BYTES: usize = 256 * 1024;
const EXTENSIONS: &[(&str, &str)] = &[
("png", "image/png"),
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("gif", "image/gif"),
("webp", "image/webp"),
];
const DATA_MIMES: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"];
pub fn builtin_svg(id: &str) -> &'static str {
match id {
"default" => include_str!("../assets/ui/avatars/default.svg"),
"magi" => include_str!("../assets/ui/avatars/magi.svg"),
"rei" => include_str!("../assets/ui/avatars/rei.svg"),
"misato" => include_str!("../assets/ui/avatars/misato.svg"),
"ritsuko" => include_str!("../assets/ui/avatars/ritsuko.svg"),
"shinji" => include_str!("../assets/ui/avatars/shinji.svg"),
"asuka" => include_str!("../assets/ui/avatars/asuka.svg"),
"kaworu" => include_str!("../assets/ui/avatars/kaworu.svg"),
_ => include_str!("../assets/ui/avatars/persona.svg"),
}
}
fn data_prefix(icon: &str) -> Option<(&'static str, &str)> {
let rest = icon.strip_prefix("data:")?;
let (head, payload) = rest.split_once(',')?;
let mime = head.strip_suffix(";base64")?;
let mime = DATA_MIMES.iter().find(|m| **m == mime)?;
Some((mime, payload))
}
fn path_mime(path: &Path) -> Option<&'static str> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
EXTENSIONS.iter().find(|(e, _)| *e == ext).map(|(_, m)| *m)
}
fn is_path_form(icon: &str) -> bool {
icon.starts_with("~/") || Path::new(icon).is_absolute()
}
pub fn validate_shape(icon: &str) -> Result<()> {
let icon = icon.trim();
if icon.is_empty() {
bail!("`icon` is empty");
}
if icon.len() > ICON_MAX_BYTES {
bail!("`icon` is longer than {} KiB", ICON_MAX_BYTES / 1024);
}
if icon.starts_with("data:") {
if data_prefix(icon).is_none() {
bail!("`icon` data URI must start `data:image/(png|jpeg|gif|webp);base64,`");
}
return Ok(());
}
if !is_path_form(icon) {
bail!("`icon` must be an absolute path, a `~/` path or a data URI");
}
if Path::new(icon)
.components()
.any(|c| matches!(c, Component::ParentDir))
{
bail!("`icon` path may not contain `..`");
}
if path_mime(Path::new(icon)).is_none() {
bail!("`icon` path must end in .png, .jpg, .jpeg, .gif or .webp");
}
Ok(())
}
pub fn load(icon: &str) -> Result<(&'static str, Vec<u8>)> {
let icon = icon.trim();
validate_shape(icon)?;
if let Some((mime, payload)) = data_prefix(icon) {
let bytes = decode_base64(payload)?;
if bytes.is_empty() {
bail!("icon image is empty");
}
return Ok((mime, bytes));
}
let path = expand(icon)?;
let mime = path_mime(&path).ok_or_else(|| anyhow::anyhow!("unsupported icon extension"))?;
let meta = std::fs::metadata(&path)?;
if !meta.is_file() {
bail!("icon is not a regular file");
}
if meta.len() == 0 || meta.len() > ICON_MAX_BYTES as u64 {
bail!("icon is empty or over {} KiB", ICON_MAX_BYTES / 1024);
}
let mut bytes = Vec::new();
std::fs::File::open(&path)?
.take(ICON_MAX_BYTES as u64 + 1)
.read_to_end(&mut bytes)?;
if bytes.len() > ICON_MAX_BYTES {
bail!("icon is over {} KiB", ICON_MAX_BYTES / 1024);
}
Ok((mime, bytes))
}
fn expand(icon: &str) -> Result<PathBuf> {
match icon.strip_prefix("~/") {
Some(rest) => {
let Some(home) = dirs::home_dir() else {
bail!("no home directory to expand `~/`");
};
Ok(home.join(rest))
}
None => Ok(PathBuf::from(icon)),
}
}
pub fn fingerprint(icon: Option<&str>) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in icon.unwrap_or("").trim().bytes() {
h ^= u64::from(b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{h:016x}")
}
fn decode_base64(s: &str) -> Result<Vec<u8>> {
let b = s.as_bytes();
if b.len() % 4 != 0 {
bail!("base64 length is not a multiple of 4");
}
let val = |c: u8| -> Option<u32> {
match c {
b'A'..=b'Z' => Some(u32::from(c - b'A')),
b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
};
let mut out = Vec::with_capacity(b.len() / 4 * 3);
let chunks = b.len() / 4;
for (i, chunk) in b.chunks(4).enumerate() {
let pad = if i + 1 == chunks {
chunk.iter().rev().take_while(|c| **c == b'=').count()
} else {
0
};
if pad > 2 {
bail!("bad base64 padding");
}
let mut n: u32 = 0;
for (j, c) in chunk.iter().enumerate() {
let v = if j >= 4 - pad {
0
} else {
match val(*c) {
Some(v) => v,
None => bail!("bad base64 character"),
}
};
n = (n << 6) | v;
}
out.extend_from_slice(&n.to_be_bytes()[1..4 - pad]);
if out.len() > ICON_MAX_BYTES {
bail!("icon image is over {} KiB", ICON_MAX_BYTES / 1024);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_builtin_has_its_own_svg() {
let ids = [
"default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
];
for id in ids {
let svg = builtin_svg(id);
assert!(svg.starts_with("<svg"), "{id}");
assert_ne!(svg, builtin_svg("custom-id"), "{id}");
}
assert!(builtin_svg("custom-id").starts_with("<svg"));
}
#[test]
fn shape_accepts_only_data_uris_and_absolute_raster_paths() {
let abs = if cfg!(windows) {
"C:/p/a.png"
} else {
"/p/a.png"
};
for ok in [abs, "~/a.JPG", "data:image/png;base64,AAAA"] {
assert!(validate_shape(ok).is_ok(), "{ok}");
}
for bad in [
"",
"a.png",
"./a.png",
"~/../a.png",
"/p/../a.png",
"/p/a.svg",
"/p/a",
"data:image/svg+xml;base64,AAAA",
"data:image/png,AAAA",
"http://x/a.png",
] {
assert!(validate_shape(bad).is_err(), "{bad}");
}
let long = format!("data:image/png;base64,{}", "A".repeat(ICON_MAX_BYTES));
assert!(validate_shape(&long).is_err());
}
#[test]
fn base64_is_strict() {
assert_eq!(decode_base64("aGk=").unwrap(), b"hi");
assert_eq!(decode_base64("aGVsbG8=").unwrap(), b"hello");
assert_eq!(decode_base64("YWJj").unwrap(), b"abc");
assert!(decode_base64("aGk").is_err());
assert!(decode_base64("a Gk=").is_err());
assert!(decode_base64("a===").is_err());
}
#[test]
fn fingerprint_follows_the_setting() {
assert_eq!(fingerprint(None), fingerprint(Some(" ")));
assert_ne!(fingerprint(Some("/a.png")), fingerprint(Some("/b.png")));
}
}