miniex/cargo-tribute
GitHub: miniex/cargo-tribute
一款零配置的 Cargo 子命令工具,用于自动生成第三方许可证归属清单和 SBOM,并提供 CI 流水线中的许可证策略门控与时效性校验。
Stars: 3 | Forks: 0
# cargo-tribute
[](https://crates.io/crates/cargo-tribute)
[](https://github.com/miniex/cargo-tribute/actions/workflows/ci.yml)
从 Cargo 依赖树生成 REUSE 风格的 `LICENSES/` 文件夹和各 crate 的归属清单(attribution manifest),而无需手动维护第三方许可证声明。
`cargo tribute` 会遍历工作区(workspace)的正常依赖闭包,根据已接受列表解析每个 crate 的 SPDX 许可证表达式,并写入:
- `LICENSES/.txt` —— 每种实际使用的许可证对应一份标准许可证文本,工作区自身的许可证也包括在内(REUSE 的文件夹涵盖项目中的所有许可证,而不仅是第三方的)
- `NOTICES/-.txt` —— 依赖项附带的 NOTICE 文件(即 Apache-2.0 第 4(d) 条要求再分发者传递的那些文件),仅当依赖项确实提供了此类文件时才会生成
- `THIRD-PARTY.md` —— 按许可证分组的依赖项及其版权所有者,并链接到相应的文本
- `THIRD-PARTY-NOTICES` —— 当配置 `layout = "flat"`(仅此单独文件)或 `"both"` 时,生成一份扁平化的、包含所有包条目及完整内联许可证文本的统一声明文档
它是一个策略门控(如果依赖项的许可证不在接受范围内,则会失败),并且通过 `--check` 作为一个时效性门控(如果已提交的输出与依赖树不再匹配,则会失败)——这两者均适用于 CI。

## 安装
```
cargo install cargo-tribute
```
需要 Rust 1.91 或更高版本(由依赖项决定,而非本 crate 中的任何设置)。
或者,通过 [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) 获取预编译的二进制文件:
```
cargo binstall cargo-tribute
```
## 使用方法
```
cargo tribute # write the attribution (LICENSES/, NOTICES/, THIRD-PARTY.md)
cargo tribute init # scaffold a commented tribute.toml
cargo tribute --check # CI gate: outputs current, every license accepted
cargo tribute --help # the rest: --audit, -p, --from-deny, --json/--format, -q, ...
```
退出代码区分了不同的失败情况:1 表示许可证策略失败,2 表示输出已过期(`--check`),3 表示其他任何错误。
## 在 CI 中使用
当依赖项的许可证不被接受,或者已提交的输出(无论 `layout` 写入了什么)与依赖树不一致时,导致构建失败:
```
# .github/workflows/licenses.yml
name: licenses
on: [push, pull_request]
jobs:
tribute:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cargo install cargo-tribute # or: cargo binstall cargo-tribute
- run: cargo tribute --locked --check
```
`--check` 会与其解析出的依赖树进行对比,因此它必须解析出与当初生成已提交输出时相同的依赖树。如果这需要用到 `--all-features` 或 `--filter-platform`,请将其放在 `tribute.toml`(`all-features`、`filter-platform` 等)中,而不是同时放在两个命令行中——否则检查会因为仅在 flag 上的差异而失败。
## 与其他工具的对比
这些都是优秀的工具;以下是 `cargo-tribute` 的不同之处(以下为撰写时的行为——请查看各项目的最新文档)。
| | cargo-tribute | cargo-about | cargo-deny | cargo-license |
| ------------------------------ | ------------------------------------------- | ------------------------ | --------------------- | --------------- |
| 归属输出 | `THIRD-PARTY.md` + REUSE `LICENSES/` 文件夹 | 从模板生成的一个文件 | 无(许可证 linter) | 列表输出到 stdout |
| 版权声明行 + NOTICE 文件 | 是 | 否 | 否 | 仅 authors |
| 接受许可证门控 | 是 | 是(通过配置) | 是(其核心功能) | 否 |
| 针对 crate 的例外 | 是 (`[[exception]]`) | 针对单个 crate 接受 | 是 (`exceptions`) | 否 |
| 非 crate 的 vendored 代码 | 是 (`[[extra]]`) | 否 | 否 | 否 |
| SBOM 输出 | CycloneDX 1.6 + SPDX 2.3(含文本) | 否 | 否 | 否 |
| 声明与实际发布审计 | 是 (`--audit`) | 不适用(直接提取文件) | 否 | 否 |
| 用于 CI 的时效性 `--check` | 是 | 否 | 不适用 | 否 |
| 设置 | 零配置(可选 `tribute.toml`) | 模板 + `about.toml` | `deny.toml` | 仅支持 flags |
需要广泛的供应链 linter(安全公告、来源封禁、重复检测)吗?请使用 `cargo-deny`。`cargo-tribute` 始终专注于生成和对归属输出进行门控。
## 配置
项目根目录下的 `tribute.toml` 可覆盖默认设置(所有字段均为可选):
```
accepted = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "0BSD", "Zlib", "Unlicense", "Unicode-3.0"]
include-dev = false # also attribute dev-dependencies
include-build = false # also attribute build-dependencies
skip-private = false # skip path/git/non-crates.io dependencies (first-party;
# their crates.io deps are still walked and attributed)
skip-proc-macros = false # skip proc-macro crates and their compile-time subtree
skip = [] # crate names not to attribute -- first-party code that is
# published to crates.io, so `skip-private` cannot see it.
# a trailing `*` matches a prefix ("mycorp-*"); the skipped
# crates' own dependencies are still walked
features = [] # forwarded to `cargo metadata`, so a run and the --check
all-features = false # that gates it resolve the same tree without repeating
no-default-features = false # the flags on both command lines
filter-platform = []
manifest = "THIRD-PARTY.md" # attribution manifest path
licenses-dir = "LICENSES" # folder for the canonical license texts
notices-dir = "NOTICES" # folder for NOTICE files shipped by dependencies
layout = "folders" # what a run writes and --check gates: folders (default,
# the three outputs above), flat (one all-in-one
# THIRD-PARTY-NOTICES file), or both
flat-file = "THIRD-PARTY-NOTICES" # the flat file's path (layout flat/both)
# 覆盖 crate 的 license —— 适用于声明了 `license-file` 而非
# `license` 的 crate,或者其 `license` 字段错误或非 SPDX。可重复使用。
[[clarify]]
name = "ring"
version = "0.17.8" # optional semver req (like Cargo); omit to match any version
expression = "MIT AND ISC AND OpenSSL"
# 仅为一个 crate 允许额外的 licenses,而无需扩大全局接受范围
# 集合。可重复使用;`version` 可选,类似于 [[clarify]]。
[[exception]]
name = "unicode-ident"
allow = ["Unicode-DFS-2016"]
# 将 crate graph 无法看到的第三方代码归属在内 —— vendored 在
# a -sys crate 中的 C 源码、bundled 的 font。相同的接受策略;url/copyright 可选。
[[extra]]
name = "zlib (bundled in libz-sys)"
expression = "Zlib"
url = "https://zlib.net"
copyright = "Copyright (C) 1995-2024 Jean-loup Gailly and Mark Adler"
notes = """
Vendored under third_party/zlib; local patches: none.
""" # free text, reproduced in the notices file
# SPDX 语料库之外的 license 的本地文本,在
# `accepted`、[[clarify]] 或 [[extra]] 中命名为 LicenseRef-;复制到 licenses 文件夹中。
[[license-text]]
id = "LicenseRef-weird"
file = "licenses-extra/weird.txt"
```
## 许可证是如何被选中的
每个 crate 的 SPDX 表达式会根据 `accepted` 列表(这也是 OR 的优先顺序)进行求值:对于 `A OR B`,它会选择优先接受的许可证;对于 `A AND B`,它会保留两者。优先顺序有一个例外:当 `OR` 的一边是另一边的子集时,子集胜出,因为它的合规要求更为宽松(`(MIT AND Apache-2.0) OR Apache-2.0` 仅视为 Apache-2.0)。系统接受旧式的以 `/` 分隔的表达式(`MIT/Apache-2.0`),并且 or-later 的 `+` 后缀会被视为其基础许可证(`GPL-2.0+` 匹配已接受的 `GPL-2.0`,并归属到该文本)。如果某个 crate 的表达式无法在已接受的集合中得到满足,则视为严重的错误。
`accepted` 条目也可以是一个组合,例如 `"GPL-2.0-only WITH Classpath-exception-2.0"`,这允许在不接受基础裸许可证的情况下仅接受该确切组合。`[[exception]]` 条目允许仅针对某个具名 crate 使用额外的许可证;但在 OR 优先级上,它们会低于全局接受的许可证。当显式设置 `accepted` 时,如果某个条目没有被任何依赖项的表达式引用,系统会发出警告,从而使过期的允许列表保持可见。
crate 图无法看到的代码——例如 vendored 在 `-sys` crate 中的 C 源码、捆绑的字体——可以通过 `[[extra]]` 条目进行归属:其表达式会流经相同的接受策略,并且其许可证会加入 `LICENSES/`、`THIRD-PARTY.md` 和 `--json` 报告中。对于不属于 SPDX 语料库的许可证,可以通过 `LicenseRef-` 表达式以及指向本地文本文件的 `[[license-text]]` 条目进行命名,该文本文件会像标准文本一样被复制到 licenses 文件夹中(并且会被清理及进行 `--check` 检查)。
## 其他输出格式
`--format json|text|cyclonedx|spdx` 会将解析出的归属信息打印到 stdout,而不是写入文件。`text` 是一份扁平化的、自包含的 THIRD-PARTY-NOTICES 文档,其结构类似于大型 Rust 产品发布时的格式:第一部分(part I)是每个包的条目(包含源码 URL、在上游完整表达式旁选定的许可证、版权所有者,以及原位复制的 crate NOTICE);第二部分(part II)存放所有引用到的许可证文本(各一次)。`[[extra]]` 条目会被放入“第一部分(续)”章节,其自由文本格式的 `notes` 将置于“附加要求/声明”之下。要提交该文档并像清单一样使用 `--check` 进行门控,请在 tribute.toml 中设置 `layout = "flat"`(该文档成为唯一输出)或 `layout = "both"`(与文件夹一起写入)。切换 layout 不会删除之前 layout 写入的内容——请自行移除旧的生成文件。`cyclonedx` 是一个 CycloneDX 1.6 SBOM,其组件携带完整的许可证文本和每个组件的版权信息,这些正是仅生成 ID 的 SBOM 生成器所留空的字段;故意省略了 `serialNumber` 和 `timestamp`,以确保输出保持确定性(相同的树,相同的字节)。`spdx` 是一个 SPDX 2.3 JSON SBOM:`licenseConcluded` 是接受策略所选定的许可证,`licenseDeclared` 是以规范拼写方式重建的 crate 自身表达式(旧式的 `MIT/Apache-2.0` 可能会无法通过校验器),并且每个 `LicenseRef-*` 都在 `hasExtractedLicensingInfos` 中携带其完整文本——这是 SPDX 接收许可证正文的唯一位置,因为列出的 ID 已隐含其自身。`documentNamespace` 派生自包名而非新生成的 uuid,并且 `creationInfo.created` 遵循 `SOURCE_DATE_EPOCH`,因此需要字节完全相同的 SBOM 的构建可以固定该值。即使许可证策略失败,`json`、`cyclonedx` 和 `spdx` 也会报告依赖树(失败会转为 stderr 警告,并且该 crate 出现时不会包含已解析的许可证);`text` 作为交付物,其门控行为与写入路径保持一致。`json` 报告包含 `schema` 版本号和工具版本,以便消费者可以拒绝其未知的结构。
## 审计声明的许可证
crates.io 的许可证元数据偶尔会出错——例如某个 crate 声明了 `BSD-2-Clause`,却附带了额外的许可证文件。`cargo tribute --audit` 会扫描每个依赖项捆绑的许可证文件,将其与 SPDX 语料库进行匹配,并报告其最佳匹配项未被该 crate 声明的表达式所覆盖的文件。这仅供参考:发现的问题不会导致运行失败;并且当声明的许可证匹配程度相当时,不会报告语料库中几乎相同的文本(如 Apache-2.0 与 Pixar)。
它还会报告无法从中提取任何版权声明的 crate——要么是它们没有提供许可证文件,要么是它们提供的文件中没有 `Copyright` 行(例如 `winnow` 提供了 MIT 文本但缺少其头部)。MIT 和 BSD 要求保留版权声明本身,因此这是一个真实的缺失:对于声明了 `authors` 的 crate,会标注原因;其余的 crate 则会被计数(归属信息回退到 `authors`,但这并不能代替声明)。
`--audit` 位于需选择启用的 `audit` cargo feature 之后(它会引入文本检测相关的依赖项)。预编译的 release 二进制文件包含了此功能;通过源码安装则需要使用 `cargo install cargo-tribute --features audit`。同样的文本检测功能也使另一条消息更加精确:在启用了该 feature 的构建中,如果 crate 的 `license` 字段缺失,错误信息中会指出其附带的文件所匹配到的 SPDX id,并给出可直接粘贴的 `[[clarify]]` 条目。
## 复用 cargo-deny 允许列表
已经使用 cargo-deny 进行许可证门控的团队会将允许列表保留在 `deny.toml` 中;在 `tribute.toml` 中重复定义容易引发不一致。`cargo tribute --from-deny deny.toml` 会将 `[licenses].allow` 作为接受列表(包括 WITH 组合),并将 `[licenses].exceptions` 映射为针对特定 crate 的 `[[exception]]` 条目。如果在 tribute.toml 中同时设置了 `accepted` 则会报错——请保持唯一的单一数据源。
默认情况下,仅对正常(运行时)依赖进行归属——设置 `include-dev`/`include-build` 可对开发依赖和构建依赖也进行归属(和门控)。另一方面,`skip-private` 会跳过 path/git/非 crates.io 依赖(即第一方代码;但它们在 crates.io 上的依赖仍会被遍历),`skip-proc-macros` 会跳过 proc-macro crate 及其编译期子树,而 `skip` 会按名称跳过特定的 crate——专门针对那些*确实*发布到了 crates.io 的第一方代码,这是 `skip-private` 无法识别的情况。`include-dev`、`include-build`、`skip-private` 和 `skip-proc-macros` 各项都有同名的命令行 flag,方便在不修改配置的情况下针对单次运行开启。默认情况下,`cargo metadata` 会解析默认的 feature 集合,因此除非你通过 `--features`/`--all-features` 开启,否则可选的(由 feature 控制的)依赖项不会被归属。标准许可证文本(以及 `WITH` 例外文本)均来自 [`spdx`](https://crates.io/crates/spdx) crate,因此涵盖了所有的 SPDX 许可证和例外情况,无需手动维护任何文本。这些是 [SPDX 许可证列表](https://github.com/spdx/license-list-data)的纯文本呈现:其排版与某些上游原始版本有所不同(例如 Apache-2.0 的居中标题),但根据 [SPDX 匹配准则](https://spdx.github.io/spdx-spec/v2.3/license-matching-guidelines-and-templates/),排版格式与许可证身份无关。
如果某个 crate 没有 `license` 字段(而是声明了 `license-file`),或者字段错误、非 SPDX 格式,在你通过 `[[clarify]]` 条目为其提供 SPDX 表达式之前,这都将被视为一个严重的错误;经过 clarify 的表达式随后流经相同的已接受集策略。错误信息会标明该 crate 实际附带的许可证文件名称,因此无需打开 crate 源码即可编写该条目。
## 版权声明行和 NOTICE 文件
仅凭标准的许可证文本并不构成完整的归属信息:MIT/BSD 系列要求保留版权声明本身,而 Apache-2.0 第 4(d) 条要求再分发者传递 NOTICE 文件。因此,系统会扫描每个依赖项的本地源码(即 cargo 构建时使用的相同文件——不会下载任何内容):
- 在 crate 捆绑的许可证/声明文件中找到的 `Copyright ...` 行,会显示在 `THIRD-PARTY.md` 中该 crate 旁边;如果某个 crate 未提供这些文件,则回退使用其 `authors` 元数据。
- `NOTICE` 文件会被打包进 `NOTICES/-.txt`,并从该 crate 的条目中进行链接。只有当依赖项确实提供了该文件时,该文件夹才会存在;并且像对待许可证文本一样,过期的文件也会被清理(并由 `--check` 标记)。
## 许可证
根据你的选择,受 [Apache License, Version 2.0](LICENSE-APACHE) 或 [MIT license](LICENSE-MIT) 许可。
标签:Cargo插件, Rust, SBOM, 可视化界面, 开源合规, 开源框架, 持续集成, 硬件无关, 网络流量审计, 许可证管理, 通知系统