diff --git a/README.md b/README.md index 4f78b49..acfb3a2 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ trust lens --topic sushi --rollup | trust sign | trust add | `trust sign [FILE]` | Sign atoms as Verifiable Credentials | | `trust verify [FILE]` | Verify credentials. Exits 1 if any are invalid | | `trust id [--credential] [FILE]` | Print atom IDs, or credential IDs (CIDv1, `bafkrei…`) | -| `trust convert --to atom\|credential\|canonical [FILE]` | Convert between formats | +| `trust convert --to FORMAT [--from FORMAT] [FILE]` | Convert between formats (below) | | `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 | @@ -100,10 +100,25 @@ trust lens --topic sushi --rollup | trust sign | trust add | `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 +or concatenated JSON (or `vc+jwt` tokens, one per line). 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. +### Other formats + +`trust convert` reads and writes formats other systems understand. Exports +start from the current atoms (signed ones verified, superseded ones left +out); each format's mapping and its sources are in [`doc/formats/`](doc/formats). + +| `--to` | `--from` | Format | +|---|---|---| +| `atom`, `credential`, `canonical` | `atom` | Trust Graph itself | +| `vc-jwt` | `vc-jwt` | [`application/vc+jwt`](doc/formats/vc-jwt.md) (W3C VC-JOSE-COSE, `alg: Ed25519`): signed with `--key`, verified strictly; `trust verify` checks them too | +| `caip-261` | `caip-261` | [CAIP-261 `PeerTrustCredential`](doc/formats/caip-261.md), one per source and target | +| `ijv-csv` | | [OpenRank / EigenTrust `i,j,v` CSV](doc/formats/ijv-csv.md) (`--topic`, `--negative drop\|keep`) | +| `atproto-label`, `nostr-label` | | [AT Protocol labels and Nostr NIP-32 events](doc/formats/labels.md), unsigned | +| `schema-org` | | [schema.org `Review` JSON-LD](doc/formats/schema-org.md), for web pages | + ## Data model The v1 format is specified in [`doc/protocol.md`](doc/protocol.md), with a diff --git a/bindings/trustgraph.d.ts b/bindings/trustgraph.d.ts index ba3b0c9..0c86b8f 100644 --- a/bindings/trustgraph.d.ts +++ b/bindings/trustgraph.d.ts @@ -124,6 +124,67 @@ export interface LensEntry { /** Atoms and/or signed credentials. */ export type Item = TrustAtomInput | Credential; +/** A Trust Atom credential secured as `application/vc+jwt` (VC-JOSE-COSE): a compact JWS, `eyJ…`. */ +export type VcJwt = string; + +/** A CAIP-261 `PeerTrustCredential` (Web of Trust Primitives), unsigned. */ +export interface PeerTrustCredential { + "@context": string[]; + type: string[]; + /** RFC 3339: the latest timestamp of its atoms. */ + issuanceDate?: string; + issuer: string | { id: string }; + credentialSubject: { + id: string; + trustworthiness: { + /** The atom's content. */ + scope?: string; + /** -1..=1: the atom's value. */ + level: number; + reason?: string[]; + /** Other `extra` fields (a Trust Graph extension). */ + extra?: Record; + }[]; + }; + [property: string]: unknown; +} + +export interface IjvCsvOptions { + /** Only atoms about this topic. */ + topic?: string; + /** Zero and negative values: left out ("drop", the default; EigenTrust needs non-negative trust) or kept. */ + negative?: "drop" | "keep"; +} + +/** An AT Protocol label (`com.atproto.label.defs#label`), unsigned (no `sig`). */ +export interface AtprotoLabel { + ver: 1; + /** The labeler: the atom's source (a DID). */ + src: string; + /** The atom's target. */ + uri: string; + /** `trusted`, `distrusted`, or `trusted-` / `distrusted-`. */ + val: string; + /** A retraction of an earlier label. */ + neg?: true; + /** RFC 3339. */ + cts: string; +} + +/** An unsigned Nostr NIP-32 label event: add `pubkey`, `id` and `sig` to publish it. */ +export interface NostrLabelEvent { + kind: 1985; + created_at: number; + tags: string[][]; + content: string; +} + +/** A schema.org JSON-LD document: `{"@context": "https://schema.org", "@graph": Review[]}`. */ +export interface SchemaOrgDocument { + "@context": "https://schema.org"; + "@graph": Record[]; +} + export function version(): string; /** Generates a new identity from a secure random source. Not deterministic: call it in a client or action, not inside a reactive query. */ export function generateKeypair(): KeyInfo; @@ -148,3 +209,19 @@ export function verify(credential: Credential): Verification; export function lens(items: Item[], root: string, options?: LensOptions | null): LensEntry[]; /** Rollup atoms (unsigned) for `root`'s lens, timestamped `at` (RFC 3339). */ export function rollup(items: Item[], root: string, options: LensOptions | null | undefined, at: string): TrustAtom[]; +/** Signs an atom as an `application/vc+jwt` (VC-JOSE-COSE, `alg: "Ed25519"`). `created` stamps an atom without a timestamp. */ +export function signVcJwt(atom: Item, secretKeyMultibase: string, created: string): VcJwt; +/** Verifies an `application/vc+jwt` as strictly as `verify`. `credentialId` is the CID of the JWT's bytes. */ +export function verifyVcJwt(jwt: VcJwt): Verification; +/** Current atoms (signed ones verified, superseded ones left out) as CAIP-261 `PeerTrustCredential`s, one per source and target. */ +export function toPeerTrust(items: Item[]): PeerTrustCredential[]; +/** The atoms in a CAIP-261 `PeerTrustCredential`. Its proof is not checked. */ +export function fromPeerTrust(credential: PeerTrustCredential): TrustAtom[]; +/** Current atoms as an OpenRank / EigenTrust `i,j,v` local-trust CSV. */ +export function toIjvCsv(items: Item[], options?: IjvCsvOptions | null): string; +/** Current atoms as unsigned AT Protocol labels, with `neg` labels for superseded ones. */ +export function toAtprotoLabels(items: Item[]): AtprotoLabel[]; +/** Current atoms as unsigned Nostr NIP-32 label events (kind 1985). */ +export function toNostrLabels(items: Item[]): NostrLabelEvent[]; +/** Current atoms as schema.org `Review`s with `Rating`s from -1 to 1. */ +export function toSchemaOrg(items: Item[]): SchemaOrgDocument; diff --git a/clippy.toml b/clippy.toml index 1a9c12c..23c666f 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,4 +1,4 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true # Words that are not code in doc comments (".." keeps clippy's defaults). -doc-valid-idents = ["CIDv1", ".."] +doc-valid-idents = ["CIDv1", "OpenRank", "EigenTrust", ".."] diff --git a/crates/trustgraph-cli/src/cli.rs b/crates/trustgraph-cli/src/cli.rs index 530cb35..ff3e4a2 100644 --- a/crates/trustgraph-cli/src/cli.rs +++ b/crates/trustgraph-cli/src/cli.rs @@ -49,13 +49,15 @@ pub enum Command { /// 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 signed Trust Atom credentials (Data Integrity or vc+jwt); exits + /// 1 if any are invalid Verify(InputArgs), /// Print the ID (a bafkrei… CIDv1) of atoms or credentials Id(IdArgs), - /// Convert atoms and credentials between formats + /// Convert atoms and credentials to and from other formats: vc+jwt, + /// CAIP-261, i,j,v CSV, AT Protocol and Nostr labels, schema.org Convert(ConvertArgs), /// Add atoms or signed credentials to the local store @@ -167,7 +169,8 @@ pub struct AtomArgs { #[derive(Debug, Args)] pub struct InputArgs { - /// JSON or NDJSON input file; `-` or nothing reads stdin + /// JSON or NDJSON input file (or vc+jwt tokens, one per line); `-` or + /// nothing reads stdin #[arg(value_name = "FILE")] pub input: Option, } @@ -204,13 +207,63 @@ pub enum Format { Credential, /// The atom's canonical JSON (RFC 8785), exactly as hashed Canonical, + /// A signed application/vc+jwt (VC-JOSE-COSE, alg Ed25519), signed + /// with --key + VcJwt, + /// Unsigned CAIP-261 peer trust credentials, one per source and target + #[value(name = "caip-261")] + Caip261, + /// An OpenRank / EigenTrust local-trust CSV (i,j,v) + IjvCsv, + /// Unsigned AT Protocol labels (trusted / distrusted, with neg) + AtprotoLabel, + /// Unsigned Nostr NIP-32 label events (kind 1985) + NostrLabel, + /// One schema.org JSON-LD document of Reviews + SchemaOrg, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum InputFormat { + /// Trust Atoms or credentials + Atom, + /// application/vc+jwt tokens, which must verify + VcJwt, + /// CAIP-261 peer trust credentials (proofs are not checked) + #[value(name = "caip-261")] + Caip261, +} + +/// What `i,j,v` CSV export does with zero and negative values. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum NegativeArg { + /// Leave them out (EigenTrust needs non-negative trust) + Drop, + /// Write them as they are + Keep, } #[derive(Debug, Args)] pub struct ConvertArgs { - /// Output format + /// Output format [default with --from: atom] + #[arg(long, value_enum, required_unless_present = "from")] + pub to: Option, + + /// Input format [default: atom] #[arg(long, value_enum)] - pub to: Format, + pub from: Option, + + /// For ijv-csv: only atoms about this topic + #[arg(long)] + pub topic: Option, + + /// For ijv-csv: zero and negative values + #[arg(long, value_enum, default_value = "drop")] + pub negative: NegativeArg, + + /// For vc-jwt: the key to sign with + #[command(flatten)] + pub key: KeyArg, #[command(flatten)] pub input: InputArgs, diff --git a/crates/trustgraph-cli/src/commands.rs b/crates/trustgraph-cli/src/commands.rs index c666b82..c99061a 100644 --- a/crates/trustgraph-cli/src/commands.rs +++ b/crates/trustgraph-cli/src/commands.rs @@ -7,10 +7,12 @@ use clap::CommandFactory; use jiff::{Timestamp, Unit}; use serde_json::{Value as Json, json}; use trustgraph_core::api; -use trustgraph_core::{Keypair, LensOptions, Query, Record, Supersession, TrustAtom, TrustGraph, credential}; +use trustgraph_core::export::ijv::{CsvOptions, Negative}; +use trustgraph_core::{Keypair, LensOptions, Query, Record, Supersession, TrustAtom, TrustGraph, credential, jose}; use crate::cli::{ - AtomArgs, Cli, Command, ConvertArgs, Format, IdArgs, InputArgs, KeyCommand, LensArgs, QueryArgs, SignArgs, + AtomArgs, Cli, Command, ConvertArgs, Format, IdArgs, InputArgs, InputFormat, KeyCommand, LensArgs, NegativeArg, + QueryArgs, SignArgs, }; use crate::home::Home; use crate::io::{Output, read_json}; @@ -31,7 +33,7 @@ pub fn run(cli: Cli, out: &mut Output) -> Result { 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::Convert(args) => convert(&home, args, out), Command::Add(args) => add(&home, &args, out), Command::Query(args) => query(&home, args, out), Command::Lens(args) => lens(&home, args, out), @@ -145,7 +147,10 @@ fn sign(home: &Home, args: &SignArgs, out: &mut Output) -> Result(args: &InputArgs, out: &mut Output) -> Result { let mut outcome = Outcome::Success; for (_, json) in read_items(args)? { - let result = api::verify(&json); + let result = match &json { + Json::String(jwt) => api::verify_vc_jwt(jwt), + _ => api::verify(&json), + }; if !result.valid { outcome = Outcome::Failed; } @@ -170,18 +175,71 @@ fn id(args: &IdArgs, out: &mut Output) -> Result { Ok(Outcome::Success) } -fn convert(args: &ConvertArgs, out: &mut Output) -> Result { - for (n, json) in read_items(&args.input)? { +fn convert(home: &Home, args: ConvertArgs, out: &mut Output) -> Result { + let mut items = read_items(&args.input)?; + match args.from.unwrap_or(InputFormat::Atom) { + InputFormat::Atom => { + if let Some((n, _)) = items.iter().find(|(_, json)| json.is_string()) { + bail!("item {n}: a JWT; use --from vc-jwt to verify and convert it"); + } + } + InputFormat::VcJwt => { + for (n, json) in &mut items { + let jwt = json.as_str().with_context(|| format!("item {n}: not a vc+jwt"))?; + *json = jose::verify(jwt).with_context(|| format!("item {n}"))?.credential; + } + } + InputFormat::Caip261 => { + let mut atoms = Vec::new(); + for (n, json) in items { + for atom in api::from_peer_trust(&json).with_context(|| format!("item {n}"))? { + atoms.push((n, serde_json::to_value(atom)?)); + } + } + items = atoms; + } + } + + let to = args.to.unwrap_or(Format::Atom); + let all = || items.iter().map(|(_, json)| json.clone()).collect::>(); + match to { + Format::Atom | Format::Credential | Format::Canonical | Format::VcJwt => {} + Format::Caip261 => return lines(out, &api::to_peer_trust(all())?), + Format::IjvCsv => { + let negative = match args.negative { + NegativeArg::Drop => Negative::Drop, + NegativeArg::Keep => Negative::Keep, + }; + out.text(&api::to_ijv_csv(all(), &CsvOptions { topic: args.topic, negative })?)?; + return Ok(Outcome::Success); + } + Format::AtprotoLabel => return lines(out, &api::to_atproto_labels(all())?), + Format::NostrLabel => return lines(out, &api::to_nostr_labels(all())?), + Format::SchemaOrg => return lines(out, &[api::to_schema_org(all())?]), + } + let keypair = if to == Format::VcJwt { Some(home.load_key(&args.key.key)?) } else { None }; + let created = now().to_string(); + for (n, json) in items { let context = || format!("item {n}"); - match args.to { - Format::Atom => out.json(&api::parse_atom(json).with_context(context)?)?, - Format::Credential => out.json(&api::to_credential(json).with_context(context)?)?, - Format::Canonical => out.line(&api::canonical_atom(json).with_context(context)?)?, + match (&keypair, to) { + (Some(keypair), _) => { + out.line(&api::sign_vc_jwt(json, &keypair.to_secret_multibase(), &created).with_context(context)?)?; + } + (None, Format::Credential) => out.json(&api::to_credential(json).with_context(context)?)?, + (None, Format::Canonical) => out.line(&api::canonical_atom(json).with_context(context)?)?, + (None, _) => out.json(&api::parse_atom(json).with_context(context)?)?, } } Ok(Outcome::Success) } +fn lines(out: &mut Output, values: &[T]) -> Result { + for value in values { + out.json(value)?; + } + Ok(Outcome::Success) +} + fn add(home: &Home, args: &InputArgs, out: &mut Output) -> Result { let mut store = home.open_store()?; for (n, json) in read_items(args)? { diff --git a/crates/trustgraph-cli/src/io.rs b/crates/trustgraph-cli/src/io.rs index 4a15b2b..2453be7 100644 --- a/crates/trustgraph-cli/src/io.rs +++ b/crates/trustgraph-cli/src/io.rs @@ -9,7 +9,8 @@ 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. +/// single document, NDJSON, or concatenated JSON; or whitespace-separated +/// compact JWS tokens (`eyJ…`), returned as JSON strings. pub fn read_json(path: Option<&Path>) -> Result> { let (reader, name): (Box, String) = match path { Some(p) if p.as_os_str() != "-" => ( @@ -23,11 +24,18 @@ pub fn read_json(path: Option<&Path>) -> Result> { (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::>>()?; + let mut text = String::new(); + BufReader::new(reader).read_to_string(&mut text).with_context(|| format!("reading {name}"))?; + // Compact JWS tokens (`eyJ…`, e.g. application/vc+jwt) become JSON strings. + let values = if text.trim_start().starts_with("eyJ") { + text.split_whitespace().map(|token| Json::String(token.to_owned())).collect() + } else { + serde_json::Deserializer::from_str(&text) + .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"); } @@ -55,6 +63,12 @@ impl Output { Ok(()) } + /// Writes text as it is (it should end with a newline). + pub fn text(&mut self, text: &str) -> Result<()> { + self.out.write_all(text.as_bytes())?; + Ok(()) + } + pub fn line(&mut self, line: &str) -> Result<()> { writeln!(self.out, "{line}")?; Ok(()) diff --git a/crates/trustgraph-cli/tests/cli.rs b/crates/trustgraph-cli/tests/cli.rs index e665165..a9c673c 100644 --- a/crates/trustgraph-cli/tests/cli.rs +++ b/crates/trustgraph-cli/tests/cli.rs @@ -272,6 +272,103 @@ fn convert_formats() { env.cmd().args(["convert", "--to", "xml"]).write_stdin(atom).assert().code(2); } +#[test] +fn convert_to_and_from_vc_jwt() { + let env = Env::new(); + let alice = env.new_key("default"); + let atoms = env.run(&["atom", "-t", "https://sushi.example", "-c", "sushi", "-v", "0.9"], "") + + &env.run(&["atom", "-t", "did:web:bob.example", "-v", "-1", "--sign"], ""); + + // One compact JWS per item; `trust verify` checks them like credentials. + let jwts = env.run(&["convert", "--to", "vc-jwt"], &atoms); + assert_eq!(jwts.lines().count(), 2); + assert!(jwts.lines().all(|l| l.starts_with("eyJ") && l.split('.').count() == 3)); + let checks = env.json_lines(&["verify"], &jwts); + assert!(checks.iter().all(|c| c["valid"] == true && c["issuer"] == alice.as_str())); + assert_eq!(checks[0]["id"], env.run(&["id"], &atoms).lines().next().unwrap()); + + // Import: verified, then any format. + let back = env.json_lines(&["convert", "--from", "vc-jwt"], &jwts); + assert_eq!(back[0]["target"], "https://sushi.example"); + let credential = &env.json_lines(&["convert", "--from", "vc-jwt", "--to", "credential"], &jwts)[1]; + assert_eq!(credential["credentialSubject"]["value"], "-1"); + assert!(credential.get("proof").is_none()); + + // Tampering is caught on import and by verify; JWTs need --from. + let first = jwts.lines().next().unwrap(); + let (rest, signature) = first.rsplit_once('.').unwrap(); + let flipped = if signature.starts_with('A') { "B" } else { "A" }; + let tampered = format!("{rest}.{flipped}{}", &signature[1..]); + env.cmd().args(["verify"]).write_stdin(tampered.clone()).assert().code(1); + env.cmd() + .args(["convert", "--from", "vc-jwt"]) + .write_stdin(tampered) + .assert() + .failure() + .stderr(predicate::str::contains("item 1")); + env.cmd() + .args(["convert", "--to", "atom"]) + .write_stdin(jwts) + .assert() + .failure() + .stderr(predicate::str::contains("--from vc-jwt")); + + // Signing someone else's atom fails. + env.cmd() + .args(["convert", "--to", "vc-jwt"]) + .write_stdin(r#"{"source":"did:web:carol.example","target":"urn:x:y"}"#) + .assert() + .failure() + .stderr(predicate::str::contains("does not match signing key")); +} + +#[test] +fn convert_to_other_formats() { + let env = Env::new(); + let atoms = "{\"source\":\"did:web:alice.example\",\"target\":\"did:web:bob.example\",\"content\":\"Rust code review\",\"value\":\"0.8\",\"timestamp\":\"2026-10-08T12:00:00Z\",\"extra\":{\"reason\":\"pairing\"}}\n\ + {\"source\":\"did:web:alice.example\",\"target\":\"https://sushi.example\",\"content\":\"sushi\",\"value\":\"-0.5\",\"timestamp\":\"2026-10-08T13:00:00Z\"}\n"; + + let peer = env.json_lines(&["convert", "--to", "caip-261"], atoms); + assert_eq!(peer.len(), 2); + assert_eq!(peer[0]["type"], json!(["VerifiableCredential", "PeerTrustCredential"])); + assert_eq!( + peer[0]["credentialSubject"]["trustworthiness"], + json!([{ "scope": "Rust code review", "level": 0.8, "reason": ["pairing"] }]) + ); + // CAIP-261 round-trips; `--to` defaults to atom. + let peer_text = peer.iter().map(|p| p.to_string() + "\n").collect::(); + assert_eq!(env.run(&["convert", "--from", "caip-261"], &peer_text), atoms); + + assert_eq!( + env.run(&["convert", "--to", "ijv-csv"], atoms), + "i,j,v\ndid:web:alice.example,did:web:bob.example,0.8\n" + ); + assert_eq!( + env.run(&["convert", "--to", "ijv-csv", "--negative", "keep", "--topic", "sushi"], atoms), + "i,j,v\ndid:web:alice.example,https://sushi.example,-0.5\n" + ); + + let labels = env.json_lines(&["convert", "--to", "atproto-label"], atoms); + assert_eq!(labels[0]["val"], "trusted-rust-code-review"); + assert_eq!(labels[1]["val"], "distrusted-sushi"); + let events = env.json_lines(&["convert", "--to", "nostr-label"], atoms); + assert_eq!(events[1]["kind"], 1985); + assert_eq!(events[1]["tags"][2], json!(["r", "https://sushi.example"])); + + let reviews = env.json_lines(&["convert", "--to", "schema-org"], atoms); + assert_eq!(reviews.len(), 1, "one JSON-LD document"); + assert_eq!(reviews[0]["@graph"][1]["reviewRating"]["ratingValue"], json!(-0.5)); + + // Formats that need a value say which item lacks one. + env.cmd() + .args(["convert", "--to", "caip-261"]) + .write_stdin(format!("{atoms}{{\"source\":\"did:web:alice.example\",\"target\":\"urn:x:y\"}}")) + .assert() + .failure() + .stderr(predicate::str::contains("item 3").and(predicate::str::contains("no value"))); + env.cmd().arg("convert").write_stdin(atoms).assert().code(2).stderr(predicate::str::contains("--to")); +} + #[test] fn reads_ndjson_files_and_concatenated_json() { let env = Env::new(); diff --git a/crates/trustgraph-core/src/api.rs b/crates/trustgraph-core/src/api.rs index d277668..bcc5b3e 100644 --- a/crates/trustgraph-core/src/api.rs +++ b/crates/trustgraph-core/src/api.rs @@ -15,8 +15,11 @@ use serde::{Deserialize, Serialize}; use serde_json::Value as Json; +use crate::export::ijv::CsvOptions; +use crate::export::labels::AtprotoLabel; use crate::{ ContentId, Did, Error, Keypair, LensEntry, LensOptions, Result, Supersession, TrustAtom, TrustGraph, credential, + export, jose, }; /// The core's version. @@ -310,6 +313,111 @@ pub fn rollup(items: Vec, root: &str, request: &LensRequest, at: &str) -> TrustGraph::rollup(root, &entries, &request.options()?, at) } +/// Signs an atom as an `application/vc+jwt` compact JWS (VC-JOSE-COSE, +/// `alg: Ed25519`), the JOSE alternative to [`sign_atom`]. `created` (RFC +/// 3339) stamps an atom that has no timestamp. A credential is accepted as +/// input; its proof, if any, is dropped and not checked. +/// +/// # Errors +/// +/// Fails like [`sign_atom`]. +pub fn sign_vc_jwt(atom: Json, secret_key_multibase: &str, created: &str) -> Result { + let atom = parse_atom(atom)?; + let keypair = Keypair::from_secret_multibase(secret_key_multibase)?; + let created = created.parse().map_err(|_| Error::InvalidInput(format!("`{created}` is not an RFC 3339 time")))?; + jose::sign_atom(&atom, &keypair, created) +} + +/// Verifies an `application/vc+jwt` Trust Atom credential as strictly as +/// [`verify`] checks Data Integrity proofs, and reports the same way. The +/// credential ID is the CID of the JWT's bytes. Never fails. +#[must_use] +pub fn verify_vc_jwt(jwt: &str) -> Verification { + match jose::verify(jwt).and_then(|v| Ok((v.atom.id()?, v))) { + Ok((id, verified)) => Verification { + valid: true, + id: Some(id.to_string()), + credential_id: Some(jose::credential_id(jwt).to_string()), + issuer: Some(verified.signer.to_string()), + atom: Some(verified.atom), + error: None, + }, + Err(err) => Verification { + valid: false, + id: None, + credential_id: None, + issuer: None, + atom: None, + error: Some(err.to_string()), + }, + } +} + +/// Current atoms in `items` (atoms and/or credentials, signed ones +/// verified) as unsigned CAIP-261 `PeerTrustCredential`s, one per source and +/// target. See [`export::caip261`]. +/// +/// # Errors +/// +/// Fails if an item is invalid, or an atom has no value; errors name the +/// item (from 1). +pub fn to_peer_trust(items: Vec) -> Result> { + export::caip261::to_credentials(&export::current(items)?.current) +} + +/// The atoms in a CAIP-261 `PeerTrustCredential` (its proof is not +/// checked). See [`export::caip261`]. +/// +/// # Errors +/// +/// Fails if the input is not a `PeerTrustCredential`, is a revocation, or +/// an entry does not make a valid atom. +pub fn from_peer_trust(credential: &Json) -> Result> { + export::caip261::from_credential(credential) +} + +/// Current atoms in `items` as an OpenRank / EigenTrust `i,j,v` local-trust +/// CSV. See [`export::ijv`]. +/// +/// # Errors +/// +/// Fails if an item is invalid. +pub fn to_ijv_csv(items: Vec, options: &CsvOptions) -> Result { + export::ijv::to_csv(&export::current(items)?.current, options) +} + +/// Current atoms in `items` as unsigned AT Protocol labels, with negations +/// for superseded ones. See [`export::labels`]. +/// +/// # Errors +/// +/// Fails if an item is invalid, or a labelled atom has no timestamp, a +/// source that is not a DID, or content with no ASCII letters. +pub fn to_atproto_labels(items: Vec) -> Result> { + export::labels::to_atproto(&export::current(items)?) +} + +/// Current atoms in `items` as unsigned Nostr NIP-32 label events +/// (`kind: 1985`). See [`export::labels`]. +/// +/// # Errors +/// +/// Fails if an item is invalid, or a labelled atom has no timestamp or +/// content with no ASCII letters. +pub fn to_nostr_labels(items: Vec) -> Result> { + export::labels::to_nostr(&export::current(items)?.current) +} + +/// Current atoms in `items` as one schema.org JSON-LD document of +/// `Review`s. See [`export::schema_org`]. +/// +/// # Errors +/// +/// Fails if an item is invalid, or an atom has no value. +pub fn to_schema_org(items: Vec) -> Result { + export::schema_org::to_json_ld(&export::current(items)?.current) +} + #[cfg(test)] mod tests { use super::*; @@ -482,6 +590,48 @@ mod tests { assert!(request.signed_only); } + #[test] + fn vc_jwt_sign_and_verify() { + let atom = + json!({ "source": alice().did, "target": "https://sushi.example", "content": "sushi", "value": "0.9" }); + let jwt = sign_vc_jwt(atom.clone(), &alice().secret_key_multibase, "2024-01-01T00:00:00Z").unwrap(); + let result = verify_vc_jwt(&jwt); + assert!(result.valid, "{result:?}"); + assert_eq!(result.issuer.as_deref(), Some(alice().did.as_str())); + assert_eq!(result.id, verify(&signed("https://sushi.example", "0.9")).id, "same atom, same atom ID"); + assert_ne!(result.credential_id, result.id); + // A Data Integrity credential converts: its proof is dropped. + let resigned = + sign_vc_jwt(signed("https://sushi.example", "0.9"), &alice().secret_key_multibase, "2030-01-01T00:00:00Z"); + assert_eq!(resigned.unwrap(), jwt); + assert!(sign_vc_jwt(atom, "zNotAKey", "2024-01-01T00:00:00Z").is_err()); + let bad = verify_vc_jwt("eyJ.x.y"); + assert!(!bad.valid); + assert!(bad.error.unwrap().contains("vc+jwt")); + } + + #[test] + fn exports_use_current_atoms() { + let old = signed("https://sushi.example", "0.9"); + let new = sign_atom( + json!({ "source": alice().did, "target": "https://sushi.example", "content": "sushi", "value": "-0.5" }), + &alice().secret_key_multibase, + "2024-02-01T00:00:00Z", + ) + .unwrap(); + let items = vec![old, new]; + let peer = to_peer_trust(items.clone()).unwrap(); + assert_eq!(peer.len(), 1); + assert_eq!(peer[0]["credentialSubject"]["trustworthiness"], json!([{ "scope": "sushi", "level": -0.5 }])); + assert_eq!(from_peer_trust(&peer[0]).unwrap()[0].value.unwrap().to_string(), "-0.5"); + assert_eq!(to_ijv_csv(items.clone(), &CsvOptions::default()).unwrap(), "i,j,v\n"); + let labels = to_atproto_labels(items.clone()).unwrap(); + let vals: Vec<_> = labels.iter().map(|l| (l.val.as_str(), l.neg)).collect(); + assert_eq!(vals, [("distrusted-sushi", false), ("trusted-sushi", true)]); + assert_eq!(to_nostr_labels(items.clone()).unwrap().len(), 1); + assert_eq!(to_schema_org(items).unwrap()["@graph"][0]["reviewRating"]["ratingValue"], json!(-0.5)); + } + #[test] fn everything_is_deterministic() { let items = vec![signed("https://a.example", "0.5"), signed("https://b.example", "-0.5")]; diff --git a/crates/trustgraph-core/src/credential.rs b/crates/trustgraph-core/src/credential.rs index b050724..69039e8 100644 --- a/crates/trustgraph-core/src/credential.rs +++ b/crates/trustgraph-core/src/credential.rs @@ -360,7 +360,7 @@ fn hash_data(proof_config: &Map, document: &Map) -> Ok(data) } -fn whole_seconds(t: Timestamp) -> Result { +pub(crate) fn whole_seconds(t: Timestamp) -> Result { t.round(TimestampRound::new().smallest(Unit::Second).mode(RoundMode::Trunc)) .map_err(|e| Error::InvalidCredential(e.to_string())) } diff --git a/crates/trustgraph-core/src/export/caip261.rs b/crates/trustgraph-core/src/export/caip261.rs new file mode 100644 index 0000000..1c4c3e0 --- /dev/null +++ b/crates/trustgraph-core/src/export/caip261.rs @@ -0,0 +1,311 @@ +//! [CAIP-261 "Web of Trust Primitives"](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-261.md) +//! (Chain Agnostic Standards Alliance, Draft, updated 2024-03-20): +//! `PeerTrustCredential`, the closest prior art to Trust Atoms. +//! +//! A CAIP-261 assertion is a VC Data Model 1.1 credential from `issuer` +//! about `credentialSubject.id`, holding a list of `trustworthiness` +//! entries, each a `scope` (the topic), a `level` in `[-1, 1]` and optional +//! `reason`s. A Trust Atom is one such entry. +//! +//! # Export: atoms to credentials +//! +//! CAIP-261 §"Trust Update": a new assertion "MUST supersede any previous +//! assertions of the same type, issued by the same entity, and pertaining +//! to the same subject". So we write **one credential per (source, +//! target)**, holding every [current](super::current) atom between them, in +//! input order: +//! +//! | Atom | `PeerTrustCredential` | +//! |---|---| +//! | `source` | `issuer` | +//! | `target` | `credentialSubject.id` | +//! | `content` | `trustworthiness[].scope` (left out if the atom has none) | +//! | `value` | `trustworthiness[].level` (a JSON number) | +//! | `extra.reason` | `trustworthiness[].reason`: one reason, or a JSON array of several written as a string | +//! | other `extra` | `trustworthiness[].extra` (a Trust Graph extension) | +//! | `timestamp` | `issuanceDate`: the latest timestamp of the atoms in the credential | +//! +//! The credentials are **unsigned**: CAIP-261 recommends EIP-712 proofs, +//! which need Ethereum keys. `replaces` links are not carried over (they +//! name Trust Graph credentials, not CAIP-261 documents); supersession has +//! already been applied. There is no `credentialSchema`, because CAIP-261 +//! does not publish one. +//! +//! # Import: credentials to atoms +//! +//! Each entry becomes an atom, timestamped with `issuanceDate` (or +//! `validFrom`). Proofs are **not** verified (EIP-712 and other suites are +//! out of scope), so the atoms are unsigned. Revocations (`credentialStatus` +//! with `statusPurpose: revocation`) are rejected: they name the revoked +//! CAIP-261 document by content ID, which has no atom equivalent. +//! `previousVersion`, `validUntil` and unknown entry members are ignored. + +use std::collections::BTreeMap; + +use jiff::Timestamp; +use serde_json::{Map, Value as Json, json}; + +use super::{Numbered, no_timestamp, no_value}; +use crate::{Error, Result, TrustAtom, Value}; + +/// The VC Data Model 1.1 context CAIP-261 uses. +pub const CONTEXT: &str = "https://www.w3.org/2018/credentials/v1"; +/// The credential type. +pub const PEER_TRUST_CREDENTIAL: &str = "PeerTrustCredential"; +/// The `extra` key that holds CAIP-261 `reason`s. +pub const REASON: &str = "reason"; + +/// Atoms as unsigned CAIP-261 `PeerTrustCredential`s: one per source and +/// target, in order of first appearance. +/// +/// # Errors +/// +/// Fails if an atom has no value, or no atom between a source and target +/// has a timestamp. +pub fn to_credentials(atoms: &[Numbered]) -> Result> { + let mut groups: Vec<((&str, &str), Vec<&Numbered>)> = Vec::new(); + for numbered in atoms { + let key = (numbered.atom.source.as_str(), numbered.atom.target.as_str()); + match groups.iter_mut().find(|(k, _)| *k == key) { + Some((_, group)) => group.push(numbered), + None => groups.push((key, vec![numbered])), + } + } + groups + .into_iter() + .map(|((issuer, subject), group)| { + let entries = group.iter().map(|a| entry(a)).collect::>>()?; + let issued = group + .iter() + .filter_map(|a| a.atom.timestamp) + .max() + .ok_or_else(|| no_timestamp(group[0].n, "a CAIP-261 credential (issuanceDate)"))?; + Ok(json!({ + "@context": [CONTEXT], + "type": ["VerifiableCredential", PEER_TRUST_CREDENTIAL], + "issuanceDate": issued.to_string(), + "issuer": issuer, + "credentialSubject": { "id": subject, "trustworthiness": entries }, + })) + }) + .collect() +} + +fn entry(numbered: &Numbered) -> Result { + let atom = &numbered.atom; + let value = atom.value.ok_or_else(|| no_value(numbered.n, "a CAIP-261 trustworthiness level"))?; + let mut entry = Map::new(); + if let Some(content) = &atom.content { + entry.insert("scope".into(), json!(content)); + } + entry.insert("level".into(), number(value)); + let mut extra = atom.extra.clone(); + if let Some(reason) = extra.remove(REASON) { + let reasons = match serde_json::from_str::>(&reason) { + Ok(reasons) => reasons, + Err(_) => vec![reason], + }; + entry.insert("reason".into(), json!(reasons)); + } + if !extra.is_empty() { + entry.insert("extra".into(), json!(extra)); + } + Ok(Json::Object(entry)) +} + +/// A value as a JSON number: the shortest float that reads back as it. +fn number(value: Value) -> Json { + serde_json::from_str(&value.to_string()).unwrap_or(Json::Null) +} + +/// The atoms in a CAIP-261 `PeerTrustCredential`, one per trustworthiness +/// entry. The proof, if any, is not checked. +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] if `credential` is not a +/// `PeerTrustCredential`, is a revocation, or an entry does not make a valid +/// atom. +pub fn from_credential(credential: &Json) -> Result> { + let bad = |msg: &str| Error::InvalidInput(format!("not a CAIP-261 PeerTrustCredential: {msg}")); + let doc = credential.as_object().ok_or_else(|| bad("not a JSON object"))?; + let types = match doc.get("type") { + Some(Json::Array(types)) => types.iter().collect(), + Some(t) => vec![t], + None => vec![], + }; + if !types.iter().any(|t| *t == PEER_TRUST_CREDENTIAL) { + return Err(bad("`type` does not include PeerTrustCredential")); + } + if let Some(status) = doc.get("credentialStatus") { + if status.get("statusPurpose").and_then(Json::as_str) == Some("revocation") { + return Err(bad("revocations (credentialStatus) are not supported")); + } + } + let issuer = match doc.get("issuer") { + Some(Json::String(issuer)) => issuer.as_str(), + Some(Json::Object(issuer)) => { + issuer.get("id").and_then(Json::as_str).ok_or_else(|| bad("`issuer.id` is missing"))? + } + _ => return Err(bad("`issuer` is missing")), + }; + let timestamp = match doc.get("issuanceDate").or_else(|| doc.get("validFrom")) { + Some(time) => Some( + time.as_str() + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| bad("`issuanceDate` is not an RFC 3339 date-time"))?, + ), + None => None, + }; + let subject = doc + .get("credentialSubject") + .and_then(Json::as_object) + .ok_or_else(|| bad("`credentialSubject` must be one object"))?; + let target = subject.get("id").and_then(Json::as_str).ok_or_else(|| bad("`credentialSubject.id` is missing"))?; + let entries = subject + .get("trustworthiness") + .and_then(Json::as_array) + .ok_or_else(|| bad("`credentialSubject.trustworthiness` must be an array"))?; + + entries + .iter() + .enumerate() + .map(|(i, entry)| { + let bad_entry = |msg: &str| Error::InvalidInput(format!("trustworthiness entry {}: {msg}", i + 1)); + let entry = entry.as_object().ok_or_else(|| bad_entry("not an object"))?; + let mut atom = TrustAtom::new(issuer, target); + atom.timestamp = timestamp; + if let Some(scope) = entry.get("scope") { + atom.content = Some(scope.as_str().ok_or_else(|| bad_entry("`scope` must be a string"))?.to_owned()); + } + let level = + entry.get("level").filter(|l| l.is_number()).ok_or_else(|| bad_entry("`level` must be a number"))?; + atom.value = Some(serde_json::from_value(level.clone()).map_err(|e| bad_entry(&e.to_string()))?); + if let Some(reason) = entry.get("reason") { + let reasons: Vec = serde_json::from_value(reason.clone()) + .map_err(|_| bad_entry("`reason` must be an array of strings"))?; + let reason = match reasons.as_slice() { + [one] => one.clone(), + _ => serde_json::to_string(&reasons)?, + }; + atom.extra.insert(REASON.into(), reason); + } + if let Some(extra) = entry.get("extra") { + let extra: BTreeMap = serde_json::from_value(extra.clone()) + .map_err(|_| bad_entry("`extra` must be an object of strings"))?; + atom.extra.extend(extra.into_iter().filter(|(k, _)| k != REASON)); + } + atom.validate().map_err(|e| bad_entry(&e.to_string()))?; + Ok(atom) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn numbered(atoms: Vec) -> Vec { + atoms.into_iter().enumerate().map(|(i, atom)| Numbered { n: i + 1, atom }).collect() + } + + /// The first example in CAIP-261, with its syntax completed (the spec's + /// snippet omits the outer braces and a closing bracket). + fn spec_example() -> Json { + json!({ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiableCredential", "PeerTrustCredential"], + "issuanceDate": "2024-02-15T07:05:56.273Z", + "issuer": "did:pkh:eip155:1:0x44dc4E3309B80eF7aBf41C7D0a68F0337a88F044", + "credentialSubject": { + "id": "did:pkh:eip155:1:0xfA045B2F2A25ad0B7365010eaf9AC2Dd9905895c", + "trustworthiness": [ + { "scope": "Honesty", "level": 0.5, "reason": ["Alumnus"] }, + { "scope": "Software development", "level": 1, "reason": ["Software engineer", "Ethereum core developer"] }, + { "scope": "Software security", "level": 0.5, "reason": ["White Hat", "Smart Contract Auditor"] } + ] + }, + "credentialSchema": [{ "id": "ipfs://QmcwYEnLysTyepjjtJw19oTDwuiopbCDbEcCuprCBiL7gl", "type": "JsonSchema" }], + "proof": {} + }) + } + + #[test] + fn imports_the_spec_example_and_round_trips_it() { + let atoms = from_credential(&spec_example()).unwrap(); + assert_eq!(atoms.len(), 3); + assert_eq!(atoms[0].source, "did:pkh:eip155:1:0x44dc4E3309B80eF7aBf41C7D0a68F0337a88F044"); + assert_eq!(atoms[0].content.as_deref(), Some("Honesty")); + assert_eq!(atoms[0].value.unwrap().to_string(), "0.5"); + assert_eq!(atoms[0].extra[REASON], "Alumnus"); + assert_eq!(atoms[1].extra[REASON], r#"["Software engineer","Ethereum core developer"]"#); + assert_eq!(atoms[2].timestamp.unwrap().to_string(), "2024-02-15T07:05:56.273Z"); + + let back = to_credentials(&numbered(atoms)).unwrap(); + assert_eq!(back.len(), 1); + let mut expected = spec_example(); + let doc = expected.as_object_mut().unwrap(); + doc.remove("credentialSchema"); + doc.remove("proof"); + assert_eq!(back[0], expected); + } + + #[test] + fn groups_by_source_and_target() { + let at = |s: &str| s.parse().unwrap(); + let atoms = numbered(vec![ + TrustAtom::new("did:key:a", "did:key:b") + .with_content("x") + .with_value(Value::MAX) + .with_timestamp(at("2024-01-01T00:00:00Z")), + TrustAtom::new("did:key:a", "did:key:c").with_value(Value::MIN).with_timestamp(at("2024-01-01T00:00:00Z")), + TrustAtom::new("did:key:a", "did:key:b") + .with_content("y") + .with_value("-0.25".parse().unwrap()) + .with_extra("via", "meetup"), + ]); + let credentials = to_credentials(&atoms).unwrap(); + assert_eq!(credentials.len(), 2); + assert_eq!( + credentials[0]["credentialSubject"]["trustworthiness"], + json!([{ "scope": "x", "level": 1 }, { "scope": "y", "level": -0.25, "extra": { "via": "meetup" } }]) + ); + assert_eq!( + credentials[1]["credentialSubject"], + json!({ "id": "did:key:c", "trustworthiness": [{ "level": -1 }] }) + ); + let back: Vec<_> = credentials.iter().flat_map(|c| from_credential(c).unwrap()).collect(); + assert_eq!(back[1].extra["via"], "meetup"); + assert_eq!(back[1].content.as_deref(), Some("y")); + assert_eq!(back[2].content, None, "no scope, no content"); + } + + #[test] + fn export_needs_values_and_a_timestamp() { + let err = to_credentials(&numbered(vec![TrustAtom::new("urn:x:a", "urn:x:b")])).unwrap_err(); + assert!(err.to_string().contains("item 1: the atom has no value"), "{err}"); + let err = + to_credentials(&numbered(vec![TrustAtom::new("urn:x:a", "urn:x:b").with_value(Value::MAX)])).unwrap_err(); + assert!(err.to_string().contains("issuanceDate"), "{err}"); + } + + #[test] + fn import_rejects_what_it_cannot_map() { + let mut revocation = spec_example(); + revocation["credentialStatus"] = + json!({ "id": "ipfs://Qm", "type": "CredentialStatus", "statusPurpose": "revocation" }); + assert!(from_credential(&revocation).unwrap_err().to_string().contains("revocation")); + let mut wrong = spec_example(); + wrong["type"] = json!(["VerifiableCredential"]); + assert!(from_credential(&wrong).is_err()); + let mut too_high = spec_example(); + too_high["credentialSubject"]["trustworthiness"][1]["level"] = json!(1.5); + assert!(from_credential(&too_high).unwrap_err().to_string().contains("entry 2")); + let mut text = spec_example(); + text["credentialSubject"]["trustworthiness"][0]["level"] = json!("0.5"); + assert!(from_credential(&text).is_err()); + let mut object_issuer = spec_example(); + object_issuer["issuer"] = json!({ "id": "did:key:z6Mk" }); + assert_eq!(from_credential(&object_issuer).unwrap()[0].source, "did:key:z6Mk"); + } +} diff --git a/crates/trustgraph-core/src/export/ijv.rs b/crates/trustgraph-core/src/export/ijv.rs new file mode 100644 index 0000000..12e9686 --- /dev/null +++ b/crates/trustgraph-core/src/export/ijv.rs @@ -0,0 +1,138 @@ +//! The `i,j,v` local-trust CSV of [OpenRank](https://docs.openrank.com/) and +//! other EigenTrust implementations: one row per edge, "peer `i` trusts +//! peer `j` this much". +//! +//! The format is the one the OpenRank SDK reads for local trust +//! ([`openrank-sdk/README.md`](https://github.com/openrankprotocol/openrank/blob/main/openrank-sdk/README.md)): +//! a header row `i,j,v`, then `i` and `j` as arbitrary strings and `v` a +//! number (an `f32` in `common/src/tx/trust.rs`). +//! +//! # Mapping +//! +//! - Only [current](super::current) atoms with a value count; atoms +//! without one are no edge (as in the Agent Lens). +//! - With a `topic`, only atoms about it (see +//! [`TrustAtom::matches_topic`](crate::TrustAtom::matches_topic)). +//! - A matrix holds one value per (`i`, `j`), so several current atoms +//! between the same source and target (different topics) are averaged, +//! exactly as the Agent Lens does. +//! - **Negative values.** EigenTrust is defined over non-negative local +//! trust, and OpenRank runs "positive EigenTrust" +//! (`common/src/algos/et.rs`), so by default ([`Negative::Drop`]) edges +//! with a value of zero or less are left out: distrust becomes "no +//! trust". [`Negative::Keep`] writes them anyway, for signed-graph tools. +//! - Rows are sorted by `i`, then `j`. Fields are quoted as RFC 4180 +//! requires (only if they hold `,`, `"` or a line break, which URIs +//! rarely do). + +use std::collections::BTreeMap; +use std::fmt::Write; + +use serde::{Deserialize, Serialize}; + +use super::Numbered; +use crate::value::Decimal; +use crate::{Error, Result, Value}; + +/// What to do with edges whose value is zero or negative. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Negative { + /// Leave them out (EigenTrust needs non-negative trust). The default. + #[default] + Drop, + /// Write them as they are. + Keep, +} + +/// Options for [`to_csv`]. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CsvOptions { + /// Only atoms about this topic. + pub topic: Option, + /// What to do with zero and negative values. + pub negative: Negative, +} + +/// Current atoms as an `i,j,v` CSV, header included, each row ending in +/// `\n`. +/// +/// # Errors +/// +/// Fails only if an average falls outside `-1..=1`, which cannot happen. +pub fn to_csv(atoms: &[Numbered], options: &CsvOptions) -> Result { + let mut edges: BTreeMap<(&str, &str), (Decimal, u32)> = BTreeMap::new(); + for Numbered { atom, .. } in atoms { + let Some(value) = atom.value else { continue }; + if options.topic.as_deref().is_some_and(|topic| !atom.matches_topic(topic)) { + continue; + } + let (sum, count) = edges.entry((&atom.source, &atom.target)).or_default(); + *sum += value.decimal(); + *count += 1; + } + let mut csv = String::from("i,j,v\n"); + for ((i, j), (sum, count)) in edges { + let v = Value::new(sum / Decimal::from(count))?; + if options.negative == Negative::Drop && !v.is_positive() { + continue; + } + writeln!(csv, "{},{},{v}", field(i), field(j)).map_err(|e| Error::InvalidInput(e.to_string()))?; + } + Ok(csv) +} + +/// An RFC 4180 field. +fn field(s: &str) -> String { + if s.contains([',', '"', '\r', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_owned() } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::TrustAtom; + + fn atoms() -> Vec { + let rate = + |s: &str, t: &str, c: &str, v: &str| TrustAtom::new(s, t).with_content(c).with_value(v.parse().unwrap()); + [ + rate("did:key:b", "did:key:c", "sushi", "0.5"), + rate("did:key:a", "did:key:b", "sushi", "1"), + rate("did:key:a", "did:key:b", "rust", "0.5"), + rate("did:key:a", "did:key:c", "sushi", "-1"), + rate("did:key:a", "urn:x:a,b", "sushi", "0.2"), + TrustAtom::new("did:key:a", "did:key:d"), + ] + .into_iter() + .enumerate() + .map(|(i, atom)| Numbered { n: i + 1, atom }) + .collect() + } + + #[test] + fn writes_a_sorted_matrix_without_distrust() { + assert_eq!( + to_csv(&atoms(), &CsvOptions::default()).unwrap(), + "i,j,v\ndid:key:a,did:key:b,0.75\ndid:key:a,\"urn:x:a,b\",0.2\ndid:key:b,did:key:c,0.5\n" + ); + } + + #[test] + fn keeps_negatives_and_filters_topics_on_request() { + let options = CsvOptions { topic: Some("SUSHI".into()), negative: Negative::Keep }; + assert_eq!( + to_csv(&atoms(), &options).unwrap(), + "i,j,v\ndid:key:a,did:key:b,1\ndid:key:a,did:key:c,-1\ndid:key:a,\"urn:x:a,b\",0.2\ndid:key:b,did:key:c,0.5\n" + ); + assert_eq!(to_csv(&[], &options).unwrap(), "i,j,v\n"); + let parsed: CsvOptions = serde_json::from_str(r#"{"negative":"keep"}"#).unwrap(); + assert_eq!(parsed.negative, Negative::Keep); + } + + #[test] + fn quotes_fields() { + assert_eq!(field("a\"b"), "\"a\"\"b\""); + assert_eq!(field("did:key:a"), "did:key:a"); + } +} diff --git a/crates/trustgraph-core/src/export/labels.rs b/crates/trustgraph-core/src/export/labels.rs new file mode 100644 index 0000000..ef5e933 --- /dev/null +++ b/crates/trustgraph-core/src/export/labels.rs @@ -0,0 +1,347 @@ +//! Labels for social protocols: [AT Protocol labels](https://atproto.com/specs/label) +//! (`com.atproto.label.defs#label`) and [Nostr NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) +//! label events (`kind: 1985`). +//! +//! Both are exported as **unsigned templates**. AT Protocol labels are +//! signed over DRISL (deterministic CBOR) with the labeler's +//! `#atproto_label` key (usually secp256k1 or P-256), and Nostr events with +//! the author's secp256k1 Schnorr key (NIP-01). Neither is a Trust Graph +//! Ed25519 key, so signing is left to the labeler or Nostr client. +//! +//! # Label values +//! +//! Labels are tokens, not scores: the AT Protocol spec advises against +//! numbers in `val` and recommends lower-case ASCII letters with internal +//! dashes, and NIP-32 says values like "3.18743" "are not labels". So an +//! atom becomes a label only by the **sign** of its value: +//! +//! | Atom | Label value | +//! |---|---| +//! | `value > 0` | `trusted` | +//! | `value < 0` | `distrusted` | +//! | `value` 0 or absent | no label (neutral) | +//! | with `content` | `trusted-` / `distrusted-` | +//! +//! `` is the content in lower case, with every run of characters +//! other than ASCII letters replaced by one `-` (`Rust code review` → +//! `rust-code-review`, `web3` → `web`). Content with no ASCII letters, or a +//! value longer than 128 bytes, is an error. +//! +//! # AT Protocol +//! +//! `{ver: 1, src, uri, val, neg?, cts}`: `src` is the atom's source (which +//! must be a DID), `uri` its target (an `at://` URI or a DID, for +//! atproto), `cts` its timestamp. Retractions use `neg`: for each label +//! that an earlier, superseded atom would have produced and the current +//! atoms no longer do, a negation label is written, timestamped with the +//! source's latest timestamp in the export (later than the label it +//! negates, as the spec requires). +//! +//! # Nostr +//! +//! One event template per labelled atom: `kind` 1985, `created_at` (the +//! atom's timestamp in seconds), tags `["L", "net.trustgraph"]`, +//! `["l", , "net.trustgraph"]` and a target tag, and empty +//! `content`. The target tag is `["p", ]` for a `nostr:npub1…` target, +//! `["e", ]` for `nostr:note1…`, and `["r", ]` for anything else. +//! `pubkey`, `id` and `sig` are left for the signer. NIP-32 has no +//! negation: retract a label by deleting its event (NIP-09), which needs +//! the signed event's ID, so retractions are not exported. + +use std::collections::{BTreeMap, HashSet}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value as Json, json}; + +use super::{Current, Numbered, no_timestamp}; +use crate::{Error, Result, TrustAtom}; + +/// The NIP-32 label namespace (`L` tag): reverse domain notation for +/// trustgraph.net. +pub const NOSTR_NAMESPACE: &str = "net.trustgraph"; +/// The NIP-32 label event kind. +pub const NOSTR_LABEL_KIND: u32 = 1985; + +/// An AT Protocol label (`com.atproto.label.defs#label`), without `sig`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AtprotoLabel { + /// The label schema version: always 1. + pub ver: u32, + /// The labeler's DID: the atom's source. + pub src: String, + /// What is labelled: the atom's target. + pub uri: String, + /// The label value, e.g. `trusted-rust-code-review`. + pub val: String, + /// True for a negation (retraction) of an earlier label. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub neg: bool, + /// When the label was created (RFC 3339). + pub cts: String, +} + +/// The label value for an atom, or `None` if it is neutral. +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] if the content has no ASCII letters, or +/// the value would exceed 128 bytes. +pub fn label_value(atom: &TrustAtom) -> Result> { + let Some(value) = atom.value.filter(|v| !v.decimal().is_zero()) else { return Ok(None) }; + let mut label = String::from(if value.is_positive() { "trusted" } else { "distrusted" }); + if let Some(content) = &atom.content { + let mut topic = String::new(); + for c in content.chars() { + if c.is_ascii_alphabetic() { + topic.push(c.to_ascii_lowercase()); + } else if !topic.is_empty() && !topic.ends_with('-') { + topic.push('-'); + } + } + let topic = topic.trim_end_matches('-'); + if topic.is_empty() { + return Err(Error::InvalidInput(format!("content `{content}` has no ASCII letters to make a label from"))); + } + label = format!("{label}-{topic}"); + } + if label.len() > 128 { + return Err(Error::InvalidInput(format!("label `{label}` is longer than 128 bytes"))); + } + Ok(Some(label)) +} + +/// AT Protocol labels for the current atoms, plus negations for labels that +/// superseded atoms would have made. +/// +/// # Errors +/// +/// Fails if a labelled atom's source is not a DID, it has no timestamp, or +/// its label value is invalid (see [`label_value`]). +pub fn to_atproto(atoms: &Current) -> Result> { + let mut labels = Vec::new(); + let mut live = HashSet::new(); + for Numbered { n, atom } in &atoms.current { + let item = |e: Error| Error::InvalidInput(format!("item {n}: {e}")); + let Some(val) = label_value(atom).map_err(item)? else { continue }; + if !atom.source.starts_with("did:") { + return Err(item(Error::InvalidInput(format!("the labeler (source) `{}` must be a DID", atom.source)))); + } + let cts = atom.timestamp.ok_or_else(|| no_timestamp(*n, "an AT Protocol label (cts)"))?; + live.insert((atom.source.clone(), atom.target.clone(), val.clone())); + labels.push(AtprotoLabel { + ver: 1, + src: atom.source.clone(), + uri: atom.target.clone(), + val, + neg: false, + cts: cts.to_string(), + }); + } + + // Each source's latest timestamp: when its negations are made. + let mut latest = BTreeMap::new(); + for Numbered { atom, .. } in atoms.current.iter().chain(&atoms.superseded) { + if let Some(t) = atom.timestamp { + let entry = latest.entry(atom.source.as_str()).or_insert(t); + *entry = (*entry).max(t); + } + } + let mut negated = HashSet::new(); + for Numbered { atom, .. } in &atoms.superseded { + // Atoms that could never have been labels need no negation. + let (Some(_), true) = (atom.timestamp, atom.source.starts_with("did:")) else { continue }; + let Ok(Some(val)) = label_value(atom) else { continue }; + let key = (atom.source.clone(), atom.target.clone(), val); + if live.contains(&key) || !negated.insert(key.clone()) { + continue; + } + let cts = latest[atom.source.as_str()].to_string(); + labels.push(AtprotoLabel { ver: 1, src: key.0, uri: key.1, val: key.2, neg: true, cts }); + } + Ok(labels) +} + +/// Unsigned Nostr NIP-32 label events (`kind: 1985`) for the current atoms. +/// +/// # Errors +/// +/// Fails if a labelled atom has no timestamp, its label value is invalid +/// (see [`label_value`]), or its target is a malformed `nostr:` URI. +pub fn to_nostr(atoms: &[Numbered]) -> Result> { + let mut events = Vec::new(); + for Numbered { n, atom } in atoms { + let item = |e: Error| Error::InvalidInput(format!("item {n}: {e}")); + let Some(val) = label_value(atom).map_err(item)? else { continue }; + let created_at = atom.timestamp.ok_or_else(|| no_timestamp(*n, "a Nostr event (created_at)"))?.as_second(); + events.push(json!({ + "kind": NOSTR_LABEL_KIND, + "created_at": created_at, + "tags": [["L", NOSTR_NAMESPACE], ["l", val, NOSTR_NAMESPACE], nostr_target(&atom.target).map_err(item)?], + "content": "", + })); + } + Ok(events) +} + +/// The NIP-32 target tag for a URI. +fn nostr_target(uri: &str) -> Result { + let Some(entity) = uri.strip_prefix("nostr:") else { return Ok(json!(["r", uri])) }; + let bad = || Error::InvalidInput(format!("`{uri}` is not a valid nostr: npub or note URI")); + for (hrp, tag) in [("npub", "p"), ("note", "e")] { + if entity.starts_with(hrp) { + let id = bech32::decode(entity, hrp).filter(|bytes| bytes.len() == 32).ok_or_else(bad)?; + return Ok(json!([tag, hex(&id)])); + } + } + Ok(json!(["r", uri])) +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + bytes.iter().flat_map(|&b| [HEX[usize::from(b >> 4)], HEX[usize::from(b & 15)]]).map(char::from).collect() +} + +/// Bech32 (BIP-173) decoding, for NIP-19 `npub` and `note` entities. +mod bech32 { + const CHARSET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + + fn polymod(values: impl IntoIterator) -> u32 { + const GEN: [u32; 5] = [0x3b6a_57b2, 0x2650_8e6d, 0x1ea1_19fa, 0x3d42_33dd, 0x2a14_62b3]; + let mut chk = 1u32; + for v in values { + let top = chk >> 25; + chk = ((chk & 0x1ff_ffff) << 5) ^ u32::from(v); + for (i, g) in GEN.iter().enumerate() { + if (top >> i) & 1 == 1 { + chk ^= g; + } + } + } + chk + } + + /// The data bytes of a lower-case bech32 string with this human-readable + /// part, if its checksum is valid. + pub(super) fn decode(s: &str, hrp: &str) -> Option> { + let data = s.strip_prefix(hrp)?.strip_prefix('1')?; + if data.len() < 6 { + return None; + } + let values: Vec = data + .bytes() + .map(|c| CHARSET.iter().position(|&x| x == c).and_then(|p| u8::try_from(p).ok())) + .collect::>()?; + let expanded = hrp.bytes().map(|b| b >> 5).chain([0]).chain(hrp.bytes().map(|b| b & 31)); + if polymod(expanded.chain(values.iter().copied())) != 1 { + return None; + } + // Regroup 5-bit values into bytes; leftover bits must be zero padding. + let (mut acc, mut bits, mut out) = (0u32, 0u32, Vec::new()); + for &v in &values[..values.len() - 6] { + acc = ((acc << 5) | u32::from(v)) & 0xfff; + bits += 5; + if bits >= 8 { + bits -= 8; + out.push(u8::try_from((acc >> bits) & 0xff).ok()?); + } + } + (bits < 5 && acc & ((1 << bits) - 1) == 0).then_some(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Value; + + fn atom(content: Option<&str>, value: &str, time: &str) -> TrustAtom { + let mut atom = + TrustAtom::new("did:plc:alice", "at://did:plc:bob/app.bsky.feed.post/1").with_value(value.parse().unwrap()); + atom.content = content.map(Into::into); + atom.with_timestamp(time.parse().unwrap()) + } + + fn numbered(atoms: Vec) -> Vec { + atoms.into_iter().enumerate().map(|(i, atom)| Numbered { n: i + 1, atom }).collect() + } + + #[test] + fn values_are_tokens() { + let value = |content: Option<&str>, v: &str| label_value(&atom(content, v, "2024-01-01T00:00:00Z")).unwrap(); + assert_eq!(value(None, "0.9").as_deref(), Some("trusted")); + assert_eq!(value(None, "-0.1").as_deref(), Some("distrusted")); + assert_eq!(value(None, "0"), None); + assert_eq!(value(Some("Rust code review"), "1").as_deref(), Some("trusted-rust-code-review")); + assert_eq!(value(Some(" web3, DeFi!"), "-1").as_deref(), Some("distrusted-web-defi")); + assert!(label_value(&atom(Some("寿司"), "1", "2024-01-01T00:00:00Z")).is_err()); + assert!(label_value(&atom(Some(&"a".repeat(121)), "1", "2024-01-01T00:00:00Z")).is_err()); + assert_eq!(label_value(&TrustAtom::new("did:x:a", "did:x:b")).unwrap(), None); + } + + #[test] + fn atproto_labels_negate_what_was_superseded() { + let atoms = Current { + current: numbered(vec![ + atom(Some("sushi"), "-0.5", "2024-03-01T00:00:00Z"), + atom(Some("rust"), "0", "2024-03-02T00:00:00Z"), + ]), + superseded: numbered(vec![ + atom(Some("sushi"), "0.9", "2024-01-01T00:00:00Z"), + atom(Some("sushi"), "0.8", "2024-02-01T00:00:00Z"), + atom(Some("rust"), "1", "2024-01-01T00:00:00Z"), + atom(Some("sushi"), "-1", "2024-01-15T00:00:00Z"), + ]), + }; + let labels = serde_json::to_value(to_atproto(&atoms).unwrap()).unwrap(); + let (src, uri) = ("did:plc:alice", "at://did:plc:bob/app.bsky.feed.post/1"); + assert_eq!( + labels, + json!([ + { "ver": 1, "src": src, "uri": uri, "val": "distrusted-sushi", "cts": "2024-03-01T00:00:00Z" }, + { "ver": 1, "src": src, "uri": uri, "val": "trusted-sushi", "neg": true, "cts": "2024-03-02T00:00:00Z" }, + { "ver": 1, "src": src, "uri": uri, "val": "trusted-rust", "neg": true, "cts": "2024-03-02T00:00:00Z" }, + ]) + ); + } + + #[test] + fn atproto_needs_did_sources_and_timestamps() { + let mut url_source = atom(None, "1", "2024-01-01T00:00:00Z"); + url_source.source = "https://alice.example".into(); + let current = Current { current: numbered(vec![url_source]), superseded: vec![] }; + assert!(to_atproto(¤t).unwrap_err().to_string().contains("must be a DID")); + let mut undated = atom(None, "1", "2024-01-01T00:00:00Z"); + undated.timestamp = None; + let current = Current { current: numbered(vec![undated]), superseded: vec![] }; + assert!(to_atproto(¤t).unwrap_err().to_string().contains("timestamp")); + } + + #[test] + fn nostr_events() { + // NIP-19's example npub and its hex key. + let npub = "nostr:npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; + let hex = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"; + let atoms = numbered(vec![ + TrustAtom::new("did:key:a", npub) + .with_content("sushi") + .with_value(Value::MAX) + .with_timestamp("2024-01-01T00:00:00Z".parse().unwrap()), + TrustAtom::new("did:key:a", "https://example.com") + .with_value(Value::MIN) + .with_timestamp("2024-01-01T00:00:01Z".parse().unwrap()), + TrustAtom::new("did:key:a", "urn:x:neutral").with_value(Value::ZERO), + ]); + assert_eq!( + serde_json::to_value(to_nostr(&atoms).unwrap()).unwrap(), + json!([ + { "kind": 1985, "created_at": 1_704_067_200, "tags": [["L", "net.trustgraph"], ["l", "trusted-sushi", "net.trustgraph"], ["p", hex]], "content": "" }, + { "kind": 1985, "created_at": 1_704_067_201, "tags": [["L", "net.trustgraph"], ["l", "distrusted", "net.trustgraph"], ["r", "https://example.com"]], "content": "" }, + ]) + ); + assert!( + nostr_target("nostr:npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptx").is_err(), + "bad checksum" + ); + assert_eq!(nostr_target("nostr:nprofile1qqs").unwrap(), json!(["r", "nostr:nprofile1qqs"])); + } +} diff --git a/crates/trustgraph-core/src/export/mod.rs b/crates/trustgraph-core/src/export/mod.rs new file mode 100644 index 0000000..2840b8e --- /dev/null +++ b/crates/trustgraph-core/src/export/mod.rs @@ -0,0 +1,166 @@ +//! Exports to (and, where it makes sense, imports from) other trust and +//! reputation formats. Each submodule documents its mapping and cites the +//! format's specification; `doc/formats/` has the user-facing versions. +//! +//! - [`caip261`]: CAIP-261 `PeerTrustCredential` (Web of Trust Primitives), +//! both ways. +//! - [`ijv`]: the `i,j,v` local-trust CSV of OpenRank / EigenTrust. +//! - [`labels`]: AT Protocol labels and Nostr NIP-32 label events, as +//! unsigned templates. +//! - [`schema_org`]: schema.org `Review` / `Rating` JSON-LD, for web pages. +//! +//! (`application/vc+jwt`, an alternative way to *sign* atoms, is in +//! [`crate::jose`].) +//! +//! Every export starts from the same set of [`current`] atoms: +//! signed credentials are verified, credentials their issuer has replaced +//! are left out, and of several atoms with the same source, target and +//! content only the latest counts, exactly as in the Agent Lens. + +pub mod caip261; +pub mod ijv; +pub mod labels; +pub mod schema_org; + +use std::collections::{HashMap, HashSet}; + +use serde_json::Value as Json; + +use crate::{Error, Record, Result, Supersession, TrustAtom, credential}; + +/// One input atom, and its position in the input (from 1, for errors). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Numbered { + /// The item number, from 1. + pub n: usize, + /// The atom. + pub atom: TrustAtom, +} + +/// The atoms of a set of items, split into those that still hold and those +/// that have been superseded. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Current { + /// The latest atom for each source, target and content, in input order. + pub current: Vec, + /// Atoms replaced (`replaces`) or overtaken by a later atom with the + /// same source, target and content, in input order. + pub superseded: Vec, +} + +/// Reads `items` (atoms, unsigned credentials, or signed credentials, which +/// are verified) and works out which atoms are current. +/// +/// # Errors +/// +/// Fails if an item is invalid or a signed credential does not verify; the +/// error names the item (from 1). +pub fn current(items: Vec) -> Result { + let mut records = Vec::with_capacity(items.len()); + for (i, item) in items.into_iter().enumerate() { + let item_error = |e: Error| Error::InvalidInput(format!("item {}: {e}", i + 1)); + let record = if item.get("proof").is_some() { + Record::from_json(item).map_err(item_error)? + } else { + // Plain atoms and unsigned credentials are both just unsigned atoms. + let atom = if item.get("@context").is_some() { + credential::from_credential(&item) + } else { + serde_json::from_value::(item).map_err(Error::from).and_then(|a| a.validate().map(|()| a)) + } + .map_err(item_error)?; + Record { id: atom.id()?, atom, credential: None } + }; + records.push(record); + } + + let supersession = Supersession::new(records.iter().filter(|r| r.is_signed()).map(|r| &r.atom)); + let mut live = Vec::with_capacity(records.len()); + for record in &records { + let replaced = !supersession.is_empty() + && record.credential_id()?.is_some_and(|id| supersession.is_replaced(&record.atom, id)); + live.push(!replaced); + } + // The latest live atom for each (source, target, content); later input wins ties, as in the lens. + let mut latest: HashMap<(&str, &str, Option<&str>), usize> = HashMap::new(); + for (i, record) in records.iter().enumerate() { + if !live[i] { + continue; + } + let atom = &record.atom; + let key = (atom.source.as_str(), atom.target.as_str(), atom.content.as_deref()); + match latest.get(&key) { + Some(&j) if records[j].atom.timestamp > atom.timestamp => {} + _ => { + latest.insert(key, i); + } + } + } + let winners: HashSet = latest.into_values().collect(); + let mut out = Current::default(); + for (i, record) in records.into_iter().enumerate() { + let numbered = Numbered { n: i + 1, atom: record.atom }; + if winners.contains(&i) { out.current.push(numbered) } else { out.superseded.push(numbered) } + } + Ok(out) +} + +/// The error for an atom a format cannot hold without a value. +fn no_value(n: usize, format: &str) -> Error { + Error::InvalidInput(format!("item {n}: the atom has no value, which {format} needs")) +} + +/// The error for an atom a format cannot hold without a timestamp. +fn no_timestamp(n: usize, format: &str) -> Error { + Error::InvalidInput(format!("item {n}: the atom has no timestamp, which {format} needs")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Keypair, api}; + use serde_json::json; + + fn alice() -> Keypair { + Keypair::from_seed(&[1; 32]) + } + + fn signed(target: &str, value: &str, time: &str) -> Json { + let atom = + json!({ "source": alice().did(), "target": target, "content": "sushi", "value": value, "timestamp": time }); + api::sign_atom(atom, &alice().to_secret_multibase(), time).unwrap() + } + + fn targets(atoms: &[Numbered]) -> Vec<(usize, &str)> { + atoms.iter().map(|a| (a.n, a.atom.target.as_str())).collect() + } + + #[test] + fn latest_wins_and_replaced_credentials_drop_out() { + let old = signed("https://a.example", "0.5", "2024-01-01T00:00:00Z"); + let new = signed("https://a.example", "-0.5", "2024-02-01T00:00:00Z"); + let typo = signed("https://b.exmaple", "1", "2024-01-01T00:00:00Z"); + let mut fix = api::parse_atom(signed("https://b.example", "1", "2024-03-01T00:00:00Z")).unwrap(); + fix.replaces = Some(credential::credential_id(&typo).unwrap()); + let fix = + api::sign_atom(serde_json::to_value(fix).unwrap(), &alice().to_secret_multibase(), "2024-03-01T00:00:00Z") + .unwrap(); + let plain = json!({ "source": "urn:x:a", "target": "urn:x:b", "value": 1 }); + + let result = current(vec![new, old, typo, fix, plain]).unwrap(); + assert_eq!(targets(&result.current), [(1, "https://a.example"), (4, "https://b.example"), (5, "urn:x:b")]); + assert_eq!(targets(&result.superseded), [(2, "https://a.example"), (3, "https://b.exmaple")]); + } + + #[test] + fn unsigned_credentials_are_unsigned_atoms_and_errors_name_the_item() { + let unsigned = api::to_credential(json!({ "source": "urn:x:a", "target": "urn:x:b" })).unwrap(); + assert_eq!(current(vec![unsigned]).unwrap().current.len(), 1); + + let mut forged = signed("https://a.example", "0.5", "2024-01-01T00:00:00Z"); + forged["credentialSubject"]["value"] = json!("1"); + let err = current(vec![json!({ "source": "urn:x:a", "target": "urn:x:b" }), forged]).unwrap_err(); + assert!(err.to_string().starts_with("invalid input: item 2: verification failed"), "{err}"); + assert!(current(vec![json!({ "source": "urn:x:a" })]).unwrap_err().to_string().contains("item 1")); + } +} diff --git a/crates/trustgraph-core/src/export/schema_org.rs b/crates/trustgraph-core/src/export/schema_org.rs new file mode 100644 index 0000000..4eb8bf2 --- /dev/null +++ b/crates/trustgraph-core/src/export/schema_org.rs @@ -0,0 +1,111 @@ +//! [schema.org](https://schema.org/) `Review` and `Rating` JSON-LD, to embed +//! ratings in web pages (`