Skip to main content

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}