Skip to content

Repository files navigation

cargo-tribute

crates.io ci

Generate a REUSE-style LICENSES/ folder and a per-crate attribution manifest from a Cargo dependency tree, instead of hand-maintaining third-party license notices.

cargo tribute walks the normal-dependency closure of your workspace, resolves each crate's SPDX license expression against an accepted list, and writes:

  • LICENSES/<id>.txt -- one canonical license text per license actually used, the workspace's own included (REUSE's folder covers every license in the project, not only the third-party ones)
  • NOTICES/<crate>-<version>.txt -- NOTICE files shipped by dependencies (the ones Apache-2.0 section 4(d) asks redistributors to pass along), only when a dependency actually ships one
  • THIRD-PARTY.md -- dependencies grouped by license with each crate's copyright holders, linking to the texts
  • THIRD-PARTY-NOTICES -- with layout = "flat" (that file alone) or "both", one flat all-in-one notices document with per-package entries and every license text inline

It is a policy gate (fails if a dependency's license is not accepted) and, with --check, a staleness gate (fails if the committed output no longer matches the dependency tree) -- both suitable for CI.

cargo tribute writing the attribution files, then --check catching a stale one

Install

cargo install cargo-tribute

Needs Rust 1.91 or newer (set by the dependencies, not by anything in this crate).

Or, for a prebuilt binary via cargo-binstall:

cargo binstall cargo-tribute

Usage

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, ...

Exit codes distinguish the failure: 1 license policy failed, 2 output out of date (--check), 3 anything else.

Use in CI

Fail the build when a dependency's license is not accepted, or when the committed outputs (whatever the layout writes) drift from the dependency tree:

# .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 compares against the tree it resolves, so it has to resolve the same one the committed output was written from. If that needed --all-features or a --filter-platform, put it in tribute.toml (all-features, filter-platform, ...) rather than on both command lines -- otherwise the check fails on a difference that is only in the flags.

Compared to other tools

All of these are good tools; this is where cargo-tribute differs (behavior as of writing -- check each project's latest docs).

cargo-tribute cargo-about cargo-deny cargo-license
Attribution output THIRD-PARTY.md + REUSE LICENSES/ folder one file from a template none (license linter) lists to stdout
Copyright lines + NOTICE files yes no no authors only
Accepted-license gate yes yes (config) yes (its focus) no
Per-crate exceptions yes ([[exception]]) per-crate accepted yes (exceptions) no
Vendored non-crate code yes ([[extra]]) no no no
SBOM output CycloneDX 1.6 + SPDX 2.3 with texts no no no
Declared-vs-shipped audit yes (--audit) n/a (harvests files) no no
Staleness --check for CI yes no n/a no
Setup zero-config (optional tribute.toml) template + about.toml deny.toml flags only

Want a broad supply-chain linter (advisories, source bans, duplicate detection)? Reach for cargo-deny. cargo-tribute stays focused on generating and gating the attribution output.

Configuration

A tribute.toml in the project root overrides the defaults (all fields optional):

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)

# override a crate's license -- for crates that declare `license-file` instead of
# `license`, or whose `license` field is wrong or non-SPDX. Repeatable.
[[clarify]]
name = "ring"
version = "0.17.8"            # optional semver req (like Cargo); omit to match any version
expression = "MIT AND ISC AND OpenSSL"

# allow extra licenses for one crate only, without widening the global accepted
# set. Repeatable; `version` optional, like [[clarify]].
[[exception]]
name = "unicode-ident"
allow = ["Unicode-DFS-2016"]

# attribute third-party code the crate graph can't see -- C sources vendored in
# a -sys crate, a bundled font. Same accepted policy; url/copyright optional.
[[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

# local text for a license outside the SPDX corpus, named as LicenseRef-<id> in
# `accepted`, a [[clarify]], or an [[extra]]; copied into the licenses folder.
[[license-text]]
id = "LicenseRef-weird"
file = "licenses-extra/weird.txt"

How a license is chosen

Each crate's SPDX expression is evaluated against accepted (which is also the OR preference order): for A OR B it picks the preferred accepted license, for A AND B it keeps both. One exception to the preference order: when one side of an OR is a subset of the other, the subset wins, since it is strictly less to comply with ((MIT AND Apache-2.0) OR Apache-2.0 is Apache-2.0 alone). Legacy /-separated expressions (MIT/Apache-2.0) are accepted, and an or-later + suffix is treated as its base license (GPL-2.0+ matches an accepted GPL-2.0, and attributes that text). A crate whose expression cannot be satisfied from the accepted set is a hard error.

An accepted entry can also be a pairing like "GPL-2.0-only WITH Classpath-exception-2.0", which allows exactly that combination without accepting the bare license. A [[exception]] entry allows extra licenses for one named crate only; they lose the OR preference to globally accepted ones. When accepted is set explicitly, an entry that no dependency's expression references is warned about, so a stale allowlist stays visible.

Code the crate graph can't see -- C sources vendored in a -sys crate, a bundled font -- can be attributed with an [[extra]] entry: its expression flows through the same accepted policy, and its licenses join LICENSES/, THIRD-PARTY.md, and the --json report. A license outside the SPDX corpus is named with a LicenseRef-<id> expression plus a [[license-text]] entry pointing at a local text file, which is copied into the licenses folder (and cleaned up, and --checked) like a canonical text.

Other output formats

--format json|text|cyclonedx|spdx prints the resolved attribution to stdout instead of writing files. text is one flat, self-contained THIRD-PARTY-NOTICES document in the shape big Rust products ship: part I is one entry per package (source URL, the chosen license beside the full upstream expression, copyright holders, and the crate's NOTICE reproduced in place), part II holds every referenced license text once. [[extra]] entries land in a "part I (continued)" section, with their free-text notes under "Additional requirements / notices". To commit the document and have --check gate it like the manifest, set layout = "flat" in tribute.toml (the document becomes the only output) or layout = "both" (written alongside the folders). Switching layout does not delete what the previous layout wrote -- remove the old artifacts yourself. cyclonedx is a CycloneDX 1.6 SBOM whose components carry the full license texts and a per-component copyright, the fields id-only SBOM generators leave empty; serialNumber and timestamp are deliberately omitted so the output stays deterministic (same tree, same bytes). spdx is an SPDX 2.3 JSON SBOM: licenseConcluded is what the accepted policy picked, licenseDeclared the crate's own expression rebuilt in canonical spelling (a legacy MIT/Apache-2.0 would fail a validator), and every LicenseRef-* carries its full text in hasExtractedLicensingInfos -- the one place SPDX takes a license body, since a listed id implies its own. documentNamespace is derived from the package name rather than a fresh uuid, and creationInfo.created honours SOURCE_DATE_EPOCH, so a build that wants a byte-identical SBOM can pin it. json, cyclonedx, and spdx report the tree even when the license policy fails (the failures become stderr warnings and the crate appears without resolved licenses); text is an attribution deliverable and stays gated, like the write path. The json report carries a schema number and the tool version, so a consumer can refuse a shape it does not know.

Auditing declared licenses

crates.io license metadata is occasionally wrong -- a crate declares BSD-2-Clause but ships an extra license file. cargo tribute --audit scans each dependency's bundled license files, matches them against the SPDX corpus, and reports files whose best match is not covered by the crate's declared expression. It is advisory only: findings do not fail the run, and near-identical corpus texts (Apache-2.0 vs Pixar) are not reported when the declared license matches about as well.

It also reports crates it could harvest no copyright notice from -- either they ship no license file, or the one they ship has no Copyright line (winnow ships an MIT text without its header). MIT and BSD ask for that notice itself, so this is a real gap: the crates that declare no authors either are named with the cause, and the rest are counted (the attribution falls back to authors, which is not a notice).

--audit is behind the opt-in audit cargo feature (it pulls in text-detection dependencies). The prebuilt release binaries ship with it; a source install needs cargo install cargo-tribute --features audit. The same text detection sharpens one other message: in a build with the feature, a crate whose license field is missing has the SPDX id its shipped file matches named in the error, together with the [[clarify]] entry to paste.

Reusing a cargo-deny allowlist

Teams already gating licenses with cargo-deny keep the allowlist in deny.toml; duplicating it in tribute.toml invites drift. cargo tribute --from-deny deny.toml takes [licenses].allow as the accepted list (WITH pairings included) and maps [licenses].exceptions onto per-crate [[exception]] entries. Setting accepted in tribute.toml at the same time is an error -- keep one source.

Only normal (runtime) dependencies are attributed by default -- set include-dev/include-build to attribute (and gate) dev- and build-dependencies too. In the other direction, skip-private drops path/git/non-crates.io dependencies (first-party code; their crates.io deps are still walked), skip-proc-macros drops proc-macro crates together with their compile-time subtree, and skip drops crates by name -- for first-party code that is published to crates.io, which skip-private cannot recognize. Each of include-dev, include-build, skip-private, and skip-proc-macros also has a command-line flag of the same name, to turn it on for one run without editing the config. By default cargo metadata resolves the default feature set, so optional (feature-gated) dependencies are not attributed unless you enable them with --features/--all-features. Canonical license texts (and WITH exception texts) come from the spdx crate, so every SPDX license and exception is covered with no texts to hand-maintain. These are the SPDX License List's plain-text renderings: the layout differs from some upstream originals (Apache-2.0's centered header, for one), but formatting is irrelevant to license identity under the SPDX matching guidelines.

A crate with no license field (it declares license-file instead), or a wrong or non-SPDX one, is a hard error until you give it an SPDX expression with a [[clarify]] entry; the clarified expression then flows through the same accepted-set policy. The error names the license file the crate does ship, so the entry can be written without opening the crate source.

Copyright lines and NOTICE files

A canonical license text alone is not complete attribution: the MIT/BSD family asks for the copyright notice itself to be reproduced, and Apache-2.0 section 4(d) asks redistributors to pass NOTICE files along. So each dependency's local sources (the same files cargo builds from -- nothing is downloaded) are scanned:

  • Copyright ... lines found in the crate's bundled license/notice files appear beside the crate in THIRD-PARTY.md; a crate that ships none falls back to its authors metadata.
  • A NOTICE file is bundled into NOTICES/<crate>-<version>.txt and linked from the crate's entry. The folder only exists while a dependency actually ships one, and stale files are cleaned up (and flagged by --check) like license texts.

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.

About

Zero-config third-party license attribution for Cargo: LICENSES/ folder, THIRD-PARTY notices, CycloneDX SBOM, CI gate

Topics

Resources

Contributing

Stars

5 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages