nir_rs/io/mod.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! HDF5 `.nir` read/write.
4//!
5//! `.nir` is the official NIR interchange format: an HDF5 container whose
6//! layout is fixed by upstream [neuromorphs/NIR](https://github.com/neuromorphs/NIR).
7//! Files written here load in Python `nir.read`, and files written by Python
8//! `nir.write` load in [`read`]. See [`wire`] for the layout itself.
9//!
10//! # Feature gate
11//!
12//! The implementation lives behind the **`hdf5`** feature, which links the
13//! native libhdf5 library. It is off by default so that the graph model stays
14//! dependency-free for consumers that only build or inspect graphs:
15//!
16//! ```toml
17//! nir-rs = { version = "0.4", features = ["hdf5"] }
18//! ```
19//!
20//! System dependency: `libhdf5-dev` (Debian/Ubuntu), `hdf5` (Homebrew), or add
21//! `hdf5-metno` as a direct dependency with `features = ["static", "zlib"]`
22//! for a hermetic build from vendored source.
23//!
24//! Every item in this module exists in both builds — only the bodies are gated.
25//! Without the feature, [`read`], [`write()`] and [`read_version`] return
26//! [`NirError::Unimplemented`] rather than failing to compile, so downstream
27//! code can be written once and feature-gated at the call site if it wants to.
28//!
29//! # Version string
30//!
31//! Upstream writes the version of the Python `nir` package into `/version` and
32//! never validates it on read. This crate follows suit:
33//!
34//! - [`read`] stores `/version` in [`NirGraph::version`], and leaves it `None`
35//! when the dataset is absent. It is never an error.
36//! - [`read_version`] is the strict accessor and *does* error when absent.
37//! - [`write()`] emits [`NirGraph::version`] when set, else
38//! [`DEFAULT_NIR_VERSION`].
39//!
40//! # Non-goals
41//!
42//! Byte-identical output versus h5py (group ordering, chunk layout and filter
43//! parameters may differ), and the separate `NIRGraphData` observables layout
44//! that upstream `read_data` / `write_data` handle.
45
46pub mod wire;
47
48#[cfg(feature = "hdf5")]
49mod hdf5_read;
50#[cfg(feature = "hdf5")]
51mod hdf5_write;
52
53use crate::graph::NirGraph;
54use std::path::Path;
55
56// `NirError` is constructed only by the feature-off backend, but the rustdoc
57// links throughout this module reference it in both builds.
58#[cfg_attr(feature = "hdf5", allow(unused_imports))]
59use crate::error::{NirError, Result};
60
61/// The three primitives the public functions delegate to.
62///
63/// Selecting the implementation once, here, is what keeps `#[cfg]` out of the
64/// public functions below — they have one body each regardless of features.
65#[cfg(feature = "hdf5")]
66mod backend {
67 pub(super) use super::hdf5_read::{read, read_version};
68 pub(super) use super::hdf5_write::write;
69}
70
71/// Stand-ins used when the `hdf5` feature is off.
72///
73/// The signatures match the real backend so the public API is identical in
74/// both builds; only the outcome differs.
75#[cfg(not(feature = "hdf5"))]
76mod backend {
77 use super::{NirError, NirGraph, Path, ReadOptions, Result, WriteOptions};
78
79 pub(super) fn read(_path: &Path, _opts: &ReadOptions) -> Result<NirGraph> {
80 Err(NirError::Unimplemented(
81 "io::read (enable feature \"hdf5\")",
82 ))
83 }
84
85 pub(super) fn read_version(_path: &Path, _opts: &ReadOptions) -> Result<String> {
86 Err(NirError::Unimplemented(
87 "io::read_version (enable feature \"hdf5\")",
88 ))
89 }
90
91 pub(super) fn write(_path: &Path, _graph: &NirGraph, _opts: &WriteOptions) -> Result<()> {
92 Err(NirError::Unimplemented(
93 "io::write (enable feature \"hdf5\")",
94 ))
95 }
96}
97
98/// Version string written to `/version` when a graph carries none.
99///
100/// Tracks the upstream `nir` release this crate's wire format is validated
101/// against. Consumers that need a specific value should set
102/// [`NirGraph::version`] or [`WriteOptions::with_version`].
103pub const DEFAULT_NIR_VERSION: &str = "1.0.8";
104
105/// Default gzip level, matching h5py's `compression="gzip"` default.
106const DEFAULT_COMPRESSION: u8 = 4;
107
108/// Allocation policy for decoding an untrusted `.nir` file with [`read_with`].
109///
110/// `max_bytes` is a **decoded-allocation budget**, not an on-disk file-size
111/// limit and not a bound on the returned graph's exact resident size. Charging
112/// is monotonic and conservative: temporary allocations stay charged after
113/// they are released. The exact rules are:
114///
115/// - numeric datasets: element count times decoded width;
116/// - `u64` datasets: both the temporary `Vec<u64>` and converted `Vec<i64>`;
117/// - `i64` extent lists converted to `Vec<usize>` (e.g. `Input.shape`): both the
118/// source `Vec<i64>` and the destination `Vec<usize>`;
119/// - fixed strings: fixed-capacity HDF5 buffers, resulting [`String`] headers,
120/// and the worst-case copied payload;
121/// - variable-length strings: descriptor buffers, payload bytes reported by
122/// `H5Dvlen_get_buf_size`, resulting [`String`] headers, and copied payload.
123/// Scalar VLEN strings use the containing file size as a payload bound
124/// because `H5Dvlen_get_buf_size` can abort on scalar VLEN;
125/// - scalar metadata: its decoded width;
126/// - missing `v_reset` and `w_in`: the synthesized tensor payload.
127///
128/// All arithmetic is checked; overflow is treated as over budget. Node and
129/// link names, collection bookkeeping, allocator overhead, and libhdf5's own
130/// caches are not charged.
131#[derive(Debug, Clone, PartialEq, Eq, Default)]
132#[non_exhaustive]
133pub struct ReadOptions {
134 /// Maximum total bytes charged by decoded allocations, or `None` for no
135 /// allocation budget.
136 pub max_bytes: Option<usize>,
137}
138
139impl ReadOptions {
140 /// Set the decoded-allocation budget in bytes; `None` makes it unbounded.
141 #[must_use]
142 pub fn with_max_bytes(mut self, max_bytes: Option<usize>) -> Self {
143 self.max_bytes = max_bytes;
144 self
145 }
146}
147
148/// Tuning knobs for [`write_with`].
149///
150/// Construct from [`Default`] and adjust:
151///
152/// ```
153/// use nir_rs::io::WriteOptions;
154///
155/// let opts = WriteOptions::default().with_compression(None);
156/// assert_eq!(opts.compression, None);
157/// ```
158#[derive(Debug, Clone, PartialEq, Eq)]
159#[non_exhaustive]
160pub struct WriteOptions {
161 /// Deflate (gzip) level for **numeric** array datasets, must be in `0..=9`.
162 ///
163 /// `None` writes arrays uncompressed. Values above 9 are rejected; use
164 /// [`Self::with_compression`] to clamp automatically.
165 ///
166 /// Three kinds of dataset are never compressed regardless of this setting:
167 ///
168 /// - **Scalars.** HDF5 cannot chunk them, and chunking is a prerequisite
169 /// for any filter.
170 /// - **Empty arrays** — any tensor with a zero-length axis. There are no
171 /// bytes to compress, and a filter would still cost a chunked layout.
172 /// - **String arrays** — `edges` and [`MetadataValue::StringList`]. These
173 /// are variable-length, so the dataset holds only heap descriptors and
174 /// the characters live on HDF5's global heap. A filter applies to the
175 /// descriptors, not to the payload, so deflating them would add chunking
176 /// overhead while compressing almost nothing.
177 ///
178 /// [`MetadataValue::StringList`]: crate::types::MetadataValue::StringList
179 pub compression: Option<u8>,
180 /// Version string to write, overriding [`NirGraph::version`].
181 pub version: Option<String>,
182 /// Run [`NirGraph::validate_structure`] and lossless-representation checks
183 /// before writing. Defaults to `true`.
184 ///
185 /// Set this to `false` to rewrite a graph whose edges do not all resolve,
186 /// or that contains values the wire format cannot preserve (nested graph
187 /// versions, rank-0 metadata tensors). Such files exist in the wild —
188 /// upstream's own `braille_noDelay_bias_zero_subgraph.nir` has a subgraph
189 /// edge naming a node that is not in that subgraph — and [`read`] loads
190 /// them faithfully, so writing them back has to be possible. The resulting
191 /// file will not load in Python `nir.read` with its default type checking,
192 /// and may lose or change the unrepresentable values on readback.
193 pub validate: bool,
194}
195
196impl Default for WriteOptions {
197 fn default() -> Self {
198 Self {
199 compression: Some(DEFAULT_COMPRESSION),
200 version: None,
201 validate: true,
202 }
203 }
204}
205
206impl WriteOptions {
207 /// Set the deflate level for array datasets; `None` disables compression.
208 #[must_use]
209 pub fn with_compression(mut self, level: Option<u8>) -> Self {
210 self.compression = level.map(|l| l.min(9));
211 self
212 }
213
214 /// Override the version string written to `/version`.
215 #[must_use]
216 pub fn with_version(mut self, version: impl Into<String>) -> Self {
217 self.version = Some(version.into());
218 self
219 }
220
221 /// Enable or disable the pre-write structure check.
222 #[must_use]
223 pub fn with_validation(mut self, validate: bool) -> Self {
224 self.validate = validate;
225 self
226 }
227}
228
229/// Read a NIR graph from a `.nir` (HDF5) path.
230///
231/// Absent optional fields are filled with the upstream Python defaults, so a
232/// graph read here matches what `nir.read` produces in memory: a missing
233/// `v_reset` becomes zeros shaped like `v_threshold`, and a missing `w_in`
234/// becomes ones shaped like `v_leak`.
235///
236/// Node **parameters** keep their on-disk float width — an `f32` weight never
237/// becomes `f64`. **Scalar metadata** is the one exception: [`MetadataValue`]
238/// has no `F32` variant, so a scalar `float32` metadata value decodes as
239/// [`MetadataValue::F64`] and is written back as a 64-bit dataset. The value
240/// survives exactly, since `f32` widens to `f64` losslessly; only the wire
241/// dtype of that one dataset changes. Narrower integers likewise widen into
242/// [`MetadataValue::I64`].
243///
244/// **Node order is not preserved.** [`NirGraph::nodes`] is an order-preserving
245/// map, but this reads names in sorted order so that decoding one file twice
246/// gives the same order both times — HDF5 does not promise a link ordering
247/// worth carrying. `edges` is a `Vec` and *is* order-significant, so it is
248/// preserved exactly.
249///
250/// [`MetadataValue`]: crate::types::MetadataValue
251/// [`MetadataValue::F64`]: crate::types::MetadataValue::F64
252/// [`MetadataValue::I64`]: crate::types::MetadataValue::I64
253///
254/// # Errors
255///
256/// - [`NirError::Io`] if the file cannot be opened or is not valid HDF5
257/// - [`NirError::MissingField`] if `/node` or a required node field is absent
258/// - [`NirError::UnknownNodeType`] for a `type` string outside
259/// [`wire::WIRE_TYPES`]
260/// - [`NirError::InvalidTensor`] for a dataset whose element type has no
261/// [`DType`](crate::DType) representation
262/// - [`NirError::InvalidGraph`] for a file that reaches outside its own
263/// container (external links, external raw storage, virtual datasets)
264/// - [`NirError::Unimplemented`] if the `hdf5` feature is off
265///
266/// # Examples
267///
268/// ```no_run
269/// let graph = nir_rs::io::read("model.nir")?;
270/// for (name, node) in &graph.nodes {
271/// println!("{name}: {}", node.type_name());
272/// }
273/// # Ok::<(), nir_rs::NirError>(())
274/// ```
275pub fn read(path: impl AsRef<Path>) -> Result<NirGraph> {
276 read_with(path, &ReadOptions::default())
277}
278
279/// Read a NIR graph with an explicit decoded-allocation budget.
280///
281/// See [`ReadOptions`] for the exact charging rules. Use this entry point for
282/// untrusted files. Plain [`read`] is intentionally unbounded for trusted
283/// callers and backward compatibility.
284///
285/// # Errors
286///
287/// As [`read`], plus [`NirError::ReadLimitExceeded`] when the next decoded
288/// allocation would cross `opts.max_bytes`.
289pub fn read_with(path: impl AsRef<Path>, opts: &ReadOptions) -> Result<NirGraph> {
290 backend::read(path.as_ref(), opts)
291}
292
293/// Read only the `/version` string from a `.nir` file.
294///
295/// # Errors
296///
297/// [`NirError::MissingField`] when the file has no `/version` dataset;
298/// otherwise as [`read`].
299pub fn read_version(path: impl AsRef<Path>) -> Result<String> {
300 read_version_with(path, &ReadOptions::default())
301}
302
303/// Read only `/version` with an explicit decoded-allocation budget.
304///
305/// # Errors
306///
307/// As [`read_version`], plus [`NirError::ReadLimitExceeded`] when decoding the
308/// version string would cross `opts.max_bytes`.
309pub fn read_version_with(path: impl AsRef<Path>, opts: &ReadOptions) -> Result<String> {
310 backend::read_version(path.as_ref(), opts)
311}
312
313/// Write a NIR graph to a `.nir` (HDF5) path atomically.
314///
315/// Equivalent to [`write_with`] using [`WriteOptions::default`] (gzip level 4,
316/// matching h5py).
317///
318/// Data is written to a temporary file inside a private staging directory
319/// (mode `0700` on Unix), flushed, closed, and then atomically renamed over the
320/// destination. A failed write leaves an existing destination unchanged.
321///
322/// **Staging base (Unix):** when the destination parent is untrusted —
323/// group/world-writable without the sticky bit, a symlink path component, or
324/// owned by a UID other than the process effective UID or root — staging
325/// attempts to use sticky temp (if owned by the current user or root, writable,
326/// and with verified symlink-free ancestry) or a private per-user runtime/cache
327/// directory (if all ancestors are owned by the current user or root, non-symlink,
328/// and free of non-sticky group/world-writable modes) so other local users cannot
329/// rename the staging directory away and plant a path for the HDF5 reopen.
330/// Foreign-owned parents are treated as untrusted even at mode `0755`, because
331/// the directory owner can always rename entries (including under a sticky bit).
332/// If no safe staging base is found, the write fails rather than falling back to
333/// the untrusted destination parent. The final replace into a multi-user
334/// non-sticky parent still has residual rename races — prefer private destination
335/// directories on shared hosts.
336///
337/// Existing Unix file permissions (mode bits) are preserved, but **ownership
338/// and group are changed** to those of the writing process, and POSIX ACLs are
339/// not preserved. A new Unix destination uses mode `0o666` filtered by the
340/// process umask.
341///
342/// **SELinux context (Unix):** On SELinux-enforcing hosts, same-filesystem renames
343/// preserve the source inode's security context. When staging under a secure base
344/// such as `/tmp` and renaming onto the destination, the written file may keep
345/// the staging label rather than the destination directory's file-creation
346/// context, which can make it inaccessible to a confined consumer. Creating the
347/// final inode under a hostile (shared/non-sticky) destination parent would
348/// reintroduce path-swap races, so this residual is accepted: apply `restorecon`
349/// or `chcon` after a successful write when a specific context is required.
350///
351/// This does not fsync the file or containing directory, so it
352/// is not a power-loss durability guarantee.
353///
354/// The graph is validated with
355/// [`NirGraph::validate_structure`](crate::NirGraph::validate_structure) first:
356/// a graph with dangling edges would produce a file that upstream refuses to
357/// load, so it is rejected here instead. Opt out with
358/// [`WriteOptions::with_validation`].
359///
360/// # Errors
361///
362/// As [`write_with`].
363///
364/// # Examples
365///
366/// ```no_run
367/// # let graph = nir_rs::NirGraph::new();
368/// nir_rs::io::write("model.nir", &graph)?;
369/// # Ok::<(), nir_rs::NirError>(())
370/// ```
371pub fn write(path: impl AsRef<Path>, graph: &NirGraph) -> Result<()> {
372 write_with(path, graph, &WriteOptions::default())
373}
374
375/// Write a NIR graph to a `.nir` (HDF5) path with explicit options.
376///
377/// Uses the same atomic staging and replacement protocol as [`write()`].
378///
379/// **Symlink handling**: When `path` is a symlink, the atomic rename replaces
380/// the symlink itself rather than updating its target. To update the target
381/// file, pass a resolved path: use [`std::fs::canonicalize`] for a fully
382/// resolved absolute path, or join a relative [`std::fs::read_link`] result
383/// with the symlink's parent before writing (raw `read_link` alone is not
384/// enough when the stored target is relative).
385///
386/// **ACL preservation**: Only basic Unix permission bits (mode) are preserved
387/// from an existing destination. POSIX ACLs and Windows DACLs are **not copied**
388/// to the new inode. If the destination is ACL-protected, the replacement may
389/// change who can access it.
390///
391/// **Multi-user destination directories**: Staging is hardened against parent
392/// directory rename races when the destination parent is shared and non-sticky
393/// (see [`write()`]). If no safe staging base can be found (sticky temp owned by
394/// current user, or private per-user directories with verified ownership ancestry),
395/// the write fails. Cross-device promotion is also rejected when the destination
396/// parent is shared and non-sticky to prevent path-swap vulnerabilities during
397/// local staging. The final `rename` into a multi-user non-sticky parent still
398/// cannot be made fully race-free while HDF5 requires a path reopen; use private
399/// directories when untrusted local users can write the parent.
400///
401/// # Errors
402///
403/// - [`NirError::MissingNode`] / [`NirError::DuplicateEdge`] /
404/// [`NirError::InvalidGraph`] if the graph does not validate, if a node name
405/// or metadata key is not a legal HDF5 link name (see
406/// [`wire::check_link_name`]), if a string payload / edge endpoint contains a
407/// NUL byte, if a `Conv2d.input_shape` is not a length-2 pair, or if it holds
408/// a value the wire format cannot carry back unchanged (a nested graph
409/// version, or a rank-0 metadata tensor)
410/// - [`NirError::Io`] if the file cannot be created or a dataset cannot be
411/// written
412/// - [`NirError::Unimplemented`] if the `hdf5` feature is off
413pub fn write_with(path: impl AsRef<Path>, graph: &NirGraph, opts: &WriteOptions) -> Result<()> {
414 backend::write(path.as_ref(), graph, opts)
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn write_options_default_matches_h5py_gzip() {
423 let opts = WriteOptions::default();
424 assert_eq!(opts.compression, Some(4));
425 assert_eq!(opts.version, None);
426 assert!(opts.validate);
427 }
428
429 #[test]
430 fn write_options_builders() {
431 let opts = WriteOptions::default()
432 .with_compression(Some(200))
433 .with_version("0.2.0")
434 .with_validation(false);
435 assert_eq!(opts.compression, Some(9), "level should clamp to 9");
436 assert_eq!(opts.version.as_deref(), Some("0.2.0"));
437 assert!(!opts.validate);
438
439 let off = WriteOptions::default().with_compression(None);
440 assert_eq!(off.compression, None);
441 }
442
443 #[test]
444 fn read_options_default_is_unbounded() {
445 let opts = ReadOptions::default();
446 assert_eq!(opts.max_bytes, None);
447 assert_eq!(opts.with_max_bytes(Some(4096)).max_bytes, Some(4096));
448 }
449
450 #[cfg(not(feature = "hdf5"))]
451 mod without_feature {
452 use super::*;
453
454 #[test]
455 fn read_is_unimplemented() {
456 let err = read("model.nir").unwrap_err();
457 assert!(matches!(err, NirError::Unimplemented(_)));
458 }
459
460 #[test]
461 fn read_version_is_unimplemented() {
462 let err = read_version("model.nir").unwrap_err();
463 assert!(matches!(err, NirError::Unimplemented(_)));
464 }
465
466 #[test]
467 fn bounded_read_is_unimplemented() {
468 let opts = ReadOptions::default().with_max_bytes(Some(1024));
469 assert!(matches!(
470 read_with("model.nir", &opts).unwrap_err(),
471 NirError::Unimplemented(_)
472 ));
473 assert!(matches!(
474 read_version_with("model.nir", &opts).unwrap_err(),
475 NirError::Unimplemented(_)
476 ));
477 }
478
479 #[test]
480 fn write_is_unimplemented() {
481 let g = NirGraph::new();
482 let err = write("out.nir", &g).unwrap_err();
483 assert!(matches!(err, NirError::Unimplemented(_)));
484 }
485 }
486}