use std::collections::{BTreeMap, HashMap, HashSet};
use std::future::Future;
use std::path::{Component, Path, PathBuf};
use std::pin::Pin;
use cargo_metadata::PackageId;
use eyre::{Context, OptionExt};
use serde::{Deserialize, Serialize};
use smol::fs;
use tracing::{debug, info, warn};
use walkdir::WalkDir;
use waterui_assets_planner::BundleManifest;
use zenwave::{Client as _, Method};
use crate::build::BuildProgress;
use crate::project::Project;
use crate::project_model::project_types::PermissionKey;
pub mod icon;
mod unified;
mod web;
#[derive(Debug, Clone, Deserialize)]
struct RegistryFont {
name: String,
url: String,
}
#[derive(Debug, Clone, Deserialize)]
struct FontRegistry {
#[serde(rename = "font")]
fonts: Vec<RegistryFont>,
}
impl FontRegistry {
fn builtin() -> eyre::Result<Self> {
toml::from_str(include_str!("assets/fonts.toml"))
.wrap_err("built-in font registry `src/project_model/assets/fonts.toml` is malformed")
}
fn url(&self, name: &str) -> Option<&str> {
self.fonts
.iter()
.find(|font| font.name == name)
.map(|font| font.url.as_str())
}
}
const HYDROLYSIS_DEFAULT_FONT_FAMILY: &str = "Roboto";
const HYDROLYSIS_WEB_FONT_MANIFEST_FILE_NAME: &str = "waterui-fonts.json";
#[derive(Debug, Clone)]
pub struct FontDeclaration {
pub name: String,
pub source: FontSource,
pub crate_name: String,
}
#[derive(Debug, Clone)]
pub enum FontSource {
Local {
crate_root: PathBuf,
relative_path: PathBuf,
},
Remote {
url: String,
},
BuiltIn,
}
#[derive(Debug, Clone)]
pub struct ResolvedFont {
pub name: String,
pub path: PathBuf,
}
#[derive(Debug, Serialize)]
struct HydrolysisWebFontManifest {
default_family: String,
fonts: Vec<HydrolysisWebFontManifestEntry>,
}
#[derive(Debug, Serialize)]
struct HydrolysisWebFontManifestEntry {
name: String,
file_name: String,
}
#[derive(Debug, Deserialize)]
struct WaterUIMetadata {
#[serde(default)]
assets: AssetsMetadata,
#[serde(default)]
permissions: BTreeMap<PermissionKey, PermissionRequirement>,
}
#[derive(Debug, Deserialize)]
struct PermissionRequirement {
reason: String,
#[serde(default, rename = "required-feature")]
required_feature: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequiredPermission {
pub package: String,
pub key: PermissionKey,
pub reason: String,
pub evidence: PermissionEvidence,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionEvidence {
Declared,
Inferred,
}
#[derive(Debug, Default, Deserialize)]
struct AssetsMetadata {
#[serde(default)]
font: Vec<FontMetadata>,
}
#[derive(Debug, Deserialize)]
struct FontMetadata {
name: String,
#[serde(default)]
local_path: Option<String>,
#[serde(default)]
remote_path: Option<String>,
#[serde(default, rename = "required-feature")]
required_feature: Option<String>,
}
fn manifest_font_declarations(
manifest: &crate::project::Manifest,
root: &Path,
) -> eyre::Result<Vec<FontDeclaration>> {
let Some(assets) = manifest.assets.as_ref() else {
return Ok(Vec::new());
};
let mut declarations = Vec::with_capacity(assets.font.len());
for font in &assets.font {
let source = match (&font.local_path, &font.remote_path) {
(Some(local_path), None) => {
let relative_path = PathBuf::from(local_path);
if matches!(
relative_path.components().next(),
Some(Component::Prefix(_) | Component::RootDir)
) {
eyre::bail!(
"[[assets.font]] entry '{}' in Water.toml: local_path must be \
relative to the project root",
font.name
);
}
FontSource::Local {
crate_root: root.to_path_buf(),
relative_path,
}
}
(None, Some(url)) => FontSource::Remote { url: url.clone() },
(None, None) => FontSource::BuiltIn,
(Some(_), Some(_)) => {
eyre::bail!(
"[[assets.font]] entry '{}' in Water.toml sets both local_path and \
remote_path; a declaration has exactly one source",
font.name
);
}
};
declarations.push(FontDeclaration {
name: font.name.clone(),
source,
crate_name: manifest.package.name.clone(),
});
}
Ok(declarations)
}
pub async fn scan_fonts(
project: &Project,
build_manifest: &Path,
) -> eyre::Result<Vec<FontDeclaration>> {
let mut declarations = manifest_font_declarations(project.manifest(), project.root())?;
declarations.extend(scan_crate_font_declarations(build_manifest).await?);
Ok(declarations)
}
async fn scan_crate_font_declarations(build_manifest: &Path) -> eyre::Result<Vec<FontDeclaration>> {
debug!(
"Scanning fonts from dependencies via cargo metadata on {}",
build_manifest.display()
);
let manifest_path = build_manifest.to_path_buf();
let metadata = smol::unblock({
let manifest_path = manifest_path.clone();
move || {
cargo_metadata::MetadataCommand::new()
.manifest_path(&manifest_path)
.exec()
}
})
.await
.wrap_err_with(|| {
format!(
"Failed to run cargo metadata on {}",
build_manifest.display()
)
})?;
let enabled_features_map: HashMap<&PackageId, HashSet<&str>> = metadata
.resolve
.as_ref()
.map(|resolve| {
resolve
.nodes
.iter()
.map(|node| (&node.id, node.features.iter().map(|f| f.as_str()).collect()))
.collect()
})
.unwrap_or_default();
let mut fonts = Vec::new();
for package in &metadata.packages {
let Some(waterui) = package.metadata.get("waterui") else {
continue;
};
let waterui_meta: WaterUIMetadata = match serde_json::from_value(waterui.clone()) {
Ok(m) => m,
Err(e) => {
warn!(
"Failed to parse waterui metadata for {}: {}",
package.name, e
);
continue;
}
};
let enabled_features = enabled_features_map
.get(&package.id)
.cloned()
.unwrap_or_default();
for font_meta in waterui_meta.assets.font {
if let Some(required) = &font_meta.required_feature
&& !enabled_features.contains(required.as_str())
{
debug!(
"Skipping font '{}': feature '{}' not enabled for {}",
font_meta.name, required, package.name
);
continue;
}
let source = if let Some(local_path) = font_meta.local_path {
let local_path = PathBuf::from(local_path);
if local_path.is_absolute() {
warn!(
"Skipping font '{}': local_path must be relative (crate: {})",
font_meta.name, package.name
);
continue;
}
let crate_root = package
.manifest_path
.parent()
.ok_or_eyre("Package has no parent directory")?
.as_std_path()
.to_path_buf();
FontSource::Local {
crate_root,
relative_path: local_path,
}
} else if let Some(url) = font_meta.remote_path {
FontSource::Remote { url }
} else {
FontSource::BuiltIn
};
fonts.push(FontDeclaration {
name: font_meta.name,
source,
crate_name: package.name.to_string(),
});
}
}
info!("Found {} font declarations from dependencies", fonts.len());
Ok(fonts)
}
pub async fn resolve_fonts(declarations: Vec<FontDeclaration>) -> eyre::Result<Vec<ResolvedFont>> {
let cache_dir = cache_dir()?;
let registry = FontRegistry::builtin()?;
let mut resolved = Vec::new();
for decl in resolve_declarations(declarations) {
let path = satisfy_font(&decl, &cache_dir, ®istry).await?;
debug!("Resolved font '{}' -> {}", decl.name, path.display());
resolved.push(ResolvedFont {
name: decl.name,
path,
});
}
info!("Resolved {} fonts", resolved.len());
Ok(resolved)
}
fn resolve_declarations(declarations: Vec<FontDeclaration>) -> Vec<FontDeclaration> {
let mut by_name: HashMap<String, Vec<FontDeclaration>> = HashMap::new();
for decl in declarations {
by_name.entry(decl.name.clone()).or_default().push(decl);
}
let mut resolved: Vec<FontDeclaration> = by_name
.into_values()
.map(|mut decls| {
decls.sort_by_key(|d| match &d.source {
FontSource::Local { .. } => 0,
FontSource::Remote { .. } => 1,
FontSource::BuiltIn => 2,
});
decls.into_iter().next().unwrap()
})
.collect();
resolved.sort_by(|left, right| left.name.cmp(&right.name));
resolved
}
async fn satisfy_font(
decl: &FontDeclaration,
cache_dir: &Path,
registry: &FontRegistry,
) -> eyre::Result<PathBuf> {
let name = &decl.name;
match &decl.source {
FontSource::Local {
crate_root,
relative_path,
} => match resolve_local_font_path(crate_root, relative_path) {
Ok(Some(full_path)) => Ok(full_path),
Ok(None) => Err(unsatisfiable_local_font(
decl,
&crate_root.join(relative_path),
)),
Err(e) => Err(e).wrap_err_with(|| {
format!(
"font '{name}' has an invalid local path '{}' (declared by {})",
relative_path.display(),
decl.crate_name
)
}),
},
FontSource::Remote { url } => cached_font(name, url, cache_dir).await,
FontSource::BuiltIn => {
let Some(url) = registry.url(name) else {
return Err(unsatisfiable_builtin_font(decl));
};
cached_font(name, url, cache_dir).await
}
}
}
fn unsatisfiable_local_font(decl: &FontDeclaration, expected: &Path) -> eyre::Report {
eyre::eyre!(
"font '{}' is declared by {} at '{}', which does not exist in that crate",
decl.name,
decl.crate_name,
expected.display(),
)
}
fn unsatisfiable_builtin_font(decl: &FontDeclaration) -> eyre::Report {
eyre::eyre!(
"font '{}' is declared by {} by name alone, but no font of that name \
is in the built-in registry — give the declaration a `local_path` or a \
`remote_path`",
decl.name,
decl.crate_name
)
}
fn cache_dir() -> eyre::Result<PathBuf> {
let cache = dirs::cache_dir()
.map(|root| root.join("waterui").join("fonts"))
.ok_or_eyre("Could not determine cache directory")?;
Ok(cache)
}
fn resolve_local_font_path(
crate_root: &Path,
relative_path: &Path,
) -> eyre::Result<Option<PathBuf>> {
let full_path = crate_root.join(relative_path);
if !full_path.exists() {
return Ok(None);
}
let canonical_root = crate_root
.canonicalize()
.wrap_err_with(|| format!("Failed to canonicalize crate root {}", crate_root.display()))?;
let canonical_path = full_path
.canonicalize()
.wrap_err_with(|| format!("Failed to canonicalize font path {}", full_path.display()))?;
if !canonical_path.starts_with(&canonical_root) {
eyre::bail!(
"path escapes crate root ({} -> {})",
full_path.display(),
canonical_path.display()
);
}
Ok(Some(canonical_path))
}
async fn cached_font(name: &str, url: &str, cache_dir: &Path) -> eyre::Result<PathBuf> {
cached_font_entry(name, url, cache_dir)
.await?
.ok_or_else(|| uncached_font_error(name, url, cache_dir))
}
async fn cached_font_entry(
name: &str,
url: &str,
cache_dir: &Path,
) -> eyre::Result<Option<PathBuf>> {
let hash = sha256_hex(url);
if is_zip_url(url) {
return cached_zip_font(name, cache_dir, &hash).await;
}
cached_file_font(name, cache_dir, &hash).await
}
fn is_zip_url(url: &str) -> bool {
let path = url.split(['?', '#']).next().unwrap_or(url);
path.rsplit_once('.')
.is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("zip"))
}
async fn cached_zip_font(
name: &str,
cache_dir: &Path,
hash: &str,
) -> eyre::Result<Option<PathBuf>> {
let extract_dir = cache_dir.join(hash);
if extract_dir.exists() {
debug!(
"Font '{}' already extracted at {}",
name,
extract_dir.display()
);
return find_font_file(&extract_dir, name).await.map(Some);
}
let cache_file = cache_dir.join(format!("{hash}.zip"));
if let Some(cache_len) = cached_file_len(name, &cache_file).await? {
if cache_len == 0 {
warn!(
"Ignoring empty cached font '{}' at {}",
name,
cache_file.display()
);
let _ = fs::remove_file(&cache_file).await;
} else {
debug!("Font '{}' already cached at {}", name, cache_file.display());
return find_font_in_extracted_zip(&cache_file, name)
.await
.map(Some);
}
}
Ok(None)
}
async fn cached_file_font(
name: &str,
cache_dir: &Path,
hash: &str,
) -> eyre::Result<Option<PathBuf>> {
let cache_file = cache_dir.join(format!("{hash}.ttf"));
if let Some(cache_len) = cached_file_len(name, &cache_file).await? {
if cache_len == 0 {
warn!(
"Ignoring empty cached font '{}' at {}",
name,
cache_file.display()
);
let _ = fs::remove_file(&cache_file).await;
} else {
debug!("Font '{}' already cached at {}", name, cache_file.display());
return Ok(Some(cache_file));
}
}
Ok(None)
}
fn uncached_font_error(name: &str, url: &str, cache_dir: &Path) -> eyre::Report {
eyre::eyre!(
"font '{name}' is declared remote ({url}) but is not in the font cache at {}; \
builds never access the network — run `water fetch` to download the project's \
fonts and retry the build",
cache_dir.display(),
)
}
async fn cached_file_len(name: &str, cache_file: &Path) -> eyre::Result<Option<u64>> {
match fs::metadata(cache_file).await {
Ok(metadata) => Ok(Some(metadata.len())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error).wrap_err_with(|| {
format!(
"Failed to read cached font metadata for '{}' at {}",
name,
cache_file.display()
)
}),
}
}
#[derive(Debug)]
pub enum FetchOutcome {
Satisfied {
name: String,
path: PathBuf,
},
Fetched {
name: String,
path: PathBuf,
},
Unsatisfiable {
name: String,
error: eyre::Report,
},
}
pub async fn seed_font_cache(project: &Project) -> eyre::Result<Vec<FetchOutcome>> {
let mut declarations = manifest_font_declarations(project.manifest(), project.root())?;
for manifest in ensure_font_scan_manifests(project).await? {
declarations.extend(scan_crate_font_declarations(&manifest).await?);
}
fetch_fonts(declarations, &cache_dir()?, download_font).await
}
async fn ensure_font_scan_manifests(project: &Project) -> eyre::Result<Vec<PathBuf>> {
let mut manifests = vec![project.root().join("Cargo.toml")];
if project.is_playground()
|| project.apple_backend().is_some()
|| project.android_backend().is_some()
{
let manifest = project.ffi_crate_path().join("Cargo.toml");
if !manifest.is_file() {
project.scaffold_ffi_companion().await.map_err(|error| {
eyre::eyre!("could not scaffold the Apple/Android FFI companion crate: {error}")
})?;
}
manifests.push(manifest);
}
ensure_backend_manifest::<crate::gtk4::backend::Gtk4Backend>(project, &mut manifests).await?;
ensure_backend_manifest::<crate::hydrolysis::backend::HydrolysisBackend>(
project,
&mut manifests,
)
.await?;
ensure_backend_manifest::<crate::winui::backend::WinUiBackend>(project, &mut manifests).await?;
Ok(manifests)
}
trait FontScanCrate: crate::backend::Backend {
const NAME: &'static str;
fn wanted(project: &Project) -> bool;
fn stale(project: &Project) -> impl Future<Output = eyre::Result<bool>> + Send;
}
async fn ensure_backend_manifest<B: FontScanCrate>(
project: &Project,
manifests: &mut Vec<PathBuf>,
) -> eyre::Result<()> {
if !B::wanted(project) {
return Ok(());
}
let stale = B::stale(project)
.await
.wrap_err_with(|| format!("could not inspect the {} backend crate", B::NAME))?;
if stale {
crate::backend::reinit_backend::<B>(project)
.await
.map_err(|error| {
eyre::eyre!("could not scaffold the {} backend crate: {error}", B::NAME)
})?;
}
manifests.push(project.backend_path::<B>().join("Cargo.toml"));
Ok(())
}
impl FontScanCrate for crate::gtk4::backend::Gtk4Backend {
const NAME: &'static str = "GTK4";
fn wanted(project: &Project) -> bool {
project.gtk4_backend().is_some() || (project.is_playground() && cfg!(target_os = "linux"))
}
async fn stale(project: &Project) -> eyre::Result<bool> {
Self::requires_regeneration(project).await
}
}
impl FontScanCrate for crate::hydrolysis::backend::HydrolysisBackend {
const NAME: &'static str = "hydrolysis";
fn wanted(project: &Project) -> bool {
project.is_playground() || project.hydrolysis_backend().is_some()
}
async fn stale(project: &Project) -> eyre::Result<bool> {
Self::requires_regeneration(project).await
}
}
impl FontScanCrate for crate::winui::backend::WinUiBackend {
const NAME: &'static str = "WinUI";
fn wanted(project: &Project) -> bool {
project.winui_backend().is_some()
|| (project.is_playground() && cfg!(target_os = "windows"))
}
async fn stale(project: &Project) -> eyre::Result<bool> {
Self::requires_regeneration(project).await
}
}
type FontFetch =
for<'a> fn(&'a str, &'a Path) -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>>;
async fn fetch_fonts(
declarations: Vec<FontDeclaration>,
cache_dir: &Path,
fetch: FontFetch,
) -> eyre::Result<Vec<FetchOutcome>> {
let registry = FontRegistry::builtin()?;
let mut outcomes = Vec::new();
for decl in resolve_declarations(declarations) {
let outcome = match &decl.source {
FontSource::Local { .. } => match satisfy_font(&decl, cache_dir, ®istry).await {
Ok(path) => FetchOutcome::Satisfied {
name: decl.name.clone(),
path,
},
Err(error) => FetchOutcome::Unsatisfiable {
name: decl.name.clone(),
error,
},
},
FontSource::Remote { .. } | FontSource::BuiltIn => {
match declaration_url(&decl, ®istry) {
Some(url) => {
if let Some(path) = cached_font_entry(&decl.name, url, cache_dir).await? {
FetchOutcome::Satisfied {
name: decl.name.clone(),
path,
}
} else {
let path = fetch_remote_font(&decl.name, url, cache_dir, fetch).await?;
FetchOutcome::Fetched {
name: decl.name.clone(),
path,
}
}
}
None => FetchOutcome::Unsatisfiable {
name: decl.name.clone(),
error: unsatisfiable_builtin_font(&decl),
},
}
}
};
outcomes.push(outcome);
}
Ok(outcomes)
}
fn declaration_url<'a>(decl: &'a FontDeclaration, registry: &'a FontRegistry) -> Option<&'a str> {
match &decl.source {
FontSource::Remote { url } => Some(url.as_str()),
FontSource::BuiltIn => registry.url(&decl.name),
FontSource::Local { .. } => None,
}
}
async fn fetch_remote_font(
name: &str,
url: &str,
cache_dir: &Path,
fetch: FontFetch,
) -> eyre::Result<PathBuf> {
waterui_assets_core::ensure_http_allowed(url)
.map_err(|error| eyre::eyre!("font '{name}' cannot be fetched from {url}: {error}"))?;
fs::create_dir_all(cache_dir).await?;
let hash = sha256_hex(url);
let extension = if is_zip_url(url) { "zip" } else { "ttf" };
let cache_file = cache_dir.join(format!("{hash}.{extension}"));
let partial = cache_dir.join(format!("{hash}.{extension}.partial"));
fetch(url, &partial)
.await
.wrap_err_with(|| format!("font '{name}' could not be fetched from {url}"))?;
if fs::metadata(&partial).await?.len() == 0 {
let _ = fs::remove_file(&partial).await;
eyre::bail!("font '{name}' fetched from {url} is empty");
}
fs::rename(&partial, &cache_file).await.wrap_err_with(|| {
format!(
"failed to place font '{name}' fetched from {url} at {}",
cache_file.display()
)
})?;
if is_zip_url(url) {
find_font_in_extracted_zip(&cache_file, name)
.await
.wrap_err_with(|| format!("font '{name}' fetched from {url}"))
} else {
Ok(cache_file)
}
}
fn download_font<'a>(
url: &'a str,
dest: &'a Path,
) -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>> {
Box::pin(async move {
let mut client = zenwave::client();
client
.method(Method::GET, url)?
.header("User-Agent", env!("CARGO_PKG_NAME"))?
.download_to_path(dest)
.await?;
Ok(())
})
}
async fn find_font_in_extracted_zip(zip_path: &Path, name: &str) -> eyre::Result<PathBuf> {
let extract_dir = zip_path.with_extension("");
if !extract_dir.exists() {
fs::create_dir_all(&extract_dir).await?;
let zip_path = zip_path.to_path_buf();
let extract_dir_clone = extract_dir.clone();
let zip_path_for_extraction = zip_path.clone();
smol::unblock(move || {
let file = std::fs::File::open(&zip_path_for_extraction)?;
let mut archive = zip::ZipArchive::new(file)?;
archive.extract(&extract_dir_clone)?;
Ok::<_, eyre::Report>(())
})
.await?;
if name.to_ascii_lowercase().contains("fontawesome") {
copy_fontawesome_icons_json(&extract_dir).await?;
}
}
remove_extracted_font_archive(zip_path).await?;
let font_file = find_font_file(&extract_dir, name).await?;
Ok(font_file)
}
async fn remove_extracted_font_archive(zip_path: &Path) -> eyre::Result<()> {
match fs::remove_file(zip_path).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error).wrap_err_with(|| {
format!(
"Failed to remove extracted font archive at {}",
zip_path.display()
)
}),
}
}
async fn copy_fontawesome_icons_json(extract_dir: &Path) -> eyre::Result<()> {
let icons_json = find_file_recursive(extract_dir, "icons.json").await?;
let version = extract_fontawesome_version(extract_dir);
let fontawesome_cache = dirs::cache_dir()
.map(|root| root.join("waterui").join("fontawesome"))
.ok_or_eyre("Could not determine cache directory")?;
fs::create_dir_all(&fontawesome_cache).await?;
let dest = fontawesome_cache.join(format!("fontawesome-{version}-icons.json"));
fs::copy(&icons_json, &dest).await?;
debug!("Copied icons.json to {}", dest.display());
Ok(())
}
async fn find_file_recursive(dir: &Path, filename: &str) -> eyre::Result<PathBuf> {
let dir = dir.to_path_buf();
let filename = filename.to_string();
smol::unblock(move || {
for entry in WalkDir::new(&dir) {
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
if entry
.file_name()
.to_str()
.is_some_and(|name| name == filename)
{
return Ok(entry.into_path());
}
}
eyre::bail!("File '{}' not found in {}", filename, dir.display());
})
.await
}
fn extract_fontawesome_version(extract_dir: &Path) -> String {
if let Ok(entries) = std::fs::read_dir(extract_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("fontawesome-") {
let parts: Vec<&str> = name.split('-').collect();
for (i, part) in parts.iter().enumerate() {
if part.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return parts[i..]
.join("-")
.split('-')
.next()
.unwrap_or("7.1.0")
.to_string();
}
}
}
}
}
"7.1.0".to_string() }
async fn find_font_file(dir: &Path, name: &str) -> eyre::Result<PathBuf> {
let dir = dir.to_path_buf();
let name = name.to_string();
smol::unblock(move || {
let mut candidates = Vec::new();
let name_lower = name.to_lowercase();
let style_keyword =
extract_style_keyword(&name_lower).unwrap_or_else(|| "regular".to_string());
for entry in WalkDir::new(&dir) {
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
let path = entry.into_path();
let Some(ext) = path.extension() else {
continue;
};
let ext = ext.to_string_lossy().to_lowercase();
if ext != "ttf" && ext != "otf" {
continue;
}
let file_name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if file_name.contains(&style_keyword) {
let name_parts: Vec<&str> = name_lower.split_whitespace().collect();
let matches_base = name_parts
.iter()
.take(3)
.all(|part| file_name.contains(part));
if matches_base {
return Ok(path);
}
}
candidates.push(path);
}
candidates
.into_iter()
.next()
.ok_or_else(|| eyre::eyre!("No font file found in zip for '{}'", name))
})
.await
}
fn extract_style_keyword(name: &str) -> Option<String> {
let styles = [
"solid", "regular", "brands", "light", "thin", "bold", "medium",
];
for style in styles {
if name.ends_with(style) || name.contains(&format!("-{style}")) {
return Some(style.to_string());
}
}
None
}
fn sha256_hex(s: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(s.as_bytes());
let result = hasher.finalize();
hex::encode(result)
}
pub async fn stage_project_assets_for_apple(
project: &Project,
dest_dir: &Path,
sccache_path: Option<&Path>,
dev_server: bool,
progress: Option<&BuildProgress>,
) -> eyre::Result<BundleManifest> {
unified::stage_for_apple(project, dest_dir, sccache_path, dev_server, progress).await
}
pub async fn stage_project_assets_for_android(
project: &Project,
backend_path: &Path,
sccache_path: Option<&Path>,
dev_server: bool,
progress: Option<&BuildProgress>,
) -> eyre::Result<BundleManifest> {
unified::stage_for_android(project, backend_path, sccache_path, dev_server, progress).await
}
#[cfg(target_os = "macos")]
pub fn project_macos_icns(project: &Project) -> eyre::Result<Vec<u8>> {
unified::macos_icns(project)
}
pub fn project_windows_ico(project: &Project) -> eyre::Result<Vec<u8>> {
unified::windows_ico(project)
}
pub async fn stage_hicolor_icons(project: &Project, icons_root: &Path) -> eyre::Result<()> {
unified::stage_hicolor_icons(project, icons_root).await
}
pub async fn stage_project_assets_for_gtk(
project: &Project,
resources_dir: &Path,
sccache_path: Option<&Path>,
dev_server: bool,
progress: Option<&BuildProgress>,
) -> eyre::Result<BundleManifest> {
unified::stage_for_gtk(project, resources_dir, sccache_path, dev_server, progress).await
}
pub fn scan_project_font_assets(manifest: &BundleManifest) -> eyre::Result<Vec<ResolvedFont>> {
unified::scan_project_fonts(manifest)
}
pub use unified::LaunchAssets;
pub fn project_launch_assets(project: &Project) -> eyre::Result<LaunchAssets> {
unified::launch_assets(project)
}
pub async fn stage_project_assets_for_web(project: &Project, site_root: &Path) -> eyre::Result<()> {
web::stage_for_web(project, site_root).await
}
pub async fn copy_fonts(fonts: &[ResolvedFont], dest: &Path) -> eyre::Result<()> {
fs::create_dir_all(dest).await?;
for font in fonts {
let file_name = font
.path
.file_name()
.ok_or_eyre("Font path has no filename")?;
let dest_path = dest.join(file_name);
debug!(
"Copying font {} -> {}",
font.path.display(),
dest_path.display()
);
fs::copy(&font.path, &dest_path).await?;
}
Ok(())
}
pub async fn stage_hydrolysis_web_fonts(
project: &Project,
backend_path: &Path,
site_root: &Path,
) -> eyre::Result<()> {
let mut resolved_fonts =
resolve_fonts(scan_fonts(project, &backend_path.join("Cargo.toml")).await?).await?;
resolved_fonts.sort_by(|left, right| left.name.cmp(&right.name));
let Some(default_family) = resolved_fonts
.iter()
.find(|font| font.name == HYDROLYSIS_DEFAULT_FONT_FAMILY)
.or_else(|| resolved_fonts.first())
else {
eyre::bail!(
"the Hydrolysis web runtime has no system fonts to fall back on, so a web \
build must bundle at least one declared font; declare one in Water.toml:\n\n\
\x20 [[assets.font]]\n\x20 name = \"{HYDROLYSIS_DEFAULT_FONT_FAMILY}\"\n\n\
or through `[package.metadata.waterui.assets.font]` in a dependency's \
Cargo.toml"
);
};
let default_family = default_family.name.clone();
let fonts_dest = site_root.join("fonts");
copy_fonts(&resolved_fonts, &fonts_dest).await?;
write_hydrolysis_web_font_manifest(&resolved_fonts, &fonts_dest, &default_family).await?;
Ok(())
}
async fn write_hydrolysis_web_font_manifest(
fonts: &[ResolvedFont],
fonts_dest: &Path,
default_family: &str,
) -> eyre::Result<()> {
let mut manifest_fonts = Vec::with_capacity(fonts.len());
for font in fonts {
let file_name = font
.path
.file_name()
.ok_or_eyre("Font path has no filename")?
.to_string_lossy()
.into_owned();
manifest_fonts.push(HydrolysisWebFontManifestEntry {
name: font.name.clone(),
file_name,
});
}
let manifest = HydrolysisWebFontManifest {
default_family: default_family.to_string(),
fonts: manifest_fonts,
};
let payload = serde_json::to_vec_pretty(&manifest)?;
fs::write(
fonts_dest.join(HYDROLYSIS_WEB_FONT_MANIFEST_FILE_NAME),
payload,
)
.await?;
Ok(())
}
pub async fn package_feature_enabled(
build_manifest: &Path,
package: &str,
feature: &str,
) -> eyre::Result<bool> {
let manifest_path = build_manifest.to_path_buf();
let metadata = smol::unblock({
let manifest_path = manifest_path.clone();
move || {
cargo_metadata::MetadataCommand::new()
.manifest_path(&manifest_path)
.exec()
}
})
.await
.wrap_err_with(|| {
format!(
"Failed to run cargo metadata on {}",
build_manifest.display()
)
})?;
let Some(resolve) = metadata.resolve.as_ref() else {
return Ok(false);
};
let enabled = metadata
.packages
.iter()
.filter(|candidate| candidate.name.as_str() == package)
.any(|candidate| {
resolve
.nodes
.iter()
.filter(|node| node.id == candidate.id)
.any(|node| {
node.features
.iter()
.any(|enabled| enabled.as_str() == feature)
})
});
debug!("resolved feature {package}/{feature}: {enabled}");
Ok(enabled)
}
struct Capability {
name: &'static str,
package: &'static str,
feature: Option<&'static str>,
}
const OPTIONAL_CAPABILITIES: &[Capability] = &[
Capability {
name: "gpu",
package: "waterui-graphics",
feature: Some("gpu"),
},
Capability {
name: "map",
package: "waterui-map",
feature: None,
},
Capability {
name: "media",
package: "waterui-video",
feature: None,
},
Capability {
name: "webview",
package: "waterui-webview",
feature: None,
},
];
pub async fn capability_enabled(
project: &Project,
build_manifest: &Path,
capability: &str,
) -> eyre::Result<bool> {
let capability = OPTIONAL_CAPABILITIES
.iter()
.find(|candidate| candidate.name == capability)
.unwrap_or_else(|| panic!("unknown WaterUI capability: {capability}"));
match capability.feature {
Some(feature) => package_feature_enabled(build_manifest, capability.package, feature).await,
None => project.links_runtime_package(capability.package).await,
}
}
pub async fn capability_ffi_features(
project: &Project,
build_manifest: &Path,
) -> eyre::Result<Vec<String>> {
let mut features = Vec::new();
for capability in OPTIONAL_CAPABILITIES {
if capability_enabled(project, build_manifest, capability.name).await? {
features.push(format!("waterui-ffi/{}", capability.name));
}
}
Ok(features)
}
pub async fn self_drawn_realization_features(
project: &Project,
build_manifest: &Path,
) -> eyre::Result<Vec<String>> {
let mut features = Vec::new();
let opted_in = package_feature_enabled(build_manifest, "waterui", "video-gpu").await?
|| project.links_runtime_package("waterui-video-gpu").await?;
if opted_in {
features.push("waterui-ffi/video".to_string());
}
Ok(features)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn must_not_download<'a>(
_url: &'a str,
_dest: &'a Path,
) -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>> {
panic!("this declaration has no download to perform")
}
fn place_font<'a>(
url: &'a str,
dest: &'a Path,
) -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>> {
assert_eq!(url, "https://example.com/inter.ttf");
let dest = dest.to_path_buf();
Box::pin(async move {
std::fs::write(dest, b"font-bytes")?;
Ok(())
})
}
fn fail_download<'a>(
_url: &'a str,
_dest: &'a Path,
) -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>> {
Box::pin(async { Err(eyre::eyre!("connection refused")) })
}
fn manifest_with_fonts(toml_fonts: &str) -> crate::project::Manifest {
toml::from_str(&format!(
"[package]\ntype = \"app\"\nname = \"Demo\"\n\
bundle_identifier = \"dev.example.demo\"\n\n{toml_fonts}"
))
.expect("manifest parses")
}
#[test]
fn water_toml_font_with_a_name_alone_uses_the_registry() {
let manifest = manifest_with_fonts("[[assets.font]]\nname = \"Inter\"");
let declarations =
manifest_font_declarations(&manifest, Path::new("/project")).expect("declarations");
assert_eq!(declarations.len(), 1);
assert_eq!(declarations[0].name, "Inter");
assert_eq!(declarations[0].crate_name, "Demo");
assert!(matches!(declarations[0].source, FontSource::BuiltIn));
}
#[test]
fn water_toml_font_local_path_resolves_against_the_project_root() {
let manifest = manifest_with_fonts(
"[[assets.font]]\nname = \"My Font\"\nlocal_path = \"fonts/my.ttf\"",
);
let declarations =
manifest_font_declarations(&manifest, Path::new("/project")).expect("declarations");
let FontSource::Local {
crate_root,
relative_path,
} = &declarations[0].source
else {
panic!("expected a local font source");
};
assert_eq!(crate_root, Path::new("/project"));
assert_eq!(relative_path, Path::new("fonts/my.ttf"));
}
#[test]
fn water_toml_font_rejects_conflicting_sources() {
let manifest = manifest_with_fonts(
"[[assets.font]]\nname = \"X\"\nlocal_path = \"a.ttf\"\nremote_path = \"https://x\"",
);
assert!(manifest_font_declarations(&manifest, Path::new("/project")).is_err());
}
#[test]
fn water_toml_font_rejects_an_absolute_local_path() {
let manifest =
manifest_with_fonts("[[assets.font]]\nname = \"X\"\nlocal_path = \"/abs/x.ttf\"");
assert!(manifest_font_declarations(&manifest, Path::new("/project")).is_err());
}
#[test]
fn a_manifest_without_assets_declares_no_fonts() {
let manifest = manifest_with_fonts("");
assert!(
manifest_font_declarations(&manifest, Path::new("/project"))
.expect("declarations")
.is_empty()
);
}
#[test]
fn an_uncached_remote_font_names_the_font_url_and_cache_dir() {
let cache_dir = tempdir().expect("temp cache dir");
let error = smol::block_on(cached_font(
"Inter",
"https://example.com/inter.ttf",
cache_dir.path(),
))
.expect_err("an uncached remote font must surface as an error");
let message = error.to_string();
assert!(message.contains("Inter"), "{message}");
assert!(
message.contains("https://example.com/inter.ttf"),
"{message}"
);
assert!(
message.contains(&cache_dir.path().display().to_string()),
"{message}"
);
assert!(message.contains("water fetch"), "{message}");
}
#[test]
fn fetch_places_a_missing_remote_font_where_the_build_looks() {
let cache_dir = tempdir().expect("temp cache dir");
let url = "https://example.com/inter.ttf";
let expected = cache_dir.path().join(format!("{}.ttf", sha256_hex(url)));
let outcomes = smol::block_on(fetch_fonts(
vec![FontDeclaration {
name: "Inter".to_string(),
source: FontSource::Remote {
url: url.to_string(),
},
crate_name: "some-theme".to_string(),
}],
cache_dir.path(),
place_font,
))
.expect("fetch succeeds");
let [FetchOutcome::Fetched { name, path }] = outcomes.as_slice() else {
panic!("expected one Fetched outcome, got {outcomes:?}");
};
assert_eq!(name, "Inter");
assert_eq!(
path, &expected,
"the fetched font lands at the cache entry the build probes"
);
assert_eq!(
fs::read(&expected).expect("read cached font"),
b"font-bytes"
);
let resolved = smol::block_on(cached_font("Inter", url, cache_dir.path()))
.expect("the build resolves the font the fetch placed");
assert_eq!(resolved, expected);
}
#[test]
fn fetch_is_a_no_op_for_a_font_already_in_the_cache() {
let cache_dir = tempdir().expect("temp cache dir");
let url = "https://example.com/inter.ttf";
let cached = cache_dir.path().join(format!("{}.ttf", sha256_hex(url)));
fs::write(&cached, b"cached-font").expect("seed the cache entry");
let outcomes = smol::block_on(fetch_fonts(
vec![FontDeclaration {
name: "Inter".to_string(),
source: FontSource::Remote {
url: url.to_string(),
},
crate_name: "some-theme".to_string(),
}],
cache_dir.path(),
must_not_download,
))
.expect("fetch succeeds");
let [FetchOutcome::Satisfied { name, path }] = outcomes.as_slice() else {
panic!("expected one Satisfied outcome, got {outcomes:?}");
};
assert_eq!(name, "Inter");
assert_eq!(path, &cached);
}
#[test]
fn a_failed_download_is_an_error_naming_the_font_and_url() {
let cache_dir = tempdir().expect("temp cache dir");
let error = smol::block_on(fetch_fonts(
vec![FontDeclaration {
name: "Inter".to_string(),
source: FontSource::Remote {
url: "https://example.com/inter.ttf".to_string(),
},
crate_name: "some-theme".to_string(),
}],
cache_dir.path(),
fail_download,
))
.expect_err("a failed download must be an error");
let message = format!("{error:#}");
assert!(message.contains("Inter"), "{message}");
assert!(
message.contains("https://example.com/inter.ttf"),
"{message}"
);
assert!(message.contains("connection refused"), "{message}");
}
#[test]
fn fetch_reports_an_unknown_builtin_name_the_way_the_build_does() {
let cache_dir = tempdir().expect("temp cache dir");
let outcomes = smol::block_on(fetch_fonts(
vec![FontDeclaration {
name: "No Such Family".to_string(),
source: FontSource::BuiltIn,
crate_name: "some-theme".to_string(),
}],
cache_dir.path(),
must_not_download,
))
.expect("an unsatisfiable declaration is an outcome, not a fetch error");
let [FetchOutcome::Unsatisfiable { name, error }] = outcomes.as_slice() else {
panic!("expected one Unsatisfiable outcome, got {outcomes:?}");
};
assert_eq!(name, "No Such Family");
let message = format!("{error:#}");
assert!(message.contains("No Such Family"), "{message}");
assert!(message.contains("some-theme"), "{message}");
assert!(message.contains("built-in registry"), "{message}");
}
#[test]
fn fetch_reports_a_missing_local_font_the_way_the_build_does() {
let cache_dir = tempdir().expect("temp cache dir");
let root = tempdir().expect("temp root");
let outcomes = smol::block_on(fetch_fonts(
vec![FontDeclaration {
name: "Roboto".to_string(),
source: FontSource::Local {
crate_root: root.path().to_path_buf(),
relative_path: PathBuf::from("assets/fonts/Roboto-Variable.ttf"),
},
crate_name: "some-theme".to_string(),
}],
cache_dir.path(),
must_not_download,
))
.expect("an unsatisfiable declaration is an outcome, not a fetch error");
let [FetchOutcome::Unsatisfiable { name, error }] = outcomes.as_slice() else {
panic!("expected one Unsatisfiable outcome, got {outcomes:?}");
};
assert_eq!(name, "Roboto");
let message = format!("{error:#}");
assert!(message.contains("Roboto"), "{message}");
assert!(message.contains("Roboto-Variable.ttf"), "{message}");
}
#[test]
fn test_font_registry_has_entries() {
let registry = FontRegistry::builtin().expect("registry parses");
assert!(!registry.fonts.is_empty());
assert!(registry.url("Inter").is_some());
assert!(registry.url("Roboto").is_some());
assert!(registry.url("Noto Sans CJK SC").is_some());
assert!(
!registry
.fonts
.iter()
.any(|font| font.name.contains("Font Awesome"))
);
assert!(
!registry
.fonts
.iter()
.any(|font| font.name.contains("Material Design"))
);
}
#[test]
fn font_registry_offers_a_math_face() {
let registry = FontRegistry::builtin().expect("registry parses");
let url = registry
.url("STIX Two Math")
.expect("registry must offer an OpenType MATH font");
assert!(
is_zip_url(url),
"the math font must resolve through the archive path so the single \
matching face is extracted rather than the whole distribution"
);
}
#[test]
fn math_face_wins_over_its_text_siblings_in_the_same_archive() {
let extracted = tempdir().expect("temp dir");
let root = extracted.path().join("static_otf");
fs::create_dir_all(&root).expect("create extract dir");
for face in [
"STIXTwoMath-Regular.otf",
"STIXTwoText-Bold.otf",
"STIXTwoText-BoldItalic.otf",
"STIXTwoText-Italic.otf",
"STIXTwoText-Medium.otf",
"STIXTwoText-MediumItalic.otf",
"STIXTwoText-Regular.otf",
"STIXTwoText-SemiBold.otf",
"STIXTwoText-SemiBoldItalic.otf",
] {
fs::write(root.join(face), []).expect("write face");
}
let selected = smol::block_on(find_font_file(extracted.path(), "STIX Two Math"))
.expect("math face must be found");
assert_eq!(
selected.file_name().expect("selected face has a name"),
"STIXTwoMath-Regular.otf",
"selected {} instead of the Math face",
selected.display()
);
}
#[test]
fn test_sha256_hex() {
let hash = sha256_hex("hello");
assert_eq!(hash.len(), 64); }
#[test]
fn test_http_allowlist() {
for url in [
"http://localhost/font.ttf",
"http://127.0.0.1:8080/font.ttf",
"http://[::1]/font.ttf",
"https://example.com/font.ttf",
] {
assert!(
waterui_assets_core::ensure_http_allowed(url).is_ok(),
"expected to allow {url}"
);
}
}
#[test]
fn test_http_rejects_non_loopback_and_prefix_bypass() {
for url in [
"http://example.com/font.ttf",
"http://localhost.evil.com/font.ttf",
"http://127.0.0.1.evil.com/font.ttf",
] {
assert!(
waterui_assets_core::ensure_http_allowed(url).is_err(),
"expected to reject {url}"
);
}
}
#[test]
fn zip_font_cache_uses_extracted_directory_without_archive() {
let cache_dir = tempdir().expect("temp cache dir");
let url = "https://example.com/inter.zip";
let extracted_dir = cache_dir.path().join(sha256_hex(url));
fs::create_dir_all(&extracted_dir).expect("create extracted dir");
let extracted_font = extracted_dir.join("inter-regular.ttf");
fs::write(&extracted_font, b"font").expect("write extracted font");
let resolved = smol::block_on(cached_font("Inter", url, cache_dir.path()))
.expect("reuse extracted cache");
assert_eq!(resolved, extracted_font);
}
#[test]
fn a_declared_local_font_that_is_missing_fails_the_build() {
let root = tempdir().expect("temp root");
let error = smol::block_on(resolve_fonts(vec![FontDeclaration {
name: "Roboto".to_string(),
source: FontSource::Local {
crate_root: root.path().to_path_buf(),
relative_path: PathBuf::from("assets/fonts/Roboto-Variable.ttf"),
},
crate_name: "some-theme".to_string(),
}]))
.expect_err("a missing crate-local font must be an error");
let message = format!("{error:#}");
assert!(
message.contains("Roboto") && message.contains("some-theme"),
"the error must name the font and the crate that declared it: {message}"
);
assert!(
message.contains("Roboto-Variable.ttf"),
"the error must name the path that was expected: {message}"
);
}
#[test]
fn test_resolve_local_font_path_rejects_escape() {
let root = tempdir().expect("temp root");
let outside = tempdir().expect("temp outside");
let outside_font = outside.path().join("outside.ttf");
fs::write(&outside_font, b"font").expect("write outside font");
let rel_escape = Path::new("..").join(outside.path().file_name().expect("outside name"));
let rel_escape = rel_escape.join("outside.ttf");
let result = resolve_local_font_path(root.path(), &rel_escape);
assert!(result.is_err(), "expected path traversal to be rejected");
}
#[test]
fn test_resolve_local_font_path_accepts_inside_root() {
let root = tempdir().expect("temp root");
let inside_dir = root.path().join("fonts");
fs::create_dir_all(&inside_dir).expect("create fonts dir");
let font_path = inside_dir.join("inside.ttf");
fs::write(&font_path, b"font").expect("write inside font");
let resolved = resolve_local_font_path(root.path(), Path::new("fonts/inside.ttf"))
.expect("resolve should succeed")
.expect("font should exist");
assert_eq!(resolved, font_path.canonicalize().expect("canonical path"));
}
}
pub async fn scan_required_permissions(
build_manifest: &Path,
) -> eyre::Result<Vec<RequiredPermission>> {
let manifest_path = build_manifest.to_path_buf();
let metadata = smol::unblock({
let manifest_path = manifest_path.clone();
move || {
cargo_metadata::MetadataCommand::new()
.manifest_path(&manifest_path)
.exec()
}
})
.await
.wrap_err_with(|| {
format!(
"Failed to run cargo metadata on {}",
build_manifest.display()
)
})?;
let enabled_features: HashMap<&PackageId, HashSet<&str>> = metadata
.resolve
.as_ref()
.map(|resolve| {
resolve
.nodes
.iter()
.map(|node| (&node.id, node.features.iter().map(|f| f.as_str()).collect()))
.collect()
})
.unwrap_or_default();
let mut required = Vec::new();
for package in &metadata.packages {
let Some(waterui) = package.metadata.get("waterui") else {
continue;
};
let parsed: WaterUIMetadata = match serde_json::from_value(waterui.clone()) {
Ok(parsed) => parsed,
Err(error) => {
warn!(
"Failed to parse waterui metadata for {}: {error}",
package.name
);
continue;
}
};
let features = enabled_features
.get(&package.id)
.cloned()
.unwrap_or_default();
for (key, requirement) in parsed.permissions {
if let Some(gate) = &requirement.required_feature
&& !features.contains(gate.as_str())
{
debug!(
"Skipping {key:?} for {}: feature `{gate}` is not enabled",
package.name
);
continue;
}
required.push(RequiredPermission {
package: package.name.to_string(),
key,
reason: requirement.reason.clone(),
evidence: PermissionEvidence::Declared,
});
}
}
if let Some(inferred) = infer_internet_from_http_clients(&metadata.packages, &required) {
required.push(inferred);
}
required.sort_by(|left, right| {
(left.key, left.package.as_str()).cmp(&(right.key, right.package.as_str()))
});
required.dedup();
Ok(required)
}
const HTTP_CLIENT_CRATES: &[&str] = &[
"attohttpc",
"curl",
"hyper",
"isahc",
"reqwest",
"surf",
"ureq",
"zenwave",
];
fn infer_internet_from_http_clients(
packages: &[cargo_metadata::Package],
declared: &[RequiredPermission],
) -> Option<RequiredPermission> {
if declared
.iter()
.any(|requirement| requirement.key == PermissionKey::Internet)
{
return None;
}
let mut clients: Vec<&str> = packages
.iter()
.map(|package| package.name.as_str())
.filter(|name| HTTP_CLIENT_CRATES.contains(name))
.collect();
clients.sort_unstable();
clients.dedup();
if clients.is_empty() {
return None;
}
Some(RequiredPermission {
package: clients.join(", "),
key: PermissionKey::Internet,
reason: String::from("the dependency graph contains an HTTP client"),
evidence: PermissionEvidence::Inferred,
})
}
fn missing_permissions<'a>(
enabled: &HashSet<PermissionKey>,
required: &'a [RequiredPermission],
relevant: impl Fn(PermissionKey) -> bool,
) -> Vec<&'a RequiredPermission> {
required
.iter()
.filter(|requirement| !enabled.contains(&requirement.key) && relevant(requirement.key))
.collect()
}
pub fn warn_missing_permissions(
project: &Project,
required: &[RequiredPermission],
relevant: impl Fn(PermissionKey) -> bool,
) {
let enabled: HashSet<PermissionKey> = project
.manifest()
.permissions
.iter()
.filter(|(_, entry)| entry.is_enabled())
.map(|(key, _)| *key)
.collect();
for requirement in missing_permissions(&enabled, required, relevant) {
let key = permission_toml_key(requirement.key);
match requirement.evidence {
PermissionEvidence::Declared => warn!(
"{} needs the `{key}` permission ({}). Add it to Water.toml:\n\n [permissions.{key}]\n enable = true\n",
requirement.package, requirement.reason
),
PermissionEvidence::Inferred => warn!(
"This app likely needs the `{key}` permission: {} ({}). If it talks to the network, add it to Water.toml:\n\n [permissions.{key}]\n enable = true\n",
requirement.reason, requirement.package
),
}
}
}
fn permission_toml_key(key: PermissionKey) -> String {
serde_json::to_value(key)
.ok()
.and_then(|value| value.as_str().map(str::to_owned))
.unwrap_or_else(|| format!("{key:?}"))
}
#[cfg(test)]
mod permission_audit_tests {
use super::*;
use tempfile::tempdir;
fn requirement(key: PermissionKey) -> RequiredPermission {
RequiredPermission {
package: String::from("waterui-map-gpu"),
key,
reason: String::from("downloads map styles and vector tiles"),
evidence: PermissionEvidence::Declared,
}
}
fn package(name: &str) -> cargo_metadata::Package {
let manifest = format!(
r#"{{
"name": "{name}",
"version": "1.0.0",
"id": "registry+https://github.com/rust-lang/crates.io-index#{name}@1.0.0",
"dependencies": [],
"targets": [],
"features": {{}},
"manifest_path": "/dev/null/Cargo.toml"
}}"#
);
serde_json::from_str(&manifest).expect("synthesize a cargo package")
}
fn write_crate(dir: &Path, name: &str, extra: &str) -> PathBuf {
std::fs::create_dir_all(dir.join("src")).expect("crate src dir");
let manifest = dir.join("Cargo.toml");
std::fs::write(
&manifest,
format!(
"[package]\nname = \"{name}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n{extra}"
),
)
.expect("crate manifest");
std::fs::write(dir.join("src/lib.rs"), "").expect("crate source");
manifest
}
#[test]
fn a_permission_declared_in_the_built_crates_graph_is_scanned() {
let project = tempdir().expect("temp project");
write_crate(
&project.path().join("theme"),
"theme",
"[package.metadata.waterui.permissions]\n\
internet = { reason = \"downloads map styles and vector tiles\" }\n",
);
let ffi_manifest = write_crate(&project.path().join("ffi"), "app-ffi", "");
let backend_manifest = write_crate(
&project.path().join("hydrolysis"),
"app-hydrolysis",
"[dependencies]\ntheme = { path = \"../theme\" }\n",
);
let required = smol::block_on(scan_required_permissions(&backend_manifest))
.expect("scan the built crate's graph");
assert!(
required
.iter()
.any(|requirement| requirement.package == "theme"
&& requirement.key == PermissionKey::Internet
&& requirement.evidence == PermissionEvidence::Declared),
"the backend graph must report the permission `theme` declares"
);
let ffi = smol::block_on(scan_required_permissions(&ffi_manifest))
.expect("scan the ffi crate's graph");
assert!(
ffi.is_empty(),
"the ffi graph does not carry `theme` and must stay silent"
);
}
#[test]
fn a_feature_enabled_in_the_built_crates_graph_is_seen() {
let project = tempdir().expect("temp project");
write_crate(
&project.path().join("theme"),
"theme",
"[features]\nextra = []\n",
);
let ffi_manifest = write_crate(&project.path().join("ffi"), "app-ffi", "");
let backend_manifest = write_crate(
&project.path().join("hydrolysis"),
"app-hydrolysis",
"[dependencies]\ntheme = { path = \"../theme\", features = [\"extra\"] }\n",
);
assert!(
smol::block_on(package_feature_enabled(&backend_manifest, "theme", "extra"))
.expect("scan the built crate's graph")
);
assert!(
!smol::block_on(package_feature_enabled(&ffi_manifest, "theme", "extra"))
.expect("scan the ffi crate's graph")
);
}
#[test]
fn an_http_client_in_the_graph_suggests_internet() {
let packages = vec![package("serde"), package("zenwave")];
let inferred = infer_internet_from_http_clients(&packages, &[])
.expect("zenwave should trigger the suggestion");
assert_eq!(inferred.key, PermissionKey::Internet);
assert_eq!(inferred.evidence, PermissionEvidence::Inferred);
assert!(inferred.package.contains("zenwave"));
}
#[test]
fn a_declared_internet_requirement_silences_the_inference() {
let packages = vec![package("reqwest")];
let declared = vec![requirement(PermissionKey::Internet)];
assert!(infer_internet_from_http_clients(&packages, &declared).is_none());
}
#[test]
fn a_graph_without_http_clients_suggests_nothing() {
let packages = vec![package("serde"), package("tracing")];
assert!(infer_internet_from_http_clients(&packages, &[]).is_none());
}
#[test]
fn a_declared_permission_the_app_enabled_is_not_reported() {
let enabled = HashSet::from([PermissionKey::Internet]);
let required = vec![requirement(PermissionKey::Internet)];
assert_eq!(
missing_permissions(&enabled, &required, |_| true),
[] as [&RequiredPermission; 0]
);
}
#[test]
fn a_missing_permission_is_reported_once() {
let required = vec![requirement(PermissionKey::Internet)];
let missing = missing_permissions(&HashSet::new(), &required, |_| true);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].key, PermissionKey::Internet);
}
#[test]
fn a_permission_the_platform_does_not_declare_stays_quiet() {
let required = vec![requirement(PermissionKey::Internet)];
let android = missing_permissions(&HashSet::new(), &required, |key| {
key.android_permission_name().is_some()
});
let ios = missing_permissions(&HashSet::new(), &required, |key| {
key.ios_plist_key().is_some()
});
assert_eq!(android.len(), 1, "Android must ask for INTERNET");
assert!(ios.is_empty(), "iOS declares no network permission");
}
#[test]
fn the_reported_key_matches_the_water_toml_spelling() {
assert_eq!(permission_toml_key(PermissionKey::Internet), "internet");
assert_eq!(
permission_toml_key(PermissionKey::CoarseLocation),
"coarse_location"
);
}
}