diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6308f0a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# EditorConfig configuration for flakehub-cache-types + +# Top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file, utf-8 charset +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +# Rust +[*.rs] +indent_style = space +indent_size = 4 + +# Misc +[*.{yaml,yml,md,nix,toml}] +indent_style = space +indent_size = 2 diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c7ecf5e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7e87ddc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + pull_request: + merge_group: + push: + branches: [main] + +permissions: + contents: read + +jobs: + checks: + name: Nix checks (${{ matrix.os }}) + permissions: + contents: read + # For flakehub-cache-action + id-token: write + strategy: + matrix: + os: + - ubuntu-latest + - macos-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: true + - uses: DeterminateSystems/flakehub-cache-action@main + - name: Build, test, clippy, rustfmt + run: nix flake check -L + - name: Check editorconfig conformance + run: nix develop --command editorconfig-checker + + cargo-deny: + name: Advisories, licenses, sources + runs-on: ubuntu-latest + permissions: + contents: read + # For flakehub-cache-action + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: true + - uses: DeterminateSystems/flakehub-cache-action@main + - run: nix develop --command cargo deny check + + flake-checker: + name: Check flake.lock health + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: DeterminateSystems/flake-checker-action@main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f027b8a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.direnv + +/target +result +result-* diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..748203f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,479 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cxx" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe442a792c7c736eea18b32a7f8a3b63cf8aafabda6760042dc2fdeda456291" +dependencies = [ + "cc", + "cxx-build", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3184a94384c663718698311a78a51ac00c484c10b4eeac06fb0a068c5f64fa2" +dependencies = [ + "cc", + "codespan-reporting", + "indexmap", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0148d8fd1199329ddf1d157a5e134e51ceff37c6a7ddd38615c399d81cb05d8d" +dependencies = [ + "clap", + "codespan-reporting", + "indexmap", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52850339faed2eaadd24e286dc1d8268cc6f8a7bd9524d713adc9099566b4c89" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c77c856545d886c9bd5215409ebb63b925e262135248b50c79e5a5f194ee47c" +dependencies = [ + "indexmap", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flakehub-cache-types" +version = "0.1.0" +dependencies = [ + "cxx", + "displaydoc", + "hex", + "lazy_static", + "nix-base32", + "regex", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "link-cplusplus" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" +dependencies = [ + "cc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nix-base32" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2628953ed836273ee4262e3708a8ef63ca38bd8a922070626eef7f9e5d8d536" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "scratch" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a648532 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "flakehub-cache-types" +version = "0.1.0" +edition = "2021" +description = "Nix store path, hash, and error types shared by FlakeHub Cache services" +license = "Apache-2.0" +repository = "https://github.com/DeterminateSystems/flakehub-cache-types" +publish = false + +[lib] +name = "flakehub_cache_types" +path = "src/lib.rs" + +[dependencies] +cxx = { version = "1.0", optional = true } +displaydoc = "0.2.4" +hex = "0.4.3" +lazy_static = "1.4.0" +nix-base32 = "0.2.0" +regex = "1.8.3" +serde = { version = "1.0.163", features = ["derive"] } +serde_json = "1.0.96" +sha2 = "0.10.6" + +[features] +default = [] + +# Enables `From` for `StoreError`. +cxx = ["dep:cxx"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b4760da --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# flakehub-cache-types + +Nix store path, hash, and error types shared by FlakeHub Cache services. + +The crate provides: + +- `nix_store::StorePath` and `nix_store::StorePathHash`: parsed, validated + store paths and their 32-character base name hashes. +- `hash::Hash`: SHA-256 hashes with Nix's base16 and base32 encodings. +- `hash::Error`: the parsing error returned by `Hash::from_typed`. +- `StoreError` and `StoreResult`: the errors returned by the store path + operations above. + +## Usage + +```console +cargo add --git https://github.com/DeterminateSystems/flakehub-cache-types flakehub-cache-types +``` + +```rust +use flakehub_cache_types::nix_store::StorePathHash; + +let hash = StorePathHash::new("ib3sh3pcz10wsmavxvkdbayhqivbghlq")?; +``` + +### Features + +- `cxx` (off by default): implements `From` for + `StoreError`, for use with C++ bindings to the Nix libraries. + +## Development + +`nix develop` provides a Rust toolchain, and `direnv` loads it +automatically. Run the tests with `cargo test`. CI enforces `cargo fmt`, +`cargo clippy`, `cargo deny`, and editorconfig conformance. + +## Provenance and license + +These types derive from [Attic](https://github.com/zhaofengli/attic) by +Zhaofeng Li and the Attic contributors. Licensed under the Apache License, +Version 2.0; see [LICENSE](LICENSE). diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..37f27ce --- /dev/null +++ b/deny.toml @@ -0,0 +1,20 @@ +[graph] +all-features = true + +[advisories] +yanked = "deny" + +[licenses] +allow = [ + "Apache-2.0", + "MIT", + "Unicode-3.0", + "Zlib", +] + +[bans] +multiple-versions = "warn" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..e751c08 --- /dev/null +++ b/flake.lock @@ -0,0 +1,78 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1779041105, + "narHash": "sha256-nnGD2f8OlAZT2i5OfwikJsw+ifWfiA4d6A8BWlgOXV0=", + "rev": "10e6e3cb966f7cfcc789fe5eee7a85f3188ce08b", + "revCount": 858, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/ipetkov/crane/0.23.4/019e3726-d9ea-7820-aece-59009301cab1/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/ipetkov/crane/0" + } + }, + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1782900513, + "narHash": "sha256-GHrsl1+ysDFgmDQtQSqWS7TiYD2Q58VlwLvnf1+crJM=", + "rev": "16810aa8f4ad89ca480b1513774e8b6f485fe368", + "revCount": 2708, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/nix-community/fenix/0.1.2708%2Brev-16810aa8f4ad89ca480b1513774e8b6f485fe368/019f1d68-8b55-7783-bed1-d5926f21ae52/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/nix-community/fenix/0.1" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1785692966, + "narHash": "sha256-vUfIeBEfpbAfZ5zjgIkYk7eHBeVfCYVjLbWnMkseYnk=", + "rev": "643809054d65fdd466a63e3155b8c498cb483c04", + "revCount": 1046609, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.1046609%2Brev-643809054d65fdd466a63e3155b8c498cb483c04/019fc6ca-7fa6-7ad3-a05c-e8e4d8fffab6/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/NixOS/nixpkgs/0.1" + } + }, + "root": { + "inputs": { + "crane": "crane", + "fenix": "fenix", + "nixpkgs": "nixpkgs" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1782864348, + "narHash": "sha256-NVYhLbaefeIUftPlo3kS6qr0xd8eFJRodEiaHrvFKR4=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "0d381ca097a8e0375a19387874d952c0a230ac4f", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..961979e --- /dev/null +++ b/flake.nix @@ -0,0 +1,136 @@ +{ + description = "Nix store path, hash, and error types shared by FlakeHub Cache services"; + + inputs = { + nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1"; + + fenix = { + url = "https://flakehub.com/f/nix-community/fenix/0.1"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + crane.url = "https://flakehub.com/f/ipetkov/crane/0"; + }; + + outputs = + { + self, + nixpkgs, + fenix, + crane, + }: + let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + + forAllSystems = + f: + nixpkgs.lib.genAttrs supportedSystems ( + system: + let + pkgs = import nixpkgs { inherit system; }; + + toolchain = fenix.packages.${system}.stable.withComponents [ + "cargo" + "clippy" + "rustc" + "rustfmt" + "rust-src" + ]; + + craneLib = (crane.mkLib pkgs).overrideToolchain (_: toolchain); + + src = pkgs.lib.fileset.toSource { + root = ./.; + fileset = pkgs.lib.fileset.unions [ + (craneLib.fileset.commonCargoSources ./.) + ./src/hash/tests + ]; + }; + + commonArgs = { + inherit src; + strictDeps = true; + }; + + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + in + f { + inherit + pkgs + toolchain + craneLib + commonArgs + cargoArtifacts + ; + } + ); + in + { + packages = forAllSystems ( + { craneLib, commonArgs, cargoArtifacts, ... }: + { + default = craneLib.buildPackage ( + commonArgs + // { + inherit cargoArtifacts; + cargoExtraArgs = "--all-features"; + } + ); + } + ); + + checks = forAllSystems ( + { + pkgs, + craneLib, + commonArgs, + cargoArtifacts, + ... + }: + { + test = craneLib.cargoTest ( + commonArgs + // { + inherit cargoArtifacts; + cargoTestExtraArgs = "--all-features"; + } + # The `cxx` feature links against the C++ standard library; + # the test binary needs it on its runtime path. + // pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ]; + } + ); + + clippy = craneLib.cargoClippy ( + commonArgs + // { + inherit cargoArtifacts; + cargoClippyExtraArgs = "--all-targets --all-features -- --deny warnings"; + } + ); + + fmt = craneLib.cargoFmt { inherit (commonArgs) src; }; + } + ); + + devShells = forAllSystems ( + { pkgs, toolchain, ... }: + { + default = pkgs.mkShell { + packages = [ + toolchain + pkgs.rust-analyzer + pkgs.cargo-watch + pkgs.cargo-deny + pkgs.editorconfig-checker + ]; + }; + } + ); + }; +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..98a704a --- /dev/null +++ b/src/error.rs @@ -0,0 +1,65 @@ +use std::error::Error as StdError; +use std::io; +use std::path::PathBuf; + +use crate::nix_store::StorePath; + +pub type StoreResult = Result; + +#[derive(Debug, displaydoc::Display)] +pub enum StoreError { + /// Invalid store path {path:?}: {reason} + InvalidStorePath { path: PathBuf, reason: &'static str }, + + /// Invalid store path base name {base_name:?}: {reason} + InvalidStorePathName { + base_name: PathBuf, + reason: &'static str, + }, + + /// Invalid store path hash "{hash}": {reason} + InvalidStorePathHash { hash: String, reason: &'static str }, + + /// I/O error: {error}. + IoError { error: io::Error }, + + /// Unknown C++ exception: {exception}. + CxxError { exception: String }, + + /// Provenance for {path:?} was not valid JSON: {error_display}: {invalid_string} + InvalidProvenance { + path: StorePath, + error_display: String, + invalid_string: String, + }, +} + +impl StoreError { + pub fn name(&self) -> &'static str { + match self { + Self::InvalidStorePath { .. } => "InvalidStorePath", + Self::InvalidStorePathName { .. } => "InvalidStorePathName", + Self::InvalidStorePathHash { .. } => "InvalidStorePathHash", + Self::IoError { .. } => "IoError", + Self::CxxError { .. } => "CxxError", + Self::InvalidProvenance { .. } => "InvalidProvenance", + } + } +} + +impl StdError for StoreError {} + +impl From for StoreError { + fn from(error: io::Error) -> Self { + Self::IoError { error } + } +} + +#[cfg(feature = "cxx")] +impl From for StoreError { + fn from(exception: cxx::Exception) -> Self { + Self::CxxError { + exception: exception.what().to_string(), + } + } +} diff --git a/src/hash/mod.rs b/src/hash/mod.rs new file mode 100644 index 0000000..75fe4c8 --- /dev/null +++ b/src/hash/mod.rs @@ -0,0 +1,141 @@ +//! Hashing utilities. + +#[cfg(test)] +mod tests; + +use displaydoc::Display; +use serde::{de, ser, Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// A hash. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Hash { + /// An SHA-256 hash. + Sha256([u8; 32]), +} + +/// A hashing error. +#[derive(Debug, Display)] +pub enum Error { + /// The string lacks a colon separator. + NoColonSeparator, + + /// Hash algorithm {0} is not supported. + UnsupportedHashAlgorithm(String), + + /// Invalid base16 hash: {0} + InvalidBase16Hash(hex::FromHexError), + + /// Invalid base32 hash. + InvalidBase32Hash, + + /// Invalid length for {typ} string: Must be either {base16_len} (hexadecimal) or {base32_len} (base32), got {actual}. + InvalidHashStringLength { + typ: &'static str, + base16_len: usize, + base32_len: usize, + actual: usize, + }, +} + +impl std::error::Error for Error {} + +impl Hash { + /// Convenience function to generate a SHA-256 hash from a slice. + pub fn sha256_from_bytes(bytes: &[u8]) -> Self { + let mut hasher = Sha256::new(); + hasher.update(bytes); + Self::Sha256(hasher.finalize().into()) + } + + /// Parses a typed representation of a hash. + pub fn from_typed(s: &str) -> Result { + let colon = s.find(':').ok_or(Error::NoColonSeparator)?; + + let (typ, rest) = s.split_at(colon); + let hash = &rest[1..]; + + match typ { + "sha256" => { + let v = decode_hash(hash, "SHA-256", 32)?; + Ok(Self::Sha256(v.try_into().unwrap())) + } + _ => Err(Error::UnsupportedHashAlgorithm(typ.to_owned())), + } + } + + /// Returns the hash in Nix-specific Base32 format, with the hash type prepended. + pub fn to_typed_base32(&self) -> String { + format!("{}:{}", self.hash_type(), self.to_base32()) + } + + /// Returns the hash in hexadecimal format, with the hash type prepended. + /// + /// This is the canonical representation of hashes in the Attic database. + pub fn to_typed_base16(&self) -> String { + format!("{}:{}", self.hash_type(), hex::encode(self.data())) + } + + fn data(&self) -> &[u8] { + match self { + Self::Sha256(d) => d, + } + } + + fn hash_type(&self) -> &'static str { + match self { + Self::Sha256(_) => "sha256", + } + } + + /// Returns the hash in Nix-specific Base32 format. + pub fn to_base32(&self) -> String { + nix_base32::to_nix_base32(self.data()) + } +} + +impl<'de> Deserialize<'de> for Hash { + /// Deserializes a typed hash string. + fn deserialize(deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + use de::Error; + + String::deserialize(deserializer) + .and_then(|s| Self::from_typed(&s).map_err(|e| Error::custom(e.to_string()))) + } +} + +impl Serialize for Hash { + /// Serializes a hash into a hexadecimal hash string. + fn serialize(&self, serializer: S) -> Result + where + S: ser::Serializer, + { + serializer.serialize_str(&self.to_typed_base16()) + } +} + +/// Decodes a base16 or base32 encoded hash containing a specified number of bytes. +fn decode_hash(s: &str, typ: &'static str, expected_bytes: usize) -> Result, Error> { + let base16_len = expected_bytes * 2; + let base32_len = (expected_bytes * 8 - 1) / 5 + 1; + + let v = if s.len() == base16_len { + hex::decode(s).map_err(Error::InvalidBase16Hash)? + } else if s.len() == base32_len { + nix_base32::from_nix_base32(s).ok_or(Error::InvalidBase32Hash)? + } else { + return Err(Error::InvalidHashStringLength { + typ, + base16_len, + base32_len, + actual: s.len(), + }); + }; + + assert!(v.len() == expected_bytes); + + Ok(v) +} diff --git a/src/hash/tests/.gitattributes b/src/hash/tests/.gitattributes new file mode 100644 index 0000000..3a4be7f --- /dev/null +++ b/src/hash/tests/.gitattributes @@ -0,0 +1 @@ +blob -text diff --git a/src/hash/tests/blob b/src/hash/tests/blob new file mode 100644 index 0000000..afbfab0 --- /dev/null +++ b/src/hash/tests/blob @@ -0,0 +1,15 @@ +⊂_ヽ +  \\ _ +   \( •_•) F +    < ⌒ヽ A +   /   へ\ B +   /  / \\ U +   レ ノ   ヽ_つ L +  / / O +  / /| U + ( (ヽ S + | |、\ + | 丿 \ ⌒) + | |  ) / +`ノ )  Lノ +(_/ diff --git a/src/hash/tests/mod.rs b/src/hash/tests/mod.rs new file mode 100644 index 0000000..bcc7ef5 --- /dev/null +++ b/src/hash/tests/mod.rs @@ -0,0 +1,50 @@ +use super::*; + +const BLOB: &[u8] = include_bytes!("blob"); + +#[test] +fn test_basic() { + let hash = Hash::sha256_from_bytes(BLOB); + + let expected_base16 = "sha256:df3404eaf1481506db9ca155e0a871d5b4d22e62a96961e8bf4ad1a8ca525330"; + assert_eq!(expected_base16, hash.to_typed_base16()); + + let expected_base32 = "sha256:0c2kab5ailaapzl62sd9c8pd5d6mf6lf0md1kkdhc5a8y7m08d6z"; + assert_eq!(expected_base32, hash.to_typed_base32()); +} + +#[test] +fn test_from_typed() { + let base16 = "sha256:baeabdb75c223d171800c17b05c5e7e8e9980723a90eb6ffcc632a305afc5a42"; + let base32 = "sha256:0hjszid30ak3rkzvc3m94c3risg8wz2hayy100c1fg92bjvvvsms"; + + assert_eq!( + Hash::from_typed(base16).unwrap(), + Hash::from_typed(base32).unwrap() + ); + + assert!(matches!( + Hash::from_typed("sha256"), + Err(Error::NoColonSeparator) + )); + + assert!(matches!( + Hash::from_typed("sha256:"), + Err(Error::InvalidHashStringLength { .. }) + )); + + assert!(matches!( + Hash::from_typed("sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"), + Err(Error::InvalidBase32Hash) + )); + + assert!(matches!( + Hash::from_typed("sha256:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg"), + Err(Error::InvalidBase16Hash(_)) + )); + + assert!(matches!( + Hash::from_typed("md5:invalid"), + Err(Error::UnsupportedHashAlgorithm(alg)) if alg == "md5" + )); +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7aa4fe2 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,20 @@ +#![deny( + asm_sub_register, + deprecated, + missing_abi, + unsafe_code, + unused_macros, + unused_must_use, + unused_unsafe +)] +#![deny(clippy::from_over_into, clippy::needless_question_mark)] +#![cfg_attr( + not(debug_assertions), + deny(unused_imports, unused_mut, unused_variables) +)] + +pub mod error; +pub mod hash; +pub mod nix_store; + +pub use error::{StoreError, StoreResult}; diff --git a/src/nix_store/mod.rs b/src/nix_store/mod.rs new file mode 100644 index 0000000..8383b62 --- /dev/null +++ b/src/nix_store/mod.rs @@ -0,0 +1,294 @@ +#[cfg(test)] +mod tests; + +use std::ffi::OsStr; +#[cfg(target_family = "unix")] +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use lazy_static::lazy_static; +use regex::Regex; +use serde::{de, Deserialize, Serialize}; + +use crate::error::{StoreError, StoreResult}; +use crate::hash::Hash; + +/// Length of the hash in a store path. +pub const STORE_PATH_HASH_LEN: usize = 32; + +/// Regex that matches a store path hash, without anchors. +pub const STORE_PATH_HASH_REGEX_FRAGMENT: &str = "[0123456789abcdfghijklmnpqrsvwxyz]{32}"; + +lazy_static! { + /// Regex for a valid store path hash. + /// + /// This is the path portion of a base name. + static ref STORE_PATH_HASH_REGEX: Regex = { + Regex::new(&format!("^{}$", STORE_PATH_HASH_REGEX_FRAGMENT)).unwrap() + }; + + /// Regex for a valid store base name. + /// + /// A base name consists of two parts: A hash and a human-readable + /// label/name. The format of the hash is described in `StorePathHash`. + /// + /// The human-readable name can only contain the following characters: + /// + /// - A-Za-z0-9 + /// - `+-._?=` + /// + /// See the Nix implementation in `src/libstore/path.cc`. + static ref STORE_BASE_NAME_REGEX: Regex = { + Regex::new(r"^[0123456789abcdfghijklmnpqrsvwxyz]{32}-[A-Za-z0-9+-._?=]+$").unwrap() + }; +} + +/// Information on a valid store path. +#[derive(Debug)] +pub struct ValidPathInfo { + /// The store path. + pub path: StorePath, + + /// Hash of the NAR. + pub nar_hash: Hash, + + /// Size of the NAR. + pub nar_size: u64, + + /// References. + /// + /// This list only contains base names of the paths. + pub references: Vec, + + /// Signatures. + pub sigs: Vec, + + /// Content Address. + pub ca: Option, + + /// Provenance. + pub provenance: Option, +} + +/// A path in a Nix store. +/// +/// This must be a direct child of the store. This path may or +/// may not actually exist. +/// +/// This guarantees that the base name is of valid format. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct StorePath { + /// Base name of the store path. + /// + /// For example, for `/nix/store/ia70ss13m22znbl8khrf2hq72qmh5drr-ruby-2.7.5`, + /// this would be `ia70ss13m22znbl8khrf2hq72qmh5drr-ruby-2.7.5`. + base_name: PathBuf, +} + +impl FromStr for StorePath { + type Err = StoreError; + + fn from_str(s: &str) -> Result { + Self::from_base_name(PathBuf::from(s)) + } +} + +impl std::fmt::Display for StorePath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.base_name.display().fmt(f) + } +} + +impl Serialize for StorePath { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.to_string().as_str()) + } +} + +impl<'de> Deserialize<'de> for StorePath { + fn deserialize(deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + String::deserialize(deserializer).and_then(|base_name_str| { + StorePath::from_str(&base_name_str).map_err(|e| de::Error::custom(e.to_string())) + }) + } +} + +impl StorePath { + /// Creates a StorePath with a base name. + pub fn from_base_name(base_name: PathBuf) -> StoreResult { + let s = base_name + .as_os_str() + .to_str() + .ok_or_else(|| StoreError::InvalidStorePathName { + base_name: base_name.clone(), + reason: "Name contains non-UTF-8 characters", + })?; + + if !STORE_BASE_NAME_REGEX.is_match(s) { + return Err(StoreError::InvalidStorePathName { + base_name, + reason: "Name is of invalid format", + }); + } + + Ok(Self { base_name }) + } + + /// Creates a StorePath with a known valid base name. + /// + /// # Safety + /// + /// The caller must ensure that the name is of a valid format (refer + /// to the documentations for `STORE_BASE_NAME_REGEX`). Other operations + /// with this object will assume it's valid. + #[allow(unsafe_code)] + pub unsafe fn from_base_name_unchecked(base_name: PathBuf) -> Self { + Self { base_name } + } + + /// Gets the hash portion of the store path. + #[cfg(target_family = "unix")] + pub fn to_hash(&self) -> StorePathHash { + // Safety: We have already validated the format of the base name, + // including the hash part. The name is guaranteed valid UTF-8. + #[allow(unsafe_code)] + unsafe { + let s = std::str::from_utf8_unchecked(self.base_name.as_os_str().as_bytes()); + let hash = s[..STORE_PATH_HASH_LEN].to_string(); + StorePathHash::new_unchecked(hash) + } + } + + /// Returns the human-readable name. + #[cfg(target_family = "unix")] + pub fn name(&self) -> String { + // Safety: Already checked + #[allow(unsafe_code)] + unsafe { + let s = std::str::from_utf8_unchecked(self.base_name.as_os_str().as_bytes()); + s[STORE_PATH_HASH_LEN + 1..].to_string() + } + } + + pub fn as_os_str(&self) -> &OsStr { + self.base_name.as_os_str() + } + + /// Returns the bytes of the base name. + #[cfg(target_family = "unix")] + pub fn as_base_name_bytes(&self) -> &[u8] { + self.base_name.as_os_str().as_bytes() + } +} + +/// A fixed-length store path hash. +/// +/// For example, for `/nix/store/ia70ss13m22znbl8khrf2hq72qmh5drr-ruby-2.7.5`, +/// this would be `ia70ss13m22znbl8khrf2hq72qmh5drr`. +/// +/// It must contain exactly 32 "base-32 characters". Nix's special scheme +/// include the following valid characters: "0123456789abcdfghijklmnpqrsvwxyz" +/// ('e', 'o', 'u', 't' are banned). +/// +/// Examples of invalid store path hashes: +/// +/// - "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" +/// - "IA70SS13M22ZNBL8KHRF2HQ72QMH5DRR" +/// - "whatevenisthisthing" +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct StorePathHash(String); + +impl<'de> Deserialize<'de> for StorePathHash { + /// Deserializes a potentially-invalid store path hash. + fn deserialize(deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + use de::Error; + String::deserialize(deserializer) + .and_then(|s| Self::new(&s).map_err(|e| Error::custom(e.to_string()))) + } +} + +impl std::fmt::Display for StorePathHash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl StorePathHash { + /// Creates a store path hash from a string. + pub fn new(hash: &str) -> StoreResult { + let hash = hash.to_owned(); + if hash.len() != STORE_PATH_HASH_LEN { + return Err(StoreError::InvalidStorePathHash { + hash, + reason: "Hash is of invalid length", + }); + } + + if !STORE_PATH_HASH_REGEX.is_match(&hash) { + return Err(StoreError::InvalidStorePathHash { + hash, + reason: "Hash is of invalid format", + }); + } + + Ok(Self(hash)) + } + + /// Creates a store path hash from a string, without checking its validity. + /// + /// # Safety + /// + /// The caller must make sure that it is of expected length and format. + #[allow(unsafe_code)] + pub unsafe fn new_unchecked(hash: String) -> Self { + Self(hash) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Returns the base store name of a path relative to a store root. +pub fn to_base_name(store_dir: &Path, path: &Path) -> StoreResult { + if let Ok(remaining) = path.strip_prefix(store_dir) { + let first = remaining + .iter() + .next() + .ok_or_else(|| StoreError::InvalidStorePath { + path: path.to_owned(), + reason: "Path is store directory itself", + })?; + + if first.len() < STORE_PATH_HASH_LEN { + return Err(StoreError::InvalidStorePath { + path: path.to_owned(), + reason: "Path is too short", + }); + } + + let store_path = StorePath::from_base_name(PathBuf::from(first)).map_err(|_| { + StoreError::InvalidStorePath { + path: path.to_owned(), + reason: "Base name is of invalid format", + } + })?; + + Ok(store_path.base_name) + } else { + Err(StoreError::InvalidStorePath { + path: path.to_owned(), + reason: "Path is not in store directory", + }) + } +} diff --git a/src/nix_store/tests.rs b/src/nix_store/tests.rs new file mode 100644 index 0000000..3633614 --- /dev/null +++ b/src/nix_store/tests.rs @@ -0,0 +1,49 @@ +use super::*; + +const STORE_DIR: &str = "/nix/store"; + +#[test] +fn test_to_base_name() { + let base_name = to_base_name( + Path::new(STORE_DIR), + Path::new("/nix/store/ia70ss13m22znbl8khrf2hq72qmh5drr-ruby-2.7.5"), + ) + .unwrap(); + + assert_eq!( + PathBuf::from("ia70ss13m22znbl8khrf2hq72qmh5drr-ruby-2.7.5"), + base_name + ); +} + +#[test] +fn test_to_base_name_invalid_base_name() { + // Long enough to pass the length check, but not a valid base name + let e = to_base_name( + Path::new(STORE_DIR), + Path::new("/nix/store/ia70ss13m22znbl8khrf2hq72qmh5drr-foo@"), + ) + .unwrap_err(); + + assert!(matches!(e, StoreError::InvalidStorePath { .. })); +} + +#[test] +fn test_to_base_name_too_short() { + let e = to_base_name(Path::new(STORE_DIR), Path::new("/nix/store/foo")).unwrap_err(); + + assert!(matches!( + e, + StoreError::InvalidStorePath { + reason: "Path is too short", + .. + } + )); +} + +#[test] +fn test_to_base_name_not_in_store() { + let e = to_base_name(Path::new(STORE_DIR), Path::new("/tmp/foo")).unwrap_err(); + + assert!(matches!(e, StoreError::InvalidStorePath { .. })); +}