Hedronite · Ops Lesson · Rust supply chain + Nix · Thu 2026-10-29

A supply-chain gate for a Rust API cargo-deny policy, RustSec advisories, and a pinned audit in Nix flake checks

Fix what an update can fix, write a reason for what it cannot, and pin the advisory data so every commit gets one answer.

Lesson Class: Ops (Rust · Cargo · cargo-deny · cargo-audit · RustSec · Nix flakes · crane checks)
Tested with: cargo-deny 0.20.2 · cargo-audit 0.22.2 (nixpkgs 31ecbac) · cargo 1.98.0 · crane e675a36 · advisory-db ee1a49a and 2290866 · Nix 2.35.2 on aarch64-darwin
Lab: lab/gate.sh · nix.sh · env.sh · flake.nix · policy/deny.toml · app/ (Loco API, Cargo.lock) · real transcripts in lab/out-gate.txt, out-nix.txt, out/
Source: cargo-deny book (check, common options, init, advisories, licenses, bans, sources) · RustSec advisory database · crane API reference · Nix reference manual (flake, check, lock, update)
Read the Exit Code
Advisories 1, bans 2, licenses 4, sources 8: the number names the failed check.
An Exception Needs a Reason
What cargo update cannot fix is ignored by ID, with the reason written beside it.
A Pin Can Go Stale
A two-week-old advisory database passed the same vulnerable lockfile.
Nobody added openssl. The pull request added an HTTP client, and the ban caught what came with it.

The problem

A Rust web API depends on hundreds of crates. A crate is Rust's unit of packaged code, and most are downloaded from the public crates.io registry, the server that hosts them. The application here is a Loco framework API called notes_api, and its license listing has 438 crates. Every pull request that touches Cargo.lock, the file recording the exact version of every dependency, can bring in a crate with a known vulnerability, a license the team cannot ship, a TLS (Transport Layer Security) stack the team decided against, or a crate from somewhere other than crates.io. Nobody reads 438 crates by hand.

The team wants one CI (continuous integration) check that ends in an exit code, so a pipeline can pass or fail a pull request without a person reading logs. It also wants the same commit to get the same answer every time, which means pinning the advisory data, and a pin can go stale.

The lab ran on 2026-10-29 against the API's real Cargo.lock on an arm64 Mac, with cargo-deny 0.20.2 and cargo-audit 0.22.2 taken from one pinned nixpkgs revision. The advisory database was fetched over the network.

The idea, in plain words

cargo-deny is a Cargo subcommand that audits a dependency graph against a written policy covering vulnerabilities, licenses, banned dependencies and download sources. The policy lives in a file called deny.toml. Four checks run against it: advisories, bans, licenses and sources. The exit code is a bitset, meaning each check owns one bit, so a CI job can tell exactly which check failed. What cargo update can fix gets fixed, and what remains becomes an exception with a written reason that shows up in review. The same gate then runs inside nix flake check, where crane's cargoAudit reads Cargo.lock against a pinned advisory database and crane's cargoDeny covers bans, licenses and sources. The two tools draw the line between error and warning in different places.

The policy file and the licenses check

Every crate declares its license as an SPDX (Software Package Data Exchange) license expression, a standard short notation such as MIT or Apache-2.0 WITH LLVM-exception. The licenses check compares those expressions with an allow list in deny.toml. The list matters because a license decision made once, in a reviewed file, replaces an argument on every pull request that adds a dependency.

cargo deny init writes a configuration file from a template as a starting point. In the lab it wrote 225 non-empty lines, and the licenses check against that file failed at once. (In the lab scripts, deny is a shell function that runs cargo deny from the pinned tool version.)

deny.toml written: 225 non-empty lines
allow = [
    #"MIT",
exit 4
license rejections: 437
licenses FAILED

The template's allow list is empty, because every license inside it is commented out, as the #"MIT", line shows. Licenses not in the allow list are denied by default, so the empty list gave 437 rejections. Exit code 4 is the licenses bit: advisories is 1, bans is 2, licenses is 4 and sources is 8. Two failed checks add their bits.

The first real policy allowed thirteen permissive licenses, banned openssl in favor of rustls, and denied unknown registries and git sources. The last two parts return in the bans and sources section.

# First policy: permissive licenses only, no git or unknown registries.
[licenses]
allow = [
  "0BSD", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause",
  "BSD-3-Clause", "BSL-1.0", "CC0-1.0", "CDLA-Permissive-2.0", "ISC",
  "MIT", "Unicode-3.0", "Unlicense", "Zlib",
]
confidence-threshold = 0.8

[bans]
multiple-versions = "warn"
deny = [{ crate = "openssl", use-instead = "rustls" }]

[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]

The licenses table lists thirteen identifiers and sets confidence-threshold to 0.8, which is the documented default. A license guess scored below it is rejected rather than passed. The file reaches the tool through --config, a common option that must come before the subcommand. The lab runs deny --config ../policy/first-policy.toml check licenses; cargo-deny 0.20.2 rejected the option when it came after check.

exit 4
   6 error[rejected]: failed to satisfy license requirements
   1 error[unlicensed]: notes_api = 0.1.0 is unlicensed
   1 warning[no-license-field]: license expression was not specified in manifest for crate 'notes_api = 0.1.0'
   1 warning[unlicensed]: a valid license expression could not be retrieved for the crate
rejected crates:
  colored-2.2.0
  colored-3.1.1
  cssparser-0.36.0
  cssparser-macros-0.6.1
  dtoa-short-0.3.5
  selectors-0.33.0

   ┌─ ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/colored-3.1.1/Cargo.toml:34:12
   │
34 │ license = "MPL-2.0"
   │            ━━━━━━━
   │            │
   │            rejected: license is not explicitly allowed
   │
   ├ MPL-2.0 - Mozilla Public License 2.0:
   ├   - OSI approved

The exit code is still 4, but now only six crates fail, and all six use MPL-2.0 (Mozilla Public License 2.0). The seventh error is the team's own crate. It has no license field, and app/Cargo.toml says publish = false, so it is never published. The fix is two edits to deny.toml.

[licenses]
allow = [
  "0BSD", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause",
  "BSD-3-Clause", "BSL-1.0", "CC0-1.0", "CDLA-Permissive-2.0", "ISC",
  "MIT", "Unicode-3.0", "Unlicense", "Zlib",
  "MPL-2.0", # file-level copyleft; reviewed and accepted for unmodified dependencies
]
confidence-threshold = 0.8
private = { ignore = true } # our own crate has publish = false and no license field

The first edit adds MPL-2.0 with a comment recording the review, so the next reader sees the decision and its grounds in the same line. The comment does not change what the check does; it is written for the reviewer. The second sets private = { ignore = true }, which tells cargo-deny to skip the license expression of workspace members that are not published. With both in place the licenses check passed:

exit 0
  39 warning[duplicate]
bans ok, licenses ok, sources ok

The 39 duplicate warnings print but do not fail the check, because the bans table keeps multiple-versions = "warn". They are covered below.

Symptom: the license check fails on the team's own crate with error[unlicensed]: notes_api = 0.1.0 is unlicensed.

Cause: the crate has no license field and is never published, so cargo-deny finds no expression to compare with the allow list.

Fix: set private = { ignore = true } so unpublished workspace members are not license-checked, rather than inventing a license for a crate nobody distributes.

Advisories, the update, and an exception with a reason

An advisory is a published report that certain versions of a crate have a problem. The RustSec Advisory Database is a repository of security advisories filed against Rust crates published via crates.io, and cargo-deny matches it against Cargo.lock. Three kinds appear in the lab: a vulnerability (a security flaw), an unsound advisory (a soundness issue) and an unmaintained advisory (a crate no longer maintained). The database changes daily, so a lockfile that was clean last month can fail today, and the check fetches a fresh copy on each run unless told otherwise.

deny --config ../policy/first-policy.toml check advisories
exit 1
RUSTSEC-2024-0449  unsafe-libyaml 0.2.11  No safe upgrade is available!
RUSTSEC-2026-0346  rustls 0.23.45  Upgrade to >=0.23.46 (try `cargo update -p rustls`)
RUSTSEC-2026-0347  rustls 0.23.45  Upgrade to >=0.23.46 (try `cargo update -p rustls`)
RUSTSEC-2026-0348  rustls 0.23.45  Upgrade to >=0.23.46 (try `cargo update -p rustls`)
RUSTSEC-2026-0349  rustls 0.23.45  Upgrade to >=0.23.46 (try `cargo update -p rustls`)
RUSTSEC-2026-0381  webpki-roots 1.0.9  Upgrade to >=1.0.10 (try `cargo update -p webpki-roots`)
RUSTSEC-2026-0382  rustls-webpki 0.103.15  Upgrade to >=0.103.16, <0.104.0-alpha.1 OR >=0.104.0-alpha.8 (try `cargo update -p rustls-webpki`)
RUSTSEC-2026-0386  tokio 1.53.2  Upgrade to >=1.51.6, <1.52.0 OR >=1.53.3 (try `cargo update -p tokio`)
RUSTSEC-2026-0391  zstd 0.14.2  Upgrade to >=0.14.3 (try `cargo update -p zstd`)
RUSTSEC-2026-0398  zstd-safe 8.1.0  Upgrade to >=8.2.0 (try `cargo update -p zstd-safe`)
RUSTSEC-2026-0403  zstd-safe 8.1.0  Upgrade to >=8.2.0 (try `cargo update -p zstd-safe`)
RUSTSEC-2026-0410  zstd-sys 2.1.1+zstd.1.5.7  Upgrade to >=2.1.2 (try `cargo update -p zstd-sys`)
errors: 12
   1 error[unmaintained]
   1 error[unsound]
  10 error[vulnerability]

Exit 1 is the advisories bit. Each row gives the advisory ID, the crate and version, and the suggested solution. Eleven rows name a fixed version and a cargo update -p command. The row for RUSTSEC-2024-0449 on unsafe-libyaml says "No safe upgrade is available!". The first-policy file has no advisories section, so the documented defaults applied: unmaintained = "all" fails any crate with an unmaintained advisory, and unsound = "workspace" fails an unsound advisory only for a direct dependency of a workspace crate. Tokio is a direct dependency in app/Cargo.toml, which is why its unsound advisory counted as an error.

The fix for the eleven fixable rows is one command naming the seven crates involved:

cargo update -p rustls -p rustls-webpki -p tokio -p webpki-roots -p zstd -p zstd-safe -p zstd-sys
    Updating crates.io index
     Locking 7 packages to latest compatible versions
    Updating rustls v0.23.45 -> v0.23.46
    Updating rustls-webpki v0.103.15 -> v0.103.16
    Updating tokio v1.53.2 -> v1.54.1
    Updating webpki-roots v1.0.9 -> v1.0.11
    Updating zstd v0.14.2 -> v0.14.3
    Updating zstd-safe v8.1.0 -> v8.2.0
    Updating zstd-sys v2.1.1+zstd.1.5.7 -> v2.1.2+zstd.1.5.7

Cargo moves each named crate to the latest release compatible with the version requirements in Cargo.toml, not merely to the minimum the advisory asked for. That is why tokio went from 1.53.2 to 1.54.1 although 1.53.3 would have satisfied the advisory. The Cargo.lock diff can be larger than the advisory needs, so read it. Rerunning the advisories check then still exited 1, with one error left.

exit 1
error[unmaintained]: unsafe-libyaml is unmaintained
    ├ ID: RUSTSEC-2024-0449
    ├ Solution: No safe upgrade is available!
unsafe-libyaml v0.2.11
├── serde_yaml v0.9.34+deprecated
│   └── rrgen v0.6.0
│       └── loco-gen v1.2.0
│           └── loco-rs v1.2.0
│               └── notes_api v0.1.0 (~/lab/work)

The tree comes from cargo tree -i unsafe-libyaml, which inverts the dependency graph to show who pulls a crate in. The path runs through serde_yaml, rrgen, loco-gen and loco-rs. No patched version exists and the team does not depend on unsafe-libyaml directly, so deny.toml records an exception with a reason:

[advisories]
ignore = [
  { id = "RUSTSEC-2024-0449", reason = "unsafe-libyaml comes in through serde_yaml for config files we write ourselves; no safe upgrade exists yet" },
]

With that file in place the same check printed exit 0 and advisories ok. The reason lets a reviewer judge the exception later. cargo-deny also has an expiry option that ignores an advisory only until a set time after publication; the lab did not use it.

Symptom: the advisories check keeps failing on an unmaintained crate even after cargo update, and the solution line says "No safe upgrade is available!".

Cause: unmaintained advisories fail for any crate by default, including deep transitive ones (dependencies of dependencies), and there is no patched version to move to.

Fix: find who pulls the crate in with cargo tree -i, then add an ignore entry with the advisory ID and a reason, so the exception is visible in review.

Bans and sources

Duplicate versions are one crate present at two or more versions, usually because two dependencies require incompatible releases. A banned crate is one the policy names in its deny list. Default features are the optional parts of a dependency that Cargo switches on unless the declaration opts out. A ban only helps if it reaches crates pulled in indirectly, because those are the ones nobody chose by hand.

Duplicates are the noisy case. Switching multiple-versions to "deny" turned the 39 warnings from the license run into 39 errors:

exit 2
duplicate errors: 39
error[duplicate]: found 2 duplicate entries for crate 'block-buffer'
error[duplicate]: found 2 duplicate entries for crate 'chrono-tz'
error[duplicate]: found 2 duplicate entries for crate 'colored'

Exit 2 is the bans bit. The documentation lists "warn" as the default, which prints a warning and does not fail the check, and "deny", which emits an error for each crate with duplicates. A framework app carries duplicates the team cannot remove, so keeping the warning and banning specific crates is the practical choice.

The specific ban is the deny entry in the bans table: { crate = "openssl", use-instead = "rustls" }. To test it, the lab simulated a pull request that adds an HTTP client with its default features:

cargo add [email protected]
deny check bans
      Adding reqwest v0.12 to dependencies
             Features as of v0.12.0:
      Adding base64 v0.22.1
exit 2
error[banned]: crate 'openssl = 0.10.81' is explicitly banned
   ┌─ ~/lab/pr/deny.toml:19:20
   │
19 │ deny = [{ crate = "openssl", use-instead = "rustls" }]
   │                    ━━━━━━━                  ────── use instead
   │                    │                         
   │                    banned here
   │
   ├ openssl v0.10.81
     └── native-tls v0.2.18
         ├── hyper-tls v0.6.0
         │   └── reqwest v0.12.28
         │       └── notes_api v0.1.0
         ├── reqwest v0.12.28 (*)
         └── tokio-native-tls v0.3.1

Nobody wrote openssl in this pull request. The inclusion graph shows how it arrived: reqwest's default features bring in hyper-tls and tokio-native-tls, both of which depend on native-tls, which depends on openssl. The diagnostic points at the policy line that bans the crate and marks rustls with "use instead", so the author learns the replacement from the failure itself. The documentation also offers a reason field that is shown in the diagnostic; the lab did not test it.

The sources check uses the third table of the policy:

[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]

Both settings default to "warn", which prints a warning and lets the check pass, so the policy sets them to "deny". Every crate in this lockfile came from crates.io, so the check had nothing to catch. The full gate on the updated Cargo.lock, with deny.toml in place, exited 0:

 advisories ok: 0 errors, 0 warnings, 2 notes
       bans ok: 0 errors, 39 warnings, 0 notes
   licenses ok: 0 errors, 0 warnings, 438 notes
    sources ok: 0 errors, 0 warnings, 0 notes

Symptom: a pull request that only adds an HTTP client fails with error[banned]: crate 'openssl = 0.10.81' is explicitly banned, though nobody wrote openssl anywhere.

Cause: the new dependency's default features pull in native-tls, which depends on openssl, as the inclusion graph shows.

Fix: read the graph to find the direct dependency responsible, then change how that dependency is declared, for example its TLS feature choice. The lab did not test which feature set avoids openssl.

The same gate as Nix flake checks, with a pinned database

A flake is a directory with a flake.nix file that declares inputs (the other repositories it depends on) and outputs. flake.lock records the exact revision of each input. nix flake check evaluates the flake and builds the derivations listed in its checks output, and a derivation is a Nix build recipe whose result is cached by its inputs. crane is a Nix library for building Cargo projects, and it provides cargoAudit and cargoDeny as ready-made checks.

{
  description = "Supply-chain gate for the notes API";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    crane.url = "github:ipetkov/crane";
    # The advisory database is plain data, not a flake. Pinning it in
    # flake.lock means a commit always gets the same audit answer.
    advisory-db = {
      url = "github:rustsec/advisory-db";
      flake = false;
    };
  };

  outputs = { self, nixpkgs, crane, advisory-db }:
    let
      forAll = f: nixpkgs.lib.genAttrs [ "aarch64-darwin" "aarch64-linux" "x86_64-linux" ]
        (system: f nixpkgs.legacyPackages.${system});
    in {
      checks = forAll (pkgs:
        let
          craneLib = crane.mkLib pkgs;
          # Keeps Cargo.toml, Cargo.lock, .rs and .toml files, so deny.toml
          # stays in and edits to README or config/*.yaml do not rerun checks.
          src = craneLib.cleanCargoSource ./.;
        in {
          audit = craneLib.cargoAudit { inherit src advisory-db; };
          deny = craneLib.cargoDeny { inherit src; };
        });
    };
}

The advisory-db input sets flake = false, the Nix manual's way to use a repository that has no flake.nix, and flake.lock pins it. The cleanCargoSource filter keeps Cargo files, Rust files and every file ending in .toml, so deny.toml reaches the deny check while edits to a README or YAML config do not rebuild it. The audit check runs cargo audit -n -d ${advisory-db} with the source filtered down to Cargo.lock. The deny check runs the equivalent of cargo --offline deny check bans licenses sources and has no advisories in it, because crane notes that cargo deny needs the full source tree and would rerun on any source change. In Nix the advisories come from cargo-audit, which never reads deny.toml, so its ignore list does not apply.

Locking the inputs pinned three revisions:

advisory-db  ee1a49a2769a  2026-10-29T14:05:38Z
crane  e675a36b7377  2026-10-18T19:09:12Z
nixpkgs  31ecbaca1bde  2026-10-27T14:17:15Z

On the branch with the updated Cargo.lock, nix flake check -L --keep-going exited 0. Successful checks are cached, so the lab read their output back from the build logs:

exit 0
audit> Loaded 1386 security advisories (from /nix/store/qnbhhjdlwg4svfyl3qzwvhqv5mhc0m8d-source)
audit> Scanning Cargo.lock for vulnerabilities (469 crate dependencies)
audit> Crate:     unsafe-libyaml
audit> Warning:   unmaintained
audit> ID:        RUSTSEC-2024-0449
audit> warning: 1 allowed warning found
audit> lines that say the yank status could not be checked: 468
deny> bans ok, licenses ok, sources ok

The audit loaded 1386 advisories and reported unsafe-libyaml as an unmaintained warning, which did not fail the build. The 468 lines about yank status appeared inside the build and did not fail it. crane's cargoAudit passes --ignore yanked by default; the lab did not test what that changes.

The original Cargo.lock with the same flake.lock gave a different answer:

exit 1
crate-audit> error: 10 vulnerabilities found!
crate-audit> warning: 12 allowed warnings found
✅ checks.aarch64-darwin.deny (previously built)
❌ checks.aarch64-darwin.audit
the 12 warnings by kind:
   1 crate-audit> Warning:   unmaintained
  11 crate-audit> Warning:   unsound
unsound warnings by crate:
   1 tokio
  10 zstd-safe

Here the deny check passed while the audit failed. Without --keep-going, Nix stops at the first failure, so one check could hide the other. The two tools split the same lockfile differently:

cargo-deny (advisories run, defaults)cargo-audit (inside the flake check)
Failed on12: 10 vulnerability, tokio unsound, unsafe-libyaml unmaintainedthe 10 vulnerabilities
Reported without failingnothing11 unsound warnings (tokio 1, zstd-safe 10) and 1 unmaintained warning

cargo-deny did not fail on the ten zstd-safe unsound advisories because zstd-safe is not a direct dependency, and the unsound = "workspace" default fails only direct ones. cargo-audit listed them as warnings and failed only on vulnerabilities.

Pinning has a cost, which the lab measured by overriding the input with an older commit:

nix flake check -L --keep-going --override-input advisory-db github:rustsec/advisory-db/22908666e50caf5d9fe40e13edab0d34bddcbaef
exit 0
audit> Loaded 1310 security advisories (from /nix/store/0l7zsbjxwmhq04ggfzkqy6r6sfqxiv7v-source)

The same vulnerable lock passed, because the database from 2026-10-14 does not contain the ten vulnerability advisories the newer pin caught. cargo-deny has a maximum-db-staleness setting, but its documentation says it is only checked when advisory database fetching has been disabled, so it does not help here. Nothing in the flake check warns about the pin's age, so the pin has to be refreshed deliberately. Moving the pin to the old commit with nix flake lock --override-input, then back with nix flake update advisory-db, shows each change:

• Updated input 'advisory-db':
    'github:rustsec/advisory-db/ee1a49a' (2026-10-29)
  → 'github:rustsec/advisory-db/2290866' (2026-10-14)
• Updated input 'advisory-db':
    'github:rustsec/advisory-db/2290866' (2026-10-14)
  → 'github:rustsec/advisory-db/ee1a49a' (2026-10-29)

Symptom: nix flake check is green on a branch whose lockfile fails cargo deny check advisories locally.

Cause: either the flake.lock pin predates the advisories (1310 advisories in the old pin against 1386 in the new one), or the reader expected the Nix deny check to run advisories, which crane leaves out.

Fix: update the advisory-db input on a schedule as its own commit, so the change in verdict is attributable, and keep the cargoAudit check next to cargoDeny.

Putting it together

Run cargo deny check in CI and read the exit code as a bitset, so the pipeline reports which check failed without anyone opening a log:

BitCheckLab exit that showed it
0x1advisories1, on the twelve advisories
0x2bans2, on duplicate versions and on openssl
0x4licenses4, on the first policy
0x8sourcesnot triggered
nonefull gate0, on the updated Cargo.lock

Keep deny.toml in review with a reason on every exception, and put a comment beside any allowed license, as the MPL-2.0 line does. Then run nix flake check --keep-going with advisory-db pinned, so a commit gets one answer everywhere, and update that single input on a schedule.

Verified: cargo-deny 0.20.2 and cargo-audit 0.22.2 from nixpkgs 31ecbac, crane e675a36, advisory-db ee1a49a (2290866 for the old-pin run), Determinate Nix 3.22.3 on aarch64-darwin, the only system checked, and cargo 1.98.0 for the update and the add. Not tested: the other systems in the flake, a CI service, crates from git or private registries, the expiry option, clarify entries, a run with advisory fetching disabled, and the effect of --ignore yanked.

Check yourself

Question 1
A CI job exits with code 6 from cargo deny check. Which checks failed?
tap to reveal
Six is 2 plus 4, so the bans check (0x2) and the licenses check (0x4) both had errors. Advisories and sources passed.
Question 2
Why did the template configuration from cargo deny init reject 437 crates?
tap to reveal
Its allow list is empty, and licenses not in the allow list are denied by default.
Question 3
After cargo update, one advisory still fails with "No safe upgrade is available!". What should the team do?
tap to reveal
Find who pulls the crate in with cargo tree -i, then add an ignore entry with the advisory ID and a reason, so the exception is visible in review.
Question 4
Why does the Nix deny check not fail on advisories, and where do they come from instead?
tap to reveal
crane's cargoDeny runs bans, licenses and sources only, because cargo deny needs the full source tree. Advisories come from cargoAudit, which reads Cargo.lock against the pinned advisory-db input.
Question 5
The flake check passes on a lockfile that fails locally. What is the likely cause, and what is the fix?
tap to reveal
The pinned database is older and lacks the newer advisories (1310 versus 1386 in the lab), and nothing warns about its age. Update the advisory-db input on a schedule as its own commit.

Sources

  1. cargo-deny book: the check command
  2. cargo-deny book: common options
  3. cargo-deny book: the init command
  4. cargo-deny book: advisories configuration
  5. cargo-deny book: licenses configuration
  6. cargo-deny book: bans configuration
  7. cargo-deny book: sources configuration
  8. RustSec advisory database
  9. RustSec advisories, human-readable site
  10. crane API reference (cargoAudit, cargoDeny, cleanCargoSource)
  11. Nix reference manual 2.28: nix flake
  12. Nix reference manual 2.28: nix flake check
  13. Nix reference manual 2.28: nix flake lock
  14. Nix reference manual 2.28: nix flake update