Skip to main content

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).map_err(|e| NHashError::Bech32(e.to_string()))
144}
145
146/// Decode bech32 and return (hrp, data)
147fn decode_bech32(s: &str) -> Result<(String, Vec<u8>), NHashError> {
148    let (hrp, data) = bech32::decode(s).map_err(|e| NHashError::Bech32(e.to_string()))?;
149
150    Ok((hrp.to_string(), data))
151}
152
153// ============================================================================
154// nhash - Permalink (hash + optional path + optional decrypt key)
155// ============================================================================
156
157/// Encode an nhash permalink from just a hash
158pub fn nhash_encode(hash: &Hash) -> Result<String, NHashError> {
159    encode_bech32("nhash", hash)
160}
161
162/// Encode an nhash permalink with optional path and decrypt key
163pub fn nhash_encode_full(data: &NHashData) -> Result<String, NHashError> {
164    // No path or decrypt key - simple encoding (just the hash bytes)
165    if data.path.is_empty() && data.decrypt_key.is_none() {
166        return encode_bech32("nhash", &data.hash);
167    }
168
169    // Has path or decrypt key - use TLV
170    let mut tlv: std::collections::HashMap<u8, Vec<Vec<u8>>> = std::collections::HashMap::new();
171    tlv.insert(tlv::HASH, vec![data.hash.to_vec()]);
172
173    if !data.path.is_empty() {
174        tlv.insert(
175            tlv::PATH,
176            data.path.iter().map(|p| p.as_bytes().to_vec()).collect(),
177        );
178    }
179
180    if let Some(key) = &data.decrypt_key {
181        tlv.insert(tlv::DECRYPT_KEY, vec![key.to_vec()]);
182    }
183
184    encode_bech32("nhash", &encode_tlv(&tlv)?)
185}
186
187/// Decode an nhash string
188pub fn nhash_decode(code: &str) -> Result<NHashData, NHashError> {
189    // Strip optional prefix
190    let code = code.strip_prefix("hashtree:").unwrap_or(code);
191
192    let (prefix, data) = decode_bech32(code)?;
193
194    if prefix != "nhash" {
195        return Err(NHashError::InvalidPrefix {
196            expected: "nhash".into(),
197            got: prefix,
198        });
199    }
200
201    // Simple 32-byte hash (no TLV)
202    if data.len() == 32 {
203        let mut hash = [0u8; 32];
204        hash.copy_from_slice(&data);
205        return Ok(NHashData {
206            hash,
207            path: Vec::new(),
208            decrypt_key: None,
209        });
210    }
211
212    // Parse TLV
213    let tlv = parse_tlv(&data)?;
214
215    let hash_bytes = tlv
216        .get(&tlv::HASH)
217        .and_then(|v| v.first())
218        .ok_or_else(|| NHashError::MissingField("hash".into()))?;
219
220    if hash_bytes.len() != 32 {
221        return Err(NHashError::InvalidHashLength(hash_bytes.len()));
222    }
223
224    let mut hash = [0u8; 32];
225    hash.copy_from_slice(hash_bytes);
226
227    // Path segments
228    let path = if let Some(paths) = tlv.get(&tlv::PATH) {
229        paths
230            .iter()
231            .map(|p| String::from_utf8(p.clone()))
232            .collect::<Result<Vec<_>, _>>()?
233    } else {
234        Vec::new()
235    };
236
237    let decrypt_key = if let Some(keys) = tlv.get(&tlv::DECRYPT_KEY) {
238        if let Some(key_bytes) = keys.first() {
239            if key_bytes.len() != 32 {
240                return Err(NHashError::InvalidKeyLength(key_bytes.len()));
241            }
242            let mut key = [0u8; 32];
243            key.copy_from_slice(key_bytes);
244            Some(key)
245        } else {
246            None
247        }
248    } else {
249        None
250    };
251
252    Ok(NHashData {
253        hash,
254        path,
255        decrypt_key,
256    })
257}
258
259// ============================================================================
260// nref - Live reference (pubkey + tree + optional path + optional decrypt key)
261// ============================================================================
262
263/// Encode an nref live reference
264pub fn nref_encode(data: &NRefData) -> Result<String, NHashError> {
265    let mut tlv: std::collections::HashMap<u8, Vec<Vec<u8>>> = std::collections::HashMap::new();
266
267    tlv.insert(tlv::PUBKEY, vec![data.pubkey.to_vec()]);
268    tlv.insert(tlv::TREE_NAME, vec![data.tree_name.as_bytes().to_vec()]);
269
270    if !data.path.is_empty() {
271        tlv.insert(
272            tlv::PATH,
273            data.path.iter().map(|p| p.as_bytes().to_vec()).collect(),
274        );
275    }
276
277    if let Some(key) = &data.decrypt_key {
278        tlv.insert(tlv::DECRYPT_KEY, vec![key.to_vec()]);
279    }
280
281    encode_bech32("nref", &encode_tlv(&tlv)?)
282}
283
284/// Decode an nref string
285pub fn nref_decode(code: &str) -> Result<NRefData, NHashError> {
286    // Strip optional prefix
287    let code = code.strip_prefix("hashtree:").unwrap_or(code);
288
289    let (prefix, data) = decode_bech32(code)?;
290
291    if prefix != "nref" {
292        return Err(NHashError::InvalidPrefix {
293            expected: "nref".into(),
294            got: prefix,
295        });
296    }
297
298    let tlv = parse_tlv(&data)?;
299
300    // Pubkey
301    let pubkey_bytes = tlv
302        .get(&tlv::PUBKEY)
303        .and_then(|v| v.first())
304        .ok_or_else(|| NHashError::MissingField("pubkey".into()))?;
305
306    if pubkey_bytes.len() != 32 {
307        return Err(NHashError::InvalidPubkeyLength(pubkey_bytes.len()));
308    }
309
310    let mut pubkey = [0u8; 32];
311    pubkey.copy_from_slice(pubkey_bytes);
312
313    // Tree name
314    let tree_name_bytes = tlv
315        .get(&tlv::TREE_NAME)
316        .and_then(|v| v.first())
317        .ok_or_else(|| NHashError::MissingField("tree_name".into()))?;
318
319    let tree_name = String::from_utf8(tree_name_bytes.clone())?;
320
321    // Path segments
322    let path = if let Some(paths) = tlv.get(&tlv::PATH) {
323        paths
324            .iter()
325            .map(|p| String::from_utf8(p.clone()))
326            .collect::<Result<Vec<_>, _>>()?
327    } else {
328        Vec::new()
329    };
330
331    // Decrypt key
332    let decrypt_key = if let Some(keys) = tlv.get(&tlv::DECRYPT_KEY) {
333        if let Some(key_bytes) = keys.first() {
334            if key_bytes.len() != 32 {
335                return Err(NHashError::InvalidKeyLength(key_bytes.len()));
336            }
337            let mut key = [0u8; 32];
338            key.copy_from_slice(key_bytes);
339            Some(key)
340        } else {
341            None
342        }
343    } else {
344        None
345    };
346
347    Ok(NRefData {
348        pubkey,
349        tree_name,
350        path,
351        decrypt_key,
352    })
353}
354
355// ============================================================================
356// Generic decode
357// ============================================================================
358
359/// Decode any nhash or nref string
360pub fn decode(code: &str) -> Result<DecodeResult, NHashError> {
361    let code = code.strip_prefix("hashtree:").unwrap_or(code);
362
363    if code.starts_with("nhash1") {
364        return Ok(DecodeResult::NHash(nhash_decode(code)?));
365    }
366    if code.starts_with("nref1") {
367        return Ok(DecodeResult::NRef(nref_decode(code)?));
368    }
369
370    Err(NHashError::InvalidPrefix {
371        expected: "nhash1 or nref1".into(),
372        got: code.chars().take(10).collect(),
373    })
374}
375
376// ============================================================================
377// Type guards
378// ============================================================================
379
380/// Check if a string is an nhash
381pub fn is_nhash(value: &str) -> bool {
382    value.starts_with("nhash1")
383}
384
385/// Check if a string is an nref
386pub fn is_nref(value: &str) -> bool {
387    value.starts_with("nref1")
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn test_nhash_simple() {
396        let hash: Hash = [
397            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
398            0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
399            0x1d, 0x1e, 0x1f, 0x20,
400        ];
401
402        let encoded = nhash_encode(&hash).unwrap();
403        assert!(encoded.starts_with("nhash1"));
404
405        let decoded = nhash_decode(&encoded).unwrap();
406        assert_eq!(decoded.hash, hash);
407        assert!(decoded.path.is_empty());
408        assert!(decoded.decrypt_key.is_none());
409    }
410
411    #[test]
412    fn test_nhash_with_path() {
413        let hash: Hash = [0xaa; 32];
414
415        let data = NHashData {
416            hash,
417            path: vec!["folder".into(), "file.txt".into()],
418            decrypt_key: None,
419        };
420
421        let encoded = nhash_encode_full(&data).unwrap();
422        assert!(encoded.starts_with("nhash1"));
423
424        let decoded = nhash_decode(&encoded).unwrap();
425        assert_eq!(decoded.hash, hash);
426        assert_eq!(decoded.path, vec!["folder", "file.txt"]);
427        assert!(decoded.decrypt_key.is_none());
428    }
429
430    #[test]
431    fn test_nhash_with_key() {
432        let hash: Hash = [0xaa; 32];
433        let key: [u8; 32] = [0xbb; 32];
434
435        let data = NHashData {
436            hash,
437            path: vec![],
438            decrypt_key: Some(key),
439        };
440
441        let encoded = nhash_encode_full(&data).unwrap();
442        assert!(encoded.starts_with("nhash1"));
443
444        let decoded = nhash_decode(&encoded).unwrap();
445        assert_eq!(decoded.hash, hash);
446        assert!(decoded.path.is_empty());
447        assert_eq!(decoded.decrypt_key, Some(key));
448    }
449
450    #[test]
451    fn test_nhash_with_path_and_key() {
452        let hash: Hash = [0xaa; 32];
453        let key: [u8; 32] = [0xbb; 32];
454
455        let data = NHashData {
456            hash,
457            path: vec!["docs".into()],
458            decrypt_key: Some(key),
459        };
460
461        let encoded = nhash_encode_full(&data).unwrap();
462        let decoded = nhash_decode(&encoded).unwrap();
463        assert_eq!(decoded.hash, hash);
464        assert_eq!(decoded.path, vec!["docs"]);
465        assert_eq!(decoded.decrypt_key, Some(key));
466    }
467
468    #[test]
469    fn test_nref_simple() {
470        let pubkey: [u8; 32] = [0xcc; 32];
471        let data = NRefData {
472            pubkey,
473            tree_name: "home".into(),
474            path: vec![],
475            decrypt_key: None,
476        };
477
478        let encoded = nref_encode(&data).unwrap();
479        assert!(encoded.starts_with("nref1"));
480
481        let decoded = nref_decode(&encoded).unwrap();
482        assert_eq!(decoded.pubkey, pubkey);
483        assert_eq!(decoded.tree_name, "home");
484        assert!(decoded.path.is_empty());
485        assert!(decoded.decrypt_key.is_none());
486    }
487
488    #[test]
489    fn test_nref_with_path_and_key() {
490        let pubkey: [u8; 32] = [0xdd; 32];
491        let key: [u8; 32] = [0xee; 32];
492
493        let data = NRefData {
494            pubkey,
495            tree_name: "photos".into(),
496            path: vec!["vacation".into(), "beach.jpg".into()],
497            decrypt_key: Some(key),
498        };
499
500        let encoded = nref_encode(&data).unwrap();
501        assert!(encoded.starts_with("nref1"));
502
503        let decoded = nref_decode(&encoded).unwrap();
504        assert_eq!(decoded.pubkey, pubkey);
505        assert_eq!(decoded.tree_name, "photos");
506        assert_eq!(decoded.path, vec!["vacation", "beach.jpg"]);
507        assert_eq!(decoded.decrypt_key, Some(key));
508    }
509
510    #[test]
511    fn test_decode_generic() {
512        let hash: Hash = [0x11; 32];
513        let nhash = nhash_encode(&hash).unwrap();
514
515        match decode(&nhash).unwrap() {
516            DecodeResult::NHash(data) => assert_eq!(data.hash, hash),
517            _ => panic!("expected NHash"),
518        }
519
520        let pubkey: [u8; 32] = [0x22; 32];
521        let nref_data = NRefData {
522            pubkey,
523            tree_name: "test".into(),
524            path: vec![],
525            decrypt_key: None,
526        };
527        let nref = nref_encode(&nref_data).unwrap();
528
529        match decode(&nref).unwrap() {
530            DecodeResult::NRef(data) => {
531                assert_eq!(data.pubkey, pubkey);
532                assert_eq!(data.tree_name, "test");
533            }
534            _ => panic!("expected NRef"),
535        }
536    }
537
538    #[test]
539    fn test_is_nhash() {
540        assert!(is_nhash("nhash1abc"));
541        assert!(!is_nhash("nref1abc"));
542        assert!(!is_nhash("npub1abc"));
543    }
544
545    #[test]
546    fn test_is_nref() {
547        assert!(is_nref("nref1abc"));
548        assert!(!is_nref("nhash1abc"));
549        assert!(!is_nref("npub1abc"));
550    }
551}