diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2045d2b8..3be385c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,7 @@ jobs: key: ${{ runner.os }}-clippy-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-clippy- - run: cargo clippy --all-targets --all-features -- -D warnings + - run: cargo run -p xtask -- check-clippy-sync schemas: name: Schema validation diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ff17cfcf..14428921 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,6 +42,13 @@ repos: files: (crates/auths-cli/src/|crates/xtask/src/gen_docs|docs/cli/commands/) pass_filenames: false + - id: check-clippy-sync + name: cargo xtask check-clippy-sync + entry: cargo run --package xtask -- check-clippy-sync + language: system + files: clippy\.toml$ + pass_filenames: false + - id: cargo-deny name: cargo deny (licenses + bans) entry: bash -c 'cargo deny check > .cargo/cargo-deny.log 2>&1; exit $?' diff --git a/crates/auths-cli/src/commands/device/authorization.rs b/crates/auths-cli/src/commands/device/authorization.rs index ab6d85c1..09593cbd 100644 --- a/crates/auths-cli/src/commands/device/authorization.rs +++ b/crates/auths-cli/src/commands/device/authorization.rs @@ -272,7 +272,7 @@ pub fn handle_device( &ctx, &auths_core::ports::clock::SystemClock, ) - .map_err(|e| anyhow!("{e}"))?; + .map_err(anyhow::Error::new)?; display_link_result(&result, &device_did) } @@ -301,7 +301,7 @@ pub fn handle_device( note, &auths_core::ports::clock::SystemClock, ) - .map_err(|e| anyhow!("{e}"))?; + .map_err(anyhow::Error::new)?; display_revoke_result(&device_did, &repo_path) } diff --git a/crates/auths-cli/src/commands/init/mod.rs b/crates/auths-cli/src/commands/init/mod.rs index 534a8cef..3b74c635 100644 --- a/crates/auths-cli/src/commands/init/mod.rs +++ b/crates/auths-cli/src/commands/init/mod.rs @@ -437,7 +437,8 @@ mod tests { registry: DEFAULT_REGISTRY_URL.to_string(), skip_registration: false, }; - // In test context, stdin is not a TTY - assert!(!resolve_interactive(&cmd).unwrap()); + // Auto-detect returns is_terminal() — result depends on environment + let result = resolve_interactive(&cmd).unwrap(); + assert_eq!(result, std::io::stdin().is_terminal()); } } diff --git a/crates/auths-cli/src/commands/verify_commit.rs b/crates/auths-cli/src/commands/verify_commit.rs index 39105ee1..62932a4d 100644 --- a/crates/auths-cli/src/commands/verify_commit.rs +++ b/crates/auths-cli/src/commands/verify_commit.rs @@ -919,6 +919,8 @@ mod tests { #[tokio::test] async fn verify_bundle_chain_empty_chain() { + #[allow(clippy::disallowed_methods)] + // INVARIANT: test-only hardcoded DID and hex string literals let bundle = IdentityBundle { identity_did: auths_verifier::IdentityDID::new_unchecked("did:keri:test"), public_key_hex: auths_verifier::PublicKeyHex::new_unchecked("aa".repeat(32)), @@ -935,6 +937,8 @@ mod tests { #[tokio::test] async fn verify_bundle_chain_invalid_hex() { + #[allow(clippy::disallowed_methods)] + // INVARIANT: test-only hardcoded DID, hex, and canonical DID string literals let bundle = IdentityBundle { identity_did: auths_verifier::IdentityDID::new_unchecked("did:keri:test"), public_key_hex: auths_verifier::PublicKeyHex::new_unchecked("not_hex"), diff --git a/crates/auths-cli/src/errors/renderer.rs b/crates/auths-cli/src/errors/renderer.rs index 06ac248f..12e348bf 100644 --- a/crates/auths-cli/src/errors/renderer.rs +++ b/crates/auths-cli/src/errors/renderer.rs @@ -28,12 +28,13 @@ pub fn render_error(err: &Error, json_mode: bool) { } /// Try to extract `AuthsErrorInfo` from an `anyhow::Error` by downcasting -/// through all known error types. +/// through all known error types. Walks the full error chain so that +/// `.with_context()` wrapping doesn't hide typed errors. fn extract_error_info(err: &Error) -> Option<(&str, &str, Option<&str>)> { macro_rules! try_downcast { - ($err:expr, $($ty:ty),+ $(,)?) => { + ($source:expr, $($ty:ty),+ $(,)?) => { $( - if let Some(e) = $err.downcast_ref::<$ty>() { + if let Some(e) = $source.downcast_ref::<$ty>() { let code = AuthsErrorInfo::error_code(e); let msg = format!("{e}"); // SAFETY: we leak the String to get a &'static str because @@ -46,21 +47,23 @@ fn extract_error_info(err: &Error) -> Option<(&str, &str, Option<&str>)> { }; } - try_downcast!( - err, - AgentError, - AttestationError, - SetupError, - DeviceError, - DeviceExtensionError, - RotationError, - RegistrationError, - McpAuthError, - OrgError, - ApprovalError, - AllowedSignersError, - SigningError, - ); + for cause in err.chain() { + try_downcast!( + cause, + AgentError, + AttestationError, + SetupError, + DeviceError, + DeviceExtensionError, + RotationError, + RegistrationError, + McpAuthError, + OrgError, + ApprovalError, + AllowedSignersError, + SigningError, + ); + } None } @@ -248,4 +251,11 @@ mod tests { assert_eq!(code, "AUTHS-E3001"); assert!(suggestion.is_some()); } + + #[test] + fn extract_error_info_walks_chain_through_context() { + let err: Error = Error::new(AgentError::KeyNotFound).context("operation failed"); + let (code, _, _) = extract_error_info(&err).unwrap(); + assert_eq!(code, "AUTHS-E3001"); + } } diff --git a/crates/auths-core/clippy.toml b/crates/auths-core/clippy.toml index 30374cbb..e3140287 100644 --- a/crates/auths-core/clippy.toml +++ b/crates/auths-core/clippy.toml @@ -12,6 +12,13 @@ disallowed-methods = [ { path = "std::env::var", reason = "use EnvironmentConfig abstraction instead of reading env vars directly", allow-invalid = true }, { path = "uuid::Uuid::new_v4", reason = "Use UuidProvider::new_id() instead. Inject SystemUuidProvider in production and DeterministicUuidProvider in tests." }, + # === DID/newtype construction: prefer parse() for external input === + { path = "auths_verifier::types::IdentityDID::new_unchecked", reason = "Use IdentityDID::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::types::DeviceDID::new_unchecked", reason = "Use DeviceDID::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::types::CanonicalDid::new_unchecked", reason = "Use CanonicalDid::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::core::CommitOid::new_unchecked", reason = "Use CommitOid::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::core::PublicKeyHex::new_unchecked", reason = "Use PublicKeyHex::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + # === Sans-IO: filesystem === { path = "std::fs::read", reason = "sans-IO crate — use a port trait" }, { path = "std::fs::read_to_string", reason = "sans-IO crate — use a port trait" }, diff --git a/crates/auths-core/src/api/ffi.rs b/crates/auths-core/src/api/ffi.rs index 520dd97f..78d7f697 100644 --- a/crates/auths-core/src/api/ffi.rs +++ b/crates/auths-core/src/api/ffi.rs @@ -381,6 +381,8 @@ pub unsafe extern "C" fn ffi_import_key( } }; + #[allow(clippy::disallowed_methods)] + // INVARIANT: validated with starts_with("did:") guard above let did_string = IdentityDID::new_unchecked(did_str.to_string()); let alias = KeyAlias::new_unchecked(alias_str); diff --git a/crates/auths-core/src/error.rs b/crates/auths-core/src/error.rs index 78fb95d3..13c3ac07 100644 --- a/crates/auths-core/src/error.rs +++ b/crates/auths-core/src/error.rs @@ -174,16 +174,27 @@ impl AuthsErrorInfo for AgentError { Some("Run the command again and provide the required input") } Self::StorageError(_) => Some("Check file permissions and disk space"), - // These errors typically don't have actionable suggestions - Self::SecurityError(_) - | Self::CryptoError(_) - | Self::KeyDeserializationError(_) - | Self::SigningFailed(_) - | Self::Proto(_) - | Self::IO(_) - | Self::InvalidInput(_) - | Self::MutexError(_) - | Self::CredentialTooLarge { .. } => None, + Self::SecurityError(_) => Some( + "Run `auths doctor` to check system keychain access and security configuration", + ), + Self::CryptoError(_) => { + Some("A cryptographic operation failed; check key material with `auths key list`") + } + Self::KeyDeserializationError(_) => { + Some("The stored key is corrupted; re-import with `auths key import`") + } + Self::SigningFailed(_) => Some( + "The signing operation failed; verify your key is accessible with `auths key list`", + ), + Self::Proto(_) => Some( + "A protocol error occurred; check that both sides are running compatible versions", + ), + Self::IO(_) => Some("Check file permissions and that the filesystem is not read-only"), + Self::InvalidInput(_) => Some("Check the command arguments and try again"), + Self::MutexError(_) => Some("A concurrency error occurred; restart the operation"), + Self::CredentialTooLarge { .. } => Some( + "Reduce the credential size or use file-based storage with AUTHS_KEYCHAIN_BACKEND=file", + ), Self::WeakPassphrase(_) => { Some("Use at least 12 characters with uppercase, lowercase, and a digit or symbol") } @@ -244,7 +255,12 @@ impl AuthsErrorInfo for TrustError { Self::Lock(_) => Some("Check file permissions and try again"), Self::Io(_) => Some("Check disk space and file permissions"), Self::AlreadyExists(_) => Some("Run `auths trust list` to see existing entries"), - Self::InvalidData(_) | Self::Serialization(_) => None, + Self::InvalidData(_) => { + Some("The trust store may be corrupted; delete and re-pin with `auths trust add`") + } + Self::Serialization(_) => { + Some("The trust store data is corrupted; delete and re-pin with `auths trust add`") + } } } } diff --git a/crates/auths-core/src/pairing/error.rs b/crates/auths-core/src/pairing/error.rs index 7e52ab3a..44b18879 100644 --- a/crates/auths-core/src/pairing/error.rs +++ b/crates/auths-core/src/pairing/error.rs @@ -55,7 +55,16 @@ impl AuthsErrorInfo for PairingError { Self::NoPeerFound => Some("Ensure both devices are on the same network"), Self::LanTimeout => Some("Check your network and try again"), Self::RelayError(_) => Some("Check your internet connection"), - _ => None, + Self::Protocol(_) => Some("Ensure both devices are running compatible auths versions"), + Self::QrCodeFailed(_) => { + Some("QR code generation failed; try `auths device pair --mode relay` instead") + } + Self::LocalServerError(_) => { + Some("The local pairing server failed to start; check that the port is available") + } + Self::MdnsError(_) => { + Some("mDNS discovery failed; try `auths device pair --mode relay` instead") + } } } } diff --git a/crates/auths-core/src/ports/network.rs b/crates/auths-core/src/ports/network.rs index 97e4832b..8c73f7ca 100644 --- a/crates/auths-core/src/ports/network.rs +++ b/crates/auths-core/src/ports/network.rs @@ -82,7 +82,15 @@ impl auths_crypto::AuthsErrorInfo for NetworkError { Self::Unreachable { .. } => Some("Check your internet connection"), Self::Timeout { .. } => Some("The server may be overloaded — retry later"), Self::Unauthorized => Some("Check your authentication credentials"), - _ => None, + Self::NotFound { .. } => Some( + "The requested resource was not found on the server; verify the URL or identifier", + ), + Self::InvalidResponse { .. } => { + Some("The server returned an unexpected response; check server compatibility") + } + Self::Internal(_) => Some( + "The server encountered an internal error; retry later or contact the server administrator", + ), } } } diff --git a/crates/auths-core/src/ports/platform.rs b/crates/auths-core/src/ports/platform.rs index 49585649..db811eb1 100644 --- a/crates/auths-core/src/ports/platform.rs +++ b/crates/auths-core/src/ports/platform.rs @@ -62,7 +62,15 @@ impl auths_crypto::AuthsErrorInfo for PlatformError { Self::AccessDenied => Some("Re-run the command and approve the authorization request"), Self::ExpiredToken => Some("The device code expired — restart the flow"), Self::Network(_) => Some("Check your internet connection"), - _ => None, + Self::AuthorizationPending => Some( + "Complete the authorization on the linked device, then the CLI will continue automatically", + ), + Self::SlowDown => { + Some("The authorization server is rate-limiting; the CLI will retry automatically") + } + Self::Platform { .. } => { + Some("A platform-specific error occurred; run `auths doctor` to diagnose") + } } } } diff --git a/crates/auths-core/src/signing.rs b/crates/auths-core/src/signing.rs index 4851c5e4..f873780a 100644 --- a/crates/auths-core/src/signing.rs +++ b/crates/auths-core/src/signing.rs @@ -782,6 +782,8 @@ mod tests { fn test_sign_for_identity_success() { let (pkcs8_bytes, pubkey_bytes) = generate_test_keypair(); let passphrase = "Test-P@ss12345"; + #[allow(clippy::disallowed_methods)] + // INVARIANT: test-only literal with valid did:keri: prefix let identity_did = IdentityDID::new_unchecked("did:keri:ABC123"); let alias = KeyAlias::new_unchecked("test-key-alias"); @@ -815,6 +817,8 @@ mod tests { let signer = StorageSigner::new(storage); let passphrase_provider = MockPassphraseProvider::new("any-passphrase"); + #[allow(clippy::disallowed_methods)] + // INVARIANT: test-only literal with valid did:keri: prefix let identity_did = IdentityDID::new_unchecked("did:keri:NONEXISTENT"); let message = b"test message"; @@ -827,6 +831,8 @@ mod tests { // Test that sign_for_identity works when multiple aliases exist for an identity let (pkcs8_bytes, pubkey_bytes) = generate_test_keypair(); let passphrase = "Test-P@ss12345"; + #[allow(clippy::disallowed_methods)] + // INVARIANT: test-only literal with valid did:keri: prefix let identity_did = IdentityDID::new_unchecked("did:keri:MULTI123"); let encrypted = encrypt_keypair(&pkcs8_bytes, passphrase).expect("Failed to encrypt"); diff --git a/crates/auths-core/src/storage/ios_keychain.rs b/crates/auths-core/src/storage/ios_keychain.rs index 756a7728..b8e3440e 100644 --- a/crates/auths-core/src/storage/ios_keychain.rs +++ b/crates/auths-core/src/storage/ios_keychain.rs @@ -226,6 +226,8 @@ impl KeyStorage for IOSKeychain { .unwrap_or(KeyRole::Primary); debug!("Successfully loaded key for alias '{}'", alias); + #[allow(clippy::disallowed_methods)] + // INVARIANT: loaded from iOS Keychain which stores validated DIDs Ok((IdentityDID::new_unchecked(identity_did_str), role, key_data)) } @@ -450,6 +452,8 @@ impl KeyStorage for IOSKeychain { })?; debug!("Found identity DID for alias '{}'", alias); + #[allow(clippy::disallowed_methods)] + // INVARIANT: loaded from iOS Keychain which stores validated DIDs Ok(IdentityDID::new_unchecked(identity_did)) } diff --git a/crates/auths-core/src/storage/keychain.rs b/crates/auths-core/src/storage/keychain.rs index d59b8c86..f56536a4 100644 --- a/crates/auths-core/src/storage/keychain.rs +++ b/crates/auths-core/src/storage/keychain.rs @@ -619,6 +619,8 @@ mod tests { use super::super::memory::IsolatedKeychainHandle; let keychain = IsolatedKeychainHandle::new(); + #[allow(clippy::disallowed_methods)] + // INVARIANT: test-only literal with valid did:keri: prefix let did = IdentityDID::new_unchecked("did:keri:Etest".to_string()); keychain diff --git a/crates/auths-core/src/storage/linux_secret_service.rs b/crates/auths-core/src/storage/linux_secret_service.rs index 9c9bed8c..0a65552d 100644 --- a/crates/auths-core/src/storage/linux_secret_service.rs +++ b/crates/auths-core/src/storage/linux_secret_service.rs @@ -210,6 +210,8 @@ impl KeyStorage for LinuxSecretServiceStorage { 3 => { let role = parts[1].parse::().unwrap_or(KeyRole::Primary); ( + #[allow(clippy::disallowed_methods)] + // INVARIANT: DID was stored by this keychain impl, already validated on write IdentityDID::new_unchecked(parts[0].to_string()), role, parts[2], @@ -218,6 +220,8 @@ impl KeyStorage for LinuxSecretServiceStorage { 2 => { // Legacy format: did|base64_key_data ( + #[allow(clippy::disallowed_methods)] + // INVARIANT: DID was stored by this keychain impl, already validated on write IdentityDID::new_unchecked(parts[0].to_string()), KeyRole::Primary, parts[1], diff --git a/crates/auths-core/src/storage/macos_keychain.rs b/crates/auths-core/src/storage/macos_keychain.rs index 0da2c05e..a4f6268a 100644 --- a/crates/auths-core/src/storage/macos_keychain.rs +++ b/crates/auths-core/src/storage/macos_keychain.rs @@ -321,6 +321,8 @@ impl KeyStorage for MacOSKeychain { .unwrap_or(KeyRole::Primary); debug!("Successfully loaded key for alias '{}'", alias); + #[allow(clippy::disallowed_methods)] + // INVARIANT: loaded from macOS Keychain which stores validated DIDs Ok((IdentityDID::new_unchecked(identity_did_str), role, key_data)) } @@ -659,6 +661,8 @@ impl KeyStorage for MacOSKeychain { } debug!("Found identity DID for alias '{}'", alias); + #[allow(clippy::disallowed_methods)] + // INVARIANT: loaded from macOS Keychain which stores validated DIDs Ok(IdentityDID::new_unchecked(identity_did_str)) } diff --git a/crates/auths-core/src/storage/pkcs11.rs b/crates/auths-core/src/storage/pkcs11.rs index d099de52..b02f8477 100644 --- a/crates/auths-core/src/storage/pkcs11.rs +++ b/crates/auths-core/src/storage/pkcs11.rs @@ -227,6 +227,8 @@ impl KeyStorage for Pkcs11KeyRef { }) .ok_or(AgentError::KeyNotFound)?; + #[allow(clippy::disallowed_methods)] + // INVARIANT: loaded from PKCS#11 token which stores validated DIDs let identity_did = IdentityDID::new_unchecked( String::from_utf8(id_bytes) .map_err(|e| AgentError::KeyDeserializationError(e.to_string()))?, @@ -317,6 +319,8 @@ impl KeyStorage for Pkcs11KeyRef { }) .ok_or(AgentError::KeyNotFound)?; + #[allow(clippy::disallowed_methods)] + // INVARIANT: loaded from PKCS#11 token which stores validated DIDs Ok(IdentityDID::new_unchecked( String::from_utf8(id_bytes) .map_err(|e| AgentError::KeyDeserializationError(e.to_string()))?, diff --git a/crates/auths-core/src/trust/resolve.rs b/crates/auths-core/src/trust/resolve.rs index d9d27518..07a48fac 100644 --- a/crates/auths-core/src/trust/resolve.rs +++ b/crates/auths-core/src/trust/resolve.rs @@ -157,6 +157,7 @@ pub fn resolve_trust( if prompt(&msg) { let pin = PinnedIdentity { did, + #[allow(clippy::disallowed_methods)] // INVARIANT: hex::encode on line 151 guarantees valid hex output public_key_hex: PublicKeyHex::new_unchecked(pk_hex), kel_tip_said: None, kel_sequence: None, @@ -188,6 +189,7 @@ pub fn resolve_trust( TrustDecision::RotationVerified { old_pin, proof } => { let updated = PinnedIdentity { did: old_pin.did, + #[allow(clippy::disallowed_methods)] // INVARIANT: hex::encode always produces valid lowercase hex public_key_hex: PublicKeyHex::new_unchecked(hex::encode(&proof.new_public_key)), kel_tip_said: Some(proof.new_kel_tip), kel_sequence: Some(proof.new_sequence), diff --git a/crates/auths-core/src/witness/server.rs b/crates/auths-core/src/witness/server.rs index 7ac38f0b..d710e89b 100644 --- a/crates/auths-core/src/witness/server.rs +++ b/crates/auths-core/src/witness/server.rs @@ -79,6 +79,8 @@ impl WitnessServerConfig { let (seed, public_key) = provider_bridge::generate_ed25519_keypair_sync() .map_err(|e| WitnessError::Network(format!("failed to generate keypair: {}", e)))?; + #[allow(clippy::disallowed_methods)] + // INVARIANT: format! with "did:key:z6Mk" prefix guarantees valid did:key URI structure let witness_did = DeviceDID::new_unchecked(format!("did:key:z6Mk{}", hex::encode(&public_key[..16]))); @@ -184,6 +186,8 @@ impl WitnessServerState { let (seed, public_key) = provider_bridge::generate_ed25519_keypair_sync() .map_err(|e| WitnessError::Network(format!("failed to generate keypair: {}", e)))?; + #[allow(clippy::disallowed_methods)] + // INVARIANT: format! with "did:key:z6Mk" prefix guarantees valid did:key URI structure let witness_did = DeviceDID::new_unchecked(format!("did:key:z6Mk{}", hex::encode(&public_key[..16]))); diff --git a/crates/auths-crypto/clippy.toml b/crates/auths-crypto/clippy.toml index a00552bc..e3140287 100644 --- a/crates/auths-crypto/clippy.toml +++ b/crates/auths-crypto/clippy.toml @@ -12,6 +12,13 @@ disallowed-methods = [ { path = "std::env::var", reason = "use EnvironmentConfig abstraction instead of reading env vars directly", allow-invalid = true }, { path = "uuid::Uuid::new_v4", reason = "Use UuidProvider::new_id() instead. Inject SystemUuidProvider in production and DeterministicUuidProvider in tests." }, + # === DID/newtype construction: prefer parse() for external input === + { path = "auths_verifier::types::IdentityDID::new_unchecked", reason = "Use IdentityDID::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::types::DeviceDID::new_unchecked", reason = "Use DeviceDID::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::types::CanonicalDid::new_unchecked", reason = "Use CanonicalDid::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::core::CommitOid::new_unchecked", reason = "Use CommitOid::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::core::PublicKeyHex::new_unchecked", reason = "Use PublicKeyHex::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + # === Sans-IO: filesystem === { path = "std::fs::read", reason = "sans-IO crate — use a port trait" }, { path = "std::fs::read_to_string", reason = "sans-IO crate — use a port trait" }, @@ -35,6 +42,7 @@ disallowed-methods = [ { path = "dirs::home_dir", reason = "sans-IO crate — inject paths via config", allow-invalid = true }, { path = "dirs::config_dir", reason = "sans-IO crate — inject paths via config", allow-invalid = true }, { path = "dirs::data_dir", reason = "sans-IO crate — inject paths via config", allow-invalid = true }, + { path = "dirs::data_local_dir", reason = "sans-IO crate — inject paths via config", allow-invalid = true }, # === Sans-IO: network === { path = "reqwest::Client::new", reason = "sans-IO crate — use a port trait for HTTP", allow-invalid = true }, @@ -43,6 +51,7 @@ disallowed-methods = [ disallowed-types = [ { path = "std::fs::File", reason = "sans-IO crate — use a port trait" }, + { path = "std::fs::OpenOptions", reason = "sans-IO crate — use a port trait" }, { path = "std::process::Command", reason = "sans-IO crate — use a port trait" }, { path = "std::net::TcpStream", reason = "sans-IO crate — use a port trait" }, { path = "std::net::TcpListener", reason = "sans-IO crate — use a port trait" }, diff --git a/crates/auths-id/clippy.toml b/crates/auths-id/clippy.toml index 30374cbb..e3140287 100644 --- a/crates/auths-id/clippy.toml +++ b/crates/auths-id/clippy.toml @@ -12,6 +12,13 @@ disallowed-methods = [ { path = "std::env::var", reason = "use EnvironmentConfig abstraction instead of reading env vars directly", allow-invalid = true }, { path = "uuid::Uuid::new_v4", reason = "Use UuidProvider::new_id() instead. Inject SystemUuidProvider in production and DeterministicUuidProvider in tests." }, + # === DID/newtype construction: prefer parse() for external input === + { path = "auths_verifier::types::IdentityDID::new_unchecked", reason = "Use IdentityDID::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::types::DeviceDID::new_unchecked", reason = "Use DeviceDID::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::types::CanonicalDid::new_unchecked", reason = "Use CanonicalDid::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::core::CommitOid::new_unchecked", reason = "Use CommitOid::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::core::PublicKeyHex::new_unchecked", reason = "Use PublicKeyHex::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + # === Sans-IO: filesystem === { path = "std::fs::read", reason = "sans-IO crate — use a port trait" }, { path = "std::fs::read_to_string", reason = "sans-IO crate — use a port trait" }, diff --git a/crates/auths-id/src/agent_identity.rs b/crates/auths-id/src/agent_identity.rs index 32b3b77c..6219eb97 100644 --- a/crates/auths-id/src/agent_identity.rs +++ b/crates/auths-id/src/agent_identity.rs @@ -252,7 +252,11 @@ fn get_or_create_identity( ) -> Result { let mut existing_did: Option = None; let _ = backend.visit_identities(&mut |prefix| { - existing_did = Some(IdentityDID::new_unchecked(format!("did:keri:{}", prefix))); + #[allow(clippy::disallowed_methods)] + // INVARIANT: visit_identities yields KERI prefixes from the registry, format! produces a valid did:keri string + { + existing_did = Some(IdentityDID::new_unchecked(format!("did:keri:{}", prefix))); + } std::ops::ControlFlow::Break(()) }); if let Some(did) = existing_did { diff --git a/crates/auths-id/src/attestation/create.rs b/crates/auths-id/src/attestation/create.rs index 537554e1..e98d7253 100644 --- a/crates/auths-id/src/attestation/create.rs +++ b/crates/auths-id/src/attestation/create.rs @@ -91,6 +91,8 @@ pub fn create_signed_attestation( } // Construct the canonical data to be signed + #[allow(clippy::disallowed_methods)] + // INVARIANT: identity_did is an IdentityDID which guarantees valid DID format let issuer_canonical = CanonicalDid::new_unchecked(identity_did.as_str()); let delegated_canonical = delegated_by.as_ref().map(|d| CanonicalDid::from(d.clone())); let data_to_canonicalize = CanonicalAttestationData { diff --git a/crates/auths-id/src/attestation/revoke.rs b/crates/auths-id/src/attestation/revoke.rs index a754d4bd..8c387bc0 100644 --- a/crates/auths-id/src/attestation/revoke.rs +++ b/crates/auths-id/src/attestation/revoke.rs @@ -45,6 +45,8 @@ pub fn create_signed_revocation( // 1. Construct the revocation-specific canonical data let revoked_at_value = Some(timestamp_arg); + #[allow(clippy::disallowed_methods)] + // INVARIANT: identity_did is an IdentityDID which guarantees valid DID format let issuer_canonical = CanonicalDid::new_unchecked(identity_did.as_str()); let data_to_canonicalize_revocation = CanonicalRevocationData { version: REVOCATION_VERSION, @@ -81,10 +83,13 @@ pub fn create_signed_revocation( debug!("Revocation signature obtained successfully"); // 4. Return the final revocation attestation object + #[allow(clippy::disallowed_methods)] + // INVARIANT: identity_did is an IdentityDID which guarantees valid DID format + let revocation_issuer = CanonicalDid::new_unchecked(identity_did.as_str()); Ok(Attestation { version: REVOCATION_VERSION, subject: device_did.clone(), - issuer: CanonicalDid::new_unchecked(identity_did.as_str()), + issuer: revocation_issuer, rid: ResourceId::new(rid), payload: payload_arg.clone(), timestamp: Some(timestamp_arg), diff --git a/crates/auths-id/src/domain/keri_resolve.rs b/crates/auths-id/src/domain/keri_resolve.rs index 1a1176eb..48a8627a 100644 --- a/crates/auths-id/src/domain/keri_resolve.rs +++ b/crates/auths-id/src/domain/keri_resolve.rs @@ -34,6 +34,7 @@ pub fn resolve_from_events( .map_err(|e| ResolveError::InvalidKeyEncoding(e.to_string()))?; Ok(DidKeriResolution { + #[allow(clippy::disallowed_methods)] // INVARIANT: callers pass a did:keri string already validated by parse_did_keri() did: IdentityDID::new_unchecked(did), prefix: prefix.clone(), public_key, diff --git a/crates/auths-id/src/error.rs b/crates/auths-id/src/error.rs index 4e4a880e..04e4fa08 100644 --- a/crates/auths-id/src/error.rs +++ b/crates/auths-id/src/error.rs @@ -98,7 +98,9 @@ impl AuthsErrorInfo for StorageError { match self { #[cfg(feature = "git-storage")] Self::Git(_) => Some("Check that the Git repository is not corrupted"), - Self::Serialization(_) => None, + Self::Serialization(_) => { + Some("Failed to serialize storage data; this may indicate a version mismatch") + } Self::Io(_) => Some("Check file permissions and disk space"), Self::NotFound(_) => Some("Verify the identity or resource exists"), Self::InvalidData(_) => Some("The stored data may be corrupted; try re-initializing"), @@ -129,11 +131,17 @@ impl AuthsErrorInfo for InitError { Self::Git(_) => Some("Check that the Git repository is accessible"), Self::Keri(_) => Some("KERI event processing failed; check identity state"), Self::Key(_) => Some("Check keychain access and passphrase"), - Self::InvalidData(_) => None, + Self::InvalidData(_) => { + Some("Identity data is malformed; try re-initializing with `auths init`") + } Self::Storage(_) => Some("Check storage backend connectivity"), Self::Registry(_) => Some("Check registry backend configuration"), - Self::Crypto(_) => None, - Self::Identity(_) => None, + Self::Crypto(_) => Some( + "A cryptographic operation during initialization failed; check your keychain access", + ), + Self::Identity(_) => { + Some("Identity initialization failed; check storage and keychain configuration") + } } } } diff --git a/crates/auths-id/src/identity/initialize.rs b/crates/auths-id/src/identity/initialize.rs index b648a862..7481ceeb 100644 --- a/crates/auths-id/src/identity/initialize.rs +++ b/crates/auths-id/src/identity/initialize.rs @@ -55,6 +55,8 @@ pub fn initialize_keri_identity( let repo = Repository::open(repo_path)?; let result = create_keri_identity(&repo, None, now).map_err(|e| InitError::Keri(e.to_string()))?; + #[allow(clippy::disallowed_methods)] + // INVARIANT: create_keri_identity returns a valid did:keri: DID let controller_did = IdentityDID::new_unchecked(result.did()); let passphrase = passphrase_provider @@ -163,6 +165,8 @@ pub fn initialize_registry_identity( .append_event(&prefix, &Event::Icp(finalized)) .map_err(|e| InitError::Registry(e.to_string()))?; + #[allow(clippy::disallowed_methods)] + // INVARIANT: prefix is from finalize_icp_event, guaranteed valid did:keri format let controller_did = IdentityDID::new_unchecked(format!("did:keri:{}", prefix)); let passphrase = passphrase_provider diff --git a/crates/auths-id/src/keri/cache.rs b/crates/auths-id/src/keri/cache.rs index 331f1dc9..6ce8f1b8 100644 --- a/crates/auths-id/src/keri/cache.rs +++ b/crates/auths-id/src/keri/cache.rs @@ -126,9 +126,11 @@ pub fn write_kel_cache( ) -> Result<(), CacheError> { let cache = CachedKelState { version: CACHE_VERSION, + #[allow(clippy::disallowed_methods)] // INVARIANT: callers pass a did:keri string that was resolved via parse_did_keri() did: IdentityDID::new_unchecked(did), sequence: state.sequence, validated_against_tip_said: Said::new_unchecked(tip_said.to_string()), + #[allow(clippy::disallowed_methods)] // INVARIANT: callers pass the hex-encoded Git commit OID read from the repository last_commit_oid: CommitOid::new_unchecked(commit_oid), state: state.clone(), cached_at: now, diff --git a/crates/auths-id/src/keri/resolve.rs b/crates/auths-id/src/keri/resolve.rs index 7847e133..17e5c07c 100644 --- a/crates/auths-id/src/keri/resolve.rs +++ b/crates/auths-id/src/keri/resolve.rs @@ -117,6 +117,7 @@ pub fn resolve_did_keri(repo: &Repository, did: &str) -> Result IdentityDID { + #[allow(clippy::disallowed_methods)] + // INVARIANT: Prefix is validated on construction, did:keri:{prefix} is always valid IdentityDID::new_unchecked(format!("did:keri:{}", prefix.as_str())) } @@ -55,6 +57,7 @@ mod tests { #[test] fn prefix_from_did_rejects_non_keri() { + #[allow(clippy::disallowed_methods)] // INVARIANT: test-only literal with valid DID format let did = IdentityDID::new_unchecked("did:key:z6Mk".to_string()); let err = prefix_from_did(&did).unwrap_err(); assert!(err.to_string().contains("Expected did:keri:")); diff --git a/crates/auths-id/src/policy/mod.rs b/crates/auths-id/src/policy/mod.rs index 189eaa3b..3346441a 100644 --- a/crates/auths-id/src/policy/mod.rs +++ b/crates/auths-id/src/policy/mod.rs @@ -310,11 +310,13 @@ pub fn verify_receipts( Ok(true) => continue, Ok(false) => { return ReceiptVerificationResult::InvalidSignature { + #[allow(clippy::disallowed_methods)] // INVARIANT: receipt.i is a witness DID from a deserialized KERI receipt witness_did: DeviceDID::new_unchecked(&receipt.i), }; } Err(_) => { return ReceiptVerificationResult::InvalidSignature { + #[allow(clippy::disallowed_methods)] // INVARIANT: receipt.i is a witness DID from a deserialized KERI receipt witness_did: DeviceDID::new_unchecked(&receipt.i), }; } diff --git a/crates/auths-id/src/storage/indexed.rs b/crates/auths-id/src/storage/indexed.rs index df5db0f3..1545da6c 100644 --- a/crates/auths-id/src/storage/indexed.rs +++ b/crates/auths-id/src/storage/indexed.rs @@ -88,6 +88,7 @@ impl IndexedAttestationStorage { ) -> Result<(), StorageError> { let indexed = IndexedAttestation { rid: att.rid.clone(), + #[allow(clippy::disallowed_methods)] // INVARIANT: att.issuer is a CanonicalDid parsed from validated attestation JSON issuer_did: IdentityDID::new_unchecked(att.issuer.as_str()), device_did: att.subject.clone(), git_ref: git_ref.to_string(), diff --git a/crates/auths-id/src/storage/registry/backend.rs b/crates/auths-id/src/storage/registry/backend.rs index 8aaac122..3c1be8e0 100644 --- a/crates/auths-id/src/storage/registry/backend.rs +++ b/crates/auths-id/src/storage/registry/backend.rs @@ -623,6 +623,7 @@ pub trait RegistryBackend: Send + Sync { status, role: att.role, capabilities: att.capabilities.clone(), + #[allow(clippy::disallowed_methods)] // INVARIANT: att.issuer is a CanonicalDid parsed from validated attestation JSON issuer: IdentityDID::new_unchecked(att.issuer.as_str()), rid: att.rid.clone(), revoked_at, diff --git a/crates/auths-id/src/testing/fakes/identity_storage.rs b/crates/auths-id/src/testing/fakes/identity_storage.rs index 3ac52f82..c03c89b0 100644 --- a/crates/auths-id/src/testing/fakes/identity_storage.rs +++ b/crates/auths-id/src/testing/fakes/identity_storage.rs @@ -42,8 +42,10 @@ impl IdentityStorage for FakeIdentityStorage { .as_ref() .ok_or_else(|| StorageError::NotFound("no identity stored".into()))?; + #[allow(clippy::disallowed_methods)] // INVARIANT: test-only value with valid DID format + let controller_did = IdentityDID::new_unchecked(did.clone()); Ok(ManagedIdentity { - controller_did: IdentityDID::new_unchecked(did.clone()), + controller_did, storage_id: "fake".to_string(), metadata: metadata.clone(), }) diff --git a/crates/auths-id/src/testing/fakes/registry.rs b/crates/auths-id/src/testing/fakes/registry.rs index 8a563f7b..060ba29b 100644 --- a/crates/auths-id/src/testing/fakes/registry.rs +++ b/crates/auths-id/src/testing/fakes/registry.rs @@ -255,7 +255,9 @@ impl RegistryBackend for FakeRegistryBackend { continue; } let entry = OrgMemberEntry { + #[allow(clippy::disallowed_methods)] // INVARIANT: org is a KERI prefix from the org_members map key, format! produces a valid did:keri string org: IdentityDID::new_unchecked(format!("did:keri:{}", org)), + #[allow(clippy::disallowed_methods)] // INVARIANT: member_did_str is a DID string stored in the org_members map key did: DeviceDID::new_unchecked(member_did_str.clone()), filename: format!("{}.json", member_did_str.replace(':', "_")), attestation: validate_org_member(org, member_did_str, att), @@ -293,12 +295,15 @@ fn validate_org_member( let expected_issuer = format!("did:keri:{}", org); if att.issuer.as_str() != expected_issuer { return Err(MemberInvalidReason::IssuerMismatch { + #[allow(clippy::disallowed_methods)] // INVARIANT: format! with "did:keri:" prefix and org KERI prefix produces a valid did:keri string expected_issuer: IdentityDID::new_unchecked(expected_issuer), + #[allow(clippy::disallowed_methods)] // INVARIANT: att.issuer is a CanonicalDid from a deserialized Attestation actual_issuer: IdentityDID::new_unchecked(att.issuer.as_str()), }); } if att.subject.as_str() != member_did_str { return Err(MemberInvalidReason::SubjectMismatch { + #[allow(clippy::disallowed_methods)] // INVARIANT: member_did_str is a DID string from the org_members map key filename_did: DeviceDID::new_unchecked(member_did_str), attestation_subject: att.subject.clone(), }); diff --git a/crates/auths-id/src/testing/fixtures.rs b/crates/auths-id/src/testing/fixtures.rs index 26ada562..1427f4cd 100644 --- a/crates/auths-id/src/testing/fixtures.rs +++ b/crates/auths-id/src/testing/fixtures.rs @@ -76,10 +76,12 @@ pub fn test_inception_event(key_seed: &str) -> Event { /// backend.store_attestation(&att).unwrap(); /// ``` pub fn test_attestation(device_did: &DeviceDID, issuer: &str) -> Attestation { + #[allow(clippy::disallowed_methods)] // INVARIANT: test-only literal with valid DID format + let issuer = CanonicalDid::new_unchecked(issuer); Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: CanonicalDid::new_unchecked(issuer), + issuer, subject: device_did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), diff --git a/crates/auths-id/src/testing/mocks.rs b/crates/auths-id/src/testing/mocks.rs index 20bdf52e..35b1749e 100644 --- a/crates/auths-id/src/testing/mocks.rs +++ b/crates/auths-id/src/testing/mocks.rs @@ -55,8 +55,11 @@ mod tests { fn mock_identity_storage_load_returns_configured_value() { let mut mock = MockIdentityStorage::new(); mock.expect_load_identity().returning(|| { + #[allow(clippy::disallowed_methods)] + // INVARIANT: test-only literal with valid DID format + let controller_did = IdentityDID::new_unchecked("did:keri:Etest".to_string()); Ok(ManagedIdentity { - controller_did: IdentityDID::new_unchecked("did:keri:Etest".to_string()), + controller_did, storage_id: "test-repo".to_string(), metadata: None, }) diff --git a/crates/auths-id/src/witness_config.rs b/crates/auths-id/src/witness_config.rs index 38d2f9e2..2e9ebf1f 100644 --- a/crates/auths-id/src/witness_config.rs +++ b/crates/auths-id/src/witness_config.rs @@ -9,6 +9,9 @@ use url::Url; /// Configuration for witness receipts on an identity. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WitnessConfig { + /// Schema version for forwards-compatible deserialization. + #[serde(default = "default_version")] + pub version: u8, /// Witness server URLs (e.g. `["http://w1:3333", "http://w2:3333"]`). pub witness_urls: Vec, /// Minimum receipts required (k-of-n threshold). @@ -19,9 +22,14 @@ pub struct WitnessConfig { pub policy: WitnessPolicy, } +fn default_version() -> u8 { + 1 +} + impl Default for WitnessConfig { fn default() -> Self { Self { + version: 1, witness_urls: vec![], threshold: 0, timeout_ms: 5000, @@ -65,7 +73,7 @@ mod tests { witness_urls: vec!["http://w1:3333".parse().unwrap()], threshold: 1, timeout_ms: 5000, - policy: WitnessPolicy::Enforce, + ..Default::default() }; assert!(config.is_enabled()); } @@ -77,6 +85,7 @@ mod tests { threshold: 1, timeout_ms: 5000, policy: WitnessPolicy::Skip, + ..Default::default() }; assert!(!config.is_enabled()); } @@ -87,8 +96,33 @@ mod tests { witness_urls: vec!["http://w1:3333".parse().unwrap()], threshold: 0, timeout_ms: 5000, - policy: WitnessPolicy::Enforce, + ..Default::default() }; assert!(!config.is_enabled()); } + + #[test] + fn default_version_is_one() { + assert_eq!(WitnessConfig::default().version, 1); + } + + #[test] + fn json_without_version_deserializes_to_v1() { + let json = r#"{ + "witness_urls": [], + "threshold": 0, + "timeout_ms": 5000, + "policy": "Enforce" + }"#; + let config: WitnessConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.version, 1); + } + + #[test] + fn json_with_version_roundtrips() { + let config = WitnessConfig::default(); + let json = serde_json::to_string(&config).unwrap(); + let roundtripped: WitnessConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(roundtripped.version, 1); + } } diff --git a/crates/auths-index/src/index.rs b/crates/auths-index/src/index.rs index b454e290..d499fb11 100644 --- a/crates/auths-index/src/index.rs +++ b/crates/auths-index/src/index.rs @@ -276,10 +276,17 @@ impl AttestationIndex { .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(|_| Utc::now()); + #[allow(clippy::disallowed_methods)] + // INVARIANT: issuer_did was validated on insert via upsert_attestation and stored in SQLite + let issuer_did = IdentityDID::new_unchecked(issuer_did); + #[allow(clippy::disallowed_methods)] + // INVARIANT: device_did was validated on insert via upsert_attestation and stored in SQLite + let device_did = DeviceDID::new_unchecked(device_did); + Ok(IndexedAttestation { rid: ResourceId::new(rid), - issuer_did: IdentityDID::new_unchecked(issuer_did), - device_did: DeviceDID::new_unchecked(device_did), + issuer_did, + device_did, git_ref, commit_oid: commit_oid .filter(|s| !s.is_empty()) @@ -408,10 +415,20 @@ impl AttestationIndex { let expires_str: Option = stmt.read(5)?; let updated_str: String = stmt.read(6)?; + #[allow(clippy::disallowed_methods)] + // INVARIANT: org_prefix was validated on insert via upsert_org_member and stored in SQLite + let org_prefix = Prefix::new_unchecked(org_prefix); + #[allow(clippy::disallowed_methods)] + // INVARIANT: member_did was validated on insert via upsert_org_member and stored in SQLite + let member_did = DeviceDID::new_unchecked(member_did); + #[allow(clippy::disallowed_methods)] + // INVARIANT: issuer_did was validated on insert via upsert_org_member and stored in SQLite + let issuer_did = IdentityDID::new_unchecked(issuer_did); + members.push(IndexedOrgMember { - org_prefix: Prefix::new_unchecked(org_prefix), - member_did: DeviceDID::new_unchecked(member_did), - issuer_did: IdentityDID::new_unchecked(issuer_did), + org_prefix, + member_did, + issuer_did, rid: ResourceId::new(rid), revoked_at: parse_dt(revoked_str), expires_at: parse_dt(expires_str), @@ -459,10 +476,15 @@ mod tests { device: &str, revoked_at: Option>, ) -> IndexedAttestation { + #[allow(clippy::disallowed_methods)] // INVARIANT: test-only hardcoded DID string literal + let issuer_did = IdentityDID::new_unchecked("did:key:issuer123"); + #[allow(clippy::disallowed_methods)] // INVARIANT: test-only DID string from caller + let device_did = DeviceDID::new_unchecked(device); + IndexedAttestation { rid: ResourceId::new(rid), - issuer_did: IdentityDID::new_unchecked("did:key:issuer123"), - device_did: DeviceDID::new_unchecked(device), + issuer_did, + device_did, git_ref: format!("refs/auths/devices/nodes/{}/signatures", device), commit_oid: None, revoked_at, @@ -642,6 +664,7 @@ mod tests { #[test] fn test_upsert_and_list_org_members() { let index = AttestationIndex::in_memory().unwrap(); + #[allow(clippy::disallowed_methods)] // INVARIANT: test-only hardcoded DID string literals let member = IndexedOrgMember { org_prefix: Prefix::new_unchecked("did:keri:EOrg".to_string()), member_did: DeviceDID::new_unchecked("did:key:z6MkMember1"), @@ -663,6 +686,7 @@ mod tests { #[test] fn test_upsert_org_member_updates_existing() { let index = AttestationIndex::in_memory().unwrap(); + #[allow(clippy::disallowed_methods)] // INVARIANT: test-only hardcoded DID string literals let member = IndexedOrgMember { org_prefix: Prefix::new_unchecked("did:keri:EOrg".to_string()), member_did: DeviceDID::new_unchecked("did:key:z6MkMember1"), @@ -674,6 +698,7 @@ mod tests { }; index.upsert_org_member(&member).unwrap(); + #[allow(clippy::disallowed_methods)] // INVARIANT: test-only hardcoded DID string literals let updated = IndexedOrgMember { org_prefix: Prefix::new_unchecked("did:keri:EOrg".to_string()), member_did: DeviceDID::new_unchecked("did:key:z6MkMember1"), @@ -697,17 +722,18 @@ mod tests { assert_eq!(index.count_org_members("did:keri:EOrg").unwrap(), 0); for i in 0..3 { - index - .upsert_org_member(&IndexedOrgMember { - org_prefix: Prefix::new_unchecked("did:keri:EOrg".to_string()), - member_did: DeviceDID::new_unchecked(format!("did:key:z6MkMember{}", i)), - issuer_did: IdentityDID::new_unchecked("did:keri:EOrg"), - rid: ResourceId::new(format!("rid-{}", i)), - revoked_at: None, - expires_at: None, - updated_at: Utc::now(), - }) - .unwrap(); + #[allow(clippy::disallowed_methods)] + // INVARIANT: test-only hardcoded DID string literals + let member = IndexedOrgMember { + org_prefix: Prefix::new_unchecked("did:keri:EOrg".to_string()), + member_did: DeviceDID::new_unchecked(format!("did:key:z6MkMember{}", i)), + issuer_did: IdentityDID::new_unchecked("did:keri:EOrg"), + rid: ResourceId::new(format!("rid-{}", i)), + revoked_at: None, + expires_at: None, + updated_at: Utc::now(), + }; + index.upsert_org_member(&member).unwrap(); } assert_eq!(index.count_org_members("did:keri:EOrg").unwrap(), 3); diff --git a/crates/auths-index/src/rebuild.rs b/crates/auths-index/src/rebuild.rs index bc9a1a03..4e17626a 100644 --- a/crates/auths-index/src/rebuild.rs +++ b/crates/auths-index/src/rebuild.rs @@ -118,10 +118,17 @@ fn extract_attestation_from_ref( .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(Utc::now); + #[allow(clippy::disallowed_methods)] + // INVARIANT: issuer_did extracted from attestation JSON stored in a signed Git commit + let issuer_did = IdentityDID::new_unchecked(&issuer_did); + #[allow(clippy::disallowed_methods)] + // INVARIANT: device_did extracted from attestation JSON stored in a signed Git commit + let device_did = DeviceDID::new_unchecked(&device_did); + Ok(IndexedAttestation { rid: ResourceId::new(rid), - issuer_did: IdentityDID::new_unchecked(&issuer_did), - device_did: DeviceDID::new_unchecked(&device_did), + issuer_did, + device_did, git_ref: ref_name.to_string(), commit_oid: CommitOid::parse(&commit.id().to_string()).ok(), revoked_at, diff --git a/crates/auths-sdk/clippy.toml b/crates/auths-sdk/clippy.toml index a00552bc..e3140287 100644 --- a/crates/auths-sdk/clippy.toml +++ b/crates/auths-sdk/clippy.toml @@ -12,6 +12,13 @@ disallowed-methods = [ { path = "std::env::var", reason = "use EnvironmentConfig abstraction instead of reading env vars directly", allow-invalid = true }, { path = "uuid::Uuid::new_v4", reason = "Use UuidProvider::new_id() instead. Inject SystemUuidProvider in production and DeterministicUuidProvider in tests." }, + # === DID/newtype construction: prefer parse() for external input === + { path = "auths_verifier::types::IdentityDID::new_unchecked", reason = "Use IdentityDID::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::types::DeviceDID::new_unchecked", reason = "Use DeviceDID::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::types::CanonicalDid::new_unchecked", reason = "Use CanonicalDid::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::core::CommitOid::new_unchecked", reason = "Use CommitOid::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + { path = "auths_verifier::core::PublicKeyHex::new_unchecked", reason = "Use PublicKeyHex::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true }, + # === Sans-IO: filesystem === { path = "std::fs::read", reason = "sans-IO crate — use a port trait" }, { path = "std::fs::read_to_string", reason = "sans-IO crate — use a port trait" }, @@ -35,6 +42,7 @@ disallowed-methods = [ { path = "dirs::home_dir", reason = "sans-IO crate — inject paths via config", allow-invalid = true }, { path = "dirs::config_dir", reason = "sans-IO crate — inject paths via config", allow-invalid = true }, { path = "dirs::data_dir", reason = "sans-IO crate — inject paths via config", allow-invalid = true }, + { path = "dirs::data_local_dir", reason = "sans-IO crate — inject paths via config", allow-invalid = true }, # === Sans-IO: network === { path = "reqwest::Client::new", reason = "sans-IO crate — use a port trait for HTTP", allow-invalid = true }, @@ -43,6 +51,7 @@ disallowed-methods = [ disallowed-types = [ { path = "std::fs::File", reason = "sans-IO crate — use a port trait" }, + { path = "std::fs::OpenOptions", reason = "sans-IO crate — use a port trait" }, { path = "std::process::Command", reason = "sans-IO crate — use a port trait" }, { path = "std::net::TcpStream", reason = "sans-IO crate — use a port trait" }, { path = "std::net::TcpListener", reason = "sans-IO crate — use a port trait" }, diff --git a/crates/auths-sdk/src/device.rs b/crates/auths-sdk/src/device.rs index 8ad848c4..11d374fe 100644 --- a/crates/auths-sdk/src/device.rs +++ b/crates/auths-sdk/src/device.rs @@ -182,6 +182,8 @@ pub fn extend_device( .map_err(|e| DeviceExtensionError::StorageError(e.into()))?, ); + #[allow(clippy::disallowed_methods)] + // INVARIANT: config.device_did is a did:key string supplied by the CLI from an existing attestation let device_did_obj = DeviceDID::new_unchecked(config.device_did.clone()); let latest = group @@ -231,6 +233,7 @@ pub fn extend_device( ctx.attestation_sink.sync_index(&extended); Ok(DeviceExtensionResult { + #[allow(clippy::disallowed_methods)] // INVARIANT: config.device_did was already validated above when constructing device_did_obj device_did: DeviceDID::new_unchecked(config.device_did), new_expires_at, previous_expires_at, diff --git a/crates/auths-sdk/src/error.rs b/crates/auths-sdk/src/error.rs index 039fc6e0..7a133dd0 100644 --- a/crates/auths-sdk/src/error.rs +++ b/crates/auths-sdk/src/error.rs @@ -319,7 +319,9 @@ impl AuthsErrorInfo for SetupError { } Self::InvalidSetupConfig(_) => Some("Check identity setup configuration parameters"), Self::RegistrationFailed(_) => Some("Check network connectivity and try again"), - Self::PlatformVerificationFailed(_) => None, + Self::PlatformVerificationFailed(_) => Some( + "Platform identity verification failed; check your platform credentials and network connectivity", + ), } } } @@ -340,7 +342,9 @@ impl AuthsErrorInfo for DeviceError { match self { Self::IdentityNotFound { .. } => Some("Run `auths init` to create an identity first"), Self::DeviceNotFound { .. } => Some("Run `auths device list` to see linked devices"), - Self::AttestationError(_) => None, + Self::AttestationError(_) => Some( + "The attestation operation failed; run `auths device list` to check device status", + ), Self::DeviceDidMismatch { .. } => Some("Check that --device-did matches the key alias"), Self::CryptoError(e) => e.suggestion(), Self::StorageError(_) => Some("Check file permissions and disk space"), @@ -365,8 +369,12 @@ impl AuthsErrorInfo for DeviceExtensionError { Self::NoAttestationFound { .. } => { Some("Run `auths device link` to create an attestation for this device") } - Self::AlreadyRevoked { .. } => None, - Self::AttestationFailed(_) => None, + Self::AlreadyRevoked { .. } => Some( + "This device has been revoked and cannot be extended; link a new device with `auths device link`", + ), + Self::AttestationFailed(_) => { + Some("Failed to create the extension attestation; check key access and try again") + } Self::StorageError(_) => Some("Check file permissions and disk space"), } } @@ -390,7 +398,9 @@ impl AuthsErrorInfo for RotationError { Self::KeyNotFound(_) => Some("Run `auths key list` to see available keys"), Self::KeyDecryptionFailed(_) => Some("Check your passphrase and try again"), Self::KelHistoryFailed(_) => Some("Run `auths doctor` to check KEL integrity"), - Self::RotationFailed(_) => None, + Self::RotationFailed(_) => Some( + "Key rotation failed; verify your current key is accessible with `auths key list`", + ), Self::PartialRotation(_) => { Some("Re-run the rotation with the same new key to complete the keychain write") } @@ -413,7 +423,9 @@ impl AuthsErrorInfo for RegistrationError { fn suggestion(&self) -> Option<&'static str> { match self { - Self::AlreadyRegistered => None, + Self::AlreadyRegistered => Some( + "This identity is already registered; use `auths id show` to see registration details", + ), Self::QuotaExceeded => Some("Wait a few minutes and try again"), Self::NetworkError(e) => e.suggestion(), Self::InvalidDidFormat { .. } => { @@ -440,7 +452,9 @@ impl AuthsErrorInfo for McpAuthError { match self { Self::BridgeUnreachable(_) => Some("Check network connectivity to the OIDC bridge"), Self::TokenExchangeFailed { .. } => Some("Verify your credentials and try again"), - Self::InvalidResponse(_) => None, + Self::InvalidResponse(_) => Some( + "The OIDC bridge returned an unexpected response; verify the bridge URL and try again", + ), Self::InsufficientCapabilities { .. } => { Some("Request fewer capabilities or contact your administrator") } @@ -472,11 +486,26 @@ impl AuthsErrorInfo for OrgError { Self::MemberNotFound { .. } => { Some("Run `auths org list-members` to see current members") } - Self::AlreadyRevoked { .. } => None, - Self::InvalidCapability { .. } => None, + Self::AlreadyRevoked { .. } => { + Some("This member has already been revoked from the organization") + } + Self::InvalidCapability { .. } => { + Some("Use a valid capability (e.g., 'sign_commit', 'manage_members', 'admin')") + } Self::InvalidDid(_) => Some("Organization DIDs must be valid did:keri identifiers"), Self::InvalidPublicKey(_) => Some("Public keys must be hex-encoded Ed25519 keys"), - _ => None, + Self::Signing(_) => { + Some("The signing operation failed; check your key access with `auths key list`") + } + Self::Identity(_) => { + Some("Failed to load identity; run `auths id show` to check identity status") + } + Self::KeyStorage(_) => { + Some("Failed to access key storage; run `auths doctor` to diagnose") + } + Self::Storage(_) => { + Some("Failed to access organization storage; check repository permissions") + } } } } @@ -495,7 +524,9 @@ impl AuthsErrorInfo for ApprovalError { fn suggestion(&self) -> Option<&'static str> { match self { - Self::NotApprovalRequired => None, + Self::NotApprovalRequired => Some( + "This operation does not require approval; run it directly without the --approve flag", + ), Self::RequestNotFound { .. } => { Some("Run `auths approval list` to see pending requests") } diff --git a/crates/auths-sdk/src/pairing/mod.rs b/crates/auths-sdk/src/pairing/mod.rs index 04fb85ff..ca57a9de 100644 --- a/crates/auths-sdk/src/pairing/mod.rs +++ b/crates/auths-sdk/src/pairing/mod.rs @@ -531,6 +531,8 @@ pub fn load_device_signing_material( .load_identity() .map_err(|e| PairingError::IdentityNotFound(e.to_string()))?; + #[allow(clippy::disallowed_methods)] + // INVARIANT: managed.controller_did is an IdentityDID loaded from IdentityStorage::load_identity(), already validated let controller_identity_did = IdentityDID::new_unchecked(managed.controller_did.to_string()); let aliases = ctx .key_storage @@ -741,6 +743,8 @@ pub async fn initiate_online_pairing( .map_err(|e| PairingError::KeyExchangeFailed(e.to_string()))?; let controller_did = load_controller_did(ctx.identity_storage.as_ref())?; + #[allow(clippy::disallowed_methods)] + // INVARIANT: controller_did comes from load_controller_did() which returns into_inner() of a validated IdentityDID from storage let controller_identity_did = IdentityDID::new_unchecked(controller_did.clone()); let aliases = ctx .key_storage @@ -756,6 +760,7 @@ pub async fn initiate_online_pairing( let decrypted = DecryptedPairingResponse { auths_dir: PathBuf::new(), device_pubkey: device_signing_bytes, + #[allow(clippy::disallowed_methods)] // INVARIANT: response.device_did was verified by session.verify_response() which validated the Ed25519 signature against the device's signing key device_did: DeviceDID::new_unchecked(response.device_did.to_string()), device_name: response.device_name.clone(), capabilities: session.token.capabilities.clone(), diff --git a/crates/auths-sdk/src/setup.rs b/crates/auths-sdk/src/setup.rs index 3edeb561..5659d47a 100644 --- a/crates/auths-sdk/src/setup.rs +++ b/crates/auths-sdk/src/setup.rs @@ -105,6 +105,7 @@ fn initialize_developer( let registered = submit_registration(&config); Ok(DeveloperIdentityResult { + #[allow(clippy::disallowed_methods)] // INVARIANT: controller_did originates from initialize_registry_identity() which returns a validated IdentityDID; into_inner() only unwraps it identity_did: IdentityDID::new_unchecked(controller_did), device_did, key_alias, @@ -128,6 +129,7 @@ fn initialize_ci( generate_ci_env_block(&key_alias, &config.registry_path, &config.ci_environment); Ok(CiIdentityResult { + #[allow(clippy::disallowed_methods)] // INVARIANT: controller_did originates from initialize_registry_identity() which returns a validated IdentityDID; into_inner() only unwraps it identity_did: IdentityDID::new_unchecked(controller_did), device_did, env_block, @@ -147,10 +149,11 @@ fn initialize_agent( agent_name: config.alias.to_string(), capabilities: cap_strings, expires_in_secs: config.expires_in_secs, - delegated_by: config - .parent_identity_did - .clone() - .map(IdentityDID::new_unchecked), + delegated_by: config.parent_identity_did.clone().map(|did| { + #[allow(clippy::disallowed_methods)] + // INVARIANT: parent_identity_did is supplied by the CLI after resolving from identity storage, which stores only validated did:keri: DIDs + IdentityDID::new_unchecked(did) + }), storage_mode: AgentStorageMode::Persistent { repo_path: Some(config.registry_path.clone()), }, diff --git a/crates/auths-sdk/src/signing.rs b/crates/auths-sdk/src/signing.rs index d968afaa..b3cb4bb0 100644 --- a/crates/auths-sdk/src/signing.rs +++ b/crates/auths-sdk/src/signing.rs @@ -77,9 +77,13 @@ impl auths_core::error::AuthsErrorInfo for SigningError { match self { Self::IdentityFrozen(_) => Some("To unfreeze: auths emergency unfreeze"), Self::KeyResolution(_) => Some("Run `auths key list` to check available keys"), - Self::SigningFailed(_) => None, + Self::SigningFailed(_) => Some( + "The signing operation failed; verify your key is accessible with `auths key list`", + ), Self::InvalidPassphrase => Some("Check your passphrase and try again"), - Self::PemEncoding(_) => None, + Self::PemEncoding(_) => { + Some("Failed to encode the key in PEM format; the key material may be corrupted") + } Self::AgentUnavailable(_) => Some("Start the agent with `auths agent start`"), Self::AgentSigningFailed(_) => Some("Check agent logs with `auths agent status`"), Self::PassphraseExhausted { .. } => { diff --git a/crates/auths-sdk/src/workflows/allowed_signers.rs b/crates/auths-sdk/src/workflows/allowed_signers.rs index 499c8567..d90f7263 100644 --- a/crates/auths-sdk/src/workflows/allowed_signers.rs +++ b/crates/auths-sdk/src/workflows/allowed_signers.rs @@ -602,6 +602,8 @@ mod tests { #[test] fn principal_display_did() { + #[allow(clippy::disallowed_methods)] + // INVARIANT: test-only literal with valid did:key: prefix let did = DeviceDID::new_unchecked("did:key:z6MkTest123"); let p = SignerPrincipal::DeviceDid(did); assert_eq!(p.to_string(), "z6MkTest123@auths.local"); @@ -613,6 +615,8 @@ mod tests { let parsed = parse_principal(&email_p.to_string()).unwrap(); assert_eq!(parsed, email_p); + #[allow(clippy::disallowed_methods)] + // INVARIANT: test-only literal with valid did:key: prefix let did = DeviceDID::new_unchecked("did:key:z6MkTest123"); let did_p = SignerPrincipal::DeviceDid(did); let parsed = parse_principal(&did_p.to_string()).unwrap(); diff --git a/crates/auths-sdk/src/workflows/org.rs b/crates/auths-sdk/src/workflows/org.rs index d48fbc20..a5505c57 100644 --- a/crates/auths-sdk/src/workflows/org.rs +++ b/crates/auths-sdk/src/workflows/org.rs @@ -336,6 +336,8 @@ pub fn add_organization_member( let now = ctx.clock.now(); let rid = ctx.uuid_provider.new_id().to_string(); + #[allow(clippy::disallowed_methods)] + // INVARIANT: cmd.member_did is a did:key string from the CLI, validated by the caller let member_did = DeviceDID::new_unchecked(&cmd.member_did); let meta = AttestationMetadata { note: cmd @@ -345,6 +347,8 @@ pub fn add_organization_member( expires_at: None, }; + #[allow(clippy::disallowed_methods)] + // INVARIANT: admin_att.issuer is a CanonicalDid from a verified attestation loaded by find_admin() let admin_issuer_did = IdentityDID::new_unchecked(admin_att.issuer.as_str()); let attestation = create_signed_attestation( now, @@ -363,7 +367,11 @@ pub fn add_organization_member( None, parsed_caps, Some(cmd.role), - Some(IdentityDID::new_unchecked(admin_att.subject.to_string())), + { + #[allow(clippy::disallowed_methods)] + // INVARIANT: admin_att.subject is a CanonicalDid from a verified attestation loaded by find_admin() + Some(IdentityDID::new_unchecked(admin_att.subject.to_string())) + }, ) .map_err(|e| OrgError::Signing(e.to_string()))?; @@ -410,8 +418,12 @@ pub fn revoke_organization_member( } let now = ctx.clock.now(); + #[allow(clippy::disallowed_methods)] + // INVARIANT: cmd.member_did is a did:key string from the CLI, validated by the caller let member_did = DeviceDID::new_unchecked(&cmd.member_did); + #[allow(clippy::disallowed_methods)] + // INVARIANT: admin_att.issuer is a CanonicalDid from a verified attestation loaded by find_admin() let admin_issuer_did = IdentityDID::new_unchecked(admin_att.issuer.as_str()); let revocation = create_signed_revocation( admin_att.rid.as_str(), diff --git a/crates/auths-sdk/src/workflows/platform.rs b/crates/auths-sdk/src/workflows/platform.rs index bd463a1a..dde0de60 100644 --- a/crates/auths-sdk/src/workflows/platform.rs +++ b/crates/auths-sdk/src/workflows/platform.rs @@ -211,6 +211,8 @@ fn resolve_signing_key_alias( ctx: &AuthsContext, controller_did: &str, ) -> Result { + #[allow(clippy::disallowed_methods)] + // INVARIANT: controller_did comes from load_controller_did() which returns into_inner() of a validated IdentityDID from storage let identity_did = IdentityDID::new_unchecked(controller_did.to_string()); let aliases = ctx .key_storage diff --git a/crates/auths-sdk/src/workflows/provision.rs b/crates/auths-sdk/src/workflows/provision.rs index c601b5fe..d522fe5d 100644 --- a/crates/auths-sdk/src/workflows/provision.rs +++ b/crates/auths-sdk/src/workflows/provision.rs @@ -178,5 +178,6 @@ fn build_witness_config(witness: Option<&WitnessOverride>) -> Option Some("Configure SSH signing: git config gpg.format ssh"), Self::UnsupportedKeyType { .. } => Some("Use an Ed25519 SSH key for signing"), Self::UnknownSigner => Some("Add the signer's key to the allowed signers list"), - _ => None, + Self::SshSigParseFailed(_) => Some( + "The SSH signature could not be parsed; verify the commit was signed correctly", + ), + Self::NamespaceMismatch { .. } => Some( + "The signature namespace doesn't match; ensure git config gpg.ssh.defaultKeyCommand is set correctly", + ), + Self::HashAlgorithmUnsupported(_) => { + Some("Use SHA-256 or SHA-512 hash algorithm for signing") + } + Self::SignatureInvalid => Some( + "The commit signature does not match the signed data; the commit may have been modified after signing", + ), + Self::CommitParseFailed(_) => Some( + "The Git commit object is malformed; check repository integrity with `git fsck`", + ), } } } diff --git a/crates/auths-verifier/src/error.rs b/crates/auths-verifier/src/error.rs index b2e427f4..641a63a9 100644 --- a/crates/auths-verifier/src/error.rs +++ b/crates/auths-verifier/src/error.rs @@ -160,11 +160,21 @@ impl AuthsErrorInfo for AttestationError { Self::AttestationTooOld { .. } => { Some("Request a fresh attestation or increase the max_age threshold") } - Self::SigningError(_) - | Self::SerializationError(_) - | Self::InvalidInput(_) - | Self::CryptoError(_) - | Self::InternalError(_) => None, + Self::SigningError(_) => { + Some("The cryptographic signing operation failed; verify key material is valid") + } + Self::SerializationError(_) => { + Some("Failed to serialize/deserialize attestation data; check JSON format") + } + Self::InvalidInput(_) => { + Some("Check the input parameters and ensure they match the expected format") + } + Self::CryptoError(_) => { + Some("A cryptographic operation failed; verify key material is valid") + } + Self::InternalError(_) => { + Some("An unexpected internal error occurred; please report this issue") + } } } } diff --git a/crates/xtask/src/check_clippy_sync.rs b/crates/xtask/src/check_clippy_sync.rs new file mode 100644 index 00000000..75fbc3ef --- /dev/null +++ b/crates/xtask/src/check_clippy_sync.rs @@ -0,0 +1,165 @@ +use std::collections::HashSet; +use std::path::Path; + +// All DID/newtype rules are now propagated to crate configs (fn-70.3 complete). +// Keep this mechanism for any future rules that need staged rollout. +const DEFERRED_RULE_PREFIXES: &[&str] = &[]; + +pub fn run(workspace_root: &Path) -> anyhow::Result<()> { + let workspace_toml = workspace_root.join("clippy.toml"); + let workspace_methods = extract_disallowed_paths(&workspace_toml, "disallowed-methods")?; + let workspace_types = extract_disallowed_paths(&workspace_toml, "disallowed-types")?; + + let crate_clippy_files = find_crate_clippy_files(workspace_root)?; + + let mut has_errors = false; + + for crate_toml in &crate_clippy_files { + let crate_methods = extract_disallowed_paths(crate_toml, "disallowed-methods")?; + let crate_types = extract_disallowed_paths(crate_toml, "disallowed-types")?; + + let rel = crate_toml + .strip_prefix(workspace_root) + .unwrap_or(crate_toml); + + for method in &workspace_methods { + if is_deferred(method) { + continue; + } + if !crate_methods.contains(method) { + eprintln!( + "DRIFT: {rel} missing disallowed-method: {method}", + rel = rel.display() + ); + has_errors = true; + } + } + + if !crate_types.is_empty() || !workspace_types.is_empty() { + for ty in &workspace_types { + if is_deferred(ty) { + continue; + } + if !crate_types.is_empty() && !crate_types.contains(ty) { + eprintln!( + "DRIFT: {rel} missing disallowed-type: {ty}", + rel = rel.display() + ); + has_errors = true; + } + } + } + } + + if has_errors { + anyhow::bail!( + "clippy.toml sync check failed. Crate-level clippy.toml files must contain \ + all workspace-root rules. See above for details." + ); + } + + println!( + "clippy.toml sync OK — {} crate-level files checked against workspace root", + crate_clippy_files.len() + ); + Ok(()) +} + +fn is_deferred(path: &str) -> bool { + DEFERRED_RULE_PREFIXES + .iter() + .any(|prefix| path.starts_with(prefix)) +} + +fn find_crate_clippy_files(workspace_root: &Path) -> anyhow::Result> { + let mut files = Vec::new(); + let crates_dir = workspace_root.join("crates"); + if crates_dir.is_dir() { + for entry in std::fs::read_dir(&crates_dir)? { + let entry = entry?; + let clippy_toml = entry.path().join("clippy.toml"); + if clippy_toml.is_file() { + files.push(clippy_toml); + } + } + } + let packages_dir = workspace_root.join("packages"); + if packages_dir.is_dir() { + for entry in std::fs::read_dir(&packages_dir)? { + let entry = entry?; + let clippy_toml = entry.path().join("clippy.toml"); + if clippy_toml.is_file() { + files.push(clippy_toml); + } + } + } + files.sort(); + Ok(files) +} + +fn extract_disallowed_paths(toml_path: &Path, key: &str) -> anyhow::Result> { + let content = std::fs::read_to_string(toml_path)?; + let mut paths = HashSet::new(); + + let in_target_section = find_array_section(&content, key); + if let Some(section) = in_target_section { + for line in section.lines() { + if let Some(path) = extract_path_value(line) { + paths.insert(path); + } + } + } + + Ok(paths) +} + +fn find_array_section<'a>(content: &'a str, key: &str) -> Option<&'a str> { + let needle = format!("{key} = ["); + let start = content.find(&needle)?; + let rest = &content[start..]; + let end = rest.find(']')?; + Some(&rest[..=end]) +} + +fn extract_path_value(line: &str) -> Option { + let trimmed = line.trim(); + if !trimmed.starts_with('{') { + return None; + } + let path_key = "path = \""; + let idx = trimmed.find(path_key)?; + let after = &trimmed[idx + path_key.len()..]; + let end = after.find('"')?; + Some(after[..end].to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_path_value() { + let line = r#" { path = "chrono::offset::Utc::now", reason = "inject ClockProvider" },"#; + assert_eq!( + extract_path_value(line), + Some("chrono::offset::Utc::now".to_string()) + ); + } + + #[test] + fn test_extract_path_value_comment_line() { + assert_eq!(extract_path_value(" # === Workspace rules ==="), None); + } + + #[test] + fn test_extract_path_value_empty() { + assert_eq!(extract_path_value(""), None); + } + + #[test] + fn test_is_deferred() { + assert!(is_deferred("auths_verifier::IdentityDID::new_unchecked")); + assert!(!is_deferred("chrono::offset::Utc::now")); + assert!(!is_deferred("std::fs::read")); + } +} diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index 3dcb4e17..085d8b29 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -6,6 +6,7 @@ clippy::unwrap_used, clippy::expect_used )] +mod check_clippy_sync; mod ci_setup; mod gen_docs; mod gen_error_docs; @@ -53,6 +54,8 @@ enum Command { #[arg(long)] filter: Option, }, + /// Check that crate-level clippy.toml files contain all workspace-root rules. + CheckClippySync, } fn main() -> anyhow::Result<()> { @@ -71,5 +74,6 @@ fn main() -> anyhow::Result<()> { Command::ValidateSchemas => schemas::validate(workspace_root()), Command::GenErrorDocs { check } => gen_error_docs::run(workspace_root(), check), Command::TestIntegration { filter } => test_integration::run(filter.as_deref()), + Command::CheckClippySync => check_clippy_sync::run(workspace_root()), } } diff --git a/docs/plans/launch_cleaning.md b/docs/plans/launch_cleaning.md index f9f75584..b03934e2 100644 --- a/docs/plans/launch_cleaning.md +++ b/docs/plans/launch_cleaning.md @@ -340,7 +340,7 @@ These are the features that separate "impressive developer tool" from "enterpris --- -#### Epic 6: `auths-sign` verify-options Allowlist (Security) +~~#### Epic 6: `auths-sign` verify-options Allowlist (Security)~~ **Why it matters:** The `verify-options` flags are passed directly to `ssh-keygen` with no validation. In a GitHub Actions context, these values can originate from PR metadata or environment variables, making this a potential vector for altering verification semantics. **Scope:** diff --git a/packages/auths-node/__test__/verify.spec.ts b/packages/auths-node/__test__/verify.spec.ts index 15ed1fc7..24339f29 100644 --- a/packages/auths-node/__test__/verify.spec.ts +++ b/packages/auths-node/__test__/verify.spec.ts @@ -52,7 +52,7 @@ describe('verifyChain', () => { describe('verifyDeviceAuthorization', () => { it('empty attestations returns report', async () => { const report = await verifyDeviceAuthorization( - 'did:key:identity', 'did:key:device', [], 'a'.repeat(64), + 'did:keri:Eidentity', 'did:key:zDevice', [], 'a'.repeat(64), ) expect(report.status).toBeDefined() expect(report.status.statusType).not.toBe('Valid') diff --git a/packages/auths-python/tests/test_verify.py b/packages/auths-python/tests/test_verify.py index 4a0971ed..169a02ad 100644 --- a/packages/auths-python/tests/test_verify.py +++ b/packages/auths-python/tests/test_verify.py @@ -60,7 +60,7 @@ class TestVerifyDeviceAuthorization: def test_empty_attestations_returns_report(self): report = verify_device_authorization( - "did:key:identity", "did:key:device", [], "a" * 64, + "did:keri:Eidentity", "did:key:zDevice", [], "a" * 64, ) assert isinstance(report, VerificationReport) assert not report.is_valid() @@ -68,13 +68,13 @@ def test_empty_attestations_returns_report(self): def test_invalid_json_raises_value_error(self): with pytest.raises(ValueError): verify_device_authorization( - "did:key:identity", "did:key:device", ["not valid json"], "a" * 64, + "did:keri:Eidentity", "did:key:zDevice", ["not valid json"], "a" * 64, ) def test_invalid_pk_hex_raises_value_error(self): with pytest.raises(ValueError): verify_device_authorization( - "did:key:identity", "did:key:device", [], "not-hex", + "did:keri:Eidentity", "did:key:zDevice", [], "not-hex", ) diff --git a/schemas/attestation-v1.json b/schemas/attestation-v1.json index ac5b6ccc..49ea2e8e 100644 --- a/schemas/attestation-v1.json +++ b/schemas/attestation-v1.json @@ -21,13 +21,9 @@ }, "delegated_by": { "description": "DID of the attestation that delegated authority (for chain tracking).", - "anyOf": [ - { - "$ref": "#/definitions/IdentityDID" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "device_public_key": { @@ -66,12 +62,8 @@ ] }, "issuer": { - "description": "DID of the issuing identity.", - "allOf": [ - { - "$ref": "#/definitions/IdentityDID" - } - ] + "description": "DID of the issuing identity (can be `did:keri:` or `did:key:`).", + "type": "string" }, "note": { "description": "Optional human-readable note.", @@ -159,10 +151,6 @@ "type": "string", "format": "hex" }, - "IdentityDID": { - "description": "Strongly-typed wrapper for identity DIDs (e.g., `\"did:keri:E...\"`).\n\nUsage: ```ignore let did = IdentityDID::new(\"did:keri:Eabc123\"); assert_eq!(did.as_str(), \"did:keri:Eabc123\");\n\nlet s: String = did.into_inner(); ```", - "type": "string" - }, "Role": { "description": "Role classification for organization members.\n\nGoverns the default capability set assigned at member authorization time. Serializes as lowercase strings: `\"admin\"`, `\"member\"`, `\"readonly\"`.", "oneOf": [ diff --git a/schemas/identity-bundle-v1.json b/schemas/identity-bundle-v1.json index b4cd86c4..9b4ca70f 100644 --- a/schemas/identity-bundle-v1.json +++ b/schemas/identity-bundle-v1.json @@ -24,8 +24,12 @@ "format": "date-time" }, "identity_did": { - "description": "The DID of the identity (e.g., \"did:keri:...\")", - "type": "string" + "description": "The DID of the identity (e.g., `\"did:keri:...\"`)", + "allOf": [ + { + "$ref": "#/definitions/IdentityDID" + } + ] }, "max_valid_for_secs": { "description": "Maximum age in seconds before this bundle is considered stale", @@ -35,7 +39,11 @@ }, "public_key_hex": { "description": "The public key in hex format for signature verification", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/PublicKeyHex" + } + ] } }, "definitions": { @@ -60,13 +68,9 @@ }, "delegated_by": { "description": "DID of the attestation that delegated authority (for chain tracking).", - "anyOf": [ - { - "$ref": "#/definitions/IdentityDID" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "device_public_key": { @@ -105,12 +109,8 @@ ] }, "issuer": { - "description": "DID of the issuing identity.", - "allOf": [ - { - "$ref": "#/definitions/IdentityDID" - } - ] + "description": "DID of the issuing identity (can be `did:keri:` or `did:key:`).", + "type": "string" }, "note": { "description": "Optional human-readable note.", @@ -199,7 +199,11 @@ "format": "hex" }, "IdentityDID": { - "description": "Strongly-typed wrapper for identity DIDs (e.g., `\"did:keri:E...\"`).\n\nUsage: ```ignore let did = IdentityDID::new(\"did:keri:Eabc123\"); assert_eq!(did.as_str(), \"did:keri:Eabc123\");\n\nlet s: String = did.into_inner(); ```", + "description": "Strongly-typed wrapper for identity DIDs (e.g., `\"did:keri:E...\"`).\n\nUsage: ```rust # use auths_verifier::IdentityDID; let did = IdentityDID::parse(\"did:keri:Eabc123\").unwrap(); assert_eq!(did.as_str(), \"did:keri:Eabc123\");\n\nlet s: String = did.into_inner(); ```", + "type": "string" + }, + "PublicKeyHex": { + "description": "A validated hex-encoded Ed25519 public key (64 hex chars = 32 bytes).\n\nUse `to_ed25519()` to convert to the byte-array `Ed25519PublicKey` type.", "type": "string" }, "Role": {