lib/os+selfhost: *u8→str path migration (#23)

Path-shaped entrypoints now take str: open, tryopen, access, remove,
mkdir, rmdir, mkdirs, stat, lstat, exists, execve (path arg only).
Each cites its Hare source (ref/hare/os/*.ha, ref/hare/sys/+linux/
*.ha).

New internal kpath(str) *u8 copies into module-level pathbuf: [4096]u8
and NUL-terminates; mirrors ref/hare/sys/+linux/syscalls.ha:25,53.
Non-reentrant — graduates with thread story. mkdirs flattens to one
kpath at entry then walks pathbuf invoking raw SYS_mkdir to avoid
nested kpath clobber.

One Hare divergence at kpath: ships *u8 with nil ENAMETOOLONG sentinel
instead of (*const u8 | errno). Reason: wwstage over-allocates
1-word-payload tagged returns to 24B (cstage emits 16B); filed as
follow-up. Repro at .ai/probe_tagged_return_pointer_payload.ww;
graduates when fix lands.

Each selfhost cmd grew a private pathstr(*u8) str (cstrlen + bs) for
remaining *u8 path sites; w6l shares via obj.ww. Probe 7 in smoke
updated.

Tests 975/976/981 cover migrated entrypoints; 976 extended with two
ENAMETOOLONG rows (-36 for stat, false for exists).
This commit is contained in:
2026-05-16 23:36:41 +09:00
parent 08ac5149e8
commit deaa777eb8
19 changed files with 963 additions and 451 deletions

View File

@@ -88,6 +88,36 @@ export fn exit(code: i32) void = {
syscall1(nr.EXIT, code: i64);
};
// PATH_MAX / pathbuf / kpath — port of Hare's ref/hare/sys/+linux/
// syscalls.ha:25,27,29-55. Hare's `path` accepts a sum `(str |
// []u8 | *const u8)`; ww's lib/os public surface narrows to `str`
// (the Hare-faithful surface at ref/hare/os/os.ha:37,47,50 etc).
// Internally, [[kpath]] copies the `str` bytes into a single
// module-level [[pathbuf]] scratch slot and NUL-terminates so the
// raw Linux syscalls (which require C strings) see a valid
// terminator. Same precedent as Hare's static `pathbuf`.
//
// Non-reentrant: one buffer, every [[stat]] / [[open]] / etc.
// rewrites it. Same caveat as strconv's `*tos` family (overwritten
// on next call). Caller must NOT hold a kpath-returned pointer
// across another lib/os path call. Graduates when ww grows a
// thread story.
//
// `nil`-as-overflow over `(*u8 | oserror)`: wwstage over-allocates
// 1-word-payload tagged returns to 24B (cstage emits 16B).
// Task #9; revert at task #10 when fixed. Repro at
// .ai/probe_tagged_return_pointer_payload.ww.
export def PATH_MAX: i32 = 4096;
let pathbuf: [4096]u8;
fn kpath(p: str) *u8 = {
if (p.len + 1 >= PATH_MAX) { return nil: *u8; }; // ENAMETOOLONG
let i: i32 = 0;
for (i < p.len) { pathbuf[i] = p[i]; i += 1; };
pathbuf[p.len] = 0u8;
return &pathbuf[0];
};
// Raw, non-fallible primitives. These return Linux's int conventions
// (negative = -errno, non-negative = bytes/fd/etc). Callers wanting a
// Hare-style fallible API use the wrappers below.
@@ -126,15 +156,17 @@ export fn trywrite(fd: i32, buf: *u8, n: u64) (i64 | oserror) = {
return r;
};
// open — Linux open(2). Path must be NUL-terminated; callers using ww
// `str` must ensure the bytes are followed by a 0 byte (literals are,
// arena-copied paths usually are by construction). Returns -errno on
// failure, fd otherwise. Higher-level callers prefer `tryopen`.
export fn open(path: *u8, flags: flag, mode: i32) i32 = {
return syscall3(nr.OPEN, path: i64, (flags as i32): i64, mode: i64): i32;
// open — Linux open(2). Returns -errno on failure, fd otherwise.
// Higher-level callers prefer `tryopen`. Mirrors Hare's os::open
// (ref/hare/os/os.ha:117); kpath lands the bytes in pathbuf.
// Returns -ENAMETOOLONG (-36) if the path overflows PATH_MAX.
export fn open(path: str, flags: flag, mode: i32) i32 = {
let p: *u8 = kpath(path);
if (p == nil: *u8) { return -36i32; }; // ENAMETOOLONG
return syscall3(nr.OPEN, p: i64, (flags as i32): i64, mode: i64): i32;
};
export fn tryopen(path: *u8, flags: flag, mode: i32) (i32 | oserror) = {
export fn tryopen(path: str, flags: flag, mode: i32) (i32 | oserror) = {
let fd: i32 = open(path, flags, mode);
if (fd < 0) { return fd: i64: oserror; };
return fd;
@@ -194,26 +226,37 @@ export fn writeall(fd: i32, buf: *u8, n: u64) (i64 | oserror) = {
// access(2): returns 0 if the file is reachable, negative errno
// otherwise. mode is the bitset described in <unistd.h> (F_OK=0).
export fn access(path: *u8, mode: i32) i32 = {
return syscall2(nr.ACCESS, path: i64, mode: i64): i32;
// Mirrors Hare's os::access (ref/hare/os/+linux/fs.ha:access).
// Returns -ENAMETOOLONG (-36) if the path overflows PATH_MAX.
export fn access(path: str, mode: i32) i32 = {
let p: *u8 = kpath(path);
if (p == nil: *u8) { return -36i32; };
return syscall2(nr.ACCESS, p: i64, mode: i64): i32;
};
// remove — unlink(2). Hare name; the underlying syscall is unlink(2).
export fn remove(path: *u8) i32 = {
return syscall1(nr.UNLINK, path: i64): i32;
// remove — unlink(2). Mirrors Hare's os::remove
// (ref/hare/os/os.ha:12).
export fn remove(path: str) i32 = {
let p: *u8 = kpath(path);
if (p == nil: *u8) { return -36i32; };
return syscall1(nr.UNLINK, p: i64): i32;
};
// mkdir — mkdir(2). Path must be NUL-terminated. Mode is the unix
// permission bitset (e.g. 0o700). Returns 0 on success, negative
// errno otherwise. Hare name (os::mkdir).
export fn mkdir(path: *u8, mode: i32) i32 = {
return syscall2(nr.MKDIR, path: i64, mode: i64): i32;
// mkdir — mkdir(2). Mode is the unix permission bitset (e.g. 0o700).
// Returns 0 on success, negative errno otherwise. Mirrors Hare's
// os::mkdir (ref/hare/os/os.ha:50).
export fn mkdir(path: str, mode: i32) i32 = {
let p: *u8 = kpath(path);
if (p == nil: *u8) { return -36i32; };
return syscall2(nr.MKDIR, p: i64, mode: i64): i32;
};
// rmdir — rmdir(2). Path must be NUL-terminated. Returns 0 on
// success, negative errno otherwise. Hare name (os::rmdir).
export fn rmdir(path: *u8) i32 = {
return syscall1(nr.RMDIR, path: i64): i32;
// rmdir — rmdir(2). Mirrors Hare's os::rmdir
// (ref/hare/os/os.ha:58).
export fn rmdir(path: str) i32 = {
let p: *u8 = kpath(path);
if (p == nil: *u8) { return -36i32; };
return syscall1(nr.RMDIR, p: i64): i32;
};
// mkdirs — recursive mkdir. Creates `path` and any non-existent
@@ -221,29 +264,28 @@ export fn rmdir(path: *u8) i32 = {
// accepted (matches Hare's `errors::exists` skip in os::mkdirs);
// any other syscall failure surfaces as `oserror`.
//
// `path` must be NUL-terminated AND its bytes must be writable —
// mkdirs temporarily replaces '/' separators with NUL while
// invoking [[mkdir]] on each prefix, then restores them. Pointing
// `path` at a string literal will segfault. Callers hold the bytes
// in a writable buffer (rt_alloc'd, a static `[N]u8`, etc.) — same
// precedent as [[temp.named]]'s pathbuf.
//
// Mirrors Hare's os::mkdirs (recursive variant of os::mkdir).
export fn mkdirs(path: *u8, mode: i32) (void | oserror) = {
// Find the path length (excluding trailing NUL).
let n: i32 = 0;
for (path[n] != 0u8) { n += 1; };
// Mirrors Hare's os::mkdirs (ref/hare/os/os.ha:54). The in-place
// '/' → NUL splice walks the kpath-loaded [[pathbuf]] directly
// instead of recursing through [[mkdir]] — re-entering kpath would
// clobber the buffer mid-walk (single static slot, see kpath's
// non-reentrancy note above).
export fn mkdirs(path: str, mode: i32) (void | oserror) = {
let cp: *u8 = kpath(path);
if (cp == nil: *u8) { return -36i64: oserror; };
let n: i32 = path.len;
if (n == 0) { return; };
// Walk forward; at each '/' boundary, NUL-terminate the prefix,
// mkdir it, restore the slash, continue. Skip index 0 so a
// leading '/' on absolute paths doesn't trigger an empty mkdir.
// raw MKDIR syscall on pathbuf, restore the slash, continue.
// Skip index 0 so a leading '/' on absolute paths doesn't
// trigger an empty mkdir.
let i: i32 = 1;
for (i < n) {
if (path[i] == 47u8) { // '/'
path[i] = 0u8;
let r: i32 = mkdir(path, mode);
path[i] = 47u8;
if (pathbuf[i] == 47u8) { // '/'
pathbuf[i] = 0u8;
let r: i32 = syscall2(nr.MKDIR,
(&pathbuf[0]): i64, mode: i64): i32;
pathbuf[i] = 47u8;
if (r < 0) {
if (r != -17) { return r: i64: oserror; };
};
@@ -251,8 +293,8 @@ export fn mkdirs(path: *u8, mode: i32) (void | oserror) = {
i += 1;
};
// mkdir the full path.
let r: i32 = mkdir(path, mode);
let r: i32 = syscall2(nr.MKDIR,
(&pathbuf[0]): i64, mode: i64): i32;
if (r < 0) {
if (r != -17) { return r: i64: oserror; };
};
@@ -270,9 +312,14 @@ export fn fork() i32 = {
return syscall0(nr.FORK): i32;
};
// execve(2): on success, does not return.
export fn execve(path: *u8, argv: **u8, envp: **u8) i32 = {
return syscall3(nr.EXECVE, path: i64, argv: i64, envp: i64): i32;
// execve(2): on success, does not return. Mirrors Hare's
// os::exec::exec path arg (str). argv/envp stay `**u8` — the
// kernel takes a NUL-pointer-terminated table of NUL-terminated
// C strings, a different shape from a path.
export fn execve(path: str, argv: **u8, envp: **u8) i32 = {
let p: *u8 = kpath(path);
if (p == nil: *u8) { return -36i32; };
return syscall3(nr.EXECVE, p: i64, argv: i64, envp: i64): i32;
};
// wait4(2): wait for `pid` (or any child if -1), store status in
@@ -545,27 +592,31 @@ fn fillfilestat(out: *filestat, k: *kstat) void = {
};
// stat — fill *out with metadata for `path`. Follows symlinks.
// `path` must be NUL-terminated (lib/os convention; see task #23
// for a planned `path: str` migration).
// Returns ENAMETOOLONG (-36) as `oserror` if the path overflows
// PATH_MAX.
//
// Mirrors Hare's sys::stat (ref/hare/sys/+linux/stat.ha:51) modulo
// the out-param shape forced by the cgreturn 24B cap. Note: Hare's
// higher-level fs::stat (ref/hare/fs/fs.ha:172) instead has lstat
// semantics — we follow sys::stat's POSIX-stat behavior here.
export fn stat(out: *filestat, path: *u8) (void | oserror) = {
export fn stat(out: *filestat, path: str) (void | oserror) = {
let cp: *u8 = kpath(path);
if (cp == nil: *u8) { return -36i64: oserror; };
let k: kstat;
let r: i64 = syscall4(nr.NEWFSTATAT,
AT_FDCWD: i64, path: i64, (&k): i64, 0i64);
AT_FDCWD: i64, cp: i64, (&k): i64, 0i64);
if (r < 0) { return r: oserror; };
fillfilestat(out, &k);
};
// lstat — like [[stat]] but does NOT follow a terminal symlink.
// Mirrors Hare's sys::lstat (ref/hare/sys/+linux/stat.ha:57).
export fn lstat(out: *filestat, path: *u8) (void | oserror) = {
export fn lstat(out: *filestat, path: str) (void | oserror) = {
let cp: *u8 = kpath(path);
if (cp == nil: *u8) { return -36i64: oserror; };
let k: kstat;
let r: i64 = syscall4(nr.NEWFSTATAT,
AT_FDCWD: i64, path: i64, (&k): i64,
AT_FDCWD: i64, cp: i64, (&k): i64,
AT_SYMLINK_NOFOLLOW: i64);
if (r < 0) { return r: oserror; };
fillfilestat(out, &k);
@@ -586,7 +637,9 @@ export fn fstat(out: *filestat, fd: i32) (void | oserror) = {
// exists — true if `path` resolves to anything (regular file,
// directory, symlink, ...). Stat-shaped (Hare's `fs::exists`,
// ref/hare/fs/fs.ha:196) — no separate syscall. Symlinks are
// followed; a dangling symlink is `false`.
// followed; a dangling symlink is `false`. ENAMETOOLONG is
// swallowed as `false` — Hare's os::exists doc says "true if a
// node exists at the given path, or false if not."
//
// Race warning: prefer "open and handle the error" over "exists
// then open" in real code (Hare's docstring carries the same
@@ -599,10 +652,12 @@ export fn fstat(out: *filestat, fd: i32) (void | oserror) = {
// — same class as STATUS #22, surfaced first time a match on this
// shape combined with an 80B local-struct local frame). Use the
// match shape once #22 lands.
export fn exists(path: *u8) bool = {
export fn exists(path: str) bool = {
let cp: *u8 = kpath(path);
if (cp == nil: *u8) { return false; };
let k: kstat;
let r: i64 = syscall4(nr.NEWFSTATAT,
AT_FDCWD: i64, path: i64, (&k): i64, 0i64);
AT_FDCWD: i64, cp: i64, (&k): i64, 0i64);
return r >= 0i64;
};
@@ -906,7 +961,7 @@ def RELA_ADDEND: u64 = 16u64;
// ---- file slurp --------------------------------------------------------
fn slurp(path: *u8) (*u8, u64) = {
let fd: i32 = os.open(path, os.flag.RDONLY, 0i32);
let fd: i32 = os.open(pathstr(path), os.flag.RDONLY, 0i32);
if (fd < 0) { return nil, 0u64; };
let szr: (i64 | os.oserror) = os.filesize(fd);
let n: i64 = 0i64;
@@ -983,6 +1038,16 @@ fn cstrlen(p: *u8) u64 = {
return n;
};
// pathstr — view a NUL-terminated *u8 as a str. Bridges argv/arena
// callers to lib/os entrypoints (str post-task-#23). Shared with
// main.ww and dyn.ww via the w6l bundle.
fn pathstr(p: *u8) str = {
let r: str;
r.ptr = p;
r.len = cstrlen(p): i32;
return r;
};
fn cstreq(p: *u8, lit: str) bool = {
let n: u64 = lit.len: u64;
let i: u64 = 0u64;
@@ -1556,7 +1621,7 @@ fn dbasename(p: *u8) *u8 = {
// ---- file slurp --------------------------------------------------------
fn slurpso(path: *u8) (*u8, u64) = {
let fd: i32 = os.open(path, os.flag.RDONLY, 0i32);
let fd: i32 = os.open(pathstr(path), os.flag.RDONLY, 0i32);
if (fd < 0) { return nil, 0u64; };
let szr: (i64 | os.oserror) = os.filesize(fd);
let n: i64 = 0i64;
@@ -3010,7 +3075,7 @@ fn buildpathv(dst: *u8, dir: *u8, name: *u8, v: u64) u64 = {
// islinkable: read first 8 bytes; require !<arch>\n or \x7fELF.
fn islinkable(path: *u8) bool = {
let fd: i32 = os.open(path, os.flag.RDONLY, 0i32);
let fd: i32 = os.open(pathstr(path), os.flag.RDONLY, 0i32);
if (fd < 0) { return false; };
let mp: *u8 = os.alloc(8u64): *u8;
let n: i64 = os.read(fd, mp, 8u64);
@@ -3079,7 +3144,7 @@ fn resolvelib(a: *arena, name: *u8, libdirs: **u8, nlibdirs: i32) *u8 = {
// Read first 20 bytes; return 1 for ET_DYN .so, 0 for ar/.o.
fn isso(path: *u8) i32 = {
let fd: i32 = os.open(path, os.flag.RDONLY, 0i32);
let fd: i32 = os.open(pathstr(path), os.flag.RDONLY, 0i32);
if (fd < 0) { return 0; };
let mp: *u8 = os.alloc(20u64): *u8;
let n: i64 = os.read(fd, mp, 20u64);
@@ -3226,7 +3291,7 @@ export fn main(argc: i32, argv: **u8) i32 = {
};
let flags: os.flag = os.flag.WRONLY | os.flag.CREATE | os.flag.TRUNC;
let fd: i32 = os.open(outpath, flags, 493i32); // 0o755
let fd: i32 = os.open(pathstr(outpath), flags, 493i32); // 0o755
if (fd < 0) {
os.write(2, "w6l: cannot open output\n".ptr, 23u64);
return 1;