Skip to main content

rust_hdf5/format/messages/
link.rs

1//! Link message (type 0x06) — encodes a single link within a group.
2//!
3//! Binary layout (version 1):
4//!   Byte 0: version = 1
5//!   Byte 1: flags
6//!     bits 0-1: size of name-length field (0=1B, 1=2B, 2=4B, 3=8B)
7//!     bit 2:    creation order present
8//!     bit 3:    link type present
9//!     bit 4:    charset field present
10//!   [if bit 3]: link_type u8 (0=hard, 1=soft, 64+=external)
11//!   [if bit 2]: creation_order i64 LE
12//!   [if bit 4]: charset u8 (0=ASCII, 1=UTF-8)
13//!   name_length: 1/2/4/8 bytes per bits 0-1
14//!   name:        name_length bytes (UTF-8)
15//!   [hard link]:  address (sizeof_addr bytes)
16//!   [soft link]:  target_length u16 LE + target string
17//!   [ud link]:    udata_length u16 LE + udata bytes
18//!
19//! The external link is the one user-defined link class libhdf5 ships
20//! (`H5L_TYPE_EXTERNAL` = 64). Its udata is a version/flags byte followed by
21//! the NUL-terminated target file name and the NUL-terminated object path
22//! within that file (`H5Lexternal.c`).
23
24use crate::format::bytes::read_le_uint as read_uint;
25use crate::format::{FormatContext, FormatError, FormatResult};
26
27const VERSION: u8 = 1;
28
29const FLAG_NAME_LEN_MASK: u8 = 0x03;
30const FLAG_CREATION_ORDER: u8 = 0x04;
31const FLAG_LINK_TYPE: u8 = 0x08;
32const FLAG_CHARSET: u8 = 0x10;
33
34const LINK_TYPE_HARD: u8 = 0;
35const LINK_TYPE_SOFT: u8 = 1;
36/// `H5L_TYPE_EXTERNAL` — also `H5L_TYPE_UD_MIN`, the bottom of the
37/// user-defined link range (64..=255).
38const LINK_TYPE_EXTERNAL: u8 = 64;
39
40/// Version nibble of the external-link udata (`H5L_EXT_VERSION`).
41const EXT_VERSION: u8 = 0;
42
43/// The character set a link name is stored in — `H5T_cset_t` narrowed to the
44/// two values `H5O__link_decode` accepts (`lnk->cset < H5T_CSET_ASCII ||
45/// lnk->cset > H5T_CSET_UTF8` is a hard error there, so a third value is not
46/// a link message at all).
47///
48/// It is a property of the *link*, not of the name bytes: it comes from the
49/// link creation property list (`H5Pset_char_encoding`), and libhdf5 happily
50/// stores a name with high bytes under `Ascii` when that is what the property
51/// list said. It is also the axis that decides a group's storage — see
52/// [`LinkMessage::fits_symbol_table`].
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum CharacterSet {
55    /// `H5T_CSET_ASCII`, and `H5F_DEFAULT_CSET`: the value a link carries
56    /// when the message has no character set field.
57    #[default]
58    Ascii,
59    /// `H5T_CSET_UTF8`.
60    Utf8,
61}
62
63impl CharacterSet {
64    /// The character set h5py picks for a name it is given as `str`: ASCII
65    /// when `name.encode('ascii')` succeeds, UTF-8 when it raises
66    /// (`CommonStateObject._e`). A Rust `&str` is the same input, so this is
67    /// the rule for every link this crate creates.
68    pub fn for_name(name: &str) -> Self {
69        if name.is_ascii() {
70            Self::Ascii
71        } else {
72            Self::Utf8
73        }
74    }
75
76    fn code(self) -> u8 {
77        match self {
78            Self::Ascii => 0,
79            Self::Utf8 => 1,
80        }
81    }
82
83    fn from_code(code: u8) -> FormatResult<Self> {
84        match code {
85            0 => Ok(Self::Ascii),
86            1 => Ok(Self::Utf8),
87            other => Err(FormatError::InvalidData(format!(
88                "link name character set {other} is neither ASCII nor UTF-8"
89            ))),
90        }
91    }
92}
93
94/// Link target discriminant.
95#[derive(Debug, Clone, PartialEq)]
96pub enum LinkTarget {
97    /// Hard link — points to an object header at `address`.
98    Hard { address: u64 },
99    /// Soft link — points to a path string, resolved at traversal time.
100    Soft { target: String },
101    /// External link — points to `path` inside the file named `file`.
102    External { file: String, path: String },
103    /// Any other user-defined link class (65..=255). libhdf5 needs a
104    /// registered link class to interpret `udata`, so it is kept verbatim:
105    /// the link still has a name and still belongs in a listing.
106    UserDefined { link_type: u8, udata: Vec<u8> },
107}
108
109/// Link message payload.
110#[derive(Debug, Clone, PartialEq)]
111pub struct LinkMessage {
112    pub name: String,
113    pub target: LinkTarget,
114    /// Creation order within the parent group, present only when the group
115    /// tracks it (`H5O_LINK_STORE_CORDER`). `H5G_obj_insert` stamps it from
116    /// the Link Info message's running maximum.
117    pub creation_order: Option<i64>,
118    /// Character set of `name` (`H5O_LINK_STORE_NAME_CSET`).
119    pub cset: CharacterSet,
120}
121
122impl LinkMessage {
123    /// Create a hard link.
124    pub fn hard(name: &str, address: u64) -> Self {
125        Self {
126            name: name.to_string(),
127            target: LinkTarget::Hard { address },
128            creation_order: None,
129            cset: CharacterSet::for_name(name),
130        }
131    }
132
133    /// Create a soft link.
134    pub fn soft(name: &str, target: &str) -> Self {
135        Self {
136            name: name.to_string(),
137            target: LinkTarget::Soft {
138                target: target.to_string(),
139            },
140            creation_order: None,
141            cset: CharacterSet::for_name(name),
142        }
143    }
144
145    /// Create an external link to `path` inside `file`.
146    pub fn external(name: &str, file: &str, path: &str) -> Self {
147        Self {
148            name: name.to_string(),
149            target: LinkTarget::External {
150                file: file.to_string(),
151                path: path.to_string(),
152            },
153            creation_order: None,
154            cset: CharacterSet::for_name(name),
155        }
156    }
157
158    /// The same link, stamped with its creation order.
159    pub fn with_creation_order(mut self, corder: i64) -> Self {
160        self.creation_order = Some(corder);
161        self
162    }
163
164    /// Restamp the creation order inside an already-encoded link message,
165    /// touching no other byte. `false` when the message carries no creation
166    /// order field, as a link from a group that does not track it does not.
167    pub fn restamp_encoded_creation_order(encoded: &mut [u8], corder: i64) -> bool {
168        if encoded.len() < 2 || encoded[0] != VERSION || encoded[1] & FLAG_CREATION_ORDER == 0 {
169            return false;
170        }
171        let at = 2 + usize::from(encoded[1] & FLAG_LINK_TYPE != 0);
172        let Some(field) = encoded.get_mut(at..at + 8) else {
173            return false;
174        };
175        field.copy_from_slice(&corder.to_le_bytes());
176        true
177    }
178
179    /// The same link, stamped with a character set the name does not imply.
180    ///
181    /// The one producer that needs it is the symbol table: an entry carries no
182    /// character set field, so `H5G__ent_to_link` gives every link it builds
183    /// `H5F_DEFAULT_CSET` (H5Gent.c:372) whatever the name's bytes are. A
184    /// group libhdf5 wrote can therefore hold a high-byte name under `Ascii`,
185    /// and re-deriving the set from the name would convert that group out of
186    /// its symbol table on a rewrite.
187    pub fn with_cset(mut self, cset: CharacterSet) -> Self {
188        self.cset = cset;
189        self
190    }
191
192    /// Whether a version-1 symbol table entry can express this link.
193    ///
194    /// `H5G_obj_insert` asks it as one condition with two halves
195    /// (`obj_lnk->cset != H5T_CSET_ASCII || obj_lnk->type >
196    /// H5L_TYPE_BUILTIN_MAX`, H5Gobj.c:514), and converts the whole group to
197    /// link messages the moment either holds.
198    ///
199    /// The entry has three cache types — nothing cached, an object header
200    /// address with the target group's own symbol table beside it, and a soft
201    /// link's offset into the local heap — and no room for a fourth, so the
202    /// user-defined classes, `H5L_TYPE_EXTERNAL` among them, exist only as
203    /// link messages. It has no character set field either, so a link whose
204    /// set is not the file default cannot be stored in one without losing it.
205    pub fn fits_symbol_table(&self) -> bool {
206        self.cset == CharacterSet::Ascii
207            && matches!(
208                self.target,
209                LinkTarget::Hard { .. } | LinkTarget::Soft { .. }
210            )
211    }
212
213    // ------------------------------------------------------------------ encode
214
215    pub fn encode(&self, ctx: &FormatContext) -> Vec<u8> {
216        let name_bytes = self.name.as_bytes();
217        let name_len = name_bytes.len();
218        let name_len_size = min_bytes_for_value(name_len as u64);
219        let name_len_code = match name_len_size {
220            1 => 0u8,
221            2 => 1,
222            4 => 2,
223            _ => 3, // 8
224        };
225
226        let link_type = match &self.target {
227            LinkTarget::Hard { .. } => LINK_TYPE_HARD,
228            LinkTarget::Soft { .. } => LINK_TYPE_SOFT,
229            LinkTarget::External { .. } => LINK_TYPE_EXTERNAL,
230            LinkTarget::UserDefined { link_type, .. } => *link_type,
231        };
232
233        // Every optional field is present exactly when its value is not the
234        // default one the decoder assumes in its absence — `H5O__link_encode`
235        // sets each flag from that test, so a hard link with an ASCII name
236        // carries neither the type byte nor the character set byte.
237        let mut flags: u8 = name_len_code & FLAG_NAME_LEN_MASK;
238        if link_type != LINK_TYPE_HARD {
239            flags |= FLAG_LINK_TYPE;
240        }
241        if self.cset != CharacterSet::Ascii {
242            flags |= FLAG_CHARSET;
243        }
244        if self.creation_order.is_some() {
245            flags |= FLAG_CREATION_ORDER;
246        }
247
248        let mut buf = Vec::with_capacity(32);
249        buf.push(VERSION);
250        buf.push(flags);
251
252        // link type
253        if flags & FLAG_LINK_TYPE != 0 {
254            buf.push(link_type);
255        }
256
257        // creation order, before the charset byte (`H5O__link_encode`)
258        if let Some(corder) = self.creation_order {
259            buf.extend_from_slice(&corder.to_le_bytes());
260        }
261
262        if flags & FLAG_CHARSET != 0 {
263            buf.push(self.cset.code());
264        }
265
266        // name length
267        match name_len_size {
268            1 => buf.push(name_len as u8),
269            2 => buf.extend_from_slice(&(name_len as u16).to_le_bytes()),
270            4 => buf.extend_from_slice(&(name_len as u32).to_le_bytes()),
271            _ => buf.extend_from_slice(&(name_len as u64).to_le_bytes()),
272        }
273
274        // name
275        buf.extend_from_slice(name_bytes);
276
277        // link info
278        match &self.target {
279            LinkTarget::Hard { address } => {
280                let sa = ctx.sizeof_addr as usize;
281                buf.extend_from_slice(&address.to_le_bytes()[..sa]);
282            }
283            LinkTarget::Soft { target } => {
284                let tbytes = target.as_bytes();
285                buf.extend_from_slice(&(tbytes.len() as u16).to_le_bytes());
286                buf.extend_from_slice(tbytes);
287            }
288            LinkTarget::External { file, path } => {
289                let udata = encode_external_udata(file, path);
290                buf.extend_from_slice(&(udata.len() as u16).to_le_bytes());
291                buf.extend_from_slice(&udata);
292            }
293            LinkTarget::UserDefined { udata, .. } => {
294                buf.extend_from_slice(&(udata.len() as u16).to_le_bytes());
295                buf.extend_from_slice(udata);
296            }
297        }
298
299        buf
300    }
301
302    // ------------------------------------------------------------------ decode
303
304    pub fn decode(buf: &[u8], ctx: &FormatContext) -> FormatResult<(Self, usize)> {
305        if buf.len() < 2 {
306            return Err(FormatError::BufferTooShort {
307                needed: 2,
308                available: buf.len(),
309            });
310        }
311
312        let version = buf[0];
313        if version != VERSION {
314            return Err(FormatError::InvalidVersion(version));
315        }
316
317        let flags = buf[1];
318        let name_len_code = flags & FLAG_NAME_LEN_MASK;
319        let has_creation_order = (flags & FLAG_CREATION_ORDER) != 0;
320        let has_link_type = (flags & FLAG_LINK_TYPE) != 0;
321        let has_charset = (flags & FLAG_CHARSET) != 0;
322
323        let mut pos = 2;
324
325        // link type
326        let link_type = if has_link_type {
327            check_len(buf, pos, 1)?;
328            let lt = buf[pos];
329            pos += 1;
330            lt
331        } else {
332            LINK_TYPE_HARD // default
333        };
334
335        // creation order — an 8-byte signed integer (H5Olink.c INT64DECODE),
336        // not 4.
337        let creation_order = if has_creation_order {
338            check_len(buf, pos, 8)?;
339            let v = i64::from_le_bytes(buf[pos..pos + 8].try_into().unwrap());
340            pos += 8;
341            Some(v)
342        } else {
343            None
344        };
345
346        // charset — absent means the file default, `H5T_CSET_ASCII`
347        let cset = if has_charset {
348            check_len(buf, pos, 1)?;
349            let c = CharacterSet::from_code(buf[pos])?;
350            pos += 1;
351            c
352        } else {
353            CharacterSet::Ascii
354        };
355
356        // name length
357        let name_len_size: usize = match name_len_code {
358            0 => 1,
359            1 => 2,
360            2 => 4,
361            _ => 8,
362        };
363        check_len(buf, pos, name_len_size)?;
364        let name_len = read_uint(&buf[pos..], name_len_size) as usize;
365        pos += name_len_size;
366
367        // name
368        check_len(buf, pos, name_len)?;
369        let name = std::str::from_utf8(&buf[pos..pos + name_len])
370            .map_err(|e| FormatError::InvalidData(format!("invalid UTF-8 link name: {}", e)))?
371            .to_string();
372        pos += name_len;
373
374        // target
375        let target = match link_type {
376            LINK_TYPE_HARD => {
377                let sa = ctx.sizeof_addr as usize;
378                check_len(buf, pos, sa)?;
379                let address = read_uint(&buf[pos..], sa);
380                pos += sa;
381                LinkTarget::Hard { address }
382            }
383            LINK_TYPE_SOFT => {
384                check_len(buf, pos, 2)?;
385                let tlen = u16::from_le_bytes([buf[pos], buf[pos + 1]]) as usize;
386                pos += 2;
387                check_len(buf, pos, tlen)?;
388                let target = std::str::from_utf8(&buf[pos..pos + tlen])
389                    .map_err(|e| {
390                        FormatError::InvalidData(format!("invalid UTF-8 soft link target: {}", e))
391                    })?
392                    .to_string();
393                pos += tlen;
394                LinkTarget::Soft { target }
395            }
396            // User-defined links (64..=255) all carry a u16-prefixed opaque
397            // value; only the external-link class is interpreted here. A type
398            // below the user-defined range is not a link libhdf5 would have
399            // written (`H5O__link_decode` rejects it too).
400            ud if ud >= LINK_TYPE_EXTERNAL => {
401                check_len(buf, pos, 2)?;
402                let ulen = u16::from_le_bytes([buf[pos], buf[pos + 1]]) as usize;
403                pos += 2;
404                check_len(buf, pos, ulen)?;
405                let udata = &buf[pos..pos + ulen];
406                pos += ulen;
407                if ud == LINK_TYPE_EXTERNAL {
408                    let (file, path) = decode_external_udata(udata)?;
409                    LinkTarget::External { file, path }
410                } else {
411                    LinkTarget::UserDefined {
412                        link_type: ud,
413                        udata: udata.to_vec(),
414                    }
415                }
416            }
417            other => {
418                return Err(FormatError::InvalidData(format!(
419                    "unknown link type {}",
420                    other
421                )));
422            }
423        };
424
425        Ok((
426            Self {
427                name,
428                target,
429                creation_order,
430                cset,
431            },
432            pos,
433        ))
434    }
435}
436
437// ========================================================================= helpers
438
439/// Strip duplicate and trailing slashes from an object path, the way
440/// `H5G_normalize` does before `H5Lcreate_external` stores it.
441///
442/// The stored value is what a reader gets back from `H5Lget_val`, so a link
443/// this crate writes and one libhdf5 writes from the same arguments have to
444/// agree here or the two files differ in a field a comparison reports.
445pub(crate) fn normalize_object_path(path: &str) -> String {
446    let mut out = String::with_capacity(path.len());
447    let mut last_slash = false;
448    for c in path.chars() {
449        if c == '/' && last_slash {
450            continue;
451        }
452        last_slash = c == '/';
453        out.push(c);
454    }
455    // The root path is the one trailing slash that stays.
456    if out.len() > 1 && out.ends_with('/') {
457        out.pop();
458    }
459    out
460}
461
462/// Encode the external-link value: `(version << 4) | flags`, then the
463/// NUL-terminated file name and the NUL-terminated object path (`H5L.c`,
464/// `H5L__create_ud` for `H5L_TYPE_EXTERNAL`).
465fn encode_external_udata(file: &str, path: &str) -> Vec<u8> {
466    let mut udata = Vec::with_capacity(1 + file.len() + path.len() + 2);
467    udata.push(EXT_VERSION << 4);
468    udata.extend_from_slice(file.as_bytes());
469    udata.push(0);
470    udata.extend_from_slice(path.as_bytes());
471    udata.push(0);
472    udata
473}
474
475/// Decode the external-link value written by `encode_external_udata`.
476/// libhdf5 rejects a value shorter than 3 bytes, a version above
477/// `H5L_EXT_VERSION`, and any flag bit set (`H5L_EXT_FLAGS_ALL` is 0).
478fn decode_external_udata(udata: &[u8]) -> FormatResult<(String, String)> {
479    if udata.len() < 3 {
480        return Err(FormatError::InvalidData(format!(
481            "external link value is {} bytes, below the 3-byte minimum",
482            udata.len()
483        )));
484    }
485    let version = udata[0] >> 4;
486    let flags = udata[0] & 0x0f;
487    if version != EXT_VERSION {
488        return Err(FormatError::InvalidVersion(version));
489    }
490    if flags != 0 {
491        return Err(FormatError::InvalidData(format!(
492            "external link flags {flags:#x} are not recognized"
493        )));
494    }
495    let body = &udata[1..];
496    let split = body.iter().position(|&b| b == 0).ok_or_else(|| {
497        FormatError::InvalidData("external link file name is not NUL-terminated".into())
498    })?;
499    let file = str_from_utf8(&body[..split], "external link file name")?;
500    let rest = &body[split + 1..];
501    // The object path's own NUL terminator is present in every file libhdf5
502    // writes; tolerate its absence by taking the remainder, as the C traverse
503    // path does once it has the file name.
504    let end = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
505    let path = str_from_utf8(&rest[..end], "external link object path")?;
506    Ok((file, path))
507}
508
509fn str_from_utf8(bytes: &[u8], what: &str) -> FormatResult<String> {
510    std::str::from_utf8(bytes)
511        .map(|s| s.to_string())
512        .map_err(|e| FormatError::InvalidData(format!("invalid UTF-8 {what}: {e}")))
513}
514
515fn check_len(buf: &[u8], pos: usize, need: usize) -> FormatResult<()> {
516    // `need` can be a file-derived length up to 8 bytes wide; a checked add
517    // ensures `pos + need` cannot wrap to a small value that spuriously
518    // passes the bound check (and then panics a slice in the caller).
519    match pos.checked_add(need) {
520        Some(end) if end <= buf.len() => Ok(()),
521        _ => Err(FormatError::BufferTooShort {
522            needed: pos.saturating_add(need),
523            available: buf.len(),
524        }),
525    }
526}
527
528/// Minimum number of bytes (1, 2, 4, or 8) to represent `v`.
529fn min_bytes_for_value(v: u64) -> usize {
530    if v <= u8::MAX as u64 {
531        1
532    } else if v <= u16::MAX as u64 {
533        2
534    } else if v <= u32::MAX as u64 {
535        4
536    } else {
537        8
538    }
539}
540
541// ======================================================================= tests
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    fn ctx8() -> FormatContext {
548        FormatContext {
549            sizeof_addr: 8,
550            sizeof_size: 8,
551        }
552    }
553
554    #[test]
555    fn restamp_rewrites_only_the_creation_order_field() {
556        let soft = LinkMessage::soft("s", "/a").with_creation_order(7);
557        let mut encoded = soft.encode(&ctx8());
558        assert!(LinkMessage::restamp_encoded_creation_order(&mut encoded, 2));
559        assert_eq!(encoded, soft.with_creation_order(2).encode(&ctx8()));
560
561        let hard = LinkMessage::hard("h", 0x1000);
562        let mut encoded = hard.encode(&ctx8());
563        let before = encoded.clone();
564        assert!(!LinkMessage::restamp_encoded_creation_order(
565            &mut encoded,
566            2
567        ));
568        assert_eq!(encoded, before);
569    }
570
571    fn ctx4() -> FormatContext {
572        FormatContext {
573            sizeof_addr: 4,
574            sizeof_size: 4,
575        }
576    }
577
578    #[test]
579    fn roundtrip_hard_link() {
580        let msg = LinkMessage::hard("dataset1", 0x1000);
581        let encoded = msg.encode(&ctx8());
582        let (decoded, consumed) = LinkMessage::decode(&encoded, &ctx8()).unwrap();
583        assert_eq!(consumed, encoded.len());
584        assert_eq!(decoded, msg);
585    }
586
587    #[test]
588    fn roundtrip_hard_link_ctx4() {
589        let msg = LinkMessage::hard("grp", 0x2000);
590        let encoded = msg.encode(&ctx4());
591        let (decoded, consumed) = LinkMessage::decode(&encoded, &ctx4()).unwrap();
592        assert_eq!(consumed, encoded.len());
593        assert_eq!(decoded, msg);
594    }
595
596    #[test]
597    fn roundtrip_soft_link() {
598        let msg = LinkMessage::soft("alias", "/group/dataset");
599        let encoded = msg.encode(&ctx8());
600        let (decoded, consumed) = LinkMessage::decode(&encoded, &ctx8()).unwrap();
601        assert_eq!(consumed, encoded.len());
602        assert_eq!(decoded, msg);
603    }
604
605    #[test]
606    fn roundtrip_empty_name() {
607        // edge case: empty name
608        let msg = LinkMessage::hard("", 0x100);
609        let encoded = msg.encode(&ctx8());
610        let (decoded, _) = LinkMessage::decode(&encoded, &ctx8()).unwrap();
611        assert_eq!(decoded, msg);
612    }
613
614    #[test]
615    fn roundtrip_long_name() {
616        // name longer than 255 bytes triggers 2-byte name length
617        let long_name: String = "a".repeat(300);
618        let msg = LinkMessage::hard(&long_name, 0xABCD);
619        let encoded = msg.encode(&ctx8());
620        let (decoded, consumed) = LinkMessage::decode(&encoded, &ctx8()).unwrap();
621        assert_eq!(consumed, encoded.len());
622        assert_eq!(decoded, msg);
623    }
624
625    #[test]
626    fn roundtrip_unicode_name() {
627        let msg = LinkMessage::hard("日本語データ", 0x4000);
628        let encoded = msg.encode(&ctx8());
629        let (decoded, _) = LinkMessage::decode(&encoded, &ctx8()).unwrap();
630        assert_eq!(decoded, msg);
631    }
632
633    #[test]
634    fn roundtrip_external_link() {
635        let msg = LinkMessage::external("ext", "sibling.h5", "/payload");
636        let encoded = msg.encode(&ctx8());
637        let (decoded, consumed) = LinkMessage::decode(&encoded, &ctx8()).unwrap();
638        assert_eq!(consumed, encoded.len());
639        assert_eq!(decoded, msg);
640    }
641
642    /// The exact bytes h5py wrote for `f['ext'] = h5py.ExternalLink(...)`:
643    /// version 1, flags 0x08 (link type present, 1-byte name length), type 64,
644    /// then the udata (version/flags byte, NUL-terminated file, NUL-terminated
645    /// path). Rejecting this message dropped the link from the listing.
646    #[test]
647    fn decode_h5py_external_link_bytes() {
648        let mut buf = vec![1u8, 0x08, 64, 3];
649        buf.extend_from_slice(b"ext");
650        let udata = {
651            let mut u = vec![0u8];
652            u.extend_from_slice(b"link_external_ext.h5\0");
653            u.extend_from_slice(b"/payload\0");
654            u
655        };
656        assert_eq!(udata.len(), 31);
657        buf.extend_from_slice(&(udata.len() as u16).to_le_bytes());
658        buf.extend_from_slice(&udata);
659
660        let (decoded, consumed) = LinkMessage::decode(&buf, &ctx8()).unwrap();
661        assert_eq!(consumed, buf.len());
662        assert_eq!(decoded.name, "ext");
663        assert_eq!(
664            decoded.target,
665            LinkTarget::External {
666                file: "link_external_ext.h5".into(),
667                path: "/payload".into(),
668            }
669        );
670    }
671
672    /// The other half of [`decode_h5py_external_link_bytes`]: for the same
673    /// arguments this encoder now produces the whole message h5py wrote, not
674    /// just the same value inside a wider envelope.
675    ///
676    /// [`decode_h5py_external_link_bytes`]: self::tests::decode_h5py_external_link_bytes
677    #[test]
678    fn encoded_external_link_is_the_message_h5lcreate_external_builds() {
679        let encoded =
680            LinkMessage::external("ext", "link_external_ext.h5", "/payload").encode(&ctx8());
681        let mut want = vec![1u8, 0x08, 64, 3];
682        want.extend_from_slice(b"ext");
683        let udata = {
684            let mut u = vec![0u8];
685            u.extend_from_slice(b"link_external_ext.h5\0");
686            u.extend_from_slice(b"/payload\0");
687            u
688        };
689        want.extend_from_slice(&(udata.len() as u16).to_le_bytes());
690        want.extend_from_slice(&udata);
691        assert_eq!(encoded, want);
692    }
693
694    /// A user-defined class other than the external link keeps its value
695    /// verbatim so the link still has a name and still appears in a listing.
696    #[test]
697    fn roundtrip_unregistered_user_defined_link() {
698        let msg = LinkMessage {
699            name: "ud".into(),
700            target: LinkTarget::UserDefined {
701                link_type: 200,
702                udata: vec![9, 8, 7],
703            },
704            creation_order: None,
705            cset: CharacterSet::Ascii,
706        };
707        let encoded = msg.encode(&ctx8());
708        let (decoded, consumed) = LinkMessage::decode(&encoded, &ctx8()).unwrap();
709        assert_eq!(consumed, encoded.len());
710        assert_eq!(decoded, msg);
711    }
712
713    #[test]
714    fn decode_unknown_link_type_below_user_defined_range() {
715        let buf = [1u8, 0x08, 7, 1, b'x'];
716        match LinkMessage::decode(&buf, &ctx8()).unwrap_err() {
717            FormatError::InvalidData(ref s) => assert!(s.contains("link type 7"), "{s}"),
718            other => panic!("unexpected error: {other:?}"),
719        }
720    }
721
722    #[test]
723    fn decode_external_value_too_short() {
724        let mut buf = vec![1u8, 0x08, 64, 1, b'e'];
725        buf.extend_from_slice(&2u16.to_le_bytes());
726        buf.extend_from_slice(&[0u8, 0]);
727        match LinkMessage::decode(&buf, &ctx8()).unwrap_err() {
728            FormatError::InvalidData(ref s) => assert!(s.contains("3-byte minimum"), "{s}"),
729            other => panic!("unexpected error: {other:?}"),
730        }
731    }
732
733    #[test]
734    fn decode_external_value_bad_version() {
735        let mut buf = vec![1u8, 0x08, 64, 1, b'e'];
736        let udata = [0x10u8, b'f', 0, b'/', 0];
737        buf.extend_from_slice(&(udata.len() as u16).to_le_bytes());
738        buf.extend_from_slice(&udata);
739        match LinkMessage::decode(&buf, &ctx8()).unwrap_err() {
740            FormatError::InvalidVersion(1) => {}
741            other => panic!("unexpected error: {other:?}"),
742        }
743    }
744
745    #[test]
746    fn decode_bad_version() {
747        let buf = [2u8, 0]; // version 2 unsupported
748        let err = LinkMessage::decode(&buf, &ctx8()).unwrap_err();
749        match err {
750            FormatError::InvalidVersion(2) => {}
751            other => panic!("unexpected error: {:?}", other),
752        }
753    }
754
755    #[test]
756    fn decode_buffer_too_short() {
757        let buf = [1u8];
758        let err = LinkMessage::decode(&buf, &ctx8()).unwrap_err();
759        match err {
760            FormatError::BufferTooShort { .. } => {}
761            other => panic!("unexpected error: {:?}", other),
762        }
763    }
764
765    #[test]
766    fn version_byte() {
767        let encoded = LinkMessage::hard("x", 0).encode(&ctx8());
768        assert_eq!(encoded[0], 1);
769    }
770
771    #[test]
772    fn roundtrip_with_creation_order() {
773        let msg = LinkMessage::hard("d00", 0x1000).with_creation_order(7);
774        let encoded = msg.encode(&ctx8());
775        // The flag byte announces it, and the value costs eight bytes.
776        assert_eq!(encoded[1] & FLAG_CREATION_ORDER, FLAG_CREATION_ORDER);
777        assert_eq!(
778            encoded.len(),
779            LinkMessage::hard("d00", 0x1000).encode(&ctx8()).len() + 8
780        );
781        let (decoded, consumed) = LinkMessage::decode(&encoded, &ctx8()).unwrap();
782        assert_eq!(consumed, encoded.len());
783        assert_eq!(decoded, msg);
784        assert_eq!(decoded.creation_order, Some(7));
785    }
786
787    /// The creation order sits between the link type and the charset byte, so
788    /// a decoder that skipped the wrong span would misread the name. A
789    /// non-ASCII name is what puts the charset byte there at all.
790    #[test]
791    fn creation_order_precedes_the_charset_byte() {
792        let msg = LinkMessage::soft("별칭", "/orig").with_creation_order(-3);
793        let encoded = msg.encode(&ctx8());
794        assert_eq!(
795            encoded[1],
796            FLAG_CREATION_ORDER | FLAG_LINK_TYPE | FLAG_CHARSET
797        );
798        assert_eq!(&encoded[3..11], &(-3i64).to_le_bytes());
799        assert_eq!(encoded[11], 1, "charset follows the creation order");
800        assert_eq!(LinkMessage::decode(&encoded, &ctx8()).unwrap().0, msg);
801    }
802
803    /// The exact bytes libhdf5 1.14.6 wrote for the ASCII-named hard link
804    /// `plain` in a group it had just converted out of a symbol table: no
805    /// link-type byte and no charset byte, because a hard link with an ASCII
806    /// name is default on both axes (`H5O__link_encode`). Writing them anyway
807    /// is what this encoder used to do.
808    #[test]
809    fn ascii_hard_link_is_encoded_the_way_libhdf5_wrote_it() {
810        let encoded = LinkMessage::hard("plain", 800).encode(&ctx8());
811        let mut want = vec![1u8, 0x00, 5];
812        want.extend_from_slice(b"plain");
813        want.extend_from_slice(&800u64.to_le_bytes());
814        assert_eq!(encoded, want);
815    }
816
817    /// The same group's non-ASCII link, from the same file: flags 0x10 and a
818    /// charset byte of 1, still with no link-type byte.
819    #[test]
820    fn non_ascii_hard_link_carries_the_utf8_charset_byte() {
821        let encoded = LinkMessage::hard("비ascii", 1832).encode(&ctx8());
822        let name = "비ascii".as_bytes();
823        assert_eq!(name.len(), 8);
824        let mut want = vec![1u8, 0x10, 1, 8];
825        want.extend_from_slice(name);
826        want.extend_from_slice(&1832u64.to_le_bytes());
827        assert_eq!(encoded, want);
828    }
829
830    /// `H5G_obj_insert`'s two-half condition (H5Gobj.c:514): a UTF-8 name
831    /// takes a group out of its symbol table exactly as an external link does.
832    #[test]
833    fn a_utf8_name_does_not_fit_a_symbol_table() {
834        assert!(LinkMessage::hard("plain", 1).fits_symbol_table());
835        assert!(LinkMessage::soft("plain", "/x").fits_symbol_table());
836        assert!(!LinkMessage::hard("비ascii", 1).fits_symbol_table());
837        assert!(!LinkMessage::soft("비ascii", "/x").fits_symbol_table());
838        assert!(!LinkMessage::external("ext", "f.h5", "/p").fits_symbol_table());
839    }
840
841    /// A symbol table entry has no charset field, so `H5G__ent_to_link` gives
842    /// every link it builds `H5F_DEFAULT_CSET` whatever the name's bytes are —
843    /// and such a link still belongs in a symbol table on a rewrite.
844    #[test]
845    fn a_symbol_table_name_keeps_the_default_charset() {
846        let msg = LinkMessage::hard("한글", 0x40).with_cset(CharacterSet::Ascii);
847        assert!(msg.fits_symbol_table());
848        let encoded = msg.encode(&ctx8());
849        assert_eq!(encoded[1] & FLAG_CHARSET, 0);
850        assert_eq!(LinkMessage::decode(&encoded, &ctx8()).unwrap().0, msg);
851    }
852
853    /// `H5O__link_decode` rejects a character set outside `H5T_CSET_ASCII ..=
854    /// H5T_CSET_UTF8` rather than carrying the value through.
855    #[test]
856    fn decode_rejects_an_unknown_charset() {
857        let buf = [1u8, 0x10, 7, 1, b'x', 0, 0, 0, 0, 0, 0, 0, 0];
858        match LinkMessage::decode(&buf, &ctx8()).unwrap_err() {
859            FormatError::InvalidData(ref s) => assert!(s.contains("character set 7"), "{s}"),
860            other => panic!("unexpected error: {other:?}"),
861        }
862    }
863
864    /// `H5G_normalize`: duplicate slashes collapse, one trailing slash goes,
865    /// and the root path keeps its only character.
866    #[test]
867    fn object_paths_normalize_like_h5g_normalize() {
868        assert_eq!(normalize_object_path("/payload"), "/payload");
869        assert_eq!(normalize_object_path("//a///b"), "/a/b");
870        assert_eq!(normalize_object_path("/a/b/"), "/a/b");
871        assert_eq!(normalize_object_path("/a/b//"), "/a/b");
872        assert_eq!(normalize_object_path("/"), "/");
873        assert_eq!(normalize_object_path("//"), "/");
874        assert_eq!(normalize_object_path("a/b"), "a/b");
875        assert_eq!(normalize_object_path(""), "");
876    }
877
878    #[test]
879    fn min_bytes_for_value_checks() {
880        assert_eq!(min_bytes_for_value(0), 1);
881        assert_eq!(min_bytes_for_value(255), 1);
882        assert_eq!(min_bytes_for_value(256), 2);
883        assert_eq!(min_bytes_for_value(65535), 2);
884        assert_eq!(min_bytes_for_value(65536), 4);
885        assert_eq!(min_bytes_for_value(u32::MAX as u64), 4);
886        assert_eq!(min_bytes_for_value(u32::MAX as u64 + 1), 8);
887    }
888}