hdf5_pure/file_lock.rs
1//! The two guards that decide who may open a file: OS advisory locking for the
2//! in-place editor (issue #73), and the superblock's durable status-flags byte
3//! (issue #245).
4//!
5//! Locking is the crash-safe half of HDF5's concurrency model and the `hdf5-pure`
6//! analogue of `H5Pset_file_locking` / the `HDF5_USE_FILE_LOCKING` environment
7//! variable. It is deliberately distinct from the *superblock consistency flag*
8//! (the durable `status_flags` byte a SWMR writer sets; see [`crate::File::open_swmr_writer`]):
9//!
10//! - An **OS lock** is owned by the kernel and tied to the open file. It is
11//! released automatically when the process exits *for any reason* — clean exit,
12//! panic, `SIGKILL`, even power loss — so it never leaves stale state and is
13//! the authoritative signal for "a writer is alive *right now*".
14//! - The **on-disk flag** is just a byte; only userspace code at clean shutdown
15//! can reset it, so a crash freezes it set. Recover it with
16//! [`crate::File::clear_swmr_flag`] (the `h5clear -s` equivalent).
17//! A crash freezing it set is also what makes it useful beyond SWMR — see
18//! [`WRITE_ACCESS`], which a page-buffered session raises for its lifetime.
19//!
20//! Both are enforced here: [`acquire_exclusive`] takes the lock, and
21//! [`check_status_flags`] refuses an open the on-disk byte says is unsafe
22//! (issue #245). The two cover different windows — the lock catches a live
23//! writer in this or another process, the flag catches one that is live *or*
24//! crashed, including a SWMR writer that holds no lock at all.
25//!
26//! ## Lock scope: the in-place editor only
27//!
28//! Only [`crate::File::open_rw`] (and the [`crate::File::clear_swmr_flag`]
29//! recovery rewrite) take a lock — an **exclusive** one — so a second editor or
30//! a concurrent writer cannot open the file. [`crate::File::open_swmr_writer`] and the
31//! readers ([`crate::File::open`] and friends) take **no** lock, on purpose:
32//!
33//! - SWMR is single-writer-*by-contract* and is designed for concurrent reads;
34//! the reference library itself runs SWMR with file locking disabled. Holding
35//! a lock would defeat the "multiple-reader" half.
36//! - Crucially, [`std::fs::File`] locking is **advisory on Unix** (`flock`) but
37//! **mandatory on Windows** (`LockFileEx`): a held lock there blocks *reads* by
38//! every other handle, not just other lock attempts. A whole-file lock on a
39//! SWMR writer would therefore make the file unreadable to its readers on
40//! Windows. Confining locking to the exclusive editor keeps reads working on
41//! every platform. (One consequence: while an editor holds the lock, a
42//! concurrent read of the same file is permitted on Unix but blocked by the OS
43//! on Windows — drop the editor before reading the file back.)
44//!
45//! Locking uses the cross-platform [`std::fs::File`] lock API, so it adds no
46//! dependency, and it lives only in the already `std`-gated edit path, so
47//! `no_std`/`wasm` builds are unaffected.
48
49use std::fs::{File, TryLockError};
50use std::path::Path;
51
52use crate::error::Error;
53use crate::superblock::Superblock;
54
55/// Policy for OS advisory file locking when opening a file for editing.
56///
57/// The default is [`FileLocking::Enabled`]. The `HDF5_USE_FILE_LOCKING`
58/// environment variable, when set to a recognized value, overrides the requested
59/// policy (matching the reference HDF5 library): `FALSE`/`0`/`NO`/`OFF` disable
60/// locking, `BEST_EFFORT` selects best-effort, and `TRUE`/`1`/`YES`/`ON` enable
61/// it.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum FileLocking {
64 /// Acquire the lock, and fail the open with [`Error::Io`] if the filesystem
65 /// does not support locking. A lock held by another process always fails the
66 /// open with [`Error::FileLocked`].
67 #[default]
68 Enabled,
69 /// Do not attempt to lock the file at all.
70 Disabled,
71 /// Attempt to lock, but proceed *without* a lock when the filesystem reports
72 /// that locking is unavailable (e.g. some NFS / network mounts). A lock that
73 /// is genuinely *held* by another process still fails the open. Mirrors the
74 /// reference library's `BEST_EFFORT` / `ignore_disabled_locks`.
75 BestEffort,
76}
77
78/// Parse a recognized `HDF5_USE_FILE_LOCKING` value into a policy, or `None` for
79/// an unrecognized value (in which case the requested policy is kept).
80///
81/// Pure (no environment access) so it can be unit-tested without the
82/// process-global, edition-2024-`unsafe` env mutators.
83fn parse_env(value: &str) -> Option<FileLocking> {
84 let v = value.trim();
85 if v.eq_ignore_ascii_case("FALSE")
86 || v == "0"
87 || v.eq_ignore_ascii_case("NO")
88 || v.eq_ignore_ascii_case("OFF")
89 {
90 Some(FileLocking::Disabled)
91 } else if v.eq_ignore_ascii_case("BEST_EFFORT") {
92 Some(FileLocking::BestEffort)
93 } else if v.eq_ignore_ascii_case("TRUE")
94 || v == "1"
95 || v.eq_ignore_ascii_case("YES")
96 || v.eq_ignore_ascii_case("ON")
97 {
98 Some(FileLocking::Enabled)
99 } else {
100 None
101 }
102}
103
104/// Apply the `HDF5_USE_FILE_LOCKING` environment override to a requested policy.
105/// The environment variable, when set to a recognized value, takes precedence.
106fn resolve(requested: FileLocking) -> FileLocking {
107 std::env::var("HDF5_USE_FILE_LOCKING")
108 .ok()
109 .and_then(|v| parse_env(&v))
110 .unwrap_or(requested)
111}
112
113/// Acquire an **exclusive** advisory lock on `handle` for a writer open.
114///
115/// Non-blocking: if another process holds a conflicting lock, this returns
116/// [`Error::FileLocked`] immediately rather than waiting. The lock is released
117/// when `handle` is dropped (or the process exits, including on a crash).
118pub(crate) fn acquire_exclusive(
119 handle: &File,
120 requested: FileLocking,
121 path: &Path,
122) -> Result<(), Error> {
123 let mode = resolve(requested);
124 if mode == FileLocking::Disabled {
125 return Ok(());
126 }
127 match handle.try_lock() {
128 Ok(()) => Ok(()),
129 // A conflicting lock is genuinely held by another process: the file is
130 // in use. `BestEffort` does not soften this — only *unavailable* locking
131 // is tolerated, not active contention.
132 Err(TryLockError::WouldBlock) => Err(Error::FileLocked(format!(
133 "{}: file is already locked by another process. If a previous writer \
134 crashed, the OS lock is released automatically (try again); a leftover \
135 on-disk SWMR flag can be cleared with File::clear_swmr_flag. Set \
136 HDF5_USE_FILE_LOCKING=FALSE or pass FileLocking::Disabled to bypass locking.",
137 path.display(),
138 ))),
139 // Locking failed for another reason — typically the filesystem does not
140 // support advisory locks (some NFS / network mounts).
141 Err(TryLockError::Error(e)) => match mode {
142 FileLocking::BestEffort => Ok(()),
143 _ => Err(Error::Io(e)),
144 },
145 }
146}
147
148/// Superblock status-flag bit 0 (`H5F_SUPER_WRITE_ACCESS`): the file is open
149/// for write access. The reference C library raises it for *any* writer; this
150/// crate raises it for two:
151///
152/// - [`crate::File::open_swmr_writer`], alongside [`SWMR_WRITE_ACCESS`];
153/// - a session given a page buffer
154/// ([`crate::FileAccessProperties::with_page_buffer_size`]), which raises this
155/// bit alone. That session holds dirty pages across the write engine's
156/// ordering barriers, so a process that died mid-flush could leave a file that
157/// reads clean and returns the wrong bytes; the mark makes it a file every
158/// reader refuses instead (issue #308), until one says with
159/// [`WriteMarkPolicy::AllowSnapshot`] that it knows what it is reading.
160///
161/// An ordinary [`crate::File::open_rw`] session raises nothing, and is guarded by
162/// the OS lock alone.
163pub(crate) const WRITE_ACCESS: u32 = 0x01;
164
165/// Superblock status-flag bit 2 (`H5F_SUPER_SWMR_WRITE_ACCESS`): the writer
166/// holding the file is a SWMR writer, so a SWMR reader may attach to it.
167pub(crate) const SWMR_WRITE_ACCESS: u32 = 0x04;
168
169/// Policy for a read-only open of a file whose superblock marks it as open for
170/// write by a writer that is *not* a SWMR writer — superblock status-flag bit 0
171/// (`H5F_SUPER_WRITE_ACCESS`) alone, the mark a page-buffered session
172/// ([`crate::FileAccessProperties::with_page_buffer_size`]) holds for its life.
173///
174/// Set it with
175/// [`FileAccessProperties::with_write_mark_policy`](crate::FileAccessProperties::with_write_mark_policy),
176/// which states when the assertion [`AllowSnapshot`](Self::AllowSnapshot) makes
177/// is true. It governs the read-only opens only: a SWMR pair is followed with
178/// [`crate::File::open_swmr`] whatever this says, and no value of it lets a
179/// second writer join a file a writer holds.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
181pub enum WriteMarkPolicy {
182 /// Refuse the open with [`Error::FileMarkedInUse`]. The default, and what
183 /// `H5Fopen` does with the same byte.
184 #[default]
185 Refuse,
186 /// Read the file as it stands, on the caller's assertion that the writer
187 /// has flushed: it called [`crate::File::sync`], or it stopped after a flush
188 /// and the mark stands only because nothing cleared it. Every other refusal
189 /// stays in place.
190 AllowSnapshot,
191}
192
193/// What an open intends to do with the file, selecting which status-flag
194/// combinations [`check_status_flags`] refuses.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub(crate) enum OpenIntent {
197 /// A plain read — [`crate::File::open`] and
198 /// [`crate::File::open_streaming`], the C library's `H5F_ACC_RDONLY`.
199 ///
200 /// Carries the caller's [`WriteMarkPolicy`], which is the one intent it can
201 /// mean anything for: the other two have no snapshot to take.
202 Read(WriteMarkPolicy),
203 /// A SWMR read — [`crate::File::open_swmr`], the C library's
204 /// `H5F_ACC_RDONLY | H5F_ACC_SWMR_READ`.
205 SwmrRead,
206 /// An open that may modify the file — [`crate::File::open_rw`] and
207 /// [`crate::File::open_swmr_writer`], the C library's `H5F_ACC_RDWR` with or
208 /// without `H5F_ACC_SWMR_WRITE`.
209 Write,
210}
211
212/// What an open was asked to open, for the refusal to name.
213///
214/// The companion of [`OpenIntent`]: that says why the file was being opened,
215/// this says what the file *was*. Neither reaches the decision — the superblock
216/// makes that alone — but both shape what the error can usefully say, and the
217/// difference is not only cosmetic. Two of the three recoveries a refused
218/// snapshot read would otherwise be told to try, [`crate::File::open_swmr`] and
219/// [`crate::File::clear_swmr_flag`], need a filesystem path; naming them to a
220/// caller who opened a byte source would be advice it cannot take.
221///
222/// A closed set rather than a `&dyn Display`, so the wording every refusal
223/// leads with lives here beside the reasons it is concatenated with, and a
224/// third kind of open arrives as a variant a reviewer sees.
225#[derive(Debug, Clone, Copy)]
226pub(crate) enum OpenTarget<'a> {
227 /// A file opened by name, which the refusal reports and whose flag
228 /// [`crate::File::clear_swmr_flag`] can clear in place.
229 Path(&'a Path),
230 /// Bytes handed to [`crate::File::from_source`], which have no name and no
231 /// in-place recovery.
232 Source,
233}
234
235impl OpenTarget<'_> {
236 /// What a caller refused a snapshot read can do instead, which depends on
237 /// both the target and the mark.
238 ///
239 /// The target, because a source has no path for `open_swmr` to follow or for
240 /// `clear_swmr_flag` to write to. The mark, because `open_swmr` follows a
241 /// SWMR writer and nothing else: naming it to a caller refused by
242 /// [`WRITE_ACCESS`] alone would name a recovery that fails in its turn, the
243 /// mismatched pair a SWMR reader refuses (issue #419). That caller's opt-in
244 /// is [`WriteMarkPolicy::AllowSnapshot`], which is what this names instead.
245 ///
246 /// `swmr_claimed` is the SWMR bit, not the pair: a file carrying it without
247 /// [`WRITE_ACCESS`] claims a SWMR writer this crate's opt-in deliberately
248 /// does not unlock, so it is pointed at the SWMR reader — whose own refusal
249 /// then names the inconsistency — rather than at an opt-in that would refuse
250 /// it again.
251 fn read_recovery(self, swmr_claimed: bool) -> &'static str {
252 match (self, swmr_claimed) {
253 (Self::Path(_), true) => {
254 "Use File::open_swmr to follow a live SWMR writer, or File::from_bytes to read \
255 the bytes as they stand; if a writer exited without closing the file, clear \
256 the flag with File::clear_swmr_flag"
257 }
258 (Self::Source, true) => {
259 "Use File::from_bytes to read the bytes as they stand; following a live writer \
260 with File::open_swmr, and clearing a flag a writer left behind with \
261 File::clear_swmr_flag, both need a filesystem path"
262 }
263 (Self::Path(_), false) => {
264 "No SWMR writer holds it, so no reader can follow it as one. Pass \
265 FileAccessProperties::with_write_mark_policy(WriteMarkPolicy::AllowSnapshot) to \
266 read it as it stands, which is a consistent snapshot once the writer has called \
267 File::sync or closed the file, or use File::from_bytes to read the bytes as they \
268 stand; if a writer exited without closing the file, clear the flag with \
269 File::clear_swmr_flag"
270 }
271 (Self::Source, false) => {
272 "No SWMR writer holds it, so no reader can follow it as one. Pass \
273 FileAccessProperties::with_write_mark_policy(WriteMarkPolicy::AllowSnapshot) to \
274 read it as it stands, which is a consistent snapshot once the writer has called \
275 File::sync or closed the file, or use File::from_bytes to read the bytes as they \
276 stand; clearing a flag a writer left behind with File::clear_swmr_flag needs a \
277 filesystem path"
278 }
279 }
280 }
281}
282
283impl core::fmt::Display for OpenTarget<'_> {
284 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
285 match self {
286 Self::Path(path) => write!(f, "{}", path.display()),
287 Self::Source => f.write_str("a byte source"),
288 }
289 }
290}
291
292/// Refuse an open the superblock's status-flags byte says is unsafe, matching
293/// `H5F_open`'s check of the same byte (issue #245).
294///
295/// The byte records that a writer holds the file. It is *durable*, so it means
296/// either "a writer is active right now" or "a writer exited without clearing
297/// it" — the two are indistinguishable from the byte alone, which is why the
298/// refusal names [`crate::File::clear_swmr_flag`] as the recovery rather than
299/// guessing. The rules, one per intent:
300///
301/// - [`Write`](OpenIntent::Write) refuses either bit: a second writer must not
302/// join a file a writer already holds. This is the one case an OS lock does
303/// not already cover, because a SWMR writer takes no lock.
304/// - [`Read`](OpenIntent::Read) refuses either bit: a plain reader buffers a
305/// snapshot with no protocol for a writer mutating the file underneath it. To
306/// follow a live SWMR writer, use [`crate::File::open_swmr`]. The one way past
307/// it is [`WriteMarkPolicy::AllowSnapshot`], which the intent carries: it
308/// admits a read of [`WRITE_ACCESS`] *alone* — the mark a page-buffered
309/// session leaves, which no SWMR reader can follow (issue #419) — on the
310/// caller's assertion that the writer has flushed. It does not admit a SWMR
311/// pair, which has a reader of its own.
312/// - [`SwmrRead`](OpenIntent::SwmrRead) refuses only a *mismatched* pair — one
313/// bit without the other. Both bits is exactly the live SWMR writer it exists
314/// to follow, and neither is a quiescent file.
315///
316/// ## Why only a version-3 superblock
317///
318/// The check is gated to superblock version 3 and up because that is where the
319/// C library gates it, and a divergence in either direction is a real cost: the
320/// C library raises the write bit on a version-0/1/2 file too and never reads
321/// it back, so checking those versions would refuse files `H5Fopen` accepts —
322/// every file left behind by a crashed C writer that predates SWMR. Nothing is
323/// lost by matching it: both paths that raise a flag here require a version-3
324/// superblock — SWMR writing because both libraries do (see
325/// [`crate::File::open_swmr_writer`]), and a page buffer because it refuses to
326/// buffer behind a mark no reader would honor — so no flag this crate raises
327/// falls outside the gate.
328///
329/// `target` does not enter the decision, which the superblock and the intent's
330/// own policy make between them. It shapes the error: what to call the file that
331/// was refused, and which recoveries to name, since some of them need a
332/// filesystem path.
333pub(crate) fn check_status_flags(
334 superblock: &Superblock,
335 intent: OpenIntent,
336 target: OpenTarget<'_>,
337) -> Result<(), Error> {
338 if superblock.version < 3 {
339 return Ok(());
340 }
341 let flags = superblock.consistency_flags;
342 let write = flags & WRITE_ACCESS != 0;
343 let swmr = flags & SWMR_WRITE_ACCESS != 0;
344 // Each arm states the whole condition: "marked open for write" describes two
345 // of the three, and reporting the SWMR-read mismatch that way would misname
346 // the case where only the SWMR bit is set.
347 let reason = match intent {
348 OpenIntent::Write if write || swmr => format!(
349 "the superblock marks the file as open for write (status flags {flags:#04x}), so \
350 another writer holds it. Open it read-only, or — if a writer exited without \
351 closing the file — clear the flag with File::clear_swmr_flag"
352 ),
353 // The opt-in is checked inside the arm rather than in its guard so that
354 // the arm keeps stating the whole rule in one place: what the flags say,
355 // and the one assertion that overrides it.
356 OpenIntent::Read(policy) if write || swmr => {
357 if policy == WriteMarkPolicy::AllowSnapshot && !swmr {
358 return Ok(());
359 }
360 format!(
361 "the superblock marks the file as open for write (status flags {flags:#04x}), so \
362 a snapshot read is not safe. {}",
363 target.read_recovery(swmr)
364 )
365 }
366 OpenIntent::SwmrRead if write != swmr => format!(
367 "the superblock's status flags disagree ({flags:#04x}): a SWMR reader needs a SWMR \
368 writer (both the write and SWMR-write bits) or a quiescent file (neither). Clear \
369 them with File::clear_swmr_flag if a writer exited without closing the file"
370 ),
371 _ => return Ok(()),
372 };
373 Err(Error::FileMarkedInUse(format!("{target}: {reason}.")))
374}
375
376/// Clear a stale status flag left in `path` by a writer that exited without a
377/// clean close — the `h5clear -s` equivalent, behind
378/// [`File::clear_swmr_flag`](crate::File::clear_swmr_flag). Safe to call on a
379/// file whose flag is already clear.
380///
381/// It clears the byte whole, so it recovers a page-buffered session's crash mark
382/// ([`WRITE_ACCESS`]) as well as a SWMR writer's pair. What it recovers is
383/// *access*, not correctness: a page-buffered writer that crashed may have left
384/// the file inconsistent in ways no checksum shows, which is the whole reason
385/// the mark stands. `h5clear` makes the same trade.
386///
387/// This is the one recovery rewrite that takes the exclusive lock without going
388/// through the editor, which is why it lives beside the locking policy it
389/// depends on.
390pub(crate) fn clear_swmr_flag_at(path: &Path) -> Result<(), Error> {
391 use crate::signature;
392 use std::fs::OpenOptions;
393 use std::io::{Read, Seek, SeekFrom, Write};
394
395 let mut w = OpenOptions::new()
396 .read(true)
397 .write(true)
398 .open(path)
399 .map_err(Error::Io)?;
400 // Refuse to clear the flag out from under a live writer: an exclusive
401 // lock here fails with `FileLocked` if another writer still holds the
402 // file. A stale flag from a *crashed* writer has no live lock, so this
403 // succeeds and the recovery proceeds.
404 acquire_exclusive(&w, FileLocking::Enabled, path)?;
405 let mut data = Vec::new();
406 w.read_to_end(&mut data).map_err(Error::Io)?;
407 let sig = signature::find_signature(&data)?;
408 let mut sb = Superblock::parse(&data, sig)?;
409 if sb.version < 2 {
410 // `Superblock::serialize` emits the v2/v3 layout, so rewriting a
411 // v0/v1 superblock here would corrupt it. This crate never SWMR-flags
412 // a v0/v1 file, so there is nothing to clear; treat it as already
413 // clean rather than risk a destructive rewrite.
414 return Ok(());
415 }
416 if sb.consistency_flags == 0 {
417 return Ok(());
418 }
419 sb.consistency_flags = 0;
420 let bytes = sb.serialize();
421 w.seek(SeekFrom::Start(sig as u64)).map_err(Error::Io)?;
422 w.write_all(&bytes).map_err(Error::Io)?;
423 w.sync_data().map_err(Error::Io)?;
424 Ok(())
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn parse_env_recognizes_disable_values() {
433 for v in ["FALSE", "false", "0", "No", "off", " false "] {
434 assert_eq!(parse_env(v), Some(FileLocking::Disabled), "value {v:?}");
435 }
436 }
437
438 #[test]
439 fn parse_env_recognizes_enable_and_best_effort() {
440 for v in ["TRUE", "true", "1", "Yes", "on"] {
441 assert_eq!(parse_env(v), Some(FileLocking::Enabled), "value {v:?}");
442 }
443 assert_eq!(parse_env("BEST_EFFORT"), Some(FileLocking::BestEffort));
444 assert_eq!(parse_env("best_effort"), Some(FileLocking::BestEffort));
445 }
446
447 #[test]
448 fn parse_env_unrecognized_is_none() {
449 assert_eq!(parse_env(""), None);
450 assert_eq!(parse_env("maybe"), None);
451 assert_eq!(parse_env("2"), None);
452 }
453
454 #[test]
455 fn default_is_enabled() {
456 assert_eq!(FileLocking::default(), FileLocking::Enabled);
457 }
458
459 /// A minimal superblock carrying `version` and `flags`; every other field is
460 /// irrelevant to the status-flag rules.
461 fn flagged(version: u8, flags: u32) -> Superblock {
462 Superblock {
463 version,
464 offset_size: 8,
465 length_size: 8,
466 base_address: crate::address::BaseAddress::ZERO,
467 eof_address: 0,
468 root_group_address: 0,
469 group_leaf_node_k: None,
470 group_internal_node_k: None,
471 indexed_storage_internal_node_k: None,
472 free_space_address: None,
473 driver_info_address: None,
474 consistency_flags: flags,
475 superblock_extension_address: None,
476 checksum: None,
477 }
478 }
479
480 /// A snapshot read under the default policy, which is what every rule
481 /// below is stated against unless it names the opt-in.
482 const READ: OpenIntent = OpenIntent::Read(WriteMarkPolicy::Refuse);
483
484 fn allows(version: u8, flags: u32, intent: OpenIntent) -> bool {
485 check_status_flags(
486 &flagged(version, flags),
487 intent,
488 OpenTarget::Path(Path::new("f.h5")),
489 )
490 .is_ok()
491 }
492
493 /// The full rule, stated per intent rather than per flag value, so a table
494 /// entry that moves has to move for a reason someone can name.
495 #[test]
496 fn status_flag_rules_per_intent() {
497 for flags in [0x00, WRITE_ACCESS, SWMR_WRITE_ACCESS, 0x05] {
498 let held = flags != 0;
499 assert_eq!(
500 allows(3, flags, OpenIntent::Write),
501 !held,
502 "a writer may open a file only when no flag claims it (flags {flags:#04x})"
503 );
504 assert_eq!(
505 allows(3, flags, READ),
506 !held,
507 "a snapshot read is refused whenever a writer holds the file (flags {flags:#04x})"
508 );
509 assert_eq!(
510 allows(3, flags, OpenIntent::SwmrRead),
511 flags == 0x00 || flags == 0x05,
512 "a SWMR reader needs both bits or neither (flags {flags:#04x})"
513 );
514 }
515 }
516
517 /// Bit 1 (`H5F_SUPER_FILE_OK`) is not one of the two the C library consults,
518 /// so a file carrying only it opens for any intent.
519 #[test]
520 fn the_file_ok_bit_alone_refuses_nothing() {
521 for intent in [READ, OpenIntent::SwmrRead, OpenIntent::Write] {
522 assert!(allows(3, 0x02, intent), "{intent:?} refused flags 0x02");
523 }
524 }
525
526 /// Versions below 3 are not checked at all: the C library raises the write
527 /// bit on them and never reads it back, so refusing one would refuse a file
528 /// `H5Fopen` accepts.
529 #[test]
530 fn an_older_superblock_is_not_checked() {
531 for version in [0, 1, 2] {
532 for intent in [READ, OpenIntent::SwmrRead, OpenIntent::Write] {
533 assert!(
534 allows(version, 0x05, intent),
535 "v{version} superblock refused {intent:?} on flags 0x05"
536 );
537 }
538 }
539 }
540
541 /// The refusal has to say what to do next: the path, the flags, and the
542 /// recovery a user would otherwise have to find in the C library's docs.
543 #[test]
544 fn the_refusal_names_the_recovery() {
545 let err = check_status_flags(&flagged(3, 0x05), READ, OpenTarget::Path(Path::new("d.h5")))
546 .expect_err("a flagged file is refused for a snapshot read");
547 let msg = err.to_string();
548 assert!(matches!(err, Error::FileMarkedInUse(_)), "got {err:?}");
549 // `from_bytes` is named because it is the only way through for a flagged
550 // file on a read-only mount, where `clear_swmr_flag` cannot get the write
551 // access it needs.
552 for part in ["d.h5", "0x05", "clear_swmr_flag", "open_swmr", "from_bytes"] {
553 assert!(msg.contains(part), "refusal does not mention {part}: {msg}");
554 }
555 }
556
557 /// The recovery a source is offered is the one a source can reach.
558 ///
559 /// Two of the three the path refusal names — `open_swmr` to follow the
560 /// writer, `clear_swmr_flag` to clear what one left behind — take a path,
561 /// so handing them to a caller that opened a byte source would be advice it
562 /// cannot take. The refusal has to say which half applies, and the check
563 /// below is that it does: the path wording contains none of this text, so
564 /// a `Source` target falling back to it fails here.
565 #[test]
566 fn a_source_is_told_which_recovery_it_can_reach() {
567 let err = check_status_flags(&flagged(3, 0x05), READ, OpenTarget::Source)
568 .expect_err("a flagged file is refused for a snapshot read");
569 let msg = err.to_string();
570 assert!(
571 msg.starts_with("file is marked in use: a byte source:"),
572 "the refusal does not name the source it refused: {msg}"
573 );
574 assert!(
575 msg.contains("File::from_bytes to read the bytes as they stand"),
576 "the refusal does not name the one recovery a source can reach: {msg}"
577 );
578 assert!(
579 msg.contains("both need a filesystem path"),
580 "the refusal offers path-only recoveries without saying they need a path: {msg}"
581 );
582 }
583
584 /// The mark a page-buffered writer leaves is the write bit alone, which no
585 /// SWMR reader can follow — so the refusal must not send the caller to
586 /// `File::open_swmr`, which refuses the same file in its turn (issue #419).
587 /// What applies instead is the opt-in, named in full so it can be pasted.
588 #[test]
589 fn a_write_only_mark_names_the_snapshot_opt_in() {
590 let err = check_status_flags(
591 &flagged(3, WRITE_ACCESS),
592 READ,
593 OpenTarget::Path(Path::new("buffered.h5")),
594 )
595 .expect_err("a marked file is refused for a snapshot read");
596 let msg = err.to_string();
597 assert!(matches!(err, Error::FileMarkedInUse(_)), "got {err:?}");
598 for part in [
599 "buffered.h5",
600 "0x01",
601 "FileAccessProperties::with_write_mark_policy(WriteMarkPolicy::AllowSnapshot)",
602 "File::sync",
603 "from_bytes",
604 "clear_swmr_flag",
605 ] {
606 assert!(msg.contains(part), "refusal does not mention {part}: {msg}");
607 }
608 assert!(
609 !msg.contains("open_swmr"),
610 "refusal names a reader that refuses this mark too: {msg}"
611 );
612 }
613
614 /// A source refused by the same mark is offered the opt-in, which it can
615 /// take — it is a property of the open, not of the path — and told that the
616 /// recovery it cannot take needs one.
617 #[test]
618 fn a_source_refused_by_a_write_only_mark_is_offered_the_opt_in() {
619 let err = check_status_flags(&flagged(3, WRITE_ACCESS), READ, OpenTarget::Source)
620 .expect_err("a marked file is refused for a snapshot read");
621 let msg = err.to_string();
622 assert!(
623 msg.contains("with_write_mark_policy(WriteMarkPolicy::AllowSnapshot)"),
624 "the refusal does not name the opt-in a source can take: {msg}"
625 );
626 assert!(
627 msg.contains("File::clear_swmr_flag needs a filesystem path"),
628 "the refusal offers a path-only recovery without saying it needs a path: {msg}"
629 );
630 assert!(!msg.contains("open_swmr"), "got {msg}");
631 }
632
633 /// The other half: the pair a SWMR writer leaves keeps the wording that
634 /// names its reader, and does not offer an opt-in that would refuse it.
635 #[test]
636 fn a_swmr_pair_names_its_reader_and_not_the_opt_in() {
637 let err = check_status_flags(
638 &flagged(3, WRITE_ACCESS | SWMR_WRITE_ACCESS),
639 READ,
640 OpenTarget::Path(Path::new("swmr.h5")),
641 )
642 .expect_err("a marked file is refused for a snapshot read");
643 let msg = err.to_string();
644 assert!(msg.contains("File::open_swmr"), "got {msg}");
645 assert!(
646 !msg.contains("write_mark_policy"),
647 "the opt-in does not admit a SWMR pair, so the refusal must not name it: {msg}"
648 );
649 }
650
651 /// What the opt-in admits, stated as the whole rule: the write bit alone,
652 /// and nothing carrying the SWMR bit — that file has a reader of its own.
653 /// The read-write opens cannot reach this at all, since only
654 /// [`OpenIntent::Read`] carries a policy.
655 #[test]
656 fn the_snapshot_opt_in_admits_the_write_mark_alone() {
657 for flags in [0x00, WRITE_ACCESS, SWMR_WRITE_ACCESS, 0x05] {
658 let allowed = flags & SWMR_WRITE_ACCESS == 0;
659 assert_eq!(
660 allows(3, flags, OpenIntent::Read(WriteMarkPolicy::AllowSnapshot)),
661 allowed,
662 "AllowSnapshot on flags {flags:#04x}"
663 );
664 }
665 }
666
667 /// Write a file at `path` whose superblock carries `version` and `flags`,
668 /// re-serializing the superblock so its checksum stays valid.
669 fn write_file_with(path: &Path, version: u8, flags: u32) {
670 let mut bytes = crate::writer::FileBuilder::new().finish().unwrap();
671 let off = crate::signature::find_signature(&bytes).unwrap();
672 let mut sb = Superblock::parse(&bytes, off).unwrap();
673 assert_eq!(sb.version, 3, "this writer emits a v3 superblock");
674 sb.version = version;
675 sb.consistency_flags = flags;
676 let patched = sb.serialize();
677 bytes[off..off + patched.len()].copy_from_slice(&patched);
678 std::fs::write(path, &bytes).unwrap();
679 }
680
681 /// The version gate is checked against a real file, not only against a
682 /// hand-built `Superblock`: a v2 file whose write flag is set still opens
683 /// through `File::open`, which is the C-library parity the gate exists for.
684 /// (v2 and v3 superblocks share a byte layout, so the rewrite above is the
685 /// whole difference between them.)
686 #[test]
687 fn a_flagged_v2_file_still_opens() {
688 let dir = tempfile::tempdir().unwrap();
689 let path = dir.path().join("v2.h5");
690 write_file_with(&path, 2, WRITE_ACCESS | SWMR_WRITE_ACCESS);
691
692 let file = crate::File::open(&path).expect("a v2 file's status flags are not checked");
693 assert_eq!(file.superblock().consistency_flags, 0x05);
694 }
695
696 /// The other half of the version gate: nothing this crate does raises a flag
697 /// on a superblock the gate skips, because the SWMR writer — the only path
698 /// that raises one — refuses a pre-v3 file outright, as the C library does.
699 #[test]
700 fn the_swmr_writer_refuses_a_superblock_the_gate_would_skip() {
701 let dir = tempfile::tempdir().unwrap();
702 let path = dir.path().join("v2.h5");
703 write_file_with(&path, 2, 0);
704
705 let err = crate::File::open_swmr_writer(&path)
706 .expect_err("SWMR writing requires a v3 superblock");
707 assert!(
708 matches!(err, Error::SwmrAppendUnsupported(_)),
709 "got {err:?}"
710 );
711 let bytes = std::fs::read(&path).unwrap();
712 let off = crate::signature::find_signature(&bytes).unwrap();
713 assert_eq!(
714 bytes[off + 11],
715 0,
716 "a refused writer must not have flagged the file on its way out"
717 );
718 }
719
720 /// Half a flag pair is what a *plain* (non-SWMR) C-library writer leaves, and
721 /// a SWMR reader has no protocol for following one, so `File::open_swmr`
722 /// refuses it where it accepts the full pair. Exercised on a real file
723 /// because the mismatch rule is the one branch a caller can reach only
724 /// through a file another library wrote.
725 #[test]
726 fn a_swmr_reader_refuses_write_access_without_the_swmr_bit() {
727 let dir = tempfile::tempdir().unwrap();
728 let path = dir.path().join("half.h5");
729 write_file_with(&path, 3, WRITE_ACCESS);
730
731 let err = crate::File::open_swmr(&path)
732 .expect_err("a SWMR reader needs a SWMR writer, not a plain one");
733 assert!(matches!(err, Error::FileMarkedInUse(_)), "got {err:?}");
734 assert!(
735 crate::File::open(&path).is_err(),
736 "a snapshot read is refused too"
737 );
738
739 write_file_with(&path, 3, WRITE_ACCESS | SWMR_WRITE_ACCESS);
740 crate::File::open_swmr(&path).expect("the full pair is the writer it follows");
741 }
742}