commit cb8fcfe1e058141220df6e3d33da4b81527595a7
parent 9b329721eae701e08c6056b9e0f3954306322d05
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Jul 2026 20:03:58 +0000
identity: complete package conformance coverage
- add governed public-value vectors and external account API tests
- enforce exact modules, root exports, features, dependencies, and traits
- add a clean Nix identity conformance lane for all supported variants
- verify strict host, no-std WASM, serde, docs, and doctest gates
Diffstat:
8 files changed, 502 insertions(+), 9 deletions(-)
diff --git a/build/nix/checks.nix b/build/nix/checks.nix
@@ -47,6 +47,33 @@ let
installPhaseCommand = "mkdir -p $out";
}
);
+ identityConformance = common.craneLib.mkCargoDerivation (
+ common.commonCraneArgs
+ // {
+ inherit (common) cargoArtifacts;
+ pname = "radroots-identity-conformance";
+ doCheck = false;
+ buildPhaseCargoCommand = ''
+ cargo check -p radroots_identity --all-targets --no-default-features --locked
+ cargo check -p radroots_identity --all-targets --no-default-features --features std --locked
+ cargo check -p radroots_identity --all-targets --no-default-features --features serde --locked
+ cargo check -p radroots_identity --all-targets --locked
+ cargo check -p radroots_identity --all-targets --all-features --locked
+ cargo clippy -p radroots_identity --all-targets --no-default-features --locked -- -D warnings
+ cargo clippy -p radroots_identity --all-targets --no-default-features --features serde --locked -- -D warnings
+ cargo clippy -p radroots_identity --all-targets --all-features --locked -- -D warnings
+ cargo test -p radroots_identity --all-targets --no-default-features --locked
+ cargo test -p radroots_identity --all-targets --no-default-features --features std --locked
+ cargo test -p radroots_identity --all-targets --no-default-features --features serde --locked
+ cargo test -p radroots_identity --all-targets --all-features --locked
+ cargo check -p radroots_identity --no-default-features --target wasm32-unknown-unknown --locked
+ cargo check -p radroots_identity --no-default-features --features serde --target wasm32-unknown-unknown --locked
+ RUSTDOCFLAGS="-D warnings" cargo doc -p radroots_identity --all-features --no-deps --locked
+ cargo test -p radroots_identity --all-features --doc --locked
+ '';
+ installPhaseCommand = "mkdir -p $out";
+ }
+ );
mkReplicaSyncLane =
{
pname,
@@ -84,6 +111,7 @@ in
cargo-check = cargoCheck;
cargo-test = cargoTest;
core-conformance = coreConformance;
+ identity-conformance = identityConformance;
replica-sync-default-check = replicaSyncDefaultCheck;
replica-sync-default-test = replicaSyncDefaultTest;
replica-sync-legacy-ingest-check = replicaSyncLegacyCheck;
diff --git a/contracts/conformance/vectors/identity/.gitkeep b/contracts/conformance/vectors/identity/.gitkeep
@@ -1 +0,0 @@
-
diff --git a/contracts/conformance/vectors/identity/public_values.v1.json b/contracts/conformance/vectors/identity/public_values.v1.json
@@ -0,0 +1,166 @@
+{
+ "suite": "identity_public_values",
+ "contract_version": "1.0.0",
+ "vectors": [
+ {
+ "id": "identity_public_key_canonical_hex_001",
+ "kind": "identity.public_key.serde",
+ "input": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF",
+ "expected": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+ },
+ {
+ "id": "identity_identity_id_canonical_hex_002",
+ "kind": "identity.identity_id.serde",
+ "input": "E0266E3CFB0D2886F91C73F5F868F3B98273713E5FCD97C081663F5518A4B3AF",
+ "expected": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"
+ },
+ {
+ "id": "identity_account_id_canonical_hex_003",
+ "kind": "identity.account_id.serde",
+ "input": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF",
+ "expected": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+ },
+ {
+ "id": "identity_username_canonical_text_004",
+ "kind": "identity.username.serde",
+ "input": " Alice.Farm ",
+ "expected": "alice.farm"
+ },
+ {
+ "id": "identity_profile_canonical_username_005",
+ "kind": "identity.profile.serde",
+ "input": {
+ "username": "Alice.Farm"
+ },
+ "expected": {
+ "username": "alice.farm"
+ }
+ },
+ {
+ "id": "identity_public_identity_discards_empty_profile_006",
+ "kind": "identity.public_identity.serde",
+ "input": {
+ "id": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF",
+ "public_key": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF",
+ "profile": {}
+ },
+ "expected": {
+ "id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "public_key": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+ }
+ },
+ {
+ "id": "identity_account_record_canonical_nested_values_007",
+ "kind": "identity.account_record.serde",
+ "input": {
+ "account_id": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF",
+ "public_identity": {
+ "id": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF",
+ "public_key": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF"
+ },
+ "label": "primary",
+ "created_at_unix": 10,
+ "updated_at_unix": 12
+ },
+ "expected": {
+ "account_id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "public_identity": {
+ "id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "public_key": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+ },
+ "label": "primary",
+ "created_at_unix": 10,
+ "updated_at_unix": 12
+ }
+ },
+ {
+ "id": "identity_account_status_canonical_ready_008",
+ "kind": "identity.account_status.serde",
+ "input": {
+ "ready": {
+ "account": {
+ "account_id": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF",
+ "public_identity": {
+ "id": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF",
+ "public_key": "585591529DA0BAB31B3B1B1F986611CF5F435DCA84F978C89EE8A40CCA7103DF"
+ },
+ "created_at_unix": 10,
+ "updated_at_unix": 10
+ }
+ }
+ },
+ "expected": {
+ "ready": {
+ "account": {
+ "account_id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "public_identity": {
+ "id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "public_key": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+ },
+ "created_at_unix": 10,
+ "updated_at_unix": 10
+ }
+ }
+ }
+ },
+ {
+ "id": "identity_public_key_rejects_invalid_curve_point_009",
+ "kind": "identity.public_key.invalid",
+ "input": "0000000000000000000000000000000000000000000000000000000000000000",
+ "expected_error_contains": "valid secp256k1"
+ },
+ {
+ "id": "identity_public_identity_rejects_mismatched_id_010",
+ "kind": "identity.public_identity.invalid",
+ "input": {
+ "id": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af",
+ "public_key": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+ },
+ "expected_error_contains": "does not match its public key"
+ },
+ {
+ "id": "identity_account_record_rejects_mismatched_id_011",
+ "kind": "identity.account_record.invalid",
+ "input": {
+ "account_id": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af",
+ "public_identity": {
+ "id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "public_key": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+ },
+ "created_at_unix": 10,
+ "updated_at_unix": 10
+ },
+ "expected_error_contains": "does not match its public identity"
+ },
+ {
+ "id": "identity_account_record_rejects_time_reversal_012",
+ "kind": "identity.account_record.invalid",
+ "input": {
+ "account_id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "public_identity": {
+ "id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "public_key": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+ },
+ "created_at_unix": 10,
+ "updated_at_unix": 9
+ },
+ "expected_error_contains": "precedes created timestamp"
+ },
+ {
+ "id": "identity_username_rejects_dot_placement_013",
+ "kind": "identity.username.invalid",
+ "input": ".alice",
+ "expected_error_contains": "dots cannot be"
+ },
+ {
+ "id": "identity_public_identity_rejects_unknown_field_014",
+ "kind": "identity.public_identity.invalid",
+ "input": {
+ "id": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "public_key": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "secret_key": "forbidden"
+ },
+ "expected_error_contains": "unknown field"
+ }
+ ]
+}
diff --git a/crates/identity/src/key.rs b/crates/identity/src/key.rs
@@ -276,10 +276,9 @@ impl From<PublicKey> for IdentityId {
#[cfg(test)]
mod tests {
- use alloc::{
- format,
- string::{String, ToString},
- };
+ #[cfg(feature = "serde")]
+ use alloc::format;
+ use alloc::string::{String, ToString};
use core::str::FromStr;
use super::*;
diff --git a/crates/identity/src/lib.rs b/crates/identity/src/lib.rs
@@ -8,11 +8,17 @@
//! ```compile_fail
//! use radroots_identity::{storage, IdentityError};
//! ```
+//!
+//! Secret-bearing and upstream Nostr event APIs are intentionally absent:
+//!
+//! ```compile_fail
+//! use radroots_identity::{NostrEvent, SecretKey};
+//! ```
extern crate alloc;
pub mod account;
-pub mod error;
+mod error;
pub mod key;
pub mod profile;
pub mod username;
diff --git a/crates/identity/tests/account_values.rs b/crates/identity/tests/account_values.rs
@@ -0,0 +1,61 @@
+use radroots_identity::{
+ AccountId, Error, PublicIdentity, PublicKey,
+ account::{Record, Status},
+};
+
+const ALICE: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
+const BOB: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af";
+
+fn public_identity(value: &str) -> PublicIdentity {
+ PublicIdentity::new(PublicKey::from_hex(value).expect("valid public key"))
+}
+
+#[test]
+fn public_account_values_preserve_identity_and_readiness() {
+ let identity = public_identity(ALICE);
+ let record = Record::new(identity.clone(), Some("primary".into()), 10);
+ let status = Status::Ready {
+ account: record.clone(),
+ };
+
+ assert_eq!(record.id(), AccountId::from_public_identity(&identity));
+ assert_eq!(record.public_identity(), &identity);
+ assert_eq!(record.label(), Some("primary"));
+ assert_eq!(status.account(), Some(&record));
+ assert!(status.is_ready());
+}
+
+#[test]
+fn decoded_account_parts_cannot_change_identity_or_reverse_time() {
+ let identity = public_identity(ALICE);
+ let wrong_id = AccountId::from_public_identity(&public_identity(BOB));
+
+ assert!(matches!(
+ Record::try_from_parts(wrong_id, identity.clone(), None, 10, 10),
+ Err(Error::AccountIdMismatch)
+ ));
+ assert!(matches!(
+ Record::try_from_parts(
+ AccountId::from_public_identity(&identity),
+ identity,
+ None,
+ 10,
+ 9,
+ ),
+ Err(Error::AccountUpdatedBeforeCreated {
+ created_at_unix: 10,
+ updated_at_unix: 9,
+ })
+ ));
+}
+
+#[test]
+fn public_identity_values_are_send_sync_without_host_state() {
+ fn assert_send_sync<T: Send + Sync>() {}
+
+ assert_send_sync::<AccountId>();
+ assert_send_sync::<PublicIdentity>();
+ assert_send_sync::<PublicKey>();
+ assert_send_sync::<Record>();
+ assert_send_sync::<Status>();
+}
diff --git a/crates/identity/tests/conformance.rs b/crates/identity/tests/conformance.rs
@@ -0,0 +1,83 @@
+#![cfg(feature = "serde")]
+
+use std::collections::BTreeSet;
+
+use radroots_identity::{
+ AccountId, IdentityId, Profile, PublicIdentity, PublicKey, Username,
+ account::{Record, Status},
+};
+use serde_json::Value;
+
+const PUBLIC_VALUE_VECTORS: &str =
+ include_str!("../../../contracts/conformance/vectors/identity/public_values.v1.json");
+
+#[test]
+fn public_value_vectors_are_unique_complete_and_executable() {
+ let suite: Value = serde_json::from_str(PUBLIC_VALUE_VECTORS).expect("valid vector suite");
+ assert_eq!(suite["suite"], "identity_public_values");
+ assert_eq!(suite["contract_version"], "1.0.0");
+
+ let vectors = suite["vectors"].as_array().expect("vector array");
+ let mut ids = BTreeSet::new();
+ for vector in vectors {
+ let id = vector["id"].as_str().expect("vector id");
+ assert!(ids.insert(id), "duplicate vector id {id}");
+ let kind = vector["kind"].as_str().expect("vector kind");
+ let input = vector["input"].clone();
+ if let Some(expected) = vector.get("expected") {
+ let actual = execute_valid(kind, input);
+ assert_eq!(&actual, expected, "vector {id}");
+ } else {
+ let expected = vector["expected_error_contains"]
+ .as_str()
+ .expect("invalid vector error fragment");
+ let error = execute_invalid(kind, input);
+ assert!(
+ error.contains(expected),
+ "vector {id} expected error containing {expected:?}, got {error:?}"
+ );
+ }
+ }
+ assert_eq!(ids.len(), 14);
+}
+
+fn execute_valid(kind: &str, input: Value) -> Value {
+ match kind {
+ "identity.public_key.serde" => round_trip::<PublicKey>(input),
+ "identity.identity_id.serde" => round_trip::<IdentityId>(input),
+ "identity.account_id.serde" => round_trip::<AccountId>(input),
+ "identity.username.serde" => round_trip::<Username>(input),
+ "identity.profile.serde" => round_trip::<Profile>(input),
+ "identity.public_identity.serde" => round_trip::<PublicIdentity>(input),
+ "identity.account_record.serde" => round_trip::<Record>(input),
+ "identity.account_status.serde" => round_trip::<Status>(input),
+ unsupported => panic!("unsupported valid identity vector kind {unsupported}"),
+ }
+}
+
+fn execute_invalid(kind: &str, input: Value) -> String {
+ match kind {
+ "identity.public_key.invalid" => decode_error::<PublicKey>(input),
+ "identity.public_identity.invalid" => decode_error::<PublicIdentity>(input),
+ "identity.account_record.invalid" => decode_error::<Record>(input),
+ "identity.username.invalid" => decode_error::<Username>(input),
+ unsupported => panic!("unsupported invalid identity vector kind {unsupported}"),
+ }
+}
+
+fn round_trip<T>(input: Value) -> Value
+where
+ T: serde::de::DeserializeOwned + serde::Serialize,
+{
+ serde_json::to_value(serde_json::from_value::<T>(input).expect("valid vector input"))
+ .expect("serialize canonical value")
+}
+
+fn decode_error<T>(input: Value) -> String
+where
+ T: serde::de::DeserializeOwned + core::fmt::Debug,
+{
+ serde_json::from_value::<T>(input)
+ .expect_err("invalid vector must be rejected")
+ .to_string()
+}
diff --git a/crates/identity/tests/package_boundary.rs b/crates/identity/tests/package_boundary.rs
@@ -1,9 +1,39 @@
+use std::{collections::BTreeSet, fs, path::Path};
+
+use radroots_identity::{
+ AccountId, Error, IdentityId, Profile, PublicIdentity, PublicKey, Username,
+ account::{Record, Status},
+};
+
const MANIFEST: &str = include_str!("../Cargo.toml");
+const ROOT: &str = include_str!("../src/lib.rs");
#[test]
-fn manifest_has_no_host_persistence_feature_or_dependency() {
+fn manifest_has_exact_features_and_dependencies() {
+ assert_eq!(
+ table_keys(MANIFEST, "[features]"),
+ BTreeSet::from(["default", "serde", "std"])
+ );
+ assert_eq!(
+ table_keys(MANIFEST, "[dependencies]"),
+ BTreeSet::from(["k256", "serde", "thiserror"])
+ );
+ assert_eq!(
+ table_keys(MANIFEST, "[dev-dependencies]"),
+ BTreeSet::from(["serde_json"])
+ );
+ assert!(MANIFEST.contains("default = [\"std\", \"serde\"]"));
+ assert!(MANIFEST.contains("std = [\"thiserror/std\"]"));
+ assert!(MANIFEST.contains("serde = [\"dep:serde\"]"));
+ assert!(MANIFEST.contains(
+ "k256 = { version = \"0.13\", default-features = false, features = [\"arithmetic\"] }"
+ ));
+ assert!(MANIFEST.contains("serde = { workspace = true, optional = true }"));
+ assert!(MANIFEST.contains("thiserror = { version = \"2\", default-features = false }"));
+
for forbidden in [
"json-file",
+ "nostr",
"radroots_protected_store",
"radroots_runtime",
"radroots_runtime_paths",
@@ -16,7 +46,128 @@ fn manifest_has_no_host_persistence_feature_or_dependency() {
"identity manifest must not contain host persistence edge {forbidden}"
);
}
+}
- assert!(MANIFEST.contains("default = [\"std\", \"serde\"]"));
- assert!(MANIFEST.contains("std = [\"thiserror/std\"]"));
+#[test]
+fn crate_root_matches_the_curated_module_and_export_contract() {
+ assert!(ROOT.contains("#![cfg_attr(not(feature = \"std\"), no_std)]"));
+ assert_eq!(
+ root_declarations("pub mod "),
+ BTreeSet::from(["account", "key", "profile", "username"])
+ );
+ assert_eq!(root_declarations("mod "), BTreeSet::from(["error"]));
+ assert_eq!(
+ ROOT.lines()
+ .map(str::trim)
+ .filter(|line| line.starts_with("pub use "))
+ .collect::<BTreeSet<_>>(),
+ BTreeSet::from([
+ "pub use account::AccountId;",
+ "pub use error::Error;",
+ "pub use key::{IdentityId, PublicKey};",
+ "pub use profile::{Profile, PublicIdentity};",
+ "pub use username::Username;",
+ ])
+ );
+}
+
+#[test]
+fn intended_public_paths_and_traits_compile() {
+ fn assert_public_value<T: Clone + core::fmt::Debug + Eq + Send + Sync>() {}
+
+ assert_public_value::<AccountId>();
+ assert_public_value::<IdentityId>();
+ assert_public_value::<Profile>();
+ assert_public_value::<PublicIdentity>();
+ assert_public_value::<PublicKey>();
+ assert_public_value::<Record>();
+ assert_public_value::<Status>();
+ assert_public_value::<Username>();
+ let _ = core::mem::size_of::<Error>();
+}
+
+#[test]
+fn production_sources_have_no_forbidden_owners_or_public_traits() {
+ let source_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
+ let mut sources = Vec::new();
+ collect_rust_sources(&source_root, &mut sources);
+ assert!(!sources.is_empty());
+
+ for path in sources {
+ let source = fs::read_to_string(&path).expect("read identity source");
+ let production = source.split("\n#[cfg(test)]").next().unwrap_or(&source);
+ for line in production.lines() {
+ let trimmed = line.trim_start();
+ if trimmed.starts_with("//") {
+ continue;
+ }
+ assert!(
+ !trimmed.starts_with("pub trait ") && !trimmed.starts_with("pub unsafe trait "),
+ "identity must not publish traits: {}: {trimmed}",
+ path.display()
+ );
+ for forbidden in [
+ "RadrootsIdentity",
+ "RadrootsSecret",
+ "SecretKey",
+ "PrivateKey",
+ "secret_key",
+ "Nsec",
+ "nsec::",
+ "nip49",
+ "Nip49",
+ "nostr::",
+ "std::fs",
+ "std::path",
+ "PathBuf",
+ "Sqlite",
+ "keyring",
+ ] {
+ assert!(
+ !line.contains(forbidden),
+ "identity production source must not contain {forbidden}: {}: {trimmed}",
+ path.display()
+ );
+ }
+ }
+ }
+}
+
+fn table_keys<'a>(manifest: &'a str, heading: &str) -> BTreeSet<&'a str> {
+ let table = manifest
+ .split_once(heading)
+ .unwrap_or_else(|| panic!("missing manifest table {heading}"))
+ .1;
+ table
+ .lines()
+ .skip(1)
+ .take_while(|line| !line.trim_start().starts_with('['))
+ .filter_map(|line| {
+ let line = line.trim();
+ (!line.is_empty() && !line.starts_with('#'))
+ .then(|| line.split_once('=').map(|(key, _)| key.trim()))
+ .flatten()
+ })
+ .collect()
+}
+
+fn root_declarations(prefix: &str) -> BTreeSet<&str> {
+ ROOT.lines()
+ .map(str::trim)
+ .filter_map(|line| {
+ line.strip_prefix(prefix)
+ .and_then(|name| name.strip_suffix(';'))
+ })
+ .collect()
+}
+
+fn collect_rust_sources(directory: &Path, paths: &mut Vec<std::path::PathBuf>) {
+ for entry in fs::read_dir(directory).expect("read identity source directory") {
+ let path = entry.expect("source directory entry").path();
+ if path.is_dir() {
+ collect_rust_sources(&path, paths);
+ } else if path.extension().and_then(|value| value.to_str()) == Some("rs") {
+ paths.push(path);
+ }
+ }
}