use std::path::{Path, PathBuf};
pub const EXTENSIONES: [&str; 10] = [
"png", "svg", "svgz", "xpm", "jpg", "jpeg", "gif", "webp", "bmp", "ico",
];
pub const LIMITE_ARCHIVO: u64 = 8 * 1024 * 1024;
pub fn has_icon_extension(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| {
let e = e.to_ascii_lowercase();
EXTENSIONES.contains(&e.as_str())
})
.unwrap_or(false)
}
fn subdirectorios_de_iconos(base: &Path, destino: &mut Vec<PathBuf>) {
destino.push(base.join("icons"));
destino.push(base.join("pixmaps"));
}
pub fn allowed_roots() -> Vec<PathBuf> {
raices_de(
std::env::var("XDG_DATA_DIRS").ok().as_deref(),
dirs::data_dir(),
dirs::home_dir(),
)
}
fn raices_de(
dirs_del_sistema: Option<&str>,
datos: Option<PathBuf>,
hogar: Option<PathBuf>,
) -> Vec<PathBuf> {
let mut raices = Vec::new();
let dirs = dirs_del_sistema
.filter(|v| !v.trim().is_empty())
.unwrap_or("/usr/local/share:/usr/share");
for parte in dirs.split(':').map(Path::new).filter(|p| p.is_absolute()) {
raices.push(parte.to_path_buf());
}
if let Some(datos) = datos.filter(|p| p.is_absolute()) {
subdirectorios_de_iconos(&datos, &mut raices);
}
if let Some(hogar) = hogar.filter(|p| p.is_absolute()) {
raices.push(hogar.join(".icons"));
}
raices
}
pub fn is_inside(canonica: &Path, raices: &[PathBuf]) -> bool {
raices.iter().any(|raiz| {
let raiz = raiz.canonicalize().unwrap_or_else(|_| raiz.clone());
canonica.starts_with(&raiz)
})
}
pub fn readable_icon_path(name: &str, raices: &[PathBuf]) -> Option<PathBuf> {
if !name.starts_with('/') {
return None;
}
let canonica = Path::new(name).canonicalize().ok()?;
if !canonica.is_file() || !has_icon_extension(&canonica) || !is_inside(&canonica, raices) {
return None;
}
let cabe = std::fs::metadata(&canonica)
.map(|m| m.len() <= LIMITE_ARCHIVO)
.unwrap_or(false);
cabe.then_some(canonica)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn escenario(quien: &str) -> (PathBuf, Vec<PathBuf>) {
let base =
std::env::temp_dir().join(format!("vicons-prueba-{}-{quien}", std::process::id()));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(base.join("permitido")).unwrap();
fs::create_dir_all(base.join("secretos")).unwrap();
fs::write(base.join("permitido/icono.png"), b"\x89PNG falso").unwrap();
fs::write(base.join("permitido/notas.txt"), b"no soy un icono").unwrap();
fs::write(base.join("secretos/id_ed25519"), b"CLAVE PRIVADA").unwrap();
fs::write(base.join("secretos/robada.png"), b"tampoco").unwrap();
let raices = vec![base.join("permitido")];
(base, raices)
}
#[test]
fn un_icono_de_un_directorio_permitido_se_lee() {
const NOMBRE: &str = "permitido";
let (base, raices) = escenario(NOMBRE);
let ruta = base.join("permitido/icono.png");
assert_eq!(
readable_icon_path(ruta.to_str().unwrap(), &raices),
Some(ruta.canonicalize().unwrap())
);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn un_archivo_de_afuera_no_se_lee() {
const NOMBRE: &str = "afuera";
let (base, raices) = escenario(NOMBRE);
let secreto = base.join("secretos/id_ed25519");
assert_eq!(readable_icon_path(secreto.to_str().unwrap(), &raices), None);
let disfrazado = base.join("secretos/robada.png");
assert_eq!(
readable_icon_path(disfrazado.to_str().unwrap(), &raices),
None
);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn un_enlace_simbolico_no_saca_nada_de_su_lugar() {
const NOMBRE: &str = "enlace";
let (base, raices) = escenario(NOMBRE);
let enlace = base.join("permitido/parece-icono.png");
std::os::unix::fs::symlink(base.join("secretos/id_ed25519"), &enlace).unwrap();
assert_eq!(readable_icon_path(enlace.to_str().unwrap(), &raices), None);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn los_dos_puntos_no_salen_del_directorio() {
const NOMBRE: &str = "travesia";
let (base, raices) = escenario(NOMBRE);
let travesia = format!("{}/permitido/../secretos/id_ed25519", base.display());
assert_eq!(readable_icon_path(&travesia, &raices), None);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn un_nombre_de_tema_no_toca_el_disco() {
const NOMBRE: &str = "tema";
let (base, raices) = escenario(NOMBRE);
assert_eq!(readable_icon_path("folder", &raices), None);
assert_eq!(readable_icon_path("", &raices), None);
assert_eq!(readable_icon_path("etc/passwd", &raices), None);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn lo_que_no_es_una_imagen_no_pasa_aunque_este_en_el_lugar_correcto() {
const NOMBRE: &str = "no-imagen";
let (base, raices) = escenario(NOMBRE);
let texto = base.join("permitido/notas.txt");
assert_eq!(readable_icon_path(texto.to_str().unwrap(), &raices), None);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn un_directorio_no_es_un_icono() {
const NOMBRE: &str = "directorio";
let (base, raices) = escenario(NOMBRE);
let dir = base.join("permitido");
assert_eq!(readable_icon_path(dir.to_str().unwrap(), &raices), None);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn un_archivo_enorme_no_se_carga_en_memoria() {
const NOMBRE: &str = "enorme";
let (base, raices) = escenario(NOMBRE);
let gordo = base.join("permitido/gordo.png");
let f = fs::File::create(&gordo).unwrap();
f.set_len(LIMITE_ARCHIVO + 1).unwrap();
assert_eq!(readable_icon_path(gordo.to_str().unwrap(), &raices), None);
let justo = base.join("permitido/justo.png");
fs::File::create(&justo)
.unwrap()
.set_len(LIMITE_ARCHIVO)
.unwrap();
assert!(readable_icon_path(justo.to_str().unwrap(), &raices).is_some());
let _ = fs::remove_dir_all(&base);
}
#[test]
fn las_extensiones_no_dependen_de_las_mayusculas() {
assert!(has_icon_extension(Path::new("a/b/ICONO.PNG")));
assert!(has_icon_extension(Path::new("a/b/icono.SvG")));
assert!(!has_icon_extension(Path::new("a/b/clave.pem")));
assert!(!has_icon_extension(Path::new("a/b/sin-extension")));
assert!(
!has_icon_extension(Path::new("a/b/.png")),
"sólo extensión, sin nombre"
);
}
#[test]
fn las_raices_permitidas_no_incluyen_el_hogar_entero() {
let raices = allowed_roots();
if let Some(hogar) = std::env::var_os("HOME") {
let hogar = PathBuf::from(hogar);
assert!(
!raices.contains(&hogar),
"el hogar entero no puede ser una raíz"
);
assert!(!raices.contains(&hogar.join(".local/share")));
assert!(raices.iter().any(|r| r.ends_with(".icons")));
}
assert!(
!raices.contains(&PathBuf::from("/")),
"la raíz del sistema tampoco"
);
assert!(!raices.contains(&PathBuf::from("/etc")));
}
#[test]
fn hay_raices_del_sistema_aunque_falte_el_entorno() {
let raices = allowed_roots();
assert!(
raices.contains(&PathBuf::from("/usr/share")) || std::env::var("XDG_DATA_DIRS").is_ok(),
"{raices:?}"
);
}
#[test]
fn una_raiz_relativa_del_sistema_no_entra() {
let raices = raices_de(Some(".:..:relativo:/usr/share"), None, None);
assert_eq!(raices, vec![PathBuf::from("/usr/share")]);
}
#[test]
fn unos_datos_relativos_no_agregan_raices() {
for relativa in ["", "datos", "./datos", "../datos"] {
let raices = raices_de(Some("/usr/share"), Some(PathBuf::from(relativa)), None);
assert_eq!(
raices,
vec![PathBuf::from("/usr/share")],
"«{relativa}» no tiene que agregar nada"
);
}
}
#[test]
fn un_hogar_relativo_no_agrega_sus_iconos() {
let raices = raices_de(Some("/usr/share"), None, Some(PathBuf::from("casa")));
assert_eq!(raices, vec![PathBuf::from("/usr/share")]);
}
#[test]
fn con_todo_absoluto_entran_los_del_usuario() {
let raices = raices_de(Some("/usr/share"), None, Some(PathBuf::from("/home/pato")));
assert!(
raices.contains(&PathBuf::from("/home/pato/.icons")),
"{raices:?}"
);
}
#[test]
fn sin_nada_quedan_los_dos_del_estandar() {
let raices = raices_de(None, None, None);
assert_eq!(
raices,
vec![
PathBuf::from("/usr/local/share"),
PathBuf::from("/usr/share")
]
);
}
}