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