app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit b6dc30950462f401104df6a6bd7e69c3113d12e3
parent 6e90de7bef0bbf08627f0c49cd68a4d7182216bf
Author: triesap <triesap@radroots.dev>
Date:   Wed, 21 Jan 2026 17:55:14 +0000

app: add versioned app state record

- introduce state record schema with checksum metadata

- define state error taxonomy for integrity checks

- add timestamped checksum helpers and exports

- cover record validation edge cases in tests

Diffstat:
Mapp/Cargo.toml | 2++
Mapp/src/data.rs | 154++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mapp/src/lib.rs | 12+++++++++++-
3 files changed, 166 insertions(+), 2 deletions(-)

diff --git a/app/Cargo.toml b/app/Cargo.toml @@ -25,6 +25,8 @@ console_error_panic_hook = "0.1" serde.workspace = true serde_json.workspace = true uuid.workspace = true +sha2.workspace = true +hex.workspace = true [dev-dependencies] async-trait.workspace = true diff --git a/app/src/data.rs b/app/src/data.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum RadrootsAppRole { Public, @@ -34,13 +36,134 @@ impl Default for RadrootsAppState { } } +pub const APP_STATE_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsAppStateError { + Missing, + Corrupt, + InvalidChecksum, + UnsupportedVersion(u32), + AlreadyExists, +} + +impl RadrootsAppStateError { + pub const fn message(&self) -> &'static str { + match self { + RadrootsAppStateError::Missing => "error.app.state.missing", + RadrootsAppStateError::Corrupt => "error.app.state.corrupt", + RadrootsAppStateError::InvalidChecksum => "error.app.state.checksum_invalid", + RadrootsAppStateError::UnsupportedVersion(_) => "error.app.state.schema_unsupported", + RadrootsAppStateError::AlreadyExists => "error.app.state.already_exists", + } + } +} + +impl std::fmt::Display for RadrootsAppStateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.message()) + } +} + +impl std::error::Error for RadrootsAppStateError {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsAppStateRecord { + pub schema_version: u32, + pub revision: u64, + pub updated_at_ms: i64, + pub checksum: String, + pub state: RadrootsAppState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct RadrootsAppStateChecksumPayload { + schema_version: u32, + revision: u64, + updated_at_ms: i64, + state: RadrootsAppState, +} + +pub fn app_state_timestamp_ms() -> i64 { + #[cfg(target_arch = "wasm32")] + { + js_sys::Date::now() as i64 + } + #[cfg(not(target_arch = "wasm32"))] + { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_millis() as i64) + .unwrap_or(0) + } +} + +fn app_state_record_checksum(payload: &RadrootsAppStateChecksumPayload) -> String { + let serialized = + serde_json::to_vec(payload).unwrap_or_else(|_| Vec::new()); + let hash = Sha256::digest(&serialized); + hex::encode(hash) +} + +pub fn app_state_record_new( + state: RadrootsAppState, + revision: u64, + updated_at_ms: i64, +) -> RadrootsAppStateRecord { + let payload = RadrootsAppStateChecksumPayload { + schema_version: APP_STATE_SCHEMA_VERSION, + revision, + updated_at_ms, + state: state.clone(), + }; + let checksum = app_state_record_checksum(&payload); + RadrootsAppStateRecord { + schema_version: APP_STATE_SCHEMA_VERSION, + revision, + updated_at_ms, + checksum, + state, + } +} + +pub fn app_state_record_validate( + record: &RadrootsAppStateRecord, +) -> Result<(), RadrootsAppStateError> { + if record.schema_version != APP_STATE_SCHEMA_VERSION { + return Err(RadrootsAppStateError::UnsupportedVersion( + record.schema_version, + )); + } + let payload = RadrootsAppStateChecksumPayload { + schema_version: record.schema_version, + revision: record.revision, + updated_at_ms: record.updated_at_ms, + state: record.state.clone(), + }; + let expected = app_state_record_checksum(&payload); + if record.checksum != expected { + return Err(RadrootsAppStateError::InvalidChecksum); + } + Ok(()) +} + pub fn app_state_is_initialized(state: &RadrootsAppState) -> bool { !state.active_key.is_empty() && !state.eula_date.is_empty() } #[cfg(test)] mod tests { - use super::{app_state_is_initialized, RadrootsAppRole, RadrootsAppState}; + use super::{ + app_state_is_initialized, + app_state_record_new, + app_state_record_validate, + app_state_timestamp_ms, + RadrootsAppRole, + RadrootsAppState, + RadrootsAppStateError, + APP_STATE_SCHEMA_VERSION, + }; #[test] fn role_defaults_to_public() { @@ -67,4 +190,33 @@ mod tests { data.eula_date = "2025-01-01T00:00:00Z".to_string(); assert!(app_state_is_initialized(&data)); } + + #[test] + fn state_record_validates_checksum() { + let mut state = RadrootsAppState::default(); + state.active_key = "pub".to_string(); + let record = app_state_record_new(state, 1, app_state_timestamp_ms()); + assert!(app_state_record_validate(&record).is_ok()); + } + + #[test] + fn state_record_detects_checksum_mismatch() { + let state = RadrootsAppState::default(); + let mut record = app_state_record_new(state, 1, app_state_timestamp_ms()); + record.checksum = "bad".to_string(); + let err = app_state_record_validate(&record).expect_err("checksum"); + assert_eq!(err, RadrootsAppStateError::InvalidChecksum); + } + + #[test] + fn state_record_rejects_unsupported_version() { + let state = RadrootsAppState::default(); + let mut record = app_state_record_new(state, 1, app_state_timestamp_ms()); + record.schema_version = APP_STATE_SCHEMA_VERSION + 1; + let err = app_state_record_validate(&record).expect_err("version"); + assert_eq!( + err, + RadrootsAppStateError::UnsupportedVersion(APP_STATE_SCHEMA_VERSION + 1) + ); + } } diff --git a/app/src/lib.rs b/app/src/lib.rs @@ -25,7 +25,17 @@ pub use bootstrap::{ app_datastore_write_state, }; pub use context::{app_context, RadrootsAppContext}; -pub use data::{app_state_is_initialized, RadrootsAppRole, RadrootsAppState}; +pub use data::{ + app_state_is_initialized, + app_state_record_new, + app_state_record_validate, + app_state_timestamp_ms, + RadrootsAppRole, + RadrootsAppState, + RadrootsAppStateError, + RadrootsAppStateRecord, + APP_STATE_SCHEMA_VERSION, +}; pub use health::{ app_health_check_all, app_health_check_all_logged,