use std::env;
use std::fs::{self, File};
use std::io::{self, Read, Seek};
use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use walkdir::WalkDir;
use zip::ZipArchive;
pub const THEME_PATH_ENV: &str = "BCKT_THEME_PATH";
pub fn theme_search_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(value) = env::var_os(THEME_PATH_ENV) {
for part in env::split_paths(&value) {
if !part.as_os_str().is_empty() {
paths.push(part);
}
}
}
if let Ok(exe) = env::current_exe() {
if let Some(dir) = exe.parent() {
paths.push(dir.to_path_buf());
}
if let Ok(real) = exe.canonicalize()
&& let Some(prefix_root) = real.parent().and_then(|bin| bin.parent())
{
paths.push(prefix_root.join("share").join("bckt"));
}
}
paths
}
pub fn resolve_theme(spec: &str) -> Result<PathBuf> {
if spec.ends_with(".zip") || spec.contains('/') || spec.contains(std::path::MAIN_SEPARATOR) {
let candidate = Path::new(spec);
if candidate.is_dir() || candidate.is_file() {
return Ok(candidate.to_path_buf());
}
bail!("theme '{}' not found", spec);
}
let file_name = format!("{spec}.zip");
for dir in theme_search_paths() {
let archive = dir.join(&file_name);
if archive.is_file() {
return Ok(archive);
}
let theme_dir = dir.join(spec);
if theme_dir.is_dir() {
return Ok(theme_dir);
}
}
bail!(
"theme '{spec}' not found in theme search path (set {THEME_PATH_ENV}, or pass a path to a .zip archive or theme directory)"
)
}
pub fn resolve_demo(name: &str) -> Result<PathBuf> {
if name.contains('/') || name.contains(std::path::MAIN_SEPARATOR) {
let candidate = Path::new(name);
if candidate.is_dir() {
return Ok(candidate.to_path_buf());
}
bail!("demo '{}' not found", name);
}
for dir in theme_search_paths() {
let demo_dir = dir.join("demo").join(name);
if demo_dir.is_dir() {
return Ok(demo_dir);
}
}
bail!(
"demo '{name}' not found in theme search path (set {THEME_PATH_ENV}, or pass a path to a demo directory)"
)
}
pub fn install_theme_source(source: &Path, destination: &Path) -> Result<()> {
if source.is_dir() {
return install_theme_dir(source, destination);
}
install_theme_archive(source, destination)
}
fn install_theme_dir(source: &Path, destination: &Path) -> Result<()> {
let source = source
.canonicalize()
.with_context(|| format!("failed to resolve theme directory {}", source.display()))?;
let target = canonical_target(destination)?;
if target == source || target.starts_with(&source) || source.starts_with(&target) {
bail!(
"theme source and destination overlap: {} -> {}",
source.display(),
destination.display()
);
}
prepare_destination(destination)?;
let mut copied = false;
for entry in WalkDir::new(&source) {
let entry = entry
.with_context(|| format!("failed to read theme directory {}", source.display()))?;
if entry.file_type().is_dir() {
continue;
}
let relative = entry.path().strip_prefix(&source).with_context(|| {
format!(
"path {} is not under {}",
entry.path().display(),
source.display()
)
})?;
let out_path = destination.join(relative);
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
fs::copy(entry.path(), &out_path).with_context(|| {
format!(
"failed to copy {} to {}",
entry.path().display(),
out_path.display()
)
})?;
copied = true;
}
if !copied {
bail!("theme directory {} is empty", source.display());
}
Ok(())
}
fn install_theme_archive(archive_path: &Path, destination: &Path) -> Result<()> {
prepare_destination(destination)?;
let file = File::open(archive_path)
.with_context(|| format!("failed to open theme archive {}", archive_path.display()))?;
let mut archive = ZipArchive::new(file)
.with_context(|| format!("failed to read theme archive {}", archive_path.display()))?;
extract_archive(&mut archive, destination)
}
fn extract_archive<R: Read + Seek>(archive: &mut ZipArchive<R>, destination: &Path) -> Result<()> {
let mut extracted_any = false;
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.with_context(|| format!("failed to read archive entry #{i}"))?;
if entry.is_dir() {
continue;
}
let Some(relative) = safe_relative_path(entry.name()) else {
continue;
};
let out_path = destination.join(&relative);
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
let mut outfile = File::create(&out_path)
.with_context(|| format!("failed to create file {}", out_path.display()))?;
io::copy(&mut entry, &mut outfile)
.with_context(|| format!("failed to write {}", out_path.display()))?;
#[cfg(unix)]
if let Some(mode) = entry.unix_mode() {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))
.with_context(|| format!("failed to set permissions on {}", out_path.display()))?;
}
extracted_any = true;
}
if !extracted_any {
return Err(anyhow!("no files extracted from archive"));
}
Ok(())
}
fn prepare_destination(destination: &Path) -> Result<()> {
if destination.exists() {
fs::remove_dir_all(destination).with_context(|| {
format!(
"failed to remove existing directory {}",
destination.display()
)
})?;
}
fs::create_dir_all(destination)
.with_context(|| format!("failed to create directory {}", destination.display()))?;
Ok(())
}
fn canonical_target(destination: &Path) -> Result<PathBuf> {
let mut suffix: Vec<std::ffi::OsString> = Vec::new();
let mut current = destination;
loop {
if let Ok(canon) = current.canonicalize() {
let mut result = canon;
for part in suffix.iter().rev() {
result.push(part);
}
return Ok(result);
}
match (current.parent(), current.file_name()) {
(Some(parent), Some(name)) => {
suffix.push(name.to_os_string());
current = parent;
}
_ => return Ok(destination.to_path_buf()),
}
}
}
fn safe_relative_path(name: &str) -> Option<PathBuf> {
let mut out = PathBuf::new();
for component in Path::new(name).components() {
match component {
Component::Normal(segment) => out.push(segment),
Component::CurDir => {}
_ => return None,
}
}
if out.as_os_str().is_empty() {
None
} else {
Some(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
use zip::write::SimpleFileOptions;
fn write_archive(path: &Path, files: &[(&str, &str)]) {
let file = File::create(path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = SimpleFileOptions::default();
for (name, contents) in files {
zip.start_file(*name, options).unwrap();
zip.write_all(contents.as_bytes()).unwrap();
}
zip.finish().unwrap();
}
#[test]
fn installs_archive_contents_at_root() {
let dir = TempDir::new().unwrap();
let archive = dir.path().join("theme.zip");
write_archive(
&archive,
&[
("templates/post.html", "<html></html>"),
("skel/assets/js/search.js", "// search"),
],
);
let destination = dir.path().join("themes/theme");
install_theme_source(&archive, &destination).unwrap();
assert!(destination.join("templates/post.html").is_file());
assert!(destination.join("skel/assets/js/search.js").is_file());
}
#[test]
fn rejects_zip_slip_entries() {
let dir = TempDir::new().unwrap();
let archive = dir.path().join("evil.zip");
write_archive(
&archive,
&[
("../escape.txt", "nope"),
("templates/post.html", "<html></html>"),
],
);
let destination = dir.path().join("themes/evil");
install_theme_source(&archive, &destination).unwrap();
assert!(!dir.path().join("escape.txt").exists());
assert!(destination.join("templates/post.html").is_file());
}
#[test]
fn installs_directory_source() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src-theme");
fs::create_dir_all(source.join("templates")).unwrap();
fs::create_dir_all(source.join("skel/assets/js")).unwrap();
fs::write(source.join("templates/post.html"), "<html></html>").unwrap();
fs::write(source.join("skel/assets/js/search.js"), "// search").unwrap();
let destination = dir.path().join("themes/theme");
install_theme_source(&source, &destination).unwrap();
assert!(destination.join("templates/post.html").is_file());
assert!(destination.join("skel/assets/js/search.js").is_file());
}
#[test]
fn rejects_overlapping_source_and_destination() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("themes/bckt3");
fs::create_dir_all(source.join("templates")).unwrap();
fs::write(source.join("templates/post.html"), "<html></html>").unwrap();
let result = install_theme_source(&source, &source);
assert!(result.is_err());
assert!(source.join("templates/post.html").is_file());
}
#[test]
fn resolve_direct_paths() {
let dir = TempDir::new().unwrap();
let archive = dir.path().join("theme.zip");
write_archive(&archive, &[("templates/post.html", "<html></html>")]);
assert_eq!(resolve_theme(archive.to_str().unwrap()).unwrap(), archive);
let theme_dir = dir.path().join("a/themedir");
fs::create_dir_all(&theme_dir).unwrap();
assert_eq!(
resolve_theme(theme_dir.to_str().unwrap()).unwrap(),
theme_dir
);
let missing = dir.path().join("missing.zip");
assert!(resolve_theme(missing.to_str().unwrap()).is_err());
}
#[test]
fn resolve_named_theme_searches_path() {
let dir = TempDir::new().unwrap();
let archive = dir.path().join("bckt3.zip");
write_archive(&archive, &[("templates/post.html", "<html></html>")]);
let other_dir = dir.path().join("plain");
fs::create_dir_all(&other_dir).unwrap();
unsafe { env::set_var(THEME_PATH_ENV, dir.path()) };
let zip = resolve_theme("bckt3");
let dir_theme = resolve_theme("plain");
let missing = resolve_theme("does-not-exist");
unsafe { env::remove_var(THEME_PATH_ENV) };
assert_eq!(zip.unwrap(), archive);
assert_eq!(dir_theme.unwrap(), other_dir);
assert!(missing.is_err());
}
}