diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index aee02c2..4d4b2ec 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -780,25 +780,25 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client ClientError::Custom(format!("Failed to read stdin: {}", e)))?; let record_set: sip7::RecordSet = serde_json::from_str(input.trim()) .map_err(|e| ClientError::Custom(format!("Invalid SIP-7 JSON: {}", e)))?; - record_set.encode() + record_set.to_bytes() } else if !txt_records.is_empty() || !blob_records.is_empty() { // Build from --txt and --blob flags - let mut record_set = sip7::RecordSet::new(); + let mut records = Vec::new(); for txt in &txt_records { let (key, value) = txt.split_once('=').ok_or_else(|| ClientError::Custom(format!("Invalid --txt format '{}': expected key=value", txt)))?; - record_set.push_txt(key, value).map_err(|e| - ClientError::Custom(format!("Invalid TXT record: {}", e)))?; + records.push(sip7::Record::txt(key, value)); } for blob in &blob_records { let (key, b64_value) = blob.split_once('=').ok_or_else(|| ClientError::Custom(format!("Invalid --blob format '{}': expected key=base64", blob)))?; let value = base64::engine::general_purpose::STANDARD.decode(b64_value) .map_err(|e| ClientError::Custom(format!("Invalid base64 in --blob '{}': {}", key, e)))?; - record_set.push_blob(key, value).map_err(|e| - ClientError::Custom(format!("Invalid BLOB record: {}", e)))?; + records.push(sip7::Record::blob(key, value)); } - record_set.encode() + sip7::RecordSet::pack(records) + .map_err(|e| ClientError::Custom(format!("Invalid record: {}", e)))? + .to_bytes() } else { return Err(ClientError::Custom( "No data specified. Use --txt, --blob, --raw, or --stdin".to_string() diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 0b21f34..a721009 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -1337,7 +1337,8 @@ impl RpcServer for RpcServerImpl { Some(raw) => { use base64::Engine; let encoded = base64::engine::general_purpose::STANDARD.encode(&raw); - let records = sip7::RecordSet::decode(&raw).ok(); + let rs = sip7::RecordSet::new(raw); + let records = if rs.unpack().is_ok() { Some(rs) } else { None }; Ok(Some(FallbackResponse { data: encoded, records, diff --git a/client/tests/ptr_tests.rs b/client/tests/ptr_tests.rs index 337fe4f..8424faf 100644 --- a/client/tests/ptr_tests.rs +++ b/client/tests/ptr_tests.rs @@ -920,10 +920,11 @@ async fn it_should_set_and_get_space_fallback(rig: &TestRig) -> anyhow::Result<( // Test 1: Set SIP-7 fallback data on the space println!("\nTest 1: Set SIP-7 fallback data on space"); - let mut records = sip7::RecordSet::new(); - records.push_txt("btc", "bc1qtest").unwrap(); - records.push_txt("nostr", "npub1abc").unwrap(); - let wire_data = records.encode(); + let records = sip7::RecordSet::pack(vec![ + sip7::Record::txt("btc", "bc1qtest"), + sip7::Record::txt("nostr", "npub1abc"), + ]).unwrap(); + let wire_data = records.as_slice().to_vec(); let set_result = wallet_do( rig, @@ -961,7 +962,7 @@ async fn it_should_set_and_get_space_fallback(rig: &TestRig) -> anyhow::Result<( let fallback = rig.spaced.client.get_fallback(subject.clone()).await? .expect("getfallback should return data"); let parsed = fallback.records.expect("should parse as SIP-7 records"); - assert_eq!(parsed.records().len(), 2, "should have 2 records"); + assert_eq!(parsed.unpack().unwrap().len(), 2, "should have 2 records"); println!("✓ getfallback returns parsed SIP-7 records"); // Test 2: Alice can still transfer the space after setfallback @@ -988,9 +989,10 @@ async fn it_should_set_and_get_space_fallback(rig: &TestRig) -> anyhow::Result<( // Test 3: Bob can overwrite the fallback data println!("\nTest 3: Bob overwrites fallback data"); - let mut new_records = sip7::RecordSet::new(); - new_records.push_txt("eth", "0xdeadbeef").unwrap(); - let new_wire = new_records.encode(); + let new_records = sip7::RecordSet::pack(vec![ + sip7::Record::txt("eth", "0xdeadbeef"), + ]).unwrap(); + let new_wire = new_records.as_slice().to_vec(); let bob_set = wallet_do( rig, @@ -1007,7 +1009,7 @@ async fn it_should_set_and_get_space_fallback(rig: &TestRig) -> anyhow::Result<( let fallback_bob = rig.spaced.client.get_fallback(subject).await? .expect("should have fallback data"); let bob_parsed = fallback_bob.records.expect("should parse as SIP-7"); - assert_eq!(bob_parsed.records().len(), 1, "should have 1 record now"); + assert_eq!(bob_parsed.unpack().unwrap().len(), 1, "should have 1 record now"); println!("✓ Bob successfully overwrote fallback data"); Ok(()) diff --git a/sip7/src/lib.rs b/sip7/src/lib.rs index 2e14c00..3455288 100644 --- a/sip7/src/lib.rs +++ b/sip7/src/lib.rs @@ -2,12 +2,23 @@ extern crate alloc; -use alloc::{string::String, vec::Vec}; +use alloc::string::String; +use alloc::vec::Vec; use core::fmt; const TYPE_TXT: u8 = 0x00; const TYPE_BLOB: u8 = 0x01; -const TYPE_RESERVED: u8 = 0xFF; + +/// Errors that can occur during record parsing or construction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Error { + UnexpectedEof, + DataOverflow, + EmptyData, + KeyTooLong, + InvalidKey, + InvalidUtf8, +} /// A single record in a SIP-7 record set. #[derive(Clone, Debug, PartialEq, Eq)] @@ -17,35 +28,197 @@ pub enum Record { Unknown { rtype: u8, rdata: Vec }, } -/// An ordered collection of SIP-7 records. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RecordSet { - records: Vec, +impl Record { + /// Creates a TXT record. + pub fn txt(key: &str, value: &str) -> Self { + Record::Txt { + key: String::from(key), + value: String::from(value), + } + } + + /// Creates a BLOB record. + pub fn blob(key: &str, value: Vec) -> Self { + Record::Blob { + key: String::from(key), + value, + } + } + + /// Creates an unknown record type (preserved for round-tripping). + pub fn unknown(rtype: u8, rdata: Vec) -> Self { + Record::Unknown { rtype, rdata } + } + + /// Packs this record into its wire-format bytes. + /// The output is a valid single-record record set. + pub fn pack(&self) -> Result, Error> { + let mut buf = Vec::new(); + self.pack_into(&mut buf)?; + Ok(buf) + } + + /// Unpacks a single record from the start of a byte slice. + /// Returns the record and the number of bytes consumed, or + /// `Ok(None)` if the slice is empty. + pub fn unpack(data: &[u8]) -> Result, Error> { + if data.is_empty() { + return Ok(None); + } + + let mut pos = 0; + let rtype = data[pos]; + pos += 1; + + let len = read_compact_size(data, &mut pos)?; + if pos + len > data.len() { + return Err(Error::DataOverflow); + } + let rdata = &data[pos..pos + len]; + pos += len; + + let record = match rtype { + TYPE_TXT => { + let (key, val_bytes) = parse_kv(rdata)?; + let value = + core::str::from_utf8(val_bytes).map_err(|_| Error::InvalidUtf8)?; + Record::Txt { + key, + value: String::from(value), + } + } + TYPE_BLOB => { + let (key, val_bytes) = parse_kv(rdata)?; + Record::Blob { + key, + value: val_bytes.to_vec(), + } + } + _ => Record::Unknown { + rtype, + rdata: rdata.to_vec(), + }, + }; + + Ok(Some((record, pos))) + } + + fn pack_into(&self, buf: &mut Vec) -> Result<(), Error> { + match self { + Record::Txt { key, value } => { + validate_key(key)?; + buf.push(TYPE_TXT); + let data_len = 1 + key.len() + value.len(); + write_compact_size(buf, data_len); + buf.push(key.len() as u8); + buf.extend_from_slice(key.as_bytes()); + buf.extend_from_slice(value.as_bytes()); + } + Record::Blob { key, value } => { + validate_key(key)?; + buf.push(TYPE_BLOB); + let data_len = 1 + key.len() + value.len(); + write_compact_size(buf, data_len); + buf.push(key.len() as u8); + buf.extend_from_slice(key.as_bytes()); + buf.extend_from_slice(value); + } + Record::Unknown { rtype, rdata } => { + buf.push(*rtype); + write_compact_size(buf, rdata.len()); + buf.extend_from_slice(rdata); + } + } + Ok(()) + } } -/// Errors that can occur during record parsing or construction. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Error { - ReservedType, - UnexpectedEof, - DataOverflow, - EmptyData, - KeyTooLong, - InvalidKey, - InvalidUtf8, +/// An ordered collection of SIP-7 records in wire format. +/// +/// Stores packed bytes internally. Records are only parsed on demand +/// via [`unpack`](RecordSet::unpack) or [`iter`](RecordSet::iter). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RecordSet(Vec); + +impl RecordSet { + /// Wraps raw wire-format bytes. No parsing is performed. + pub fn new(data: Vec) -> Self { + Self(data) + } + + /// Packs a collection of records into a record set. + pub fn pack(records: impl IntoIterator) -> Result { + let mut data = Vec::new(); + for record in records { + record.pack_into(&mut data)?; + } + Ok(Self(data)) + } + + /// Unpacks all records, returning an error if any record is malformed. + pub fn unpack(&self) -> Result, Error> { + self.iter().collect() + } + + /// Returns a lazy iterator over the records. + pub fn iter(&self) -> RecordIter<'_> { + RecordIter { + data: self.0.as_slice(), + } + } + + /// Returns the raw wire-format bytes. + pub fn as_slice(&self) -> &[u8] { + self.0.as_slice() + } + + /// Consumes the record set and returns the raw bytes. + pub fn to_bytes(self) -> Vec { + self.0 + } + + /// Returns true if the record set contains no data. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +/// An iterator that lazily unpacks records from a byte slice. +pub struct RecordIter<'a> { + data: &'a [u8], +} + +impl<'a> Iterator for RecordIter<'a> { + type Item = Result; + + fn next(&mut self) -> Option { + if self.data.is_empty() { + return None; + } + match Record::unpack(self.data) { + Ok(Some((record, consumed))) => { + self.data = &self.data[consumed..]; + Some(Ok(record)) + } + Ok(None) => None, + Err(e) => { + self.data = &[]; + Some(Err(e)) + } + } + } } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Error::ReservedType => write!(f, "reserved type 0xFF"), Error::UnexpectedEof => write!(f, "unexpected end of data"), Error::DataOverflow => write!(f, "data length exceeds available bytes"), Error::EmptyData => write!(f, "empty record data"), - Error::KeyTooLong => write!(f, "key length exceeds record data"), + Error::KeyTooLong => write!(f, "key length exceeds 255 bytes"), Error::InvalidKey => write!(f, "key must be lowercase ascii, digits, or hyphens"), Error::InvalidUtf8 => write!(f, "invalid UTF-8 in text value"), -} + } } } @@ -93,7 +266,8 @@ fn read_compact_size(data: &[u8], pos: &mut usize) -> Result { if *pos + 8 > data.len() { return Err(Error::UnexpectedEof); } - let v = u64::from_le_bytes(data[*pos..*pos + 8].try_into().unwrap()) as usize; + let v = u64::from_le_bytes(data[*pos..*pos + 8].try_into().unwrap()); + let v: usize = v.try_into().map_err(|_| Error::DataOverflow)?; *pos += 8; Ok(v) } @@ -128,153 +302,6 @@ fn parse_kv(data: &[u8]) -> Result<(String, &[u8]), Error> { Ok((String::from(key), &data[1 + kl..])) } -impl Record { - fn encode(&self, buf: &mut Vec) { - match self { - Record::Txt { key, value } => { - buf.push(TYPE_TXT); - let data_len = 1 + key.len() + value.len(); - write_compact_size(buf, data_len); - buf.push(key.len() as u8); - buf.extend_from_slice(key.as_bytes()); - buf.extend_from_slice(value.as_bytes()); - } - Record::Blob { key, value } => { - buf.push(TYPE_BLOB); - let data_len = 1 + key.len() + value.len(); - write_compact_size(buf, data_len); - buf.push(key.len() as u8); - buf.extend_from_slice(key.as_bytes()); - buf.extend_from_slice(value); - } - Record::Unknown { rtype, rdata } => { - buf.push(*rtype); - write_compact_size(buf, rdata.len()); - buf.extend_from_slice(rdata); - } - } - } -} - -impl RecordSet { - /// Creates an empty record set. - pub fn new() -> Self { - Self { - records: Vec::new(), - } - } - - /// Decodes a record set from its wire format. - pub fn decode(data: &[u8]) -> Result { - let mut records = Vec::new(); - let mut pos = 0; - - while pos < data.len() { - let rtype = data[pos]; - pos += 1; - - if rtype == TYPE_RESERVED { - return Err(Error::ReservedType); - } - - let len = read_compact_size(data, &mut pos)?; - if pos + len > data.len() { - return Err(Error::DataOverflow); - } - let rdata = &data[pos..pos + len]; - pos += len; - - let record = match rtype { - TYPE_TXT => { - let (key, val_bytes) = parse_kv(rdata)?; - let value = - core::str::from_utf8(val_bytes).map_err(|_| Error::InvalidUtf8)?; - Record::Txt { - key, - value: String::from(value), - } - } - TYPE_BLOB => { - let (key, val_bytes) = parse_kv(rdata)?; - Record::Blob { - key, - value: val_bytes.to_vec(), - } - } - _ => Record::Unknown { - rtype, - rdata: rdata.to_vec(), - }, - }; - - records.push(record); - } - - Ok(Self { records }) - } - - /// Adds a TXT record. - pub fn push_txt(&mut self, key: &str, value: &str) -> Result<(), Error> { - validate_key(key)?; - self.records.push(Record::Txt { - key: String::from(key), - value: String::from(value), - }); - Ok(()) - } - - /// Adds a BLOB record. - pub fn push_blob(&mut self, key: &str, value: Vec) -> Result<(), Error> { - validate_key(key)?; - self.records.push(Record::Blob { - key: String::from(key), - value, - }); - Ok(()) - } - - /// Adds an unknown record type (preserved for round-tripping). - pub fn push_unknown(&mut self, rtype: u8, rdata: Vec) -> Result<(), Error> { - if rtype == TYPE_RESERVED { - return Err(Error::ReservedType); - } - self.records.push(Record::Unknown { rtype, rdata }); - Ok(()) - } - - /// Encodes the record set to its wire format. - pub fn encode(&self) -> Vec { - let mut buf = Vec::new(); - for record in &self.records { - record.encode(&mut buf); - } - buf - } - - /// Returns a slice of all records. - pub fn records(&self) -> &[Record] { - &self.records - } - - /// Returns true if the record set contains no records. - pub fn is_empty(&self) -> bool { - self.records.is_empty() - } - - /// Returns the number of records. - pub fn len(&self) -> usize { - self.records.len() - } -} - -impl Default for RecordSet { - fn default() -> Self { - Self::new() - } -} - -// --- Serde support --- - #[cfg(feature = "serde")] mod serde_impl { use super::*; @@ -336,27 +363,18 @@ mod serde_impl { fn try_from(j: RecordJson) -> Result { match j { - RecordJson::Txt(t) => Ok(Record::Txt { - key: t.key, - value: t.value, - }), + RecordJson::Txt(t) => Ok(Record::txt(&t.key, &t.value)), RecordJson::Blob(b) => { let value = BASE64_STANDARD .decode(&b.value) .map_err(|_| "invalid base64 in blob value")?; - Ok(Record::Blob { - key: b.key, - value, - }) + Ok(Record::blob(&b.key, value)) } RecordJson::Unknown(u) => { let rdata = BASE64_STANDARD .decode(&u.rdata) .map_err(|_| "invalid base64 in unknown rdata")?; - Ok(Record::Unknown { - rtype: u.rtype, - rdata, - }) + Ok(Record::unknown(u.rtype, rdata)) } } } @@ -386,8 +404,12 @@ mod serde_impl { where S: Serializer, { - let mut seq = serializer.serialize_seq(Some(self.records.len()))?; - for record in &self.records { + let records: Vec = self + .iter() + .collect::, _>>() + .map_err(serde::ser::Error::custom)?; + let mut seq = serializer.serialize_seq(Some(records.len()))?; + for record in &records { seq.serialize_element(record)?; } seq.end() @@ -416,7 +438,7 @@ mod serde_impl { while let Some(record) = seq.next_element::()? { records.push(record); } - Ok(RecordSet { records }) + Ok(RecordSet::pack(records).map_err(de::Error::custom)?) } } @@ -427,31 +449,35 @@ mod serde_impl { #[cfg(test)] mod tests { + use alloc::vec; use super::*; #[test] - fn encode_decode_txt() { - let mut rs = RecordSet::new(); - rs.push_txt("btc", "bc1qtest").unwrap(); - rs.push_txt("nostr", "npub1abc").unwrap(); - - let encoded = rs.encode(); - let decoded = RecordSet::decode(&encoded).unwrap(); - - assert_eq!(rs, decoded); - assert_eq!(decoded.len(), 2); + fn pack_unpack_txt() { + let rs = RecordSet::pack(vec![ + Record::txt("btc", "bc1qtest"), + Record::txt("nostr", "npub1abc"), + ]).unwrap(); + + let records = rs.unpack().unwrap(); + assert_eq!(records.len(), 2); + assert_eq!( + records[0], + Record::Txt { + key: String::from("btc"), + value: String::from("bc1qtest"), + } + ); } #[test] - fn encode_decode_blob() { - let mut rs = RecordSet::new(); - rs.push_blob("avatar", vec![0x89, 0x50, 0x4E, 0x47]).unwrap(); - - let encoded = rs.encode(); - let decoded = RecordSet::decode(&encoded).unwrap(); + fn pack_unpack_blob() { + let rs = RecordSet::pack(vec![ + Record::blob("avatar", vec![0x89, 0x50, 0x4E, 0x47]), + ]).unwrap(); - assert_eq!(rs, decoded); - match &decoded.records()[0] { + let records = rs.unpack().unwrap(); + match &records[0] { Record::Blob { key, value } => { assert_eq!(key, "avatar"); assert_eq!(value, &[0x89, 0x50, 0x4E, 0x47]); @@ -461,15 +487,11 @@ mod tests { } #[test] - fn encode_decode_unknown() { - let mut rs = RecordSet::new(); - rs.push_unknown(42, vec![1, 2, 3]).unwrap(); + fn pack_unpack_unknown() { + let rs = RecordSet::pack(vec![Record::unknown(42, vec![1, 2, 3])]).unwrap(); - let encoded = rs.encode(); - let decoded = RecordSet::decode(&encoded).unwrap(); - - assert_eq!(rs, decoded); - match &decoded.records()[0] { + let records = rs.unpack().unwrap(); + match &records[0] { Record::Unknown { rtype, rdata } => { assert_eq!(*rtype, 42); assert_eq!(rdata, &[1, 2, 3]); @@ -479,39 +501,55 @@ mod tests { } #[test] - fn encode_decode_mixed() { - let mut rs = RecordSet::new(); - rs.push_txt("btc", "bc1qtest").unwrap(); - rs.push_blob("data", vec![0xFF, 0x00]).unwrap(); - rs.push_unknown(0x10, vec![0xAB]).unwrap(); - rs.push_txt("email", "alice@example.com").unwrap(); + fn pack_unpack_mixed() { + let rs = RecordSet::pack(vec![ + Record::txt("btc", "bc1qtest"), + Record::blob("data", vec![0xFF, 0x00]), + Record::unknown(0x10, vec![0xAB]), + Record::txt("email", "alice@example.com"), + ]).unwrap(); + + let records = rs.unpack().unwrap(); + assert_eq!(records.len(), 4); + } - let encoded = rs.encode(); - let decoded = RecordSet::decode(&encoded).unwrap(); + #[test] + fn round_trip_type_0xff() { + let rs = RecordSet::pack(vec![Record::unknown(0xFF, vec![1, 2, 3])]).unwrap(); + let records = rs.unpack().unwrap(); + assert_eq!(records.len(), 1); + match &records[0] { + Record::Unknown { rtype, rdata } => { + assert_eq!(*rtype, 0xFF); + assert_eq!(rdata, &[1, 2, 3]); + } + _ => panic!("expected unknown"), + } + } - assert_eq!(rs, decoded); - assert_eq!(decoded.len(), 4); + #[test] + fn single_record_pack_is_valid_record_set() { + let record = Record::txt("btc", "bc1qtest"); + let bytes = record.pack().unwrap(); + let rs = RecordSet::new(bytes); + let records = rs.unpack().unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0], record); } #[test] fn empty_record_set() { - let rs = RecordSet::new(); + let rs = RecordSet::default(); assert!(rs.is_empty()); - let encoded = rs.encode(); - assert!(encoded.is_empty()); - let decoded = RecordSet::decode(&encoded).unwrap(); - assert!(decoded.is_empty()); + let records = rs.unpack().unwrap(); + assert!(records.is_empty()); } #[test] fn empty_txt_value() { - let mut rs = RecordSet::new(); - rs.push_txt("btc", "").unwrap(); - - let encoded = rs.encode(); - let decoded = RecordSet::decode(&encoded).unwrap(); - - match &decoded.records()[0] { + let rs = RecordSet::pack(vec![Record::txt("btc", "")]).unwrap(); + let records = rs.unpack().unwrap(); + match &records[0] { Record::Txt { key, value } => { assert_eq!(key, "btc"); assert_eq!(value, ""); @@ -521,128 +559,144 @@ mod tests { } #[test] - fn reject_reserved_type() { - let data = vec![0xFF, 0x00]; - assert_eq!(RecordSet::decode(&data), Err(Error::ReservedType)); + fn new_wraps_raw_bytes() { + let original = RecordSet::pack(vec![Record::txt("btc", "bc1qtest")]).unwrap(); + let bytes = original.to_bytes(); + let restored = RecordSet::new(bytes.clone()); + assert_eq!(restored.as_slice(), &bytes); + assert_eq!(restored.unpack().unwrap().len(), 1); } #[test] - fn reject_reserved_type_on_push() { - let mut rs = RecordSet::new(); + fn lazy_iter() { + let rs = RecordSet::pack(vec![ + Record::txt("a", "1"), + Record::txt("b", "2"), + Record::txt("c", "3"), + ]).unwrap(); + + let first = rs.iter().next().unwrap().unwrap(); assert_eq!( - rs.push_unknown(0xFF, vec![]), - Err(Error::ReservedType) + first, + Record::Txt { + key: String::from("a"), + value: String::from("1"), + } ); } #[test] fn reject_uppercase_key() { - let mut rs = RecordSet::new(); - assert_eq!(rs.push_txt("BTC", "bc1q"), Err(Error::InvalidKey)); + assert_eq!(Record::txt("BTC", "bc1q").pack().unwrap_err(), Error::InvalidKey); } #[test] fn reject_invalid_key_chars() { - let mut rs = RecordSet::new(); - assert_eq!(rs.push_txt("my_key", "val"), Err(Error::InvalidKey)); - assert_eq!(rs.push_txt("my.key", "val"), Err(Error::InvalidKey)); - assert_eq!(rs.push_txt("my key", "val"), Err(Error::InvalidKey)); + assert_eq!(Record::txt("my_key", "v").pack().unwrap_err(), Error::InvalidKey); + assert_eq!(Record::txt("my.key", "v").pack().unwrap_err(), Error::InvalidKey); + assert_eq!(Record::txt("my key", "v").pack().unwrap_err(), Error::InvalidKey); } #[test] fn reject_empty_key() { - let mut rs = RecordSet::new(); - assert_eq!(rs.push_txt("", "val"), Err(Error::InvalidKey)); + assert_eq!(Record::txt("", "v").pack().unwrap_err(), Error::InvalidKey); } #[test] fn allow_valid_key_chars() { - let mut rs = RecordSet::new(); - rs.push_txt("my-key-123", "val").unwrap(); - rs.push_txt("a", "val").unwrap(); - rs.push_txt("abc-def", "val").unwrap(); + Record::txt("my-key-123", "v").pack().unwrap(); + Record::txt("a", "v").pack().unwrap(); + Record::txt("abc-def", "v").pack().unwrap(); + } + + #[test] + fn pack_rejects_invalid_key() { + let bad = Record::Txt { + key: String::from("INVALID"), + value: String::from("v"), + }; + assert_eq!(bad.pack().unwrap_err(), Error::InvalidKey); + } + + #[test] + fn recordset_pack_rejects_invalid_key() { + let bad = Record::Txt { + key: String::from("BAD_KEY"), + value: String::from("v"), + }; + assert_eq!(RecordSet::pack(vec![bad]).unwrap_err(), Error::InvalidKey); } #[test] fn truncated_data() { - // RType present but no length - let data = vec![TYPE_TXT]; - assert_eq!(RecordSet::decode(&data), Err(Error::UnexpectedEof)); + let rs = RecordSet::new(vec![TYPE_TXT]); + assert_eq!(rs.unpack(), Err(Error::UnexpectedEof)); } #[test] fn data_overflow() { - // Claims 10 bytes of data but only has 3 - let data = vec![TYPE_TXT, 10, 3, b'b', b't', b'c']; - assert_eq!(RecordSet::decode(&data), Err(Error::DataOverflow)); + let rs = RecordSet::new(vec![TYPE_TXT, 10, 3, b'b', b't', b'c']); + assert_eq!(rs.unpack(), Err(Error::DataOverflow)); } #[test] fn compact_size_boundary() { - // Test value at 0xFC boundary (252) - let mut rs = RecordSet::new(); - let long_value = "x".repeat(248); // key_len(1) + key(3) + value(248) = 252 - rs.push_txt("btc", &long_value).unwrap(); - - let encoded = rs.encode(); - let decoded = RecordSet::decode(&encoded).unwrap(); - assert_eq!(rs, decoded); + let long_value = "x".repeat(248); + let rs = RecordSet::pack(vec![Record::txt("btc", &long_value)]).unwrap(); + let records = rs.unpack().unwrap(); + assert_eq!(records.len(), 1); } #[test] fn compact_size_multi_byte() { - // Test value requiring 0xFD prefix (> 252 bytes) - let mut rs = RecordSet::new(); let long_value = "x".repeat(300); - rs.push_txt("btc", &long_value).unwrap(); - - let encoded = rs.encode(); - // Verify the compact size prefix is 0xFD - assert_eq!(encoded[1], 0xFD); - let decoded = RecordSet::decode(&encoded).unwrap(); - assert_eq!(rs, decoded); + let rs = RecordSet::pack(vec![Record::txt("btc", &long_value)]).unwrap(); + assert_eq!(rs.as_slice()[1], 0xFD); + let records = rs.unpack().unwrap(); + assert_eq!(records.len(), 1); } #[cfg(feature = "serde")] mod serde_tests { + use alloc::vec; use super::*; #[test] fn json_round_trip_txt() { - let mut rs = RecordSet::new(); - rs.push_txt("btc", "bc1qtest").unwrap(); - rs.push_txt("nostr", "npub1abc").unwrap(); + let rs = RecordSet::pack(vec![ + Record::txt("btc", "bc1qtest"), + Record::txt("nostr", "npub1abc"), + ]).unwrap(); let json = serde_json::to_string(&rs).unwrap(); let decoded: RecordSet = serde_json::from_str(&json).unwrap(); - assert_eq!(rs, decoded); + assert_eq!(rs.unpack().unwrap(), decoded.unpack().unwrap()); } #[test] fn json_round_trip_blob() { - let mut rs = RecordSet::new(); - rs.push_blob("avatar", vec![0x89, 0x50, 0x4E, 0x47]).unwrap(); + let rs = RecordSet::pack(vec![ + Record::blob("avatar", vec![0x89, 0x50, 0x4E, 0x47]), + ]).unwrap(); let json = serde_json::to_string(&rs).unwrap(); assert!(json.contains("\"type\":\"blob\"")); - // BLOB value should be base64 encoded assert!(json.contains("\"value\":\"iVBORw==\"")); let decoded: RecordSet = serde_json::from_str(&json).unwrap(); - assert_eq!(rs, decoded); + assert_eq!(rs.unpack().unwrap(), decoded.unpack().unwrap()); } #[test] fn json_round_trip_unknown() { - let mut rs = RecordSet::new(); - rs.push_unknown(42, vec![1, 2, 3]).unwrap(); + let rs = RecordSet::pack(vec![Record::unknown(42, vec![1, 2, 3])]).unwrap(); let json = serde_json::to_string(&rs).unwrap(); assert!(json.contains("\"type\":\"unknown\"")); assert!(json.contains("\"rtype\":42")); let decoded: RecordSet = serde_json::from_str(&json).unwrap(); - assert_eq!(rs, decoded); + assert_eq!(rs.unpack().unwrap(), decoded.unpack().unwrap()); } #[test] @@ -654,9 +708,10 @@ mod tests { ]"#; let rs: RecordSet = serde_json::from_str(json).unwrap(); - assert_eq!(rs.len(), 3); + let records = rs.unpack().unwrap(); + assert_eq!(records.len(), 3); - match &rs.records()[0] { + match &records[0] { Record::Txt { key, value } => { assert_eq!(key, "btc"); assert_eq!(value, "bc1q..."); @@ -664,7 +719,7 @@ mod tests { _ => panic!("expected txt"), } - match &rs.records()[1] { + match &records[1] { Record::Blob { key, value } => { assert_eq!(key, "some-data"); assert_eq!(value, b"hello"); @@ -672,7 +727,7 @@ mod tests { _ => panic!("expected blob"), } - match &rs.records()[2] { + match &records[2] { Record::Unknown { rtype, rdata } => { assert_eq!(*rtype, 42); assert_eq!(rdata, &[1, 2, 3]); @@ -683,14 +738,15 @@ mod tests { #[test] fn json_pretty_output() { - let mut rs = RecordSet::new(); - rs.push_txt("nostr", "npub1abc").unwrap(); - rs.push_txt("btc", "bc1q...").unwrap(); - rs.push_blob("some-data", b"hello".to_vec()).unwrap(); + let rs = RecordSet::pack(vec![ + Record::txt("nostr", "npub1abc"), + Record::txt("btc", "bc1q..."), + Record::blob("some-data", b"hello".to_vec()), + ]).unwrap(); let json = serde_json::to_string_pretty(&rs).unwrap(); let decoded: RecordSet = serde_json::from_str(&json).unwrap(); - assert_eq!(rs, decoded); + assert_eq!(rs.unpack().unwrap(), decoded.unpack().unwrap()); } } }