app

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

commit db185935de8735e909c1e3180a759762db46df63
parent ad175ba7ce1b615c748ef3e867b71996622d5946
Author: triesap <tyson@radroots.org>
Date:   Fri,  2 Oct 2026 10:00:34 +0000

metadata: enforce existing UTF-8 byte limits

- Validate normalized identity and profile bytes before Rust copies
- Align Kotlin metadata limits and reject malformed Unicode safely
- Cover domain, signed profiles, storage and generated mapping boundaries
- Retain actual test-first failures and verified native regression evidence

Diffstat:
Mapp/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ApplicationModels.kt | 44+++++++++++++++++++++++++++++++++++---------
Mcore/crates/harvestcircle_domain/src/identity.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcore/crates/harvestcircle_domain/src/profile.rs | 134++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcore/crates/harvestcircle_nostr/src/profile.rs | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/harvestcircle_storage/tests/sqlx_storage.rs | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
6 files changed, 679 insertions(+), 27 deletions(-)

diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt @@ -49,6 +49,122 @@ import org.harvestcircle.application.generated.NativeCompatibilityExpectations a class NativeRuntimeMappingsTest { @Test + fun generatedMappingsPreserveExactUtf8Boundaries() { + listOf("x", "é", "🥕", "e\u0301").forEach { unit -> + val label = textAtUtf8Limit(unit, 80) + val values = listOf(128, 128, 320, 4096, 2048).map { textAtUtf8Limit(unit, it) } + val identity = nativeIdentity().copy(displayLabel = label) + val profile = ProfileDto(values[0], values[1], values[2], values[3], values[4]) + val native = populatedSnapshot(2UL) + val snapshot = + native.copy( + identities = listOf(identity), + activeIdentity = requireNotNull(native.activeIdentity).copy(identity = identity, profile = profile), + ) + val mapped = snapshot.toApplicationSnapshot() + val mappedProfile = requireNotNull(mapped.activeIdentity?.profile) + + assertEquals(80, label.encodeToByteArray().size) + values.zip(listOf(128, 128, 320, 4096, 2048)).forEach { (value, maximum) -> + assertEquals(maximum, value.encodeToByteArray().size) + } + assertEquals(label, mapped.identities.single().displayLabel) + assertEquals(label, mapped.activeIdentity?.identity?.displayLabel) + assertEquals(values[0], mappedProfile.name) + assertEquals(values[1], mappedProfile.displayName) + assertEquals(values[2], mappedProfile.nip05) + assertEquals(values[3], mappedProfile.about) + assertEquals(values[4], mappedProfile.picture) + } + } + + @Test + fun identityMappingRejectsLabelOneByteOverUtf8Limit() { + listOf("x", "é", "🥕", "e\u0301").forEach { unit -> + val value = textAtUtf8Limit(unit, 80) + "x" + assertEquals(81, value.encodeToByteArray().size) + assertFailsWith<IllegalArgumentException> { + nativeIdentity().copy(displayLabel = value).toIdentitySummary() + } + } + } + + @Test + fun profileMappingRejectsNameOneByteOverUtf8Limit() { + assertProfileMappingRejectsOneByteOverLimit(0, 128) + } + + @Test + fun profileMappingRejectsDisplayNameOneByteOverUtf8Limit() { + assertProfileMappingRejectsOneByteOverLimit(1, 128) + } + + @Test + fun profileMappingRejectsNip05OneByteOverUtf8Limit() { + assertProfileMappingRejectsOneByteOverLimit(2, 320) + } + + @Test + fun profileMappingRejectsAboutOneByteOverUtf8Limit() { + assertProfileMappingRejectsOneByteOverLimit(3, 4096) + } + + @Test + fun profileMappingRejectsPictureOneByteOverUtf8Limit() { + assertProfileMappingRejectsOneByteOverLimit(4, 2048) + } + + @Test + fun profileMappingPreservesValidAboutLayoutControls() { + val value = "First\nSecond\rThird\tFourth" + val mapped = profileDtoWithField(3, value).toProfileSummary() + assertEquals(value, mapped.about) + assertNull(mapped.name) + assertNull(mapped.displayName) + assertNull(mapped.nip05) + assertNull(mapped.picture) + } + + @Test + fun generatedMappingsRejectBlankAndForbiddenControls() { + val invalid = listOf("", " \u2003\t\r\n ", "a\u0000b", "a\u001Bb", "a\u007Fb", "a\u0085b") + invalid.forEach { value -> + assertFailsWith<IllegalArgumentException> { + nativeIdentity().copy(displayLabel = value).toIdentitySummary() + } + (0..4).forEach { index -> + assertFailsWith<IllegalArgumentException> { + profileDtoWithField(index, value).toProfileSummary() + } + } + } + listOf("a\nb", "a\rb", "a\tb").forEach { value -> + assertFailsWith<IllegalArgumentException> { + nativeIdentity().copy(displayLabel = value).toIdentitySummary() + } + listOf(0, 1, 2, 4).forEach { index -> + assertFailsWith<IllegalArgumentException> { + profileDtoWithField(index, value).toProfileSummary() + } + } + } + } + + @Test + fun generatedMappingsRejectMalformedUtf16Metadata() { + listOf("a\uD800b", "a\uDC00b", "a\uDC00\uD800b").forEach { value -> + assertFailsWith<IllegalArgumentException> { + nativeIdentity().copy(displayLabel = value).toIdentitySummary() + } + (0..4).forEach { index -> + assertFailsWith<IllegalArgumentException> { + profileDtoWithField(index, value).toProfileSummary() + } + } + } + } + + @Test fun desktopCombinesNativeAndHostOwnedBuildMetadataForReadiness() { val descriptor = CompatibilityDescriptor( @@ -727,6 +843,36 @@ private class FakeRemovalHandle : NativeRemovalHandle { } } +private fun textAtUtf8Limit( + unit: String, + maximum: Int, +): String { + val width = unit.encodeToByteArray().size + return unit.repeat(maximum / width) + "x".repeat(maximum % width) +} + +private fun profileDtoWithField( + index: Int, + value: String, +): ProfileDto { + val fields = MutableList<String?>(5) { null } + fields[index] = value + return ProfileDto(fields[0], fields[1], fields[2], fields[3], fields[4]) +} + +private fun assertProfileMappingRejectsOneByteOverLimit( + index: Int, + maximum: Int, +) { + listOf("x", "é", "🥕", "e\u0301").forEach { unit -> + val value = textAtUtf8Limit(unit, maximum) + "x" + assertEquals(maximum + 1, value.encodeToByteArray().size) + assertFailsWith<IllegalArgumentException> { + profileDtoWithField(index, value).toProfileSummary() + } + } +} + private fun populatedSnapshot(revision: ULong): AppSnapshotDto { val identity = nativeIdentity() return AppSnapshotDto( diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ApplicationModels.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ApplicationModels.kt @@ -47,7 +47,7 @@ data class IdentitySummary( ) { init { requireSafeText(npub, "Nostr public identity", 128) - requireSafeText(displayLabel, "Identity display label", 128) + validateUtf8Metadata(displayLabel, "Identity display label", 80) require(lastUsedAt == null || lastUsedAt.value >= createdAt.value) { "Identity last-used time precedes creation" } @@ -107,11 +107,11 @@ data class ProfileSummary( val picture: String?, ) { init { - validateOptional(name, "Profile name", 256) - validateOptional(displayName, "Profile display name", 256) - validateOptional(nip05, "Profile NIP-05 identifier", 320) - validateOptional(about, "Profile about text", 4096) - validateOptional(picture, "Profile picture URL", 2048) + validateUtf8Metadata(name, "Profile name", 128) + validateUtf8Metadata(displayName, "Profile display name", 128) + validateUtf8Metadata(nip05, "Profile NIP-05 identifier", 320) + validateUtf8Metadata(about, "Profile about text", 4096, allowLayoutControls = true) + validateUtf8Metadata(picture, "Profile picture URL", 2048) } } @@ -223,12 +223,38 @@ data class ApplicationSnapshot( } } -private fun validateOptional( +private fun validateUtf8Metadata( value: String?, label: String, - maximumLength: Int, + maximumBytes: Int, + allowLayoutControls: Boolean = false, ) { - if (value != null) requireSafeText(value, label, maximumLength) + if (value == null) return + val message = "$label is empty, oversized, malformed, or contains a control character" + require(value.length <= maximumBytes && value.isNotBlank()) { message } + var index = 0 + var bytes = 0 + while (index < value.length) { + val character = value[index] + require( + !character.isISOControl() || + (allowLayoutControls && (character == '\n' || character == '\r' || character == '\t')), + ) { message } + require(character !in '\uDC00'..'\uDFFF') { message } + bytes += + when { + character <= '\u007F' -> 1 + character <= '\u07FF' -> 2 + character in '\uD800'..'\uDBFF' -> { + require(index + 1 < value.length && value[index + 1] in '\uDC00'..'\uDFFF') { message } + index += 1 + 4 + } + else -> 3 + } + require(bytes <= maximumBytes) { message } + index += 1 + } } private fun requireSafeText( diff --git a/core/crates/harvestcircle_domain/src/identity.rs b/core/crates/harvestcircle_domain/src/identity.rs @@ -3,7 +3,7 @@ use crate::time::UnixTimestamp; use crate::{Npub, PublicKey, SafeError, SafeErrorCode, SafeMessage}; -const MAX_IDENTITY_LABEL_CHARS: usize = 80; +const MAX_IDENTITY_LABEL_UTF8_BYTES: usize = 80; #[derive(Clone, Debug, Eq, PartialEq)] pub struct NostrIdentityReference { @@ -200,7 +200,7 @@ impl IdentityLabel { pub fn parse(value: &str) -> Result<Self, SafeError> { let normalized = value.trim(); if normalized.is_empty() - || normalized.chars().count() > MAX_IDENTITY_LABEL_CHARS + || normalized.len() > MAX_IDENTITY_LABEL_UTF8_BYTES || normalized.chars().any(char::is_control) { return Err(invalid_identity_metadata()); @@ -368,6 +368,55 @@ mod tests { .expect("identity") } + fn label_at_utf8_limit(unit: &str) -> String { + unit.repeat(80 / unit.len()) + &"x".repeat(80 % unit.len()) + } + + #[test] + fn identity_label_accepts_exact_utf8_boundaries_after_trimming() { + for unit in ["x", "é", "🥕", "e\u{301}"] { + let value = label_at_utf8_limit(unit); + assert_eq!(value.len(), 80); + let padded = format!(" \u{2003}{value}\u{2003} "); + let label = IdentityLabel::parse(&padded).expect("80-byte normalized label"); + assert_eq!(label.as_str(), value); + assert_eq!(identity(Some(label)).display_label(), value); + } + } + + #[test] + fn identity_label_rejects_one_byte_over_utf8_limit() { + for unit in ["x", "é", "🥕", "e\u{301}"] { + let value = label_at_utf8_limit(unit) + "x"; + assert_eq!(value.len(), 81); + let error = IdentityLabel::parse(&format!(" {value} ")) + .expect_err("81-byte normalized label must fail before storage"); + assert_eq!(error.code(), crate::SafeErrorCode::InvalidIdentityMetadata); + } + } + + #[test] + fn identity_label_preserves_blank_and_embedded_control_policy() { + for value in [ + "", + " \u{2003}\t\r\n ", + "a\nb", + "a\rb", + "a\tb", + "a\0b", + "a\u{1b}b", + "a\u{7f}b", + "a\u{85}b", + ] { + assert_eq!( + IdentityLabel::parse(value) + .expect_err("invalid label") + .code(), + crate::SafeErrorCode::InvalidIdentityMetadata + ); + } + } + #[test] fn identity_label_is_trimmed_bounded_and_control_free() { let label = IdentityLabel::parse(" Farm identity ").expect("valid label"); diff --git a/core/crates/harvestcircle_domain/src/profile.rs b/core/crates/harvestcircle_domain/src/profile.rs @@ -4,10 +4,10 @@ use crate::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp}; const EVENT_ID_BYTES: usize = 32; const EVENT_ID_HEX: usize = EVENT_ID_BYTES * 2; -const MAX_NAME_CHARS: usize = 128; -const MAX_NIP05_CHARS: usize = 320; -const MAX_ABOUT_CHARS: usize = 4_096; -const MAX_PICTURE_CHARS: usize = 2_048; +const MAX_NAME_UTF8_BYTES: usize = 128; +const MAX_NIP05_UTF8_BYTES: usize = 320; +const MAX_ABOUT_UTF8_BYTES: usize = 4_096; +const MAX_PICTURE_UTF8_BYTES: usize = 2_048; #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct EventId([u8; EVENT_ID_BYTES]); @@ -82,11 +82,11 @@ impl ProfileMetadata { picture: Option<String>, ) -> Result<Self, SafeError> { Ok(Self { - name: normalize_field(name, MAX_NAME_CHARS, false)?, - display_name: normalize_field(display_name, MAX_NAME_CHARS, false)?, - nip05: normalize_field(nip05, MAX_NIP05_CHARS, false)?, - about: normalize_field(about, MAX_ABOUT_CHARS, true)?, - picture: normalize_field(picture, MAX_PICTURE_CHARS, false)?, + name: normalize_field(name, MAX_NAME_UTF8_BYTES, false)?, + display_name: normalize_field(display_name, MAX_NAME_UTF8_BYTES, false)?, + nip05: normalize_field(nip05, MAX_NIP05_UTF8_BYTES, false)?, + about: normalize_field(about, MAX_ABOUT_UTF8_BYTES, true)?, + picture: normalize_field(picture, MAX_PICTURE_UTF8_BYTES, false)?, }) } @@ -184,7 +184,7 @@ pub fn select_latest_kind0( fn normalize_field( value: Option<String>, - max_chars: usize, + max_utf8_bytes: usize, allow_layout_controls: bool, ) -> Result<Option<String>, SafeError> { let Some(value) = value else { @@ -194,7 +194,7 @@ fn normalize_field( if normalized.is_empty() { return Ok(None); } - if normalized.chars().count() > max_chars + if normalized.len() > max_utf8_bytes || normalized.chars().any(|character| { character.is_control() && !(allow_layout_controls && matches!(character, '\n' | '\r' | '\t')) @@ -239,6 +239,118 @@ mod tests { ) } + fn text_at_utf8_limit(unit: &str, maximum: usize) -> String { + unit.repeat(maximum / unit.len()) + &"x".repeat(maximum % unit.len()) + } + + fn metadata_with_field( + index: usize, + value: String, + ) -> Result<ProfileMetadata, crate::SafeError> { + let mut fields: [Option<String>; 5] = std::array::from_fn(|_| None); + fields[index] = Some(value); + let [name, display_name, nip05, about, picture] = fields; + ProfileMetadata::new(name, display_name, nip05, about, picture) + } + + fn metadata_field(metadata: &ProfileMetadata, index: usize) -> Option<&str> { + match index { + 0 => metadata.name(), + 1 => metadata.display_name(), + 2 => metadata.nip05(), + 3 => metadata.about(), + 4 => metadata.picture(), + _ => unreachable!("test field index"), + } + } + + fn assert_one_byte_over_limit_is_rejected(index: usize, maximum: usize) { + for unit in ["x", "é", "🥕", "e\u{301}"] { + let value = text_at_utf8_limit(unit, maximum) + "x"; + assert_eq!(value.len(), maximum + 1); + let error = metadata_with_field(index, format!(" {value} ")) + .expect_err("normalized UTF-8 field must fail before storage"); + assert_eq!(error.code(), crate::SafeErrorCode::InvalidProfileMetadata); + } + } + + #[test] + fn profile_fields_accept_exact_utf8_boundaries_after_trimming() { + for (index, maximum) in [128, 128, 320, 4_096, 2_048].into_iter().enumerate() { + for unit in ["x", "é", "🥕", "e\u{301}"] { + let value = text_at_utf8_limit(unit, maximum); + assert_eq!(value.len(), maximum); + let metadata = metadata_with_field(index, format!(" \u{2003}{value}\u{2003} ")) + .expect("exact normalized UTF-8 boundary"); + assert_eq!(metadata_field(&metadata, index), Some(value.as_str())); + } + } + } + + #[test] + fn profile_name_rejects_one_byte_over_utf8_limit() { + assert_one_byte_over_limit_is_rejected(0, 128); + } + + #[test] + fn profile_display_name_rejects_one_byte_over_utf8_limit() { + assert_one_byte_over_limit_is_rejected(1, 128); + } + + #[test] + fn profile_nip05_rejects_one_byte_over_utf8_limit() { + assert_one_byte_over_limit_is_rejected(2, 320); + } + + #[test] + fn profile_about_rejects_one_byte_over_utf8_limit() { + assert_one_byte_over_limit_is_rejected(3, 4_096); + } + + #[test] + fn profile_picture_rejects_one_byte_over_utf8_limit() { + assert_one_byte_over_limit_is_rejected(4, 2_048); + } + + #[test] + fn profile_fields_preserve_blank_and_about_layout_control_policy() { + for index in 0..5 { + for blank in ["", " \u{2003}\t\r\n "] { + assert_eq!( + metadata_with_field(index, blank.to_owned()).expect("blank optional metadata"), + ProfileMetadata::default() + ); + } + for control in ['\0', '\u{1b}', '\u{7f}', '\u{85}'] { + assert_eq!( + metadata_with_field(index, format!("a{control}b")) + .expect_err("forbidden embedded control") + .code(), + crate::SafeErrorCode::InvalidProfileMetadata + ); + } + for control in ['\n', '\r', '\t'] { + let value = format!("a{control}b"); + let result = metadata_with_field(index, value.clone()); + if index == 3 { + assert_eq!(result.expect("about layout").about(), Some(value.as_str())); + } else { + assert_eq!( + result.expect_err("layout outside about").code(), + crate::SafeErrorCode::InvalidProfileMetadata + ); + } + } + } + let about = "First\nSecond\rThird\tFourth"; + assert_eq!( + metadata_with_field(3, format!(" \n{about}\t ")) + .expect("normalized about layout") + .about(), + Some(about) + ); + } + #[test] fn profile_fields_are_trimmed_bounded_and_public() { let metadata = ProfileMetadata::new( diff --git a/core/crates/harvestcircle_nostr/src/profile.rs b/core/crates/harvestcircle_nostr/src/profile.rs @@ -90,6 +90,123 @@ mod tests { (keys, event.as_json()) } + fn text_at_utf8_limit(unit: &str, maximum: usize) -> String { + unit.repeat(maximum / unit.len()) + &"x".repeat(maximum % unit.len()) + } + + fn signed_metadata(fields: [Option<String>; 5]) -> (PublicKey, String) { + let [name, display_name, nip05, about, picture] = fields; + let mut metadata = Metadata::new(); + metadata.name = name; + metadata.display_name = display_name; + metadata.nip05 = nip05; + metadata.about = about; + metadata.picture = picture; + let keys = Keys::generate(); + let event = EventBuilder::new(nostr::Kind::Metadata, metadata.as_json()) + .sign_with_keys(&keys) + .expect("signed metadata"); + event + .verify() + .expect("fixture has a valid signature and event ID"); + let author = PublicKey::from_bytes(keys.public_key().to_bytes()).expect("public author"); + (author, event.as_json()) + } + + fn assert_signed_one_byte_over_limit_is_rejected(index: usize, maximum: usize) { + for unit in ["x", "é", "🥕", "e\u{301}"] { + let value = text_at_utf8_limit(unit, maximum) + "x"; + assert_eq!(value.len(), maximum + 1); + let mut fields: [Option<String>; 5] = std::array::from_fn(|_| None); + fields[index] = Some(format!(" {value} ")); + let (author, json) = signed_metadata(fields); + assert_eq!( + parse_verified_kind0(&json, author) + .expect_err("correctly signed overlimit metadata") + .code(), + SafeErrorCode::InvalidProfileMetadata + ); + } + } + + #[test] + fn signed_kind0_accepts_exact_utf8_profile_boundaries() { + for unit in ["x", "é", "🥕", "e\u{301}"] { + let fields = [128, 128, 320, 4_096, 2_048] + .map(|maximum| Some(text_at_utf8_limit(unit, maximum))); + let (author, json) = signed_metadata(fields.clone()); + let candidate = parse_verified_kind0(&json, author).expect("signed UTF-8 boundaries"); + assert_eq!(candidate.author(), author); + assert_eq!(candidate.metadata().name(), fields[0].as_deref()); + assert_eq!(candidate.metadata().display_name(), fields[1].as_deref()); + assert_eq!(candidate.metadata().nip05(), fields[2].as_deref()); + assert_eq!(candidate.metadata().about(), fields[3].as_deref()); + assert_eq!(candidate.metadata().picture(), fields[4].as_deref()); + } + } + + #[test] + fn signed_kind0_rejects_name_one_byte_over_utf8_limit() { + assert_signed_one_byte_over_limit_is_rejected(0, 128); + } + + #[test] + fn signed_kind0_rejects_display_name_one_byte_over_utf8_limit() { + assert_signed_one_byte_over_limit_is_rejected(1, 128); + } + + #[test] + fn signed_kind0_rejects_nip05_one_byte_over_utf8_limit() { + assert_signed_one_byte_over_limit_is_rejected(2, 320); + } + + #[test] + fn signed_kind0_rejects_about_one_byte_over_utf8_limit() { + assert_signed_one_byte_over_limit_is_rejected(3, 4_096); + } + + #[test] + fn signed_kind0_rejects_picture_one_byte_over_utf8_limit() { + assert_signed_one_byte_over_limit_is_rejected(4, 2_048); + } + + #[test] + fn signed_kind0_preserves_trim_blank_and_about_layout_policy() { + let (author, json) = signed_metadata([ + Some(" \u{2003}e\u{301}\u{2003} ".to_owned()), + Some(" \t ".to_owned()), + None, + Some(" \nFirst\nSecond\rThird\tFourth\t ".to_owned()), + Some(" ".to_owned()), + ]); + let candidate = parse_verified_kind0(&json, author).expect("normalized signed metadata"); + assert_eq!(candidate.metadata().name(), Some("e\u{301}")); + assert_eq!(candidate.metadata().display_name(), None); + assert_eq!(candidate.metadata().nip05(), None); + assert_eq!( + candidate.metadata().about(), + Some("First\nSecond\rThird\tFourth") + ); + assert_eq!(candidate.metadata().picture(), None); + + for index in 0..5 { + for control in ['\0', '\u{1b}', '\u{7f}', '\u{85}', '\n', '\r', '\t'] { + if index == 3 && matches!(control, '\n' | '\r' | '\t') { + continue; + } + let mut fields: [Option<String>; 5] = std::array::from_fn(|_| None); + fields[index] = Some(format!("a{control}b")); + let (author, json) = signed_metadata(fields); + assert_eq!( + parse_verified_kind0(&json, author) + .expect_err("signed metadata with forbidden embedded control") + .code(), + SafeErrorCode::InvalidProfileMetadata + ); + } + } + } + #[test] fn profile_event_verifies_signature_author_kind_and_metadata() { let (keys, json) = signed_profile(); diff --git a/core/crates/harvestcircle_storage/tests/sqlx_storage.rs b/core/crates/harvestcircle_storage/tests/sqlx_storage.rs @@ -1,13 +1,14 @@ use std::fs; use harvestcircle_application::{ - DurableOperationKind, DurableOperationPhase, DurableOperationRepository, DurableOperationStart, - DurableRequestId, DurableTerminalOutcome, IdentityRepository, KeyMaterialProvider, - OperationPriorState, + CachedProfile, DurableOperationKind, DurableOperationPhase, DurableOperationRepository, + DurableOperationStart, DurableRequestId, DurableTerminalOutcome, IdentityRepository, + KeyMaterialProvider, OperationPriorState, ProfileRefreshStatus, ProfileRepository, }; use harvestcircle_domain::{ - IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, PublicKey, - SafeErrorCode, SignerAvailability, UnixTimestamp, + EventId, IdentityCreatedAt, IdentityLabel, Kind0ProfileCandidate, LocalKeyringBinding, + NostrIdentity, NostrIdentityReference, ProfileMetadata, PublicKey, SafeErrorCode, + SignerAvailability, UnixTimestamp, }; use harvestcircle_nostr::NostrKeyMaterialProvider; use harvestcircle_storage::{ @@ -90,6 +91,207 @@ fn identity(index: usize) -> NostrIdentity { .expect("identity") } +fn text_at_utf8_limit(unit: &str, maximum: usize) -> String { + unit.repeat(maximum / unit.len()) + &"x".repeat(maximum % unit.len()) +} + +fn identity_with_label(base: &NostrIdentity, label: IdentityLabel) -> NostrIdentity { + NostrIdentity::new( + NostrIdentityReference::derive(base.public_key()).expect("identity reference"), + base.signer_binding(), + Some(label), + base.created_at(), + base.last_used_at(), + ) + .expect("labelled identity") +} + +fn metadata_from_fields( + fields: [Option<String>; 5], +) -> Result<ProfileMetadata, harvestcircle_domain::SafeError> { + let [name, display_name, nip05, about, picture] = fields; + ProfileMetadata::new(name, display_name, nip05, about, picture) +} + +fn cached_profile(author: PublicKey, metadata: ProfileMetadata, created_at: i64) -> CachedProfile { + let timestamp = UnixTimestamp::from_seconds(created_at).expect("profile timestamp"); + CachedProfile::new( + Kind0ProfileCandidate::new(EventId::from_bytes([7; 32]), author, timestamp, metadata), + timestamp, + ProfileRefreshStatus::Success, + ) +} + +#[tokio::test] +async fn utf8_boundary_identity_and_profile_metadata_round_trip_through_governed_storage() { + let fixtures = ["x", "é", "🥕", "e\u{301}"] + .into_iter() + .enumerate() + .map(|(index, unit)| { + let label = text_at_utf8_limit(unit, 80); + assert_eq!(label.len(), 80); + let identity = identity_with_label( + &identity(index), + IdentityLabel::parse(&format!(" \u{2003}{label}\u{2003} ")) + .expect("boundary label"), + ); + let mut values = + [128, 128, 320, 4_096, 2_048].map(|maximum| text_at_utf8_limit(unit, maximum)); + let layout = "First\nSecond\rThird\t"; + values[3] = layout.to_owned() + &text_at_utf8_limit(unit, 4_096 - layout.len()); + for (value, maximum) in values.iter().zip([128, 128, 320, 4_096, 2_048]) { + assert_eq!(value.len(), maximum); + } + let metadata = metadata_from_fields( + values.map(|value| Some(format!(" \u{2003}{value}\u{2003} "))), + ) + .expect("normalized boundary profile"); + let profile = cached_profile(identity.public_key(), metadata, 10); + (identity, profile) + }) + .collect::<Vec<_>>(); + let directory = tempdir().expect("directory"); + let context = runtime_context(&directory); + let build = build_identity(); + let database = Database::open(&context, 1, 1, &build) + .await + .expect("database"); + let mut writes = Vec::new(); + for (identity, profile) in &fixtures { + writes.push(( + database.insert_identity(identity).await, + database.save_profile(profile).await, + )); + } + database + .close() + .await + .expect("close before persistent roundtrip"); + + let reopened = Database::open(&context, 2, 2, &build) + .await + .expect("reopen"); + let schema_version = reopened.metadata().state_schema_version().get(); + let mut reads = Vec::new(); + for (identity, _) in &fixtures { + reads.push(( + reopened.find_identity(identity.public_key()).await, + reopened.load_profile(identity.public_key()).await, + )); + } + let all_identities = reopened.list_identities().await; + reopened + .close() + .await + .expect("close before final assertions"); + + assert_eq!(schema_version, 2); + assert_eq!( + all_identities.expect("persisted identities").len(), + fixtures.len() + ); + for (identity_write, profile_write) in writes { + identity_write.expect("boundary identity write"); + profile_write.expect("boundary profile write"); + } + for ((identity, profile), (identity_read, profile_read)) in fixtures.into_iter().zip(reads) { + assert_eq!(identity_read.expect("identity read"), Some(identity)); + assert_eq!(profile_read.expect("profile read"), Some(profile)); + } +} + +#[tokio::test] +async fn overlimit_identity_metadata_is_rejected_before_repository_write() { + let base = identity(0); + let directory = tempdir().expect("directory"); + let context = runtime_context(&directory); + let database = Database::open(&context, 1, 1, &build_identity()) + .await + .expect("database"); + let mut outcomes = Vec::new(); + for unit in ["x", "é", "🥕", "e\u{301}"] { + let value = text_at_utf8_limit(unit, 80) + "x"; + let result = match IdentityLabel::parse(&format!(" {value} ")) { + Err(error) => Err(error), + Ok(label) => { + database + .insert_identity(&identity_with_label(&base, label)) + .await + } + }; + outcomes.push((unit, value.len(), result)); + } + let stored = database.list_identities().await; + database + .close() + .await + .expect("close before typed validation assertions"); + + assert!(stored.expect("identity list").is_empty()); + for (unit, bytes, result) in outcomes { + assert_eq!(bytes, 81); + assert_eq!( + result.expect_err("overlimit label must not persist").code(), + SafeErrorCode::InvalidIdentityMetadata, + "validation must reject {unit:?} before SQL constraint failure" + ); + } +} + +#[tokio::test] +async fn overlimit_profile_metadata_is_rejected_before_repository_write() { + let identity = identity(0); + let original = cached_profile( + identity.public_key(), + ProfileMetadata::new(Some("original".to_owned()), None, None, None, None) + .expect("original metadata"), + 1, + ); + let directory = tempdir().expect("directory"); + let context = runtime_context(&directory); + let database = Database::open(&context, 1, 1, &build_identity()) + .await + .expect("database"); + let identity_write = database.insert_identity(&identity).await; + let original_write = database.save_profile(&original).await; + let mut outcomes = Vec::new(); + for (index, maximum) in [128, 128, 320, 4_096, 2_048].into_iter().enumerate() { + for unit in ["x", "é", "🥕", "e\u{301}"] { + let value = text_at_utf8_limit(unit, maximum) + "x"; + let mut fields: [Option<String>; 5] = std::array::from_fn(|_| None); + fields[index] = Some(format!(" {value} ")); + let result = match metadata_from_fields(fields) { + Err(error) => Err(error), + Ok(metadata) => { + database + .save_profile(&cached_profile(identity.public_key(), metadata, 2)) + .await + } + }; + outcomes.push((index, unit, value.len(), maximum, result)); + } + } + let stored = database.load_profile(identity.public_key()).await; + database + .close() + .await + .expect("close before typed validation assertions"); + + identity_write.expect("identity setup"); + original_write.expect("profile setup"); + assert_eq!(stored.expect("profile read"), Some(original)); + for (index, unit, bytes, maximum, result) in outcomes { + assert_eq!(bytes, maximum + 1); + assert_eq!( + result + .expect_err("overlimit profile must not persist") + .code(), + SafeErrorCode::InvalidProfileMetadata, + "field {index} must reject {unit:?} before SQL constraint failure" + ); + } +} + #[tokio::test] async fn bootstrap_requires_the_existing_governed_state_root() { let directory = tempdir().expect("directory");