mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 10:39:01 -06:00
A font the document does not embed was replaced by whatever the caller happened to pick, and nothing told the user. `doc_info` said only `embedded: false`, which is why "my Chinese PDF shows nothing" has been a mystery rather than a sentence. `crates/fonts/src/fallback.rs` applies one chain, in a documented order, and returns what it chose with a reason: 1. embedded -> nothing substituted; 2. /BaseFont names a standard-14 face or a known alias (exact metrics); 3. craft-fonts holds that exact family; 4. /CIDSystemInfo /Ordering names a script we have a face for; 5. the ordering names a script we have no licensed face for -- report none, so nothing is drawn rather than the wrong glyphs; 6. otherwise the nearest of the fourteen by descriptor flags. It delegates to the existing per-script selectors instead of restating their order, so each keeps one source of truth. It answers the dictionary question only; "which face has a glyph for this character?" depends on the string being drawn and stays where the drawing happens. `doc_info` gains `substitute` and `substitution` per font. Both null means the document's own glyphs are shown; a null `substitute` with a reason means no face was available at all. That is Acrobat's Actual Font, minus the interface, which `parity/` now records as still missing. Two findings on the way: `Std14::from_base_font` matches loosely, so "SomeHouseSerif" resolved to Times by its name containing "serif". The face is a fair guess but reporting it as "names one of the standard 14" would have been untrue of a font nobody has heard of. `Std14::recognise` now says whether a name matched an alias or only a family hint, and the two report differently. `from_base_font` is unchanged. The generated table moved to `crates/fonts/src/std14_metrics.rs`, data alone, and the ATTRIBUTION entry and AGENTS.md 1.1 line follow it. F1 put the hash on a file holding both the table and the code reading it, so this commit's ordinary edits tripped the gate -- and a hash that churns on unrelated changes gets re-attested reflexively, which is the protection gone. Verified both directions after the split: editing std14.rs passes the gate, changing one width in std14_metrics.rs fails it. Every new test was watched failing first: removing the script step breaks the two unit tests and the doc_info integration test. Co-Authored-By: Claude <noreply@anthropic.com>
612 lines
26 KiB
Rust
612 lines
26 KiB
Rust
//! `cargo xtask assets`: enforce the asset policy (AGENTS.md §1).
|
|
//!
|
|
//! `ATTRIBUTION.toml` lists every asset: committed or vendored files (`[[asset]]`), files that
|
|
//! Cargo dependencies compile into PdfCraft (`[[bundled]]`), and files xtask downloads at build
|
|
//! time (`[[fetched]]`). This gate fails when:
|
|
//! - an asset-like file in the repository has no entry, or its SHA-256 differs;
|
|
//! - a licence is not on the allowlist, or a declared licence file is missing;
|
|
//! - an icon, image or font names Adobe as author or source, or an `adobe_data` entry is not on
|
|
//! the closed list approved in AGENTS.md §1.1;
|
|
//! - a `[[bundled]]` crate version is not the one in Cargo.lock;
|
|
//! - `ATTRIBUTION.md` is out of date (`--write` regenerates it).
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::fmt::Write as _;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use serde::Deserialize;
|
|
use sha2::{Digest, Sha256};
|
|
|
|
/// Licences allowed by AGENTS.md §1.2 (SPDX identifiers).
|
|
pub const ALLOWED_LICENCES: &[&str] = &[
|
|
"ISC",
|
|
"MIT",
|
|
"BSD-2-Clause",
|
|
"BSD-3-Clause",
|
|
"Apache-2.0",
|
|
"OFL-1.1",
|
|
"Ubuntu-font-1.0",
|
|
"Bitstream-Vera",
|
|
"CC0-1.0",
|
|
"CC-BY-4.0",
|
|
"CC-BY-SA-4.0",
|
|
"LicenseRef-Public-Domain",
|
|
];
|
|
|
|
/// The closed list of Adobe-authored, non-visual technical data (AGENTS.md §1.1): (crate, path).
|
|
/// Storyteller's own brand marks (the ArtCraft name and logos): shipped in the app and docs, but
|
|
/// not licensed for reuse by forks. Allowed only for `kind = "logo"` entries under `docs/brand/`
|
|
/// (AGENTS.md §1.2).
|
|
pub const BRAND_LICENCE: &str = "LicenseRef-Storyteller-Trademark";
|
|
|
|
pub const ADOBE_DATA_ALLOWED: &[(&str, &str)] = &[("hayro-cmap", "assets/cmaps.brotli"), ("hayro-interpret", "src/font/generated/metrics.rs")];
|
|
|
|
/// First-party files on that same closed list: generated tables of Adobe-authored non-visual
|
|
/// technical data we hold ourselves rather than through a dependency. Each still needs an
|
|
/// `[[asset]]` entry with `adobe_data = true`, so its SHA-256 is checked and any change to the
|
|
/// table has to be re-attested deliberately.
|
|
pub const ADOBE_DATA_FILES: &[&str] = &["crates/fonts/src/std14_metrics.rs"];
|
|
|
|
/// File extensions that count as assets wherever they appear in the repository.
|
|
const ASSET_EXTENSIONS: &[&str] = &[
|
|
"png", "jpg", "jpeg", "gif", "svg", "ico", "icns", "webp", "bmp", "tif", "tiff", "avif", "heic", "ttf", "otf", "ttc", "woff", "woff2", "pfb",
|
|
"pfa", "afm", "icc", "icm", "pdf", "eps", "ps", "ai", "psd", "mp3", "wav", "ogg", "flac", "mp4", "mov", "webm", "cur", "ani", "brotli", "tsv",
|
|
];
|
|
|
|
#[derive(Deserialize, Default)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Manifest {
|
|
#[serde(default)]
|
|
pub asset: Vec<Asset>,
|
|
#[serde(default)]
|
|
pub bundled: Vec<Bundled>,
|
|
#[serde(default)]
|
|
pub fetched: Vec<Fetched>,
|
|
#[serde(default)]
|
|
pub build_input: Vec<BuildInput>,
|
|
}
|
|
|
|
/// Material compiled in only when the builder opts in (e.g. `CRAFT_FONTS_DIR`): not in this
|
|
/// repository, not downloaded by it. Its own repository attributes each file.
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct BuildInput {
|
|
pub name: String,
|
|
/// The environment variable that turns it on.
|
|
pub option: String,
|
|
pub title: String,
|
|
pub author: String,
|
|
pub source: String,
|
|
pub licence: String,
|
|
/// The input's own per-file attribution.
|
|
pub attribution: String,
|
|
pub kind: String,
|
|
pub usage: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Asset {
|
|
pub path: String,
|
|
pub title: String,
|
|
pub author: String,
|
|
pub source: String,
|
|
pub licence: String,
|
|
pub licence_file: String,
|
|
pub kind: String,
|
|
pub usage: String,
|
|
pub sha256: String,
|
|
#[serde(default)]
|
|
pub adobe_data: bool,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Bundled {
|
|
#[serde(rename = "crate")]
|
|
pub krate: String,
|
|
pub version: String,
|
|
pub path: String,
|
|
pub title: String,
|
|
pub author: String,
|
|
pub source: String,
|
|
pub licence: String,
|
|
pub kind: String,
|
|
pub usage: String,
|
|
pub sha256: String,
|
|
#[serde(default)]
|
|
pub adobe_data: bool,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Fetched {
|
|
pub file: String,
|
|
pub url: String,
|
|
pub title: String,
|
|
pub author: String,
|
|
pub source: String,
|
|
pub licence: String,
|
|
pub licence_url: String,
|
|
pub licence_sha256: String,
|
|
pub kind: String,
|
|
pub usage: String,
|
|
pub sha256: String,
|
|
}
|
|
|
|
pub fn root() -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask lives in the workspace").to_path_buf()
|
|
}
|
|
|
|
pub fn load(root: &Path) -> Result<Manifest> {
|
|
let path = root.join("ATTRIBUTION.toml");
|
|
let text = std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
|
|
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))
|
|
}
|
|
|
|
pub fn sha256_hex(bytes: &[u8]) -> String {
|
|
Sha256::digest(bytes).iter().fold(String::with_capacity(64), |mut s, b| {
|
|
let _ = write!(s, "{b:02x}");
|
|
s
|
|
})
|
|
}
|
|
|
|
/// `cargo xtask assets [--write]`.
|
|
pub fn run(args: &[String]) -> Result<()> {
|
|
let root = root();
|
|
let manifest = load(&root)?;
|
|
let markdown = render_markdown(&manifest);
|
|
if args.iter().any(|a| a == "--write") {
|
|
std::fs::write(root.join("ATTRIBUTION.md"), &markdown)?;
|
|
println!("assets: wrote ATTRIBUTION.md");
|
|
}
|
|
let mut problems = check(&root, &manifest, &repo_files(&root)?, &cargo_lock_versions(&root)?);
|
|
match std::fs::read_to_string(root.join("ATTRIBUTION.md")) {
|
|
Ok(existing) if existing == markdown => {}
|
|
_ => problems.push("ATTRIBUTION.md is out of date: run `cargo xtask assets --write`".into()),
|
|
}
|
|
if problems.is_empty() {
|
|
println!(
|
|
"assets: ok ({} repository assets, {} bundled by dependencies, {} fetched at build time)",
|
|
manifest.asset.len(),
|
|
manifest.bundled.len(),
|
|
manifest.fetched.len()
|
|
);
|
|
Ok(())
|
|
} else {
|
|
for p in &problems {
|
|
eprintln!(" ✗ {p}");
|
|
}
|
|
bail!("{} asset policy violation(s); see AGENTS.md §1", problems.len())
|
|
}
|
|
}
|
|
|
|
/// Files in the working tree that git tracks or would track (respects .gitignore).
|
|
fn repo_files(root: &Path) -> Result<Vec<String>> {
|
|
let out = Command::new("git")
|
|
.args(["ls-files", "--cached", "--others", "--exclude-standard", "-z"])
|
|
.current_dir(root)
|
|
.output()
|
|
.context("running git ls-files")?;
|
|
if !out.status.success() {
|
|
bail!("git ls-files failed");
|
|
}
|
|
Ok(out.stdout.split(|b| *b == 0).filter(|s| !s.is_empty()).map(|s| String::from_utf8_lossy(s).into_owned()).collect())
|
|
}
|
|
|
|
fn cargo_lock_versions(root: &Path) -> Result<BTreeSet<(String, String)>> {
|
|
let text = std::fs::read_to_string(root.join("Cargo.lock")).context("reading Cargo.lock")?;
|
|
let mut out = BTreeSet::new();
|
|
let mut name = None;
|
|
for line in text.lines() {
|
|
if let Some(n) = line.strip_prefix("name = \"") {
|
|
name = Some(n.trim_end_matches('"').to_string());
|
|
} else if let (Some(v), Some(n)) = (line.strip_prefix("version = \""), name.take()) {
|
|
out.insert((n, v.trim_end_matches('"').to_string()));
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
pub fn is_asset_path(path: &str) -> bool {
|
|
Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| ASSET_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str()))
|
|
}
|
|
|
|
/// Every licence id in an SPDX expression (`MIT OR Apache-2.0`, `MIT AND Bitstream-Vera`).
|
|
fn licence_ids(expr: &str) -> impl Iterator<Item = &str> {
|
|
expr.split(|c: char| c.is_whitespace() || c == '(' || c == ')').filter(|t| !t.is_empty() && *t != "OR" && *t != "AND" && *t != "WITH")
|
|
}
|
|
|
|
fn mentions_adobe(s: &str) -> bool {
|
|
s.to_ascii_lowercase().contains("adobe")
|
|
}
|
|
|
|
/// All policy checks that don't depend on the filesystem layout beyond `root` (unit-testable).
|
|
pub fn check(root: &Path, m: &Manifest, repo_files: &[String], lock: &BTreeSet<(String, String)>) -> Vec<String> {
|
|
let mut problems = Vec::new();
|
|
let mut seen = BTreeSet::new();
|
|
// Kinds that may never come from Adobe (AGENTS.md §1.1): visual design, and UI translations
|
|
// (which must be clean-room, never taken from a product's string tables).
|
|
let visual = |kind: &str| matches!(kind, "icon" | "image" | "font" | "logo" | "cursor" | "video" | "translation");
|
|
let licence_ok = |what: &str, licence: &str, problems: &mut Vec<String>| {
|
|
for id in licence_ids(licence) {
|
|
if !ALLOWED_LICENCES.contains(&id) {
|
|
problems.push(format!("{what}: licence `{id}` is not on the allowlist (AGENTS.md §1.2)"));
|
|
}
|
|
}
|
|
};
|
|
for a in &m.asset {
|
|
if !seen.insert(a.path.clone()) {
|
|
problems.push(format!("{}: listed twice", a.path));
|
|
}
|
|
let brand = a.licence == BRAND_LICENCE && a.kind == "logo" && a.path.starts_with("docs/brand/");
|
|
if !brand {
|
|
licence_ok(&a.path, &a.licence, &mut problems);
|
|
}
|
|
if !root.join(&a.licence_file).is_file() {
|
|
problems.push(format!("{}: licence file {} is missing", a.path, a.licence_file));
|
|
}
|
|
if visual(&a.kind) && (mentions_adobe(&a.author) || mentions_adobe(&a.source) || mentions_adobe(&a.title)) {
|
|
problems.push(format!("{}: {} from Adobe is forbidden (AGENTS.md §1.1)", a.path, a.kind));
|
|
}
|
|
if a.adobe_data && !(ADOBE_DATA_FILES.contains(&a.path.as_str()) && a.kind == "data") {
|
|
problems.push(format!("{}: adobe_data is only allowed for the closed list in AGENTS.md §1.1", a.path));
|
|
}
|
|
match std::fs::read(root.join(&a.path)) {
|
|
Ok(bytes) if sha256_hex(&bytes) == a.sha256 => {}
|
|
Ok(_) => problems.push(format!("{}: SHA-256 does not match ATTRIBUTION.toml (re-verify provenance, then update the entry)", a.path)),
|
|
Err(_) => problems.push(format!("{}: listed but missing", a.path)),
|
|
}
|
|
}
|
|
for f in repo_files {
|
|
if is_asset_path(f) && !seen.contains(f) && root.join(f).is_file() {
|
|
problems.push(format!("{f}: asset has no ATTRIBUTION.toml entry"));
|
|
}
|
|
}
|
|
for b in &m.bundled {
|
|
let what = format!("{}@{} {}", b.krate, b.version, b.path);
|
|
licence_ok(&what, &b.licence, &mut problems);
|
|
if !lock.contains(&(b.krate.clone(), b.version.clone())) {
|
|
problems.push(format!("{what}: crate version not in Cargo.lock (dependency changed: re-audit its bundled assets)"));
|
|
}
|
|
if visual(&b.kind) && (mentions_adobe(&b.author) || mentions_adobe(&b.source)) {
|
|
problems.push(format!("{what}: {} from Adobe is forbidden (AGENTS.md §1.1)", b.kind));
|
|
}
|
|
let adobe = mentions_adobe(&b.author);
|
|
let approved = ADOBE_DATA_ALLOWED.contains(&(b.krate.as_str(), b.path.as_str())) && b.kind == "data";
|
|
if (adobe || b.adobe_data) && !(approved && b.adobe_data) {
|
|
problems.push(format!("{what}: Adobe-authored material outside the closed list in AGENTS.md §1.1"));
|
|
}
|
|
if b.sha256.len() != 64 {
|
|
problems.push(format!("{what}: sha256 must be 64 hex digits"));
|
|
}
|
|
}
|
|
for f in &m.fetched {
|
|
licence_ok(&f.file, &f.licence, &mut problems);
|
|
if visual(&f.kind) && (mentions_adobe(&f.author) || mentions_adobe(&f.source) || mentions_adobe(&f.title)) {
|
|
problems.push(format!("{}: {} from Adobe is forbidden (AGENTS.md §1.1)", f.file, f.kind));
|
|
}
|
|
if f.sha256.len() != 64 || f.licence_sha256.len() != 64 {
|
|
problems.push(format!("{}: sha256 and licence_sha256 must be 64 hex digits", f.file));
|
|
}
|
|
if !f.url.starts_with("https://") || !f.licence_url.starts_with("https://") {
|
|
problems.push(format!("{}: URLs must be https", f.file));
|
|
}
|
|
}
|
|
for b in &m.build_input {
|
|
licence_ok(&b.name, &b.licence, &mut problems);
|
|
if visual(&b.kind) && (mentions_adobe(&b.author) || mentions_adobe(&b.source) || mentions_adobe(&b.title)) {
|
|
problems.push(format!("{}: {} from Adobe is forbidden (AGENTS.md §1.1)", b.name, b.kind));
|
|
}
|
|
if !b.source.starts_with("https://") || !b.attribution.starts_with("https://") {
|
|
problems.push(format!("{}: URLs must be https", b.name));
|
|
}
|
|
}
|
|
problems
|
|
}
|
|
|
|
/// Download (if needed) and verify every `[[fetched]]` entry of `kind` into `dir`, with its licence
|
|
/// text. Returns the paths of the fetched files. Uses `curl`, present on macOS, Windows 10+ and Linux.
|
|
pub fn fetch_all(m: &Manifest, dir: &Path, kind: &str) -> Result<Vec<PathBuf>> {
|
|
std::fs::create_dir_all(dir)?;
|
|
let mut out = Vec::new();
|
|
for f in m.fetched.iter().filter(|f| f.kind == kind) {
|
|
let path = dir.join(&f.file);
|
|
fetch_verified(&f.url, &path, &f.sha256)?;
|
|
let licence = dir.join(format!("{}.LICENCE.txt", f.file));
|
|
fetch_verified(&f.licence_url, &licence, &f.licence_sha256)?;
|
|
out.push(path);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn fetch_verified(url: &str, path: &Path, sha256: &str) -> Result<()> {
|
|
if std::fs::read(path).is_ok_and(|b| sha256_hex(&b) == sha256) {
|
|
return Ok(());
|
|
}
|
|
println!("assets: fetching {url}");
|
|
let tmp = path.with_extension("part");
|
|
let status = Command::new("curl").args(["-sSfL", "--retry", "3", "-o"]).arg(&tmp).arg(url).status().context("running curl")?;
|
|
if !status.success() {
|
|
bail!("download failed: {url}");
|
|
}
|
|
let bytes = std::fs::read(&tmp)?;
|
|
if sha256_hex(&bytes) != sha256 {
|
|
let _ = std::fs::remove_file(&tmp);
|
|
bail!("{url}: SHA-256 mismatch (expected {sha256}); refusing to use it");
|
|
}
|
|
std::fs::rename(&tmp, path)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// `cargo xtask models [DIR]`: fetch the OCR models (`kind = "model"`) into `assets/models/` (git-ignored),
|
|
/// where the app and tests look for them. Each comes with its licence (`<file>.LICENCE.txt`), and
|
|
/// `ATTRIBUTION.txt` credits them all, so the release packages can ship the directory as it is.
|
|
pub fn models(args: &[String]) -> Result<()> {
|
|
let root = root();
|
|
let m = load(&root)?;
|
|
let dir = args.first().map(PathBuf::from).unwrap_or_else(|| root.join("assets/models"));
|
|
for p in fetch_all(&m, &dir, "model")? {
|
|
println!("models: {}", p.display());
|
|
}
|
|
std::fs::write(dir.join("ATTRIBUTION.txt"), models_attribution(&m))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// The credits shipped beside the models (their licences require attribution).
|
|
fn models_attribution(m: &Manifest) -> String {
|
|
let mut s = String::from("PdfCraft's text recognition (Scan & OCR) uses these models, unmodified.\n");
|
|
for f in m.fetched.iter().filter(|f| f.kind == "model") {
|
|
let _ = write!(
|
|
s,
|
|
"\n{}\n {}\n by {}\n from {}\n licence: {} ({}; full text in {}.LICENCE.txt)\n",
|
|
f.file, f.title, f.author, f.source, f.licence, f.licence_url, f.file
|
|
);
|
|
}
|
|
s
|
|
}
|
|
|
|
/// ATTRIBUTION.md, generated from the manifest.
|
|
pub fn render_markdown(m: &Manifest) -> String {
|
|
let mut s = String::new();
|
|
s.push_str("# Attribution\n\n");
|
|
s.push_str("<!-- Generated from ATTRIBUTION.toml by `cargo xtask assets --write`. Do not edit by hand. -->\n\n");
|
|
s.push_str("Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. ");
|
|
s.push_str(
|
|
"The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). ",
|
|
);
|
|
s.push_str("Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).\n\n");
|
|
let esc = |t: &str| t.replace('|', "\\|");
|
|
s.push_str(&format!(
|
|
"## In this repository ({})\n\n| Asset | Title | Author | Licence | Source | Used for |\n|---|---|---|---|---|---|\n",
|
|
m.asset.len()
|
|
));
|
|
for a in &m.asset {
|
|
let _ = writeln!(s, "| `{}` | {} | {} | {} | {} | {} |", a.path, esc(&a.title), esc(&a.author), a.licence, esc(&a.source), esc(&a.usage));
|
|
}
|
|
s.push_str(&format!(
|
|
"\n## Compiled in through dependencies ({})\n\n| Crate | File | Title | Author | Licence | Source | Used for |\n|---|---|---|---|---|---|---|\n",
|
|
m.bundled.len()
|
|
));
|
|
for b in &m.bundled {
|
|
let note = if b.adobe_data { " (non-visual data, AGENTS.md §1.1)" } else { "" };
|
|
let _ = writeln!(
|
|
s,
|
|
"| `{}` {} | `{}` | {}{} | {} | {} | {} | {} |",
|
|
b.krate,
|
|
b.version,
|
|
b.path,
|
|
esc(&b.title),
|
|
note,
|
|
esc(&b.author),
|
|
b.licence,
|
|
esc(&b.source),
|
|
esc(&b.usage)
|
|
);
|
|
}
|
|
s.push_str(&format!(
|
|
"\n## Downloaded at build time ({})\n\nFonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR models by `cargo xtask models` into `assets/models/`; each is verified by SHA-256 and never committed.\n\n| File | Title | Author | Licence | Source | Used for |\n|---|---|---|---|---|---|\n",
|
|
m.fetched.len()
|
|
));
|
|
for f in &m.fetched {
|
|
let _ = writeln!(s, "| `{}` | {} | {} | {} | {} | {} |", f.file, esc(&f.title), esc(&f.author), f.licence, esc(&f.source), esc(&f.usage));
|
|
}
|
|
s.push_str(&format!(
|
|
"\n## Optional build inputs ({})\n\nNot in this repository and never downloaded by it: compiled in only when the build sets the option (official releases do). Each input attributes its own files.\n\n| Input | Option | Title | Author | Licence | Source | Attribution | Used for |\n|---|---|---|---|---|---|---|---|\n",
|
|
m.build_input.len()
|
|
));
|
|
for b in &m.build_input {
|
|
let _ = writeln!(
|
|
s,
|
|
"| `{}` | `{}` | {} | {} | {} | {} | {} | {} |",
|
|
b.name,
|
|
b.option,
|
|
esc(&b.title),
|
|
esc(&b.author),
|
|
b.licence,
|
|
esc(&b.source),
|
|
esc(&b.attribution),
|
|
esc(&b.usage)
|
|
);
|
|
}
|
|
s
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The release packages ship assets/models as fetched (#103): every model is credited there.
|
|
#[test]
|
|
fn models_attribution_credits_every_model() {
|
|
let m = load(&root()).unwrap();
|
|
let text = models_attribution(&m);
|
|
let models: Vec<_> = m.fetched.iter().filter(|f| f.kind == "model").collect();
|
|
assert!(!models.is_empty());
|
|
for f in models {
|
|
assert!(text.contains(&f.file) && text.contains(&f.author) && text.contains(&f.licence), "{}", f.file);
|
|
}
|
|
}
|
|
|
|
fn asset(path: &str, author: &str, kind: &str, licence: &str) -> Asset {
|
|
Asset {
|
|
path: path.into(),
|
|
title: "t".into(),
|
|
author: author.into(),
|
|
source: "s".into(),
|
|
licence: licence.into(),
|
|
licence_file: "Cargo.toml".into(),
|
|
kind: kind.into(),
|
|
usage: "u".into(),
|
|
sha256: sha256_hex(&std::fs::read(root().join(path)).unwrap_or_default()),
|
|
adobe_data: false,
|
|
}
|
|
}
|
|
|
|
fn bundled(krate: &str, path: &str, author: &str, kind: &str, adobe_data: bool) -> Bundled {
|
|
Bundled {
|
|
krate: krate.into(),
|
|
version: "1.0.0".into(),
|
|
path: path.into(),
|
|
title: "t".into(),
|
|
author: author.into(),
|
|
source: "s".into(),
|
|
licence: "BSD-3-Clause".into(),
|
|
kind: kind.into(),
|
|
usage: "u".into(),
|
|
sha256: "0".repeat(64),
|
|
adobe_data,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_inputs_need_an_open_licence_and_https_links() {
|
|
let input = |licence: &str, attribution: &str| BuildInput {
|
|
name: "craft-fonts".into(),
|
|
option: "CRAFT_FONTS_DIR".into(),
|
|
title: "t".into(),
|
|
author: "a".into(),
|
|
source: "https://github.com/storytold/craft-fonts".into(),
|
|
licence: licence.into(),
|
|
attribution: attribution.into(),
|
|
kind: "font".into(),
|
|
usage: "u".into(),
|
|
};
|
|
let check_one = |b: BuildInput| check(&root(), &Manifest { build_input: vec![b], ..Default::default() }, &[], &lock(&[]));
|
|
assert!(check_one(input("OFL-1.1", "https://example.org/ATTRIBUTION.md")).is_empty());
|
|
assert_eq!(check_one(input("LicenseRef-Proprietary", "https://example.org/A.md")).len(), 1);
|
|
assert_eq!(check_one(input("OFL-1.1", "ATTRIBUTION.md")).len(), 1);
|
|
}
|
|
|
|
fn lock(entries: &[(&str, &str)]) -> BTreeSet<(String, String)> {
|
|
entries.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn the_real_manifest_passes() {
|
|
let root = root();
|
|
let m = load(&root).unwrap();
|
|
let problems = check(&root, &m, &repo_files(&root).unwrap(), &cargo_lock_versions(&root).unwrap());
|
|
assert!(problems.is_empty(), "{problems:#?}");
|
|
}
|
|
|
|
#[test]
|
|
fn unlisted_asset_is_rejected() {
|
|
let p = check(&root(), &Manifest::default(), &["assets/icons/x.svg".into()], &lock(&[]));
|
|
assert!(p.iter().any(|p| p.contains("no ATTRIBUTION.toml entry")), "{p:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn translation_catalogs_require_attribution() {
|
|
assert!(is_asset_path("crates/ui-egui/src/i18n/ja.tsv"));
|
|
let p = check(&root(), &Manifest::default(), &["crates/ui-egui/src/i18n/ja.tsv".into()], &lock(&[]));
|
|
assert!(p.iter().any(|p| p.contains("no ATTRIBUTION.toml entry")), "{p:?}");
|
|
let m = Manifest { asset: vec![asset("crates/ui-egui/src/i18n/ja.tsv", "Adobe", "translation", "MIT")], ..Default::default() };
|
|
assert!(check(&root(), &m, &[], &lock(&[])).iter().any(|p| p.contains("from Adobe is forbidden")));
|
|
}
|
|
|
|
#[test]
|
|
fn adobe_icons_are_rejected() {
|
|
let m = Manifest { asset: vec![asset("assets/icons/x.svg", "Adobe Inc.", "icon", "MIT")], ..Default::default() };
|
|
let p = check(&root(), &m, &[], &lock(&[]));
|
|
assert!(p.iter().any(|p| p.contains("from Adobe is forbidden")), "{p:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn disallowed_licences_are_rejected() {
|
|
let m = Manifest { asset: vec![asset("assets/icons/x.svg", "Someone", "icon", "LicenseRef-Proprietary OR MIT")], ..Default::default() };
|
|
let p = check(&root(), &m, &[], &lock(&[]));
|
|
assert!(p.iter().any(|p| p.contains("not on the allowlist")), "{p:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn changed_file_fails_hash_check() {
|
|
let mut a = asset("assets/icons/x.svg", "Lucide", "icon", "ISC");
|
|
a.sha256 = "0".repeat(64);
|
|
let p = check(&root(), &Manifest { asset: vec![a], ..Default::default() }, &[], &lock(&[]));
|
|
assert!(p.iter().any(|p| p.contains("SHA-256 does not match")), "{p:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn brand_licence_only_for_brand_logos() {
|
|
let root = super::root();
|
|
for (path, kind, pass) in [
|
|
("docs/brand/artcraft-mark.svg", "logo", true),
|
|
("docs/brand/artcraft-mark.svg", "icon", false),
|
|
("assets/icons/mark.svg", "logo", false),
|
|
] {
|
|
let mut a = asset(path, "Storyteller", kind, BRAND_LICENCE);
|
|
a.licence_file = "docs/brand/LICENSE-brand.txt".into();
|
|
let m = Manifest { asset: vec![a], ..Default::default() };
|
|
let p = check(&root, &m, &[], &BTreeSet::new());
|
|
assert_eq!(p.iter().all(|p| !p.contains("allowlist")), pass, "{path} {kind}: {p:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn adobe_data_only_on_the_closed_list() {
|
|
let l = lock(&[("hayro-cmap", "1.0.0"), ("other", "1.0.0")]);
|
|
let ok = Manifest { bundled: vec![bundled("hayro-cmap", "assets/cmaps.brotli", "Adobe", "data", true)], ..Default::default() };
|
|
assert!(check(&root(), &ok, &[], &l).is_empty());
|
|
let sneaky = Manifest { bundled: vec![bundled("other", "fonts/Myriad.otf", "Adobe", "data", true)], ..Default::default() };
|
|
assert!(check(&root(), &sneaky, &[], &l).iter().any(|p| p.contains("closed list")));
|
|
let font = Manifest { bundled: vec![bundled("hayro-cmap", "assets/cmaps.brotli", "Adobe", "font", true)], ..Default::default() };
|
|
assert!(check(&root(), &font, &[], &l).iter().any(|p| p.contains("forbidden")));
|
|
}
|
|
|
|
#[test]
|
|
fn first_party_adobe_data_only_on_the_closed_list() {
|
|
let l = lock(&[]);
|
|
// The generated standard-14 table is on the list, as `kind = "data"`.
|
|
let mut ok = asset("crates/fonts/src/std14_metrics.rs", "Adobe (Core14 AFM metrics); tables by the PdfCraft contributors", "data", "MIT");
|
|
ok.adobe_data = true;
|
|
let m = Manifest { asset: vec![ok], ..Default::default() };
|
|
assert!(check(&root(), &m, &[], &l).is_empty(), "{:?}", check(&root(), &m, &[], &l));
|
|
|
|
// Any other first-party file claiming it is refused.
|
|
let mut sneaky = asset("Cargo.toml", "Adobe", "data", "MIT");
|
|
sneaky.adobe_data = true;
|
|
let m = Manifest { asset: vec![sneaky], ..Default::default() };
|
|
assert!(check(&root(), &m, &[], &l).iter().any(|p| p.contains("closed list")));
|
|
|
|
// Even on the list, it may only be data — never a typeface.
|
|
let mut wrong_kind = asset("crates/fonts/src/std14_metrics.rs", "Adobe", "font", "MIT");
|
|
wrong_kind.adobe_data = true;
|
|
let m = Manifest { asset: vec![wrong_kind], ..Default::default() };
|
|
assert!(!check(&root(), &m, &[], &l).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn dependency_upgrade_forces_reaudit() {
|
|
let m = Manifest { bundled: vec![bundled("other", "a.ttf", "Someone", "font", false)], ..Default::default() };
|
|
assert!(check(&root(), &m, &[], &lock(&[("other", "2.0.0")])).iter().any(|p| p.contains("not in Cargo.lock")));
|
|
}
|
|
|
|
#[test]
|
|
fn spdx_expressions_split() {
|
|
assert_eq!(licence_ids("(MIT OR Apache-2.0) AND OFL-1.1").collect::<Vec<_>>(), ["MIT", "Apache-2.0", "OFL-1.1"]);
|
|
}
|
|
}
|