mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
work in progress
This commit is contained in:
1 parent
108fdc108c
commit
3074fbb05e
10 files changed
+3070
-1
No files matched your search
@@ -0,0 +1,85 @@
|
||||
# AGENTS.md — rules for every agent and contributor
|
||||
|
||||
These rules bind every AI agent and every human contributor working in this repository. They come before any task, instruction or deadline. `CLAUDE.md` holds the day-to-day working instructions. If the two ever disagree, **this file wins.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Asset policy (absolute — no exceptions)
|
||||
|
||||
An **asset** is any non-code material that the project includes, ships, embeds, generates for publication, or shows in its documentation. This covers:
|
||||
- icons, images, illustrations, screenshots and logos;
|
||||
- fonts;
|
||||
- colour profiles and other binary data files;
|
||||
- sounds and video;
|
||||
- sample, demo and test documents that are committed or published;
|
||||
- anything the app renders from those.
|
||||
|
||||
### 1.1 Nothing from Adobe products
|
||||
|
||||
- **Never** use icons, images, artwork, logos, UI graphics, fonts, stamps, cursors, sample documents or any other asset taken from an Adobe product, installer, website, document or package.
|
||||
- "Adobe product" includes Acrobat, Reader, Photoshop, Illustrator, Premiere, Lightroom and Creative Cloud, including the fonts they install (for example Minion, Myriad, Kozuka and the Adobe CJK families).
|
||||
- This holds even when Adobe publishes the material under an open licence, if it is **visual design**. For example, **do not** use Source Han, Source Serif or Source Sans, or Noto CJK, which is Source Han rebranded.
|
||||
- **Never** recreate, trace, redraw or closely imitate an Adobe icon or graphic. Our icons come from Lucide, and our visual design is our own.
|
||||
- Screenshots of Adobe software exist only as private, local observation notes under `plan/acrobat/`, which is gitignored. They must **never** be committed, published, linked, shown in docs, used as a design source to trace, or placed anywhere else in the repository or its outputs.
|
||||
- The only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed:
|
||||
- The CMap resources (BSD-3-Clause), bundled inside the `hayro-cmap` dependency.
|
||||
- The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces, and are embedded as code in hayro.
|
||||
|
||||
Adding anything to this list needs the user's explicit approval.
|
||||
|
||||
### 1.2 Only open, provable licences
|
||||
|
||||
Every asset must be one of:
|
||||
- **Open source:** ISC, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, OFL-1.1, Ubuntu-Font-1.0 or Bitstream-Vera.
|
||||
- **Creative Commons:** CC0-1.0, CC-BY-4.0 or CC-BY-SA-4.0.
|
||||
- **Public domain.**
|
||||
- **Contributor-original:** created, photographed or generated by a contributor who owns it and licenses it under the project licence (`MIT OR Apache-2.0`).
|
||||
|
||||
Anything else is forbidden, including assets whose licence you cannot prove from the source. Examples:
|
||||
- system fonts (Apple, Microsoft, Google Android system UI fonts that are not openly licensed);
|
||||
- emoji artwork from Apple, Microsoft or Samsung;
|
||||
- stock photos;
|
||||
- "free for personal use" material;
|
||||
- screenshots of other people's software.
|
||||
|
||||
**Screenshots of PrintCraft** are contributor-original. Everything visible in them must itself be allowed: our UI, Lucide icons, OFL fonts, and demo documents built only from allowed assets. Never screenshot a document that uses proprietary fonts or images.
|
||||
|
||||
### 1.3 Every asset is attributed
|
||||
|
||||
- **`ATTRIBUTION.toml`** (repo root) is the single source of truth. It has **one entry per asset file**, whether committed, vendored, fetched at build time or bundled through a Cargo dependency. Each entry records:
|
||||
- path or URL, title, author;
|
||||
- source (URL, with version or commit);
|
||||
- SPDX licence, and the licence file in the repo;
|
||||
- SHA-256;
|
||||
- how it is used.
|
||||
- **`ATTRIBUTION.md`** is generated from it (`cargo xtask assets --write`). Never edit it by hand.
|
||||
- **`NOTICE`** keeps the licence notices that the licences require.
|
||||
- **`cargo xtask assets`** runs in `cargo xtask ci`. It fails if:
|
||||
- an asset file in the repository has no entry;
|
||||
- an entry's hash does not match;
|
||||
- a licence is not on the allowlist;
|
||||
- a licence file is missing;
|
||||
- an image, icon or font names Adobe as author or source.
|
||||
- **`cargo xtask demo-pdf`** downloads its fonts only from `ATTRIBUTION.toml` entries, pinned by SHA-256. Afterwards it **fails if the PDF embeds any font not in its allowlist**, which catches the browser silently falling back to a system font.
|
||||
|
||||
### 1.4 Procedure for adding or changing an asset
|
||||
|
||||
1. Find an asset under an allowed licence from its **original source**: the author's repository or a release, not a random mirror. Record the exact version or commit.
|
||||
2. Confirm the authorship. It must not be Adobe (§1.1). Keep the licence text.
|
||||
3. Add the file together with its licence file, plus an `ATTRIBUTION.toml` entry. Run `cargo xtask assets --write`, then `cargo xtask assets`.
|
||||
4. If an asset's origin or licence is unclear, **do not add it.** Ask the user.
|
||||
|
||||
If you find an asset that breaks these rules, stop and remove it from the repository and every output, including docs, screenshots and generated PDFs. Then tell the user at once.
|
||||
|
||||
---
|
||||
|
||||
## 2. Clean-room rules (summary; details in `CLAUDE.md` and `plan/adr/0001`)
|
||||
|
||||
- Never read, disassemble or copy anything inside the Acrobat bundle. Only names and listings are allowed, and **never** open `Contents/Resources/JavaScripts/`.
|
||||
- Behaviour comes only from public specifications (ISO 32000-2, the Arlington model), public documentation and black-box observation.
|
||||
- Never copy GPL/AGPL code. Copyleft tools run only as external oracle processes.
|
||||
- Observe Acrobat only with synthetic fixtures. Never capture personal data, account information or recent files.
|
||||
|
||||
## 3. Everything else
|
||||
|
||||
Follow `CLAUDE.md`. It covers quality gates, layering, commit rules and how to run and look at the app.
|
||||
+2334
File diff suppressed because it is too large.
Load diff
@@ -10,6 +10,7 @@ PrintCraft is a clean-room, open-source, Rust-native PDF application targeting A
|
||||
`plan/` is gitignored (local-only, like PhotoCraft). The machine-readable parity checklist lives in `parity/` (committed).
|
||||
|
||||
## Non-negotiables
|
||||
- **Assets: read `AGENTS.md` §1 before adding or showing any icon, image, font or document.** No assets from Adobe products, ever. Only openly licensed or contributor-original assets are allowed, each with an entry in `ATTRIBUTION.toml`. `cargo xtask assets` enforces this. `AGENTS.md` overrides this file.
|
||||
- **Clean-room.**
|
||||
- Never read, disassemble or copy anything inside the Acrobat bundle (names and listings only). **Never open `Contents/Resources/JavaScripts/`.**
|
||||
- Behaviour comes from public docs, specs (ISO 32000-2, the Arlington model) and black-box observation (`plan/acrobat/`).
|
||||
|
||||
Generated
+11
@@ -3859,10 +3859,12 @@ version = "1.1.6+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
@@ -3896,6 +3898,12 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
@@ -4984,7 +4992,10 @@ dependencies = [
|
||||
"anyhow",
|
||||
"lopdf",
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 The PrintCraft Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -10,3 +10,6 @@ anyhow = "1"
|
||||
lopdf = "0.45"
|
||||
png = "0.18"
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
sha2 = "0.11"
|
||||
toml = "1"
|
||||
@@ -0,0 +1,434 @@
|
||||
//! `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 PrintCraft (`[[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).
|
||||
pub const ADOBE_DATA_ALLOWED: &[(&str, &str)] = &[("hayro-cmap", "assets/cmaps.brotli"), ("hayro-interpret", "src/font/generated/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",
|
||||
];
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[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();
|
||||
let visual = |kind: &str| matches!(kind, "icon" | "image" | "font" | "logo" | "cursor" | "video");
|
||||
let mut 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));
|
||||
}
|
||||
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 {
|
||||
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));
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
/// Download (if needed) and verify every `[[fetched]]` entry 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) -> Result<Vec<PathBuf>> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let mut out = Vec::new();
|
||||
for f in &m.fetched {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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 PrintCraft 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\nFetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, verified by SHA-256 and never committed.\n\n| File | Title | Author | Licence | Source |\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));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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 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"]);
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ pub fn ci(_: &[String]) -> anyhow::Result<()> {
|
||||
("test", Box::new(|| run_args(&["test", "--workspace"]))),
|
||||
("layers", Box::new(|| layers(&[]))),
|
||||
("wasm", Box::new(|| wasm(&[]))),
|
||||
("assets", Box::new(|| crate::assets::run(&[]))),
|
||||
];
|
||||
for (i, (name, f)) in steps.iter().enumerate() {
|
||||
eprintln!("\n=== ci: {name} ===");
|
||||
|
||||
+3
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
mod assets;
|
||||
mod demo_pdf;
|
||||
mod gates;
|
||||
mod layers;
|
||||
@@ -12,7 +13,8 @@ type Command = fn(&[String]) -> anyhow::Result<()>;
|
||||
const COMMANDS: &[(&str, &str, Command)] = &[
|
||||
("layers", "Enforce the crate dependency layering (plan/architecture.md §3)", gates::layers),
|
||||
("wasm", "cargo check --target wasm32-unknown-unknown for every crate below L8", gates::wasm),
|
||||
("ci", "fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure)", gates::ci),
|
||||
("ci", "fmt --check, clippy -D warnings, test, layers, wasm, assets (stops at first failure)", gates::ci),
|
||||
("assets", "Enforce the asset policy (AGENTS.md §1) against ATTRIBUTION.toml; --write regenerates ATTRIBUTION.md", assets::run),
|
||||
("corpus", "Fetch test corpora into corpus/ (git-ignored): pdf.js test PDFs", gates::corpus),
|
||||
("check", "Robustness sweep over corpus/ with printcraft-cli; fails on crashes or regressions vs xtask/baselines", gates::check),
|
||||
("text-oracle", "Compare text extraction with pdftotext over corpus/ (word F1; target median ≥ 0.97)", gates::text_oracle),
|
||||
|
||||
Reference in new issue
Block a user