v0.2.5-pre.009

This commit is contained in:
2026-08-20 09:24:10 +02:00
parent 1125ade4c1
commit e91bf36e9e
83 changed files with 2467 additions and 1801 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 157
# version: 158
[workspace]
resolver = "3"
members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
[workspace.package]
version = "0.2.5-pre.8"
version = "0.2.5-pre.9"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -1,5 +1,5 @@
<!-- file: ROADMAP.md -->
<!-- version: 51 -->
<!-- version: 52 -->
# Roadmap KSP
@@ -49,7 +49,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
- [X] `0.2.2` — HTTP Accounts + Tokens + Cluster : 22 wrappers typés (5 Accounts + 5 Tokens + 12 Cluster), canaries de complétude 52+14, smoke Devnet Transport pur et smoke historique Config -> Transport validés, documentation durable et prompt `0.2.3` publiés stables.
- [X] `0.2.3` — HTTP Transactions stable : 11/11 wrappers typés publiés, classification `8 Read / 2 WriteSubmission / 1 Simulation`, no-resend ambigu prouvé pour les write submissions, `KSP-TRANSPORT-007` réaudité conforme sur les 37 wrappers HTTP courants, graphes Cargo et deux smokes Devnet validés ; `0.2.4` reprend les 15 Blocks/Economics restants.
- [X] `0.2.4` — HTTP Blocks + Economics stable : 15/15 wrappers `V0_2_4` publiés, surface typed complète à 52/52 méthodes courantes, 14/14 historiques conservées, réaudit SIMD/inventaire final et `KSP-TRANSPORT-007` global validés ; deux smokes Devnet passés avant publication.
- [/] `0.2.5` — Wallet foundation : `pre.001` fixe threat model/format autonome ; `pre.002` crée la crate et les capabilities ; `pre.003` fige wire/transcript/AAD ; `pre.004` ajoute Argon2id/XChaCha20-Poly1305/CSPRNG ; `pre.005` fixe defaults benchmarkés, payloads, autorité Ed25519 et create/open ; `pre.006` ajoute persistence no-clobber ; `pre.007` ajoute signature Solana, administration, rotations et révocation forte VIEW ; `pre.008` ajoute maintenant inspection/import/export Solana CLI JSON + keypair Base58 complet, avec import natif no-clobber et export OWNER uniquement. `Pubkey` reste via `ksp-core-lib`, la keypair reste encapsulée dans Wallet et Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet. `pre.009` = audit security/compliance ; `pre.010` = documentation/interop/prompt Wallet Desk.
- [/] `0.2.5` — Wallet foundation : `pre.001` fixe threat model/format autonome ; `pre.002` crée la crate et les capabilities ; `pre.003` fige wire/transcript/AAD ; `pre.004` ajoute Argon2id/XChaCha20-Poly1305/CSPRNG ; `pre.005` fixe defaults benchmarkés, payloads, autorité Ed25519 et create/open ; `pre.006` ajoute persistence no-clobber ; `pre.007` ajoute signature Solana, administration, rotations et révocation forte VIEW ; `pre.008` ajoute inspection/import/export Solana CLI JSON + keypair Base58 complet ; `pre.009` clôt maintenant l'audit adversarial/security/interoperability/compliance et le graphe Cargo. `Pubkey` reste via `ksp-core-lib`, la keypair reste encapsulée dans Wallet et Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet. `pre.010` = documentation finale/README/USAGE/prompt Wallet Desk.
- [ ] `0.2.6` — Introduire `ksp-app-wallet-desk` utilisant Config composite + Wallet + transport HTTP, notamment pour afficher l'identité et le solde d'un wallet.
- [ ] `0.2.7` — Étendre `ksp-onchain-transport-lib` au WebSocket Solana standard complet ; permettre plusieurs sessions sur une même URL sans imposer encore un pool automatique complexe.
- [ ] `0.2.8` — Ajouter Helius LaserStream WebSocket comme extension du moteur WebSocket standard, sans duplication de client.

View File

@@ -1,17 +1,8 @@
// file: crates/ksp-app-config-desk/src/app_state.rs
// version: 6
// version: 7
//! Shared backend state owned by the Tauri application.
struct LoggingRuntimeState {
guard: ksp_logging_lib::LoggingGuard,
active_profile_id: std::option::Option<String>,
selection_source: String,
generation: u32,
fallback_active: bool,
startup_diagnostic: std::option::Option<crate::CommandErrorDto>,
}
/// Shared Config Desk application state managed by Tauri.
pub(crate) struct AppState {
config_management: ksp_config_lib::ConfigManagement,
@@ -28,7 +19,7 @@ impl AppState {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let runtime_identity = crate::logging_runtime::launch_identity();
let runtime_identity = crate::launch_identity();
let runtime_identity = match runtime_identity {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
@@ -185,9 +176,9 @@ impl AppState {
application_id: identity.application_id().to_owned(),
launch_timestamp: identity.launch_timestamp().to_owned(),
console_enabled,
dropped_console_lines: crate::logging_runtime::count_to_u64(dropped.console()),
dropped_file_lines: crate::logging_runtime::count_to_u64(dropped.file()),
dropped_total_lines: crate::logging_runtime::count_to_u64(dropped.total()),
dropped_console_lines: crate::count_to_u64(dropped.console()),
dropped_file_lines: crate::count_to_u64(dropped.file()),
dropped_total_lines: crate::count_to_u64(dropped.total()),
files,
});
}
@@ -206,3 +197,12 @@ impl AppState {
.is_ok();
}
}
struct LoggingRuntimeState {
guard: ksp_logging_lib::LoggingGuard,
active_profile_id: std::option::Option<String>,
selection_source: String,
generation: u32,
fallback_active: bool,
startup_diagnostic: std::option::Option<crate::CommandErrorDto>,
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/bootstrap.rs
// version: 3
// version: 4
//! Config and Logging bootstrap for the desktop application.
@@ -71,6 +71,15 @@ pub(crate) fn initialize_logging(
};
}
fn fallback_logging_settings() -> ksp_logging_lib::LoggingSettings {
return ksp_logging_lib::LoggingSettings::new(
ksp_logging_lib::LogFilterLevel::Info,
ksp_logging_lib::SpanEvents::Off,
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
std::vec::Vec::new(),
);
}
fn resolve_logging_startup(management: &ksp_config_lib::ConfigManagement) -> LoggingStartupPlan {
let environment = ksp_config_lib::ConfigEnvironment::load();
let environment = match environment {
@@ -132,15 +141,6 @@ fn initialize_planned_fallback_logging(
});
}
pub(crate) fn fallback_logging_settings() -> ksp_logging_lib::LoggingSettings {
return ksp_logging_lib::LoggingSettings::new(
ksp_logging_lib::LogFilterLevel::Info,
ksp_logging_lib::SpanEvents::Off,
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
std::vec::Vec::new(),
);
}
#[cfg(test)]
#[path = "../unit_tests/bootstrap.rs"]
mod tests;

View File

@@ -1,8 +1,28 @@
// file: crates/ksp-app-config-desk/src/constants.rs
// version: 9
// version: 10
//! Application-owned tracing targets and domains.
/// Structured domain used while bootstrapping Config and Logging.
pub(crate) const TRACING_DOMAIN_BOOTSTRAP: &str = "config.bootstrap";
/// Structured domain for Config document inventory, diagnostics and repair.
pub(crate) const TRACING_DOMAIN_DOCUMENTS: &str = "config.documents";
/// Structured domain for safe Config environment reports.
pub(crate) const TRACING_DOMAIN_ENVIRONMENT: &str = "config.environment";
/// Structured domain used by technical frontend events.
pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend";
/// Structured domain for typed Logging editor inspection.
pub(crate) const TRACING_DOMAIN_LOGGING_EDITOR: &str = "config.logging_editor";
/// Structured domain for active Logging runtime metadata and explicit profile application.
pub(crate) const TRACING_DOMAIN_LOGGING_RUNTIME: &str = "config.logging_runtime";
/// Structured domain used by controlled Logging test events.
pub(crate) const TRACING_DOMAIN_LOGGING_TEST: &str = "config.logging_test";
/// Structured domain for Config profile selection and provenance inspection.
pub(crate) const TRACING_DOMAIN_PROFILES: &str = "config.profiles";
/// Structured domain for explicit privileged Secret reveal operations.
pub(crate) const TRACING_DOMAIN_SECRETS: &str = "config.secrets";
/// Structured domain used by Tauri window lifecycle operations.
pub(crate) const TRACING_DOMAIN_WINDOWS: &str = "desktop.window";
/// Owning target for backend events emitted by Config Desk.
pub(crate) const TRACING_TARGET: &str = "ksp-app-config-desk";
/// Owning target for generic frontend events emitted through the KSP bridge.
@@ -13,23 +33,3 @@ pub(crate) const TRACING_TARGET_FRONTEND_MAIN: &str = "ksp-app-config-desk.front
pub(crate) const TRACING_TARGET_FRONTEND_SPLASH: &str = "ksp-app-config-desk.frontend.splash";
/// Dedicated target used by the controlled Logging test panel.
pub(crate) const TRACING_TARGET_LOGGING_TEST: &str = "ksp-app-config-desk.logging-test";
/// Structured domain used while bootstrapping Config and Logging.
pub(crate) const TRACING_DOMAIN_BOOTSTRAP: &str = "config.bootstrap";
/// Structured domain used by technical frontend events.
pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend";
/// Structured domain used by Tauri window lifecycle operations.
pub(crate) const TRACING_DOMAIN_WINDOWS: &str = "desktop.window";
/// Structured domain for Config document inventory, diagnostics and repair.
pub(crate) const TRACING_DOMAIN_DOCUMENTS: &str = "config.documents";
/// Structured domain for Config profile selection and provenance inspection.
pub(crate) const TRACING_DOMAIN_PROFILES: &str = "config.profiles";
/// Structured domain for safe Config environment reports.
pub(crate) const TRACING_DOMAIN_ENVIRONMENT: &str = "config.environment";
/// Structured domain for explicit privileged Secret reveal operations.
pub(crate) const TRACING_DOMAIN_SECRETS: &str = "config.secrets";
/// Structured domain for typed Logging editor inspection.
pub(crate) const TRACING_DOMAIN_LOGGING_EDITOR: &str = "config.logging_editor";
/// Structured domain for active Logging runtime metadata and explicit profile application.
pub(crate) const TRACING_DOMAIN_LOGGING_RUNTIME: &str = "config.logging_runtime";
/// Structured domain used by controlled Logging test events.
pub(crate) const TRACING_DOMAIN_LOGGING_TEST: &str = "config.logging_test";

View File

@@ -1,44 +1,42 @@
// file: crates/ksp-app-config-desk/src/errors.rs
// version: 9
// version: 10
//! Application-local error codes for the configuration desktop shell.
/// Tauri runtime assembly or execution failed.
pub(crate) const ERROR_CODE_TAURI_RUNTIME_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "tauri_runtime_failed");
/// Shared Config Desk application state is internally inconsistent.
pub(crate) const ERROR_CODE_APP_STATE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "app_state_invalid");
/// Shared Config Desk runtime state cannot be locked safely.
pub(crate) const ERROR_CODE_APP_STATE_LOCK_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "app_state_lock_failed");
/// A Documents command requested a registered file that is not a Config-kind document.
pub(crate) const ERROR_CODE_DOCUMENT_KIND_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "document_kind_invalid");
/// Frontend logging requested an unsupported level.
pub(crate) const ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "frontend_log_level_invalid");
/// Frontend logging requested a target outside the application whitelist.
pub(crate) const ERROR_CODE_FRONTEND_LOG_TARGET_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "frontend_log_target_invalid");
/// Config Desk could not install the managed Logging runtime or its safe fallback.
pub(crate) const ERROR_CODE_LOGGING_BOOTSTRAP_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "logging_bootstrap_failed");
/// Config Desk persisted a Logging candidate but could not restore the previous source after runtime application failed.
pub(crate) const ERROR_CODE_LOGGING_SOURCE_ROLLBACK_FAILED: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("config_desk", "logging_source_rollback_failed");
/// Shared Config Desk application state is internally inconsistent.
pub(crate) const ERROR_CODE_APP_STATE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "app_state_invalid");
/// Shared Config Desk runtime state cannot be locked safely.
pub(crate) const ERROR_CODE_APP_STATE_LOCK_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "app_state_lock_failed");
/// Frontend logging requested an unsupported level.
pub(crate) const ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "frontend_log_level_invalid");
/// Frontend logging requested a target outside the application whitelist.
pub(crate) const ERROR_CODE_FRONTEND_LOG_TARGET_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "frontend_log_target_invalid");
/// Controlled Logging test request contains an unsupported or invalid selector.
pub(crate) const ERROR_CODE_LOGGING_TEST_REQUEST_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "logging_test_request_invalid");
/// A validated Config document does not expose the standard profile contract expected by the Profiles panel.
pub(crate) const ERROR_CODE_PROFILE_CONTRACT_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "profile_contract_missing");
/// Config profile data could not be projected safely for the frontend.
pub(crate) const ERROR_CODE_PROFILE_PROJECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "profile_projection_failed");
/// A KSP desk splash environment duration is malformed or exceeds its safety bound.
pub(crate) const ERROR_CODE_SPLASH_SETTING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "splash_setting_invalid");
/// Splash readiness was invoked from a window other than the splash window.
pub(crate) const ERROR_CODE_SPLASH_ORIGIN_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "splash_origin_invalid");
/// A required Tauri window is missing from the configured application runtime.
pub(crate) const ERROR_CODE_TAURI_WINDOW_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "tauri_window_missing");
/// A Tauri window show/focus/destroy/event operation failed.
pub(crate) const ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("config_desk", "tauri_window_operation_failed");
/// A Documents command requested a registered file that is not a Config-kind document.
pub(crate) const ERROR_CODE_DOCUMENT_KIND_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "document_kind_invalid");
/// Privileged reveal was requested for a non-Secret KSP/KSPB namespace.
pub(crate) const ERROR_CODE_SECRET_REVEAL_REQUIRES_SECRET: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("config_desk", "secret_reveal_requires_secret");
/// Privileged reveal requested an unsupported source selector.
pub(crate) const ERROR_CODE_SECRET_REVEAL_SOURCE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "secret_reveal_source_invalid");
/// Splash readiness was invoked from a window other than the splash window.
pub(crate) const ERROR_CODE_SPLASH_ORIGIN_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "splash_origin_invalid");
/// A KSP desk splash environment duration is malformed or exceeds its safety bound.
pub(crate) const ERROR_CODE_SPLASH_SETTING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "splash_setting_invalid");
/// Tauri runtime assembly or execution failed.
pub(crate) const ERROR_CODE_TAURI_RUNTIME_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "tauri_runtime_failed");
/// A required Tauri window is missing from the configured application runtime.
pub(crate) const ERROR_CODE_TAURI_WINDOW_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "tauri_window_missing");
/// A Tauri window show/focus/destroy/event operation failed.
pub(crate) const ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("config_desk", "tauri_window_operation_failed");

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/lib.rs
// version: 14
// version: 15
//! Tauri desktop application for managing and validating KSP configuration.
@@ -73,16 +73,25 @@ pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_MISSING;
pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED;
pub(crate) use self::frontend_logging::FrontendLogPayloadDto;
pub(crate) use self::frontend_logging::emit_frontend_log_event;
pub(crate) use self::logging_editor::LoggingConsoleDto;
pub(crate) use self::logging_editor::LoggingDocumentCandidateDto;
pub(crate) use self::logging_editor::LoggingDocumentDto;
pub(crate) use self::logging_editor::LoggingDocumentSaveResultDto;
pub(crate) use self::logging_runtime::{LoggingRuntimeFileDto, LoggingRuntimeStatusDto};
pub(crate) use self::logging_test::{LoggingTestRequestDto, LoggingTestResultDto};
pub(crate) use self::logging_editor::LoggingFileDto;
pub(crate) use self::logging_editor::LoggingOutputFilterDto;
pub(crate) use self::logging_editor::LoggingProfileDto;
pub(crate) use self::logging_editor::LoggingTargetFilterDto;
pub(crate) use self::logging_runtime::LoggingRuntimeFileDto;
pub(crate) use self::logging_runtime::LoggingRuntimeStatusDto;
pub(crate) use self::logging_runtime::count_to_u64;
pub(crate) use self::logging_runtime::launch_identity;
pub(crate) use self::logging_test::LoggingTestRequestDto;
pub(crate) use self::logging_test::LoggingTestResultDto;
pub(crate) use self::profiles::ConfigProfileDetailDto;
pub(crate) use self::profiles::ConfigProfileDocumentDto;
pub(crate) use self::secrets::SecretRevealRequestDto;
pub(crate) use self::secrets::SecretRevealResponseDto;
pub(crate) use self::splash::SplashOrderDto;
pub(crate) use self::splash::SplashSettings;
pub(crate) use self::tw_main::show_and_focus as show_main_window;
pub(crate) use self::tw_splash::frontend_ready as splash_frontend_ready_service;
pub(crate) use self::tw_main::show_main_window;
pub(crate) use self::tw_splash::splash_frontend_ready_service;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/logging_editor.rs
// version: 4
// version: 5
//! Typed Logging document projection and validated persistence for the Config Desk Logging editor.
@@ -32,7 +32,7 @@ pub(crate) struct LoggingConsoleDto {
/// Console format source text.
pub(crate) format: String,
/// Sink-local selectors.
pub(crate) filter: LoggingOutputFilterDto,
pub(crate) filter: crate::LoggingOutputFilterDto,
}
/// One persistent file sink exposed to the editor.
@@ -53,7 +53,7 @@ pub(crate) struct LoggingFileDto {
/// Whether ANSI output is requested.
pub(crate) ansi: bool,
/// Sink-local selectors.
pub(crate) filter: LoggingOutputFilterDto,
pub(crate) filter: crate::LoggingOutputFilterDto,
}
/// One global Logging target override exposed to the editor.
@@ -79,11 +79,11 @@ pub(crate) struct LoggingProfileDto {
/// Span lifecycle source text.
pub(crate) span_events: String,
/// Console sink configuration.
pub(crate) console: LoggingConsoleDto,
pub(crate) console: crate::LoggingConsoleDto,
/// Persistent file sinks in source order.
pub(crate) files: std::vec::Vec<LoggingFileDto>,
pub(crate) files: std::vec::Vec<crate::LoggingFileDto>,
/// Global target overrides in source order.
pub(crate) target_filters: std::vec::Vec<LoggingTargetFilterDto>,
pub(crate) target_filters: std::vec::Vec<crate::LoggingTargetFilterDto>,
}
/// Complete typed Logging document exposed to the editor.
@@ -102,7 +102,7 @@ pub(crate) struct LoggingDocumentDto {
/// Autonomous default profile identifier.
pub(crate) default_profile: String,
/// Typed profiles in source order.
pub(crate) profiles: std::vec::Vec<LoggingProfileDto>,
pub(crate) profiles: std::vec::Vec<crate::LoggingProfileDto>,
}
/// Complete editable Logging candidate accepted from the frontend.
@@ -115,7 +115,7 @@ pub(crate) struct LoggingDocumentCandidateDto {
/// Autonomous default profile identifier.
pub(crate) default_profile: String,
/// Typed profiles in source order.
pub(crate) profiles: std::vec::Vec<LoggingProfileDto>,
pub(crate) profiles: std::vec::Vec<crate::LoggingProfileDto>,
}
/// Result of one validated typed Logging persistence operation.
@@ -255,7 +255,7 @@ fn document_from_management(management: &ksp_config_lib::ConfigManagement) -> ks
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut profiles = std::vec::Vec::<LoggingProfileDto>::with_capacity(source.profiles().len());
let mut profiles = std::vec::Vec::<crate::LoggingProfileDto>::with_capacity(source.profiles().len());
let mut file_count = 0usize;
let mut target_filter_count = 0usize;
for profile in source.profiles() {
@@ -291,7 +291,7 @@ fn config_candidate(candidate: LoggingDocumentCandidateDto) -> ksp_config_lib::L
return ksp_config_lib::LoggingConfigDocument::new(candidate.logs_directory, candidate.default_profile, profiles);
}
fn config_profile(profile: LoggingProfileDto) -> ksp_config_lib::LoggingProfileConfig {
fn config_profile(profile: crate::LoggingProfileDto) -> ksp_config_lib::LoggingProfileConfig {
let mut files = std::vec::Vec::<ksp_config_lib::LoggingFileConfig>::with_capacity(profile.files.len());
for file in profile.files {
let mut config =
@@ -319,14 +319,14 @@ fn config_profile(profile: LoggingProfileDto) -> ksp_config_lib::LoggingProfileC
);
}
fn config_output_filter(filter: LoggingOutputFilterDto) -> ksp_config_lib::LoggingOutputFilterConfig {
fn config_output_filter(filter: crate::LoggingOutputFilterDto) -> ksp_config_lib::LoggingOutputFilterConfig {
return ksp_config_lib::LoggingOutputFilterConfig::new(filter.level, filter.targets, filter.domains);
}
fn project_profile(profile: &ksp_config_lib::LoggingProfileConfig) -> LoggingProfileDto {
let mut files = std::vec::Vec::<LoggingFileDto>::with_capacity(profile.files().len());
fn project_profile(profile: &ksp_config_lib::LoggingProfileConfig) -> crate::LoggingProfileDto {
let mut files = std::vec::Vec::<crate::LoggingFileDto>::with_capacity(profile.files().len());
for file in profile.files() {
files.push(LoggingFileDto {
files.push(crate::LoggingFileDto {
output_id: file.output_id().to_owned(),
enabled: file.enabled(),
path: file.path().to_owned(),
@@ -336,15 +336,15 @@ fn project_profile(profile: &ksp_config_lib::LoggingProfileConfig) -> LoggingPro
filter: project_output_filter(file.filter()),
});
}
let mut target_filters = std::vec::Vec::<LoggingTargetFilterDto>::with_capacity(profile.target_filters().len());
let mut target_filters = std::vec::Vec::<crate::LoggingTargetFilterDto>::with_capacity(profile.target_filters().len());
for target_filter in profile.target_filters() {
target_filters.push(LoggingTargetFilterDto { target_prefix: target_filter.target_prefix().to_owned(), level: target_filter.level().to_owned() });
target_filters.push(crate::LoggingTargetFilterDto { target_prefix: target_filter.target_prefix().to_owned(), level: target_filter.level().to_owned() });
}
return LoggingProfileDto {
return crate::LoggingProfileDto {
profile_id: profile.profile_id().to_owned(),
default_filter: profile.default_filter().to_owned(),
span_events: profile.span_events().to_owned(),
console: LoggingConsoleDto {
console: crate::LoggingConsoleDto {
enabled: profile.console().enabled(),
output: profile.console().output().to_owned(),
ansi: profile.console().ansi(),
@@ -356,8 +356,8 @@ fn project_profile(profile: &ksp_config_lib::LoggingProfileConfig) -> LoggingPro
};
}
fn project_output_filter(filter: &ksp_config_lib::LoggingOutputFilterConfig) -> LoggingOutputFilterDto {
return LoggingOutputFilterDto {
fn project_output_filter(filter: &ksp_config_lib::LoggingOutputFilterConfig) -> crate::LoggingOutputFilterDto {
return crate::LoggingOutputFilterDto {
level: filter.level().to_owned(),
targets: filter.targets().to_vec(),
domains: filter.domains().to_vec(),

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/logging_runtime.rs
// version: 2
// version: 3
//! Runtime Logging metadata, launch identity and explicit profile application for Config Desk.
@@ -97,7 +97,7 @@ pub(crate) fn project_file(metadata: &ksp_logging_lib::RuntimeFileMetadata) -> L
};
}
pub(crate) const fn rotation_label(rotation: ksp_logging_lib::FileRotation) -> &'static str {
const fn rotation_label(rotation: ksp_logging_lib::FileRotation) -> &'static str {
return match rotation {
ksp_logging_lib::FileRotation::Never => "never",
ksp_logging_lib::FileRotation::Hourly => "hourly",

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/profiles.rs
// version: 1
// version: 2
//! Safe Config profile inspection and provenance projections for Config Desk.
@@ -77,6 +77,11 @@ pub(crate) struct ConfigProfileDetailDto {
pub(crate) environment_provenance: std::vec::Vec<ConfigProfileEnvironmentProvenanceDto>,
}
struct ProfileContract {
default_profile: String,
profile_ids: std::vec::Vec<String>,
}
/// Lists validated Config documents that expose standard profiles.
pub(crate) fn inventory(state: &crate::AppState) -> ksp_core_lib::Result<std::vec::Vec<ConfigProfileDocumentDto>> {
return inventory_from_management(state.config_management());
@@ -213,11 +218,6 @@ fn detail_from_management(
});
}
struct ProfileContract {
default_profile: String,
profile_ids: std::vec::Vec<String>,
}
fn profile_contract(document: &ksp_config_lib::ConfigJsonDocument) -> ksp_core_lib::Result<std::option::Option<ProfileContract>> {
let root = match document.value().as_object() {
std::option::Option::Some(value) => value,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/splash.rs
// version: 3
// version: 4
//! Common splash settings and frontend event contracts for Config Desk.
@@ -94,14 +94,6 @@ impl SplashSettings {
}
}
const fn environment_source_code(source: ksp_config_lib::ConfigEnvironmentSource) -> &'static str {
return match source {
ksp_config_lib::ConfigEnvironmentSource::Process => "process",
ksp_config_lib::ConfigEnvironmentSource::DotEnv => "dotenv",
ksp_config_lib::ConfigEnvironmentSource::Fallback => "fallback",
};
}
/// Command emitted by Rust to the splash frontend.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
@@ -122,6 +114,14 @@ impl SplashOrderDto {
}
}
const fn environment_source_code(source: ksp_config_lib::ConfigEnvironmentSource) -> &'static str {
return match source {
ksp_config_lib::ConfigEnvironmentSource::Process => "process",
ksp_config_lib::ConfigEnvironmentSource::DotEnv => "dotenv",
ksp_config_lib::ConfigEnvironmentSource::Fallback => "fallback",
};
}
fn parse_u64_setting(variable_name: &str, value: &str, maximum: u64) -> ksp_core_lib::Result<u64> {
let parsed = value.parse::<u64>();
let parsed = match parsed {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/tw_main.rs
// version: 1
// version: 2
//! Tauri-window helpers for the Config Desk main window.
@@ -18,7 +18,7 @@ pub(crate) fn require_window(manager: &impl Manager<tauri::Wry>) -> ksp_core_lib
};
}
pub(crate) fn show_and_focus(app: &tauri::AppHandle) -> ksp_core_lib::Result<()> {
pub(crate) fn show_main_window(app: &tauri::AppHandle) -> ksp_core_lib::Result<()> {
let window = require_window(app);
let window = match window {
std::result::Result::Ok(value) => value,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/tw_splash.rs
// version: 2
// version: 3
//! Tauri-window lifecycle for the Config Desk splash window.
@@ -20,7 +20,11 @@ pub(crate) fn require_window(manager: &impl Manager<tauri::Wry>) -> ksp_core_lib
};
}
pub(crate) async fn frontend_ready(app: tauri::AppHandle, invoking_window: tauri::WebviewWindow, state: &crate::AppState) -> ksp_core_lib::Result<()> {
pub(crate) async fn splash_frontend_ready_service(
app: tauri::AppHandle,
invoking_window: tauri::WebviewWindow,
state: &crate::AppState,
) -> ksp_core_lib::Result<()> {
if invoking_window.label() != WINDOW_LABEL_SPLASH {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_ORIGIN_INVALID, "Splash readiness may only originate from the splash window")

View File

@@ -1,31 +1,29 @@
// file: crates/ksp-app-config-desk/unit_tests/documents.rs
// version: 1
use super::*;
// version: 2
#[test]
fn diagnostic_classifier_distinguishes_json_schema_semantic_and_effective_errors() {
let json = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_JSON_SYNTAX_INVALID, "json");
assert_eq!(classify_error(&json), STAGE_JSON);
assert_eq!(super::classify_error(&json), super::STAGE_JSON);
let schema = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_SCHEMA_VALIDATION_FAILED, "schema");
assert_eq!(classify_error(&schema), STAGE_SCHEMA);
assert_eq!(super::classify_error(&schema), super::STAGE_SCHEMA);
let semantic = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "semantic");
assert_eq!(classify_error(&semantic), STAGE_SEMANTIC);
assert_eq!(super::classify_error(&semantic), super::STAGE_SEMANTIC);
let effective = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective");
assert_eq!(classify_error(&effective), STAGE_EFFECTIVE);
assert_eq!(super::classify_error(&effective), super::STAGE_EFFECTIVE);
}
#[test]
fn schema_source_read_errors_are_classified_as_schema_failures() {
let error = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_JSON_FILE_READ_FAILED, "missing schema").with_context("file_id", "schema.std.logging");
assert_eq!(classify_error(&error), STAGE_SCHEMA);
assert_eq!(super::classify_error(&error), super::STAGE_SCHEMA);
}
#[test]
fn document_error_projection_does_not_expose_arbitrary_error_context() {
let error = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_JSON_SYNTAX_INVALID, "invalid source").with_context("detail", "sensitive-canary");
let projection = ConfigDocumentErrorDto::from_error(&error);
assert_eq!(projection.diagnostic_stage, STAGE_JSON);
let projection = crate::ConfigDocumentErrorDto::from_error(&error);
assert_eq!(projection.diagnostic_stage, super::STAGE_JSON);
assert_eq!(projection.error.domain, "config");
assert_eq!(projection.error.code, "json_syntax_invalid");
assert_eq!(projection.error.message, "invalid source");

View File

@@ -1,10 +1,10 @@
// file: crates/ksp-app-config-desk/unit_tests/dto_common.rs
// version: 1
// version: 2
#[test]
fn command_error_projection_excludes_context_values() {
let error = ksp_core_lib::Error::new(ksp_core_lib::ErrorCode::new("test", "failure"), "safe message").with_context("secret_canary", "must-not-be-exported");
let dto = super::CommandErrorDto::from_error(&error);
let dto = crate::CommandErrorDto::from_error(&error);
assert_eq!(dto.domain, "test");
assert_eq!(dto.code, "failure");
assert_eq!(dto.message, "safe message");

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/unit_tests/logging_editor.rs
// version: 4
// version: 5
#[test]
fn committed_logging_document_maps_complete_read_only_editor_contract() {
@@ -66,7 +66,7 @@ fn editor_candidate_round_trips_through_typed_config_contract() {
let document = super::document_from_management(&management);
assert!(document.is_ok(), "Logging editor document should load: {document:?}");
if let std::result::Result::Ok(document) = document {
let candidate = super::LoggingDocumentCandidateDto {
let candidate = crate::LoggingDocumentCandidateDto {
logs_directory: document.logs_directory.clone(),
default_profile: document.default_profile.clone(),
profiles: document.profiles.clone(),
@@ -89,42 +89,42 @@ fn editor_candidate_round_trips_through_typed_config_contract() {
#[test]
fn profile_candidate_preserves_multi_file_and_target_filter_shape() {
let profile = super::LoggingProfileDto {
let profile = crate::LoggingProfileDto {
profile_id: "clone_test".to_owned(),
default_filter: "info".to_owned(),
span_events: "full".to_owned(),
console: super::LoggingConsoleDto {
console: crate::LoggingConsoleDto {
enabled: true,
output: "stdout".to_owned(),
ansi: false,
format: "human".to_owned(),
filter: super::LoggingOutputFilterDto { level: "info".to_owned(), targets: std::vec!["*".to_owned()], domains: std::vec!["*".to_owned()] },
filter: crate::LoggingOutputFilterDto { level: "info".to_owned(), targets: std::vec!["*".to_owned()], domains: std::vec!["*".to_owned()] },
},
files: std::vec![
super::LoggingFileDto {
crate::LoggingFileDto {
output_id: "file.one".to_owned(),
enabled: true,
path: "one.log".to_owned(),
rotation: "daily".to_owned(),
format: "human".to_owned(),
ansi: false,
filter: super::LoggingOutputFilterDto { level: "debug".to_owned(), targets: std::vec!["*".to_owned()], domains: std::vec!["*".to_owned()] },
filter: crate::LoggingOutputFilterDto { level: "debug".to_owned(), targets: std::vec!["*".to_owned()], domains: std::vec!["*".to_owned()] },
},
super::LoggingFileDto {
crate::LoggingFileDto {
output_id: "file.two".to_owned(),
enabled: false,
path: "two.jsonl".to_owned(),
rotation: "hourly".to_owned(),
format: "json".to_owned(),
ansi: false,
filter: super::LoggingOutputFilterDto {
filter: crate::LoggingOutputFilterDto {
level: "error".to_owned(),
targets: std::vec!["ksp-config-lib".to_owned()],
domains: std::vec!["config".to_owned()],
},
},
],
target_filters: std::vec![super::LoggingTargetFilterDto { target_prefix: "ksp-app-config-desk".to_owned(), level: "trace".to_owned() }],
target_filters: std::vec![crate::LoggingTargetFilterDto { target_prefix: "ksp-app-config-desk".to_owned(), level: "trace".to_owned() }],
};
let config = super::config_profile(profile);
assert_eq!(config.profile_id(), "clone_test");

View File

@@ -1,9 +1,9 @@
// file: crates/ksp-app-config-desk/unit_tests/logging_runtime.rs
// version: 1
// version: 2
#[test]
fn launch_identity_is_safe_and_contains_application_and_process_identity() {
let identity = super::launch_identity();
let identity = crate::launch_identity();
assert!(identity.is_ok());
if let std::result::Result::Ok(identity) = identity {
assert_eq!(identity.application_id(), crate::TRACING_TARGET);
@@ -21,6 +21,6 @@ fn runtime_rotation_labels_are_stable() {
#[test]
fn runtime_count_projection_is_non_lossy_for_normal_values() {
assert_eq!(super::count_to_u64(0), 0);
assert_eq!(super::count_to_u64(42), 42);
assert_eq!(crate::count_to_u64(0), 0);
assert_eq!(crate::count_to_u64(42), 42);
}

View File

@@ -1,25 +1,23 @@
// file: crates/ksp-app-config-desk/unit_tests/logging_test.rs
// version: 1
use super::{LoggingTestLevel, LoggingTestTarget};
// version: 2
#[test]
fn controlled_logging_test_levels_include_all_five_levels_and_batch_mode() {
assert!(matches!(super::parse_level("trace"), std::result::Result::Ok(LoggingTestLevel::Trace)));
assert!(matches!(super::parse_level("debug"), std::result::Result::Ok(LoggingTestLevel::Debug)));
assert!(matches!(super::parse_level("info"), std::result::Result::Ok(LoggingTestLevel::Info)));
assert!(matches!(super::parse_level("warn"), std::result::Result::Ok(LoggingTestLevel::Warn)));
assert!(matches!(super::parse_level("error"), std::result::Result::Ok(LoggingTestLevel::Error)));
assert!(matches!(super::parse_level("all"), std::result::Result::Ok(LoggingTestLevel::All)));
assert!(matches!(super::parse_level("trace"), std::result::Result::Ok(super::LoggingTestLevel::Trace)));
assert!(matches!(super::parse_level("debug"), std::result::Result::Ok(super::LoggingTestLevel::Debug)));
assert!(matches!(super::parse_level("info"), std::result::Result::Ok(super::LoggingTestLevel::Info)));
assert!(matches!(super::parse_level("warn"), std::result::Result::Ok(super::LoggingTestLevel::Warn)));
assert!(matches!(super::parse_level("error"), std::result::Result::Ok(super::LoggingTestLevel::Error)));
assert!(matches!(super::parse_level("all"), std::result::Result::Ok(super::LoggingTestLevel::All)));
assert!(super::parse_level("fatal").is_err());
}
#[test]
fn controlled_logging_test_targets_are_static_ksp_targets() {
assert!(matches!(super::parse_target("app"), std::result::Result::Ok(LoggingTestTarget::App)));
assert!(matches!(super::parse_target("logging_test"), std::result::Result::Ok(LoggingTestTarget::Dedicated)));
assert_eq!(super::target_value(LoggingTestTarget::App), crate::TRACING_TARGET);
assert_eq!(super::target_value(LoggingTestTarget::Dedicated), crate::TRACING_TARGET_LOGGING_TEST);
assert!(matches!(super::parse_target("app"), std::result::Result::Ok(super::LoggingTestTarget::App)));
assert!(matches!(super::parse_target("logging_test"), std::result::Result::Ok(super::LoggingTestTarget::Dedicated)));
assert_eq!(super::target_value(super::LoggingTestTarget::App), crate::TRACING_TARGET);
assert_eq!(super::target_value(super::LoggingTestTarget::Dedicated), crate::TRACING_TARGET_LOGGING_TEST);
assert!(super::parse_target("external").is_err());
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/unit_tests/secrets.rs
// version: 1
// version: 2
#[test]
fn reveal_policy_accepts_only_secret_names_and_supported_sources() {
@@ -21,7 +21,7 @@ fn reveal_policy_accepts_only_secret_names_and_supported_sources() {
#[test]
fn reveal_response_type_does_not_require_debug_or_clone_contracts() {
let response = super::SecretRevealResponseDto {
let response = crate::SecretRevealResponseDto {
variable_name: "KSP_SECRET_TEST_VALUE".to_owned(),
source: "dotenv".to_owned(),
value: std::option::Option::Some("secret-canary".to_owned()),

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/environment.rs
// version: 5
// version: 6
/// Default local environment file read by Config from the process launch directory.
pub const DEFAULT_DOTENV_PATH: &str = ".env";
@@ -258,43 +258,6 @@ impl ConfigEnvironment {
}
}
fn collect_process_environment<I>(values: I) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>>
where
I: std::iter::IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
{
let mut output = std::collections::BTreeMap::<String, String>::new();
for (name, value) in values {
let name = match name.to_str() {
std::option::Option::Some(value) => value,
std::option::Option::None => continue,
};
if !has_supported_namespace(name) {
continue;
}
let validation = validate_supported_variable_name(name);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
let value = value.into_string();
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(invalid_environment_value_error(name)),
};
output.insert(name.to_owned(), value);
}
return std::result::Result::Ok(output);
}
fn load_dotenv_file(path: &std::path::Path) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
let content = std::fs::read_to_string(path);
let content = match content {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => return std::result::Result::Ok(std::collections::BTreeMap::new()),
std::result::Result::Err(error) => return std::result::Result::Err(dotenv_read_error(path, error)),
};
return parse_dotenv_content(path, content.as_str());
}
pub(crate) fn parse_dotenv_content(path: &std::path::Path, content: &str) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
let mut output = std::collections::BTreeMap::<String, String>::new();
for (line_index, raw_line) in content.lines().enumerate() {
@@ -342,6 +305,60 @@ pub(crate) fn parse_dotenv_content(path: &std::path::Path, content: &str) -> ksp
return std::result::Result::Ok(output);
}
pub(crate) fn validate_supported_variable_name(variable_name: &str) -> ksp_core_lib::Result<()> {
if !has_supported_namespace(variable_name) {
return std::result::Result::Err(invalid_variable_error(variable_name, "variable must use the KSP_ or KSPB_ namespace"));
}
let prefix_length = if variable_name.starts_with("KSPB_") { 5 } else { 4 };
if variable_name.len() <= prefix_length {
return std::result::Result::Err(invalid_variable_error(variable_name, "variable namespace must be followed by a name"));
}
for byte in variable_name.bytes() {
let valid = byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_';
if !valid {
return std::result::Result::Err(invalid_variable_error(variable_name, "variable names use uppercase ASCII letters, digits and underscores"));
}
}
return std::result::Result::Ok(());
}
fn collect_process_environment<I>(values: I) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>>
where
I: std::iter::IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
{
let mut output = std::collections::BTreeMap::<String, String>::new();
for (name, value) in values {
let name = match name.to_str() {
std::option::Option::Some(value) => value,
std::option::Option::None => continue,
};
if !has_supported_namespace(name) {
continue;
}
let validation = validate_supported_variable_name(name);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
let value = value.into_string();
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(invalid_environment_value_error(name)),
};
output.insert(name.to_owned(), value);
}
return std::result::Result::Ok(output);
}
fn load_dotenv_file(path: &std::path::Path) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
let content = std::fs::read_to_string(path);
let content = match content {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => return std::result::Result::Ok(std::collections::BTreeMap::new()),
std::result::Result::Err(error) => return std::result::Result::Err(dotenv_read_error(path, error)),
};
return crate::parse_dotenv_content(path, content.as_str());
}
fn parse_dotenv_value(path: &std::path::Path, line_number: usize, raw_value: &str) -> ksp_core_lib::Result<String> {
if raw_value.starts_with('\'') && (raw_value.len() < 2 || !raw_value.ends_with('\'')) {
return std::result::Result::Err(dotenv_syntax_error(path, line_number, "single-quoted value is not terminated"));
@@ -516,23 +533,6 @@ fn escape_json_pointer_token(value: &str) -> String {
return value.replace('~', "~0").replace('/', "~1");
}
pub(crate) fn validate_supported_variable_name(variable_name: &str) -> ksp_core_lib::Result<()> {
if !has_supported_namespace(variable_name) {
return std::result::Result::Err(invalid_variable_error(variable_name, "variable must use the KSP_ or KSPB_ namespace"));
}
let prefix_length = if variable_name.starts_with("KSPB_") { 5 } else { 4 };
if variable_name.len() <= prefix_length {
return std::result::Result::Err(invalid_variable_error(variable_name, "variable namespace must be followed by a name"));
}
for byte in variable_name.bytes() {
let valid = byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_';
if !valid {
return std::result::Result::Err(invalid_variable_error(variable_name, "variable names use uppercase ASCII letters, digits and underscores"));
}
}
return std::result::Result::Ok(());
}
fn has_supported_namespace(variable_name: &str) -> bool {
return variable_name.starts_with("KSP_") || variable_name.starts_with("KSPB_");
}

View File

@@ -1,68 +1,47 @@
// file: crates/ksp-config-lib/src/error.rs
// version: 8
// version: 9
/// Error code used when a Config bootstrap argument is missing its value.
pub const ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "bootstrap_argument_missing_value");
/// Error code used when a Config bootstrap path is empty, inaccessible, or resolves to an existing non-directory path.
pub const ERROR_CODE_BOOTSTRAP_INVALID_PATH: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "bootstrap_invalid_path");
/// Error code used when a logical Config file identifier is malformed.
pub const ERROR_CODE_FILE_ID_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_invalid");
/// Error code used when a requested logical Config file identifier is not registered.
pub const ERROR_CODE_FILE_ID_UNKNOWN: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_unknown");
/// Error code used when the same logical Config file identifier is registered more than once.
pub const ERROR_CODE_FILE_ID_DUPLICATE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_duplicate");
/// Error code used when a Config filename mapping or descriptor relation is invalid.
pub const ERROR_CODE_FILE_MAPPING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_mapping_invalid");
/// Error code used when a Config-managed JSON document or schema cannot be read from its resolved path.
pub const ERROR_CODE_JSON_FILE_READ_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_file_read_failed");
/// Error code used when a Config-managed file contains invalid JSON syntax.
pub const ERROR_CODE_JSON_SYNTAX_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_syntax_invalid");
/// Error code used when a JSON Schema document is itself invalid for the selected JSON Schema draft.
pub const ERROR_CODE_SCHEMA_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_invalid");
/// Error code used when a Config document does not satisfy its registered JSON Schema.
pub const ERROR_CODE_SCHEMA_VALIDATION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_validation_failed");
/// Error code used when a schema-valid Config document violates KSP semantic invariants for its document type.
pub const ERROR_CODE_DOCUMENT_SEMANTIC_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "document_semantic_invalid");
/// Error code used when an explicitly requested Config profile does not exist in a validated document.
pub const ERROR_CODE_PROFILE_NOT_FOUND: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "profile_not_found");
/// Error code used when a composite document references an invalid, unknown, or unsupported Config document.
pub const ERROR_CODE_COMPOSITE_REFERENCE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "composite_reference_invalid");
/// Error code used when a schema-valid Config document violates KSP semantic invariants for its document type.
pub const ERROR_CODE_DOCUMENT_SEMANTIC_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "document_semantic_invalid");
/// Error code used when the local `.env` file cannot be read for a reason other than absence.
pub const ERROR_CODE_DOTENV_FILE_READ_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "dotenv_file_read_failed");
/// Error code used when the local `.env` file contains syntax Config cannot interpret safely.
pub const ERROR_CODE_DOTENV_SYNTAX_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "dotenv_syntax_invalid");
/// Error code used when a Config environment variable name is malformed or outside the KSP/KSPB namespaces.
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_invalid");
/// Error code used when a referenced Config environment variable is absent and has no fallback.
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_missing");
/// Error code used when a supported process environment variable has a value that cannot become a JSON UTF-8 string.
pub const ERROR_CODE_ENVIRONMENT_VALUE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_value_invalid");
/// Error code used when a `${NAME}` / `${NAME:-fallback}` expression is malformed.
pub const ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_placeholder_invalid");
/// Error code used when an environment-resolved Config cannot be mapped safely to a runtime consumer contract.
pub const ERROR_CODE_EFFECTIVE_CONFIG_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "effective_config_invalid");
/// Error code used when a `${NAME}` / `${NAME:-fallback}` expression is malformed.
pub const ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_placeholder_invalid");
/// Error code used when a supported process environment variable has a value that cannot become a JSON UTF-8 string.
pub const ERROR_CODE_ENVIRONMENT_VALUE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_value_invalid");
/// Error code used when a Config environment variable name is malformed or outside the KSP/KSPB namespaces.
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_invalid");
/// Error code used when a referenced Config environment variable is absent and has no fallback.
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_missing");
/// Error code used when the same logical Config file identifier is registered more than once.
pub const ERROR_CODE_FILE_ID_DUPLICATE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_duplicate");
/// Error code used when a logical Config file identifier is malformed.
pub const ERROR_CODE_FILE_ID_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_invalid");
/// Error code used when a requested logical Config file identifier is not registered.
pub const ERROR_CODE_FILE_ID_UNKNOWN: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_unknown");
/// Error code used when a Config filename mapping or descriptor relation is invalid.
pub const ERROR_CODE_FILE_MAPPING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_mapping_invalid");
/// Error code used when a Config-managed JSON document or schema cannot be read from its resolved path.
pub const ERROR_CODE_JSON_FILE_READ_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_file_read_failed");
/// Error code used when a Config-managed file contains invalid JSON syntax.
pub const ERROR_CODE_JSON_SYNTAX_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_syntax_invalid");
/// Error code used when an explicit Config management operation is unsupported or targets the wrong managed resource kind.
pub const ERROR_CODE_MANAGEMENT_OPERATION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "management_operation_invalid");
/// Error code used when an atomic managed Config or `.env` persistence operation fails before commit.
pub const ERROR_CODE_PERSISTENCE_WRITE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "persistence_write_failed");
/// Error code used when an explicitly requested Config profile does not exist in a validated document.
pub const ERROR_CODE_PROFILE_NOT_FOUND: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "profile_not_found");
/// Error code used when a JSON Schema document is itself invalid for the selected JSON Schema draft.
pub const ERROR_CODE_SCHEMA_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_invalid");
/// Error code used when a Config document does not satisfy its registered JSON Schema.
pub const ERROR_CODE_SCHEMA_VALIDATION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_validation_failed");

View File

@@ -1,5 +1,6 @@
// file: crates/ksp-config-lib/src/lib.rs
// version: 12
// version: 13
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
@@ -25,8 +26,6 @@ mod registry;
mod sensitivity;
mod transport;
pub(crate) use self::constants::TRACING_TARGET;
/// Bootstrap argument used to replace the configuration document root.
pub use self::bootstrap::ARG_CFG_PATH;
/// Bootstrap argument used to replace the schema root.
@@ -171,3 +170,7 @@ pub use self::sensitivity::ResolvedConfigJson;
pub use self::sensitivity::ResolvedConfigText;
/// Effective standard HTTP Transport configuration mapped to `ksp_onchain_transport_lib::HttpTransportSettings`.
pub use self::transport::ResolvedTransportConfig;
pub(crate) use self::constants::TRACING_TARGET;
pub(crate) use self::environment::parse_dotenv_content;
pub(crate) use self::registry::build_registry;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/management.rs
// version: 4
// version: 5
/// Raw source of one registered Config document read for explicit management/correction.
#[derive(Clone, Eq, PartialEq)]
@@ -921,7 +921,7 @@ fn read_dotenv_source(path: &std::path::Path) -> ksp_core_lib::Result<String> {
let content = std::fs::read_to_string(path);
return match content {
std::result::Result::Ok(value) => {
let validation = crate::environment::parse_dotenv_content(path, value.as_str());
let validation = crate::parse_dotenv_content(path, value.as_str());
match validation {
std::result::Result::Ok(_) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(error),
@@ -937,7 +937,7 @@ fn read_dotenv_source(path: &std::path::Path) -> ksp_core_lib::Result<String> {
}
fn update_dotenv_source(path: &std::path::Path, source: &str, variable_name: &str, replacement: std::option::Option<&str>) -> ksp_core_lib::Result<String> {
let validation = crate::environment::parse_dotenv_content(path, source);
let validation = crate::parse_dotenv_content(path, source);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
@@ -967,7 +967,7 @@ fn update_dotenv_source(path: &std::path::Path, source: &str, variable_name: &st
if !candidate.is_empty() {
candidate.push('\n');
}
let candidate_validation = crate::environment::parse_dotenv_content(path, candidate.as_str());
let candidate_validation = crate::parse_dotenv_content(path, candidate.as_str());
return match candidate_validation {
std::result::Result::Ok(_) => std::result::Result::Ok(candidate),
std::result::Result::Err(error) => std::result::Result::Err(error),

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/registry.rs
// version: 5
// version: 6
/// Bootstrap argument used to replace a known Config filename mapping.
pub const ARG_FILE_MAP: &str = "--filemap";
@@ -165,7 +165,7 @@ impl ConfigFileRegistry {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return build_registry([logging, logging_schema, transport, transport_schema, composite_schema]);
return crate::build_registry([logging, logging_schema, transport, transport_schema, composite_schema]);
}
/// Creates the default registry and applies repeatable `--filemap=<file_id>=<filename>` overrides from raw process arguments.

View File

@@ -1,39 +1,39 @@
// file: crates/ksp-config-lib/unit_tests/bootstrap.rs
// version: 1
// version: 2
#[test]
fn defaults_use_hardcoded_ksp_roots() {
let result = super::ConfigBootstrapOptions::defaults();
let result = crate::ConfigBootstrapOptions::defaults();
assert!(result.is_ok(), "default bootstrap paths should be valid: {result:?}");
if let std::result::Result::Ok(options) = result {
assert_eq!(options.cfg_path(), std::path::Path::new(super::DEFAULT_CFG_PATH));
assert_eq!(options.schema_path(), std::path::Path::new(super::DEFAULT_SCHEMA_PATH));
assert_eq!(options.cfg_path(), std::path::Path::new(crate::DEFAULT_CFG_PATH));
assert_eq!(options.schema_path(), std::path::Path::new(crate::DEFAULT_SCHEMA_PATH));
}
}
#[test]
fn cfg_path_override_keeps_schema_default() {
let defaults = super::ConfigBootstrapOptions::defaults();
let defaults = crate::ConfigBootstrapOptions::defaults();
assert!(defaults.is_ok(), "default bootstrap paths should be valid: {defaults:?}");
if let std::result::Result::Ok(options) = defaults {
let result = options.with_cfg_path("custom-config");
assert!(result.is_ok(), "cfg path override should be valid: {result:?}");
if let std::result::Result::Ok(options) = result {
assert_eq!(options.cfg_path(), std::path::Path::new("custom-config"));
assert_eq!(options.schema_path(), std::path::Path::new(super::DEFAULT_SCHEMA_PATH));
assert_eq!(options.schema_path(), std::path::Path::new(crate::DEFAULT_SCHEMA_PATH));
}
}
}
#[test]
fn schema_path_override_keeps_cfg_default() {
let defaults = super::ConfigBootstrapOptions::defaults();
let defaults = crate::ConfigBootstrapOptions::defaults();
assert!(defaults.is_ok(), "default bootstrap paths should be valid: {defaults:?}");
if let std::result::Result::Ok(options) = defaults {
let result = options.with_schema_path("custom-schemas");
assert!(result.is_ok(), "schema path override should be valid: {result:?}");
if let std::result::Result::Ok(options) = result {
assert_eq!(options.cfg_path(), std::path::Path::new(super::DEFAULT_CFG_PATH));
assert_eq!(options.cfg_path(), std::path::Path::new(crate::DEFAULT_CFG_PATH));
assert_eq!(options.schema_path(), std::path::Path::new("custom-schemas"));
}
}
@@ -42,21 +42,21 @@ fn schema_path_override_keeps_cfg_default() {
#[test]
fn cfg_cli_override_keeps_schema_default() {
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--cfgpath=cli-config")];
let result = super::ConfigBootstrapOptions::from_args(&args);
let result = crate::ConfigBootstrapOptions::from_args(&args);
assert!(result.is_ok(), "cfg CLI override should parse: {result:?}");
if let std::result::Result::Ok(options) = result {
assert_eq!(options.cfg_path(), std::path::Path::new("cli-config"));
assert_eq!(options.schema_path(), std::path::Path::new(super::DEFAULT_SCHEMA_PATH));
assert_eq!(options.schema_path(), std::path::Path::new(crate::DEFAULT_SCHEMA_PATH));
}
}
#[test]
fn schema_cli_override_keeps_cfg_default() {
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--schemapath=cli-schemas")];
let result = super::ConfigBootstrapOptions::from_args(&args);
let result = crate::ConfigBootstrapOptions::from_args(&args);
assert!(result.is_ok(), "schema CLI override should parse: {result:?}");
if let std::result::Result::Ok(options) = result {
assert_eq!(options.cfg_path(), std::path::Path::new(super::DEFAULT_CFG_PATH));
assert_eq!(options.cfg_path(), std::path::Path::new(crate::DEFAULT_CFG_PATH));
assert_eq!(options.schema_path(), std::path::Path::new("cli-schemas"));
}
}
@@ -73,7 +73,7 @@ fn parser_accepts_inline_and_separate_forms_and_last_value_wins() {
std::ffi::OsString::from("--schemapath"),
std::ffi::OsString::from("second-schemas"),
];
let result = super::ConfigBootstrapOptions::from_args(&args);
let result = crate::ConfigBootstrapOptions::from_args(&args);
assert!(result.is_ok(), "bootstrap arguments should parse: {result:?}");
if let std::result::Result::Ok(options) = result {
assert_eq!(options.cfg_path(), std::path::Path::new("second-config"));
@@ -84,7 +84,7 @@ fn parser_accepts_inline_and_separate_forms_and_last_value_wins() {
#[test]
fn parser_reports_missing_separate_value() {
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--cfgpath")];
let result = super::ConfigBootstrapOptions::from_args(&args);
let result = crate::ConfigBootstrapOptions::from_args(&args);
assert!(result.is_err(), "missing value must be rejected");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE);
@@ -94,7 +94,7 @@ fn parser_reports_missing_separate_value() {
#[test]
fn parser_reports_another_option_as_missing_separate_value() {
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--cfgpath"), std::ffi::OsString::from("--other-option")];
let result = super::ConfigBootstrapOptions::from_args(&args);
let result = crate::ConfigBootstrapOptions::from_args(&args);
assert!(result.is_err(), "another option must not become a bootstrap path value");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE);
@@ -104,7 +104,7 @@ fn parser_reports_another_option_as_missing_separate_value() {
#[test]
fn empty_inline_path_is_rejected() {
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--schemapath=")];
let result = super::ConfigBootstrapOptions::from_args(&args);
let result = crate::ConfigBootstrapOptions::from_args(&args);
assert!(result.is_err(), "empty path must be rejected");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_BOOTSTRAP_INVALID_PATH);
@@ -113,7 +113,7 @@ fn empty_inline_path_is_rejected() {
#[test]
fn explicit_programmatic_paths_do_not_depend_on_default_roots() {
let result = super::ConfigBootstrapOptions::from_paths("programmatic-config", "programmatic-schemas");
let result = crate::ConfigBootstrapOptions::from_paths("programmatic-config", "programmatic-schemas");
assert!(result.is_ok(), "explicit programmatic paths should be valid: {result:?}");
if let std::result::Result::Ok(options) = result {
assert_eq!(options.cfg_path(), std::path::Path::new("programmatic-config"));
@@ -126,7 +126,7 @@ fn existing_non_directory_path_is_rejected() {
let fixture = unique_fixture_path("existing-file");
let create = std::fs::write(fixture.as_path(), b"fixture");
assert!(create.is_ok(), "fixture file should be creatable: {create:?}");
let result = super::ConfigBootstrapOptions::from_paths(fixture.as_path(), "programmatic-schemas");
let result = crate::ConfigBootstrapOptions::from_paths(fixture.as_path(), "programmatic-schemas");
let remove = std::fs::remove_file(fixture.as_path());
assert!(remove.is_ok(), "fixture file should be removable: {remove:?}");
assert!(result.is_err(), "existing file must not be accepted as a bootstrap directory");

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/document.rs
// version: 3
// version: 4
#[test]
fn committed_logging_document_passes_registered_schema_and_semantic_validation() {
@@ -11,7 +11,7 @@ fn committed_logging_document_passes_registered_schema_and_semantic_validation()
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}");
if let (std::result::Result::Ok(bootstrap), std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (bootstrap, registry, file_id) {
let engine = super::ConfigDocumentEngine::new(bootstrap, registry);
let engine = crate::ConfigDocumentEngine::new(bootstrap, registry);
let document = engine.load_validated_document(&file_id);
assert!(document.is_ok(), "committed std.logging.json should validate: {document:?}");
if let std::result::Result::Ok(document) = document {
@@ -228,7 +228,7 @@ fn valid_logging_profile(profile_id: &str, output_id: &str) -> String {
);
}
fn load_fixture(fixture: &FixtureRoots) -> ksp_core_lib::Result<super::ConfigJsonDocument> {
fn load_fixture(fixture: &FixtureRoots) -> ksp_core_lib::Result<crate::ConfigJsonDocument> {
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture.config.as_path(), fixture.schemas.as_path());
let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
@@ -244,7 +244,7 @@ fn load_fixture(fixture: &FixtureRoots) -> ksp_core_lib::Result<super::ConfigJso
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let engine = super::ConfigDocumentEngine::new(bootstrap, registry);
let engine = crate::ConfigDocumentEngine::new(bootstrap, registry);
return engine.load_validated_document(&file_id);
}
@@ -278,7 +278,7 @@ fn valid_minimal_logging_schema() -> &'static str {
}"#;
}
fn assert_error_code(result: ksp_core_lib::Result<super::ConfigJsonDocument>, expected: ksp_core_lib::ErrorCode) {
fn assert_error_code(result: ksp_core_lib::Result<crate::ConfigJsonDocument>, expected: ksp_core_lib::ErrorCode) {
assert!(result.is_err(), "fixture should fail with {expected:?}: {result:?}");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), expected);

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/environment.rs
// version: 5
// version: 6
#[test]
fn process_environment_wins_over_dotenv_and_fallback_even_when_empty() {
@@ -7,7 +7,7 @@ fn process_environment_wins_over_dotenv_and_fallback_even_when_empty() {
process.insert("KSP_LOGS_DIRECTORY".to_owned(), String::new());
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), "dotenv-logs".to_owned());
let environment = super::ConfigEnvironment::from_maps(process, dotenv);
let environment = crate::ConfigEnvironment::from_maps(process, dotenv);
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
assert!(resolved.is_ok(), "process value should resolve");
let resolved = match resolved {
@@ -15,7 +15,7 @@ fn process_environment_wins_over_dotenv_and_fallback_even_when_empty() {
std::result::Result::Err(_) => return,
};
assert_eq!(resolved.value(), "");
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::Process);
assert_eq!(resolved.source(), crate::ConfigEnvironmentSource::Process);
}
#[test]
@@ -23,7 +23,7 @@ fn dotenv_wins_over_fallback_when_process_value_is_absent() {
let process = std::collections::BTreeMap::<String, String>::new();
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), "dotenv-logs".to_owned());
let environment = super::ConfigEnvironment::from_maps(process, dotenv);
let environment = crate::ConfigEnvironment::from_maps(process, dotenv);
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
assert!(resolved.is_ok(), "dotenv value should resolve");
let resolved = match resolved {
@@ -31,25 +31,25 @@ fn dotenv_wins_over_fallback_when_process_value_is_absent() {
std::result::Result::Err(_) => return,
};
assert_eq!(resolved.value(), "dotenv-logs");
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::DotEnv);
assert_eq!(resolved.source(), crate::ConfigEnvironmentSource::DotEnv);
}
#[test]
fn empty_dotenv_value_is_defined_and_beats_fallback() {
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), String::new());
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
assert!(resolved.is_ok(), "empty dotenv value should resolve");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.value(), "");
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::DotEnv);
assert_eq!(resolved.source(), crate::ConfigEnvironmentSource::DotEnv);
}
}
#[test]
fn fallback_is_used_only_when_external_sources_are_absent() {
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
assert!(resolved.is_ok(), "fallback should resolve");
let resolved = match resolved {
@@ -57,12 +57,12 @@ fn fallback_is_used_only_when_external_sources_are_absent() {
std::result::Result::Err(_) => return,
};
assert_eq!(resolved.value(), "fallback-logs");
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::Fallback);
assert_eq!(resolved.source(), crate::ConfigEnvironmentSource::Fallback);
}
#[test]
fn missing_variable_without_fallback_is_a_distinct_error() {
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::None);
let error = match resolved {
std::result::Result::Ok(_) => return,
@@ -78,7 +78,7 @@ fn ksp_and_kspb_namespaces_are_supported_but_external_names_are_rejected() {
process.insert("KSP_LOGS_DIRECTORY".to_owned(), "logs".to_owned());
let bot_variable = ["KSPB_", "TEST_KEY"].concat();
process.insert(bot_variable.clone(), "hidden".to_owned());
let environment = super::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
assert!(environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::None).is_ok());
assert!(environment.resolve_variable(bot_variable.as_str(), std::option::Option::None).is_ok());
let external = environment.resolve_variable("OTHER_NETWORK", std::option::Option::None);
@@ -91,7 +91,7 @@ fn ksp_and_kspb_namespaces_are_supported_but_external_names_are_rejected() {
#[test]
fn text_resolver_supports_multiple_placeholders_and_literal_fallbacks() {
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let resolved = environment.resolve_text("logs=${KSP_LOGS_DIRECTORY:-logs};second=${KSP_LOGS_DIRECTORY:-other}");
assert!(resolved.is_ok(), "multiple placeholders should resolve");
if let std::result::Result::Ok(resolved) = resolved {
@@ -101,7 +101,7 @@ fn text_resolver_supports_multiple_placeholders_and_literal_fallbacks() {
#[test]
fn malformed_or_nested_placeholders_are_rejected() {
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let unclosed = environment.resolve_text("${KSP_LOGS_DIRECTORY");
let unclosed = match unclosed {
std::result::Result::Ok(_) => return,
@@ -120,7 +120,7 @@ fn malformed_or_nested_placeholders_are_rejected() {
fn json_resolver_walks_objects_and_arrays_without_changing_keys() {
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), "runtime-logs".to_owned());
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
let source = serde_json::json!({"path": "${KSP_LOGS_DIRECTORY}", "items": [1, "${KSP_LOGS_DIRECTORY}"], "enabled": true});
let resolved = environment.resolve_json(&source);
assert!(resolved.is_ok(), "recursive JSON resolution should succeed");
@@ -137,7 +137,7 @@ fn json_resolver_walks_objects_and_arrays_without_changing_keys() {
fn dotenv_parser_supports_comments_export_quotes_empty_values_and_ignores_external_keys() {
let path = std::path::Path::new("fixture.env");
let content = "# comment\nexport KSP_LOGS_DIRECTORY = 'quoted logs'\nOTHER_TOOL=value\n";
let parsed = super::parse_dotenv_content(path, content);
let parsed = crate::parse_dotenv_content(path, content);
assert!(parsed.is_ok(), "dotenv fixture should parse");
let parsed = match parsed {
std::result::Result::Ok(value) => value,
@@ -149,7 +149,7 @@ fn dotenv_parser_supports_comments_export_quotes_empty_values_and_ignores_extern
#[test]
fn dotenv_duplicate_ksp_key_is_rejected() {
let parsed = super::parse_dotenv_content(std::path::Path::new("fixture.env"), "KSP_LOGS_DIRECTORY=one\nKSP_LOGS_DIRECTORY=two\n");
let parsed = crate::parse_dotenv_content(std::path::Path::new("fixture.env"), "KSP_LOGS_DIRECTORY=one\nKSP_LOGS_DIRECTORY=two\n");
let error = match parsed {
std::result::Result::Ok(_) => return,
std::result::Result::Err(error) => error,
@@ -199,7 +199,7 @@ fn logging_fixture_profile_resolves_environment_fallback_without_changing_source
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let effective = profile.resolve_effective_environment(&environment);
assert!(effective.is_ok(), "committed Logging environment fallback should resolve");
let effective = match effective {
@@ -245,7 +245,7 @@ fn secret_environment_value_keeps_real_value_but_redacts_safe_and_debug_views()
let canary = "KSP_SECRET_CANARY_91b7c6";
let mut process = std::collections::BTreeMap::<String, String>::new();
process.insert("KSP_SECRET_TEST_TOKEN".to_owned(), canary.to_owned());
let environment = super::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
let resolved = environment.resolve_variable("KSP_SECRET_TEST_TOKEN", std::option::Option::None);
assert!(resolved.is_ok(), "secret process value should resolve");
let resolved = match resolved {
@@ -255,7 +255,7 @@ fn secret_environment_value_keeps_real_value_but_redacts_safe_and_debug_views()
assert_eq!(resolved.value(), canary);
assert_eq!(resolved.safe_value(), crate::REDACTED_CONFIG_VALUE);
assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret);
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::Process);
assert_eq!(resolved.source(), crate::ConfigEnvironmentSource::Process);
let debug = format!("{resolved:?}");
assert!(!debug.contains(canary), "Debug must not reveal the secret canary");
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
@@ -267,7 +267,7 @@ fn detailed_text_redacts_only_secret_segments_and_keeps_ordered_provenance() {
let mut process = std::collections::BTreeMap::<String, String>::new();
process.insert("KSP_PUBLIC_HOST".to_owned(), "rpc.example.test".to_owned());
process.insert("KSP_SECRET_TOKEN".to_owned(), secret.to_owned());
let environment = super::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
let resolved = environment.resolve_text_detailed("https://${KSP_PUBLIC_HOST}/?token=${KSP_SECRET_TOKEN}");
assert!(resolved.is_ok(), "composed secret URL should resolve");
let resolved = match resolved {
@@ -288,7 +288,7 @@ fn detailed_text_redacts_only_secret_segments_and_keeps_ordered_provenance() {
#[test]
fn secret_fallback_inherits_secret_sensitivity_and_is_redacted() {
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let resolved = environment.resolve_text_detailed("token=${KSP_SECRET_TOKEN:-false-secret}");
assert!(resolved.is_ok(), "secret fallback should resolve");
let resolved = match resolved {
@@ -298,7 +298,7 @@ fn secret_fallback_inherits_secret_sensitivity_and_is_redacted() {
assert_eq!(resolved.value(), "token=false-secret");
assert_eq!(resolved.safe_value(), "token=********");
assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret);
assert_eq!(resolved.provenance()[1].environment_source(), std::option::Option::Some(super::ConfigEnvironmentSource::Fallback));
assert_eq!(resolved.provenance()[1].environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Fallback));
}
#[test]
@@ -306,7 +306,7 @@ fn detailed_json_preserves_safe_tree_sensitivity_and_pointer_provenance() {
let secret = "nested-secret-canary-2d11";
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
dotenv.insert("KSP_SECRET_TOKEN".to_owned(), secret.to_owned());
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
let source = serde_json::json!({"transport": {"url": "https://host/?token=${KSP_SECRET_TOKEN}"}, "items": ["plain", 7]});
let resolved = environment.resolve_json_detailed(&source);
assert!(resolved.is_ok(), "detailed JSON should resolve");
@@ -349,7 +349,7 @@ fn detailed_fixture_profile_environment_keeps_global_origin_and_adds_environment
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let effective = profile.resolve_effective_environment_detailed(&environment);
assert!(effective.is_ok(), "detailed committed Logging profile should resolve");
let effective = match effective {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/profile.rs
// version: 2
// version: 3
#[test]
fn fixture_default_profile_resolves_globals_profile_and_provenance() {
@@ -15,9 +15,9 @@ fn fixture_default_profile_resolves_globals_profile_and_provenance() {
if let (std::result::Result::Ok(document), std::result::Result::Ok(resolved)) = (document, resolved) {
let default_profile = document.value().get("default_profile").and_then(serde_json::Value::as_str);
assert_eq!(default_profile, std::option::Option::Some(resolved.profile_id()));
assert_eq!(resolved.selection_source(), super::ConfigProfileSelectionSource::DefaultProfile);
assert_eq!(resolved.origin("logs_directory"), std::option::Option::Some(super::ConfigValueOrigin::Global));
assert_eq!(resolved.origin("default_filter"), std::option::Option::Some(super::ConfigValueOrigin::Profile));
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
assert_eq!(resolved.origin("logs_directory"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
assert_eq!(resolved.origin("default_filter"), std::option::Option::Some(crate::ConfigValueOrigin::Profile));
assert_eq!(
resolved.profile().get("default_filter").and_then(serde_json::Value::as_str),
resolved.effective().get("default_filter").and_then(serde_json::Value::as_str),
@@ -45,7 +45,7 @@ fn explicit_profile_selection_is_distinct_from_default_selection() {
assert!(resolved.is_ok(), "explicit committed profile should resolve: {resolved:?}");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.profile_id(), profile_id);
assert_eq!(resolved.selection_source(), super::ConfigProfileSelectionSource::Explicit);
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
}
}
}

View File

@@ -1,49 +1,49 @@
// file: crates/ksp-config-lib/unit_tests/registry.rs
// version: 5
// version: 6
#[test]
fn descriptors_expose_complete_registry_in_deterministic_file_id_order() {
let registry = super::ConfigFileRegistry::defaults();
let registry = crate::ConfigFileRegistry::defaults();
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
if let std::result::Result::Ok(registry) = registry {
let descriptors: std::vec::Vec<&super::ConfigFileDescriptor> = registry.descriptors().collect();
let descriptors: std::vec::Vec<&crate::ConfigFileDescriptor> = registry.descriptors().collect();
assert_eq!(descriptors.len(), 5);
assert_eq!(descriptors[0].file_id().as_str(), super::FILE_ID_STD_LOGGING);
assert_eq!(descriptors[0].kind(), super::ConfigFileKind::Config);
assert_eq!(descriptors[0].filename(), std::path::Path::new(super::DEFAULT_STD_LOGGING_FILENAME));
assert_eq!(descriptors[0].file_id().as_str(), crate::FILE_ID_STD_LOGGING);
assert_eq!(descriptors[0].kind(), crate::ConfigFileKind::Config);
assert_eq!(descriptors[0].filename(), std::path::Path::new(crate::DEFAULT_STD_LOGGING_FILENAME));
let logging_schema_file_id = descriptors[0].schema_file_id();
assert!(logging_schema_file_id.is_some(), "logging descriptor should expose its validation schema");
if let std::option::Option::Some(schema_file_id) = logging_schema_file_id {
assert_eq!(schema_file_id.as_str(), super::FILE_ID_SCHEMA_STD_LOGGING);
assert_eq!(schema_file_id.as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING);
}
assert_eq!(descriptors[1].file_id().as_str(), super::FILE_ID_STD_TRANSPORT);
assert_eq!(descriptors[1].kind(), super::ConfigFileKind::Config);
assert_eq!(descriptors[1].filename(), std::path::Path::new(super::DEFAULT_STD_TRANSPORT_FILENAME));
assert_eq!(descriptors[1].file_id().as_str(), crate::FILE_ID_STD_TRANSPORT);
assert_eq!(descriptors[1].kind(), crate::ConfigFileKind::Config);
assert_eq!(descriptors[1].filename(), std::path::Path::new(crate::DEFAULT_STD_TRANSPORT_FILENAME));
let transport_schema_file_id = descriptors[1].schema_file_id();
assert!(transport_schema_file_id.is_some(), "transport descriptor should expose its validation schema");
if let std::option::Option::Some(schema_file_id) = transport_schema_file_id {
assert_eq!(schema_file_id.as_str(), super::FILE_ID_SCHEMA_STD_TRANSPORT);
assert_eq!(schema_file_id.as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT);
}
assert_eq!(descriptors[2].file_id().as_str(), super::FILE_ID_SCHEMA_COMPOSITE);
assert_eq!(descriptors[2].kind(), super::ConfigFileKind::Schema);
assert_eq!(descriptors[3].file_id().as_str(), super::FILE_ID_SCHEMA_STD_LOGGING);
assert_eq!(descriptors[3].kind(), super::ConfigFileKind::Schema);
assert_eq!(descriptors[4].file_id().as_str(), super::FILE_ID_SCHEMA_STD_TRANSPORT);
assert_eq!(descriptors[4].kind(), super::ConfigFileKind::Schema);
assert_eq!(descriptors[2].file_id().as_str(), crate::FILE_ID_SCHEMA_COMPOSITE);
assert_eq!(descriptors[2].kind(), crate::ConfigFileKind::Schema);
assert_eq!(descriptors[3].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING);
assert_eq!(descriptors[3].kind(), crate::ConfigFileKind::Schema);
assert_eq!(descriptors[4].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT);
assert_eq!(descriptors[4].kind(), crate::ConfigFileKind::Schema);
}
}
#[test]
fn descriptors_reflect_filename_overrides_without_changing_logical_metadata() {
let registry = super::ConfigFileRegistry::defaults();
let file_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
let registry = crate::ConfigFileRegistry::defaults();
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}");
if let (std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (registry, file_id) {
let overridden = registry.with_filename_override(&file_id, "profiles/desktop.logging.json");
assert!(overridden.is_ok(), "filename override should remain valid: {overridden:?}");
if let std::result::Result::Ok(overridden) = overridden {
let mut found: std::option::Option<&super::ConfigFileDescriptor> = std::option::Option::None;
let mut found: std::option::Option<&crate::ConfigFileDescriptor> = std::option::Option::None;
for descriptor in overridden.descriptors() {
if descriptor.file_id() == &file_id {
found = std::option::Option::Some(descriptor);
@@ -52,12 +52,12 @@ fn descriptors_reflect_filename_overrides_without_changing_logical_metadata() {
assert!(found.is_some(), "public descriptor inventory should retain the overridden logging descriptor");
if let std::option::Option::Some(descriptor) = found {
assert_eq!(descriptor.file_id(), &file_id);
assert_eq!(descriptor.kind(), super::ConfigFileKind::Config);
assert_eq!(descriptor.kind(), crate::ConfigFileKind::Config);
assert_eq!(descriptor.filename(), std::path::Path::new("profiles/desktop.logging.json"));
let schema_file_id = descriptor.schema_file_id();
assert!(schema_file_id.is_some(), "filename override should preserve schema association");
if let std::option::Option::Some(schema_file_id) = schema_file_id {
assert_eq!(schema_file_id.as_str(), super::FILE_ID_SCHEMA_STD_LOGGING);
assert_eq!(schema_file_id.as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING);
}
}
}
@@ -66,11 +66,11 @@ fn descriptors_reflect_filename_overrides_without_changing_logical_metadata() {
#[test]
fn defaults_register_logging_document_and_schema_with_distinct_roots() {
let registry = super::ConfigFileRegistry::defaults();
let registry = crate::ConfigFileRegistry::defaults();
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
if let std::result::Result::Ok(registry) = registry {
let logging_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
let schema_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_STD_LOGGING);
let logging_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
let schema_id = crate::ConfigFileId::new(crate::FILE_ID_SCHEMA_STD_LOGGING);
assert!(logging_id.is_ok(), "logging file_id should be valid: {logging_id:?}");
assert!(schema_id.is_ok(), "logging schema file_id should be valid: {schema_id:?}");
if let (std::result::Result::Ok(logging_id), std::result::Result::Ok(schema_id)) = (logging_id, schema_id) {
@@ -79,15 +79,15 @@ fn defaults_register_logging_document_and_schema_with_distinct_roots() {
assert!(logging.is_ok(), "logging descriptor should exist: {logging:?}");
assert!(schema.is_ok(), "logging schema descriptor should exist: {schema:?}");
if let (std::result::Result::Ok(logging), std::result::Result::Ok(schema)) = (logging, schema) {
assert_eq!(logging.kind(), super::ConfigFileKind::Config);
assert_eq!(logging.filename(), std::path::Path::new(super::DEFAULT_STD_LOGGING_FILENAME));
assert_eq!(logging.kind(), crate::ConfigFileKind::Config);
assert_eq!(logging.filename(), std::path::Path::new(crate::DEFAULT_STD_LOGGING_FILENAME));
let logging_schema = logging.schema_file_id();
assert!(logging_schema.is_some(), "logging document should declare its validation schema");
if let std::option::Option::Some(logging_schema) = logging_schema {
assert_eq!(logging_schema, &schema_id);
}
assert_eq!(schema.kind(), super::ConfigFileKind::Schema);
assert_eq!(schema.filename(), std::path::Path::new(super::DEFAULT_STD_LOGGING_SCHEMA_FILENAME));
assert_eq!(schema.kind(), crate::ConfigFileKind::Schema);
assert_eq!(schema.filename(), std::path::Path::new(crate::DEFAULT_STD_LOGGING_SCHEMA_FILENAME));
}
}
}
@@ -95,11 +95,11 @@ fn defaults_register_logging_document_and_schema_with_distinct_roots() {
#[test]
fn defaults_register_transport_document_and_schema_with_distinct_roots() {
let registry = super::ConfigFileRegistry::defaults();
let registry = crate::ConfigFileRegistry::defaults();
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
if let std::result::Result::Ok(registry) = registry {
let transport_id = super::ConfigFileId::new(super::FILE_ID_STD_TRANSPORT);
let schema_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_STD_TRANSPORT);
let transport_id = crate::ConfigFileId::new(crate::FILE_ID_STD_TRANSPORT);
let schema_id = crate::ConfigFileId::new(crate::FILE_ID_SCHEMA_STD_TRANSPORT);
assert!(transport_id.is_ok(), "transport file_id should be valid: {transport_id:?}");
assert!(schema_id.is_ok(), "transport schema file_id should be valid: {schema_id:?}");
if let (std::result::Result::Ok(transport_id), std::result::Result::Ok(schema_id)) = (transport_id, schema_id) {
@@ -108,11 +108,11 @@ fn defaults_register_transport_document_and_schema_with_distinct_roots() {
assert!(transport.is_ok(), "transport descriptor should exist: {transport:?}");
assert!(schema.is_ok(), "transport schema descriptor should exist: {schema:?}");
if let (std::result::Result::Ok(transport), std::result::Result::Ok(schema)) = (transport, schema) {
assert_eq!(transport.kind(), super::ConfigFileKind::Config);
assert_eq!(transport.filename(), std::path::Path::new(super::DEFAULT_STD_TRANSPORT_FILENAME));
assert_eq!(transport.kind(), crate::ConfigFileKind::Config);
assert_eq!(transport.filename(), std::path::Path::new(crate::DEFAULT_STD_TRANSPORT_FILENAME));
assert_eq!(transport.schema_file_id(), std::option::Option::Some(&schema_id));
assert_eq!(schema.kind(), super::ConfigFileKind::Schema);
assert_eq!(schema.filename(), std::path::Path::new(super::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME));
assert_eq!(schema.kind(), crate::ConfigFileKind::Schema);
assert_eq!(schema.filename(), std::path::Path::new(crate::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME));
}
}
}
@@ -120,13 +120,13 @@ fn defaults_register_transport_document_and_schema_with_distinct_roots() {
#[test]
fn resolve_path_uses_descriptor_kind_to_select_bootstrap_root() {
let registry = super::ConfigFileRegistry::defaults();
let registry = crate::ConfigFileRegistry::defaults();
let bootstrap = crate::ConfigBootstrapOptions::from_paths("runtime-config", "runtime-schemas");
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
assert!(bootstrap.is_ok(), "bootstrap paths should be valid: {bootstrap:?}");
if let (std::result::Result::Ok(registry), std::result::Result::Ok(bootstrap)) = (registry, bootstrap) {
let logging_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
let schema_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_STD_LOGGING);
let logging_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
let schema_id = crate::ConfigFileId::new(crate::FILE_ID_SCHEMA_STD_LOGGING);
if let (std::result::Result::Ok(logging_id), std::result::Result::Ok(schema_id)) = (logging_id, schema_id) {
let logging = registry.resolve_path(&bootstrap, &logging_id);
let schema = registry.resolve_path(&bootstrap, &schema_id);
@@ -150,16 +150,16 @@ fn cli_filemap_override_replaces_filename_and_last_value_wins() {
std::ffi::OsString::from("--other-option"),
std::ffi::OsString::from("--filemap=cfg.std.logging=profiles/custom.logging.json"),
];
let registry = super::ConfigFileRegistry::from_args(&args);
let registry = crate::ConfigFileRegistry::from_args(&args);
assert!(registry.is_ok(), "filemap overrides should parse: {registry:?}");
if let std::result::Result::Ok(registry) = registry {
let file_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
if let std::result::Result::Ok(file_id) = file_id {
let descriptor = registry.descriptor(&file_id);
assert!(descriptor.is_ok(), "logging descriptor should remain registered: {descriptor:?}");
if let std::result::Result::Ok(descriptor) = descriptor {
assert_eq!(descriptor.filename(), std::path::Path::new("profiles/custom.logging.json"));
assert_eq!(descriptor.kind(), super::ConfigFileKind::Config);
assert_eq!(descriptor.kind(), crate::ConfigFileKind::Config);
}
}
}
@@ -167,8 +167,8 @@ fn cli_filemap_override_replaces_filename_and_last_value_wins() {
#[test]
fn programmatic_override_preserves_file_id_and_kind() {
let registry = super::ConfigFileRegistry::defaults();
let file_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_STD_LOGGING);
let registry = crate::ConfigFileRegistry::defaults();
let file_id = crate::ConfigFileId::new(crate::FILE_ID_SCHEMA_STD_LOGGING);
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
assert!(file_id.is_ok(), "schema file_id should be valid: {file_id:?}");
if let (std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (registry, file_id) {
@@ -178,7 +178,7 @@ fn programmatic_override_preserves_file_id_and_kind() {
let descriptor = overridden.descriptor(&file_id);
if let std::result::Result::Ok(descriptor) = descriptor {
assert_eq!(descriptor.file_id(), &file_id);
assert_eq!(descriptor.kind(), super::ConfigFileKind::Schema);
assert_eq!(descriptor.kind(), crate::ConfigFileKind::Schema);
assert_eq!(descriptor.filename(), std::path::Path::new("alternate/logging.schema.json"));
}
}
@@ -188,7 +188,7 @@ fn programmatic_override_preserves_file_id_and_kind() {
#[test]
fn unknown_file_id_override_is_rejected() {
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--filemap=cfg.unknown=unknown.json")];
let result = super::ConfigFileRegistry::from_args(&args);
let result = crate::ConfigFileRegistry::from_args(&args);
assert!(result.is_err(), "unknown logical files must not be introduced by CLI override");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_UNKNOWN);
@@ -198,7 +198,7 @@ fn unknown_file_id_override_is_rejected() {
#[test]
fn invalid_file_ids_are_rejected() {
for value in ["", ".cfg", "cfg.", "cfg..logging", "CFG.logging", "cfg/logging"] {
let result = super::ConfigFileId::new(value);
let result = crate::ConfigFileId::new(value);
assert!(result.is_err(), "invalid file_id must be rejected: {value}");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_INVALID);
@@ -208,8 +208,8 @@ fn invalid_file_ids_are_rejected() {
#[test]
fn absolute_and_traversing_filenames_are_rejected() {
let registry = super::ConfigFileRegistry::defaults();
let file_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
let registry = crate::ConfigFileRegistry::defaults();
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}");
if let (std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (registry, file_id) {
@@ -226,7 +226,7 @@ fn absolute_and_traversing_filenames_are_rejected() {
fn malformed_filemap_arguments_are_rejected() {
for argument in ["--filemap", "--filemap=cfg.std.logging", "--filemap==logging.json", "--filemap=cfg.std.logging="] {
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from(argument)];
let result = super::ConfigFileRegistry::from_args(&args);
let result = crate::ConfigFileRegistry::from_args(&args);
assert!(result.is_err(), "malformed filemap argument must be rejected: {argument}");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID);
@@ -236,12 +236,12 @@ fn malformed_filemap_arguments_are_rejected() {
#[test]
fn duplicate_registry_ids_are_rejected() {
let first = super::ConfigFileDescriptor::new("cfg.duplicate", super::ConfigFileKind::Config, "first.json", std::option::Option::None);
let second = super::ConfigFileDescriptor::new("cfg.duplicate", super::ConfigFileKind::Config, "second.json", std::option::Option::None);
let first = crate::ConfigFileDescriptor::new("cfg.duplicate", crate::ConfigFileKind::Config, "first.json", std::option::Option::None);
let second = crate::ConfigFileDescriptor::new("cfg.duplicate", crate::ConfigFileKind::Config, "second.json", std::option::Option::None);
assert!(first.is_ok(), "first descriptor should be valid: {first:?}");
assert!(second.is_ok(), "second descriptor should be valid: {second:?}");
if let (std::result::Result::Ok(first), std::result::Result::Ok(second)) = (first, second) {
let result = super::build_registry([first, second]);
let result = crate::build_registry([first, second]);
assert!(result.is_err(), "duplicate file_ids must be rejected");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_DUPLICATE);
@@ -251,7 +251,7 @@ fn duplicate_registry_ids_are_rejected() {
#[test]
fn descriptor_kind_must_match_file_id_namespace() {
let result = super::ConfigFileDescriptor::new("schema.invalid-kind", super::ConfigFileKind::Config, "invalid.json", std::option::Option::None);
let result = crate::ConfigFileDescriptor::new("schema.invalid-kind", crate::ConfigFileKind::Config, "invalid.json", std::option::Option::None);
assert!(result.is_err(), "descriptor kind mismatch must be rejected");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID);
@@ -260,10 +260,10 @@ fn descriptor_kind_must_match_file_id_namespace() {
#[test]
fn config_schema_association_must_reference_registered_schema_descriptor() {
let config = super::ConfigFileDescriptor::new("cfg.test", super::ConfigFileKind::Config, "test.json", std::option::Option::Some("schema.test"));
let config = crate::ConfigFileDescriptor::new("cfg.test", crate::ConfigFileKind::Config, "test.json", std::option::Option::Some("schema.test"));
assert!(config.is_ok(), "config descriptor should be valid before registry association validation: {config:?}");
if let std::result::Result::Ok(config) = config {
let result = super::build_registry([config]);
let result = crate::build_registry([config]);
assert!(result.is_err(), "registry must reject a missing schema association");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID);
@@ -286,9 +286,9 @@ fn absolute_fixture_path() -> std::path::PathBuf {
#[test]
fn defaults_register_generic_composite_schema_without_runtime_composite() {
let registry = super::ConfigFileRegistry::defaults();
let schema_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_COMPOSITE);
let runtime_id = super::ConfigFileId::new("cfg.composite.ksp-app-wallet-desk");
let registry = crate::ConfigFileRegistry::defaults();
let schema_id = crate::ConfigFileId::new(crate::FILE_ID_SCHEMA_COMPOSITE);
let runtime_id = crate::ConfigFileId::new("cfg.composite.ksp-app-wallet-desk");
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
assert!(schema_id.is_ok(), "composite schema file_id should be valid: {schema_id:?}");
assert!(runtime_id.is_ok(), "future composite runtime file_id syntax should be valid: {runtime_id:?}");
@@ -298,8 +298,8 @@ fn defaults_register_generic_composite_schema_without_runtime_composite() {
assert!(schema.is_ok(), "generic composite schema should be registered: {schema:?}");
assert!(runtime.is_err(), "no fictitious runtime composite should be registered");
if let std::result::Result::Ok(schema) = schema {
assert_eq!(schema.kind(), super::ConfigFileKind::Schema);
assert_eq!(schema.filename(), std::path::Path::new(super::DEFAULT_COMPOSITE_SCHEMA_FILENAME));
assert_eq!(schema.kind(), crate::ConfigFileKind::Schema);
assert_eq!(schema.filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_SCHEMA_FILENAME));
}
if let std::result::Result::Err(error) = runtime {
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_UNKNOWN);

View File

@@ -1,31 +1,31 @@
// file: crates/ksp-config-lib/unit_tests/sensitivity.rs
// version: 1
// version: 2
#[test]
fn environment_names_map_to_expected_sensitivity() {
assert_eq!(super::ConfigSensitivity::from_variable_name("KSP_PUBLIC_ENDPOINT").ok(), std::option::Option::Some(super::ConfigSensitivity::Public));
assert_eq!(super::ConfigSensitivity::from_variable_name("KSP_MODE").ok(), std::option::Option::Some(super::ConfigSensitivity::Internal));
assert_eq!(super::ConfigSensitivity::from_variable_name("KSP_SECRET_PASSWORD").ok(), std::option::Option::Some(super::ConfigSensitivity::Secret));
assert_eq!(super::ConfigSensitivity::from_variable_name("KSPB_PUBLIC_ENDPOINT").ok(), std::option::Option::Some(super::ConfigSensitivity::Public));
assert_eq!(super::ConfigSensitivity::from_variable_name("KSPB_MODE").ok(), std::option::Option::Some(super::ConfigSensitivity::Internal));
assert_eq!(super::ConfigSensitivity::from_variable_name("KSPB_SECRET_PASSWORD").ok(), std::option::Option::Some(super::ConfigSensitivity::Secret));
assert_eq!(crate::ConfigSensitivity::from_variable_name("KSP_PUBLIC_ENDPOINT").ok(), std::option::Option::Some(crate::ConfigSensitivity::Public));
assert_eq!(crate::ConfigSensitivity::from_variable_name("KSP_MODE").ok(), std::option::Option::Some(crate::ConfigSensitivity::Internal));
assert_eq!(crate::ConfigSensitivity::from_variable_name("KSP_SECRET_PASSWORD").ok(), std::option::Option::Some(crate::ConfigSensitivity::Secret));
assert_eq!(crate::ConfigSensitivity::from_variable_name("KSPB_PUBLIC_ENDPOINT").ok(), std::option::Option::Some(crate::ConfigSensitivity::Public));
assert_eq!(crate::ConfigSensitivity::from_variable_name("KSPB_MODE").ok(), std::option::Option::Some(crate::ConfigSensitivity::Internal));
assert_eq!(crate::ConfigSensitivity::from_variable_name("KSPB_SECRET_PASSWORD").ok(), std::option::Option::Some(crate::ConfigSensitivity::Secret));
}
#[test]
fn strongest_sensitivity_follows_secret_internal_public_order() {
assert_eq!(super::ConfigSensitivity::Public.strongest(super::ConfigSensitivity::Internal), super::ConfigSensitivity::Internal);
assert_eq!(super::ConfigSensitivity::Internal.strongest(super::ConfigSensitivity::Secret), super::ConfigSensitivity::Secret);
assert_eq!(super::ConfigSensitivity::Secret.strongest(super::ConfigSensitivity::Public), super::ConfigSensitivity::Secret);
assert_eq!(crate::ConfigSensitivity::Public.strongest(crate::ConfigSensitivity::Internal), crate::ConfigSensitivity::Internal);
assert_eq!(crate::ConfigSensitivity::Internal.strongest(crate::ConfigSensitivity::Secret), crate::ConfigSensitivity::Secret);
assert_eq!(crate::ConfigSensitivity::Secret.strongest(crate::ConfigSensitivity::Public), crate::ConfigSensitivity::Secret);
}
#[test]
fn provenance_exposes_names_and_sources_without_values() {
let process = super::ConfigValueProvenance::EnvironmentProcess { variable_name: "KSP_SECRET_TOKEN".to_owned() };
let dotenv = super::ConfigValueProvenance::EnvironmentDotEnv { variable_name: "KSP_MODE".to_owned() };
let fallback = super::ConfigValueProvenance::EnvironmentFallback { variable_name: "KSP_PUBLIC_HOST".to_owned() };
let process = crate::ConfigValueProvenance::EnvironmentProcess { variable_name: "KSP_SECRET_TOKEN".to_owned() };
let dotenv = crate::ConfigValueProvenance::EnvironmentDotEnv { variable_name: "KSP_MODE".to_owned() };
let fallback = crate::ConfigValueProvenance::EnvironmentFallback { variable_name: "KSP_PUBLIC_HOST".to_owned() };
assert_eq!(process.variable_name(), std::option::Option::Some("KSP_SECRET_TOKEN"));
assert_eq!(process.environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
assert_eq!(dotenv.environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::DotEnv));
assert_eq!(fallback.environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Fallback));
assert_eq!(super::ConfigValueProvenance::DocumentLiteral.variable_name(), std::option::Option::None);
assert_eq!(crate::ConfigValueProvenance::DocumentLiteral.variable_name(), std::option::Option::None);
}

View File

@@ -1,5 +1,6 @@
// file: crates/ksp-core-lib/src/lib.rs
// version: 6
// version: 7
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]

View File

@@ -1,34 +1,10 @@
// file: crates/ksp-logging-lib/src/domain.rs
// version: 1
// version: 2
std::thread_local! {
static CURRENT_DOMAIN: std::cell::RefCell<std::option::Option<std::string::String>> = const { std::cell::RefCell::new(std::option::Option::None) };
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct SpanDomain {
value: std::option::Option<std::string::String>,
}
#[derive(Default)]
struct DomainVisitor {
value: std::option::Option<std::string::String>,
}
impl tracing::field::Visit for DomainVisitor {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "domain" {
self.value = std::option::Option::Some(value.to_string());
}
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "domain" {
self.value = std::option::Option::Some(format!("{value:?}"));
}
}
}
pub(crate) struct DomainContextLayer;
impl DomainContextLayer {
@@ -99,6 +75,30 @@ where
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct SpanDomain {
value: std::option::Option<std::string::String>,
}
#[derive(Default)]
struct DomainVisitor {
value: std::option::Option<std::string::String>,
}
impl tracing::field::Visit for DomainVisitor {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "domain" {
self.value = std::option::Option::Some(value.to_string());
}
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "domain" {
self.value = std::option::Option::Some(format!("{value:?}"));
}
}
}
pub(crate) fn current_domain_matches(selectors: &[std::string::String]) -> bool {
if let [selector] = selectors
&& selector == "*"

View File

@@ -1,13 +1,13 @@
// file: crates/ksp-logging-lib/src/error.rs
// version: 4
// version: 5
/// Error code used when runtime logging settings are invalid.
pub const ERROR_CODE_INVALID_SETTINGS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("logging", "invalid_settings");
/// Error code used when a global logging subscriber is already installed.
pub const ERROR_CODE_ALREADY_INITIALIZED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("logging", "already_initialized");
/// Error code used when a hot reload cannot replace the active runtime layers.
pub const ERROR_CODE_RELOAD_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("logging", "reload_failed");
/// Error code used when the rolling file output cannot be initialized.
pub const ERROR_CODE_FILE_OUTPUT_INITIALIZATION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("logging", "file_output_initialization_failed");
/// Error code used when an application Logging runtime identity is invalid.
pub const ERROR_CODE_INVALID_RUNTIME_IDENTITY: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("logging", "invalid_runtime_identity");
/// Error code used when runtime logging settings are invalid.
pub const ERROR_CODE_INVALID_SETTINGS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("logging", "invalid_settings");
/// Error code used when a hot reload cannot replace the active runtime layers.
pub const ERROR_CODE_RELOAD_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("logging", "reload_failed");

View File

@@ -1,5 +1,6 @@
// file: crates/ksp-logging-lib/src/lib.rs
// version: 8
// version: 9
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
@@ -69,6 +70,10 @@ pub use self::span::Span;
/// Instruments an asynchronous future with a KSP span.
pub use self::span::instrument;
pub(crate) use self::domain::current_domain_matches;
pub(crate) use self::writer::RoutedWriter;
pub(crate) use self::writer::StripAnsiWriter;
#[doc(hidden)]
/// Internal macro bridge. KSP consumers must not use this reexport directly.
pub extern crate tracing as __private_tracing;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/src/runtime.rs
// version: 13
// version: 14
use tracing_subscriber::Layer; // rust-rules: trait-import
use tracing_subscriber::layer::SubscriberExt; // rust-rules: trait-import
@@ -228,31 +228,6 @@ pub fn initialize_with_identity(settings: &crate::LoggingSettings, identity: &cr
return initialize_runtime(settings, std::option::Option::Some(identity.clone()));
}
fn initialize_runtime(
settings: &crate::LoggingSettings,
runtime_identity: std::option::Option<crate::LoggingRuntimeIdentity>,
) -> ksp_core_lib::Result<crate::LoggingGuard> {
return prepare_runtime_with_identity(settings, runtime_identity.as_ref()).and_then(|prepared| -> ksp_core_lib::Result<crate::LoggingGuard> {
let PreparedRuntime { layers, outputs } = prepared;
let (reload_layer, reload_handle) = tracing_subscriber::reload::Layer::new(layers);
let subscriber = tracing_subscriber::registry().with(reload_layer);
let install_result = tracing::subscriber::set_global_default(subscriber);
return match install_result {
std::result::Result::Ok(()) => std::result::Result::Ok(crate::LoggingGuard {
reload_handle,
settings: settings.clone(),
runtime_identity,
outputs,
retired_dropped_lines: crate::DroppedLines::zero(),
retired_file_dropped_lines: std::collections::HashMap::new(),
}),
std::result::Result::Err(error) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_ALREADY_INITIALIZED, "the global KSP tracing subscriber is already installed").with_source(error),
),
};
});
}
/// Replaces the active KSP logging settings and non-blocking outputs without reinstalling the global subscriber.
///
/// New runtime layers, writers and guards are fully prepared before the reload is attempted. If validation or preparation fails, the currently active
@@ -282,6 +257,31 @@ pub fn reinitialize(guard: &mut crate::LoggingGuard, settings: &crate::LoggingSe
});
}
fn initialize_runtime(
settings: &crate::LoggingSettings,
runtime_identity: std::option::Option<crate::LoggingRuntimeIdentity>,
) -> ksp_core_lib::Result<crate::LoggingGuard> {
return prepare_runtime_with_identity(settings, runtime_identity.as_ref()).and_then(|prepared| -> ksp_core_lib::Result<crate::LoggingGuard> {
let PreparedRuntime { layers, outputs } = prepared;
let (reload_layer, reload_handle) = tracing_subscriber::reload::Layer::new(layers);
let subscriber = tracing_subscriber::registry().with(reload_layer);
let install_result = tracing::subscriber::set_global_default(subscriber);
return match install_result {
std::result::Result::Ok(()) => std::result::Result::Ok(crate::LoggingGuard {
reload_handle,
settings: settings.clone(),
runtime_identity,
outputs,
retired_dropped_lines: crate::DroppedLines::zero(),
retired_file_dropped_lines: std::collections::HashMap::new(),
}),
std::result::Result::Err(error) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_ALREADY_INITIALIZED, "the global KSP tracing subscriber is already installed").with_source(error),
),
};
});
}
fn prepare_runtime_with_identity(
settings: &crate::LoggingSettings,
runtime_identity: std::option::Option<&crate::LoggingRuntimeIdentity>,
@@ -368,7 +368,7 @@ fn build_file_output(
);
},
};
let stripped_writer = crate::writer::StripAnsiWriter::new(appender);
let stripped_writer = crate::StripAnsiWriter::new(appender);
let thread_name = format!("ksp-logging-{}", file.output_id());
let prepared = build_non_blocking_output(stripped_writer, thread_name.as_str(), settings.span_events(), false, false, file.format(), file.filter());
let metadata = crate::RuntimeFileMetadata {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/src/writer.rs
// version: 3
// version: 4
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum StripAnsiState {
@@ -117,7 +117,7 @@ pub(crate) enum RoutedWriter<W> {
Disabled,
}
impl<W> std::io::Write for RoutedWriter<W>
impl<W> std::io::Write for crate::RoutedWriter<W>
where
W: std::io::Write,
{
@@ -140,17 +140,17 @@ impl<'writer, W> tracing_subscriber::fmt::MakeWriter<'writer> for RouteMakeWrite
where
W: tracing_subscriber::fmt::MakeWriter<'writer>,
{
type Writer = RoutedWriter<W::Writer>;
type Writer = crate::RoutedWriter<W::Writer>;
fn make_writer(&'writer self) -> Self::Writer {
return RoutedWriter::Enabled(tracing_subscriber::fmt::MakeWriter::make_writer(&self.inner));
return crate::RoutedWriter::Enabled(tracing_subscriber::fmt::MakeWriter::make_writer(&self.inner));
}
fn make_writer_for(&'writer self, metadata: &tracing::Metadata<'_>) -> Self::Writer {
if metadata_matches_filter(metadata, &self.filter) {
return RoutedWriter::Enabled(tracing_subscriber::fmt::MakeWriter::make_writer_for(&self.inner, metadata));
return crate::RoutedWriter::Enabled(tracing_subscriber::fmt::MakeWriter::make_writer_for(&self.inner, metadata));
}
return RoutedWriter::Disabled;
return crate::RoutedWriter::Disabled;
}
}
@@ -163,7 +163,7 @@ fn metadata_matches_filter(metadata: &tracing::Metadata<'_>, filter: &crate::Out
}) {
return false;
}
return crate::domain::current_domain_matches(filter.domains());
return crate::current_domain_matches(filter.domains());
}
fn level_is_enabled(level: &tracing::Level, filter: crate::LogFilterLevel) -> bool {

View File

@@ -1,20 +1,20 @@
// file: crates/ksp-logging-lib/unit_tests/domain.rs
// version: 1
// version: 2
#[test]
fn wildcard_domain_matches_with_or_without_current_domain() {
super::set_current_domain(std::option::Option::None);
assert!(super::current_domain_matches(&["*".to_string()]));
assert!(crate::current_domain_matches(&["*".to_string()]));
super::set_current_domain(std::option::Option::Some("logging"));
assert!(super::current_domain_matches(&["*".to_string()]));
assert!(crate::current_domain_matches(&["*".to_string()]));
}
#[test]
fn named_domain_requires_current_matching_prefix() {
super::set_current_domain(std::option::Option::None);
assert!(!super::current_domain_matches(&["logging".to_string()]));
assert!(!crate::current_domain_matches(&["logging".to_string()]));
super::set_current_domain(std::option::Option::Some("logging.runtime"));
assert!(super::current_domain_matches(&["logging".to_string()]));
assert!(super::current_domain_matches(&["store".to_string(), "logging.runtime".to_string()]));
assert!(!super::current_domain_matches(&["store".to_string()]));
assert!(crate::current_domain_matches(&["logging".to_string()]));
assert!(crate::current_domain_matches(&["store".to_string(), "logging.runtime".to_string()]));
assert!(!crate::current_domain_matches(&["store".to_string()]));
}

View File

@@ -1,9 +1,9 @@
// file: crates/ksp-logging-lib/unit_tests/writer.rs
// version: 2
// version: 3
#[test]
fn ansi_writer_strips_csi_sequences() {
let mut writer = super::StripAnsiWriter::new(std::vec::Vec::<u8>::new());
let mut writer = crate::StripAnsiWriter::new(std::vec::Vec::<u8>::new());
let write_result = std::io::Write::write_all(&mut writer, b"before\x1b[31mred\x1b[0mafter");
assert!(write_result.is_ok());
assert_eq!(writer.into_inner(), b"beforeredafter");
@@ -11,7 +11,7 @@ fn ansi_writer_strips_csi_sequences() {
#[test]
fn ansi_writer_preserves_state_across_split_writes() {
let mut writer = super::StripAnsiWriter::new(std::vec::Vec::<u8>::new());
let mut writer = crate::StripAnsiWriter::new(std::vec::Vec::<u8>::new());
let first = std::io::Write::write_all(&mut writer, b"a\x1b[");
let second = std::io::Write::write_all(&mut writer, b"32mb");
assert!(first.is_ok());
@@ -21,7 +21,7 @@ fn ansi_writer_preserves_state_across_split_writes() {
#[test]
fn ansi_writer_strips_osc_sequences_terminated_by_bell_or_st() {
let mut writer = super::StripAnsiWriter::new(std::vec::Vec::<u8>::new());
let mut writer = crate::StripAnsiWriter::new(std::vec::Vec::<u8>::new());
let first = std::io::Write::write_all(&mut writer, b"a\x1b]0;title\x07b");
let second = std::io::Write::write_all(&mut writer, b"c\x1b]8;;https://example.invalid\x1b\\d");
assert!(first.is_ok());
@@ -43,7 +43,7 @@ fn output_level_routing_covers_all_filter_levels() {
#[test]
fn disabled_routed_writer_discards_bytes_without_error() {
let mut writer = super::RoutedWriter::<std::vec::Vec<u8>>::Disabled;
let mut writer = crate::RoutedWriter::<std::vec::Vec<u8>>::Disabled;
let write_result = std::io::Write::write_all(&mut writer, b"discarded");
let flush_result = std::io::Write::flush(&mut writer);
assert!(write_result.is_ok());

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/client.rs
// version: 5
// version: 6
/// Passive runtime availability reported for one logical HTTP endpoint or role.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -163,26 +163,6 @@ impl HttpEndpointSnapshot {
}
}
pub(crate) struct HttpEndpointHttpResponse {
status: u16,
retry_after: std::option::Option<std::time::Duration>,
body: std::vec::Vec<u8>,
}
impl HttpEndpointHttpResponse {
pub(crate) const fn status(&self) -> u16 {
return self.status;
}
pub(crate) const fn retry_after(&self) -> std::option::Option<std::time::Duration> {
return self.retry_after;
}
pub(crate) fn body(&self) -> &[u8] {
return self.body.as_slice();
}
}
/// Shareable logical HTTP endpoint client owned by KSP Transport.
///
/// The underlying `reqwest::Client` owns socket pooling. KSP keeps the configured URL private from diagnostics and exposes only safe routing metadata.
@@ -191,12 +171,6 @@ pub struct HttpEndpointClient {
inner: std::sync::Arc<HttpEndpointClientInner>,
}
struct HttpEndpointClientInner {
settings: crate::HttpEndpointSettings,
client: reqwest::Client,
role_runtimes: std::vec::Vec<std::sync::Arc<crate::resilience::HttpRoleRuntime>>,
}
impl HttpEndpointClient {
/// Builds one logical endpoint client from KSP-owned runtime settings.
pub fn new(settings: crate::HttpEndpointSettings) -> ksp_core_lib::Result<Self> {
@@ -221,7 +195,7 @@ impl HttpEndpointClient {
};
let mut role_runtimes = std::vec::Vec::with_capacity(settings.roles().len());
for role in settings.roles() {
role_runtimes.push(std::sync::Arc::new(crate::resilience::HttpRoleRuntime::new(role, std::sync::Arc::clone(&notify))));
role_runtimes.push(std::sync::Arc::new(crate::HttpRoleRuntime::new(role, std::sync::Arc::clone(&notify))));
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
@@ -352,7 +326,7 @@ impl HttpEndpointClient {
&self,
role: &crate::HttpRoleName,
request_kind: &crate::HttpRequestKind,
) -> std::option::Option<(u32, std::sync::Arc<crate::resilience::HttpRoleRuntime>)> {
) -> std::option::Option<(u32, std::sync::Arc<crate::HttpRoleRuntime>)> {
if !self.enabled() {
return std::option::Option::None;
}
@@ -419,10 +393,36 @@ impl std::fmt::Debug for HttpEndpointClient {
}
}
pub(crate) struct HttpEndpointHttpResponse {
status: u16,
retry_after: std::option::Option<std::time::Duration>,
body: std::vec::Vec<u8>,
}
impl HttpEndpointHttpResponse {
pub(crate) const fn status(&self) -> u16 {
return self.status;
}
pub(crate) const fn retry_after(&self) -> std::option::Option<std::time::Duration> {
return self.retry_after;
}
pub(crate) fn body(&self) -> &[u8] {
return self.body.as_slice();
}
}
struct HttpEndpointClientInner {
settings: crate::HttpEndpointSettings,
client: reqwest::Client,
role_runtimes: std::vec::Vec<std::sync::Arc<crate::HttpRoleRuntime>>,
}
fn role_snapshot(
role: &crate::HttpEndpointRoleSettings,
request_kinds: std::vec::Vec<std::string::String>,
runtime: &crate::resilience::HttpRoleRuntime,
runtime: &crate::HttpRoleRuntime,
) -> crate::HttpEndpointRoleSnapshot {
let availability = if role.enabled() { runtime.availability(std::time::Instant::now()) } else { crate::HttpEndpointAvailability::Disabled };
return crate::HttpEndpointRoleSnapshot {

View File

@@ -1,29 +1,29 @@
// file: crates/ksp-onchain-transport-lib/src/error.rs
// version: 2
// version: 3
/// Error code used when HTTP transport runtime settings are invalid.
pub const ERROR_CODE_INVALID_SETTINGS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_settings");
/// Error code used when no logical endpoint can satisfy a request.
pub const ERROR_CODE_ENDPOINT_SELECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "endpoint_selection_failed");
/// Error code used when an HTTP connection cannot be established.
pub const ERROR_CODE_HTTP_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "http_connection_failed");
/// Error code used when an HTTP request fails after a connection exists.
pub const ERROR_CODE_HTTP_REQUEST_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "http_request_failed");
/// Error code used when a transport deadline expires.
pub const ERROR_CODE_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "timeout");
/// Error code used when an endpoint or provider rate-limits a request.
pub const ERROR_CODE_RATE_LIMITED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rate_limited");
/// Error code used when a JSON-RPC request cannot be encoded.
pub const ERROR_CODE_JSON_ENCODE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_encode_failed");
/// Error code used when an HTTP JSON-RPC payload cannot be decoded as JSON.
pub const ERROR_CODE_JSON_DECODE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_decode_failed");
/// Error code used when a decoded JSON-RPC envelope violates protocol invariants.
pub const ERROR_CODE_JSON_RPC_PROTOCOL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_rpc_protocol_invalid");
/// Error code used when a remote JSON-RPC endpoint returns an application-level RPC error.
pub const ERROR_CODE_RPC_APPLICATION_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rpc_application_error");
/// Error code used when a historically documented RPC method is no longer supported by the targeted runtime.
pub const ERROR_CODE_METHOD_REMOVED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "method_removed");
/// Error code used when a decoded response cannot satisfy the KSP transport contract expected by the caller.
pub const ERROR_CODE_INVALID_RESPONSE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_response");
/// Error code used when typed Solana RPC parameters violate a locally enforceable method contract.
pub const ERROR_CODE_INVALID_RPC_PARAMETERS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_rpc_parameters");
/// Error code used when HTTP transport runtime settings are invalid.
pub const ERROR_CODE_INVALID_SETTINGS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_settings");
/// Error code used when an HTTP JSON-RPC payload cannot be decoded as JSON.
pub const ERROR_CODE_JSON_DECODE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_decode_failed");
/// Error code used when a JSON-RPC request cannot be encoded.
pub const ERROR_CODE_JSON_ENCODE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_encode_failed");
/// Error code used when a decoded JSON-RPC envelope violates protocol invariants.
pub const ERROR_CODE_JSON_RPC_PROTOCOL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_rpc_protocol_invalid");
/// Error code used when a historically documented RPC method is no longer supported by the targeted runtime.
pub const ERROR_CODE_METHOD_REMOVED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "method_removed");
/// Error code used when an endpoint or provider rate-limits a request.
pub const ERROR_CODE_RATE_LIMITED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rate_limited");
/// Error code used when a remote JSON-RPC endpoint returns an application-level RPC error.
pub const ERROR_CODE_RPC_APPLICATION_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rpc_application_error");
/// Error code used when a transport deadline expires.
pub const ERROR_CODE_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "timeout");

View File

@@ -1,5 +1,6 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 19
// version: 20
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
@@ -34,8 +35,6 @@ mod rpc_tokens;
mod rpc_transactions;
mod settings;
pub(crate) use self::constants::TRACING_TARGET;
/// Passive runtime availability reported for one logical HTTP endpoint.
pub use self::client::HttpEndpointAvailability;
/// Shareable logical HTTP endpoint client owned by KSP Transport.
@@ -296,3 +295,8 @@ pub use self::settings::HttpRoleLimits;
pub use self::settings::HttpRoleName;
/// Complete runtime settings consumed by the Solana HTTP transport foundation.
pub use self::settings::HttpTransportSettings;
pub(crate) use self::constants::TRACING_TARGET;
pub(crate) use self::resilience::HttpConcurrencyPermit;
pub(crate) use self::resilience::HttpRoleRuntime;
pub(crate) use self::resilience::RoleAdmissionAttempt;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/pool.rs
// version: 5
// version: 6
/// Safe snapshot of the logical HTTP endpoint pool.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -74,8 +74,8 @@ impl HttpEndpointSelection {
pub struct HttpRequestPermit {
selection: crate::HttpEndpointSelection,
deadline: std::time::Instant,
role_runtime: std::sync::Arc<crate::resilience::HttpRoleRuntime>,
_concurrency_permit: crate::resilience::HttpConcurrencyPermit,
role_runtime: std::sync::Arc<crate::HttpRoleRuntime>,
_concurrency_permit: crate::HttpConcurrencyPermit,
}
impl HttpRequestPermit {
@@ -165,14 +165,6 @@ pub struct HttpTransportPool {
inner: std::sync::Arc<HttpTransportPoolInner>,
}
struct HttpTransportPoolInner {
clients: std::vec::Vec<crate::HttpEndpointClient>,
retry: crate::HttpRetrySettings,
notify: std::sync::Arc<tokio::sync::Notify>,
request_ids: std::sync::atomic::AtomicU64,
cursors: std::sync::Mutex<std::collections::BTreeMap<(std::string::String, std::string::String, u32), usize>>,
}
impl HttpTransportPool {
/// Builds a logical endpoint pool after validating all Transport-owned runtime settings.
pub fn new(settings: crate::HttpTransportSettings) -> ksp_core_lib::Result<Self> {
@@ -369,7 +361,7 @@ impl HttpTransportPool {
let candidate = &candidates[position];
let admission = candidate.runtime.try_acquire(now);
match admission {
crate::resilience::RoleAdmissionAttempt::Ready(concurrency_permit) => {
crate::RoleAdmissionAttempt::Ready(concurrency_permit) => {
let selection_result = self.selection_from_runtime_candidate(role, request_kind, candidate);
let selection = match selection_result {
std::result::Result::Ok(value) => value,
@@ -391,13 +383,13 @@ impl HttpTransportPool {
_concurrency_permit: concurrency_permit,
});
},
crate::resilience::RoleAdmissionAttempt::BlockedUntil(ready_at) => {
crate::RoleAdmissionAttempt::BlockedUntil(ready_at) => {
earliest_ready = earlier_instant(earliest_ready, ready_at);
},
crate::resilience::RoleAdmissionAttempt::ConcurrencySaturated => {
crate::RoleAdmissionAttempt::ConcurrencySaturated => {
concurrency_saturated = true;
},
crate::resilience::RoleAdmissionAttempt::Unavailable => {},
crate::RoleAdmissionAttempt::Unavailable => {},
}
offset = offset.saturating_add(1);
}
@@ -548,6 +540,14 @@ impl std::fmt::Debug for HttpTransportPool {
}
}
struct HttpTransportPoolInner {
clients: std::vec::Vec<crate::HttpEndpointClient>,
retry: crate::HttpRetrySettings,
notify: std::sync::Arc<tokio::sync::Notify>,
request_ids: std::sync::atomic::AtomicU64,
cursors: std::sync::Mutex<std::collections::BTreeMap<(std::string::String, std::string::String, u32), usize>>,
}
#[derive(Clone, Copy)]
struct PoolCandidate {
client_index: usize,
@@ -557,7 +557,7 @@ struct PoolCandidate {
struct RuntimePoolCandidate {
client_index: usize,
priority: u32,
runtime: std::sync::Arc<crate::resilience::HttpRoleRuntime>,
runtime: std::sync::Arc<crate::HttpRoleRuntime>,
}
enum RuntimeSelectionAttempt {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/resilience.rs
// version: 1
// version: 2
const DEFAULT_RATE_LIMIT_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(1);
const MAX_PROVIDER_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(60);
@@ -71,61 +71,6 @@ impl HttpRetryDecision {
}
}
/// Evaluates the centralized bounded HTTP retry policy for one audited RPC method.
///
/// `completed_retries` counts retries already performed after the initial attempt. Provider `Retry-After` values are defensively bounded to sixty seconds
/// before they can extend the local exponential backoff. RPC application errors are never converted into transport retries.
#[must_use]
pub fn evaluate_transport_retry(
method: &crate::HttpRpcMethodDescriptor,
settings: &crate::HttpRetrySettings,
cause: crate::HttpRetryCause,
dispatch_state: crate::HttpDispatchState,
completed_retries: u32,
provider_retry_after: std::option::Option<std::time::Duration>,
) -> crate::HttpRetryDecision {
if completed_retries >= settings.max_retries() || !cause.is_retryable() {
return crate::HttpRetryDecision::Stop;
}
if method.transport_retry_class() == crate::TransportRetryClass::NotApplicable {
return crate::HttpRetryDecision::Stop;
}
if method.transport_retry_class() == crate::TransportRetryClass::NeverAfterDispatch && dispatch_state == crate::HttpDispatchState::DispatchedAmbiguous {
return crate::HttpRetryDecision::Stop;
}
let retry_number = completed_retries.saturating_add(1);
let mut delay = retry_backoff(settings, retry_number);
if cause == crate::HttpRetryCause::RateLimited
&& let std::option::Option::Some(provider_delay) = provider_retry_after
{
let bounded_provider_delay = std::cmp::min(provider_delay, MAX_PROVIDER_RETRY_AFTER);
if bounded_provider_delay > delay {
delay = bounded_provider_delay;
}
}
return crate::HttpRetryDecision::RetryAfter(delay);
}
pub(crate) fn retry_backoff(settings: &crate::HttpRetrySettings, retry_number: u32) -> std::time::Duration {
let mut delay = settings.initial_backoff();
if retry_number <= 1 {
return std::cmp::min(delay, settings.max_backoff());
}
let mut step = 1_u32;
while step < retry_number {
let doubled = match delay.checked_mul(2) {
std::option::Option::Some(value) => value,
std::option::Option::None => settings.max_backoff(),
};
delay = std::cmp::min(doubled, settings.max_backoff());
if delay >= settings.max_backoff() {
return settings.max_backoff();
}
step = step.saturating_add(1);
}
return delay;
}
pub(crate) struct HttpRoleRuntime {
limits: crate::HttpRoleLimits,
bucket: std::sync::Mutex<std::option::Option<HttpTokenBucketState>>,
@@ -216,21 +161,21 @@ impl HttpRoleRuntime {
return self.rate_limit_count.load(std::sync::atomic::Ordering::Relaxed);
}
pub(crate) fn try_acquire(self: &std::sync::Arc<Self>, now: std::time::Instant) -> RoleAdmissionAttempt {
pub(crate) fn try_acquire(self: &std::sync::Arc<Self>, now: std::time::Instant) -> crate::RoleAdmissionAttempt {
if let std::option::Option::Some(remaining) = self.cooldown_remaining_at(now) {
let ready_at = match now.checked_add(remaining) {
std::option::Option::Some(value) => value,
std::option::Option::None => now,
};
return RoleAdmissionAttempt::BlockedUntil(ready_at);
return crate::RoleAdmissionAttempt::BlockedUntil(ready_at);
}
let semaphore_permit = match &self.semaphore {
std::option::Option::Some(semaphore) => {
let permit_result = std::sync::Arc::clone(semaphore).try_acquire_owned();
match permit_result {
std::result::Result::Ok(permit) => std::option::Option::Some(permit),
std::result::Result::Err(tokio::sync::TryAcquireError::NoPermits) => return RoleAdmissionAttempt::ConcurrencySaturated,
std::result::Result::Err(tokio::sync::TryAcquireError::Closed) => return RoleAdmissionAttempt::Unavailable,
std::result::Result::Err(tokio::sync::TryAcquireError::NoPermits) => return crate::RoleAdmissionAttempt::ConcurrencySaturated,
std::result::Result::Err(tokio::sync::TryAcquireError::Closed) => return crate::RoleAdmissionAttempt::Unavailable,
}
},
std::option::Option::None => std::option::Option::None,
@@ -239,9 +184,9 @@ impl HttpRoleRuntime {
if let std::option::Option::Some(ready_at) = token_result {
drop(semaphore_permit);
self.notify.notify_one();
return RoleAdmissionAttempt::BlockedUntil(ready_at);
return crate::RoleAdmissionAttempt::BlockedUntil(ready_at);
}
return RoleAdmissionAttempt::Ready(HttpConcurrencyPermit { semaphore_permit, notify: std::sync::Arc::clone(&self.notify) });
return crate::RoleAdmissionAttempt::Ready(HttpConcurrencyPermit { semaphore_permit, notify: std::sync::Arc::clone(&self.notify) });
}
pub(crate) fn record_success(&self) {
@@ -385,6 +330,61 @@ impl HttpTokenBucketState {
}
}
/// Evaluates the centralized bounded HTTP retry policy for one audited RPC method.
///
/// `completed_retries` counts retries already performed after the initial attempt. Provider `Retry-After` values are defensively bounded to sixty seconds
/// before they can extend the local exponential backoff. RPC application errors are never converted into transport retries.
#[must_use]
pub fn evaluate_transport_retry(
method: &crate::HttpRpcMethodDescriptor,
settings: &crate::HttpRetrySettings,
cause: crate::HttpRetryCause,
dispatch_state: crate::HttpDispatchState,
completed_retries: u32,
provider_retry_after: std::option::Option<std::time::Duration>,
) -> crate::HttpRetryDecision {
if completed_retries >= settings.max_retries() || !cause.is_retryable() {
return crate::HttpRetryDecision::Stop;
}
if method.transport_retry_class() == crate::TransportRetryClass::NotApplicable {
return crate::HttpRetryDecision::Stop;
}
if method.transport_retry_class() == crate::TransportRetryClass::NeverAfterDispatch && dispatch_state == crate::HttpDispatchState::DispatchedAmbiguous {
return crate::HttpRetryDecision::Stop;
}
let retry_number = completed_retries.saturating_add(1);
let mut delay = retry_backoff(settings, retry_number);
if cause == crate::HttpRetryCause::RateLimited
&& let std::option::Option::Some(provider_delay) = provider_retry_after
{
let bounded_provider_delay = std::cmp::min(provider_delay, MAX_PROVIDER_RETRY_AFTER);
if bounded_provider_delay > delay {
delay = bounded_provider_delay;
}
}
return crate::HttpRetryDecision::RetryAfter(delay);
}
fn retry_backoff(settings: &crate::HttpRetrySettings, retry_number: u32) -> std::time::Duration {
let mut delay = settings.initial_backoff();
if retry_number <= 1 {
return std::cmp::min(delay, settings.max_backoff());
}
let mut step = 1_u32;
while step < retry_number {
let doubled = match delay.checked_mul(2) {
std::option::Option::Some(value) => value,
std::option::Option::None => settings.max_backoff(),
};
delay = std::cmp::min(doubled, settings.max_backoff());
if delay >= settings.max_backoff() {
return settings.max_backoff();
}
step = step.saturating_add(1);
}
return delay;
}
#[cfg(test)]
#[path = "../unit_tests/resilience.rs"]
mod tests;

View File

@@ -1,5 +1,9 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
// version: 4
// version: 5
const MAX_MULTIPLE_ACCOUNTS: usize = 100;
const MAX_PROGRAM_ACCOUNT_FILTERS: usize = 4;
const MAX_MEMCMP_BYTES: usize = 128;
/// Account-data encoding accepted by Solana HTTP account methods.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -592,10 +596,6 @@ pub enum SolanaProgramAccountsResult {
Context(crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>),
}
const MAX_MULTIPLE_ACCOUNTS: usize = 100;
const MAX_PROGRAM_ACCOUNT_FILTERS: usize = 4;
const MAX_MEMCMP_BYTES: usize = 128;
impl crate::HttpTransportPool {
/// Executes typed `getAccountInfo` through the common KSP HTTP transport path.
pub async fn get_account_info(
@@ -752,6 +752,58 @@ impl crate::HttpTransportPool {
}
}
#[derive(serde::Deserialize)]
struct WireRpcResponse<T> {
context: serde_json::Value,
value: T,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum WireProgramAccountsResult {
Context(WireRpcResponse<std::vec::Vec<serde_json::Value>>),
Accounts(std::vec::Vec<serde_json::Value>),
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum WireAccountData {
LegacyBinary(std::string::String),
JsonParsed(WireParsedAccountData),
Encoded((std::string::String, std::string::String)),
}
#[derive(serde::Deserialize)]
struct WireParsedAccountData {
program: std::string::String,
parsed: serde_json::Value,
space: u64,
}
#[derive(serde::Deserialize)]
struct WireAccount {
lamports: u64,
data: serde_json::Value,
owner: std::string::String,
executable: bool,
#[serde(rename = "rentEpoch")]
rent_epoch: u64,
#[serde(default)]
space: std::option::Option<u64>,
}
#[derive(serde::Deserialize)]
struct WireKeyedAccount {
pubkey: std::string::String,
account: serde_json::Value,
}
#[derive(serde::Deserialize)]
struct WireAccountBalance {
address: std::string::String,
lamports: u64,
}
fn account_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
let descriptor = crate::find_http_rpc_method(method);
return match descriptor {
@@ -934,58 +986,6 @@ fn decode_keyed_accounts(method: &str, values: std::vec::Vec<serde_json::Value>)
return std::result::Result::Ok(decoded_values);
}
#[derive(serde::Deserialize)]
struct WireRpcResponse<T> {
context: serde_json::Value,
value: T,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum WireProgramAccountsResult {
Context(WireRpcResponse<std::vec::Vec<serde_json::Value>>),
Accounts(std::vec::Vec<serde_json::Value>),
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum WireAccountData {
LegacyBinary(std::string::String),
JsonParsed(WireParsedAccountData),
Encoded((std::string::String, std::string::String)),
}
#[derive(serde::Deserialize)]
struct WireParsedAccountData {
program: std::string::String,
parsed: serde_json::Value,
space: u64,
}
#[derive(serde::Deserialize)]
struct WireAccount {
lamports: u64,
data: serde_json::Value,
owner: std::string::String,
executable: bool,
#[serde(rename = "rentEpoch")]
rent_epoch: u64,
#[serde(default)]
space: std::option::Option<u64>,
}
#[derive(serde::Deserialize)]
struct WireKeyedAccount {
pubkey: std::string::String,
account: serde_json::Value,
}
#[derive(serde::Deserialize)]
struct WireAccountBalance {
address: std::string::String,
lamports: u64,
}
#[cfg(test)]
#[path = "../unit_tests/rpc_accounts.rs"]
mod tests;

View File

@@ -1,5 +1,8 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_blocks.rs
// version: 6
// version: 7
const MAX_GET_BLOCKS_RANGE: u64 = 500_000;
const MAX_GET_RECENT_PERFORMANCE_SAMPLES: u64 = 720;
/// Transaction detail level accepted by modern `getBlock` requests.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
@@ -828,8 +831,85 @@ impl crate::HttpTransportPool {
}
}
const MAX_GET_BLOCKS_RANGE: u64 = 500_000;
const MAX_GET_RECENT_PERFORMANCE_SAMPLES: u64 = 720;
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireBlockCommitment {
commitment: std::option::Option<std::vec::Vec<u64>>,
total_stake: u64,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireBlockProductionRange {
first_slot: u64,
last_slot: u64,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireBlockProduction {
by_identity: std::collections::BTreeMap<std::string::String, (usize, usize)>,
range: WireBlockProductionRange,
}
#[derive(serde::Deserialize)]
struct WireBlockProductionRpcResponse {
context: serde_json::Value,
value: serde_json::Value,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireBlockReward {
pubkey: std::string::String,
lamports: i64,
post_balance: u64,
#[serde(default)]
reward_type: std::option::Option<std::string::String>,
#[serde(default)]
commission: std::option::Option<u8>,
#[serde(default)]
commission_bps: crate::SolanaWireField<u16>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireBlockTransaction {
transaction: serde_json::Value,
#[serde(default)]
meta: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
version: crate::SolanaWireField<serde_json::Value>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireConfirmedBlock {
previous_blockhash: std::string::String,
blockhash: std::string::String,
parent_slot: u64,
#[serde(default)]
transactions: crate::SolanaWireField<std::vec::Vec<serde_json::Value>>,
#[serde(default)]
signatures: crate::SolanaWireField<std::vec::Vec<std::string::String>>,
#[serde(default)]
rewards: crate::SolanaWireField<std::vec::Vec<serde_json::Value>>,
#[serde(default)]
num_reward_partitions: crate::SolanaWireField<u64>,
block_time: std::option::Option<i64>,
block_height: std::option::Option<u64>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WirePerformanceSample {
slot: u64,
num_transactions: u64,
#[serde(default)]
num_non_vote_transactions: std::option::Option<u64>,
num_slots: u64,
sample_period_secs: u16,
}
fn validate_blocks_context_commitment(method: &'static str, config: std::option::Option<&crate::SolanaContextConfig>) -> ksp_core_lib::Result<()> {
if let std::option::Option::Some(config) = config
@@ -978,86 +1058,6 @@ fn decode_block_rewards(
return std::result::Result::Ok(crate::SolanaWireField::Value(rewards));
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireBlockCommitment {
commitment: std::option::Option<std::vec::Vec<u64>>,
total_stake: u64,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireBlockProductionRange {
first_slot: u64,
last_slot: u64,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireBlockProduction {
by_identity: std::collections::BTreeMap<std::string::String, (usize, usize)>,
range: WireBlockProductionRange,
}
#[derive(serde::Deserialize)]
struct WireBlockProductionRpcResponse {
context: serde_json::Value,
value: serde_json::Value,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireBlockReward {
pubkey: std::string::String,
lamports: i64,
post_balance: u64,
#[serde(default)]
reward_type: std::option::Option<std::string::String>,
#[serde(default)]
commission: std::option::Option<u8>,
#[serde(default)]
commission_bps: crate::SolanaWireField<u16>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireBlockTransaction {
transaction: serde_json::Value,
#[serde(default)]
meta: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
version: crate::SolanaWireField<serde_json::Value>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireConfirmedBlock {
previous_blockhash: std::string::String,
blockhash: std::string::String,
parent_slot: u64,
#[serde(default)]
transactions: crate::SolanaWireField<std::vec::Vec<serde_json::Value>>,
#[serde(default)]
signatures: crate::SolanaWireField<std::vec::Vec<std::string::String>>,
#[serde(default)]
rewards: crate::SolanaWireField<std::vec::Vec<serde_json::Value>>,
#[serde(default)]
num_reward_partitions: crate::SolanaWireField<u64>,
block_time: std::option::Option<i64>,
block_height: std::option::Option<u64>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WirePerformanceSample {
slot: u64,
num_transactions: u64,
#[serde(default)]
num_non_vote_transactions: std::option::Option<u64>,
num_slots: u64,
sample_period_secs: u16,
}
#[cfg(test)]
#[path = "../unit_tests/rpc_blocks.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
// version: 4
// version: 5
const MAX_GET_SLOT_LEADERS: u64 = 5_000;
@@ -807,55 +807,6 @@ impl crate::HttpTransportPool {
}
}
fn push_context_config(params: &mut std::vec::Vec<serde_json::Value>, config: std::option::Option<&crate::SolanaContextConfig>) {
if let std::option::Option::Some(config) = config
&& (config.commitment().is_some() || config.min_context_slot().is_some())
{
params.push((*config).to_json_value());
}
return;
}
fn decode_pubkey_list(method: &str, field: &'static str, value: serde_json::Value) -> ksp_core_lib::Result<std::vec::Vec<ksp_core_lib::Pubkey>> {
let decoded = crate::decode_wire_json::<std::vec::Vec<std::string::String>>(method, value);
let values = match decoded {
std::result::Result::Ok(values) => values,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut pubkeys = std::vec::Vec::with_capacity(values.len());
for value in values {
let pubkey = crate::parse_wire_pubkey(method, field, value.as_str());
match pubkey {
std::result::Result::Ok(pubkey) => pubkeys.push(pubkey),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(pubkeys);
}
fn invalid_cluster_parameters<T>(method: &str, message: &str, field: &'static str, value: u64) -> ksp_core_lib::Result<T> {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, message)
.with_context("rpc_method", method)
.with_context(field, value.to_string()),
);
}
fn cluster_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
let descriptor = crate::find_http_rpc_method(method);
return match descriptor {
std::option::Option::Some(descriptor)
if descriptor.category() == crate::HttpRpcCategory::Cluster && descriptor.coverage_release() == crate::HttpRpcCoverageRelease::V0_2_2 =>
{
std::result::Result::Ok(descriptor)
},
_ => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Cluster descriptor is missing from the audited 0.2.2 registry")
.with_context("rpc_method", method),
),
};
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireClusterNode {
@@ -938,6 +889,55 @@ struct WireVoteAccountStatus {
delinquent: std::vec::Vec<serde_json::Value>,
}
fn push_context_config(params: &mut std::vec::Vec<serde_json::Value>, config: std::option::Option<&crate::SolanaContextConfig>) {
if let std::option::Option::Some(config) = config
&& (config.commitment().is_some() || config.min_context_slot().is_some())
{
params.push((*config).to_json_value());
}
return;
}
fn decode_pubkey_list(method: &str, field: &'static str, value: serde_json::Value) -> ksp_core_lib::Result<std::vec::Vec<ksp_core_lib::Pubkey>> {
let decoded = crate::decode_wire_json::<std::vec::Vec<std::string::String>>(method, value);
let values = match decoded {
std::result::Result::Ok(values) => values,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut pubkeys = std::vec::Vec::with_capacity(values.len());
for value in values {
let pubkey = crate::parse_wire_pubkey(method, field, value.as_str());
match pubkey {
std::result::Result::Ok(pubkey) => pubkeys.push(pubkey),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(pubkeys);
}
fn invalid_cluster_parameters<T>(method: &str, message: &str, field: &'static str, value: u64) -> ksp_core_lib::Result<T> {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, message)
.with_context("rpc_method", method)
.with_context(field, value.to_string()),
);
}
fn cluster_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
let descriptor = crate::find_http_rpc_method(method);
return match descriptor {
std::option::Option::Some(descriptor)
if descriptor.category() == crate::HttpRpcCategory::Cluster && descriptor.coverage_release() == crate::HttpRpcCoverageRelease::V0_2_2 =>
{
std::result::Result::Ok(descriptor)
},
_ => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Cluster descriptor is missing from the audited 0.2.2 registry")
.with_context("rpc_method", method),
),
};
}
#[cfg(test)]
#[path = "../unit_tests/rpc_cluster.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_common.rs
// version: 4
// version: 5
/// Commitment level accepted by typed Solana HTTP RPC adapters.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -155,6 +155,13 @@ impl<T> SolanaRpcResponse<T> {
}
}
#[derive(serde::Deserialize)]
struct WireRpcContext {
slot: u64,
#[serde(rename = "apiVersion", default)]
api_version: std::option::Option<std::string::String>,
}
/// Decodes one private serde wire type and maps shape failures to the shared Transport error domain.
pub(crate) fn decode_wire_json<T: serde::de::DeserializeOwned>(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<T> {
let decoded = serde_json::from_value::<T>(value);
@@ -181,13 +188,6 @@ pub(crate) fn parse_wire_pubkey(method: &str, field: &str, value: &str) -> ksp_c
};
}
#[derive(serde::Deserialize)]
struct WireRpcContext {
slot: u64,
#[serde(rename = "apiVersion", default)]
api_version: std::option::Option<std::string::String>,
}
#[cfg(test)]
#[path = "../unit_tests/rpc_common.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_economics.rs
// version: 5
// version: 6
/// Inflation-governor values returned by `getInflationGovernor`.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -441,6 +441,53 @@ impl crate::HttpTransportPool {
}
}
#[derive(serde::Deserialize)]
struct WireEconomicsRpcResponse<T> {
context: serde_json::Value,
value: T,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireInflationGovernor {
initial: f64,
terminal: f64,
taper: f64,
foundation: f64,
foundation_term: f64,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireInflationRate {
total: f64,
validator: f64,
foundation: f64,
epoch: u64,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireInflationReward {
epoch: u64,
effective_slot: u64,
amount: u64,
post_balance: u64,
#[serde(default)]
commission: std::option::Option<u8>,
#[serde(default)]
commission_bps: crate::SolanaWireField<u16>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireSupply {
total: u64,
circulating: u64,
non_circulating: u64,
non_circulating_accounts: std::vec::Vec<std::string::String>,
}
fn push_economics_commitment_config(params: &mut std::vec::Vec<serde_json::Value>, config: std::option::Option<&crate::SolanaCommitmentConfig>) {
if let std::option::Option::Some(config) = config
&& config.commitment().is_some()
@@ -539,53 +586,6 @@ fn economics_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::Ht
};
}
#[derive(serde::Deserialize)]
struct WireEconomicsRpcResponse<T> {
context: serde_json::Value,
value: T,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireInflationGovernor {
initial: f64,
terminal: f64,
taper: f64,
foundation: f64,
foundation_term: f64,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireInflationRate {
total: f64,
validator: f64,
foundation: f64,
epoch: u64,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireInflationReward {
epoch: u64,
effective_slot: u64,
amount: u64,
post_balance: u64,
#[serde(default)]
commission: std::option::Option<u8>,
#[serde(default)]
commission_bps: crate::SolanaWireField<u16>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireSupply {
total: u64,
circulating: u64,
non_circulating: u64,
non_circulating_accounts: std::vec::Vec<std::string::String>,
}
#[cfg(test)]
#[path = "../unit_tests/rpc_economics.rs"]
mod tests;

View File

@@ -1,281 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_method.rs
// version: 2
/// Functional category used by the audited Solana HTTP JSON-RPC registry.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum HttpRpcCategory {
/// Account state and rent queries.
Accounts,
/// SPL token-oriented RPC queries exposed by the standard Solana HTTP surface.
Tokens,
/// Transaction, signature, fee, simulation and submission methods.
Transactions,
/// Block, slot-history and performance-sample methods.
Blocks,
/// Cluster identity, epoch, leader, health and validator methods.
Cluster,
/// Supply, inflation and stake-economics methods.
Economics,
/// Historically documented methods removed from the targeted Agave runtime generation.
Historical,
}
/// Documentation lifecycle status of one audited RPC method.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RpcDocumentationStatus {
/// The method is documented as stable.
Stable,
/// The method is documented as deprecated or obsolete.
Deprecated,
/// The method is documented as unstable or experimental.
Unstable,
}
impl RpcDocumentationStatus {
/// Returns the stable machine-readable status code.
#[must_use]
pub const fn code(self) -> &'static str {
return match self {
Self::Stable => "stable",
Self::Deprecated => "deprecated",
Self::Unstable => "unstable",
};
}
}
/// Runtime availability status of one audited RPC method.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RpcRuntimeStatus {
/// The targeted runtime generation still supports the method.
Supported,
/// The method is historically documented but removed from the targeted runtime generation.
Removed,
}
impl RpcRuntimeStatus {
/// Returns the stable machine-readable runtime status code.
#[must_use]
pub const fn code(self) -> &'static str {
return match self {
Self::Supported => "supported",
Self::Removed => "removed",
};
}
}
/// Request-form policy attached to a stable RPC method.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RpcRequestFormStatus {
/// Only the currently documented stable request form is tracked by KSP.
Stable,
/// The method is stable but also has a documented deprecated legacy request form that must warn when explicitly used.
StableWithDeprecatedLegacy,
}
impl RpcRequestFormStatus {
/// Returns whether the method has a documented deprecated legacy request form.
#[must_use]
pub const fn has_deprecated_legacy(self) -> bool {
return match self {
Self::Stable => false,
Self::StableWithDeprecatedLegacy => true,
};
}
}
/// Technical operation kind used to separate reads, simulations and submissions.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RpcOperationKind {
/// Read-only RPC operation.
Read,
/// Simulation operation that does not submit a transaction for execution.
Simulation,
/// Technical write/submission operation whose ambiguous post-dispatch outcome must not be resent automatically.
WriteSubmission,
}
/// HTTP transport retry classification attached to an RPC method descriptor.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum TransportRetryClass {
/// The identical transport request may be retried when the transport failure is classified as retryable.
RetrySafe,
/// The request must not be resent automatically after an ambiguous dispatch.
NeverAfterDispatch,
/// Retry classification does not apply because the method is not callable on the targeted runtime.
NotApplicable,
}
/// Release that owns the typed KSP coverage for one audited current HTTP method.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum HttpRpcCoverageRelease {
/// `0.2.1` HTTP foundation and four canary methods.
V0_2_1,
/// `0.2.2` Accounts + Tokens + remaining Cluster methods.
V0_2_2,
/// `0.2.3` Transactions methods.
V0_2_3,
/// `0.2.4` Blocks + Economics methods and final HTTP compliance.
V0_2_4,
/// Historical registry entry with no callable typed release.
Historical,
}
/// Immutable audited descriptor for one Solana HTTP JSON-RPC method.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct HttpRpcMethodDescriptor {
method: &'static str,
category: crate::HttpRpcCategory,
request_kind: &'static str,
documentation_status: crate::RpcDocumentationStatus,
runtime_status: crate::RpcRuntimeStatus,
request_form_status: crate::RpcRequestFormStatus,
operation_kind: crate::RpcOperationKind,
transport_retry_class: crate::TransportRetryClass,
replacement: std::option::Option<&'static str>,
coverage_release: crate::HttpRpcCoverageRelease,
}
impl HttpRpcMethodDescriptor {
const fn new(
method: &'static str,
category: crate::HttpRpcCategory,
request_kind: &'static str,
documentation_status: crate::RpcDocumentationStatus,
runtime_status: crate::RpcRuntimeStatus,
request_form_status: crate::RpcRequestFormStatus,
operation_kind: crate::RpcOperationKind,
transport_retry_class: crate::TransportRetryClass,
replacement: std::option::Option<&'static str>,
coverage_release: crate::HttpRpcCoverageRelease,
) -> Self {
return Self {
method,
category,
request_kind,
documentation_status,
runtime_status,
request_form_status,
operation_kind,
transport_retry_class,
replacement,
coverage_release,
};
}
/// Returns the exact JSON-RPC method name.
#[must_use]
pub const fn method(&self) -> &'static str {
return self.method;
}
/// Returns the audited functional category.
#[must_use]
pub const fn category(&self) -> crate::HttpRpcCategory {
return self.category;
}
/// Returns the stable open request-kind descriptor used by future endpoint role matching.
#[must_use]
pub const fn request_kind(&self) -> &'static str {
return self.request_kind;
}
/// Returns the method documentation lifecycle status.
#[must_use]
pub const fn documentation_status(&self) -> crate::RpcDocumentationStatus {
return self.documentation_status;
}
/// Returns runtime availability on the targeted Agave generation.
#[must_use]
pub const fn runtime_status(&self) -> crate::RpcRuntimeStatus {
return self.runtime_status;
}
/// Returns the request-form policy, including whether a documented deprecated legacy form exists.
#[must_use]
pub const fn request_form_status(&self) -> crate::RpcRequestFormStatus {
return self.request_form_status;
}
/// Returns the technical operation kind.
#[must_use]
pub const fn operation_kind(&self) -> crate::RpcOperationKind {
return self.operation_kind;
}
/// Returns the transport retry classification.
#[must_use]
pub const fn transport_retry_class(&self) -> crate::TransportRetryClass {
return self.transport_retry_class;
}
/// Returns the documented replacement or migration direction when one exists.
#[must_use]
pub const fn replacement(&self) -> std::option::Option<&'static str> {
return self.replacement;
}
/// Returns the release assigned to typed KSP coverage.
#[must_use]
pub const fn coverage_release(&self) -> crate::HttpRpcCoverageRelease {
return self.coverage_release;
}
/// Returns whether calling the method itself must emit a lifecycle warning when runtime support exists.
#[must_use]
pub const fn requires_method_usage_warning(&self) -> bool {
return match self.runtime_status {
crate::RpcRuntimeStatus::Removed => false,
crate::RpcRuntimeStatus::Supported => match self.documentation_status {
crate::RpcDocumentationStatus::Stable => false,
crate::RpcDocumentationStatus::Deprecated | crate::RpcDocumentationStatus::Unstable => true,
},
};
}
/// Checks runtime support and centrally emits the KSP warning required for deprecated, unstable or removed methods.
///
/// Removed methods return [`crate::ERROR_CODE_METHOD_REMOVED`] and are never presented as callable standard RPC operations.
pub fn ensure_runtime_supported(&self) -> ksp_core_lib::Result<()> {
if self.runtime_status == crate::RpcRuntimeStatus::Removed {
let replacement = match self.replacement {
std::option::Option::Some(replacement) => replacement,
std::option::Option::None => "none",
};
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
rpc_method = self.method,
documentation_status = self.documentation_status.code(),
runtime_status = self.runtime_status.code(),
replacement,
"removed Solana HTTP RPC method requested"
);
let mut error = ksp_core_lib::Error::new(crate::ERROR_CODE_METHOD_REMOVED, "Solana HTTP RPC method is removed from the targeted runtime")
.with_context("rpc_method", self.method)
.with_context("documentation_status", self.documentation_status.code());
if let std::option::Option::Some(replacement) = self.replacement {
error = error.with_context("replacement", replacement);
}
return std::result::Result::Err(error);
}
if self.requires_method_usage_warning() {
let replacement = match self.replacement {
std::option::Option::Some(replacement) => replacement,
std::option::Option::None => "none",
};
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
rpc_method = self.method,
documentation_status = self.documentation_status.code(),
runtime_status = self.runtime_status.code(),
replacement,
"non-stable Solana HTTP RPC method requested"
);
}
return std::result::Result::Ok(());
}
}
// version: 3
const CURRENT_HTTP_RPC_METHODS: [crate::HttpRpcMethodDescriptor; 52] = [
crate::HttpRpcMethodDescriptor::new(
@@ -1075,6 +799,282 @@ const HISTORICAL_HTTP_RPC_METHODS: [crate::HttpRpcMethodDescriptor; 14] = [
),
];
/// Functional category used by the audited Solana HTTP JSON-RPC registry.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum HttpRpcCategory {
/// Account state and rent queries.
Accounts,
/// SPL token-oriented RPC queries exposed by the standard Solana HTTP surface.
Tokens,
/// Transaction, signature, fee, simulation and submission methods.
Transactions,
/// Block, slot-history and performance-sample methods.
Blocks,
/// Cluster identity, epoch, leader, health and validator methods.
Cluster,
/// Supply, inflation and stake-economics methods.
Economics,
/// Historically documented methods removed from the targeted Agave runtime generation.
Historical,
}
/// Documentation lifecycle status of one audited RPC method.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RpcDocumentationStatus {
/// The method is documented as stable.
Stable,
/// The method is documented as deprecated or obsolete.
Deprecated,
/// The method is documented as unstable or experimental.
Unstable,
}
impl RpcDocumentationStatus {
/// Returns the stable machine-readable status code.
#[must_use]
pub const fn code(self) -> &'static str {
return match self {
Self::Stable => "stable",
Self::Deprecated => "deprecated",
Self::Unstable => "unstable",
};
}
}
/// Runtime availability status of one audited RPC method.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RpcRuntimeStatus {
/// The targeted runtime generation still supports the method.
Supported,
/// The method is historically documented but removed from the targeted runtime generation.
Removed,
}
impl RpcRuntimeStatus {
/// Returns the stable machine-readable runtime status code.
#[must_use]
pub const fn code(self) -> &'static str {
return match self {
Self::Supported => "supported",
Self::Removed => "removed",
};
}
}
/// Request-form policy attached to a stable RPC method.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RpcRequestFormStatus {
/// Only the currently documented stable request form is tracked by KSP.
Stable,
/// The method is stable but also has a documented deprecated legacy request form that must warn when explicitly used.
StableWithDeprecatedLegacy,
}
impl RpcRequestFormStatus {
/// Returns whether the method has a documented deprecated legacy request form.
#[must_use]
pub const fn has_deprecated_legacy(self) -> bool {
return match self {
Self::Stable => false,
Self::StableWithDeprecatedLegacy => true,
};
}
}
/// Technical operation kind used to separate reads, simulations and submissions.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RpcOperationKind {
/// Read-only RPC operation.
Read,
/// Simulation operation that does not submit a transaction for execution.
Simulation,
/// Technical write/submission operation whose ambiguous post-dispatch outcome must not be resent automatically.
WriteSubmission,
}
/// HTTP transport retry classification attached to an RPC method descriptor.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum TransportRetryClass {
/// The identical transport request may be retried when the transport failure is classified as retryable.
RetrySafe,
/// The request must not be resent automatically after an ambiguous dispatch.
NeverAfterDispatch,
/// Retry classification does not apply because the method is not callable on the targeted runtime.
NotApplicable,
}
/// Release that owns the typed KSP coverage for one audited current HTTP method.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum HttpRpcCoverageRelease {
/// `0.2.1` HTTP foundation and four canary methods.
V0_2_1,
/// `0.2.2` Accounts + Tokens + remaining Cluster methods.
V0_2_2,
/// `0.2.3` Transactions methods.
V0_2_3,
/// `0.2.4` Blocks + Economics methods and final HTTP compliance.
V0_2_4,
/// Historical registry entry with no callable typed release.
Historical,
}
/// Immutable audited descriptor for one Solana HTTP JSON-RPC method.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct HttpRpcMethodDescriptor {
method: &'static str,
category: crate::HttpRpcCategory,
request_kind: &'static str,
documentation_status: crate::RpcDocumentationStatus,
runtime_status: crate::RpcRuntimeStatus,
request_form_status: crate::RpcRequestFormStatus,
operation_kind: crate::RpcOperationKind,
transport_retry_class: crate::TransportRetryClass,
replacement: std::option::Option<&'static str>,
coverage_release: crate::HttpRpcCoverageRelease,
}
impl HttpRpcMethodDescriptor {
const fn new(
method: &'static str,
category: crate::HttpRpcCategory,
request_kind: &'static str,
documentation_status: crate::RpcDocumentationStatus,
runtime_status: crate::RpcRuntimeStatus,
request_form_status: crate::RpcRequestFormStatus,
operation_kind: crate::RpcOperationKind,
transport_retry_class: crate::TransportRetryClass,
replacement: std::option::Option<&'static str>,
coverage_release: crate::HttpRpcCoverageRelease,
) -> Self {
return Self {
method,
category,
request_kind,
documentation_status,
runtime_status,
request_form_status,
operation_kind,
transport_retry_class,
replacement,
coverage_release,
};
}
/// Returns the exact JSON-RPC method name.
#[must_use]
pub const fn method(&self) -> &'static str {
return self.method;
}
/// Returns the audited functional category.
#[must_use]
pub const fn category(&self) -> crate::HttpRpcCategory {
return self.category;
}
/// Returns the stable open request-kind descriptor used by future endpoint role matching.
#[must_use]
pub const fn request_kind(&self) -> &'static str {
return self.request_kind;
}
/// Returns the method documentation lifecycle status.
#[must_use]
pub const fn documentation_status(&self) -> crate::RpcDocumentationStatus {
return self.documentation_status;
}
/// Returns runtime availability on the targeted Agave generation.
#[must_use]
pub const fn runtime_status(&self) -> crate::RpcRuntimeStatus {
return self.runtime_status;
}
/// Returns the request-form policy, including whether a documented deprecated legacy form exists.
#[must_use]
pub const fn request_form_status(&self) -> crate::RpcRequestFormStatus {
return self.request_form_status;
}
/// Returns the technical operation kind.
#[must_use]
pub const fn operation_kind(&self) -> crate::RpcOperationKind {
return self.operation_kind;
}
/// Returns the transport retry classification.
#[must_use]
pub const fn transport_retry_class(&self) -> crate::TransportRetryClass {
return self.transport_retry_class;
}
/// Returns the documented replacement or migration direction when one exists.
#[must_use]
pub const fn replacement(&self) -> std::option::Option<&'static str> {
return self.replacement;
}
/// Returns the release assigned to typed KSP coverage.
#[must_use]
pub const fn coverage_release(&self) -> crate::HttpRpcCoverageRelease {
return self.coverage_release;
}
/// Returns whether calling the method itself must emit a lifecycle warning when runtime support exists.
#[must_use]
pub const fn requires_method_usage_warning(&self) -> bool {
return match self.runtime_status {
crate::RpcRuntimeStatus::Removed => false,
crate::RpcRuntimeStatus::Supported => match self.documentation_status {
crate::RpcDocumentationStatus::Stable => false,
crate::RpcDocumentationStatus::Deprecated | crate::RpcDocumentationStatus::Unstable => true,
},
};
}
/// Checks runtime support and centrally emits the KSP warning required for deprecated, unstable or removed methods.
///
/// Removed methods return [`crate::ERROR_CODE_METHOD_REMOVED`] and are never presented as callable standard RPC operations.
pub fn ensure_runtime_supported(&self) -> ksp_core_lib::Result<()> {
if self.runtime_status == crate::RpcRuntimeStatus::Removed {
let replacement = match self.replacement {
std::option::Option::Some(replacement) => replacement,
std::option::Option::None => "none",
};
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
rpc_method = self.method,
documentation_status = self.documentation_status.code(),
runtime_status = self.runtime_status.code(),
replacement,
"removed Solana HTTP RPC method requested"
);
let mut error = ksp_core_lib::Error::new(crate::ERROR_CODE_METHOD_REMOVED, "Solana HTTP RPC method is removed from the targeted runtime")
.with_context("rpc_method", self.method)
.with_context("documentation_status", self.documentation_status.code());
if let std::option::Option::Some(replacement) = self.replacement {
error = error.with_context("replacement", replacement);
}
return std::result::Result::Err(error);
}
if self.requires_method_usage_warning() {
let replacement = match self.replacement {
std::option::Option::Some(replacement) => replacement,
std::option::Option::None => "none",
};
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
rpc_method = self.method,
documentation_status = self.documentation_status.code(),
runtime_status = self.runtime_status.code(),
replacement,
"non-stable Solana HTTP RPC method requested"
);
}
return std::result::Result::Ok(());
}
}
/// Returns all 52 current Solana HTTP RPC method descriptors audited for the `0.2.1``0.2.4` coverage sequence.
#[must_use]
pub const fn current_http_rpc_methods() -> &'static [crate::HttpRpcMethodDescriptor] {

View File

@@ -1,5 +1,9 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_transactions.rs
// version: 8
// version: 9
const MAX_RECENT_PRIORITIZATION_FEE_ACCOUNTS: usize = 128;
const MAX_SIGNATURES_FOR_ADDRESS_LIMIT: usize = 1_000;
const MAX_SIGNATURE_STATUSES: usize = 256;
/// Binary encoding accepted for serialized transaction input payloads.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -1083,10 +1087,6 @@ impl SolanaSimulateTransactionResult {
}
}
const MAX_RECENT_PRIORITIZATION_FEE_ACCOUNTS: usize = 128;
const MAX_SIGNATURES_FOR_ADDRESS_LIMIT: usize = 1_000;
const MAX_SIGNATURE_STATUSES: usize = 256;
impl crate::HttpTransportPool {
/// Executes typed `getFeeForMessage` through the common KSP HTTP transport path.
pub async fn get_fee_for_message(
@@ -1469,6 +1469,104 @@ impl crate::HttpTransportPool {
}
}
#[derive(serde::Deserialize)]
struct WireTransactionRpcResponse {
context: serde_json::Value,
value: serde_json::Value,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireLatestBlockhash {
blockhash: std::string::String,
last_valid_block_height: u64,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WirePrioritizationFee {
slot: u64,
prioritization_fee: u64,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireSignatureInfo {
signature: std::string::String,
slot: u64,
#[serde(default)]
err: std::option::Option<serde_json::Value>,
#[serde(default)]
memo: std::option::Option<std::string::String>,
#[serde(default)]
block_time: std::option::Option<i64>,
#[serde(default)]
confirmation_status: std::option::Option<std::string::String>,
#[serde(default)]
transaction_index: std::option::Option<u32>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireSignatureStatus {
slot: u64,
#[serde(default)]
confirmations: std::option::Option<u64>,
status: serde_json::Value,
#[serde(default)]
err: std::option::Option<serde_json::Value>,
#[serde(default)]
confirmation_status: std::option::Option<std::string::String>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireConfirmedTransaction {
slot: u64,
transaction: serde_json::Value,
#[serde(default)]
meta: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
block_time: std::option::Option<i64>,
#[serde(default)]
version: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
transaction_index: crate::SolanaWireField<u32>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireSimulateTransactionResult {
#[serde(default)]
err: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
logs: crate::SolanaWireField<std::vec::Vec<std::string::String>>,
#[serde(default)]
accounts: crate::SolanaWireField<std::vec::Vec<std::option::Option<serde_json::Value>>>,
#[serde(default)]
units_consumed: crate::SolanaWireField<u64>,
#[serde(default)]
loaded_accounts_data_size: crate::SolanaWireField<u32>,
#[serde(default)]
return_data: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
inner_instructions: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
replacement_blockhash: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
fee: crate::SolanaWireField<u64>,
#[serde(default)]
pre_balances: crate::SolanaWireField<std::vec::Vec<u64>>,
#[serde(default)]
post_balances: crate::SolanaWireField<std::vec::Vec<u64>>,
#[serde(default)]
pre_token_balances: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
post_token_balances: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
loaded_addresses: crate::SolanaWireField<serde_json::Value>,
}
fn push_transaction_context_config(params: &mut std::vec::Vec<serde_json::Value>, config: std::option::Option<&crate::SolanaContextConfig>) {
if let std::option::Option::Some(config) = config
&& (config.commitment().is_some() || config.min_context_slot().is_some())
@@ -1584,12 +1682,6 @@ fn transaction_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::
};
}
#[derive(serde::Deserialize)]
struct WireTransactionRpcResponse {
context: serde_json::Value,
value: serde_json::Value,
}
fn invalid_transaction_wire(method: &str, field: &str, message: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("rpc_method", method).with_context("field", field);
}
@@ -1711,98 +1803,6 @@ fn decode_replacement_blockhash_field(
};
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireLatestBlockhash {
blockhash: std::string::String,
last_valid_block_height: u64,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WirePrioritizationFee {
slot: u64,
prioritization_fee: u64,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireSignatureInfo {
signature: std::string::String,
slot: u64,
#[serde(default)]
err: std::option::Option<serde_json::Value>,
#[serde(default)]
memo: std::option::Option<std::string::String>,
#[serde(default)]
block_time: std::option::Option<i64>,
#[serde(default)]
confirmation_status: std::option::Option<std::string::String>,
#[serde(default)]
transaction_index: std::option::Option<u32>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireSignatureStatus {
slot: u64,
#[serde(default)]
confirmations: std::option::Option<u64>,
status: serde_json::Value,
#[serde(default)]
err: std::option::Option<serde_json::Value>,
#[serde(default)]
confirmation_status: std::option::Option<std::string::String>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireConfirmedTransaction {
slot: u64,
transaction: serde_json::Value,
#[serde(default)]
meta: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
block_time: std::option::Option<i64>,
#[serde(default)]
version: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
transaction_index: crate::SolanaWireField<u32>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireSimulateTransactionResult {
#[serde(default)]
err: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
logs: crate::SolanaWireField<std::vec::Vec<std::string::String>>,
#[serde(default)]
accounts: crate::SolanaWireField<std::vec::Vec<std::option::Option<serde_json::Value>>>,
#[serde(default)]
units_consumed: crate::SolanaWireField<u64>,
#[serde(default)]
loaded_accounts_data_size: crate::SolanaWireField<u32>,
#[serde(default)]
return_data: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
inner_instructions: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
replacement_blockhash: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
fee: crate::SolanaWireField<u64>,
#[serde(default)]
pre_balances: crate::SolanaWireField<std::vec::Vec<u64>>,
#[serde(default)]
post_balances: crate::SolanaWireField<std::vec::Vec<u64>>,
#[serde(default)]
pre_token_balances: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
post_token_balances: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
loaded_addresses: crate::SolanaWireField<serde_json::Value>,
}
#[cfg(test)]
#[path = "../unit_tests/rpc_transactions.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/client.rs
// version: 2
// version: 3
fn endpoint(enabled: bool, url_text: &str) -> crate::HttpEndpointSettings {
let url = crate::HttpEndpointUrl::parse(url_text).expect("test endpoint URL must parse");
@@ -25,7 +25,7 @@ fn endpoint(enabled: bool, url_text: &str) -> crate::HttpEndpointSettings {
#[test]
fn endpoint_client_snapshot_never_contains_url_or_secret_material() {
let client = super::HttpEndpointClient::new(endpoint(true, "https://provider.invalid/rpc?api-key=SECRET-CANARY")).expect("client must build");
let client = crate::HttpEndpointClient::new(endpoint(true, "https://provider.invalid/rpc?api-key=SECRET-CANARY")).expect("client must build");
let snapshot = client.snapshot();
let rendered = format!("{snapshot:?} {client:?}");
assert_eq!(snapshot.availability(), crate::HttpEndpointAvailability::Available);
@@ -36,21 +36,21 @@ fn endpoint_client_snapshot_never_contains_url_or_secret_material() {
#[test]
fn disabled_endpoint_client_is_visible_but_not_selectable() {
let client = super::HttpEndpointClient::new(endpoint(false, "https://api.devnet.solana.com")).expect("disabled client must still build");
let client = crate::HttpEndpointClient::new(endpoint(false, "https://api.devnet.solana.com")).expect("disabled client must still build");
assert_eq!(client.snapshot().availability(), crate::HttpEndpointAvailability::Disabled);
assert!(!client.supports(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance")));
}
#[test]
fn endpoint_client_matches_exact_and_wildcard_capabilities() {
let client = super::HttpEndpointClient::new(endpoint(true, "https://api.devnet.solana.com")).expect("client must build");
let client = crate::HttpEndpointClient::new(endpoint(true, "https://api.devnet.solana.com")).expect("client must build");
assert!(client.supports(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance")));
assert!(!client.supports(&crate::HttpRoleName::new("write"), &crate::HttpRequestKind::new("get_balance")));
}
#[test]
fn endpoint_role_snapshot_exposes_safe_resilience_state() {
let client = super::HttpEndpointClient::new(endpoint(true, "https://api.devnet.solana.com")).expect("client must build");
let client = crate::HttpEndpointClient::new(endpoint(true, "https://api.devnet.solana.com")).expect("client must build");
let snapshot = client.snapshot();
let role = &snapshot.roles()[0];
assert_eq!(role.availability(), crate::HttpEndpointAvailability::Available);

View File

@@ -1,9 +1,9 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/json_rpc.rs
// version: 2
// version: 3
#[test]
fn request_serialization_matches_json_rpc_2_0_shape() {
let request = super::JsonRpcRequest::new(7, "getBalance", std::vec![serde_json::json!("Address111"), serde_json::json!({"commitment":"confirmed"})])
let request = crate::JsonRpcRequest::new(7, "getBalance", std::vec![serde_json::json!("Address111"), serde_json::json!({"commitment":"confirmed"})])
.expect("valid request must construct");
let encoded = request.to_json_string().expect("serializable request must encode");
let value: serde_json::Value = serde_json::from_str(encoded.as_str()).expect("encoded request must remain JSON");
@@ -15,28 +15,28 @@ fn request_serialization_matches_json_rpc_2_0_shape() {
#[test]
fn request_rejects_empty_or_untrimmed_method() {
assert!(super::JsonRpcRequest::new(1, "", std::vec![]).is_err());
assert!(super::JsonRpcRequest::new(1, " getHealth", std::vec![]).is_err());
assert!(crate::JsonRpcRequest::new(1, "", std::vec![]).is_err());
assert!(crate::JsonRpcRequest::new(1, " getHealth", std::vec![]).is_err());
}
#[test]
fn response_parser_preserves_null_success_result() {
let response = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":null,"id":9}"#, 9).expect("null result is a valid success payload");
assert!(matches!(&response, super::JsonRpcResponse::Success(_)), "success response must not parse as error");
if let super::JsonRpcResponse::Success(success) = response {
let response = crate::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":null,"id":9}"#, 9).expect("null result is a valid success payload");
assert!(matches!(&response, crate::JsonRpcResponse::Success(_)), "success response must not parse as error");
if let crate::JsonRpcResponse::Success(success) = response {
assert!(success.result().is_null());
}
}
#[test]
fn response_parser_preserves_rpc_error_payload() {
let response = super::parse_json_rpc_response_text(
let response = crate::parse_json_rpc_response_text(
r#"{"jsonrpc":"2.0","error":{"code":-32005,"message":"Node is unhealthy","data":{"numSlotsBehind":12}},"id":4}"#,
4,
)
.expect("valid JSON-RPC error envelope must parse");
assert!(matches!(&response, super::JsonRpcResponse::Error(_)), "RPC error response must not parse as success");
if let super::JsonRpcResponse::Error(error_response) = response {
assert!(matches!(&response, crate::JsonRpcResponse::Error(_)), "RPC error response must not parse as success");
if let crate::JsonRpcResponse::Error(error_response) = response {
assert_eq!(error_response.error().code(), -32005);
assert_eq!(error_response.error().message(), "Node is unhealthy");
assert_eq!(error_response.error().data(), std::option::Option::Some(&serde_json::json!({"numSlotsBehind":12})));
@@ -45,38 +45,38 @@ fn response_parser_preserves_rpc_error_payload() {
#[test]
fn response_parser_rejects_id_mismatch() {
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":"ok","id":2}"#, 1).expect_err("mismatched id must fail");
let error = crate::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":"ok","id":2}"#, 1).expect_err("mismatched id must fail");
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
}
#[test]
fn response_parser_rejects_wrong_protocol_version() {
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"1.0","result":"ok","id":1}"#, 1).expect_err("wrong version must fail");
let error = crate::parse_json_rpc_response_text(r#"{"jsonrpc":"1.0","result":"ok","id":1}"#, 1).expect_err("wrong version must fail");
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
}
#[test]
fn response_parser_rejects_both_result_and_error() {
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":"ok","error":{"code":-1,"message":"bad"},"id":1}"#, 1)
let error = crate::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":"ok","error":{"code":-1,"message":"bad"},"id":1}"#, 1)
.expect_err("mutually exclusive fields must fail");
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
}
#[test]
fn response_parser_rejects_missing_result_and_error() {
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","id":1}"#, 1).expect_err("missing outcome must fail");
let error = crate::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","id":1}"#, 1).expect_err("missing outcome must fail");
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
}
#[test]
fn response_parser_distinguishes_invalid_json_from_protocol_error() {
let error = super::parse_json_rpc_response_text("not-json", 1).expect_err("invalid JSON must fail decoding");
let error = crate::parse_json_rpc_response_text("not-json", 1).expect_err("invalid JSON must fail decoding");
assert_eq!(error.code(), crate::ERROR_CODE_JSON_DECODE_FAILED);
}
#[test]
fn rpc_error_maps_to_shared_ksp_error_without_copying_remote_payload_into_context() {
let response = super::parse_json_rpc_response_text(
let response = crate::parse_json_rpc_response_text(
r#"{"jsonrpc":"2.0","error":{"code":-32000,"message":"SECRET-CANARY","data":{"payload":"SECRET-DATA"}},"id":1}"#,
1,
)
@@ -90,7 +90,7 @@ fn rpc_error_maps_to_shared_ksp_error_without_copying_remote_payload_into_contex
#[test]
fn request_debug_omits_parameter_payloads() {
let request = super::JsonRpcRequest::new(1, "sendTransaction", std::vec![serde_json::json!("SIGNED-TRANSACTION-SECRET-CANARY")])
let request = crate::JsonRpcRequest::new(1, "sendTransaction", std::vec![serde_json::json!("SIGNED-TRANSACTION-SECRET-CANARY")])
.expect("test request must construct");
let rendered = format!("{request:?}");
assert!(rendered.contains("sendTransaction"));
@@ -100,11 +100,11 @@ fn request_debug_omits_parameter_payloads() {
#[test]
fn response_debug_omits_result_and_remote_error_payloads() {
let success = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":{"secret":"RESULT-SECRET-CANARY"},"id":1}"#, 1)
let success = crate::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":{"secret":"RESULT-SECRET-CANARY"},"id":1}"#, 1)
.expect("test success response must parse");
let success_rendered = format!("{success:?}");
assert!(!success_rendered.contains("RESULT-SECRET-CANARY"));
let failure = super::parse_json_rpc_response_text(
let failure = crate::parse_json_rpc_response_text(
r#"{"jsonrpc":"2.0","error":{"code":-32000,"message":"MESSAGE-SECRET-CANARY","data":{"secret":"DATA-SECRET-CANARY"}},"id":2}"#,
2,
)

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/pool.rs
// version: 3
// version: 4
fn role(name: &str, priority: u32, request_kinds: std::vec::Vec<crate::HttpRequestKind>) -> crate::HttpEndpointRoleSettings {
return crate::HttpEndpointRoleSettings::new(
@@ -66,7 +66,7 @@ fn settings(endpoints: std::vec::Vec<crate::HttpEndpointSettings>) -> crate::Htt
#[test]
fn pool_prefers_lowest_priority_tier() {
let pool = super::HttpTransportPool::new(settings(std::vec![
let pool = crate::HttpTransportPool::new(settings(std::vec![
endpoint("secondary", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
endpoint("primary", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
]))
@@ -80,7 +80,7 @@ fn pool_prefers_lowest_priority_tier() {
#[test]
fn pool_round_robins_fairly_inside_best_priority_tier() {
let pool = super::HttpTransportPool::new(settings(std::vec![
let pool = crate::HttpTransportPool::new(settings(std::vec![
endpoint("one", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
endpoint("two", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
endpoint("fallback", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
@@ -98,7 +98,7 @@ fn pool_round_robins_fairly_inside_best_priority_tier() {
#[test]
fn disabled_best_priority_endpoint_falls_back_to_next_tier() {
let pool = super::HttpTransportPool::new(settings(std::vec![
let pool = crate::HttpTransportPool::new(settings(std::vec![
endpoint("disabled-primary", false, 1, std::vec![crate::HttpRequestKind::wildcard()]),
endpoint("fallback", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
]))
@@ -111,7 +111,7 @@ fn disabled_best_priority_endpoint_falls_back_to_next_tier() {
#[test]
fn pool_filters_role_and_capability_before_priority() {
let pool = super::HttpTransportPool::new(settings(std::vec![
let pool = crate::HttpTransportPool::new(settings(std::vec![
endpoint("wrong-capability", true, 1, std::vec![crate::HttpRequestKind::new("send_transaction")]),
endpoint("matching", true, 50, std::vec![crate::HttpRequestKind::new("get_balance")]),
]))
@@ -124,7 +124,7 @@ fn pool_filters_role_and_capability_before_priority() {
#[test]
fn pool_returns_structured_error_when_no_endpoint_matches() {
let pool = super::HttpTransportPool::new(settings(std::vec![endpoint("read-only", true, 10, std::vec![crate::HttpRequestKind::new("get_balance")],)]))
let pool = crate::HttpTransportPool::new(settings(std::vec![endpoint("read-only", true, 10, std::vec![crate::HttpRequestKind::new("get_balance")],)]))
.expect("pool must build");
let error = pool
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("send_transaction"))
@@ -135,7 +135,7 @@ fn pool_returns_structured_error_when_no_endpoint_matches() {
#[test]
fn standard_method_selection_uses_registry_request_kind() {
let pool = super::HttpTransportPool::new(settings(std::vec![endpoint("balance", true, 10, std::vec![crate::HttpRequestKind::new("get_balance")],)]))
let pool = crate::HttpTransportPool::new(settings(std::vec![endpoint("balance", true, 10, std::vec![crate::HttpRequestKind::new("get_balance")],)]))
.expect("pool must build");
let method = crate::find_http_rpc_method("getBalance").expect("audited method must exist");
let selection = pool.select_for_method(&crate::HttpRoleName::new("default"), method).expect("standard method must route");
@@ -144,7 +144,7 @@ fn standard_method_selection_uses_registry_request_kind() {
#[test]
fn pool_snapshot_is_safe_and_preserves_disabled_endpoints() {
let pool = super::HttpTransportPool::new(settings(std::vec![
let pool = crate::HttpTransportPool::new(settings(std::vec![
endpoint("enabled", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
endpoint("disabled", false, 10, std::vec![crate::HttpRequestKind::wildcard()]),
]))
@@ -185,7 +185,7 @@ fn disabled_role_is_excluded_before_priority_selection() {
base.max_idle_connections_per_host(),
std::vec![disabled_role, enabled_non_matching_role],
);
let pool = super::HttpTransportPool::new(settings(std::vec![
let pool = crate::HttpTransportPool::new(settings(std::vec![
disabled_role_endpoint,
endpoint("fallback", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
]))
@@ -198,7 +198,7 @@ fn disabled_role_is_excluded_before_priority_selection() {
#[test]
fn removed_standard_method_is_rejected_before_endpoint_routing() {
let pool = super::HttpTransportPool::new(settings(std::vec![endpoint("wildcard", true, 10, std::vec![crate::HttpRequestKind::wildcard()],)]))
let pool = crate::HttpTransportPool::new(settings(std::vec![endpoint("wildcard", true, 10, std::vec![crate::HttpRequestKind::wildcard()],)]))
.expect("pool must build");
let method = crate::find_http_rpc_method("confirmTransaction").expect("historical method must exist");
let error = pool.select_for_method(&crate::HttpRoleName::new("default"), method).expect_err("removed standard method must be rejected before routing");
@@ -207,7 +207,7 @@ fn removed_standard_method_is_rejected_before_endpoint_routing() {
#[tokio::test]
async fn runtime_concurrency_saturation_falls_back_to_lower_priority_tier() {
let pool = super::HttpTransportPool::new(settings(std::vec![
let pool = crate::HttpTransportPool::new(settings(std::vec![
limited_endpoint("primary", 1, std::option::Option::None, std::option::Option::None, std::option::Option::Some(1), std::option::Option::None),
limited_endpoint("fallback", 20, std::option::Option::None, std::option::Option::None, std::option::Option::Some(1), std::option::Option::None),
]))
@@ -222,7 +222,7 @@ async fn runtime_concurrency_saturation_falls_back_to_lower_priority_tier() {
#[tokio::test]
async fn runtime_token_bucket_exhaustion_falls_back_without_busy_waiting() {
let pool = super::HttpTransportPool::new(settings(std::vec![
let pool = crate::HttpTransportPool::new(settings(std::vec![
limited_endpoint("primary", 1, std::option::Option::Some(1), std::option::Option::Some(1), std::option::Option::None, std::option::Option::None),
limited_endpoint("fallback", 20, std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
]))
@@ -238,7 +238,7 @@ async fn runtime_token_bucket_exhaustion_falls_back_without_busy_waiting() {
#[tokio::test]
async fn provider_cooldown_excludes_rate_limited_role_and_uses_fallback() {
let pool = super::HttpTransportPool::new(settings(std::vec![
let pool = crate::HttpTransportPool::new(settings(std::vec![
limited_endpoint(
"primary",
1,
@@ -263,7 +263,7 @@ async fn provider_cooldown_excludes_rate_limited_role_and_uses_fallback() {
#[tokio::test]
async fn admission_waits_for_released_concurrency_without_holding_a_sync_mutex_across_await() {
let pool = super::HttpTransportPool::new(settings(std::vec![limited_endpoint(
let pool = crate::HttpTransportPool::new(settings(std::vec![limited_endpoint(
"primary",
1,
std::option::Option::None,
@@ -289,7 +289,7 @@ async fn admission_waits_for_released_concurrency_without_holding_a_sync_mutex_a
#[tokio::test]
async fn admission_timeout_is_bounded_when_concurrency_never_becomes_available() {
let pool = super::HttpTransportPool::new(settings(std::vec![limited_endpoint(
let pool = crate::HttpTransportPool::new(settings(std::vec![limited_endpoint(
"primary",
1,
std::option::Option::None,
@@ -310,7 +310,7 @@ async fn admission_timeout_is_bounded_when_concurrency_never_becomes_available()
#[tokio::test]
async fn passive_health_snapshot_moves_from_degraded_back_to_available_after_success() {
let pool = super::HttpTransportPool::new(settings(std::vec![limited_endpoint(
let pool = crate::HttpTransportPool::new(settings(std::vec![limited_endpoint(
"primary",
1,
std::option::Option::None,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/resilience.rs
// version: 1
// version: 2
fn non_zero(value: u32) -> std::num::NonZeroU32 {
return std::num::NonZeroU32::new(value).expect("test limit must be non-zero");
@@ -46,46 +46,46 @@ fn retry_backoff_is_exponential_and_bounded() {
#[test]
fn retry_safe_timeout_is_retried_until_budget_is_exhausted() {
let settings = retry_settings();
let first = super::evaluate_transport_retry(
let first = crate::evaluate_transport_retry(
method("getBalance"),
&settings,
super::HttpRetryCause::Timeout,
super::HttpDispatchState::DispatchedAmbiguous,
crate::HttpRetryCause::Timeout,
crate::HttpDispatchState::DispatchedAmbiguous,
0,
std::option::Option::None,
);
assert_eq!(first, super::HttpRetryDecision::RetryAfter(std::time::Duration::from_millis(100)));
let exhausted = super::evaluate_transport_retry(
assert_eq!(first, crate::HttpRetryDecision::RetryAfter(std::time::Duration::from_millis(100)));
let exhausted = crate::evaluate_transport_retry(
method("getBalance"),
&settings,
super::HttpRetryCause::Timeout,
super::HttpDispatchState::DispatchedAmbiguous,
crate::HttpRetryCause::Timeout,
crate::HttpDispatchState::DispatchedAmbiguous,
settings.max_retries(),
std::option::Option::None,
);
assert_eq!(exhausted, super::HttpRetryDecision::Stop);
assert_eq!(exhausted, crate::HttpRetryDecision::Stop);
}
#[test]
fn write_submission_never_retries_after_ambiguous_dispatch() {
let decision = super::evaluate_transport_retry(
let decision = crate::evaluate_transport_retry(
method("sendTransaction"),
&retry_settings(),
super::HttpRetryCause::Connection,
super::HttpDispatchState::DispatchedAmbiguous,
crate::HttpRetryCause::Connection,
crate::HttpDispatchState::DispatchedAmbiguous,
0,
std::option::Option::None,
);
assert_eq!(decision, super::HttpRetryDecision::Stop);
assert_eq!(decision, crate::HttpRetryDecision::Stop);
}
#[test]
fn write_submission_can_retry_when_transport_proves_no_dispatch() {
let decision = super::evaluate_transport_retry(
let decision = crate::evaluate_transport_retry(
method("sendTransaction"),
&retry_settings(),
super::HttpRetryCause::Connection,
super::HttpDispatchState::NotDispatched,
crate::HttpRetryCause::Connection,
crate::HttpDispatchState::NotDispatched,
0,
std::option::Option::None,
);
@@ -94,36 +94,36 @@ fn write_submission_can_retry_when_transport_proves_no_dispatch() {
#[test]
fn rpc_application_and_invalid_response_are_not_transport_retries() {
for cause in [super::HttpRetryCause::RpcApplication, super::HttpRetryCause::InvalidResponse, super::HttpRetryCause::Request] {
let decision = super::evaluate_transport_retry(
for cause in [crate::HttpRetryCause::RpcApplication, crate::HttpRetryCause::InvalidResponse, crate::HttpRetryCause::Request] {
let decision = crate::evaluate_transport_retry(
method("getBalance"),
&retry_settings(),
cause,
super::HttpDispatchState::NotDispatched,
crate::HttpDispatchState::NotDispatched,
0,
std::option::Option::None,
);
assert_eq!(decision, super::HttpRetryDecision::Stop);
assert_eq!(decision, crate::HttpRetryDecision::Stop);
}
}
#[test]
fn provider_retry_after_can_extend_backoff_but_is_defensively_bounded() {
let settings = retry_settings();
let extended = super::evaluate_transport_retry(
let extended = crate::evaluate_transport_retry(
method("getBalance"),
&settings,
super::HttpRetryCause::RateLimited,
super::HttpDispatchState::DispatchedAmbiguous,
crate::HttpRetryCause::RateLimited,
crate::HttpDispatchState::DispatchedAmbiguous,
0,
std::option::Option::Some(std::time::Duration::from_secs(3)),
);
assert_eq!(extended.delay(), std::option::Option::Some(std::time::Duration::from_secs(3)));
let bounded = super::evaluate_transport_retry(
let bounded = crate::evaluate_transport_retry(
method("getBalance"),
&settings,
super::HttpRetryCause::RateLimited,
super::HttpDispatchState::DispatchedAmbiguous,
crate::HttpRetryCause::RateLimited,
crate::HttpDispatchState::DispatchedAmbiguous,
0,
std::option::Option::Some(std::time::Duration::from_secs(600)),
);
@@ -144,29 +144,29 @@ fn token_bucket_consumes_burst_then_refills_from_elapsed_time() {
#[test]
fn absent_burst_capacity_defaults_to_one_second_of_rps_capacity() {
let role = role_limits(std::option::Option::Some(2), std::option::Option::None, std::option::Option::None, std::option::Option::None);
let runtime = std::sync::Arc::new(super::HttpRoleRuntime::new(&role, std::sync::Arc::new(tokio::sync::Notify::new())));
let runtime = std::sync::Arc::new(crate::HttpRoleRuntime::new(&role, std::sync::Arc::new(tokio::sync::Notify::new())));
let now = std::time::Instant::now();
let first = runtime.try_acquire(now);
let second = runtime.try_acquire(now);
let third = runtime.try_acquire(now);
assert!(matches!(first, super::RoleAdmissionAttempt::Ready(_)));
assert!(matches!(second, super::RoleAdmissionAttempt::Ready(_)));
assert!(matches!(third, super::RoleAdmissionAttempt::BlockedUntil(_)));
assert!(matches!(first, crate::RoleAdmissionAttempt::Ready(_)));
assert!(matches!(second, crate::RoleAdmissionAttempt::Ready(_)));
assert!(matches!(third, crate::RoleAdmissionAttempt::BlockedUntil(_)));
}
#[test]
fn concurrency_semaphore_releases_capacity_when_permit_is_dropped() {
let role = role_limits(std::option::Option::None, std::option::Option::None, std::option::Option::Some(1), std::option::Option::None);
let runtime = std::sync::Arc::new(super::HttpRoleRuntime::new(&role, std::sync::Arc::new(tokio::sync::Notify::new())));
let runtime = std::sync::Arc::new(crate::HttpRoleRuntime::new(&role, std::sync::Arc::new(tokio::sync::Notify::new())));
let now = std::time::Instant::now();
let first = runtime.try_acquire(now);
let held = match first {
super::RoleAdmissionAttempt::Ready(permit) => permit,
crate::RoleAdmissionAttempt::Ready(permit) => permit,
_ => panic!("first concurrency permit must be available"),
};
assert!(matches!(runtime.try_acquire(now), super::RoleAdmissionAttempt::ConcurrencySaturated));
assert!(matches!(runtime.try_acquire(now), crate::RoleAdmissionAttempt::ConcurrencySaturated));
drop(held);
assert!(matches!(runtime.try_acquire(now), super::RoleAdmissionAttempt::Ready(_)));
assert!(matches!(runtime.try_acquire(now), crate::RoleAdmissionAttempt::Ready(_)));
}
#[test]
@@ -177,7 +177,7 @@ fn rate_limit_cooldown_marks_role_and_caps_provider_delay() {
std::option::Option::None,
std::option::Option::Some(std::time::Duration::from_millis(10)),
);
let runtime = super::HttpRoleRuntime::new(&role, std::sync::Arc::new(tokio::sync::Notify::new()));
let runtime = crate::HttpRoleRuntime::new(&role, std::sync::Arc::new(tokio::sync::Notify::new()));
let pause = runtime.record_rate_limited(std::option::Option::Some(std::time::Duration::from_secs(600)));
assert_eq!(pause, std::time::Duration::from_secs(60));
assert_eq!(runtime.rate_limit_count(), 1);

View File

@@ -1,19 +1,19 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_method.rs
// version: 2
// version: 3
#[test]
fn audited_registry_has_expected_current_and_historical_counts() {
assert_eq!(super::current_http_rpc_methods().len(), 52);
assert_eq!(super::historical_http_rpc_methods().len(), 14);
assert_eq!(crate::current_http_rpc_methods().len(), 52);
assert_eq!(crate::historical_http_rpc_methods().len(), 14);
}
#[test]
fn audited_registry_method_names_are_unique() {
let mut names = std::collections::BTreeSet::<&str>::new();
for descriptor in super::current_http_rpc_methods() {
for descriptor in crate::current_http_rpc_methods() {
assert!(names.insert(descriptor.method()), "duplicate current method {}", descriptor.method());
}
for descriptor in super::historical_http_rpc_methods() {
for descriptor in crate::historical_http_rpc_methods() {
assert!(names.insert(descriptor.method()), "duplicate historical method {}", descriptor.method());
}
assert_eq!(names.len(), 66);
@@ -26,13 +26,13 @@ fn coverage_release_counts_match_recalibrated_matrix() {
let mut transactions = 0_usize;
let mut blocks_economics = 0_usize;
let mut historical = 0_usize;
for descriptor in super::current_http_rpc_methods() {
for descriptor in crate::current_http_rpc_methods() {
match descriptor.coverage_release() {
super::HttpRpcCoverageRelease::V0_2_1 => foundation += 1,
super::HttpRpcCoverageRelease::V0_2_2 => accounts_tokens_cluster += 1,
super::HttpRpcCoverageRelease::V0_2_3 => transactions += 1,
super::HttpRpcCoverageRelease::V0_2_4 => blocks_economics += 1,
super::HttpRpcCoverageRelease::Historical => historical += 1,
crate::HttpRpcCoverageRelease::V0_2_1 => foundation += 1,
crate::HttpRpcCoverageRelease::V0_2_2 => accounts_tokens_cluster += 1,
crate::HttpRpcCoverageRelease::V0_2_3 => transactions += 1,
crate::HttpRpcCoverageRelease::V0_2_4 => blocks_economics += 1,
crate::HttpRpcCoverageRelease::Historical => historical += 1,
}
}
assert_eq!(historical, 0);
@@ -45,8 +45,8 @@ fn coverage_release_counts_match_recalibrated_matrix() {
#[test]
fn foundation_canary_assignment_is_exact() {
let mut names = std::vec::Vec::<&str>::new();
for descriptor in super::current_http_rpc_methods() {
if descriptor.coverage_release() == super::HttpRpcCoverageRelease::V0_2_1 {
for descriptor in crate::current_http_rpc_methods() {
if descriptor.coverage_release() == crate::HttpRpcCoverageRelease::V0_2_1 {
names.push(descriptor.method());
}
}
@@ -56,62 +56,62 @@ fn foundation_canary_assignment_is_exact() {
#[test]
fn historical_methods_are_deprecated_removed_and_not_retryable() {
for descriptor in super::historical_http_rpc_methods() {
assert_eq!(descriptor.documentation_status(), super::RpcDocumentationStatus::Deprecated);
assert_eq!(descriptor.runtime_status(), super::RpcRuntimeStatus::Removed);
assert_eq!(descriptor.transport_retry_class(), super::TransportRetryClass::NotApplicable);
assert_eq!(descriptor.coverage_release(), super::HttpRpcCoverageRelease::Historical);
for descriptor in crate::historical_http_rpc_methods() {
assert_eq!(descriptor.documentation_status(), crate::RpcDocumentationStatus::Deprecated);
assert_eq!(descriptor.runtime_status(), crate::RpcRuntimeStatus::Removed);
assert_eq!(descriptor.transport_retry_class(), crate::TransportRetryClass::NotApplicable);
assert_eq!(descriptor.coverage_release(), crate::HttpRpcCoverageRelease::Historical);
}
}
#[test]
fn get_transaction_and_get_block_track_deprecated_legacy_request_form() {
let get_transaction = super::find_http_rpc_method("getTransaction").expect("getTransaction descriptor must exist");
let get_block = super::find_http_rpc_method("getBlock").expect("getBlock descriptor must exist");
let get_transaction = crate::find_http_rpc_method("getTransaction").expect("getTransaction descriptor must exist");
let get_block = crate::find_http_rpc_method("getBlock").expect("getBlock descriptor must exist");
assert!(get_transaction.request_form_status().has_deprecated_legacy());
assert!(get_block.request_form_status().has_deprecated_legacy());
let get_balance = super::find_http_rpc_method("getBalance").expect("getBalance descriptor must exist");
let get_balance = crate::find_http_rpc_method("getBalance").expect("getBalance descriptor must exist");
assert!(!get_balance.request_form_status().has_deprecated_legacy());
}
#[test]
fn write_submission_methods_are_never_retry_after_ambiguous_dispatch() {
for method in ["sendTransaction", "requestAirdrop"] {
let descriptor = super::find_http_rpc_method(method).expect("write descriptor must exist");
assert_eq!(descriptor.operation_kind(), super::RpcOperationKind::WriteSubmission);
assert_eq!(descriptor.transport_retry_class(), super::TransportRetryClass::NeverAfterDispatch);
let descriptor = crate::find_http_rpc_method(method).expect("write descriptor must exist");
assert_eq!(descriptor.operation_kind(), crate::RpcOperationKind::WriteSubmission);
assert_eq!(descriptor.transport_retry_class(), crate::TransportRetryClass::NeverAfterDispatch);
}
let simulation = super::find_http_rpc_method("simulateTransaction").expect("simulation descriptor must exist");
assert_eq!(simulation.operation_kind(), super::RpcOperationKind::Simulation);
assert_eq!(simulation.transport_retry_class(), super::TransportRetryClass::RetrySafe);
let simulation = crate::find_http_rpc_method("simulateTransaction").expect("simulation descriptor must exist");
assert_eq!(simulation.operation_kind(), crate::RpcOperationKind::Simulation);
assert_eq!(simulation.transport_retry_class(), crate::TransportRetryClass::RetrySafe);
}
#[test]
fn removed_method_support_check_returns_method_removed_error() {
let descriptor = super::find_http_rpc_method("confirmTransaction").expect("historical descriptor must exist");
let descriptor = crate::find_http_rpc_method("confirmTransaction").expect("historical descriptor must exist");
let error = descriptor.ensure_runtime_supported().expect_err("removed method must not be callable");
assert_eq!(error.code(), crate::ERROR_CODE_METHOD_REMOVED);
}
#[test]
fn stable_supported_method_passes_runtime_support_check() {
let descriptor = super::find_http_rpc_method("getHealth").expect("current descriptor must exist");
let descriptor = crate::find_http_rpc_method("getHealth").expect("current descriptor must exist");
assert!(descriptor.ensure_runtime_supported().is_ok());
}
#[test]
fn supported_unstable_descriptor_executes_central_warning_path() {
let descriptor = super::HttpRpcMethodDescriptor::new(
let descriptor = crate::HttpRpcMethodDescriptor::new(
"experimentalMethod",
super::HttpRpcCategory::Cluster,
crate::HttpRpcCategory::Cluster,
"experimental_method",
super::RpcDocumentationStatus::Unstable,
super::RpcRuntimeStatus::Supported,
super::RpcRequestFormStatus::Stable,
super::RpcOperationKind::Read,
super::TransportRetryClass::RetrySafe,
crate::RpcDocumentationStatus::Unstable,
crate::RpcRuntimeStatus::Supported,
crate::RpcRequestFormStatus::Stable,
crate::RpcOperationKind::Read,
crate::TransportRetryClass::RetrySafe,
std::option::Option::None,
super::HttpRpcCoverageRelease::V0_2_1,
crate::HttpRpcCoverageRelease::V0_2_1,
);
assert!(descriptor.requires_method_usage_warning());
assert!(descriptor.ensure_runtime_supported().is_ok());
@@ -119,5 +119,5 @@ fn supported_unstable_descriptor_executes_central_warning_path() {
#[test]
fn lookup_rejects_unknown_method_without_affecting_raw_provider_extensions() {
assert!(super::find_http_rpc_method("providerCustomMethod").is_none());
assert!(crate::find_http_rpc_method("providerCustomMethod").is_none());
}

View File

@@ -1,52 +1,52 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/settings.rs
// version: 1
// version: 2
fn non_zero(value: u32) -> std::num::NonZeroU32 {
return std::num::NonZeroU32::new(value).expect("test non-zero value must remain non-zero");
}
fn valid_settings(url_text: &str) -> super::HttpTransportSettings {
let url = super::HttpEndpointUrl::parse(url_text).expect("test URL must be valid");
let limits = super::HttpRoleLimits::new(
fn valid_settings(url_text: &str) -> crate::HttpTransportSettings {
let url = crate::HttpEndpointUrl::parse(url_text).expect("test URL must be valid");
let limits = crate::HttpRoleLimits::new(
std::option::Option::Some(non_zero(10)),
std::option::Option::Some(non_zero(20)),
std::option::Option::Some(non_zero(4)),
std::option::Option::Some(std::time::Duration::from_millis(500)),
);
let role = super::HttpEndpointRoleSettings::new(super::HttpRoleName::new("default"), true, std::vec![super::HttpRequestKind::wildcard()], 100, limits);
let endpoint = super::HttpEndpointSettings::new(
let role = crate::HttpEndpointRoleSettings::new(crate::HttpRoleName::new("default"), true, std::vec![crate::HttpRequestKind::wildcard()], 100, limits);
let endpoint = crate::HttpEndpointSettings::new(
"devnet_public",
true,
super::HttpProviderName::new("solana-public"),
super::HttpClusterName::new("devnet"),
crate::HttpProviderName::new("solana-public"),
crate::HttpClusterName::new("devnet"),
url,
std::time::Duration::from_secs(5),
std::time::Duration::from_secs(15),
std::option::Option::Some(8),
std::vec![role],
);
return super::HttpTransportSettings::new(
return crate::HttpTransportSettings::new(
std::vec![endpoint],
super::HttpRetrySettings::new(2, std::time::Duration::from_millis(100), std::time::Duration::from_secs(2)),
crate::HttpRetrySettings::new(2, std::time::Duration::from_millis(100), std::time::Duration::from_secs(2)),
);
}
#[test]
fn endpoint_url_accepts_http_and_https() {
assert!(super::HttpEndpointUrl::parse("https://api.devnet.solana.com").is_ok());
assert!(super::HttpEndpointUrl::parse("http://127.0.0.1:8899").is_ok());
assert!(crate::HttpEndpointUrl::parse("https://api.devnet.solana.com").is_ok());
assert!(crate::HttpEndpointUrl::parse("http://127.0.0.1:8899").is_ok());
}
#[test]
fn endpoint_url_rejects_non_http_schemes() {
let result = super::HttpEndpointUrl::parse("ws://api.devnet.solana.com");
let result = crate::HttpEndpointUrl::parse("ws://api.devnet.solana.com");
let error = result.expect_err("WebSocket URL must not be accepted by HTTP settings");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
}
#[test]
fn endpoint_url_debug_redacts_secret_material() {
let url = super::HttpEndpointUrl::parse("https://provider.invalid/rpc?api-key=SECRET-CANARY").expect("test URL must parse");
let url = crate::HttpEndpointUrl::parse("https://provider.invalid/rpc?api-key=SECRET-CANARY").expect("test URL must parse");
let rendered = format!("{url:?}");
assert!(rendered.contains("<redacted>"));
assert!(!rendered.contains("SECRET-CANARY"));
@@ -70,28 +70,28 @@ fn transport_settings_debug_does_not_leak_endpoint_url() {
#[test]
fn transport_settings_require_one_enabled_endpoint() {
let url = super::HttpEndpointUrl::parse("https://api.devnet.solana.com").expect("test URL must parse");
let role = super::HttpEndpointRoleSettings::new(
super::HttpRoleName::new("default"),
let url = crate::HttpEndpointUrl::parse("https://api.devnet.solana.com").expect("test URL must parse");
let role = crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new("default"),
true,
std::vec![super::HttpRequestKind::wildcard()],
std::vec![crate::HttpRequestKind::wildcard()],
100,
super::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
);
let endpoint = super::HttpEndpointSettings::new(
let endpoint = crate::HttpEndpointSettings::new(
"disabled",
false,
super::HttpProviderName::new("provider"),
super::HttpClusterName::new("devnet"),
crate::HttpProviderName::new("provider"),
crate::HttpClusterName::new("devnet"),
url,
std::time::Duration::from_secs(1),
std::time::Duration::from_secs(1),
std::option::Option::None,
std::vec![role],
);
let settings = super::HttpTransportSettings::new(
let settings = crate::HttpTransportSettings::new(
std::vec![endpoint],
super::HttpRetrySettings::new(1, std::time::Duration::from_millis(1), std::time::Duration::from_millis(2)),
crate::HttpRetrySettings::new(1, std::time::Duration::from_millis(1), std::time::Duration::from_millis(2)),
);
let error = settings.validate().expect_err("all-disabled settings must fail");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
@@ -101,7 +101,7 @@ fn transport_settings_require_one_enabled_endpoint() {
fn transport_settings_reject_duplicate_endpoint_names() {
let first = valid_settings("https://one.invalid");
let second = valid_settings("https://two.invalid");
let settings = super::HttpTransportSettings::new(std::vec![first.endpoints()[0].clone(), second.endpoints()[0].clone()], first.retry().clone());
let settings = crate::HttpTransportSettings::new(std::vec![first.endpoints()[0].clone(), second.endpoints()[0].clone()], first.retry().clone());
let error = settings.validate().expect_err("duplicate endpoint names must fail");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
}
@@ -110,7 +110,7 @@ fn transport_settings_reject_duplicate_endpoint_names() {
fn transport_settings_reject_duplicate_roles() {
let base = valid_settings("https://api.devnet.solana.com");
let endpoint = &base.endpoints()[0];
let duplicated_endpoint = super::HttpEndpointSettings::new(
let duplicated_endpoint = crate::HttpEndpointSettings::new(
endpoint.name(),
true,
endpoint.provider().clone(),
@@ -121,7 +121,7 @@ fn transport_settings_reject_duplicate_roles() {
endpoint.max_idle_connections_per_host(),
std::vec![endpoint.roles()[0].clone(), endpoint.roles()[0].clone()],
);
let settings = super::HttpTransportSettings::new(std::vec![duplicated_endpoint], base.retry().clone());
let settings = crate::HttpTransportSettings::new(std::vec![duplicated_endpoint], base.retry().clone());
assert!(settings.validate().is_err());
}
@@ -129,14 +129,14 @@ fn transport_settings_reject_duplicate_roles() {
fn transport_settings_reject_wildcard_mixed_with_specific_kind() {
let base = valid_settings("https://api.devnet.solana.com");
let endpoint = &base.endpoints()[0];
let role = super::HttpEndpointRoleSettings::new(
super::HttpRoleName::new("default"),
let role = crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new("default"),
true,
std::vec![super::HttpRequestKind::wildcard(), super::HttpRequestKind::new("get_balance")],
std::vec![crate::HttpRequestKind::wildcard(), crate::HttpRequestKind::new("get_balance")],
100,
endpoint.roles()[0].limits().clone(),
);
let modified_endpoint = super::HttpEndpointSettings::new(
let modified_endpoint = crate::HttpEndpointSettings::new(
endpoint.name(),
true,
endpoint.provider().clone(),
@@ -147,7 +147,7 @@ fn transport_settings_reject_wildcard_mixed_with_specific_kind() {
endpoint.max_idle_connections_per_host(),
std::vec![role],
);
let settings = super::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
let settings = crate::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
assert!(settings.validate().is_err());
}
@@ -155,14 +155,14 @@ fn transport_settings_reject_wildcard_mixed_with_specific_kind() {
fn transport_settings_reject_burst_without_rps() {
let base = valid_settings("https://api.devnet.solana.com");
let endpoint = &base.endpoints()[0];
let role = super::HttpEndpointRoleSettings::new(
super::HttpRoleName::new("default"),
let role = crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new("default"),
true,
std::vec![super::HttpRequestKind::wildcard()],
std::vec![crate::HttpRequestKind::wildcard()],
100,
super::HttpRoleLimits::new(std::option::Option::None, std::option::Option::Some(non_zero(2)), std::option::Option::None, std::option::Option::None),
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::Some(non_zero(2)), std::option::Option::None, std::option::Option::None),
);
let modified_endpoint = super::HttpEndpointSettings::new(
let modified_endpoint = crate::HttpEndpointSettings::new(
endpoint.name(),
true,
endpoint.provider().clone(),
@@ -173,16 +173,16 @@ fn transport_settings_reject_burst_without_rps() {
endpoint.max_idle_connections_per_host(),
std::vec![role],
);
let settings = super::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
let settings = crate::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
assert!(settings.validate().is_err());
}
#[test]
fn transport_settings_reject_reversed_retry_backoff() {
let base = valid_settings("https://api.devnet.solana.com");
let settings = super::HttpTransportSettings::new(
let settings = crate::HttpTransportSettings::new(
base.endpoints().to_vec(),
super::HttpRetrySettings::new(2, std::time::Duration::from_secs(2), std::time::Duration::from_secs(1)),
crate::HttpRetrySettings::new(2, std::time::Duration::from_secs(2), std::time::Duration::from_secs(1)),
);
assert!(settings.validate().is_err());
}
@@ -191,7 +191,7 @@ fn transport_settings_reject_reversed_retry_backoff() {
fn transport_settings_reject_zero_request_timeout() {
let base = valid_settings("https://api.devnet.solana.com");
let endpoint = &base.endpoints()[0];
let modified_endpoint = super::HttpEndpointSettings::new(
let modified_endpoint = crate::HttpEndpointSettings::new(
endpoint.name(),
true,
endpoint.provider().clone(),
@@ -202,6 +202,6 @@ fn transport_settings_reject_zero_request_timeout() {
endpoint.max_idle_connections_per_host(),
endpoint.roles().to_vec(),
);
let settings = super::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
let settings = crate::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
assert!(settings.validate().is_err());
}

View File

@@ -1,10 +1,10 @@
// file: crates/ksp-wallet-lib/src/crypto.rs
// version: 2
// version: 3
//! In-memory cryptographic primitives for native `.kspwallet` V1.
use chacha20poly1305::KeyInit as _;
use chacha20poly1305::aead::Aead as _;
use chacha20poly1305::KeyInit; // rust-rules: derive-import
use chacha20poly1305::aead::Aead; // rust-rules: derive-import
/// Exact V1 content-key and password-derived-key size in bytes.
pub(crate) const SECRET_KEY_BYTES: usize = 32;
@@ -14,7 +14,7 @@ pub(crate) struct SecretKeyV1 {
bytes: [u8; SECRET_KEY_BYTES],
}
impl SecretKeyV1 {
impl crate::SecretKeyV1 {
/// Takes ownership of exact 32-byte secret material.
pub(crate) const fn from_bytes(bytes: [u8; SECRET_KEY_BYTES]) -> Self {
return Self { bytes };
@@ -35,13 +35,13 @@ impl SecretKeyV1 {
}
}
impl std::fmt::Debug for SecretKeyV1 {
impl std::fmt::Debug for crate::SecretKeyV1 {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str("SecretKeyV1(<redacted>)");
}
}
impl std::ops::Drop for SecretKeyV1 {
impl std::ops::Drop for crate::SecretKeyV1 {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.bytes);
}
@@ -64,28 +64,28 @@ pub(crate) fn random_nonce() -> ksp_core_lib::Result<[u8; crate::KSPWALLET_V1_XC
}
/// Derives one V1 password wrapping key from serialized Argon2id parameters.
pub(crate) fn derive_password_key(password: &[u8], kdf: &crate::WalletKdfParametersV1) -> ksp_core_lib::Result<SecretKeyV1> {
pub(crate) fn derive_password_key(password: &[u8], kdf: &crate::WalletKdfParametersV1) -> ksp_core_lib::Result<crate::SecretKeyV1> {
return derive_argon2id(password, kdf.salt(), kdf.memory_kib(), kdf.iterations(), kdf.parallelism());
}
/// Wraps one 32-byte content key with XChaCha20-Poly1305 and caller-provided domain-separated AAD.
pub(crate) fn wrap_key(
wrapping_key: &SecretKeyV1,
key_to_wrap: &SecretKeyV1,
wrapping_key: &crate::SecretKeyV1,
key_to_wrap: &crate::SecretKeyV1,
nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
aad: &[u8],
) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
return encrypt_bytes(wrapping_key, nonce, aad, key_to_wrap.as_bytes());
return crate::encrypt_bytes(wrapping_key, nonce, aad, key_to_wrap.as_bytes());
}
/// Unwraps one 32-byte content key and maps every AEAD authentication failure to the generic Wallet authentication error.
pub(crate) fn unwrap_key(
wrapping_key: &SecretKeyV1,
wrapping_key: &crate::SecretKeyV1,
nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
aad: &[u8],
ciphertext: &[u8],
) -> ksp_core_lib::Result<SecretKeyV1> {
let plaintext_result = decrypt_bytes(wrapping_key, nonce, aad, ciphertext);
) -> ksp_core_lib::Result<crate::SecretKeyV1> {
let plaintext_result = crate::decrypt_bytes(wrapping_key, nonce, aad, ciphertext);
let mut plaintext = match plaintext_result {
std::result::Result::Ok(plaintext) => plaintext,
std::result::Result::Err(error) => return std::result::Result::Err(error),
@@ -96,7 +96,7 @@ pub(crate) fn unwrap_key(
}
let converted = <[u8; SECRET_KEY_BYTES]>::try_from(plaintext.as_slice());
let key = match converted {
std::result::Result::Ok(key) => SecretKeyV1::from_bytes(key),
std::result::Result::Ok(key) => crate::SecretKeyV1::from_bytes(key),
std::result::Result::Err(_) => {
zeroize::Zeroize::zeroize(plaintext.as_mut_slice());
return std::result::Result::Err(authentication_error());
@@ -108,7 +108,7 @@ pub(crate) fn unwrap_key(
/// Encrypts bounded plaintext bytes with XChaCha20-Poly1305 and caller-provided domain-separated AAD.
pub(crate) fn encrypt_bytes(
key: &SecretKeyV1,
key: &crate::SecretKeyV1,
nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
aad: &[u8],
plaintext: &[u8],
@@ -129,7 +129,7 @@ pub(crate) fn encrypt_bytes(
/// Decrypts authenticated ciphertext bytes and returns a generic authentication error on tag failure.
pub(crate) fn decrypt_bytes(
key: &SecretKeyV1,
key: &crate::SecretKeyV1,
nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
aad: &[u8],
ciphertext: &[u8],
@@ -148,7 +148,7 @@ pub(crate) fn decrypt_bytes(
};
}
pub(crate) fn derive_argon2id(password: &[u8], salt: &[u8], memory_kib: u32, iterations: u32, parallelism: u32) -> ksp_core_lib::Result<SecretKeyV1> {
fn derive_argon2id(password: &[u8], salt: &[u8], memory_kib: u32, iterations: u32, parallelism: u32) -> ksp_core_lib::Result<crate::SecretKeyV1> {
if password.is_empty() || password.len() > crate::KSPWALLET_V1_MAX_PASSWORD_BYTES {
return std::result::Result::Err(crypto_parameter_error());
}
@@ -178,7 +178,7 @@ pub(crate) fn derive_argon2id(password: &[u8], salt: &[u8], memory_kib: u32, ite
zeroize::Zeroize::zeroize(&mut output);
return std::result::Result::Err(crypto_parameter_error());
}
return std::result::Result::Ok(SecretKeyV1::from_bytes(output));
return std::result::Result::Ok(crate::SecretKeyV1::from_bytes(output));
}
fn randomness_error() -> ksp_core_lib::Error {

View File

@@ -1,5 +1,6 @@
// file: crates/ksp-wallet-lib/src/lib.rs
// version: 8
// version: 10
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
@@ -13,7 +14,8 @@
//! `0.2.5-pre.006` adds bounded async-first filesystem reads plus same-directory synchronized no-clobber publication for new native files. `0.2.5-pre.007`
//! adds Solana message signing, protected metadata administration, password rotation and strong VIEW disable/recreate with capability-bound atomic
//! replacement. `0.2.5-pre.008` adds bounded Solana CLI JSON and canonical full-keypair Base58 import/export adapters with safe inspection and no-clobber
//! native import/export publication. Public keys are consumed exclusively through the [`ksp_core_lib::Pubkey`] re-export owned by KSP Core, and behavioral
//! native import/export publication. `0.2.5-pre.009` adds adversarial security/compliance canaries and records the final dependency/interop audit before
//! documentation closure. Public keys are consumed exclusively through the [`ksp_core_lib::Pubkey`] re-export owned by KSP Core, and behavioral
//! observability uses only
//! `ksp-logging-lib` with the explicit crate target defined in `src/constants.rs`.
@@ -32,6 +34,10 @@ mod view;
mod wallet;
mod wire;
#[cfg(test)]
#[path = "../unit_tests/security.rs"]
mod security_tests;
/// Authorized capability represented by an unlocked Wallet handle.
pub use self::capability::WalletCapability;
/// Native `.kspwallet` V1 format version.
@@ -221,6 +227,26 @@ pub use self::wire::WalletViewDescriptorV1;
/// Wallet-owned tracing target used by the KSP logging facade.
pub(crate) use self::constants::TRACING_TARGET;
/// Exact V1 content-key and password-derived-key size in bytes.
pub(crate) use self::crypto::SECRET_KEY_BYTES;
/// Owned 32-byte secret key with redacted diagnostics and drop-time zeroization.
pub(crate) use self::crypto::SecretKeyV1;
/// Decrypts authenticated ciphertext bytes and returns a generic authentication error on tag failure.
pub(crate) use self::crypto::decrypt_bytes;
/// Derives one V1 password wrapping key from serialized Argon2id parameters.
pub(crate) use self::crypto::derive_password_key;
/// Encrypts bounded plaintext bytes with XChaCha20-Poly1305 and caller-provided domain-separated AAD.
pub(crate) use self::crypto::encrypt_bytes;
/// Generates a fresh fixed-size byte array from the operating-system CSPRNG.
pub(crate) use self::crypto::random_bytes;
/// Generates a fresh XChaCha20-Poly1305 nonce from the operating-system CSPRNG.
pub(crate) use self::crypto::random_nonce;
/// Unwraps one 32-byte content key and maps every AEAD authentication failure to the generic Wallet authentication error.
pub(crate) use self::crypto::unwrap_key;
/// Wraps one 32-byte content key with XChaCha20-Poly1305 and caller-provided domain-separated AAD.
pub(crate) use self::crypto::wrap_key;
pub(crate) use self::payload::MetadataPayloadV1;
pub(crate) use self::payload::decode_metadata_payload;
/// Internal no-clobber native persistence path shared by transfer adapters.
pub(crate) use self::persistence::persist_new_wallet_content_v1;
/// Internal deterministic compartment-AAD codec shared by Wallet crypto layers.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-wallet-lib/src/owner.rs
// version: 4
// version: 5
/// Authorized OWNER capability handle.
///
@@ -119,7 +119,7 @@ impl WalletOwner {
destination: impl std::convert::AsRef<std::path::Path>,
alias: std::option::Option<std::string::String>,
) -> ksp_core_lib::Result<()> {
let mut payload = crate::payload::MetadataPayloadV1::from_info(&self.info);
let mut payload = crate::MetadataPayloadV1::from_info(&self.info);
let mutation_result = payload.set_alias(alias);
if let std::result::Result::Err(error) = mutation_result {
return std::result::Result::Err(error);
@@ -133,7 +133,7 @@ impl WalletOwner {
destination: impl std::convert::AsRef<std::path::Path>,
text: std::string::String,
) -> ksp_core_lib::Result<std::string::String> {
let mut payload = crate::payload::MetadataPayloadV1::from_info(&self.info);
let mut payload = crate::MetadataPayloadV1::from_info(&self.info);
let note_id = match payload.add_note(text) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
@@ -152,7 +152,7 @@ impl WalletOwner {
note_id: &str,
text: std::string::String,
) -> ksp_core_lib::Result<()> {
let mut payload = crate::payload::MetadataPayloadV1::from_info(&self.info);
let mut payload = crate::MetadataPayloadV1::from_info(&self.info);
let mutation_result = payload.update_note(note_id, text);
if let std::result::Result::Err(error) = mutation_result {
return std::result::Result::Err(error);
@@ -162,7 +162,7 @@ impl WalletOwner {
/// Deletes one protected note selected by its stable identifier.
pub async fn delete_note(&mut self, destination: impl std::convert::AsRef<std::path::Path>, note_id: &str) -> ksp_core_lib::Result<()> {
let mut payload = crate::payload::MetadataPayloadV1::from_info(&self.info);
let mut payload = crate::MetadataPayloadV1::from_info(&self.info);
let mutation_result = payload.delete_note(note_id);
if let std::result::Result::Err(error) = mutation_result {
return std::result::Result::Err(error);
@@ -274,7 +274,7 @@ impl WalletOwner {
async fn persist_metadata_payload(
&mut self,
destination: std::path::PathBuf,
payload: crate::payload::MetadataPayloadV1,
payload: crate::MetadataPayloadV1,
operation: &'static str,
) -> ksp_core_lib::Result<()> {
let (envelope, info) = match self.state.stage_metadata_payload(payload) {
@@ -292,6 +292,12 @@ impl WalletOwner {
}
}
impl std::fmt::Debug for WalletOwner {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.debug_struct("WalletOwner").field("info", &self.info).field("unlocked_state", &"<redacted>").finish();
}
}
async fn persist_staged(
destination: std::path::PathBuf,
expected_current: &crate::KspWalletEnvelopeV1,
@@ -304,12 +310,6 @@ async fn persist_staged(
return crate::persistence::replace_wallet_file_v1(destination, expected_current.clone(), serialized).await;
}
impl std::fmt::Debug for WalletOwner {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.debug_struct("WalletOwner").field("info", &self.info).field("unlocked_state", &"<redacted>").finish();
}
}
#[cfg(test)]
#[path = "../unit_tests/administration.rs"]
mod tests;

View File

@@ -1,10 +1,10 @@
// file: crates/ksp-wallet-lib/src/payload.rs
// version: 3
// version: 4
//! Plaintext payload codecs protected inside native `.kspwallet` V1 compartments.
use base64::Engine as _;
use std::str::FromStr as _;
use base64::Engine; // rust-rules: derive-import
use std::str::FromStr; // rust-rules: derive-import
pub(crate) struct MetadataPayloadV1 {
pubkey: ksp_core_lib::Pubkey,
@@ -12,7 +12,7 @@ pub(crate) struct MetadataPayloadV1 {
notes: std::vec::Vec<crate::WalletNote>,
}
impl MetadataPayloadV1 {
impl crate::MetadataPayloadV1 {
pub(crate) const fn pubkey(&self) -> &ksp_core_lib::Pubkey {
return &self.pubkey;
}
@@ -117,16 +117,16 @@ impl MetadataPayloadV1 {
pub(crate) struct OwnerControlMaterialV1 {
admin_signing_secret: [u8; crate::crypto::SECRET_KEY_BYTES],
metadata_key: crate::crypto::SecretKeyV1,
secret_key: crate::crypto::SecretKeyV1,
metadata_key: crate::SecretKeyV1,
secret_key: crate::SecretKeyV1,
}
impl OwnerControlMaterialV1 {
pub(crate) fn into_parts(mut self) -> ([u8; crate::crypto::SECRET_KEY_BYTES], crate::crypto::SecretKeyV1, crate::crypto::SecretKeyV1) {
pub(crate) fn into_parts(mut self) -> ([u8; crate::crypto::SECRET_KEY_BYTES], crate::SecretKeyV1, crate::SecretKeyV1) {
let mut admin_signing_secret = [0_u8; crate::crypto::SECRET_KEY_BYTES];
std::mem::swap(&mut admin_signing_secret, &mut self.admin_signing_secret);
let metadata_key = std::mem::replace(&mut self.metadata_key, crate::crypto::SecretKeyV1::from_bytes([0_u8; crate::crypto::SECRET_KEY_BYTES]));
let secret_key = std::mem::replace(&mut self.secret_key, crate::crypto::SecretKeyV1::from_bytes([0_u8; crate::crypto::SECRET_KEY_BYTES]));
let metadata_key = std::mem::replace(&mut self.metadata_key, crate::SecretKeyV1::from_bytes([0_u8; crate::crypto::SECRET_KEY_BYTES]));
let secret_key = std::mem::replace(&mut self.secret_key, crate::SecretKeyV1::from_bytes([0_u8; crate::crypto::SECRET_KEY_BYTES]));
return (admin_signing_secret, metadata_key, secret_key);
}
}
@@ -155,7 +155,7 @@ struct RawMetadataNoteV1 {
pub(crate) fn encode_initial_metadata_payload(
pubkey: ksp_core_lib::Pubkey,
metadata: crate::WalletCreateMetadataV1,
) -> ksp_core_lib::Result<(std::vec::Vec<u8>, MetadataPayloadV1)> {
) -> ksp_core_lib::Result<(std::vec::Vec<u8>, crate::MetadataPayloadV1)> {
let (alias, note_texts) = metadata.into_parts();
let validation_result = validate_alias(alias.as_deref());
if let std::result::Result::Err(error) = validation_result {
@@ -183,7 +183,7 @@ pub(crate) fn encode_initial_metadata_payload(
notes.push(crate::WalletNote::new(note_id, text));
}
let payload = MetadataPayloadV1 { pubkey, alias, notes };
let payload = crate::MetadataPayloadV1 { pubkey, alias, notes };
let serialized = match payload.encode() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
@@ -191,7 +191,7 @@ pub(crate) fn encode_initial_metadata_payload(
return std::result::Result::Ok((serialized, payload));
}
pub(crate) fn decode_metadata_payload(source: &[u8]) -> ksp_core_lib::Result<MetadataPayloadV1> {
pub(crate) fn decode_metadata_payload(source: &[u8]) -> ksp_core_lib::Result<crate::MetadataPayloadV1> {
if source.len() > crate::KSPWALLET_V1_MAX_METADATA_PLAINTEXT_BYTES {
return std::result::Result::Err(metadata_error("Wallet metadata payload exceeds the V1 plaintext limit", "metadata"));
}
@@ -236,13 +236,13 @@ pub(crate) fn decode_metadata_payload(source: &[u8]) -> ksp_core_lib::Result<Met
}
notes.push(crate::WalletNote::new(raw_note.id, raw_note.text));
}
return std::result::Result::Ok(MetadataPayloadV1 { pubkey, alias: raw.alias, notes });
return std::result::Result::Ok(crate::MetadataPayloadV1 { pubkey, alias: raw.alias, notes });
}
pub(crate) fn encode_owner_control_payload(
admin_signing_secret: &[u8; crate::crypto::SECRET_KEY_BYTES],
metadata_key: &crate::crypto::SecretKeyV1,
secret_key: &crate::crypto::SecretKeyV1,
metadata_key: &crate::SecretKeyV1,
secret_key: &crate::SecretKeyV1,
) -> [u8; crate::KSPWALLET_V1_OWNER_CONTROL_PLAINTEXT_BYTES] {
let mut output = [0_u8; crate::KSPWALLET_V1_OWNER_CONTROL_PLAINTEXT_BYTES];
output[0..32].copy_from_slice(admin_signing_secret);
@@ -263,8 +263,8 @@ pub(crate) fn decode_owner_control_payload(source: &[u8]) -> ksp_core_lib::Resul
secret_key.copy_from_slice(&source[64..96]);
return std::result::Result::Ok(OwnerControlMaterialV1 {
admin_signing_secret,
metadata_key: crate::crypto::SecretKeyV1::from_bytes(metadata_key),
secret_key: crate::crypto::SecretKeyV1::from_bytes(secret_key),
metadata_key: crate::SecretKeyV1::from_bytes(metadata_key),
secret_key: crate::SecretKeyV1::from_bytes(secret_key),
});
}

View File

@@ -1,9 +1,10 @@
// file: crates/ksp-wallet-lib/src/persistence.rs
// version: 4
// version: 6
//! Async-first native Wallet V1 filesystem persistence.
use std::io::{Read as _, Write as _};
use std::io::Read; // rust-rules: derive-import
use std::io::Write; // rust-rules: derive-import
/// Creates a new native `.kspwallet` V1 at `destination` without overwriting an existing path.
///
@@ -82,26 +83,10 @@ pub async fn inspect_locked_wallet_file_v1(source: impl std::convert::AsRef<std:
return crate::inspect_locked_wallet_v1(bytes.as_slice());
}
async fn read_wallet_file_async(source: std::path::PathBuf) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
let task = tokio::task::spawn_blocking(move || return read_wallet_file_blocking(source.as_path()));
return match task.await {
std::result::Result::Ok(result) => result,
std::result::Result::Err(error) => std::result::Result::Err(blocking_io_error("read_task", error)),
};
}
pub(crate) async fn persist_new_wallet_content_v1(destination: std::path::PathBuf, content: std::vec::Vec<u8>) -> ksp_core_lib::Result<()> {
return persist_new_wallet_async(destination, content).await;
}
async fn persist_new_wallet_async(destination: std::path::PathBuf, content: std::vec::Vec<u8>) -> ksp_core_lib::Result<()> {
let task = tokio::task::spawn_blocking(move || return persist_new_wallet_blocking(destination.as_path(), content.as_slice()));
return match task.await {
std::result::Result::Ok(result) => result,
std::result::Result::Err(error) => std::result::Result::Err(blocking_atomic_error("create_task", error)),
};
}
pub(crate) async fn replace_wallet_file_v1(
destination: std::path::PathBuf,
expected_current: crate::KspWalletEnvelopeV1,
@@ -116,6 +101,22 @@ pub(crate) async fn replace_wallet_file_v1(
};
}
async fn read_wallet_file_async(source: std::path::PathBuf) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
let task = tokio::task::spawn_blocking(move || return read_wallet_file_blocking(source.as_path()));
return match task.await {
std::result::Result::Ok(result) => result,
std::result::Result::Err(error) => std::result::Result::Err(blocking_io_error("read_task", error)),
};
}
async fn persist_new_wallet_async(destination: std::path::PathBuf, content: std::vec::Vec<u8>) -> ksp_core_lib::Result<()> {
let task = tokio::task::spawn_blocking(move || return persist_new_wallet_blocking(destination.as_path(), content.as_slice()));
return match task.await {
std::result::Result::Ok(result) => result,
std::result::Result::Err(error) => std::result::Result::Err(blocking_atomic_error("create_task", error)),
};
}
fn read_wallet_file_blocking(source: &std::path::Path) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
let opened = std::fs::File::open(source);
let file = match opened {

View File

@@ -1,10 +1,13 @@
// file: crates/ksp-wallet-lib/src/transfer.rs
// version: 1
// version: 2
//! Explicit OWNER-only Solana keypair import/export adapters.
use std::io::{Read as _, Write as _};
use zeroize::Zeroize as _;
use std::io::Read; // rust-rules: derive-import
use std::io::Write; // rust-rules: derive-import
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt; // rust-rules: derive-import
use zeroize::Zeroize; // rust-rules: derive-import
/// Explicit secret-transfer formats supported by Wallet `0.2.5`.
#[non_exhaustive]
@@ -317,8 +320,6 @@ fn destination_parent(destination: &std::path::Path) -> ksp_core_lib::Result<&st
#[cfg(unix)]
fn set_private_permissions_best_effort(file: &std::fs::File) {
use std::os::unix::fs::PermissionsExt as _;
let permissions = std::fs::Permissions::from_mode(0o600);
if let std::result::Result::Err(error) = file.set_permissions(permissions) {
ksp_logging_lib::warn!(

View File

@@ -1,16 +1,16 @@
// file: crates/ksp-wallet-lib/src/wallet.rs
// version: 5
// version: 7
//! In-memory native Wallet V1 create/open orchestration.
use ed25519_dalek::Signer as _;
use zeroize::Zeroize as _;
use ed25519_dalek::Signer; // rust-rules: derive-import
use zeroize::Zeroize; // rust-rules: derive-import
pub(crate) struct OwnerStateV1 {
envelope: crate::KspWalletEnvelopeV1,
owner_root: std::option::Option<crate::crypto::SecretKeyV1>,
metadata_key: std::option::Option<crate::crypto::SecretKeyV1>,
secret_key: std::option::Option<crate::crypto::SecretKeyV1>,
owner_root: std::option::Option<crate::SecretKeyV1>,
metadata_key: std::option::Option<crate::SecretKeyV1>,
secret_key: std::option::Option<crate::SecretKeyV1>,
admin_signing_key: std::option::Option<ed25519_dalek::SigningKey>,
solana_keypair: std::option::Option<solana_keypair::Keypair>,
}
@@ -18,9 +18,9 @@ pub(crate) struct OwnerStateV1 {
impl OwnerStateV1 {
pub(crate) fn new(
envelope: crate::KspWalletEnvelopeV1,
owner_root: crate::crypto::SecretKeyV1,
metadata_key: crate::crypto::SecretKeyV1,
secret_key: crate::crypto::SecretKeyV1,
owner_root: crate::SecretKeyV1,
metadata_key: crate::SecretKeyV1,
secret_key: crate::SecretKeyV1,
admin_signing_key: ed25519_dalek::SigningKey,
solana_keypair: solana_keypair::Keypair,
) -> Self {
@@ -43,7 +43,7 @@ impl OwnerStateV1 {
return;
}
pub(crate) fn apply_strong_view_state(&mut self, envelope: crate::KspWalletEnvelopeV1, metadata_key: crate::crypto::SecretKeyV1) {
pub(crate) fn apply_strong_view_state(&mut self, envelope: crate::KspWalletEnvelopeV1, metadata_key: crate::SecretKeyV1) {
self.envelope = envelope;
let previous = self.metadata_key.replace(metadata_key);
drop(previous);
@@ -80,8 +80,8 @@ impl OwnerStateV1 {
std::option::Option::None => return std::result::Result::Err(signing_error()),
};
let mut keypair_bytes = keypair.to_bytes();
let mut seed = [0_u8; crate::crypto::SECRET_KEY_BYTES];
seed.copy_from_slice(&keypair_bytes[0..crate::crypto::SECRET_KEY_BYTES]);
let mut seed = [0_u8; crate::SECRET_KEY_BYTES];
seed.copy_from_slice(&keypair_bytes[0..crate::SECRET_KEY_BYTES]);
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
let signature = signing_key.sign(message).to_bytes();
seed.zeroize();
@@ -89,10 +89,7 @@ impl OwnerStateV1 {
return std::result::Result::Ok(signature);
}
pub(crate) fn stage_metadata_payload(
&self,
payload: crate::payload::MetadataPayloadV1,
) -> ksp_core_lib::Result<(crate::KspWalletEnvelopeV1, crate::WalletInfo)> {
pub(crate) fn stage_metadata_payload(&self, payload: crate::MetadataPayloadV1) -> ksp_core_lib::Result<(crate::KspWalletEnvelopeV1, crate::WalletInfo)> {
let metadata_key = match self.metadata_key.as_ref() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crypto_operation_error()),
@@ -105,14 +102,14 @@ impl OwnerStateV1 {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let nonce = match crate::crypto::random_nonce() {
let nonce = match crate::random_nonce() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
plaintext.zeroize();
return std::result::Result::Err(error);
},
};
let encrypted = crate::crypto::encrypt_bytes(
let encrypted = crate::encrypt_bytes(
metadata_key,
&nonce,
self.envelope.compartment_aad(crate::WalletCompartmentKindV1::Metadata).as_slice(),
@@ -204,7 +201,7 @@ impl OwnerStateV1 {
return verify_and_return(envelope);
}
pub(crate) fn stage_disable_view(&self) -> ksp_core_lib::Result<(crate::KspWalletEnvelopeV1, crate::crypto::SecretKeyV1)> {
pub(crate) fn stage_disable_view(&self) -> ksp_core_lib::Result<(crate::KspWalletEnvelopeV1, crate::SecretKeyV1)> {
if !self.envelope.view_descriptor().enabled() {
return std::result::Result::Err(capability_error("Wallet VIEW capability is already disabled"));
}
@@ -214,12 +211,12 @@ impl OwnerStateV1 {
pub(crate) async fn stage_recreate_view(
&self,
new_password: crate::ViewPassword,
) -> ksp_core_lib::Result<(crate::KspWalletEnvelopeV1, crate::crypto::SecretKeyV1)> {
let slot_id = match crate::crypto::random_bytes::<{ crate::KSPWALLET_V1_SLOT_ID_BYTES }>() {
) -> ksp_core_lib::Result<(crate::KspWalletEnvelopeV1, crate::SecretKeyV1)> {
let slot_id = match crate::random_bytes::<{ crate::KSPWALLET_V1_SLOT_ID_BYTES }>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let new_metadata_key = match crate::crypto::SecretKeyV1::random() {
let new_metadata_key = match crate::SecretKeyV1::random() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -234,8 +231,8 @@ impl OwnerStateV1 {
fn stage_strong_view_change(
&self,
view_slot: std::option::Option<crate::WalletKeySlotV1>,
) -> ksp_core_lib::Result<(crate::KspWalletEnvelopeV1, crate::crypto::SecretKeyV1)> {
let new_metadata_key = match crate::crypto::SecretKeyV1::random() {
) -> ksp_core_lib::Result<(crate::KspWalletEnvelopeV1, crate::SecretKeyV1)> {
let new_metadata_key = match crate::SecretKeyV1::random() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -245,8 +242,8 @@ impl OwnerStateV1 {
fn stage_strong_view_change_with_key(
&self,
view_slot: std::option::Option<crate::WalletKeySlotV1>,
new_metadata_key: crate::crypto::SecretKeyV1,
) -> ksp_core_lib::Result<(crate::KspWalletEnvelopeV1, crate::crypto::SecretKeyV1)> {
new_metadata_key: crate::SecretKeyV1,
) -> ksp_core_lib::Result<(crate::KspWalletEnvelopeV1, crate::SecretKeyV1)> {
let current_metadata_key = match self.metadata_key.as_ref() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crypto_operation_error()),
@@ -264,7 +261,7 @@ impl OwnerStateV1 {
std::option::Option::None => return std::result::Result::Err(crypto_operation_error()),
};
let current_plaintext_result = crate::crypto::decrypt_bytes(
let current_plaintext_result = crate::decrypt_bytes(
current_metadata_key,
self.envelope.metadata().nonce(),
self.envelope.compartment_aad(crate::WalletCompartmentKindV1::Metadata).as_slice(),
@@ -274,14 +271,14 @@ impl OwnerStateV1 {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(authentication_error()),
};
let metadata_nonce = match crate::crypto::random_nonce() {
let metadata_nonce = match crate::random_nonce() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
metadata_plaintext.zeroize();
return std::result::Result::Err(error);
},
};
let metadata_ciphertext_result = crate::crypto::encrypt_bytes(
let metadata_ciphertext_result = crate::encrypt_bytes(
&new_metadata_key,
&metadata_nonce,
self.envelope.compartment_aad(crate::WalletCompartmentKindV1::Metadata).as_slice(),
@@ -297,14 +294,14 @@ impl OwnerStateV1 {
let mut admin_secret = admin_signing_key.to_bytes();
let mut owner_control_plaintext = crate::payload::encode_owner_control_payload(&admin_secret, &new_metadata_key, secret_key);
admin_secret.zeroize();
let owner_control_nonce = match crate::crypto::random_nonce() {
let owner_control_nonce = match crate::random_nonce() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
owner_control_plaintext.zeroize();
return std::result::Result::Err(error);
},
};
let owner_control_ciphertext_result = crate::crypto::encrypt_bytes(
let owner_control_ciphertext_result = crate::encrypt_bytes(
owner_root,
&owner_control_nonce,
self.envelope.compartment_aad(crate::WalletCompartmentKindV1::OwnerControl).as_slice(),
@@ -357,11 +354,11 @@ impl std::ops::Drop for OwnerStateV1 {
pub(crate) struct ViewStateV1 {
envelope: crate::KspWalletEnvelopeV1,
metadata_key: std::option::Option<crate::crypto::SecretKeyV1>,
metadata_key: std::option::Option<crate::SecretKeyV1>,
}
impl ViewStateV1 {
pub(crate) fn new(envelope: crate::KspWalletEnvelopeV1, metadata_key: crate::crypto::SecretKeyV1) -> Self {
pub(crate) fn new(envelope: crate::KspWalletEnvelopeV1, metadata_key: crate::SecretKeyV1) -> Self {
return Self { envelope, metadata_key: std::option::Option::Some(metadata_key) };
}
@@ -411,6 +408,18 @@ impl std::ops::Drop for ViewStateV1 {
}
}
enum PasswordRotationV1 {
Owner(crate::OwnerPassword),
View(crate::ViewPassword),
}
struct ViewCreationMaterialV1 {
slot_id: [u8; crate::KSPWALLET_V1_SLOT_ID_BYTES],
kdf: crate::WalletKdfParametersV1,
wrap_nonce: [u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
password: crate::ViewPassword,
}
/// Creates a new in-memory native `.kspwallet` V1 with a fresh Solana keypair.
///
/// The optional VIEW password creates an independent VIEW slot. This function performs no filesystem I/O; use [`crate::create_wallet_file_v1`] when
@@ -420,7 +429,7 @@ pub async fn create_wallet_v1(
view_password: std::option::Option<crate::ViewPassword>,
metadata: crate::WalletCreateMetadataV1,
) -> ksp_core_lib::Result<crate::WalletOwner> {
let mut solana_secret = match crate::crypto::random_bytes::<32>() {
let mut solana_secret = match crate::random_bytes::<32>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -429,26 +438,204 @@ pub async fn create_wallet_v1(
return crate::create_wallet_v1_from_keypair(owner_password, view_password, metadata, solana_keypair).await;
}
/// Opens the VIEW capability from a native `.kspwallet` V1 JSON document.
pub async fn open_wallet_view_v1(source: &[u8], password: crate::ViewPassword) -> ksp_core_lib::Result<crate::WalletView> {
let envelope = match crate::KspWalletEnvelopeV1::parse_json(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let verify_result = verify_state_signature(&envelope);
if let std::result::Result::Err(error) = verify_result {
return std::result::Result::Err(error);
}
let view_slot = match envelope.view_slot() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(capability_error("Wallet VIEW capability is disabled")),
};
let derived_result = derive_view_password_key_async(password, view_slot.kdf().clone()).await;
let derived = match derived_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let aad = envelope.view_slot_aad();
let view_aad = match aad {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(capability_error("Wallet VIEW capability is disabled")),
};
let metadata_key_result = crate::unwrap_key(&derived, view_slot.wrap().nonce(), view_aad.as_slice(), view_slot.wrap().ciphertext());
let metadata_key = match metadata_key_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(view_unlock_error()),
};
let metadata_plaintext_result = crate::decrypt_bytes(
&metadata_key,
envelope.metadata().nonce(),
envelope.compartment_aad(crate::WalletCompartmentKindV1::Metadata).as_slice(),
envelope.metadata().ciphertext(),
);
let mut metadata_plaintext = match metadata_plaintext_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(view_unlock_error()),
};
let metadata_payload_result = crate::decode_metadata_payload(metadata_plaintext.as_slice());
metadata_plaintext.zeroize();
let metadata_payload = match metadata_payload_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let info = metadata_payload.into_info(crate::WalletCapability::View);
let state = ViewStateV1::new(envelope, metadata_key);
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
operation = "wallet_open_view",
format_version = crate::KSPWALLET_FORMAT_VERSION_V1,
capability = "view",
"native wallet VIEW capability opened"
);
return std::result::Result::Ok(crate::WalletView::from_unlocked(info, state));
}
/// Opens the OWNER capability from a native `.kspwallet` V1 JSON document.
pub async fn open_wallet_owner_v1(source: &[u8], password: crate::OwnerPassword) -> ksp_core_lib::Result<crate::WalletOwner> {
let envelope = match crate::KspWalletEnvelopeV1::parse_json(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let verify_result = verify_state_signature(&envelope);
if let std::result::Result::Err(error) = verify_result {
return std::result::Result::Err(error);
}
let owner_slot = envelope.owner_slot();
let derived_result = derive_owner_password_key_async(password, owner_slot.kdf().clone()).await;
let derived = match derived_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let owner_root_result = crate::unwrap_key(&derived, owner_slot.wrap().nonce(), envelope.owner_slot_aad().as_slice(), owner_slot.wrap().ciphertext());
let owner_root = match owner_root_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(owner_unlock_error()),
};
let owner_control_plaintext_result = crate::decrypt_bytes(
&owner_root,
envelope.owner_control().nonce(),
envelope.compartment_aad(crate::WalletCompartmentKindV1::OwnerControl).as_slice(),
envelope.owner_control().ciphertext(),
);
let mut owner_control_plaintext = match owner_control_plaintext_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(owner_unlock_error()),
};
let control_result = crate::payload::decode_owner_control_payload(owner_control_plaintext.as_slice());
owner_control_plaintext.zeroize();
let control = match control_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let (mut admin_secret, metadata_key, secret_key) = control.into_parts();
let admin_signing_key = ed25519_dalek::SigningKey::from_bytes(&admin_secret);
admin_secret.zeroize();
if admin_signing_key.verifying_key().to_bytes() != *envelope.owner_auth_public_key() {
return std::result::Result::Err(authentication_error());
}
let metadata_plaintext_result = crate::decrypt_bytes(
&metadata_key,
envelope.metadata().nonce(),
envelope.compartment_aad(crate::WalletCompartmentKindV1::Metadata).as_slice(),
envelope.metadata().ciphertext(),
);
let mut metadata_plaintext = match metadata_plaintext_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(authentication_error()),
};
let metadata_payload_result = crate::decode_metadata_payload(metadata_plaintext.as_slice());
metadata_plaintext.zeroize();
let metadata_payload = match metadata_payload_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let secret_plaintext_result = crate::decrypt_bytes(
&secret_key,
envelope.secret().nonce(),
envelope.compartment_aad(crate::WalletCompartmentKindV1::Secret).as_slice(),
envelope.secret().ciphertext(),
);
let mut secret_plaintext = match secret_plaintext_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(authentication_error()),
};
if secret_plaintext.len() != crate::KSPWALLET_V1_SECRET_PLAINTEXT_BYTES {
secret_plaintext.zeroize();
return std::result::Result::Err(key_material_error());
}
let keypair_result = solana_keypair::Keypair::try_from(secret_plaintext.as_slice());
let solana_keypair = match keypair_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
secret_plaintext.zeroize();
return std::result::Result::Err(key_material_error());
},
};
let secret_pubkey_result = pubkey_from_keypair_bytes(secret_plaintext.as_slice());
let secret_pubkey = match secret_pubkey_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
secret_plaintext.zeroize();
return std::result::Result::Err(error);
},
};
secret_plaintext.zeroize();
if &secret_pubkey != metadata_payload.pubkey() {
return std::result::Result::Err(key_material_error());
}
let info = metadata_payload.into_info(crate::WalletCapability::Owner);
let state = OwnerStateV1::new(envelope, owner_root, metadata_key, secret_key, admin_signing_key, solana_keypair);
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
operation = "wallet_open_owner",
format_version = crate::KSPWALLET_FORMAT_VERSION_V1,
capability = "owner",
"native wallet OWNER capability opened"
);
return std::result::Result::Ok(crate::WalletOwner::from_unlocked(info, state));
}
/// Parses and verifies the OWNER-authenticated locked state without unlocking metadata or secret material.
pub fn inspect_locked_wallet_v1(source: &[u8]) -> ksp_core_lib::Result<crate::LockedWalletInfo> {
let envelope = match crate::KspWalletEnvelopeV1::parse_json(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let verify_result = verify_state_signature(&envelope);
if let std::result::Result::Err(error) = verify_result {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(crate::LockedWalletInfo::new(envelope.view_descriptor().enabled()));
}
pub(crate) async fn create_wallet_v1_from_keypair(
owner_password: crate::OwnerPassword,
view_password: std::option::Option<crate::ViewPassword>,
metadata: crate::WalletCreateMetadataV1,
solana_keypair: solana_keypair::Keypair,
) -> ksp_core_lib::Result<crate::WalletOwner> {
let owner_root = match crate::crypto::SecretKeyV1::random() {
let owner_root = match crate::SecretKeyV1::random() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let metadata_key = match crate::crypto::SecretKeyV1::random() {
let metadata_key = match crate::SecretKeyV1::random() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let secret_key = match crate::crypto::SecretKeyV1::random() {
let secret_key = match crate::SecretKeyV1::random() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut admin_secret = match crate::crypto::random_bytes::<{ crate::crypto::SECRET_KEY_BYTES }>() {
let mut admin_secret = match crate::random_bytes::<{ crate::SECRET_KEY_BYTES }>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -478,20 +665,20 @@ pub(crate) async fn create_wallet_v1_from_keypair(
let mut owner_control_plaintext = crate::payload::encode_owner_control_payload(&admin_secret, &metadata_key, &secret_key);
admin_secret.zeroize();
let owner_slot_id = match crate::crypto::random_bytes::<{ crate::KSPWALLET_V1_SLOT_ID_BYTES }>() {
let owner_slot_id = match crate::random_bytes::<{ crate::KSPWALLET_V1_SLOT_ID_BYTES }>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
let owner_salt = match crate::crypto::random_bytes::<{ crate::KSPWALLET_V1_DEFAULT_KDF_SALT_BYTES }>() {
let owner_salt = match crate::random_bytes::<{ crate::KSPWALLET_V1_DEFAULT_KDF_SALT_BYTES }>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
let owner_kdf = crate::WalletKdfParametersV1::new_creation(owner_salt.to_vec());
let owner_wrap_nonce = match crate::crypto::random_nonce() {
let owner_wrap_nonce = match crate::random_nonce() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
@@ -506,19 +693,19 @@ pub(crate) async fn create_wallet_v1_from_keypair(
},
};
let owner_control_nonce = match crate::crypto::random_nonce() {
let owner_control_nonce = match crate::random_nonce() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
let metadata_nonce = match crate::crypto::random_nonce() {
let metadata_nonce = match crate::random_nonce() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
let secret_nonce = match crate::crypto::random_nonce() {
let secret_nonce = match crate::random_nonce() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
@@ -543,7 +730,7 @@ pub(crate) async fn create_wallet_v1_from_keypair(
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
let owner_wrapped_result = crate::crypto::wrap_key(&owner_derived, &owner_root, &owner_wrap_nonce, provisional.owner_slot_aad().as_slice());
let owner_wrapped_result = crate::wrap_key(&owner_derived, &owner_root, &owner_wrap_nonce, provisional.owner_slot_aad().as_slice());
let owner_wrapped = match owner_wrapped_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
@@ -559,7 +746,7 @@ pub(crate) async fn create_wallet_v1_from_keypair(
},
};
let owner_control_ciphertext_result = crate::crypto::encrypt_bytes(
let owner_control_ciphertext_result = crate::encrypt_bytes(
&owner_root,
&owner_control_nonce,
provisional.compartment_aad(crate::WalletCompartmentKindV1::OwnerControl).as_slice(),
@@ -573,7 +760,7 @@ pub(crate) async fn create_wallet_v1_from_keypair(
};
owner_control_plaintext.zeroize();
let metadata_ciphertext_result = crate::crypto::encrypt_bytes(
let metadata_ciphertext_result = crate::encrypt_bytes(
&metadata_key,
&metadata_nonce,
provisional.compartment_aad(crate::WalletCompartmentKindV1::Metadata).as_slice(),
@@ -587,7 +774,7 @@ pub(crate) async fn create_wallet_v1_from_keypair(
};
metadata_plaintext.zeroize();
let secret_ciphertext_result = crate::crypto::encrypt_bytes(
let secret_ciphertext_result = crate::encrypt_bytes(
&secret_key,
&secret_nonce,
provisional.compartment_aad(crate::WalletCompartmentKindV1::Secret).as_slice(),
@@ -648,185 +835,6 @@ pub(crate) async fn create_wallet_v1_from_keypair(
return std::result::Result::Ok(crate::WalletOwner::from_unlocked(info, state));
}
/// Opens the VIEW capability from a native `.kspwallet` V1 JSON document.
pub async fn open_wallet_view_v1(source: &[u8], password: crate::ViewPassword) -> ksp_core_lib::Result<crate::WalletView> {
let envelope = match crate::KspWalletEnvelopeV1::parse_json(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let verify_result = verify_state_signature(&envelope);
if let std::result::Result::Err(error) = verify_result {
return std::result::Result::Err(error);
}
let view_slot = match envelope.view_slot() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(capability_error("Wallet VIEW capability is disabled")),
};
let derived_result = derive_view_password_key_async(password, view_slot.kdf().clone()).await;
let derived = match derived_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let aad = envelope.view_slot_aad();
let view_aad = match aad {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(capability_error("Wallet VIEW capability is disabled")),
};
let metadata_key_result = crate::crypto::unwrap_key(&derived, view_slot.wrap().nonce(), view_aad.as_slice(), view_slot.wrap().ciphertext());
let metadata_key = match metadata_key_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(view_unlock_error()),
};
let metadata_plaintext_result = crate::crypto::decrypt_bytes(
&metadata_key,
envelope.metadata().nonce(),
envelope.compartment_aad(crate::WalletCompartmentKindV1::Metadata).as_slice(),
envelope.metadata().ciphertext(),
);
let mut metadata_plaintext = match metadata_plaintext_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(view_unlock_error()),
};
let metadata_payload_result = crate::payload::decode_metadata_payload(metadata_plaintext.as_slice());
metadata_plaintext.zeroize();
let metadata_payload = match metadata_payload_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let info = metadata_payload.into_info(crate::WalletCapability::View);
let state = ViewStateV1::new(envelope, metadata_key);
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
operation = "wallet_open_view",
format_version = crate::KSPWALLET_FORMAT_VERSION_V1,
capability = "view",
"native wallet VIEW capability opened"
);
return std::result::Result::Ok(crate::WalletView::from_unlocked(info, state));
}
/// Opens the OWNER capability from a native `.kspwallet` V1 JSON document.
pub async fn open_wallet_owner_v1(source: &[u8], password: crate::OwnerPassword) -> ksp_core_lib::Result<crate::WalletOwner> {
let envelope = match crate::KspWalletEnvelopeV1::parse_json(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let verify_result = verify_state_signature(&envelope);
if let std::result::Result::Err(error) = verify_result {
return std::result::Result::Err(error);
}
let owner_slot = envelope.owner_slot();
let derived_result = derive_owner_password_key_async(password, owner_slot.kdf().clone()).await;
let derived = match derived_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let owner_root_result =
crate::crypto::unwrap_key(&derived, owner_slot.wrap().nonce(), envelope.owner_slot_aad().as_slice(), owner_slot.wrap().ciphertext());
let owner_root = match owner_root_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(owner_unlock_error()),
};
let owner_control_plaintext_result = crate::crypto::decrypt_bytes(
&owner_root,
envelope.owner_control().nonce(),
envelope.compartment_aad(crate::WalletCompartmentKindV1::OwnerControl).as_slice(),
envelope.owner_control().ciphertext(),
);
let mut owner_control_plaintext = match owner_control_plaintext_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(owner_unlock_error()),
};
let control_result = crate::payload::decode_owner_control_payload(owner_control_plaintext.as_slice());
owner_control_plaintext.zeroize();
let control = match control_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let (mut admin_secret, metadata_key, secret_key) = control.into_parts();
let admin_signing_key = ed25519_dalek::SigningKey::from_bytes(&admin_secret);
admin_secret.zeroize();
if admin_signing_key.verifying_key().to_bytes() != *envelope.owner_auth_public_key() {
return std::result::Result::Err(authentication_error());
}
let metadata_plaintext_result = crate::crypto::decrypt_bytes(
&metadata_key,
envelope.metadata().nonce(),
envelope.compartment_aad(crate::WalletCompartmentKindV1::Metadata).as_slice(),
envelope.metadata().ciphertext(),
);
let mut metadata_plaintext = match metadata_plaintext_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(authentication_error()),
};
let metadata_payload_result = crate::payload::decode_metadata_payload(metadata_plaintext.as_slice());
metadata_plaintext.zeroize();
let metadata_payload = match metadata_payload_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let secret_plaintext_result = crate::crypto::decrypt_bytes(
&secret_key,
envelope.secret().nonce(),
envelope.compartment_aad(crate::WalletCompartmentKindV1::Secret).as_slice(),
envelope.secret().ciphertext(),
);
let mut secret_plaintext = match secret_plaintext_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(authentication_error()),
};
if secret_plaintext.len() != crate::KSPWALLET_V1_SECRET_PLAINTEXT_BYTES {
secret_plaintext.zeroize();
return std::result::Result::Err(key_material_error());
}
let keypair_result = solana_keypair::Keypair::try_from(secret_plaintext.as_slice());
let solana_keypair = match keypair_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
secret_plaintext.zeroize();
return std::result::Result::Err(key_material_error());
},
};
let secret_pubkey_result = pubkey_from_keypair_bytes(secret_plaintext.as_slice());
let secret_pubkey = match secret_pubkey_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
secret_plaintext.zeroize();
return std::result::Result::Err(error);
},
};
secret_plaintext.zeroize();
if &secret_pubkey != metadata_payload.pubkey() {
return std::result::Result::Err(key_material_error());
}
let info = metadata_payload.into_info(crate::WalletCapability::Owner);
let state = OwnerStateV1::new(envelope, owner_root, metadata_key, secret_key, admin_signing_key, solana_keypair);
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
operation = "wallet_open_owner",
format_version = crate::KSPWALLET_FORMAT_VERSION_V1,
capability = "owner",
"native wallet OWNER capability opened"
);
return std::result::Result::Ok(crate::WalletOwner::from_unlocked(info, state));
}
/// Parses and verifies the OWNER-authenticated locked state without unlocking metadata or secret material.
pub fn inspect_locked_wallet_v1(source: &[u8]) -> ksp_core_lib::Result<crate::LockedWalletInfo> {
let envelope = match crate::KspWalletEnvelopeV1::parse_json(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let verify_result = verify_state_signature(&envelope);
if let std::result::Result::Err(error) = verify_result {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(crate::LockedWalletInfo::new(envelope.view_descriptor().enabled()));
}
pub(crate) fn verify_state_signature(envelope: &crate::KspWalletEnvelopeV1) -> ksp_core_lib::Result<()> {
let verifying_key_result = ed25519_dalek::VerifyingKey::from_bytes(envelope.owner_auth_public_key());
let verifying_key = match verifying_key_result {
@@ -841,24 +849,19 @@ pub(crate) fn verify_state_signature(envelope: &crate::KspWalletEnvelopeV1) -> k
};
}
enum PasswordRotationV1 {
Owner(crate::OwnerPassword),
View(crate::ViewPassword),
}
async fn rewrap_slot(
envelope: &crate::KspWalletEnvelopeV1,
slot_id: [u8; crate::KSPWALLET_V1_SLOT_ID_BYTES],
role: crate::WalletKeySlotRoleV1,
capability_key: &crate::crypto::SecretKeyV1,
capability_key: &crate::SecretKeyV1,
password: PasswordRotationV1,
) -> ksp_core_lib::Result<crate::WalletKeySlotV1> {
let salt = match crate::crypto::random_bytes::<{ crate::KSPWALLET_V1_DEFAULT_KDF_SALT_BYTES }>() {
let salt = match crate::random_bytes::<{ crate::KSPWALLET_V1_DEFAULT_KDF_SALT_BYTES }>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let kdf = crate::WalletKdfParametersV1::new_creation(salt.to_vec());
let nonce = match crate::crypto::random_nonce() {
let nonce = match crate::random_nonce() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -874,10 +877,10 @@ async fn rewrap_slot(
slot_id,
role,
kdf.clone(),
crate::WalletKeyWrapV1::new(nonce, std::vec![0_u8; crate::crypto::SECRET_KEY_BYTES + crate::KSPWALLET_V1_AEAD_TAG_BYTES]),
crate::WalletKeyWrapV1::new(nonce, std::vec![0_u8; crate::SECRET_KEY_BYTES + crate::KSPWALLET_V1_AEAD_TAG_BYTES]),
);
let aad = crate::slot_aad(envelope, &provisional);
let wrapped = match crate::crypto::wrap_key(&derived, capability_key, &nonce, aad.as_slice()) {
let wrapped = match crate::wrap_key(&derived, capability_key, &nonce, aad.as_slice()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -925,27 +928,20 @@ fn verify_and_return(envelope: crate::KspWalletEnvelopeV1) -> ksp_core_lib::Resu
return std::result::Result::Ok(envelope);
}
struct ViewCreationMaterialV1 {
slot_id: [u8; crate::KSPWALLET_V1_SLOT_ID_BYTES],
kdf: crate::WalletKdfParametersV1,
wrap_nonce: [u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
password: crate::ViewPassword,
}
fn prepare_view_creation(password: std::option::Option<crate::ViewPassword>) -> ksp_core_lib::Result<std::option::Option<ViewCreationMaterialV1>> {
let password = match password {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
};
let slot_id = match crate::crypto::random_bytes::<{ crate::KSPWALLET_V1_SLOT_ID_BYTES }>() {
let slot_id = match crate::random_bytes::<{ crate::KSPWALLET_V1_SLOT_ID_BYTES }>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let salt = match crate::crypto::random_bytes::<{ crate::KSPWALLET_V1_DEFAULT_KDF_SALT_BYTES }>() {
let salt = match crate::random_bytes::<{ crate::KSPWALLET_V1_DEFAULT_KDF_SALT_BYTES }>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let wrap_nonce = match crate::crypto::random_nonce() {
let wrap_nonce = match crate::random_nonce() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -960,7 +956,7 @@ fn prepare_view_creation(password: std::option::Option<crate::ViewPassword>) ->
async fn seal_view_slot(
envelope: &crate::KspWalletEnvelopeV1,
material: std::option::Option<ViewCreationMaterialV1>,
metadata_key: &crate::crypto::SecretKeyV1,
metadata_key: &crate::SecretKeyV1,
) -> ksp_core_lib::Result<std::option::Option<crate::WalletKeySlotV1>> {
let material = match material {
std::option::Option::Some(value) => value,
@@ -975,10 +971,10 @@ async fn seal_view_slot(
material.slot_id,
crate::WalletKeySlotRoleV1::View,
material.kdf.clone(),
crate::WalletKeyWrapV1::new(material.wrap_nonce, std::vec![0_u8; crate::crypto::SECRET_KEY_BYTES + crate::KSPWALLET_V1_AEAD_TAG_BYTES]),
crate::WalletKeyWrapV1::new(material.wrap_nonce, std::vec![0_u8; crate::SECRET_KEY_BYTES + crate::KSPWALLET_V1_AEAD_TAG_BYTES]),
);
let aad = crate::slot_aad(envelope, &provisional_slot);
let wrapped_result = crate::crypto::wrap_key(&derived, metadata_key, &material.wrap_nonce, aad.as_slice());
let wrapped_result = crate::wrap_key(&derived, metadata_key, &material.wrap_nonce, aad.as_slice());
let wrapped = match wrapped_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
@@ -1005,7 +1001,7 @@ fn provisional_envelope(
owner_slot_id,
crate::WalletKeySlotRoleV1::Owner,
owner_kdf,
crate::WalletKeyWrapV1::new(owner_wrap_nonce, std::vec![0_u8; crate::crypto::SECRET_KEY_BYTES + crate::KSPWALLET_V1_AEAD_TAG_BYTES]),
crate::WalletKeyWrapV1::new(owner_wrap_nonce, std::vec![0_u8; crate::SECRET_KEY_BYTES + crate::KSPWALLET_V1_AEAD_TAG_BYTES]),
);
let (view_descriptor, view_slot) = match view_material {
std::option::Option::Some(material) => (
@@ -1014,7 +1010,7 @@ fn provisional_envelope(
material.slot_id,
crate::WalletKeySlotRoleV1::View,
material.kdf.clone(),
crate::WalletKeyWrapV1::new(material.wrap_nonce, std::vec![0_u8; crate::crypto::SECRET_KEY_BYTES + crate::KSPWALLET_V1_AEAD_TAG_BYTES]),
crate::WalletKeyWrapV1::new(material.wrap_nonce, std::vec![0_u8; crate::SECRET_KEY_BYTES + crate::KSPWALLET_V1_AEAD_TAG_BYTES]),
)),
),
std::option::Option::None => (crate::WalletViewDescriptorV1::disabled(), std::option::Option::None),
@@ -1035,19 +1031,16 @@ fn provisional_envelope(
);
}
async fn derive_owner_password_key_async(
password: crate::OwnerPassword,
kdf: crate::WalletKdfParametersV1,
) -> ksp_core_lib::Result<crate::crypto::SecretKeyV1> {
let task = tokio::task::spawn_blocking(move || return crate::crypto::derive_password_key(password.as_bytes(), &kdf));
async fn derive_owner_password_key_async(password: crate::OwnerPassword, kdf: crate::WalletKdfParametersV1) -> ksp_core_lib::Result<crate::SecretKeyV1> {
let task = tokio::task::spawn_blocking(move || return crate::derive_password_key(password.as_bytes(), &kdf));
return match task.await {
std::result::Result::Ok(result) => result,
std::result::Result::Err(_) => std::result::Result::Err(crypto_operation_error()),
};
}
async fn derive_view_password_key_async(password: crate::ViewPassword, kdf: crate::WalletKdfParametersV1) -> ksp_core_lib::Result<crate::crypto::SecretKeyV1> {
let task = tokio::task::spawn_blocking(move || return crate::crypto::derive_password_key(password.as_bytes(), &kdf));
async fn derive_view_password_key_async(password: crate::ViewPassword, kdf: crate::WalletKdfParametersV1) -> ksp_core_lib::Result<crate::SecretKeyV1> {
let task = tokio::task::spawn_blocking(move || return crate::derive_password_key(password.as_bytes(), &kdf));
return match task.await {
std::result::Result::Ok(result) => result,
std::result::Result::Err(_) => std::result::Result::Err(crypto_operation_error()),

View File

@@ -1,9 +1,9 @@
// file: crates/ksp-wallet-lib/src/wire.rs
// version: 5
// version: 6
//! Strict native `.kspwallet` V1 wire envelope.
use base64::Engine as _;
use base64::Engine; // rust-rules: derive-import
/// Password KDF supported by `.kspwallet` V1 key slots.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-wallet-lib/tests/dependency_boundary.rs
// version: 9
// version: 10
//! Wallet-specific dependency and ownership canaries.
@@ -60,6 +60,8 @@ fn wallet_manifest_preserves_dependency_firewall() -> std::io::Result<()> {
"tauri",
"tracing =",
"solana-pubkey",
"solana-signer",
"solana-signature",
"bs58",
] {
assert!(!manifest.contains(forbidden), "forbidden direct Wallet dependency detected: {forbidden}");
@@ -90,3 +92,26 @@ fn wallet_sources_use_core_pubkey_logging_facade_and_no_environment() -> std::io
assert!(!all_source.contains(direct_tracing_path.as_str()));
return std::result::Result::Ok(());
}
#[test]
fn wallet_public_surface_does_not_reexport_secret_or_signer_types() -> std::io::Result<()> {
let lib_source = match std::fs::read_to_string(crate_root().join("src/lib.rs")) {
std::result::Result::Ok(source) => source,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
for forbidden in ["pub use solana_keypair", "pub use solana_signer", "pub use solana_signature"] {
assert!(!lib_source.contains(forbidden), "Wallet public surface reexports secret/signing implementation type: {forbidden}");
}
let view_source = match std::fs::read_to_string(crate_root().join("src/view.rs")) {
std::result::Result::Ok(source) => source,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(!view_source.contains("export_transfer("));
assert!(!view_source.contains("sign_message("));
let transfer_source = match std::fs::read_to_string(crate_root().join("src/transfer.rs")) {
std::result::Result::Ok(source) => source,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(transfer_source.contains("#[non_exhaustive]"));
return std::result::Result::Ok(());
}

View File

@@ -1,9 +1,9 @@
// file: crates/ksp-wallet-lib/unit_tests/capability.rs
// version: 1
// version: 2
#[test]
fn capability_variants_are_distinct_and_stable() {
assert_ne!(super::WalletCapability::View, super::WalletCapability::Owner);
assert_eq!(format!("{:?}", super::WalletCapability::View), "View");
assert_eq!(format!("{:?}", super::WalletCapability::Owner), "Owner");
assert_ne!(crate::WalletCapability::View, crate::WalletCapability::Owner);
assert_eq!(format!("{:?}", crate::WalletCapability::View), "View");
assert_eq!(format!("{:?}", crate::WalletCapability::Owner), "Owner");
}

View File

@@ -1,7 +1,7 @@
// file: crates/ksp-wallet-lib/unit_tests/crypto.rs
// version: 1
// version: 2
use base64::Engine as _;
use base64::Engine; // rust-rules: derive-import
const VECTOR: &[u8] = include_bytes!("../tests/fixtures/kspwallet_v1_crypto_vectors.json");
@@ -70,15 +70,15 @@ fn deterministic_argon2id_and_xchacha_wrap_vector_matches_external_canary() -> k
};
assert_eq!(derived.as_bytes(), &expected_derived);
let content = super::SecretKeyV1::from_bytes(content_key);
let wrapped_result = super::wrap_key(&derived, &content, &nonce, aad.as_slice());
let content = crate::SecretKeyV1::from_bytes(content_key);
let wrapped_result = crate::wrap_key(&derived, &content, &nonce, aad.as_slice());
let wrapped = match wrapped_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(wrapped, expected_wrapped);
let unwrapped_result = super::unwrap_key(&derived, &nonce, aad.as_slice(), wrapped.as_slice());
let unwrapped_result = crate::unwrap_key(&derived, &nonce, aad.as_slice(), wrapped.as_slice());
let unwrapped = match unwrapped_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
@@ -89,16 +89,16 @@ fn deterministic_argon2id_and_xchacha_wrap_vector_matches_external_canary() -> k
#[test]
fn xchacha_tampering_is_reported_as_generic_authentication_failure() -> ksp_core_lib::Result<()> {
let key = super::SecretKeyV1::from_bytes([0x11_u8; 32]);
let key = crate::SecretKeyV1::from_bytes([0x11_u8; 32]);
let nonce = [0x22_u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES];
let plaintext = [0x33_u8; 32];
let encrypted_result = super::encrypt_bytes(&key, &nonce, b"kspwallet-test-aad", plaintext.as_slice());
let encrypted_result = crate::encrypt_bytes(&key, &nonce, b"kspwallet-test-aad", plaintext.as_slice());
let mut encrypted = match encrypted_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
encrypted[0] ^= 1;
let result = super::decrypt_bytes(&key, &nonce, b"kspwallet-test-aad", encrypted.as_slice());
let result = crate::decrypt_bytes(&key, &nonce, b"kspwallet-test-aad", encrypted.as_slice());
assert!(result.is_err());
let error = match result {
std::result::Result::Err(error) => error,
@@ -110,12 +110,12 @@ fn xchacha_tampering_is_reported_as_generic_authentication_failure() -> ksp_core
#[test]
fn secret_key_debug_is_redacted_and_random_sources_are_callable() -> ksp_core_lib::Result<()> {
let key_result = super::SecretKeyV1::random();
let key_result = crate::SecretKeyV1::random();
let key = match key_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let nonce_result = super::random_nonce();
let nonce_result = crate::random_nonce();
let nonce = match nonce_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-wallet-lib/unit_tests/metadata.rs
// version: 1
// version: 2
fn test_pubkey() -> ksp_core_lib::Pubkey {
return ksp_core_lib::PRGIDPK_SOLANA_SYSTEM;
@@ -7,8 +7,8 @@ fn test_pubkey() -> ksp_core_lib::Pubkey {
#[test]
fn authorized_info_exposes_metadata_after_authorization() {
let note = super::WalletNote { id: std::string::String::from("purpose"), text: std::string::String::from("devnet test wallet") };
let info = super::WalletInfo {
let note = crate::WalletNote { id: std::string::String::from("purpose"), text: std::string::String::from("devnet test wallet") };
let info = crate::WalletInfo {
format_version: 1,
capability: crate::WalletCapability::View,
pubkey: test_pubkey(),
@@ -25,11 +25,11 @@ fn authorized_info_exposes_metadata_after_authorization() {
#[test]
fn authorized_info_debug_redacts_alias_and_note_text() {
let note = super::WalletNote {
let note = crate::WalletNote {
id: std::string::String::from("NOTE-ID-CANARY"),
text: std::string::String::from("NOTE-SECRET-CANARY"),
};
let info = super::WalletInfo {
let info = crate::WalletInfo {
format_version: 1,
capability: crate::WalletCapability::Owner,
pubkey: test_pubkey(),
@@ -46,7 +46,7 @@ fn authorized_info_debug_redacts_alias_and_note_text() {
#[test]
fn locked_info_does_not_carry_authorized_identity_or_metadata() {
let info = super::LockedWalletInfo { format_version: 1, view_enabled: true };
let info = crate::LockedWalletInfo { format_version: 1, view_enabled: true };
assert_eq!(info.format_version(), 1);
assert!(info.view_enabled());
let rendered = format!("{info:?}");

View File

@@ -1,9 +1,9 @@
// file: crates/ksp-wallet-lib/unit_tests/password.rs
// version: 1
// version: 2
#[test]
fn view_password_debug_is_redacted() {
let password = super::ViewPassword::new(std::string::String::from("VIEW-SECRET-CANARY"));
let password = crate::ViewPassword::new(std::string::String::from("VIEW-SECRET-CANARY"));
let rendered = format!("{password:?}");
assert_eq!(rendered, "ViewPassword(<redacted>)");
assert!(!rendered.contains("VIEW-SECRET-CANARY"));
@@ -11,7 +11,7 @@ fn view_password_debug_is_redacted() {
#[test]
fn owner_password_debug_is_redacted() {
let password = super::OwnerPassword::new(std::string::String::from("OWNER-SECRET-CANARY"));
let password = crate::OwnerPassword::new(std::string::String::from("OWNER-SECRET-CANARY"));
let rendered = format!("{password:?}");
assert_eq!(rendered, "OwnerPassword(<redacted>)");
assert!(!rendered.contains("OWNER-SECRET-CANARY"));
@@ -19,6 +19,6 @@ fn owner_password_debug_is_redacted() {
#[test]
fn password_wrappers_require_drop_for_zeroization() {
assert!(std::mem::needs_drop::<super::ViewPassword>());
assert!(std::mem::needs_drop::<super::OwnerPassword>());
assert!(std::mem::needs_drop::<crate::ViewPassword>());
assert!(std::mem::needs_drop::<crate::OwnerPassword>());
}

View File

@@ -1,7 +1,7 @@
// file: crates/ksp-wallet-lib/unit_tests/payload.rs
// version: 1
// version: 2
use base64::Engine as _;
use base64::Engine; // rust-rules: derive-import
#[test]
fn metadata_payload_rejects_duplicate_note_identifiers() -> ksp_core_lib::Result<()> {
@@ -18,7 +18,7 @@ fn metadata_payload_rejects_duplicate_note_identifiers() -> ksp_core_lib::Result
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(test_error("test metadata payload could not be serialized")),
};
let result = super::decode_metadata_payload(encoded.as_slice());
let result = crate::decode_metadata_payload(encoded.as_slice());
let error = match result {
std::result::Result::Ok(_) => return std::result::Result::Err(test_error("duplicate note identifiers unexpectedly parsed")),
std::result::Result::Err(error) => error,

View File

@@ -0,0 +1,86 @@
// file: crates/ksp-wallet-lib/unit_tests/security.rs
// version: 2
//! Adversarial security canaries for native `.kspwallet` V1.
use base64::Engine; // rust-rules: derive-import
const FULL_VECTOR: &[u8] = include_bytes!("../tests/fixtures/kspwallet_v1_full_vector.json");
#[test]
fn owner_signed_regions_reject_canonical_tampering_before_unlock() {
for pointer in [
"/key_slots/0/kdf/salt",
"/key_slots/0/wrap/ciphertext",
"/owner_control/ciphertext",
"/metadata/ciphertext",
"/secret/ciphertext",
"/state_signature/signature",
] {
let tampered = tamper_base64url_field(FULL_VECTOR, pointer);
let error = crate::inspect_locked_wallet_v1(tampered.as_slice()).expect_err("OWNER-signed canonical tampering must be rejected");
assert_eq!(error.code(), crate::ERROR_CODE_AUTHENTICATION_FAILED, "unexpected error code for tampered field {pointer}");
}
}
#[test]
fn owner_signature_verification_precedes_owner_and_view_password_kdf() {
let tampered = tamper_base64url_field(FULL_VECTOR, "/metadata/ciphertext");
let runtime = runtime();
let owner = runtime.block_on(crate::open_wallet_owner_v1(tampered.as_slice(), crate::OwnerPassword::new(std::string::String::new())));
assert_eq!(owner.expect_err("tampered state must fail before an empty OWNER password reaches Argon2").code(), crate::ERROR_CODE_AUTHENTICATION_FAILED);
let view = runtime.block_on(crate::open_wallet_view_v1(tampered.as_slice(), crate::ViewPassword::new(std::string::String::new())));
assert_eq!(view.expect_err("tampered state must fail before an empty VIEW password reaches Argon2").code(), crate::ERROR_CODE_AUTHENTICATION_FAILED);
}
#[test]
fn view_credential_tampering_does_not_forge_owner_state_and_cannot_unlock_view() {
let tampered = tamper_base64url_field(FULL_VECTOR, "/key_slots/1/wrap/ciphertext");
let locked = crate::inspect_locked_wallet_v1(tampered.as_slice()).expect("VIEW wrapping credentials are intentionally outside the OWNER state transcript");
assert!(locked.view_enabled());
let runtime = runtime();
let owner =
runtime.block_on(crate::open_wallet_owner_v1(tampered.as_slice(), crate::OwnerPassword::new(std::string::String::from("pre005-owner-password"))));
assert!(owner.is_ok(), "VIEW credential tampering must not invalidate OWNER unlock");
let view = runtime.block_on(crate::open_wallet_view_v1(tampered.as_slice(), crate::ViewPassword::new(std::string::String::from("pre005-view-password"))));
assert_eq!(view.expect_err("tampered VIEW wrapping must not unlock metadata").code(), crate::ERROR_CODE_VIEW_UNLOCK_FAILED);
}
#[test]
fn unlock_failures_do_not_echo_password_material() {
let owner_canary = "OWNER-PASSWORD-LEAK-CANARY";
let view_canary = "VIEW-PASSWORD-LEAK-CANARY";
let runtime = runtime();
let owner = runtime.block_on(crate::open_wallet_owner_v1(FULL_VECTOR, crate::OwnerPassword::new(std::string::String::from(owner_canary))));
let owner_error = owner.expect_err("wrong OWNER password must fail");
assert_eq!(owner_error.code(), crate::ERROR_CODE_OWNER_UNLOCK_FAILED);
assert!(!owner_error.to_string().contains(owner_canary));
assert!(!format!("{owner_error:?}").contains(owner_canary));
let view = runtime.block_on(crate::open_wallet_view_v1(FULL_VECTOR, crate::ViewPassword::new(std::string::String::from(view_canary))));
let view_error = view.expect_err("wrong VIEW password must fail");
assert_eq!(view_error.code(), crate::ERROR_CODE_VIEW_UNLOCK_FAILED);
assert!(!view_error.to_string().contains(view_canary));
assert!(!format!("{view_error:?}").contains(view_canary));
}
fn tamper_base64url_field(source: &[u8], pointer: &str) -> std::vec::Vec<u8> {
let mut value: serde_json::Value = serde_json::from_slice(source).expect("security fixture must be valid JSON");
let target = value.pointer_mut(pointer).expect("security fixture pointer must exist");
let encoded = target.as_str().expect("security fixture target must be a Base64url string");
let mut decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(encoded.as_bytes()).expect("security fixture Base64url must decode");
let first = decoded.first_mut().expect("security fixture Base64url field must not be empty");
*first ^= 0x01;
*target = serde_json::Value::String(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(decoded.as_slice()));
zeroize::Zeroize::zeroize(decoded.as_mut_slice());
return serde_json::to_vec(&value).expect("tampered security fixture must serialize");
}
fn runtime() -> tokio::runtime::Runtime {
return tokio::runtime::Builder::new_current_thread().build().expect("security test runtime must be creatable");
}

View File

@@ -1,7 +1,7 @@
// file: crates/ksp-wallet-lib/unit_tests/transcript.rs
// version: 1
// version: 2
use base64::Engine as _;
use base64::Engine; // rust-rules: derive-import
const FIXTURE: &[u8] = include_bytes!("../tests/fixtures/kspwallet_v1_wire_only.json");

View File

@@ -1,7 +1,9 @@
// file: crates/ksp-wallet-lib/unit_tests/transfer.rs
// version: 1
// version: 2
use zeroize::Zeroize as _;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt; // rust-rules: derive-import
use zeroize::Zeroize; // rust-rules: derive-import
fn runtime() -> tokio::runtime::Runtime {
return tokio::runtime::Builder::new_current_thread().build().expect("Wallet transfer test runtime must build");
@@ -234,7 +236,6 @@ fn owner_transfer_file_export_is_no_clobber() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(export_path.as_path()).expect("export metadata must be readable").permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-wallet-lib/unit_tests/wallet.rs
// version: 2
// version: 3
const FULL_VECTOR: &[u8] = include_bytes!("../tests/fixtures/kspwallet_v1_full_vector.json");
const FULL_VECTOR_META: &[u8] = include_bytes!("../tests/fixtures/kspwallet_v1_full_vector_meta.json");
@@ -26,7 +26,7 @@ fn externally_generated_full_vector_opens_view_and_owner_independently() -> ksp_
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let view_result = runtime.block_on(super::open_wallet_view_v1(FULL_VECTOR, crate::ViewPassword::new(vector.view_password_utf8)));
let view_result = runtime.block_on(crate::open_wallet_view_v1(FULL_VECTOR, crate::ViewPassword::new(vector.view_password_utf8)));
let view = match view_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
@@ -36,7 +36,7 @@ fn externally_generated_full_vector_opens_view_and_owner_independently() -> ksp_
assert_eq!(view.alias(), std::option::Option::Some(vector.expected_alias.as_str()));
assert_note_texts(view.notes(), vector.expected_notes.as_slice());
let owner_result = runtime.block_on(super::open_wallet_owner_v1(FULL_VECTOR, crate::OwnerPassword::new(vector.owner_password_utf8)));
let owner_result = runtime.block_on(crate::open_wallet_owner_v1(FULL_VECTOR, crate::OwnerPassword::new(vector.owner_password_utf8)));
let owner = match owner_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
@@ -59,21 +59,21 @@ fn wrong_passwords_do_not_cross_unlock_capabilities() -> ksp_core_lib::Result<()
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let wrong_view = runtime.block_on(super::open_wallet_view_v1(FULL_VECTOR, crate::ViewPassword::new(std::string::String::from("wrong-view-password"))));
let wrong_view = runtime.block_on(crate::open_wallet_view_v1(FULL_VECTOR, crate::ViewPassword::new(std::string::String::from("wrong-view-password"))));
let wrong_view_error = match wrong_view {
std::result::Result::Ok(_) => return std::result::Result::Err(test_error("wrong VIEW password unexpectedly unlocked Wallet")),
std::result::Result::Err(error) => error,
};
assert_eq!(wrong_view_error.code(), crate::ERROR_CODE_VIEW_UNLOCK_FAILED);
let wrong_owner = runtime.block_on(super::open_wallet_owner_v1(FULL_VECTOR, crate::OwnerPassword::new(std::string::String::from("wrong-owner-password"))));
let wrong_owner = runtime.block_on(crate::open_wallet_owner_v1(FULL_VECTOR, crate::OwnerPassword::new(std::string::String::from("wrong-owner-password"))));
let wrong_owner_error = match wrong_owner {
std::result::Result::Ok(_) => return std::result::Result::Err(test_error("wrong OWNER password unexpectedly unlocked Wallet")),
std::result::Result::Err(error) => error,
};
assert_eq!(wrong_owner_error.code(), crate::ERROR_CODE_OWNER_UNLOCK_FAILED);
let view_as_owner = runtime.block_on(super::open_wallet_owner_v1(FULL_VECTOR, crate::OwnerPassword::new(vector.view_password_utf8)));
let view_as_owner = runtime.block_on(crate::open_wallet_owner_v1(FULL_VECTOR, crate::OwnerPassword::new(vector.view_password_utf8)));
let view_as_owner_error = match view_as_owner {
std::result::Result::Ok(_) => return std::result::Result::Err(test_error("VIEW password unexpectedly unlocked OWNER")),
std::result::Result::Err(error) => error,
@@ -88,7 +88,7 @@ fn owner_signed_metadata_tampering_is_rejected_before_unlock() -> ksp_core_lib::
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let inspect_result = super::inspect_locked_wallet_v1(tampered.as_slice());
let inspect_result = crate::inspect_locked_wallet_v1(tampered.as_slice());
let error = match inspect_result {
std::result::Result::Ok(_) => return std::result::Result::Err(test_error("tampered OWNER-signed metadata unexpectedly verified")),
std::result::Result::Err(error) => error,
@@ -107,7 +107,7 @@ fn view_wrap_can_change_without_breaking_owner_authenticated_state() -> ksp_core
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let inspected = match super::inspect_locked_wallet_v1(tampered.as_slice()) {
let inspected = match crate::inspect_locked_wallet_v1(tampered.as_slice()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -117,12 +117,12 @@ fn view_wrap_can_change_without_breaking_owner_authenticated_state() -> ksp_core
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let owner_result = runtime.block_on(super::open_wallet_owner_v1(tampered.as_slice(), crate::OwnerPassword::new(vector.owner_password_utf8)));
let owner_result = runtime.block_on(crate::open_wallet_owner_v1(tampered.as_slice(), crate::OwnerPassword::new(vector.owner_password_utf8)));
if let std::result::Result::Err(error) = owner_result {
return std::result::Result::Err(error);
}
let view_result = runtime.block_on(super::open_wallet_view_v1(tampered.as_slice(), crate::ViewPassword::new(vector.view_password_utf8)));
let view_result = runtime.block_on(crate::open_wallet_view_v1(tampered.as_slice(), crate::ViewPassword::new(vector.view_password_utf8)));
let view_error = match view_result {
std::result::Result::Ok(_) => return std::result::Result::Err(test_error("modified VIEW wrapping unexpectedly unlocked metadata")),
std::result::Result::Err(error) => error,
@@ -147,7 +147,7 @@ fn locked_full_vector_contains_no_authorized_identity_or_metadata_plaintext() ->
for note in vector.expected_notes {
assert!(!document.contains(note.as_str()));
}
let locked = match super::inspect_locked_wallet_v1(FULL_VECTOR) {
let locked = match crate::inspect_locked_wallet_v1(FULL_VECTOR) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -181,7 +181,7 @@ fn create_uses_calibrated_defaults_and_keeps_locked_projection_private() -> ksp_
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let owner_result = runtime.block_on(super::create_wallet_v1(
let owner_result = runtime.block_on(crate::create_wallet_v1(
crate::OwnerPassword::new(std::string::String::from("pre005-create-owner-password")),
std::option::Option::None,
metadata,

View File

@@ -1,18 +1,18 @@
// file: crates/ksp-wallet-lib/unit_tests/wire.rs
// version: 2
// version: 3
const FIXTURE: &[u8] = include_bytes!("../tests/fixtures/kspwallet_v1_wire_only.json");
#[test]
fn strict_v1_fixture_parses_and_round_trips_semantically() -> ksp_core_lib::Result<()> {
let parsed = match super::KspWalletEnvelopeV1::parse_json(FIXTURE) {
let parsed = match crate::KspWalletEnvelopeV1::parse_json(FIXTURE) {
std::result::Result::Ok(parsed) => parsed,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(parsed.format_version(), crate::KSPWALLET_FORMAT_VERSION_V1);
assert!(parsed.view_descriptor().enabled());
assert_eq!(parsed.owner_slot().role(), super::WalletKeySlotRoleV1::Owner);
assert_eq!(parsed.view_slot().map(super::WalletKeySlotV1::role), std::option::Option::Some(super::WalletKeySlotRoleV1::View));
assert_eq!(parsed.owner_slot().role(), crate::WalletKeySlotRoleV1::Owner);
assert_eq!(parsed.view_slot().map(crate::WalletKeySlotV1::role), std::option::Option::Some(crate::WalletKeySlotRoleV1::View));
assert_eq!(parsed.owner_control().payload_version(), 1);
assert_eq!(parsed.metadata().payload_version(), 1);
assert_eq!(parsed.secret().payload_version(), 1);
@@ -21,7 +21,7 @@ fn strict_v1_fixture_parses_and_round_trips_semantically() -> ksp_core_lib::Resu
std::result::Result::Ok(serialized) => serialized,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let reparsed = match super::KspWalletEnvelopeV1::parse_json(serialized.as_slice()) {
let reparsed = match crate::KspWalletEnvelopeV1::parse_json(serialized.as_slice()) {
std::result::Result::Ok(reparsed) => reparsed,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -32,7 +32,7 @@ fn strict_v1_fixture_parses_and_round_trips_semantically() -> ksp_core_lib::Resu
#[test]
fn unknown_format_version_is_rejected_before_v1_shape_validation() {
let source = std::string::String::from_utf8_lossy(FIXTURE).replace("\"format_version\": 1", "\"format_version\": 2");
let result = super::KspWalletEnvelopeV1::parse_json(source.as_bytes());
let result = crate::KspWalletEnvelopeV1::parse_json(source.as_bytes());
assert!(result.is_err());
let error = match result {
std::result::Result::Err(error) => error,
@@ -44,7 +44,7 @@ fn unknown_format_version_is_rejected_before_v1_shape_validation() {
#[test]
fn unknown_top_level_field_is_rejected() {
let source = std::string::String::from_utf8_lossy(FIXTURE).replacen("{", "{\n \"unexpected\": true,", 1);
let result = super::KspWalletEnvelopeV1::parse_json(source.as_bytes());
let result = crate::KspWalletEnvelopeV1::parse_json(source.as_bytes());
assert!(result.is_err());
let error = match result {
std::result::Result::Err(error) => error,
@@ -57,7 +57,7 @@ fn unknown_top_level_field_is_rejected() {
fn padded_or_noncanonical_base64url_is_rejected() {
let source = std::string::String::from_utf8_lossy(FIXTURE)
.replace("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\"", "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=\"");
let result = super::KspWalletEnvelopeV1::parse_json(source.as_bytes());
let result = crate::KspWalletEnvelopeV1::parse_json(source.as_bytes());
assert!(result.is_err());
let error = match result {
std::result::Result::Err(error) => error,
@@ -70,7 +70,7 @@ fn padded_or_noncanonical_base64url_is_rejected() {
fn enabled_view_descriptor_must_match_the_view_slot() {
let source =
std::string::String::from_utf8_lossy(FIXTURE).replacen("\"slot_id\": \"ICEiIyQlJicoKSorLC0uLw\"", "\"slot_id\": \"EBESExQVFhcYGRobHB0eHw\"", 1);
let result = super::KspWalletEnvelopeV1::parse_json(source.as_bytes());
let result = crate::KspWalletEnvelopeV1::parse_json(source.as_bytes());
assert!(result.is_err());
let error = match result {
std::result::Result::Err(error) => error,
@@ -82,7 +82,7 @@ fn enabled_view_descriptor_must_match_the_view_slot() {
#[test]
fn zero_or_pathological_kdf_parameters_are_rejected_before_crypto() {
let zero_source = std::string::String::from_utf8_lossy(FIXTURE).replacen("\"memory_kib\": 65536", "\"memory_kib\": 0", 1);
let zero_result = super::KspWalletEnvelopeV1::parse_json(zero_source.as_bytes());
let zero_result = crate::KspWalletEnvelopeV1::parse_json(zero_source.as_bytes());
assert!(zero_result.is_err());
let zero_error = match zero_result {
std::result::Result::Err(error) => error,
@@ -91,7 +91,7 @@ fn zero_or_pathological_kdf_parameters_are_rejected_before_crypto() {
assert_eq!(zero_error.code(), crate::ERROR_CODE_CRYPTO_PARAMETERS_INVALID);
let high_source = std::string::String::from_utf8_lossy(FIXTURE).replacen("\"memory_kib\": 65536", "\"memory_kib\": 1048577", 1);
let high_result = super::KspWalletEnvelopeV1::parse_json(high_source.as_bytes());
let high_result = crate::KspWalletEnvelopeV1::parse_json(high_source.as_bytes());
assert!(high_result.is_err());
let high_error = match high_result {
std::result::Result::Err(error) => error,
@@ -107,7 +107,7 @@ fn argon2_memory_must_cover_all_lanes_before_crypto() {
"\"parallelism\": 2",
1,
);
let result = super::KspWalletEnvelopeV1::parse_json(source.as_bytes());
let result = crate::KspWalletEnvelopeV1::parse_json(source.as_bytes());
assert!(result.is_err());
let error = match result {
std::result::Result::Err(error) => error,
@@ -119,7 +119,7 @@ fn argon2_memory_must_cover_all_lanes_before_crypto() {
#[test]
fn oversized_document_is_rejected_before_json_or_crypto() {
let oversized = std::vec![b' '; crate::KSPWALLET_MAX_FILE_BYTES + 1];
let result = super::KspWalletEnvelopeV1::parse_json(oversized.as_slice());
let result = crate::KspWalletEnvelopeV1::parse_json(oversized.as_slice());
assert!(result.is_err());
let error = match result {
std::result::Result::Err(error) => error,
@@ -130,7 +130,7 @@ fn oversized_document_is_rejected_before_json_or_crypto() {
#[test]
fn envelope_debug_does_not_render_ciphertext_contents() -> ksp_core_lib::Result<()> {
let parsed = match super::KspWalletEnvelopeV1::parse_json(FIXTURE) {
let parsed = match crate::KspWalletEnvelopeV1::parse_json(FIXTURE) {
std::result::Result::Ok(parsed) => parsed,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};

249
deltas/0.2.5/pre.009.md Normal file
View File

@@ -0,0 +1,249 @@
<!-- file: deltas/0.2.5/pre.009.md -->
<!-- version: 1 -->
# Delta `0.2.5-pre.009` — security / interoperability / compliance Wallet
## Base
```text
0.2.5-pre.8
```
Le checkpoint opérateur de la base a validé sans warning :
```text
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-wallet-lib
cargo test --workspace
```
Wallet : `54 passed / 1 ignored` en unit tests, `2/2` dependency boundary, `9/9` public API et `2/2` doctests.
Le tree opérateur confirme aussi :
```text
solana-keypair 3.1.2 -> parent KSP direct unique = ksp-wallet-lib
ed25519-dalek 2.2.0 -> génération unique
solana-address 2.7.0 -> génération unique partagée solana-pubkey / solana-keypair
```
Version workspace cible :
```text
0.2.5-pre.9
```
## Objectif
Cette tranche ne change ni le wire `.kspwallet` V1 ni les primitives crypto. Elle ferme le gate technique avant `pre.010` :
```text
adversarial tests
security contract
independent interoperability verification
dependency/cargo-tree compliance
normative Wallet rules
known limitations made explicit
```
## Canaris adversariaux
Nouveau fichier :
```text
crates/ksp-wallet-lib/unit_tests/security.rs
```
Il couvre :
```text
OWNER KDF salt tampering -> wallet.authentication_failed
OWNER wrap tampering -> wallet.authentication_failed
owner-control tampering -> wallet.authentication_failed
metadata tampering -> wallet.authentication_failed
secret tampering -> wallet.authentication_failed
state_signature tampering -> wallet.authentication_failed
state invalid + empty password -> authentication_failed before Argon2
VIEW wrap tampering only -> OWNER state signature stays valid
-> OWNER remains unlockable
-> VIEW fails with view_unlock_failed
wrong OWNER/VIEW password -> generic capability-specific errors
-> no password echo in Display/Debug
```
Le test de priorité `state_signature invalide + password vide` matérialise le contrat anti-oracle : la signature OWNER est vérifiée avant tout KDF password.
## Frontière publique renforcée
Le canari `dependency_boundary` vérifie désormais aussi :
```text
pas de dépendance directe solana-signer
pas de dépendance directe solana-signature
pas de re-export public solana-keypair / solana-signer / solana-signature
WalletView ne possède ni sign_message ni export_transfer
WalletTransferFormat reste #[non_exhaustive]
```
`ksp-core-lib::Pubkey` reste le seul type Solana public transversal du domaine Wallet ; la keypair secrète reste encapsulée dans Wallet.
## Règles normatives Wallet
`docs/rules/RULES_KSP.md` formalise maintenant :
```text
KSP-WALLET-002 format V1 autonome, sans facteur/ancre externe obligatoire
KSP-WALLET-003 indépendance VIEW / OWNER et capacité VIEW bornée
KSP-WALLET-004 keypair immuable/encapsulée, Pubkey via Core
KSP-WALLET-005 create/import no-clobber
KSP-WALLET-006 chemins fournis par caller, aucun Config/env Wallet
KSP-WALLET-007 état OWNER-signed + limites replacement/rollback total
```
## Interopérabilité externe
Une sonde indépendante hors Rust/KSP a été exécutée pendant la préparation du delta. Elle reproduit les fixtures publiques test-only avec une implémentation distincte :
```text
Argon2id OWNER derived key exact
Argon2id VIEW derived key exact
XChaCha20-Poly1305 pre.004 wrapped key exact + unwrap exact
Ed25519 state_signature vérifiée sur le state_transcript exact
Base58 keypair 64 octets reproduite indépendamment
Base58 Pubkey 32 octets reproduite indépendamment
```
La sonde n'est pas ajoutée au dépôt et ne devient aucune dépendance runtime. Elle utilise les primitives disponibles dans l'environnement Python de préparation et une construction HChaCha20 + ChaCha20-Poly1305 IETF pour le contrôle XChaCha.
## Réaudit crypto primaire
Le gate réaudit les références primaires :
```text
RFC 9106 Argon2id
RFC 8032 Ed25519
RustCrypto chacha20poly1305
solana-keypair 3.1.2
tempfile 3.27
```
RFC 9106 décrit `Argon2id, t=3, 64 MiB` comme seconde recommandation pour les environnements contraints, cohérente avec le profil de création KSP calibré en `pre.005` :
```text
64 MiB / 3 / 1
```
Les paramètres restent sérialisés par slot ; ce default peut donc évoluer ultérieurement sans casser les wallets existants.
## Audit Cargo / doublons
Le tree opérateur `pre.008` montre les doublons transitifs suivants :
```text
block-buffer 0.10 / 0.12
cpufeatures 0.2 / 0.3
crypto-common 0.1 / 0.2
digest 0.10 / 0.11
getrandom 0.3 / 0.4
rand 0.9 / 0.10
rand_core 0.6 / 0.9 / 0.10
sha2 0.10 / 0.11
syn 2 / 3
```
Verdict : acceptés comme transitifs des générations RustCrypto, Solana et Logging actuellement consommées. Wallet ne déclare pas plusieurs versions directes pour contourner les upstream. Les convergences critiques restent obtenues pour Dalek et `solana-address`.
`solana-keypair 3.1.2` conserve un `unsafe` upstream dans son codec Base58 interne. KSP n'ajoute pas `bs58` uniquement pour dupliquer ce codec ; `#![forbid(unsafe_code)]` continue de s'appliquer au code KSP lui-même.
## Extensibilité transfer clarifiée
`WalletTransferFormat` reste `#[non_exhaustive]` : les formats built-in sont additifs et les consumers ne peuvent pas considérer la liste comme définitivement fermée.
`pre.009` ne crée pas de trait/plugin public arbitraire de codec : un adapter externe d'import/export devrait recevoir/retourner les 64 octets secrets de la keypair, ce qui créerait une nouvelle surface publique de secret. Un vrai besoin futur d'adapters tiers recevra un contrat dédié et audité plutôt qu'une abstraction V1 prématurée.
## Limites explicitement conservées
Le verdict security est positif dans le threat model V1, sans revendiquer :
```text
anti-rollback externe
reconnaissance d'un remplacement intégral par un autre wallet valide
CAS filesystem portable linéarisable
atomicité/crash durability identique sur tous les OS/filesystems
garantie cryptographique fondée sur 0600/ACL
effacement physique absolu de toute copie mémoire
protection hardware / OTP / keychain / remote signer
```
Ces limites doivent rester visibles dans `pre.010` et la release stable.
## Documentation durable
Ajout :
```text
docs/validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md
```
Mise à jour :
```text
ROADMAP.md
docs/000-README.md
docs/formats/000-README.md
docs/formats/KSPWALLET_V1.md
docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md
docs/rules/RULES_KSP.md
docs/validation/000-README.md
```
La prochaine tranche devient `pre.010` : documentation finale, README/USAGE de `ksp-wallet-lib`, synchronisation de clôture et prompt `0.2.6`.
## Nettoyage test
Le double attribut `#[test]` accidentel devant `owner_and_view_slots_use_independent_kdf_material` est réduit à un seul attribut. Aucun comportement de production n'est modifié.
## Dépendances
Aucune nouvelle dépendance tierce.
## Validation statique de préparation
Le sandbox de préparation ne possède pas Cargo/Rust. Les contrôles statiques ont vérifié :
- aucune dépendance nouvelle ;
- aucun accès Config/Transport/Tauri/env/tracing direct/solana-pubkey direct ;
- aucun `unsafe` dans les sources Wallet KSP ;
- aucun changement du wire ni des fixtures ;
- versions de fichiers modifiés incrémentées ;
- archive delta limitée aux fichiers ajoutés/modifiés ;
- reconstruction exacte du delta sur la base `pre.008` avant livraison.
Une sonde crypto externe indépendante a en revanche réellement été exécutée comme indiqué plus haut.
## Validation opérateur requise
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-wallet-lib
cargo test --workspace
cargo tree -p ksp-wallet-lib
cargo tree -p ksp-wallet-lib -d
cargo tree -i ed25519-dalek@2.2.0
cargo tree -i solana-keypair@3.1.2
cargo tree -i solana-address@2.7.0
```
## Commit attendu
```text
v0.2.5-pre.009
```

View File

@@ -1,5 +1,5 @@
<!-- file: docs/000-README.md -->
<!-- version: 36 -->
<!-- version: 37 -->
# Documentation KSP
@@ -56,7 +56,8 @@ docs/
│ ├── 004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md
│ ├── 005-V0_2_3_KSP_TRANSPORT_007_RETRO_AUDIT.md
│ ├── 006-V0_2_3_HTTP_TRANSACTIONS.md
── 007-V0_2_4_HTTP_FINAL_COMPLIANCE.md
── 007-V0_2_4_HTTP_FINAL_COMPLIANCE.md
│ └── 008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md
└── rules/
├── FILE_CONTRACTS.md
├── PROMPT_STRUCTURE.md
@@ -73,11 +74,11 @@ D'autres sous-répertoires seront ajoutés uniquement lorsque leur rôle aura é
## Documents de planification
Le plan historique de la phase fondatrice clôturée est conservé dans [`plans/001-V0_0_3_PLAN.md`](plans/001-V0_0_3_PLAN.md). La séquence active des premières releases fonctionnelles est définie dans [`plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`](plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md). Le plan détaillé de la release stable `0.1.1` est conservé comme historique clôturé dans [`plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`](plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.2` est conservé comme historique clôturé dans [`plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.3 — Configuration foundation` est conservé comme historique clôturé dans [`plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.4 — ksp-app-config-desk` est conservé comme historique clôturé dans [`plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md`](plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md), avec sa matrice finale [`validation/001-V0_1_4_CONFIG_DESKTOP.md`](validation/001-V0_1_4_CONFIG_DESKTOP.md). Son prompt d'ouverture historique reste [`../prompts/004-V0_1_4_START_PROMPT.md`](../prompts/004-V0_1_4_START_PROMPT.md). La release stable `0.2.0` clôt l'audit de bot3 et le découpage de la série. Son plan directeur est conservé comme historique clôturé dans [`plans/007-V0_2_0_SERIES_PLANNING.md`](plans/007-V0_2_0_SERIES_PLANNING.md), avec sa matrice finale [`validation/002-V0_2_0_SERIES_PLANNING.md`](validation/002-V0_2_0_SERIES_PLANNING.md). La release stable `0.2.1 — HTTP Solana foundation` a été ouverte par [`../prompts/006-V0_2_1_START_PROMPT.md`](../prompts/006-V0_2_1_START_PROMPT.md). Son gate de sizing et sa matrice exhaustive sont conservés dans [`plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md), avec la validation finale [`validation/003-V0_2_1_ONCHAIN_HTTP.md`](validation/003-V0_2_1_ONCHAIN_HTTP.md), README/USAGE Transport et le smoke Devnet opt-in de composition Config -> Transport. Le prompt [`../prompts/007-V0_2_2_START_PROMPT.md`](../prompts/007-V0_2_2_START_PROMPT.md) a ouvert la release stable `0.2.2 — HTTP Accounts + Tokens + Cluster`. Son plan clôturé [`plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) conserve l'audit et l'implémentation des 22 wrappers typés, tandis que [`validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) enregistre les validations déterministes, les graphes Cargo et les deux smokes Devnet passés avant publication. Le prompt [`../prompts/008-V0_2_3_START_PROMPT.md`](../prompts/008-V0_2_3_START_PROMPT.md) a ouvert la release stable `0.2.3 — HTTP Transactions`. Son plan clôturé [`plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) conserve l'audit et l'implémentation des 11 wrappers ; le réaudit [`validation/005-V0_2_3_KSP_TRANSPORT_007_RETRO_AUDIT.md`](validation/005-V0_2_3_KSP_TRANSPORT_007_RETRO_AUDIT.md) confirme la complétude des 37 wrappers HTTP typés et [`validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](validation/006-V0_2_3_HTTP_TRANSACTIONS.md) enregistre les validations finales, graphes Cargo et deux smokes Devnet passés avant publication. Le prompt [`../prompts/009-V0_2_4_START_PROMPT.md`](../prompts/009-V0_2_4_START_PROMPT.md) a ouvert la release stable `0.2.4 — HTTP Blocks + Economics + compliance HTTP finale`. Son plan clôturé [`plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md`](plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md) conserve limplémentation des 15 wrappers et la compliance `52/52 + 14/14`; la matrice finale [`validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md`](validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md) enregistre le réaudit SIMD/inventaire, les canaries globales et les preuves opérateur avant publication. Le prompt [`../prompts/010-V0_2_5_START_PROMPT.md`](../prompts/010-V0_2_5_START_PROMPT.md), finalisé par `0.2.4-pre.009-fix.001`, ouvre `0.2.5 — Wallet foundation` sur la base stable `v0.2.4`. Son plan actif [`plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md) part du gate `pre.001` (héritage, threat model offline, VIEW/OWNER indépendants et niveau B read-only), puis matérialise la crate en `pre.002`, le wire/transcript en `pre.003`, les primitives Argon2id/XChaCha20-Poly1305 en `pre.004` et, en `pre.005`, les payloads protégés, le profil de création benchmarké, lautorité Ed25519 séparée ainsi que les flux in-memory create/open VIEW/OWNER avec vecteur complet interopérable. `pre.006` porte la persistence atomique/no-clobber.
Le plan historique de la phase fondatrice clôturée est conservé dans [`plans/001-V0_0_3_PLAN.md`](plans/001-V0_0_3_PLAN.md). La séquence active des premières releases fonctionnelles est définie dans [`plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`](plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md). Le plan détaillé de la release stable `0.1.1` est conservé comme historique clôturé dans [`plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`](plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.2` est conservé comme historique clôturé dans [`plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.3 — Configuration foundation` est conservé comme historique clôturé dans [`plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.4 — ksp-app-config-desk` est conservé comme historique clôturé dans [`plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md`](plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md), avec sa matrice finale [`validation/001-V0_1_4_CONFIG_DESKTOP.md`](validation/001-V0_1_4_CONFIG_DESKTOP.md). Son prompt d'ouverture historique reste [`../prompts/004-V0_1_4_START_PROMPT.md`](../prompts/004-V0_1_4_START_PROMPT.md). La release stable `0.2.0` clôt l'audit de bot3 et le découpage de la série. Son plan directeur est conservé comme historique clôturé dans [`plans/007-V0_2_0_SERIES_PLANNING.md`](plans/007-V0_2_0_SERIES_PLANNING.md), avec sa matrice finale [`validation/002-V0_2_0_SERIES_PLANNING.md`](validation/002-V0_2_0_SERIES_PLANNING.md). La release stable `0.2.1 — HTTP Solana foundation` a été ouverte par [`../prompts/006-V0_2_1_START_PROMPT.md`](../prompts/006-V0_2_1_START_PROMPT.md). Son gate de sizing et sa matrice exhaustive sont conservés dans [`plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md), avec la validation finale [`validation/003-V0_2_1_ONCHAIN_HTTP.md`](validation/003-V0_2_1_ONCHAIN_HTTP.md), README/USAGE Transport et le smoke Devnet opt-in de composition Config -> Transport. Le prompt [`../prompts/007-V0_2_2_START_PROMPT.md`](../prompts/007-V0_2_2_START_PROMPT.md) a ouvert la release stable `0.2.2 — HTTP Accounts + Tokens + Cluster`. Son plan clôturé [`plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) conserve l'audit et l'implémentation des 22 wrappers typés, tandis que [`validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) enregistre les validations déterministes, les graphes Cargo et les deux smokes Devnet passés avant publication. Le prompt [`../prompts/008-V0_2_3_START_PROMPT.md`](../prompts/008-V0_2_3_START_PROMPT.md) a ouvert la release stable `0.2.3 — HTTP Transactions`. Son plan clôturé [`plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) conserve l'audit et l'implémentation des 11 wrappers ; le réaudit [`validation/005-V0_2_3_KSP_TRANSPORT_007_RETRO_AUDIT.md`](validation/005-V0_2_3_KSP_TRANSPORT_007_RETRO_AUDIT.md) confirme la complétude des 37 wrappers HTTP typés et [`validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](validation/006-V0_2_3_HTTP_TRANSACTIONS.md) enregistre les validations finales, graphes Cargo et deux smokes Devnet passés avant publication. Le prompt [`../prompts/009-V0_2_4_START_PROMPT.md`](../prompts/009-V0_2_4_START_PROMPT.md) a ouvert la release stable `0.2.4 — HTTP Blocks + Economics + compliance HTTP finale`. Son plan clôturé [`plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md`](plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md) conserve limplémentation des 15 wrappers et la compliance `52/52 + 14/14`; la matrice finale [`validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md`](validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md) enregistre le réaudit SIMD/inventaire, les canaries globales et les preuves opérateur avant publication. Le prompt [`../prompts/010-V0_2_5_START_PROMPT.md`](../prompts/010-V0_2_5_START_PROMPT.md), finalisé par `0.2.4-pre.009-fix.001`, ouvre `0.2.5 — Wallet foundation` sur la base stable `v0.2.4`. Son plan actif [`plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md) part du gate `pre.001` (héritage, threat model offline, VIEW/OWNER indépendants et niveau B read-only), puis matérialise la crate en `pre.002`, le wire/transcript en `pre.003`, les primitives Argon2id/XChaCha20-Poly1305 en `pre.004`, les payloads/create/open en `pre.005`, la persistence en `pre.006`, l'administration/signature en `pre.007` et les adapters transfer en `pre.008`. `pre.009` ferme l'audit adversarial/interoperability/compliance dans [`validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md`](validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md) avant la documentation finale `pre.010`.
## Spécifications de formats
Les formats durables, interopérables et destinés à être réimplémentables hors de KSP sont indexés depuis [`formats/000-README.md`](formats/000-README.md). Le premier format natif publié dans cette famille est [`.kspwallet` V1](formats/KSPWALLET_V1.md) : `pre.003` en fige le wire/transcript/AAD, `pre.004` les primitives KDF/AEAD et `pre.005` les payloads plaintext, lautorité Ed25519 OWNER, les procédures create/open VIEW/OWNER, le profil de création KSP issu du benchmark et un vecteur complet interopérable indépendant du code Rust.
Les formats durables, interopérables et destinés à être réimplémentables hors de KSP sont indexés depuis [`formats/000-README.md`](formats/000-README.md). Le premier format natif publié dans cette famille est [`.kspwallet` V1](formats/KSPWALLET_V1.md) : `pre.003` en fige le wire/transcript/AAD, `pre.004` les primitives KDF/AEAD, `pre.005` les payloads/autorité OWNER/vecteur complet, `pre.006``pre.008` les flux persistence/administration/transfert, et `pre.009` l'audit adversarial ainsi que la reproduction externe des vecteurs avant clôture documentaire.
`IDEAS.md` conserve les pistes et questions qui ne sont pas encore des engagements du roadmap ni des décisions architecturales.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/formats/000-README.md -->
<!-- version: 3 -->
<!-- version: 4 -->
# Formats KSP
@@ -9,4 +9,4 @@ Une spécification de format décrit le wire exact, les encodages, les limites,
## Formats actifs
- [`KSPWALLET_V1.md`](KSPWALLET_V1.md) — spécification du format natif autonome `.kspwallet` V1. `0.2.5-pre.003` fige l'enveloppe/wire et les transcripts/AAD, `pre.004` ajoute Argon2id/XChaCha20-Poly1305/CSPRNG OS et `pre.005` fixe les payloads plaintext, le profil de création KSP calibré, l'autorité Ed25519 OWNER, les procédures create/open VIEW/OWNER et un vecteur complet interopérable test-only.
- [`KSPWALLET_V1.md`](KSPWALLET_V1.md) — spécification du format natif autonome `.kspwallet` V1. `0.2.5-pre.003` fige l'enveloppe/wire et les transcripts/AAD, `pre.004` ajoute Argon2id/XChaCha20-Poly1305/CSPRNG OS, `pre.005` fixe les payloads plaintext, le profil de création KSP calibré, l'autorité Ed25519 OWNER et le vecteur complet, `pre.006``pre.008` matérialisent persistence/administration/transfert, et `pre.009` clôt l'audit adversarial/interoperabilité/compliance avant la documentation finale.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/formats/KSPWALLET_V1.md -->
<!-- version: 6 -->
<!-- version: 7 -->
# `.kspwallet` V1 — spécification du format natif Wallet KSP
@@ -988,3 +988,53 @@ La première retourne au caller des octets contenant volontairement le secret et
Les exports Base58 et Solana CLI JSON doivent reconstruire exactement la même keypair et la même Pubkey que le wallet OWNER source.
## 25. Audit security/interoperability `pre.009`
`0.2.5-pre.009` n'ajoute aucun octet au wire V1 et ne change aucun algorithme. La tranche ferme le gate technique par des canaris adversariaux, une reproduction externe des vecteurs et un audit du graphe Cargo.
Les canaris exécutables vérifient notamment :
```text
tampering canonique du slot OWNER / owner-control / metadata / secret / state_signature
-> wallet.authentication_failed avant unlock
state_signature invalide + password vide
-> wallet.authentication_failed avant Argon2
changement du ciphertext de wrapping VIEW uniquement
-> signature OWNER toujours valide
-> OWNER reste ouvrable
-> VIEW échoue avec wallet.view_unlock_failed
wrong OWNER / wrong VIEW
-> erreurs génériques dédiées
-> aucun password reflété dans Display/Debug
public API
-> aucun re-export solana-keypair / solana-signer / solana-signature
-> aucune surface sign/export sur WalletView
```
Le vecteur complet publié en section 22 a été reproduit indépendamment du code Rust KSP avec une sonde externe :
```text
Argon2id v19 dérivations OWNER et VIEW exactes
XChaCha20-Poly1305 vecteur de wrapping pre.004 exact
Ed25519 state_signature exacte vérifiée sur state_transcript
Base58 keypair 64 octets et Pubkey attendue reproduites
```
Cette sonde n'est pas une dépendance KSP et n'est pas requise au runtime ; elle sert uniquement de preuve d'interopérabilité indépendante.
Le profil de création `64 MiB / 3 / 1` est cohérent avec la seconde recommandation Argon2id de RFC 9106 pour les environnements contraints. Les paramètres restent sérialisés par slot afin que les futurs defaults puissent évoluer sans rendre les wallets existants illisibles. XChaCha20-Poly1305 conserve une clé 256 bits et un nonce 192 bits généré par le CSPRNG OS. Les signatures d'état utilisent Ed25519 au format 64 octets défini par RFC 8032.
Le graphe Cargo observé au gate `pre.008` conserve une seule génération `ed25519-dalek 2.2.0`, une seule `solana-address 2.7.0` et un unique parent KSP direct de `solana-keypair 3.1.2` : `ksp-wallet-lib`. Les doublons `digest 0.10/0.11`, `crypto-common 0.1/0.2`, `block-buffer 0.10/0.12`, `cpufeatures 0.2/0.3`, `getrandom 0.3/0.4`, `rand 0.9/0.10`, `rand_core 0.6/0.9/0.10`, `sha2 0.10/0.11` et `syn 2/3` sont transitoires et imposés par les générations actuellement consommées par RustCrypto, Solana et Logging ; KSP n'ajoute pas une deuxième dépendance directe pour les contourner.
Limites explicitement conservées en V1 :
- aucun anti-rollback ni détection d'un remplacement intégral par un autre wallet valide sans ancre externe ;
- aucune promesse de CAS filesystem linéarisable ; l'atomicité réelle dépend de l'OS/filesystem ;
- les permissions `0600` d'export Unix sont une hygiène, pas une garantie cryptographique ;
- la zeroization réduit les copies possédées mais ne constitue pas une preuve d'effacement physique de toute copie potentielle produite par le compilateur, l'OS ou le matériel ;
- aucune revendication de résistance side-channel supplémentaire au-delà des primitives et bibliothèques retenues.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md -->
<!-- version: 11 -->
<!-- version: 12 -->
# Plan `0.2.5` — Wallet foundation
@@ -382,7 +382,7 @@ La génération SDK récente définit `Pubkey` comme alias du type `Address`, ma
Un `cargo tree` est obligatoire avec `pre.005` afin de confirmer l'unification de `solana-address`, `ed25519-dalek`, `rand/getrandom` et d'éviter des duplications crypto injustifiées.
Audit source notable : `solana-keypair 3.1.2` contient un bloc `unsafe` interne dans sa conversion Base58 vers `String`. Cela ne modifie pas la règle `#![forbid(unsafe_code)]` du code KSP, mais doit rester visible dans l'audit des transitifs ; `pre.008` réévaluera le chemin Base58 réellement appelé et les alternatives avant de figer l'adapter.
Audit source notable : `solana-keypair 3.1.2` contient un bloc `unsafe` interne dans sa conversion Base58 vers `String`. Cela ne modifie pas la règle `#![forbid(unsafe_code)]` du code KSP. `pre.008` conserve finalement le codec maintenu par `solana-keypair` au lieu d'ajouter un second codec `bs58` direct ; `pre.009` classe donc cet `unsafe` comme implémentation upstream auditée, sans `unsafe` KSP et sans duplication de codec.
### 8.2 KDF
@@ -870,11 +870,11 @@ Pas de full path/filename par défaut dans les logs Wallet ; le caller peut corr
### 17.1 Architecture
Le cœur ne doit pas utiliser un enum central fermé qui oblige à modifier toutes les branches à chaque nouveau format.
Le cœur ne doit pas figer les consumers sur une liste exhaustive de formats. `pre.008` matérialise donc `WalletTransferFormat` en enum public `#[non_exhaustive]`, avec codes stables et sélection explicite du format par le caller. De nouveaux formats built-in peuvent être ajoutés sans rendre les matches downstream exhaustifs.
`pre.008` doit introduire un contrat d'adapter/descriptor ouvert avec un code stable et des implémentations built-in enregistrables. L'import et l'export sont séparables pour qu'un format read-only n'implémente pas artificiellement l'autre sens.
Le gate `pre.009` renonce volontairement à un trait/plugin public arbitraire de codec V1 : une implémentation externe d'import/export devrait nécessairement recevoir ou retourner les 64 octets secrets de la keypair, ce qui créerait une nouvelle surface publique de key material contraire à l'encapsulation Wallet. Si un besoin réel d'adapters externes apparaît, il recevra un contrat secret explicite et audité dans une release dédiée ; il n'est pas simulé par une abstraction prématurée.
Toute API d'export qui remet temporairement du key material à un adapter est explicitement OWNER-only, nommée dangereusement et limite la durée de vie de la copie via zeroization. Aucun getter secret général n'est introduit.
L'import et l'export built-in restent séparables conceptuellement. Toute API d'export qui remet temporairement du key material au caller est explicitement OWNER-only et le caller devient responsable de la durée de vie/zeroization de sa copie. Aucun getter secret général n'est introduit.
### 17.2 Formats engagés pour `0.2.5`
@@ -1126,7 +1126,7 @@ Une `fix` ou tranche supplémentaire est préférable à la suppression d'une ga
## 22. Dépendances par tranche
État réellement acquis au terme de `pre.007` :
État réellement acquis au terme de `pre.009` :
```text
pre.002 zeroize ^1.9
@@ -1139,18 +1139,21 @@ pre.005 ed25519-dalek ^2.2
tokio déjà workspace, feature locale rt pour spawn_blocking
pre.006 tempfile ^3.27
pre.007 aucune nouvelle dépendance tierce
pre.008 aucune nouvelle dépendance tierce
pre.009 aucune nouvelle dépendance tierce
```
Toutes les dépendances tierces communes restent centralisées sous `[workspace.dependencies]`; le membre Wallet active uniquement les features nécessaires. `ed25519-dalek ^2.2` est volontairement aligné avec la contrainte `^2.1.1` de `solana-keypair 3.1.2` afin de permettre une seule génération Dalek et d'activer `zeroize` sur la `SigningKey` partagée par résolution Cargo.
Candidates restantes, à réauditer juste avant insertion :
Dépendances réauditées mais **non retenues directement** en `0.2.5` :
```text
solana-signer ^3.0 # seulement si un contrat public/impl l'exige réellement
solana-signature ^3.5 # seulement si un type public futur l'exige réellement
solana-signer # aucune API publique Wallet n'exige le trait externe
solana-signature # la signature publique reste [u8; 64]
bs58 # le codec Base58 maintenu par solana-keypair suffit
```
`pre.007` confirme qu'une signature Solana publique peut rester un `[u8; 64]` KSP sans ajouter `solana-signature` à la surface publique. `solana-keypair` reste propriétaire de Wallet : Core continue de réexporter uniquement la `Pubkey` transversale et ne devient pas propriétaire d'un secret/signing capability.
`pre.007` confirme qu'une signature Solana publique peut rester un `[u8; 64]` KSP sans ajouter `solana-signature` à la surface publique. `pre.008` confirme qu'aucun `bs58` direct n'est nécessaire. `solana-keypair` reste propriétaire de Wallet : Core continue de réexporter uniquement la `Pubkey` transversale et ne devient pas propriétaire d'un secret/signing capability.
Déjà présents et réutilisés :
@@ -1255,4 +1258,4 @@ Une future `format_version >= 2` pourra réétudier des facteurs/ancrages extern
## 26. Suite immédiate
`0.2.5-pre.003` fige le codec JSON strict, les limites structurelles, `slot_id` 16 octets, le descripteur VIEW, les DTOs denveloppe/key slots, les TLV transcript/AAD et la première spécification `docs/formats/KSPWALLET_V1.md`. `pre.004` ajoute Argon2id/XChaCha20-Poly1305/CSPRNG OS et le wrapping de content keys. Le benchmark opérateur permet à `pre.005` de retenir le profil initial `64 MiB / 3 / 1`, de figer les payloads `owner_control`/metadata/secret, d'introduire l'autorité Ed25519 OWNER distincte de la keypair Solana et de publier un vecteur complet. `pre.006` ajoute la persistence no-clobber et les ouvertures fichier. `pre.007` ajoute signature Solana, administration metadata, rotations OWNER/VIEW, révocation forte VIEW et remplacement administratif contrôlé. `pre.008` ajoute maintenant les adapters import/export Solana CLI JSON et keypair Base58 complet, l'inspection sûre, l'import vers un nouveau `.kspwallet` no-clobber et l'export OWNER explicite sans `bs58` direct. **La suite immédiate est `pre.009` : audit adversarial/security/compliance et graphes Cargo**, avant la documentation finale `pre.010`.
`0.2.5-pre.003` fige le codec JSON strict, les limites structurelles, `slot_id` 16 octets, le descripteur VIEW, les DTOs denveloppe/key slots, les TLV transcript/AAD et la première spécification `docs/formats/KSPWALLET_V1.md`. `pre.004` ajoute Argon2id/XChaCha20-Poly1305/CSPRNG OS et le wrapping de content keys. Le benchmark opérateur permet à `pre.005` de retenir le profil initial `64 MiB / 3 / 1`, de figer les payloads `owner_control`/metadata/secret, d'introduire l'autorité Ed25519 OWNER distincte de la keypair Solana et de publier un vecteur complet. `pre.006` ajoute la persistence no-clobber et les ouvertures fichier. `pre.007` ajoute signature Solana, administration metadata, rotations OWNER/VIEW, révocation forte VIEW et remplacement administratif contrôlé. `pre.008` ajoute les adapters import/export Solana CLI JSON et keypair Base58 complet, l'inspection sûre, l'import vers un nouveau `.kspwallet` no-clobber et l'export OWNER explicite sans `bs58` direct. `pre.009` ajoute maintenant les canaris adversariaux, formalise les règles Wallet durables, reproduit les vecteurs hors Rust/KSP et audite le graphe Cargo/les duplications transitoires. **La suite immédiate est `pre.010` : documentation finale, README/USAGE Wallet, candidate de clôture et prompt `0.2.6`.**

View File

@@ -1,5 +1,5 @@
<!-- file: docs/rules/RULES_KSP.md -->
<!-- version: 33 -->
<!-- version: 34 -->
# Règles spécifiques à KSP
@@ -76,6 +76,12 @@
## Wallet
- **KSP-WALLET-001** — KSP ne crée pas de `ksp-wallet-api` dans l'architecture actuelle ; `ksp-wallet-lib` possède le format wallet KSP et ses capacités de lecture/protection/import/export/pubkey/secret/signature.
- **KSP-WALLET-002** — `.kspwallet` V1 est autonome : le fichier et le password de la capability concernée suffisent au parsing, à la vérification et au déverrouillage ; aucun Config, environnement, pepper global, keychain, réseau, OTP ou ancre de confiance externe n'est requis par le format.
- **KSP-WALLET-003** — VIEW et OWNER sont des capabilities cryptographiquement indépendantes. VIEW peut lire Pubkey/alias/notes et tourner uniquement son propre password ; il ne peut ni signer, ni exporter le secret, ni administrer les metadata ou l'état OWNER-controlled.
- **KSP-WALLET-004** — La keypair Solana d'un wallet V1 est immuable après création/import et reste encapsulée dans `ksp-wallet-lib`. La surface publique expose la `Pubkey` via `ksp-core-lib` et une opération OWNER de signature, pas la `solana_keypair::Keypair` brute.
- **KSP-WALLET-005** — Toute création ou import natif est no-clobber. Un import crée un nouveau `.kspwallet` et ne remplace jamais un wallet natif existant ; les replacements sont réservés aux mutations capability-bound explicitement autorisées.
- **KSP-WALLET-006** — `ksp-wallet-lib` ne possède aucun répertoire Wallet par défaut et ne lit ni Config ni environnement pour choisir un chemin ; le caller fournit explicitement les chemins de persistence/import/export.
- **KSP-WALLET-007** — L'état OWNER-controlled V1 est authentifié par une autorité Ed25519 distincte de la keypair Solana. V1 ne prétend pas détecter un remplacement intégral par un autre wallet autonome valide ni un rollback intégral vers une ancienne copie valide ; ces garanties exigeraient une ancre externe hors du format V1.
- **KSP-PROGRAM-007** — Le contrat de préparation d'exécution est nommé conceptuellement `ProgramExecutionPreparer`; il ne signe, ne simule, n'envoie et ne confirme pas une transaction.
- **KSP-PROGRAM-008** — `ksp-program-api` reste ouvert : aucun enum central fermé ne doit imposer une modification de l'API pour ajouter un Program ID externe.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/validation/000-README.md -->
<!-- version: 11 -->
<!-- version: 12 -->
# Validations KSP
@@ -17,3 +17,4 @@ Documents :
- [`006-V0_2_3_HTTP_TRANSACTIONS.md`](006-V0_2_3_HTTP_TRANSACTIONS.md) — matrice finale validée de `0.2.3`, 11 wrappers Transactions, sécurité write/simulation, réaudit 52+14, `KSP-TRANSPORT-007` 37/37, graphes Cargo et deux smokes Devnet passés avant publication stable.
- [`007-V0_2_4_HTTP_FINAL_COMPLIANCE.md`](007-V0_2_4_HTTP_FINAL_COMPLIANCE.md) — matrice finale validée de `0.2.4`, inventaire exact 52 current + 14 Deprecated, preuve typed 52/52, audit SIMD final, `KSP-TRANSPORT-007`, workspace complet et deux smokes Devnet passés avant publication stable.
- [`008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md`](008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md) — matrice security/interoperability/compliance de `0.2.5`, threat model V1, adversarial canaries, reproduction externe des vecteurs, audit de frontières et graphes Cargo Wallet.

View File

@@ -0,0 +1,224 @@
<!-- file: docs/validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md -->
<!-- version: 1 -->
# Validation `0.2.5` — Wallet security / interoperability / compliance
## 1. Objet
Cette matrice ferme le gate technique de `0.2.5-pre.009` avant la documentation finale `pre.010`. Elle ne remplace ni la spécification [`../formats/KSPWALLET_V1.md`](../formats/KSPWALLET_V1.md), ni le threat model du plan [`../plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](../plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md), ni les deltas.
Le verdict recherché porte sur quatre axes :
```text
security/adversarial
interoperability indépendante
frontières KSP
Cargo/dependency compliance
```
## 2. État fonctionnel audité
Au gate `pre.009`, Wallet possède :
```text
format autonome .kspwallet V1
VIEW / OWNER indépendants
Argon2id v19
XChaCha20-Poly1305
CSPRNG OS
content keys séparées OWNER / metadata / secret
Ed25519 distinct pour state_signature OWNER
keypair Solana immuable et encapsulée
create/open VIEW/OWNER
persistence no-clobber
replacement administratif capability-bound
signature Solana OWNER
alias/notes + rotations OWNER/VIEW
strong disable/recreate VIEW
import/export Solana CLI JSON + Base58 keypair 64 octets
```
Aucune dépendance Wallet vers Config, Transport, ExecutionPolicy, Store ou Tauri n'est autorisée.
## 3. Matrice adversariale
| Cas | Résultat attendu | Preuve |
|-----------------------------------------------------|---------------------------------------------------------|-----------------------------------------------------------------------------------------------|
| mutation canonique KDF/wrap OWNER | rejet | `security_tests::owner_signed_regions_reject_canonical_tampering_before_unlock` |
| mutation owner-control | rejet | même canari |
| mutation metadata | rejet | même canari |
| mutation secret | rejet | même canari |
| mutation `state_signature` | rejet | même canari |
| état OWNER-signed invalide + password vide | `wallet.authentication_failed` avant Argon2 | `security_tests::owner_signature_verification_precedes_owner_and_view_password_kdf` |
| mutation des credentials rotatables VIEW uniquement | état OWNER toujours valide, OWNER ouvrable, VIEW rejeté | `security_tests::view_credential_tampering_does_not_forge_owner_state_and_cannot_unlock_view` |
| wrong VIEW | `wallet.view_unlock_failed` générique | tests Wallet + security |
| wrong OWNER | `wallet.owner_unlock_failed` générique | tests Wallet + security |
| password canary dans Display/Debug | absent | `security_tests::unlock_failures_do_not_echo_password_material` |
| metadata plaintext dans wallet verrouillé | absent | `wallet::tests::locked_full_vector_contains_no_authorized_identity_or_metadata_plaintext` |
| stale OWNER handle | `wallet.state_conflict` | administration tests |
| wallet A handle vers wallet B | `wallet.state_conflict` | administration tests |
| écriture interrompue avant publish | ancien/destination absent préservé | persistence fault tests |
| concurrence create no-clobber | exactement un gagnant | persistence concurrency test |
| export secret depuis VIEW | surface publique absente | dependency/public-surface canary |
## 4. Ordre de vérification et anti-oracle
Pour VIEW et OWNER :
```text
parse structurel borné
-> state_signature OWNER
-> seulement ensuite Argon2 du slot demandé
-> unwrap AEAD
-> déchiffrement compartment
-> parsing plaintext protégé
```
Le canari `state_signature invalide + password vide` distingue explicitement cet ordre : si Argon2 était évalué avant l'authentification d'état, l'entrée vide produirait une erreur de paramètres crypto ; le résultat attendu reste `wallet.authentication_failed`.
Les erreurs de password ne distinguent pas KDF, wrapping AEAD ou contenu protégé et ne reflètent pas le password fourni.
## 5. Interopérabilité indépendante
Les fixtures publiques test-only restent :
```text
kspwallet_v1_wire_only.json
kspwallet_v1_crypto_vectors.json
kspwallet_v1_full_vector.json
kspwallet_v1_full_vector_meta.json
```
Une sonde indépendante hors Rust/KSP exécutée pendant `pre.009` reproduit :
```text
Argon2id OWNER derived key = exact
Argon2id VIEW derived key = exact
pre.004 XChaCha wrapped key = exact
pre.004 unwrap content key = exact
Ed25519 state signature = vérifiée sur le transcript exact
Solana keypair Base58 = reproduite depuis 64 octets
Solana Pubkey Base58 = reproduite depuis les 32 octets publics
```
La sonde utilise Python avec Argon2 et Ed25519/ChaCha20-Poly1305 disponibles localement ; XChaCha20 est reproduit par la construction HChaCha20 + ChaCha20-Poly1305 IETF. Cette sonde n'est ni versionnée ni requise par KSP : elle sert uniquement de seconde implémentation de vérification.
Références cryptographiques normatives/primaires réauditées :
- RFC 9106 — Argon2 ; le profil Argon2id `64 MiB / t=3` est la seconde recommandation pour environnements contraints ;
- RFC 8032 — Ed25519/EdDSA et signatures 64 octets ;
- documentation RustCrypto `chacha20poly1305` — XChaCha20-Poly1305 et nonce étendu 192 bits ;
- documentation `solana-keypair 3.1.2` — keypair Ed25519 64 octets, validation secret/public et codecs Base58/JSON ;
- documentation `tempfile 3.27` — publication/replacement et limites d'atomicité selon OS/filesystem.
## 6. Audit Cargo observé après `pre.008`
Commande opérateur :
```bash
cargo tree -p ksp-wallet-lib
cargo tree -p ksp-wallet-lib -d
cargo tree -i solana-keypair@3.1.2
```
Dépendances directes de Wallet observées :
```text
argon2 0.5.3
base64 0.23.1
chacha20poly1305 0.11.0
ed25519-dalek 2.2.0
getrandom 0.4.3
ksp-core-lib
ksp-logging-lib
serde 1.0.229
serde_json 1.0.151
solana-keypair 3.1.2
tempfile 3.27.0
tokio 1.53.1
zeroize 1.9.0
```
Points de convergence recherchés :
```text
ed25519-dalek 2.2.0 : une seule génération, partagée KSP + solana-keypair
solana-address 2.7.0: une seule génération, partagée solana-pubkey + solana-keypair
solana-keypair 3.1.2: unique parent KSP direct = ksp-wallet-lib
```
Doublons transitifs observés et acceptés au gate :
```text
block-buffer 0.10 / 0.12
cpufeatures 0.2 / 0.3
crypto-common 0.1 / 0.2
digest 0.10 / 0.11
getrandom 0.3 / 0.4
rand 0.9 / 0.10
rand_core 0.6 / 0.9 / 0.10
sha2 0.10 / 0.11
syn 2 / 3
```
Ces doublons proviennent des générations transitives actuelles de RustCrypto, `solana-keypair`/`solana-ed25519` et Logging. Ils ne correspondent pas à deux versions directes déclarées par Wallet pour une même fonction. Les éliminer exigerait de forcer des upstream incompatibles ou de régresser les versions retenues ; aucun contournement KSP n'est introduit.
## 7. Frontières KSP
Canaries durables :
```text
Wallet -> Core + Logging + primitives explicites
Wallet -X-> Config
Wallet -X-> Transport
Wallet -X-> ExecutionPolicy
Wallet -X-> Store
Wallet -X-> Tauri
Wallet -X-> tracing direct
Wallet -X-> std::env
Wallet -X-> solana-pubkey direct
Wallet -X-> solana-signer direct
Wallet -X-> solana-signature direct
Wallet -X-> bs58 direct
```
`ksp-core-lib::Pubkey` reste le type public transversal. `solana-keypair` reste un détail secret/signing propre à Wallet ; aucune `Keypair`, `Signer` ou `Signature` Solana externe n'est réexportée par la surface publique Wallet.
`WalletTransferFormat` est `#[non_exhaustive]` : la liste built-in reste additive et les consumers ne peuvent pas supposer qu'elle est fermée. `pre.009` ne crée pas de trait/plugin externe de codec secret : un tel trait devrait exposer les 64 octets de keypair à une implémentation tierce et constituerait une nouvelle surface secrète. Cette extension éventuelle exige un contrat dédié futur plutôt qu'une abstraction V1 prématurée.
## 8. Limites et non-garanties V1
Le verdict security est positif **dans le threat model documenté**, avec les limites explicites suivantes :
1. un remplacement total par un autre wallet autonome valide n'est pas détectable à partir du nouveau fichier seul ;
2. un rollback total vers une ancienne copie valide n'est pas détecté sans état/ancre externe ;
3. le garde-fou `wallet.state_conflict` réduit les erreurs de cible/stale handle mais n'est pas un CAS filesystem portable linéarisable ;
4. l'atomicité et la crash-durability du filesystem dépendent de l'OS/filesystem ;
5. les ACL/permissions sont une hygiène externe, pas une garantie cryptographique ;
6. `zeroize` s'applique aux buffers possédés explicitement mais ne prouve pas l'effacement physique de toutes les copies potentielles ;
7. aucune protection hardware, second facteur, keychain, remote signer ou anti-rollback externe n'est incluse en V1.
Aucune de ces limites ne doit être masquée dans `pre.010`.
## 9. Gate opérateur `pre.009`
À exécuter après application du delta :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-wallet-lib
cargo test --workspace
cargo tree -p ksp-wallet-lib
cargo tree -p ksp-wallet-lib -d
cargo tree -i ed25519-dalek@2.2.0
cargo tree -i solana-keypair@3.1.2
cargo tree -i solana-address@2.7.0
```
Le gate est vert seulement si les canaris adversariaux passent sans warning et si le tree ne révèle aucune nouvelle dépendance directe ou génération Dalek/Solana-address inattendue.
## 10. Verdict avant `pre.010`
Le verdict de conception et d'interopérabilité est **positif sous réserve du checkpoint Cargo opérateur `pre.009`**. Aucun changement de wire/crypto n'est requis par l'audit. `pre.010` doit donc rester documentaire : README/USAGE Wallet, synchronisation finale de la spec/graphes, candidate de clôture et prompt `0.2.6`.