From 47ef01d2f686458bc15b4e4c14f23ccc6da66795 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 22:14:07 +0000 Subject: [PATCH 1/4] Modernize: clap 4, edition 2024, tests, and CI - Upgrade clap 3.2 -> 4.6 (derive `#[command]`/`#[arg]` syntax) and refresh Cargo.lock. - Move to edition 2024 with rust-version 1.85, add package metadata and workspace lints (clippy pedantic, forbid unsafe). - Split into lib + thin bin so logic is unit-testable; handle broken pipes and write errors with proper exit codes. - Reject `--count 0` (previously exited 0 silently printing nothing). - Fix clippy::empty_line_after_outer_attr (TODO block sat between the derive attributes and the struct) and apply rustfmt. - Add 12 unit tests and 7 end-to-end tests (assert_cmd). - Add GitHub Actions CI (fmt, clippy, test on Linux/macOS/Windows, MSRV) and Dependabot for cargo and actions. - Rewrite README with build, dev, and layout instructions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UdKneRqUnsbv1t7NyvcT68 --- .github/dependabot.yml | 10 ++ .github/workflows/ci.yml | 48 ++++++ Cargo.lock | 339 +++++++++++++++++++++++++++------------ Cargo.toml | 25 ++- README.md | 39 ++++- src/lib.rs | 156 ++++++++++++++++++ src/main.rs | 38 ++--- tests/cli.rs | 69 ++++++++ 8 files changed, 587 insertions(+), 137 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 src/lib.rs create mode 100644 tests/cli.rs 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..6e01947 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings + +jobs: + lint: + name: Format & Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all --check + - run: cargo clippy --all-targets --locked + + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --locked + + msrv: + name: MSRV (1.85) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@1.85 + - uses: Swatinem/rust-cache@v2 + - run: cargo test --locked diff --git a/Cargo.lock b/Cargo.lock index b39da7c..a95b6ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,55 +1,127 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] -name = "atty" -version = "0.2.14" +name = "aho-corasick" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ - "hermit-abi", + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", "libc", - "winapi", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", ] [[package]] name = "autocfg" -version = "1.1.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] -name = "bitflags" -version = "1.3.2" +name = "bstr" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] [[package]] name = "clap" -version = "3.2.22" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86447ad904c7fb335a790c9d7fe3d0d971dc523b8ccd1561a520de9a85302750" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ - "atty", - "bitflags", + "clap_builder", "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", "clap_lex", - "indexmap", - "once_cell", "strsim", - "termcolor", - "textwrap", ] [[package]] name = "clap_derive" -version = "3.2.18" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0c8bce528c4be4da13ea6fead8965e95b6073585a2f05204bd8f4119f82a65" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", - "proc-macro-error", "proc-macro2", "quote", "syn", @@ -57,182 +129,237 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.2.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" -dependencies = [ - "os_str_bytes", -] +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] -name = "hashbrown" -version = "0.12.3" +name = "colorchoice" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "heck" +name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" [[package]] -name = "hermit-abi" -version = "0.1.19" +name = "float-cmp" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" dependencies = [ - "libc", + "num-traits", ] [[package]] -name = "indexmap" -version = "1.9.1" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" -dependencies = [ - "autocfg", - "hashbrown", -] +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "libc" -version = "0.2.133" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f80d65747a3e43d1596c7c5492d95d5edddaabd45a7fcdb02b95f644164966" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] -name = "once_cell" -version = "1.15.0" +name = "memchr" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e82dad04139b71a90c080c8463fe0dc7902db5192d939bd0950f074d014339e1" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "os_str_bytes" -version = "6.3.0" +name = "normalize-line-endings" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" [[package]] -name = "proc-macro-error" -version = "1.0.4" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn", - "version_check", + "autocfg", ] [[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ - "proc-macro2", - "quote", - "version_check", + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", ] [[package]] name = "proc-macro2" -version = "1.0.43" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a2ca2c61bc9f3d74d2886294ab7b9853abd9c1ad903a3ac7815c58989bb7bab" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.21" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] [[package]] -name = "strsim" -version = "0.10.0" +name = "regex" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] [[package]] -name = "syn" -version = "1.0.100" +name = "regex-automata" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52205623b1b0f064a4e71182c3b18ae902267282930c6d5462c91b859668426e" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +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 = "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", - "unicode-ident", + "syn", ] [[package]] -name = "termcolor" -version = "1.1.3" +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ - "winapi-util", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "textwrap" -version = "0.15.1" +name = "termtree" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "949517c0cf1bf4ee812e2e07e08ab448e3ae0d23472aee8a06c985f0c8815b16" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "trustgraph-rust-cli" version = "0.1.0" dependencies = [ + "assert_cmd", "clap", + "predicates", ] [[package]] name = "unicode-ident" -version = "1.0.4" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcc811dc4066ac62f84f11307873c4850cb653bfa9b1719cee2bd2204a4bc5dd" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] -name = "version_check" -version = "0.9.4" +name = "utf8parse" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] -name = "winapi" -version = "0.3.9" +name = "wait-timeout" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "libc", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "winapi-util" -version = "0.1.5" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "winapi", + "windows-link", ] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/Cargo.toml b/Cargo.toml index c91d4ea..137946c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,26 @@ [package] name = "trustgraph-rust-cli" version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +edition = "2024" +rust-version = "1.85" +description = "Command line interface for Trust Graph" +repository = "https://github.com/trustgraph/trustgraph-rust-cli" +readme = "README.md" +keywords = ["trust", "reputation", "trustgraph", "cli"] +categories = ["command-line-utilities"] +publish = false [dependencies] -clap = { version = "3.2.22", features = ["derive"] } +clap = { version = "4.6", features = ["derive"] } + +[dev-dependencies] +assert_cmd = "2.2" +predicates = "3.1" + +[lints.rust] +unsafe_code = "forbid" +missing_debug_implementations = "warn" + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } diff --git a/README.md b/README.md index 634a43d..85db777 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,38 @@ -# First draft of a Trustgraph CLI +# Trust Graph CLI (Rust) -to build it do +[![CI](https://github.com/trustgraph/trustgraph-rust-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/trustgraph/trustgraph-rust-cli/actions/workflows/ci.yml) -`cargo build` +Early-stage command line interface for [Trust Graph](https://github.com/trustgraph/trustgraph). +It is intended to become the core that other Trust Graph components build on; +right now it is a scaffold with a placeholder `--name` command. -To run it do +## Requirements -` +- Rust 1.85 or newer (edition 2024). Install via [rustup](https://rustup.rs). + +## Build and run + +```sh +cargo build ./target/debug/trustgraph-rust-cli --name HI -` +# or +cargo run -- --name HI --count 3 +``` + +Run `trustgraph-rust-cli --help` for all options. + +## Development + +The same checks run in CI: + +```sh +cargo fmt --all --check +cargo clippy --all-targets -- -D warnings +cargo test +``` + +Layout: + +- `src/lib.rs` – argument definitions and command logic, with unit tests +- `src/main.rs` – thin binary entry point (exit codes, stdout handling) +- `tests/cli.rs` – end-to-end tests that run the compiled binary diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..5612e5c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,156 @@ +//! Core of the Trust Graph command line interface. +//! +//! Argument parsing and command logic live here, separate from `main.rs`, so +//! that they can be unit tested and reused by other components. + +use std::io::{self, Write}; + +use clap::Parser; + +/// Simple program to greet a person +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +#[command(author, version, about, long_about = None)] +pub struct Cli { + /// Name of the person to greet + #[arg(short, long)] + pub name: String, + + /// Number of times to greet + #[arg(short, long, default_value_t = 1, value_parser = clap::value_parser!(u8).range(1..))] + pub count: u8, +} + +/// Runs the CLI with already-parsed arguments, writing output to `out`. +/// +/// # Errors +/// +/// Returns any I/O error raised while writing to `out`. +pub fn run(cli: &Cli, out: &mut impl Write) -> io::Result<()> { + for _ in 0..cli.count { + writeln!(out, "{}", greeting(&cli.name))?; + } + Ok(()) +} + +/// Builds the greeting for `name`. +#[must_use] +pub fn greeting(name: &str) -> String { + format!("Hello {name}!") +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + fn parse(args: &[&str]) -> Result { + Cli::try_parse_from(std::iter::once("trustgraph-rust-cli").chain(args.iter().copied())) + } + + fn output(cli: &Cli) -> String { + let mut buf = Vec::new(); + run(cli, &mut buf).expect("writing to a Vec cannot fail"); + String::from_utf8(buf).expect("output is UTF-8") + } + + #[test] + fn command_definition_is_valid() { + Cli::command().debug_assert(); + } + + #[test] + fn greeting_formats_name() { + assert_eq!(greeting("Ada"), "Hello Ada!"); + } + + #[test] + fn greeting_preserves_unicode() { + assert_eq!(greeting("Ŧrust"), "Hello Ŧrust!"); + } + + #[test] + fn count_defaults_to_one() { + let cli = parse(&["--name", "Ada"]).unwrap(); + assert_eq!( + cli, + Cli { + name: "Ada".into(), + count: 1 + } + ); + } + + #[test] + fn short_flags_are_accepted() { + let cli = parse(&["-n", "Ada", "-c", "3"]).unwrap(); + assert_eq!( + cli, + Cli { + name: "Ada".into(), + count: 3 + } + ); + } + + #[test] + fn name_is_required() { + let err = parse(&[]).unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); + } + + #[test] + fn count_of_zero_is_rejected() { + let err = parse(&["--name", "Ada", "--count", "0"]).unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation); + } + + #[test] + fn count_above_u8_range_is_rejected() { + let err = parse(&["--name", "Ada", "--count", "256"]).unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation); + } + + #[test] + fn count_must_be_numeric() { + let err = parse(&["--name", "Ada", "--count", "many"]).unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation); + } + + #[test] + fn run_prints_one_line_per_count() { + let cli = Cli { + name: "Ada".into(), + count: 3, + }; + assert_eq!(output(&cli), "Hello Ada!\nHello Ada!\nHello Ada!\n"); + } + + #[test] + fn run_handles_max_count() { + let cli = Cli { + name: "Ada".into(), + count: u8::MAX, + }; + assert_eq!(output(&cli).lines().count(), usize::from(u8::MAX)); + } + + #[test] + fn run_propagates_write_errors() { + struct Failing; + impl Write for Failing { + fn write(&mut self, _: &[u8]) -> io::Result { + Err(io::Error::from(io::ErrorKind::BrokenPipe)) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + let cli = Cli { + name: "Ada".into(), + count: 1, + }; + let err = run(&cli, &mut Failing).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::BrokenPipe); + } +} diff --git a/src/main.rs b/src/main.rs index 7117689..07132c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,8 @@ -use clap::Parser; - -/// Simple program to greet a person -#[derive(Parser, Debug)] -#[clap(author, version, about, long_about = None)] +use std::io::{self, Write}; +use std::process::ExitCode; +use clap::Parser; +use trustgraph_rust_cli::{Cli, run}; /* TODO next: @@ -21,20 +20,17 @@ use clap::Parser; */ -struct Args { - /// Name of the person to greet - #[clap(short, long, value_parser)] - name: String, - - /// Number of times to greet - #[clap(short, long, value_parser, default_value_t = 1)] - count: u8, -} - -fn main() { - let args = Args::parse(); - - for _ in 0..args.count { - println!("Hello {}!", args.name) - } +fn main() -> ExitCode { + let cli = Cli::parse(); + let mut stdout = io::stdout().lock(); + + match run(&cli, &mut stdout).and_then(|()| stdout.flush()) { + Ok(()) => ExitCode::SUCCESS, + // Being piped into `head` or similar is not an error. + Err(err) if err.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS, + Err(err) => { + eprintln!("error: {err}"); + ExitCode::FAILURE + } + } } diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..5893c8c --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,69 @@ +//! End-to-end tests that run the compiled binary. + +use assert_cmd::Command; +use predicates::prelude::*; + +fn cli() -> Command { + Command::cargo_bin("trustgraph-rust-cli").expect("binary is built") +} + +#[test] +fn greets_once_by_default() { + cli() + .args(["--name", "HI"]) + .assert() + .success() + .stdout("Hello HI!\n") + .stderr(""); +} + +#[test] +fn greets_count_times() { + cli() + .args(["--name", "Ada", "--count", "2"]) + .assert() + .success() + .stdout("Hello Ada!\nHello Ada!\n"); +} + +#[test] +fn missing_name_is_a_usage_error() { + cli() + .assert() + .code(2) + .stdout("") + .stderr(predicate::str::contains("--name ")); +} + +#[test] +fn zero_count_is_a_usage_error() { + cli() + .args(["--name", "Ada", "--count", "0"]) + .assert() + .code(2) + .stderr(predicate::str::contains("invalid value '0'")); +} + +#[test] +fn unknown_flag_is_a_usage_error() { + cli() + .args(["--name", "Ada", "--bogus"]) + .assert() + .code(2) + .stderr(predicate::str::contains("unexpected argument '--bogus'")); +} + +#[test] +fn help_lists_options() { + cli().arg("--help").assert().success().stdout( + predicate::str::contains("--name ").and(predicate::str::contains("--count ")), + ); +} + +#[test] +fn version_matches_cargo_manifest() { + cli().arg("--version").assert().success().stdout(format!( + "trustgraph-rust-cli {}\n", + env!("CARGO_PKG_VERSION") + )); +} From e3745c583b15158a92c212c9fb6de2c761e5ec06 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 22:32:43 +0000 Subject: [PATCH 2/4] Rebuild as `trust`: the Trust Graph CLI and protocol library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the placeholder greeter with a working implementation of the Trust Graph protocol, split into a reusable library and a thin CLI. Library (`crates/trustgraph`): - TrustAtom (source, target, content, value, timestamp, extra) with validation, RFC 8785 canonical JSON, and Qm... SHA2-256 content IDs - Value: exact decimal in -1..=1, nine significant figures, matching trustgraph-holochain's normalization (its test tables are ported) - Ed25519 keys as did:key; Multikey secret/public encodings - Signed atoms as W3C VC 2.0 with eddsa-jcs-2022 Data Integrity proofs, verified against the W3C spec test vectors; verification also checks the issuer is the signer - Holochain link-tag codec (Ŧ→ / Ŧ↩, NUL-separated chunks, buckets) - Agent Lens / Trust Cascade: bounded strongest-path trust with decay, topic filters, non-transitive distrust, and rollup atoms - Append-only NDJSON store that verifies credentials on the way in CLI (`crates/trust-cli`, binary `trust`): key new|list|show|export|import, atom, sign, verify, id, convert, add, query, lens, info, completions. JSON/NDJSON in and out so commands compose with pipes; keys stored 0600 under $TRUST_HOME. Also: cargo workspace, Apache-2.0 LICENSE (matching the other trustgraph repos), CI adds rustdoc, README rewritten. Tests: 67 unit, 9 property (proptest), 12 end-to-end, 6 CLI unit, 2 doctests. Clippy pedantic clean; MSRV 1.85 verified. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UdKneRqUnsbv1t7NyvcT68 --- .github/workflows/ci.yml | 10 +- Cargo.lock | 961 +++++++++++++++++- Cargo.toml | 50 +- LICENSE | 202 ++++ README.md | 157 ++- clippy.toml | 2 + crates/trust-cli/Cargo.toml | 38 + crates/trust-cli/src/cli.rs | 339 ++++++ crates/trust-cli/src/commands.rs | 261 +++++ crates/trust-cli/src/home.rs | 162 +++ crates/trust-cli/src/io.rs | 67 ++ crates/trust-cli/src/main.rs | 35 + crates/trust-cli/tests/cli.rs | 266 +++++ crates/trustgraph/Cargo.toml | 34 + crates/trustgraph/README.md | 12 + crates/trustgraph/src/atom.rs | 245 +++++ crates/trustgraph/src/canonical.rs | 35 + crates/trustgraph/src/credential.rs | 430 ++++++++ crates/trustgraph/src/error.rs | 51 + crates/trustgraph/src/graph.rs | 446 ++++++++ crates/trustgraph/src/holochain.rs | 285 ++++++ crates/trustgraph/src/id.rs | 96 ++ crates/trustgraph/src/keys.rs | 287 ++++++ crates/trustgraph/src/lib.rs | 46 + crates/trustgraph/src/store.rs | 249 +++++ crates/trustgraph/src/value.rs | 358 +++++++ .../tests/properties.proptest-regressions | 7 + crates/trustgraph/tests/properties.rs | 139 +++ rustfmt.toml | 2 + src/lib.rs | 156 --- src/main.rs | 36 - tests/cli.rs | 69 -- 32 files changed, 5233 insertions(+), 300 deletions(-) create mode 100644 LICENSE create mode 100644 clippy.toml create mode 100644 crates/trust-cli/Cargo.toml create mode 100644 crates/trust-cli/src/cli.rs create mode 100644 crates/trust-cli/src/commands.rs create mode 100644 crates/trust-cli/src/home.rs create mode 100644 crates/trust-cli/src/io.rs create mode 100644 crates/trust-cli/src/main.rs create mode 100644 crates/trust-cli/tests/cli.rs create mode 100644 crates/trustgraph/Cargo.toml create mode 100644 crates/trustgraph/README.md create mode 100644 crates/trustgraph/src/atom.rs create mode 100644 crates/trustgraph/src/canonical.rs create mode 100644 crates/trustgraph/src/credential.rs create mode 100644 crates/trustgraph/src/error.rs create mode 100644 crates/trustgraph/src/graph.rs create mode 100644 crates/trustgraph/src/holochain.rs create mode 100644 crates/trustgraph/src/id.rs create mode 100644 crates/trustgraph/src/keys.rs create mode 100644 crates/trustgraph/src/lib.rs create mode 100644 crates/trustgraph/src/store.rs create mode 100644 crates/trustgraph/src/value.rs create mode 100644 crates/trustgraph/tests/properties.proptest-regressions create mode 100644 crates/trustgraph/tests/properties.rs create mode 100644 rustfmt.toml delete mode 100644 src/lib.rs delete mode 100644 src/main.rs delete mode 100644 tests/cli.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e01947..05f659d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,11 @@ permissions: env: CARGO_TERM_COLOR: always RUSTFLAGS: -D warnings + RUSTDOCFLAGS: -D warnings jobs: lint: - name: Format & Clippy + name: Format, Clippy & Docs runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -23,7 +24,8 @@ jobs: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - run: cargo fmt --all --check - - run: cargo clippy --all-targets --locked + - run: cargo clippy --workspace --all-targets --locked + - run: cargo doc --workspace --no-deps --locked test: name: Test (${{ matrix.os }}) @@ -36,7 +38,7 @@ jobs: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - run: cargo test --locked + - run: cargo test --workspace --locked msrv: name: MSRV (1.85) @@ -45,4 +47,4 @@ jobs: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@1.85 - uses: Swatinem/rust-cache@v2 - - run: cargo test --locked + - run: cargo test --workspace --locked diff --git a/Cargo.lock b/Cargo.lock index a95b6ef..e531926 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "assert_cmd" version = "2.2.2" @@ -82,6 +94,75 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.13.1" @@ -93,6 +174,30 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "clap" version = "4.6.7" @@ -113,6 +218,16 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size", +] + +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", ] [[package]] @@ -124,7 +239,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -139,12 +254,176 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "float-cmp" version = "0.10.0" @@ -154,24 +433,176 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "libc" version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "memchr" version = "2.8.3" @@ -193,12 +624,48 @@ dependencies = [ "autocfg", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.4" @@ -229,6 +696,15 @@ dependencies = [ "termtree", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -238,6 +714,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.47" @@ -247,6 +748,96 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.13.1" @@ -276,6 +867,85 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.8", + "rand 0.9.5", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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" @@ -293,15 +963,74 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.6" @@ -313,6 +1042,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys", +] + [[package]] name = "termtree" version = "0.5.1" @@ -320,14 +1072,109 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] -name = "trustgraph-rust-cli" +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "trust-cli" version = "0.1.0" dependencies = [ + "anyhow", "assert_cmd", "clap", + "clap_complete", + "directories", + "jiff", "predicates", + "serde", + "serde_json", + "tempfile", + "trustgraph", ] +[[package]] +name = "trustgraph" +version = "0.1.0" +dependencies = [ + "bs58", + "ed25519-dalek", + "getrandom 0.4.3", + "hex", + "jiff", + "proptest", + "rust_decimal", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sha2", + "tempfile", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -349,6 +1196,67 @@ dependencies = [ "libc", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -363,3 +1271,50 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[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 index 137946c..5b2cbd4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,26 +1,52 @@ -[package] -name = "trustgraph-rust-cli" +[workspace] +resolver = "3" +members = ["crates/trustgraph", "crates/trust-cli"] + +[workspace.package] version = "0.1.0" edition = "2024" rust-version = "1.85" -description = "Command line interface for Trust Graph" +license = "Apache-2.0" repository = "https://github.com/trustgraph/trustgraph-rust-cli" -readme = "README.md" -keywords = ["trust", "reputation", "trustgraph", "cli"] -categories = ["command-line-utilities"] -publish = false +homepage = "https://trustgraph.net" +authors = ["Trust Graph contributors"] + +[workspace.dependencies] +trustgraph = { path = "crates/trustgraph", version = "0.1.0" } -[dependencies] -clap = { version = "4.6", features = ["derive"] } +anyhow = "1.0" +bs58 = "0.5" +clap = { version = "4.6", features = ["derive", "env", "wrap_help"] } +clap_complete = "4.6" +directories = "6.0" +ed25519-dalek = "3.0" +getrandom = "0.4" +jiff = { version = "0.2", features = ["serde"] } +rust_decimal = "1.43" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +serde_json_canonicalizer = "0.3" +sha2 = "0.11" +thiserror = "2.0" -[dev-dependencies] assert_cmd = "2.2" +hex = "0.4" predicates = "3.1" +proptest = "1.11" +tempfile = "3.27" -[lints.rust] +[workspace.lints.rust] unsafe_code = "forbid" missing_debug_implementations = "warn" +missing_docs = "warn" -[lints.clippy] +[workspace.lints.clippy] all = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 } +unwrap_used = "warn" +module_name_repetitions = "allow" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 85db777..6407548 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,155 @@ -# Trust Graph CLI (Rust) +# ŦRUSŦ GRΔPH CLI [![CI](https://github.com/trustgraph/trustgraph-rust-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/trustgraph/trustgraph-rust-cli/actions/workflows/ci.yml) +[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -Early-stage command line interface for [Trust Graph](https://github.com/trustgraph/trustgraph). -It is intended to become the core that other Trust Graph components build on; -right now it is a scaffold with a placeholder `--name` command. +`trust` is the command line interface and Rust library for +[Trust Graph](https://trustgraph.net), an open protocol for sourcing and +rendering trust relationships. -## Requirements +- **Trust Atoms.** Every rating, vouch or review is one small statement: + *source* trusts *target*, about *content*, this much (`-1` to `1`). +- **Self-sovereign.** You sign with your own key (a `did:key` identity). Signed + atoms are standard [W3C Verifiable Credentials 2.0](https://www.w3.org/TR/vc-data-model-2.0/) + using the [`eddsa-jcs-2022`](https://www.w3.org/TR/vc-di-eddsa/) cryptosuite, + so any VC library can verify them. +- **Agent-centric.** There is no global score. You see the world through your + **Agent Lens**: your own ratings, plus those of the people you trust, cascading + outward with decreasing weight (the **Trust Cascade**). +- **Interoperable.** Reads and writes the link-tag format of + [trustgraph-holochain](https://github.com/trustgraph/trustgraph-holochain). +- **Unix-friendly.** JSON in, JSON out, one item per line. Commands pipe into + each other and into `jq`. +- **Offline.** Nothing needs a server or a network connection. -- Rust 1.85 or newer (edition 2024). Install via [rustup](https://rustup.rs). +> Status: early but solid. The data formats may still change before 1.0. See the +> [roadmap](doc/plan/README.md). -## Build and run +## Install + +Requires [Rust](https://rustup.rs) 1.85 or newer. ```sh -cargo build -./target/debug/trustgraph-rust-cli --name HI -# or -cargo run -- --name HI --count 3 +cargo install --git https://github.com/trustgraph/trustgraph-rust-cli trust-cli ``` -Run `trustgraph-rust-cli --help` for all options. +Or from a checkout: `cargo install --path crates/trust-cli`. -## Development +## Quick start + +```sh +# Create your identity. +trust key new +# {"name":"default","did":"did:key:z6Mk…"} + +# Rate something, sign it, and store it. +trust atom --target https://sushi.example --content sushi --value 0.9 --sign | trust add + +# Vouch for a friend's taste in sushi (4 out of 5). +trust atom --target did:key:z6MkFriend… --content sushi --value 4/5 --sign | trust add + +# Bring in signed atoms from others (they are verified on the way in). +trust add friends-atoms.ndjson + +# What does the sushi world look like from where you stand? +trust lens --topic sushi +# {"target":"https://sushi.example","score":0.9,"confidence":1.0,"hops":1,"raters":1} +# {"target":"https://other-sushi.example","score":0.75,"confidence":0.4,"hops":2,"raters":1} + +# Cache your lens as signed "rollup" atoms that others can build on. +trust lens --topic sushi --rollup | trust sign | trust add +``` + +## Commands + +| Command | What it does | +|---|---| +| `trust key new\|list\|show\|export\|import` | Manage identities (Ed25519, `did:key`) | +| `trust atom -t TARGET [-v VALUE] [-c CONTENT] [--sign]` | Create a Trust Atom. `VALUE` is `-1..=1` or `RATING/BEST` such as `4/5` | +| `trust sign [FILE]` | Sign atoms as Verifiable Credentials | +| `trust verify [FILE]` | Verify credentials. Exits 1 if any are invalid | +| `trust id [FILE]` | Print content IDs (`Qm…` SHA2-256 multihashes) | +| `trust convert --to atom\|credential\|canonical\|holochain [FILE]` | Convert between formats | +| `trust add [FILE]` | Add atoms or signed credentials to the local store | +| `trust query [--source] [--target] [--topic] [--signed-only]` | Search the local store | +| `trust lens [AGENT] [--topic] [--depth] [--decay] [--rollup]` | View the graph through an agent's lens | +| `trust info` | Show where keys and data live | +| `trust completions SHELL` | Shell completions | + +Input is read from `FILE` or stdin and may be a single JSON document, NDJSON, +or concatenated JSON. Add `--pretty` to any command for readable output. Keys +and the store live in the platform data directory, or in `$TRUST_HOME`. +`$TRUST_KEY` selects the key. + +## Data model + +A Trust Atom: -The same checks run in CI: +```json +{ + "source": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "target": "https://sushi.example", + "content": "sushi", + "value": "0.9", + "timestamp": "2026-10-05T12:00:00Z", + "extra": { "lang": "en" } +} +``` + +Only `source` and `target` are required. `value` is an exact decimal in +`-1..=1`, rounded to nine significant figures. It is written as a string so it +hashes identically everywhere, but numbers are accepted on input. + +Signed, it becomes a Verifiable Credential: `source` is the `issuer`, +`target` is the `credentialSubject.id`, and `timestamp` is `validFrom`. +Verification checks that the issuer is the key that signed it. + +### How the lens works + +From the agent's point of view (weight 1), trust flows along *positive* +ratings. Each hop multiplies by the rating, and each hop after the first also +multiplies by `--decay` (default 0.5). A target's score is the agent's own +rating if there is one. Otherwise it is the average of the ratings by agents +the agent can reach, weighted by how much the agent trusts each of them. +Distrust is shown but never passed along. With `--topic`, only trust about +that topic is followed. Details are in [`graph.rs`](crates/trustgraph/src/graph.rs). + +## Library + +The protocol lives in the [`trustgraph`](crates/trustgraph) crate, which has no +CLI dependencies so other components can embed it: + +```rust +use trustgraph::{credential, Keypair, LensOptions, TrustAtom, TrustGraph}; + +let alice = Keypair::generate()?; +let atom = TrustAtom::new(alice.did().to_string(), "https://sushi.example") + .with_content("sushi") + .with_value("0.9".parse()?); +let signed = credential::sign_atom(&atom, &alice, jiff::Timestamp::now())?; +assert_eq!(credential::verify_atom(&signed)?, atom); + +let graph: TrustGraph = [atom].iter().collect(); +let view = graph.lens(alice.did().as_str(), &LensOptions::default()); +``` + +## Development ```sh +cargo test --workspace # unit, property, end-to-end, and doc tests +cargo clippy --workspace --all-targets -- -D warnings cargo fmt --all --check -cargo clippy --all-targets -- -D warnings -cargo test ``` -Layout: +| Path | Contents | +|---|---| +| `crates/trustgraph/` | Protocol library: atoms, values, IDs, keys, credentials, Holochain tags, graph, store | +| `crates/trust-cli/` | The `trust` binary | +| `doc/plan/` | Roadmap | + +The signing code is checked against the W3C `eddsa-jcs-2022` test vectors, and +the Holochain encoding against `trustgraph-holochain`'s own test cases. + +## License -- `src/lib.rs` – argument definitions and command logic, with unit tests -- `src/main.rs` – thin binary entry point (exit codes, stdout handling) -- `tests/cli.rs` – end-to-end tests that run the compiled binary +[Apache-2.0](LICENSE) diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..0358cdb --- /dev/null +++ b/clippy.toml @@ -0,0 +1,2 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true diff --git a/crates/trust-cli/Cargo.toml b/crates/trust-cli/Cargo.toml new file mode 100644 index 0000000..4fc1a3f --- /dev/null +++ b/crates/trust-cli/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "trust-cli" +description = "`trust`: the Trust Graph command line interface" +readme = "../../README.md" +keywords = ["trust", "reputation", "did", "cli", "trustgraph"] +categories = ["command-line-utilities", "cryptography"] +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +publish = false + +[[bin]] +name = "trust" +path = "src/main.rs" + +[dependencies] +trustgraph.workspace = true + +anyhow.workspace = true +clap.workspace = true +clap_complete.workspace = true +directories.workspace = true +jiff.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +serde_json.workspace = true +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/trust-cli/src/cli.rs b/crates/trust-cli/src/cli.rs new file mode 100644 index 0000000..bfe6096 --- /dev/null +++ b/crates/trust-cli/src/cli.rs @@ -0,0 +1,339 @@ +//! Command line definitions. + +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand, ValueEnum}; +use clap_complete::Shell; +use jiff::Timestamp; +use trustgraph::Value; +use trustgraph::value::Decimal; + +/// trust: sign, share and explore trust relationships. +/// +/// Trust Graph is an open protocol for trust and reputation. Every rating is +/// a Trust Atom ("source trusts target, about content, this much"), signed +/// with your own key, and nobody's view of the world is global: you see it +/// through your own Agent Lens. +#[derive(Debug, Parser)] +#[command(name = "trust", version, about, long_about, after_help = EXAMPLES)] +pub struct Cli { + /// Directory for keys and the local store [default: platform data dir] + #[arg(long, global = true, env = "TRUST_HOME", hide_env_values = true, value_name = "DIR")] + pub home: Option, + + /// Pretty-print JSON output + #[arg(long, global = true)] + pub pretty: bool, + + #[command(subcommand)] + pub command: Command, +} + +const EXAMPLES: &str = "\ +Examples: + trust key new + trust atom --target https://example.com/sushi-bar --content sushi --value 0.9 --sign | trust add + trust query --topic sushi + trust lens --topic sushi + trust lens --rollup | trust sign | trust add"; + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Manage your identities (Ed25519 keys, shown as did:key DIDs) + #[command(subcommand)] + Key(KeyCommand), + + /// Create a Trust Atom: SOURCE trusts TARGET, about CONTENT, VALUE much + Atom(AtomArgs), + + /// Sign Trust Atoms (from a file or stdin) as Verifiable Credentials + Sign(SignArgs), + + /// Verify signed Trust Atom credentials; exits 1 if any are invalid + Verify(InputArgs), + + /// Print the content ID (a Qm… multihash) of atoms or credentials + Id(InputArgs), + + /// Convert atoms and credentials between formats + Convert(ConvertArgs), + + /// Add atoms or signed credentials to the local store + Add(InputArgs), + + /// Search the local store + Query(QueryArgs), + + /// See the world through an agent's lens: their own ratings, plus the + /// ratings of the agents they trust, cascading outward + Lens(LensArgs), + + /// Show where keys and data are kept + Info, + + /// Generate shell completions + Completions { + /// The shell to generate completions for + shell: Shell, + }, +} + +#[derive(Debug, Subcommand)] +pub enum KeyCommand { + /// Generate a new identity + New { + /// Name for the key + #[arg(default_value = "default", value_parser = parse_key_name)] + name: String, + /// Replace an existing key with the same name + #[arg(long)] + force: bool, + }, + /// List identities + List, + /// Show an identity's DID and public key + Show { + /// Name of the key + #[arg(default_value = "default", value_parser = parse_key_name)] + name: String, + }, + /// Print a key's secret (secretKeyMultibase). Keep it safe! + Export { + /// Name of the key + #[arg(default_value = "default", value_parser = parse_key_name)] + name: String, + }, + /// Import a secret key (secretKeyMultibase) from stdin + Import { + /// Name for the key + #[arg(default_value = "default", value_parser = parse_key_name)] + name: String, + /// Replace an existing key with the same name + #[arg(long)] + force: bool, + }, +} + +#[derive(Debug, Args)] +pub struct KeyArg { + /// Name of the key to use + #[arg(long, env = "TRUST_KEY", default_value = "default", value_parser = parse_key_name)] + pub key: String, +} + +#[derive(Debug, Args)] +pub struct AtomArgs { + /// Who or what is being rated: a DID, URL, or other identifier + #[arg(short, long)] + pub target: String, + + /// Trust value from -1 (distrust) through 0 (neutral) to 1 (full trust). + /// Ratings on other scales can be given as RATING/BEST (e.g. 4/5 stars) + #[arg(short, long, value_parser = parse_value, allow_hyphen_values = true)] + pub value: Option, + + /// What the trust is about: a topic or comma-separated tags + #[arg(short, long)] + pub content: Option, + + /// Who is rating [default: the DID of --key] + #[arg(short, long)] + pub source: Option, + + /// Extra fields, as KEY=VALUE (repeatable) + #[arg(short, long, value_parser = parse_key_value, value_name = "KEY=VALUE")] + pub extra: Vec<(String, String)>, + + /// Timestamp (RFC 3339) [default: now] + #[arg(long, conflicts_with = "no_timestamp")] + pub timestamp: Option, + + /// Leave out the timestamp + #[arg(long)] + pub no_timestamp: bool, + + /// Sign the atom, producing a Verifiable Credential + #[arg(long)] + pub sign: bool, + + #[command(flatten)] + pub key: KeyArg, +} + +#[derive(Debug, Args)] +pub struct InputArgs { + /// JSON or NDJSON input file; `-` or nothing reads stdin + #[arg(value_name = "FILE")] + pub input: Option, +} + +#[derive(Debug, Args)] +pub struct SignArgs { + #[command(flatten)] + pub input: InputArgs, + + #[command(flatten)] + pub key: KeyArg, + + /// Proof creation time (RFC 3339) [default: now] + #[arg(long)] + pub created: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum Format { + /// A plain Trust Atom + Atom, + /// An unsigned W3C Verifiable Credential + Credential, + /// The atom's canonical JSON (RFC 8785), exactly as hashed + Canonical, + /// Holochain link tags, as used by trustgraph-holochain + Holochain, +} + +#[derive(Debug, Args)] +pub struct ConvertArgs { + /// Output format + #[arg(long, value_enum)] + pub to: Format, + + /// Bucket digits for Holochain tags [default: random] + #[arg(long, value_name = "DIGITS")] + pub bucket: Option, + + #[command(flatten)] + pub input: InputArgs, +} + +#[derive(Debug, Args)] +pub struct QueryArgs { + /// Only atoms from this source + #[arg(long)] + pub source: Option, + + /// Only atoms about this target + #[arg(long)] + pub target: Option, + + /// Only atoms about this topic (matches whole content or any tag) + #[arg(long)] + pub topic: Option, + + /// Only atoms whose content starts with this + #[arg(long)] + pub content_prefix: Option, + + /// Only signed atoms + #[arg(long)] + pub signed_only: bool, + + /// Print full records (ID, atom, and credential) instead of atoms + #[arg(long)] + pub full: bool, +} + +#[derive(Debug, Args)] +pub struct LensArgs { + /// Whose lens to look through [default: the DID of --key] + pub agent: Option, + + /// Maximum hops from the agent to a rating (1 = direct ratings only) + #[arg(long, default_value_t = 3, value_parser = clap::value_parser!(u8).range(1..=10))] + pub depth: u8, + + /// How much each hop after the first counts (0..=1) + #[arg(long, default_value_t = 0.5, value_parser = parse_decay)] + pub decay: f64, + + /// Only follow and score trust about this topic + #[arg(long)] + pub topic: Option, + + /// Only use signed atoms + #[arg(long)] + pub signed_only: bool, + + /// Show at most this many results + #[arg(long)] + pub limit: Option, + + /// Print rollup atoms (cached trust from the agent's view) instead of + /// scores; pipe them to `trust sign` to publish them + #[arg(long)] + pub rollup: bool, + + #[command(flatten)] + pub key: KeyArg, +} + +fn parse_value(s: &str) -> Result { + if let Some((rating, best)) = s.split_once('/') { + let parse = |x: &str| x.trim().parse::().map_err(|_| format!("`{x}` is not a number")); + let (rating, best) = (parse(rating)?, parse(best)?); + return Value::from_scale(rating, Decimal::ZERO, best).map_err(|e| e.to_string()); + } + s.parse().map_err(|e: trustgraph::Error| e.to_string()) +} + +fn parse_decay(s: &str) -> Result { + let decay: f64 = s.parse().map_err(|_| format!("`{s}` is not a number"))?; + if (0.0..=1.0).contains(&decay) { Ok(decay) } else { Err("must be between 0 and 1".into()) } +} + +fn parse_key_value(s: &str) -> Result<(String, String), String> { + match s.split_once('=') { + Some((k, v)) if !k.is_empty() => Ok((k.to_owned(), v.to_owned())), + _ => Err(format!("expected KEY=VALUE, got `{s}`")), + } +} + +pub fn parse_key_name(s: &str) -> Result { + if !s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') { + Ok(s.to_owned()) + } else { + Err("key names may contain only letters, digits, `-` and `_`".into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn command_definition_is_valid() { + Cli::command().debug_assert(); + } + + #[test] + fn values_and_scales() { + assert_eq!(parse_value("0.9").unwrap().to_string(), "0.9"); + assert_eq!(parse_value("-1").unwrap().to_string(), "-1"); + assert_eq!(parse_value("4/5").unwrap().to_string(), "0.8"); + assert_eq!(parse_value("80/100").unwrap().to_string(), "0.8"); + assert!(parse_value("6/5").is_err()); + assert!(parse_value("1/0").is_err()); + assert!(parse_value("x/5").is_err()); + assert!(parse_value("1.5").is_err()); + } + + #[test] + fn extras_and_names() { + assert_eq!(parse_key_value("a=b=c").unwrap(), ("a".into(), "b=c".into())); + assert_eq!(parse_key_value("a=").unwrap(), ("a".into(), String::new())); + assert!(parse_key_value("=b").is_err()); + assert!(parse_key_value("ab").is_err()); + assert!(parse_key_name("work-2").is_ok()); + assert!(parse_key_name("../etc").is_err()); + assert!(parse_key_name("").is_err()); + } + + #[test] + fn decay_range() { + assert!(parse_decay("0").is_ok()); + assert!(parse_decay("1").is_ok()); + assert!(parse_decay("1.1").is_err()); + assert!(parse_decay("-0.1").is_err()); + } +} diff --git a/crates/trust-cli/src/commands.rs b/crates/trust-cli/src/commands.rs new file mode 100644 index 0000000..ec7924d --- /dev/null +++ b/crates/trust-cli/src/commands.rs @@ -0,0 +1,261 @@ +//! What each subcommand does. + +use std::io::{self, Read, Write}; + +use anyhow::{Context, Result, bail}; +use clap::CommandFactory; +use jiff::{Timestamp, Unit}; +use serde_json::{Value as Json, json}; +use trustgraph::holochain::{self, Direction, LinkTag}; +use trustgraph::{Keypair, LensOptions, Query, Record, TrustAtom, TrustGraph, credential}; + +use crate::cli::{AtomArgs, Cli, Command, ConvertArgs, Format, InputArgs, KeyCommand, LensArgs, QueryArgs, SignArgs}; +use crate::home::Home; +use crate::io::{Output, read_json}; + +/// Whether the command succeeded. `Failed` means the command ran, but its +/// answer was "no" (e.g. a signature did not verify). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + Success, + Failed, +} + +pub fn run(cli: Cli, out: &mut Output) -> Result { + let home = Home::new(cli.home)?; + match cli.command { + Command::Key(cmd) => key(&home, cmd, out), + Command::Atom(args) => atom(&home, args, out), + Command::Sign(args) => sign(&home, &args, out), + Command::Verify(args) => verify(&args, out), + Command::Id(args) => id(&args, out), + Command::Convert(args) => convert(&args, out), + Command::Add(args) => add(&home, &args, out), + Command::Query(args) => query(&home, args, out), + Command::Lens(args) => lens(&home, args, out), + Command::Info => { + out.json(&json!({ + "version": env!("CARGO_PKG_VERSION"), + "home": home.dir(), + "keys": home.keys_dir(), + "store": home.store_path(), + }))?; + Ok(Outcome::Success) + } + Command::Completions { shell } => { + let mut buf = Vec::new(); + clap_complete::generate(shell, &mut Cli::command(), "trust", &mut buf); + out.line(String::from_utf8_lossy(&buf).trim_end())?; + Ok(Outcome::Success) + } + } +} + +fn now() -> Timestamp { + Timestamp::now().round(Unit::Second).unwrap_or_else(|_| Timestamp::now()) +} + +fn key(home: &Home, cmd: KeyCommand, out: &mut Output) -> Result { + match cmd { + KeyCommand::New { name, force } => { + let keypair = Keypair::generate()?; + home.save_key(&name, &keypair, force)?; + out.json(&json!({ "name": name, "did": keypair.did() }))?; + } + KeyCommand::List => { + for (name, did) in home.list_keys()? { + out.json(&json!({ "name": name, "did": did }))?; + } + } + KeyCommand::Show { name } => { + let keypair = home.load_key(&name)?; + out.json(&json!({ + "name": name, + "did": keypair.did(), + "publicKeyMultibase": keypair.public().to_multibase(), + }))?; + } + KeyCommand::Export { name } => { + let keypair = home.load_key(&name)?; + eprintln!("warning: anyone with this secret can sign as {}", keypair.did()); + out.line(&keypair.to_secret_multibase())?; + } + KeyCommand::Import { name, force } => { + let mut secret = String::new(); + io::stdin().read_to_string(&mut secret).context("reading secret key from stdin")?; + let keypair = Keypair::from_secret_multibase(secret.trim())?; + home.save_key(&name, &keypair, force)?; + out.json(&json!({ "name": name, "did": keypair.did() }))?; + } + } + Ok(Outcome::Success) +} + +fn atom(home: &Home, args: AtomArgs, out: &mut Output) -> Result { + let keypair = if args.sign || args.source.is_none() { Some(home.load_key(&args.key.key)?) } else { None }; + let source = match (&args.source, &keypair) { + (Some(source), _) => source.clone(), + (None, Some(keypair)) => keypair.did().to_string(), + (None, None) => unreachable!("a key is loaded when there is no --source"), + }; + let mut atom = TrustAtom::new(source, args.target); + atom.content = args.content; + atom.value = args.value; + atom.extra = args.extra.into_iter().collect(); + if !args.no_timestamp { + atom.timestamp = Some(args.timestamp.unwrap_or_else(now)); + } + atom.validate()?; + + match keypair.filter(|_| args.sign) { + Some(keypair) => out.json(&credential::sign_atom(&atom, &keypair, now())?)?, + None => out.json(&atom)?, + } + Ok(Outcome::Success) +} + +/// Parses input as atoms, accepting plain atoms or credentials (whose proof +/// is *not* checked). +fn atoms_from(input: &InputArgs) -> Result> { + read_json(input.input.as_deref())? + .into_iter() + .enumerate() + .map(|(n, json)| parse_atom(json).with_context(|| format!("item {}", n + 1))) + .collect() +} + +fn parse_atom(json: Json) -> Result { + let atom = if json.get("@context").is_some() { + credential::from_credential(&json)? + } else { + serde_json::from_value(json)? + }; + atom.validate()?; + Ok(atom) +} + +fn sign(home: &Home, args: &SignArgs, out: &mut Output) -> Result { + let keypair = home.load_key(&args.key.key)?; + let created = args.created.unwrap_or_else(now); + for (n, json) in read_json(args.input.input.as_deref())?.into_iter().enumerate() { + if json.get("proof").is_some() { + bail!("item {}: already signed", n + 1); + } + let atom: TrustAtom = + serde_json::from_value(json).with_context(|| format!("item {}: not a Trust Atom", n + 1))?; + let signed = credential::sign_atom(&atom, &keypair, created).with_context(|| format!("item {}", n + 1))?; + out.json(&signed)?; + } + Ok(Outcome::Success) +} + +fn verify(args: &InputArgs, out: &mut Output) -> Result { + let mut outcome = Outcome::Success; + for json in read_json(args.input.as_deref())? { + match credential::verify_atom(&json) { + Ok(atom) => out.json(&json!({ "valid": true, "id": atom.id()?, "issuer": atom.source, "atom": atom }))?, + Err(err) => { + outcome = Outcome::Failed; + out.json(&json!({ "valid": false, "error": err.to_string() }))?; + } + } + } + Ok(outcome) +} + +fn id(args: &InputArgs, out: &mut Output) -> Result { + for atom in atoms_from(args)? { + out.line(&atom.id()?.to_string())?; + } + Ok(Outcome::Success) +} + +fn convert(args: &ConvertArgs, out: &mut Output) -> Result { + for atom in atoms_from(&args.input)? { + match args.to { + Format::Atom => out.json(&atom)?, + Format::Credential => out.json(&credential::to_credential(&atom)?)?, + Format::Canonical => out.line(&atom.canonical_json()?)?, + Format::Holochain => { + let bucket = match &args.bucket { + Some(bucket) => bucket.clone(), + None => holochain::random_bucket()?, + }; + let tag = |direction| -> Result { + let bytes = LinkTag::for_atom(&atom, direction, Some(bucket.clone()), None).encode()?; + Ok(json!({ "tag": String::from_utf8_lossy(&bytes), "hex": to_hex(&bytes) })) + }; + out.json(&json!({ + "source": atom.source, + "target": atom.target, + "forward": tag(Direction::Forward)?, + "reverse": tag(Direction::Reverse)?, + }))?; + } + } + } + Ok(Outcome::Success) +} + +fn to_hex(bytes: &[u8]) -> String { + use std::fmt::Write as _; + bytes.iter().fold(String::with_capacity(bytes.len() * 2), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + +fn add(home: &Home, args: &InputArgs, out: &mut Output) -> Result { + let mut store = home.open_store()?; + for (n, json) in read_json(args.input.as_deref())?.into_iter().enumerate() { + let record = Record::from_json(json).with_context(|| format!("item {}", n + 1))?; + let (id, signed) = (record.id, record.is_signed()); + let added = store.add(record)?; + out.json(&json!({ "id": id, "added": added, "signed": signed }))?; + } + Ok(Outcome::Success) +} + +fn query(home: &Home, args: QueryArgs, out: &mut Output) -> Result { + let store = home.open_store()?; + let q = Query { + source: args.source, + target: args.target, + topic: args.topic, + content_prefix: args.content_prefix, + signed_only: args.signed_only, + }; + for record in store.query(&q) { + if args.full { + out.json(record)?; + } else { + out.json(&record.atom)?; + } + } + Ok(Outcome::Success) +} + +fn lens(home: &Home, args: LensArgs, out: &mut Output) -> Result { + let agent = match args.agent { + Some(agent) => agent, + None => home.load_key(&args.key.key)?.did().to_string(), + }; + let store = home.open_store()?; + let q = Query { signed_only: args.signed_only, ..Query::default() }; + let graph: TrustGraph = store.query(&q).map(|r| &r.atom).collect(); + let options = LensOptions { depth: usize::from(args.depth), decay: args.decay, topic: args.topic }; + let mut entries = graph.lens(&agent, &options); + if let Some(limit) = args.limit { + entries.truncate(limit); + } + if args.rollup { + for atom in TrustGraph::rollup(&agent, &entries, &options, now())? { + out.json(&atom)?; + } + } else { + for entry in &entries { + out.json(entry)?; + } + } + Ok(Outcome::Success) +} diff --git a/crates/trust-cli/src/home.rs b/crates/trust-cli/src/home.rs new file mode 100644 index 0000000..43382d9 --- /dev/null +++ b/crates/trust-cli/src/home.rs @@ -0,0 +1,162 @@ +//! The `trust` home directory: keys and the local store. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use trustgraph::{Did, Keypair, Store}; + +/// Paths under the home directory. +#[derive(Debug, Clone)] +pub struct Home { + dir: PathBuf, +} + +/// A key as stored on disk. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct KeyFile { + name: String, + did: Did, + secret_key_multibase: String, +} + +impl Home { + /// Uses `dir`, or the platform data directory (e.g. + /// `~/.local/share/trust` on Linux). + pub fn new(dir: Option) -> Result { + let dir = match dir { + Some(dir) => dir, + None => directories::ProjectDirs::from("net", "trustgraph", "trust") + .context("cannot determine a home directory; set TRUST_HOME or pass --home")? + .data_dir() + .to_path_buf(), + }; + Ok(Self { dir }) + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + pub fn keys_dir(&self) -> PathBuf { + self.dir.join("keys") + } + + pub fn store_path(&self) -> PathBuf { + self.dir.join("atoms.ndjson") + } + + pub fn open_store(&self) -> Result { + Store::open(self.store_path()).with_context(|| format!("opening store {}", self.store_path().display())) + } + + fn key_path(&self, name: &str) -> PathBuf { + self.keys_dir().join(format!("{name}.json")) + } + + /// Saves a key. Refuses to overwrite unless `force`. + pub fn save_key(&self, name: &str, keypair: &Keypair, force: bool) -> Result<()> { + let path = self.key_path(name); + if path.exists() && !force { + bail!("key `{name}` already exists (use --force to replace it)"); + } + fs::create_dir_all(self.keys_dir()).with_context(|| format!("creating {}", self.keys_dir().display()))?; + let file = + KeyFile { name: name.to_owned(), did: keypair.did(), secret_key_multibase: keypair.to_secret_multibase() }; + let mut json = serde_json::to_string_pretty(&file)?; + json.push('\n'); + write_private(&path, json.as_bytes()).with_context(|| format!("writing {}", path.display())) + } + + /// Loads a key by name. + pub fn load_key(&self, name: &str) -> Result { + let path = self.key_path(name); + let json = match fs::read_to_string(&path) { + Ok(json) => json, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + bail!("no key named `{name}`; create one with `trust key new {name}`") + } + Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())), + }; + let file: KeyFile = serde_json::from_str(&json).with_context(|| format!("parsing {}", path.display()))?; + let keypair = Keypair::from_secret_multibase(&file.secret_key_multibase)?; + if keypair.did() != file.did { + bail!("{} is corrupt: its DID does not match its secret key", path.display()); + } + Ok(keypair) + } + + /// Names and DIDs of all keys, sorted by name. + pub fn list_keys(&self) -> Result> { + let entries = match fs::read_dir(self.keys_dir()) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e).context("listing keys"), + }; + let mut keys = Vec::new(); + for entry in entries { + let path = entry?.path(); + if path.extension().is_some_and(|ext| ext == "json") { + let name = path.file_stem().unwrap_or_default().to_string_lossy().into_owned(); + keys.push((name.clone(), self.load_key(&name)?.did())); + } + } + keys.sort(); + Ok(keys) + } +} + +/// Writes a file readable only by its owner (on Unix). +fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let mut options = fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path)?.write_all(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_round_trip_and_are_private() { + let dir = tempfile::tempdir().unwrap(); + let home = Home::new(Some(dir.path().to_path_buf())).unwrap(); + assert!(home.list_keys().unwrap().is_empty()); + assert!(home.load_key("default").is_err()); + + let keypair = Keypair::generate().unwrap(); + home.save_key("default", &keypair, false).unwrap(); + assert_eq!(home.load_key("default").unwrap().did(), keypair.did()); + assert!(home.save_key("default", &Keypair::generate().unwrap(), false).is_err()); + home.save_key("default", &Keypair::generate().unwrap(), true).unwrap(); + assert_ne!(home.load_key("default").unwrap().did(), keypair.did()); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(home.key_path("default")).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + } + + #[test] + fn detects_corrupt_key_files() { + let dir = tempfile::tempdir().unwrap(); + let home = Home::new(Some(dir.path().to_path_buf())).unwrap(); + home.save_key("a", &Keypair::from_seed(&[1; 32]), false).unwrap(); + let other = Keypair::from_seed(&[2; 32]).did(); + let path = home.key_path("a"); + let json = + fs::read_to_string(&path).unwrap().replace(Keypair::from_seed(&[1; 32]).did().as_str(), other.as_str()); + fs::write(&path, json).unwrap(); + assert!(home.load_key("a").unwrap_err().to_string().contains("corrupt")); + } +} diff --git a/crates/trust-cli/src/io.rs b/crates/trust-cli/src/io.rs new file mode 100644 index 0000000..4a15b2b --- /dev/null +++ b/crates/trust-cli/src/io.rs @@ -0,0 +1,67 @@ +//! Reading JSON input and writing JSON output. + +use std::fs::File; +use std::io::{self, BufReader, IsTerminal, Read, Write}; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use serde::Serialize; +use serde_json::Value as Json; + +/// Reads every JSON value from `path` (or stdin for `None` / `-`). Accepts a +/// single document, NDJSON, or concatenated JSON. +pub fn read_json(path: Option<&Path>) -> Result> { + let (reader, name): (Box, String) = match path { + Some(p) if p.as_os_str() != "-" => ( + Box::new(BufReader::new(File::open(p).with_context(|| format!("opening {}", p.display()))?)), + p.display().to_string(), + ), + _ => { + if io::stdin().is_terminal() { + bail!("expected JSON on stdin; pipe some in, or pass a file name"); + } + (Box::new(io::stdin().lock()), "stdin".to_owned()) + } + }; + let values = serde_json::Deserializer::from_reader(reader) + .into_iter::() + .enumerate() + .map(|(n, v)| v.with_context(|| format!("{name}: item {} is not valid JSON", n + 1))) + .collect::>>()?; + if values.is_empty() { + bail!("{name}: no input"); + } + Ok(values) +} + +/// Writes JSON values, one per line (or pretty-printed). +pub struct Output { + out: W, + pretty: bool, +} + +impl Output { + pub fn new(out: W, pretty: bool) -> Self { + Self { out, pretty } + } + + pub fn json(&mut self, value: &T) -> Result<()> { + if self.pretty { + serde_json::to_writer_pretty(&mut self.out, value)?; + } else { + serde_json::to_writer(&mut self.out, value)?; + } + self.out.write_all(b"\n")?; + Ok(()) + } + + pub fn line(&mut self, line: &str) -> Result<()> { + writeln!(self.out, "{line}")?; + Ok(()) + } + + pub fn flush(&mut self) -> Result<()> { + self.out.flush()?; + Ok(()) + } +} diff --git a/crates/trust-cli/src/main.rs b/crates/trust-cli/src/main.rs new file mode 100644 index 0000000..ac9eed6 --- /dev/null +++ b/crates/trust-cli/src/main.rs @@ -0,0 +1,35 @@ +//! `trust`: the Trust Graph command line interface. + +mod cli; +mod commands; +mod home; +mod io; + +use std::process::ExitCode; + +use clap::Parser; + +use crate::cli::Cli; +use crate::commands::Outcome; +use crate::io::Output; + +fn main() -> ExitCode { + let cli = Cli::parse(); + let mut out = Output::new(std::io::stdout().lock(), cli.pretty); + + match commands::run(cli, &mut out).and_then(|outcome| out.flush().map(|()| outcome)) { + Ok(Outcome::Success) => ExitCode::SUCCESS, + Ok(Outcome::Failed) => ExitCode::FAILURE, + Err(err) if is_broken_pipe(&err) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("error: {err:#}"); + ExitCode::FAILURE + } + } +} + +/// Being piped into `head` or similar is not an error. +fn is_broken_pipe(err: &anyhow::Error) -> bool { + err.chain() + .any(|cause| cause.downcast_ref::().is_some_and(|e| e.kind() == std::io::ErrorKind::BrokenPipe)) +} diff --git a/crates/trust-cli/tests/cli.rs b/crates/trust-cli/tests/cli.rs new file mode 100644 index 0000000..b136fb5 --- /dev/null +++ b/crates/trust-cli/tests/cli.rs @@ -0,0 +1,266 @@ +//! End-to-end tests: run the `trust` binary as a user would. + +#![allow(clippy::unwrap_used)] // Panicking is how tests fail. + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::{Value as Json, json}; +use tempfile::TempDir; + +struct Env { + home: TempDir, +} + +impl Env { + fn new() -> Self { + Self { home: tempfile::tempdir().unwrap() } + } + + fn cmd(&self) -> Command { + let mut cmd = Command::cargo_bin("trust").unwrap(); + cmd.env("TRUST_HOME", self.home.path()).env_remove("TRUST_KEY"); + cmd + } + + /// Runs `trust ARGS` with `stdin`, expecting success; returns stdout. + fn run(&self, args: &[&str], stdin: &str) -> String { + let out = self.cmd().args(args).write_stdin(stdin).assert().success().get_output().stdout.clone(); + String::from_utf8(out).unwrap() + } + + fn json_lines(&self, args: &[&str], stdin: &str) -> Vec { + self.run(args, stdin).lines().map(|l| serde_json::from_str(l).unwrap()).collect() + } + + fn new_key(&self, name: &str) -> String { + let out = self.json_lines(&["key", "new", name], ""); + out[0]["did"].as_str().unwrap().to_owned() + } +} + +#[test] +fn help_and_version() { + let env = Env::new(); + env.cmd() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("Agent Lens").and(predicate::str::contains("Examples:"))); + env.cmd().arg("--version").assert().success().stdout(format!("trust {}\n", env!("CARGO_PKG_VERSION"))); + env.cmd().assert().code(2).stderr(predicate::str::contains("Usage: trust")); +} + +#[test] +fn key_lifecycle() { + let env = Env::new(); + assert_eq!(env.run(&["key", "list"], ""), ""); + let did = env.new_key("default"); + assert!(did.starts_with("did:key:z6Mk")); + + env.cmd().args(["key", "new"]).assert().failure().stderr(predicate::str::contains("already exists")); + + let shown = &env.json_lines(&["key", "show"], "")[0]; + assert_eq!(shown["did"], did.as_str()); + assert_eq!(format!("did:key:{}", shown["publicKeyMultibase"].as_str().unwrap()), did); + + // Export, then import under another name: same identity. + let secret = env.run(&["key", "export"], ""); + let imported = &env.json_lines(&["key", "import", "copy"], &secret)[0]; + assert_eq!(imported["did"], did.as_str()); + + let names: Vec<_> = env.json_lines(&["key", "list"], "").iter().map(|k| k["name"].clone()).collect(); + assert_eq!(names, [json!("copy"), json!("default")]); + + env.cmd().args(["key", "new", "../evil"]).assert().code(2); + env.cmd() + .args(["key", "show", "missing"]) + .assert() + .failure() + .stderr(predicate::str::contains("trust key new missing")); + env.cmd().args(["key", "import", "bad"]).write_stdin("not a key").assert().failure(); +} + +#[test] +fn atom_creation() { + let env = Env::new(); + let did = env.new_key("default"); + + let atom = &env.json_lines( + &["atom", "--target", "https://example.com", "--content", "sushi", "--value", "4/5", "--extra", "lang=en"], + "", + )[0]; + assert_eq!(atom["source"], did.as_str()); + assert_eq!(atom["value"], "0.8"); + assert_eq!(atom["extra"], json!({ "lang": "en" })); + assert!(atom["timestamp"].as_str().unwrap().ends_with('Z')); + + // An explicit source needs no key; negative values parse. + let atom = &env.json_lines(&["atom", "-s", "alice", "-t", "bob", "-v", "-0.5", "--no-timestamp"], "")[0]; + assert_eq!(atom, &json!({ "source": "alice", "target": "bob", "value": "-0.5" })); + + env.cmd().args(["atom", "-t", "x", "-v", "1.5"]).assert().code(2); + env.cmd().args(["atom", "-t", "x", "-v", "6/5"]).assert().code(2); + env.cmd().args(["atom", "-s", "a b", "-t", "x"]).assert().failure().stderr(predicate::str::contains("whitespace")); +} + +#[test] +fn sign_verify_pipeline() { + let env = Env::new(); + env.new_key("default"); + let atom = env.run(&["atom", "-t", "https://example.com", "-c", "sushi", "-v", "1"], ""); + let signed = env.run(&["sign", "--created", "2024-01-01T00:00:00Z"], &atom); + let credential: Json = serde_json::from_str(&signed).unwrap(); + assert_eq!(credential["proof"]["cryptosuite"], "eddsa-jcs-2022"); + assert_eq!(credential["proof"]["created"], "2024-01-01T00:00:00Z"); + + let verified = &env.json_lines(&["verify"], &signed)[0]; + assert_eq!(verified["valid"], true); + assert_eq!(verified["atom"], serde_json::from_str::(&atom).unwrap()); + + // Tampering is detected and gives exit code 1. + let tampered = signed.replace("\"value\":\"1\"", "\"value\":\"-1\""); + assert_ne!(tampered, signed); + env.cmd().arg("verify").write_stdin(tampered).assert().code(1).stdout(predicate::str::contains("\"valid\":false")); + + // Signing someone else's atom, or re-signing, fails. + let foreign = env.run(&["atom", "-s", "did:key:z6MkSomeoneElse", "-t", "x"], ""); + env.cmd().arg("sign").write_stdin(foreign).assert().failure().stderr(predicate::str::contains("does not match")); + env.cmd().arg("sign").write_stdin(signed).assert().failure().stderr(predicate::str::contains("already signed")); +} + +#[test] +fn atom_sign_flag_matches_sign_command() { + let env = Env::new(); + env.new_key("default"); + let signed = &env.json_lines(&["atom", "-t", "x", "-v", "1", "--sign"], "")[0]; + assert_eq!(env.json_lines(&["verify"], &signed.to_string())[0]["valid"], true); +} + +#[test] +fn ids_are_stable_across_formats() { + let env = Env::new(); + env.new_key("default"); + let atom = env.run(&["atom", "-t", "x", "-v", "0.5", "--timestamp", "2024-01-01T00:00:00Z"], ""); + let signed = env.run(&["sign"], &atom); + let pretty: Json = serde_json::from_str(&atom).unwrap(); + let id = env.run(&["id"], &atom); + assert!(id.starts_with("Qm")); + assert_eq!(env.run(&["id"], &signed), id); + assert_eq!(env.run(&["id"], &serde_json::to_string_pretty(&pretty).unwrap()), id); +} + +#[test] +fn convert_formats() { + let env = Env::new(); + let atom = r#"{"source":"alice","target":"bob","content":"sushi","value":"1"}"#; + + let canonical = env.run(&["convert", "--to", "canonical"], atom); + assert_eq!(canonical, "{\"content\":\"sushi\",\"source\":\"alice\",\"target\":\"bob\",\"value\":\"1\"}\n"); + + let credential = env.run(&["convert", "--to", "credential"], atom); + assert!(credential.contains("TrustAtomCredential")); + assert_eq!(env.run(&["convert", "--to", "atom"], &credential), format!("{atom}\n")); + + let holo = &env.json_lines(&["convert", "--to", "holochain", "--bucket", "892412523"], atom)[0]; + assert_eq!(holo["forward"]["tag"], "Ŧ→sushi\u{0}.999999999\u{0}892412523\u{0}"); + assert_eq!(holo["reverse"]["tag"], "Ŧ↩sushi\u{0}.999999999\u{0}892412523\u{0}"); + assert!(holo["forward"]["hex"].as_str().unwrap().starts_with("c5a6e28692")); + + env.cmd().args(["convert", "--to", "holochain", "--bucket", "12"]).write_stdin(atom).assert().failure(); +} + +#[test] +fn reads_ndjson_files_and_concatenated_json() { + let env = Env::new(); + let path = env.home.path().join("atoms.json"); + std::fs::write(&path, "{\"source\":\"a\",\"target\":\"b\"}\n\n{\"source\":\"a\",\"target\":\"c\"} {\"source\":\"a\",\"target\":\"d\"}").unwrap(); + assert_eq!(env.run(&["id", path.to_str().unwrap()], "").lines().count(), 3); + + env.cmd().args(["id", "missing.json"]).assert().failure().stderr(predicate::str::contains("missing.json")); + env.cmd() + .arg("id") + .write_stdin("{\"source\":\"a\",\"target\":\"b\"} {oops") + .assert() + .failure() + .stderr(predicate::str::contains("item 2")); + env.cmd().arg("id").write_stdin("").assert().failure().stderr(predicate::str::contains("no input")); +} + +#[test] +fn store_and_query() { + let env = Env::new(); + env.new_key("default"); + let signed = env.run(&["atom", "-t", "bob", "-c", "sushi, ramen", "-v", "1", "--sign"], ""); + let plain = env.run(&["atom", "-s", "carol", "-t", "bob", "-c", "rust", "-v", "0.5"], ""); + + let added = env.json_lines(&["add"], &format!("{signed}{plain}")); + assert_eq!(added.len(), 2); + assert_eq!((added[0]["added"].clone(), added[0]["signed"].clone()), (json!(true), json!(true))); + assert_eq!(added[1]["signed"], false); + // Adding again is a no-op. + assert_eq!(env.json_lines(&["add"], &signed)[0]["added"], false); + + assert_eq!(env.json_lines(&["query"], "").len(), 2); + assert_eq!(env.json_lines(&["query", "--topic", "ramen"], "").len(), 1); + assert_eq!(env.json_lines(&["query", "--source", "carol"], "")[0]["content"], "rust"); + assert_eq!(env.json_lines(&["query", "--signed-only"], "").len(), 1); + let full = &env.json_lines(&["query", "--signed-only", "--full"], "")[0]; + assert!(full["id"].as_str().unwrap().starts_with("Qm")); + assert!(full["credential"]["proof"].is_object()); + + // Forged credentials never reach the store. + let forged = signed.replace("\"value\":\"1\"", "\"value\":\"0.1\""); + env.cmd().arg("add").write_stdin(forged).assert().failure().stderr(predicate::str::contains("verification failed")); + assert_eq!(env.json_lines(&["query"], "").len(), 2); +} + +#[test] +fn lens_and_rollups() { + let env = Env::new(); + let alice = env.new_key("default"); + let bob = env.new_key("bob"); + for (key, target, value) in + [("default", bob.as_str(), "1"), ("bob", "https://sushi.example", "0.8"), ("bob", "https://bad.example", "-1")] + { + let signed = env.run(&["atom", "--key", key, "-t", target, "-c", "sushi", "-v", value, "--sign"], ""); + env.run(&["add"], &signed); + } + let lens = env.json_lines(&["lens", "--topic", "sushi"], ""); + let targets: Vec<_> = lens.iter().map(|e| e["target"].as_str().unwrap()).collect(); + assert_eq!(targets, [bob.as_str(), "https://sushi.example", "https://bad.example"]); + assert_eq!(lens[1]["score"], 0.8); + assert_eq!(lens[1]["confidence"], 0.5); + assert_eq!(lens[1]["hops"], 2); + + assert_eq!(env.json_lines(&["lens", "--topic", "sushi", "--depth", "1"], "").len(), 1); + assert_eq!(env.json_lines(&["lens", "--topic", "sushi", "--limit", "2"], "").len(), 2); + assert!(env.run(&["lens", "--topic", "ramen"], "").is_empty()); + assert_eq!(env.json_lines(&["lens", &bob, "--topic", "sushi"], "").len(), 2); + + // Rollups are atoms from the agent that can be signed, verified, and stored. + let rollups = env.run(&["lens", "--topic", "sushi", "--rollup"], ""); + let signed = env.run(&["sign", "--key", "default"], &rollups); + let verified = env.json_lines(&["verify"], &signed); + assert_eq!(verified.len(), 3); + assert!(verified.iter().all(|v| v["valid"] == true && v["issuer"] == alice.as_str())); + assert_eq!(verified[1]["atom"]["extra"]["rollup"], "agent-lens"); + + env.cmd().args(["lens", "--decay", "2"]).assert().code(2); + env.cmd().args(["lens", "--depth", "0"]).assert().code(2); +} + +#[test] +fn info_and_completions() { + let env = Env::new(); + let info = &env.json_lines(&["info"], "")[0]; + assert_eq!(info["home"], env.home.path().to_str().unwrap()); + assert!(info["store"].as_str().unwrap().ends_with("atoms.ndjson")); + env.cmd().args(["completions", "bash"]).assert().success().stdout(predicate::str::contains("_trust")); +} + +#[test] +fn pretty_output() { + let env = Env::new(); + let out = env.run(&["--pretty", "atom", "-s", "a", "-t", "b", "--no-timestamp"], ""); + assert_eq!(out, "{\n \"source\": \"a\",\n \"target\": \"b\"\n}\n"); +} diff --git a/crates/trustgraph/Cargo.toml b/crates/trustgraph/Cargo.toml new file mode 100644 index 0000000..80396be --- /dev/null +++ b/crates/trustgraph/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "trustgraph" +description = "Trust Graph protocol: Trust Atoms, signed trust claims, and agent-centric trust graphs" +readme = "README.md" +keywords = ["trust", "reputation", "did", "verifiable-credentials", "holochain"] +categories = ["cryptography", "data-structures"] +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +publish = false + +[dependencies] +bs58.workspace = true +ed25519-dalek.workspace = true +getrandom.workspace = true +jiff.workspace = true +rust_decimal.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_json_canonicalizer.workspace = true +sha2.workspace = true +thiserror.workspace = true + +[dev-dependencies] +hex.workspace = true +proptest.workspace = true +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/trustgraph/README.md b/crates/trustgraph/README.md new file mode 100644 index 0000000..d68c4c1 --- /dev/null +++ b/crates/trustgraph/README.md @@ -0,0 +1,12 @@ +# trustgraph + +Rust implementation of the [Trust Graph](https://trustgraph.net) protocol: + +- `TrustAtom`: *source* trusts *target*, about *content*, to degree *value* (`-1..=1`) +- `Keypair` / `Did`: Ed25519 identities as `did:key` +- `credential`: W3C Verifiable Credentials 2.0 with `eddsa-jcs-2022` proofs +- `TrustGraph::lens`: the Agent Lens / Trust Cascade +- `holochain`: the `trustgraph-holochain` link-tag codec +- `Store`: an append-only NDJSON store + +See the [repository README](../../README.md) for an overview. diff --git a/crates/trustgraph/src/atom.rs b/crates/trustgraph/src/atom.rs new file mode 100644 index 0000000..0440e36 --- /dev/null +++ b/crates/trustgraph/src/atom.rs @@ -0,0 +1,245 @@ +//! Trust Atoms: the unit of data in a trust graph. + +use std::collections::BTreeMap; + +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; + +use crate::{ContentId, Error, Result, Value, canonical}; + +/// One statement of trust: `source` trusts `target`, regarding `content`, +/// to the degree `value`. +/// +/// Only `source` and `target` are required. Everything else is optional, +/// as in the [Trust Graph protocol](https://github.com/trustgraph/trustgraph). +/// +/// ``` +/// use trustgraph::{TrustAtom, Value}; +/// +/// let atom = TrustAtom::new("did:key:z6MkAlice", "https://ipfs.io") +/// .with_content("content addressable graph infrastructure") +/// .with_value("0.99".parse::()?); +/// atom.validate()?; +/// # Ok::<(), trustgraph::Error>(()) +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TrustAtom { + /// Who is making the statement: usually a DID such as `did:key:z6Mk…`. + pub source: String, + + /// What the statement is about: a DID, a URL, or another identifier. + pub target: String, + + /// What the trust is about: a topic, tag or description (e.g. `sushi`, + /// `Rust programming`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + + /// How much the source trusts the target, in `-1..=1`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + + /// When the statement was made. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + + /// Any additional application-specific fields. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extra: BTreeMap, +} + +impl TrustAtom { + /// An atom with just a source and target. + #[must_use] + pub fn new(source: impl Into, target: impl Into) -> Self { + Self { + source: source.into(), + target: target.into(), + content: None, + value: None, + timestamp: None, + extra: BTreeMap::new(), + } + } + + /// Sets the content. + #[must_use] + pub fn with_content(mut self, content: impl Into) -> Self { + self.content = Some(content.into()); + self + } + + /// Sets the value. + #[must_use] + pub fn with_value(mut self, value: Value) -> Self { + self.value = Some(value); + self + } + + /// Sets the timestamp. + #[must_use] + pub fn with_timestamp(mut self, timestamp: Timestamp) -> Self { + self.timestamp = Some(timestamp); + self + } + + /// Adds an extra field. + #[must_use] + pub fn with_extra(mut self, key: impl Into, value: impl Into) -> Self { + self.extra.insert(key.into(), value.into()); + self + } + + /// Checks the atom's invariants: `source` and `target` are non-empty and + /// contain no whitespace or control characters, `content` contains no + /// control characters, and extra keys are non-empty. + /// + /// # Errors + /// + /// Returns [`Error::InvalidAtom`] describing the first problem found. + pub fn validate(&self) -> Result<()> { + check_identifier("source", &self.source)?; + check_identifier("target", &self.target)?; + if self.source == self.target { + return Err(Error::InvalidAtom("source and target must differ".into())); + } + if self.content.as_deref().is_some_and(|c| c.chars().any(char::is_control)) { + return Err(Error::InvalidAtom("content must not contain control characters".into())); + } + if self.extra.keys().any(String::is_empty) { + return Err(Error::InvalidAtom("extra keys must not be empty".into())); + } + Ok(()) + } + + /// The atom's canonical JSON (RFC 8785): the exact bytes that are hashed + /// for its [`ContentId`]. + /// + /// # Errors + /// + /// Fails only if serialization fails, which cannot happen for valid atoms. + pub fn canonical_json(&self) -> Result { + canonical::to_string(self) + } + + /// The atom's content-addressed identifier. + /// + /// # Errors + /// + /// See [`TrustAtom::canonical_json`]. + pub fn id(&self) -> Result { + Ok(ContentId::of_bytes(self.canonical_json()?.as_bytes())) + } + + /// True if this atom's content matches `topic`: case-insensitively equal + /// to the whole content, or to one of its comma-separated tags. + #[must_use] + pub fn matches_topic(&self, topic: &str) -> bool { + self.content.as_deref().is_some_and(|content| content_matches_topic(content, topic)) + } +} + +/// See [`TrustAtom::matches_topic`]. +pub(crate) fn content_matches_topic(content: &str, topic: &str) -> bool { + let topic = topic.trim(); + content.trim().eq_ignore_ascii_case(topic) || content.split(',').any(|tag| tag.trim().eq_ignore_ascii_case(topic)) +} + +fn check_identifier(field: &str, s: &str) -> Result<()> { + if s.is_empty() { + return Err(Error::InvalidAtom(format!("{field} must not be empty"))); + } + if s.chars().any(|c| c.is_whitespace() || c.is_control()) { + return Err(Error::InvalidAtom(format!("{field} must not contain whitespace or control characters"))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn atom() -> TrustAtom { + TrustAtom::new("did:key:z6MkAlice", "did:key:z6MkBob") + } + + #[test] + fn minimal_atom_serializes_only_required_fields() { + assert_eq!( + serde_json::to_string(&atom()).unwrap(), + r#"{"source":"did:key:z6MkAlice","target":"did:key:z6MkBob"}"# + ); + } + + #[test] + fn full_atom_round_trips() { + let atom = atom() + .with_content("sushi") + .with_value("0.8".parse().unwrap()) + .with_timestamp("2024-01-02T03:04:05Z".parse().unwrap()) + .with_extra("lang", "en"); + let json = serde_json::to_string(&atom).unwrap(); + assert_eq!( + json, + r#"{"source":"did:key:z6MkAlice","target":"did:key:z6MkBob","content":"sushi","value":"0.8","timestamp":"2024-01-02T03:04:05Z","extra":{"lang":"en"}}"# + ); + assert_eq!(serde_json::from_str::(&json).unwrap(), atom); + } + + #[test] + fn accepts_protocol_readme_example() { + // From https://github.com/trustgraph/trustgraph#protocol-trust-atoms + let atom: TrustAtom = serde_json::from_str( + r#"{ + "source": "QmWdprFxhCWzjJ6D9Tw9tj5FyWFauhYuGtDQigVvwfteNv", + "target": "http://ipfs.io/", + "value": 0.99, + "content": "content addressable graph infrastructure", + "timestamp": "2015-08-11T22:32:23.207Z" + }"#, + ) + .unwrap(); + atom.validate().unwrap(); + assert_eq!(atom.value.unwrap().to_string(), "0.99"); + } + + #[test] + fn rejects_unknown_fields() { + let err = serde_json::from_str::(r#"{"source":"a","target":"b","vaule":"1"}"#).unwrap_err(); + assert!(err.to_string().contains("unknown field `vaule`")); + } + + #[test] + fn validation() { + atom().validate().unwrap(); + assert!(TrustAtom::new("", "b").validate().is_err()); + assert!(TrustAtom::new("a", "").validate().is_err()); + assert!(TrustAtom::new("a b", "c").validate().is_err()); + assert!(TrustAtom::new("a", "a").validate().is_err()); + assert!(atom().with_content("nul\0byte").validate().is_err()); + assert!(atom().with_extra("", "x").validate().is_err()); + atom().with_content("Ŧrust, émoji 🍣").validate().unwrap(); + } + + #[test] + fn canonical_json_sorts_keys_and_id_is_stable() { + let atom = atom().with_value(Value::MAX).with_content("x"); + assert_eq!( + atom.canonical_json().unwrap(), + r#"{"content":"x","source":"did:key:z6MkAlice","target":"did:key:z6MkBob","value":"1"}"# + ); + assert_eq!(atom.id().unwrap(), atom.clone().id().unwrap()); + assert_ne!(atom.id().unwrap(), atom.with_content("y").id().unwrap()); + } + + #[test] + fn topic_matching() { + let tagged = atom().with_content("programming, Elixir"); + assert!(tagged.matches_topic("elixir")); + assert!(tagged.matches_topic("Programming")); + assert!(tagged.matches_topic("programming, elixir")); + assert!(!tagged.matches_topic("rust")); + assert!(!atom().matches_topic("rust")); + } +} diff --git a/crates/trustgraph/src/canonical.rs b/crates/trustgraph/src/canonical.rs new file mode 100644 index 0000000..7ee9c8b --- /dev/null +++ b/crates/trustgraph/src/canonical.rs @@ -0,0 +1,35 @@ +//! JSON Canonicalization Scheme ([RFC 8785](https://www.rfc-editor.org/rfc/rfc8785)). +//! +//! Hashes and signatures are always computed over canonical JSON, so that +//! the same data produces the same bytes regardless of key order or +//! whitespace. + +use serde::Serialize; + +use crate::Result; + +/// Serializes `value` as canonical JSON. +/// +/// # Errors +/// +/// Fails if `value` cannot be represented as JSON. +pub fn to_string(value: &T) -> Result { + Ok(serde_json_canonicalizer::to_string(value)?) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + #[test] + fn sorts_keys_and_strips_whitespace() { + let doc = json!({ "b": [1, 2, { "z": null, "a": true }], "a": "x" }); + assert_eq!(super::to_string(&doc).unwrap(), r#"{"a":"x","b":[1,2,{"a":true,"z":null}]}"#); + } + + #[test] + fn formats_numbers_like_ecmascript() { + let doc = json!({ "n": [1.0, 1e21, 0.000_001, -0.0] }); + assert_eq!(super::to_string(&doc).unwrap(), r#"{"n":[1,1e+21,0.000001,0]}"#); + } +} diff --git a/crates/trustgraph/src/credential.rs b/crates/trustgraph/src/credential.rs new file mode 100644 index 0000000..d299001 --- /dev/null +++ b/crates/trustgraph/src/credential.rs @@ -0,0 +1,430 @@ +//! Signed trust claims as [W3C Verifiable Credentials 2.0](https://www.w3.org/TR/vc-data-model-2.0/), +//! secured with the [`eddsa-jcs-2022`](https://www.w3.org/TR/vc-di-eddsa/#eddsa-jcs-2022) +//! Data Integrity cryptosuite. +//! +//! `eddsa-jcs-2022` canonicalizes with JCS rather than RDF, so signing and +//! verifying need no JSON-LD processing and no network access. + +use jiff::{Timestamp, Unit}; +use serde_json::{Map, Value as Json, json}; +use sha2::{Digest, Sha256}; + +use crate::{Did, Error, Keypair, Result, TrustAtom, canonical}; + +/// The base W3C Verifiable Credentials 2.0 context. +pub const CREDENTIALS_V2_CONTEXT: &str = "https://www.w3.org/ns/credentials/v2"; + +/// The Trust Graph JSON-LD context. +/// +/// Provisional: it must be published (with `trustgraph-schema`) before 1.0. +pub const TRUSTGRAPH_CONTEXT: &str = "https://trustgraph.net/ns/v1"; + +/// The credential `type` for a signed Trust Atom. +pub const TRUST_ATOM_CREDENTIAL: &str = "TrustAtomCredential"; + +const PROOF_TYPE: &str = "DataIntegrityProof"; +const CRYPTOSUITE: &str = "eddsa-jcs-2022"; +const PROOF_PURPOSE: &str = "assertionMethod"; + +/// Converts an atom into an unsigned Verifiable Credential: +/// `source` becomes the `issuer`, `target` the `credentialSubject.id`, and +/// `timestamp` the `validFrom`. +/// +/// # Errors +/// +/// Returns [`Error::InvalidAtom`] if the atom is invalid. +pub fn to_credential(atom: &TrustAtom) -> Result { + atom.validate()?; + let mut subject = Map::new(); + subject.insert("id".into(), json!(atom.target)); + if let Some(content) = &atom.content { + subject.insert("content".into(), json!(content)); + } + if let Some(value) = atom.value { + subject.insert("value".into(), serde_json::to_value(value)?); + } + if !atom.extra.is_empty() { + subject.insert("extra".into(), serde_json::to_value(&atom.extra)?); + } + + let mut credential = Map::new(); + credential.insert("@context".into(), json!([CREDENTIALS_V2_CONTEXT, TRUSTGRAPH_CONTEXT])); + credential.insert("type".into(), json!(["VerifiableCredential", TRUST_ATOM_CREDENTIAL])); + credential.insert("issuer".into(), json!(atom.source)); + if let Some(timestamp) = atom.timestamp { + credential.insert("validFrom".into(), json!(timestamp.to_string())); + } + credential.insert("credentialSubject".into(), Json::Object(subject)); + Ok(Json::Object(credential)) +} + +/// Extracts the Trust Atom from a Trust Atom credential. Does **not** check +/// the proof; use [`verify_atom`] for that. +/// +/// # Errors +/// +/// Returns [`Error::InvalidCredential`] if the document is not a Trust Atom +/// credential. +pub fn from_credential(credential: &Json) -> Result { + let bad = |msg: &str| Error::InvalidCredential(msg.into()); + let types = credential.get("type").and_then(Json::as_array).ok_or_else(|| bad("missing `type`"))?; + if !types.iter().any(|t| t == TRUST_ATOM_CREDENTIAL) { + return Err(bad("not a TrustAtomCredential")); + } + let issuer = match credential.get("issuer") { + Some(Json::String(s)) => s.clone(), + Some(Json::Object(o)) => o.get("id").and_then(Json::as_str).ok_or_else(|| bad("issuer has no `id`"))?.into(), + _ => return Err(bad("missing `issuer`")), + }; + let subject = credential + .get("credentialSubject") + .and_then(Json::as_object) + .ok_or_else(|| bad("missing `credentialSubject`"))?; + let target = subject.get("id").and_then(Json::as_str).ok_or_else(|| bad("credentialSubject has no `id`"))?; + + let mut atom = TrustAtom::new(issuer, target); + if let Some(content) = subject.get("content") { + atom.content = Some(content.as_str().ok_or_else(|| bad("`content` must be a string"))?.into()); + } + if let Some(value) = subject.get("value") { + atom.value = Some(serde_json::from_value(value.clone())?); + } + if let Some(extra) = subject.get("extra") { + atom.extra = serde_json::from_value(extra.clone())?; + } + if let Some(valid_from) = credential.get("validFrom") { + let s = valid_from.as_str().ok_or_else(|| bad("`validFrom` must be a string"))?; + atom.timestamp = Some(s.parse().map_err(|_| bad("`validFrom` is not an RFC 3339 date-time"))?); + } + atom.validate()?; + Ok(atom) +} + +/// Signs a Trust Atom, producing a Verifiable Credential with an +/// `eddsa-jcs-2022` proof. +/// +/// # Errors +/// +/// Returns [`Error::InvalidAtom`] if the atom is invalid or its `source` is +/// not the DID of `keypair`. +pub fn sign_atom(atom: &TrustAtom, keypair: &Keypair, created: Timestamp) -> Result { + let did = keypair.did(); + if atom.source != did.as_str() { + return Err(Error::InvalidAtom(format!("atom source `{}` does not match signing key `{did}`", atom.source))); + } + sign(&to_credential(atom)?, keypair, created) +} + +/// Verifies a signed Trust Atom credential and returns the atom. +/// +/// Checks the proof, and that the credential was issued by the key that +/// signed it. +/// +/// # Errors +/// +/// Returns [`Error::Verification`] if the proof is invalid or was made by +/// someone other than the issuer, or [`Error::InvalidCredential`] if the +/// document is malformed. +pub fn verify_atom(credential: &Json) -> Result { + let signer = verify(credential)?; + let atom = from_credential(credential)?; + if atom.source != signer.as_str() { + return Err(Error::Verification(format!( + "issuer `{}` did not sign this credential (signed by `{signer}`)", + atom.source + ))); + } + Ok(atom) +} + +/// Adds an `eddsa-jcs-2022` Data Integrity proof to any JSON object. +/// +/// `created` is truncated to whole seconds. +/// +/// # Errors +/// +/// Returns [`Error::InvalidCredential`] if `document` is not a JSON object +/// or already has a proof. +pub fn sign(document: &Json, keypair: &Keypair, created: Timestamp) -> Result { + let doc = document.as_object().ok_or_else(|| Error::InvalidCredential("document must be a JSON object".into()))?; + if doc.contains_key("proof") { + return Err(Error::InvalidCredential("document is already signed".into())); + } + let created = created.round(Unit::Second).map_err(|e| Error::InvalidCredential(e.to_string()))?; + + let mut proof = Map::new(); + proof.insert("type".into(), json!(PROOF_TYPE)); + proof.insert("cryptosuite".into(), json!(CRYPTOSUITE)); + proof.insert("created".into(), json!(created.to_string())); + proof.insert("verificationMethod".into(), json!(keypair.did().verification_method())); + proof.insert("proofPurpose".into(), json!(PROOF_PURPOSE)); + if let Some(context) = doc.get("@context") { + proof.insert("@context".into(), context.clone()); + } + + let signature = keypair.sign(&hash_data(&proof, doc)?); + proof.insert("proofValue".into(), json!(format!("z{}", bs58::encode(signature).into_string()))); + + let mut secured = doc.clone(); + secured.insert("proof".into(), Json::Object(proof)); + Ok(Json::Object(secured)) +} + +/// Verifies an `eddsa-jcs-2022` proof on any JSON object and returns the +/// DID of the key that made it. +/// +/// # Errors +/// +/// Returns [`Error::InvalidCredential`] if the proof is missing or +/// malformed, or [`Error::Verification`] if the signature does not match. +pub fn verify(secured: &Json) -> Result { + let bad = |msg: &str| Error::InvalidCredential(msg.into()); + let mut doc = secured.as_object().ok_or_else(|| bad("document must be a JSON object"))?.clone(); + let mut proof = match doc.remove("proof") { + Some(Json::Object(proof)) => proof, + Some(_) => return Err(bad("only a single proof object is supported")), + None => return Err(bad("document has no proof")), + }; + let Some(Json::String(proof_value)) = proof.remove("proofValue") else { + return Err(bad("proof has no string `proofValue`")); + }; + if proof.get("type") != Some(&json!(PROOF_TYPE)) || proof.get("cryptosuite") != Some(&json!(CRYPTOSUITE)) { + return Err(bad("proof is not a DataIntegrityProof using eddsa-jcs-2022")); + } + if proof.get("proofPurpose") != Some(&json!(PROOF_PURPOSE)) { + return Err(bad("proofPurpose must be assertionMethod")); + } + if let Some(created) = proof.get("created") { + created + .as_str() + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| bad("proof `created` is not an RFC 3339 date-time"))?; + } + // The proof's @context must be a prefix of the document's. + if let Some(proof_context) = proof.get("@context") { + let as_list = |v: &Json| match v { + Json::Array(items) => items.clone(), + other => vec![other.clone()], + }; + let doc_context = doc.get("@context").map(as_list).unwrap_or_default(); + if !doc_context.starts_with(&as_list(proof_context)) { + return Err(Error::Verification("proof @context does not match the document".into())); + } + } + + let method = proof + .get("verificationMethod") + .and_then(Json::as_str) + .ok_or_else(|| bad("proof has no `verificationMethod`"))?; + let did: Did = method.parse()?; + let signature: [u8; 64] = proof_value + .strip_prefix('z') + .and_then(|s| bs58::decode(s).into_vec().ok()) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| bad("`proofValue` is not a base58btc multibase Ed25519 signature"))?; + + did.public_key() + .verify(&hash_data(&proof, &doc)?, &signature) + .map_err(|_| Error::Verification("signature does not match the document".into()))?; + Ok(did) +} + +/// `SHA-256(JCS(proof config)) || SHA-256(JCS(document))`. +fn hash_data(proof_config: &Map, document: &Map) -> Result> { + let mut data = Sha256::digest(canonical::to_string(proof_config)?.as_bytes()).to_vec(); + data.extend_from_slice(&Sha256::digest(canonical::to_string(document)?.as_bytes())); + Ok(data) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SPEC_SECRET: &str = "z3u2en7t5LR2WtQH5PfFqMqwVHBeXouLzo6haApm8XHqvjxq"; + + /// Test vector from . + fn spec_unsecured() -> Json { + json!({ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://www.w3.org/ns/credentials/examples/v2" + ], + "id": "urn:uuid:58172aac-d8ba-11ed-83dd-0b3aef56cc33", + "type": ["VerifiableCredential", "AlumniCredential"], + "name": "Alumni Credential", + "description": "A minimum viable example of an Alumni Credential.", + "issuer": "https://vc.example/issuers/5678", + "validFrom": "2023-01-01T00:00:00Z", + "credentialSubject": { + "id": "did:example:abcdefgh", + "alumniOf": "The School of Examples" + } + }) + } + + const SPEC_PROOF_VALUE: &str = + "z2HnFSSPPBzR36zdDgK8PbEHeXbR56YF24jwMpt3R1eHXQzJDMWS93FCzpvJpwTWd3GAVFuUfjoJdcnTMuVor51aX"; + + fn alice() -> Keypair { + Keypair::from_seed(&[1; 32]) + } + + fn signed_atom() -> Json { + let atom = TrustAtom::new(alice().did().to_string(), "https://example.com/sushi-bar") + .with_content("sushi") + .with_value("0.9".parse().unwrap()) + .with_timestamp("2024-05-01T12:00:00Z".parse().unwrap()); + sign_atom(&atom, &alice(), "2024-05-01T12:00:01Z".parse().unwrap()).unwrap() + } + + #[test] + fn reproduces_w3c_test_vector_hashes() { + let doc = spec_unsecured(); + let keypair = Keypair::from_secret_multibase(SPEC_SECRET).unwrap(); + let mut proof = Map::new(); + proof.insert("type".into(), json!(PROOF_TYPE)); + proof.insert("cryptosuite".into(), json!(CRYPTOSUITE)); + proof.insert("created".into(), json!("2023-02-24T23:36:38Z")); + proof.insert("verificationMethod".into(), json!(keypair.did().verification_method())); + proof.insert("proofPurpose".into(), json!(PROOF_PURPOSE)); + proof.insert("@context".into(), doc["@context"].clone()); + let data = hash_data(&proof, doc.as_object().unwrap()).unwrap(); + assert_eq!( + hex::encode(data), + "66ab154f5c2890a140cb8388a22a160454f80575f6eae09e5a097cabe539a1db\ + 59b7cb6251b8991add1ce0bc83107e3db9dbbab5bd2c28f687db1a03abc92f19" + ); + } + + #[test] + fn reproduces_w3c_test_vector_signature() { + let keypair = Keypair::from_secret_multibase(SPEC_SECRET).unwrap(); + let signed = sign(&spec_unsecured(), &keypair, "2023-02-24T23:36:38Z".parse().unwrap()).unwrap(); + assert_eq!(signed["proof"]["proofValue"], SPEC_PROOF_VALUE); + assert_eq!(verify(&signed).unwrap(), keypair.did()); + } + + #[test] + fn verifies_w3c_signed_credential_as_published() { + let mut signed = spec_unsecured(); + signed["proof"] = json!({ + "type": "DataIntegrityProof", + "cryptosuite": "eddsa-jcs-2022", + "created": "2023-02-24T23:36:38Z", + "verificationMethod": "did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2#z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2", + "proofPurpose": "assertionMethod", + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://www.w3.org/ns/credentials/examples/v2" + ], + "proofValue": SPEC_PROOF_VALUE + }); + verify(&signed).unwrap(); + } + + #[test] + fn atom_credential_shape() { + let signed = signed_atom(); + assert_eq!(signed["@context"], json!([CREDENTIALS_V2_CONTEXT, TRUSTGRAPH_CONTEXT])); + assert_eq!(signed["type"], json!(["VerifiableCredential", "TrustAtomCredential"])); + assert_eq!(signed["issuer"], json!(alice().did().as_str())); + assert_eq!(signed["validFrom"], "2024-05-01T12:00:00Z"); + assert_eq!( + signed["credentialSubject"], + json!({ "id": "https://example.com/sushi-bar", "content": "sushi", "value": "0.9" }) + ); + assert_eq!(signed["proof"]["created"], "2024-05-01T12:00:01Z"); + } + + #[test] + fn sign_then_verify_returns_the_atom() { + let atom = verify_atom(&signed_atom()).unwrap(); + assert_eq!(atom.source, alice().did().as_str()); + assert_eq!(atom.target, "https://example.com/sushi-bar"); + assert_eq!(atom.content.as_deref(), Some("sushi")); + assert_eq!(atom.value.unwrap().to_string(), "0.9"); + } + + #[test] + fn atom_credential_round_trip_preserves_every_field() { + let atom = TrustAtom::new("did:key:z6MkA", "did:key:z6MkB") + .with_content("x") + .with_value("-0.5".parse().unwrap()) + .with_timestamp("2020-01-01T00:00:00Z".parse().unwrap()) + .with_extra("k", "v"); + assert_eq!(from_credential(&to_credential(&atom).unwrap()).unwrap(), atom); + let minimal = TrustAtom::new("did:key:z6MkA", "did:key:z6MkB"); + assert_eq!(from_credential(&to_credential(&minimal).unwrap()).unwrap(), minimal); + } + + #[test] + fn tampering_with_any_field_breaks_verification() { + let signed = signed_atom(); + let tamper = |path: &[&str], value: Json| { + let mut doc = signed.clone(); + let mut slot = &mut doc; + for key in path { + slot = &mut slot[*key]; + } + *slot = value; + doc + }; + for doc in [ + tamper(&["credentialSubject", "value"], json!("1")), + tamper(&["credentialSubject", "content"], json!("ramen")), + tamper(&["credentialSubject", "id"], json!("https://evil.example")), + tamper(&["validFrom"], json!("2030-01-01T00:00:00Z")), + tamper(&["proof", "created"], json!("2030-01-01T00:00:00Z")), + tamper(&["@context"], json!([CREDENTIALS_V2_CONTEXT])), + ] { + assert!(verify_atom(&doc).is_err(), "tampered document verified: {doc}"); + } + } + + #[test] + fn rejects_credential_signed_by_someone_other_than_the_issuer() { + let mallory = Keypair::from_seed(&[9; 32]); + let atom = TrustAtom::new(alice().did().to_string(), "https://example.com"); + let forged = sign(&to_credential(&atom).unwrap(), &mallory, Timestamp::UNIX_EPOCH).unwrap(); + // The signature itself is valid... + assert_eq!(verify(&forged).unwrap(), mallory.did()); + // ...but Mallory is not the issuer. + let err = verify_atom(&forged).unwrap_err(); + assert!(matches!(err, Error::Verification(_)), "{err}"); + } + + #[test] + fn refuses_to_sign_for_another_source() { + let atom = TrustAtom::new("did:key:z6MkSomeoneElse", "https://example.com"); + assert!(sign_atom(&atom, &alice(), Timestamp::UNIX_EPOCH).is_err()); + } + + #[test] + fn rejects_malformed_proofs() { + let signed = signed_atom(); + let mut no_proof = signed.clone(); + no_proof.as_object_mut().unwrap().remove("proof"); + assert!(verify(&no_proof).is_err()); + + let mut wrong_suite = signed.clone(); + wrong_suite["proof"]["cryptosuite"] = json!("eddsa-rdfc-2022"); + assert!(verify(&wrong_suite).is_err()); + + let mut bad_value = signed.clone(); + bad_value["proof"]["proofValue"] = json!("not-multibase"); + assert!(verify(&bad_value).is_err()); + + let mut array_proof = signed.clone(); + array_proof["proof"] = json!([signed["proof"].clone()]); + assert!(verify(&array_proof).is_err()); + + assert!(sign(&signed, &alice(), Timestamp::UNIX_EPOCH).is_err(), "double signing"); + assert!(sign(&json!([1, 2]), &alice(), Timestamp::UNIX_EPOCH).is_err()); + } + + #[test] + fn from_credential_rejects_other_credentials() { + assert!(from_credential(&spec_unsecured()).is_err()); + assert!(from_credential(&json!({})).is_err()); + } +} diff --git a/crates/trustgraph/src/error.rs b/crates/trustgraph/src/error.rs new file mode 100644 index 0000000..d71fd54 --- /dev/null +++ b/crates/trustgraph/src/error.rs @@ -0,0 +1,51 @@ +//! Error type shared by the whole crate. + +/// Everything that can go wrong while building, encoding, signing or +/// verifying Trust Graph data. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// A trust value was not a number, or was outside `-1..=1`. + #[error("invalid trust value `{input}`: {reason}")] + InvalidValue { + /// The rejected input. + input: String, + /// Why it was rejected. + reason: &'static str, + }, + + /// A Trust Atom field failed validation. + #[error("invalid trust atom: {0}")] + InvalidAtom(String), + + /// A `did:key` (or multibase key) could not be decoded. + #[error("invalid key: {0}")] + InvalidKey(String), + + /// A credential is malformed (missing fields, wrong types, ...). + #[error("invalid credential: {0}")] + InvalidCredential(String), + + /// A credential is well formed but its proof does not verify. + #[error("verification failed: {0}")] + Verification(String), + + /// A Holochain link tag could not be encoded or decoded. + #[error("invalid Holochain link tag: {0}")] + InvalidLinkTag(String), + + /// The operating system's random number generator failed. + #[error("random number generator failed: {0}")] + Random(String), + + /// JSON (de)serialization failed. + #[error(transparent)] + Json(#[from] serde_json::Error), + + /// Reading or writing a file failed. + #[error(transparent)] + Io(#[from] std::io::Error), +} + +/// Shorthand for `Result`. +pub type Result = std::result::Result; diff --git a/crates/trustgraph/src/graph.rs b/crates/trustgraph/src/graph.rs new file mode 100644 index 0000000..cb02e8f --- /dev/null +++ b/crates/trustgraph/src/graph.rs @@ -0,0 +1,446 @@ +//! Agent-centric trust graphs: the **Agent Lens** and **Trust Cascade**. +//! +//! There is no global reputation score in Trust Graph. Every agent sees the +//! world through their own *lens*: their direct ratings, plus ratings made +//! by the agents they trust, plus ratings made by the agents *those* agents +//! trust, and so on. Each extra hop counts for less: this is the *cascade*. +//! +//! # Algorithm +//! +//! 1. **Edges.** Only atoms with a value are used. If a `topic` is given, +//! only atoms about that topic are used, for both passing trust along and +//! for the final rating: trusting someone about sushi says nothing about +//! their taste in software. If one source rated the same target several +//! times, the latest rating per content wins, and ratings with different +//! contents are averaged. +//! 2. **Reach.** Starting from the root agent (weight 1), trust flows along +//! *positive* edges only. The weight of an agent is the strongest path to +//! it: the product of the edge values along the path, times `decay` for +//! each hop after the first. Distrust is never passed along: you can see +//! who your friends distrust, but not who the people they distrust trust. +//! 3. **Scores.** A target rated directly by the root gets the root's own +//! rating. Otherwise its score is the average of the ratings made by +//! agents the root can reach, weighted by each rater's weight (times +//! `decay`). `confidence` is the weight of the most trusted rater. +//! +//! Only paths of at most `depth` hops, counting the final rating, are used. + +use std::collections::{BTreeMap, HashMap}; + +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; + +use crate::atom::content_matches_topic; +use crate::{Result, TrustAtom, Value}; + +/// Options for [`TrustGraph::lens`]. +#[derive(Debug, Clone, PartialEq)] +pub struct LensOptions { + /// Maximum number of hops from the root to a rated target. `1` means + /// direct ratings only. + pub depth: usize, + /// How much each hop after the first counts, in `0..=1`. + pub decay: f64, + /// If set, only use atoms about this topic. + pub topic: Option, +} + +impl Default for LensOptions { + fn default() -> Self { + Self { depth: 3, decay: 0.5, topic: None } + } +} + +/// One target as seen through an agent's lens. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LensEntry { + /// The target being scored. + pub target: String, + /// The score, in `-1..=1`. + pub score: f64, + /// How strongly the root trusts the most trusted rater (1 for a direct + /// rating), in `0..=1`. + pub confidence: f64, + /// Number of hops from the root to the closest rating. + pub hops: usize, + /// Number of agents whose ratings were combined. + pub raters: usize, +} + +/// `target → content → (timestamp, value)`: one source's latest ratings. +type Ratings = BTreeMap, Value)>>; + +/// A set of Trust Atoms, indexed for lens queries. +#[derive(Debug, Clone, Default)] +pub struct TrustGraph { + /// `source → target → content → (timestamp, value)`, latest rating only. + ratings: HashMap, +} + +impl TrustGraph { + /// An empty graph. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Adds an atom. Atoms without a value are ignored. Of two atoms with + /// the same source, target and content, the later one (by timestamp, + /// then by insertion order) wins. + pub fn insert(&mut self, atom: &TrustAtom) { + let Some(value) = atom.value else { return }; + let slot = self + .ratings + .entry(atom.source.clone()) + .or_default() + .entry(atom.target.clone()) + .or_default() + .entry(atom.content.clone().unwrap_or_default()); + match slot { + std::collections::btree_map::Entry::Vacant(e) => { + e.insert((atom.timestamp, value)); + } + std::collections::btree_map::Entry::Occupied(mut e) => { + if atom.timestamp >= e.get().0 { + e.insert((atom.timestamp, value)); + } + } + } + } + + /// Number of distinct (source, target, content) ratings. + #[must_use] + pub fn len(&self) -> usize { + self.ratings.values().flat_map(BTreeMap::values).map(BTreeMap::len).sum() + } + + /// True if the graph has no ratings. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// The edges out of `source` for `topic`: `target → averaged value`. + fn edges(&self, source: &str, topic: Option<&str>) -> BTreeMap<&str, f64> { + let mut out = BTreeMap::new(); + let Some(targets) = self.ratings.get(source) else { + return out; + }; + for (target, by_content) in targets { + let values: Vec = by_content + .iter() + .filter(|(content, _)| topic.is_none_or(|t| content_matches_topic(content, t))) + .map(|(_, (_, value))| value.as_f64()) + .collect(); + if !values.is_empty() { + #[allow(clippy::cast_precision_loss)] + out.insert(target.as_str(), values.iter().sum::() / values.len() as f64); + } + } + out + } + + /// Everything `root` can see, best first (highest score, then highest + /// confidence, then target). + #[must_use] + pub fn lens(&self, root: &str, options: &LensOptions) -> Vec { + let topic = options.topic.as_deref(); + let decay = options.decay.clamp(0.0, 1.0); + if options.depth == 0 { + return Vec::new(); + } + + // Reach: best weight to each agent using at most depth - 1 hops + // (bounded Bellman-Ford, maximizing the product of weights). + let mut reach: BTreeMap<&str, (f64, usize)> = BTreeMap::from([(root, (1.0, 0))]); + for hop in 1..options.depth { + // Relax from the previous round's snapshot so paths grow by at most one hop per round. + let mut next = reach.clone(); + for (&agent, &(weight, _)) in &reach { + let factor = if agent == root { 1.0 } else { decay }; + for (target, value) in self.edges(agent, topic) { + if value <= 0.0 || target == root { + continue; + } + let candidate = weight * value * factor; + // Exact float equality is intended: it only breaks exact ties. + #[allow(clippy::float_cmp)] + let better = next.get(target).is_none_or(|&(w, h)| candidate > w || (candidate == w && hop < h)); + if better { + next.insert(target, (candidate, hop)); + } + } + } + if next == reach { + break; + } + reach = next; + } + + // Scores. + let direct = self.edges(root, topic); + let mut sums: BTreeMap<&str, (f64, f64, f64, usize, usize)> = BTreeMap::new(); // (Σw·v, Σw, max w, min hops, raters) + for (&agent, &(weight, hops)) in &reach { + if agent == root || weight <= 0.0 { + continue; + } + let voice = weight * decay; + if voice <= 0.0 { + continue; + } + for (target, value) in self.edges(agent, topic) { + if target == root || direct.contains_key(target) { + continue; + } + let e = sums.entry(target).or_insert((0.0, 0.0, 0.0, usize::MAX, 0)); + e.0 += voice * value; + e.1 += voice; + e.2 = e.2.max(voice); + e.3 = e.3.min(hops + 1); + e.4 += 1; + } + } + + let mut entries: Vec = direct + .iter() + .map(|(&target, &value)| LensEntry { + target: target.to_owned(), + score: round9(value), + confidence: 1.0, + hops: 1, + raters: 1, + }) + .chain(sums.into_iter().map(|(target, (weighted, total, max, hops, raters))| LensEntry { + target: target.to_owned(), + score: round9((weighted / total).clamp(-1.0, 1.0)), + confidence: round9(max), + hops, + raters, + })) + .collect(); + entries.sort_by(|a, b| { + b.score + .total_cmp(&a.score) + .then(b.confidence.total_cmp(&a.confidence)) + .then_with(|| a.target.cmp(&b.target)) + }); + entries + } + + /// Turns lens results into *rollup* atoms: cached trust, from the root's + /// point of view, that can be stored and shared like any other atom. + /// Each rollup is marked with `extra.rollup = "agent-lens"` and the + /// parameters used. + /// + /// # Errors + /// + /// Fails only if a score is not finite, which `lens` never produces. + pub fn rollup(root: &str, entries: &[LensEntry], options: &LensOptions, at: Timestamp) -> Result> { + entries + .iter() + .map(|entry| { + let mut atom = TrustAtom::new(root, &entry.target) + .with_value(Value::from_f64(entry.score)?) + .with_timestamp(at) + .with_extra("rollup", "agent-lens") + .with_extra("depth", options.depth.to_string()) + .with_extra("decay", options.decay.to_string()) + .with_extra("confidence", format!("{:.6}", entry.confidence)); + atom.content.clone_from(&options.topic); + Ok(atom) + }) + .collect() + } +} + +impl<'a> FromIterator<&'a TrustAtom> for TrustGraph { + fn from_iter>(iter: I) -> Self { + let mut graph = Self::new(); + for atom in iter { + graph.insert(atom); + } + graph + } +} + +/// Rounds to nine decimal places (the precision of a [`Value`]), hiding +/// floating-point noise such as `0.18000000000000002`. +fn round9(x: f64) -> f64 { + (x * 1e9).round() / 1e9 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rate(source: &str, target: &str, value: &str) -> TrustAtom { + TrustAtom::new(source, target).with_value(value.parse().unwrap()) + } + + fn about(source: &str, target: &str, value: &str, topic: &str) -> TrustAtom { + rate(source, target, value).with_content(topic) + } + + fn lens(atoms: &[TrustAtom], root: &str, options: &LensOptions) -> BTreeMap { + atoms.iter().collect::().lens(root, options).into_iter().map(|e| (e.target.clone(), e)).collect() + } + + fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 + } + + #[test] + fn direct_ratings_are_returned_as_is() { + let out = + lens(&[rate("alice", "bob", "0.8"), rate("alice", "carol", "-0.5")], "alice", &LensOptions::default()); + assert!(close(out["bob"].score, 0.8)); + assert!(close(out["bob"].confidence, 1.0)); + assert_eq!(out["bob"].hops, 1); + assert!(close(out["carol"].score, -0.5)); + } + + #[test] + fn trust_cascades_with_decay() { + let atoms = [rate("alice", "bob", "0.8"), rate("bob", "carol", "1"), rate("carol", "dave", "0.5")]; + let out = lens(&atoms, "alice", &LensOptions::default()); + // carol: rated by bob (weight 0.8), voice 0.8 * 0.5. + assert!(close(out["carol"].score, 1.0)); + assert!(close(out["carol"].confidence, 0.4)); + assert_eq!(out["carol"].hops, 2); + // dave: carol's weight is 0.8 * 1 * 0.5 = 0.4, voice 0.2. + assert!(close(out["dave"].score, 0.5)); + assert!(close(out["dave"].confidence, 0.2)); + assert_eq!(out["dave"].hops, 3); + } + + #[test] + fn depth_limits_the_cascade() { + let atoms = [rate("alice", "bob", "1"), rate("bob", "carol", "1"), rate("carol", "dave", "1")]; + let one = lens(&atoms, "alice", &LensOptions { depth: 1, ..LensOptions::default() }); + assert_eq!(one.keys().collect::>(), ["bob"]); + let two = lens(&atoms, "alice", &LensOptions { depth: 2, ..LensOptions::default() }); + assert_eq!(two.keys().collect::>(), ["bob", "carol"]); + assert!(lens(&atoms, "alice", &LensOptions { depth: 0, ..LensOptions::default() }).is_empty()); + } + + #[test] + fn distrust_is_visible_but_not_transitive() { + let atoms = [rate("alice", "bob", "1"), rate("bob", "mallory", "-1"), rate("mallory", "scam", "1")]; + let out = lens(&atoms, "alice", &LensOptions::default()); + assert!(close(out["mallory"].score, -1.0)); + assert!(!out.contains_key("scam")); + } + + #[test] + fn own_rating_overrides_friends() { + let atoms = [rate("alice", "bob", "1"), rate("bob", "carol", "1"), rate("alice", "carol", "-0.2")]; + let out = lens(&atoms, "alice", &LensOptions::default()); + assert!(close(out["carol"].score, -0.2)); + assert!(close(out["carol"].confidence, 1.0)); + } + + #[test] + fn opinions_are_weighted_by_trust() { + let atoms = [ + rate("alice", "bob", "1"), + rate("alice", "carol", "0.25"), + rate("bob", "cafe", "1"), + rate("carol", "cafe", "-1"), + ]; + let out = lens(&atoms, "alice", &LensOptions::default()); + // voices: bob 0.5, carol 0.125 → (0.5 - 0.125) / 0.625 = 0.6 + assert!(close(out["cafe"].score, 0.6)); + assert_eq!(out["cafe"].raters, 2); + assert!(close(out["cafe"].confidence, 0.5)); + } + + #[test] + fn strongest_path_wins_even_if_longer() { + let atoms = [ + rate("alice", "weak", "0.1"), + rate("weak", "target", "0.1"), + rate("alice", "strong", "1"), + rate("strong", "middle", "1"), + rate("middle", "target", "1"), + rate("target", "x", "1"), + ]; + let out = lens(&atoms, "alice", &LensOptions { depth: 4, ..LensOptions::default() }); + // target's weight via weak: 0.1 * 0.1 * 0.5 = 0.005 (2 hops); + // via strong → middle: 1 * (1 * 0.5) * (1 * 0.5) = 0.25 (3 hops). + // So x is rated with voice 0.25 * 0.5. + assert!(close(out["x"].confidence, 0.125)); + assert_eq!(out["x"].hops, 4); + } + + #[test] + fn topics_filter_the_whole_cascade() { + let atoms = [ + about("alice", "bob", "1", "sushi"), + about("bob", "sushi-bar", "0.9", "sushi"), + about("alice", "carol", "1", "rust"), + about("carol", "other-bar", "0.9", "sushi"), + ]; + let out = lens(&atoms, "alice", &LensOptions { topic: Some("sushi".into()), ..LensOptions::default() }); + assert!(out.contains_key("sushi-bar")); + assert!(!out.contains_key("other-bar"), "carol is trusted for rust, not sushi"); + assert!(!out.contains_key("carol")); + } + + #[test] + fn latest_rating_wins_and_contents_are_averaged() { + let atoms = [ + rate("alice", "bob", "0.2").with_timestamp("2020-01-01T00:00:00Z".parse().unwrap()), + rate("alice", "bob", "1").with_timestamp("2024-01-01T00:00:00Z".parse().unwrap()), + rate("alice", "bob", "-1").with_timestamp("2010-01-01T00:00:00Z".parse().unwrap()), + about("alice", "bob", "0", "chess"), + ]; + let graph: TrustGraph = atoms.iter().collect(); + assert_eq!(graph.len(), 2); + let out = lens(&atoms, "alice", &LensOptions::default()); + assert!(close(out["bob"].score, 0.5)); + } + + #[test] + fn cycles_terminate_and_root_is_excluded() { + let atoms = [ + rate("alice", "bob", "1"), + rate("bob", "alice", "1"), + rate("bob", "carol", "1"), + rate("carol", "bob", "1"), + ]; + let out = lens(&atoms, "alice", &LensOptions { depth: 10, ..LensOptions::default() }); + assert!(!out.contains_key("alice")); + assert_eq!(out.len(), 2); + } + + #[test] + fn atoms_without_values_are_ignored() { + let graph: TrustGraph = [TrustAtom::new("alice", "bob")].iter().collect(); + assert!(graph.is_empty()); + assert!(graph.lens("alice", &LensOptions::default()).is_empty()); + } + + #[test] + fn results_are_sorted_best_first() { + let atoms = [rate("alice", "a", "0.1"), rate("alice", "b", "0.9"), rate("alice", "c", "-0.3")]; + let graph: TrustGraph = atoms.iter().collect(); + let order: Vec<_> = graph.lens("alice", &LensOptions::default()).into_iter().map(|e| e.target).collect(); + assert_eq!(order, ["b", "a", "c"]); + } + + #[test] + fn rollups_are_valid_atoms() { + let atoms = [about("alice", "bob", "1", "sushi"), about("bob", "bar", "0.8", "sushi")]; + let graph: TrustGraph = atoms.iter().collect(); + let options = LensOptions { topic: Some("sushi".into()), ..LensOptions::default() }; + let entries = graph.lens("alice", &options); + let rollups = TrustGraph::rollup("alice", &entries, &options, Timestamp::UNIX_EPOCH).unwrap(); + assert_eq!(rollups.len(), 2); + for atom in &rollups { + atom.validate().unwrap(); + assert_eq!(atom.source, "alice"); + assert_eq!(atom.content.as_deref(), Some("sushi")); + assert_eq!(atom.extra["rollup"], "agent-lens"); + } + } +} diff --git a/crates/trustgraph/src/holochain.rs b/crates/trustgraph/src/holochain.rs new file mode 100644 index 0000000..62577ed --- /dev/null +++ b/crates/trustgraph/src/holochain.rs @@ -0,0 +1,285 @@ +//! The Holochain link-tag encoding used by +//! [`trustgraph-holochain`](https://github.com/trustgraph/trustgraph-holochain). +//! +//! A Trust Atom is stored on Holochain as two links: a forward link +//! `source → target` and a reverse link `target → source`. Both carry a tag: +//! +//! ```text +//! Ŧ→ content NUL value NUL bucket NUL extra +//! Ŧ↩ content NUL value NUL bucket NUL extra +//! ``` +//! +//! Holochain can search links by tag prefix, so this layout makes atoms +//! searchable by topic (`Ŧ→sushi`) and by topic and value +//! (`Ŧ→sushi\0.9`). The source and target are the link's base and target +//! hashes, so they are not part of the tag. + +use crate::{Error, Result, TrustAtom, Value}; + +/// `Ŧ` (U+0166), the first two bytes of every Trust Atom link tag. +pub const HEADER: &str = "Ŧ"; +/// `→`: the link points from source to target. +pub const ARROW_FORWARD: &str = "→"; +/// `↩`: the link points from target back to source. +pub const ARROW_REVERSE: &str = "↩"; +/// Separates the chunks of a tag. +pub const SEPARATOR: char = '\0'; +/// Maximum size of `content`, in bytes. +pub const MAX_CONTENT_BYTES: usize = 900; +/// Maximum size of a whole tag, in bytes. +pub const MAX_TAG_BYTES: usize = 999; +/// Number of digits in a bucket. +pub const BUCKET_DIGITS: usize = 9; + +/// Which way a link points. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Direction { + /// Base is the atom's source; target is the atom's target. + Forward, + /// Base is the atom's target; target is the atom's source. + Reverse, +} + +impl Direction { + fn arrow(self) -> &'static str { + match self { + Self::Forward => ARROW_FORWARD, + Self::Reverse => ARROW_REVERSE, + } + } +} + +/// A decoded Trust Atom link tag. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LinkTag { + /// Which way the link points. + pub direction: Direction, + /// The atom's content. + pub content: Option, + /// The atom's value. + pub value: Option, + /// Nine random digits, so that many links with the same tag prefix can + /// be spread over buckets. + pub bucket: Option, + /// The hash of an entry holding extra fields (as a string, e.g. `uhCEk…`). + pub extra: Option, +} + +impl LinkTag { + /// Builds the tag for `atom` in the given direction. + /// + /// The atom's `extra` fields are not part of the tag: on Holochain they + /// live in a separate entry whose hash is passed as `extra_hash`. + #[must_use] + pub fn for_atom( + atom: &TrustAtom, + direction: Direction, + bucket: Option, + extra_hash: Option, + ) -> Self { + Self { direction, content: atom.content.clone(), value: atom.value, bucket, extra: extra_hash } + } + + /// Encodes the tag as bytes. + /// + /// # Errors + /// + /// Returns [`Error::InvalidLinkTag`] if a chunk contains a NUL byte, the + /// content is longer than 900 bytes, the bucket is not nine digits, or + /// the tag is longer than 999 bytes. + pub fn encode(&self) -> Result> { + let content = self.content.as_deref().unwrap_or_default(); + if content.len() > MAX_CONTENT_BYTES { + return Err(bad(format!("content is {} bytes; the maximum is {MAX_CONTENT_BYTES}", content.len()))); + } + if let Some(bucket) = + self.bucket.as_deref().filter(|b| b.len() != BUCKET_DIGITS || !b.bytes().all(|d| d.is_ascii_digit())) + { + return Err(bad(format!("bucket must be {BUCKET_DIGITS} digits, got `{bucket}`"))); + } + let value = self.value.map(Value::to_holochain_string); + let chunks = [Some(content), value.as_deref(), self.bucket.as_deref(), self.extra.as_deref()]; + if chunks.iter().flatten().any(|chunk| chunk.contains(SEPARATOR)) { + return Err(bad("chunks must not contain NUL bytes".into())); + } + + let mut tag = String::from(HEADER); + tag.push_str(self.direction.arrow()); + for (i, chunk) in chunks.iter().enumerate() { + if i > 0 { + tag.push(SEPARATOR); + } + tag.push_str(chunk.unwrap_or_default()); + } + if tag.len() > MAX_TAG_BYTES { + return Err(bad(format!("tag is {} bytes; the maximum is {MAX_TAG_BYTES}", tag.len()))); + } + Ok(tag.into_bytes()) + } + + /// Decodes a tag. + /// + /// # Errors + /// + /// Returns [`Error::InvalidLinkTag`] if the bytes are not a Trust Atom + /// link tag. + pub fn decode(bytes: &[u8]) -> Result { + let tag = std::str::from_utf8(bytes).map_err(|_| bad("tag is not UTF-8".into()))?; + let rest = tag.strip_prefix(HEADER).ok_or_else(|| bad("tag does not start with `Ŧ`".into()))?; + let (direction, rest) = if let Some(rest) = rest.strip_prefix(ARROW_FORWARD) { + (Direction::Forward, rest) + } else if let Some(rest) = rest.strip_prefix(ARROW_REVERSE) { + (Direction::Reverse, rest) + } else { + return Err(bad("tag has no direction arrow".into())); + }; + + let mut chunks = rest.split(SEPARATOR); + let mut next = || chunks.next().filter(|s| !s.is_empty()).map(str::to_owned); + let content = next(); + let value = next().map(|v| v.parse::()).transpose()?; + let bucket = next(); + let extra = next(); + if chunks.next().is_some() { + return Err(bad("tag has too many chunks".into())); + } + Ok(Self { direction, content, value, bucket, extra }) + } + + /// Rebuilds the atom, given the link's base and target (as strings). + /// For a [`Direction::Reverse`] link they are swapped. + #[must_use] + pub fn to_atom(&self, link_base: &str, link_target: &str) -> TrustAtom { + let (source, target) = match self.direction { + Direction::Forward => (link_base, link_target), + Direction::Reverse => (link_target, link_base), + }; + TrustAtom { content: self.content.clone(), value: self.value, ..TrustAtom::new(source, target) } + } +} + +/// Generates a random bucket of nine digits, as the reference implementation does. +/// +/// # Errors +/// +/// Returns [`Error::Random`] if the OS cannot provide randomness. +pub fn random_bucket() -> Result { + let mut bytes = [0u8; BUCKET_DIGITS]; + getrandom::fill(&mut bytes).map_err(|e| Error::Random(e.to_string()))?; + Ok(bucket_from_bytes(&bytes)) +} + +fn bucket_from_bytes(bytes: &[u8]) -> String { + bytes.iter().map(|b| char::from(b'0' + b % 10)).collect() +} + +fn bad(msg: String) -> Error { + Error::InvalidLinkTag(msg) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tag(content: Option<&str>, value: Option<&str>, bucket: Option<&str>, extra: Option<&str>) -> LinkTag { + LinkTag { + direction: Direction::Forward, + content: content.map(Into::into), + value: value.map(|v| v.parse().unwrap()), + bucket: bucket.map(Into::into), + extra: extra.map(Into::into), + } + } + + #[test] + fn header_bytes_match_reference_implementation() { + assert_eq!(HEADER.as_bytes(), [0xC5, 0xA6]); + assert_eq!(ARROW_FORWARD.as_bytes(), [0xE2, 0x86, 0x92]); + assert_eq!(ARROW_REVERSE.as_bytes(), [0xE2, 0x86, 0xA9]); + } + + // Matches the "Full Example Link Tags" in the trustgraph-holochain README. + #[test] + fn encodes_readme_example() { + let forward = tag(Some("sushi"), Some("1"), Some("892412523"), Some("uhCEkUFnFF")); + assert_eq!(forward.encode().unwrap(), "Ŧ→sushi\0.999999999\0892412523\0uhCEkUFnFF".as_bytes()); + let reverse = LinkTag { direction: Direction::Reverse, ..forward }; + assert_eq!(reverse.encode().unwrap(), "Ŧ↩sushi\0.999999999\0892412523\0uhCEkUFnFF".as_bytes()); + } + + #[test] + fn missing_chunks_leave_empty_slots() { + assert_eq!(tag(None, None, None, None).encode().unwrap(), "Ŧ→\0\0\0".as_bytes()); + assert_eq!( + tag(Some("x"), None, Some("000000000"), None).encode().unwrap(), + "Ŧ→x\u{0}\u{0}000000000\u{0}".as_bytes() + ); + } + + #[test] + fn decode_round_trips() { + for t in [ + tag(Some("sushi"), Some("0.9"), Some("123456789"), Some("uhCEk")), + tag(Some("🍣 Ŧ"), Some("-1"), None, None), + tag(None, None, None, None), + LinkTag { direction: Direction::Reverse, ..tag(Some("a"), Some("0"), None, None) }, + ] { + let decoded = LinkTag::decode(&t.encode().unwrap()).unwrap(); + // ±1 is stored as ±0.999999999. + let expected_value = t.value.map(|v| v.to_holochain_string().parse::().unwrap()); + assert_eq!(decoded, LinkTag { value: expected_value, ..t }); + } + } + + #[test] + fn decodes_tags_without_trailing_chunks() { + // Older tags may stop after the value. + let t = LinkTag::decode("Ŧ→sushi\0.5".as_bytes()).unwrap(); + assert_eq!(t.content.as_deref(), Some("sushi")); + assert_eq!(t.value.unwrap().to_string(), "0.5"); + assert_eq!(t.bucket, None); + } + + #[test] + fn rejects_bad_tags() { + for bytes in [ + &b""[..], + b"sushi", + "Ŧsushi".as_bytes(), + "Ŧ→sushi\0two\0".as_bytes(), + "Ŧ→a\u{0}.5\u{0}123456789\u{0}x\u{0}extra".as_bytes(), + &[0xC5, 0xA6, 0xFF], + ] { + assert!(LinkTag::decode(bytes).is_err(), "{bytes:?}"); + } + } + + #[test] + fn enforces_size_limits() { + assert!(tag(Some(&"a".repeat(900)), None, None, None).encode().is_ok()); + assert!(tag(Some(&"a".repeat(901)), None, None, None).encode().is_err()); + let long_extra = "e".repeat(200); + assert!(tag(Some(&"a".repeat(900)), Some("0.5"), Some("123456789"), Some(&long_extra)).encode().is_err()); + assert!(tag(Some("nul\0"), None, None, None).encode().is_err()); + assert!(tag(None, None, Some("12345"), None).encode().is_err()); + } + + #[test] + fn rebuilds_atoms_in_both_directions() { + let forward = tag(Some("sushi"), Some("0.5"), None, None); + let atom = forward.to_atom("agent", "restaurant"); + assert_eq!((atom.source.as_str(), atom.target.as_str()), ("agent", "restaurant")); + let reverse = LinkTag { direction: Direction::Reverse, ..forward }; + let atom = reverse.to_atom("restaurant", "agent"); + assert_eq!((atom.source.as_str(), atom.target.as_str()), ("agent", "restaurant")); + } + + // Ported from trustgraph-holochain `test_bucket_val`. + #[test] + fn bucket_digits_match_reference_implementation() { + assert_eq!(bucket_from_bytes(&[9, 10, 11, 12, 13, 14, 15, 16, 17]), "901234567"); + let bucket = random_bucket().unwrap(); + assert_eq!(bucket.len(), 9); + assert!(bucket.bytes().all(|b| b.is_ascii_digit())); + } +} diff --git a/crates/trustgraph/src/id.rs b/crates/trustgraph/src/id.rs new file mode 100644 index 0000000..f3c68a1 --- /dev/null +++ b/crates/trustgraph/src/id.rs @@ -0,0 +1,96 @@ +//! Content-addressed identifiers. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::{Error, Result}; + +/// Multihash prefix for SHA2-256 (code `0x12`, length `0x20`). +const SHA2_256_PREFIX: [u8; 2] = [0x12, 0x20]; + +/// A SHA2-256 [multihash](https://multiformats.io/multihash/), written in +/// base58btc. This is the familiar IPFS `Qm…` form used in the Trust Graph +/// protocol docs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(into = "String", try_from = "String")] +pub struct ContentId([u8; 32]); + +impl ContentId { + /// Hashes `bytes`. + #[must_use] + pub fn of_bytes(bytes: &[u8]) -> Self { + Self(Sha256::digest(bytes).into()) + } + + /// The raw SHA2-256 digest. + #[must_use] + pub const fn digest(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Display for ContentId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut bytes = Vec::with_capacity(34); + bytes.extend_from_slice(&SHA2_256_PREFIX); + bytes.extend_from_slice(&self.0); + f.write_str(&bs58::encode(bytes).into_string()) + } +} + +impl FromStr for ContentId { + type Err = Error; + + fn from_str(s: &str) -> Result { + let bad = || Error::InvalidAtom(format!("`{s}` is not a SHA2-256 multihash")); + let bytes = bs58::decode(s).into_vec().map_err(|_| bad())?; + let digest = bytes.strip_prefix(&SHA2_256_PREFIX).ok_or_else(bad)?; + Ok(Self(digest.try_into().map_err(|_| bad())?)) + } +} + +impl From for String { + fn from(id: ContentId) -> Self { + id.to_string() + } +} + +impl TryFrom for ContentId { + type Error = Error; + + fn try_from(s: String) -> Result { + s.parse() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_ipfs_multihash_of_known_input() { + // SHA-256("hello"), multihash-prefixed and base58btc-encoded. + let id = ContentId::of_bytes(b"hello"); + assert_eq!(hex::encode(id.digest()), "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"); + assert_eq!(id.to_string(), "QmRN6wdp1S2A5EtjW9A3M1vKSBuQQGcgvuhoMUoEz4iiT5"); + } + + #[test] + fn round_trips_through_string_and_json() { + let id = ContentId::of_bytes(b"trust"); + assert!(id.to_string().starts_with("Qm")); + assert_eq!(id.to_string().parse::().unwrap(), id); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), id); + } + + #[test] + fn rejects_other_strings() { + for s in ["", "Qm", "not base58 0OIl", "zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme"] { + assert!(s.parse::().is_err(), "{s}"); + } + } +} diff --git a/crates/trustgraph/src/keys.rs b/crates/trustgraph/src/keys.rs new file mode 100644 index 0000000..0b606bc --- /dev/null +++ b/crates/trustgraph/src/keys.rs @@ -0,0 +1,287 @@ +//! Ed25519 identities, written as [`did:key`](https://w3c-ccg.github.io/did-key-spec/) DIDs. + +use std::fmt; +use std::str::FromStr; + +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +use crate::{Error, Result}; + +/// Multicodec prefix for an Ed25519 public key (`0xed`, varint-encoded). +const ED25519_PUB: [u8; 2] = [0xed, 0x01]; +/// Multicodec prefix for an Ed25519 private key (`0x1300`, varint-encoded). +const ED25519_PRIV: [u8; 2] = [0x80, 0x26]; +const DID_KEY_PREFIX: &str = "did:key:"; + +/// An Ed25519 key pair: an agent's identity. +/// +/// The secret key is wiped from memory when the key pair is dropped. +#[derive(Clone)] +pub struct Keypair(SigningKey); + +impl Keypair { + /// Generates a new key pair using the operating system's secure random + /// number generator. + /// + /// # Errors + /// + /// Returns [`Error::Random`] if the OS cannot provide randomness. + pub fn generate() -> Result { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).map_err(|e| Error::Random(e.to_string()))?; + Ok(Self::from_seed(&seed)) + } + + /// Builds a key pair from a 32-byte Ed25519 seed. + #[must_use] + pub fn from_seed(seed: &[u8; 32]) -> Self { + Self(SigningKey::from_bytes(seed)) + } + + /// Parses a `secretKeyMultibase` string (`z3u2…`), as used in W3C + /// Multikey documents. + /// + /// # Errors + /// + /// Returns [`Error::InvalidKey`] if the string is not an Ed25519 secret key. + pub fn from_secret_multibase(s: &str) -> Result { + let seed = decode_multibase(s, ED25519_PRIV, "Ed25519 secret key")?; + Ok(Self::from_seed(&seed)) + } + + /// The secret key as `secretKeyMultibase`. Handle with care. + #[must_use] + pub fn to_secret_multibase(&self) -> String { + encode_multibase(ED25519_PRIV, self.0.as_bytes()) + } + + /// The public half of this key pair. + #[must_use] + pub fn public(&self) -> PublicKey { + PublicKey(self.0.verifying_key()) + } + + /// This identity's `did:key`. + #[must_use] + pub fn did(&self) -> Did { + self.public().did() + } + + /// Signs `message` with pure Ed25519. + #[must_use] + pub fn sign(&self, message: &[u8]) -> [u8; 64] { + self.0.sign(message).to_bytes() + } +} + +impl fmt::Debug for Keypair { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Keypair").field("did", &self.did()).finish_non_exhaustive() + } +} + +/// An Ed25519 public key. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct PublicKey(VerifyingKey); + +impl PublicKey { + /// The key as `publicKeyMultibase` (`z6Mk…`). + #[must_use] + pub fn to_multibase(&self) -> String { + encode_multibase(ED25519_PUB, self.0.as_bytes()) + } + + /// Parses a `publicKeyMultibase` string (`z6Mk…`). + /// + /// # Errors + /// + /// Returns [`Error::InvalidKey`] if the string is not an Ed25519 public key. + pub fn from_multibase(s: &str) -> Result { + let bytes = decode_multibase(s, ED25519_PUB, "Ed25519 public key")?; + VerifyingKey::from_bytes(&bytes) + .map(Self) + .map_err(|_| Error::InvalidKey(format!("`{s}` is not a valid Ed25519 point"))) + } + + /// The `did:key` for this public key. + #[must_use] + pub fn did(&self) -> Did { + Did(format!("{DID_KEY_PREFIX}{}", self.to_multibase())) + } + + /// Verifies a pure Ed25519 signature over `message`. + /// + /// # Errors + /// + /// Returns [`Error::Verification`] if the signature does not match. + pub fn verify(&self, message: &[u8], signature: &[u8; 64]) -> Result<()> { + self.0 + .verify(message, &Signature::from_bytes(signature)) + .map_err(|_| Error::Verification("signature does not match".into())) + } +} + +impl fmt::Debug for PublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("PublicKey").field(&self.to_multibase()).finish() + } +} + +/// A `did:key` decentralized identifier for an Ed25519 key, e.g. +/// `did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(into = "String", try_from = "String")] +pub struct Did(String); + +impl Did { + /// The DID's public key. + /// + /// # Panics + /// + /// Never: every `Did` is validated when it is constructed. + #[must_use] + pub fn public_key(&self) -> PublicKey { + // Every `Did` is validated on construction. + PublicKey::from_multibase(self.multibase()).expect("Did holds a valid key") + } + + /// The multibase part after `did:key:`. + #[must_use] + pub fn multibase(&self) -> &str { + &self.0[DID_KEY_PREFIX.len()..] + } + + /// The verification method ID used in proofs: `did:key:z…#z…`. + #[must_use] + pub fn verification_method(&self) -> String { + format!("{}#{}", self.0, self.multibase()) + } + + /// The DID as a string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for Did { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl FromStr for Did { + type Err = Error; + + /// Parses `did:key:z6Mk…`. A `#fragment` (as in verification method + /// IDs) is accepted if it repeats the key, and is dropped. + fn from_str(s: &str) -> Result { + let (did, fragment) = s.split_once('#').map_or((s, None), |(d, f)| (d, Some(f))); + let multibase = + did.strip_prefix(DID_KEY_PREFIX).ok_or_else(|| Error::InvalidKey(format!("`{s}` is not a did:key")))?; + if fragment.is_some_and(|f| f != multibase) { + return Err(Error::InvalidKey(format!("`{s}` has an unexpected fragment"))); + } + PublicKey::from_multibase(multibase)?; + Ok(Self(did.to_owned())) + } +} + +impl From for String { + fn from(did: Did) -> Self { + did.0 + } +} + +impl TryFrom for Did { + type Error = Error; + + fn try_from(s: String) -> Result { + s.parse() + } +} + +fn encode_multibase(codec: [u8; 2], key: &[u8; 32]) -> String { + let mut bytes = Vec::with_capacity(34); + bytes.extend_from_slice(&codec); + bytes.extend_from_slice(key); + format!("z{}", bs58::encode(bytes).into_string()) +} + +fn decode_multibase(s: &str, codec: [u8; 2], what: &str) -> Result<[u8; 32]> { + let bad = || Error::InvalidKey(format!("`{s}` is not a base58btc multibase {what}")); + let encoded = s.strip_prefix('z').ok_or_else(bad)?; + let bytes = bs58::decode(encoded).into_vec().map_err(|_| bad())?; + let key = bytes.strip_prefix(&codec).ok_or_else(bad)?; + key.try_into().map_err(|_| bad()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Key pair from the W3C vc-di-eddsa spec, eddsa-jcs-2022 test vectors. + const SPEC_SECRET: &str = "z3u2en7t5LR2WtQH5PfFqMqwVHBeXouLzo6haApm8XHqvjxq"; + const SPEC_PUBLIC: &str = "z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2"; + + #[test] + fn derives_spec_public_key_from_spec_secret_key() { + let keypair = Keypair::from_secret_multibase(SPEC_SECRET).unwrap(); + assert_eq!(keypair.public().to_multibase(), SPEC_PUBLIC); + assert_eq!(keypair.did().as_str(), format!("did:key:{SPEC_PUBLIC}")); + assert_eq!(keypair.to_secret_multibase(), SPEC_SECRET); + } + + #[test] + fn generated_keys_are_unique_and_round_trip() { + let a = Keypair::generate().unwrap(); + let b = Keypair::generate().unwrap(); + assert_ne!(a.did(), b.did()); + let restored = Keypair::from_secret_multibase(&a.to_secret_multibase()).unwrap(); + assert_eq!(restored.did(), a.did()); + assert!(a.did().multibase().starts_with("z6Mk")); + } + + #[test] + fn sign_and_verify() { + let keypair = Keypair::from_seed(&[7; 32]); + let sig = keypair.sign(b"trust"); + keypair.public().verify(b"trust", &sig).unwrap(); + assert!(keypair.public().verify(b"trusT", &sig).is_err()); + let other = Keypair::from_seed(&[8; 32]); + assert!(other.public().verify(b"trust", &sig).is_err()); + } + + #[test] + fn parses_dids_and_verification_methods() { + let did: Did = format!("did:key:{SPEC_PUBLIC}").parse().unwrap(); + assert_eq!(did.public_key().to_multibase(), SPEC_PUBLIC); + assert_eq!(did.verification_method(), format!("did:key:{SPEC_PUBLIC}#{SPEC_PUBLIC}")); + assert_eq!(did.verification_method().parse::().unwrap(), did); + } + + #[test] + fn rejects_bad_dids() { + for s in [ + "", + "did:example:123", + "did:key:", + "did:key:6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2", + "did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ", + &format!("did:key:{SPEC_PUBLIC}#key-1"), + // A secp256k1 did:key: valid, but not Ed25519. + "did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme", + ] { + assert!(s.parse::().is_err(), "{s}"); + } + } + + #[test] + fn debug_output_never_contains_the_secret() { + let keypair = Keypair::from_secret_multibase(SPEC_SECRET).unwrap(); + let debug = format!("{keypair:?}"); + assert!(!debug.contains(SPEC_SECRET)); + assert!(debug.contains(SPEC_PUBLIC)); + } +} diff --git a/crates/trustgraph/src/lib.rs b/crates/trustgraph/src/lib.rs new file mode 100644 index 0000000..0ba256d --- /dev/null +++ b/crates/trustgraph/src/lib.rs @@ -0,0 +1,46 @@ +//! # Trust Graph +//! +//! An open protocol for sourcing and rendering trust relationships +//! ([trustgraph.net](https://trustgraph.net)). +//! +//! - [`TrustAtom`]: one statement of trust: *source* trusts *target*, +//! regarding *content*, to the degree *value* (`-1..=1`). +//! - [`Keypair`] / [`Did`]: Ed25519 identities as `did:key` DIDs. +//! - [`credential`]: signed atoms as W3C Verifiable Credentials 2.0, using +//! the `eddsa-jcs-2022` cryptosuite. +//! - [`TrustGraph`]: the **Agent Lens**, everything one agent can see +//! through the **Trust Cascade** of the agents they trust. +//! - [`holochain`]: the link-tag encoding used by `trustgraph-holochain`. +//! - [`Store`]: a local append-only store of atoms. +//! +//! ``` +//! use trustgraph::{Keypair, TrustAtom, credential}; +//! +//! let alice = Keypair::generate()?; +//! let atom = TrustAtom::new(alice.did().to_string(), "https://example.com/sushi-bar") +//! .with_content("sushi") +//! .with_value("0.9".parse()?); +//! +//! let signed = credential::sign_atom(&atom, &alice, jiff::Timestamp::now())?; +//! assert_eq!(credential::verify_atom(&signed)?, atom); +//! # Ok::<(), trustgraph::Error>(()) +//! ``` + +pub mod atom; +pub mod canonical; +pub mod credential; +pub mod error; +pub mod graph; +pub mod holochain; +pub mod id; +pub mod keys; +pub mod store; +pub mod value; + +pub use atom::TrustAtom; +pub use error::{Error, Result}; +pub use graph::{LensEntry, LensOptions, TrustGraph}; +pub use id::ContentId; +pub use keys::{Did, Keypair, PublicKey}; +pub use store::{Query, Record, Store}; +pub use value::Value; diff --git a/crates/trustgraph/src/store.rs b/crates/trustgraph/src/store.rs new file mode 100644 index 0000000..57fd51b --- /dev/null +++ b/crates/trustgraph/src/store.rs @@ -0,0 +1,249 @@ +//! A local, append-only store of trust atoms. +//! +//! Records are kept one per line (NDJSON) in a single file. The format is +//! readable with standard tools such as `jq` and `grep`, can be synced or +//! versioned like any text file, and never rewrites existing lines. + +use std::collections::HashSet; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value as Json; + +use crate::{ContentId, Error, Result, TrustAtom, credential}; + +/// A stored atom, with its signed credential if it has one. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Record { + /// The atom's [`ContentId`]. + pub id: ContentId, + /// The atom. + pub atom: TrustAtom, + /// The signed credential the atom came from, if any. Already verified. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credential: Option, +} + +impl Record { + /// Builds a record from JSON: either a plain [`TrustAtom`], or a signed + /// Trust Atom credential (which is verified). + /// + /// # Errors + /// + /// Fails if the JSON is neither, the atom is invalid, or the + /// credential's proof does not verify. + pub fn from_json(json: Json) -> Result { + if json.get("proof").is_some() { + let atom = credential::verify_atom(&json)?; + Ok(Self { id: atom.id()?, atom, credential: Some(json) }) + } else if json.get("@context").is_some() { + Err(Error::InvalidCredential("credential is not signed".into())) + } else { + let atom: TrustAtom = serde_json::from_value(json)?; + atom.validate()?; + Ok(Self { id: atom.id()?, atom, credential: None }) + } + } + + /// True if this record carries a verified signature. + #[must_use] + pub fn is_signed(&self) -> bool { + self.credential.is_some() + } +} + +/// Filters for [`Store::query`]. Empty fields match everything. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Query { + /// Only atoms from this source. + pub source: Option, + /// Only atoms about this target. + pub target: Option, + /// Only atoms about this topic (see [`TrustAtom::matches_topic`]). + pub topic: Option, + /// Only atoms whose content starts with this prefix. + pub content_prefix: Option, + /// Only signed atoms. + pub signed_only: bool, +} + +impl Query { + /// True if `record` passes every filter. + #[must_use] + pub fn matches(&self, record: &Record) -> bool { + let atom = &record.atom; + self.source.as_ref().is_none_or(|s| *s == atom.source) + && self.target.as_ref().is_none_or(|t| *t == atom.target) + && self.topic.as_deref().is_none_or(|t| atom.matches_topic(t)) + && self.content_prefix.as_deref().is_none_or(|p| atom.content.as_deref().is_some_and(|c| c.starts_with(p))) + && (!self.signed_only || record.is_signed()) + } +} + +/// An append-only NDJSON file of [`Record`]s. +#[derive(Debug)] +pub struct Store { + path: PathBuf, + records: Vec, + ids: HashSet, +} + +impl Store { + /// Opens the store at `path`, creating it (and its directory) if needed. + /// + /// # Errors + /// + /// Fails if the file cannot be read, or a line in it is not a valid record. + pub fn open(path: impl Into) -> Result { + let path = path.into(); + let mut store = Self { path, records: Vec::new(), ids: HashSet::new() }; + match File::open(&store.path) { + Ok(file) => { + for (n, line) in BufReader::new(file).lines().enumerate() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let record: Record = serde_json::from_str(&line) + .map_err(|e| Error::InvalidAtom(format!("{}:{}: {e}", store.path.display(), n + 1)))?; + if store.ids.insert(record.id) { + store.records.push(record); + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + Ok(store) + } + + /// Where the store lives. + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + /// Adds a record. Returns `false` (and writes nothing) if an atom with + /// the same ID is already stored. + /// + /// # Errors + /// + /// Fails if the file cannot be written. + pub fn add(&mut self, record: Record) -> Result { + if self.ids.contains(&record.id) { + return Ok(false); + } + if let Some(dir) = self.path.parent().filter(|d| !d.as_os_str().is_empty()) { + fs::create_dir_all(dir)?; + } + let mut line = serde_json::to_string(&record)?; + line.push('\n'); + OpenOptions::new().create(true).append(true).open(&self.path)?.write_all(line.as_bytes())?; + self.ids.insert(record.id); + self.records.push(record); + Ok(true) + } + + /// All records, in the order they were added. + #[must_use] + pub fn records(&self) -> &[Record] { + &self.records + } + + /// Records matching `query`, in the order they were added. + pub fn query<'a>(&'a self, query: &'a Query) -> impl Iterator + 'a { + self.records.iter().filter(move |r| query.matches(r)) + } + + /// Number of records. + #[must_use] + pub fn len(&self) -> usize { + self.records.len() + } + + /// True if the store is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.records.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Keypair; + use jiff::Timestamp; + + fn atom(source: &str, target: &str, content: &str) -> TrustAtom { + TrustAtom::new(source, target).with_content(content).with_value("0.5".parse().unwrap()) + } + + fn record(source: &str, target: &str, content: &str) -> Record { + Record::from_json(serde_json::to_value(atom(source, target, content)).unwrap()).unwrap() + } + + #[test] + fn add_persists_and_deduplicates() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested/atoms.ndjson"); + let mut store = Store::open(&path).unwrap(); + assert!(store.is_empty()); + assert!(store.add(record("a", "b", "x")).unwrap()); + assert!(!store.add(record("a", "b", "x")).unwrap()); + assert!(store.add(record("a", "c", "x")).unwrap()); + + let reopened = Store::open(&path).unwrap(); + assert_eq!(reopened.len(), 2); + assert_eq!(reopened.records(), store.records()); + assert_eq!(fs::read_to_string(&path).unwrap().lines().count(), 2); + } + + #[test] + fn signed_credentials_are_verified_on_the_way_in() { + let key = Keypair::from_seed(&[3; 32]); + let signed = credential::sign_atom(&atom(key.did().as_str(), "t", "x"), &key, Timestamp::UNIX_EPOCH).unwrap(); + let record = Record::from_json(signed.clone()).unwrap(); + assert!(record.is_signed()); + assert_eq!(record.id, atom(key.did().as_str(), "t", "x").id().unwrap()); + + let mut forged = signed; + forged["credentialSubject"]["value"] = "1".into(); + assert!(Record::from_json(forged.clone()).is_err()); + forged.as_object_mut().unwrap().remove("proof"); + assert!(Record::from_json(forged).is_err(), "unsigned credentials are rejected"); + } + + #[test] + fn rejects_invalid_atoms() { + assert!(Record::from_json(serde_json::json!({"source": "", "target": "b"})).is_err()); + assert!(Record::from_json(serde_json::json!({"target": "b"})).is_err()); + } + + #[test] + fn queries_filter_records() { + let dir = tempfile::tempdir().unwrap(); + let mut store = Store::open(dir.path().join("s.ndjson")).unwrap(); + store.add(record("alice", "bob", "sushi, ramen")).unwrap(); + store.add(record("alice", "carol", "rust")).unwrap(); + store.add(record("bob", "carol", "rust programming")).unwrap(); + + let count = |q: Query| store.query(&q).count(); + assert_eq!(count(Query::default()), 3); + assert_eq!(count(Query { source: Some("alice".into()), ..Query::default() }), 2); + assert_eq!(count(Query { target: Some("carol".into()), ..Query::default() }), 2); + assert_eq!(count(Query { topic: Some("ramen".into()), ..Query::default() }), 1); + assert_eq!(count(Query { content_prefix: Some("rust".into()), ..Query::default() }), 2); + assert_eq!(count(Query { signed_only: true, ..Query::default() }), 0); + } + + #[test] + fn reports_corrupt_lines_with_location() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("s.ndjson"); + fs::write(&path, "\n{not json}\n").unwrap(); + let err = Store::open(&path).unwrap_err().to_string(); + assert!(err.contains("s.ndjson:2"), "{err}"); + } +} diff --git a/crates/trustgraph/src/value.rs b/crates/trustgraph/src/value.rs new file mode 100644 index 0000000..132acd5 --- /dev/null +++ b/crates/trustgraph/src/value.rs @@ -0,0 +1,358 @@ +//! Trust values: decimals in the range `-1..=1`. + +use std::fmt; +use std::str::FromStr; + +pub use rust_decimal::Decimal; +use rust_decimal::RoundingStrategy; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + +use crate::{Error, Result}; + +/// Number of significant figures a [`Value`] keeps. This matches the +/// `trustgraph-holochain` reference implementation. +pub const SIGNIFICANT_FIGURES: u32 = 9; + +/// A trust rating in the range `-1..=1`. +/// +/// - `1` means full trust (or a five-star rating, an upvote, ...) +/// - `0` means neutral +/// - `-1` means full distrust +/// +/// Values are kept as exact decimals, rounded to nine significant figures +/// (half away from zero), so that the same input always produces the same +/// bytes when serialized, hashed or signed. Ratings on other scales can be +/// converted with [`Value::from_scale`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub struct Value(Decimal); + +impl Value { + /// Full trust. + pub const MAX: Self = Self(Decimal::ONE); + /// Neutral. + pub const ZERO: Self = Self(Decimal::ZERO); + /// Full distrust. + pub const MIN: Self = Self(Decimal::NEGATIVE_ONE); + + /// Builds a value from a decimal, rounding to nine significant figures. + /// + /// # Errors + /// + /// Returns [`Error::InvalidValue`] if the rounded value is outside `-1..=1`. + pub fn new(decimal: Decimal) -> Result { + let rounded = decimal + .round_sf_with_strategy(SIGNIFICANT_FIGURES, RoundingStrategy::MidpointAwayFromZero) + .ok_or_else(|| invalid(decimal.to_string(), "cannot be rounded"))?; + if rounded > Decimal::ONE || rounded < Decimal::NEGATIVE_ONE { + return Err(invalid(decimal.to_string(), "must be in the range -1..=1")); + } + Ok(Self(rounded.normalize())) + } + + /// Converts a rating on a `worst..=best` scale (e.g. 1 to 5 stars) to a + /// value in `0..=1`. + /// + /// # Errors + /// + /// Returns [`Error::InvalidValue`] if `worst >= best` or `rating` is not + /// within the scale. + pub fn from_scale(rating: Decimal, worst: Decimal, best: Decimal) -> Result { + if worst >= best { + return Err(invalid(format!("{worst}..={best}"), "scale must have worst < best")); + } + if rating < worst || rating > best { + return Err(invalid(rating.to_string(), "rating is outside its scale")); + } + Self::new((rating - worst) / (best - worst)) + } + + /// The exact decimal. + #[must_use] + pub const fn decimal(self) -> Decimal { + self.0 + } + + /// The value as a float, for scoring and display. Lossy. + #[must_use] + pub fn as_f64(self) -> f64 { + use rust_decimal::prelude::ToPrimitive; + self.0.to_f64().unwrap_or_default() + } + + /// Builds a value from a float, e.g. a computed score. + /// + /// # Errors + /// + /// Returns [`Error::InvalidValue`] if `f` is not finite or outside `-1..=1`. + pub fn from_f64(f: f64) -> Result { + let decimal = Decimal::from_f64_retain(f).ok_or_else(|| invalid(f.to_string(), "not a finite number"))?; + Self::new(decimal) + } + + /// True if the value expresses positive trust. + #[must_use] + pub fn is_positive(self) -> bool { + self.0.is_sign_positive() && !self.0.is_zero() + } + + /// The fixed-width form used in Holochain link tags: no leading zero, + /// nine decimal places, and `±1` stored as `±.999999999` + /// (e.g. `.900000000`, `-.250000000`). + #[must_use] + pub fn to_holochain_string(self) -> String { + let clamped = self.0.clamp(Decimal::new(-999_999_999, 9), Decimal::new(999_999_999, 9)); + // The reference implementation rounds to nine *significant figures*. + let rounded = clamped + .round_sf_with_strategy(SIGNIFICANT_FIGURES, RoundingStrategy::MidpointAwayFromZero) + .unwrap_or(clamped); + let s = strip_leading_zero(rounded.to_string()); + if s.len() <= MAX_HOLOCHAIN_VALUE_CHARS { + return s; + } + // For tiny values (|v| < 0.01) that gives more than the 12 characters + // a tag allows (e.g. `-.00000000100000000`), so use nine decimal + // places instead. + let mut fixed = clamped.round_dp_with_strategy(9, RoundingStrategy::MidpointAwayFromZero); + fixed.rescale(9); + strip_leading_zero(fixed.to_string()) + } +} + +/// Maximum length of the value chunk in a Holochain link tag. +const MAX_HOLOCHAIN_VALUE_CHARS: usize = 12; + +fn strip_leading_zero(s: String) -> String { + if s.starts_with("0.") || s.starts_with("-0.") { s.replacen("0.", ".", 1) } else { s } +} + +fn invalid(input: String, reason: &'static str) -> Error { + Error::InvalidValue { input, reason } +} + +impl FromStr for Value { + type Err = Error; + + /// Parses plain decimals such as `0.9`, `.9`, `-1` or `1.0`. + /// Exponents, whitespace and other formats are rejected. + fn from_str(s: &str) -> Result { + let well_formed = { + let digits = s.strip_prefix('-').unwrap_or(s); + let mut parts = digits.splitn(2, '.'); + let int = parts.next().unwrap_or_default(); + let frac = parts.next(); + (!int.is_empty() || frac.is_some_and(|f| !f.is_empty())) + && int.bytes().all(|b| b.is_ascii_digit()) + && frac.is_none_or(|f| f.bytes().all(|b| b.is_ascii_digit())) + }; + if !well_formed { + return Err(invalid(s.to_owned(), "not a decimal number")); + } + let decimal = Decimal::from_str(s).map_err(|_| invalid(s.to_owned(), "not a decimal number"))?; + Self::new(decimal) + } +} + +impl fmt::Display for Value { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl TryFrom for Value { + type Error = Error; + + fn try_from(f: f64) -> Result { + Self::from_f64(f) + } +} + +/// Serialized as a JSON string (e.g. `"0.9"`), so that it survives any JSON +/// implementation and canonicalizes to the same bytes everywhere. +impl Serialize for Value { + fn serialize(&self, serializer: S) -> std::result::Result { + serializer.collect_str(self) + } +} + +/// Accepts a JSON string (`"0.9"`) or a JSON number (`0.9`). +impl<'de> Deserialize<'de> for Value { + fn deserialize>(deserializer: D) -> std::result::Result { + struct Visitor; + + impl de::Visitor<'_> for Visitor { + type Value = Value; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a trust value in -1..=1, as a string or number") + } + + fn visit_str(self, s: &str) -> std::result::Result { + s.parse().map_err(E::custom) + } + + fn visit_f64(self, f: f64) -> std::result::Result { + // Go through the shortest round-trip string, so 0.1 stays 0.1. + f.to_string().parse().map_err(E::custom) + } + + fn visit_i64(self, i: i64) -> std::result::Result { + Value::new(Decimal::from(i)).map_err(E::custom) + } + + fn visit_u64(self, u: u64) -> std::result::Result { + Value::new(Decimal::from(u)).map_err(E::custom) + } + } + + deserializer.deserialize_any(Visitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(s: &str) -> Value { + s.parse().unwrap_or_else(|e| panic!("{s}: {e}")) + } + + #[test] + fn parses_plain_decimals() { + assert_eq!(v("0.9").to_string(), "0.9"); + assert_eq!(v(".9").to_string(), "0.9"); + assert_eq!(v("-1").to_string(), "-1"); + assert_eq!(v("1.000").to_string(), "1"); + assert_eq!(v("0").to_string(), "0"); + assert_eq!(v("-0.0").to_string(), "0"); + } + + #[test] + fn rounds_to_nine_significant_figures() { + assert_eq!(v("0.534857395723489529357489283").to_string(), "0.534857396"); + assert_eq!(v("0.8999999995").to_string(), "0.9"); + assert_eq!(v("0.8999999994").to_string(), "0.899999999"); + assert_eq!(v("-0.9000000005").to_string(), "-0.900000001"); + } + + // Ported from trustgraph-holochain `test_normalize_value__values_out_of_range`. + #[test] + fn rejects_out_of_range() { + for s in ["2", "-2", "1.000000005", "1.00000001", "-1.00000001", "-1.000000005", "100000000000000000"] { + assert!(s.parse::().is_err(), "{s} should be rejected"); + } + } + + // Ported from trustgraph-holochain `test_normalize_value__values_not_numeric`. + #[test] + fn rejects_non_numeric() { + for s in [ + " ", + " 0 ", + " 0", + "-.", + "-", + "-1e", + "-1e0", + "-e0", + "!", + ".", + "", + "\u{1f9d0}", + "0 ", + "1e", + "1e0", + "e", + "e0", + "foo", + "+1", + "0x1", + "1_0", + "NaN", + "inf", + ] { + assert!(s.parse::().is_err(), "{s:?} should be rejected"); + } + } + + // Ported from trustgraph-holochain `test_normalize_value`. + #[test] + fn holochain_string_matches_reference_implementation() { + for (input, expected) in [ + ("-.9", "-.900000000"), + ("-.9000", "-.900000000"), + ("-.900000000", "-.900000000"), + ("-.9000000004", "-.900000000"), + ("-.9000000005", "-.900000001"), + ("-0.900000000", "-.900000000"), + ("0.8999999995", ".900000000"), + ("0.7999999995", ".800000000"), + ("-0.8999999995", "-.900000000"), + ("-0.7999999995", "-.800000000"), + ("0.8999999994", ".899999999"), + ("0.7999999994", ".799999999"), + ("-0.8999999994", "-.899999999"), + ("-0.7999999994", "-.799999999"), + (".9", ".900000000"), + (".9000", ".900000000"), + (".900000000", ".900000000"), + ("0.900000000", ".900000000"), + ("1", ".999999999"), + ("1.0", ".999999999"), + ("-1", "-.999999999"), + ("-1.0", "-.999999999"), + ] { + assert_eq!(v(input).to_holochain_string(), expected, "input {input}"); + } + } + + #[test] + fn holochain_string_of_zero() { + assert_eq!(Value::ZERO.to_holochain_string(), "0"); + } + + #[test] + fn holochain_string_of_tiny_values_fits_twelve_chars() { + // Nine significant figures would give `-.00000000100000000`. + assert_eq!(v("-0.000000001").to_holochain_string(), "-.000000001"); + assert_eq!(v("0.00123456789").to_holochain_string(), ".00123456789"); + assert_eq!(v("0.000123456789").to_holochain_string(), ".000123457"); + // Small values that fit keep the reference format. + assert_eq!(v("0.05").to_holochain_string(), ".0500000000"); + } + + #[test] + fn serializes_as_string_and_accepts_numbers() { + assert_eq!(serde_json::to_string(&v("0.25")).unwrap(), r#""0.25""#); + assert_eq!(serde_json::from_str::("0.1").unwrap(), v("0.1")); + assert_eq!(serde_json::from_str::("1").unwrap(), Value::MAX); + assert_eq!(serde_json::from_str::("-1").unwrap(), Value::MIN); + assert_eq!(serde_json::from_str::(r#""-0.5""#).unwrap(), v("-0.5")); + assert!(serde_json::from_str::("2").is_err()); + assert!(serde_json::from_str::("true").is_err()); + } + + #[test] + fn converts_from_scales() { + let five_stars = |n: i64| Value::from_scale(Decimal::from(n), Decimal::ONE, Decimal::from(5)).unwrap(); + assert_eq!(five_stars(1), Value::ZERO); + assert_eq!(five_stars(3).to_string(), "0.5"); + assert_eq!(five_stars(5), Value::MAX); + assert!(Value::from_scale(Decimal::from(6), Decimal::ONE, Decimal::from(5)).is_err()); + assert!(Value::from_scale(Decimal::ONE, Decimal::ONE, Decimal::ONE).is_err()); + } + + #[test] + fn float_conversions() { + assert_eq!(Value::from_f64(0.5).unwrap().to_string(), "0.5"); + assert!(Value::from_f64(f64::NAN).is_err()); + assert!(Value::from_f64(1.5).is_err()); + assert!((v("-0.25").as_f64() + 0.25).abs() < f64::EPSILON); + } + + #[test] + fn positivity() { + assert!(v("0.1").is_positive()); + assert!(!Value::ZERO.is_positive()); + assert!(!v("-0.1").is_positive()); + } +} diff --git a/crates/trustgraph/tests/properties.proptest-regressions b/crates/trustgraph/tests/properties.proptest-regressions new file mode 100644 index 0000000..101bf53 --- /dev/null +++ b/crates/trustgraph/tests/properties.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 32314319d1573eb79126a9dc22326098e4fa6caaf892fbc82d0f910a98f73fc5 # shrinks to v = Value(-0.000000001) diff --git a/crates/trustgraph/tests/properties.rs b/crates/trustgraph/tests/properties.rs new file mode 100644 index 0000000..07d0604 --- /dev/null +++ b/crates/trustgraph/tests/properties.rs @@ -0,0 +1,139 @@ +//! Property tests: invariants that must hold for *any* input. + +#![allow(clippy::unwrap_used)] // Panicking is how tests fail. + +use jiff::Timestamp; +use proptest::prelude::*; +use trustgraph::holochain::{Direction, LinkTag}; +use trustgraph::value::Decimal; +use trustgraph::{Keypair, LensOptions, TrustAtom, TrustGraph, Value, credential}; + +fn value() -> impl Strategy { + (-1_000_000_000i64..=1_000_000_000).prop_map(|n| Value::new(Decimal::new(n, 9)).unwrap()) +} + +fn identifier() -> impl Strategy { + "[a-zA-Z0-9:/._#?=-]{1,40}" +} + +fn timestamp() -> impl Strategy { + (0i64..4_000_000_000).prop_map(|s| Timestamp::from_second(s).unwrap()) +} + +fn atom() -> impl Strategy { + ( + identifier(), + identifier(), + proptest::option::of("[^\\p{Cc}]{0,60}"), + proptest::option::of(value()), + proptest::option::of(timestamp()), + proptest::collection::btree_map("[a-z]{1,8}", ".{0,20}", 0..3), + ) + .prop_filter("source and target differ", |(s, t, ..)| s != t) + .prop_map(|(source, target, content, value, timestamp, extra)| TrustAtom { + content, + value, + timestamp, + extra, + ..TrustAtom::new(source, target) + }) +} + +proptest! { + #[test] + fn value_string_round_trips(v in value()) { + prop_assert_eq!(v.to_string().parse::().unwrap(), v); + let json = serde_json::to_string(&v).unwrap(); + prop_assert_eq!(serde_json::from_str::(&json).unwrap(), v); + } + + #[test] + fn holochain_value_string_is_within_twelve_chars_and_reparses(v in value()) { + let s = v.to_holochain_string(); + prop_assert!(s.len() <= 12, "{} is too long", s); + let back: Value = s.parse().unwrap(); + prop_assert!((back.as_f64() - v.as_f64()).abs() <= 1e-9); + } + + #[test] + fn values_outside_range_are_rejected(n in prop_oneof![1_000_000_001i64..i64::MAX / 2, i64::MIN / 2..-1_000_000_000]) { + prop_assert!(Value::new(Decimal::new(n, 9)).is_err()); + } + + #[test] + fn atom_json_round_trips(atom in atom()) { + let json = serde_json::to_string(&atom).unwrap(); + prop_assert_eq!(serde_json::from_str::(&json).unwrap(), atom); + } + + #[test] + fn id_ignores_key_order_and_whitespace(atom in atom()) { + let pretty = serde_json::to_string_pretty(&atom).unwrap(); + let reparsed: TrustAtom = serde_json::from_str(&pretty).unwrap(); + prop_assert_eq!(reparsed.id().unwrap(), atom.id().unwrap()); + } + + #[test] + fn credential_round_trips(atom in atom()) { + let credential = credential::to_credential(&atom).unwrap(); + prop_assert_eq!(credential::from_credential(&credential).unwrap(), atom); + } + + #[test] + fn signatures_verify_and_bind_every_byte(atom in atom(), seed in any::<[u8; 32]>(), flip in any::()) { + let key = Keypair::from_seed(&seed); + let atom = TrustAtom { source: key.did().to_string(), ..atom }; + prop_assume!(atom.source != atom.target); + let signed = credential::sign_atom(&atom, &key, Timestamp::UNIX_EPOCH).unwrap(); + prop_assert_eq!(credential::verify_atom(&signed).unwrap(), atom.clone()); + + // Changing the target to any other string must break the signature. + let mut tampered = signed.clone(); + let target = &atom.target; + let i = flip.index(target.len()); + let mut bytes = target.clone().into_bytes(); + bytes[i] = if bytes[i] == b'a' { b'b' } else { b'a' }; + tampered["credentialSubject"]["id"] = String::from_utf8(bytes).unwrap().into(); + prop_assert!(credential::verify_atom(&tampered).is_err()); + } + + #[test] + fn holochain_tags_round_trip(atom in atom(), reverse in any::(), bucket in "[0-9]{9}") { + let direction = if reverse { Direction::Reverse } else { Direction::Forward }; + let tag = LinkTag::for_atom(&atom, direction, Some(bucket), None); + if let Ok(bytes) = tag.encode() { + let decoded = LinkTag::decode(&bytes).unwrap(); + prop_assert_eq!(decoded.direction, direction); + prop_assert_eq!(decoded.content.clone(), atom.content.clone().filter(|c| !c.is_empty())); + prop_assert_eq!(decoded.bucket.clone(), tag.bucket); + let (base, target) = if reverse { (&atom.target, &atom.source) } else { (&atom.source, &atom.target) }; + let rebuilt = decoded.to_atom(base, target); + prop_assert_eq!(&rebuilt.source, &atom.source); + prop_assert_eq!(&rebuilt.target, &atom.target); + } + } + + #[test] + fn lens_scores_are_bounded_and_root_is_never_scored( + edges in proptest::collection::vec((0u8..6, 0u8..6, value()), 0..40), + depth in 1usize..6, + decay in 0.0f64..=1.0, + ) { + let atoms: Vec = edges + .into_iter() + .filter(|(s, t, _)| s != t) + .map(|(s, t, v)| TrustAtom::new(format!("agent{s}"), format!("agent{t}")).with_value(v)) + .collect(); + let graph: TrustGraph = atoms.iter().collect(); + let entries = graph.lens("agent0", &LensOptions { depth, decay, topic: None }); + for entry in &entries { + prop_assert!((-1.0..=1.0).contains(&entry.score)); + prop_assert!((0.0..=1.0).contains(&entry.confidence)); + prop_assert!(entry.hops >= 1 && entry.hops <= depth); + prop_assert_ne!(&entry.target, "agent0"); + } + let mut targets: Vec<_> = entries.iter().map(|e| &e.target).collect(); + targets.dedup(); + prop_assert_eq!(targets.len(), entries.len()); + } +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..c775577 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 120 +use_small_heuristics = "Max" diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 5612e5c..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! Core of the Trust Graph command line interface. -//! -//! Argument parsing and command logic live here, separate from `main.rs`, so -//! that they can be unit tested and reused by other components. - -use std::io::{self, Write}; - -use clap::Parser; - -/// Simple program to greet a person -#[derive(Parser, Debug, Clone, PartialEq, Eq)] -#[command(author, version, about, long_about = None)] -pub struct Cli { - /// Name of the person to greet - #[arg(short, long)] - pub name: String, - - /// Number of times to greet - #[arg(short, long, default_value_t = 1, value_parser = clap::value_parser!(u8).range(1..))] - pub count: u8, -} - -/// Runs the CLI with already-parsed arguments, writing output to `out`. -/// -/// # Errors -/// -/// Returns any I/O error raised while writing to `out`. -pub fn run(cli: &Cli, out: &mut impl Write) -> io::Result<()> { - for _ in 0..cli.count { - writeln!(out, "{}", greeting(&cli.name))?; - } - Ok(()) -} - -/// Builds the greeting for `name`. -#[must_use] -pub fn greeting(name: &str) -> String { - format!("Hello {name}!") -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::CommandFactory; - - fn parse(args: &[&str]) -> Result { - Cli::try_parse_from(std::iter::once("trustgraph-rust-cli").chain(args.iter().copied())) - } - - fn output(cli: &Cli) -> String { - let mut buf = Vec::new(); - run(cli, &mut buf).expect("writing to a Vec cannot fail"); - String::from_utf8(buf).expect("output is UTF-8") - } - - #[test] - fn command_definition_is_valid() { - Cli::command().debug_assert(); - } - - #[test] - fn greeting_formats_name() { - assert_eq!(greeting("Ada"), "Hello Ada!"); - } - - #[test] - fn greeting_preserves_unicode() { - assert_eq!(greeting("Ŧrust"), "Hello Ŧrust!"); - } - - #[test] - fn count_defaults_to_one() { - let cli = parse(&["--name", "Ada"]).unwrap(); - assert_eq!( - cli, - Cli { - name: "Ada".into(), - count: 1 - } - ); - } - - #[test] - fn short_flags_are_accepted() { - let cli = parse(&["-n", "Ada", "-c", "3"]).unwrap(); - assert_eq!( - cli, - Cli { - name: "Ada".into(), - count: 3 - } - ); - } - - #[test] - fn name_is_required() { - let err = parse(&[]).unwrap_err(); - assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); - } - - #[test] - fn count_of_zero_is_rejected() { - let err = parse(&["--name", "Ada", "--count", "0"]).unwrap_err(); - assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation); - } - - #[test] - fn count_above_u8_range_is_rejected() { - let err = parse(&["--name", "Ada", "--count", "256"]).unwrap_err(); - assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation); - } - - #[test] - fn count_must_be_numeric() { - let err = parse(&["--name", "Ada", "--count", "many"]).unwrap_err(); - assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation); - } - - #[test] - fn run_prints_one_line_per_count() { - let cli = Cli { - name: "Ada".into(), - count: 3, - }; - assert_eq!(output(&cli), "Hello Ada!\nHello Ada!\nHello Ada!\n"); - } - - #[test] - fn run_handles_max_count() { - let cli = Cli { - name: "Ada".into(), - count: u8::MAX, - }; - assert_eq!(output(&cli).lines().count(), usize::from(u8::MAX)); - } - - #[test] - fn run_propagates_write_errors() { - struct Failing; - impl Write for Failing { - fn write(&mut self, _: &[u8]) -> io::Result { - Err(io::Error::from(io::ErrorKind::BrokenPipe)) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - - let cli = Cli { - name: "Ada".into(), - count: 1, - }; - let err = run(&cli, &mut Failing).unwrap_err(); - assert_eq!(err.kind(), io::ErrorKind::BrokenPipe); - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 07132c1..0000000 --- a/src/main.rs +++ /dev/null @@ -1,36 +0,0 @@ -use std::io::{self, Write}; -use std::process::ExitCode; - -use clap::Parser; -use trustgraph_rust_cli::{Cli, run}; - -/* TODO next: - - decide on some initial use cases - - make ArgGroup - make prompts that enforce things - - do something with the args - call a API? write to trustgraph right now? - what will be the interface between the CLI and the backend? is there a backend? - - spit out jsonld and optionally pipe to a thing that writes it to various storages or pushes it out - - make separate components for cli, and pipes - -*/ - -fn main() -> ExitCode { - let cli = Cli::parse(); - let mut stdout = io::stdout().lock(); - - match run(&cli, &mut stdout).and_then(|()| stdout.flush()) { - Ok(()) => ExitCode::SUCCESS, - // Being piped into `head` or similar is not an error. - Err(err) if err.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS, - Err(err) => { - eprintln!("error: {err}"); - ExitCode::FAILURE - } - } -} diff --git a/tests/cli.rs b/tests/cli.rs deleted file mode 100644 index 5893c8c..0000000 --- a/tests/cli.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! End-to-end tests that run the compiled binary. - -use assert_cmd::Command; -use predicates::prelude::*; - -fn cli() -> Command { - Command::cargo_bin("trustgraph-rust-cli").expect("binary is built") -} - -#[test] -fn greets_once_by_default() { - cli() - .args(["--name", "HI"]) - .assert() - .success() - .stdout("Hello HI!\n") - .stderr(""); -} - -#[test] -fn greets_count_times() { - cli() - .args(["--name", "Ada", "--count", "2"]) - .assert() - .success() - .stdout("Hello Ada!\nHello Ada!\n"); -} - -#[test] -fn missing_name_is_a_usage_error() { - cli() - .assert() - .code(2) - .stdout("") - .stderr(predicate::str::contains("--name ")); -} - -#[test] -fn zero_count_is_a_usage_error() { - cli() - .args(["--name", "Ada", "--count", "0"]) - .assert() - .code(2) - .stderr(predicate::str::contains("invalid value '0'")); -} - -#[test] -fn unknown_flag_is_a_usage_error() { - cli() - .args(["--name", "Ada", "--bogus"]) - .assert() - .code(2) - .stderr(predicate::str::contains("unexpected argument '--bogus'")); -} - -#[test] -fn help_lists_options() { - cli().arg("--help").assert().success().stdout( - predicate::str::contains("--name ").and(predicate::str::contains("--count ")), - ); -} - -#[test] -fn version_matches_cargo_manifest() { - cli().arg("--version").assert().success().stdout(format!( - "trustgraph-rust-cli {}\n", - env!("CARGO_PKG_VERSION") - )); -} From b0d68736f9f4e34c395f8d7e90d04d693aa94e05 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 22:35:22 +0000 Subject: [PATCH 3/4] Fix clippy::assert_is_empty on Rust 1.99 CI's stable toolchain (1.99) adds the `assert_is_empty` lint, which fails the build under `-D warnings`. Use `assert_eq!` so failures show the actual value. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UdKneRqUnsbv1t7NyvcT68 --- crates/trust-cli/src/home.rs | 2 +- crates/trust-cli/tests/cli.rs | 2 +- crates/trustgraph/src/graph.rs | 6 +++--- crates/trustgraph/src/store.rs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/trust-cli/src/home.rs b/crates/trust-cli/src/home.rs index 43382d9..a106013 100644 --- a/crates/trust-cli/src/home.rs +++ b/crates/trust-cli/src/home.rs @@ -129,7 +129,7 @@ mod tests { fn keys_round_trip_and_are_private() { let dir = tempfile::tempdir().unwrap(); let home = Home::new(Some(dir.path().to_path_buf())).unwrap(); - assert!(home.list_keys().unwrap().is_empty()); + assert_eq!(home.list_keys().unwrap(), []); assert!(home.load_key("default").is_err()); let keypair = Keypair::generate().unwrap(); diff --git a/crates/trust-cli/tests/cli.rs b/crates/trust-cli/tests/cli.rs index b136fb5..262329b 100644 --- a/crates/trust-cli/tests/cli.rs +++ b/crates/trust-cli/tests/cli.rs @@ -234,7 +234,7 @@ fn lens_and_rollups() { assert_eq!(env.json_lines(&["lens", "--topic", "sushi", "--depth", "1"], "").len(), 1); assert_eq!(env.json_lines(&["lens", "--topic", "sushi", "--limit", "2"], "").len(), 2); - assert!(env.run(&["lens", "--topic", "ramen"], "").is_empty()); + assert_eq!(env.run(&["lens", "--topic", "ramen"], ""), ""); assert_eq!(env.json_lines(&["lens", &bob, "--topic", "sushi"], "").len(), 2); // Rollups are atoms from the agent that can be signed, verified, and stored. diff --git a/crates/trustgraph/src/graph.rs b/crates/trustgraph/src/graph.rs index cb02e8f..2e1ecb7 100644 --- a/crates/trustgraph/src/graph.rs +++ b/crates/trustgraph/src/graph.rs @@ -320,7 +320,7 @@ mod tests { assert_eq!(one.keys().collect::>(), ["bob"]); let two = lens(&atoms, "alice", &LensOptions { depth: 2, ..LensOptions::default() }); assert_eq!(two.keys().collect::>(), ["bob", "carol"]); - assert!(lens(&atoms, "alice", &LensOptions { depth: 0, ..LensOptions::default() }).is_empty()); + assert_eq!(lens(&atoms, "alice", &LensOptions { depth: 0, ..LensOptions::default() }).len(), 0); } #[test] @@ -416,8 +416,8 @@ mod tests { #[test] fn atoms_without_values_are_ignored() { let graph: TrustGraph = [TrustAtom::new("alice", "bob")].iter().collect(); - assert!(graph.is_empty()); - assert!(graph.lens("alice", &LensOptions::default()).is_empty()); + assert_eq!(graph.len(), 0); + assert_eq!(graph.lens("alice", &LensOptions::default()), []); } #[test] diff --git a/crates/trustgraph/src/store.rs b/crates/trustgraph/src/store.rs index 57fd51b..450a25f 100644 --- a/crates/trustgraph/src/store.rs +++ b/crates/trustgraph/src/store.rs @@ -189,7 +189,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("nested/atoms.ndjson"); let mut store = Store::open(&path).unwrap(); - assert!(store.is_empty()); + assert_eq!(store.len(), 0); assert!(store.add(record("a", "b", "x")).unwrap()); assert!(!store.add(record("a", "b", "x")).unwrap()); assert!(store.add(record("a", "c", "x")).unwrap()); From 590618b6a824665307bd7f469134e793e0b10c8a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 22:36:31 +0000 Subject: [PATCH 4/4] Run the README's Rust example as a doctest The library example in README.md was not compiled anywhere, so it could silently go stale. Include the README as a doctest-only item and make the example a complete program. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UdKneRqUnsbv1t7NyvcT68 --- README.md | 22 +++++++++++++--------- crates/trustgraph/src/lib.rs | 6 ++++++ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6407548..dda6ddb 100644 --- a/README.md +++ b/README.md @@ -122,15 +122,19 @@ CLI dependencies so other components can embed it: ```rust use trustgraph::{credential, Keypair, LensOptions, TrustAtom, TrustGraph}; -let alice = Keypair::generate()?; -let atom = TrustAtom::new(alice.did().to_string(), "https://sushi.example") - .with_content("sushi") - .with_value("0.9".parse()?); -let signed = credential::sign_atom(&atom, &alice, jiff::Timestamp::now())?; -assert_eq!(credential::verify_atom(&signed)?, atom); - -let graph: TrustGraph = [atom].iter().collect(); -let view = graph.lens(alice.did().as_str(), &LensOptions::default()); +fn main() -> Result<(), trustgraph::Error> { + let alice = Keypair::generate()?; + let atom = TrustAtom::new(alice.did().to_string(), "https://sushi.example") + .with_content("sushi") + .with_value("0.9".parse()?); + let signed = credential::sign_atom(&atom, &alice, jiff::Timestamp::now())?; + assert_eq!(credential::verify_atom(&signed)?, atom); + + let graph: TrustGraph = [atom].iter().collect(); + let view = graph.lens(alice.did().as_str(), &LensOptions::default()); + assert_eq!(view[0].target, "https://sushi.example"); + Ok(()) +} ``` ## Development diff --git a/crates/trustgraph/src/lib.rs b/crates/trustgraph/src/lib.rs index 0ba256d..a39f32c 100644 --- a/crates/trustgraph/src/lib.rs +++ b/crates/trustgraph/src/lib.rs @@ -44,3 +44,9 @@ pub use id::ContentId; pub use keys::{Did, Keypair, PublicKey}; pub use store::{Query, Record, Store}; pub use value::Value; + +/// Compiles and runs the Rust examples in the repository README, so they +/// never go stale. +#[cfg(doctest)] +#[doc = include_str!("../../../README.md")] +pub struct ReadmeDoctests;