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