hashtree_core/
nhash.rs

1//! Bech32-encoded identifiers for hashtree content
2//!
3//! Similar to nostr's nip19 (npub, nprofile, nevent, naddr),
4//! provides human-readable, copy-pasteable identifiers.
5//!
6//! Types:
7//! - nhash: Permalink (hash + optional path + optional decrypt key)
8//! - nref: Live reference (pubkey + tree + optional path + optional decrypt key)
9
10use crate::types::Hash;
11use thiserror::Error;
12
13/// TLV type constants
14mod tlv {
15    /// 32-byte hash (required for nhash)
16    pub const HASH: u8 = 0;
17    /// 32-byte nostr pubkey (required for nref)
18    pub const PUBKEY: u8 = 2;
19    /// UTF-8 tree name (required for nref)
20    pub const TREE_NAME: u8 = 3;
21    /// UTF-8 path segment (can appear multiple times, in order)
22    pub const PATH: u8 = 4;
23    /// 32-byte decryption key (optional)
24    pub const DECRYPT_KEY: u8 = 5;
25}
26
27/// Errors for nhash/npath encoding/decoding
28#[derive(Debug, Error)]
29pub enum NHashError {
30    #[error("Bech32 error: {0}")]
31    Bech32(String),
32    #[error("Invalid prefix: expected {expected}, got {got}")]
33    InvalidPrefix { expected: String, got: String },
34    #[error("Invalid hash length: expected 32 bytes, got {0}")]
35    InvalidHashLength(usize),
36    #[error("Invalid key length: expected 32 bytes, got {0}")]
37    InvalidKeyLength(usize),
38    #[error("Invalid pubkey length: expected 32 bytes, got {0}")]
39    InvalidPubkeyLength(usize),
40    #[error("Missing required field: {0}")]
41    MissingField(String),
42    #[error("TLV error: {0}")]
43    TlvError(String),
44    #[error("UTF-8 error: {0}")]
45    Utf8Error(#[from] std::string::FromUtf8Error),
46    #[error("Hex error: {0}")]
47    HexError(#[from] hex::FromHexError),
48}
49
50/// NHash data - permalink to content by hash
51#[derive(Debug, Clone, PartialEq)]
52pub struct NHashData {
53    /// 32-byte merkle hash
54    pub hash: Hash,
55    /// Path segments (optional, e.g., ["folder", "file.txt"])
56    pub path: Vec<String>,
57    /// 32-byte decryption key (optional)
58    pub decrypt_key: Option<[u8; 32]>,
59}
60
61/// NRef data - live reference via pubkey + tree + path
62#[derive(Debug, Clone, PartialEq)]
63pub struct NRefData {
64    /// 32-byte nostr pubkey
65    pub pubkey: [u8; 32],
66    /// Tree name (e.g., "home", "photos")
67    pub tree_name: String,
68    /// Path segments within the tree (optional, e.g., ["folder", "file.txt"])
69    pub path: Vec<String>,
70    /// 32-byte decryption key (optional)
71    pub decrypt_key: Option<[u8; 32]>,
72}
73
74/// Decode result
75#[derive(Debug, Clone, PartialEq)]
76pub enum DecodeResult {
77    NHash(NHashData),
78    NRef(NRefData),
79}
80
81/// Parse TLV-encoded data into a map of type -> values
82fn parse_tlv(data: &[u8]) -> Result<std::collections::HashMap<u8, Vec<Vec<u8>>>, NHashError> {
83    let mut result: std::collections::HashMap<u8, Vec<Vec<u8>>> = std::collections::HashMap::new();
84    let mut offset = 0;
85
86    while offset < data.len() {
87        if offset + 2 > data.len() {
88            return Err(NHashError::TlvError("unexpected end of data".into()));
89        }
90        let t = data[offset];
91        let l = data[offset + 1] as usize;
92        offset += 2;
93
94        if offset + l > data.len() {
95            return Err(NHashError::TlvError(format!(
96                "not enough data for type {}, need {} bytes",
97                t, l
98            )));
99        }
100        let v = data[offset..offset + l].to_vec();
101        offset += l;
102
103        result.entry(t).or_default().push(v);
104    }
105
106    Ok(result)
107}
108
109/// Encode TLV data to bytes
110fn encode_tlv(tlv: &std::collections::HashMap<u8, Vec<Vec<u8>>>) -> Result<Vec<u8>, NHashError> {
111    let mut entries: Vec<u8> = Vec::new();
112
113    // Process in ascending key order for consistent encoding
114    let mut keys: Vec<u8> = tlv.keys().copied().collect();
115    keys.sort();
116
117    for t in keys {
118        if let Some(values) = tlv.get(&t) {
119            for v in values {
120                if v.len() > 255 {
121                    return Err(NHashError::TlvError(format!(
122                        "value too long for type {}: {} bytes",
123                        t,
124                        v.len()
125                    )));
126                }
127                entries.push(t);
128                entries.push(v.len() as u8);
129                entries.extend_from_slice(v);
130            }
131        }
132    }
133
134    Ok(entries)
135}
136
137/// Encode bech32 with given prefix and data
138/// Uses regular bech32 (not bech32m) for compatibility with nostr nip19
139fn encode_bech32(hrp: &str, data: &[u8]) -> Result<String, NHashError> {
140    use bech32::{Bech32, Hrp};
141
142    let hrp = Hrp::parse(hrp).map_err(|e| NHashError::Bech32(e.to_string()))?;
143    bech32::encode::<Bech32>(hrp, data)
144        .map_err(|e| NHashError::Bech32(e.to_string()))
145}
146
147/// Decode bech32 and return (hrp, data)
148fn decode_bech32(s: &str) -> Result<(String, Vec<u8>), NHashError> {
149    let (hrp, data) = bech32::decode(s)
150        .map_err(|e| NHashError::Bech32(e.to_string()))?;
151
152    Ok((hrp.to_string(), data))
153}
154
155// ============================================================================
156// nhash - Permalink (hash + optional path + optional decrypt key)
157// ============================================================================
158
159/// Encode an nhash permalink from just a hash
160pub fn nhash_encode(hash: &Hash) -> Result<String, NHashError> {
161    encode_bech32("nhash", hash)
162}
163
164/// Encode an nhash permalink with optional path and decrypt key
165pub fn nhash_encode_full(data: &NHashData) -> Result<String, NHashError> {
166    // No path or decrypt key - simple encoding (just the hash bytes)
167    if data.path.is_empty() && data.decrypt_key.is_none() {
168        return encode_bech32("nhash", &data.hash);
169    }
170
171    // Has path or decrypt key - use TLV
172    let mut tlv: std::collections::HashMap<u8, Vec<Vec<u8>>> = std::collections::HashMap::new();
173    tlv.insert(tlv::HASH, vec![data.hash.to_vec()]);
174
175    if !data.path.is_empty() {
176        tlv.insert(
177            tlv::PATH,
178            data.path.iter().map(|p| p.as_bytes().to_vec()).collect(),
179        );
180    }
181
182    if let Some(key) = &data.decrypt_key {
183        tlv.insert(tlv::DECRYPT_KEY, vec![key.to_vec()]);
184    }
185
186    encode_bech32("nhash", &encode_tlv(&tlv)?)
187}
188
189/// Decode an nhash string
190pub fn nhash_decode(code: &str) -> Result<NHashData, NHashError> {
191    // Strip optional prefix
192    let code = code.strip_prefix("hashtree:").unwrap_or(code);
193
194    let (prefix, data) = decode_bech32(code)?;
195
196    if prefix != "nhash" {
197        return Err(NHashError::InvalidPrefix {
198            expected: "nhash".into(),
199            got: prefix,
200        });
201    }
202
203    // Simple 32-byte hash (no TLV)
204    if data.len() == 32 {
205        let mut hash = [0u8; 32];
206        hash.copy_from_slice(&data);
207        return Ok(NHashData {
208            hash,
209            path: Vec::new(),
210            decrypt_key: None,
211        });
212    }
213
214    // Parse TLV
215    let tlv = parse_tlv(&data)?;
216
217    let hash_bytes = tlv
218        .get(&tlv::HASH)
219        .and_then(|v| v.first())
220        .ok_or_else(|| NHashError::MissingField("hash".into()))?;
221
222    if hash_bytes.len() != 32 {
223        return Err(NHashError::InvalidHashLength(hash_bytes.len()));
224    }
225
226    let mut hash = [0u8; 32];
227    hash.copy_from_slice(hash_bytes);
228
229    // Path segments
230    let path = if let Some(paths) = tlv.get(&tlv::PATH) {
231        paths
232            .iter()
233            .map(|p| String::from_utf8(p.clone()))
234            .collect::<Result<Vec<_>, _>>()?
235    } else {
236        Vec::new()
237    };
238
239    let decrypt_key = if let Some(keys) = tlv.get(&tlv::DECRYPT_KEY) {
240        if let Some(key_bytes) = keys.first() {
241            if key_bytes.len() != 32 {
242                return Err(NHashError::InvalidKeyLength(key_bytes.len()));
243            }
244            let mut key = [0u8; 32];
245            key.copy_from_slice(key_bytes);
246            Some(key)
247        } else {
248            None
249        }
250    } else {
251        None
252    };
253
254    Ok(NHashData { hash, path, decrypt_key })
255}
256
257// ============================================================================
258// nref - Live reference (pubkey + tree + optional path + optional decrypt key)
259// ============================================================================
260
261/// Encode an nref live reference
262pub fn nref_encode(data: &NRefData) -> Result<String, NHashError> {
263    let mut tlv: std::collections::HashMap<u8, Vec<Vec<u8>>> = std::collections::HashMap::new();
264
265    tlv.insert(tlv::PUBKEY, vec![data.pubkey.to_vec()]);
266    tlv.insert(tlv::TREE_NAME, vec![data.tree_name.as_bytes().to_vec()]);
267
268    if !data.path.is_empty() {
269        tlv.insert(
270            tlv::PATH,
271            data.path.iter().map(|p| p.as_bytes().to_vec()).collect(),
272        );
273    }
274
275    if let Some(key) = &data.decrypt_key {
276        tlv.insert(tlv::DECRYPT_KEY, vec![key.to_vec()]);
277    }
278
279    encode_bech32("nref", &encode_tlv(&tlv)?)
280}
281
282/// Decode an nref string
283pub fn nref_decode(code: &str) -> Result<NRefData, NHashError> {
284    // Strip optional prefix
285    let code = code.strip_prefix("hashtree:").unwrap_or(code);
286
287    let (prefix, data) = decode_bech32(code)?;
288
289    if prefix != "nref" {
290        return Err(NHashError::InvalidPrefix {
291            expected: "nref".into(),
292            got: prefix,
293        });
294    }
295
296    let tlv = parse_tlv(&data)?;
297
298    // Pubkey
299    let pubkey_bytes = tlv
300        .get(&tlv::PUBKEY)
301        .and_then(|v| v.first())
302        .ok_or_else(|| NHashError::MissingField("pubkey".into()))?;
303
304    if pubkey_bytes.len() != 32 {
305        return Err(NHashError::InvalidPubkeyLength(pubkey_bytes.len()));
306    }
307
308    let mut pubkey = [0u8; 32];
309    pubkey.copy_from_slice(pubkey_bytes);
310
311    // Tree name
312    let tree_name_bytes = tlv
313        .get(&tlv::TREE_NAME)
314        .and_then(|v| v.first())
315        .ok_or_else(|| NHashError::MissingField("tree_name".into()))?;
316
317    let tree_name = String::from_utf8(tree_name_bytes.clone())?;
318
319    // Path segments
320    let path = if let Some(paths) = tlv.get(&tlv::PATH) {
321        paths
322            .iter()
323            .map(|p| String::from_utf8(p.clone()))
324            .collect::<Result<Vec<_>, _>>()?
325    } else {
326        Vec::new()
327    };
328
329    // Decrypt key
330    let decrypt_key = if let Some(keys) = tlv.get(&tlv::DECRYPT_KEY) {
331        if let Some(key_bytes) = keys.first() {
332            if key_bytes.len() != 32 {
333                return Err(NHashError::InvalidKeyLength(key_bytes.len()));
334            }
335            let mut key = [0u8; 32];
336            key.copy_from_slice(key_bytes);
337            Some(key)
338        } else {
339            None
340        }
341    } else {
342        None
343    };
344
345    Ok(NRefData {
346        pubkey,
347        tree_name,
348        path,
349        decrypt_key,
350    })
351}
352
353// ============================================================================
354// Generic decode
355// ============================================================================
356
357/// Decode any nhash or nref string
358pub fn decode(code: &str) -> Result<DecodeResult, NHashError> {
359    let code = code.strip_prefix("hashtree:").unwrap_or(code);
360
361    if code.starts_with("nhash1") {
362        return Ok(DecodeResult::NHash(nhash_decode(code)?));
363    }
364    if code.starts_with("nref1") {
365        return Ok(DecodeResult::NRef(nref_decode(code)?));
366    }
367
368    Err(NHashError::InvalidPrefix {
369        expected: "nhash1 or nref1".into(),
370        got: code.chars().take(10).collect(),
371    })
372}
373
374// ============================================================================
375// Type guards
376// ============================================================================
377
378/// Check if a string is an nhash
379pub fn is_nhash(value: &str) -> bool {
380    value.starts_with("nhash1")
381}
382
383/// Check if a string is an nref
384pub fn is_nref(value: &str) -> bool {
385    value.starts_with("nref1")
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn test_nhash_simple() {
394        let hash: Hash = [
395            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
396            0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
397            0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
398            0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
399        ];
400
401        let encoded = nhash_encode(&hash).unwrap();
402        assert!(encoded.starts_with("nhash1"));
403
404        let decoded = nhash_decode(&encoded).unwrap();
405        assert_eq!(decoded.hash, hash);
406        assert!(decoded.path.is_empty());
407        assert!(decoded.decrypt_key.is_none());
408    }
409
410    #[test]
411    fn test_nhash_with_path() {
412        let hash: Hash = [0xaa; 32];
413
414        let data = NHashData {
415            hash,
416            path: vec!["folder".into(), "file.txt".into()],
417            decrypt_key: None,
418        };
419
420        let encoded = nhash_encode_full(&data).unwrap();
421        assert!(encoded.starts_with("nhash1"));
422
423        let decoded = nhash_decode(&encoded).unwrap();
424        assert_eq!(decoded.hash, hash);
425        assert_eq!(decoded.path, vec!["folder", "file.txt"]);
426        assert!(decoded.decrypt_key.is_none());
427    }
428
429    #[test]
430    fn test_nhash_with_key() {
431        let hash: Hash = [0xaa; 32];
432        let key: [u8; 32] = [0xbb; 32];
433
434        let data = NHashData {
435            hash,
436            path: vec![],
437            decrypt_key: Some(key),
438        };
439
440        let encoded = nhash_encode_full(&data).unwrap();
441        assert!(encoded.starts_with("nhash1"));
442
443        let decoded = nhash_decode(&encoded).unwrap();
444        assert_eq!(decoded.hash, hash);
445        assert!(decoded.path.is_empty());
446        assert_eq!(decoded.decrypt_key, Some(key));
447    }
448
449    #[test]
450    fn test_nhash_with_path_and_key() {
451        let hash: Hash = [0xaa; 32];
452        let key: [u8; 32] = [0xbb; 32];
453
454        let data = NHashData {
455            hash,
456            path: vec!["docs".into()],
457            decrypt_key: Some(key),
458        };
459
460        let encoded = nhash_encode_full(&data).unwrap();
461        let decoded = nhash_decode(&encoded).unwrap();
462        assert_eq!(decoded.hash, hash);
463        assert_eq!(decoded.path, vec!["docs"]);
464        assert_eq!(decoded.decrypt_key, Some(key));
465    }
466
467    #[test]
468    fn test_nref_simple() {
469        let pubkey: [u8; 32] = [0xcc; 32];
470        let data = NRefData {
471            pubkey,
472            tree_name: "home".into(),
473            path: vec![],
474            decrypt_key: None,
475        };
476
477        let encoded = nref_encode(&data).unwrap();
478        assert!(encoded.starts_with("nref1"));
479
480        let decoded = nref_decode(&encoded).unwrap();
481        assert_eq!(decoded.pubkey, pubkey);
482        assert_eq!(decoded.tree_name, "home");
483        assert!(decoded.path.is_empty());
484        assert!(decoded.decrypt_key.is_none());
485    }
486
487    #[test]
488    fn test_nref_with_path_and_key() {
489        let pubkey: [u8; 32] = [0xdd; 32];
490        let key: [u8; 32] = [0xee; 32];
491
492        let data = NRefData {
493            pubkey,
494            tree_name: "photos".into(),
495            path: vec!["vacation".into(), "beach.jpg".into()],
496            decrypt_key: Some(key),
497        };
498
499        let encoded = nref_encode(&data).unwrap();
500        assert!(encoded.starts_with("nref1"));
501
502        let decoded = nref_decode(&encoded).unwrap();
503        assert_eq!(decoded.pubkey, pubkey);
504        assert_eq!(decoded.tree_name, "photos");
505        assert_eq!(decoded.path, vec!["vacation", "beach.jpg"]);
506        assert_eq!(decoded.decrypt_key, Some(key));
507    }
508
509    #[test]
510    fn test_decode_generic() {
511        let hash: Hash = [0x11; 32];
512        let nhash = nhash_encode(&hash).unwrap();
513
514        match decode(&nhash).unwrap() {
515            DecodeResult::NHash(data) => assert_eq!(data.hash, hash),
516            _ => panic!("expected NHash"),
517        }
518
519        let pubkey: [u8; 32] = [0x22; 32];
520        let nref_data = NRefData {
521            pubkey,
522            tree_name: "test".into(),
523            path: vec![],
524            decrypt_key: None,
525        };
526        let nref = nref_encode(&nref_data).unwrap();
527
528        match decode(&nref).unwrap() {
529            DecodeResult::NRef(data) => {
530                assert_eq!(data.pubkey, pubkey);
531                assert_eq!(data.tree_name, "test");
532            }
533            _ => panic!("expected NRef"),
534        }
535    }
536
537    #[test]
538    fn test_is_nhash() {
539        assert!(is_nhash("nhash1abc"));
540        assert!(!is_nhash("nref1abc"));
541        assert!(!is_nhash("npub1abc"));
542    }
543
544    #[test]
545    fn test_is_nref() {
546        assert!(is_nref("nref1abc"));
547        assert!(!is_nref("nhash1abc"));
548        assert!(!is_nref("npub1abc"));
549    }
550}