nil_zonefile/
lib.rs

1// Copyright (c) 2025 New Internet Labs Limited
2// SPDX-License-Identifier: MIT
3#![doc = include_str!("../README.md")]
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use ciborium;
7use brotli::{BrotliCompress, BrotliDecompress};
8use brotli::enc::BrotliEncoderParams;
9use log::{debug, error};
10
11mod id;
12pub use id::Id;
13
14mod resource;
15pub use resource::{Resource, ResourceValue};
16
17mod wallet;
18pub use wallet::{Wallet, WalletType};
19
20mod dns;
21pub use dns::Dns;
22
23#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
24pub struct SubDomain {
25    pub owner: String,
26    pub general: String,
27    pub twitter: String,
28    pub url: String,
29    pub nostr: String,
30    pub lightning: String,
31    pub btc: String,
32}
33
34// #[derive(Debug, Serialize, Deserialize, Clone)]
35// pub struct Dns {
36//     pub zone: String,
37//     pub version: String,
38// }
39
40#[derive(Debug, Serialize, Deserialize, Clone)]
41pub struct Wallets {
42    pub wallets: HashMap<WalletType, String> // key is the SLIP44 ID, value is the address
43}
44
45#[derive(Debug, Serialize, Deserialize, Clone)]
46#[serde(tag = "v")]
47pub enum ZoneFile {
48    #[serde(rename = "0")]
49    V0 {
50        owner: String,
51        general: String,
52        twitter: String,
53        url: String,
54        nostr: String,
55        lightning: String,
56        btc: String,
57        subdomains: HashMap<String, SubDomain>,
58    },
59    #[serde(rename = "1")]
60    V1 {
61        #[serde(skip_serializing_if = "Option::is_none")]
62        default: Option<String>,
63
64        // #[serde(skip_serializing_if = "Option::is_none")]
65        // app: Option<String>,
66
67        // #[serde(skip_serializing_if = "Option::is_none")]
68        // service: Option<Service>,
69        
70        #[serde(skip_serializing_if = "Option::is_none")]
71        id: Option<Id>,
72        
73        #[serde(skip_serializing_if = "Option::is_none")]
74        resources: Option<HashMap<String, Resource>>,
75
76        #[serde(skip_serializing_if = "Option::is_none")]
77        wallets: Option<HashMap<WalletType, String>>,
78
79        #[serde(skip_serializing_if = "crate::dns::is_dns_none")]
80        dns: Option<Dns>,
81    }
82}
83
84#[derive(Debug, Serialize, Deserialize, Clone)]
85pub struct Service {
86    pub name: String,
87    pub proof: String,
88}
89
90impl ZoneFile {
91    /// Parse a zonefile from a JSON string
92    pub fn from_str(zonefile_str: &str) -> Result<Self, serde_json::Error> {
93        debug!("Attempting to parse zonefile from string");
94        
95        // First try to parse as V1
96        if let Ok(v1) = serde_json::from_str::<Self>(zonefile_str) {
97            debug!("Successfully parsed V1 zonefile");
98            return Ok(v1);
99        }
100
101        // If that fails, try to parse as legacy V0 without version field
102        debug!("V1 parsing failed, attempting to parse as legacy V0");
103        let legacy: Result<LegacyZoneFile, _> = serde_json::from_str(zonefile_str);
104        match legacy {
105            Ok(v0) => {
106                debug!("Converting legacy V0 to proper V0 with version tag");
107                // Convert legacy V0 to a proper V0 with version tag
108                let v0_json = serde_json::json!({
109                    "v": "0",
110                    "owner": v0.owner,
111                    "general": v0.general,
112                    "twitter": v0.twitter,
113                    "url": v0.url,
114                    "nostr": v0.nostr,
115                    "lightning": v0.lightning,
116                    "btc": v0.btc,
117                    "subdomains": v0.subdomains,
118                });
119                let result = serde_json::from_value(v0_json);
120                if result.is_ok() {
121                    debug!("Successfully parsed and converted legacy V0 zonefile");
122                } else {
123                    error!("Failed to convert legacy V0 to proper V0 format");
124                }
125                result
126            },
127            Err(e) => {
128                error!("Failed to parse zonefile: {}", e);
129                Err(e)
130            }
131        }
132    }
133
134    /// Convert the zonefile to a JSON string
135    pub fn to_string(&self) -> Result<String, serde_json::Error> {
136        debug!("Converting zonefile to JSON string");
137        let result = serde_json::to_string(self);
138        if let Err(ref e) = result {
139            error!("Failed to convert zonefile to string: {}", e);
140        } else {
141            debug!("Successfully converted zonefile to string");
142        }
143        result
144    }
145
146    /// Create a new V1 zonefile
147    pub fn new_v1(default: &str) -> Self {
148        debug!("Creating new V1 zonefile with default: {}", default);
149        ZoneFile::V1 { default: Some(default.to_string()), id: None, resources: None, wallets: None, dns: None }
150    }
151
152    /// Create a new V0 zonefile
153    pub fn new_v0(
154        owner: String,
155        general: String,
156        twitter: String,
157        url: String,
158        nostr: String,
159        lightning: String,
160        btc: String,
161        subdomains: HashMap<String, SubDomain>,
162    ) -> Self {
163        debug!("Creating new V0 zonefile for owner: {}", owner);
164        ZoneFile::V0 {
165            owner,
166            general,
167            twitter,
168            url,
169            nostr,
170            lightning,
171            btc,
172            subdomains,
173        }
174    }
175
176    /// Parse a zonefile from a Clarity Buffer Hex String
177    pub fn from_clarity_buffer_hex_string(hex_string: &str) -> Result<Self, serde_json::Error> {
178        debug!("Parsing zonefile from clarity buffer hex string");
179        let bytes = ZoneFile::clarity_buffer_to_uint8_array(hex_string);
180        let zonefile = ZoneFile::from_bytes(&bytes)?;
181        debug!("Successfully parsed zonefile from clarity buffer");
182        Ok(zonefile)
183    }
184
185    pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
186        debug!("Attempting to parse zonefile from {} bytes", bytes.len());
187        
188        // Try decompressing as brotli first
189        let mut decompressed = Vec::new();
190        let decompressed_result = {
191            let mut reader = bytes;
192            let mut writer = &mut decompressed;
193            BrotliDecompress(&mut reader, &mut writer)
194        };
195        
196        let processed_bytes = match decompressed_result {
197            Ok(_) => {
198                debug!("Successfully decompressed brotli data");
199                decompressed
200            },
201            Err(e) => {
202                debug!("Brotli decompression failed ({}), using raw bytes", e);
203                bytes.to_vec()
204            }
205        };
206
207        // Try parsing as CBOR
208        debug!("Attempting to parse as CBOR");
209        match ZoneFile::from_cbor(&processed_bytes) {
210            Ok(zonefile) => {
211                debug!("Successfully parsed CBOR data");
212                Ok(zonefile)
213            },
214            Err(e) => {
215                debug!("CBOR parsing failed ({}), attempting JSON parsing", e);
216                // If CBOR parsing fails, try parsing as JSON string
217                match std::str::from_utf8(&processed_bytes) {
218                    Ok(str_data) => {
219                        debug!("Attempting to parse as JSON string");
220                        let result = ZoneFile::from_str(str_data);
221                        if result.is_ok() {
222                            debug!("Successfully parsed JSON data");
223                        } else {
224                            error!("Failed to parse JSON data");
225                        }
226                        result
227                    },
228                    Err(e) => {
229                        error!("Invalid UTF-8 in data: {}", e);
230                        Err(serde_json::Error::io(std::io::Error::new(
231                            std::io::ErrorKind::InvalidData,
232                            format!("Invalid UTF-8: {}", e)
233                        )))
234                    }
235                }
236            }
237        }
238    }
239
240    /// Parse a zonefile from CBOR bytes
241    pub fn from_cbor(cbor_bytes: &[u8]) -> Result<Self, ciborium::de::Error<std::io::Error>> {
242        debug!("Parsing zonefile from {} bytes of CBOR data", cbor_bytes.len());
243        let result = ciborium::de::from_reader(cbor_bytes);
244        if result.is_err() {
245            error!("Failed to parse CBOR data");
246        }
247        result
248    }
249
250    /// Convert the zonefile to CBOR bytes
251    pub fn to_cbor(&self) -> Result<Vec<u8>, ciborium::ser::Error<std::io::Error>> {
252        debug!("Converting zonefile to CBOR");
253        let mut bytes = Vec::new();
254        let result = ciborium::ser::into_writer(self, &mut bytes);
255        if let Err(ref e) = result {
256            error!("Failed to convert to CBOR: {}", e);
257        } else {
258            debug!("Successfully converted to {} bytes of CBOR", bytes.len());
259        }
260        result.map(|_| bytes)
261    }
262
263    /// Convert the zonefile to compressed CBOR bytes using brotli
264    pub fn to_compressed_cbor(&self) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
265        debug!("Converting zonefile to compressed CBOR");
266        let cbor_bytes = self.to_cbor()?;
267        let mut compressed = Vec::new();
268        {
269            let mut reader = &cbor_bytes[..];
270            let mut writer = &mut compressed;
271            let params = BrotliEncoderParams {
272                quality: 11, // Maximum compression
273                lgwin: 22,  // Maximum window size
274                ..Default::default()
275            };
276            debug!("Compressing {} bytes of CBOR data", cbor_bytes.len());
277            match BrotliCompress(&mut reader, &mut writer, &params) {
278                Ok(_) => {
279                    debug!("Successfully compressed CBOR data: {} -> {} bytes", cbor_bytes.len(), compressed.len());
280                },
281                Err(e) => {
282                    error!("Failed to compress CBOR data: {}", e);
283                    return Err(Box::new(e));
284                }
285            }
286        }
287        Ok(compressed)
288    }
289
290    fn clarity_buffer_to_uint8_array(hex_string: &str) -> Vec<u8> {
291        debug!("Converting clarity buffer hex string to bytes");
292        // Remove '0x' prefix if present
293        let hex = hex_string.strip_prefix("0x").unwrap_or(hex_string);
294        
295        // Convert hex string to bytes
296        let bytes: Vec<u8> = (0..hex.len())
297            .step_by(2)
298            .filter_map(|i| {
299                if i + 2 <= hex.len() {
300                    u8::from_str_radix(&hex[i..i + 2], 16).ok()
301                } else {
302                    None
303                }
304            })
305            .collect();
306        
307        debug!("Converted {} hex chars to {} bytes", hex.len(), bytes.len());
308        bytes
309    }
310}
311
312// Internal struct for parsing legacy V0 zonefiles
313#[derive(Debug, Serialize, Deserialize)]
314struct LegacyZoneFile {
315    owner: String,
316    general: String,
317    twitter: String,
318    url: String,
319    nostr: String,
320    lightning: String,
321    btc: String,
322    subdomains: HashMap<String, SubDomain>,
323}
324
325// Wrap the entire WASM module in a feature flag
326#[cfg(feature = "wasm")]
327pub mod wasm {
328    use super::*;
329    use wasm_bindgen::prelude::*;
330
331    #[wasm_bindgen]
332    pub fn parse_zonefile_from_clarity_buffer_hex_string(hex_string: &str) -> Result<Vec<u8>, JsValue> {
333        ZoneFile::from_clarity_buffer_hex_string(hex_string)
334            .and_then(|zf| zf.to_cbor().map_err(|e| serde_json::Error::io(std::io::Error::new(
335                std::io::ErrorKind::Other,
336                e.to_string()
337            ))))
338            .map_err(|e| JsValue::from_str(&e.to_string()))
339    }
340
341    #[wasm_bindgen(js_name = "generate_zonefile")]
342    pub fn generate_zonefile(val: JsValue, compress: bool) -> Result<Vec<u8>, JsValue> {
343        let zonefile: ZoneFile = serde_wasm_bindgen::from_value(val)
344            .map_err(|e| JsValue::from_str(&format!("Failed to parse zonefile: {}", e)))?;
345
346        if compress {
347            zonefile.to_compressed_cbor()
348                .map_err(|e| JsValue::from_str(&format!("Failed to compress zonefile: {}", e)))
349        } else {
350            zonefile.to_cbor()
351                .map_err(|e| JsValue::from_str(&format!("Failed to generate CBOR: {}", e)))
352        }
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::wallet::WalletType;
360
361    const SAMPLE_V0_ZONEFILE: &str = r#"{"owner":"SP3D03X5BHMNSAAW71NN7BQRMV4DW2G4JB3MZAGJ8","general":"Pato Gómez","twitter":"@setpato","url":"pato.locker","nostr":"","lightning":"","btc":"bc1q4x98c6gwjj34lqs97zdvfyv2j0fcjhhw7hj0pc","subdomains":{"test":{"owner":"SP3D03X5BHMNSAAW71NN7BQRMV4DW2G4JB3MZAGJ8","general":"test","twitter":"setpato","url":"testurl","nostr":"","lightning":"","btc":"bc1q4x98c6gwjj34lqs97zdvfyv2j0fcjhhw7hj0pc"}}}"#;
362    const SAMPLE_V0_ZONEFILE_EMPTY_SUBDOMAINS: &str = r#"{"owner":"SP3D03X5BHMNSAAW71NN7BQRMV4DW2G4JB3MZAGJ8","general":"Pato Gómez","twitter":"@setpato","url":"pato.locker","nostr":"","lightning":"","btc":"bc1q4x98c6gwjj34lqs97zdvfyv2j0fcjhhw7hj0pc","subdomains":{}}"#;
363    const SAMPLE_ZONEFILE_HEX_STRING_LARRY_ID: &str = "7b226f776e6572223a2253503252374358454244474248374d374b3052484347514750593659364b524a314d37444541414d57222c2267656e6572616c223a22222c2274776974746572223a226c6172727973616c69627261222c2275726c223a22222c226e6f737472223a22222c226c696768746e696e67223a22222c22627463223a22222c22737562646f6d61696e73223a7b7d7d";
364    const SAMPLE_ZONEFILE_HEX_STRING_DEFAULT_ID: &str = "0x070a02000000148b0780a2617661316764656661756c7462696403";
365    
366    fn setup() {
367        let _ = env_logger::builder()
368            .is_test(true)
369            .try_init();
370    }
371
372    #[test]
373    fn test_parse_zonefile_valid_v0() {
374        setup();
375        let result = ZoneFile::from_str(SAMPLE_V0_ZONEFILE);
376        assert!(result.is_ok());
377        
378        let zonefile = result.unwrap();
379        match zonefile {
380            ZoneFile::V0 { owner, general, twitter, .. } => {
381                assert_eq!(&owner, "SP3D03X5BHMNSAAW71NN7BQRMV4DW2G4JB3MZAGJ8");
382                assert_eq!(&general, "Pato Gómez");
383                assert_eq!(&twitter, "@setpato");
384            },
385            _ => panic!("Expected V0 zonefile"),
386        }
387    }
388    
389    #[test]
390    #[ignore] // TODO: Fix this test - current hex string may be malformed CBOR data
391    fn test_parse_zonefile_valid_v1() {
392        setup();
393        // Add debug output to see what we're working with
394        debug!("Testing V1 zonefile parsing with hex string: {}", SAMPLE_ZONEFILE_HEX_STRING_DEFAULT_ID);
395        
396        let result = ZoneFile::from_clarity_buffer_hex_string(SAMPLE_ZONEFILE_HEX_STRING_DEFAULT_ID);
397        
398        // If there's an error, log it
399        if let Err(ref e) = result {
400            error!("Error parsing zonefile: {}", e);
401        }
402        
403        assert!(result.is_ok());
404        
405        let zonefile = result.unwrap();
406        debug!("Parsed zonefile: {:?}", zonefile);
407        
408        match zonefile {
409            ZoneFile::V1 { default, .. } => {
410                assert_eq!(default, Some("id".to_string()));
411            },
412            _ => panic!("Expected V1 zonefile"),
413        }
414    }
415
416    #[test]
417    fn test_parse_zonefile_invalid() {
418        setup();
419        let result = ZoneFile::from_str("{invalid json}");
420        assert!(result.is_err());
421    }
422
423    #[test]
424    fn test_zonefile_roundtrip() {
425        setup();
426        let zonefile = ZoneFile::from_str(SAMPLE_V0_ZONEFILE).unwrap();
427        let json = zonefile.to_string().unwrap();
428        let parsed_again = ZoneFile::from_str(&json).unwrap();
429        
430        match (&zonefile, &parsed_again) {
431            (ZoneFile::V0 { owner: orig_owner, general: orig_general, subdomains: orig_subdomains, .. },
432             ZoneFile::V0 { owner: parsed_owner, general: parsed_general, subdomains: parsed_subdomains, .. }) => {
433                assert_eq!(orig_owner, parsed_owner);
434                assert_eq!(orig_general, parsed_general);
435                
436                let orig_subdomain = orig_subdomains.get("test").unwrap();
437                let parsed_subdomain = parsed_subdomains.get("test").unwrap();
438                assert_eq!(orig_subdomain.general, parsed_subdomain.general);
439                assert_eq!(orig_subdomain.twitter, parsed_subdomain.twitter);
440            },
441            _ => panic!("Expected V0 zonefile"),
442        }
443    }
444
445    #[test]
446    fn test_cbor_roundtrip() {
447        setup();
448        let zonefile = ZoneFile::from_str(SAMPLE_V0_ZONEFILE).unwrap();
449        
450        // Test regular CBOR still works
451        let cbor_bytes = zonefile.to_cbor().unwrap();
452        let parsed_from_cbor = ZoneFile::from_bytes(&cbor_bytes).unwrap();
453        match (&zonefile, &parsed_from_cbor) {
454            (ZoneFile::V0 { owner: orig_owner, .. }, ZoneFile::V0 { owner: parsed_owner, .. }) => {
455                assert_eq!(orig_owner, parsed_owner);
456            },
457            _ => panic!("Expected V0 zonefile"),
458        }
459        
460        // Test compressed CBOR
461        let compressed_bytes = zonefile.to_compressed_cbor().unwrap();
462        let parsed_from_compressed = ZoneFile::from_bytes(&compressed_bytes).unwrap();
463        match (&zonefile, &parsed_from_compressed) {
464            (ZoneFile::V0 { owner: orig_owner, .. }, ZoneFile::V0 { owner: parsed_owner, .. }) => {
465                assert_eq!(orig_owner, parsed_owner);
466            },
467            _ => panic!("Expected V0 zonefile"),
468        }
469
470        // Verify compression actually reduces size
471        assert!(compressed_bytes.len() < cbor_bytes.len());
472        debug!("CBOR size: {}, Compressed size: {}", cbor_bytes.len(), compressed_bytes.len());
473    }
474
475    #[test]
476    fn test_cbor_invalid_input() {
477        setup();
478        assert!(ZoneFile::from_cbor(&[]).is_err());
479        assert!(ZoneFile::from_cbor(&[0xFF, 0xFF, 0xFF]).is_err());
480    }
481
482    #[test]
483    fn test_cbor_size() {
484        setup();
485        let zonefile = ZoneFile::from_str(SAMPLE_V0_ZONEFILE).unwrap();
486        let json_size = SAMPLE_V0_ZONEFILE.len();
487        let cbor_size = zonefile.to_cbor().unwrap().len();
488        assert!(cbor_size < json_size);
489        debug!("JSON size: {}, CBOR size: {}", json_size, cbor_size);
490    }
491
492    #[test]
493    fn test_cbor_empty_fields() {
494        setup();
495        let zonefile = ZoneFile::from_str(SAMPLE_V0_ZONEFILE_EMPTY_SUBDOMAINS).unwrap();
496
497        let cbor_bytes = zonefile.to_cbor().unwrap();
498        let parsed_zonefile = ZoneFile::from_cbor(&cbor_bytes).unwrap();
499
500        match parsed_zonefile {
501            ZoneFile::V0 { nostr, lightning, subdomains, .. } => {
502                assert_eq!(nostr, "");
503                assert_eq!(lightning, "");
504                assert!(subdomains.is_empty());
505            },
506            _ => panic!("Expected V0 zonefile"),
507        }
508    }
509
510    #[test]
511    fn test_clarity_buffer_conversion() {
512        setup();
513        // Test with '0x' prefix
514        let hex = "0x5361746f736869"; // "Satoshi" in hex
515        let bytes = ZoneFile::clarity_buffer_to_uint8_array(hex);
516        assert_eq!(bytes, b"Satoshi");
517
518        // Test without '0x' prefix
519        let hex = "5361746f736869"; // "Satoshi" in hex
520        let bytes = ZoneFile::clarity_buffer_to_uint8_array(hex);
521        assert_eq!(bytes, b"Satoshi");
522
523        // Test empty string
524        let hex = "";
525        let bytes = ZoneFile::clarity_buffer_to_uint8_array(hex);
526        assert_eq!(bytes, Vec::<u8>::new());
527
528        // Test invalid hex (odd length)
529        let hex = "5361746f73686"; // Odd length
530        let bytes = ZoneFile::clarity_buffer_to_uint8_array(hex);
531        assert_eq!(bytes, vec![0x53, 0x61, 0x74, 0x6f, 0x73, 0x68]); // Should ignore last character
532
533        // Test invalid hex characters
534        let hex = "0xZZ";
535        let bytes = ZoneFile::clarity_buffer_to_uint8_array(hex);
536        assert_eq!(bytes, Vec::<u8>::new()); // Should return empty vec for invalid hex
537    }
538
539    #[test]
540    fn test_from_clarity_buffer_hex_string() {
541        setup();
542        // Create a valid zonefile JSON and convert to hex
543        let hex = format!("0x{}", hex::encode(SAMPLE_V0_ZONEFILE));
544        debug!("Testing with hex string: {}", hex);
545        
546        let result = ZoneFile::from_clarity_buffer_hex_string(&hex);
547        assert!(result.is_ok());
548        let zonefile = result.unwrap();
549        match zonefile {
550            ZoneFile::V0 { owner, general, .. } => {
551                assert_eq!(&owner, "SP3D03X5BHMNSAAW71NN7BQRMV4DW2G4JB3MZAGJ8");
552                assert_eq!(&general, "Pato Gómez");
553            },
554            _ => panic!("Expected V0 zonefile"),
555        }
556
557        // Test invalid hex string
558        let result = ZoneFile::from_clarity_buffer_hex_string("0xZZ");
559        assert!(result.is_err());
560
561        // Test empty hex string
562        let result = ZoneFile::from_clarity_buffer_hex_string("");
563        assert!(result.is_err());
564    }
565
566    #[test]
567    fn test_parse_hex_string_sample() {
568        setup();
569        let result = ZoneFile::from_clarity_buffer_hex_string(SAMPLE_ZONEFILE_HEX_STRING_LARRY_ID);
570        assert!(result.is_ok());
571        
572        let zonefile = result.unwrap();
573        match zonefile {
574            ZoneFile::V0 { owner, general, twitter, url, nostr, lightning, btc, subdomains } => {
575                assert_eq!(owner, "SP2R7CXEBDGBH7M7K0RHCGQGPY6Y6KRJ1M7DEAAMW");
576                assert_eq!(general, "");
577                assert_eq!(twitter, "larrysalibra");
578                assert_eq!(url, "");
579                assert_eq!(nostr, "");
580                assert_eq!(lightning, "");
581                assert_eq!(btc, "");
582                assert!(subdomains.is_empty());
583            },
584            _ => panic!("Expected V0 zonefile"),
585        }
586    }
587
588    #[test]
589    fn test_version_handling() {
590        setup();
591        // Test parsing legacy V0 zonefile without version
592        let json = r#"{"owner":"SP123","general":"Test","twitter":"@test","url":"test.url","nostr":"","lightning":"","btc":"","subdomains":{}}"#;
593        let zonefile = ZoneFile::from_str(json).unwrap();
594        
595        // Verify that serializing adds the version tag
596        let serialized = zonefile.to_string().unwrap();
597        debug!("Serialized V0 from legacy: {}", serialized);
598        assert!(serialized.contains(r#""v":"0""#), "Serialized output should contain version tag");
599        
600        // Test parsing explicit V0 zonefile
601        let json_v0 = r#"{"v":"0","owner":"SP123","general":"Test","twitter":"@test","url":"test.url","nostr":"","lightning":"","btc":"","subdomains":{}}"#;
602        let zonefile = ZoneFile::from_str(json_v0).unwrap();
603        let serialized = zonefile.to_string().unwrap();
604        debug!("Serialized V0 from explicit: {}", serialized);
605        assert!(serialized.contains(r#""v":"0""#), "Serialized output should contain version tag");
606
607        // Test that CBOR serialization includes version tag
608        let cbor_bytes = zonefile.to_cbor().unwrap();
609        let parsed_from_cbor = ZoneFile::from_cbor(&cbor_bytes).unwrap();
610        let serialized_from_cbor = parsed_from_cbor.to_string().unwrap();
611        debug!("Serialized from CBOR: {}", serialized_from_cbor);
612        assert!(serialized_from_cbor.contains(r#""v":"0""#), "CBOR roundtrip should preserve version tag");
613
614        // Test parsing V1 zonefile
615        let json_v1 = r#"{"v":"1","default":"test.btc"}"#;
616        let zonefile = ZoneFile::from_str(json_v1).unwrap();
617        match zonefile {
618            ZoneFile::V1 { default, id: _, resources: _, wallets: _, dns: _ } => assert_eq!(default, Some("test.btc".to_string())),
619            _ => panic!("Expected V1 zonefile"),
620        }
621
622        // Test creating and serializing V1 zonefile
623        let v1 = ZoneFile::new_v1("test.btc");
624        let serialized = v1.to_string().unwrap();
625        debug!("Serialized V1: {}", serialized);
626        assert!(serialized.contains(r#""v":"1""#), "Serialized V1 output should contain version tag");
627
628        // Test creating and serializing V0 zonefile
629        let v0 = ZoneFile::new_v0(
630            "SP123".to_string(),
631            "Test".to_string(),
632            "@test".to_string(),
633            "test.url".to_string(),
634            "".to_string(),
635            "".to_string(),
636            "".to_string(),
637            HashMap::new(),
638        );
639        let serialized = v0.to_string().unwrap();
640        debug!("Serialized V0 from new: {}", serialized);
641        assert!(serialized.contains(r#""v":"0""#), "Serialized V0 output should contain version tag");
642    }
643
644    #[test]
645    fn test_version_tag_simple() {
646        setup();
647        // Test V0
648        let v0 = ZoneFile::new_v0(
649            "SP123".to_string(),
650            "Test".to_string(),
651            "@test".to_string(),
652            "test.url".to_string(),
653            "".to_string(),
654            "".to_string(),
655            "".to_string(),
656            HashMap::new(),
657        );
658        let serialized = serde_json::to_string(&v0).unwrap();
659        debug!("Direct V0 serialization: {}", serialized);
660        assert!(serialized.contains(r#""v":"0""#), "Direct V0 serialization should contain version tag");
661
662        // Test V1
663        let v1 = ZoneFile::new_v1("test.btc");
664        let serialized = serde_json::to_string(&v1).unwrap();
665        debug!("Direct V1 serialization: {}", serialized);
666        assert!(serialized.contains(r#""v":"1""#), "Direct V1 serialization should contain version tag");
667    }
668
669    #[test]
670    fn test_zonefile_v1_with_assets() {
671        setup();
672        let mut resources = HashMap::new();
673        
674        // Add a text resource
675        resources.insert("greeting.txt".to_string(), Resource {
676            val: Some(ResourceValue::Text("Hello, world!".to_string())),
677            mime: Some("text/plain".to_string()),
678        });
679
680        // Add a binary resource
681        resources.insert("data.bin".to_string(), Resource {
682            val: Some(ResourceValue::Binary(vec![0x01, 0x02, 0x03])),
683            mime: Some("application/octet-stream".to_string()),
684        });
685
686        let zonefile = ZoneFile::V1 {
687            default: Some("example.btc".to_string()),
688            id: None,
689            resources: Some(resources),
690            wallets: None,
691            dns: None,
692        };
693
694        // Test full zonefile serialization roundtrip
695        let cbor_bytes = zonefile.to_cbor().unwrap();
696        let parsed_zonefile = ZoneFile::from_cbor(&cbor_bytes).unwrap();
697
698        match parsed_zonefile {
699            ZoneFile::V1 { resources: Some(parsed_resources), .. } => {
700                assert_eq!(parsed_resources.len(), 2);
701                assert!(parsed_resources.contains_key("greeting.txt"));
702                assert!(parsed_resources.contains_key("data.bin"));
703            },
704            _ => panic!("Expected V1 zonefile with resources"),
705        }
706    }
707
708    #[test]
709    fn test_v1_zonefile_with_wallets() {
710        setup();
711        // Create a wallet map with multiple wallet types
712        let mut wallets = HashMap::new();
713        wallets.insert(WalletType::bitcoin(), "bc1qxxx...".to_string());
714        wallets.insert(WalletType::stacks(), "SP2JXKMSH007NPYAQHKJPQMAQYAD90NQGTVJVQ02B".to_string());
715        wallets.insert(WalletType::solana(), "DxPv2QMA5cWR5Xj7BHt8J9LZkxGGx8DDsicafbLnGm9R".to_string());
716        wallets.insert(WalletType::ethereum(), "0x123...".to_string());
717        wallets.insert(WalletType::lightning(), "larry@getalby.com".to_string());
718
719        // Create sample binary data for images
720        let avatar_data = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; // PNG header
721        let banner_data = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46]; // JPEG header
722
723        // Create a resources map
724        let mut resources = HashMap::new();
725        resources.insert("avatar".to_string(), Resource {
726            val: Some(ResourceValue::Binary(avatar_data.clone())),
727            mime: Some("image/png".to_string()),
728        });
729        resources.insert("banner".to_string(), Resource {
730            val: Some(ResourceValue::Binary(banner_data.clone())),
731            mime: Some("image/jpeg".to_string()),
732        });
733
734        // Create a complete V1 zonefile
735        let zonefile = ZoneFile::V1 {
736            default: Some("larry.btc".to_string()),
737            id: None,
738            resources: Some(resources),
739            wallets: Some(wallets),
740            dns: None,
741        };
742
743        // Serialize to JSON
744        let json = serde_json::to_string_pretty(&zonefile).unwrap();
745        debug!("Complete V1 zonefile JSON:\n{}", json);
746
747        // Verify specific parts of the serialized output
748        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
749        
750        // Check version
751        assert_eq!(parsed["v"], "1");
752        
753        // Check default name
754        assert_eq!(parsed["default"], "larry.btc");
755        
756        // Check wallet IDs are numbers, not strings (except lightning)
757        assert_eq!(parsed["wallets"]["0"], "bc1qxxx...");  // Bitcoin
758        assert_eq!(parsed["wallets"]["5757"], "SP2JXKMSH007NPYAQHKJPQMAQYAD90NQGTVJVQ02B");  // Stacks
759        assert_eq!(parsed["wallets"]["501"], "DxPv2QMA5cWR5Xj7BHt8J9LZkxGGx8DDsicafbLnGm9R");  // Solana
760        assert_eq!(parsed["wallets"]["60"], "0x123...");  // Ethereum
761        assert_eq!(parsed["wallets"]["lightning"], "larry@getalby.com");  // Lightning
762
763        // Check resources
764        assert_eq!(parsed["resources"]["avatar"]["mime"], "image/png");
765        assert_eq!(parsed["resources"]["banner"]["mime"], "image/jpeg");
766
767        // Test deserialization
768        let deserialized: ZoneFile = serde_json::from_str(&json).unwrap();
769        match deserialized {
770            ZoneFile::V1 { default, id, resources, wallets, dns } => {
771                assert_eq!(default, Some("larry.btc".to_string()));
772                assert_eq!(id, None);
773                assert_eq!(dns, None);
774                
775                // Check wallets deserialized correctly
776                let wallets = wallets.unwrap();
777                assert_eq!(wallets.get(&WalletType::bitcoin()).unwrap(), "bc1qxxx...");
778                assert_eq!(wallets.get(&WalletType::stacks()).unwrap(), "SP2JXKMSH007NPYAQHKJPQMAQYAD90NQGTVJVQ02B");
779                assert_eq!(wallets.get(&WalletType::solana()).unwrap(), "DxPv2QMA5cWR5Xj7BHt8J9LZkxGGx8DDsicafbLnGm9R");
780                assert_eq!(wallets.get(&WalletType::ethereum()).unwrap(), "0x123...");
781                assert_eq!(wallets.get(&WalletType::lightning()).unwrap(), "larry@getalby.com");
782
783                // Check resources deserialized correctly
784                let resources = resources.unwrap();
785                match &resources.get("avatar").unwrap().val {
786                    Some(ResourceValue::Binary(data)) => assert_eq!(data, &avatar_data),
787                    _ => panic!("Expected Binary resource value for avatar"),
788                }
789                assert_eq!(resources.get("avatar").unwrap().mime, Some("image/png".to_string()));
790
791                match &resources.get("banner").unwrap().val {
792                    Some(ResourceValue::Binary(data)) => assert_eq!(data, &banner_data),
793                    _ => panic!("Expected Binary resource value for banner"),
794                }
795                assert_eq!(resources.get("banner").unwrap().mime, Some("image/jpeg".to_string()));
796            },
797            _ => panic!("Expected V1 zonefile"),
798        }
799    }
800
801    #[test]
802    fn test_zonefile_v1_with_dns() {
803        setup();
804        // Test with Some(zone)
805        let zonefile = ZoneFile::V1 {
806            default: Some("example.btc".to_string()),
807            id: None,
808            resources: None,
809            wallets: None,
810            dns: Some(Dns {
811                zone: Some("example.com".to_string()),
812            }),
813        };
814
815        // Test serialization
816        let json = serde_json::to_string_pretty(&zonefile).unwrap();
817        debug!("V1 zonefile with DNS JSON:\n{}", json);
818
819        // Verify JSON structure
820        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
821        assert_eq!(parsed["v"], "1");
822        assert_eq!(parsed["default"], "example.btc");
823        assert_eq!(parsed["dns"], "example.com");
824
825        // Test with None zone
826        let zonefile = ZoneFile::V1 {
827            default: Some("example.btc".to_string()),
828            id: None,
829            resources: None,
830            wallets: None,
831            dns: Some(Dns { zone: None }),
832        };
833
834        let json = serde_json::to_string_pretty(&zonefile).unwrap();
835        debug!("V1 zonefile with empty DNS JSON:\n{}", json);
836
837        // Verify DNS field is omitted when zone is None
838        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
839        assert!(!parsed.as_object().unwrap().contains_key("dns"));
840
841        // Test with None dns
842        let zonefile = ZoneFile::V1 {
843            default: Some("example.btc".to_string()),
844            id: None,
845            resources: None,
846            wallets: None,
847            dns: None,
848        };
849
850        let json = serde_json::to_string_pretty(&zonefile).unwrap();
851        debug!("V1 zonefile without DNS JSON:\n{}", json);
852
853        // Verify DNS field is omitted when dns is None
854        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
855        assert!(!parsed.as_object().unwrap().contains_key("dns"));
856
857        // Test CBOR roundtrip with DNS
858        let zonefile = ZoneFile::V1 {
859            default: Some("example.btc".to_string()),
860            id: None,
861            resources: None,
862            wallets: None,
863            dns: Some(Dns {
864                zone: Some("example.com".to_string()),
865            }),
866        };
867
868        let cbor_bytes = zonefile.to_cbor().unwrap();
869        let parsed_zonefile = ZoneFile::from_cbor(&cbor_bytes).unwrap();
870
871        match parsed_zonefile {
872            ZoneFile::V1 { dns: Some(dns), .. } => {
873                assert_eq!(dns.zone, Some("example.com".to_string()));
874            },
875            _ => panic!("Expected V1 zonefile with DNS"),
876        }
877    }
878}