gitoria
All repositories: gitoria
40.4 KB
// hl:fs plugin — native shared library// Compiled to fs.so, loaded by runtime via dlopen//// Exports:// hl_fs_file(path) → file descriptor with metadata + line iterator// hl_fs_read_file(path) → entire file as string (raw bytes, unchecked)// hl_fs_write_file(path, text, mode?) / hl_fs_write_file_hex(path, hex, mode?)// hl_fs_list_dir(path) → streaming directory iteratorconst std = @import("std");const api = @import("plugin_api");const linux = std.os.linux;const HlValue = api.HlValue;const HlObject = api.HlObject;const HlField = api.HlField;const HlIterator = api.HlIterator;const HlString = api.HlString;// stack_trace_frames = 0 (mission 068): plugin code runs on interpreter FIBER// stacks; Debug trace capture unwinds off them and segfaults.var gpa = std.heap.DebugAllocator(.{ .stack_trace_frames = 0 }){};const allocator = gpa.allocator();// =========================================================================// Path resolution (mission 068)//// CONTRACT: relative paths passed to hl:fs resolve against the MAIN SCRIPT's// directory, not the process CWD — Hybriel path resolution is file-relative// everywhere (import, inherit, plugin discovery), and fs follows suit.// Absolute paths are used as-is. The runtime injects the script dir via the// optional plugin hook hl_fs_set_script_dir right after dlopen.// =========================================================================var script_dir: ?[]u8 = null;export fn hl_fs_set_script_dir(ptr: [*]const u8, len: usize) callconv(.c) void {if (script_dir) |old| allocator.free(old);script_dir = allocator.dupe(u8, ptr[0..len]) catch null;}/// Resolve a (possibly relative) path against the script dir. Returned slice/// is allocated with the plugin allocator; caller frees.fn resolvePath(path: []const u8) ?[]u8 {if (path.len == 0) return allocator.dupe(u8, path) catch null;if (path[0] == '/') return allocator.dupe(u8, path) catch null;if (script_dir) |sd| {return std.fmt.allocPrint(allocator, "{s}/{s}", .{ sd, path }) catch null;}return allocator.dupe(u8, path) catch null;}// =========================================================================// Error values (mission 068): open failures are LOUD — api.makeError with// the operation, the path as the caller wrote it, and the OS reason. The// runtime turns a top-level error value into a located runtime error.// =========================================================================fn errnoText(errno: usize) []const u8 {return switch (errno) {2 => "no such file or directory",13 => "permission denied",20 => "not a directory",21 => "is a directory",else => "I/O error",};}fn errDeinit(val: *api.HlValue) callconv(.c) void {if (val.type == .hl_error) {const s = val.data.string;allocator.free(@constCast(s.ptr[0..s.len]));}}fn makeOpenError(op: []const u8, path: []const u8, errno: usize) api.HlValue {const msg = std.fmt.allocPrint(allocator, "{s}: cannot open '{s}' — {s} (errno {d}); relative paths resolve against the script's directory", .{ op, path, errnoText(errno), errno }) catchreturn api.makeError("fs: cannot open file");var v = api.makeError(msg);v.deinit_fn = &errDeinit;return v;}// =========================================================================// hl_fs_file — file descriptor with metadata + line iterator// =========================================================================const FileIterState = struct {fd: i32,allocated_strings: std.array_list.Managed([]u8),done: bool,buf: [4096]u8,buf_pos: usize,buf_len: usize,};export fn hl_fs_file(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeError("fs.file expects a string path");const path_str = argv[0].data.string;const path = path_str.ptr[0..path_str.len];// Resolve relative paths against the script dir; keep the caller's path// for the descriptor's name/path fields.const resolved = resolvePath(path) orelse return api.makeNull();defer allocator.free(resolved);// We need a null-terminated path for the OSconst path_z = allocator.dupeZ(u8, resolved) catch return api.makeNull();defer allocator.free(path_z);// Open fileconst rc = linuxOpenRc(path_z, .{}, 0);if (rc < 0) return makeOpenError("fs.file", path, @intCast(-rc));const fd: i32 = @intCast(rc);// Stat for size, chmod, uid, gidvar size: f64 = 0;var chmod: f64 = 0;var uid: f64 = 0;var gid: f64 = 0;var stat_ok = false;var statx_buf: std.os.linux.Statx = undefined;const statx_rc = std.os.linux.statx(fd,"",std.os.linux.AT.EMPTY_PATH,std.os.linux.STATX.BASIC_STATS,&statx_buf,);if (statx_rc == 0) {size = @floatFromInt(statx_buf.size);chmod = @floatFromInt(statx_buf.mode & 0o7777);uid = @floatFromInt(statx_buf.uid);gid = @floatFromInt(statx_buf.gid);stat_ok = true;}// Extract name from pathconst name = if (std.mem.lastIndexOfScalar(u8, path, '/')) |idx|path[idx + 1 ..]elsepath;// Mime from extensionconst mime = mimeFromName(name);// Build fields: name, path, size, mime, chmod, user, group = 7 fieldsconst field_count: usize = 7;const fields = allocator.alloc(HlField, field_count) catch return api.makeNull();fields[0] = .{ .key = hlStr("name"), .value = api.makeString(name) };fields[1] = .{ .key = hlStr("path"), .value = api.makeString(path) };fields[2] = .{ .key = hlStr("size"), .value = api.makeNumber(size) };fields[3] = .{ .key = hlStr("mime"), .value = api.makeString(mime) };fields[4] = .{ .key = hlStr("chmod"), .value = api.makeNumber(chmod) };if (stat_ok) {fields[5] = .{ .key = hlStr("user"), .value = api.makeNumber(uid) };fields[6] = .{ .key = hlStr("group"), .value = api.makeNumber(gid) };} else {fields[5] = .{ .key = hlStr("user"), .value = api.makeNull() };fields[6] = .{ .key = hlStr("group"), .value = api.makeNull() };}const obj = allocator.create(HlObject) catch return api.makeNull();obj.* = .{.fields = fields.ptr,.field_count = field_count,.deinit_fn = &fileObjDeinit,};// Create line iteratorconst iter_state = allocator.create(FileIterState) catch return api.makeNull();iter_state.* = .{.fd = fd,.allocated_strings = std.array_list.Managed([]u8).init(allocator),.done = false,.buf = undefined,.buf_pos = 0,.buf_len = 0,};const iter = allocator.create(HlIterator) catch return api.makeNull();iter.* = .{.context = @ptrCast(iter_state),.next_fn = &fileIterNext,.deinit_fn = &fileIterDeinit,};// Return object — runtime will see it has both object fields and an iterator// We pack the iterator pointer into the object by adding one more fieldconst full_fields = allocator.alloc(HlField, field_count + 1) catch return api.makeNull();@memcpy(full_fields[0..field_count], fields);full_fields[field_count] = .{ .key = hlStr("__iter"), .value = api.makeIterator(iter) };// Free the original fields, use full_fieldsallocator.free(fields);obj.fields = full_fields.ptr;obj.field_count = field_count + 1;return api.makeObject(obj);}fn fileIterNext(ctx: ?*anyopaque) callconv(.c) HlValue {const state: *FileIterState = @ptrCast(@alignCast(ctx orelse return api.makeNull()));if (state.done) return api.makeNull();var line_buf = std.array_list.Managed(u8).init(allocator);while (true) {if (state.buf_pos < state.buf_len) {const remaining = state.buf[state.buf_pos..state.buf_len];if (std.mem.indexOfScalar(u8, remaining, '\n')) |nl_pos| {line_buf.appendSlice(remaining[0..nl_pos]) catch return api.makeNull();state.buf_pos += nl_pos + 1;break;} else {line_buf.appendSlice(remaining) catch return api.makeNull();state.buf_pos = 0;state.buf_len = 0;}}// Read from file using posix readconst n = linuxRead(state.fd, &state.buf) orelse {state.done = true;break;};if (n == 0) {state.done = true;break;}state.buf_pos = 0;state.buf_len = n;}if (line_buf.items.len == 0 and state.done) {line_buf.deinit();return api.makeNull();}const line = line_buf.toOwnedSlice() catch return api.makeNull();state.allocated_strings.append(line) catch return api.makeNull();return api.makeString(line);}fn fileIterDeinit(ctx: ?*anyopaque) callconv(.c) void {const state: *FileIterState = @ptrCast(@alignCast(ctx orelse return));_ = linux.close(state.fd);for (state.allocated_strings.items) |s| {allocator.free(s);}state.allocated_strings.deinit();allocator.destroy(state);}fn fileObjDeinit(obj: *HlObject) callconv(.c) void {const fields = obj.fields[0..obj.field_count];// Check for iterator field and clean it up (only if not already nulled out by runtime)for (fields) |field| {if (field.value.type == .hl_iterator) {const iter = field.value.data.iterator;if (iter.deinit_fn) |deinit_fn| {deinit_fn(iter.context);}allocator.destroy(iter);}}allocator.free(fields);allocator.destroy(obj);}// =========================================================================// hl_fs_read_file — read entire file as string// =========================================================================fn readFileDeinit(val: *HlValue) callconv(.c) void {if (val.type == .hl_string) {const s = val.data.string;allocator.free(@constCast(s.ptr[0..s.len]));}}/// exists(path) → Boolean (mission 077, 074 GAP 8). The ONLY hl:fs entry point/// that never errors: it answers a question, and "no" is an answer, not a/// failure. Same script-relative resolution as everything else. Without it the/// only way to test a path was to open it and survive the error — which is why/// the retired live_server used to index `static/` blind instead of probing it.export fn hl_fs_exists(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeBool(false);const path_str = argv[0].data.string;const path = path_str.ptr[0..path_str.len];const resolved = resolvePath(path) orelse return api.makeBool(false);defer allocator.free(resolved);const path_z = allocator.dupeZ(u8, resolved) catch return api.makeBool(false);defer allocator.free(path_z);// statx, not open: a directory, a symlink target, a device — anything the// OS can name — counts, and nothing is opened (no fd, no side effect).var statx_buf: std.os.linux.Statx = undefined;const rc = std.os.linux.statx(std.os.linux.AT.FDCWD,path_z,0,std.os.linux.STATX.BASIC_STATS,&statx_buf,);return api.makeBool(@as(isize, @bitCast(rc)) == 0);}export fn hl_fs_read_file(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeError("fs.readFile expects a string path");const path_str = argv[0].data.string;const path = path_str.ptr[0..path_str.len];const resolved = resolvePath(path) orelse return api.makeNull();defer allocator.free(resolved);const path_z = allocator.dupeZ(u8, resolved) catch return api.makeNull();defer allocator.free(path_z);const rc = linuxOpenRc(path_z, .{}, 0);if (rc < 0) return makeOpenError("fs.readFile", path, @intCast(-rc));const fd: i32 = @intCast(rc);defer _ = linux.close(fd);// Read up to 10MBvar content = std.array_list.Managed(u8).init(allocator);var buf: [4096]u8 = undefined;var total: usize = 0;const max_size: usize = 1024 * 1024 * 10;while (total < max_size) {const n = linuxRead(fd, &buf) orelse break;if (n == 0) break;content.appendSlice(buf[0..n]) catch return api.makeNull();total += n;}const result_slice = content.toOwnedSlice() catch return api.makeNull();var result = api.makeString(result_slice);result.deinit_fn = &readFileDeinit;return result;}// =========================================================================// hl_fs_list_dir — streaming directory iterator with metadata// =========================================================================const DirIterState = struct {fd: i32,base_path: []const u8,allocated_strings: std.array_list.Managed([]u8),// Linux getdents64 bufferdents_buf: [4096]u8,dents_pos: usize,dents_len: usize,done: bool,};export fn hl_fs_list_dir(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeError("fs.listDir expects a string path");const path_str = argv[0].data.string;const path = path_str.ptr[0..path_str.len];const resolved = resolvePath(path) orelse return api.makeNull();defer allocator.free(resolved);const path_z = allocator.dupeZ(u8, resolved) catch return api.makeNull();defer allocator.free(path_z);const rc = linuxOpenRc(path_z, .{ .DIRECTORY = true }, 0);if (rc < 0) return makeOpenError("fs.listDir", path, @intCast(-rc));const fd: i32 = @intCast(rc);const path_copy = allocator.dupe(u8, path) catch return api.makeNull();const state = allocator.create(DirIterState) catch return api.makeNull();state.* = .{.fd = fd,.base_path = path_copy,.allocated_strings = std.array_list.Managed([]u8).init(allocator),.dents_buf = undefined,.dents_pos = 0,.dents_len = 0,.done = false,};const iter = allocator.create(HlIterator) catch return api.makeNull();iter.* = .{.context = @ptrCast(state),.next_fn = &dirIterNext,.deinit_fn = &dirIterDeinit,};return api.makeIterator(iter);}fn dirIterNext(ctx: ?*anyopaque) callconv(.c) HlValue {const state: *DirIterState = @ptrCast(@alignCast(ctx orelse return api.makeNull()));if (state.done) return api.makeNull();while (true) {// Need more directory entries?if (state.dents_pos >= state.dents_len) {const rc = std.os.linux.getdents64(state.fd, &state.dents_buf, state.dents_buf.len);if (@as(isize, @bitCast(rc)) <= 0) {state.done = true;return api.makeNull();}state.dents_len = rc;state.dents_pos = 0;}// Parse next dirent64const entry_ptr: *align(1) std.os.linux.dirent64 = @ptrCast(&state.dents_buf[state.dents_pos]);state.dents_pos += entry_ptr.reclen;// Get nameconst name_ptr: [*]const u8 = @ptrCast(&entry_ptr.name);const name_len = std.mem.indexOfScalar(u8, name_ptr[0..256], 0) orelse continue;const name = name_ptr[0..name_len];// Skip . and ..if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) continue;const name_copy = allocator.dupe(u8, name) catch return api.makeNull();state.allocated_strings.append(name_copy) catch return api.makeNull();// Build full pathconst full_path = std.fmt.allocPrint(allocator, "{s}/{s}", .{ state.base_path, name }) catch return api.makeNull();state.allocated_strings.append(full_path) catch return api.makeNull();// Entry type from d_typeconst kind_str: []const u8 = switch (entry_ptr.type) {std.os.linux.DT.DIR => "directory",std.os.linux.DT.REG => "file",std.os.linux.DT.LNK => "symlink",else => "other",};// Stat for size, chmod, uid, gidvar size: f64 = 0;var chmod: f64 = 0;var e_uid: f64 = 0;var e_gid: f64 = 0;// null-terminate name for statxvar name_z_buf: [256]u8 = undefined;if (name.len < name_z_buf.len) {@memcpy(name_z_buf[0..name.len], name);name_z_buf[name.len] = 0;var statx_buf: std.os.linux.Statx = undefined;const src = std.os.linux.statx(state.fd,@ptrCast(name_z_buf[0 .. name.len + 1]),0,std.os.linux.STATX.BASIC_STATS,&statx_buf,);if (src == 0) {size = @floatFromInt(statx_buf.size);chmod = @floatFromInt(statx_buf.mode & 0o7777);e_uid = @floatFromInt(statx_buf.uid);e_gid = @floatFromInt(statx_buf.gid);}}const mime = mimeFromName(name_copy);// Build object: name, path, type, size, mime, chmod, user, group// The runtime's hlObjectToValue will call entryObjDeinit after conversion,// so these objects are freed immediately upon consumption.const field_count: usize = 8;const fields = allocator.alloc(HlField, field_count) catch return api.makeNull();fields[0] = .{ .key = hlStr("name"), .value = api.makeString(name_copy) };fields[1] = .{ .key = hlStr("path"), .value = api.makeString(full_path) };fields[2] = .{ .key = hlStr("type"), .value = api.makeString(kind_str) };fields[3] = .{ .key = hlStr("size"), .value = api.makeNumber(size) };fields[4] = .{ .key = hlStr("mime"), .value = api.makeString(mime) };fields[5] = .{ .key = hlStr("chmod"), .value = api.makeNumber(chmod) };fields[6] = .{ .key = hlStr("user"), .value = api.makeNumber(e_uid) };fields[7] = .{ .key = hlStr("group"), .value = api.makeNumber(e_gid) };const obj = allocator.create(HlObject) catch return api.makeNull();obj.* = .{.fields = fields.ptr,.field_count = field_count,.deinit_fn = &entryObjDeinit,};return api.makeObject(obj);}}fn entryObjDeinit(obj: *HlObject) callconv(.c) void {allocator.free(obj.fields[0..obj.field_count]);allocator.destroy(obj);}fn dirIterDeinit(ctx: ?*anyopaque) callconv(.c) void {const state: *DirIterState = @ptrCast(@alignCast(ctx orelse return));_ = linux.close(state.fd);for (state.allocated_strings.items) |s| {allocator.free(s);}state.allocated_strings.deinit();allocator.free(@constCast(state.base_path));allocator.destroy(state);}// =========================================================================// Helpers// =========================================================================/// Raw open: >= 0 is the fd, < 0 is -errno.fn linuxOpenRc(path_z: [*:0]const u8, flags: linux.O, mode: u32) isize {const rc = linux.open(path_z, flags, mode);return @bitCast(rc);}fn linuxRead(fd: i32, buf: []u8) ?usize {const rc = linux.read(fd, buf.ptr, buf.len);if (@as(isize, @bitCast(rc)) <= 0) return null;return rc;}fn hlStr(comptime s: []const u8) HlString {return .{ .ptr = s.ptr, .len = s.len };}fn mimeFromName(name: []const u8) []const u8 {const ext = if (std.mem.lastIndexOfScalar(u8, name, '.')) |dot|name[dot..]elsereturn "application/octet-stream";if (std.mem.eql(u8, ext, ".txt")) return "text/plain";if (std.mem.eql(u8, ext, ".html") or std.mem.eql(u8, ext, ".htm")) return "text/html";if (std.mem.eql(u8, ext, ".css")) return "text/css";if (std.mem.eql(u8, ext, ".js")) return "text/javascript";if (std.mem.eql(u8, ext, ".json")) return "application/json";if (std.mem.eql(u8, ext, ".xml")) return "application/xml";if (std.mem.eql(u8, ext, ".png")) return "image/png";if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) return "image/jpeg";if (std.mem.eql(u8, ext, ".gif")) return "image/gif";if (std.mem.eql(u8, ext, ".svg")) return "image/svg+xml";if (std.mem.eql(u8, ext, ".pdf")) return "application/pdf";if (std.mem.eql(u8, ext, ".zip")) return "application/zip";if (std.mem.eql(u8, ext, ".gz")) return "application/gzip";if (std.mem.eql(u8, ext, ".mp3")) return "audio/mpeg";if (std.mem.eql(u8, ext, ".mp4")) return "video/mp4";if (std.mem.eql(u8, ext, ".wasm")) return "application/wasm";if (std.mem.eql(u8, ext, ".hl")) return "text/x-hybriel";if (std.mem.eql(u8, ext, ".md")) return "text/markdown";if (std.mem.eql(u8, ext, ".yml") or std.mem.eql(u8, ext, ".yaml")) return "text/yaml";if (std.mem.eql(u8, ext, ".toml")) return "application/toml";if (std.mem.eql(u8, ext, ".csv")) return "text/csv";if (std.mem.eql(u8, ext, ".ts")) return "text/typescript";if (std.mem.eql(u8, ext, ".c") or std.mem.eql(u8, ext, ".h")) return "text/x-c";if (std.mem.eql(u8, ext, ".zig")) return "text/x-zig";if (std.mem.eql(u8, ext, ".rs")) return "text/x-rust";if (std.mem.eql(u8, ext, ".py")) return "text/x-python";if (std.mem.eql(u8, ext, ".sh")) return "text/x-shellscript";if (std.mem.eql(u8, ext, ".sql")) return "application/sql";if (std.mem.eql(u8, ext, ".webp")) return "image/webp";if (std.mem.eql(u8, ext, ".ico")) return "image/x-icon";if (std.mem.eql(u8, ext, ".woff2")) return "font/woff2";if (std.mem.eql(u8, ext, ".woff")) return "font/woff";if (std.mem.eql(u8, ext, ".ttf")) return "font/ttf";return "application/octet-stream";}// ── fs.watch: an inotify LOOP SOURCE (the dev-reload bell) ───────────────────// `watch(path)` returns an event-loop source whose wake_fd IS the inotify fd,// so the loop's own epoll_wait wakes on a save — no polling anywhere. Watches// the directory RECURSIVELY at registration (dot-dirs skipped); directories// created afterwards are picked up the next time the app is restarted, which// is the honest v1 for a dev tool.//// EXCLUSIONS (mission 266). `watch(path, exclude)` takes a second argument —// a path STRING or a LIST of them, the same either-shape the plugin surface// already uses in `hl_proc_spawn` — naming directories the descent must not// enter. One argument still means "watch everything": the argument is optional// and a null is the same as absent.//// This exists because a program that knows, at startup, about a directory it// will write to constantly (hl:web's session store is the first) had NO way to// say so. The only skip the walker possessed was "the name starts with a dot",// so the framework's advice was to RENAME the directory — a lecture standing// in for a fix. The dot-dir skip STAYS; `.git` is its reason and it is a good// one. This is a second, explicit reason to skip, not a replacement.const WatchState = struct {ifd: i32,buf: [4096]u8 align(4) = undefined,len: usize = 0,pos: usize = 0,};/// LEXICAL path normalisation — collapses `//` and resolves `.` / `..`/// without touching the filesystem. It must be lexical: an excluded directory/// need not exist yet at the moment the watch is armed (hl:web resolves its/// session dir before it creates it), and realpath(2) on a missing path fails./// Both the watch root and every exclusion go through this, so the comparison/// below is between two paths written the same way.fn normalizePath(path: []const u8) ?[]u8 {const absolute = path.len > 0 and path[0] == '/';var parts = std.ArrayListUnmanaged([]const u8).empty;defer parts.deinit(allocator);var it = std.mem.tokenizeScalar(u8, path, '/');while (it.next()) |seg| {if (std.mem.eql(u8, seg, ".")) continue;if (std.mem.eql(u8, seg, "..")) {if (parts.items.len > 0 and !std.mem.eql(u8, parts.items[parts.items.len - 1], "..")) {_ = parts.pop();continue;}if (absolute) continue; // ".." above "/" is "/"}parts.append(allocator, seg) catch return null;}var out = std.ArrayListUnmanaged(u8).empty;defer out.deinit(allocator);for (parts.items, 0..) |seg, i| {if (absolute or i > 0) out.append(allocator, '/') catch return null;out.appendSlice(allocator, seg) catch return null;}if (out.items.len == 0) out.appendSlice(allocator, if (absolute) "/" else ".") catch return null;return allocator.dupe(u8, out.items) catch null;}/// Is `child` the directory `root`, or somewhere under it? Both must already/// be normalised. A bare `startsWith` would call `/a/sessions-old` a child of/// `/a/sessions`, which is the whole reason this is a function: the byte after/// the prefix has to BE a separator.fn pathIsInside(child: []const u8, root: []const u8) bool {if (root.len == 0) return false;if (std.mem.eql(u8, child, root)) return true;if (child.len <= root.len) return false;if (!std.mem.startsWith(u8, child, root)) return false;if (root[root.len - 1] == '/') return true; // root is "/" itselfreturn child[root.len] == '/';}fn addWatchesRecursive(ifd: i32, dir_path: []const u8, depth: u32, excludes: []const []const u8) void {if (depth > 8) return;// The SECOND reason to skip a directory (mission 266). `dir_path` is// normalised: the root was normalised before the first call and every// child below is that root plus one plain name.for (excludes) |ex| if (pathIsInside(dir_path, ex)) return;const path_z = allocator.dupeZ(u8, dir_path) catch return;defer allocator.free(path_z);const mask: u32 = linux.IN.CLOSE_WRITE | linux.IN.CREATE | linux.IN.DELETE | linux.IN.MOVED_TO | linux.IN.MOVED_FROM;_ = linux.inotify_add_watch(ifd, path_z, mask);// The descent walks getdents64 directly, like hl_fs_list_dir does — this// plugin speaks raw syscalls throughout and never pulls in std.fs.const rc = linuxOpenRc(path_z, .{ .DIRECTORY = true }, 0);if (rc < 0) return;const fd: i32 = @intCast(rc);defer _ = linux.close(fd);var dents: [4096]u8 align(8) = undefined;while (true) {const got = linux.getdents64(fd, &dents, dents.len);if (@as(isize, @bitCast(got)) <= 0) break;var pos: usize = 0;while (pos < got) {const entry: *align(1) linux.dirent64 = @ptrCast(&dents[pos]);pos += entry.reclen;if (entry.type != linux.DT.DIR) continue;const name_ptr: [*]const u8 = @ptrCast(&entry.name);const name_len = std.mem.indexOfScalar(u8, name_ptr[0..256], 0) orelse continue;const name = name_ptr[0..name_len];// "." and ".." would recurse forever; every other dot-dir is noise// for a dev watcher (.git alone would cost thousands of watches).if (name.len == 0 or name[0] == '.') continue;const child = std.fmt.allocPrint(allocator, "{s}/{s}", .{ dir_path, name }) catch continue;defer allocator.free(child);addWatchesRecursive(ifd, child, depth + 1, excludes);}}}/// One exclusion, resolved against the script dir like every other hl:fs path/// and then normalised. Appends nothing on an empty string.fn appendExclusion(list: *std.ArrayListUnmanaged([]u8), raw: []const u8) bool {if (raw.len == 0) return true;const resolved = resolvePath(raw) orelse return false;defer allocator.free(resolved);const norm = normalizePath(resolved) orelse return false;list.append(allocator, norm) catch {allocator.free(norm);return false;};return true;}export fn hl_fs_watch(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeError("hl:fs: watch(path) needs a path string");const raw = argv[0].data.string.ptr[0..argv[0].data.string.len];const resolved = resolvePath(raw) orelse return api.makeError("hl:fs: watch: could not resolve the path");defer allocator.free(resolved);const path = normalizePath(resolved) orelse return api.makeError("hl:fs: watch: could not resolve the path");defer allocator.free(path);// THE OPTIONAL SECOND ARGUMENT: absent or null → watch everything, exactly// as before. A string is one directory; an object with numeric keys is the// loader's list shape (`hl_proc_spawn` reads its argv list the same way).var excludes = std.ArrayListUnmanaged([]u8).empty;defer {for (excludes.items) |e| allocator.free(e);excludes.deinit(allocator);}if (argc >= 2) {switch (argv[1].type) {.hl_null => {},.hl_string => {const sv = argv[1].data.string;if (!appendExclusion(&excludes, sv.ptr[0..sv.len])) return api.makeError("hl:fs: out of memory");},.hl_object => {const obj_in = argv[1].data.object;for (obj_in.fields[0..obj_in.field_count]) |field| {if (field.value.type == .hl_null) continue;if (field.value.type != .hl_string) return api.makeError("hl:fs: watch(path, exclude): every exclusion must be a path string");const sv = field.value.data.string;if (!appendExclusion(&excludes, sv.ptr[0..sv.len])) return api.makeError("hl:fs: out of memory");}},else => return api.makeError("hl:fs: watch(path, exclude): exclude is a path string or a list of path strings"),}}const rc = linux.inotify_init1(linux.IN.NONBLOCK);const ifd: i32 = @intCast(@as(isize, @bitCast(rc)));if (ifd < 0) return api.makeError("hl:fs: watch: inotify unavailable");addWatchesRecursive(ifd, path, 0, excludes.items);const state = allocator.create(WatchState) catch return api.makeError("hl:fs: out of memory");state.* = .{ .ifd = ifd };const iter = allocator.create(HlIterator) catch return api.makeError("hl:fs: out of memory");iter.* = .{.context = @ptrCast(state),.next_fn = &watchTryNext, // non-blocking either way — event loop only.try_next_fn = &watchTryNext,.deinit_fn = &watchDeinit,.wake_fd = ifd,};return api.makeIterator(iter);}fn watchTryNext(ctx: ?*anyopaque) callconv(.c) HlValue {const st: *WatchState = @ptrCast(@alignCast(ctx orelse return api.makeNull()));if (st.pos >= st.len) {const r = linux.read(st.ifd, &st.buf, st.buf.len);const n: isize = @bitCast(r);if (n <= 0) return api.makeNull(); // EAGAIN: drainedst.len = @intCast(n);st.pos = 0;}// one inotify_event: wd(i32) mask(u32) cookie(u32) len(u32) name[len]const base = st.pos;if (st.len - base < 16) {st.pos = st.len;return api.makeNull();}const name_len = std.mem.readInt(u32, st.buf[base + 12 ..][0..4], .little);const name_start = base + 16;var name: []const u8 = "";if (name_len > 0 and name_start + name_len <= st.len) {const raw_name = st.buf[name_start .. name_start + name_len];name = std.mem.sliceTo(raw_name, 0);}st.pos = name_start + name_len;const fields = allocator.alloc(api.HlField, 1) catch return api.makeNull();fields[0] = .{ .key = hlStr("name"), .value = api.makeString(allocator.dupe(u8, name) catch "") };const obj = allocator.create(api.HlObject) catch {allocator.free(fields);return api.makeNull();};obj.* = .{ .fields = fields.ptr, .field_count = 1, .deinit_fn = null };return api.makeObject(obj);}fn watchDeinit(ctx: ?*anyopaque) callconv(.c) void {const st: *WatchState = @ptrCast(@alignCast(ctx orelse return));_ = linux.close(st.ifd);allocator.destroy(st);}// =========================================================================// THE WRITE SURFACE (2026-08-28)//// hl:fs was READ-ONLY until this block: file, readFile, listDir, exists,// watch. It could report a file's `chmod` and never set one. Session// persistence is the first thing in this tree that has to put bytes on a// disk, and the rule when the framework can do something the language// cannot is that the LANGUAGE is missing it — so the capability lands here,// as an ordinary hl:fs surface any program can use, not as a private hook// the session store reaches through.//// Three calls, and the shape of each is a decision://// writeFile(path, contents, mode?) ATOMIC. Writes a sibling temp file,// fsyncs it, then rename()s it over the target — so a reader sees// either the whole previous file or the whole new one, never a torn// one. A half-written session file that revives as garbage is a// silent logout, and the crash that produces it is exactly the crash// nobody can reproduce. rename(2) within one directory is atomic on// every filesystem Linux ships; that is the whole reason the temp// file is a SIBLING rather than in /tmp (a cross-device rename fails).// mkDir(path, mode?) recursive, and succeeds when the// directory is already there — the caller wants it to exist, not to// be the one who created it.// remove(path) IDEMPOTENT: true when the path is// gone on return, including when it was already gone. A sweep// deleting an expired file must not fail because something else// deleted it first.//// MODES ARE EXPLICIT AND DEFAULT TIGHT. hybrilior passes no mode anywhere,// so its session files land at whatever the umask says — 0644 on a shared// box, i.e. every session on the machine readable by every other tenant.// Here the default is 0600 for a file and 0700 for a directory, and a// caller who wants them looser has to say so. This is not the kind of bug// that shows up in a test; it shows up in someone else's logs.// =========================================================================fn makeWriteError(op: []const u8, path: []const u8, errno: usize) api.HlValue {const msg = std.fmt.allocPrint(allocator, "{s}: cannot write '{s}' — {s} (errno {d}); relative paths resolve against the script's directory", .{ op, path, errnoText(errno), errno }) catchreturn api.makeError("fs: write failed");var v = api.makeError(msg);v.deinit_fn = &errDeinit;return v;}/// mkdir every missing component of `path`. Returns 0, or the errno of the/// first component that could not be created for a reason other than "it is/// already there".fn mkdirRecursive(path: []const u8, mode: u32) usize {if (path.len == 0) return 0;const buf = allocator.dupeZ(u8, path) catch return 12; // ENOMEMdefer allocator.free(buf);// Walk the separators left to right, creating each prefix in turn. Index// 0 is skipped so a leading '/' is never itself a component to create.var i: usize = 1;while (i <= buf.len) : (i += 1) {const at_end = i == buf.len;if (!at_end and buf[i] != '/') continue;if (!at_end) buf[i] = 0;const rc: isize = @bitCast(linux.mkdir(buf.ptr, mode));if (!at_end) buf[i] = '/';if (rc < 0) {const errno: usize = @intCast(-rc);// EEXIST is the success case: the caller wants it to exist.if (errno != 17) return errno;}}return 0;}export fn hl_fs_mk_dir(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeError("fs.mkDir expects a string path");const path_str = argv[0].data.string;const path = path_str.ptr[0..path_str.len];var mode: u32 = 0o700;if (argc >= 2 and argv[1].type == .hl_number) mode = @intFromFloat(argv[1].data.number);const resolved = resolvePath(path) orelse return api.makeError("fs.mkDir: out of memory");defer allocator.free(resolved);const errno = mkdirRecursive(resolved, mode);if (errno != 0) return makeWriteError("fs.mkDir", path, errno);return api.makeBool(true);}const write_usage = "fs.writeFile expects (String path, String or Bytes contents, Number mode?)";export fn hl_fs_write_file(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 2 or argv[0].type != .hl_string or argv[1].type != .hl_string) {return api.makeError(write_usage);}const path_str = argv[0].data.string;const body_str = argv[1].data.string;return writeWhole(path_str.ptr[0..path_str.len], body_str.ptr[0..body_str.len], modeArg(argc, argv));}/// hl_fs_write_file_hex(path, hex, mode?) — writeFile with a Bytes (ticket/// #88): a Bytes crosses the plugin boundary as its hex text (the ABI has no/// Bytes, as hl:proc's write_hex says), is decoded here and written raw.export fn hl_fs_write_file_hex(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 2 or argv[0].type != .hl_string or argv[1].type != .hl_string) {return api.makeError(write_usage);}const path_str = argv[0].data.string;const hex = argv[1].data.string.ptr[0..argv[1].data.string.len];const raw = allocator.alloc(u8, hex.len / 2) catch return api.makeError("fs.writeFile: out of memory");defer allocator.free(raw);_ = std.fmt.hexToBytes(raw, hex) catch return api.makeError("fs.writeFile: not a Bytes");return writeWhole(path_str.ptr[0..path_str.len], raw, modeArg(argc, argv));}fn modeArg(argc: u32, argv: [*]const HlValue) u32 {if (argc >= 3 and argv[2].type == .hl_number) return @intFromFloat(argv[2].data.number);return 0o600;}fn writeWhole(path: []const u8, body: []const u8, mode: u32) HlValue {const resolved = resolvePath(path) orelse return api.makeError("fs.writeFile: out of memory");defer allocator.free(resolved);// The temp name is a SIBLING (see the block comment): rename(2) is only// atomic within one filesystem, and /tmp is routinely a different one.// The pid keeps two processes writing the same path off each other's temp// file; the rename itself settles who wins the target.const pid = linux.getpid();const tmp = std.fmt.allocPrintSentinel(allocator, "{s}.hltmp{d}", .{ resolved, pid }, 0) catchreturn api.makeError("fs.writeFile: out of memory");defer allocator.free(tmp);const rc = linuxOpenRc(tmp.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, mode);if (rc < 0) return makeWriteError("fs.writeFile", path, @intCast(-rc));const fd: i32 = @intCast(rc);var wrote: usize = 0;while (wrote < body.len) {const n: isize = @bitCast(linux.write(fd, body.ptr + wrote, body.len - wrote));if (n <= 0) {_ = linux.close(fd);_ = linux.unlink(tmp.ptr);return makeWriteError("fs.writeFile", path, if (n < 0) @intCast(-n) else 5);}wrote += @intCast(n);}// fsync BEFORE the rename, or the rename can land while the bytes are// still only in the page cache — a power cut then leaves an EMPTY file// where a complete one used to be, which is worse than either outcome._ = linux.fsync(fd);_ = linux.close(fd);// O_CREAT honours the mode only through the umask; say it outright so a// 0600 request is 0600 whatever the process umask happens to be._ = linux.chmod(tmp.ptr, mode);const path_z = allocator.dupeZ(u8, resolved) catch return api.makeError("fs.writeFile: out of memory");defer allocator.free(path_z);const ren: isize = @bitCast(linux.rename(tmp.ptr, path_z.ptr));if (ren < 0) {_ = linux.unlink(tmp.ptr);return makeWriteError("fs.writeFile", path, @intCast(-ren));}return api.makeBool(true);}export fn hl_fs_remove(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeError("fs.remove expects a string path");const path_str = argv[0].data.string;const path = path_str.ptr[0..path_str.len];const resolved = resolvePath(path) orelse return api.makeError("fs.remove: out of memory");defer allocator.free(resolved);const path_z = allocator.dupeZ(u8, resolved) catch return api.makeError("fs.remove: out of memory");defer allocator.free(path_z);const rc: isize = @bitCast(linux.unlink(path_z.ptr));if (rc < 0) {const errno: usize = @intCast(-rc);// ENOENT is success: the caller asked for the path to be gone.if (errno == 2) return api.makeBool(true);return makeWriteError("fs.remove", path, errno);}return api.makeBool(true);}
Branches
- mainmain branch