Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This file is binding for AI coding agents working in this repository.
- Confirm the task, acceptance criteria, target branch, and files in scope.
- Do not guess kernel API, license text, or release intent.
- Do not work directly on `main` unless the owner explicitly authorizes an emergency fix.
- Userspace (`src/`, `tests/`) is the current product. Kernel / Rust-for-Linux work stays out of this tree until a later phase.
- Userspace lives in `userspace/`. The loadable distro module is `kernel/rust_null.c`. `kernel/rfl/rust_null.rs` is RfL source for a `CONFIG_RUST=y` kernel and is not built by `make -C kernel`.

## During editing

Expand All @@ -21,7 +21,7 @@ This file is binding for AI coding agents working in this repository.
## Before commit / push / merge

- Review the complete diff and scan for secrets, conflict markers, TODO/FIXME, debug output, and accidental files.
- Run `cargo fmt --check`, `cargo clippy --all-targets --locked -- -D warnings`, and `cargo test --locked`.
- Run `cargo fmt --check`, `cargo clippy --all-targets --locked -- -D warnings`, and `cargo test --locked` from the repository root.
- New failures block the commit. Do not hide failures as warnings.
- Use a focused Conventional Commit: `type(scope): imperative summary`.
- Push a branch and use a PR. Do not bypass required checks.
Expand All @@ -30,4 +30,6 @@ This file is binding for AI coding agents working in this repository.

## Stop conditions

Stop and ask when scope, destructive action, public behavior, credentials, or factual copy is unclear. Block when security, correctness, required checks, or release prerequisites fail. Do not `insmod` / `rmmod` / build against a live kernel without an explicit owner order.
Stop and ask when scope, destructive action, public behavior, credentials, or factual copy is unclear. Block when security, correctness, required checks, or release prerequisites fail.

Do not `insmod` / `rmmod` / `make -C kernel load` without an explicit owner order. Do not compile or boot a replacement kernel, and do not treat distro `linux-headers` as sufficient to build `kernel/rfl/*.rs`.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versio
- Userspace `NullDevice` matching Linux `/dev/null` (`read` EOF, `write` discard, `lseek` always 0).
- Contract tests against `drivers/char/mem.c` helpers `read_null`, `write_null`, and `null_lseek`.
- `examples/discard` stdin sink.
- C misc module `kernel/rust_null.c` (`misc_register`, `/dev/rust-null`) with the same fops contract.
- Rust-for-Linux `MiscDevice` source in `kernel/rfl/` (not built against distro headers).
- `/dev/null` vs `/dev/rust-null` tests (`compare_devices`); skip when the node is missing.

### Changed

- Crate lives under `userspace/`; repository root is a Cargo workspace.
- README states this tree is work in progress while learning Rust for Linux.
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ cargo fmt --check
cargo clippy --all-targets --locked -- -D warnings
cargo test --locked
./scripts/governance-check.sh
# optional, needs linux-headers: make -C kernel
```

## Release
Expand Down
18 changes: 3 additions & 15 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,15 +1,3 @@
[package]
name = "rust-null"
version = "0.1.0"
edition = "2021"
rust-version = "1.80"
authors = ["evie <evie@hareai.dev>"]
license = "GPL-2.0-only"
description = "Userspace model of Linux /dev/null, heading toward a Rust-for-Linux misc device."
repository = "https://github.com/hareai/rust-null"
readme = "README.md"
keywords = ["linux", "null", "kernel", "device"]
categories = ["os"]
publish = false

[dependencies]
[workspace]
members = ["userspace"]
resolver = "2"
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# rust-null

A userspace model of Linux `/dev/null`, on the way to a Rust-for-Linux `/dev/rust-null` device.
A userspace model of Linux `/dev/null`, plus a misc device `/dev/rust-null` with the same I/O contract. Work in progress, written while learning [Rust for Linux](https://rust-for-linux.com).

[简体中文](README.zh-CN.md)

Expand All @@ -10,10 +10,10 @@ A userspace model of Linux `/dev/null`, on the way to a Rust-for-Linux `/dev/rus
git clone https://github.com/hareai/rust-null.git
cd rust-null
cargo test
echo hello | cargo run --example discard
echo hello | cargo run -p rust-null --example discard
```

Rust 1.80 or newer. `cargo test` is the userspace contract; it does not load a kernel module.
Rust 1.80 or newer. `cargo test` is the userspace contract. It does not load a kernel module. `/dev/null` vs `/dev/rust-null` tests skip unless the device node exists.

## Usage

Expand All @@ -37,11 +37,22 @@ assert_eq!(n.read(&mut buf).unwrap(), 0); // EOF, buffer untouched

That last row is the surprising one: `lseek` on `/dev/null` always returns `0`, even for `SEEK_END`.

## Roadmap
## Tree

1. **Userspace** (this tree) — `NullDevice` + contract tests.
2. **Rust for Linux** — out-of-tree module against a `CONFIG_RUST=y` kernel.
3. **`/dev/rust-null`** — misc device with the same read/write/llseek contract.
```text
userspace/ NullDevice crate and contract tests
kernel/ loadable misc device (C) and RfL source (not built here)
```

The C module registers a misc device named `rust-null` (`misc_register`, major 10, dynamic minor) whose `file_operations` copy `null_fops`. That is the same VFS path Rust-for-Linux `MiscDevice` wraps.

```bash
make -C kernel # needs linux-headers for the running kernel
# make -C kernel load # insmod; only on a machine you intend to test
RUST_NULL_REQUIRE_DEV=1 cargo test -p rust-null --test compare_devices --locked
```

A distro kernel without `CONFIG_RUST=y` cannot compile `kernel/rfl/rust_null.rs`. That file is the out-of-tree module for a self-built Rust-enabled kernel (`make LLVM=1`).

## License

Expand Down
25 changes: 18 additions & 7 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# rust-null

Linux `/dev/null` 的用户态模型,目标是做成 Rust-for-Linux 的 `/dev/rust-null`。
Linux `/dev/null` 的用户态模型,以及合同相同的 misc 设备 `/dev/rust-null`。边学 [Rust for Linux](https://rust-for-linux.com) 边写。

[English](README.md)

Expand All @@ -10,10 +10,10 @@ Linux `/dev/null` 的用户态模型,目标是做成 Rust-for-Linux 的 `/dev/
git clone https://github.com/hareai/rust-null.git
cd rust-null
cargo test
echo hello | cargo run --example discard
echo hello | cargo run -p rust-null --example discard
```

需要 Rust 1.80 或更新。`cargo test` 只验证用户态合同,不会加载内核模块。
需要 Rust 1.80 或更新。`cargo test` 只验证用户态合同,不会加载内核模块。没有 `/dev/rust-null` 时,设备对比测试会跳过。

## 用法

Expand All @@ -37,11 +37,22 @@ assert_eq!(n.read(&mut buf).unwrap(), 0); // EOF,缓冲区不动

最后一行容易踩:`/dev/null` 上的 `lseek` 永远返回 `0`,包括 `SEEK_END`。

## 路线
## 目录

1. **用户态**(当前树)— `NullDevice` + 合同测试。
2. **Rust for Linux** — 对着 `CONFIG_RUST=y` 的内核做 out-of-tree 模块。
3. **`/dev/rust-null`** — 同样 read/write/llseek 合同的 misc 设备。
```text
userspace/ NullDevice crate 和合同测试
kernel/ 可加载的 misc 设备(C)和 RfL 源码(这里不编)
```

C 模块用 `misc_register` 注册名为 `rust-null` 的 misc 设备(主设备号 10,动态次设备号),`file_operations` 抄 `null_fops`。这就是 Rust-for-Linux `MiscDevice` 封装的那条 VFS 路径。

```bash
make -C kernel # 需要正在运行的内核的 linux-headers
# make -C kernel load # insmod;只在打算测试的机器上
RUST_NULL_REQUIRE_DEV=1 cargo test -p rust-null --test compare_devices --locked
```

发行核没有 `CONFIG_RUST=y` 时编不了 `kernel/rfl/rust_null.rs`。那是给自己编的 Rust 内核用的 out-of-tree 模块(`make LLVM=1`)。

## 许可证

Expand Down
22 changes: 17 additions & 5 deletions docs/contract.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# `/dev/null` contract

Userspace `NullDevice` copies three helpers from Linux `drivers/char/mem.c`. It is not a file, not a buffer, and not `/dev/zero`.
Userspace `NullDevice` and the `rust-null` misc device copy three helpers from Linux `drivers/char/mem.c`. They are not a file, not a buffer, and not `/dev/zero`.

## Kernel

Expand All @@ -25,6 +25,8 @@ static loff_t null_lseek(struct file *file, loff_t offset, int orig)

`ppos` / `offset` / `orig` are unused. A successful write does not move the file position, because the next seek (and the implicit position) is always zero.

`null_fops` also sets `read_iter` / `write_iter` to the same idea (`return 0` / `return count` after `iov_iter_advance`).

## Userspace mapping

| Kernel | `NullDevice` | `std::io` |
Expand All @@ -33,14 +35,24 @@ static loff_t null_lseek(struct file *file, loff_t offset, int orig)
| `write_null` | `write_null` | `Write::write` → `Ok(count)` |
| `null_lseek` | `null_lseek` | `Seek::seek` → `Ok(0)` |

## `/dev/null` vs `/dev/rust-null`

| | `/dev/null` | `/dev/rust-null` |
|---|---|---|
| Class | mem char `1:3` | misc, major 10, dynamic minor |
| Register | `chr_dev_init` / `devlist[3]` | `misc_register` (`name = "rust-null"`) |
| Contract | `null_fops` | same read / write / llseek / `*_iter` |

`userspace/tests/compare_devices.rs` opens both and checks the table. Without the device node the tests skip. After `make -C kernel load`:

```bash
RUST_NULL_REQUIRE_DEV=1 cargo test -p rust-null --test compare_devices --locked
```

## Not this device

- `/dev/zero` fills reads with `0x00`.
- `/dev/full` fails writes with `ENOSPC`.
- A regular file of length 0 still has a moving offset after `SEEK_SET`.

Live Linux `/dev/null` (`1:3`) matches the table above: `lseek(SEEK_SET, 100)` returns `0`.

## Next

The out-of-tree Rust-for-Linux module will register a misc device named `rust-null` and implement the same three operations on `kernel::file` types. The contract tests here stay the oracle.
8 changes: 8 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ cargo clippy --all-targets --locked -- -D warnings
cargo test --locked
```

Optional, when `linux-headers-$(uname -r)` is installed:

```bash
make -C kernel
```

Do not `make -C kernel load` unless the owner asked to test `/dev/rust-null` on that machine.

Public GitHub repos: `git config hermes.public true`. Do not commit `.env`, live keys, or household/private domains (history counts). Private repos are exempt.

## PR gate
Expand Down
12 changes: 12 additions & 0 deletions kernel/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
*.o
*.ko
*.mod
*.mod.c
*.mod.o
.*.cmd
.tmp_versions/
Module.symvers
modules.order
*.dwo
.rust_null.o.d
rust_null.o.d
3 changes: 3 additions & 0 deletions kernel/Kbuild
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SPDX-License-Identifier: GPL-2.0

obj-m := rust_null.o
20 changes: 20 additions & 0 deletions kernel/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# SPDX-License-Identifier: GPL-2.0

KDIR ?= /lib/modules/$(shell uname -r)/build

default:
$(MAKE) -C $(KDIR) M=$(CURDIR) modules

clean:
$(MAKE) -C $(KDIR) M=$(CURDIR) clean

# Opt-in. Do not run on a machine you are not prepared to test.
load: default
@if lsmod | grep -q '^rust_null '; then rmmod rust_null; fi
insmod $(CURDIR)/rust_null.ko
@test -e /dev/rust-null
@ls -l /dev/rust-null

unload:
@if lsmod | grep -q '^rust_null '; then rmmod rust_null; fi
@test ! -e /dev/rust-null
35 changes: 35 additions & 0 deletions kernel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# kernel

Two sources, one device name: `rust-null`.

| File | What it is | When it builds |
|---|---|---|
| `rust_null.c` | `misc_register` + `file_operations` copied from `null_fops` | Distro kernels with `CONFIG_MODULES=y` and matching `linux-headers` |
| `rfl/rust_null.rs` | Rust-for-Linux `MiscDevice` | A self-built kernel with `CONFIG_RUST=y` (`make LLVM=1`) |

`make` in this directory builds **only** the C module (`obj-m := rust_null.o`). It does not compile the `.rs`. Distro headers have no Rust metadata.

## VFS

`/dev/null` is mem `1:3` (`drivers/char/mem.c`). This module is a *misc* device (major 10, `MISC_DYNAMIC_MINOR`). The I/O contract is the same:

```c
read → 0
write → count
llseek → f_pos = 0
read_iter → 0
write_iter → count (iov_iter_advance)
```

`kernel::miscdevice::MiscDevice` on mainline currently wires `read_iter` / `write_iter` / `ioctl` / `mmap`, not `.llseek`. The C module implements `llseek` so a loaded `/dev/rust-null` can match `/dev/null` on seek. A future RfL hook should do the same.

## Commands

```bash
make # rust_null.ko
make load # insmod; creates /dev/rust-null
make unload # rmmod
make clean
```

`load` / `unload` are opt-in. Do not run them on a machine you are not prepared to test.
78 changes: 78 additions & 0 deletions kernel/rfl/rust_null.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: GPL-2.0

//! Rust-for-Linux `MiscDevice` with the `/dev/null` contract.
//!
//! This file is the out-of-tree module you would build against a
//! `CONFIG_RUST=y` kernel (`make LLVM=1` in that tree, `M=` this
//! directory, `obj-m := rust_null.o` compiling *this* `.rs`).
//!
//! Distro kernels often ship `CONFIG_HAVE_RUST=y` but not `CONFIG_RUST`.
//! They cannot compile this file. The loadable module in that case is
//! [`rust_null.c`](../rust_null.c), which registers the same
//! `struct miscdevice` name (`rust-null`) and the same
//! `file_operations` (`read` / `write` / `read_iter` / `write_iter` /
//! `llseek`).
//!
//! `kernel::miscdevice::MiscDevice` (mainline) currently hooks
//! `read_iter` / `write_iter` / `ioctl` / `mmap`. It does **not**
//! expose `.llseek`. `/dev/null` uses `null_lseek` (`f_pos = 0`).
//! The C module implements that; a future RfL `llseek` hook should
//! match it. Until then, a pure `MiscDevice` would not pass the seek
//! half of `tests/compare_devices.rs`.

use kernel::{
fs::{File, Kiocb},
iov::{IovIterDest, IovIterSource},
miscdevice::{MiscDevice, MiscDeviceOptions, MiscDeviceRegistration},
prelude::*,
};

module! {
type: RustNullModule,
name: "rust_null",
authors: ["evie"],
description: "misc device with the /dev/null I/O contract",
license: "GPL",
}

#[pin_data]
struct RustNullModule {
#[pin]
_miscdev: MiscDeviceRegistration<RustNull>,
}

impl kernel::InPlaceModule for RustNullModule {
fn init(_module: &'static ThisModule) -> impl PinInit<Self, Error> {
pr_info!("rust_null: registering misc device rust-null\n");

let options = MiscDeviceOptions { name: c"rust-null" };

try_pin_init!(Self {
_miscdev <- MiscDeviceRegistration::register(options),
})
}
}

/// Per-open state. `/dev/null` has none; neither do we.
struct RustNull;

#[vtable]
impl MiscDevice for RustNull {
type Ptr = ();

fn open(_file: &File, _misc: &MiscDeviceRegistration<Self>) -> Result<Self::Ptr> {
Ok(())
}

/// `read_null` / `read_iter_null`: always EOF. Do not fill `iov`.
fn read_iter(_kiocb: Kiocb<'_, Self::Ptr>, _iov: &mut IovIterDest<'_>) -> Result<usize> {
Ok(0)
}

/// `write_null` / `write_iter_null`: accept every byte, discard it.
fn write_iter(_kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterSource<'_>) -> Result<usize> {
let count = iov.len();
iov.advance(count);
Ok(count)
}
}
Loading
Loading