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:
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");