PiROS — Rust Program Development Brief
Paste this whole document at the start of a fresh Claude (claude.ai) session. It is the complete contract for writing programs for PiROS; Claude has no other access to the OS. Ask it to write/adapt a program and it should produce Rust that fits this exactly.
0. Your role
You are helping write programs ("tools") for PiROS, a from-scratch no_std x86_64 microkernel OS. You cannot see the OS source — this brief is authoritative. Generate Rust that compiles for PiROS's freestanding target and uses only the ABI below. When unsure whether something is available, assume it is not and ask.
1. What a PiROS program is
- A separately-compiled, statically-linked, freestanding
#![no_std]ELF that runs
unprivileged (ring 3).
- It reaches the kernel only through a small syscall API (a
Syshandle). There is
no std, no direct hardware access, no shared libraries, no dynamic linking.
- Every program is signed and the kernel verifies it before running (you don't handle
signing). It runs to completion and returns an i32 exit code (0 = success).
2. Crate layout
programs/<name>/
Cargo.toml
src/main.rs
<optional asset files, embedded with include_bytes!>
Cargo.toml:
[package]
name = "<name>"
version = "0.1.0"
edition = "2021"
[dependencies]
pros-rt = { path = "../../crates/rt" } # the only dependency you normally need
3. Required skeleton (do not deviate)
#![no_std]
#![no_main]
use pros_rt::{tool, Sys};
// Your program is this function. `args` is the raw command-line bytes after the
// program name (NOT a parsed argv). Return 0 on success.
fn main(sys: &Sys, args: &[u8]) -> i32 {
sys.write_str("hello\n");
0
}
tool!(main); // generates the real entry point; required
4. The Sys API — the ONLY way to do I/O
impl Sys {
// Output to the console (or the shell's redirect target, transparently).
fn write(&self, bytes: &[u8]);
fn write_str(&self, s: &str);
// Files/dirs in the CALLING USER's /data only. `dir` is a tree selector (below);
// `name` is a path WITHIN that tree and may be nested ("proj/src/main.rs").
fn read_file(&self, dir: u32, name: &[u8], out: &mut [u8]) -> Option<usize>; // bytes read
fn write_file(&self, dir: u32, name: &[u8], data: &[u8]) -> bool; // files/ & tmp/ only
fn delete(&self, dir: u32, name: &[u8]) -> bool; // files (not dirs)
fn list_dir(&self, dir: u32, name: &[u8], out: &mut [u8]) -> usize; // entries of dir/name;
// subdirs suffixed "/"
fn stat(&self, dir: u32, name: &[u8]) -> Option<usize>; // file size in bytes
fn mkdir(&self, dir: u32, name: &[u8]) -> bool; // parent must exist
fn mv(&self, dir_src: u32, src: &[u8], dir_dst: u32, dst: &[u8]) -> bool; // move/rename
fn rmdir(&self, dir: u32, name: &[u8]) -> bool; // empty dir only
fn user(&self, out: &mut [u8]) -> usize; // the owning user's name into `out`
fn getc(&self) -> u8; // read ONE console byte (blocking)
// Cross-user sharing (see the OS's publish/wget model). You may publish a file OR a
// directory (its whole subtree becomes reachable to the audience).
fn publish(&self, dir: u32, name: &[u8], audience: &[u8]) -> bool; // audience: username or b"*"
fn fetch(&self, spec: &[u8], out: &mut [u8]) -> Option<usize>; // spec = b"owner:name" (name may be a path)
fn list_pub(&self, spec: &[u8], out: &mut [u8]) -> usize; // b"owner" or b"owner:subdir"
// Networking: HTTP/1.0 GET over TCP. `spec` = b"host" or b"host/path". The raw
// response (status line + headers + body) is written to `out`. No TLS (http only).
fn http_get(&self, spec: &[u8], out: &mut [u8]) -> Option<usize>;
// --- Server-side networking (build daemons: HTTP/FTP/SQL). Socket handles are opaque
// u32. accept/read YIELD to other tasks while they'd block, so a server that runs
// as a background service doesn't hog the CPU. These are all REAL now (not assumed). ---
fn tcp_listen(&self, port: u16) -> Option<u32>; // -> listener handle
fn tcp_accept(&self, listener: u32) -> Option<u32>; // -> connection (blocks/yields)
fn tcp_read(&self, conn: u32, out: &mut [u8]) -> Option<usize>; // Some(0) = peer closed
fn tcp_write(&self, conn: u32, data: &[u8]) -> bool;
fn tcp_close(&self, conn: u32);
fn tcp_connect(&self, ip: [u8; 4], port: u16) -> Option<u32>; // dial out (FTP active mode)
fn tcp_listen_ephemeral(&self) -> Option<(u32, u16)>; // (listener, port) for FTP PASV
fn tcp_local_ip(&self) -> [u8; 4]; // our IPv4 (for the PASV reply)
fn dns_resolve(&self, name: &[u8]) -> Option<[u8; 4]>; // hostname -> IPv4
fn auth(&self, user: &[u8], pass: &[u8]) -> bool; // verify against the user store
fn append_file(&self, dir: u32, name: &[u8], data: &[u8]) -> bool; // append+create (NOT logs/)
fn pollkey(&self) -> Option<u8>; // non-blocking console key
// --- Run a signed tool as a child process and capture its stdout (REAL now). ---
// The child runs as YOUR user (sees your /data), blocks until it exits, and only its
// OWN stdout is captured into `out`. Returns (exit_code, bytes_captured), or None if the
// tool isn't installed or fails signature verification (A+B=C still applies to children).
fn exec(&self, name: &[u8], args: &[u8], out: &mut [u8]) -> Option<(i32, usize)>;
// e.g. tintin serving a .psh page:
// let mut body = [0u8; 4096];
// if let Some((code, n)) = sys.exec(b"psh", b"files/page.psh", &mut body) { ... }
// Notes: `out` bounds the capture (kernel side caps at 8 KiB); one exec finishes before
// the next in a single program; args are limited to 512 bytes.
// --- Cached file descriptors (the storage cache map). Open a file in YOUR /data into a
// RAM-cached descriptor and do random access by offset — unlike read_file/write_file
// (whole-file), these let you seek. fflush/fclose write back through the vault-sealed
// store; a descriptor left unflushed is NOT persisted. `dir`/`name` are the usual tree
// selector + path. (REAL now.) ---
fn fopen(&self, dir: u32, name: &[u8]) -> Option<usize>; // -> fd
fn fread(&self, fd: usize, off: usize, out: &mut [u8]) -> Option<usize>; // bytes read at off
fn fwrite(&self, fd: usize, off: usize, data: &[u8]) -> Option<usize>; // bytes written at off (grows)
fn fsize(&self, fd: usize) -> Option<usize>; // cached length in bytes
fn fflush(&self, fd: usize) -> bool; // persist now, keep open
fn fclose(&self, fd: usize) -> bool; // flush + close
}
// Tree selectors. files/ and tmp/ are real directory trees (nest freely); logs/ is flat
// and read-only to programs. DIR_CWD means "resolve `name` relative to the session's
// current directory" — parse_path returns it for bare/relative paths, so you normally
// just pass whatever parse_path gives you straight to the syscall.
pub const DIR_FILES: u32 = 0;
pub const DIR_LOGS: u32 = 1; // read-only to programs
pub const DIR_TMP: u32 = 2;
pub const DIR_CWD: u32 = 3; // relative to the current directory
// Parsing helpers exported by pros_rt (use these; don't reinvent):
pub fn trim(s: &[u8]) -> &[u8]; // strip ASCII space/tab both ends
pub fn split_first(s: &[u8]) -> (&[u8], &[u8]); // first space-separated word, rest
pub fn parse_path(p: &[u8]) -> (u32, &[u8]); // "files/a/b" -> (DIR_FILES, b"a/b");
// "a/b" -> (DIR_CWD, b"a/b")
pub fn contains(haystack: &[u8], needle: &[u8]) -> bool;
Directories & the working directory. /data/{files,tmp} are real trees. Pass a nested name ("proj/src/main.rs") to any file syscall and the kernel resolves it. The shell has a working directory; a bare/relative path from the user ("note.txt", "../out") becomes DIR_CWD and resolves against it — so a tool that just forwards parse_path(arg) to the syscalls automatically respects the user's cd. To list the current directory, list_dir(DIR_CWD, b"", &mut buf). Entries that are subdirectories come back with a trailing /.
There is no println!, print!, or std::io. All output goes through sys.write*.
5. What you CAN and CANNOT use
CAN use:
- All of
core::— slices, arrays, iterators,Option/Result, pattern matching,
core::str, core::cmp, integer math, const generics, core::fmt::Write (for formatting into a buffer), etc.
alloc::(heap) —Vec,String,Box,BTreeMap,format!, etc. A global
allocator is wired, so use it freely. Add extern crate alloc; at the top and use alloc::{vec::Vec, string::String};. (Per-program heap is a few MiB.)
CANNOT use:
stdin any form (nostd::io,std::fs,std::net,std::thread,std::collections,
Instant, env, process, etc.).
- Threads, async/await runtimes, networking/sockets — none exist.
- Panicking as control flow — a panic aborts the process. Avoid
.unwrap()/.expect()
on data you don't fully control; handle errors and return non-zero.
- Heavy floating point — the target is soft-float with SSE disabled; simple float
ops work but are slow. Prefer integer math.
- Arbitrary crates — most need
std. Only add a dependency if it isno_std
(default-features = false) and builds for a bare target; even then it may fail to compile (e.g. crates with SIMD/wide-integer asm). Prefer vendoring a small algorithm over pulling a crate. pros-rt (and core/alloc) is the safe default.
6. Hard constraints & current limits (proof-of-concept)
- Files are per-user and local. A program can only touch the calling user's
/data/{files,logs,tmp}. write_file/delete work on files/ and tmp/; logs/ is written only by the kernel. files/ and tmp/ are directory trees — use nested paths and mkdir/mv/rmdir.
- Per-file size cap: 4096 bytes (4 KiB) (read and write capped at 4096). Design around
this; large content must be split or chunked. (This limit is expandable later.)
- Capacity: up to 24 entries per directory and 48 files total per user (also
expandable). write_file to a path whose parent directory doesn't exist fails — mkdir first.
- Output is a text serial console. ANSI escape sequences work (e.g.
\x1b[2Jclear,
\x1b[<row>;<col>H move cursor) — see the editor pattern. Convert \n to \r\n for the terminal.
- Input is
getc()— one byte at a time, blocking. Arrow keys arrive as three bytes:
0x1b '[' 'A'|'B'|'C'|'D' (up/down/right/left). Ctrl-keys are their control codes (e.g. ^X = 0x18, ^O = 0x0f).
argsis raw bytes, space-separated by convention. Parse withtrim/split_first.- Single-threaded, no persistent process — the program runs once and exits. State
lives on the stack, in statics, or (with alloc) on the heap.
7. Idioms you should use
Buffered output + heap-free formatting (no format! without alloc): implement core::fmt::Write on a fixed buffer and use write!.
use core::fmt::{self, Write};
struct Buf<'a> { data: &'a mut [u8], len: usize }
impl<'a> Buf<'a> {
fn new(data: &'a mut [u8]) -> Self { Buf { data, len: 0 } }
fn as_slice(&self) -> &[u8] { &self.data[..self.len] }
}
impl Write for Buf<'_> {
fn write_str(&mut self, s: &str) -> fmt::Result {
let b = s.as_bytes();
let n = core::cmp::min(b.len(), self.data.len() - self.len);
self.data[self.len..self.len + n].copy_from_slice(&b[..n]);
self.len += n;
Ok(())
}
}
// usage:
// let mut out = [0u8; 128]; let mut b = Buf::new(&mut out);
// let _ = write!(b, "count = {}\n", n);
// sys.write(b.as_slice());
Reading a file into a fixed buffer (files are capped at 4096 bytes):
let (dir, name) = pros_rt::parse_path(pros_rt::trim(args));
let mut buf = [0u8; 4096];
let n = match sys.read_file(dir, name, &mut buf) {
Some(n) => n,
None => { sys.write_str("not found\n"); return 1; }
};
let data = &buf[..n];
Interactive input (editor/menu style): loop on sys.getc(), match control codes and 0x1b '[' … escape sequences, redraw with ANSI. (The OS's prano editor is built this way.)
8. Full worked example — wc (lines / words / bytes)
#![no_std]
#![no_main]
use core::fmt::{self, Write};
use pros_rt::{parse_path, tool, trim, Sys};
struct Buf<'a> { data: &'a mut [u8], len: usize }
impl<'a> Buf<'a> {
fn new(data: &'a mut [u8]) -> Self { Buf { data, len: 0 } }
fn as_slice(&self) -> &[u8] { &self.data[..self.len] }
}
impl Write for Buf<'_> {
fn write_str(&mut self, s: &str) -> fmt::Result {
let b = s.as_bytes();
let n = core::cmp::min(b.len(), self.data.len() - self.len);
self.data[self.len..self.len + n].copy_from_slice(&b[..n]);
self.len += n;
Ok(())
}
}
fn main(sys: &Sys, args: &[u8]) -> i32 {
let path = trim(args);
if path.is_empty() {
sys.write_str("wc: usage: wc <path>\n");
return 1;
}
let (dir, name) = parse_path(path);
let mut file = [0u8; 4096];
let n = match sys.read_file(dir, name, &mut file) {
Some(n) => n,
None => { sys.write_str("wc: not found\n"); return 1; }
};
let data = &file[..n];
let bytes = data.len();
let lines = data.iter().filter(|&&b| b == b'\n').count();
let words = data.split(|b| b.is_ascii_whitespace()).filter(|w| !w.is_empty()).count();
let mut out = [0u8; 64];
let mut b = Buf::new(&mut out);
let _ = write!(b, "{lines} {words} {bytes}\n");
sys.write(b.as_slice());
0
}
tool!(main);
(With alloc enabled, the formatting could instead be sys.write_str(&format!("{lines} {words} {bytes}\n")).)
9. Build, sign & install (the human runs these; for your awareness)
# compile for the bare target (release!)
(cd programs && cargo build --release) # -> programs/target/x86_64-unknown-none/release/<name>
# sign with the authority key
cargo run --manifest-path tools/manifest/Cargo.toml -- \
sign --key keys/manifest_private.bin --name <name> --version 1 \
--image programs/target/x86_64-unknown-none/release/<name> --out dist/certs/<name>.cert
# install into the persistent /bin overlay (no OS rebuild)
cargo run --manifest-path tools/install/Cargo.toml -- \
dist/data.img <name> dist/certs/<name>.cert programs/target/x86_64-unknown-none/release/<name>
A program must also be granted a namespace (added to the core cluster in build.sh, one-time) before it can run — otherwise it reports unknown tool (granted, no binary) or [DENIED] no grant (not granted).
10. Design ethos
Small, composable, single-purpose tools. Everything through the ABI. No shared state except files. Deny-by-default. Prefer clarity and bounds-safety over cleverness — the program runs unprivileged and a fault kills it (cleanly), so validate inputs.
## STATUS — read this - Confirmed available today:#![no_std]+core::+alloc::(a global allocator is wired, soVec,String,Box,BTreeMap,format!all work — addextern crate alloc;) + theSysAPI above, including directories (nested paths,mkdir/mv/rmdir, a shell working directory viaDIR_CWD) and **publish/fetch of files or whole directory subtrees, andhttp_getnetworking** (a real NIC→ARP→IP→TCP→HTTP stack;curlandasterix http://…use it). Per-file cap is 4096 bytes. - Existing tools (for reference/style): `echo cat ls mkdir mv rmdir du cp rm grep prano psh publish wget argo curl asterix. Yours should feel like these. Notewget` andargosplit:wget <url> [dest]is the traditional HTTP downloader (saves the response body tofiles/;curlprints the raw response), whileargo owner:name/argo --list owneris the inter-user consume-published fetch (reads another user's published resource straight from the store — no network). - Open file handles ARE now available — the cached file-descriptor API (fopen/fread/fwrite/fsize/fflush/fcloseabove) gives random-access, offset-addressed I/O over the storage cache map. Use these when you need to seek within a file; the older whole-fileread_file/write_filestill work for small reads/writes. - Not available (do NOT design around these — they need OS features not built yet): HTTPS/TLS, raw/lower-level sockets (only the one-shothttp_getexists, plus the server-sidetcp_*calls), threads,std, background/long-running processes, wall-clock date/time. - On the roadmap (coming, but assume absent until this brief says otherwise): TLS/HTTPS, alsof-style view of open descriptors, and raw block/device access. If a task needs one of these, say so rather than inventing an API. - Content larger than 4 KiB must be split across files or chunked.