Skip to content
24 changes: 24 additions & 0 deletions issues/a-pipe-console-socket-or-shared-memory-has-no-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
status: open
kind: defect
opened: 2026-10-11
---

# A pipe, console, socket or shared memory has no identity

`ops::fstat` (`kernel/src/object/ops.rs`) answers `volume: 0, node: 0` for
every object but a file: a pipe or tty end, a connection, an acceptor, a
console, shared memory, a device and the capability objects. libc answers the
pair as `st_dev` and `st_ino` and std as `MetadataExt::dev` and `ino`, so
every such descriptor is one file to a program that asks: two pipes, a pipe's
two ends and a console beside a pipe all compare equal, and `0` is the pair a
caller reads as "nothing". POSIX's `<sys/stat.h>` says `st_ino` and `st_dev`
uniquely identify a file, a pipe included.

**Read off the code; no program that depends on it has been seen to fail.**

**Exit condition.** `fstat` of two pipes answers two nonzero pairs, both ends
of one pipe answer one, and a console answers one apart from them; a guest C
case reads them.

**Owner.** `kernel/src/object/ops.rs`. Nobody holds it.
7 changes: 6 additions & 1 deletion issues/no-physical-memory-fairness.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ costs and refuse past it, and a pipe allocates its ring page on first use rather
than at `create`. **What a bound alone still does not answer is whose window to
refuse.** The memory is charged to nobody, so a cap is the only thing between one
client and the machine — which is this entry, and is why both of those functions'
doc comments name a kernel memory limit as what deletes them.
doc comments name a kernel memory limit as what deletes them. A third such cap
is the kernel's directory numbering, `MAX_DIRECTORIES` in `kernel/pure/dirnodes.rs`,
and its refusal crosses processes: once one process's `/tmp` names fill it, every
process's `stat`, `access`, `opendir` or listing that reaches a directory not yet
numbered is refused `ResourceExhausted`. Charging each number to whoever made it
deletes the cap.

**Promoted to `defect` 2026-08-25** (finding-lifecycle ruling: isolation
findings are security-adjacent and are promoted, never folded). One process
Expand Down
24 changes: 0 additions & 24 deletions issues/the-kernel-numbers-no-directory-symlink-or-listing-entry.md

This file was deleted.

11 changes: 4 additions & 7 deletions issues/toyos-builds-itself.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,8 @@ libc's state it is `userland/libc` at `15625e0cb`.
`socketpair`, which it does not define; a file `mmap` and record locks
(`issues/libc-refuses-what-toyos-cannot-yet-answer.md`); paths resolved by
POSIX's rules, which `realpath` answers by the kernel's
(`issues/the-kernel-resolves-a-path-by-rules-of-its-own.md`); file
identities
(`issues/the-kernel-numbers-no-directory-symlink-or-listing-entry.md`),
served like the locks by fsd; and socket descriptors that are pollable and
(`issues/the-kernel-resolves-a-path-by-rules-of-its-own.md`); and socket
descriptors that are pollable and
can be non-blocking
(`issues/libc-close-of-a-socket-ends-the-process.md`). Read from each
library's source and not run: SQLite's default VFS takes `fcntl` record
Expand Down Expand Up @@ -130,9 +128,8 @@ commit that lands M2's compile-and-run test, which links inside the guest.
--hosted-clang` makes a clang and an `ld.lld` for `x86_64-unknown-toyos`,
static PIEs that name no library (`src/hostedclang.rs`): 147.5 MB and
85.6 MB, 121.9 MB and 70.9 MB stripped. No guest has run either.
- Run: what `issues/libc-refuses-what-toyos-cannot-yet-answer.md` lists,
`issues/libc-has-no-pread-or-pwrite.md`, and
`issues/the-kernel-numbers-no-directory-symlink-or-listing-entry.md`.
- Run: what `issues/libc-refuses-what-toyos-cannot-yet-answer.md` lists, and
`issues/libc-has-no-pread-or-pwrite.md`.

**What M3 adds: a rustc that carries that LLVM**, after all of M2's.
- Build: `issues/rustc-llvm-cannot-build-for-a-toyos-host.md`, and
Expand Down
203 changes: 203 additions & 0 deletions kernel/pure/dirnodes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
//! The numbers a kernel volume gives its directories
//! (`toyos_abi::syscall::Stat::node`).
//!
//! ROOT and `/tmp` keep no record of a directory: one is the prefix its names
//! share, or a name `mkdir` left in the VFS. So the VFS numbers each by its
//! absolute path, drawing the number on the first ask, and forgets it once a
//! removal beneath the path leaves no directory there: a number is never
//! another directory's while both exist. The paths are userland's choice, so
//! what this holds is bounded: [`MAX_DIRECTORIES`] keys of at most
//! [`MAX_PATH`] bytes, a directory past either refused by name.

#![forbid(unsafe_code)]

use alloc::collections::BTreeMap;
use alloc::string::String;
use toyos_abi::syscall::{SyscallError, MAX_PATH};

/// The most directories numbered at once: past it a directory not yet
/// numbered is refused `ResourceExhausted` until one that is ends.
pub const MAX_DIRECTORIES: usize = 16_384;

#[derive(Default)]
pub struct Directories {
of: BTreeMap<String, u64>,
}

impl Directories {
pub const fn new() -> Self {
Self { of: BTreeMap::new() }
}

/// The number of the directory at `path`, drawn by `draw` when this does
/// not hold one; `draw` answers a number nothing else on its volume holds.
/// A path longer than [`MAX_PATH`] is refused `InvalidArgument`, as `cd`
/// and `mkdir` refuse it, and a new one past [`MAX_DIRECTORIES`]
/// `ResourceExhausted`.
pub fn of(&mut self, path: &str, draw: impl FnOnce() -> u64) -> Result<u64, SyscallError> {
if let Some(&number) = self.of.get(path) {
return Ok(number);
}
if path.len() > MAX_PATH {
return Err(SyscallError::InvalidArgument);
}
if self.of.len() >= MAX_DIRECTORIES {
return Err(SyscallError::ResourceExhausted);
}
let number = draw();
self.of.insert(String::from(path), number);
Ok(number)
}

/// A name at `path` was removed or moved away: `path` and every directory
/// above it whose number this holds and that `exists` no longer answers for
/// is forgotten, so a directory made there later is drawn a new one. The
/// walk begins at the longest of them within [`MAX_PATH`], since nothing
/// longer is held, so its length is bounded whatever `path`'s is.
pub fn removed(&mut self, path: &str, mut exists: impl FnMut(&str) -> bool) {
let mut at = path;
if at.len() > MAX_PATH {
match at.as_bytes()[..=MAX_PATH].iter().rposition(|&b| b == b'/') {
Some(0) | None => return,
Some(slash) => at = &at[..slash],
}
}
loop {
if self.of.contains_key(at) && !exists(at) {
self.of.remove(at);
}
match at.rfind('/') {
Some(0) | None => return,
Some(slash) => at = &at[..slash],
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use alloc::format;
use std::cell::Cell;
use std::vec::Vec;

/// A counter that never rewinds, as the file cache's is.
fn counter() -> impl FnMut() -> u64 {
let mut next = 0;
move || {
next += 1;
next
}
}

fn never() -> u64 {
panic!("a held number is never drawn again, and a refused one never drawn")
}

/// Asked twice, a directory answers one number; two directories answer two.
#[test]
fn a_directory_is_one_number_and_two_are_two() {
let mut draw = counter();
let mut dirs = Directories::new();
let a = dirs.of("/tmp/a", &mut draw).unwrap();
let b = dirs.of("/tmp/b", &mut draw).unwrap();
assert_ne!(a, b);
assert_eq!(dirs.of("/tmp/a", never), Ok(a));
}

/// A removal that leaves the directory standing keeps its number; one that
/// ends it forgets it, and the directory made there next is a new one.
#[test]
fn a_number_lasts_exactly_as_long_as_its_directory() {
let mut draw = counter();
let mut dirs = Directories::new();
let a = dirs.of("/tmp/a", &mut draw).unwrap();

dirs.removed("/tmp/a/f", |_| true);
assert_eq!(dirs.of("/tmp/a", never), Ok(a));

dirs.removed("/tmp/a/f", |dir| dir != "/tmp/a");
let again = dirs.of("/tmp/a", &mut draw).unwrap();
assert_ne!(again, a, "a directory made again where one was removed is another directory");
}

/// Every directory above the removal is judged, not only its parent, and
/// one that only shares a prefix with it is not.
#[test]
fn a_removal_forgets_every_directory_it_ended_and_no_other() {
let mut draw = counter();
let mut dirs = Directories::new();
let tmp = dirs.of("/tmp", &mut draw).unwrap();
let a = dirs.of("/tmp/a", &mut draw).unwrap();
let b = dirs.of("/tmp/a/b", &mut draw).unwrap();
let sibling = dirs.of("/tmp/ab", &mut draw).unwrap();

dirs.removed("/tmp/a/b/f", |dir| dir == "/tmp" || dir == "/tmp/ab");
assert_eq!(dirs.of("/tmp", never), Ok(tmp));
assert_eq!(dirs.of("/tmp/ab", never), Ok(sibling));
let (a2, b2) = (dirs.of("/tmp/a", &mut draw).unwrap(), dirs.of("/tmp/a/b", &mut draw).unwrap());
assert!(![tmp, a, b, sibling].contains(&a2) && ![tmp, a, b, sibling, a2].contains(&b2));
}

/// The mount's directories are asked about only where a number is held:
/// a removal costs nothing for what nobody numbered.
#[test]
fn only_a_held_number_is_judged() {
let mut dirs = Directories::new();
dirs.of("/tmp/a", || 7).unwrap();
let asked = Cell::new(Vec::new());
dirs.removed("/tmp/a/b/c/f", |dir| {
let mut seen = asked.take();
seen.push(String::from(dir));
asked.set(seen);
true
});
assert_eq!(asked.take(), [String::from("/tmp/a")]);
}

/// A path of `MAX_PATH` bytes is numbered; one byte more is refused by
/// name, and nothing is drawn or held for it.
#[test]
fn a_path_past_max_path_is_refused_invalid() {
let mut draw = counter();
let mut dirs = Directories::new();
let longest = format!("/{}", "a".repeat(MAX_PATH - 1));
assert!(dirs.of(&longest, &mut draw).is_ok());
let past = format!("{longest}a");
assert_eq!(dirs.of(&past, never), Err(SyscallError::InvalidArgument));
assert_eq!(dirs.of.len(), 1);
}

/// Full, a new directory is refused by name while a numbered one still
/// answers; once one ends, a new one is numbered again.
#[test]
fn past_max_directories_a_new_directory_is_refused_exhausted() {
let mut draw = counter();
let mut dirs = Directories::new();
for i in 0..MAX_DIRECTORIES {
dirs.of(&format!("/tmp/{i}"), &mut draw).unwrap();
}
assert_eq!(dirs.of("/tmp/new", never), Err(SyscallError::ResourceExhausted));
assert_eq!(dirs.of("/tmp/0", never), Ok(1));

dirs.removed("/tmp/0/f", |dir| dir != "/tmp/0");
assert!(dirs.of("/tmp/new", &mut draw).is_ok());
assert_eq!(dirs.of("/tmp/0", never), Err(SyscallError::ResourceExhausted));
}

/// A removal beneath a path longer than `MAX_PATH` still judges every
/// numbered directory above it.
#[test]
fn a_removal_past_max_path_judges_the_numbered_above_it() {
let mut draw = counter();
let mut dirs = Directories::new();
let near = format!("/tmp/{}", "a".repeat(MAX_PATH - 5));
assert_eq!(near.len(), MAX_PATH);
let top = dirs.of("/tmp", &mut draw).unwrap();
dirs.of(&near, &mut draw).unwrap();
let deep = format!("{near}{}/f", "/b".repeat(MAX_PATH));
dirs.removed(&deep, |dir| dir == "/tmp");
assert_eq!(dirs.of("/tmp", never), Ok(top));
assert_ne!(dirs.of(&near, &mut draw), Ok(2), "the directory MAX_PATH long ended and kept its number");
}
}
4 changes: 3 additions & 1 deletion kernel/pure/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
//! address space is handed ([`pcid`]), what type the range registers give a
//! range ([`mtrr`]), what a pipe's ends are told of each other ([`pipe`]), what
//! a CPU waiting on the boot processor's write to `SMI_CMD` decides
//! ([`bootwrite`]) and what the clock computes from counter ticks ([`clock`]).
//! ([`bootwrite`]), what the clock computes from counter ticks ([`clock`]) and
//! which number a kernel volume's directory answers ([`dirnodes`]).
//! The kernel binary links it; the host runs
//! its tests, because none of it reads a register, a clock or a kernel lock.

Expand All @@ -16,6 +17,7 @@ extern crate std;

pub mod bootwrite;
pub mod clock;
pub mod dirnodes;
pub mod mtrr;
pub mod pcid;
pub mod pipe;
Expand Down
Loading
Loading