use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use ciborium;
use zstd;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SubDomain {
pub owner: String,
pub general: String,
pub twitter: String,
pub url: String,
pub nostr: String,
pub lightning: String,
pub btc: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "v")]
pub enum ZoneFile {
#[serde(rename = "0")]
V0 {
owner: String,
general: String,
twitter: String,
url: String,
nostr: String,
lightning: String,
btc: String,
subdomains: HashMap<String, SubDomain>,
},
#[serde(rename = "1")]
V1 {
default: String,
}
}
impl ZoneFile {
pub fn from_str(zonefile_str: &str) -> Result<Self, serde_json::Error> {
if let Ok(v1) = serde_json::from_str::<Self>(zonefile_str) {
return Ok(v1);
}
let legacy: Result<LegacyZoneFile, _> = serde_json::from_str(zonefile_str);
match legacy {
Ok(v0) => {
let v0_json = serde_json::json!({
"v": "0",
"owner": v0.owner,
"general": v0.general,
"twitter": v0.twitter,
"url": v0.url,
"nostr": v0.nostr,
"lightning": v0.lightning,
"btc": v0.btc,
"subdomains": v0.subdomains,
});
serde_json::from_value(v0_json)
},
Err(e) => Err(e)
}
}
pub fn to_string(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn new_v1(default: String) -> Self {
ZoneFile::V1 { default }
}
pub fn new_v0(
owner: String,
general: String,
twitter: String,
url: String,
nostr: String,
lightning: String,
btc: String,
subdomains: HashMap<String, SubDomain>,
) -> Self {
ZoneFile::V0 {
owner,
general,
twitter,
url,
nostr,
lightning,
btc,
subdomains,
}
}
pub fn from_clarity_buffer_hex_string(hex_string: &str) -> Result<Self, serde_json::Error> {
let bytes = ZoneFile::clarity_buffer_to_uint8_array(hex_string);
let zonefile = ZoneFile::from_bytes(&bytes)?;
Ok(zonefile)
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
let decompressed_result = zstd::decode_all(bytes);
let processed_bytes = match decompressed_result {
Ok(decompressed) => decompressed,
Err(_) => bytes.to_vec(), };
match ZoneFile::from_cbor(&processed_bytes) {
Ok(zonefile) => Ok(zonefile),
Err(_) => {
match std::str::from_utf8(&processed_bytes) {
Ok(str_data) => ZoneFile::from_str(str_data),
Err(e) => Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Invalid UTF-8: {}", e)
)))
}
}
}
}
pub fn from_cbor(cbor_bytes: &[u8]) -> Result<Self, ciborium::de::Error<std::io::Error>> {
ciborium::de::from_reader(cbor_bytes)
}
pub fn to_cbor(&self) -> Result<Vec<u8>, ciborium::ser::Error<std::io::Error>> {
let mut bytes = Vec::new();
ciborium::ser::into_writer(self, &mut bytes)?;
Ok(bytes)
}
pub fn to_compressed_cbor(&self) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let cbor_bytes = self.to_cbor()?;
let compressed = zstd::encode_all(&cbor_bytes[..], 22)?;
Ok(compressed)
}
fn clarity_buffer_to_uint8_array(hex_string: &str) -> Vec<u8> {
let hex = hex_string.strip_prefix("0x").unwrap_or(hex_string);
(0..hex.len())
.step_by(2)
.filter_map(|i| {
if i + 2 <= hex.len() {
u8::from_str_radix(&hex[i..i + 2], 16).ok()
} else {
None
}
})
.collect()
}
}
#[derive(Debug, Serialize, Deserialize)]
struct LegacyZoneFile {
owner: String,
general: String,
twitter: String,
url: String,
nostr: String,
lightning: String,
btc: String,
subdomains: HashMap<String, SubDomain>,
}
#[cfg(feature = "wasm")]
pub mod wasm {
use super::*;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn parse_zonefile_from_clarity_buffer_hex_string(hex_string: &str) -> Result<Vec<u8>, JsValue> {
ZoneFile::from_clarity_buffer_hex_string(hex_string)
.and_then(|zf| zf.to_cbor().map_err(|e| serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string()
))))
.map_err(|e| JsValue::from_str(&e.to_string()))
}
#[wasm_bindgen(js_name = "generate_zonefile")]
pub fn generate_zonefile(val: JsValue, compress: bool) -> Result<Vec<u8>, JsValue> {
let zonefile: ZoneFile = serde_wasm_bindgen::from_value(val)
.map_err(|e| JsValue::from_str(&format!("Failed to parse zonefile: {}", e)))?;
if compress {
zonefile.to_compressed_cbor()
.map_err(|e| JsValue::from_str(&format!("Failed to compress zonefile: {}", e)))
} else {
zonefile.to_cbor()
.map_err(|e| JsValue::from_str(&format!("Failed to generate CBOR: {}", e)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_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"}}}"#;
const SAMPLE_ZONEFILE_EMPTY_SUBDOMAINS: &str = r#"{"owner":"SP3D03X5BHMNSAAW71NN7BQRMV4DW2G4JB3MZAGJ8","general":"Pato Gómez","twitter":"@setpato","url":"pato.locker","nostr":"","lightning":"","btc":"bc1q4x98c6gwjj34lqs97zdvfyv2j0fcjhhw7hj0pc","subdomains":{}}"#;
const SAMPLE_ZONEFILE_HEX_STRING_LARRY_ID: &str = "7b226f776e6572223a2253503252374358454244474248374d374b3052484347514750593659364b524a314d37444541414d57222c2267656e6572616c223a22222c2274776974746572223a226c6172727973616c69627261222c2275726c223a22222c226e6f737472223a22222c226c696768746e696e67223a22222c22627463223a22222c22737562646f6d61696e73223a7b7d7d";
#[test]
fn test_parse_zonefile_valid() {
let result = ZoneFile::from_str(SAMPLE_ZONEFILE);
assert!(result.is_ok());
let zonefile = result.unwrap();
match zonefile {
ZoneFile::V0 { owner, general, twitter, .. } => {
assert_eq!(&owner, "SP3D03X5BHMNSAAW71NN7BQRMV4DW2G4JB3MZAGJ8");
assert_eq!(&general, "Pato Gómez");
assert_eq!(&twitter, "@setpato");
},
_ => panic!("Expected V0 zonefile"),
}
}
#[test]
fn test_parse_zonefile_invalid() {
let result = ZoneFile::from_str("{invalid json}");
assert!(result.is_err());
}
#[test]
fn test_zonefile_roundtrip() {
let zonefile = ZoneFile::from_str(SAMPLE_ZONEFILE).unwrap();
let json = zonefile.to_string().unwrap();
let parsed_again = ZoneFile::from_str(&json).unwrap();
match (&zonefile, &parsed_again) {
(ZoneFile::V0 { owner: orig_owner, general: orig_general, subdomains: orig_subdomains, .. },
ZoneFile::V0 { owner: parsed_owner, general: parsed_general, subdomains: parsed_subdomains, .. }) => {
assert_eq!(orig_owner, parsed_owner);
assert_eq!(orig_general, parsed_general);
let orig_subdomain = orig_subdomains.get("test").unwrap();
let parsed_subdomain = parsed_subdomains.get("test").unwrap();
assert_eq!(orig_subdomain.general, parsed_subdomain.general);
assert_eq!(orig_subdomain.twitter, parsed_subdomain.twitter);
},
_ => panic!("Expected V0 zonefile"),
}
}
#[test]
fn test_cbor_roundtrip() {
let zonefile = ZoneFile::from_str(SAMPLE_ZONEFILE).unwrap();
let cbor_bytes = zonefile.to_cbor().unwrap();
let parsed_from_cbor = ZoneFile::from_bytes(&cbor_bytes).unwrap();
match (&zonefile, &parsed_from_cbor) {
(ZoneFile::V0 { owner: orig_owner, .. }, ZoneFile::V0 { owner: parsed_owner, .. }) => {
assert_eq!(orig_owner, parsed_owner);
},
_ => panic!("Expected V0 zonefile"),
}
let compressed_bytes = zonefile.to_compressed_cbor().unwrap();
let parsed_from_compressed = ZoneFile::from_bytes(&compressed_bytes).unwrap();
match (&zonefile, &parsed_from_compressed) {
(ZoneFile::V0 { owner: orig_owner, .. }, ZoneFile::V0 { owner: parsed_owner, .. }) => {
assert_eq!(orig_owner, parsed_owner);
},
_ => panic!("Expected V0 zonefile"),
}
assert!(compressed_bytes.len() < cbor_bytes.len());
println!("CBOR size: {}, Compressed size: {}", cbor_bytes.len(), compressed_bytes.len());
}
#[test]
fn test_cbor_invalid_input() {
assert!(ZoneFile::from_cbor(&[]).is_err());
assert!(ZoneFile::from_cbor(&[0xFF, 0xFF, 0xFF]).is_err());
}
#[test]
fn test_cbor_size() {
let zonefile = ZoneFile::from_str(SAMPLE_ZONEFILE).unwrap();
let json_size = SAMPLE_ZONEFILE.len();
let cbor_size = zonefile.to_cbor().unwrap().len();
assert!(cbor_size < json_size);
println!("JSON size: {}, CBOR size: {}", json_size, cbor_size);
}
#[test]
fn test_cbor_empty_fields() {
let zonefile = ZoneFile::from_str(SAMPLE_ZONEFILE_EMPTY_SUBDOMAINS).unwrap();
let cbor_bytes = zonefile.to_cbor().unwrap();
let parsed_zonefile = ZoneFile::from_cbor(&cbor_bytes).unwrap();
match parsed_zonefile {
ZoneFile::V0 { nostr, lightning, subdomains, .. } => {
assert_eq!(nostr, "");
assert_eq!(lightning, "");
assert!(subdomains.is_empty());
},
_ => panic!("Expected V0 zonefile"),
}
}
#[test]
fn test_clarity_buffer_conversion() {
let hex = "0x5361746f736869"; let bytes = ZoneFile::clarity_buffer_to_uint8_array(hex);
assert_eq!(bytes, b"Satoshi");
let hex = "5361746f736869"; let bytes = ZoneFile::clarity_buffer_to_uint8_array(hex);
assert_eq!(bytes, b"Satoshi");
let hex = "";
let bytes = ZoneFile::clarity_buffer_to_uint8_array(hex);
assert_eq!(bytes, Vec::<u8>::new());
let hex = "5361746f73686"; let bytes = ZoneFile::clarity_buffer_to_uint8_array(hex);
assert_eq!(bytes, vec![0x53, 0x61, 0x74, 0x6f, 0x73, 0x68]); let hex = "0xZZ";
let bytes = ZoneFile::clarity_buffer_to_uint8_array(hex);
assert_eq!(bytes, Vec::<u8>::new()); }
#[test]
fn test_from_clarity_buffer_hex_string() {
let hex = format!("0x{}", hex::encode(SAMPLE_ZONEFILE));
println!("Hex: {}", hex);
let result = ZoneFile::from_clarity_buffer_hex_string(&hex);
assert!(result.is_ok());
let zonefile = result.unwrap();
match zonefile {
ZoneFile::V0 { owner, general, .. } => {
assert_eq!(&owner, "SP3D03X5BHMNSAAW71NN7BQRMV4DW2G4JB3MZAGJ8");
assert_eq!(&general, "Pato Gómez");
},
_ => panic!("Expected V0 zonefile"),
}
let result = ZoneFile::from_clarity_buffer_hex_string("0xZZ");
assert!(result.is_err());
let result = ZoneFile::from_clarity_buffer_hex_string("");
assert!(result.is_err());
}
#[test]
fn test_parse_hex_string_sample() {
let result = ZoneFile::from_clarity_buffer_hex_string(SAMPLE_ZONEFILE_HEX_STRING_LARRY_ID);
assert!(result.is_ok());
let zonefile = result.unwrap();
match zonefile {
ZoneFile::V0 { owner, general, twitter, url, nostr, lightning, btc, subdomains } => {
assert_eq!(owner, "SP2R7CXEBDGBH7M7K0RHCGQGPY6Y6KRJ1M7DEAAMW");
assert_eq!(general, "");
assert_eq!(twitter, "larrysalibra");
assert_eq!(url, "");
assert_eq!(nostr, "");
assert_eq!(lightning, "");
assert_eq!(btc, "");
assert!(subdomains.is_empty());
},
_ => panic!("Expected V0 zonefile"),
}
}
#[test]
fn test_version_handling() {
let json = r#"{"owner":"SP123","general":"Test","twitter":"@test","url":"test.url","nostr":"","lightning":"","btc":"","subdomains":{}}"#;
let zonefile = ZoneFile::from_str(json).unwrap();
let serialized = zonefile.to_string().unwrap();
println!("Serialized V0 from legacy: {}", serialized);
assert!(serialized.contains(r#""v":"0""#), "Serialized output should contain version tag");
let json_v0 = r#"{"v":"0","owner":"SP123","general":"Test","twitter":"@test","url":"test.url","nostr":"","lightning":"","btc":"","subdomains":{}}"#;
let zonefile = ZoneFile::from_str(json_v0).unwrap();
let serialized = zonefile.to_string().unwrap();
println!("Serialized V0 from explicit: {}", serialized);
assert!(serialized.contains(r#""v":"0""#), "Serialized output should contain version tag");
let cbor_bytes = zonefile.to_cbor().unwrap();
let parsed_from_cbor = ZoneFile::from_cbor(&cbor_bytes).unwrap();
let serialized_from_cbor = parsed_from_cbor.to_string().unwrap();
println!("Serialized from CBOR: {}", serialized_from_cbor);
assert!(serialized_from_cbor.contains(r#""v":"0""#), "CBOR roundtrip should preserve version tag");
let json_v1 = r#"{"v":"1","default":"test.btc"}"#;
let zonefile = ZoneFile::from_str(json_v1).unwrap();
match zonefile {
ZoneFile::V1 { default } => assert_eq!(default, "test.btc"),
_ => panic!("Expected V1 zonefile"),
}
let v1 = ZoneFile::new_v1("test.btc".to_string());
let serialized = v1.to_string().unwrap();
println!("Serialized V1: {}", serialized);
assert!(serialized.contains(r#""v":"1""#), "Serialized V1 output should contain version tag");
let v0 = ZoneFile::new_v0(
"SP123".to_string(),
"Test".to_string(),
"@test".to_string(),
"test.url".to_string(),
"".to_string(),
"".to_string(),
"".to_string(),
HashMap::new(),
);
let serialized = v0.to_string().unwrap();
println!("Serialized V0 from new: {}", serialized);
assert!(serialized.contains(r#""v":"0""#), "Serialized V0 output should contain version tag");
}
#[test]
fn test_version_tag_simple() {
let v0 = ZoneFile::new_v0(
"SP123".to_string(),
"Test".to_string(),
"@test".to_string(),
"test.url".to_string(),
"".to_string(),
"".to_string(),
"".to_string(),
HashMap::new(),
);
let serialized = serde_json::to_string(&v0).unwrap();
println!("Direct V0 serialization: {}", serialized);
assert!(serialized.contains(r#""v":"0""#), "Direct V0 serialization should contain version tag");
let v1 = ZoneFile::new_v1("test.btc".to_string());
let serialized = serde_json::to_string(&v1).unwrap();
println!("Direct V1 serialization: {}", serialized);
assert!(serialized.contains(r#""v":"1""#), "Direct V1 serialization should contain version tag");
}
}