package_boundary.rs (21039B)
1 use std::collections::BTreeSet; 2 use std::fs; 3 use std::path::{Path, PathBuf}; 4 5 #[allow(unused_imports)] 6 use radroots_nostr::{ 7 Error as _, 8 event::{Coordinate as _, Event as _, EventId as _, Kind as _, Metadata as _, Timestamp as _}, 9 filter::Filter as _, 10 key as _, 11 tag::{Tag as _, TagKind as _, TagStandard as _}, 12 }; 13 14 const MANIFEST: &str = include_str!("../Cargo.toml"); 15 const ROOT: &str = include_str!("../src/lib.rs"); 16 const EVENT_MODULE: &str = include_str!("../src/event.rs"); 17 const FILTER_MODULE: &str = include_str!("../src/filter.rs"); 18 const KEY_MODULE: &str = include_str!("../src/key.rs"); 19 const NIP17_MODULE: &str = include_str!("../src/nip17.rs"); 20 const BLOSSOM_MODULE: &str = include_str!("../src/blossom.rs"); 21 const README: &str = include_str!("../README.md"); 22 const SIGNING_MODULE: &str = include_str!("../src/signing.rs"); 23 const TAG_MODULE: &str = include_str!("../src/tag.rs"); 24 const TYPES_MODULE: &str = include_str!("../src/types.rs"); 25 const PUBLIC_API: &str = include_str!("../../../contracts/api_baselines/radroots_nostr.txt"); 26 const IDENTITY_MANIFEST: &str = include_str!("../../identity/Cargo.toml"); 27 const IDENTITY_KEY_MODULE: &str = include_str!("../../identity/src/key.rs"); 28 const TRANSPORT_MANIFEST: &str = include_str!("../../transport_nostr/Cargo.toml"); 29 const TRANSPORT_ROOT: &str = include_str!("../../transport_nostr/src/lib.rs"); 30 31 #[test] 32 fn manifest_has_final_identity_features_and_radroots_dependencies() { 33 for required in [ 34 "name = \"radroots_nostr\"", 35 "version = \"0.1.0-alpha\"", 36 "publish = [\"crates-io\"]", 37 "[lib]\nname = \"radroots_nostr\"", 38 "default = [\"std\", \"events\"]", 39 "radroots_identity = { workspace = true, default-features = false }", 40 "nostr = { workspace = true, default-features = false, features = [", 41 "\"nostr/std\"", 42 "\"radroots_event/std\"", 43 "\"radroots_event_codec/std\"", 44 "\"radroots_identity/std\"", 45 ] { 46 assert!( 47 MANIFEST.contains(required), 48 "manifest is missing `{required}`" 49 ); 50 } 51 52 let dependencies = table_keys(MANIFEST, "[dependencies]"); 53 for required in [ 54 "radroots_identity", 55 "radroots_event", 56 "radroots_event_codec", 57 ] { 58 let declaration = table_value(MANIFEST, "[dependencies]", required) 59 .unwrap_or_else(|| panic!("missing required dependency `{required}`")); 60 assert!( 61 !declaration.contains("optional = true"), 62 "`{required}` must be required" 63 ); 64 } 65 for optional in ["radroots_signing", "radroots_blossom"] { 66 let declaration = table_value(MANIFEST, "[dependencies]", optional) 67 .unwrap_or_else(|| panic!("missing optional dependency `{optional}`")); 68 assert!( 69 declaration.contains("optional = true"), 70 "`{optional}` must be optional" 71 ); 72 } 73 assert_eq!( 74 dependencies 75 .iter() 76 .copied() 77 .filter(|dependency| dependency.starts_with("radroots_")) 78 .collect::<BTreeSet<_>>(), 79 BTreeSet::from([ 80 "radroots_blossom", 81 "radroots_event", 82 "radroots_event_codec", 83 "radroots_identity", 84 "radroots_signing", 85 ]) 86 ); 87 88 for forbidden in [ 89 "keyring", 90 "nostr-sdk", 91 "nostr_sdk", 92 "radroots_outbox", 93 "radroots_storage", 94 "radroots_transport", 95 "reqwest", 96 "sqlx", 97 "tokio", 98 ] { 99 assert!( 100 !dependencies.contains(forbidden), 101 "portable Nostr adapter must not depend on `{forbidden}`" 102 ); 103 } 104 105 let features = table_keys(MANIFEST, "[features]"); 106 assert_eq!( 107 features, 108 BTreeSet::from(["blossom", "default", "events", "nip17", "signing", "std"]), 109 "manifest feature vocabulary must match the Release V1 charter" 110 ); 111 for feature in features { 112 let declaration = table_value(MANIFEST, "[features]", feature) 113 .unwrap_or_else(|| panic!("missing feature declaration `{feature}`")); 114 for forbidden in [ 115 "client", 116 "http", 117 "network", 118 "relay", 119 "reqwest", 120 "runtime", 121 "transport", 122 ] { 123 assert!( 124 !declaration.contains(forbidden), 125 "feature `{feature}` activates forbidden live-I/O authority `{forbidden}`" 126 ); 127 } 128 } 129 } 130 131 #[test] 132 fn crate_root_establishes_the_final_public_module_skeleton() { 133 for module in [ 134 "blossom", "event", "filter", "key", "nip17", "signing", "tag", 135 ] { 136 assert!( 137 ROOT.contains(&format!("pub mod {module};")), 138 "crate root is missing final module `{module}`" 139 ); 140 } 141 let root_reexports = ROOT 142 .lines() 143 .map(str::trim) 144 .filter(|line| line.starts_with("pub use ")) 145 .collect::<Vec<_>>(); 146 assert_eq!( 147 root_reexports, 148 ["pub use error::Error;"], 149 "the crate root must re-export only the canonical Error alias" 150 ); 151 assert!( 152 !ROOT.contains("pub mod prelude"), 153 "lower-level Nostr crate must not publish a broad prelude" 154 ); 155 for retired in [ 156 "codec_adapters", 157 "draft_signing", 158 "error", 159 "event_adapters", 160 "event_verify", 161 "events", 162 "job_adapter", 163 "types", 164 "util", 165 ] { 166 assert!( 167 !ROOT.contains(&format!("pub mod {retired};")), 168 "superseded module remains public: `{retired}`" 169 ); 170 } 171 } 172 173 #[test] 174 fn protocol_values_are_exposed_only_at_explicit_adapter_modules() { 175 for (module, aliases) in [ 176 ( 177 EVENT_MODULE, 178 [ 179 "pub type Coordinate", 180 "pub type Event", 181 "pub type EventId", 182 "pub type Kind", 183 "pub type Metadata", 184 "pub type Timestamp", 185 ] 186 .as_slice(), 187 ), 188 (FILTER_MODULE, ["pub type Filter"].as_slice()), 189 ( 190 TAG_MODULE, 191 ["pub type Tag", "pub type TagKind", "pub type TagStandard"].as_slice(), 192 ), 193 ] { 194 for alias in aliases { 195 assert!( 196 module.contains(alias), 197 "explicit adapter module is missing `{alias}`" 198 ); 199 } 200 } 201 202 for forbidden in [ 203 "pub type RadrootsNostrCoordinate", 204 "pub type RadrootsNostrEvent", 205 "pub type RadrootsNostrEventId", 206 "pub type RadrootsNostrFilter", 207 "pub type RadrootsNostrKind", 208 "pub type RadrootsNostrMetadata", 209 "pub type RadrootsNostrTag", 210 "pub type RadrootsNostrTagKind", 211 "pub type RadrootsNostrTagStandard", 212 "pub type RadrootsNostrTimestamp", 213 ] { 214 assert!( 215 !TYPES_MODULE.contains(forbidden), 216 "broad predecessor alias remains public in types: `{forbidden}`" 217 ); 218 } 219 220 assert!(ROOT.contains("mod event_convert;")); 221 assert!(ROOT.contains("mod tags;")); 222 assert!(!ROOT.contains("pub mod event_convert;")); 223 assert!(!ROOT.contains("pub mod tags;")); 224 } 225 226 #[test] 227 fn live_client_and_http_ownership_belongs_to_transport_nostr() { 228 for forbidden in [ 229 "nostr-sdk", 230 "reqwest", 231 "client =", 232 "http =", 233 "radroots_nostr/client", 234 ] { 235 assert!( 236 !MANIFEST.contains(forbidden), 237 "portable manifest still owns forbidden live-client surface `{forbidden}`" 238 ); 239 } 240 241 for forbidden in ["pub mod client;", "pub mod relays;", "pub mod nip11;"] { 242 assert!( 243 !ROOT.contains(forbidden), 244 "portable crate root still exposes live-client module `{forbidden}`" 245 ); 246 } 247 248 for required in ["nostr-sdk", "radroots_transport", "radroots_event_codec"] { 249 assert!( 250 TRANSPORT_MANIFEST.contains(required), 251 "transport manifest is missing live-client ownership marker `{required}`" 252 ); 253 } 254 for required in [ 255 "mod auth;", 256 "mod client;", 257 "mod relay;", 258 "mod sink;", 259 "mod source;", 260 "mod status;", 261 "pub use client::{Config, NostrTransport, ReconnectBackoff};", 262 "pub use relay::{RelayUrl, RelayUrlPolicy};", 263 ] { 264 assert!( 265 TRANSPORT_ROOT.contains(required), 266 "transport crate root is missing live-client owner `{required}`" 267 ); 268 } 269 270 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 271 assert!(!manifest_dir.join("src/client.rs").exists()); 272 assert!(!manifest_dir.join("src/relays.rs").exists()); 273 assert!(!manifest_dir.join("src/nip11.rs").exists()); 274 assert!( 275 manifest_dir 276 .join("../transport_nostr/src/client.rs") 277 .exists() 278 ); 279 assert!( 280 !manifest_dir 281 .join("../transport_nostr/src/relays.rs") 282 .exists() 283 ); 284 assert!( 285 !manifest_dir 286 .join("../transport_nostr/src/nip11.rs") 287 .exists() 288 ); 289 assert!(!TRANSPORT_MANIFEST.contains("reqwest")); 290 291 let forbidden_source = [ 292 "nostr_sdk", 293 "reqwest::", 294 "feature = \"client\"", 295 "feature = \"http\"", 296 "crate::client", 297 ]; 298 for source_path in rust_sources(&manifest_dir.join("src")) { 299 let source = fs::read_to_string(&source_path) 300 .unwrap_or_else(|error| panic!("failed to read {}: {error}", source_path.display())); 301 for forbidden in forbidden_source { 302 assert!( 303 !source.contains(forbidden), 304 "{} still contains live-client ownership marker `{forbidden}`", 305 source_path.display() 306 ); 307 } 308 } 309 } 310 311 #[test] 312 fn nostr_key_conversion_is_explicit_and_identity_remains_public_only() { 313 for required in [ 314 "nostr/nip49", 315 "pub fn public_key_to_nostr", 316 "pub fn public_key_from_nostr", 317 "pub fn public_key_to_npub", 318 "pub fn public_key_from_npub", 319 "pub fn parse_public_key", 320 "pub fn parse_secret_key", 321 "pub fn secret_key_to_nsec", 322 "pub fn encrypt_secret_key_nip49", 323 "pub fn encrypt_secret_key_nip49_with_options", 324 "pub fn decrypt_secret_key_nip49", 325 ] { 326 let authority = if required == "nostr/nip49" { 327 MANIFEST 328 } else { 329 KEY_MODULE 330 }; 331 assert!( 332 authority.contains(required), 333 "Nostr key authority is missing `{required}`" 334 ); 335 } 336 337 for forbidden in ["nostr =", "nip49", "nsec", "ncryptsec", "SecretKey"] { 338 assert!( 339 !IDENTITY_MANIFEST.contains(forbidden), 340 "identity manifest regained Nostr secret ownership `{forbidden}`" 341 ); 342 assert!( 343 !IDENTITY_KEY_MODULE.contains(forbidden), 344 "identity key module regained Nostr secret ownership `{forbidden}`" 345 ); 346 } 347 348 for secret_function in [ 349 "parse_secret_key", 350 "secret_key_to_nsec", 351 "encrypt_secret_key_nip49", 352 "encrypt_secret_key_nip49_with_options", 353 "decrypt_secret_key_nip49", 354 ] { 355 let signature = format!("pub fn {secret_function}"); 356 let position = KEY_MODULE 357 .find(&signature) 358 .unwrap_or_else(|| panic!("missing secret adapter `{secret_function}`")); 359 let prefix = &KEY_MODULE[..position]; 360 let nearby = &prefix[prefix.len().saturating_sub(160)..]; 361 assert!( 362 nearby.contains("#[cfg(feature = \"signing\")]"), 363 "secret adapter `{secret_function}` is not signing-gated" 364 ); 365 } 366 } 367 368 #[test] 369 fn local_signer_consumes_only_the_opaque_secret_boundary() { 370 for required in [ 371 "pub fn new(secret_key: SecretKey)", 372 "pub fn generate()", 373 "pub const fn public_key(&self) -> PublicKey", 374 "key::SecretKey", 375 ] { 376 assert!( 377 SIGNING_MODULE.contains(required), 378 "local signer boundary is missing `{required}`" 379 ); 380 } 381 for forbidden in [ 382 "pub const fn new(keys: RadrootsNostrKeys)", 383 "pub fn new(keys: RadrootsNostrKeys)", 384 "pub fn keys(", 385 "pub fn secret_key(", 386 ] { 387 assert!( 388 !SIGNING_MODULE.contains(forbidden), 389 "local signer leaks an upstream representation: `{forbidden}`" 390 ); 391 } 392 } 393 394 #[test] 395 fn focused_nip_and_blossom_features_own_no_network_operations() { 396 for required in [ 397 "blossom = [\"events\", \"dep:base64\", \"dep:radroots_blossom\"]", 398 "nip17 = [\"events\", \"nostr/nip44\", \"nostr/nip59\"]", 399 ] { 400 assert!( 401 MANIFEST.contains(required), 402 "focused feature contract is missing `{required}`" 403 ); 404 } 405 for required in [ 406 "pub async fn wrap_message<T>(", 407 "pub async fn wrap_message_file<T>(", 408 "pub async fn unwrap_gift_wrap<T>(", 409 "pub const fn code(&self) -> &'static str", 410 ] { 411 assert!( 412 NIP17_MODULE.contains(required), 413 "NIP-17 adapter is missing `{required}`" 414 ); 415 } 416 for required in [ 417 "pub fn sign_authorization(", 418 "pub fn encode_authorization_header(", 419 "pub fn decode_verify_authorization_header(", 420 ] { 421 assert!( 422 BLOSSOM_MODULE.contains(required), 423 "Blossom adapter is missing `{required}`" 424 ); 425 } 426 for (name, source) in [("NIP-17", NIP17_MODULE), ("Blossom", BLOSSOM_MODULE)] { 427 for forbidden in [ 428 "nostr_sdk", 429 "reqwest::", 430 "tokio::spawn", 431 "std::net", 432 "TcpStream", 433 "UdpSocket", 434 "RelayPool", 435 ] { 436 assert!( 437 !source.contains(forbidden), 438 "{name} adapter owns forbidden network operation `{forbidden}`" 439 ); 440 } 441 } 442 let readme_words = README.split_whitespace().collect::<Vec<_>>().join(" "); 443 for required in [ 444 "This crate owns no relay client", 445 "It does not select relays, deliver events, retry operations, or persist message state.", 446 ] { 447 assert!( 448 readme_words.contains(required), 449 "README is missing focused ownership statement `{required}`" 450 ); 451 } 452 for forbidden in [ 453 "Portable relay-client lifecycle", 454 "With the `client` feature", 455 ] { 456 assert!( 457 !README.contains(forbidden), 458 "README retains removed network ownership statement `{forbidden}`" 459 ); 460 } 461 } 462 463 #[test] 464 fn production_api_declares_no_traits_or_host_runtime_implementations() { 465 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 466 let mut public_traits = BTreeSet::new(); 467 468 for source_path in rust_sources(&manifest_dir.join("src")) { 469 let source = fs::read_to_string(&source_path) 470 .unwrap_or_else(|error| panic!("failed to read {}: {error}", source_path.display())); 471 let production = source.split("\n#[cfg(test)]").next().unwrap_or(&source); 472 for line in production.lines() { 473 let trimmed = line.trim_start(); 474 if let Some(name) = trimmed 475 .strip_prefix("pub trait ") 476 .and_then(|rest| rest.split([':', '<', ' ']).next()) 477 { 478 public_traits.insert(name.to_owned()); 479 } 480 for forbidden in [ 481 "nostr_sdk::", 482 "reqwest::", 483 "sqlx::", 484 "tokio::spawn", 485 "std::net::", 486 "std::thread::spawn", 487 ] { 488 assert!( 489 !trimmed.contains(forbidden), 490 "{} owns forbidden host/runtime implementation `{forbidden}`: {trimmed}", 491 source_path.display() 492 ); 493 } 494 } 495 } 496 497 assert!( 498 public_traits.is_empty(), 499 "concrete protocol adapter must not publish an SPI trait: {public_traits:?}" 500 ); 501 } 502 503 #[test] 504 fn workspace_consumers_do_not_use_superseded_nostr_alias_paths() { 505 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 506 let crates_dir = manifest_dir 507 .parent() 508 .expect("Nostr crate must have a crates directory") 509 .to_path_buf(); 510 511 for source_path in rust_sources(&crates_dir) { 512 if source_path.starts_with(&manifest_dir) { 513 continue; 514 } 515 let source = fs::read_to_string(&source_path) 516 .unwrap_or_else(|error| panic!("failed to read {}: {error}", source_path.display())); 517 for forbidden in [ 518 "radroots_nostr::codec_adapters::", 519 "radroots_nostr::draft_signing::", 520 "radroots_nostr::error::", 521 "radroots_nostr::event_adapters::", 522 "radroots_nostr::event_verify::", 523 "radroots_nostr::events::", 524 "radroots_nostr::job_adapter::", 525 "radroots_nostr::prelude", 526 "radroots_nostr::types::", 527 "radroots_nostr::util::", 528 ] { 529 assert!( 530 !source.contains(forbidden), 531 "{} still imports superseded Nostr path `{forbidden}`", 532 source_path.display() 533 ); 534 } 535 } 536 } 537 538 #[test] 539 fn superseded_surface_retirement_is_explicit_and_release_bounded() { 540 assert!(!MANIFEST.contains("codec = []")); 541 for forbidden in ["pub fn radroots_nostr_", "pub async fn radroots_nostr_"] { 542 for source_path in rust_sources(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src")) { 543 let source = fs::read_to_string(&source_path).unwrap_or_else(|error| { 544 panic!("failed to read {}: {error}", source_path.display()) 545 }); 546 assert!( 547 !source.contains(forbidden), 548 "{} retains prefixed public item `{forbidden}`", 549 source_path.display() 550 ); 551 } 552 } 553 assert!(MANIFEST.contains("publish = [\"crates-io\"]")); 554 assert!( 555 !Path::new(env!("CARGO_MANIFEST_DIR")) 556 .join("COMPATIBILITY.md") 557 .exists(), 558 "the completed migration record must not ship as an active compatibility contract" 559 ); 560 561 let public_modules = PUBLIC_API 562 .lines() 563 .filter_map(|line| line.strip_prefix("pub mod radroots_nostr::")) 564 .collect::<BTreeSet<_>>(); 565 assert_eq!( 566 public_modules, 567 BTreeSet::from([ 568 "blossom", "event", "filter", "key", "nip17", "signing", "tag" 569 ]), 570 "reviewed API baseline must expose only chartered modules" 571 ); 572 for forbidden in [ 573 "::RadrootsNostr", 574 "::radroots_nostr_", 575 "pub radroots_nostr::event::ApplicationHandlerSpec::", 576 "pub radroots_nostr::nip17::WrapOptions::", 577 ] { 578 assert!( 579 !PUBLIC_API.contains(forbidden), 580 "reviewed API baseline retains forbidden surface `{forbidden}`" 581 ); 582 } 583 for required in [ 584 "pub fn radroots_nostr::blossom::SignedAuthorization::from_signed_event", 585 "pub fn radroots_nostr::blossom::encode_signed_event_authorization_header", 586 ] { 587 assert!( 588 PUBLIC_API.contains(required), 589 "reviewed API baseline is missing `{required}`" 590 ); 591 } 592 } 593 594 fn rust_sources(root: &Path) -> Vec<PathBuf> { 595 let mut pending = vec![root.to_path_buf()]; 596 let mut sources = Vec::new(); 597 while let Some(directory) = pending.pop() { 598 for entry in fs::read_dir(&directory) 599 .unwrap_or_else(|error| panic!("failed to read {}: {error}", directory.display())) 600 { 601 let path = entry 602 .expect("source directory entry must be readable") 603 .path(); 604 if path.is_dir() { 605 pending.push(path); 606 } else if path.extension().is_some_and(|extension| extension == "rs") { 607 sources.push(path); 608 } 609 } 610 } 611 sources 612 } 613 614 fn table_keys<'a>(source: &'a str, header: &str) -> BTreeSet<&'a str> { 615 table_lines(source, header) 616 .filter_map(|line| line.split_once('=').map(|(key, _)| key.trim())) 617 .collect() 618 } 619 620 fn table_value<'a>(source: &'a str, header: &str, key: &str) -> Option<&'a str> { 621 table_lines(source, header).find_map(|line| { 622 let (candidate, value) = line.split_once('=')?; 623 (candidate.trim() == key).then_some(value.trim()) 624 }) 625 } 626 627 fn table_lines<'a>(source: &'a str, header: &str) -> impl Iterator<Item = &'a str> { 628 let start = source 629 .find(header) 630 .unwrap_or_else(|| panic!("missing table `{header}`")); 631 source[start + header.len()..] 632 .lines() 633 .skip_while(|line| line.trim().is_empty()) 634 .take_while(|line| !line.trim_start().starts_with('[')) 635 .filter(|line| !line.trim().is_empty() && !line.trim_start().starts_with('#')) 636 }