Skip to main content

godspeed/
fs.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Files, without the byte layout.
3//!
4//! # What this replaces
5//!
6//! Reading a file today means knowing the wire protocol. From `services/shell`, abbreviated:
7//!
8//! ```text
9//!   let stat = fs_request_q(ctx, OP_STAT_FILE, path, &[]);   // three outcome arms
10//!   let sp = stat.payload_bytes();
11//!   let exists = sp.first() == Some(&FS_OK) && sp.len() >= 11 && sp[1] == 1;
12//!   let is_dir = exists && sp[10] == 1;
13//!   let size = u64::from_le_bytes([sp[2], sp[3], sp[4], sp[5], sp[6], sp[7], sp[8], sp[9]]);
14//!   // then loop read_at in IO_CHUNK pieces, checking errors at each step
15//! ```
16//!
17//! Roughly thirty lines, every one an opportunity to index the wrong byte. `FS_OK` was declared
18//! independently in four crates, and the shell re-declared 21 of the filesystem's 27 opcodes. A
19//! guessed opcode in one of them (`1` where the protocol says `10`) made a command write nothing
20//! for twelve seconds and report that it was done.
21//!
22//! **The layout is owned here, once.** It is a fact `services/fs` defines; every other crate was
23//! re-deriving it from a comment.
24//!
25//! # There is no `read_to_string`
26//!
27//! GodspeedOS has no heap, deliberately (CLAUDE.md ยง26.6.1: fixed stack arrays and bounded arenas,
28//! so the maximum a subsystem can use is readable from its source). A function returning an
29//! allocated `String` cannot exist, and adding an allocator to make this library look like Rust's
30//! would be trading the architecture for a familiar shape.
31//!
32//! So the caller owns the buffer:
33//!
34//! ```ignore
35//! let mut buf = [0u8; 4096];
36//! let n = fs::read_into(&fs, "/data/hello.txt", &mut buf)?;
37//! io::println(ctx, core::str::from_utf8(&buf[..n]).unwrap_or("<not utf-8>"));
38//! ```
39//!
40//! Six lines against thirty, no byte indices, and the bound is visible in the source.
41
42use godspeed_sdk::ipc::Message;
43use godspeed_sdk::service_context::ServiceContext;
44
45use crate::call;
46use crate::error::{from_fs_status, Error};
47
48/// How long to wait for a whole-volume sweep ([`Fs::check`] or [`Fs::scrub`]).
49///
50/// **Far longer than an ordinary call, and that is the point.** These walk every referenced block on
51/// the volume; a chunk write and a full scrub are each one request and one reply, and only one of
52/// them can be expected back in seconds. A deadline shorter than the operation turns a slow success
53/// into [`Error::OutcomeUnknown`], which is worse than waiting because it forbids the retry that
54/// would have fixed it.
55pub const SWEEP_SECS: i64 = 120;
56
57/// What [`Fs::check`] repaired, and what it found.
58#[derive(Copy, Clone, PartialEq, Eq, Debug)]
59pub struct Check {
60    /// Files reachable from the root.
61    pub files: u32,
62    /// Directories reachable from the root.
63    pub dirs: u32,
64    /// Entries whose CRC did not verify. **Not repaired** - reported, so somebody decides.
65    pub bad: u32,
66    /// Blocks the rebuilt bitmap says are in use.
67    pub used: u64,
68    /// Blocks now free.
69    pub free: u64,
70    /// What the superblock claimed was free BEFORE the rebuild. Compare it with `free`: equal means
71    /// nothing needed repairing, and different means the count had drifted and now has not.
72    pub free_before: u64,
73}
74
75/// What [`Fs::scrub`] found. Read-only: it changes nothing on disk.
76#[derive(Copy, Clone, PartialEq, Eq, Debug)]
77pub struct Scrub {
78    /// Files reachable from the root.
79    pub files: u32,
80    /// Directories reachable from the root.
81    pub dirs: u32,
82    /// Entries whose CRC did not verify.
83    pub bad: u32,
84    /// Blocks read and verified.
85    pub scanned: u64,
86}
87
88/// What [`Fs::drive`] reports about the storage `fs` serves.
89#[derive(Copy, Clone, PartialEq, Eq, Debug)]
90pub struct Drive {
91    /// The device's size in 512-byte sectors, as `fs` re-derives it from the block driver on each ask.
92    /// ZERO means there is no storage device at all - not a device of size zero.
93    pub capacity_sectors: u64,
94    /// Whether a GSFS volume is mounted on it.
95    pub mounted: bool,
96    /// Blocks in the volume, and blocks in use (zero when nothing is mounted).
97    pub total_blocks: u64,
98    pub used_blocks: u64,
99    label: [u8; 31],
100    label_len: u8,
101}
102
103impl Drive {
104    /// The volume's label, as bytes; empty when it has none or nothing is mounted.
105    pub fn label(&self) -> &[u8] {
106        &self.label[..self.label_len as usize]
107    }
108}
109
110/// The longest path `services/fs` accepts. A longer one is [`Error::InvalidInput`] and is never
111/// sent, rather than being silently truncated into a request for a DIFFERENT file.
112///
113/// # Paths are BYTES
114///
115/// Every path parameter here takes `impl AsRef<[u8]>`, so a string literal works as it reads -
116/// `fs.read_into("/data/message.txt", &mut buf)` - and so does a byte slice a program is holding
117/// from somewhere else.
118///
119/// That is not generality for its own sake. `services/fs` accepts any byte above 0x1f except `/`
120/// and 0x7f in a name, so a disk prepared elsewhere holds names that are not UTF-8, and this OS must
121/// still be able to list and DELETE them. A `&str`-only API forces such a caller through
122/// `from_utf8(..).unwrap_or("")`, which turns an unreadable name into a request for a different
123/// file - silently. [`DirEntry::name`] hands back raw bytes for the same reason; these parameters
124/// accept them for the other half of the round trip.
125pub const PATH_MAX: usize = 120;
126
127/// The most file content one request can carry, as `services/fs` frames it (7 * 508). Reads and
128/// writes larger than this are split by [`read_into`](Fs::read_into) and [`write`]; it is public because a caller
129/// sizing its own buffer benefits from knowing the natural stride.
130pub const IO_CHUNK: usize = 7 * 508;
131
132// The opcodes, owned here. These are `services/fs`'s numbers and must not be guessed.
133const OP_WRITE_FILE: u8 = 10;
134const OP_LIST_DIR: u8 = 14;
135const OP_MOVE: u8 = 17;
136const OP_MKDIR_P: u8 = 18;
137const OP_DELETE_TREE: u8 = 19;
138const OP_OPEN: u8 = 30;
139const OP_CHECK: u8 = 27;
140const OP_SEAL: u8 = 31;
141const OP_QUIESCE: u8 = 32;
142const OP_SCRUB: u8 = 29;
143const OP_STAT_FILE: u8 = 12;
144const OP_MKDIR: u8 = 13;
145const OP_DELETE: u8 = 16;
146const OP_RENAME: u8 = 15;
147const OP_WRITE_NEW: u8 = 24;
148const OP_WRITE_AT: u8 = 25;
149const OP_READ_AT: u8 = 26;
150const OP_DRIVES_INFO: u8 = 20;
151
152/// The tag a fresh handle starts from. Any non-zero value would do; what matters is that
153/// consecutive requests on one channel differ.
154///
155/// The tag is the first byte of every request and of its reply, so a late answer to an EARLIER
156/// request is recognised instead of being read as the answer to this one. This is not decoration:
157/// without it, running one command twice could leave the filesystem protocol "out of step" - a
158/// reply arriving after its deadline matched to the next request, and every exchange after it
159/// answering the question before.
160///
161/// **There is deliberately no separate tag RANGE for this library.** An earlier draft minted tags
162/// from a 0xC0..0xFF band, which reads as tidy and is actively harmful: `services/shell` mints
163/// from the whole 1..=255 range, so the two overlapped, and a collision does not fail loudly - it
164/// lets a stale reply be ACCEPTED as the current request's answer. Two counters on one channel is
165/// the bug the tag exists to prevent. Use [`Fs::from_tag`] to share the one counter instead.
166const TAG_START: u8 = 1;
167
168/// The most of a service-supplied failure reason [`Fs::reason`] keeps. Bounded on purpose: the
169/// handle is a stack value, so its size must be readable from this line (26.6.1).
170pub const REASON_MAX: usize = 64;
171
172/// The most pages [`Fs::list_dir`] will request before stopping and saying so.
173///
174/// The same bound `services/shell` uses. A walk has to terminate on its own rather than on the
175/// service being well-behaved (26.6), and 512 pages is far past any directory this filesystem
176/// holds while still being a number rather than "however many it takes".
177pub const DIR_PAGE_MAX: u16 = 512;
178
179/// What [`Fs::list_dir`] did.
180///
181/// `visited` cannot be read without seeing `complete`, and that is the whole point of it being a
182/// struct: a partial listing that reads as a full one is the failure this type exists to prevent.
183#[derive(Copy, Clone, PartialEq, Eq, Debug)]
184pub struct Listing {
185    /// How many entries the closure was shown.
186    pub visited: usize,
187    /// `true` if the directory ended. `false` means the walk stopped first - the closure returned
188    /// `false`, or [`DIR_PAGE_MAX`] was reached - and there are MORE ENTRIES THAN YOU SAW. Say so
189    /// in whatever you report; do not present the count as the size of the directory.
190    pub complete: bool,
191}
192
193/// One entry from [`Fs::list_dir`].
194///
195/// `name` is BYTES, not `&str`, and that is not laziness. `services/fs` accepts any byte above
196/// 0x1f except `/` and 0x7f in a name, so a name is not guaranteed to be UTF-8 and a type that
197/// promised otherwise would be lying about the filesystem it reads. [`DirEntry::name_str`] is the
198/// convenience for the ordinary case.
199#[derive(Copy, Clone, PartialEq, Eq, Debug)]
200pub struct DirEntry<'n> {
201    /// The entry's name within its directory. Never contains a `/`.
202    pub name: &'n [u8],
203    /// `true` if this entry is itself a directory.
204    pub is_dir: bool,
205    /// Size in bytes. Zero for a directory.
206    pub size: u64,
207    /// Last-modified time as `services/fs` records it.
208    pub mtime: u32,
209    /// `true` if the file is sealed (append-only; writes to it are refused).
210    pub sealed: bool,
211}
212
213impl<'n> DirEntry<'n> {
214    /// The name as text, or `None` if it is not UTF-8.
215    ///
216    /// The honest call site is `e.name_str().unwrap_or("<not utf-8>")` - print something rather
217    /// than skipping a file that really exists.
218    pub fn name_str(&self) -> Option<&'n str> {
219        core::str::from_utf8(self.name).ok()
220    }
221}
222
223/// A handle to the filesystem service.
224///
225/// # Authority
226///
227/// Constructed from a `&ServiceContext`, and carries no authority of its own. Holding one does not
228/// grant filesystem access: every call goes through the context's existing send capability for
229/// `fs`, and a task whose spawn request granted none gets [`Error::Unreachable`], exactly as it
230/// would for any other peer it cannot reach. **This handle makes the filesystem convenient to use,
231/// never available where it was not granted.**
232///
233/// # Why it owns a counter
234///
235/// The request tag has to differ between consecutive requests, and it may not live in a `static`:
236/// unowned global mutable state is forbidden (invariant 9), and the shell's own tag counter was
237/// moved out of a `static AtomicU8` for exactly that reason (audit finding C6-1). So the counter
238/// lives here, owned by the handle, which is also why this type takes `&mut self` on operations
239/// that send.
240///
241/// # A missing path is not always [`Error::NotFound`]
242///
243/// `services/fs` answers `FS_NOTFOUND` only from its READS - stat, read and list. Every operation
244/// that changes something (write, create, delete, rename, move, seal, open) reports an absent path
245/// as `FS_ERR` with a reason such as "not found", "path not found" or "source not found", which this
246/// library returns as [`Error::Failed`] with that text in [`reason`](Fs::reason) - except
247/// [`open`](Fs::open), whose `FS_ERR` carries no reason at all. Ask
248/// [`exists`](Fs::exists) first where the difference matters.
249pub struct Fs<'a> {
250    ctx: &'a ServiceContext,
251    tag: u8,
252    reason: [u8; REASON_MAX],
253    reason_len: u8,
254    notice: Option<&'a dyn Fn()>,
255    /// Where the keys a noticing wait reads (and does not act on) go - see [`Fs::keeping`].
256    keep: Option<&'a dyn Fn(u8)>,
257    /// The command's watch (`call::Watch`): when lent and active, every request shows the command's
258    /// `[q] quit` and honours it, and the notice and keep above are not used.
259    watch: Option<&'a dyn call::Watch>,
260    /// How long ORDINARY operations wait. `None` means [`call::DEFAULT_SECS`].
261    ///
262    /// The operations with a budget of their own - a subtree delete walks a tree, so it carries
263    /// `SWEEP_SECS` - keep it. Overriding those would let a caller set a deadline shorter than the
264    /// work it is waiting for, which is the bug this whole module keeps finding elsewhere.
265    patience: Option<i64>,
266}
267
268/// What [`stat`](Fs::stat) found.
269#[derive(Copy, Clone, PartialEq, Eq, Debug)]
270pub struct Stat {
271    /// Size in bytes. Zero for a directory.
272    pub size: u64,
273    /// `true` if this path is a directory rather than a file.
274    pub is_dir: bool,
275}
276
277impl<'a> Fs<'a> {
278    /// Take a filesystem handle from a context.
279    ///
280    /// Cheap, allocates nothing, and grants nothing. See the type's authority note.
281    pub fn new(ctx: &'a ServiceContext) -> Fs<'a> {
282        Fs { ctx, tag: TAG_START, reason: [0; REASON_MAX], reason_len: 0, notice: None, keep: None, watch: None, patience: None }
283    }
284
285    /// A handle that calls `notice` when a request has been waiting a while.
286    ///
287    /// The callback takes nothing and returns nothing, and that is the whole of its contract: it
288    /// means only **"you have been waiting"**. It cannot cancel, cannot inspect the request, and
289    /// the library learns nothing about consoles from it - the shell prints `[q] quit` there, and
290    /// something headless prints nothing at all.
291    ///
292    /// **Use this for anything a person is waiting at.** A filesystem call can block on a device
293    /// that is settling after a replug, and a command that goes quiet for twenty seconds with no
294    /// way out is one the operator has to reboot out of (`utilities/0_conventions.md` rule 9). A
295    /// request the operator then aborts returns [`Error::Cancelled`], which is NOT a fault - report
296    /// it as the deliberate act it was.
297    pub fn with_notice(ctx: &'a ServiceContext, notice: &'a dyn Fn()) -> Fs<'a> {
298        Fs { ctx, tag: TAG_START, reason: [0; REASON_MAX], reason_len: 0, notice: Some(notice), keep: None, watch: None,
299             patience: None }
300    }
301
302    /// Wait `secs` for an ordinary operation instead of [`call::DEFAULT_SECS`].
303    ///
304    /// **This is the CALLER's patience, not a promise about the service.** Use it where waiting the
305    /// default would be worse than giving up: a shell loading its history at startup must not sit on
306    /// a prompt because `fs` respawned and is still re-mounting, and a background marker write is
307    /// not worth a long wait at all.
308    ///
309    /// **A short deadline does not make a failure smaller.** Giving up on a WRITE leaves
310    /// [`Error::OutcomeUnknown`], and shortening the wait makes that outcome MORE likely, not less -
311    /// so a caller that sets this must have something sensible to do with "it may have happened".
312    /// The right use is a write nobody is waiting on; the wrong one is shortening a write somebody
313    /// will act on the result of.
314    ///
315    /// Operations that carry their own budget because they do more work - [`delete_all`](Fs::delete_all)
316    /// walks a subtree - are NOT shortened by this. A caller cannot set a deadline under the work.
317    pub fn patience_secs(mut self, secs: i64) -> Fs<'a> {
318        self.patience = if secs > 0 { Some(secs) } else { None };
319        self
320    }
321
322    /// Take a handle that CONTINUES an existing tag sequence for this channel.
323    ///
324    /// For a caller that still makes some `fs` requests by hand: lend the handle your counter, and
325    /// take it back with [`tag`](Fs::tag) when you are done.
326    ///
327    /// ```ignore
328    /// let mut fs = Fs::from_tag(ctx, my_fs_tag.get());
329    /// let r = fs.list_dir("/data", |e| { .. })?;
330    /// my_fs_tag.set(fs.tag());          // hand-rolled calls resume where the handle left off
331    /// ```
332    ///
333    /// **Use this whenever anything else in the same task also talks to `fs` directly.** Two
334    /// counters on one channel can mint the same tag for two in-flight requests, and the result is
335    /// not a loud rejection - it is a stale reply silently accepted as the answer to the current
336    /// question. See [`TAG_START`](self).
337    pub fn from_tag(ctx: &'a ServiceContext, tag: u8) -> Fs<'a> {
338        Fs { ctx, tag, reason: [0; REASON_MAX], reason_len: 0, notice: None, keep: None, watch: None, patience: None }
339    }
340
341    /// Lend this handle a waiting-notice, as [`with_notice`](Fs::with_notice) describes.
342    ///
343    /// Separate from [`from_tag`](Fs::from_tag) rather than a fourth constructor taking both,
344    /// because a caller that shares a tag counter usually also wants the notice and the
345    /// combinations multiply faster than the constructors are worth.
346    pub fn noticing(mut self, notice: &'a dyn Fn()) -> Fs<'a> {
347        self.notice = Some(notice);
348        self
349    }
350
351    /// Hand every key a noticing wait reads, other than a quit key, to `keep` - in order - instead of
352    /// discarding it. A wait that watches the console for `q` takes what is typed; without this,
353    /// typing ahead while a command waits loses those keys (`backlog/fixed/80` H16). Only matters with a
354    /// notice, since only a noticing wait reads the console.
355    pub fn keeping(mut self, keep: &'a dyn Fn(u8)) -> Fs<'a> {
356        self.keep = Some(keep);
357        self
358    }
359
360    /// Lend this handle the command's watch ([`call::Watch`]): every request then shows the command's
361    /// `[q] quit` once the COMMAND has run 2 s, and stops at the next request after a quit. Takes
362    /// precedence over a notice; a watch that is not active leaves the handle's own notice, if any, else the
363    /// plain wait.
364    pub fn watching(mut self, watch: &'a dyn call::Watch) -> Fs<'a> {
365        self.watch = Some(watch);
366        self
367    }
368
369    /// The tag this handle last used, to hand back to whatever owns the channel's counter.
370    pub fn tag(&self) -> u8 {
371        self.tag
372    }
373
374    /// Why the last call failed, IN THE SERVICE'S OWN WORDS, or `""` if it did not fail or said
375    /// nothing.
376    ///
377    /// **This is for reporting, never for control flow.** Branch on the [`Error`]; print this.
378    /// It is free-text owned by `services/fs` and may be reworded at any time, so a caller that
379    /// matched on its content would break silently the next time somebody improved a message.
380    ///
381    /// It exists because the service already sends it and this library used to discard it:
382    /// `FS_ERR` became [`Error::Failed`] and "name already exists" was lost between the two. An
383    /// operator reading `fs: failed` has to go to the service log for a sentence the reply was
384    /// already carrying, which makes a loud failure quieter for no reason (26.7).
385    pub fn reason(&self) -> &str {
386        core::str::from_utf8(&self.reason[..self.reason_len as usize]).unwrap_or("")
387    }
388
389    /// The next tag, for the `cap` module - which speaks to the SAME service on the SAME endpoint
390    /// and therefore must draw from THIS counter rather than start a second one.
391    ///
392    /// `pub(crate)` rather than public: sharing the counter is an internal invariant of this
393    /// library, not something a caller should be able to interleave with by hand.
394    pub(crate) fn next_tag_pub(&mut self) -> u8 {
395        self.next_tag()
396    }
397
398    /// The deadline an ordinary operation waits: the caller's, or the library's default.
399    fn secs(&self) -> i64 {
400        self.patience.unwrap_or(call::DEFAULT_SECS)
401    }
402
403    /// The next request tag: wrapping +1, never 0.
404    ///
405    /// Identical to the rule `services/shell` uses, so one counter can be shared across both (see
406    /// [`from_tag`](Fs::from_tag)). Zero is skipped because a zero tag can only have come from a
407    /// sender that does not tag at all, and that must stay distinguishable.
408    fn next_tag(&mut self) -> u8 {
409        let t = self.tag.wrapping_add(1);
410        self.tag = if t == 0 { 1 } else { t };
411        self.tag
412    }
413
414    /// Build `[tag, op, path_len, path.., tail..]` and send it.
415    ///
416    /// Returns the reply body with the tag and status already checked and stripped, so callers work
417    /// in terms of the operation's own answer rather than in terms of offsets.
418    fn call(&mut self, op: u8, path: &[u8], tail: &[u8], secs: i64) -> Result<Reply, Error> {
419        if path.len() > PATH_MAX {
420            return Err(Error::InvalidInput);
421        }
422        let mut req = [0u8; 3 + PATH_MAX + 8 + IO_CHUNK];
423        let n = 3 + path.len() + tail.len();
424        if n > req.len() {
425            // Could not be built, so it was never sent: a failure, not a slow answer.
426            return Err(Error::InvalidInput);
427        }
428        let tag = self.next_tag();
429        req[0] = tag;
430        req[1] = op;
431        req[2] = path.len() as u8;
432        req[3..3 + path.len()].copy_from_slice(path);
433        req[3 + path.len()..n].copy_from_slice(tail);
434
435        let msg = Message::from_bytes(&req[..n]);
436        let reply = match self.watch {
437            // Only the reply carrying THIS request's tag: a late answer to a request already quit is dropped.
438            Some(w) if w.active() => call::request_watched_sifted(self.ctx, "fs", &msg, secs, w,
439                &mut |m| m.payload_bytes().first() == Some(&tag))?,
440            _ => call::request_within_notice(self.ctx, "fs", &msg, secs, self.notice, self.keep)?,
441        };
442        let body = reply.payload_bytes();
443
444        // The tag must match, or this is an answer to a question we already gave up on.
445        if body.first() != Some(&tag) {
446            return Err(Error::Malformed);
447        }
448        let status = *body.get(1).ok_or(Error::Malformed)?;
449        // Take the service's words BEFORE `from_fs_status` collapses the byte to an `Error`. Only
450        // FS_ERR carries a reason; every other status has one meaning and needs no sentence.
451        self.reason_len = 0;
452        if status == 1 {
453            let why = if body.len() > 2 { &body[2..] } else { &[][..] };
454            let n = why.len().min(REASON_MAX);
455            self.reason[..n].copy_from_slice(&why[..n]);
456            self.reason_len = n as u8;
457        }
458        from_fs_status(status)?;
459        Ok(Reply { msg: reply })
460    }
461
462
463    /// Visit every entry of a directory, in the order `services/fs` stores them.
464    ///
465    /// `f` is called once per entry and returns `true` to continue or `false` to stop early.
466    ///
467    /// ```ignore
468    /// let mut files = 0;
469    /// let r = fs.list_dir("/data", |e| { if !e.is_dir { files += 1; } true })?;
470    /// if !r.complete { io::println(ctx, "  (listing truncated - there are more)"); }
471    /// ```
472    ///
473    /// # This is where a hand-rolled version goes wrong
474    ///
475    /// The reply is ONE PAGE: `[count, more, next]` plus the entries that fit in a block. A caller
476    /// that reads `count` entries and stops has silently listed a PREFIX of a large directory, and
477    /// nothing anywhere reports a problem. This follows `next` until `more` is clear, so the
478    /// closure sees the whole directory or the call returns an error - never a quiet partial.
479    ///
480    /// **Blocks**, once per page. **Authority:** the caller's existing `fs` capability.
481    ///
482    /// # Errors
483    /// - [`Error::NotFound`] - no such path, or it is a file rather than a directory. Returned
484    ///   only for the FIRST page: a later page that fails ends the walk with `complete: false`,
485    ///   because entries were already delivered and reporting "no such directory" for a read error
486    ///   part way through would name the wrong fault.
487    /// - [`Error::Malformed`] - the service claimed more entries without advancing. Listing is
488    ///   read-only, so every no-answer error here is safe to retry.
489    pub fn list_dir<F>(&mut self, path: impl AsRef<[u8]>, mut f: F) -> Result<Listing, Error>
490    where
491        F: FnMut(DirEntry<'_>) -> bool,
492    {
493        const HDR: usize = 6; // [count, more, next:u32] - the entries follow
494        let mut from = 0u32;
495        let mut total = 0usize;
496        let mut pages = 0u16;
497        loop {
498            let r = match self.call(OP_LIST_DIR, path.as_ref(), &from.to_le_bytes(), self.secs()) {
499                Ok(r) => r,
500                // The FIRST page failing means the path is not a readable directory, and the caller
501                // needs that error. A LATER one failing is a read error inside a walk that already
502                // produced real entries - so the honest answer is the entries plus "not complete",
503                // not an error that describes the directory rather than what happened.
504                Err(e) if pages == 0 => return Err(e),
505                Err(_) => return Ok(Listing { visited: total, complete: false }),
506            };
507            pages += 1;
508            let b = r.body();
509            if b.len() < HDR {
510                return Err(Error::Malformed);
511            }
512            let count = b[0] as usize;
513            let more = b[1] == 1;
514            let next = u32::from_le_bytes([b[2], b[3], b[4], b[5]]);
515
516            let mut w = HDR;
517            for _ in 0..count {
518                // Every field is bounds-checked against the REPLY, not against what the header
519                // promised. A truncated or malformed page must not be read past its end.
520                if w >= b.len() {
521                    return Err(Error::Malformed);
522                }
523                let nl = b[w] as usize;
524                if w + 15 + nl > b.len() {
525                    return Err(Error::Malformed);
526                }
527                let name = &b[w + 1..w + 1 + nl];
528                let size = u64::from_le_bytes([
529                    b[w + 2 + nl], b[w + 3 + nl], b[w + 4 + nl], b[w + 5 + nl],
530                    b[w + 6 + nl], b[w + 7 + nl], b[w + 8 + nl], b[w + 9 + nl],
531                ]);
532                let mtime = u32::from_le_bytes([
533                    b[w + 10 + nl], b[w + 11 + nl], b[w + 12 + nl], b[w + 13 + nl],
534                ]);
535                total += 1;
536                let go_on = f(DirEntry {
537                    name,
538                    is_dir: b[w + 1 + nl] == 1,
539                    size,
540                    mtime,
541                    sealed: b[w + 14 + nl] & 1 != 0,
542                });
543                if !go_on {
544                    // The caller stopped us, so the directory did not end: NOT complete.
545                    return Ok(Listing { visited: total, complete: false });
546                }
547                w += 15 + nl;
548            }
549
550            if !more {
551                return Ok(Listing { visited: total, complete: true });
552            }
553            // THE WALK TERMINATES ON ITS OWN. Following `more` for as long as the service offers it
554            // is an unbounded loop wearing a condition; the cap is what makes this bounded (26.6),
555            // and `complete: false` is what stops the cap from lying about what was read.
556            if pages >= DIR_PAGE_MAX {
557                return Ok(Listing { visited: total, complete: false });
558            }
559            // NO PROGRESS IS A LOUD ERROR, NOT A LOOP. `next` names the entry that did not fit, so
560            // it must advance past where this page started. A service that answers "more" without
561            // moving would otherwise spin here forever, asking the same question (26.6).
562            if next <= from {
563                return Err(Error::Malformed);
564            }
565            from = next;
566        }
567    }
568
569    /// Create a directory and any missing parents, like `mkdir -p`.
570    ///
571    /// Succeeds if the directory already exists, which is what makes it usable at start-up: a
572    /// service that ensures its own data directory should not have to care whether it is the first
573    /// to run. Use [`create_dir`](Fs::create_dir) where the path's absence is itself the thing being asserted.
574    ///
575    /// **Blocks** up to [`call::DEFAULT_SECS`], or this handle's
576    /// [`patience_secs`](Fs::patience_secs) if it was given one. **Authority:** the caller's existing `fs` capability.
577    ///
578    /// # Errors
579    /// [`Error::Failed`] with [`reason`](Fs::reason) naming the step that failed (a parent that is
580    /// a file, say). **A no-answer error must NOT be blindly retried** - see [`Error::retry_is_safe`];
581    /// creating directories is idempotent, but the transaction may have committed unseen, so
582    /// re-issuing is only safe once you accept that it is a second attempt rather than the first.
583    pub fn create_dir_all(&mut self, path: impl AsRef<[u8]>) -> Result<(), Error> {
584        self.call(OP_MKDIR_P, path.as_ref(), &[], self.secs())?;
585        Ok(())
586    }
587
588    /// Move a file or directory to `dest`, ACROSS directories.
589    ///
590    /// This is the different operation from [`rename`](Fs::rename), which changes a name within one
591    /// parent. `dest` is a full path, and `services/fs` refuses a move that would put a directory
592    /// inside itself - the tree stays a tree, and that rule is the service's to enforce, not the
593    /// caller's to remember.
594    ///
595    /// **Blocks** up to [`call::DEFAULT_SECS`], or this handle's
596    /// [`patience_secs`](Fs::patience_secs) if it was given one. **Authority:** the caller's existing `fs` capability.
597    ///
598    /// # Errors
599    /// [`Error::Failed`] with [`reason`](Fs::reason) - "source not found" for an absent source
600    /// (NOT [`Error::NotFound`]; see the type's note), or the refusal ("name already exists",
601    /// "cannot move root", a destination inside the source). A move MUTATES - do not retry on a no-answer error without
602    /// first checking what happened.
603    pub fn move_to(&mut self, path: impl AsRef<[u8]>, dest: impl AsRef<[u8]>) -> Result<(), Error> {
604        if dest.as_ref().len() > PATH_MAX {
605            return Err(Error::InvalidInput);
606        }
607        self.call(OP_MOVE, path.as_ref(), dest.as_ref(), self.secs())?;
608        Ok(())
609    }
610
611    /// Delete a file, or a directory AND EVERYTHING INSIDE IT.
612    ///
613    /// The recursive one. [`delete`](Fs::delete) removes a single file and refuses a non-empty
614    /// directory; this removes the subtree. The names differ deliberately - a caller reaching for
615    /// the destructive one should have to type something that says so.
616    ///
617    /// **Blocks** up to [`SWEEP_SECS`], not the ordinary deadline: removing a subtree walks it, and
618    /// a bound shorter than the work turns a slow success into [`Error::OutcomeUnknown`] - which is
619    /// worse than waiting, because an unknown outcome forbids the retry that would have fixed it.
620    /// (This doc previously said "up to `DEFAULT_SECS`, and longer for a large tree", which cannot
621    /// happen: a call does not block past its own deadline, it gives up.)
622    ///
623    /// **Authority:** the caller's existing `fs` capability.
624    ///
625    /// # Errors
626    /// [`Error::Failed`] with [`reason`](Fs::reason), "not found" included (NOT
627    /// [`Error::NotFound`]; see the type's note). **Never retry this on
628    /// [`Error::OutcomeUnknown`]**: the service unlinks the subtree in one transaction and then
629    /// frees its blocks in many, so a deadline that passes mid-delete may leave the entry already
630    /// gone, and a retry then fails with "not found" for work that succeeded. A failure DURING the
631    /// freeing is also reported as [`Error::Failed`] although the unlink has committed; the blocks
632    /// it did not free are leaked until [`check`](Fs::check) rebuilds the bitmap.
633    pub fn delete_all(&mut self, path: impl AsRef<[u8]>) -> Result<(), Error> {
634        self.call(OP_DELETE_TREE, path.as_ref(), &[], SWEEP_SECS)?;
635        Ok(())
636    }
637
638    /// Open a file as a CAPABILITY (CLAUDE.md 7.10), rather than acting on it by path.
639    ///
640    /// The returned [`File`](crate::file::File) holds an unforgeable, revocable, non-escalating
641    /// kernel capability to exactly this file. A read-only one cannot write, and the refusal comes
642    /// from the KERNEL before `fs` is reached - which is the difference between a capability and a
643    /// handle a service merely agrees to honour.
644    ///
645    /// `rights` is a mask of [`cap::READ`](crate::cap::READ), [`cap::WRITE`](crate::cap::WRITE) and
646    /// [`cap::APPEND`](crate::cap::APPEND). **Check
647    /// [`File::rights`](crate::file::File::rights) on the result**: it is what the capability
648    /// carries, read from the kernel. `fs` narrows rather than refuses in one case - it will not mint
649    /// a writable capability to a SEALED file, and hands back a read-only one instead.
650    ///
651    /// # Why this borrows the handle
652    ///
653    /// The `File` and this `Fs` share one correlation-tag counter, because they talk to one service
654    /// over one endpoint. The borrow is what makes that structural rather than a rule to remember.
655    /// See [`File`](crate::file::File) for how to hold two files at once.
656    ///
657    /// **Blocks** up to [`call::DEFAULT_SECS`], or this handle's
658    /// [`patience_secs`](Fs::patience_secs) if it was given one. **Authority:** the caller's existing `fs` capability
659    /// - opening a file grants nothing the contract did not already grant.
660    ///
661    /// # Errors
662    /// - [`Error::PermissionDenied`] - a writable capability was asked for on a sealed file and no
663    ///   read-only fallback was available (`rights` did not include [`cap::READ`](crate::cap::READ)).
664    /// - [`Error::Failed`] - no such file, a directory, a path longer than `fs`'s 96-byte open
665    ///   table allows, too many files open, or `fs` replied without a capability. `fs` sends this
666    ///   one WITHOUT a reason, so [`reason`](Fs::reason) is empty and the cause is in `fs`'s log
667    ///   only. Retrying an open is safe.
668    pub fn open<'f>(&'f mut self, path: impl AsRef<[u8]>, rights: u8) -> Result<crate::file::File<'f, 'a>, Error> {
669        let ctx = self.ctx;
670        self.call(OP_OPEN, path.as_ref(), &[rights], self.secs())?;
671        // The capability rode the reply as an EMBEDDED cap, not as payload bytes; the kernel placed
672        // it in our table on receipt and it is ours to claim or leak.
673        let cap = ctx.take_pending_cap().ok_or(Error::Failed)?;
674        // Record what the capability CARRIES, not what was asked: `fs` narrows a sealed file to
675        // read-only, and APPEND is a request flag that never becomes a kernel right. A slot the
676        // kernel cannot describe is no capability we can use (`backlog/fixed/80` G2).
677        let granted = match ctx.query_cap_rights(cap) {
678            Some(r) => (r as u8) & (crate::cap::READ | crate::cap::WRITE),
679            None => return Err(Error::Failed),
680        };
681        Ok(crate::file::File::new(self, ctx, cap, granted))
682    }
683
684    /// Rebuild the free-space bitmap from the file tree, and report what was found.
685    ///
686    /// This is `fsck`. It WRITES - the bitmap is rebuilt from the tree, which is the irreducible
687    /// source (26.4: the bitmap and free count are derived views, and this is their repair path).
688    /// A read-only mount refuses it.
689    ///
690    /// **Blocks** up to [`SWEEP_SECS`]. It walks the whole volume.
691    ///
692    /// # Errors
693    /// [`Error::Failed`] on a read-only mount or a failed sweep, with an EMPTY
694    /// [`reason`](Fs::reason) (`fs` logs why but sends a bare `FS_ERR`);
695    /// [`Error::NoFilesystem`] if nothing is mounted, or [`Error::Unavailable`] if storage is
696    /// present but unreadable. Rebuilding is idempotent - it derives the
697    /// bitmap from the tree either way - so a repeat is safe, though it costs another full sweep.
698    pub fn check(&mut self) -> Result<Check, Error> {
699        let r = self.call(OP_CHECK, &[], &[], SWEEP_SECS)?;
700        let b = r.body();
701        if b.len() < 36 {
702            return Err(Error::Malformed);
703        }
704        Ok(Check {
705            files: u32::from_le_bytes([b[0], b[1], b[2], b[3]]),
706            dirs: u32::from_le_bytes([b[4], b[5], b[6], b[7]]),
707            bad: u32::from_le_bytes([b[8], b[9], b[10], b[11]]),
708            used: u64::from_le_bytes([b[12], b[13], b[14], b[15], b[16], b[17], b[18], b[19]]),
709            free: u64::from_le_bytes([b[20], b[21], b[22], b[23], b[24], b[25], b[26], b[27]]),
710            free_before: u64::from_le_bytes([b[28], b[29], b[30], b[31], b[32], b[33], b[34], b[35]]),
711        })
712    }
713
714    /// What storage `fs` is serving: the device's size, whether a volume is mounted, how full it is
715    /// and its label (`OP_DRIVES_INFO`, the request behind the shell's `drives`). **Changes nothing**,
716    /// except that `fs` drops a mount whose device has gone - which is it noticing, not this asking.
717    ///
718    /// # Errors
719    /// [`Error::Malformed`] on a reply too short to be one; the no-answer errors of every request.
720    pub fn drive(&mut self) -> Result<Drive, Error> {
721        let secs = self.secs();
722        let r = self.call(OP_DRIVES_INFO, &[], &[], secs)?;
723        // [mounted, capacity u64, total u64, used u64, flags, label_len, label..]
724        let b = r.body();
725        if b.len() < 27 {
726            return Err(Error::Malformed);
727        }
728        let u = |i: usize| u64::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3], b[i + 4], b[i + 5], b[i + 6], b[i + 7]]);
729        let mut d = Drive { capacity_sectors: u(1), mounted: b[0] != 0, total_blocks: u(9), used_blocks: u(17),
730                            label: [0; 31], label_len: 0 };
731        if d.mounted {
732            let ll = (b[26] as usize).min(31).min(b.len().saturating_sub(27));
733            d.label[..ll].copy_from_slice(&b[27..27 + ll]);
734            d.label_len = ll as u8;
735        }
736        Ok(d)
737    }
738
739    /// Verify every referenced block's CRC and report. **Changes nothing.**
740    ///
741    /// The read-only twin of [`check`](Fs::check): that one repairs the bitmap, this one reads the
742    /// data and says what does not verify. Safe on a read-only mount, and safe to run at any time.
743    ///
744    /// **Blocks** up to [`SWEEP_SECS`]. It reads the whole volume.
745    ///
746    /// # Errors
747    /// [`Error::NoFilesystem`] if nothing is mounted ([`Error::Unavailable`] if storage is present
748    /// but unreadable); [`Error::Failed`], with no reason, if the sweep itself failed. A scrub
749    /// writes nothing, so every no-answer
750    /// error here is safe to retry.
751    pub fn scrub(&mut self) -> Result<Scrub, Error> {
752        let r = self.call(OP_SCRUB, &[], &[], SWEEP_SECS)?;
753        let b = r.body();
754        if b.len() < 20 {
755            return Err(Error::Malformed);
756        }
757        Ok(Scrub {
758            files: u32::from_le_bytes([b[0], b[1], b[2], b[3]]),
759            dirs: u32::from_le_bytes([b[4], b[5], b[6], b[7]]),
760            bad: u32::from_le_bytes([b[8], b[9], b[10], b[11]]),
761            scanned: u64::from_le_bytes([b[12], b[13], b[14], b[15], b[16], b[17], b[18], b[19]]),
762        })
763    }
764
765    /// Freeze a file's content. **Permanently.**
766    ///
767    /// A sealed file can never be written again - not by you, not by anything holding a capability
768    /// to it, not after a restart. `fs` will not even mint a writable capability for one
769    /// ([`open`](Fs::open) narrows to read-only rather than handing back a capability it could not
770    /// honour).
771    ///
772    /// **There is no unseal**, by design: a seal a holder can lift is a request rather than a
773    /// guarantee. If you are offering this to a person, ask them first - every other destructive
774    /// operation here can be undone by doing the opposite, and this one cannot.
775    ///
776    /// **Blocks** up to [`call::DEFAULT_SECS`], or this handle's
777    /// [`patience_secs`](Fs::patience_secs) if it was given one. **Authority:** the caller's existing `fs` capability.
778    ///
779    /// # Errors
780    /// - [`Error::Failed`] - with [`reason`](Fs::reason) saying why: "not found" for an absent path
781    ///   (NOT [`Error::NotFound`]; see the type's note), or "only a file can be sealed", because a
782    ///   directory cannot be.
783    /// - Sealing twice is harmless - the second call finds it already sealed - so a no-answer error
784    ///   here may be retried, unlike most mutations.
785    pub fn seal(&mut self, path: impl AsRef<[u8]>) -> Result<(), Error> {
786        self.call(OP_SEAL, path.as_ref(), &[], self.secs())?;
787        Ok(())
788    }
789
790    /// Quiesce the filesystem for a shutdown (`docs/shutdown.md` 4.3): `fs` finishes the request in hand,
791    /// flushes the device, and then refuses every write for ten seconds - so nothing changes between the
792    /// flush and the power going off, and a shutdown that never comes leaves a filesystem that takes
793    /// writes again by itself. `Ok(true)` when the device confirmed the flush, `Ok(false)` when it did not.
794    ///
795    /// **Blocks** up to [`call::DEFAULT_SECS`], or this handle's [`patience_secs`](Fs::patience_secs) - a
796    /// shutdown gives it about one. **Authority:** the caller's existing `fs` capability.
797    pub fn quiesce(&mut self) -> Result<bool, Error> {
798        let r = self.call(OP_QUIESCE, b"", &[], self.secs())?;
799        Ok(r.msg.payload_bytes().get(2) == Some(&1))
800    }
801
802    /// End a [`quiesce`](Fs::quiesce) early, for a shutdown that was cancelled after its tidy-up: `fs` takes writes
803    /// again at once rather than refusing them for the rest of its ten seconds. **Authority:** the caller's `fs`
804    /// capability.
805    pub fn resume(&mut self) -> Result<(), Error> {
806        self.call(OP_QUIESCE, b"", &[1], self.secs())?;
807        Ok(())
808    }
809
810    /// Ask whether a path exists, and what it is.
811    ///
812    /// **Blocks** up to [`call::DEFAULT_SECS`], or this handle's
813    /// [`patience_secs`](Fs::patience_secs) if it was given one. **Authority:** the caller's existing `fs` capability.
814    ///
815    /// # Errors
816    /// [`Error::NotFound`] if the path is absent. See [`Error`] for the no-answer cases; a `stat` is
817    /// read-only, so retrying any of them is safe.
818    pub fn stat(&mut self, path: impl AsRef<[u8]>) -> Result<Stat, Error> {
819        let r = self.call(OP_STAT_FILE, path.as_ref(), &[], self.secs())?;
820        let b = r.body();
821        // [status, exists, size:u64, is_dir] after the tag - 11 bytes, 12 with the tag. `body` has
822        // stripped the tag and the status, leaving [exists, size, is_dir].
823        if b.len() < 10 {
824            return Err(Error::Malformed);
825        }
826        if b[0] != 1 {
827            return Err(Error::NotFound);
828        }
829        let size = u64::from_le_bytes([b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8]]);
830        Ok(Stat { size, is_dir: b[9] == 1 })
831    }
832
833    /// Read at most `buf.len()` bytes from `offset`, and say how many arrived.
834    ///
835    /// The positional read. Use it to move a file larger than any buffer, or to resume where a
836    /// previous pass stopped; use [`read_into`](Fs::read_into) when the whole file fits and you want
837    /// it in one call.
838    ///
839    /// Returns 0 at end of file. One call moves at most [`IO_CHUNK`] bytes however large `buf` is,
840    /// so a caller wanting more must loop - and the loop is the caller's, because only the caller
841    /// knows whether a short read means "done" or "keep going".
842    ///
843    /// **Blocks** up to [`call::DEFAULT_SECS`], or this handle's
844    /// [`patience_secs`](Fs::patience_secs) if it was given one. **Authority:** the caller's existing `fs` capability.
845    ///
846    /// # Errors
847    /// - [`Error::NotFound`] - no such file, or it is a directory. **Also a data block that failed
848    ///   its CRC**: `services/fs` answers every read failure with `FS_NOTFOUND`, so a damaged file
849    ///   reads as an absent one. [`stat`](Fs::stat) tells the two apart.
850    /// - A read changes nothing, so **every no-answer error here is safe to retry** - which is what
851    ///   makes a resumable copy possible at all. See [`Error::retry_is_safe`].
852    pub fn read_at(&mut self, path: impl AsRef<[u8]>, offset: u64, buf: &mut [u8]) -> Result<usize, Error> {
853        let want = buf.len().min(IO_CHUNK);
854        if want == 0 {
855            return Ok(0);
856        }
857        let mut tail = [0u8; 12];
858        tail[..8].copy_from_slice(&offset.to_le_bytes());
859        tail[8..].copy_from_slice(&(want as u32).to_le_bytes());
860        let r = self.call(OP_READ_AT, path.as_ref(), &tail, self.secs())?;
861        let b = r.body();
862        // `[n:u32, bytes..]` after the tag and status the call already checked.
863        if b.len() < 4 {
864            return Err(Error::Malformed);
865        }
866        let n = u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize;
867        if n > want || b.len() < 4 + n {
868            return Err(Error::Malformed);
869        }
870        buf[..n].copy_from_slice(&b[4..4 + n]);
871        Ok(n)
872    }
873
874    /// Read a whole file into `buf`, returning how many bytes were written.
875    ///
876    /// Streams in [`IO_CHUNK`] pieces, so a file far larger than one IPC message reads correctly.
877    ///
878    /// **Blocks**, once per chunk. **Authority:** the caller's existing `fs` capability.
879    ///
880    /// # Errors
881    /// - [`Error::NotFound`] - no such file, or it is a directory, or (as for
882    ///   [`read_at`](Fs::read_at)) a data block failed its CRC part way through.
883    /// - [`Error::BufferTooSmall`] - the file does not fit. **Nothing is written**; call again with
884    ///   room, having learned the size from [`stat`](Fs::stat).
885    /// - A read is idempotent, so every no-answer error here may safely be retried.
886    pub fn read_into(&mut self, path: impl AsRef<[u8]>, buf: &mut [u8]) -> Result<usize, Error> {
887        // Bound once: `impl AsRef<[u8]>` is not `Copy`, and this uses the path twice (a stat, then a
888        // chunk loop). A `&[u8]` is `Copy`, so taking the reference first is all it needs.
889        let path = path.as_ref();
890        let st = self.stat(path)?;
891        if st.is_dir {
892            return Err(Error::NotFound);
893        }
894        let size = st.size as usize;
895        if size > buf.len() {
896            return Err(Error::BufferTooSmall);
897        }
898        let mut off = 0usize;
899        while off < size {
900            let want = (size - off).min(IO_CHUNK);
901            let mut tail = [0u8; 12];
902            tail[..8].copy_from_slice(&(off as u64).to_le_bytes());
903            tail[8..].copy_from_slice(&(want as u32).to_le_bytes());
904            let r = self.call(OP_READ_AT, path.as_ref(), &tail, self.secs())?;
905            let b = r.body();
906            if b.len() < 4 {
907                return Err(Error::Malformed);
908            }
909            let n = u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize;
910            if n == 0 || n > want || b.len() < 4 + n {
911                return Err(Error::Malformed);
912            }
913            buf[off..off + n].copy_from_slice(&b[4..4 + n]);
914            off += n;
915        }
916        Ok(off)
917    }
918
919    /// Create or replace a file with `data`.
920    ///
921    /// **Blocks**. **Authority:** the caller's existing `fs` capability.
922    ///
923    /// # This operation CHANGES STATE
924    ///
925    /// If it returns [`Error::OutcomeUnknown`], the write may have happened. Do not call it again
926    /// to "make sure": read the file back, or report the uncertainty. [`Error::retry_is_safe`]
927    /// answers this for you, and says `false` for that case on purpose.
928    ///
929    /// # Larger than one message
930    ///
931    /// `data` longer than [`IO_CHUNK`] is written as the file at its whole size first
932    /// ([`create_sized`](Fs::create_sized)), then filled chunk by chunk from offset 0. A failure
933    /// part way leaves a file of the full size whose tail is not yet written, and is reported.
934    /// (Until 2026-10-10 the file was created from its first chunk alone, sized for that chunk, so
935    /// `services/fs` refused the second as "write past extent" - `backlog/fixed/80` G1.)
936    pub fn write(&mut self, path: impl AsRef<[u8]>, data: &[u8]) -> Result<(), Error> {
937        if data.len() <= IO_CHUNK {
938            self.call(OP_WRITE_FILE, path.as_ref(), data, self.secs())?;
939            return Ok(());
940        }
941        // Larger than one message: size it, then fill it positionally. `OP_WRITE_AT` at a fixed
942        // offset is positionally idempotent - the same bytes at the same offset land the same way
943        // twice - which is what makes a chunked write safe to resume at all.
944        self.create_sized(path.as_ref(), data.len() as u64)?;
945        let mut off = 0;
946        while off < data.len() {
947            let n = (data.len() - off).min(IO_CHUNK);
948            self.write_at(path.as_ref(), off as u64, &data[off..off + n])?;
949            off += n;
950        }
951        Ok(())
952    }
953
954    /// Create a directory. Fails if the parent does not exist, or if the name already does
955    /// ([`Error::Failed`], reason "path not found" or "already exists").
956    ///
957    /// **Blocks**. Changes state: see the note on [`write`] about [`Error::OutcomeUnknown`].
958    pub fn create_dir(&mut self, path: impl AsRef<[u8]>) -> Result<(), Error> {
959        self.call(OP_MKDIR, path.as_ref(), &[], self.secs())?;
960        Ok(())
961    }
962
963    /// Delete one file or one empty directory.
964    ///
965    /// **Blocks**. **Destructive, and not idempotent in the way that matters**: on
966    /// [`Error::OutcomeUnknown`] the file may already be gone, and a second delete would fail
967    /// ([`Error::Failed`], reason "not found") for work that succeeded. Report the uncertainty; do
968    /// not re-send. A non-empty directory is refused with reason "directory not empty".
969    pub fn delete(&mut self, path: impl AsRef<[u8]>) -> Result<(), Error> {
970        self.call(OP_DELETE, path.as_ref(), &[], self.secs())?;
971        Ok(())
972    }
973
974    /// Allocate a file of `capacity` bytes without writing content into it.
975    ///
976    /// The extent is reserved up front, so later [`write_at`](Fs::write_at) calls land in space that is already
977    /// the file's. That is what makes a long append-style writer bounded: it cannot run out of room
978    /// halfway and leave a half-file behind.
979    ///
980    /// **An existing file at `path` is REPLACED**: `services/fs` truncates it to a fresh extent of
981    /// this size (a sealed file is refused). A directory there is refused.
982    ///
983    /// **Blocks. Changes state**: on [`Error::OutcomeUnknown`] the file may exist. Use
984    /// [`exists`](Fs::exists) to find out rather than calling this again, which would succeed and
985    /// replace the file a second time, discarding anything written into it in between.
986    ///
987    /// Added because `services/recorder` needed it during migration. It is a real filesystem
988    /// operation, so it belongs in the typed surface rather than behind an opcode escape hatch.
989    pub fn create_sized(&mut self, path: impl AsRef<[u8]>, capacity: u64) -> Result<(), Error> {
990        self.call(OP_WRITE_NEW, path.as_ref(), &capacity.to_le_bytes(), self.secs())?;
991        Ok(())
992    }
993
994    /// Write `data` at a byte `offset` into an existing file.
995    ///
996    /// **Blocks. Changes state.** Unusually among the write operations, a positional write at a
997    /// FIXED offset into an already-allocated extent is idempotent: repeating it puts the same bytes
998    /// in the same place. It is safe to re-send because it is positional, not appending, which is
999    /// what makes a chunked write resumable at all.
1000    ///
1001    /// **`offset` must be a multiple of 508** (one data block's payload; [`IO_CHUNK`] is seven of
1002    /// them), and `offset + data.len()` must lie inside the file's existing extent - `fs` does not
1003    /// grow a file here. Either violation is [`Error::Failed`], reason "unaligned offset" or
1004    /// "write past extent". Size the file first with [`create_sized`](Fs::create_sized).
1005    ///
1006    /// Even so this returns [`Error::OutcomeUnknown`] honestly on a timeout, because the DECISION to
1007    /// re-send belongs to the caller who knows the offset is fixed - not to a library that would be
1008    /// guessing.
1009    pub fn write_at(&mut self, path: impl AsRef<[u8]>, offset: u64, data: &[u8]) -> Result<(), Error> {
1010        if data.len() > IO_CHUNK {
1011            return Err(Error::InvalidInput);
1012        }
1013        let mut tail = [0u8; 8 + IO_CHUNK];
1014        tail[..8].copy_from_slice(&offset.to_le_bytes());
1015        tail[8..8 + data.len()].copy_from_slice(data);
1016        self.call(OP_WRITE_AT, path.as_ref(), &tail[..8 + data.len()], self.secs())?;
1017        Ok(())
1018    }
1019
1020    /// Rename a file within its directory. `new_name` is a bare name, not a path.
1021    ///
1022    /// **Blocks. Changes state, and is NOT idempotent**: a second rename after a successful one
1023    /// fails with [`Error::Failed`], reason "entry not found", because the source is already gone. On
1024    /// [`Error::OutcomeUnknown`] check with [`exists`](Fs::exists) rather than re-sending - this is precisely
1025    /// the case where a retry reports failure for work that succeeded.
1026    pub fn rename(&mut self, path: impl AsRef<[u8]>, new_name: impl AsRef<[u8]>) -> Result<(), Error> {
1027        self.call(OP_RENAME, path.as_ref(), new_name.as_ref(), self.secs())?;
1028        Ok(())
1029    }
1030
1031    /// Does this path exist? A convenience over [`stat`](Fs::stat), and read-only.
1032    pub fn exists(&mut self, path: impl AsRef<[u8]>) -> Result<bool, Error> {
1033        match self.stat(path) {
1034            Ok(_) => Ok(true),
1035            Err(Error::NotFound) => Ok(false),
1036            Err(e) => Err(e),
1037        }
1038    }
1039}
1040
1041/// A checked reply: the tag matched and the status said OK.
1042struct Reply {
1043    msg: Message,
1044}
1045
1046impl Reply {
1047    /// Everything after the tag and the status byte.
1048    fn body(&self) -> &[u8] {
1049        let b = self.msg.payload_bytes();
1050        if b.len() > 2 { &b[2..] } else { &[] }
1051    }
1052}