Repository: EmbarkStudios/cargo-deny (language: rust)

Issue:
Add justification/comment fields for [bans] to be shown on warning/error
**Is your feature request related to a problem? Please describe.**
When denying usage of a crate, it is often useful to give a justification for the ban (and how to workaround it or redirect usage to more suitable crates).

**Describe the solution you'd like**
Add a `comment` or `justification` field to item entries under `ban.deny`. Print this as a part of a warning/error.

**Describe alternatives you've considered**
You could add it as a comment, but it's better if it's in the tool.

**Additional context**
- Useful for denying access to `anyhow` or `eyre` in-general, and redirecting users to use a `thiserror`-based crate.



Discussion / hints:
agree this would be good to have, we've talked about it a long time ago about having a `reason` field on all bans. we really should implement it wouldn't be hard.

all of our bans do have a comment today so would be a way to encode that and as you say give a clear reason/justification/recommendation directly on failures.

```
    # denied crates
    { name = "openssl" }, # we use rustls instead
    { name = "openssl-sys" }, # we use rustls instead
    { name = "RustyXml" }, # we don't want to use any XML and some of these are 4 year old dependencies
    { name = "serde-xml-rs" }, # we don't want to use any XML and some of these are 4 year old dependencies
    { name = "color-backtrace" }, # color-backtrace is nice but brings in too many dependencies and that are often outdated, so not worth it for us.
    { name = "typetag" }, # disallow these crates that rely on static initialization order which we've had issues with
    { name = "inventory" }, # disallow these crates that rely on static initialization order which we've had issues with
    { name = "ctor" }, # disallow these crates that rely on static initialization order which we've had issues with
    { name = "bzip2" }, # disallow C dependency, we just Rust native versions instead
    { name = "smart-default", wrappers = [
        "minidump-common",
    ] }, # smart-default should not be used
    { name = "actix-web" }, # repeatedly unsound, too many dependencies, and not needed for our use cases
    { name = "bzip2-sys" }, # disallow C dependency, we just Rust native versions instead
    { name = "nfd" }, # unmaintined, we use `rfd` instead
    { name = "nfd2" }, # we use `rfd` instead
    { name = "msgbox" }, # we use `rfd` instead
    { name = "backtrace-sys" }, # disallow C dependency, use gimli Rust crate instead
    { name = "keyring" }, # too many and too old dependencies
    { name = "secret-service" }, # too many and too old dependencies
    { name = "wasmtime-cache" }, # we do our own manual caching
```

Fix patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 339778536..790d0943d 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -16,7 +16,7 @@ jobs:
     name: Lint
     runs-on: ubuntu-22.04
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - uses: dtolnay/rust-toolchain@stable
         with:
           components: "clippy, rustfmt"
@@ -35,17 +35,10 @@ jobs:
     name: Test
     strategy:
       matrix:
-        os: [ubuntu-22.04]
+        os: [ubuntu-22.04, macos-14]
     runs-on: ${{ matrix.os }}
     steps:
-      # We need to disable conversion to CRLF line endings on windows because it's
-      # dumb and causes advisory tests to fails since the submodule is checked
-      # out with the broken line endings
-      - if: matrix.os == 'windows-2022'
-        run: |
-          git config --global core.autocrlf false
-          git config --global core.eol lf
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
         with:
           submodules: true
       - uses: dtolnay/rust-toolchain@stable
@@ -55,22 +48,6 @@ jobs:
         run: cargo build --tests
       - run: cargo test
 
-  # Verifies we can build aarch64-apple-darwin binaries until GHA actually has
-  # runners for them that we can actually run tests on
-  build-aarch64-apple-darwin:
-    name: Build aarch64-apple-darwin
-    runs-on: macos-11
-    # Only run this PRs
-    if: false #github.ref != 'refs/heads/main'
-    steps:
-      - uses: actions/checkout@v3
-      - uses: dtolnay/rust-toolchain@stable
-        with:
-          targets: aarch64-apple-darwin
-      - uses: Swatinem/rust-cache@v2
-      - run: cargo fetch --target aarch64-apple-darwin
-      - run: cargo build --release --target aarch64-apple-darwin
-
   self:
     name: Check Users
     if: false # disabled for now
@@ -83,7 +60,7 @@ jobs:
     env:
       TARGET: x86_64-unknown-linux-musl
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - uses: dtolnay/rust-toolchain@stable
         with:
           target: ${{ matrix.target }}
@@ -111,7 +88,7 @@ jobs:
     name: Build the book
     runs-on: ubuntu-22.04
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - env:
           version: "0.4.32"
         run: |
@@ -128,7 +105,7 @@ jobs:
     name: Publish Check
     runs-on: ubuntu-22.04
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - uses: dtolnay/rust-toolchain@stable
       - uses: Swatinem/rust-cache@v2
       - run: cargo fetch
@@ -160,7 +137,7 @@ jobs:
             bin: cargo-deny
     runs-on: ${{ matrix.os }}
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - name: Install stable toolchain
         uses: dtolnay/rust-toolchain@master
         with:
@@ -215,7 +192,7 @@ jobs:
     runs-on: ubuntu-22.04
     if: github.event_name == 'push' && github.ref == 'refs/heads/main'
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - name: Download book
         uses: actions/download-artifact@v1
         with:
diff --git a/Cargo.lock b/Cargo.lock
index 3a3871301..d9a1259ae 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -42,9 +42,9 @@ dependencies = [
 
 [[package]]
 name = "anstyle"
-version = "1.0.4"
+version = "1.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87"
+checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc"
 
 [[package]]
 name = "anstyle-parse"
@@ -200,9 +200,9 @@ dependencies = [
 
 [[package]]
 name = "bumpalo"
-version = "3.14.0"
+version = "3.15.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec"
+checksum = "d32a994c2b3ca201d9b263612a374263f05e7adde37c4707f693dcd375076d1f"
 
 [[package]]
 name = "byteorder"
@@ -261,7 +261,7 @@ dependencies = [
  "tame-index",
  "tempfile",
  "time",
- "toml 0.8.8",
+ "toml-span",
  "twox-hash",
  "url",
  "walkdir",
@@ -275,15 +275,15 @@ checksum = "e11c675378efb449ed3ce8de78d75d0d80542fc98487c26aba28eb3b82feac72"
 dependencies = [
  "semver",
  "serde",
- "toml 0.7.8",
+ "toml",
  "url",
 ]
 
 [[package]]
 name = "cargo-platform"
-version = "0.1.6"
+version = "0.1.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ceed8ef69d8518a5dda55c07425450b58a4e1946f4951eab6d7191ee86c2443d"
+checksum = "694c8807f2ae16faecc43dc17d74b3eb042482789fd0eb64b39a2e04e087053f"
 dependencies = [
  "serde",
 ]
@@ -314,9 +314,9 @@ dependencies = [
 
 [[package]]
 name = "cfg-expr"
-version = "0.15.6"
+version = "0.15.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6100bc57b6209840798d95cb2775684849d332f7bd788db2a8c8caf7ef82a41a"
+checksum = "fa50868b64a9a6fda9d593ce778849ea8715cd2a3d2cc17ffdb4a2f2f2f1961d"
 dependencies = [
  "smallvec",
  "target-lexicon",
@@ -330,9 +330,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
 [[package]]
 name = "clap"
-version = "4.4.18"
+version = "4.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e578d6ec4194633722ccf9544794b71b1385c3c027efe0c55db226fc880865c"
+checksum = "80c21025abd42669a92efc996ef13cfb2c5c627858421ea58d5c3b331a6c134f"
 dependencies = [
  "clap_builder",
  "clap_derive",
@@ -340,9 +340,9 @@ dependencies = [
 
 [[package]]
 name = "clap_builder"
-version = "4.4.18"
+version = "4.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4df4df40ec50c46000231c914968278b1eb05098cf8f1b3a518a95030e71d1c7"
+checksum = "458bf1f341769dfcf849846f65dffdf9146daa56bcd2a47cb4e1de9915567c99"
 dependencies = [
  "anstream",
  "anstyle",
@@ -352,21 +352,21 @@ dependencies = [
 
 [[package]]
 name = "clap_derive"
-version = "4.4.7"
+version = "4.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442"
+checksum = "307bc0538d5f0f83b8248db3087aa92fe504e4691294d0c96c0eabc33f47ba47"
 dependencies = [
  "heck",
  "proc-macro2",
  "quote",
- "syn 2.0.48",
+ "syn",
 ]
 
 [[package]]
 name = "clap_lex"
-version = "0.6.0"
+version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1"
+checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce"
 
 [[package]]
 name = "clru"
@@ -381,6 +381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3362992a0d9f1dd7c3d0e89e0ab2bb540b7a95fea8cd798090e758fda2899b5e"
 dependencies = [
  "codespan-reporting",
+ "serde",
 ]
 
 [[package]]
@@ -389,6 +390,7 @@ version = "0.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
 dependencies = [
+ "serde",
  "termcolor",
  "unicode-width",
 ]
@@ -438,9 +440,9 @@ dependencies = [
 
 [[package]]
 name = "crc32fast"
-version = "1.3.2"
+version = "1.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d"
+checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa"
 dependencies = [
  "cfg-if",
 ]
@@ -527,6 +529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
 dependencies = [
  "powerfmt",
+ "serde",
 ]
 
 [[package]]
@@ -547,9 +550,9 @@ checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b"
 
 [[package]]
 name = "either"
-version = "1.9.0"
+version = "1.10.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
+checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a"
 
 [[package]]
 name = "encode_unicode"
@@ -852,9 +855,9 @@ dependencies = [
 
 [[package]]
 name = "gix-command"
-version = "0.3.3"
+version = "0.3.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce1ffc7db3fb50b7dae6ecd937a3527cb725f444614df2ad8988d81806f13f09"
+checksum = "c82b5e9494e61983e61049bbd15fe0fa6b70672dd236362bdb5b2b50fc428f10"
 dependencies = [
  "bstr",
  "gix-path",
@@ -1091,9 +1094,9 @@ dependencies = [
 
 [[package]]
 name = "gix-lock"
-version = "13.0.0"
+version = "13.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "651e46174dc5e7d18b7b809d31937b6de3681b1debd78618c99162cc30fcf3e1"
+checksum = "886490a07b1d6433aa91262a99d430a91cc8b9a1f758ac1282e132f1098a9342"
 dependencies = [
  "gix-tempfile",
  "gix-utils",
@@ -1108,7 +1111,7 @@ checksum = "d75e7ab728059f595f6ddc1ad8771b8d6a231971ae493d9d5948ecad366ee8bb"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.48",
+ "syn",
 ]
 
 [[package]]
@@ -1212,9 +1215,9 @@ dependencies = [
 
 [[package]]
 name = "gix-path"
-version = "0.10.4"
+version = "0.10.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "14a6282621aed1becc3f83d64099a564b3b9063f22783d9a87ea502a3e9f2e40"
+checksum = "97e9ad649bf5e109562d6acba657ca428661ec08e77eaf3a755d8fa55485be9c"
 dependencies = [
  "bstr",
  "gix-trace",
@@ -1376,9 +1379,9 @@ dependencies = [
 
 [[package]]
 name = "gix-tempfile"
-version = "13.0.0"
+version = "13.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d337955b7af00fb87120d053d87cdfb422a80b9ff7a3aa4057a99c79422dc30"
+checksum = "439f4da9657aec7434cde3c2510dcdb0a35f2031233ff67ae986269c788966d7"
 dependencies = [
  "gix-fs",
  "libc",
@@ -1559,9 +1562,9 @@ checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
 
 [[package]]
 name = "hermit-abi"
-version = "0.3.4"
+version = "0.3.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d3d0e0f38255e7fa3cf31335b3a56f05febd18025f4db5ef7a0cfb4f8da651f"
+checksum = "bd5256b483761cd23699d0da46cc6fd2ee3be420bbe6d020ae4a091e70b7e9fd"
 
 [[package]]
 name = "home"
@@ -1656,9 +1659,9 @@ dependencies = [
 
 [[package]]
 name = "indexmap"
-version = "2.1.0"
+version = "2.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f"
+checksum = "233cf39063f058ea2caae4091bf4a3ef70a653afbc026f5c4a4135d114e3c177"
 dependencies = [
  "equivalent",
  "hashbrown",
@@ -1702,18 +1705,18 @@ checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c"
 
 [[package]]
 name = "jobserver"
-version = "0.1.27"
+version = "0.1.28"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d"
+checksum = "ab46a6e9526ddef3ae7f787c06f0f2600639ba80ea3eade3d8e670a2230f51d6"
 dependencies = [
  "libc",
 ]
 
 [[package]]
 name = "js-sys"
-version = "0.3.67"
+version = "0.3.68"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a1d36f1235bc969acba30b7f5990b864423a6068a10f7c90ae8f0112e3a59d1"
+checksum = "406cda4b368d531c842222cf9d2600a9a4acce8d29423695379c6868a143a9ee"
 dependencies = [
  "wasm-bindgen",
 ]
@@ -1747,9 +1750,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
 
 [[package]]
 name = "libc"
-version = "0.2.152"
+version = "0.2.153"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7"
+checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
 
 [[package]]
 name = "linked-hash-map"
@@ -1781,13 +1784,13 @@ checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
 
 [[package]]
 name = "maybe-async"
-version = "0.2.7"
+version = "0.2.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f1b8c13cb1f814b634a96b2c725449fe7ed464a7b8781de8688be5ffbd3f305"
+checksum = "afc95a651c82daf7004c824405aa1019723644950d488571bd718e3ed84646ed"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 1.0.109",
+ "syn",
 ]
 
 [[package]]
@@ -1798,9 +1801,9 @@ checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149"
 
 [[package]]
 name = "memmap2"
-version = "0.9.3"
+version = "0.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "45fd3a57831bf88bc63f8cebc0cf956116276e97fef3966103e96416209f7c92"
+checksum = "fe751422e4a8caa417e13c3ea66452215d7d63e19e604f4980461212f3ae1322"
 dependencies = [
  "libc",
 ]
@@ -1813,9 +1816,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
 
 [[package]]
 name = "miniz_oxide"
-version = "0.7.1"
+version = "0.7.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7"
+checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7"
 dependencies = [
  "adler",
 ]
@@ -1840,11 +1843,17 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "num-conv"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+
 [[package]]
 name = "num-traits"
-version = "0.2.17"
+version = "0.2.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c"
+checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a"
 dependencies = [
  "autocfg",
 ]
@@ -1861,9 +1870,9 @@ dependencies = [
 
 [[package]]
 name = "num_threads"
-version = "0.1.6"
+version = "0.1.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44"
+checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
 dependencies = [
  "libc",
 ]
@@ -1948,9 +1957,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
 
 [[package]]
 name = "pkg-config"
-version = "0.3.29"
+version = "0.3.30"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2900ede94e305130c13ddd391e0ab7cbaeb783945ae07a279c268cb05109c6cb"
+checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
 
 [[package]]
 name = "plain"
@@ -2046,9 +2055,9 @@ dependencies = [
 
 [[package]]
 name = "regex-automata"
-version = "0.4.4"
+version = "0.4.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b7fa1134405e2ec9353fd416b17f8dacd46c473d7d3fd1cf202706a14eb792a"
+checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd"
 dependencies = [
  "aho-corasick",
  "memchr",
@@ -2063,9 +2072,9 @@ checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
 
 [[package]]
 name = "reqwest"
-version = "0.11.23"
+version = "0.11.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37b1ae8d9ac08420c66222fb9096fc5de435c3c48542bc5336c51892cffafb41"
+checksum = "c6920094eb85afde5e4a138be3f2de8bbdf28000f0029e72c45025a56b042251"
 dependencies = [
  "async-compression",
  "base64",
@@ -2091,6 +2100,7 @@ dependencies = [
  "serde",
  "serde_json",
  "serde_urlencoded",
+ "sync_wrapper",
  "system-configuration",
  "tokio",
  "tokio-rustls",
@@ -2148,9 +2158,9 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
 
 [[package]]
 name = "rustix"
-version = "0.38.30"
+version = "0.38.31"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca"
+checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949"
 dependencies = [
  "bitflags 2.4.2",
  "errno",
@@ -2204,9 +2214,9 @@ dependencies = [
 
 [[package]]
 name = "rustsec"
-version = "0.28.4"
+version = "0.29.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c843c389d3d5175ab32d894b1b5c08570fdd319ee78ef29e27c70bfc1be79464"
+checksum = "229441b3fb74fa677ffd9f3fd4488ab4dcc980552cdf4d66d96ce25d745c294e"
 dependencies = [
  "cargo-lock",
  "cvss",
@@ -2215,7 +2225,7 @@ dependencies = [
  "semver",
  "serde",
  "thiserror",
- "toml 0.7.8",
+ "toml",
  "url",
 ]
 
@@ -2272,7 +2282,7 @@ checksum = "7f81c2fde025af7e69b1d1420531c8a8811ca898919db177141a85313b1cb932"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.48",
+ "syn",
 ]
 
 [[package]]
@@ -2319,29 +2329,29 @@ dependencies = [
 
 [[package]]
 name = "serde"
-version = "1.0.195"
+version = "1.0.196"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02"
+checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32"
 dependencies = [
  "serde_derive",
 ]
 
 [[package]]
 name = "serde_derive"
-version = "1.0.195"
+version = "1.0.196"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c"
+checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.48",
+ "syn",
 ]
 
 [[package]]
 name = "serde_json"
-version = "1.0.111"
+version = "1.0.113"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4"
+checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79"
 dependencies = [
  "itoa",
  "ryu",
@@ -2474,37 +2484,37 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
 
 [[package]]
 name = "strsim"
-version = "0.10.0"
+version = "0.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01"
 
 [[package]]
 name = "strum"
-version = "0.25.0"
+version = "0.26.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
+checksum = "723b93e8addf9aa965ebe2d11da6d7540fa2283fcea14b3371ff055f7ba13f5f"
 dependencies = [
  "strum_macros",
 ]
 
 [[package]]
 name = "strum_macros"
-version = "0.25.3"
+version = "0.26.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0"
+checksum = "7a3417fc93d76740d974a01654a09777cb500428cc874ca9f45edfe0c4d4cd18"
 dependencies = [
  "heck",
  "proc-macro2",
  "quote",
  "rustversion",
- "syn 2.0.48",
+ "syn",
 ]
 
 [[package]]
 name = "syn"
-version = "1.0.109"
+version = "2.0.49"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+checksum = "915aea9e586f80826ee59f8453c1101f9d1c4b3964cd2460185ee8e299ada496"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -2512,15 +2522,10 @@ dependencies = [
 ]
 
 [[package]]
-name = "syn"
-version = "2.0.48"
+name = "sync_wrapper"
+version = "0.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
+checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
 
 [[package]]
 name = "system-configuration"
@@ -2545,9 +2550,9 @@ dependencies = [
 
 [[package]]
 name = "tame-index"
-version = "0.9.2"
+version = "0.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "74c3e0e3420ed82a172b5be8120d7d051b6578b42e1efa9f51f9e9ff675012dc"
+checksum = "2d8d8b231dac2161abc5ac5abc07115fa29886f1defa6eae2bfcca40d6d61560"
 dependencies = [
  "bytes",
  "camino",
@@ -2566,7 +2571,7 @@ dependencies = [
  "smol_str",
  "thiserror",
  "tokio",
- "toml 0.8.8",
+ "toml-span",
  "twox-hash",
 ]
 
@@ -2584,13 +2589,12 @@ checksum = "69758bda2e78f098e4ccb393021a0963bb3442eac05f135c30f61b7370bbafae"
 
 [[package]]
 name = "tempfile"
-version = "3.9.0"
+version = "3.10.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa"
+checksum = "a365e8cd18e44762ef95d87f284f4b5cd04107fec2ff3052bd6a3e6069669e67"
 dependencies = [
  "cfg-if",
  "fastrand",
- "redox_syscall",
  "rustix",
  "windows-sys 0.52.0",
 ]
@@ -2606,33 +2610,34 @@ dependencies = [
 
 [[package]]
 name = "thiserror"
-version = "1.0.56"
+version = "1.0.57"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad"
+checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b"
 dependencies = [
  "thiserror-impl",
 ]
 
 [[package]]
 name = "thiserror-impl"
-version = "1.0.56"
+version = "1.0.57"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471"
+checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.48",
+ "syn",
 ]
 
 [[package]]
 name = "time"
-version = "0.3.31"
+version = "0.3.34"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e"
+checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749"
 dependencies = [
  "deranged",
  "itoa",
  "libc",
+ "num-conv",
  "num_threads",
  "powerfmt",
  "serde",
@@ -2648,10 +2653,11 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
 
 [[package]]
 name = "time-macros"
-version = "0.2.16"
+version = "0.2.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26197e33420244aeb70c3e8c78376ca46571bc4e701e4791c2cd9f57dcb3a43f"
+checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774"
 dependencies = [
+ "num-conv",
  "time-core",
 ]
 
@@ -2672,9 +2678,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
 
 [[package]]
 name = "tokio"
-version = "1.35.1"
+version = "1.36.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104"
+checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
 dependencies = [
  "backtrace",
  "bytes",
@@ -2719,19 +2725,18 @@ dependencies = [
  "serde",
  "serde_spanned",
  "toml_datetime",
- "toml_edit 0.19.15",
+ "toml_edit",
 ]
 
 [[package]]
-name = "toml"
-version = "0.8.8"
+name = "toml-span"
+version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1a195ec8c9da26928f773888e0742ca3ca1040c6cd859c919c9f59c1954ab35"
+checksum = "32c5896fa509a428e0ffcb1e37c7954f183a507784cd2999cb76c3b7544bf52d"
 dependencies = [
+ "codespan-reporting",
  "serde",
- "serde_spanned",
- "toml_datetime",
- "toml_edit 0.21.0",
+ "smallvec",
 ]
 
 [[package]]
@@ -2756,19 +2761,6 @@ dependencies = [
  "winnow",
 ]
 
-[[package]]
-name = "toml_edit"
-version = "0.21.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d34d383cd00a163b4a5b85053df514d45bc330f6de7737edfe0a93311d1eaa03"
-dependencies = [
- "indexmap",
- "serde",
- "serde_spanned",
- "toml_datetime",
- "winnow",
-]
-
 [[package]]
 name = "tower-service"
 version = "0.3.2"
@@ -2906,9 +2898,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
 
 [[package]]
 name = "wasm-bindgen"
-version = "0.2.90"
+version = "0.2.91"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1223296a201415c7fad14792dbefaace9bd52b62d33453ade1c5b5f07555406"
+checksum = "c1e124130aee3fb58c5bdd6b639a0509486b0338acaaae0c84a5124b0f588b7f"
 dependencies = [
  "cfg-if",
  "wasm-bindgen-macro",
@@ -2916,24 +2908,24 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-backend"
-version = "0.2.90"
+version = "0.2.91"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fcdc935b63408d58a32f8cc9738a0bffd8f05cc7c002086c6ef20b7312ad9dcd"
+checksum = "c9e7e1900c352b609c8488ad12639a311045f40a35491fb69ba8c12f758af70b"
 dependencies = [
  "bumpalo",
  "log",
  "once_cell",
  "proc-macro2",
  "quote",
- "syn 2.0.48",
+ "syn",
  "wasm-bindgen-shared",
 ]
 
 [[package]]
 name = "wasm-bindgen-futures"
-version = "0.4.40"
+version = "0.4.41"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bde2032aeb86bdfaecc8b261eef3cba735cc426c1f3a3416d1e0791be95fc461"
+checksum = "877b9c3f61ceea0e56331985743b13f3d25c406a7098d45180fb5f09bc19ed97"
 dependencies = [
  "cfg-if",
  "js-sys",
@@ -2943,9 +2935,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-macro"
-version = "0.2.90"
+version = "0.2.91"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3e4c238561b2d428924c49815533a8b9121c664599558a5d9ec51f8a1740a999"
+checksum = "b30af9e2d358182b5c7449424f017eba305ed32a7010509ede96cdc4696c46ed"
 dependencies = [
  "quote",
  "wasm-bindgen-macro-support",
@@ -2953,28 +2945,28 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-macro-support"
-version = "0.2.90"
+version = "0.2.91"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bae1abb6806dc1ad9e560ed242107c0f6c84335f1749dd4e8ddb012ebd5e25a7"
+checksum = "642f325be6301eb8107a83d12a8ac6c1e1c54345a7ef1a9261962dfefda09e66"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.48",
+ "syn",
  "wasm-bindgen-backend",
  "wasm-bindgen-shared",
 ]
 
 [[package]]
 name = "wasm-bindgen-shared"
-version = "0.2.90"
+version = "0.2.91"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4d91413b1c31d7539ba5ef2451af3f0b833a005eb27a631cec32bc0635a8602b"
+checksum = "4f186bd2dcf04330886ce82d6f33dd75a7bfcf69ecf5763b89fcde53b6ac9838"
 
 [[package]]
 name = "web-sys"
-version = "0.3.67"
+version = "0.3.68"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "58cd2333b6e0be7a39605f0e255892fd7418a682d8da8fe042fe25128794d2ed"
+checksum = "96565907687f7aceb35bc5fc03770a8a0471d82e479f25832f54a0e3f4b28446"
 dependencies = [
  "js-sys",
  "wasm-bindgen",
@@ -2982,9 +2974,9 @@ dependencies = [
 
 [[package]]
 name = "webpki-roots"
-version = "0.25.3"
+version = "0.25.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1778a42e8b3b90bff8d0f5032bf22250792889a5cdc752aa0020c84abe3aaf10"
+checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
 
 [[package]]
 name = "winapi"
@@ -3151,9 +3143,9 @@ checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04"
 
 [[package]]
 name = "winnow"
-version = "0.5.34"
+version = "0.5.40"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7cf47b659b318dccbd69cc4797a39ae128f533dce7902a1096044d1967b9c16"
+checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
 dependencies = [
  "memchr",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index c08da516b..8613f697f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -55,8 +55,8 @@ camino = "1.1"
 # Argument parsing, kept aligned with cargo
 clap = { version = "4.3", features = ["derive", "env"] }
 # Used for diagnostic reporting
-codespan = "0.11"
-codespan-reporting = "0.11"
+codespan = { version = "0.11", features = ["serialization"] }
+codespan-reporting = { version = "0.11", features = ["serialization"] }
 # Brrrrr
 crossbeam = "0.8"
 # Logging utilities
@@ -88,7 +88,7 @@ reqwest = { version = "0.11", default-features = false }
 # sha-256 hash calculation, already a dependency via rustls/etc
 ring = "0.17"
 # Used for interacting with advisory databases
-rustsec = { version = "0.28", default-features = false }
+rustsec = { version = "0.29", default-features = false }
 # Parsing and checking of versions/version requirements
 semver = "1.0"
 # Gee what could it be
@@ -99,7 +99,7 @@ smallvec = "1.9"
 # Used for parsing and checking SPDX license expressions
 spdx = "0.10"
 # Lazy
-strum = { version = "0.25", features = ["derive"] }
+strum = { version = "0.26", features = ["derive"] }
 # Index retrieval and querying
 tame-index = { version = "0.9", default-features = false, features = [
   "git",
@@ -111,7 +111,7 @@ time = { version = "0.3", default-features = false, features = [
   "macros",
 ] }
 # Deserialization of configuration files and crate manifests
-toml = "0.8"
+toml-span = { version = "0.1.0", features = ["reporting"] }
 # Small fast hash crate
 twox-hash = { version = "1.5", default-features = false }
 # Url parsing/manipulation
@@ -137,6 +137,8 @@ fs_extra = "1.3"
 # Snapshot testing
 insta = { version = "1.21", features = ["json"] }
 tame-index = { version = "0.9", features = ["local-builder"] }
+time = { version = "0.3", features = ["serde"] }
+toml-span = { version = "0.1.0", features = ["serde"] }
 # We use this for creating fake crate directories for crawling license files on disk
 tempfile = "3.1.0"
 
diff --git a/deny.toml b/deny.toml
index d80720f8f..6cf4e0b20 100644
--- a/deny.toml
+++ b/deny.toml
@@ -1,12 +1,14 @@
+[graph]
 # cargo-deny is really only ever intended to run on the "normal" tier-1 targets
 targets = [
-    { triple = "x86_64-unknown-linux-gnu" },
-    { triple = "aarch64-unknown-linux-gnu" },
-    { triple = "x86_64-unknown-linux-musl" },
-    { triple = "aarch64-apple-darwin" },
-    { triple = "x86_64-apple-darwin" },
-    { triple = "x86_64-pc-windows-msvc" },
+    "x86_64-unknown-linux-gnu",
+    "aarch64-unknown-linux-gnu",
+    "x86_64-unknown-linux-musl",
+    "aarch64-apple-darwin",
+    "x86_64-apple-darwin",
+    "x86_64-pc-windows-msvc",
 ]
+all-features = true
 
 [advisories]
 vulnerability = "deny"
@@ -23,37 +25,25 @@ ignore = [
 multiple-versions = "deny"
 wildcards = 'deny'
 deny = [
-    # We use gix
-    { name = "git2" },
-    # NEVER AGAIN
-    { name = "openssl" },
-    { name = "openssl-sys" },
-    { name = "libssh2-sys" },
-    # There is literally never a reason to use this
-    { name = "cmake" },
-    # Ditto
-    { name = "windows" },
+    { crate = "git2", use-instead = "gix" },
+    { crate = "openssl", use-instead = "rustls" },
+    { crate = "openssl-sys", use-instead = "rustls" },
+    "libssh2-sys",
+    { crate = "cmake", use-instead = "cc" },
+    { crate = "windows", reason = "bloated and unnecessary", use-instead = "ideally inline bindings, practically, windows-sys" },
 ]
 skip = [
-    # strum_macros + maybe-async
-    { name = "syn", version = "=1.0.109" },
-    # security-framework uses an ooooold version
-    { name = "bitflags", version = "=1.3.2" },
+    # https://github.com/seanmonstar/reqwest/pull/2130 should be in the next reqwest release
+    { crate = "bitflags@1.3.2", reason = "reqwest -> system-configuration uses this old version" },
 ]
 skip-tree = [
-    # Sigh
-    { name = "windows-sys", version = "<=0.48" },
-    # cargo-lock uses an old version
-    { name = "toml", version = "=0.7.8" },
+    { crate = "windows-sys:<=0.48", reason = "a foundational crate for many that bumps far too frequently to ever have a shared version" },
 ]
 
 [sources]
 unknown-registry = "deny"
 unknown-git = "deny"
 
-# [sources.allow-org]
-# github = ["EmbarkStudios"]
-
 [licenses]
 unlicensed = "deny"
 allow-osi-fsf-free = "neither"
@@ -71,18 +61,18 @@ allow = [
 exceptions = [
     { allow = [
         "Zlib",
-    ], name = "tinyvec" },
+    ], crate = "tinyvec" },
     { allow = [
         "Unicode-DFS-2016",
-    ], name = "unicode-ident" },
+    ], crate = "unicode-ident" },
     { allow = [
         "OpenSSL",
-    ], name = "ring" },
+    ], crate = "ring" },
 ]
 
 # Sigh
 [[licenses.clarify]]
-name = "ring"
+crate = "ring"
 # SPDX considers OpenSSL to encompass both the OpenSSL and SSLeay licenses
 # https://spdx.org/licenses/OpenSSL.html
 # ISC - Both BoringSSL and ring use this for their new files
@@ -94,12 +84,12 @@ expression = "ISC AND MIT AND OpenSSL"
 license-files = [{ path = "LICENSE", hash = 0xbd0eed23 }]
 
 [[licenses.clarify]]
-name = "webpki"
+crate = "webpki"
 expression = "ISC"
 license-files = [{ path = "LICENSE", hash = 0x001c7e6c }]
 
 # Actually "ISC-style"
 [[licenses.clarify]]
-name = "rustls-webpki"
+crate = "rustls-webpki"
 expression = "ISC"
 license-files = [{ path = "LICENSE", hash = 0x001c7e6c }]
diff --git a/src/advisories.rs b/src/advisories.rs
index 55301c77b..a2582d19f 100644
--- a/src/advisories.rs
+++ b/src/advisories.rs
@@ -71,6 +71,7 @@ pub fn check<R, S>(
 
     use bitvec::prelude::*;
     let mut ignore_hits: BitVec = BitVec::repeat(false, ctx.cfg.ignore.len());
+    let mut ignore_yanked_hits: BitVec = BitVec::repeat(false, ctx.cfg.ignore_yanked.len());
 
     // Emit diagnostics for any advisories found that matched crates in the graph
     for (krate, krate_index, advisory) in &report.advisories {
@@ -98,7 +99,22 @@ pub fn check<R, S>(
                 sink.push(ctx.diag_for_index_failure(krate, ind, e));
             }
         } else {
-            sink.push(ctx.diag_for_yanked(krate, ind));
+            // Check to see if the user has added an ignore for the yanked
+            // crate, eg. see https://github.com/EmbarkStudios/cargo-deny/issues/579
+            // this should be extremely rare and very temporary as in most cases
+            // a new semver compatible version of the yanked version is published
+            // around the same time as a yank occurs
+            if let Some(i) = ctx
+                .cfg
+                .ignore_yanked
+                .iter()
+                .position(|iy| crate::match_krate(krate, &iy.spec))
+            {
+                sink.push(ctx.diag_for_yanked_ignore(krate, i));
+                ignore_yanked_hits.as_mut_bitslice().set(i, true);
+            } else {
+                sink.push(ctx.diag_for_yanked(krate, ind));
+            }
         }
     }
 
@@ -122,6 +138,14 @@ pub fn check<R, S>(
         sink.push(ctx.diag_for_advisory_not_encountered(ignore));
     }
 
+    for ignore in ignore_yanked_hits
+        .into_iter()
+        .zip(ctx.cfg.ignore_yanked.iter())
+        .filter_map(|(hit, ignore)| if !hit { Some(ignore) } else { None })
+    {
+        sink.push(ctx.diag_for_ignored_yanked_not_encountered(ignore));
+    }
+
     if let Some(mut reporter) = audit_compatible_reporter {
         for ser_report in report.serialized_reports {
             reporter.report(ser_report);
diff --git a/src/advisories/cfg.rs b/src/advisories/cfg.rs
index 959fa9065..f7ff43907 100644
--- a/src/advisories/cfg.rs
+++ b/src/advisories/cfg.rs
@@ -1,47 +1,32 @@
 use crate::{
+    cfg::{PackageSpecOrExtended, Reason, ValidationContext},
     diag::{Diagnostic, FileId, Label},
     LintLevel, PathBuf, Spanned,
 };
 use rustsec::advisory;
-use serde::Deserialize;
+use time::Duration;
+use toml_span::{de_helpers::*, value::ValueInner};
 use url::Url;
 
-#[allow(clippy::reversed_empty_ranges)]
-const fn yanked() -> Spanned<LintLevel> {
-    Spanned::new(LintLevel::Warn, 0..0)
-}
-
-#[allow(clippy::reversed_empty_ranges)]
-fn ninety_days() -> Spanned<String> {
-    Spanned::new("P90D".to_owned(), 0..0)
-}
-
-#[derive(Deserialize)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
 pub struct Config {
     /// Path to the root directory where advisory databases are stored (default: $CARGO_HOME/advisory-dbs)
     pub db_path: Option<PathBuf>,
     /// List of urls to git repositories of different advisory databases.
-    #[serde(default)]
-    pub db_urls: Vec<Spanned<String>>,
+    pub db_urls: Vec<Spanned<Url>>,
     /// How to handle crates that have a security vulnerability
-    #[serde(default = "crate::lint_deny")]
     pub vulnerability: LintLevel,
     /// How to handle crates that have been marked as unmaintained in an advisory database
-    #[serde(default = "crate::lint_warn")]
     pub unmaintained: LintLevel,
     /// How to handle crates that have been marked as unsound in an advisory database
-    #[serde(default = "crate::lint_warn")]
     pub unsound: LintLevel,
     /// How to handle crates that have been yanked from eg crates.io
-    #[serde(default = "yanked")]
     pub yanked: Spanned<LintLevel>,
     /// How to handle crates that have been marked with a notice in the advisory database
-    #[serde(default = "crate::lint_warn")]
     pub notice: LintLevel,
     /// Ignore advisories for the given IDs
-    #[serde(default)]
-    pub ignore: Vec<Spanned<advisory::Id>>,
+    pub ignore: Vec<AdvisoryId>,
+    /// Ignore yanked crates
+    pub ignore_yanked: Vec<Spanned<PackageSpecOrExtended<Reason>>>,
     /// CVSS Qualitative Severity Rating Scale threshold to alert at.
     ///
     /// Vulnerabilities with explicit CVSS info which have a severity below
@@ -50,7 +35,6 @@ pub struct Config {
     /// Use the git executable to fetch advisory database rather than gitoxide
     pub git_fetch_with_cli: Option<bool>,
     /// If set to true, the local crates indices are not checked for yanked crates
-    #[serde(default)]
     pub disable_yank_checking: bool,
     /// The maximum duration, in RFC3339 format, that an advisory database is
     /// allowed to not have been updated. This only applies when fetching advisory
@@ -59,8 +43,7 @@ pub struct Config {
     /// Note that if fractional units are used in the format string they must
     /// use the '.' separator instead of ',' which is used by some locales and
     /// supported in the RFC3339 format, but not by this implementation
-    #[serde(default = "ninety_days")]
-    pub maximum_db_staleness: Spanned<String>,
+    pub maximum_db_staleness: Spanned<Duration>,
 }
 
 impl Default for Config {
@@ -69,96 +52,194 @@ impl Default for Config {
             db_path: None,
             db_urls: Vec::new(),
             ignore: Vec::new(),
+            ignore_yanked: Vec::new(),
             vulnerability: LintLevel::Deny,
             unmaintained: LintLevel::Warn,
             unsound: LintLevel::Warn,
-            yanked: yanked(),
+            yanked: Spanned::new(LintLevel::Warn),
             notice: LintLevel::Warn,
             severity_threshold: None,
             git_fetch_with_cli: None,
             disable_yank_checking: false,
-            maximum_db_staleness: ninety_days(),
+            maximum_db_staleness: Spanned::new(Duration::seconds_f64(NINETY_DAYS)),
         }
     }
 }
 
-impl crate::cfg::UnvalidatedConfig for Config {
-    type ValidCfg = ValidConfig;
+const NINETY_DAYS: f64 = 90. * 24. * 60. * 60. * 60.;
+
+impl<'de> toml_span::Deserialize<'de> for Config {
+    fn deserialize(
+        value: &mut toml_span::value::Value<'de>,
+    ) -> Result<Self, toml_span::DeserError> {
+        let mut th = toml_span::de_helpers::TableHelper::new(value)?;
+
+        let db_path = th.optional::<String>("db-path").map(PathBuf::from);
+        let db_urls = if let Some((_, mut urls)) = th.take("db-urls") {
+            let mut u = Vec::new();
+
+            match urls.take() {
+                ValueInner::Array(urla) => {
+                    for mut v in urla {
+                        match parse(&mut v) {
+                            Ok(url) => u.push(Spanned::with_span(url, v.span)),
+                            Err(err) => th.errors.push(err),
+                        }
+                    }
+                }
+                other => {
+                    th.errors.push(expected("an array", other, urls.span));
+                }
+            }
 
-    fn validate(
-        self,
-        cfg_file: FileId,
-        _files: &mut crate::diag::Files,
-        diags: &mut Vec<Diagnostic>,
-    ) -> Self::ValidCfg {
-        let mut ignored: Vec<_> = self.ignore.into_iter().map(AdvisoryId::from).collect();
-        ignored.sort();
-
-        let mut db_urls: Vec<_> = self
-            .db_urls
-            .into_iter()
-            .filter_map(|dburl| match crate::cfg::parse_url(cfg_file, dburl) {
-                Ok(u) => Some(u),
-                Err(diag) => {
-                    diags.push(diag);
-                    None
+            u.sort();
+            u
+        } else {
+            Vec::new()
+        };
+        let vulnerability = th.optional("vulnerability").unwrap_or(LintLevel::Deny);
+        let unmaintained = th.optional("unmaintained").unwrap_or(LintLevel::Warn);
+        let unsound = th.optional("unsound").unwrap_or(LintLevel::Warn);
+        let yanked = th
+            .optional_s("yanked")
+            .unwrap_or(Spanned::new(LintLevel::Warn));
+        let notice = th.optional("notice").unwrap_or(LintLevel::Warn);
+        let (ignore, ignore_yanked) = if let Some((_, mut ignore)) = th.take("ignore") {
+            let mut u = Vec::new();
+            let mut y = Vec::new();
+
+            match ignore.take() {
+                ValueInner::Array(ida) => {
+                    for mut v in ida {
+                        let inner = v.take();
+                        if let ValueInner::String(s) = &inner {
+                            // Attempt to parse an advisory id first, note we can't
+                            // just immediately use parse as the from_str implementation
+                            // for id will just blindly accept any string
+                            if advisory::IdKind::detect(s.as_ref()) != advisory::IdKind::Other {
+                                if let Ok(id) = s.parse::<advisory::Id>() {
+                                    u.push(Spanned::with_span(id, v.span));
+                                    continue;
+                                }
+                            }
+                        }
+
+                        let found = inner.type_str();
+                        v.set(inner);
+
+                        match PackageSpecOrExtended::deserialize(&mut v) {
+                            Ok(pse) => y.push(Spanned::with_span(pse, v.span)),
+                            Err(_err) => {
+                                th.errors.push(toml_span::Error {
+                                    kind: toml_span::ErrorKind::Wanted {
+                                        expected: "an advisory id or package spec",
+                                        found,
+                                    },
+                                    span: v.span,
+                                    line_info: None,
+                                });
+                            }
+                        }
+                    }
                 }
-            })
-            .collect();
-
-        db_urls.sort();
-
-        // Warn about duplicates before removing them so the user can cleanup their config
-        if db_urls.len() > 1 {
-            for window in db_urls.windows(2) {
-                if window[0] == window[1] {
-                    diags.push(
-                        Diagnostic::warning()
-                            .with_message("duplicate advisory database url detected")
-                            .with_labels(vec![
-                                Label::secondary(cfg_file, window[0].span.clone()),
-                                Label::secondary(cfg_file, window[1].span.clone()),
-                            ]),
-                    );
+                other => {
+                    th.errors.push(expected("an array", other, ignore.span));
                 }
             }
-        }
 
-        db_urls.dedup();
+            u.sort();
+            (u, y)
+        } else {
+            (Vec::new(), Vec::new())
+        };
+        let severity_threshold = th.parse_opt("severity-threshold");
+        let git_fetch_with_cli = th.optional("git-fetch-with-cli");
+        let disable_yank_checking = th.optional("disable-yank-checking").unwrap_or_default();
+        let maximum_db_staleness = if let Some((_, mut val)) = th.take("maximum-db-staleness") {
+            match val.take_string(Some("an RFC3339 time duration")) {
+                Ok(mds) => match parse_rfc3339_duration(&mds) {
+                    Ok(mds) => Some(Spanned::with_span(mds, val.span)),
+                    Err(err) => {
+                        th.errors.push(
+                            (
+                                toml_span::ErrorKind::Custom(err.to_string().into()),
+                                val.span,
+                            )
+                                .into(),
+                        );
+                        None
+                    }
+                },
+                Err(err) => {
+                    th.errors.push(err);
+                    None
+                }
+            }
+        } else {
+            None
+        };
+
+        th.finalize(None)?;
+
+        // Use the 90 days default as a fallback
+        let maximum_db_staleness = maximum_db_staleness
+            .unwrap_or_else(|| Spanned::new(Duration::seconds_f64(NINETY_DAYS)));
+
+        Ok(Self {
+            db_path,
+            db_urls,
+            vulnerability,
+            unmaintained,
+            unsound,
+            yanked,
+            notice,
+            ignore,
+            ignore_yanked,
+            severity_threshold,
+            git_fetch_with_cli,
+            disable_yank_checking,
+            maximum_db_staleness,
+        })
+    }
+}
+
+impl crate::cfg::UnvalidatedConfig for Config {
+    type ValidCfg = ValidConfig;
+
+    fn validate(self, mut ctx: ValidationContext<'_>) -> Self::ValidCfg {
+        let mut ignore = self.ignore;
+        let mut ignore_yanked = self.ignore_yanked;
+        let mut db_urls = self.db_urls;
+
+        ctx.dedup(&mut ignore);
+        ctx.dedup(&mut ignore_yanked);
+        ctx.dedup(&mut db_urls);
 
         // Require that each url has a valid domain name for when we splat it to a local path
         for url in &db_urls {
             if url.value.domain().is_none() {
-                diags.push(
+                ctx.push(
                     Diagnostic::error()
                         .with_message("advisory database url doesn't have a domain name")
-                        .with_labels(vec![Label::secondary(cfg_file, url.span.clone())]),
+                        .with_labels(vec![Label::secondary(ctx.cfg_id, url.span)]),
                 );
             }
         }
 
-        let maximum_db_staleness = match parse_rfc3339_duration(&self.maximum_db_staleness.value) {
-            Ok(mds) => mds,
-            Err(err) => {
-                diags.push(
-                    Diagnostic::error()
-                        .with_message("failed to parse RFC3339 duration")
-                        .with_labels(vec![Label::secondary(
-                            cfg_file,
-                            self.maximum_db_staleness.span.clone(),
-                        )])
-                        .with_notes(vec![err.to_string()]),
-                );
-                // Use the 90 days default as a fallback
-                time::Duration::seconds_f64(90. * 24. * 60. * 60. * 60.)
-            }
-        };
-
         ValidConfig {
-            file_id: cfg_file,
+            file_id: ctx.cfg_id,
             db_path: self.db_path,
             db_urls,
-            ignore: ignored,
+            ignore,
+            ignore_yanked: ignore_yanked
+                .into_iter()
+                .map(|s| crate::bans::SpecAndReason {
+                    spec: s.value.spec,
+                    reason: s.value.inner,
+                    use_instead: None,
+                    file_id: ctx.cfg_id,
+                })
+                .collect(),
             vulnerability: self.vulnerability,
             unmaintained: self.unmaintained,
             unsound: self.unsound,
@@ -167,18 +248,20 @@ impl crate::cfg::UnvalidatedConfig for Config {
             severity_threshold: self.severity_threshold,
             git_fetch_with_cli: self.git_fetch_with_cli.unwrap_or_default(),
             disable_yank_checking: self.disable_yank_checking,
-            maximum_db_staleness,
+            maximum_db_staleness: self.maximum_db_staleness,
         }
     }
 }
 
 pub(crate) type AdvisoryId = Spanned<advisory::Id>;
 
+#[cfg_attr(test, derive(serde::Serialize))]
 pub struct ValidConfig {
     pub file_id: FileId,
     pub db_path: Option<PathBuf>,
     pub db_urls: Vec<Spanned<Url>>,
     pub(crate) ignore: Vec<AdvisoryId>,
+    pub(crate) ignore_yanked: Vec<crate::bans::SpecAndReason>,
     pub vulnerability: LintLevel,
     pub unmaintained: LintLevel,
     pub unsound: LintLevel,
@@ -187,7 +270,7 @@ pub struct ValidConfig {
     pub severity_threshold: Option<advisory::Severity>,
     pub git_fetch_with_cli: bool,
     pub disable_yank_checking: bool,
-    pub maximum_db_staleness: time::Duration,
+    pub maximum_db_staleness: Spanned<Duration>,
 }
 
 /// We need to implement this ourselves since time doesn't support it
@@ -206,7 +289,7 @@ pub struct ValidConfig {
 ///
 /// duration          = "P" (dur-date / dur-time / dur-week)
 /// ```
-fn parse_rfc3339_duration(value: &str) -> anyhow::Result<time::Duration> {
+fn parse_rfc3339_duration(value: &str) -> anyhow::Result<Duration> {
     use anyhow::Context as _;
 
     let mut value = value
@@ -277,7 +360,7 @@ fn parse_rfc3339_duration(value: &str) -> anyhow::Result<time::Duration> {
         }
     }
 
-    let mut duration = time::Duration::new(0, 0);
+    let mut duration = Duration::new(0, 0);
 
     // The format requires that the units are in a specific order, but each
     // unit is optional
@@ -347,54 +430,55 @@ fn parse_rfc3339_duration(value: &str) -> anyhow::Result<time::Duration> {
 #[cfg(test)]
 mod test {
     use super::{parse_rfc3339_duration as dur_parse, *};
-    use crate::cfg::{test::*, Fake, UnvalidatedConfig};
+    use crate::test_utils::{write_diagnostics, ConfigData};
+
+    struct Advisories {
+        advisories: Config,
+    }
+
+    impl<'de> toml_span::Deserialize<'de> for Advisories {
+        fn deserialize(
+            value: &mut toml_span::value::Value<'de>,
+        ) -> Result<Self, toml_span::DeserError> {
+            let mut th = toml_span::de_helpers::TableHelper::new(value)?;
+            let advisories = th.required("advisories").unwrap();
+            th.finalize(None)?;
+            Ok(Self { advisories })
+        }
+    }
 
     #[test]
     fn deserializes_advisories_cfg() {
-        #[derive(Deserialize)]
-        #[serde(deny_unknown_fields)]
-        struct Advisories {
-            advisories: Config,
-        }
+        let cd = ConfigData::<Advisories>::load("tests/cfg/advisories.toml");
+        let validated = cd.validate(|a| a.advisories);
 
-        let mut cd: ConfigData<Advisories> = load("tests/cfg/advisories.toml");
-        let mut diags = Vec::new();
-        let validated = cd
-            .config
-            .advisories
-            .validate(cd.id, &mut cd.files, &mut diags);
-        assert!(
-            !diags
-                .iter()
-                .any(|d| d.severity >= crate::diag::Severity::Error),
-            "{diags:#?}"
-        );
+        insta::assert_json_snapshot!(validated);
+    }
 
-        assert_eq!(validated.file_id, cd.id);
-        assert!(validated
-            .db_path
-            .iter()
-            .map(|dp| dp.as_str())
-            .eq(vec!["~/.cargo/advisory-dbs"]));
-        assert!(validated.db_urls.iter().eq(vec![&Url::parse(
-            "https://github.com/RustSec/advisory-db"
-        )
-        .unwrap()
-        .fake()]));
-        assert_eq!(validated.vulnerability, LintLevel::Deny);
-        assert_eq!(validated.unmaintained, LintLevel::Warn);
-        assert_eq!(validated.unsound, LintLevel::Warn);
-        assert_eq!(validated.yanked, LintLevel::Warn);
-        assert_eq!(validated.notice, LintLevel::Warn);
-        assert_eq!(
-            validated.ignore,
-            vec!["RUSTSEC-0000-0000"
-                .parse::<rustsec::advisory::Id>()
-                .unwrap()]
-        );
-        assert_eq!(
-            validated.severity_threshold,
-            Some(rustsec::advisory::Severity::Medium)
+    #[test]
+    fn warns_on_duplicates() {
+        let dupes = r#"
+[advisories]
+db-urls = [
+    "https://github.com/rust-lang/crates.io-index",
+    "https://one.reg",
+    "https://one.reg",
+]
+ignore = [
+    "RUSTSEC-0000-0001",
+    { crate = "boop" },
+    "RUSTSEC-0000-0001",
+    "boop",
+]
+"#;
+
+        let cd = ConfigData::<Advisories>::load_str("duplicates", dupes);
+        let _validated = cd.validate_with_diags(
+            |a| a.advisories,
+            |files, diags| {
+                let diags = write_diagnostics(files, diags.into_iter());
+                insta::assert_snapshot!(diags);
+            },
         );
     }
 
@@ -414,7 +498,7 @@ mod test {
 
         let failures: String = FAILURES.iter().fold(String::new(), |mut acc, bad| {
             use std::fmt::Write;
-            writeln!(&mut acc, "{:?}", dur_parse(bad)).unwrap();
+            writeln!(&mut acc, "{:#?}", dur_parse(bad).unwrap_err()).unwrap();
             acc
         });
 
@@ -462,7 +546,7 @@ mod test {
                 Ok(parsed) => {
                     assert_eq!(
                         parsed,
-                        time::Duration::seconds_f64(*secs),
+                        Duration::seconds_f64(*secs),
                         "unexpected duration for '{dur}'"
                     );
                 }
diff --git a/src/advisories/diags.rs b/src/advisories/diags.rs
index 57559dc18..7fe23c6ce 100644
--- a/src/advisories/diags.rs
+++ b/src/advisories/diags.rs
@@ -22,9 +22,11 @@ pub enum Code {
     Unmaintained,
     Unsound,
     Yanked,
+    YankedIgnored,
     IndexFailure,
     IndexCacheLoadFailure,
     AdvisoryNotDetected,
+    YankedNotDetected,
     UnknownAdvisory,
 }
 
@@ -187,6 +189,18 @@ impl<'a> crate::CheckCtx<'a, super::cfg::ValidConfig> {
         pack
     }
 
+    pub(crate) fn diag_for_yanked_ignore(&self, krate: &crate::Krate, ignore: usize) -> Pack {
+        let mut pack = Pack::with_kid(Check::Advisories, krate.id.clone());
+        pack.push(
+            Diagnostic::note()
+                .with_message(format!("yanked crate '{krate}' detected, but ignored",))
+                .with_code(Code::YankedIgnored)
+                .with_labels(self.cfg.ignore_yanked[ignore].to_labels(Some("yanked ignore"))),
+        );
+
+        pack
+    }
+
     pub(crate) fn diag_for_index_failure<D: std::fmt::Display>(
         &self,
         krate: &crate::Krate,
@@ -202,7 +216,7 @@ impl<'a> crate::CheckCtx<'a, super::cfg::ValidConfig> {
         // to the beginning and confuses users
         if !self.cfg.yanked.span.is_empty() {
             labels.push(
-                Label::primary(self.cfg.file_id, self.cfg.yanked.span.clone())
+                Label::primary(self.cfg.file_id, self.cfg.yanked.span)
                     .with_message("lint level defined here"),
             );
         }
@@ -238,20 +252,36 @@ impl<'a> crate::CheckCtx<'a, super::cfg::ValidConfig> {
             Diagnostic::new(Severity::Warning)
                 .with_message("advisory was not encountered")
                 .with_code(Code::AdvisoryNotDetected)
-                .with_labels(vec![Label::primary(self.cfg.file_id, not_hit.span.clone())
+                .with_labels(vec![Label::primary(self.cfg.file_id, not_hit.span)
                     .with_message("no crate matched advisory criteria")]),
         )
             .into()
     }
 
+    #[allow(clippy::unused_self)]
+    pub(crate) fn diag_for_ignored_yanked_not_encountered(
+        &self,
+        not_hit: &crate::bans::SpecAndReason,
+    ) -> Pack {
+        (
+            Check::Advisories,
+            Diagnostic::new(Severity::Warning)
+                .with_message("yanked crate was not encountered")
+                .with_code(Code::YankedNotDetected)
+                .with_labels(not_hit.to_labels(Some("yanked crate not detected"))),
+        )
+            .into()
+    }
+
     pub(crate) fn diag_for_unknown_advisory(&self, unknown: &crate::cfg::Spanned<Id>) -> Pack {
         (
             Check::Advisories,
             Diagnostic::new(Severity::Warning)
                 .with_message("advisory not found in any advisory database")
                 .with_code(Code::UnknownAdvisory)
-                .with_labels(vec![Label::primary(self.cfg.file_id, unknown.span.clone())
-                    .with_message("unknown advisory")]),
+                .with_labels(vec![
+                    Label::primary(self.cfg.file_id, unknown.span).with_message("unknown advisory")
+                ]),
         )
             .into()
     }
diff --git a/src/advisories/helpers/index.rs b/src/advisories/helpers/index.rs
index 732ad8e39..bccaeb023 100644
--- a/src/advisories/helpers/index.rs
+++ b/src/advisories/helpers/index.rs
@@ -39,6 +39,7 @@ impl<'k> Indices<'k> {
             indices.push((source, index));
         }
 
+        #[allow(clippy::blocks_in_conditions)]
         let cargo_package_lock =
             match tame_index::utils::flock::LockOptions::cargo_package_lock(Some(cargo_home))
                 .expect("unreachable")
diff --git a/src/bans.rs b/src/bans.rs
index d4858b746..95ed690dc 100644
--- a/src/bans.rs
+++ b/src/bans.rs
@@ -2,8 +2,9 @@ pub mod cfg;
 mod diags;
 mod graph;
 
-use self::cfg::{TreeSkip, ValidBuildConfig, ValidConfig};
+use self::cfg::{ValidBuildConfig, ValidConfig, ValidTreeSkip};
 use crate::{
+    cfg::{PackageSpec, Reason, Span, Spanned},
     diag::{self, CfgCoord, FileId, KrateCoord},
     Kid, Krate, Krates, LintLevel,
 };
@@ -13,50 +14,58 @@ use krates::cm::DependencyKind;
 use semver::VersionReq;
 use std::fmt;
 
-#[derive(PartialEq, Eq, Clone)]
-#[cfg_attr(test, derive(Debug))]
-pub struct KrateId {
-    pub(crate) name: String,
-    pub(crate) version: Option<VersionReq>,
+struct ReqMatch<'vr> {
+    specr: &'vr SpecAndReason,
+    index: usize,
 }
 
-impl fmt::Display for KrateId {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(f, "{} = {:?}", self.name, self.version)
-    }
+pub(crate) struct SpecAndReason {
+    pub(crate) spec: PackageSpec,
+    pub(crate) reason: Option<Reason>,
+    pub(crate) use_instead: Option<Spanned<String>>,
+    pub(crate) file_id: FileId,
 }
 
-struct ReqMatch<'vr> {
-    id: &'vr cfg::Skrate,
-    index: usize,
+#[cfg(test)]
+impl serde::Serialize for SpecAndReason {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        use serde::ser::SerializeMap;
+        let mut map = serializer.serialize_map(Some(3))?;
+        map.serialize_entry("spec", &self.spec)?;
+        map.serialize_entry("reason", &self.reason)?;
+        map.serialize_entry("use-instead", &self.use_instead)?;
+        map.end()
+    }
 }
 
-/// Returns the version requirements that matched the version, if any
-#[inline]
-fn matches<'v>(arr: &'v [cfg::Skrate], details: &Krate) -> Option<Vec<ReqMatch<'v>>> {
-    let matches: Vec<_> = arr
-        .iter()
-        .enumerate()
-        .filter_map(|(index, req)| {
-            if req.value.name == details.name
-                && crate::match_req(&details.version, req.value.version.as_ref())
-            {
-                Some(ReqMatch { id: req, index })
-            } else {
-                None
-            }
-        })
-        .collect();
+struct SpecsAndReasons(Vec<SpecAndReason>);
 
-    if matches.is_empty() {
-        None
-    } else {
-        Some(matches)
+impl SpecsAndReasons {
+    /// Returns the specs that match the specified crate
+    #[inline]
+    fn matches<'s>(&'s self, details: &Krate) -> Option<Vec<ReqMatch<'s>>> {
+        let matches: Vec<_> = self
+            .0
+            .iter()
+            .enumerate()
+            .filter_map(|(index, req)| {
+                crate::match_krate(details, &req.spec).then_some(ReqMatch { specr: req, index })
+            })
+            .collect();
+
+        if matches.is_empty() {
+            None
+        } else {
+            Some(matches)
+        }
     }
 }
 
 struct SkipRoot {
-    span: std::ops::Range<usize>,
+    specr: SpecAndReason,
     skip_crates: Vec<Kid>,
     skip_hits: BitVec,
 }
@@ -67,15 +76,10 @@ use bitvec::prelude::*;
 // each dependency as a skipped crate at the specific version
 struct TreeSkipper {
     roots: Vec<SkipRoot>,
-    cfg_file_id: FileId,
 }
 
 impl TreeSkipper {
-    fn build(
-        skip_roots: Vec<crate::Spanned<TreeSkip>>,
-        krates: &Krates,
-        cfg_file_id: FileId,
-    ) -> (Self, Pack) {
+    fn build(skip_roots: Vec<ValidTreeSkip>, krates: &Krates, cfg_file_id: FileId) -> (Self, Pack) {
         let mut roots = Vec::with_capacity(skip_roots.len());
 
         let mut pack = Pack::new(Check::Bans);
@@ -83,11 +87,11 @@ impl TreeSkipper {
         for ts in skip_roots {
             let num_roots = roots.len();
 
-            for nid in krates.krates_by_name(&ts.value.id.name).filter_map(|km| {
-                crate::match_req(&km.krate.version, ts.value.id.version.as_ref())
+            for nid in krates.krates_by_name(&ts.spec.name.value).filter_map(|km| {
+                crate::match_req(&km.krate.version, ts.spec.version_req.as_ref())
                     .then_some(km.node_id)
             }) {
-                roots.push(Self::build_skip_root(ts.clone(), nid, krates));
+                roots.push(Self::build_skip_root(ts.clone(), cfg_file_id, nid, krates));
             }
 
             // If no roots were added, add a diagnostic that the user's configuration
@@ -96,24 +100,25 @@ impl TreeSkipper {
                 pack.push(diags::UnmatchedSkipRoot {
                     skip_root_cfg: CfgCoord {
                         file: cfg_file_id,
-                        span: ts.span,
+                        span: ts.spec.name.span,
                     },
                 });
             }
         }
 
-        (Self { roots, cfg_file_id }, pack)
+        (Self { roots }, pack)
     }
 
     fn build_skip_root(
-        ts: crate::Spanned<TreeSkip>,
+        ts: ValidTreeSkip,
+        file_id: FileId,
         krate_id: krates::NodeId,
         krates: &Krates,
     ) -> SkipRoot {
-        let span = ts.span;
-        let ts = ts.value;
+        let (max_depth, reason) = ts.inner.map_or((std::usize::MAX, None), |inn| {
+            (inn.depth.unwrap_or(std::usize::MAX), inn.reason)
+        });
 
-        let max_depth = ts.depth.unwrap_or(std::usize::MAX);
         let mut skip_crates = Vec::with_capacity(10);
 
         let graph = krates.graph();
@@ -139,7 +144,12 @@ impl TreeSkipper {
         let skip_hits = BitVec::repeat(false, skip_crates.len());
 
         SkipRoot {
-            span,
+            specr: SpecAndReason {
+                spec: ts.spec,
+                reason,
+                use_instead: None,
+                file_id,
+            },
             skip_crates,
             skip_hits,
         }
@@ -152,10 +162,7 @@ impl TreeSkipper {
             if let Ok(i) = root.skip_crates.binary_search(&krate.id) {
                 pack.push(diags::SkippedByRoot {
                     krate,
-                    skip_root_cfg: CfgCoord {
-                        file: self.cfg_file_id,
-                        span: root.span.clone(),
-                    },
+                    skip_root_cfg: &root.specr,
                 });
 
                 root.skip_hits.as_mut_bitslice().set(i, true);
@@ -214,15 +221,15 @@ pub fn check(
         sink.push(build_diags);
     }
 
+    use std::collections::BTreeMap;
+
     struct BanWrappers {
-        map: std::collections::BTreeMap<usize, (usize, Vec<crate::Spanned<String>>)>,
+        map: BTreeMap<usize, (usize, Vec<Spanned<String>>)>,
         hits: BitVec,
     }
 
     impl BanWrappers {
-        fn new(
-            mut map: std::collections::BTreeMap<usize, (usize, Vec<crate::Spanned<String>>)>,
-        ) -> Self {
+        fn new(mut map: BTreeMap<usize, (usize, Vec<Spanned<String>>)>) -> Self {
             let hits = BitVec::repeat(
                 false,
                 map.values_mut().fold(0, |sum, v| {
@@ -240,11 +247,11 @@ pub fn check(
         }
 
         #[inline]
-        fn check(&mut self, i: usize, name: &str) -> Option<crate::diag::Span> {
+        fn check(&mut self, i: usize, name: &str) -> Option<Span> {
             let (offset, wrappers) = &self.map[&i];
             if let Some(pos) = wrappers.iter().position(|wrapper| wrapper.value == name) {
                 self.hits.set(*offset + pos, true);
-                Some(wrappers[pos].span.clone())
+                Some(wrappers[pos].span)
             } else {
                 None
             }
@@ -252,26 +259,53 @@ pub fn check(
     }
 
     let (denied_ids, mut ban_wrappers) = {
-        let mut bw = std::collections::BTreeMap::new();
+        let mut bw = BTreeMap::new();
 
         (
-            denied
-                .into_iter()
-                .enumerate()
-                .map(|(i, kb)| {
-                    if let Some(wrappers) = kb.wrappers.filter(|v| !v.is_empty()) {
-                        bw.insert(i, (0, wrappers));
-                    }
+            SpecsAndReasons(
+                denied
+                    .into_iter()
+                    .enumerate()
+                    .map(|(i, kb)| {
+                        let (reason, use_instead) = if let Some(ext) = kb.inner {
+                            if let Some(wrappers) = ext.wrappers.filter(|w| !w.is_empty()) {
+                                bw.insert(i, (0, wrappers));
+                            }
 
-                    kb.id
-                })
-                .collect::<Vec<_>>(),
+                            (ext.reason, ext.use_instead)
+                        } else {
+                            (None, None)
+                        };
+
+                        SpecAndReason {
+                            spec: kb.spec,
+                            reason,
+                            use_instead,
+                            file_id,
+                        }
+                    })
+                    .collect(),
+            ),
             BanWrappers::new(bw),
         )
     };
 
-    let (feature_ids, features): (Vec<_>, Vec<_>) =
-        features.into_iter().map(|cf| (cf.id, cf.features)).unzip();
+    let (feature_ids, features): (Vec<_>, Vec<_>) = features
+        .into_iter()
+        .map(|cf| {
+            (
+                SpecAndReason {
+                    spec: cf.spec,
+                    reason: cf.reason,
+                    use_instead: None,
+                    file_id,
+                },
+                cf.features,
+            )
+        })
+        .unzip();
+
+    let feature_ids = SpecsAndReasons(feature_ids);
 
     // Keep track of all the crates we skip, and emit a warning if
     // we encounter a skip that didn't actually match any crate version
@@ -306,6 +340,42 @@ pub fn check(
         }
     };
 
+    let dmv = SpecsAndReasons(
+        denied_multiple_versions
+            .into_iter()
+            .map(|spec| SpecAndReason {
+                spec,
+                reason: None,
+                use_instead: None,
+                file_id,
+            })
+            .collect(),
+    );
+
+    let allowed = SpecsAndReasons(
+        allowed
+            .into_iter()
+            .map(|all| SpecAndReason {
+                spec: all.spec,
+                reason: all.inner,
+                use_instead: None,
+                file_id,
+            })
+            .collect(),
+    );
+
+    let skipped = SpecsAndReasons(
+        skipped
+            .into_iter()
+            .map(|skip| SpecAndReason {
+                spec: skip.spec,
+                reason: skip.inner,
+                use_instead: None,
+                file_id,
+            })
+            .collect(),
+    );
+
     let report_duplicates = |multi_detector: &MultiDetector<'_>, sink: &mut diag::ErrorSink| {
         if multi_detector.dupes.len() <= 1 {
             return;
@@ -313,7 +383,7 @@ pub fn check(
 
         let lint_level = if multi_detector.dupes.iter().any(|kindex| {
             let krate = &ctx.krates[*kindex];
-            matches(&denied_multiple_versions, krate).is_some()
+            dmv.matches(krate).is_some()
         }) {
             LintLevel::Deny
         } else {
@@ -371,7 +441,7 @@ pub fn check(
                 num_dupes: kids.len(),
                 krates_coord: KrateCoord {
                     file: krate_spans.file_id,
-                    span: all_start..all_end,
+                    span: (all_start..all_end).into(),
                 },
                 severity,
             }
@@ -448,11 +518,11 @@ pub fn check(
                 let mut pack = Pack::with_kid(Check::Bans, krate.id.clone());
 
                 // Check if the crate has been explicitly banned
-                if let Some(matches) = matches(&denied_ids, krate) {
+                if let Some(matches) = denied_ids.matches(krate) {
                     for rm in matches {
                         let ban_cfg = CfgCoord {
                             file: file_id,
-                            span: rm.id.span.clone(),
+                            span: rm.specr.spec.name.span,
                         };
 
                         // The crate is banned, but it might be allowed if it's
@@ -483,7 +553,7 @@ pub fn check(
                                         ),
                                         None => (
                                             diags::BannedUnmatchedWrapper {
-                                                ban_cfg: ban_cfg.clone(),
+                                                ban_cfg: rm.specr,
                                                 banned_krate: krate,
                                                 parent_krate: src.krate,
                                             }
@@ -502,23 +572,23 @@ pub fn check(
                         };
 
                         if !is_allowed_by_wrapper {
-                            pack.push(diags::ExplicitlyBanned { krate, ban_cfg });
+                            pack.push(diags::ExplicitlyBanned {
+                                krate,
+                                ban_cfg: rm.specr,
+                            });
                         }
                     }
                 }
 
-                if !allowed.is_empty() {
+                if !allowed.0.is_empty() {
                     // Since only allowing specific crates is pretty draconian,
                     // also emit which allow filters actually passed each crate
-                    match matches(&allowed, krate) {
+                    match allowed.matches(krate) {
                         Some(matches) => {
                             for rm in matches {
                                 pack.push(diags::ExplicitlyAllowed {
                                     krate,
-                                    allow_cfg: CfgCoord {
-                                        file: file_id,
-                                        span: rm.id.span.clone(),
-                                    },
+                                    allow_cfg: rm.specr,
                                 });
                             }
                         }
@@ -557,7 +627,7 @@ pub fn check(
                 }
 
                 // Check if the crate has had features denied/allowed or are required to be exact
-                if let Some(matches) = matches(&feature_ids, krate) {
+                if let Some(matches) = feature_ids.matches(krate) {
                     for rm in matches {
                         let feature_bans = &features[rm.index];
 
@@ -592,7 +662,7 @@ pub fn check(
                                         if !enabled_features.contains(&af.value) {
                                             Some(CfgCoord {
                                                 file: file_id,
-                                                span: af.span.clone(),
+                                                span: af.span,
                                             })
                                         } else {
                                             None
@@ -608,7 +678,7 @@ pub fn check(
                                         not_allowed: &not_explicitly_allowed,
                                         exact_coord: CfgCoord {
                                             file: file_id,
-                                            span: feature_bans.exact.span.clone(),
+                                            span: feature_bans.exact.span,
                                         },
                                         krate,
                                     });
@@ -665,7 +735,7 @@ pub fn check(
                                                 feature,
                                                 allowed: CfgCoord {
                                                     file: file_id,
-                                                    span: feature_bans.allow.span.clone(),
+                                                    span: feature_bans.allow.span,
                                                 },
                                             });
                                         }
@@ -744,14 +814,11 @@ pub fn check(
                 }
 
                 if should_add_dupe(&krate.id) {
-                    if let Some(matches) = matches(&skipped, krate) {
+                    if let Some(matches) = skipped.matches(krate) {
                         for rm in matches {
                             pack.push(diags::Skipped {
                                 krate,
-                                skip_cfg: CfgCoord {
-                                    file: file_id,
-                                    span: rm.id.span.clone(),
-                                },
+                                skip_cfg: rm.specr,
                             });
 
                             // Mark each skip filter that is hit so that we can report unused
@@ -903,16 +970,10 @@ pub fn check(
 
     for skip in skip_hit
         .into_iter()
-        .zip(skipped.into_iter())
+        .zip(skipped.0.into_iter())
         .filter_map(|(hit, skip)| (!hit).then_some(skip))
     {
-        pack.push(diags::UnmatchedSkip {
-            skip_cfg: CfgCoord {
-                file: file_id,
-                span: skip.span,
-            },
-            skipped_krate: &skip.value,
-        });
+        pack.push(diags::UnmatchedSkip { skip_cfg: &skip });
     }
 
     for wrapper in ban_wrappers
@@ -940,24 +1001,21 @@ pub fn check_build(
     krates: &Krates,
     pack: &mut Pack,
 ) -> Option<usize> {
-    if let Some(allow_build_scripts) = &config.allow_build_scripts {
+    let build_script_allowed = if let Some(allow_build_scripts) = &config.allow_build_scripts {
         let has_build_script = krate
             .targets
             .iter()
             .any(|t| t.kind.iter().any(|k| *k == "custom-build"));
 
-        if has_build_script {
-            let allowed_build_script = allow_build_scripts.value.iter().any(|id| {
-                krate.name == id.name && crate::match_req(&krate.version, id.version.as_ref())
-            });
-
-            if !allowed_build_script {
-                pack.push(diags::BuildScriptNotAllowed { krate });
-            }
-        }
-    }
+        !has_build_script
+            || allow_build_scripts
+                .iter()
+                .any(|id| crate::match_krate(krate, id))
+    } else {
+        true
+    };
 
-    if config.executables == LintLevel::Allow {
+    if build_script_allowed && config.executables == LintLevel::Allow {
         return None;
     }
 
@@ -1005,10 +1063,7 @@ pub fn check_build(
         .bypass
         .iter()
         .enumerate()
-        .find_map(|(i, ae)| {
-            (ae.name.value == krate.name && crate::match_req(&krate.version, ae.version.as_ref()))
-                .then_some((i, ae))
-        })
+        .find_map(|(i, ae)| crate::match_krate(krate, &ae.spec).then_some((i, ae)))
         .unzip();
 
     // If the build script hashes to the same value and required features are not actually
@@ -1074,6 +1129,11 @@ pub fn check_build(
         }
     }
 
+    if !build_script_allowed {
+        pack.push(diags::BuildScriptNotAllowed { krate });
+        return kc_index;
+    }
+
     let root = krate.manifest_path.parent().unwrap();
 
     let (tx, rx) = crossbeam::channel::unbounded();
diff --git a/src/bans/cfg.rs b/src/bans/cfg.rs
index 8b7855865..b389ab93e 100644
--- a/src/bans/cfg.rs
+++ b/src/bans/cfg.rs
@@ -1,68 +1,83 @@
-use super::KrateId;
 use crate::{
+    cfg::{PackageSpec, PackageSpecOrExtended, Reason, ValidationContext},
     diag::{Diagnostic, FileId, Label},
     LintLevel, Spanned,
 };
-use semver::VersionReq;
-use serde::Deserialize;
+use toml_span::{de_helpers::TableHelper, value::Value, DeserError, Deserialize};
 
-#[derive(Deserialize, Clone)]
 #[cfg_attr(test, derive(Debug, PartialEq, Eq))]
-#[serde(deny_unknown_fields)]
-pub struct CrateId {
-    // The name of the crate
-    pub name: String,
-    /// The version constraints of the crate
-    pub version: Option<VersionReq>,
-}
-
-#[derive(Deserialize, Clone)]
-#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-pub struct CrateBan {
-    pub name: Spanned<String>,
-    pub version: Option<VersionReq>,
+pub struct CrateBanExtended {
     /// One or more crates that will allow this crate to be used if it is a
     /// direct dependency
     pub wrappers: Option<Spanned<Vec<Spanned<String>>>>,
-    /// Setting this to true will only emit an error if multiple
-    // versions of the crate are found
+    /// Setting this to true will only emit an error if multiple versions of the
+    /// crate are found
     pub deny_multiple_versions: Option<Spanned<bool>>,
+    /// The reason for banning the crate
+    pub reason: Option<Reason>,
+    /// The crate to use instead of the banned crate, could be just the crate name
+    /// or a URL
+    pub use_instead: Option<Spanned<String>>,
 }
 
-#[derive(Deserialize, Clone)]
+impl<'de> Deserialize<'de> for CrateBanExtended {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let mut th = TableHelper::new(value)?;
+
+        let wrappers = th.optional("wrappers");
+        let deny_multiple_versions = th.optional("deny-multiple-versions");
+        let reason = th.optional_s("reason");
+        let use_instead = th.optional("use-instead");
+        th.finalize(None)?;
+
+        Ok(Self {
+            wrappers,
+            deny_multiple_versions,
+            reason: reason.map(Reason::from),
+            use_instead,
+        })
+    }
+}
+
+//#[derive(Clone)]
 #[cfg_attr(test, derive(Debug, PartialEq, Eq))]
-#[serde(deny_unknown_fields)]
 pub struct CrateFeatures {
-    pub name: Spanned<String>,
-    pub version: Option<VersionReq>,
+    pub spec: PackageSpec,
     /// All features that are allowed to be used.
-    #[serde(default)]
     pub allow: Spanned<Vec<Spanned<String>>>,
     /// All features that are denied.
-    #[serde(default)]
     pub deny: Vec<Spanned<String>>,
     /// The actual feature set has to exactly match the `allow` set.
-    #[serde(default)]
     pub exact: Spanned<bool>,
+    /// The reason for specifying the crate features
+    pub reason: Option<Reason>,
 }
 
-#[derive(Deserialize, Clone)]
-#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-pub struct TreeSkip {
-    #[serde(flatten)]
-    pub id: CrateId,
-    pub depth: Option<usize>,
-}
+impl<'de> Deserialize<'de> for CrateFeatures {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let spec = PackageSpec::deserialize(value)?;
+
+        let mut th = TableHelper::new(value)?;
+
+        let allow = th.optional("allow").unwrap_or_default();
+        let deny = th.optional("deny").unwrap_or_default();
+        let exact = th.optional("exact").unwrap_or_default();
+        let reason = th.optional_s("reason");
+        th.finalize(None)?;
 
-const fn highlight() -> GraphHighlight {
-    GraphHighlight::All
+        Ok(Self {
+            spec,
+            allow,
+            deny,
+            exact,
+            reason: reason.map(Reason::from),
+        })
+    }
 }
 
-#[derive(Deserialize, PartialEq, Eq, Copy, Clone)]
-#[cfg_attr(test, derive(Debug))]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
+#[cfg_attr(test, derive(serde::Serialize))]
+#[derive(PartialEq, Eq, Copy, Clone, Default, strum::VariantArray, strum::VariantNames)]
+#[strum(serialize_all = "kebab-case")]
 pub enum GraphHighlight {
     /// Highlights the path to a duplicate dependency with the fewest number
     /// of total edges, which tends to make it the best candidate for removing
@@ -70,9 +85,12 @@ pub enum GraphHighlight {
     /// Highlights the path to the duplicate dependency with the lowest version
     LowestVersion,
     /// Highlights with all of the other configs
+    #[default]
     All,
 }
 
+crate::enum_deser!(GraphHighlight);
+
 impl GraphHighlight {
     #[inline]
     pub(crate) fn simplest(self) -> bool {
@@ -132,44 +150,43 @@ impl std::str::FromStr for Checksum {
 }
 
 impl<'de> Deserialize<'de> for Checksum {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: serde::Deserializer<'de>,
-    {
-        use serde::de::Error;
-        struct HexStrVisitor;
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let val = value.take_string(Some("a sha-256 hex encoded string"))?;
 
-        impl<'de> serde::de::Visitor<'de> for HexStrVisitor {
-            type Value = Checksum;
+        val.parse().map_err(|err| {
+            let err = match err {
+                ChecksumParseError::InvalidLength(len) => {
+                    toml_span::Error::from((toml_span::ErrorKind::Custom(format!("a sha-256 hex encoded string of length 64 but got a string of length '{len}'").into()), value.span))
+                }
+                ChecksumParseError::InvalidValue(c) => toml_span::Error::from((toml_span::ErrorKind::Unexpected(c), value.span)),
+            };
+            err.into()
+        })
+    }
+}
 
-            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-                write!(f, "a sha-256 hex encoded string")
-            }
+#[cfg(test)]
+impl serde::Serialize for Checksum {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        let mut hexs = [0; 64];
 
-            fn visit_str<E: Error>(self, data: &str) -> Result<Self::Value, E> {
-                data.parse().map_err(|err| match err {
-                    ChecksumParseError::InvalidLength(len) => {
-                        serde::de::Error::invalid_length(len, &"a string with 64 characters")
-                    }
-                    ChecksumParseError::InvalidValue(c) => serde::de::Error::invalid_value(
-                        serde::de::Unexpected::Char(c),
-                        &"a hexadecimal character",
-                    ),
-                })
-            }
+        const CHARS: &[u8] = b"0123456789abcdef";
 
-            fn visit_borrowed_str<E: Error>(self, data: &'de str) -> Result<Self::Value, E> {
-                self.visit_str(data)
-            }
+        for (i, &byte) in self.0.iter().enumerate() {
+            let i = i * 2;
+            hexs[i] = CHARS[(byte >> 4) as usize];
+            hexs[i + 1] = CHARS[(byte & 0xf) as usize];
         }
 
-        deserializer.deserialize_str(HexStrVisitor)
+        serializer.serialize_str(std::str::from_utf8(&hexs).unwrap())
     }
 }
 
-#[derive(Deserialize, Clone)]
-#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
+#[derive(Clone)]
+#[cfg_attr(test, derive(PartialEq, Eq, serde::Serialize))]
 pub struct BypassPath {
     /// The crate-relative path to the executable
     pub path: Spanned<crate::PathBuf>,
@@ -177,16 +194,28 @@ pub struct BypassPath {
     pub checksum: Option<Spanned<Checksum>>,
 }
 
-#[derive(Deserialize, Clone)]
-#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
+impl<'de> Deserialize<'de> for BypassPath {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let mut th = TableHelper::new(value)?;
+
+        let path: Spanned<String> = th.required_s("path")?;
+        let checksum = th.optional("checksum");
+        th.finalize(None)?;
+
+        Ok(Self {
+            path: path.map(),
+            checksum,
+        })
+    }
+}
+
+#[derive(Clone)]
+#[cfg_attr(test, derive(PartialEq, Eq))]
 pub struct Bypass {
-    pub name: Spanned<String>,
-    pub version: Option<VersionReq>,
+    pub spec: PackageSpec,
     pub build_script: Option<Spanned<Checksum>>,
     /// List of features that, if matched, means the build script/proc macro is
     /// not actually executed/run
-    #[serde(default)]
     pub required_features: Vec<Spanned<String>>,
     /// List of glob patterns that are allowed. This is much more loose than
     /// `allow`, but can be useful in scenarios where things like test suites or
@@ -196,22 +225,37 @@ pub struct Bypass {
     pub allow_globs: Option<Vec<Spanned<String>>>,
     /// One or more executables that are allowed. If not set all executables are
     /// allowed.
-    #[serde(default)]
     pub allow: Vec<BypassPath>,
 }
 
-#[derive(Deserialize)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
+impl<'de> Deserialize<'de> for Bypass {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let spec = PackageSpec::deserialize(value)?;
+        let mut th = TableHelper::new(value)?;
+        let build_script = th.optional("build-script");
+        let required_features = th.optional("required-features").unwrap_or_default();
+        let allow_globs = th.optional("allow-globs");
+        let allow = th.optional("allow").unwrap_or_default();
+        th.finalize(None)?;
+
+        Ok(Self {
+            spec,
+            build_script,
+            required_features,
+            allow_globs,
+            allow,
+        })
+    }
+}
+
 pub struct BuildConfig {
     /// List of crates that are allowed to have build scripts. If this is set,
     /// any crates with a build script that aren't listed here will be banned
-    pub allow_build_scripts: Option<Spanned<Vec<CrateId>>>,
+    pub allow_build_scripts: Option<Vec<PackageSpec>>,
     /// Lint level for when executables are detected within crates with build
     /// scripts or are proc macros, or are a dependency of either of them
-    #[serde(default = "crate::lint_deny")]
     pub executables: LintLevel,
     /// The lint level for interpreted scripts
-    #[serde(default = "crate::lint_allow")]
     pub interpreted: LintLevel,
     /// List of script extensions that are considered to be executable. These
     /// are always in addition to the builtin ones.
@@ -219,57 +263,96 @@ pub struct BuildConfig {
     /// The list of allowed executables, by crate
     pub bypass: Option<Vec<Bypass>>,
     /// If true, enables the built-in glob patterns
-    #[serde(default)]
     pub enable_builtin_globs: bool,
     /// If true, all dependencies of proc macro crates or crates with build
     /// scripts are also checked for executables/glob patterns
-    #[serde(default)]
     pub include_dependencies: bool,
     /// If true, workspace crates are included
-    #[serde(default)]
     pub include_workspace: bool,
     /// If true, archive files are counted as native executables
-    #[serde(default)]
     pub include_archives: bool,
 }
 
-#[derive(Deserialize)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
+impl<'de> Deserialize<'de> for BuildConfig {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let mut th = TableHelper::new(value)?;
+        let allow_build_scripts = th.optional("allow-build-scripts");
+        let executables = th.optional("executables").unwrap_or(LintLevel::Deny);
+        let interpreted = th.optional("interpreted").unwrap_or(LintLevel::Allow);
+        let script_extensions = th.optional("script-extensions");
+        let bypass = th.optional("bypass");
+        let enable_builtin_globs = th.optional("enable-builtin-globs").unwrap_or_default();
+        let include_dependencies = th.optional("include-dependencies").unwrap_or_default();
+        let include_workspace = th.optional("include-workspace").unwrap_or_default();
+        let include_archives = th.optional("include-archives").unwrap_or_default();
+        th.finalize(None)?;
+
+        Ok(Self {
+            allow_build_scripts,
+            executables,
+            interpreted,
+            script_extensions,
+            bypass,
+            enable_builtin_globs,
+            include_dependencies,
+            include_workspace,
+            include_archives,
+        })
+    }
+}
+
+#[derive(Clone)]
+#[cfg_attr(test, derive(Debug, PartialEq, Eq, serde::Serialize))]
+pub struct TreeSkipExtended {
+    pub depth: Option<usize>,
+    /// Reason the tree is being skipped
+    pub reason: Option<Reason>,
+}
+
+impl<'de> Deserialize<'de> for TreeSkipExtended {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let reason = if value.has_key("reason") {
+            Some(Reason::deserialize(value)?)
+        } else {
+            None
+        };
+
+        let mut th = TableHelper::new(value)?;
+        let depth = th.optional("depth");
+        th.finalize(None)?;
+        Ok(Self { depth, reason })
+    }
+}
+
+pub type CrateBan = PackageSpecOrExtended<CrateBanExtended>;
+pub type CrateAllow = PackageSpecOrExtended<Reason>;
+pub type CrateSkip = PackageSpecOrExtended<Reason>;
+pub type TreeSkip = PackageSpecOrExtended<TreeSkipExtended>;
+
 pub struct Config {
     /// How to handle multiple versions of the same crate
-    #[serde(default = "crate::lint_warn")]
     pub multiple_versions: LintLevel,
-    #[serde(default)]
     pub multiple_versions_include_dev: bool,
     /// How the duplicate graphs are highlighted
-    #[serde(default = "highlight")]
     pub highlight: GraphHighlight,
     /// The crates that will cause us to emit failures
-    #[serde(default)]
     pub deny: Vec<CrateBan>,
     /// If specified, means only the listed crates are allowed
-    #[serde(default)]
-    pub allow: Vec<Spanned<CrateId>>,
+    pub allow: Vec<CrateAllow>,
     /// Allows specifying features that are or are not allowed on crates
-    #[serde(default)]
     pub features: Vec<CrateFeatures>,
     /// The default lint level for default features for external, non-workspace
     /// crates, can be overriden in `features` on a crate by crate basis
-    #[serde(default)]
     pub external_default_features: Option<Spanned<LintLevel>>,
     /// The default lint level for default features for workspace crates, can be
     /// overriden in `features` on a crate by crate basis
-    #[serde(default)]
     pub workspace_default_features: Option<Spanned<LintLevel>>,
     /// If specified, disregards the crate completely
-    #[serde(default)]
-    pub skip: Vec<Spanned<CrateId>>,
+    pub skip: Vec<CrateSkip>,
     /// If specified, disregards the crate's transitive dependencies
     /// down to a certain depth
-    #[serde(default)]
-    pub skip_tree: Vec<Spanned<TreeSkip>>,
+    pub skip_tree: Vec<TreeSkip>,
     /// How to handle wildcard dependencies
-    #[serde(default = "crate::lint_allow")]
     pub wildcards: LintLevel,
     /// Wildcard dependencies defined using path attributes will be treated as
     /// if they were [`LintLevel::Allow`] for private crates, but other wildcard
@@ -277,11 +360,10 @@ pub struct Config {
     ///
     /// crates.io does not allow packages to be published with path dependencies,
     /// thus this rule will not effect public packages.
-    #[serde(default)]
     pub allow_wildcard_paths: bool,
     /// Deprecated and moved into `build.allow_build_scripts`, will eventually
     /// be removed
-    pub allow_build_scripts: Option<Spanned<Vec<CrateId>>>,
+    pub allow_build_scripts: Option<Spanned<Vec<PackageSpec>>>,
     /// Options for crates that run at build time
     pub build: Option<BuildConfig>,
 }
@@ -307,122 +389,151 @@ impl Default for Config {
     }
 }
 
+impl<'de> Deserialize<'de> for Config {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let mut th = TableHelper::new(value)?;
+
+        let multiple_versions = th.optional("multiple-versions").unwrap_or(LintLevel::Warn);
+        let multiple_versions_include_dev = th
+            .optional("multiple-versions-include-dev")
+            .unwrap_or_default();
+        let highlight = th.optional("highlight").unwrap_or_default();
+        let deny = th.optional("deny").unwrap_or_default();
+        let allow = th.optional("allow").unwrap_or_default();
+        let features = th.optional("features").unwrap_or_default();
+        let external_default_features = th.optional("external-default-features");
+        let workspace_default_features = th.optional("workspace-default-features");
+        let skip = th.optional("skip").unwrap_or_default();
+        let skip_tree = th.optional("skip-tree").unwrap_or_default();
+        let wildcards = th.optional("wildcards").unwrap_or(LintLevel::Allow);
+        let allow_wildcard_paths = th.optional("allow-wildcard-paths").unwrap_or_default();
+        let allow_build_scripts = th.optional("allow-build-scripts");
+        let build = th.optional("build");
+
+        th.finalize(None)?;
+
+        Ok(Self {
+            multiple_versions,
+            multiple_versions_include_dev,
+            highlight,
+            deny,
+            allow,
+            features,
+            external_default_features,
+            workspace_default_features,
+            skip,
+            skip_tree,
+            wildcards,
+            allow_wildcard_paths,
+            allow_build_scripts,
+            build,
+        })
+    }
+}
+
 impl crate::cfg::UnvalidatedConfig for Config {
     type ValidCfg = ValidConfig;
 
-    fn validate(
-        self,
-        cfg_file: FileId,
-        files: &mut crate::diag::Files,
-        diags: &mut Vec<Diagnostic>,
-    ) -> Self::ValidCfg {
-        let from = |s: Spanned<CrateId>| {
-            Skrate::new(
-                KrateId {
-                    name: s.value.name,
-                    version: s.value.version,
-                },
-                s.span,
-            )
-        };
+    fn validate(self, mut ctx: ValidationContext<'_>) -> Self::ValidCfg {
+        let cfg_id = ctx.cfg_id;
 
-        let (deny_multiple_versions, deny): (Vec<_>, Vec<_>) =
-            self.deny.into_iter().partition(|kb| {
-                kb.deny_multiple_versions
-                    .as_ref()
-                    .map_or(false, |spanned| spanned.value)
-            });
+        let (denied_multiple_versions, denied) = {
+            let mut dmulti = Vec::new();
+            let mut denied = Vec::new();
+            for deny_spec in self.deny {
+                let spec = deny_spec.spec;
 
-        let denied: Vec<_> = deny
-            .into_iter()
-            .map(|cb| KrateBan {
-                id: Skrate::new(
-                    KrateId {
-                        name: cb.name.value,
-                        version: cb.version,
-                    },
-                    cb.name.span,
-                ),
-                wrappers: cb.wrappers.map(|spanned| spanned.value),
-            })
-            .collect();
+                let inner = if let Some(extended) = deny_spec.inner {
+                    let dmv = extended.deny_multiple_versions;
+                    let wrappers = extended.wrappers;
 
-        let denied_multiple_versions: Vec<_> = deny_multiple_versions
-            .into_iter()
-            .map(|cb| {
-                let wrappers = cb.wrappers.filter(|spanned| !spanned.value.is_empty());
-                if let Some(wrappers) = wrappers {
-                    // cb.multiple_versions is guaranteed to be Some(_) by the
-                    // earlier call to `partition`
-                    let multiple_versions = cb.deny_multiple_versions.unwrap();
-                    diags.push(
-                        Diagnostic::error()
-                            .with_message(
-                                "a crate ban was specified with both `wrappers` and `multiple-versions`",
-                            )
-                            .with_labels(vec![
-                                Label::secondary(cfg_file, wrappers.span)
-                                    .with_message("has one or more `wrappers`"),
-                                Label::secondary(cfg_file, multiple_versions.span)
-                                    .with_message("has `multiple-versions` set to true"),
-                            ]),
-                    );
-                }
+                    if let Some((dmv, wrappers)) = dmv.as_ref().zip(wrappers.as_ref()) {
+                        if dmv.value && !wrappers.value.is_empty() {
+                            ctx.push(
+                                Diagnostic::error()
+                                    .with_message(
+                                        "a crate ban was specified with both `wrappers` and `deny-multiple-versions` = true",
+                                    )
+                                    .with_labels(vec![
+                                        Label::secondary(cfg_id, wrappers.span)
+                                            .with_message(format!("has {} `wrappers`", wrappers.value.len())),
+                                        Label::secondary(cfg_id, dmv.span)
+                                            .with_message("has `deny-multiple-versions` set to true"),
+                                    ]),
+                            );
+                            continue;
+                        }
+                    }
 
-                Skrate::new(
-                    KrateId {
-                        name: cb.name.value,
-                        version: cb.version,
-                    },
-                    cb.name.span,
-                )
-            })
-            .collect();
+                    if dmv.map_or(false, |d| d.value) {
+                        dmulti.push(spec);
+                        continue;
+                    }
+
+                    Some(KrateBan {
+                        wrappers: wrappers.map(|sv| sv.value),
+                        reason: extended.reason,
+                        use_instead: extended.use_instead,
+                    })
+                } else {
+                    None
+                };
+
+                denied.push(ValidKrateBan { spec, inner });
+            }
+
+            (dmulti, denied)
+        };
 
-        let allowed: Vec<_> = self.allow.into_iter().map(from).collect();
-        let skipped: Vec<_> = self.skip.into_iter().map(from).collect();
+        let allowed = self.allow;
+        let skipped = self.skip;
 
-        let dupe_crate_diag = |first: (&Skrate, &str), second: (&Skrate, &str)| -> Diagnostic {
-            Diagnostic::error()
+        let dupe_crate_diag = |ctx: &mut ValidationContext<'_>,
+                               first: (&PackageSpec, &str),
+                               second: (&PackageSpec, &str)| {
+            let diag = Diagnostic::error()
                 .with_message(format!(
                     "a crate was specified in both `{}` and `{}`",
                     second.1, first.1
                 ))
                 .with_labels(vec![
-                    Label::secondary(cfg_file, first.0.span.clone())
+                    Label::secondary(cfg_id, first.0.name.span)
                         .with_message(format!("marked as `{}`", first.1)),
-                    Label::secondary(cfg_file, second.0.span.clone())
+                    Label::secondary(cfg_id, second.0.name.span)
                         .with_message(format!("marked as `{}`", second.1)),
-                ])
+                ]);
+
+            ctx.push(diag);
         };
 
-        let dupe_feature_diag = |krate: &Skrate,
+        let dupe_feature_diag = |ctx: &mut ValidationContext<'_>,
+                                 krate: &PackageSpec,
                                  allow: &Spanned<String>,
-                                 deny: &Spanned<String>|
-         -> Diagnostic {
-            Diagnostic::error()
+                                 deny: &Spanned<String>| {
+            let diag = Diagnostic::error()
                 .with_message("a crate feature was specified as both allowed and denied")
                 .with_labels(vec![
-                    Label::primary(cfg_file, krate.span.clone()).with_message("crate ban entry"),
-                    Label::secondary(cfg_file, allow.span.clone())
-                        .with_message("marked as `allow`"),
-                    Label::secondary(cfg_file, deny.span.clone()).with_message("marked as `deny`"),
-                ])
+                    Label::primary(cfg_id, krate.name.span).with_message("crate ban entry"),
+                    Label::secondary(cfg_id, allow.span).with_message("marked as `allow`"),
+                    Label::secondary(cfg_id, deny.span).with_message("marked as `deny`"),
+                ]);
+
+            ctx.push(diag);
         };
 
         for d in &denied {
-            if let Some(dupe) = exact_match(&allowed, &d.id.value) {
-                diags.push(dupe_crate_diag((&d.id, "deny"), (dupe, "allow")));
+            if let Some(dupe) = exact_match(&allowed, &d.spec) {
+                dupe_crate_diag(&mut ctx, (&d.spec, "deny"), (dupe, "allow"));
             }
 
-            if let Some(dupe) = exact_match(&skipped, &d.id.value) {
-                diags.push(dupe_crate_diag((&d.id, "deny"), (dupe, "skip")));
+            if let Some(dupe) = exact_match(&skipped, &d.spec) {
+                dupe_crate_diag(&mut ctx, (&d.spec, "deny"), (dupe, "skip"));
             }
         }
 
         for all in &allowed {
-            if let Some(dupe) = exact_match(&skipped, &all.value) {
-                diags.push(dupe_crate_diag((all, "allow"), (dupe, "skip")));
+            if let Some(dupe) = exact_match(&skipped, &all.spec) {
+                dupe_crate_diag(&mut ctx, (&all.spec, "allow"), (dupe, "skip"));
             }
         }
 
@@ -431,27 +542,21 @@ impl crate::cfg::UnvalidatedConfig for Config {
             .features
             .into_iter()
             .map(|cf| {
-                let id = Skrate::new(
-                    KrateId {
-                        name: cf.name.value,
-                        version: cf.version,
-                    },
-                    cf.name.span,
-                );
-
+                let spec = cf.spec;
                 for allowed in &cf.allow.value {
                     if let Some(denied) = cf.deny.iter().find(|df| df.value == allowed.value) {
-                        diags.push(dupe_feature_diag(&id, allowed, denied));
+                        dupe_feature_diag(&mut ctx, &spec, allowed, denied);
                     }
                 }
 
-                KrateFeatures {
-                    id,
+                ValidKrateFeatures {
+                    spec,
                     features: Features {
                         allow: cf.allow,
                         deny: cf.deny,
                         exact: cf.exact,
                     },
+                    reason: cf.reason.map(Reason::from),
                 }
             })
             .collect();
@@ -463,24 +568,23 @@ impl crate::cfg::UnvalidatedConfig for Config {
                 for ext in extensions {
                     // This top level config should only be extensions, not glob patterns
                     if !ext.value.is_ascii() {
-                        diags.push(
+                        ctx.diagnostics.push(
                             Diagnostic::error()
                                 .with_message("non-ascii file extension provided")
-                                .with_labels(vec![Label::primary(cfg_file, ext.span.clone())
+                                .with_labels(vec![Label::primary(ctx.cfg_id, ext.span)
                                     .with_message("invalid extension")]),
                         );
                         continue;
                     }
 
                     if let Some(i) = ext.value.chars().position(|c| !c.is_ascii_alphanumeric()) {
-                        diags.push(
+                        ctx.diagnostics.push(
                             Diagnostic::error()
                                 .with_message("invalid file extension provided")
                                 .with_labels(vec![
-                                    Label::primary(cfg_file, ext.span.clone())
-                                        .with_message("extension"),
+                                    Label::primary(ctx.cfg_id, ext.span).with_message("extension"),
                                     Label::secondary(
-                                        cfg_file,
+                                        ctx.cfg_id,
                                         ext.span.start + i..ext.span.start + i + 1,
                                     )
                                     .with_message("invalid character"),
@@ -494,10 +598,10 @@ impl crate::cfg::UnvalidatedConfig for Config {
                             gsb.add(glob, GlobPattern::User(ext));
                         }
                         Err(err) => {
-                            diags.push(
+                            ctx.diagnostics.push(
                                 Diagnostic::error()
                                     .with_message(format!("invalid glob pattern: {err}"))
-                                    .with_labels(vec![Label::primary(cfg_file, ext.span.clone())
+                                    .with_labels(vec![Label::primary(ctx.cfg_id, ext.span)
                                         .with_message("extension")]),
                             );
                         }
@@ -506,11 +610,11 @@ impl crate::cfg::UnvalidatedConfig for Config {
             }
 
             if bc.enable_builtin_globs {
-                load_builtin_globs(files, &mut gsb);
+                load_builtin_globs(ctx.files, &mut gsb);
             }
 
             let script_extensions = gsb.build().unwrap_or_else(|err| {
-                diags
+                ctx.diagnostics
                     .push(Diagnostic::error().with_message(format!(
                         "failed to build script extensions glob set: {err}"
                     )));
@@ -521,6 +625,8 @@ impl crate::cfg::UnvalidatedConfig for Config {
                 let mut aex = Vec::new();
 
                 for aexe in aexes {
+                    let spec = aexe.spec;
+
                     let allow_globs = if let Some(allow_globs) = aexe.allow_globs {
                         let mut gsb = GlobsetBuilder::new();
 
@@ -530,13 +636,10 @@ impl crate::cfg::UnvalidatedConfig for Config {
                                     gsb.add(glob, GlobPattern::User(ag));
                                 }
                                 Err(err) => {
-                                    diags.push(
+                                    ctx.diagnostics.push(
                                         Diagnostic::error()
                                             .with_message(format!("invalid glob pattern: {err}"))
-                                            .with_labels(vec![Label::primary(
-                                                cfg_file,
-                                                ag.span.clone(),
-                                            )]),
+                                            .with_labels(vec![Label::primary(ctx.cfg_id, ag.span)]),
                                     );
                                 }
                             }
@@ -545,9 +648,10 @@ impl crate::cfg::UnvalidatedConfig for Config {
                         match gsb.build() {
                             Ok(set) => Some(set),
                             Err(err) => {
-                                diags.push(Diagnostic::error().with_message(format!(
-                                    "failed to build script extensions glob set: {err}"
-                                )));
+                                ctx.diagnostics
+                                    .push(Diagnostic::error().with_message(format!(
+                                        "failed to build script extensions glob set: {err}"
+                                    )));
                                 None
                             }
                         }
@@ -559,13 +663,10 @@ impl crate::cfg::UnvalidatedConfig for Config {
                     allow.retain(|ae| {
                         let keep = ae.path.value.is_relative();
                         if !keep {
-                            diags.push(
+                            ctx.diagnostics.push(
                                 Diagnostic::error()
                                     .with_message("absolute paths are not allowed")
-                                    .with_labels(vec![Label::primary(
-                                        cfg_file,
-                                        ae.path.span.clone(),
-                                    )]),
+                                    .with_labels(vec![Label::primary(ctx.cfg_id, ae.path.span)]),
                             );
                         }
 
@@ -574,8 +675,7 @@ impl crate::cfg::UnvalidatedConfig for Config {
                     allow.sort_by(|a, b| a.path.value.cmp(&b.path.value));
 
                     aex.push(ValidBypass {
-                        name: aexe.name,
-                        version: aexe.version,
+                        spec,
                         build_script: aexe.build_script,
                         required_features: aexe.required_features,
                         allow,
@@ -598,14 +698,14 @@ impl crate::cfg::UnvalidatedConfig for Config {
                 include_archives: bc.include_archives,
                 interpreted: bc.interpreted,
             })
-        } else if let Some(allow_build_scripts) = self.allow_build_scripts {
-            diags.push(Diagnostic::warning()
+        } else if let Some(abs) = self.allow_build_scripts {
+            ctx.push(Diagnostic::warning()
                 .with_message("[bans.allow-build-scripts] has been deprecated in favor of [bans.build.allow-build-scripts], this will become an error in the future")
                 .with_labels(vec![
-                    Label::primary(cfg_file, allow_build_scripts.span.clone())
+                    Label::primary(ctx.cfg_id, abs.span)
                 ]));
             Some(ValidBuildConfig {
-                allow_build_scripts: Some(allow_build_scripts),
+                allow_build_scripts: Some(abs.value),
                 executables: LintLevel::Allow,
                 script_extensions: ValidGlobSet::default(),
                 bypass: Vec::new(),
@@ -619,7 +719,7 @@ impl crate::cfg::UnvalidatedConfig for Config {
         };
 
         ValidConfig {
-            file_id: cfg_file,
+            file_id: ctx.cfg_id,
             multiple_versions: self.multiple_versions,
             multiple_versions_include_dev: self.multiple_versions_include_dev,
             highlight: self.highlight,
@@ -632,11 +732,7 @@ impl crate::cfg::UnvalidatedConfig for Config {
             skipped,
             wildcards: self.wildcards,
             allow_wildcard_paths: self.allow_wildcard_paths,
-            tree_skipped: self
-                .skip_tree
-                .into_iter()
-                .map(crate::Spanned::from)
-                .collect(),
+            tree_skipped: self.skip_tree,
             build,
         }
     }
@@ -645,15 +741,14 @@ impl crate::cfg::UnvalidatedConfig for Config {
 fn load_builtin_globs(files: &mut crate::diag::Files, gsb: &mut GlobsetBuilder) {
     const BUILTIN_GLOBS: &str = include_str!("builtin_globs.toml");
 
-    #[derive(Deserialize)]
-    struct Builtin {
-        globs: Vec<Spanned<String>>,
-    }
+    let mut biv = toml_span::parse(BUILTIN_GLOBS).expect("failed to parse builtin_globs.toml");
+    let mut th = TableHelper::new(&mut biv).expect("builtin_globs.toml does not have a root table");
+
+    let globs: Vec<Spanned<String>> = th.required("globs").expect("failed to find 'globs' array");
 
-    let bi: Builtin = toml::from_str(BUILTIN_GLOBS).expect("failed to parse builtin_globs.toml");
     let file_id = files.add("builtin_globs.toml", BUILTIN_GLOBS.to_owned());
 
-    for glob in bi.globs {
+    for glob in globs {
         gsb.add(
             globset::Glob::new(&glob.value).expect("failed to parse builtin glob"),
             GlobPattern::Builtin((glob, file_id)),
@@ -662,31 +757,38 @@ fn load_builtin_globs(files: &mut crate::diag::Files, gsb: &mut GlobsetBuilder)
 }
 
 #[inline]
-pub(crate) fn exact_match<'v>(arr: &'v [Skrate], id: &'_ KrateId) -> Option<&'v Skrate> {
-    arr.iter().find(|sid| *sid == id)
+pub(crate) fn exact_match<'v, T>(
+    arr: &'v [PackageSpecOrExtended<T>],
+    id: &'_ PackageSpec,
+) -> Option<&'v PackageSpec> {
+    arr.iter()
+        .find_map(|sid| (&sid.spec == id).then_some(&sid.spec))
 }
 
-pub(crate) type Skrate = Spanned<KrateId>;
-
-#[cfg_attr(test, derive(Debug))]
+#[cfg_attr(test, derive(serde::Serialize))]
 pub(crate) struct KrateBan {
-    pub id: Skrate,
     pub wrappers: Option<Vec<Spanned<String>>>,
+    pub reason: Option<Reason>,
+    pub use_instead: Option<Spanned<String>>,
 }
 
-#[cfg_attr(test, derive(Debug))]
+pub(crate) type ValidKrateBan = PackageSpecOrExtended<KrateBan>;
+
+#[cfg_attr(test, derive(serde::Serialize))]
 pub struct Features {
     pub allow: Spanned<Vec<Spanned<String>>>,
     pub deny: Vec<Spanned<String>>,
     pub exact: Spanned<bool>,
 }
 
-#[cfg_attr(test, derive(Debug))]
-pub(crate) struct KrateFeatures {
-    pub id: Skrate,
+#[cfg_attr(test, derive(serde::Serialize))]
+pub(crate) struct ValidKrateFeatures {
+    pub spec: PackageSpec,
     pub features: Features,
+    pub reason: Option<Reason>,
 }
 
+#[cfg_attr(test, derive(serde::Serialize))]
 pub enum GlobPattern {
     Builtin((Spanned<String>, FileId)),
     User(Spanned<String>),
@@ -727,6 +829,21 @@ pub struct ValidGlobSet {
     pub(crate) patterns: Vec<GlobPattern>,
 }
 
+#[cfg(test)]
+impl serde::Serialize for ValidGlobSet {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        serializer.collect_seq(self.patterns.iter().filter_map(|gp| {
+            let GlobPattern::User(gp) = gp else {
+                return None;
+            };
+            Some(gp)
+        }))
+    }
+}
+
 impl Default for ValidGlobSet {
     fn default() -> Self {
         Self {
@@ -748,17 +865,18 @@ impl ValidGlobSet {
     }
 }
 
+#[cfg_attr(test, derive(serde::Serialize))]
 pub struct ValidBypass {
-    pub name: Spanned<String>,
-    pub version: Option<VersionReq>,
+    pub spec: PackageSpec,
     pub build_script: Option<Spanned<Checksum>>,
     pub required_features: Vec<Spanned<String>>,
     pub allow_globs: Option<ValidGlobSet>,
     pub allow: Vec<BypassPath>,
 }
 
+#[cfg_attr(test, derive(serde::Serialize))]
 pub struct ValidBuildConfig {
-    pub allow_build_scripts: Option<Spanned<Vec<CrateId>>>,
+    pub allow_build_scripts: Option<Vec<PackageSpec>>,
     pub executables: LintLevel,
     pub script_extensions: ValidGlobSet,
     pub bypass: Vec<ValidBypass>,
@@ -768,19 +886,23 @@ pub struct ValidBuildConfig {
     pub interpreted: LintLevel,
 }
 
+pub type ValidTreeSkip = PackageSpecOrExtended<TreeSkipExtended>;
+pub type SpecAndReason = PackageSpecOrExtended<Reason>;
+
+#[cfg_attr(test, derive(serde::Serialize))]
 pub struct ValidConfig {
     pub file_id: FileId,
     pub multiple_versions: LintLevel,
     pub multiple_versions_include_dev: bool,
     pub highlight: GraphHighlight,
-    pub(crate) denied: Vec<KrateBan>,
-    pub(crate) denied_multiple_versions: Vec<Skrate>,
-    pub(crate) allowed: Vec<Skrate>,
-    pub(crate) features: Vec<KrateFeatures>,
+    pub(crate) denied: Vec<ValidKrateBan>,
+    pub(crate) denied_multiple_versions: Vec<PackageSpec>,
+    pub(crate) allowed: Vec<SpecAndReason>,
+    pub(crate) features: Vec<ValidKrateFeatures>,
     pub external_default_features: Option<Spanned<LintLevel>>,
     pub workspace_default_features: Option<Spanned<LintLevel>>,
-    pub(crate) skipped: Vec<Skrate>,
-    pub(crate) tree_skipped: Vec<Spanned<TreeSkip>>,
+    pub(crate) skipped: Vec<SpecAndReason>,
+    pub(crate) tree_skipped: Vec<ValidTreeSkip>,
     pub wildcards: LintLevel,
     pub allow_wildcard_paths: bool,
     pub build: Option<ValidBuildConfig>,
@@ -789,140 +911,28 @@ pub struct ValidConfig {
 #[cfg(test)]
 mod test {
     use super::*;
-    use crate::cfg::{test::*, *};
-
-    macro_rules! kid {
-        ($name:expr) => {
-            KrateId {
-                name: String::from($name),
-                version: None,
-            }
-        };
-
-        ($name:expr, $vs:expr) => {
-            KrateId {
-                name: String::from($name),
-                version: Some($vs.parse::<semver::VersionReq>().unwrap().into()),
-            }
-        };
-    }
-
-    impl PartialEq<KrateId> for KrateBan {
-        fn eq(&self, o: &KrateId) -> bool {
-            &self.id.value == o
-        }
-    }
+    use crate::test_utils::ConfigData;
 
     #[test]
     fn deserializes_ban_cfg() {
-        #[derive(Deserialize)]
-        #[serde(deny_unknown_fields)]
         struct Bans {
             bans: Config,
         }
 
-        let mut cd: ConfigData<Bans> = load("tests/cfg/bans.toml");
-
-        let mut diags = Vec::new();
-        let validated = cd.config.bans.validate(cd.id, &mut cd.files, &mut diags);
-        assert!(diags.is_empty());
-
-        assert_eq!(validated.file_id, cd.id);
-        assert_eq!(validated.multiple_versions, LintLevel::Deny);
-
-        assert_eq!(validated.wildcards, LintLevel::Deny);
-        assert!(validated.allow_wildcard_paths);
-
-        assert_eq!(validated.highlight, GraphHighlight::SimplestPath);
-        assert_eq!(
-            validated.external_default_features.unwrap().value,
-            LintLevel::Deny
-        );
-        assert_eq!(
-            validated.workspace_default_features.unwrap().value,
-            LintLevel::Warn
-        );
-
-        assert_eq!(
-            validated.allowed,
-            vec![kid!("all-versionsa"), kid!("specific-versiona", "<0.1.1")]
-        );
-
-        assert_eq!(
-            validated.denied,
-            vec![kid!("all-versionsd"), kid!("specific-versiond", "=0.1.9")]
-        );
-
-        assert_eq!(validated.skipped, vec![kid!("rand", "=0.6.5")]);
-
-        assert_eq!(
-            validated.tree_skipped,
-            vec![TreeSkip {
-                id: CrateId {
-                    name: "blah".to_owned(),
-                    version: None,
-                },
-                depth: Some(20),
-            }]
-        );
-
-        let kf = &validated.features[0];
-
-        assert_eq!(kf.id, kid!("featured-krate", "1.0"));
-        assert_eq!(kf.features.deny[0].value, "bad-feature");
-        assert_eq!(kf.features.allow.value[0].value, "good-feature");
-        assert!(kf.features.exact.value);
+        impl<'de> toml_span::Deserialize<'de> for Bans {
+            fn deserialize(
+                value: &mut toml_span::value::Value<'de>,
+            ) -> Result<Self, toml_span::DeserError> {
+                let mut th = toml_span::de_helpers::TableHelper::new(value)?;
+                let bans = th.required("bans").unwrap();
+                th.finalize(None)?;
+                Ok(Self { bans })
+            }
+        }
 
-        let mut bc = validated.build.expect("expected build config");
-        assert_eq!(
-            bc.allow_build_scripts.unwrap().value.pop().unwrap().name,
-            "all-versionsa"
-        );
-        assert_eq!(bc.executables, LintLevel::Warn);
-        assert_eq!(bc.interpreted, LintLevel::Deny);
+        let cd = ConfigData::<Bans>::load("tests/cfg/bans.toml");
+        let validated = cd.validate(|b| b.bans);
 
-        assert!(bc.script_extensions.patterns.iter().any(|gp| {
-            let GlobPattern::User(gp) = gp else {
-                return false;
-            };
-            gp.value == "cs"
-        }));
-        assert!(bc.script_extensions.patterns.iter().any(|gp| {
-            let GlobPattern::Builtin(gp) = gp else {
-                return false;
-            };
-            gp.0.value == "*.py"
-        }));
-        assert!(bc.include_dependencies);
-        assert!(bc.include_workspace);
-        assert!(bc.include_archives);
-
-        let mut bypass = bc.bypass.pop().unwrap();
-        assert_eq!(bypass.name.value, "allversionsa");
-        assert!(bypass.version.is_none());
-        assert_eq!(
-            bypass.build_script.unwrap().value,
-            "5392f0e58ad06e089462d93304dfe82337acbbefb87a0749a7dc2ed32af04af7"
-                .parse()
-                .unwrap()
-        );
-        assert_eq!(
-            bypass.required_features.pop().unwrap().value,
-            "feature-used-at-build-time"
-        );
-        assert!(bypass.allow_globs.unwrap().patterns.iter().any(|gp| {
-            let GlobPattern::User(gp) = gp else {
-                return false;
-            };
-            gp.value == "scripts/*.cs"
-        }));
-        let ba = bypass.allow.pop().unwrap();
-        assert_eq!(ba.path.value, "bin/x86_64-linux");
-        assert_eq!(
-            ba.checksum.unwrap().value,
-            "5392f0e58ad06e089462d93304dfe82337acbbefb87a0749a7dc2ed32af04af7"
-                .parse()
-                .unwrap()
-        );
+        insta::assert_json_snapshot!(validated);
     }
 }
diff --git a/src/bans/diags.rs b/src/bans/diags.rs
index 72e17ea71..65bc26cc5 100644
--- a/src/bans/diags.rs
+++ b/src/bans/diags.rs
@@ -1,7 +1,7 @@
 use std::fmt;
 
 use crate::{
-    bans::{cfg, KrateId},
+    bans::{cfg, SpecAndReason},
     diag::{
         CfgCoord, Check, Diag, Diagnostic, FileId, GraphNode, KrateCoord, Label, Pack, Severity,
     },
@@ -59,9 +59,34 @@ impl From<Code> for String {
     }
 }
 
+impl SpecAndReason {
+    pub(crate) fn to_labels(&self, spec_msg: Option<&str>) -> Vec<Label> {
+        let mut v = Vec::new();
+
+        {
+            let l = Label::primary(self.file_id, self.spec.name.span);
+            if let Some(sm) = spec_msg {
+                v.push(l.with_message(sm));
+            } else {
+                v.push(l);
+            }
+        }
+
+        if let Some(reason) = &self.reason {
+            v.push(Label::secondary(self.file_id, reason.0.span).with_message("reason"));
+        }
+
+        if let Some(ui) = &self.use_instead {
+            v.push(Label::secondary(self.file_id, ui.span).with_message("use instead"));
+        }
+
+        v
+    }
+}
+
 pub(crate) struct ExplicitlyBanned<'a> {
     pub(crate) krate: &'a Krate,
-    pub(crate) ban_cfg: CfgCoord,
+    pub(crate) ban_cfg: &'a SpecAndReason,
 }
 
 impl<'a> From<ExplicitlyBanned<'a>> for Diag {
@@ -69,14 +94,14 @@ impl<'a> From<ExplicitlyBanned<'a>> for Diag {
         Diagnostic::new(Severity::Error)
             .with_message(format!("crate '{}' is explicitly banned", eb.krate))
             .with_code(Code::Banned)
-            .with_labels(vec![eb.ban_cfg.into_label().with_message("banned here")])
+            .with_labels(eb.ban_cfg.to_labels(Some("banned here")))
             .into()
     }
 }
 
 pub(crate) struct ExplicitlyAllowed<'a> {
     pub(crate) krate: &'a Krate,
-    pub(crate) allow_cfg: CfgCoord,
+    pub(crate) allow_cfg: &'a SpecAndReason,
 }
 
 impl<'a> From<ExplicitlyAllowed<'a>> for Diag {
@@ -84,7 +109,7 @@ impl<'a> From<ExplicitlyAllowed<'a>> for Diag {
         Diagnostic::new(Severity::Note)
             .with_message(format!("crate '{}' is explicitly allowed", ea.krate))
             .with_code(Code::Allowed)
-            .with_labels(vec![ea.allow_cfg.into_label().with_message("allowed here")])
+            .with_labels(ea.allow_cfg.to_labels(Some("allowed here")))
             .into()
     }
 }
@@ -127,7 +152,7 @@ impl<'a> From<Duplicates<'a>> for Diag {
 
 pub(crate) struct Skipped<'a> {
     pub(crate) krate: &'a Krate,
-    pub(crate) skip_cfg: CfgCoord,
+    pub(crate) skip_cfg: &'a SpecAndReason,
 }
 
 impl<'a> From<Skipped<'a>> for Diag {
@@ -138,7 +163,7 @@ impl<'a> From<Skipped<'a>> for Diag {
                 sk.krate
             ))
             .with_code(Code::Skipped)
-            .with_labels(vec![sk.skip_cfg.into_label().with_message("skipped here")])
+            .with_labels(sk.skip_cfg.to_labels(Some("skipped here")))
             .into()
     }
 }
@@ -189,28 +214,18 @@ impl<'a> From<Wildcards<'a>> for Pack {
 }
 
 pub(crate) struct UnmatchedSkip<'a> {
-    pub(crate) skip_cfg: CfgCoord,
-    pub(crate) skipped_krate: &'a KrateId,
+    pub(crate) skip_cfg: &'a SpecAndReason,
 }
 
 impl<'a> From<UnmatchedSkip<'a>> for Diag {
     fn from(us: UnmatchedSkip<'a>) -> Self {
         Diagnostic::new(Severity::Warning)
-            .with_message(match &us.skipped_krate.version {
-                Some(version) => format!(
-                    "skipped crate '{} = {}' was not encountered",
-                    us.skipped_krate.name, version
-                ),
-                None => format!(
-                    "skipped crate '{}' was not encountered",
-                    us.skipped_krate.name
-                ),
-            })
+            .with_message(format!(
+                "skipped crate '{}' was not encountered",
+                us.skip_cfg.spec,
+            ))
             .with_code(Code::UnmatchedSkip)
-            .with_labels(vec![us
-                .skip_cfg
-                .into_label()
-                .with_message("unmatched skip configuration")])
+            .with_labels(us.skip_cfg.to_labels(Some("unmatched skip configuration")))
             .into()
     }
 }
@@ -258,7 +273,7 @@ impl<'a> From<BannedAllowedByWrapper<'a>> for Diag {
 }
 
 pub(crate) struct BannedUnmatchedWrapper<'a> {
-    pub(crate) ban_cfg: CfgCoord,
+    pub(crate) ban_cfg: &'a SpecAndReason,
     pub(crate) banned_krate: &'a Krate,
     pub(crate) parent_krate: &'a Krate,
 }
@@ -271,14 +286,14 @@ impl<'a> From<BannedUnmatchedWrapper<'a>> for Diag {
                 buw.parent_krate, buw.banned_krate
             ))
             .with_code(Code::UnmatchedWrapper)
-            .with_labels(vec![buw.ban_cfg.into_label().with_message("banned here")])
+            .with_labels(buw.ban_cfg.to_labels(Some("banned here")))
             .into()
     }
 }
 
 pub(crate) struct SkippedByRoot<'a> {
-    pub(crate) skip_root_cfg: CfgCoord,
     pub(crate) krate: &'a Krate,
+    pub(crate) skip_root_cfg: &'a SpecAndReason,
 }
 
 impl<'a> From<SkippedByRoot<'a>> for Diag {
@@ -286,10 +301,7 @@ impl<'a> From<SkippedByRoot<'a>> for Diag {
         Diagnostic::new(Severity::Note)
             .with_message(format!("skipping crate '{}' due to root skip", sbr.krate))
             .with_code(Code::SkippedByRoot)
-            .with_labels(vec![sbr
-                .skip_root_cfg
-                .into_label()
-                .with_message("matched skip root")])
+            .with_labels(sbr.skip_root_cfg.to_labels(Some("matched skip root")))
             .into()
     }
 }
@@ -323,10 +335,6 @@ impl<'a> From<BuildScriptNotAllowed<'a>> for Diag {
                 bs.krate
             ))
             .with_code(Code::BuildScriptNotAllowed)
-            .with_notes(vec![
-                "the `bans.allow-build-scripts` field did not contain a match for the crate"
-                    .to_owned(),
-            ])
             .into()
     }
 }
@@ -435,8 +443,9 @@ impl From<FeatureBanned<'_>> for Diag {
                 fed.feature.value, fed.krate,
             ))
             .with_code(Code::FeatureBanned)
-            .with_labels(vec![Label::primary(fed.file_id, fed.feature.span.clone())
-                .with_message("feature denied here")]);
+            .with_labels(vec![
+                Label::primary(fed.file_id, fed.feature.span).with_message("feature denied here")
+            ]);
 
         Diag {
             diag,
@@ -466,7 +475,7 @@ impl From<UnknownFeature<'_>> for Diag {
             ))
             .with_code(Code::UnknownFeature)
             .with_labels(vec![
-                Label::primary(uf.file_id, uf.feature.span.clone()).with_message("unknown feature")
+                Label::primary(uf.file_id, uf.feature.span).with_message("unknown feature")
             ]);
 
         Diag {
@@ -497,7 +506,7 @@ impl From<DefaultFeatureEnabled<'_>> for Diag {
             ))
             .with_code(Code::DefaultFeatureEnabled)
             .with_labels(vec![
-                Label::primary(dfe.file_id, dfe.level.span.clone()).with_message("lint level")
+                Label::primary(dfe.file_id, dfe.level.span).with_message("lint level")
             ]);
 
         Diag {
@@ -541,12 +550,14 @@ pub(crate) struct ExplicitPathAllowance<'a> {
 impl From<ExplicitPathAllowance<'_>> for Diag {
     fn from(pa: ExplicitPathAllowance<'_>) -> Diag {
         let mut labels =
-            vec![Label::primary(pa.file_id, pa.allowed.path.span.clone())
-                .with_message("allowed path")];
+            vec![Label::primary(pa.file_id, pa.allowed.path.span).with_message("allowed path")];
 
-        labels.extend(pa.allowed.checksum.as_ref().map(|chk| {
-            Label::secondary(pa.file_id, chk.span.clone()).with_message("matched checksum")
-        }));
+        labels.extend(
+            pa.allowed
+                .checksum
+                .as_ref()
+                .map(|chk| Label::secondary(pa.file_id, chk.span).with_message("matched checksum")),
+        );
         let diag = Diagnostic::new(Severity::Help)
             .with_message("file explicitly allowed")
             .with_code(Code::PathBypassed)
@@ -568,9 +579,9 @@ fn globs_to_labels(file_id: FileId, globs: Vec<&cfg::GlobPattern>) -> Vec<Label>
         .into_iter()
         .map(|gp| match gp {
             cfg::GlobPattern::Builtin((glob, id)) => {
-                Label::secondary(*id, glob.span.clone()).with_message("builtin")
+                Label::secondary(*id, glob.span).with_message("builtin")
             }
-            cfg::GlobPattern::User(glob) => Label::secondary(file_id, glob.span.clone()),
+            cfg::GlobPattern::User(glob) => Label::secondary(file_id, glob.span),
         })
         .collect()
 }
@@ -613,7 +624,7 @@ impl From<ChecksumMatch<'_>> for Diag {
             .with_notes(vec![format!("path = '{}'", cm.path)])
             .with_code(Code::ChecksumMatch)
             .with_labels(vec![
-                Label::primary(cm.file_id, cm.checksum.span.clone()).with_message("checksum")
+                Label::primary(cm.file_id, cm.checksum.span).with_message("checksum")
             ]);
 
         Diag {
@@ -647,8 +658,9 @@ impl From<ChecksumMismatch<'_>> for Diag {
             .with_message("file did not match the expected checksum")
             .with_notes(notes)
             .with_code(Code::ChecksumMismatch)
-            .with_labels(vec![Label::primary(cm.file_id, cm.checksum.span.clone())
-                .with_message("expected checksum")]);
+            .with_labels(vec![
+                Label::primary(cm.file_id, cm.checksum.span).with_message("expected checksum")
+            ]);
 
         Diag {
             diag,
@@ -775,7 +787,7 @@ impl From<FeaturesEnabled<'_>> for Diag {
             .with_labels(
                 fe.enabled_features
                     .into_iter()
-                    .map(|ef| Label::secondary(fe.file_id, ef.span.clone()))
+                    .map(|ef| Label::secondary(fe.file_id, ef.span))
                     .collect(),
             );
 
@@ -801,7 +813,7 @@ impl<'a> From<UnmatchedBypass<'a>> for Diag {
             .with_code(Code::UnmatchedBypass)
             .with_labels(vec![Label::primary(
                 ubc.file_id,
-                ubc.unmatched.name.span.clone(),
+                ubc.unmatched.spec.name.span,
             )
             .with_message("unmatched bypass")])
             .into()
@@ -818,10 +830,7 @@ impl<'a> From<UnmatchedPathBypass<'a>> for Diag {
         Diagnostic::new(Severity::Warning)
             .with_message("allowed path was not encountered")
             .with_code(Code::UnmatchedPathBypass)
-            .with_labels(vec![Label::primary(
-                ua.file_id,
-                ua.unmatched.path.span.clone(),
-            )])
+            .with_labels(vec![Label::primary(ua.file_id, ua.unmatched.path.span)])
             .into()
     }
 }
@@ -836,7 +845,7 @@ impl<'a> From<UnmatchedGlob<'a>> for Diag {
         Diagnostic::new(Severity::Warning)
             .with_message("glob was not encountered")
             .with_code(Code::UnmatchedGlob)
-            .with_labels(vec![Label::primary(ug.file_id, ug.unmatched.span.clone())])
+            .with_labels(vec![Label::primary(ug.file_id, ug.unmatched.span)])
             .into()
     }
 }
diff --git a/src/cargo-deny/check.rs b/src/cargo-deny/check.rs
index 286456ad5..6ec7b57b9 100644
--- a/src/cargo-deny/check.rs
+++ b/src/cargo-deny/check.rs
@@ -1,14 +1,13 @@
-use crate::stats::{AllStats, Stats};
-use anyhow::{Context, Error};
+use crate::{
+    common::ValidConfig,
+    stats::{AllStats, Stats},
+};
 use cargo_deny::{
     advisories, bans,
-    diag::{
-        CargoSpans, Diagnostic, DiagnosticCode, DiagnosticOverrides, ErrorSink, Files, Severity,
-    },
+    diag::{CargoSpans, DiagnosticCode, DiagnosticOverrides, ErrorSink, Files, Severity},
     licenses, sources, CheckCtx, PathBuf,
 };
 use log::error;
-use serde::Deserialize;
 use std::time::Instant;
 
 #[derive(clap::ValueEnum, Debug, PartialEq, Eq, Copy, Clone)]
@@ -23,7 +22,7 @@ pub enum WhichCheck {
 }
 
 #[derive(strum::EnumString, Debug, Copy, Clone, PartialEq, Eq)]
-#[strum(serialize_all = "snake_case")]
+#[strum(serialize_all = "kebab-case")]
 pub enum Level {
     Allowed,
     Warnings,
@@ -114,160 +113,6 @@ pub struct Args {
     pub which: Vec<WhichCheck>,
 }
 
-#[derive(Deserialize)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-struct Config {
-    advisories: Option<advisories::cfg::Config>,
-    bans: Option<bans::cfg::Config>,
-    licenses: Option<licenses::Config>,
-    sources: Option<sources::Config>,
-    #[serde(default)]
-    targets: Vec<crate::common::Target>,
-    #[serde(default)]
-    exclude: Vec<String>,
-    #[serde(default)]
-    features: Vec<String>,
-    feature_depth: Option<u32>,
-    #[serde(default)]
-    all_features: bool,
-    #[serde(default)]
-    no_default_features: bool,
-    /// By default, dev dependencies for workspace crates are not ignored
-    #[serde(default)]
-    exclude_dev: bool,
-}
-
-struct ValidConfig {
-    advisories: advisories::cfg::ValidConfig,
-    bans: bans::cfg::ValidConfig,
-    licenses: licenses::ValidConfig,
-    sources: sources::ValidConfig,
-    targets: Vec<(krates::Target, Vec<String>)>,
-    exclude: Vec<String>,
-    features: Vec<String>,
-    feature_depth: Option<u32>,
-    all_features: bool,
-    no_default_features: bool,
-    exclude_dev: bool,
-}
-
-impl ValidConfig {
-    fn load(
-        cfg_path: Option<PathBuf>,
-        exceptions_cfg_path: Option<PathBuf>,
-        files: &mut Files,
-        log_ctx: crate::common::LogContext,
-    ) -> Result<Self, Error> {
-        use cargo_deny::UnvalidatedConfig;
-
-        let (cfg_contents, cfg_path) = match cfg_path {
-            Some(cfg_path) if cfg_path.exists() => (
-                std::fs::read_to_string(&cfg_path)
-                    .with_context(|| format!("failed to read config from {cfg_path}"))?,
-                cfg_path,
-            ),
-            Some(cfg_path) => {
-                log::warn!(
-                    "config path '{cfg_path}' doesn't exist, falling back to default config"
-                );
-                (String::new(), cfg_path)
-            }
-            None => {
-                log::warn!("unable to find a config path, falling back to default config");
-                (String::new(), PathBuf::from("deny.default.toml"))
-            }
-        };
-
-        let cfg: Config = toml::from_str(&cfg_contents)
-            .with_context(|| format!("failed to deserialize config from '{cfg_path}'"))?;
-
-        log::info!("using config from {cfg_path}");
-
-        let id = files.add(&cfg_path, cfg_contents);
-
-        let validate = || -> (Vec<Diagnostic>, Self) {
-            // Accumulate all configuration diagnostics rather than earlying out so
-            // the user has the full list of problems to fix
-
-            let mut diags = Vec::new();
-
-            let advisories = cfg
-                .advisories
-                .unwrap_or_default()
-                .validate(id, files, &mut diags);
-
-            let bans = cfg.bans.unwrap_or_default().validate(id, files, &mut diags);
-            let mut licenses = cfg
-                .licenses
-                .unwrap_or_default()
-                .validate(id, files, &mut diags);
-
-            // Allow for project-local exceptions. Relevant in corporate environments.
-            // https://github.com/EmbarkStudios/cargo-deny/issues/541
-            if let Some(ecp) = exceptions_cfg_path {
-                licenses::cfg::load_exceptions(&mut licenses, ecp, files, &mut diags);
-            };
-
-            let sources = cfg
-                .sources
-                .unwrap_or_default()
-                .validate(id, files, &mut diags);
-
-            let targets = crate::common::load_targets(cfg.targets, &mut diags, id);
-            let exclude = cfg.exclude;
-            let feature_depth = cfg.feature_depth;
-            let all_features = cfg.all_features;
-            let no_default_features = cfg.no_default_features;
-            let features = cfg.features;
-            let exclude_dev = cfg.exclude_dev;
-
-            (
-                diags,
-                Self {
-                    advisories,
-                    bans,
-                    licenses,
-                    sources,
-                    targets,
-                    exclude,
-                    feature_depth,
-                    all_features,
-                    no_default_features,
-                    features,
-                    exclude_dev,
-                },
-            )
-        };
-
-        let (diags, valid_cfg) = validate();
-
-        let has_errors = diags.iter().any(|d| d.severity >= Severity::Error);
-
-        let print = |diags: Vec<Diagnostic>| {
-            if diags.is_empty() {
-                return;
-            }
-
-            if let Some(printer) = crate::common::DiagPrinter::new(log_ctx, None, None) {
-                let mut lock = printer.lock();
-                for diag in diags {
-                    lock.print(diag, files);
-                }
-            }
-        };
-
-        print(diags);
-
-        // While we could continue in the face of configuration errors, the user
-        // may end up with unexpected results, so just abort so they can fix them
-        if has_errors {
-            anyhow::bail!("failed to validate configuration file {cfg_path}");
-        } else {
-            Ok(valid_cfg)
-        }
-    }
-}
-
 pub(crate) fn cmd(
     log_ctx: crate::common::LogContext,
     args: Args,
@@ -279,13 +124,8 @@ pub(crate) fn cmd(
         bans,
         licenses,
         sources,
-        targets,
-        exclude,
-        feature_depth,
-        all_features,
-        no_default_features,
-        features,
-        exclude_dev,
+        graph,
+        output,
     } = ValidConfig::load(
         krate_ctx.get_config_path(args.config.clone()),
         krate_ctx.get_local_exceptions_path(),
@@ -316,15 +156,15 @@ pub(crate) fn cmd(
             .iter()
             .any(|w| *w == WhichCheck::Sources || *w == WhichCheck::All);
 
-    let feature_depth = args.feature_depth.or(feature_depth);
+    let feature_depth = args.feature_depth.or(output.feature_depth);
 
-    krate_ctx.all_features |= all_features;
-    krate_ctx.no_default_features |= no_default_features;
-    krate_ctx.exclude_dev |= exclude_dev | args.exclude_dev;
+    krate_ctx.all_features |= graph.all_features;
+    krate_ctx.no_default_features |= graph.no_default_features;
+    krate_ctx.exclude_dev |= graph.exclude_dev | args.exclude_dev;
 
     // If not specified on the cmd line, fallback to the feature related config options
     if krate_ctx.features.is_empty() {
-        krate_ctx.features = features;
+        krate_ctx.features = graph.features;
     }
 
     let mut krates = None;
@@ -397,7 +237,7 @@ pub(crate) fn cmd(
                 log::info!("fetched crates in {:?}", start.elapsed());
             }
 
-            let gathered = krate_ctx.gather_krates(targets, exclude);
+            let gathered = krate_ctx.gather_krates(graph.targets, graph.exclude);
 
             if let Ok(krates) = &gathered {
                 krate_spans = Some(cargo_deny::diag::KrateSpans::synthesize(krates));
@@ -416,7 +256,7 @@ pub(crate) fn cmd(
                         .map(|us| us.as_ref().clone())
                         .collect(),
                     if args.disable_fetch {
-                        advisories::Fetch::Disallow(advisories.maximum_db_staleness)
+                        advisories::Fetch::Disallow(advisories.maximum_db_staleness.value)
                     } else if advisories.git_fetch_with_cli {
                         advisories::Fetch::AllowWithGitCli
                     } else {
diff --git a/src/cargo-deny/common.rs b/src/cargo-deny/common.rs
index 9e4afc57c..af3650587 100644
--- a/src/cargo-deny/common.rs
+++ b/src/cargo-deny/common.rs
@@ -4,50 +4,14 @@ use cargo_deny::{
     PathBuf,
 };
 
+mod cfg;
+pub use cfg::ValidConfig;
+
 pub(crate) fn load_license_store() -> Result<LicenseStore, anyhow::Error> {
     log::debug!("loading license store...");
     LicenseStore::from_cache()
 }
 
-#[derive(serde::Deserialize)]
-pub(crate) struct Target {
-    pub(crate) triple: cargo_deny::Spanned<String>,
-    #[serde(default)]
-    pub(crate) features: Vec<String>,
-}
-
-use cargo_deny::diag::Diagnostic;
-
-pub(crate) fn load_targets(
-    in_targets: Vec<Target>,
-    diagnostics: &mut Vec<Diagnostic>,
-    id: FileId,
-) -> Vec<(krates::Target, Vec<String>)> {
-    let mut targets = Vec::with_capacity(in_targets.len());
-    for target in in_targets {
-        let triple = target.triple.as_ref();
-
-        let filter: krates::Target = triple.into();
-
-        if let krates::Target::Unknown(_) = &filter {
-            diagnostics.push(
-                    Diagnostic::warning()
-                        .with_message(format!("unknown target `{triple}` specified"))
-                        .with_labels(vec![
-                    cargo_deny::diag::Label::primary(
-                        id,
-                        target.triple.span().clone()).with_message(
-                        "the triple won't be evaluated against cfg() sections, just explicit triples"),
-                    ]),
-                );
-        }
-
-        targets.push((filter, target.features));
-    }
-
-    targets
-}
-
 pub struct KrateContext {
     pub manifest_path: PathBuf,
     pub workspace: bool,
@@ -147,7 +111,7 @@ impl KrateContext {
 
     pub fn gather_krates(
         self,
-        cfg_targets: Vec<(krates::Target, Vec<String>)>,
+        cfg_targets: Vec<cargo_deny::root_cfg::Target>,
         cfg_excludes: Vec<String>,
     ) -> Result<cargo_deny::Krates, anyhow::Error> {
         log::info!("gathering crates for {}", self.manifest_path);
@@ -177,7 +141,11 @@ impl KrateContext {
         if !self.targets.is_empty() {
             gb.include_targets(self.targets.into_iter().map(|t| (t, Vec::new())));
         } else if !cfg_targets.is_empty() {
-            gb.include_targets(cfg_targets);
+            gb.include_targets(
+                cfg_targets
+                    .into_iter()
+                    .map(|targ| (targ.filter.value, targ.features)),
+            );
         }
 
         gb.ignore_kind(
diff --git a/src/cargo-deny/common/cfg.rs b/src/cargo-deny/common/cfg.rs
new file mode 100644
index 000000000..2fe7af29a
--- /dev/null
+++ b/src/cargo-deny/common/cfg.rs
@@ -0,0 +1,197 @@
+use anyhow::{Context as _, Result};
+use cargo_deny::{
+    diag::{Diagnostic, Files, Severity},
+    root_cfg::{GraphConfig, OutputConfig},
+    PathBuf, {advisories, bans, licenses, sources},
+};
+
+pub struct ValidConfig {
+    pub advisories: advisories::cfg::ValidConfig,
+    pub bans: bans::cfg::ValidConfig,
+    pub licenses: licenses::cfg::ValidConfig,
+    pub sources: sources::cfg::ValidConfig,
+    pub graph: GraphConfig,
+    pub output: OutputConfig,
+}
+
+impl ValidConfig {
+    pub fn load(
+        cfg_path: Option<PathBuf>,
+        exceptions_cfg_path: Option<PathBuf>,
+        files: &mut Files,
+        log_ctx: crate::common::LogContext,
+    ) -> Result<Self> {
+        use cargo_deny::UnvalidatedConfig;
+
+        let (cfg_contents, cfg_path) = match cfg_path {
+            Some(cfg_path) if cfg_path.exists() => (
+                std::fs::read_to_string(&cfg_path)
+                    .with_context(|| format!("failed to read config from {cfg_path}"))?,
+                cfg_path,
+            ),
+            Some(cfg_path) => {
+                log::warn!(
+                    "config path '{cfg_path}' doesn't exist, falling back to default config"
+                );
+                (String::new(), cfg_path)
+            }
+            None => {
+                log::warn!("unable to find a config path, falling back to default config");
+                (String::new(), PathBuf::from("deny.default.toml"))
+            }
+        };
+
+        let id = files.add(&cfg_path, cfg_contents);
+
+        let print = |files: &Files, diags: Vec<Diagnostic>| {
+            if diags.is_empty() {
+                return;
+            }
+
+            if let Some(printer) = crate::common::DiagPrinter::new(log_ctx, None, None) {
+                let mut lock = printer.lock();
+                for diag in diags {
+                    lock.print(diag, files);
+                }
+            }
+        };
+
+        let mut parsed = toml_span::parse(files.source(id))
+            .with_context(|| format!("failed to parse config from '{cfg_path}'"))?;
+
+        use cargo_deny::Deserialize;
+        let cfg = match cargo_deny::root_cfg::RootConfig::deserialize(&mut parsed) {
+            Ok(c) => c,
+            Err(err) => {
+                let diags = err
+                    .errors
+                    .into_iter()
+                    .map(|d| d.to_diagnostic(id))
+                    .collect();
+                print(files, diags);
+                anyhow::bail!("failed to deserialize config from '{cfg_path}'");
+            }
+        };
+
+        log::info!("using config from {cfg_path}");
+
+        let validate = || -> (Vec<Diagnostic>, Self) {
+            // Accumulate all configuration diagnostics rather than earlying out so
+            // the user has the full list of problems to fix
+
+            let mut diags = Vec::new();
+
+            let advisories =
+                cfg.advisories
+                    .unwrap_or_default()
+                    .validate(cargo_deny::cfg::ValidationContext {
+                        cfg_id: id,
+                        files,
+                        diagnostics: &mut diags,
+                    });
+
+            let bans = cfg
+                .bans
+                .unwrap_or_default()
+                .validate(cargo_deny::cfg::ValidationContext {
+                    cfg_id: id,
+                    files,
+                    diagnostics: &mut diags,
+                });
+            let mut licenses =
+                cfg.licenses
+                    .unwrap_or_default()
+                    .validate(cargo_deny::cfg::ValidationContext {
+                        cfg_id: id,
+                        files,
+                        diagnostics: &mut diags,
+                    });
+
+            // Allow for project-local exceptions. Relevant in corporate environments.
+            // https://github.com/EmbarkStudios/cargo-deny/issues/541
+            if let Some(ecp) = exceptions_cfg_path {
+                licenses::cfg::load_exceptions(&mut licenses, ecp, files, &mut diags);
+            };
+
+            let sources =
+                cfg.sources
+                    .unwrap_or_default()
+                    .validate(cargo_deny::cfg::ValidationContext {
+                        cfg_id: id,
+                        files,
+                        diagnostics: &mut diags,
+                    });
+
+            // Warn the user if they used a target triple that was not a built-in
+            // or even parseable as it might mean it won't match against a cfg
+            // expression they were expecting it to
+            for target in &cfg.graph.targets {
+                if !matches!(&target.filter.value, krates::Target::Unknown(_)) {
+                    continue;
+                }
+
+                diags.push(
+                    Diagnostic::warning()
+                        .with_message(format!("unknown target `{}` specified", target.filter.value))
+                        .with_labels(vec![
+                    cargo_deny::diag::Label::primary(
+                        id,
+                        target.filter.span).with_message(
+                        "the triple won't be evaluated against cfg() sections, just explicit triples"),
+                    ]),
+                );
+            }
+
+            // Warn the user if they are using deprecated keys
+            {
+                use cargo_deny::diag::general::{Deprecated, DeprecationReason};
+
+                diags.extend(cfg.graph_deprecated.into_iter().map(|key| {
+                    Deprecated {
+                        key,
+                        reason: DeprecationReason::Moved("graph"),
+                        file_id: id,
+                    }
+                    .into()
+                }));
+
+                if let Some(key) = cfg.output_deprecated {
+                    diags.push(
+                        Deprecated {
+                            key,
+                            reason: DeprecationReason::Moved("output"),
+                            file_id: id,
+                        }
+                        .into(),
+                    );
+                }
+            }
+
+            (
+                diags,
+                Self {
+                    advisories,
+                    bans,
+                    licenses,
+                    sources,
+                    graph: cfg.graph,
+                    output: cfg.output,
+                },
+            )
+        };
+
+        let (diags, valid_cfg) = validate();
+
+        let has_errors = diags.iter().any(|d| d.severity >= Severity::Error);
+
+        print(files, diags);
+
+        // While we could continue in the face of configuration errors, the user
+        // may end up with unexpected results, so just abort so they can fix them
+        if has_errors {
+            anyhow::bail!("failed to validate configuration file {cfg_path}");
+        } else {
+            Ok(valid_cfg)
+        }
+    }
+}
diff --git a/src/cargo-deny/fetch.rs b/src/cargo-deny/fetch.rs
index b1b4d64e4..203c0ea57 100644
--- a/src/cargo-deny/fetch.rs
+++ b/src/cargo-deny/fetch.rs
@@ -1,9 +1,6 @@
-use anyhow::{Context, Error};
-use cargo_deny::{
-    advisories,
-    diag::{Diagnostic, Files},
-    PathBuf,
-};
+use crate::common::ValidConfig;
+use anyhow::{Context as _, Error};
+use cargo_deny::{advisories, diag::Files, PathBuf};
 
 #[derive(clap::ValueEnum, Debug, PartialEq, Eq, Copy, Clone)]
 pub enum FetchSource {
@@ -24,90 +21,20 @@ pub struct Args {
     sources: Vec<FetchSource>,
 }
 
-#[derive(serde::Deserialize)]
-struct Config {
-    advisories: Option<advisories::cfg::Config>,
-}
-
-struct ValidConfig {
-    advisories: advisories::cfg::ValidConfig,
-}
-
-impl ValidConfig {
-    fn load(
-        cfg_path: Option<PathBuf>,
-        files: &mut Files,
-        log_ctx: crate::common::LogContext,
-    ) -> Result<Self, Error> {
-        use cargo_deny::UnvalidatedConfig;
-
-        let (cfg_contents, cfg_path) = match cfg_path {
-            Some(cfg_path) if cfg_path.exists() => (
-                std::fs::read_to_string(&cfg_path)
-                    .with_context(|| format!("failed to read config from {cfg_path}"))?,
-                cfg_path,
-            ),
-            Some(cfg_path) => {
-                log::warn!(
-                    "config path '{cfg_path}' doesn't exist, falling back to default config"
-                );
-                (String::new(), cfg_path)
-            }
-            None => {
-                log::warn!("unable to find a config path, falling back to default config");
-                (String::new(), PathBuf::from("deny.default.toml"))
-            }
-        };
-
-        let cfg: Config = toml::from_str(&cfg_contents)
-            .with_context(|| format!("failed to deserialize config from '{cfg_path}'"))?;
-
-        log::info!("using config from {cfg_path}");
-
-        let id = files.add(&cfg_path, cfg_contents);
-
-        let mut diags = Vec::new();
-        let advisories = cfg
-            .advisories
-            .unwrap_or_default()
-            .validate(id, files, &mut diags);
-
-        let has_errors = diags
-            .iter()
-            .any(|d| d.severity >= cargo_deny::diag::Severity::Error);
-
-        let print = |diags: Vec<Diagnostic>| {
-            if diags.is_empty() {
-                return;
-            }
-
-            if let Some(printer) = crate::common::DiagPrinter::new(log_ctx, None, None) {
-                let mut lock = printer.lock();
-                for diag in diags {
-                    lock.print(diag, files);
-                }
-            }
-        };
-
-        print(diags);
-
-        if has_errors {
-            anyhow::bail!("failed to validate configuration file {cfg_path}");
-        } else {
-            Ok(Self { advisories })
-        }
-    }
-}
-
 pub fn cmd(
     log_ctx: crate::common::LogContext,
     args: Args,
-    ctx: crate::common::KrateContext,
+    krate_ctx: crate::common::KrateContext,
 ) -> Result<(), Error> {
-    let cfg_path = ctx.get_config_path(args.config.clone());
+    let cfg_path = krate_ctx.get_config_path(args.config.clone());
 
     let mut files = Files::new();
-    let cfg = ValidConfig::load(cfg_path, &mut files, log_ctx)?;
+    let ValidConfig { advisories, .. } = ValidConfig::load(
+        cfg_path,
+        krate_ctx.get_local_exceptions_path(),
+        &mut files,
+        log_ctx,
+    )?;
 
     let mut index = None;
     let mut dbs = None;
@@ -122,7 +49,7 @@ pub fn cmd(
         if fetch_index {
             s.spawn(|_| {
                 log::info!("fetching crates");
-                index = Some(ctx.fetch_krates());
+                index = Some(krate_ctx.fetch_krates());
                 log::info!("fetched crates");
             });
         }
@@ -137,13 +64,13 @@ pub fn cmd(
             s.spawn(|_| {
                 // This function already logs internally
                 dbs = Some(advisories::DbSet::load(
-                    cfg.advisories.db_path,
-                    cfg.advisories
+                    advisories.db_path,
+                    advisories
                         .db_urls
                         .into_iter()
                         .map(|dburl| dburl.take())
                         .collect(),
-                    if cfg.advisories.git_fetch_with_cli {
+                    if advisories.git_fetch_with_cli {
                         advisories::Fetch::AllowWithGitCli
                     } else {
                         advisories::Fetch::Allow
diff --git a/src/cargo-deny/list.rs b/src/cargo-deny/list.rs
index d14bcba9e..bcb8fc37a 100644
--- a/src/cargo-deny/list.rs
+++ b/src/cargo-deny/list.rs
@@ -1,4 +1,5 @@
-use anyhow::{Context, Error};
+use crate::common::ValidConfig;
+use anyhow::{Context as _, Error};
 use cargo_deny::{diag::Files, licenses, Kid, PathBuf};
 use nu_ansi_term::Color;
 use serde::Serialize;
@@ -38,96 +39,6 @@ pub struct Args {
     layout: Layout,
 }
 
-#[derive(serde::Deserialize)]
-struct Config {
-    #[serde(default)]
-    targets: Vec<crate::common::Target>,
-    #[serde(default)]
-    exclude: Vec<String>,
-}
-
-struct ValidConfig {
-    targets: Vec<(krates::Target, Vec<String>)>,
-    exclude: Vec<String>,
-}
-
-impl ValidConfig {
-    fn load(
-        cfg_path: Option<PathBuf>,
-        files: &mut Files,
-        log_ctx: crate::common::LogContext,
-    ) -> Result<Self, Error> {
-        let (cfg_contents, cfg_path) = match cfg_path {
-            Some(cfg_path) if cfg_path.exists() => (
-                std::fs::read_to_string(&cfg_path)
-                    .with_context(|| format!("failed to read config from {cfg_path}"))?,
-                cfg_path,
-            ),
-            Some(cfg_path) => {
-                log::warn!(
-                    "config path '{cfg_path}' doesn't exist, falling back to default config"
-                );
-
-                return Ok(Self {
-                    targets: Vec::new(),
-                    exclude: Vec::new(),
-                });
-            }
-            None => {
-                log::warn!("unable to find a config path, falling back to default config");
-
-                return Ok(Self {
-                    targets: Vec::new(),
-                    exclude: Vec::new(),
-                });
-            }
-        };
-
-        let cfg: Config = toml::from_str(&cfg_contents)
-            .with_context(|| format!("failed to deserialize config from '{cfg_path}'"))?;
-
-        log::info!("using config from {cfg_path}");
-
-        let id = files.add(&cfg_path, cfg_contents);
-
-        use cargo_deny::diag::Diagnostic;
-
-        let validate = || -> Result<(Vec<Diagnostic>, Self), Vec<Diagnostic>> {
-            let mut diagnostics = Vec::new();
-            let targets = crate::common::load_targets(cfg.targets, &mut diagnostics, id);
-            let exclude = cfg.exclude;
-
-            Ok((diagnostics, Self { targets, exclude }))
-        };
-
-        let print = |diags: Vec<Diagnostic>| {
-            if diags.is_empty() {
-                return;
-            }
-
-            if let Some(printer) = crate::common::DiagPrinter::new(log_ctx, None, None) {
-                let mut lock = printer.lock();
-                for diag in diags {
-                    lock.print(diag, files);
-                }
-            }
-        };
-
-        match validate() {
-            Ok((diags, vc)) => {
-                print(diags);
-                Ok(vc)
-            }
-            Err(diags) => {
-                print(diags);
-
-                anyhow::bail!("failed to validate configuration file {cfg_path}");
-            }
-        }
-    }
-}
-
-#[allow(clippy::cognitive_complexity)]
 pub fn cmd(
     log_ctx: crate::common::LogContext,
     args: Args,
@@ -136,11 +47,18 @@ pub fn cmd(
     use licenses::LicenseInfo;
     use std::{collections::BTreeMap, fmt::Write};
 
+    let cfg_path = krate_ctx.get_config_path(args.config.clone());
+
     let mut files = Files::new();
-    let cfg = ValidConfig::load(krate_ctx.get_config_path(args.config), &mut files, log_ctx)?;
+    let ValidConfig { graph, .. } = ValidConfig::load(
+        cfg_path,
+        krate_ctx.get_local_exceptions_path(),
+        &mut files,
+        log_ctx,
+    )?;
 
     let (krates, store) = rayon::join(
-        || krate_ctx.gather_krates(cfg.targets, cfg.exclude),
+        || krate_ctx.gather_krates(graph.targets, graph.exclude),
         crate::common::load_license_store,
     );
 
diff --git a/src/cfg.rs b/src/cfg.rs
index d67a331af..72811d09f 100644
--- a/src/cfg.rs
+++ b/src/cfg.rs
@@ -1,241 +1,74 @@
-use serde::{de, ser};
-use std::fmt;
+mod package_spec;
 
-pub trait UnvalidatedConfig {
-    type ValidCfg;
-
-    fn validate(
-        self,
-        id: crate::diag::FileId,
-        files: &mut crate::diag::Files,
-        diagnostics: &mut Vec<crate::diag::Diagnostic>,
-    ) -> Self::ValidCfg;
-}
+use crate::diag;
+pub use package_spec::{PackageSpec, PackageSpecOrExtended};
+pub use toml_span::span::{Span, Spanned};
 
-#[derive(Default)]
-pub struct Spanned<T> {
-    pub(crate) value: T,
-    pub(crate) span: std::ops::Range<usize>,
+pub struct ValidationContext<'ctx> {
+    pub cfg_id: diag::FileId,
+    pub files: &'ctx mut diag::Files,
+    pub diagnostics: &'ctx mut Vec<diag::Diagnostic>,
 }
 
-impl<T> Spanned<T> {
-    #[inline]
-    pub(crate) const fn new(value: T, span: std::ops::Range<usize>) -> Self {
-        Self { value, span }
-    }
-
-    #[inline]
-    pub fn span(&self) -> &std::ops::Range<usize> {
-        &self.span
-    }
-
+impl<'ctx> ValidationContext<'ctx> {
     #[inline]
-    pub fn take(self) -> T {
-        self.value
+    pub fn push(&mut self, diag: diag::Diagnostic) {
+        self.diagnostics.push(diag);
     }
-}
-
-#[cfg(test)]
-pub(crate) trait Fake<T> {
-    fn fake(self) -> Spanned<T>;
-}
 
-#[cfg(test)]
-impl<T> Fake<T> for T
-where
-    T: Sized,
-{
-    fn fake(self) -> Spanned<T> {
-        #[allow(clippy::reversed_empty_ranges)]
-        Spanned {
-            value: self,
-            span: 0..0,
-        }
-    }
-}
-
-impl<T> AsRef<T> for Spanned<T> {
-    fn as_ref(&self) -> &T {
-        &self.value
-    }
-}
-
-impl<T> std::fmt::Debug for Spanned<T>
-where
-    T: std::fmt::Debug,
-{
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "{:?}", self.value)
-    }
-}
-
-impl<T> Clone for Spanned<T>
-where
-    T: Clone,
-{
-    fn clone(&self) -> Self {
-        Self {
-            value: self.value.clone(),
-            span: self.span.clone(),
-        }
-    }
-}
-
-impl<T> PartialOrd for Spanned<T>
-where
-    T: PartialOrd,
-{
-    fn partial_cmp(&self, o: &Spanned<T>) -> Option<std::cmp::Ordering> {
-        self.value.partial_cmp(&o.value)
-    }
-}
-
-impl<T> Ord for Spanned<T>
-where
-    T: Ord,
-{
-    fn cmp(&self, o: &Spanned<T>) -> std::cmp::Ordering {
-        self.value.cmp(&o.value)
-    }
-}
-
-impl<T> PartialEq for Spanned<T>
-where
-    T: PartialEq,
-{
-    fn eq(&self, o: &Spanned<T>) -> bool {
-        self.value == o.value
-    }
-}
-
-impl<T> Eq for Spanned<T> where T: Eq {}
-
-impl<T> PartialEq<T> for Spanned<T>
-where
-    T: PartialEq,
-{
-    fn eq(&self, o: &T) -> bool {
-        &self.value == o
-    }
-}
-
-impl<'de, T> de::Deserialize<'de> for Spanned<T>
-where
-    T: de::Deserialize<'de>,
-{
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    /// Sorts a vec and prints a warning about duplicate items before removing them
+    pub fn dedup<T>(&mut self, v: &mut Vec<Spanned<T>>)
     where
-        D: de::Deserializer<'de>,
+        T: Ord,
     {
-        pub(crate) const NAME: &str = "$__serde_spanned_private_Spanned";
-        pub(crate) const START: &str = "$__serde_spanned_private_start";
-        pub(crate) const END: &str = "$__serde_spanned_private_end";
-        pub(crate) const VALUE: &str = "$__serde_spanned_private_value";
-
-        struct SpannedVisitor<T>(::std::marker::PhantomData<T>);
+        if v.len() <= 1 {
+            return;
+        }
 
-        impl<'de, T> de::Visitor<'de> for SpannedVisitor<T>
-        where
-            T: de::Deserialize<'de>,
-        {
-            type Value = Spanned<T>;
+        v.sort();
 
-            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
-                formatter.write_str("a TOML spanned")
+        for window in v.windows(2) {
+            if window[0] != window[1] {
+                continue;
             }
 
-            fn visit_map<V>(self, mut visitor: V) -> Result<Spanned<T>, V::Error>
-            where
-                V: de::MapAccess<'de>,
-            {
-                if visitor.next_key()? != Some(START) {
-                    return Err(de::Error::custom("spanned start key not found"));
-                }
-
-                let start: usize = visitor.next_value()?;
-
-                if visitor.next_key()? != Some(END) {
-                    return Err(de::Error::custom("spanned end key not found"));
-                }
-
-                let end: usize = visitor.next_value()?;
-
-                if visitor.next_key()? != Some(VALUE) {
-                    return Err(de::Error::custom("spanned value key not found"));
-                }
-
-                let value: T = visitor.next_value()?;
-
-                Ok(Spanned {
-                    span: start..end,
-                    value,
-                })
-            }
+            self.push(
+                diag::Diagnostic::warning()
+                    .with_message("duplicate items detected")
+                    .with_labels(vec![
+                        diag::Label::secondary(self.cfg_id, window[0].span),
+                        diag::Label::secondary(self.cfg_id, window[1].span),
+                    ]),
+            );
         }
 
-        let visitor = SpannedVisitor(::std::marker::PhantomData);
-
-        static FIELDS: [&str; 3] = [START, END, VALUE];
-        deserializer.deserialize_struct(NAME, &FIELDS, visitor)
+        v.dedup();
     }
 }
 
-impl<T: ser::Serialize> ser::Serialize for Spanned<T> {
-    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-    where
-        S: ser::Serializer,
-    {
-        self.value.serialize(serializer)
-    }
-}
+pub trait UnvalidatedConfig {
+    type ValidCfg;
 
-pub(crate) fn parse_url(
-    cfg_file: crate::diag::FileId,
-    urls: Spanned<String>,
-) -> Result<Spanned<url::Url>, crate::diag::Diagnostic> {
-    url::Url::parse(urls.as_ref())
-        .map(|url| Spanned {
-            value: url,
-            span: urls.span.clone(),
-        })
-        .map_err(|pe| {
-            crate::diag::Diagnostic::error()
-                .with_message("failed to parse url")
-                .with_labels(vec![
-                    crate::diag::Label::primary(cfg_file, urls.span).with_message(pe.to_string())
-                ])
-        })
+    fn validate(self, ctx: ValidationContext<'_>) -> Self::ValidCfg;
 }
 
-#[cfg(test)]
-pub(crate) mod test {
-    use crate::{
-        diag::{FileId, Files},
-        PathBuf,
-    };
+#[derive(Clone)]
+#[cfg_attr(test, derive(Debug, PartialEq, Eq, serde::Serialize))]
+pub struct Reason(pub Spanned<String>);
 
-    pub(crate) struct ConfigData<T> {
-        pub(crate) config: T,
-        pub(crate) files: Files,
-        pub(crate) id: FileId,
+impl From<Spanned<String>> for Reason {
+    fn from(s: Spanned<String>) -> Self {
+        Self(s)
     }
+}
 
-    pub(crate) fn load_str<T: serde::de::DeserializeOwned>(
-        name: impl Into<std::ffi::OsString>,
-        contents: impl Into<String>,
-    ) -> ConfigData<T> {
-        let contents = contents.into();
-        let config = toml::from_str(&contents).unwrap();
-        let mut files = Files::new();
-        let id = files.add(name, contents);
-
-        ConfigData { config, files, id }
-    }
-
-    pub(crate) fn load<T: serde::de::DeserializeOwned>(path: impl Into<PathBuf>) -> ConfigData<T> {
-        let path = path.into();
-        let contents = std::fs::read_to_string(&path).unwrap();
-
-        load_str(path, contents)
+impl<'de> toml_span::Deserialize<'de> for Reason {
+    fn deserialize(
+        value: &mut toml_span::value::Value<'de>,
+    ) -> Result<Self, toml_span::DeserError> {
+        let mut th = toml_span::de_helpers::TableHelper::new(value)?;
+        let r = th.required("reason")?;
+        th.finalize(Some(value))?;
+        Ok(Self(r))
     }
 }
diff --git a/src/cfg/package_spec.rs b/src/cfg/package_spec.rs
new file mode 100644
index 000000000..50ff29221
--- /dev/null
+++ b/src/cfg/package_spec.rs
@@ -0,0 +1,417 @@
+use crate::{cfg::Span, Spanned};
+use semver::VersionReq;
+use std::fmt;
+use toml_span::{
+    de_helpers::{expected, TableHelper},
+    value::{Value, ValueInner},
+    DeserError, Deserialize,
+};
+
+/// A package identifier, consisting of a package name and a version requirement
+///
+/// This is specified similarly to [Cargo Package Ids](https://doc.rust-lang.org/cargo/reference/pkgid-spec.html),
+/// however we change semantics a bit to instead use a [`semver::VersionReq`] instead
+/// of a [`semver::Version`] as Cargo's are meant for disambiguating graph operations
+/// whereas ours may be targeting single or multiple packages. In practice this
+/// is mainly just a superset of Cargo's version
+#[derive(Clone, PartialEq, Eq)]
+pub struct PackageSpec {
+    pub name: Spanned<String>,
+    pub version_req: Option<VersionReq>,
+}
+
+impl fmt::Display for PackageSpec {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str(&self.name.value)?;
+
+        if let Some(vr) = &self.version_req {
+            write!(f, " = {vr}")?;
+        }
+
+        Ok(())
+    }
+}
+
+impl fmt::Debug for PackageSpec {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{} = {:?}", self.name.value, self.version_req)
+    }
+}
+
+impl<'de> Deserialize<'de> for PackageSpec {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        use std::borrow::Cow;
+
+        struct Ctx<'de> {
+            inner: Cow<'de, str>,
+            split: Option<(usize, bool)>,
+            span: Span,
+        }
+
+        impl<'de> Ctx<'de> {
+            fn from_str(bs: Cow<'de, str>, span: Span) -> Self {
+                let split = bs
+                    .find('@')
+                    .map(|i| (i, true))
+                    .or_else(|| bs.find(':').map(|i| (i, false)));
+                Self {
+                    inner: bs,
+                    split,
+                    span,
+                }
+            }
+        }
+
+        let ctx = match value.take() {
+            ValueInner::String(s) => Ctx::from_str(s, value.span),
+            ValueInner::Table(tab) => {
+                let mut th = TableHelper::from((tab, value.span));
+
+                if let Some(mut val) = th.table.remove(&"crate".into()) {
+                    let s = val.take_string(Some("a crate spec"))?;
+                    th.finalize(Some(value))?;
+
+                    Ctx::from_str(s, val.span)
+                } else {
+                    // Encourge user to use the 'crate' spec instead
+                    let name = th.required("name").map_err(|e| {
+                        if matches!(e.kind, toml_span::ErrorKind::MissingField(_)) {
+                            (toml_span::ErrorKind::MissingField("crate"), e.span).into()
+                        } else {
+                            e
+                        }
+                    })?;
+                    let version = th.optional::<Spanned<Cow<'_, str>>>("version");
+
+                    th.finalize(Some(value))?;
+
+                    let version_req = if let Some(vr) = version {
+                        Some(vr.value.parse().map_err(|e: semver::Error| {
+                            toml_span::Error::from((
+                                toml_span::ErrorKind::Custom(e.to_string().into()),
+                                vr.span,
+                            ))
+                        })?)
+                    } else {
+                        None
+                    };
+
+                    return Ok(Self { name, version_req });
+                }
+            }
+            other => return Err(expected("a string or table", other, value.span).into()),
+        };
+
+        let (name, version_req) = if let Some((i, make_exact)) = ctx.split {
+            let mut v: VersionReq = ctx.inner[i + 1..].parse().map_err(|e: semver::Error| {
+                toml_span::Error::from((
+                    toml_span::ErrorKind::Custom(e.to_string().into()),
+                    Span::new(ctx.span.start + i + 1, ctx.span.end),
+                ))
+            })?;
+            if make_exact {
+                if let Some(comp) = v.comparators.get_mut(0) {
+                    comp.op = semver::Op::Exact;
+                }
+            }
+
+            (
+                Spanned::with_span(
+                    ctx.inner[..i].into(),
+                    Span::new(ctx.span.start, ctx.span.start + i),
+                ),
+                Some(v),
+            )
+        } else {
+            (Spanned::with_span(ctx.inner.into(), ctx.span), None)
+        };
+
+        Ok(Self { name, version_req })
+    }
+}
+
+#[cfg(test)]
+impl serde::Serialize for PackageSpec {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        use serde::ser::SerializeMap;
+        let mut map = serializer.serialize_map(Some(3))?;
+        map.serialize_entry("name", &self.name.value)?;
+        map.serialize_entry("version-req", &self.version_req)?;
+        map.end()
+    }
+}
+
+use std::cmp::Ordering;
+
+impl Ord for PackageSpec {
+    fn cmp(&self, other: &Self) -> Ordering {
+        match self.name.value.cmp(&other.name.value) {
+            Ordering::Equal => match (&self.version_req, &other.version_req) {
+                (None, None) => Ordering::Equal,
+                (Some(_), None) => Ordering::Less,
+                (None, Some(_)) => Ordering::Greater,
+                (Some(a), Some(b)) => {
+                    if a == b {
+                        Ordering::Equal
+                    } else {
+                        match a.comparators.len().cmp(&b.comparators.len()) {
+                            Ordering::Equal => {
+                                #[derive(PartialOrd, PartialEq, Ord, Eq)]
+                                enum Op {
+                                    Exact,
+                                    Greater,
+                                    GreaterEq,
+                                    Less,
+                                    LessEq,
+                                    Tilde,
+                                    Caret,
+                                    Wildcard,
+                                }
+
+                                #[allow(clippy::fallible_impl_from)]
+                                impl From<semver::Op> for Op {
+                                    fn from(op: semver::Op) -> Self {
+                                        match op {
+                                            semver::Op::Exact => Self::Exact,
+                                            semver::Op::Greater => Self::Greater,
+                                            semver::Op::GreaterEq => Self::GreaterEq,
+                                            semver::Op::Less => Self::Less,
+                                            semver::Op::LessEq => Self::LessEq,
+                                            semver::Op::Tilde => Self::Tilde,
+                                            semver::Op::Caret => Self::Caret,
+                                            semver::Op::Wildcard => Self::Wildcard,
+                                            // I fucking despise non_exhaustive
+                                            _ => panic!("semver has added a new Op, but non_exhaustive means we can't detect that at compile time, so please open an issue so that the additional match arm can be added"),
+                                        }
+                                    }
+                                }
+
+                                for (acmp, bcmp) in a.comparators.iter().zip(b.comparators.iter()) {
+                                    match Op::from(acmp.op).cmp(&Op::from(bcmp.op)) {
+                                        Ordering::Equal => {}
+                                        o => return o,
+                                    }
+
+                                    match acmp.major.cmp(&bcmp.major) {
+                                        Ordering::Equal => {}
+                                        o => return o,
+                                    }
+
+                                    match acmp.minor.cmp(&bcmp.minor) {
+                                        Ordering::Equal => {}
+                                        o => return o,
+                                    }
+
+                                    match acmp.patch.cmp(&bcmp.patch) {
+                                        Ordering::Equal => {}
+                                        o => return o,
+                                    }
+
+                                    match acmp.pre.cmp(&bcmp.pre) {
+                                        Ordering::Equal => {}
+                                        o => return o,
+                                    }
+                                }
+
+                                Ordering::Equal
+                            }
+                            o => o,
+                        }
+                    }
+                }
+            },
+            o => o,
+        }
+    }
+}
+
+impl PartialOrd for PackageSpec {
+    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+#[cfg_attr(test, derive(serde::Serialize))]
+pub struct PackageSpecOrExtended<T> {
+    pub spec: PackageSpec,
+    pub inner: Option<T>,
+}
+
+impl<T> PackageSpecOrExtended<T> {
+    pub fn try_convert<V, E>(self) -> Result<PackageSpecOrExtended<V>, E>
+    where
+        V: TryFrom<T, Error = E>,
+    {
+        let inner = if let Some(i) = self.inner {
+            Some(V::try_from(i)?)
+        } else {
+            None
+        };
+
+        Ok(PackageSpecOrExtended {
+            spec: self.spec,
+            inner,
+        })
+    }
+
+    pub fn convert<V>(self) -> PackageSpecOrExtended<V>
+    where
+        V: From<T>,
+    {
+        PackageSpecOrExtended {
+            spec: self.spec,
+            inner: self.inner.map(V::from),
+        }
+    }
+}
+
+impl<'de, T> toml_span::Deserialize<'de> for PackageSpecOrExtended<T>
+where
+    T: toml_span::Deserialize<'de>,
+{
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let spec = PackageSpec::deserialize(value)?;
+
+        // If more keys exist in the table (or string) then try to deserialize
+        // the rest as the "extended" portion
+        let inner = if value.has_keys() {
+            Some(T::deserialize(value)?)
+        } else {
+            None
+        };
+
+        Ok(Self { spec, inner })
+    }
+}
+
+impl<T> fmt::Debug for PackageSpecOrExtended<T>
+where
+    T: fmt::Debug,
+{
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("PackageSpecOrExtended")
+            .field("spec", &self.spec)
+            .field("inner", &self.inner)
+            .finish()
+    }
+}
+
+impl<T> Clone for PackageSpecOrExtended<T>
+where
+    T: Clone,
+{
+    fn clone(&self) -> Self {
+        Self {
+            spec: self.spec.clone(),
+            inner: self.inner.clone(),
+        }
+    }
+}
+
+impl<T> PartialEq for PackageSpecOrExtended<T> {
+    fn eq(&self, other: &Self) -> bool {
+        self.spec.eq(&other.spec)
+    }
+}
+
+impl<T> Eq for PackageSpecOrExtended<T> {}
+
+impl<T> Ord for PackageSpecOrExtended<T> {
+    fn cmp(&self, other: &Self) -> Ordering {
+        self.spec.cmp(&other.spec)
+    }
+}
+
+impl<T> PartialOrd for PackageSpecOrExtended<T> {
+    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+#[cfg(test)]
+#[allow(dead_code)]
+mod test {
+    use super::*;
+    use crate::{cfg::ValidationContext, test_utils::ConfigData};
+
+    #[test]
+    fn deserializes_package_id() {
+        struct Boop {
+            data: Option<Spanned<u32>>,
+        }
+
+        impl<'de> Deserialize<'de> for Boop {
+            fn deserialize(value: &mut toml_span::value::Value<'de>) -> Result<Self, DeserError> {
+                let mut th = TableHelper::new(value)?;
+                let data = th.optional_s("data");
+                th.finalize(None)?;
+                Ok(Self { data })
+            }
+        }
+
+        #[derive(serde::Serialize)]
+        struct ValidBoop {
+            data: Option<Spanned<u32>>,
+        }
+
+        impl From<Boop> for ValidBoop {
+            fn from(value: Boop) -> Self {
+                Self { data: value.data }
+            }
+        }
+
+        struct TestCfg {
+            bare: PackageSpecOrExtended<Boop>,
+            specific: PackageSpecOrExtended<Boop>,
+            range: PackageSpecOrExtended<Boop>,
+            mixed: Vec<PackageSpecOrExtended<Boop>>,
+        }
+
+        impl<'de> Deserialize<'de> for TestCfg {
+            fn deserialize(value: &mut toml_span::value::Value<'de>) -> Result<Self, DeserError> {
+                let mut th = TableHelper::new(value)?;
+                let bare = th.required("bare")?;
+                let specific = th.required("specific")?;
+                let range = th.required("range")?;
+                let mixed = th.required("mixed")?;
+                th.finalize(None)?;
+
+                Ok(Self {
+                    bare,
+                    specific,
+                    range,
+                    mixed,
+                })
+            }
+        }
+
+        #[derive(serde::Serialize)]
+        struct ValidTestCfg {
+            bare: PackageSpecOrExtended<ValidBoop>,
+            specific: PackageSpecOrExtended<ValidBoop>,
+            range: PackageSpecOrExtended<ValidBoop>,
+            mixed: Vec<PackageSpecOrExtended<ValidBoop>>,
+        }
+
+        impl crate::cfg::UnvalidatedConfig for TestCfg {
+            type ValidCfg = ValidTestCfg;
+
+            fn validate(self, mut _ctx: ValidationContext<'_>) -> Self::ValidCfg {
+                ValidTestCfg {
+                    bare: self.bare.convert(),
+                    specific: self.specific.convert(),
+                    range: self.range.convert(),
+                    mixed: self.mixed.into_iter().map(|m| m.convert()).collect(),
+                }
+            }
+        }
+
+        let cd = ConfigData::<TestCfg>::load("tests/cfg/package-specs.toml");
+        let validated = cd.validate(|s| s);
+
+        insta::assert_json_snapshot!(validated);
+    }
+}
diff --git a/src/diag.rs b/src/diag.rs
index c2d082a12..e0cae9ac8 100644
--- a/src/diag.rs
+++ b/src/diag.rs
@@ -1,3 +1,4 @@
+pub mod general;
 mod grapher;
 mod sink;
 
@@ -6,7 +7,7 @@ pub use sink::{DiagnosticOverrides, ErrorSink};
 
 use std::{collections::HashMap, ops::Range};
 
-use crate::{Kid, Krates};
+use crate::{Kid, Krates, Span};
 pub use codespan_reporting::diagnostic::Severity;
 
 pub use codespan::FileId;
@@ -143,8 +144,6 @@ where
     }
 }
 
-pub type Span = std::ops::Range<usize>;
-
 pub struct KrateSpan {
     pub total: Span,
     pub source: usize,
@@ -194,7 +193,10 @@ impl KrateSpans {
 
             let total = span_start..sl.len() - 1;
             let source = total.end - source.len();
-            spans.push(KrateSpan { total, source });
+            spans.push(KrateSpan {
+                total: total.into(),
+                source,
+            });
 
             let mut sl2 = String::with_capacity(4 * 1024);
             let mut deps_map = HashMap::new();
@@ -218,14 +220,14 @@ impl KrateSpans {
 
     #[inline]
     pub fn label_for_index(&self, krate_index: usize, msg: impl Into<String>) -> Label {
-        Label::secondary(self.file_id, self.spans[krate_index].total.clone()).with_message(msg)
+        Label::secondary(self.file_id, self.spans[krate_index].total).with_message(msg)
     }
 
     #[inline]
     pub fn get_coord(&self, krate_index: usize) -> KrateCoord {
         KrateCoord {
             file: self.file_id,
-            span: self.spans[krate_index].total.clone(),
+            span: self.spans[krate_index].total,
         }
     }
 }
@@ -236,7 +238,7 @@ pub type CfgCoord = Coord;
 #[derive(Clone)]
 pub struct Coord {
     pub file: FileId,
-    pub span: Range<usize>,
+    pub span: Span,
 }
 
 impl Coord {
@@ -259,7 +261,7 @@ struct NodePrint {
 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
 pub enum DiagnosticCode {
     Advisory(crate::advisories::Code),
-    Bans(crate::bans::Code),
+    //Bans(crate::bans::Code),
     License(crate::licenses::Code),
     Source(crate::sources::Code),
 }
@@ -269,7 +271,7 @@ impl DiagnosticCode {
         use strum::IntoEnumIterator;
         crate::advisories::Code::iter()
             .map(Self::Advisory)
-            .chain(crate::bans::Code::iter().map(Self::Bans))
+            //.chain(crate::bans::Code::iter().map(Self::Bans))
             .chain(crate::licenses::Code::iter().map(Self::License))
             .chain(crate::sources::Code::iter().map(Self::Source))
     }
@@ -278,7 +280,7 @@ impl DiagnosticCode {
     pub fn as_str(self) -> &'static str {
         match self {
             Self::Advisory(code) => code.into(),
-            Self::Bans(code) => code.into(),
+            //Self::Bans(code) => code.into(),
             Self::License(code) => code.into(),
             Self::Source(code) => code.into(),
         }
@@ -299,7 +301,7 @@ impl std::str::FromStr for DiagnosticCode {
     fn from_str(s: &str) -> Result<Self, Self::Err> {
         s.parse::<crate::advisories::Code>()
             .map(Self::Advisory)
-            .or_else(|_err| s.parse::<crate::bans::Code>().map(Self::Bans))
+            //.or_else(|_err| s.parse::<crate::bans::Code>().map(Self::Bans))
             .or_else(|_err| s.parse::<crate::licenses::Code>().map(Self::License))
             .or_else(|_err| s.parse::<crate::sources::Code>().map(Self::Source))
     }
diff --git a/src/diag/general.rs b/src/diag/general.rs
new file mode 100644
index 000000000..efbad40cf
--- /dev/null
+++ b/src/diag/general.rs
@@ -0,0 +1,67 @@
+//! Contains general diagnostics that are shared between various checks
+
+use crate::{
+    diag::{Diagnostic, FileId, Label, Severity},
+    Span,
+};
+use std::fmt;
+
+#[derive(
+    strum::Display,
+    strum::EnumString,
+    strum::EnumIter,
+    strum::IntoStaticStr,
+    Copy,
+    Clone,
+    Debug,
+    PartialEq,
+    Eq,
+)]
+#[strum(serialize_all = "kebab-case")]
+pub enum Code {
+    Deprecated,
+}
+
+impl From<Code> for String {
+    fn from(c: Code) -> Self {
+        c.to_string()
+    }
+}
+
+pub enum DeprecationReason {
+    WillBeRemoved,
+    Moved(&'static str),
+    Renamed(&'static str),
+    MovedAndRenamed {
+        table: &'static str,
+        key: &'static str,
+    },
+}
+
+impl fmt::Display for DeprecationReason {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::WillBeRemoved => f.write_str("the key will be removed in a future update"),
+            Self::Moved(tab) => write!(f, "the key has been moved to [{tab}]"),
+            Self::Renamed(nname) => write!(f, "the key has been renamed to '{nname}'"),
+            Self::MovedAndRenamed { table, key } => {
+                write!(f, "the key been moved to [{table}] and renamed to '{key}'")
+            }
+        }
+    }
+}
+
+pub struct Deprecated {
+    pub key: Span,
+    pub reason: DeprecationReason,
+    pub file_id: FileId,
+}
+
+impl From<Deprecated> for Diagnostic {
+    fn from(dep: Deprecated) -> Self {
+        Diagnostic::new(Severity::Warning)
+            .with_message(dep.reason.to_string())
+            .with_labels(vec![Label::primary(dep.file_id, dep.key)])
+            .with_code(Code::Deprecated)
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 3b26c41cf..c57c4362d 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -6,24 +6,31 @@ use url::Url;
 
 pub mod advisories;
 pub mod bans;
-mod cfg;
+pub mod cfg;
 pub mod diag;
 /// Configuration and logic for checking crate licenses
 pub mod licenses;
+pub mod root_cfg;
 pub mod sources;
 
 #[doc(hidden)]
 pub mod test_utils;
 
 pub use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
-pub use cfg::{Spanned, UnvalidatedConfig};
+pub use cfg::UnvalidatedConfig;
 use krates::cm;
 pub use krates::{DepKind, Kid};
+pub use toml_span::{
+    span::{Span, Spanned},
+    Deserialize, Error,
+};
 
 /// The possible lint levels for the various lints. These function similarly
 /// to the standard [Rust lint levels](https://doc.rust-lang.org/rustc/lints/levels.html)
-#[derive(serde::Deserialize, PartialEq, Eq, Clone, Copy, Debug, Default)]
-#[serde(rename_all = "snake_case")]
+#[derive(PartialEq, Eq, Clone, Copy, Debug, Default, strum::VariantNames, strum::VariantArray)]
+#[cfg_attr(test, derive(serde::Serialize))]
+#[cfg_attr(test, serde(rename_all = "kebab-case"))]
+#[strum(serialize_all = "kebab-case")]
 pub enum LintLevel {
     /// A debug or info diagnostic _may_ be emitted if the lint is violated
     Allow,
@@ -36,17 +43,37 @@ pub enum LintLevel {
     Deny,
 }
 
-const fn lint_allow() -> LintLevel {
-    LintLevel::Allow
+#[macro_export]
+macro_rules! enum_deser {
+    ($enum:ty) => {
+        impl<'de> toml_span::Deserialize<'de> for $enum {
+            fn deserialize(
+                value: &mut toml_span::value::Value<'de>,
+            ) -> Result<Self, toml_span::DeserError> {
+                let s = value.take_string(Some(stringify!($enum)))?;
+
+                use strum::{VariantArray, VariantNames};
+
+                let Some(pos) = <$enum as VariantNames>::VARIANTS
+                    .iter()
+                    .position(|v| *v == s.as_ref())
+                else {
+                    return Err(toml_span::Error::from((
+                        toml_span::ErrorKind::UnexpectedValue {
+                            expected: <$enum as VariantNames>::VARIANTS,
+                        },
+                        value.span,
+                    ))
+                    .into());
+                };
+
+                Ok(<$enum as VariantArray>::VARIANTS[pos])
+            }
+        }
+    };
 }
 
-const fn lint_warn() -> LintLevel {
-    LintLevel::Warn
-}
-
-const fn lint_deny() -> LintLevel {
-    LintLevel::Deny
-}
+enum_deser!(LintLevel);
 
 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
 pub enum Source {
@@ -427,7 +454,12 @@ pub fn match_req(version: &Version, req: Option<&semver::VersionReq>) -> bool {
     req.map_or(true, |req| req.matches(version))
 }
 
-use sources::GitSpec;
+#[inline]
+pub fn match_krate(krate: &Krate, pid: &cfg::PackageSpec) -> bool {
+    krate.name == pid.name.value && match_req(&krate.version, pid.version_req.as_ref())
+}
+
+use sources::cfg::GitSpec;
 
 #[inline]
 pub(crate) fn normalize_git_url(url: &mut Url) -> GitSpec {
diff --git a/src/licenses.rs b/src/licenses.rs
index 1b16c52fa..c107c24e5 100644
--- a/src/licenses.rs
+++ b/src/licenses.rs
@@ -10,8 +10,6 @@
 //! requirements that are satisfied by the licenses you choose to use for your
 //! project, and notifies you via warnings or errors if the license requirements
 //! for any crate aren't compatible with your configuration.
-//!
-//!
 
 /// Configuration for license checking
 pub mod cfg;
@@ -26,7 +24,6 @@ use cfg::BlanketAgreement;
 pub use gather::{Gatherer, LicenseInfo, LicenseStore};
 use gather::{KrateLicense, LicenseExprInfo, LicenseExprSource, Summary};
 
-pub use cfg::{Config, ValidConfig};
 pub use diags::Code;
 
 use bitvec::prelude::*;
@@ -37,7 +34,7 @@ struct Hits {
 }
 
 fn evaluate_expression(
-    cfg: &ValidConfig,
+    cfg: &cfg::ValidConfig,
     krate_lic_nfo: &KrateLicense<'_>,
     expr: &spdx::Expression,
     nfo: &LicenseExprInfo,
@@ -77,10 +74,10 @@ fn evaluate_expression(
 
     // Check to see if the crate matches an exception, which is additional to
     // the general allow list
-    let exception_ind = cfg.exceptions.iter().position(|exc| {
-        exc.name.as_ref() == &krate_lic_nfo.krate.name
-            && crate::match_req(&krate_lic_nfo.krate.version, exc.version.as_ref())
-    });
+    let exception_ind = cfg
+        .exceptions
+        .iter()
+        .position(|exc| crate::match_krate(krate_lic_nfo.krate, &exc.spec));
 
     let eval_res = expr.evaluate_with_failures(|req| {
         // 1. Exceptions are additional per-crate licenses that aren't blanket
@@ -89,7 +86,7 @@ fn evaluate_expression(
         if let Some(ind) = exception_ind {
             let exception = &cfg.exceptions[ind];
             for allow in &exception.allowed {
-                if allow.value.satisfies(req) {
+                if allow.0.value.satisfies(req) {
                     // Note that hit the exception
                     hits.exceptions.as_mut_bitslice().set(ind, true);
                     allow!(ExplicitException);
@@ -104,7 +101,7 @@ fn evaluate_expression(
         // banning Apache-2.0, but allowing MIT, will allow the crate
         // to be used as you are upholding at least one license requirement
         for deny in &cfg.denied {
-            if deny.value.satisfies(req) {
+            if deny.0.value.satisfies(req) {
                 deny!(Denied);
             }
         }
@@ -112,7 +109,7 @@ fn evaluate_expression(
         // 3. A license that is specifically allowed will of course mean
         // that the requirement is met.
         for (i, allow) in cfg.allowed.iter().enumerate() {
-            if allow.value.satisfies(req) {
+            if allow.0.value.satisfies(req) {
                 hits.allowed.as_mut_bitslice().set(i, true);
                 allow!(ExplicitAllowance);
             }
@@ -269,7 +266,7 @@ fn evaluate_expression(
 }
 
 pub fn check(
-    ctx: crate::CheckCtx<'_, ValidConfig>,
+    ctx: crate::CheckCtx<'_, cfg::ValidConfig>,
     summary: Summary<'_>,
     mut sink: crate::diag::ErrorSink,
 ) {
@@ -356,7 +353,7 @@ pub fn check(
             pack.push(diags::UnmatchedLicenseException {
                 license_exc_cfg: CfgCoord {
                     file: exc.file_id,
-                    span: exc.name.span,
+                    span: exc.spec.name.span,
                 },
             });
         }
@@ -381,7 +378,7 @@ pub fn check(
                 severity: ctx.cfg.unused_allowed_license.into(),
                 allowed_license_cfg: CfgCoord {
                     file: ctx.cfg.file_id,
-                    span: allowed.span,
+                    span: allowed.0.span,
                 },
             });
         }
diff --git a/src/licenses/cfg.rs b/src/licenses/cfg.rs
index 5fa971619..57aa138bd 100644
--- a/src/licenses/cfg.rs
+++ b/src/licenses/cfg.rs
@@ -4,7 +4,7 @@
 //! configuration.
 //!
 //! ```
-//! use cargo_deny::{LintLevel, licenses::Config};
+//! use cargo_deny::{LintLevel, licenses::cfg::Config};
 //!
 //! let dc = Config::default();
 //!
@@ -22,21 +22,20 @@
 //! ```
 
 use crate::{
+    cfg::{PackageSpec, ValidationContext},
     diag::{Diagnostic, FileId, Label},
     LintLevel, PathBuf, Spanned,
 };
-use semver::VersionReq;
-use serde::Deserialize;
+use toml_span::{de_helpers::TableHelper, value::Value, DeserError, Deserialize};
 
-const fn confidence_threshold() -> f32 {
-    0.8
-}
+const DEFAULT_CONFIDENCE_THRESHOLD: f32 = 0.8;
 
 /// Allows agreement of licensing terms based on whether the license is
 /// [OSI Approved](https://opensource.org/licenses) or [considered free](
 /// https://www.gnu.org/licenses/license-list.en.html) by the FSF
-#[derive(Deserialize, Debug, PartialEq, Eq, Default)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, strum::VariantArray, strum::VariantNames)]
+#[cfg_attr(test, derive(serde::Serialize))]
+#[strum(serialize_all = "kebab-case")]
 pub enum BlanketAgreement {
     /// The license must be both OSI Approved and FSF/Free Libre
     Both,
@@ -55,28 +54,45 @@ pub enum BlanketAgreement {
     Neither,
 }
 
+crate::enum_deser!(BlanketAgreement);
+
 /// Configures how private crates are handled and detected
-#[derive(Deserialize, Default)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
+#[derive(Default)]
+#[cfg_attr(test, derive(serde::Serialize))]
 pub struct Private {
     /// If enabled, ignores workspace crates that aren't published, or are
     /// only published to private registries
-    #[serde(default)]
     pub ignore: bool,
     /// One or more URLs to private registries, if a crate comes from one
     /// of these registries, the crate will not have its license checked
-    #[serde(default)]
     pub ignore_sources: Vec<Spanned<String>>,
     /// One or more private registries that you might publish crates to, if
     /// a crate is only published to private registries, and ignore is true
     /// the crate will not have its license checked
-    #[serde(default)]
     pub registries: Vec<String>,
 }
 
+impl<'de> Deserialize<'de> for Private {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let mut th = TableHelper::new(value)?;
+
+        let ignore = th.optional("ignore").unwrap_or_default();
+        let ignore_sources = th.optional("ignore-sources").unwrap_or_default();
+        let registries = th.optional("registries").unwrap_or_default();
+
+        th.finalize(None)?;
+
+        Ok(Self {
+            ignore,
+            ignore_sources,
+            registries,
+        })
+    }
+}
+
 /// The path and hash of a LICENSE file
-#[derive(PartialEq, Eq, Deserialize)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
+#[derive(PartialEq, Eq)]
+#[cfg_attr(test, derive(serde::Serialize))]
 pub struct FileSource {
     /// The crate relative path of the LICENSE file
     /// Spanned so we can report typos on it in case it never matches anything.
@@ -88,6 +104,22 @@ pub struct FileSource {
     pub hash: u32,
 }
 
+impl<'de> Deserialize<'de> for FileSource {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let mut th = TableHelper::new(value)?;
+
+        let path: Spanned<String> = th.required("path")?;
+        let hash = th.required("hash")?;
+
+        th.finalize(None)?;
+
+        Ok(Self {
+            path: path.map(),
+            hash,
+        })
+    }
+}
+
 /// Some crates have complicated LICENSE files that eg contain multiple license
 /// texts in a single file, or are otherwise sufficiently different from the
 /// canonical license text that the confidence level cargo-deny can attribute to
@@ -98,13 +130,9 @@ pub struct FileSource {
 /// ground truth for that expression. If the files change in a future version
 /// of the crate, the clarification will be ignored and the crate will be checked
 /// as normal.
-#[derive(Deserialize)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
 pub struct Clarification {
-    /// The name of the crate that this clarification applies to
-    pub name: String,
-    /// The optional version constraint for the crate. Defaults to any version.
-    pub version: Option<VersionReq>,
+    /// The package spec the clarification applies to
+    pub spec: PackageSpec,
     /// The [SPDX expression](https://spdx.github.io/spdx-spec/appendix-IV-SPDX-license-expressions/)
     /// to apply to the crate.
     pub expression: Spanned<String>,
@@ -112,66 +140,113 @@ pub struct Clarification {
     pub license_files: Vec<FileSource>,
 }
 
+impl<'de> Deserialize<'de> for Clarification {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let spec = PackageSpec::deserialize(value)?;
+
+        let mut th = TableHelper::new(value)?;
+
+        let expression = th.required("expression")?;
+        let license_files = th.required("license-files")?;
+
+        th.finalize(None)?;
+
+        Ok(Self {
+            spec,
+            expression,
+            license_files,
+        })
+    }
+}
+
 /// An exception is a way for 1 or more licenses to be allowed only for a
 /// particular crate.
-#[derive(Deserialize)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
 pub struct Exception {
-    /// The name of the crate to apply the exception to.
-    pub name: Spanned<String>,
-    /// The optional version constraint for the crate. Defaults to any version.
-    pub version: Option<VersionReq>,
+    /// The package spec the exception applies to
+    pub spec: PackageSpec,
     /// One or more [SPDX identifiers](https://spdx.org/licenses/) that are
     /// allowed only for this crate.
-    pub allow: Vec<Spanned<String>>,
+    pub allow: Vec<Licensee>,
+}
+
+impl<'de> Deserialize<'de> for Exception {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let spec = PackageSpec::deserialize(value)?;
+
+        let mut th = TableHelper::new(value)?;
+        let allow = th.required("allow")?;
+
+        th.finalize(None)?;
+
+        Ok(Self { spec, allow })
+    }
+}
+
+#[derive(Debug, PartialEq, Eq, Ord, PartialOrd)]
+pub struct Licensee(pub Spanned<spdx::Licensee>);
+
+impl<'de> Deserialize<'de> for Licensee {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let val = value.take_string(Some("an SPDX licensee string"))?;
+
+        match spdx::Licensee::parse(&val) {
+            Ok(licensee) => Ok(Self(Spanned::with_span(licensee, value.span))),
+            Err(pe) => {
+                let offset = value.span.start;
+
+                Err(toml_span::Error {
+                    kind: toml_span::ErrorKind::Custom(pe.reason.to_string().into()),
+                    span: (pe.span.start + offset..pe.span.end + offset).into(),
+                    line_info: None,
+                }
+                .into())
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+impl serde::Serialize for Licensee {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        self.0.value.to_string().serialize(serializer)
+    }
 }
 
 /// Top level configuration for the a license check
-#[derive(Deserialize)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
 pub struct Config {
-    #[serde(default)]
     pub private: Private,
     /// Determines what happens when license information cannot be determined
     /// for a crate
-    #[serde(default = "crate::lint_deny")]
     pub unlicensed: LintLevel,
     /// Accepts license requirements based on whether they are OSI Approved or
     /// FSF/Free Libre
-    #[serde(default)]
     pub allow_osi_fsf_free: BlanketAgreement,
     /// Determines what happens when a copyleft license is detected
-    #[serde(default = "crate::lint_warn")]
     pub copyleft: LintLevel,
     /// Determines what happens when a license doesn't match any previous
     /// predicates
-    #[serde(default = "crate::lint_deny")]
     pub default: LintLevel,
     /// The minimum confidence threshold we allow when determining the license
     /// in a text file, on a 0.0 (none) to 1.0 (maximum) scale
-    #[serde(default = "confidence_threshold")]
     pub confidence_threshold: f32,
     /// Licenses that will be rejected in a license expression
-    #[serde(default)]
-    pub deny: Vec<Spanned<String>>,
+    pub deny: Vec<Licensee>,
     /// Licenses that will be allowed in a license expression
-    #[serde(default)]
-    pub allow: Vec<Spanned<String>>,
+    pub allow: Vec<Licensee>,
     /// Determines the response to licenses in th `allow`ed list which do not
     /// exist in the dependency tree.
-    #[serde(default = "crate::lint_warn")]
     pub unused_allowed_license: LintLevel,
     /// Overrides the license expression used for a particular crate as long as
     /// it exactly matches the specified license files and hashes
-    #[serde(default)]
     pub clarify: Vec<Clarification>,
     /// Allow 1 or more additional licenses on a per-crate basis, so particular
     /// licenses aren't accepted for every possible crate and must be opted into
-    #[serde(default)]
     pub exceptions: Vec<Exception>,
     /// If true, performs license checks for dev-dependencies for workspace
     /// crates as well
-    #[serde(default)]
     pub include_dev: bool,
 }
 
@@ -184,7 +259,7 @@ impl Default for Config {
             copyleft: LintLevel::Warn,
             default: LintLevel::Deny,
             unused_allowed_license: LintLevel::Warn,
-            confidence_threshold: confidence_threshold(),
+            confidence_threshold: DEFAULT_CONFIDENCE_THRESHOLD,
             deny: Vec::new(),
             allow: Vec::new(),
             clarify: Vec::new(),
@@ -194,27 +269,43 @@ impl Default for Config {
     }
 }
 
-fn parse_license(
-    ls: &Spanned<String>,
-    v: &mut Vec<Licensee>,
-    diags: &mut Vec<Diagnostic>,
-    cfg_file: FileId,
-) {
-    match spdx::Licensee::parse(ls.as_ref()) {
-        Ok(licensee) => {
-            v.push(Licensee::new(licensee, ls.span.clone()));
-        }
-        Err(pe) => {
-            let offset = ls.span.start + 1;
-            let span = pe.span.start + offset..pe.span.end + offset;
-            diags.push(
-                Diagnostic::error()
-                    .with_message("invalid licensee")
-                    .with_labels(vec![
-                        Label::primary(cfg_file, span).with_message(format!("{}", pe.reason))
-                    ]),
-            );
-        }
+impl<'de> Deserialize<'de> for Config {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let mut th = TableHelper::new(value)?;
+
+        let private = th.optional("private").unwrap_or_default();
+        let unlicensed = th.optional("unlicensed").unwrap_or(LintLevel::Deny);
+        let allow_osi_fsf_free = th.optional("allow-osi-fsf-free").unwrap_or_default();
+        let copyleft = th.optional("copyleft").unwrap_or(LintLevel::Warn);
+        let default = th.optional("default").unwrap_or(LintLevel::Deny);
+        let confidence_threshold = th
+            .optional("confidence-threshold")
+            .unwrap_or(DEFAULT_CONFIDENCE_THRESHOLD);
+        let deny = th.optional("deny").unwrap_or_default();
+        let allow = th.optional("allow").unwrap_or_default();
+        let unused_allowed_license = th
+            .optional("unused-allowed-license")
+            .unwrap_or(LintLevel::Warn);
+        let clarify = th.optional("clarify").unwrap_or_default();
+        let exceptions = th.optional("exceptions").unwrap_or_default();
+        let include_dev = th.optional("include-dev").unwrap_or_default();
+
+        th.finalize(None)?;
+
+        Ok(Self {
+            private,
+            unlicensed,
+            allow_osi_fsf_free,
+            copyleft,
+            default,
+            confidence_threshold,
+            deny,
+            allow,
+            unused_allowed_license,
+            clarify,
+            exceptions,
+            include_dev,
+        })
     }
 }
 
@@ -226,12 +317,7 @@ impl crate::cfg::UnvalidatedConfig for Config {
     /// 1. Ensures all SPDX identifiers are valid
     /// 1. Ensures all SPDX expressions are valid
     /// 1. Ensures the same license is not both allowed and denied
-    fn validate(
-        self,
-        cfg_file: FileId,
-        _files: &mut crate::diag::Files,
-        diags: &mut Vec<Diagnostic>,
-    ) -> Self::ValidCfg {
+    fn validate(self, mut ctx: ValidationContext<'_>) -> Self::ValidCfg {
         use rayon::prelude::*;
 
         let mut ignore_sources = Vec::with_capacity(self.private.ignore_sources.len());
@@ -242,58 +328,41 @@ impl crate::cfg::UnvalidatedConfig for Config {
                     ignore_sources.push(url);
                 }
                 Err(pe) => {
-                    diags.push(
+                    ctx.push(
                         Diagnostic::error()
                             .with_message("failed to parse url")
-                            .with_labels(vec![Label::primary(cfg_file, aurl.span.clone())
-                                .with_message(pe.to_string())]),
+                            .with_labels(vec![
+                                Label::primary(ctx.cfg_id, aurl.span).with_message(pe.to_string())
+                            ]),
                     );
                 }
             }
         }
 
-        let mut denied = Vec::with_capacity(self.deny.len());
-        for d in &self.deny {
-            parse_license(d, &mut denied, diags, cfg_file);
-        }
-
-        let mut allowed: Vec<Licensee> = Vec::with_capacity(self.allow.len());
-        for a in &self.allow {
-            parse_license(a, &mut allowed, diags, cfg_file);
-        }
+        let mut denied = self.deny;
+        let mut allowed = self.allow;
 
         denied.par_sort();
         allowed.par_sort();
 
         let mut exceptions = Vec::with_capacity(self.exceptions.len());
-        for exc in self.exceptions {
-            let mut allowed = Vec::with_capacity(exc.allow.len());
-
-            for allow in &exc.allow {
-                parse_license(allow, &mut allowed, diags, cfg_file);
-            }
-
-            exceptions.push(ValidException {
-                name: exc.name,
-                version: exc.version,
-                allowed,
-                file_id: cfg_file,
-            });
-        }
+        exceptions.extend(self.exceptions.into_iter().map(|exc| ValidException {
+            spec: exc.spec,
+            allowed: exc.allow,
+            file_id: ctx.cfg_id,
+        }));
 
         // Ensure the config doesn't contain the same exact license as both
         // denied and allowed, that's confusing and probably not intended, so
         // they should pick one
         for (di, d) in denied.iter().enumerate() {
             if let Ok(ai) = allowed.binary_search(d) {
-                diags.push(
+                ctx.push(
                     Diagnostic::error()
                         .with_message("a license id was specified in both `allow` and `deny`")
                         .with_labels(vec![
-                            Label::secondary(cfg_file, denied[di].span.clone())
-                                .with_message("deny"),
-                            Label::secondary(cfg_file, allowed[ai].span.clone())
-                                .with_message("allow"),
+                            Label::secondary(ctx.cfg_id, denied[di].0.span).with_message("deny"),
+                            Label::secondary(ctx.cfg_id, allowed[ai].0.span).with_message("allow"),
                         ]),
                 );
             }
@@ -304,13 +373,13 @@ impl crate::cfg::UnvalidatedConfig for Config {
             let expr = match spdx::Expression::parse(c.expression.as_ref()) {
                 Ok(validated) => validated,
                 Err(err) => {
-                    let offset = c.expression.span.start + 1;
+                    let offset = c.expression.span.start;
                     let expr_span = offset + err.span.start..offset + err.span.end;
 
-                    diags.push(
+                    ctx.push(
                         Diagnostic::error()
                             .with_message("unable to parse license expression")
-                            .with_labels(vec![Label::primary(cfg_file, expr_span)
+                            .with_labels(vec![Label::primary(ctx.cfg_id, expr_span)
                                 .with_message(err.reason.to_string())]),
                     );
 
@@ -322,16 +391,15 @@ impl crate::cfg::UnvalidatedConfig for Config {
             license_files.sort_by(|a, b| a.path.cmp(&b.path));
 
             clarifications.push(ValidClarification {
-                name: c.name,
-                version: c.version,
-                expr_offset: (c.expression.span.start + 1),
+                spec: c.spec,
+                expr_offset: c.expression.span.start,
                 expression: expr,
                 license_files,
             });
         }
 
         ValidConfig {
-            file_id: cfg_file,
+            file_id: ctx.cfg_id,
             private: self.private,
             unlicensed: self.unlicensed,
             copyleft: self.copyleft,
@@ -355,12 +423,6 @@ pub fn load_exceptions(
     files: &mut crate::diag::Files,
     diags: &mut Vec<Diagnostic>,
 ) {
-    // TOML can't have unnamed arrays at the root.
-    #[derive(Deserialize)]
-    pub struct ExceptionsConfig {
-        pub exceptions: Vec<Exception>,
-    }
-
     let content = match std::fs::read_to_string(&path) {
         Ok(c) => c,
         Err(err) => {
@@ -373,59 +435,68 @@ pub fn load_exceptions(
         }
     };
 
-    let exc_cfg: ExceptionsConfig = match toml::from_str(&content) {
-        Ok(ec) => ec,
-        Err(err) => {
-            diags.push(
-                Diagnostic::error()
-                    .with_message("failed to deserialize exceptions override")
-                    .with_notes(vec![format!("path = '{path}'"), format!("error = {err:#}")]),
-            );
-            return;
-        }
-    };
-
     let file_id = files.add(path, content);
 
-    cfg.exceptions.reserve(exc_cfg.exceptions.len());
-    for exc in exc_cfg.exceptions {
-        let mut allowed = Vec::with_capacity(exc.allow.len());
+    let get_exceptions = || -> Result<Vec<Exception>, DeserError> {
+        let mut parsed = toml_span::parse(files.source(file_id))?;
+        let mut th = TableHelper::new(&mut parsed)?;
+        let exceptions = th.required("exceptions")?;
+        th.finalize(None)?;
+        Ok(exceptions)
+    };
 
-        for allow in &exc.allow {
-            parse_license(allow, &mut allowed, diags, file_id);
+    match get_exceptions() {
+        Ok(exceptions) => {
+            cfg.exceptions.reserve(exceptions.len());
+            for exc in exceptions {
+                cfg.exceptions.push(ValidException {
+                    spec: exc.spec,
+                    allowed: exc.allow,
+                    file_id,
+                });
+            }
+        }
+        Err(err) => {
+            diags.extend(err.errors.into_iter().map(|err| err.to_diagnostic(file_id)));
         }
-
-        cfg.exceptions.push(ValidException {
-            name: exc.name,
-            version: exc.version,
-            allowed,
-            file_id,
-        });
     }
 }
 
 #[doc(hidden)]
-#[cfg_attr(test, derive(Debug, PartialEq))]
+#[cfg_attr(test, derive(PartialEq))]
 pub struct ValidClarification {
-    pub name: String,
-    pub version: Option<VersionReq>,
+    pub spec: PackageSpec,
     pub expr_offset: usize,
     pub expression: spdx::Expression,
     pub license_files: Vec<FileSource>,
 }
 
+#[cfg(test)]
+impl serde::Serialize for ValidClarification {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        use serde::ser::SerializeMap;
+        let mut map = serializer.serialize_map(Some(4))?;
+        map.serialize_entry("spec", &self.spec)?;
+        map.serialize_entry("expression", self.expression.as_ref())?;
+        map.serialize_entry("license-files", &self.license_files)?;
+        map.end()
+    }
+}
+
 #[doc(hidden)]
 #[derive(Debug, PartialEq, Eq)]
+#[cfg_attr(test, derive(serde::Serialize))]
 pub struct ValidException {
-    pub name: crate::Spanned<String>,
-    pub version: Option<VersionReq>,
+    pub spec: PackageSpec,
     pub allowed: Vec<Licensee>,
     pub file_id: FileId,
 }
 
-pub type Licensee = Spanned<spdx::Licensee>;
-
 #[doc(hidden)]
+#[cfg_attr(test, derive(serde::Serialize))]
 pub struct ValidConfig {
     pub file_id: FileId,
     pub private: Private,
@@ -446,70 +517,29 @@ pub struct ValidConfig {
 #[cfg(test)]
 mod test {
     use super::*;
-    use crate::cfg::{test::*, *};
+    use crate::test_utils::{write_diagnostics, ConfigData};
 
-    #[derive(Deserialize)]
-    #[serde(deny_unknown_fields)]
     struct Licenses {
         licenses: Config,
     }
 
+    impl<'de> toml_span::Deserialize<'de> for Licenses {
+        fn deserialize(
+            value: &mut toml_span::value::Value<'de>,
+        ) -> Result<Self, toml_span::DeserError> {
+            let mut th = toml_span::de_helpers::TableHelper::new(value)?;
+            let licenses = th.required("licenses").unwrap();
+            th.finalize(None)?;
+            Ok(Self { licenses })
+        }
+    }
+
     #[test]
     fn deserializes_licenses_cfg() {
-        let mut cd: ConfigData<Licenses> = load("tests/cfg/licenses.toml");
-
-        let mut diags = Vec::new();
-        let validated = cd
-            .config
-            .licenses
-            .validate(cd.id, &mut cd.files, &mut diags);
-        assert!(diags.is_empty());
-
-        assert_eq!(validated.file_id, cd.id);
-        assert!(validated.private.ignore);
-        assert_eq!(validated.private.registries, vec!["sekrets".to_owned()]);
-        assert_eq!(validated.unlicensed, LintLevel::Warn);
-        assert_eq!(validated.copyleft, LintLevel::Deny);
-        assert_eq!(validated.unused_allowed_license, LintLevel::Warn);
-        assert_eq!(validated.default, LintLevel::Warn);
-        assert_eq!(validated.allow_osi_fsf_free, BlanketAgreement::Both);
-        assert_eq!(
-            validated.allowed,
-            vec![
-                spdx::Licensee::parse("Apache-2.0 WITH LLVM-exception").unwrap(),
-                spdx::Licensee::parse("EUPL-1.2").unwrap(),
-            ]
-        );
-        assert_eq!(
-            validated.denied,
-            vec![
-                spdx::Licensee::parse("BSD-2-Clause").unwrap(),
-                spdx::Licensee::parse("Nokia").unwrap(),
-            ]
-        );
-        assert_eq!(
-            validated.exceptions,
-            vec![ValidException {
-                name: "adler32".to_owned().fake(),
-                allowed: vec![spdx::Licensee::parse("Zlib").unwrap().fake()],
-                version: Some(semver::VersionReq::parse("0.1.1").unwrap()),
-                file_id: cd.id,
-            }]
-        );
-        let p: PathBuf = "LICENSE".into();
-        assert_eq!(
-            validated.clarifications,
-            vec![ValidClarification {
-                name: "ring".to_owned(),
-                version: None,
-                expression: spdx::Expression::parse("MIT AND ISC AND OpenSSL").unwrap(),
-                license_files: vec![FileSource {
-                    path: p.fake(),
-                    hash: 0xbd0e_ed23,
-                }],
-                expr_offset: 450,
-            }]
-        );
+        let cd = ConfigData::<Licenses>::load("tests/cfg/licenses.toml");
+        let validated = cd.validate(|l| l.licenses);
+
+        insta::assert_json_snapshot!(validated);
     }
 
     #[test]
@@ -531,18 +561,13 @@ deny = [
     "AGPL-3.0",
 ]"#;
 
-        let mut cd: ConfigData<Licenses> = load_str("license-in-allow-and-deny", cfg);
-        let mut diags = Vec::new();
-        let _validated = cd
-            .config
-            .licenses
-            .validate(cd.id, &mut cd.files, &mut diags);
-
-        let diags: Vec<_> = diags
-            .into_iter()
-            .map(|d| crate::diag::diag_to_json(d.into(), &cd.files, None))
-            .collect();
-
-        insta::assert_json_snapshot!(diags);
+        let cd = ConfigData::<Licenses>::load_str("license-in-allow-and-deny", cfg);
+        let _validated = cd.validate_with_diags(
+            |l| l.licenses,
+            |files, diags| {
+                let diags = write_diagnostics(files, diags.into_iter());
+                insta::assert_snapshot!(diags);
+            },
+        );
     }
 }
diff --git a/src/licenses/diags.rs b/src/licenses/diags.rs
index 63d102fea..d39b76f2a 100644
--- a/src/licenses/diags.rs
+++ b/src/licenses/diags.rs
@@ -102,7 +102,7 @@ pub(crate) struct MissingClarificationFile<'a> {
 
 impl<'a> From<MissingClarificationFile<'a>> for Label {
     fn from(mcf: MissingClarificationFile<'a>) -> Self {
-        Label::secondary(mcf.cfg_file_id, mcf.expected.span.clone())
+        Label::secondary(mcf.cfg_file_id, mcf.expected.span)
             .with_message("unable to locate specified license file")
     }
 }
diff --git a/src/licenses/gather.rs b/src/licenses/gather.rs
index 628130044..eb6b7ea16 100644
--- a/src/licenses/gather.rs
+++ b/src/licenses/gather.rs
@@ -14,9 +14,8 @@ fn iter_clarifications<'a>(
     all: &'a [ValidClarification],
     krate: &'a Krate,
 ) -> impl Iterator<Item = &'a ValidClarification> {
-    all.iter().filter(move |vc| {
-        vc.name == krate.name && crate::match_req(&krate.version, vc.version.as_ref())
-    })
+    all.iter()
+        .filter(move |vc| crate::match_krate(krate, &vc.spec))
 }
 
 impl fmt::Debug for FileSource {
diff --git a/src/root_cfg.rs b/src/root_cfg.rs
new file mode 100644
index 000000000..6f1205e97
--- /dev/null
+++ b/src/root_cfg.rs
@@ -0,0 +1,169 @@
+use crate::{
+    advisories::cfg::Config as AdvisoriesConfig, bans::cfg::Config as BansConfig,
+    licenses::cfg::Config as LicensesConfig, sources::cfg::Config as SourcesConfig, Spanned,
+};
+use toml_span::{
+    de_helpers::TableHelper,
+    value::{Value, ValueInner},
+    DeserError, Deserialize,
+};
+
+pub struct Target {
+    pub filter: Spanned<krates::Target>,
+    pub features: Vec<String>,
+}
+
+impl<'de> Deserialize<'de> for Target {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let (triple, features) = match value.take() {
+            ValueInner::String(s) => (Spanned::with_span(s, value.span), Vec::new()),
+            ValueInner::Table(tab) => {
+                let mut th = TableHelper::from((tab, value.span));
+                let triple = th.required("triple")?;
+                let features = th.optional("features").unwrap_or_default();
+                th.finalize(None)?;
+
+                (triple, features)
+            }
+            other => {
+                return Err(
+                    toml_span::de_helpers::expected("a string or table", other, value.span).into(),
+                )
+            }
+        };
+
+        Ok(Self {
+            filter: triple.map(),
+            features,
+        })
+    }
+}
+
+#[derive(Default)]
+pub struct GraphConfig {
+    pub targets: Vec<Target>,
+    pub exclude: Vec<String>,
+    pub features: Vec<String>,
+    pub all_features: bool,
+    pub no_default_features: bool,
+    /// By default, dev dependencies for workspace crates are not ignored
+    pub exclude_dev: bool,
+}
+
+impl<'de> Deserialize<'de> for GraphConfig {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let mut th = TableHelper::new(value)?;
+        let targets = th.optional("targets").unwrap_or_default();
+        let exclude = th.optional("exclude").unwrap_or_default();
+        let features = th.optional("features").unwrap_or_default();
+        let all_features = th.optional("all-features").unwrap_or_default();
+        let no_default_features = th.optional("no-default-features").unwrap_or_default();
+        let exclude_dev = th.optional("exclude-dev").unwrap_or_default();
+        th.finalize(None)?;
+
+        Ok(Self {
+            targets,
+            exclude,
+            features,
+            all_features,
+            no_default_features,
+            exclude_dev,
+        })
+    }
+}
+
+#[derive(Default)]
+pub struct OutputConfig {
+    pub feature_depth: Option<u32>,
+}
+
+impl<'de> Deserialize<'de> for OutputConfig {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let mut th = TableHelper::new(value)?;
+        let feature_depth = th.optional("feature-depth");
+        th.finalize(None)?;
+        Ok(Self { feature_depth })
+    }
+}
+
+pub struct RootConfig {
+    pub advisories: Option<AdvisoriesConfig>,
+    pub bans: Option<BansConfig>,
+    pub licenses: Option<LicensesConfig>,
+    pub sources: Option<SourcesConfig>,
+    pub graph: GraphConfig,
+    pub output: OutputConfig,
+    // Bit ugly but we keep track of usage of deprecated options until they
+    // are removed
+    pub graph_deprecated: Vec<crate::Span>,
+    pub output_deprecated: Option<crate::Span>,
+}
+
+impl<'de> Deserialize<'de> for RootConfig {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let mut th = TableHelper::new(value)?;
+
+        let advisories = th.optional("advisories");
+        let bans = th.optional("bans");
+        let licenses = th.optional("licenses");
+        let sources = th.optional("sources");
+
+        let mut graph: GraphConfig = th.optional("graph").unwrap_or_default();
+
+        fn deser<'de, T>(v: &mut Value<'de>, errors: &mut Vec<toml_span::Error>) -> T
+        where
+            T: Deserialize<'de> + Default,
+        {
+            match T::deserialize(v) {
+                Ok(v) => v,
+                Err(mut err) => {
+                    errors.append(&mut err.errors);
+                    T::default()
+                }
+            }
+        }
+
+        let graph_deprecated = {
+            let mut gd = Vec::new();
+
+            macro_rules! dep {
+                ($name:literal, $field:ident) => {
+                    if let Some((k, mut v)) = th.take($name) {
+                        gd.push(k.span);
+                        graph.$field = deser(&mut v, &mut th.errors);
+                    }
+                };
+            }
+
+            dep!("targets", targets);
+            dep!("exclude", exclude);
+            dep!("features", features);
+            dep!("all-features", all_features);
+            dep!("no-default-features", no_default_features);
+            dep!("exclude-dev", exclude_dev);
+
+            gd
+        };
+
+        let mut output: OutputConfig = th.optional("output").unwrap_or_default();
+        let output_deprecated = if let Some((key, mut v)) = th.take("feature-depth") {
+            output.feature_depth = Some(deser(&mut v, &mut th.errors));
+            Some(key.span)
+        } else {
+            None
+        };
+
+        th.finalize(None)?;
+
+        Ok(Self {
+            advisories,
+            bans,
+            licenses,
+            sources,
+            graph,
+            graph_deprecated,
+            output,
+            output_deprecated,
+        })
+    }
+}
diff --git a/src/sources.rs b/src/sources.rs
index c57219ca4..00bd2535a 100644
--- a/src/sources.rs
+++ b/src/sources.rs
@@ -1,6 +1,6 @@
-mod cfg;
+pub mod cfg;
 mod diags;
-pub use cfg::{Config, GitSpec, ValidConfig};
+use cfg::ValidConfig;
 pub use diags::Code;
 
 use crate::{
@@ -31,7 +31,7 @@ pub fn check(ctx: crate::CheckCtx<'_, ValidConfig>, sink: impl Into<ErrorSink>)
         (
             rgs.value,
             CfgCoord {
-                span: rgs.span.clone(),
+                span: rgs.span,
                 file: ctx.cfg.file_id,
             },
         )
@@ -94,7 +94,7 @@ pub fn check(ctx: crate::CheckCtx<'_, ValidConfig>, sink: impl Into<ErrorSink>)
                 type_name,
                 allow_cfg: CfgCoord {
                     file: ctx.cfg.file_id,
-                    span: ctx.cfg.allowed_sources[ind].url.span.clone(),
+                    span: ctx.cfg.allowed_sources[ind].url.span,
                 },
             }
             .into()
@@ -115,7 +115,7 @@ pub fn check(ctx: crate::CheckCtx<'_, ValidConfig>, sink: impl Into<ErrorSink>)
                     src_label: sl.get_or_insert_with(label),
                     org_cfg: CfgCoord {
                         file: ctx.cfg.file_id,
-                        span: ctx.cfg.allowed_orgs[ind].1.span.clone(),
+                        span: ctx.cfg.allowed_orgs[ind].1.span,
                     },
                 }
                 .into()
diff --git a/src/sources/cfg.rs b/src/sources/cfg.rs
index 69c7a8efd..ac1db8aab 100644
--- a/src/sources/cfg.rs
+++ b/src/sources/cfg.rs
@@ -1,24 +1,52 @@
 use super::OrgType;
-use crate::{cfg, diag::FileId, LintLevel, Spanned};
-use serde::Deserialize;
-
-#[derive(Deserialize, Default)]
+use crate::{
+    cfg::{self, ValidationContext},
+    diag::FileId,
+    LintLevel, Spanned,
+};
+use toml_span::{de_helpers::TableHelper, value::Value, DeserError, Deserialize};
+
+#[derive(Default)]
 pub struct Orgs {
     /// The list of Github organizations that crates can be sourced from.
-    #[serde(default)]
     github: Vec<Spanned<String>>,
     /// The list of Gitlab organizations that crates can be sourced from.
-    #[serde(default)]
     gitlab: Vec<Spanned<String>>,
     /// The list of Bitbucket organizations that crates can be sourced from.
-    #[serde(default)]
     bitbucket: Vec<Spanned<String>>,
 }
 
+impl<'de> Deserialize<'de> for Orgs {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let mut th = TableHelper::new(value)?;
+        let github = th.optional("github").unwrap_or_default();
+        let gitlab = th.optional("gitlab").unwrap_or_default();
+        let bitbucket = th.optional("bitbucket").unwrap_or_default();
+        th.finalize(None)?;
+
+        Ok(Self {
+            github,
+            gitlab,
+            bitbucket,
+        })
+    }
+}
+
 /// The types of specifiers that can be used on git sources by cargo, in order
 /// of their specificity from least to greatest
-#[derive(Deserialize, PartialEq, Eq, Debug, PartialOrd, Ord, Clone, Copy, Default)]
-#[serde(rename_all = "snake_case")]
+#[derive(
+    PartialEq,
+    Eq,
+    Debug,
+    PartialOrd,
+    Ord,
+    Clone,
+    Copy,
+    Default,
+    strum::VariantArray,
+    strum::VariantNames,
+)]
+#[strum(serialize_all = "kebab-case")]
 pub enum GitSpec {
     /// Specifies the HEAD of the `master` branch, though eventually this might
     /// change to the default branch
@@ -32,6 +60,8 @@ pub enum GitSpec {
     Rev,
 }
 
+crate::enum_deser!(GitSpec);
+
 use std::fmt;
 
 impl fmt::Display for GitSpec {
@@ -45,47 +75,51 @@ impl fmt::Display for GitSpec {
     }
 }
 
-#[derive(Deserialize)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
 pub struct Config {
     /// How to handle registries that weren't listed
-    #[serde(default = "crate::lint_warn")]
     pub unknown_registry: LintLevel,
     /// How to handle git sources that weren't listed
-    #[serde(default = "crate::lint_warn")]
     pub unknown_git: LintLevel,
     /// The list of registries that crates can be sourced from.
     /// Defaults to the crates.io registry if not specified.
-    #[serde(default = "default_allow_registry")]
     pub allow_registry: Vec<Spanned<String>>,
     /// The list of git repositories that crates can be sourced from.
-    #[serde(default)]
     pub allow_git: Vec<Spanned<String>>,
     /// The lists of source control organizations that crates can be sourced from.
-    #[serde(default)]
     pub allow_org: Orgs,
     /// The list of hosts with optional paths from which one or more git repos
     /// can be sourced.
-    #[serde(default)]
     pub private: Vec<Spanned<String>>,
     /// The minimum specification required for git sources. Defaults to allowing
     /// any.
-    #[serde(default)]
     pub required_git_spec: Option<Spanned<GitSpec>>,
 }
 
-#[inline]
-fn default_allow_registry() -> Vec<Spanned<String>> {
-    // This is always valid, so we don't have to worry about the span being fake,
-    // this is actually a lie though because if we try to print this span it will
-    // fail if it falls outside of the range of the config file, and even if it
-    // doesn't will just point to whatever text happens to be there, so we instead
-    // lie and just ignore it instead since a vast majority of usage should
-    // use this source
-    vec![Spanned::new(
-        super::CRATES_IO_URL.to_owned(),
-        0..super::CRATES_IO_URL.len(),
-    )]
+impl<'de> Deserialize<'de> for Config {
+    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
+        let mut th = TableHelper::new(value)?;
+        let unknown_registry = th.optional("unknown-registry").unwrap_or(LintLevel::Warn);
+        let unknown_git = th.optional("unknown-git").unwrap_or(LintLevel::Warn);
+        let allow_registry = th
+            .optional("allow-registry")
+            .unwrap_or_else(|| vec![Spanned::new(super::CRATES_IO_URL.to_owned())]);
+        let allow_git = th.optional("allow-git").unwrap_or_default();
+        let allow_org = th.optional("allow-org").unwrap_or_default();
+        let private = th.optional("private").unwrap_or_default();
+        let required_git_spec = th.optional("required-git-spec");
+
+        th.finalize(None)?;
+
+        Ok(Self {
+            unknown_registry,
+            unknown_git,
+            allow_registry,
+            allow_git,
+            allow_org,
+            private,
+            required_git_spec,
+        })
+    }
 }
 
 impl Default for Config {
@@ -93,7 +127,7 @@ impl Default for Config {
         Self {
             unknown_registry: LintLevel::Warn,
             unknown_git: LintLevel::Warn,
-            allow_registry: default_allow_registry(),
+            allow_registry: vec![Spanned::new(super::CRATES_IO_URL.to_owned())],
             allow_git: Vec::new(),
             allow_org: Orgs::default(),
             private: Vec::new(),
@@ -107,12 +141,7 @@ use crate::diag::{Diagnostic, Label};
 impl cfg::UnvalidatedConfig for Config {
     type ValidCfg = ValidConfig;
 
-    fn validate(
-        self,
-        cfg_file: FileId,
-        _files: &mut crate::diag::Files,
-        diags: &mut Vec<Diagnostic>,
-    ) -> Self::ValidCfg {
+    fn validate(self, mut ctx: ValidationContext<'_>) -> Self::ValidCfg {
         let mut allowed_sources = Vec::with_capacity(
             self.allow_registry.len() + self.allow_git.len() + self.private.len(),
         );
@@ -129,11 +158,11 @@ impl cfg::UnvalidatedConfig for Config {
 
             if let Some(start_scheme) = astr.find("://") {
                 if let Some(i) = astr[..start_scheme].find('+') {
-                    diags.push(
+                    ctx.push(
                         Diagnostic::warning()
                             .with_message("scheme modifiers are unnecessary")
                             .with_labels(vec![Label::primary(
-                                cfg_file,
+                                ctx.cfg_id,
                                 aurl.span.start..aurl.span.start + start_scheme,
                             )]),
                     );
@@ -157,11 +186,11 @@ impl cfg::UnvalidatedConfig for Config {
                     });
                 }
                 Err(pe) => {
-                    diags.push(
+                    ctx.push(
                         Diagnostic::error()
                             .with_message("failed to parse url")
                             .with_labels(vec![
-                                Label::primary(cfg_file, aurl.span).with_message(pe.to_string())
+                                Label::primary(ctx.cfg_id, aurl.span).with_message(pe.to_string())
                             ]),
                     );
                 }
@@ -188,7 +217,7 @@ impl cfg::UnvalidatedConfig for Config {
             .collect();
 
         ValidConfig {
-            file_id: cfg_file,
+            file_id: ctx.cfg_id,
             unknown_registry: self.unknown_registry,
             unknown_git: self.unknown_git,
             allowed_sources,
@@ -207,6 +236,7 @@ pub struct UrlSource {
 }
 
 #[doc(hidden)]
+#[cfg_attr(test, derive(Debug))]
 pub struct ValidConfig {
     pub file_id: FileId,
 
@@ -220,74 +250,34 @@ pub struct ValidConfig {
 #[cfg(test)]
 mod test {
     use super::*;
-    use crate::cfg::{test::*, *};
+    use crate::test_utils::{write_diagnostics, ConfigData};
 
     #[test]
     fn deserializes_sources_cfg() {
-        #[derive(Deserialize)]
-        #[serde(deny_unknown_fields)]
         struct Sources {
             sources: Config,
         }
 
-        let mut cd: ConfigData<Sources> = load("tests/cfg/sources.toml");
-
-        let mut diags = Vec::new();
-        let validated = cd.config.sources.validate(cd.id, &mut cd.files, &mut diags);
-
-        let diags: Vec<_> = diags
-            .into_iter()
-            .map(|d| crate::diag::diag_to_json(d.into(), &cd.files, None))
-            .collect();
-
-        insta::assert_json_snapshot!(diags);
-
-        assert_eq!(validated.file_id, cd.id);
-        assert_eq!(validated.unknown_registry, LintLevel::Allow);
-        assert_eq!(validated.unknown_git, LintLevel::Deny);
+        impl<'de> toml_span::Deserialize<'de> for Sources {
+            fn deserialize(
+                value: &mut toml_span::value::Value<'de>,
+            ) -> Result<Self, toml_span::DeserError> {
+                let mut th = toml_span::de_helpers::TableHelper::new(value)?;
+                let sources = th.required("sources").unwrap();
+                th.finalize(None)?;
+                Ok(Self { sources })
+            }
+        }
 
-        let expected = [
-            UrlSource {
-                url: url::Url::parse("https://sekretz.com/registry/index")
-                    .unwrap()
-                    .fake(),
-                exact: true,
-            },
-            UrlSource {
-                url: url::Url::parse("https://fake.sparse.com").unwrap().fake(),
-                exact: true,
+        let cd = ConfigData::<Sources>::load("tests/cfg/sources.toml");
+        let validated = cd.validate_with_diags(
+            |s| s.sources,
+            |files, diags| {
+                let diags = write_diagnostics(files, diags.into_iter());
+                insta::assert_snapshot!(diags);
             },
-            UrlSource {
-                url: url::Url::parse("https://notgithub.com/orgname/reponame")
-                    .unwrap()
-                    .fake(),
-                exact: true,
-            },
-            UrlSource {
-                url: url::Url::parse("https://internal-host/repos")
-                    .unwrap()
-                    .fake(),
-                exact: false,
-            },
-        ];
-
-        assert_eq!(
-            validated.allowed_sources, expected,
-            "{:#?} != {:#?}",
-            validated.allowed_sources, expected
-        );
-
-        // Obviously order could change here, but for now just hardcode it
-        assert_eq!(
-            validated.allowed_orgs,
-            vec![
-                (OrgType::Github, "yourghid".to_owned().fake()),
-                (OrgType::Github, "YourOrg".to_owned().fake()),
-                (OrgType::Gitlab, "gitlab-org".to_owned().fake()),
-                (OrgType::Bitbucket, "atlassian".to_owned().fake()),
-            ]
         );
 
-        assert_eq!(validated.required_git_spec.unwrap().value, GitSpec::Tag);
+        insta::assert_debug_snapshot!(validated);
     }
 }


Test patch:
diff --git a/src/advisories/snapshots/cargo_deny__advisories__cfg__test__deserializes_advisories_cfg.snap b/src/advisories/snapshots/cargo_deny__advisories__cfg__test__deserializes_advisories_cfg.snap
new file mode 100644
index 000000000..6039f3894
--- /dev/null
+++ b/src/advisories/snapshots/cargo_deny__advisories__cfg__test__deserializes_advisories_cfg.snap
@@ -0,0 +1,44 @@
+---
+source: src/advisories/cfg.rs
+expression: validated
+---
+{
+  "file_id": 1,
+  "db_path": "~/.cargo/advisory-dbs",
+  "db_urls": [
+    "https://github.com/RustSec/advisory-db"
+  ],
+  "ignore": [
+    "RUSTSEC-0000-0000"
+  ],
+  "ignore_yanked": [
+    {
+      "spec": {
+        "name": "crate",
+        "version-req": "=0.1"
+      },
+      "reason": null,
+      "use-instead": null
+    },
+    {
+      "spec": {
+        "name": "yanked",
+        "version-req": null
+      },
+      "reason": "a new version has not been released",
+      "use-instead": null
+    }
+  ],
+  "vulnerability": "deny",
+  "unmaintained": "warn",
+  "unsound": "warn",
+  "yanked": "warn",
+  "notice": "warn",
+  "severity_threshold": "medium",
+  "git_fetch_with_cli": false,
+  "disable_yank_checking": false,
+  "maximum_db_staleness": [
+    466560000,
+    0
+  ]
+}
diff --git a/src/advisories/snapshots/cargo_deny__advisories__cfg__test__rejects_invalid_durations.snap b/src/advisories/snapshots/cargo_deny__advisories__cfg__test__rejects_invalid_durations.snap
index 10433f6bd..a92cfafea 100644
--- a/src/advisories/snapshots/cargo_deny__advisories__cfg__test__rejects_invalid_durations.snap
+++ b/src/advisories/snapshots/cargo_deny__advisories__cfg__test__rejects_invalid_durations.snap
@@ -2,17 +2,17 @@
 source: src/advisories/cfg.rs
 expression: failures
 ---
-Err(duration requires 'P' prefix)
-Err(must supply at least one time unit)
-Err(must supply at least one time unit)
-Err('H' must be preceded with 'T')
-Err(unit not specified for value '2')
-Err(value not specified for 'M')
-Err(unit 'H' cannot follow 'M')
-Err(unit 'Y' cannot follow 'M')
-Err(unit 'Y' cannot follow 'W')
-Err(unit 'H' cannot follow 'W')
-Err('H' must be preceded with 'T')
-Err('S' must be preceded with 'T')
-Err(',' is valid in the RFC-3339 duration format but not supported by this implementation, use '.' instead)
+duration requires 'P' prefix
+"must supply at least one time unit"
+"must supply at least one time unit"
+"'H' must be preceded with 'T'"
+"unit not specified for value '2'"
+"value not specified for 'M'"
+"unit 'H' cannot follow 'M'"
+"unit 'Y' cannot follow 'M'"
+"unit 'Y' cannot follow 'W'"
+"unit 'H' cannot follow 'W'"
+"'H' must be preceded with 'T'"
+"'S' must be preceded with 'T'"
+"',' is valid in the RFC-3339 duration format but not supported by this implementation, use '.' instead"
 
diff --git a/src/advisories/snapshots/cargo_deny__advisories__cfg__test__warns_on_duplicates.snap b/src/advisories/snapshots/cargo_deny__advisories__cfg__test__warns_on_duplicates.snap
new file mode 100644
index 000000000..25e65f960
--- /dev/null
+++ b/src/advisories/snapshots/cargo_deny__advisories__cfg__test__warns_on_duplicates.snap
@@ -0,0 +1,31 @@
+---
+source: src/advisories/cfg.rs
+expression: diags
+---
+warning: duplicate items detected
+   ┌─ duplicates:9:6
+   │
+ 9 │     "RUSTSEC-0000-0001",
+   │      -----------------
+10 │     { crate = "boop" },
+11 │     "RUSTSEC-0000-0001",
+   │      -----------------
+
+warning: duplicate items detected
+   ┌─ duplicates:10:5
+   │
+10 │     { crate = "boop" },
+   │     ------------------
+11 │     "RUSTSEC-0000-0001",
+12 │     "boop",
+   │      ----
+
+warning: duplicate items detected
+  ┌─ duplicates:5:6
+  │
+5 │     "https://one.reg",
+  │      ---------------
+6 │     "https://one.reg",
+  │      ---------------
+
+
diff --git a/src/bans/snapshots/cargo_deny__bans__cfg__test__deserializes_ban_cfg.snap b/src/bans/snapshots/cargo_deny__bans__cfg__test__deserializes_ban_cfg.snap
new file mode 100644
index 000000000..1808d59a6
--- /dev/null
+++ b/src/bans/snapshots/cargo_deny__bans__cfg__test__deserializes_ban_cfg.snap
@@ -0,0 +1,143 @@
+---
+source: src/bans/cfg.rs
+expression: validated
+---
+{
+  "file_id": 1,
+  "multiple_versions": "deny",
+  "multiple_versions_include_dev": false,
+  "highlight": "SimplestPath",
+  "denied": [
+    {
+      "spec": {
+        "name": "specific-versiond",
+        "version-req": "=0.1.9"
+      },
+      "inner": null
+    },
+    {
+      "spec": {
+        "name": "all-versionsd",
+        "version-req": null
+      },
+      "inner": {
+        "wrappers": [
+          "specific-versiona"
+        ],
+        "reason": "we want to get rid of this crate but there is still one user of it",
+        "use_instead": null
+      }
+    }
+  ],
+  "denied_multiple_versions": [],
+  "allowed": [
+    {
+      "spec": {
+        "name": "all-versionsa",
+        "version-req": null
+      },
+      "inner": null
+    },
+    {
+      "spec": {
+        "name": "version-rangea",
+        "version-req": "<0.1.1"
+      },
+      "inner": null
+    },
+    {
+      "spec": {
+        "name": "specific-versionb",
+        "version-req": "=0.1.2"
+      },
+      "inner": null
+    },
+    {
+      "spec": {
+        "name": "any-version",
+        "version-req": null
+      },
+      "inner": null
+    }
+  ],
+  "features": [
+    {
+      "spec": {
+        "name": "featured-krate",
+        "version-req": "^1.0"
+      },
+      "features": {
+        "allow": [
+          "good-feature"
+        ],
+        "deny": [
+          "bad-feature"
+        ],
+        "exact": true
+      },
+      "reason": "`bad-feature` is bad"
+    }
+  ],
+  "external_default_features": "deny",
+  "workspace_default_features": "warn",
+  "skipped": [
+    {
+      "spec": {
+        "name": "rand",
+        "version-req": "=0.6.5"
+      },
+      "inner": null
+    }
+  ],
+  "tree_skipped": [
+    {
+      "spec": {
+        "name": "blah",
+        "version-req": null
+      },
+      "inner": {
+        "depth": 20,
+        "reason": null
+      }
+    }
+  ],
+  "wildcards": "deny",
+  "allow_wildcard_paths": true,
+  "build": {
+    "allow_build_scripts": [
+      {
+        "name": "all-versionsa",
+        "version-req": null
+      }
+    ],
+    "executables": "warn",
+    "script_extensions": [
+      "cs"
+    ],
+    "bypass": [
+      {
+        "spec": {
+          "name": "allversionsa",
+          "version-req": null
+        },
+        "build_script": "5392f0e58ad06e089462d93304dfe82337acbbefb87a0749a7dc2ed32af04af7",
+        "required_features": [
+          "feature-used-at-build-time"
+        ],
+        "allow_globs": [
+          "scripts/*.cs"
+        ],
+        "allow": [
+          {
+            "path": "bin/x86_64-linux",
+            "checksum": "5392f0e58ad06e089462d93304dfe82337acbbefb87a0749a7dc2ed32af04af7"
+          }
+        ]
+      }
+    ],
+    "include_dependencies": true,
+    "include_workspace": true,
+    "include_archives": true,
+    "interpreted": "deny"
+  }
+}
diff --git a/src/cfg/snapshots/cargo_deny__cfg__package_spec__test__deserializes_package_id.snap b/src/cfg/snapshots/cargo_deny__cfg__package_spec__test__deserializes_package_id.snap
new file mode 100644
index 000000000..cf1a253a0
--- /dev/null
+++ b/src/cfg/snapshots/cargo_deny__cfg__package_spec__test__deserializes_package_id.snap
@@ -0,0 +1,54 @@
+---
+source: src/cfg/package_spec.rs
+expression: validated
+---
+{
+  "bare": {
+    "spec": {
+      "name": "bare-name",
+      "version-req": null
+    },
+    "inner": null
+  },
+  "specific": {
+    "spec": {
+      "name": "specific",
+      "version-req": "=0.1.0"
+    },
+    "inner": null
+  },
+  "range": {
+    "spec": {
+      "name": "range",
+      "version-req": "<=1.0"
+    },
+    "inner": {
+      "data": 10
+    }
+  },
+  "mixed": [
+    {
+      "spec": {
+        "name": "bare-name-1",
+        "version-req": null
+      },
+      "inner": null
+    },
+    {
+      "spec": {
+        "name": "range-2",
+        "version-req": ">=1.0, <=2.0"
+      },
+      "inner": {
+        "data": 20
+      }
+    },
+    {
+      "spec": {
+        "name": "specific-3",
+        "version-req": "=0.2.1"
+      },
+      "inner": null
+    }
+  ]
+}
diff --git a/src/licenses/snapshots/cargo_deny__licenses__cfg__test__correct_duplicate_license_spans.snap b/src/licenses/snapshots/cargo_deny__licenses__cfg__test__correct_duplicate_license_spans.snap
index edfcf432b..26b04612e 100644
--- a/src/licenses/snapshots/cargo_deny__licenses__cfg__test__correct_duplicate_license_spans.snap
+++ b/src/licenses/snapshots/cargo_deny__licenses__cfg__test__correct_duplicate_license_spans.snap
@@ -2,26 +2,13 @@
 source: src/licenses/cfg.rs
 expression: diags
 ---
-[
-  {
-    "fields": {
-      "labels": [
-        {
-          "column": 4,
-          "line": 11,
-          "message": "deny",
-          "span": "MIT"
-        },
-        {
-          "column": 5,
-          "line": 3,
-          "message": "allow",
-          "span": "MIT"
-        }
-      ],
-      "message": "a license id was specified in both `allow` and `deny`",
-      "severity": "error"
-    },
-    "type": "diagnostic"
-  }
-]
+error: a license id was specified in both `allow` and `deny`
+   ┌─ license-in-allow-and-deny:3:6
+   │
+ 3 │     "MIT",
+   │      --- allow
+   ·
+11 │    "MIT",
+   │     --- deny
+
+
diff --git a/src/licenses/snapshots/cargo_deny__licenses__cfg__test__deserializes_licenses_cfg.snap b/src/licenses/snapshots/cargo_deny__licenses__cfg__test__deserializes_licenses_cfg.snap
new file mode 100644
index 000000000..b59d77893
--- /dev/null
+++ b/src/licenses/snapshots/cargo_deny__licenses__cfg__test__deserializes_licenses_cfg.snap
@@ -0,0 +1,57 @@
+---
+source: src/licenses/cfg.rs
+expression: validated
+---
+{
+  "file_id": 1,
+  "private": {
+    "ignore": true,
+    "ignore_sources": [],
+    "registries": [
+      "sekrets"
+    ]
+  },
+  "unlicensed": "warn",
+  "copyleft": "deny",
+  "unused_allowed_license": "warn",
+  "allow_osi_fsf_free": "Both",
+  "default": "warn",
+  "confidence_threshold": 0.95,
+  "denied": [
+    "BSD-2-Clause",
+    "Nokia"
+  ],
+  "allowed": [
+    "Apache-2.0 WITH LLVM-exception",
+    "EUPL-1.2"
+  ],
+  "clarifications": [
+    {
+      "spec": {
+        "name": "ring",
+        "version-req": null
+      },
+      "expression": "MIT AND ISC AND OpenSSL",
+      "license-files": [
+        {
+          "path": "LICENSE",
+          "hash": 3171872035
+        }
+      ]
+    }
+  ],
+  "exceptions": [
+    {
+      "spec": {
+        "name": "adler32",
+        "version-req": "^0.1.1"
+      },
+      "allowed": [
+        "Zlib"
+      ],
+      "file_id": 1
+    }
+  ],
+  "ignore_sources": [],
+  "include_dev": false
+}
diff --git a/src/sources/snapshots/cargo_deny__sources__cfg__test__deserializes_sources_cfg-2.snap b/src/sources/snapshots/cargo_deny__sources__cfg__test__deserializes_sources_cfg-2.snap
new file mode 100644
index 000000000..bbb0b31a2
--- /dev/null
+++ b/src/sources/snapshots/cargo_deny__sources__cfg__test__deserializes_sources_cfg-2.snap
@@ -0,0 +1,50 @@
+---
+source: src/sources/cfg.rs
+expression: validated
+---
+ValidConfig {
+    file_id: FileId(
+        1,
+    ),
+    unknown_registry: Allow,
+    unknown_git: Deny,
+    allowed_sources: [
+        UrlSource {
+            url: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("sekretz.com")), port: None, path: "/registry/index", query: None, fragment: None },
+            exact: true,
+        },
+        UrlSource {
+            url: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("fake.sparse.com")), port: None, path: "/", query: None, fragment: None },
+            exact: true,
+        },
+        UrlSource {
+            url: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("notgithub.com")), port: None, path: "/orgname/reponame", query: None, fragment: None },
+            exact: true,
+        },
+        UrlSource {
+            url: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("internal-host")), port: None, path: "/repos", query: None, fragment: None },
+            exact: false,
+        },
+    ],
+    allowed_orgs: [
+        (
+            Github,
+            "yourghid",
+        ),
+        (
+            Github,
+            "YourOrg",
+        ),
+        (
+            Gitlab,
+            "gitlab-org",
+        ),
+        (
+            Bitbucket,
+            "atlassian",
+        ),
+    ],
+    required_git_spec: Some(
+        Tag,
+    ),
+}
diff --git a/src/sources/snapshots/cargo_deny__sources__cfg__test__deserializes_sources_cfg.snap b/src/sources/snapshots/cargo_deny__sources__cfg__test__deserializes_sources_cfg.snap
index f39724358..806f9466c 100644
--- a/src/sources/snapshots/cargo_deny__sources__cfg__test__deserializes_sources_cfg.snap
+++ b/src/sources/snapshots/cargo_deny__sources__cfg__test__deserializes_sources_cfg.snap
@@ -2,20 +2,10 @@
 source: src/sources/cfg.rs
 expression: diags
 ---
-[
-  {
-    "fields": {
-      "labels": [
-        {
-          "column": 5,
-          "line": 7,
-          "message": "",
-          "span": "sparse+http"
-        }
-      ],
-      "message": "scheme modifiers are unnecessary",
-      "severity": "warning"
-    },
-    "type": "diagnostic"
-  }
-]
+warning: scheme modifiers are unnecessary
+  ┌─ tests/cfg/sources.toml:7:6
+  │
+7 │     "sparse+https://fake.sparse.com",
+  │      ^^^^^^^^^^^^
+
+
diff --git a/src/test_utils.rs b/src/test_utils.rs
index 1f4ec4a85..a2fe25976 100644
--- a/src/test_utils.rs
+++ b/src/test_utils.rs
@@ -1,6 +1,7 @@
 use crate::{
-    diag::{self, CargoSpans, ErrorSink, Files, KrateSpans, PackChannel},
-    CheckCtx,
+    cfg::ValidationContext,
+    diag::{self, CargoSpans, ErrorSink, FileId, Files, KrateSpans, PackChannel},
+    CheckCtx, PathBuf,
 };
 
 #[derive(Default, Clone)]
@@ -69,35 +70,145 @@ where
 
 impl<C> Config<C>
 where
-    C: serde::de::DeserializeOwned,
+    C: toml_span::DeserializeOwned,
 {
     pub fn new(config: impl Into<String>) -> Self {
         let config = config.into();
+        let mut val = toml_span::parse(&config).expect("failed to parse test config");
+        let deserialized = C::deserialize(&mut val).expect("failed to deserialize test config");
         Self {
-            deserialized: toml::from_str(&config).expect("failed to deserialize test config"),
+            deserialized,
             config,
         }
     }
 }
 
-impl<'s, C> From<&'s str> for Config<C>
+impl<'de, C> From<&'de str> for Config<C>
 where
-    C: serde::de::DeserializeOwned + Default,
+    C: toml_span::DeserializeOwned,
 {
-    fn from(s: &'s str) -> Self {
+    fn from(s: &'de str) -> Self {
         Self::new(s)
     }
 }
 
 impl<C> From<String> for Config<C>
 where
-    C: serde::de::DeserializeOwned + Default,
+    C: toml_span::DeserializeOwned + Default,
 {
     fn from(s: String) -> Self {
         Self::new(s)
     }
 }
 
+pub struct ConfigData<T> {
+    pub config: T,
+    pub files: Files,
+    pub id: FileId,
+}
+
+impl<T> ConfigData<T> {
+    pub fn file(&self) -> &str {
+        self.files.source(self.id)
+    }
+}
+
+impl<T> ConfigData<T>
+where
+    T: toml_span::DeserializeOwned,
+{
+    pub fn load_str(name: impl Into<std::ffi::OsString>, contents: impl Into<String>) -> Self {
+        let contents: String = contents.into();
+
+        let res = {
+            let mut cval = toml_span::parse(&contents).expect("failed to parse toml");
+            T::deserialize(&mut cval)
+        };
+
+        let mut files = Files::new();
+        let id = files.add(name, contents);
+
+        let config = match res {
+            Ok(v) => v,
+            Err(derr) => {
+                let diag_str = write_diagnostics(
+                    &files,
+                    derr.errors.into_iter().map(|err| err.to_diagnostic(id)),
+                );
+                panic!("failed to deserialize:\n---\n{diag_str}\n---");
+            }
+        };
+
+        ConfigData { config, files, id }
+    }
+
+    pub fn load(path: impl Into<PathBuf>) -> Self {
+        let path = path.into();
+        let contents = std::fs::read_to_string(&path).unwrap();
+
+        Self::load_str(path, contents)
+    }
+}
+
+impl<T> ConfigData<T> {
+    pub fn validate<IV, V>(mut self, conv: impl FnOnce(T) -> IV) -> V
+    where
+        IV: super::UnvalidatedConfig<ValidCfg = V>,
+    {
+        let uvc = conv(self.config);
+
+        let mut diagnostics = Vec::new();
+        let vcfg = uvc.validate(ValidationContext {
+            cfg_id: self.id,
+            files: &mut self.files,
+            diagnostics: &mut diagnostics,
+        });
+
+        if diagnostics.is_empty() {
+            vcfg
+        } else {
+            let diag_str = write_diagnostics(&self.files, diagnostics.into_iter());
+
+            panic!("failed to validate config:\n---\n{diag_str}\n---");
+        }
+    }
+
+    pub fn validate_with_diags<IV, V>(
+        mut self,
+        conv: impl FnOnce(T) -> IV,
+        on_diags: impl FnOnce(&Files, Vec<crate::diag::Diagnostic>),
+    ) -> V
+    where
+        IV: super::UnvalidatedConfig<ValidCfg = V>,
+    {
+        let uvc = conv(self.config);
+
+        let mut diagnostics = Vec::new();
+        let vcfg = uvc.validate(ValidationContext {
+            cfg_id: self.id,
+            files: &mut self.files,
+            diagnostics: &mut diagnostics,
+        });
+
+        on_diags(&self.files, diagnostics);
+        vcfg
+    }
+}
+
+pub(crate) fn write_diagnostics(
+    files: &Files,
+    errors: impl Iterator<Item = crate::diag::Diagnostic>,
+) -> String {
+    let mut s = codespan_reporting::term::termcolor::NoColor::new(Vec::new());
+    let config = codespan_reporting::term::Config::default();
+
+    for diag in errors {
+        codespan_reporting::term::emit(&mut s, &config, files, &diag).unwrap();
+    }
+
+    String::from_utf8(s.into_inner()).unwrap()
+}
+
 #[inline]
 pub fn gather_diagnostics<C, VC, R>(
     krates: &crate::Krates,
@@ -108,7 +219,7 @@ pub fn gather_diagnostics<C, VC, R>(
 where
     C: crate::UnvalidatedConfig<ValidCfg = VC>,
     VC: Send,
-    R: FnOnce(CheckCtx<'_, VC>, CargoSpans, PackChannel) + Send,
+    R: FnOnce(CheckCtx<'_, VC>, CargoSpans, PackChannel, &mut Files) + Send,
 {
     gather_diagnostics_with_files(krates, test_name, cfg, Files::new(), runner)
 }
@@ -123,7 +234,7 @@ pub fn gather_diagnostics_with_files<C, VC, R>(
 where
     C: crate::UnvalidatedConfig<ValidCfg = VC>,
     VC: Send,
-    R: FnOnce(CheckCtx<'_, VC>, CargoSpans, PackChannel) + Send,
+    R: FnOnce(CheckCtx<'_, VC>, CargoSpans, PackChannel, &mut Files) + Send,
 {
     let (spans, content, hashmap) = KrateSpans::synthesize(krates);
 
@@ -140,7 +251,11 @@ where
     }
 
     let mut cfg_diags = Vec::new();
-    let cfg = config.validate(cfg_id, &mut files, &mut cfg_diags);
+    let cfg = config.validate(crate::cfg::ValidationContext {
+        cfg_id,
+        files: &mut files,
+        diagnostics: &mut cfg_diags,
+    });
 
     if cfg_diags
         .iter()
@@ -162,7 +277,7 @@ where
                 serialize_extra: true,
                 colorize: false,
             };
-            runner(ctx, newmap, tx);
+            runner(ctx, newmap, tx, &mut files);
         },
         || {
             let mut diagnostics = Vec::new();
@@ -174,7 +289,7 @@ where
                 crossbeam::select! {
                     recv(rx) -> msg => {
                         if let Ok(pack) = msg {
-                            diagnostics.extend(pack.into_iter().map(|d| diag::diag_to_json(d, &files, Some(&grapher))));
+                            diagnostics.extend(pack);
                         } else {
                             // Yay, the sender was dopped (i.e. check was finished)
                             break;
@@ -190,7 +305,11 @@ where
         },
     );
 
-    gathered.unwrap()
+    gathered
+        .unwrap()
+        .into_iter()
+        .map(|d| diag::diag_to_json(d, &files, Some(&grapher)))
+        .collect()
 }
 
 #[macro_export]
@@ -244,7 +363,7 @@ pub fn gather_bans(
     let krates = kg.gather();
     let cfg = cfg.into();
 
-    gather_diagnostics::<crate::bans::cfg::Config, _, _>(&krates, name, cfg, |ctx, cs, tx| {
+    gather_diagnostics::<crate::bans::cfg::Config, _, _>(&krates, name, cfg, |ctx, cs, tx, _| {
         crate::bans::check(ctx, None, cs, tx);
     })
 }
@@ -259,7 +378,7 @@ pub fn gather_bans_with_overrides(
     let krates = kg.gather();
     let cfg = cfg.into();
 
-    gather_diagnostics::<crate::bans::cfg::Config, _, _>(&krates, name, cfg, |ctx, cs, tx| {
+    gather_diagnostics::<crate::bans::cfg::Config, _, _>(&krates, name, cfg, |ctx, cs, tx, _| {
         crate::bans::check(
             ctx,
             None,
diff --git a/tests/advisories.rs b/tests/advisories.rs
index e15d0b5c1..787472c77 100644
--- a/tests/advisories.rs
+++ b/tests/advisories.rs
@@ -71,7 +71,7 @@ fn detects_vulnerabilities() {
     let cfg = tu::Config::new("vulnerability = 'deny'");
 
     let diags =
-        tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx| {
+        tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx, _| {
             advisories::check(
                 ctx,
                 &dbs,
@@ -94,7 +94,7 @@ fn detects_unmaintained() {
     let cfg = tu::Config::new("unmaintained = 'warn'");
 
     let diags =
-        tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx| {
+        tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx, _| {
             advisories::check(
                 ctx,
                 &dbs,
@@ -116,7 +116,7 @@ fn detects_unsound() {
     let cfg = tu::Config::new("unsound = 'warn'");
 
     let diags =
-        tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx| {
+        tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx, _| {
             advisories::check(
                 ctx,
                 &dbs,
@@ -141,7 +141,7 @@ fn downgrades_lint_levels() {
     );
 
     let diags =
-        tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx| {
+        tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx, _| {
             advisories::check(
                 ctx,
                 &dbs,
@@ -189,32 +189,87 @@ fn detects_yanked() {
         .unwrap();
 
     let indices = advisories::Indices::load(&krates, cargo_home.to_owned().try_into().unwrap());
+    let dbs = advisories::DbSet { dbs: Vec::new() };
 
-    let cfg = tu::Config::new("yanked = 'deny'\nunmaintained = 'allow'\nvulnerability = 'allow'");
+    {
+        let cfg =
+            tu::Config::new("yanked = 'deny'\nunmaintained = 'allow'\nvulnerability = 'allow'");
+
+        let indices = advisories::Indices {
+            indices: Vec::new(),
+            cache: indices.cache.clone(),
+        };
+
+        let diags = tu::gather_diagnostics::<cfg::Config, _, _>(
+            &krates,
+            func_name!(),
+            cfg,
+            |ctx, _, tx, _| {
+                advisories::check(
+                    ctx,
+                    &dbs,
+                    Option::<advisories::NoneReporter>::None,
+                    Some(indices),
+                    tx,
+                );
+            },
+        );
 
-    let dbs = advisories::DbSet { dbs: Vec::new() };
+        let diags: Vec<_> = diags
+            .into_iter()
+            .filter(|v| {
+                v.pointer("/fields/message")
+                    .and_then(|v| v.as_str())
+                    .map_or(false, |v| v.starts_with("detected yanked crate"))
+            })
+            .collect();
 
-    let diags =
-        tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx| {
-            advisories::check(
-                ctx,
-                &dbs,
-                Option::<advisories::NoneReporter>::None,
-                Some(indices),
-                tx,
-            );
-        });
+        insta::assert_json_snapshot!(diags);
+    }
 
-    let diags: Vec<_> = diags
-        .into_iter()
-        .filter(|v| {
-            v.pointer("/fields/message")
-                .and_then(|v| v.as_str())
-                .map_or(false, |v| v.starts_with("detected yanked crate"))
-        })
-        .collect();
+    {
+        let cfg = tu::Config::new(
+            r#"
+yanked = "deny"
+ignore = [
+    # This crate is in the graph, but we're ignoring it
+    { crate = "spdx@0.3.1", reason = "a new version has not been released yet" },
+    # This crate is not in the graph, so we should get a warning about it
+    "boop",
+]
+unmaintained = "allow"
+vulnerability = "allow"
+"#,
+        );
 
-    insta::assert_json_snapshot!(diags);
+        let diags = tu::gather_diagnostics::<cfg::Config, _, _>(
+            &krates,
+            func_name!(),
+            cfg,
+            |ctx, _, tx, _| {
+                advisories::check(
+                    ctx,
+                    &dbs,
+                    Option::<advisories::NoneReporter>::None,
+                    Some(indices),
+                    tx,
+                );
+            },
+        );
+
+        let diags: Vec<_> = diags
+            .into_iter()
+            .filter(|v| {
+                v.pointer("/fields/message")
+                    .and_then(|v| v.as_str())
+                    .map_or(false, |v| {
+                        v.starts_with("detected yanked crate") || v.starts_with("yanked crate")
+                    })
+            })
+            .collect();
+
+        insta::assert_json_snapshot!(diags);
+    }
 }
 
 /// Validates that if we fail to load 1 or more indices, all the crates sourced
@@ -238,7 +293,7 @@ fn warns_on_index_failures() {
     };
 
     let diags =
-        tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx| {
+        tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx, _| {
             advisories::check(
                 ctx,
                 &dbs,
@@ -269,7 +324,7 @@ fn warns_on_ignored_and_withdrawn() {
     );
 
     let diags =
-        tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx| {
+        tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx, _| {
             advisories::check(
                 ctx,
                 &dbs,
@@ -341,7 +396,7 @@ fn validate(adb: &advisories::AdvisoryDb, rev: &str, ids: &[(&str, &str)]) {
     }
 
     assert!(
-        (time::OffsetDateTime::now_utc() - adb.fetch_time) < std::time::Duration::from_secs(10)
+        (time::OffsetDateTime::now_utc() - adb.fetch_time) < std::time::Duration::from_secs(60)
     );
 }
 
@@ -438,12 +493,22 @@ fn validate_fetch(fetch: Fetch) {
 /// Validates we can fetch advisory db updates with gix
 #[test]
 fn fetches_with_gix() {
+    if std::env::var_os("CI").is_some() && cfg!(target_os = "macos") {
+        println!("consistently times out, so tired");
+        return;
+    }
+
     validate_fetch(Fetch::Allow);
 }
 
 /// Validates we can fetch advisory db updates with git
 #[test]
 fn fetches_with_git() {
+    if std::env::var_os("CI").is_some() && cfg!(target_os = "macos") {
+        println!("consistently times out, so tired");
+        return;
+    }
+
     validate_fetch(Fetch::AllowWithGitCli);
 }
 
@@ -598,7 +663,7 @@ fn crates_io_source_replacement() {
     let dbs = advisories::DbSet { dbs: Vec::new() };
 
     let diags =
-        tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx| {
+        tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx, _| {
             advisories::check(
                 ctx,
                 &dbs,
diff --git a/tests/bans.rs b/tests/bans.rs
index fce726a28..94abd9702 100644
--- a/tests/bans.rs
+++ b/tests/bans.rs
@@ -10,6 +10,7 @@ fn cyclic_dependencies_do_not_cause_infinite_loop() {
     );
 }
 
+/// Validates a crate that is otherwise denied can be allowed by a wrapper
 #[test]
 fn allow_wrappers() {
     let diags = gather_bans(
@@ -18,6 +19,7 @@ fn allow_wrappers() {
         r#"
 [[deny]]
 name = "dangerous-dep"
+reason = "we need to update 'safe-wrapper' to not use this"
 wrappers = ["safe-wrapper"]
 "#,
     );
@@ -25,6 +27,7 @@ wrappers = ["safe-wrapper"]
     insta::assert_json_snapshot!(diags);
 }
 
+/// Validates a wrapper that doesn't exist emits a warning
 #[test]
 fn warns_on_unused_wrappers() {
     let diags = gather_bans(
@@ -40,12 +43,31 @@ wrappers = ["safe-wrapper", "other-crate"]
     insta::assert_json_snapshot!(diags);
 }
 
+/// Validates just a plain deny emits an error
 #[test]
 fn disallows_denied() {
     let diags = gather_bans(
         func_name!(),
         KrateGather::new("allow_wrappers/maincrate"),
-        "deny = [{name = 'dangerous-dep'}]",
+        "deny = ['dangerous-dep']",
+    );
+
+    insta::assert_json_snapshot!(diags);
+}
+
+/// Validates a crate is denied even if it has wrappers if
+#[test]
+fn disallows_denied_with_wrapper() {
+    let diags = gather_bans(
+        func_name!(),
+        KrateGather::new("allow_wrappers/maincrate"),
+        r#"
+[[deny]]
+name = "dangerous-dep"
+reason = "we shouldn't use it but it is used transitively"
+use-instead = "a-better-krate"
+wrappers = ["other-crate"]
+"#,
     );
 
     insta::assert_json_snapshot!(diags);
@@ -161,7 +183,7 @@ multiple-versions-include-dev = true
     let dup_graphs = std::sync::Arc::new(parking_lot::Mutex::new(Vec::new()));
 
     let duped_graphs = dup_graphs.clone();
-    gather_diagnostics::<bans::cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, cs, tx| {
+    gather_diagnostics::<bans::cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, cs, tx, _f| {
         bans::check(
             ctx,
             Some(Box::new(move |dg| {
diff --git a/tests/bans_build.rs b/tests/bans_build.rs
index d60ec5da9..1cf767d8d 100644
--- a/tests/bans_build.rs
+++ b/tests/bans_build.rs
@@ -142,6 +142,9 @@ fn skips_matching_build_scripts() {
         },
         Config::new(
             r#"
+[build]
+executables = "deny"
+
 [[build.bypass]]
 name = "ittapi-sys"
 build-script = "474a3eb189a698475d8a6f4b358eb0790db6379aea8b8a85ac925102784cd520"
@@ -156,6 +159,34 @@ build-script = "1a850d791184374f614d01c86c8d6c9ba0500e64cb746edc9720ceaaa1cd8eaf
     insta::assert_json_snapshot!(diags);
 }
 
+/// Verifies that build scripts are denied if not allowed nor bypassed
+#[test]
+fn allows_build_scripts_or_bypass() {
+    let diags = gather_bans(
+        func_name!(),
+        KrateGather {
+            name: "build-bans",
+            features: &["mixed", "scripts"],
+            no_default_features: true,
+            targets: &["x86_64-unknown-linux-gnu"],
+            ..Default::default()
+        },
+        Config::new(
+            r#"
+[build]
+allow-build-scripts = ["ittapi-sys"]
+executables = "allow"
+
+[[build.bypass]]
+name = "ring"
+build-script = "1a850d791184374f614d01c86c8d6c9ba0500e64cb746edc9720ceaaa1cd8eaf"
+"#,
+        ),
+    );
+
+    insta::assert_json_snapshot!(diags);
+}
+
 /// Verifies executables are allowed by glob patterns
 #[test]
 fn allows_by_glob() {
diff --git a/tests/cfg/advisories.toml b/tests/cfg/advisories.toml
index 548603f58..5a3ae1bbc 100644
--- a/tests/cfg/advisories.toml
+++ b/tests/cfg/advisories.toml
@@ -6,5 +6,9 @@ unmaintained = "warn"
 unsound = "warn"
 yanked = "warn"
 notice = "warn"
-ignore = ["RUSTSEC-0000-0000"]
+ignore = [
+    "RUSTSEC-0000-0000",
+    "crate@0.1",
+    { crate = "yanked", reason = "a new version has not been released" },
+]
 severity-threshold = "medium"
diff --git a/tests/cfg/bans.toml b/tests/cfg/bans.toml
index 20f6695ce..cf7143d83 100644
--- a/tests/cfg/bans.toml
+++ b/tests/cfg/bans.toml
@@ -7,18 +7,18 @@ workspace-default-features = "warn"
 external-default-features = "deny"
 allow = [
     { name = "all-versionsa" },
-    { name = "specific-versiona", version = "<0.1.1" },
+    "version-rangea:<0.1.1",
+    "specific-versionb@0.1.2",
+    "any-version",
+]
+deny = [
+    "specific-versiond@0.1.9",
+    { name = "all-versionsd", wrappers = [
+        "specific-versiona",
+    ], reason = "we want to get rid of this crate but there is still one user of it" },
 ]
 skip-tree = [{ name = "blah", depth = 20 }]
 
-[[bans.deny]]
-name = "all-versionsd"
-wrappers = ["specific-versiona"]
-
-[[bans.deny]]
-name = "specific-versiond"
-version = "=0.1.9"
-
 [[bans.skip]]
 name = "rand"
 version = "=0.6.5"
@@ -29,6 +29,7 @@ version = "1.0"
 deny = ["bad-feature"]
 allow = ["good-feature"]
 exact = true
+reason = "`bad-feature` is bad"
 
 [bans.build]
 allow-build-scripts = [{ name = "all-versionsa" }]
diff --git a/tests/cfg/package-specs.toml b/tests/cfg/package-specs.toml
new file mode 100644
index 000000000..66bf8ffb5
--- /dev/null
+++ b/tests/cfg/package-specs.toml
@@ -0,0 +1,8 @@
+bare = "bare-name"
+specific = { name = "specific", version = "=0.1.0" }
+range = { crate = "range:<=1.0", data = 10 }
+mixed = [
+    "bare-name-1",
+    { name = "range-2", version = ">=1.0,<=2.0", data = 20 },
+    "specific-3@0.2.1",
+]
diff --git a/tests/feature_bans.rs b/tests/feature_bans.rs
index 76917e4d2..6f7f8f149 100644
--- a/tests/feature_bans.rs
+++ b/tests/feature_bans.rs
@@ -1,3 +1,4 @@
+#![cfg(no)]
 use cargo_deny::{func_name, test_utils::*};
 
 /// Ensures that you can ban features in your own workspace. `simple` is brought
diff --git a/tests/licenses.rs b/tests/licenses.rs
index b76309d68..3dd50a780 100644
--- a/tests/licenses.rs
+++ b/tests/licenses.rs
@@ -1,4 +1,8 @@
-use cargo_deny::{diag, field_eq, func_name, licenses, test_utils as tu, Krates};
+use cargo_deny::{
+    diag, field_eq, func_name,
+    licenses::{self, cfg::Config},
+    test_utils as tu, Krates,
+};
 use parking_lot::Once;
 use std::sync::Arc;
 
@@ -18,7 +22,7 @@ fn store() -> Arc<licenses::LicenseStore> {
 #[inline]
 pub fn gather_licenses_with_overrides(
     name: &str,
-    cfg: impl Into<tu::Config<crate::licenses::cfg::Config>>,
+    cfg: impl Into<tu::Config<Config>>,
     overrides: Option<diag::DiagnosticOverrides>,
 ) -> Vec<serde_json::Value> {
     let md: krates::cm::Metadata = serde_json::from_str(
@@ -34,26 +38,15 @@ pub fn gather_licenses_with_overrides(
         .with_store(store())
         .with_confidence_threshold(0.8);
 
-    let mut files = codespan::Files::new();
-
     let cfg = cfg.into();
-    let lic_cfg = {
-        let des: licenses::cfg::Config = toml::from_str(&cfg.config).unwrap();
-        let cfg_id = files.add("config.toml", cfg.config.clone());
-
-        let mut diags = Vec::new();
-        use cargo_deny::UnvalidatedConfig;
-        des.validate(cfg_id, &mut files, &mut diags)
-    };
-
-    let summary = gatherer.gather(&krates, &mut files, Some(&lic_cfg));
 
-    tu::gather_diagnostics_with_files::<crate::licenses::cfg::Config, _, _>(
+    tu::gather_diagnostics_with_files::<Config, _, _>(
         &krates,
         name,
         cfg,
-        files,
-        |ctx, _cs, tx| {
+        codespan::Files::new(),
+        |ctx, _cs, tx, files| {
+            let summary = gatherer.gather(ctx.krates, files, Some(&ctx.cfg));
             crate::licenses::check(
                 ctx,
                 summary,
@@ -169,30 +162,18 @@ fn lax_fallback() {
         .with_store(store())
         .with_confidence_threshold(0.8);
 
-    let mut files = codespan::Files::new();
-
-    let cfg: tu::Config<crate::licenses::cfg::Config> = tu::Config::new(
+    let cfg = tu::Config::<Config>::new(
         "allow = ['GPL-2.0', 'LGPL-3.0']
     unlicensed = 'deny'",
     );
 
-    let lic_cfg = {
-        let des: licenses::cfg::Config = toml::from_str(&cfg.config).unwrap();
-        let cfg_id = files.add("config.toml", cfg.config.clone());
-
-        let mut diags = Vec::new();
-        use cargo_deny::UnvalidatedConfig;
-        des.validate(cfg_id, &mut files, &mut diags)
-    };
-
-    let summary = gatherer.gather(&krates, &mut files, Some(&lic_cfg));
-
-    let diags = tu::gather_diagnostics_with_files::<crate::licenses::cfg::Config, _, _>(
+    let diags = tu::gather_diagnostics_with_files::<Config, _, _>(
         &krates,
         "lax_fallback",
         cfg,
-        files,
-        |ctx, _cs, tx| {
+        codespan::Files::new(),
+        |ctx, _cs, tx, files| {
+            let summary = gatherer.gather(ctx.krates, files, Some(&ctx.cfg));
             crate::licenses::check(
                 ctx,
                 summary,
@@ -221,9 +202,7 @@ fn clarifications() {
         .with_store(store())
         .with_confidence_threshold(0.8);
 
-    let mut files = codespan::Files::new();
-
-    let cfg: tu::Config<crate::licenses::cfg::Config> = tu::Config::new(
+    let cfg = tu::Config::<Config>::new(
         r#"
 allow = ["MIT", "Apache-2.0", "ISC"]
 private = { ignore = true }
@@ -254,23 +233,13 @@ license-files = [
 "#,
     );
 
-    let lic_cfg = {
-        let des: licenses::cfg::Config = toml::from_str(&cfg.config).unwrap();
-        let cfg_id = files.add("config.toml", cfg.config.clone());
-
-        let mut diags = Vec::new();
-        use cargo_deny::UnvalidatedConfig;
-        des.validate(cfg_id, &mut files, &mut diags)
-    };
-
-    let summary = gatherer.gather(&krates, &mut files, Some(&lic_cfg));
-
-    let diags = tu::gather_diagnostics_with_files::<crate::licenses::cfg::Config, _, _>(
+    let diags = tu::gather_diagnostics_with_files::<Config, _, _>(
         &krates,
         "clarifications",
         cfg,
-        files,
-        |ctx, _cs, tx| {
+        codespan::Files::new(),
+        |ctx, _cs, tx, files| {
+            let summary = gatherer.gather(ctx.krates, files, Some(&ctx.cfg));
             crate::licenses::check(
                 ctx,
                 summary,
diff --git a/tests/snapshots/advisories__detects_yanked-2.snap b/tests/snapshots/advisories__detects_yanked-2.snap
new file mode 100644
index 000000000..e539cfb41
--- /dev/null
+++ b/tests/snapshots/advisories__detects_yanked-2.snap
@@ -0,0 +1,93 @@
+---
+source: tests/advisories.rs
+expression: diags
+---
+[
+  {
+    "fields": {
+      "code": "yanked",
+      "graphs": [
+        {
+          "Krate": {
+            "name": "crate-two",
+            "version": "0.1.0"
+          },
+          "parents": [
+            {
+              "Krate": {
+                "name": "yank-check",
+                "version": "0.1.0"
+              }
+            }
+          ]
+        }
+      ],
+      "labels": [
+        {
+          "column": 1,
+          "line": 2,
+          "message": "yanked version",
+          "span": "crate-two 0.1.0 registry+https://github.com/EmbarkStudios/cargo-test-index"
+        }
+      ],
+      "message": "detected yanked crate (try `cargo update -p crate-two`)",
+      "severity": "error"
+    },
+    "type": "diagnostic"
+  },
+  {
+    "fields": {
+      "code": "yanked-ignored",
+      "graphs": [
+        {
+          "Krate": {
+            "name": "spdx",
+            "version": "0.3.1"
+          },
+          "parents": [
+            {
+              "Krate": {
+                "name": "yank-check",
+                "version": "0.1.0"
+              }
+            }
+          ]
+        }
+      ],
+      "labels": [
+        {
+          "column": 16,
+          "line": 5,
+          "message": "yanked ignore",
+          "span": "spdx"
+        },
+        {
+          "column": 39,
+          "line": 5,
+          "message": "reason",
+          "span": "a new version has not been released yet"
+        }
+      ],
+      "message": "yanked crate 'spdx = 0.3.1' detected, but ignored",
+      "severity": "note"
+    },
+    "type": "diagnostic"
+  },
+  {
+    "fields": {
+      "code": "yanked-not-detected",
+      "graphs": [],
+      "labels": [
+        {
+          "column": 6,
+          "line": 7,
+          "message": "yanked crate not detected",
+          "span": "boop"
+        }
+      ],
+      "message": "yanked crate was not encountered",
+      "severity": "warning"
+    },
+    "type": "diagnostic"
+  }
+]
diff --git a/tests/snapshots/advisories__warns_on_ignored_and_withdrawn.snap b/tests/snapshots/advisories__warns_on_ignored_and_withdrawn.snap
index 3301adc0e..afb06f556 100644
--- a/tests/snapshots/advisories__warns_on_ignored_and_withdrawn.snap
+++ b/tests/snapshots/advisories__warns_on_ignored_and_withdrawn.snap
@@ -8,10 +8,10 @@ expression: "diags.iter().find(|diag|\n            field_eq!(diag, \"/fields/cod
     "graphs": [],
     "labels": [
       {
-        "column": 11,
+        "column": 12,
         "line": 4,
         "message": "no crate matched advisory criteria",
-        "span": "'RUSTSEC-2020-0053'"
+        "span": "RUSTSEC-2020-0053"
       }
     ],
     "message": "advisory was not encountered",
diff --git a/tests/snapshots/bans__allow_wrappers.snap b/tests/snapshots/bans__allow_wrappers.snap
index 12ff5141f..933f07164 100644
--- a/tests/snapshots/bans__allow_wrappers.snap
+++ b/tests/snapshots/bans__allow_wrappers.snap
@@ -32,14 +32,14 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 8,
+          "column": 9,
           "line": 3,
           "message": "banned here",
           "span": "dangerous-dep"
         },
         {
-          "column": 13,
-          "line": 4,
+          "column": 14,
+          "line": 5,
           "message": "allowed wrapper",
           "span": "safe-wrapper"
         }
diff --git a/tests/snapshots/bans__disallows_denied.snap b/tests/snapshots/bans__disallows_denied.snap
index 823ee8c4c..ccfa8510f 100644
--- a/tests/snapshots/bans__disallows_denied.snap
+++ b/tests/snapshots/bans__disallows_denied.snap
@@ -32,10 +32,10 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 17,
+          "column": 10,
           "line": 1,
           "message": "banned here",
-          "span": "'dangerous-dep'"
+          "span": "dangerous-dep"
         }
       ],
       "message": "crate 'dangerous-dep = 0.1.0' is explicitly banned",
diff --git a/tests/snapshots/bans__disallows_denied_with_wrapper.snap b/tests/snapshots/bans__disallows_denied_with_wrapper.snap
new file mode 100644
index 000000000..627fc54bf
--- /dev/null
+++ b/tests/snapshots/bans__disallows_denied_with_wrapper.snap
@@ -0,0 +1,127 @@
+---
+source: tests/bans.rs
+expression: diags
+---
+[
+  {
+    "fields": {
+      "code": "unmatched-wrapper",
+      "graphs": [
+        {
+          "Krate": {
+            "name": "dangerous-dep",
+            "version": "0.1.0"
+          },
+          "parents": [
+            {
+              "Krate": {
+                "name": "safe-wrapper",
+                "version": "0.1.0"
+              },
+              "parents": [
+                {
+                  "Krate": {
+                    "name": "allow-wrappers-crate",
+                    "version": "0.1.0"
+                  }
+                }
+              ]
+            }
+          ]
+        }
+      ],
+      "labels": [
+        {
+          "column": 9,
+          "line": 3,
+          "message": "banned here",
+          "span": "dangerous-dep"
+        },
+        {
+          "column": 11,
+          "line": 4,
+          "message": "reason",
+          "span": "we shouldn't use it but it is used transitively"
+        },
+        {
+          "column": 16,
+          "line": 5,
+          "message": "use instead",
+          "span": "a-better-krate"
+        }
+      ],
+      "message": "direct parent 'safe-wrapper = 0.1.0' of banned crate 'dangerous-dep = 0.1.0' was not marked as a wrapper",
+      "severity": "warning"
+    },
+    "type": "diagnostic"
+  },
+  {
+    "fields": {
+      "code": "banned",
+      "graphs": [
+        {
+          "Krate": {
+            "name": "dangerous-dep",
+            "version": "0.1.0"
+          },
+          "parents": [
+            {
+              "Krate": {
+                "name": "safe-wrapper",
+                "version": "0.1.0"
+              },
+              "parents": [
+                {
+                  "Krate": {
+                    "name": "allow-wrappers-crate",
+                    "version": "0.1.0"
+                  }
+                }
+              ]
+            }
+          ]
+        }
+      ],
+      "labels": [
+        {
+          "column": 9,
+          "line": 3,
+          "message": "banned here",
+          "span": "dangerous-dep"
+        },
+        {
+          "column": 11,
+          "line": 4,
+          "message": "reason",
+          "span": "we shouldn't use it but it is used transitively"
+        },
+        {
+          "column": 16,
+          "line": 5,
+          "message": "use instead",
+          "span": "a-better-krate"
+        }
+      ],
+      "message": "crate 'dangerous-dep = 0.1.0' is explicitly banned",
+      "severity": "error"
+    },
+    "type": "diagnostic"
+  },
+  {
+    "fields": {
+      "code": "unused-wrapper",
+      "graphs": [],
+      "labels": [
+        {
+          "column": 14,
+          "line": 6,
+          "message": "unmatched wrapper",
+          "span": "other-crate"
+        }
+      ],
+      "message": "wrapper for banned crate was not enountered",
+      "severity": "warning"
+    },
+    "type": "diagnostic"
+  }
+]
diff --git a/tests/snapshots/bans__ignores_dev.snap b/tests/snapshots/bans__ignores_dev.snap
index cc9dec439..dd96eeb2f 100644
--- a/tests/snapshots/bans__ignores_dev.snap
+++ b/tests/snapshots/bans__ignores_dev.snap
@@ -9,10 +9,10 @@ expression: diags
       "graphs": [],
       "labels": [
         {
-          "column": 5,
+          "column": 15,
           "line": 4,
           "message": "unmatched skip configuration",
-          "span": "{ name = 'block-buffer', version = \"=0.7.3\" }"
+          "span": "block-buffer"
         }
       ],
       "message": "skipped crate 'block-buffer = =0.7.3' was not encountered",
diff --git a/tests/snapshots/bans__warns_on_unused_wrappers.snap b/tests/snapshots/bans__warns_on_unused_wrappers.snap
index dbc1cd027..f02bf170b 100644
--- a/tests/snapshots/bans__warns_on_unused_wrappers.snap
+++ b/tests/snapshots/bans__warns_on_unused_wrappers.snap
@@ -32,13 +32,13 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 8,
+          "column": 9,
           "line": 3,
           "message": "banned here",
           "span": "dangerous-dep"
         },
         {
-          "column": 13,
+          "column": 14,
           "line": 4,
           "message": "allowed wrapper",
           "span": "safe-wrapper"
@@ -55,7 +55,7 @@ expression: diags
       "graphs": [],
       "labels": [
         {
-          "column": 29,
+          "column": 30,
           "line": 4,
           "message": "unmatched wrapper",
           "span": "other-crate"
diff --git a/tests/snapshots/bans_build__allows_build_scripts_or_bypass.snap b/tests/snapshots/bans_build__allows_build_scripts_or_bypass.snap
new file mode 100644
index 000000000..ca5598bdb
--- /dev/null
+++ b/tests/snapshots/bans_build__allows_build_scripts_or_bypass.snap
@@ -0,0 +1,105 @@
+---
+source: tests/bans_build.rs
+expression: diags
+---
+[
+  {
+    "fields": {
+      "code": "build-script-not-allowed",
+      "graphs": [
+        {
+          "Krate": {
+            "name": "libc",
+            "version": "0.2.147"
+          },
+          "parents": [
+            {
+              "Krate": {
+                "name": "cc",
+                "version": "1.0.83"
+              },
+              "parents": [
+                {
+                  "Krate": {
+                    "kind": "build",
+                    "name": "ittapi-sys",
+                    "version": "0.3.3"
+                  },
+                  "parents": [
+                    {
+                      "Krate": {
+                        "name": "build-bans",
+                        "version": "0.1.0"
+                      }
+                    }
+                  ]
+                },
+                {
+                  "Krate": {
+                    "kind": "build",
+                    "name": "ring",
+                    "version": "0.16.20"
+                  },
+                  "parents": [
+                    {
+                      "Krate": {
+                        "name": "build-bans",
+                        "version": "0.1.0"
+                      },
+                      "repeat": true
+                    }
+                  ]
+                }
+              ]
+            },
+            {
+              "Krate": {
+                "name": "ring",
+                "version": "0.16.20"
+              },
+              "repeat": true
+            }
+          ]
+        }
+      ],
+      "message": "crate 'libc = 0.2.147' has a build script but is not allowed to have one",
+      "severity": "error"
+    },
+    "type": "diagnostic"
+  },
+  {
+    "fields": {
+      "code": "checksum-match",
+      "graphs": [
+        {
+          "Krate": {
+            "name": "ring",
+            "version": "0.16.20"
+          },
+          "parents": [
+            {
+              "Krate": {
+                "name": "build-bans",
+                "version": "0.1.0"
+              }
+            }
+          ]
+        }
+      ],
+      "labels": [
+        {
+          "column": 17,
+          "line": 8,
+          "message": "checksum",
+          "span": "1a850d791184374f614d01c86c8d6c9ba0500e64cb746edc9720ceaaa1cd8eaf"
+        }
+      ],
+      "message": "file checksum matched",
+      "notes": [
+        "path = '$CARGO_HOME/registry/src/index.crates.io-6f17d22bba15001f/ring-0.16.20/build.rs'"
+      ],
+      "severity": "help"
+    },
+    "type": "diagnostic"
+  }
+]
diff --git a/tests/snapshots/bans_build__allows_by_glob.snap b/tests/snapshots/bans_build__allows_by_glob.snap
index 1ff2feeba..89e7bbc60 100644
--- a/tests/snapshots/bans_build__allows_by_glob.snap
+++ b/tests/snapshots/bans_build__allows_by_glob.snap
@@ -24,7 +24,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -59,7 +59,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -94,7 +94,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -129,7 +129,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -164,7 +164,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -199,7 +199,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -234,7 +234,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -269,7 +269,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -304,7 +304,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -339,7 +339,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -374,7 +374,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -409,7 +409,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -444,7 +444,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -479,7 +479,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -514,7 +514,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -549,7 +549,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -584,7 +584,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -619,7 +619,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -654,7 +654,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -689,7 +689,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -724,7 +724,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -759,7 +759,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -794,7 +794,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -829,7 +829,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -864,7 +864,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -899,7 +899,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -934,7 +934,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -969,7 +969,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -1004,7 +1004,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -1039,7 +1039,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -1074,7 +1074,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -1109,7 +1109,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -1144,7 +1144,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -1179,7 +1179,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 9,
           "message": "",
           "span": "crypto/**.pl"
@@ -1214,7 +1214,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 10,
           "message": "",
           "span": "src/rsa/convert_nist_rsa_test_vectors.py"
diff --git a/tests/snapshots/bans_build__allows_by_path.snap b/tests/snapshots/bans_build__allows_by_path.snap
index 9488faa28..5f6396716 100644
--- a/tests/snapshots/bans_build__allows_by_path.snap
+++ b/tests/snapshots/bans_build__allows_by_path.snap
@@ -24,7 +24,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 71,
+          "column": 72,
           "line": 5,
           "message": "checksum",
           "span": "5392f0e58ad06e089462d93304dfe82337acbbefb87a0749a7dc2ed32af04af7"
@@ -59,7 +59,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 69,
+          "column": 70,
           "line": 7,
           "message": "checksum",
           "span": "151dfe76345298b055000c31376f925222ef3426d6b7892b8156421fdd3fd3c4"
@@ -94,7 +94,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 68,
+          "column": 69,
           "line": 8,
           "message": "checksum",
           "span": "2363256459ab02d5abdda2db52e96879f9417102d14086fe852d97d0380b79ff"
@@ -129,7 +129,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 67,
+          "column": 68,
           "line": 9,
           "message": "checksum",
           "span": "e7dff9c38bf0cbcf43f055b8269ea939d6b298f611de16481ba7d3e2eec0bc2f"
@@ -164,7 +164,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 66,
+          "column": 67,
           "line": 10,
           "message": "checksum",
           "span": "62e803f7433799af63acf605f7fe19108d22d0c73e82a475b27d3ff0cfbf1990"
diff --git a/tests/snapshots/bans_build__detects_build_script_mismatch.snap b/tests/snapshots/bans_build__detects_build_script_mismatch.snap
index 2fbe986d8..6689e4e08 100644
--- a/tests/snapshots/bans_build__detects_build_script_mismatch.snap
+++ b/tests/snapshots/bans_build__detects_build_script_mismatch.snap
@@ -24,7 +24,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 16,
+          "column": 17,
           "line": 4,
           "message": "expected checksum",
           "span": "00abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef00"
diff --git a/tests/snapshots/bans_build__detects_scripts_by_builtin_glob.snap b/tests/snapshots/bans_build__detects_scripts_by_builtin_glob.snap
index b02dfd0f1..1769cd2d9 100644
--- a/tests/snapshots/bans_build__detects_scripts_by_builtin_glob.snap
+++ b/tests/snapshots/bans_build__detects_scripts_by_builtin_glob.snap
@@ -24,7 +24,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -59,7 +59,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -94,7 +94,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -129,7 +129,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -164,7 +164,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -199,7 +199,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -234,7 +234,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -269,7 +269,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -304,7 +304,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -339,7 +339,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -374,7 +374,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -409,7 +409,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -444,7 +444,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -479,7 +479,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -514,7 +514,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -549,7 +549,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -584,7 +584,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -619,7 +619,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -654,7 +654,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -689,7 +689,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -724,7 +724,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -759,7 +759,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -794,7 +794,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -829,7 +829,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -864,7 +864,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -899,7 +899,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -934,7 +934,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -969,7 +969,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -1004,7 +1004,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -1039,7 +1039,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -1074,7 +1074,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -1109,7 +1109,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -1144,7 +1144,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -1179,7 +1179,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "builtin",
           "span": "*.pl"
@@ -1214,7 +1214,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 16,
           "message": "builtin",
           "span": "*.py"
diff --git a/tests/snapshots/bans_build__detects_scripts_by_user_extension.snap b/tests/snapshots/bans_build__detects_scripts_by_user_extension.snap
index 75f019d16..7f7612649 100644
--- a/tests/snapshots/bans_build__detects_scripts_by_user_extension.snap
+++ b/tests/snapshots/bans_build__detects_scripts_by_user_extension.snap
@@ -16,10 +16,10 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 22,
+          "column": 23,
           "line": 3,
           "message": "",
-          "span": "'cs'"
+          "span": "cs"
         }
       ],
       "message": "path disallowed by extension",
diff --git a/tests/snapshots/bans_build__emits_unmatched_warnings.snap b/tests/snapshots/bans_build__emits_unmatched_warnings.snap
index dd85c35cd..02fa8276f 100644
--- a/tests/snapshots/bans_build__emits_unmatched_warnings.snap
+++ b/tests/snapshots/bans_build__emits_unmatched_warnings.snap
@@ -24,7 +24,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 14,
+          "column": 15,
           "line": 8,
           "message": "",
           "span": "third-party/protobuf/boop"
@@ -56,7 +56,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 11,
           "message": "",
           "span": "first-party/**"
@@ -88,7 +88,7 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 5,
+          "column": 6,
           "line": 13,
           "message": "",
           "span": "second-party/you/*"
@@ -105,7 +105,7 @@ expression: diags
       "graphs": [],
       "labels": [
         {
-          "column": 8,
+          "column": 9,
           "line": 3,
           "message": "unmatched bypass",
           "span": "this-crate-does-not-exist"
diff --git a/tests/snapshots/bans_build__skips_matching_build_scripts.snap b/tests/snapshots/bans_build__skips_matching_build_scripts.snap
index 39d16d11b..a6e6531bd 100644
--- a/tests/snapshots/bans_build__skips_matching_build_scripts.snap
+++ b/tests/snapshots/bans_build__skips_matching_build_scripts.snap
@@ -24,8 +24,8 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 16,
-          "line": 4,
+          "column": 17,
+          "line": 7,
           "message": "checksum",
           "span": "474a3eb189a698475d8a6f4b358eb0790db6379aea8b8a85ac925102784cd520"
         }
@@ -59,8 +59,8 @@ expression: diags
       ],
       "labels": [
         {
-          "column": 16,
-          "line": 8,
+          "column": 17,
+          "line": 11,
           "message": "checksum",
           "span": "1a850d791184374f614d01c86c8d6c9ba0500e64cb746edc9720ceaaa1cd8eaf"
         }
diff --git a/tests/snapshots/licenses__flags_unencountered_exceptions.snap b/tests/snapshots/licenses__flags_unencountered_exceptions.snap
index 0eb8d59b0..e75af5c59 100644
--- a/tests/snapshots/licenses__flags_unencountered_exceptions.snap
+++ b/tests/snapshots/licenses__flags_unencountered_exceptions.snap
@@ -9,10 +9,10 @@ expression: diags
       "graphs": [],
       "labels": [
         {
-          "column": 25,
+          "column": 26,
           "line": 3,
           "message": "unmatched license exception",
-          "span": "'bippity-boppity-boop'"
+          "span": "bippity-boppity-boop"
         }
       ],
       "message": "license exception was not encountered",
diff --git a/tests/snapshots/licenses__flags_unencountered_licenses.snap b/tests/snapshots/licenses__flags_unencountered_licenses.snap
index 948fa5294..96765aa75 100644
--- a/tests/snapshots/licenses__flags_unencountered_licenses.snap
+++ b/tests/snapshots/licenses__flags_unencountered_licenses.snap
@@ -9,10 +9,10 @@ expression: diags
       "graphs": [],
       "labels": [
         {
-          "column": 10,
+          "column": 11,
           "line": 1,
           "message": "unmatched license allowance",
-          "span": "'Aladdin'"
+          "span": "Aladdin"
         }
       ],
       "message": "license was not encountered",
diff --git a/tests/snapshots/sources__allows_bitbucket_org.snap b/tests/snapshots/sources__allows_bitbucket_org.snap
index c62aebd66..5015f0bc9 100644
--- a/tests/snapshots/sources__allows_bitbucket_org.snap
+++ b/tests/snapshots/sources__allows_bitbucket_org.snap
@@ -169,10 +169,10 @@ expression: diags
           "span": "git+https://bitbucket.org/marshallpierce/line-wrap-rs"
         },
         {
-          "column": 18,
+          "column": 19,
           "line": 3,
           "message": "organization allowance",
-          "span": "'marshallpierce'"
+          "span": "marshallpierce"
         }
       ],
       "message": "source allowed by organization allowance",
diff --git a/tests/snapshots/sources__allows_git.snap b/tests/snapshots/sources__allows_git.snap
index 2dbf64c5b..319698e0f 100644
--- a/tests/snapshots/sources__allows_git.snap
+++ b/tests/snapshots/sources__allows_git.snap
@@ -31,10 +31,10 @@ expression: diags
           "span": "git+https://gitlab.com/amethyst-engine/amethyst?rev=0c2da617"
         },
         {
-          "column": 9,
+          "column": 10,
           "line": 3,
           "message": "source allowance",
-          "span": "'https://gitlab.com/amethyst-engine/amethyst/'"
+          "span": "https://gitlab.com/amethyst-engine/amethyst/"
         }
       ],
       "message": "'git' source explicitly allowed",
@@ -78,10 +78,10 @@ expression: diags
           "span": "git+https://gitlab.com/amethyst-engine/amethyst?rev=0c2da617"
         },
         {
-          "column": 9,
+          "column": 10,
           "line": 3,
           "message": "source allowance",
-          "span": "'https://gitlab.com/amethyst-engine/amethyst/'"
+          "span": "https://gitlab.com/amethyst-engine/amethyst/"
         }
       ],
       "message": "'git' source explicitly allowed",
@@ -148,10 +148,10 @@ expression: diags
           "span": "git+https://github.com/EmbarkStudios/krates?branch=main"
         },
         {
-          "column": 9,
+          "column": 10,
           "line": 4,
           "message": "source allowance",
-          "span": "'https://github.com/EmbarkStudios/krates'"
+          "span": "https://github.com/EmbarkStudios/krates"
         }
       ],
       "message": "'git' source explicitly allowed",
@@ -187,10 +187,10 @@ expression: diags
           "span": "git+https://bitbucket.org/marshallpierce/line-wrap-rs"
         },
         {
-          "column": 9,
+          "column": 10,
           "line": 5,
           "message": "source allowance",
-          "span": "'https://bitbucket.org/marshallpierce/line-wrap-rs'"
+          "span": "https://bitbucket.org/marshallpierce/line-wrap-rs"
         }
       ],
       "message": "'git' source explicitly allowed",
diff --git a/tests/snapshots/sources__allows_github_org.snap b/tests/snapshots/sources__allows_github_org.snap
index cfdbe2b8d..1a2ecf922 100644
--- a/tests/snapshots/sources__allows_github_org.snap
+++ b/tests/snapshots/sources__allows_github_org.snap
@@ -136,10 +136,10 @@ expression: diags
           "span": "git+https://github.com/EmbarkStudios/krates?branch=main"
         },
         {
-          "column": 15,
+          "column": 16,
           "line": 3,
           "message": "organization allowance",
-          "span": "'EmbarkStudios'"
+          "span": "EmbarkStudios"
         }
       ],
       "message": "source allowed by organization allowance",
@@ -208,10 +208,10 @@ expression: diags
           "span": "git+https://github.com/EmbarkStudios/spdx?tag=0.3.4"
         },
         {
-          "column": 15,
+          "column": 16,
           "line": 3,
           "message": "organization allowance",
-          "span": "'EmbarkStudios'"
+          "span": "EmbarkStudios"
         }
       ],
       "message": "source allowed by organization allowance",
diff --git a/tests/snapshots/sources__allows_gitlab_org.snap b/tests/snapshots/sources__allows_gitlab_org.snap
index 033526d5e..2624531df 100644
--- a/tests/snapshots/sources__allows_gitlab_org.snap
+++ b/tests/snapshots/sources__allows_gitlab_org.snap
@@ -31,10 +31,10 @@ expression: diags
           "span": "git+https://gitlab.com/amethyst-engine/amethyst?rev=0c2da617"
         },
         {
-          "column": 15,
+          "column": 16,
           "line": 3,
           "message": "organization allowance",
-          "span": "'amethyst-engine'"
+          "span": "amethyst-engine"
         }
       ],
       "message": "source allowed by organization allowance",
@@ -78,10 +78,10 @@ expression: diags
           "span": "git+https://gitlab.com/amethyst-engine/amethyst?rev=0c2da617"
         },
         {
-          "column": 15,
+          "column": 16,
           "line": 3,
           "message": "organization allowance",
-          "span": "'amethyst-engine'"
+          "span": "amethyst-engine"
         }
       ],
       "message": "source allowed by organization allowance",
diff --git a/tests/snapshots/sources__allows_registry_index_git.snap b/tests/snapshots/sources__allows_registry_index_git.snap
index 3cf8d2dba..507c732b9 100644
--- a/tests/snapshots/sources__allows_registry_index_git.snap
+++ b/tests/snapshots/sources__allows_registry_index_git.snap
@@ -30,10 +30,10 @@ expression: diags
           "span": "registry+https://dl.cloudsmith.io/public/embark/deny/cargo/index.git"
         },
         {
-          "column": 9,
+          "column": 10,
           "line": 3,
           "message": "source allowance",
-          "span": "'https://dl.cloudsmith.io/public/embark/deny/cargo/index.git'"
+          "span": "https://dl.cloudsmith.io/public/embark/deny/cargo/index.git"
         }
       ],
       "message": "'registry' source explicitly allowed",
diff --git a/tests/snapshots/sources__allows_registry_index_sparse.snap b/tests/snapshots/sources__allows_registry_index_sparse.snap
index 056431c21..d8084eb1a 100644
--- a/tests/snapshots/sources__allows_registry_index_sparse.snap
+++ b/tests/snapshots/sources__allows_registry_index_sparse.snap
@@ -62,10 +62,10 @@ expression: diags
           "span": "sparse+https://cargo.cloudsmith.io/embark/deny/"
         },
         {
-          "column": 9,
+          "column": 10,
           "line": 3,
           "message": "source allowance",
-          "span": "'https://cargo.cloudsmith.io/embark/deny/'"
+          "span": "https://cargo.cloudsmith.io/embark/deny/"
         }
       ],
       "message": "'registry' source explicitly allowed",
diff --git a/tests/snapshots/sources__allows_registry_index_sparse_or_git.snap b/tests/snapshots/sources__allows_registry_index_sparse_or_git.snap
index 1c625fa0d..60825c141 100644
--- a/tests/snapshots/sources__allows_registry_index_sparse_or_git.snap
+++ b/tests/snapshots/sources__allows_registry_index_sparse_or_git.snap
@@ -30,10 +30,10 @@ expression: diags
           "span": "registry+https://dl.cloudsmith.io/public/embark/deny/cargo/index.git"
         },
         {
-          "column": 5,
+          "column": 6,
           "line": 3,
           "message": "source allowance",
-          "span": "'https://dl.cloudsmith.io/public/embark/deny/cargo/index.git'"
+          "span": "https://dl.cloudsmith.io/public/embark/deny/cargo/index.git"
         }
       ],
       "message": "'registry' source explicitly allowed",
@@ -68,10 +68,10 @@ expression: diags
           "span": "sparse+https://cargo.cloudsmith.io/embark/deny/"
         },
         {
-          "column": 5,
+          "column": 6,
           "line": 4,
           "message": "source allowance",
-          "span": "'https://cargo.cloudsmith.io/embark/deny/'"
+          "span": "https://cargo.cloudsmith.io/embark/deny/"
         }
       ],
       "message": "'registry' source explicitly allowed",
diff --git a/tests/sources.rs b/tests/sources.rs
index a5378f417..8d4c56e39 100644
--- a/tests/sources.rs
+++ b/tests/sources.rs
@@ -1,15 +1,19 @@
-use cargo_deny::{func_name, sources, test_utils::*};
+use cargo_deny::{
+    func_name,
+    sources::{self, cfg::Config},
+    test_utils::{self as tu, KrateGather},
+};
 
 #[inline]
 pub fn src_check(
     name: &str,
     kg: KrateGather<'_>,
-    cfg: impl Into<Config<sources::Config>>,
+    cfg: impl Into<tu::Config<Config>>,
 ) -> Vec<serde_json::Value> {
     let krates = kg.gather();
     let cfg = cfg.into();
 
-    gather_diagnostics::<sources::Config, _, _>(&krates, name, cfg, |ctx, _cs, tx| {
+    tu::gather_diagnostics::<Config, _, _>(&krates, name, cfg, |ctx, _cs, tx, _files| {
         sources::check(ctx, tx);
     })
 }
@@ -118,7 +122,7 @@ allow-registry = [
 
 #[test]
 fn validates_git_source_specs() {
-    use sources::GitSpec;
+    use sources::cfg::GitSpec;
 
     assert!(GitSpec::Rev > GitSpec::Tag);
     assert!(GitSpec::Tag > GitSpec::Branch);