gitoriaLog in with ident

calendar

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit7bd0337c7bd0337ccalendar: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gates 78/0 + 18/0mre7bd0337c/plugins/fs/fs.zig

44.1 KB

  1. // hl:fs plugin — native shared library
  2. // Compiled to fs.so, loaded by runtime via dlopen
  3. //
  4. // Exports:
  5. // hl_fs_file(path) → file descriptor with metadata + line iterator
  6. // hl_fs_read_file(path) → entire file as string (raw bytes, unchecked)
  7. // hl_fs_write_file(path, text, mode?) / hl_fs_write_file_hex(path, hex, mode?)
  8. // hl_fs_append_file(path, text, mode?) / hl_fs_append_file_hex(path, hex, mode?)
  9. // hl_fs_list_dir(path) → streaming directory iterator
  10. const std = @import("std");
  11. const api = @import("plugin_api");
  12. const linux = std.os.linux;
  13. const HlValue = api.HlValue;
  14. const HlObject = api.HlObject;
  15. const HlField = api.HlField;
  16. const HlIterator = api.HlIterator;
  17. const HlString = api.HlString;
  18. // stack_trace_frames = 0 (mission 068): plugin code runs on interpreter FIBER
  19. // stacks; Debug trace capture unwinds off them and segfaults.
  20. var gpa = std.heap.DebugAllocator(.{ .stack_trace_frames = 0 }){};
  21. const allocator = gpa.allocator();
  22. // =========================================================================
  23. // Path resolution (mission 068)
  24. //
  25. // CONTRACT: relative paths passed to hl:fs resolve against the MAIN SCRIPT's
  26. // directory, not the process CWD — Hybriel path resolution is file-relative
  27. // everywhere (import, inherit, plugin discovery), and fs follows suit.
  28. // Absolute paths are used as-is. The runtime injects the script dir via the
  29. // optional plugin hook hl_fs_set_script_dir right after dlopen.
  30. // =========================================================================
  31. var script_dir: ?[]u8 = null;
  32. export fn hl_fs_set_script_dir(ptr: [*]const u8, len: usize) callconv(.c) void {
  33. if (script_dir) |old| allocator.free(old);
  34. script_dir = allocator.dupe(u8, ptr[0..len]) catch null;
  35. }
  36. /// Resolve a (possibly relative) path against the script dir. Returned slice
  37. /// is allocated with the plugin allocator; caller frees.
  38. fn resolvePath(path: []const u8) ?[]u8 {
  39. if (path.len == 0) return allocator.dupe(u8, path) catch null;
  40. if (path[0] == '/') return allocator.dupe(u8, path) catch null;
  41. if (script_dir) |sd| {
  42. return std.fmt.allocPrint(allocator, "{s}/{s}", .{ sd, path }) catch null;
  43. }
  44. return allocator.dupe(u8, path) catch null;
  45. }
  46. // =========================================================================
  47. // Error values (mission 068): open failures are LOUD — api.makeError with
  48. // the operation, the path as the caller wrote it, and the OS reason. The
  49. // runtime turns a top-level error value into a located runtime error.
  50. // =========================================================================
  51. fn errnoText(errno: usize) []const u8 {
  52. return switch (errno) {
  53. 2 => "no such file or directory",
  54. 13 => "permission denied",
  55. 20 => "not a directory",
  56. 21 => "is a directory",
  57. else => "I/O error",
  58. };
  59. }
  60. fn errDeinit(val: *api.HlValue) callconv(.c) void {
  61. if (val.type == .hl_error) {
  62. const s = val.data.string;
  63. allocator.free(@constCast(s.ptr[0..s.len]));
  64. }
  65. }
  66. fn makeOpenError(op: []const u8, path: []const u8, errno: usize) api.HlValue {
  67. 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 }) catch
  68. return api.makeError("fs: cannot open file");
  69. var v = api.makeError(msg);
  70. v.deinit_fn = &errDeinit;
  71. return v;
  72. }
  73. fn makeReadError(op: []const u8, path: []const u8, errno: usize) api.HlValue {
  74. const msg = std.fmt.allocPrint(allocator, "{s}: cannot read '{s}' — {s} (errno {d})", .{ op, path, errnoText(errno), errno }) catch
  75. return api.makeError("fs: cannot read file");
  76. var v = api.makeError(msg);
  77. v.deinit_fn = &errDeinit;
  78. return v;
  79. }
  80. // =========================================================================
  81. // hl_fs_file — file descriptor with metadata + line iterator
  82. // =========================================================================
  83. const FileIterState = struct {
  84. fd: i32,
  85. allocated_strings: std.array_list.Managed([]u8),
  86. done: bool,
  87. buf: [4096]u8,
  88. buf_pos: usize,
  89. buf_len: usize,
  90. };
  91. export fn hl_fs_file(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  92. if (argc < 1 or argv[0].type != .hl_string) return api.makeError("fs.file expects a string path");
  93. const path_str = argv[0].data.string;
  94. const path = path_str.ptr[0..path_str.len];
  95. // Resolve relative paths against the script dir; keep the caller's path
  96. // for the descriptor's name/path fields.
  97. const resolved = resolvePath(path) orelse return api.makeNull();
  98. defer allocator.free(resolved);
  99. // We need a null-terminated path for the OS
  100. const path_z = allocator.dupeZ(u8, resolved) catch return api.makeNull();
  101. defer allocator.free(path_z);
  102. // Open file
  103. const rc = linuxOpenRc(path_z, .{}, 0);
  104. if (rc < 0) return makeOpenError("fs.file", path, @intCast(-rc));
  105. const fd: i32 = @intCast(rc);
  106. // Stat for size, chmod, uid, gid
  107. var size: f64 = 0;
  108. var chmod: f64 = 0;
  109. var uid: f64 = 0;
  110. var gid: f64 = 0;
  111. var stat_ok = false;
  112. var statx_buf: std.os.linux.Statx = undefined;
  113. const statx_rc = std.os.linux.statx(
  114. fd,
  115. "",
  116. std.os.linux.AT.EMPTY_PATH,
  117. std.os.linux.STATX.BASIC_STATS,
  118. &statx_buf,
  119. );
  120. if (statx_rc == 0) {
  121. size = @floatFromInt(statx_buf.size);
  122. chmod = @floatFromInt(statx_buf.mode & 0o7777);
  123. uid = @floatFromInt(statx_buf.uid);
  124. gid = @floatFromInt(statx_buf.gid);
  125. stat_ok = true;
  126. }
  127. // Extract name from path
  128. const name = if (std.mem.lastIndexOfScalar(u8, path, '/')) |idx|
  129. path[idx + 1 ..]
  130. else
  131. path;
  132. // Mime from extension
  133. const mime = mimeFromName(name);
  134. // Build fields: name, path, size, mime, chmod, user, group = 7 fields
  135. const field_count: usize = 7;
  136. const fields = allocator.alloc(HlField, field_count) catch return api.makeNull();
  137. fields[0] = .{ .key = hlStr("name"), .value = api.makeString(name) };
  138. fields[1] = .{ .key = hlStr("path"), .value = api.makeString(path) };
  139. fields[2] = .{ .key = hlStr("size"), .value = api.makeNumber(size) };
  140. fields[3] = .{ .key = hlStr("mime"), .value = api.makeString(mime) };
  141. fields[4] = .{ .key = hlStr("chmod"), .value = api.makeNumber(chmod) };
  142. if (stat_ok) {
  143. fields[5] = .{ .key = hlStr("user"), .value = api.makeNumber(uid) };
  144. fields[6] = .{ .key = hlStr("group"), .value = api.makeNumber(gid) };
  145. } else {
  146. fields[5] = .{ .key = hlStr("user"), .value = api.makeNull() };
  147. fields[6] = .{ .key = hlStr("group"), .value = api.makeNull() };
  148. }
  149. const obj = allocator.create(HlObject) catch return api.makeNull();
  150. obj.* = .{
  151. .fields = fields.ptr,
  152. .field_count = field_count,
  153. .deinit_fn = &fileObjDeinit,
  154. };
  155. // Create line iterator
  156. const iter_state = allocator.create(FileIterState) catch return api.makeNull();
  157. iter_state.* = .{
  158. .fd = fd,
  159. .allocated_strings = std.array_list.Managed([]u8).init(allocator),
  160. .done = false,
  161. .buf = undefined,
  162. .buf_pos = 0,
  163. .buf_len = 0,
  164. };
  165. const iter = allocator.create(HlIterator) catch return api.makeNull();
  166. iter.* = .{
  167. .context = @ptrCast(iter_state),
  168. .next_fn = &fileIterNext,
  169. .deinit_fn = &fileIterDeinit,
  170. };
  171. // Return object — runtime will see it has both object fields and an iterator
  172. // We pack the iterator pointer into the object by adding one more field
  173. const full_fields = allocator.alloc(HlField, field_count + 1) catch return api.makeNull();
  174. @memcpy(full_fields[0..field_count], fields);
  175. full_fields[field_count] = .{ .key = hlStr("__iter"), .value = api.makeIterator(iter) };
  176. // Free the original fields, use full_fields
  177. allocator.free(fields);
  178. obj.fields = full_fields.ptr;
  179. obj.field_count = field_count + 1;
  180. return api.makeObject(obj);
  181. }
  182. fn fileIterNext(ctx: ?*anyopaque) callconv(.c) HlValue {
  183. const state: *FileIterState = @ptrCast(@alignCast(ctx orelse return api.makeNull()));
  184. if (state.done) return api.makeNull();
  185. var line_buf = std.array_list.Managed(u8).init(allocator);
  186. while (true) {
  187. if (state.buf_pos < state.buf_len) {
  188. const remaining = state.buf[state.buf_pos..state.buf_len];
  189. if (std.mem.indexOfScalar(u8, remaining, '\n')) |nl_pos| {
  190. line_buf.appendSlice(remaining[0..nl_pos]) catch return api.makeNull();
  191. state.buf_pos += nl_pos + 1;
  192. break;
  193. } else {
  194. line_buf.appendSlice(remaining) catch return api.makeNull();
  195. state.buf_pos = 0;
  196. state.buf_len = 0;
  197. }
  198. }
  199. // Read from file using posix read
  200. const n = linuxRead(state.fd, &state.buf) orelse {
  201. state.done = true;
  202. break;
  203. };
  204. if (n == 0) {
  205. state.done = true;
  206. break;
  207. }
  208. state.buf_pos = 0;
  209. state.buf_len = n;
  210. }
  211. if (line_buf.items.len == 0 and state.done) {
  212. line_buf.deinit();
  213. return api.makeNull();
  214. }
  215. const line = line_buf.toOwnedSlice() catch return api.makeNull();
  216. state.allocated_strings.append(line) catch return api.makeNull();
  217. return api.makeString(line);
  218. }
  219. fn fileIterDeinit(ctx: ?*anyopaque) callconv(.c) void {
  220. const state: *FileIterState = @ptrCast(@alignCast(ctx orelse return));
  221. _ = linux.close(state.fd);
  222. for (state.allocated_strings.items) |s| {
  223. allocator.free(s);
  224. }
  225. state.allocated_strings.deinit();
  226. allocator.destroy(state);
  227. }
  228. fn fileObjDeinit(obj: *HlObject) callconv(.c) void {
  229. const fields = obj.fields[0..obj.field_count];
  230. // Check for iterator field and clean it up (only if not already nulled out by runtime)
  231. for (fields) |field| {
  232. if (field.value.type == .hl_iterator) {
  233. const iter = field.value.data.iterator;
  234. if (iter.deinit_fn) |deinit_fn| {
  235. deinit_fn(iter.context);
  236. }
  237. allocator.destroy(iter);
  238. }
  239. }
  240. allocator.free(fields);
  241. allocator.destroy(obj);
  242. }
  243. // =========================================================================
  244. // hl_fs_read_file — read entire file as string
  245. // =========================================================================
  246. fn readFileDeinit(val: *HlValue) callconv(.c) void {
  247. if (val.type == .hl_string) {
  248. const s = val.data.string;
  249. allocator.free(@constCast(s.ptr[0..s.len]));
  250. }
  251. }
  252. /// exists(path) → Boolean (mission 077, 074 GAP 8). The ONLY hl:fs entry point
  253. /// that never errors: it answers a question, and "no" is an answer, not a
  254. /// failure. Same script-relative resolution as everything else. Without it the
  255. /// only way to test a path was to open it and survive the error — which is why
  256. /// the retired live_server used to index `static/` blind instead of probing it.
  257. export fn hl_fs_exists(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  258. if (argc < 1 or argv[0].type != .hl_string) return api.makeBool(false);
  259. const path_str = argv[0].data.string;
  260. const path = path_str.ptr[0..path_str.len];
  261. const resolved = resolvePath(path) orelse return api.makeBool(false);
  262. defer allocator.free(resolved);
  263. const path_z = allocator.dupeZ(u8, resolved) catch return api.makeBool(false);
  264. defer allocator.free(path_z);
  265. // statx, not open: a directory, a symlink target, a device — anything the
  266. // OS can name — counts, and nothing is opened (no fd, no side effect).
  267. var statx_buf: std.os.linux.Statx = undefined;
  268. const rc = std.os.linux.statx(
  269. std.os.linux.AT.FDCWD,
  270. path_z,
  271. 0,
  272. std.os.linux.STATX.BASIC_STATS,
  273. &statx_buf,
  274. );
  275. return api.makeBool(@as(isize, @bitCast(rc)) == 0);
  276. }
  277. export fn hl_fs_read_file(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  278. if (argc < 1 or argv[0].type != .hl_string) return api.makeError("fs.readFile expects a string path");
  279. const path_str = argv[0].data.string;
  280. const path = path_str.ptr[0..path_str.len];
  281. const resolved = resolvePath(path) orelse return api.makeNull();
  282. defer allocator.free(resolved);
  283. const path_z = allocator.dupeZ(u8, resolved) catch return api.makeNull();
  284. defer allocator.free(path_z);
  285. const rc = linuxOpenRc(path_z, .{}, 0);
  286. if (rc < 0) return makeOpenError("fs.readFile", path, @intCast(-rc));
  287. const fd: i32 = @intCast(rc);
  288. defer _ = linux.close(fd);
  289. // The whole file: no size cap (a 10 MiB cap once truncated silently, ticket #112).
  290. // A failed read is an error naming the path, never a short string.
  291. var content = std.array_list.Managed(u8).init(allocator);
  292. defer content.deinit();
  293. var buf: [65536]u8 = undefined;
  294. while (true) {
  295. const n_read: isize = @bitCast(linux.read(fd, &buf, buf.len));
  296. if (n_read == 0) break;
  297. if (n_read < 0) {
  298. const errno: usize = @intCast(-n_read);
  299. if (errno == @intFromEnum(linux.E.INTR)) continue;
  300. return makeReadError("fs.readFile", path, errno);
  301. }
  302. content.appendSlice(buf[0..@intCast(n_read)]) catch return api.makeError("fs.readFile: out of memory reading the file");
  303. }
  304. const result_slice = content.toOwnedSlice() catch return api.makeNull();
  305. var result = api.makeString(result_slice);
  306. result.deinit_fn = &readFileDeinit;
  307. return result;
  308. }
  309. // =========================================================================
  310. // hl_fs_list_dir — streaming directory iterator with metadata
  311. // =========================================================================
  312. const DirIterState = struct {
  313. fd: i32,
  314. base_path: []const u8,
  315. allocated_strings: std.array_list.Managed([]u8),
  316. // Linux getdents64 buffer
  317. dents_buf: [4096]u8,
  318. dents_pos: usize,
  319. dents_len: usize,
  320. done: bool,
  321. };
  322. export fn hl_fs_list_dir(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  323. if (argc < 1 or argv[0].type != .hl_string) return api.makeError("fs.listDir expects a string path");
  324. const path_str = argv[0].data.string;
  325. const path = path_str.ptr[0..path_str.len];
  326. const resolved = resolvePath(path) orelse return api.makeNull();
  327. defer allocator.free(resolved);
  328. const path_z = allocator.dupeZ(u8, resolved) catch return api.makeNull();
  329. defer allocator.free(path_z);
  330. const rc = linuxOpenRc(path_z, .{ .DIRECTORY = true }, 0);
  331. if (rc < 0) return makeOpenError("fs.listDir", path, @intCast(-rc));
  332. const fd: i32 = @intCast(rc);
  333. const path_copy = allocator.dupe(u8, path) catch return api.makeNull();
  334. const state = allocator.create(DirIterState) catch return api.makeNull();
  335. state.* = .{
  336. .fd = fd,
  337. .base_path = path_copy,
  338. .allocated_strings = std.array_list.Managed([]u8).init(allocator),
  339. .dents_buf = undefined,
  340. .dents_pos = 0,
  341. .dents_len = 0,
  342. .done = false,
  343. };
  344. const iter = allocator.create(HlIterator) catch return api.makeNull();
  345. iter.* = .{
  346. .context = @ptrCast(state),
  347. .next_fn = &dirIterNext,
  348. .deinit_fn = &dirIterDeinit,
  349. };
  350. return api.makeIterator(iter);
  351. }
  352. fn dirIterNext(ctx: ?*anyopaque) callconv(.c) HlValue {
  353. const state: *DirIterState = @ptrCast(@alignCast(ctx orelse return api.makeNull()));
  354. if (state.done) return api.makeNull();
  355. while (true) {
  356. // Need more directory entries?
  357. if (state.dents_pos >= state.dents_len) {
  358. const rc = std.os.linux.getdents64(state.fd, &state.dents_buf, state.dents_buf.len);
  359. if (@as(isize, @bitCast(rc)) <= 0) {
  360. state.done = true;
  361. return api.makeNull();
  362. }
  363. state.dents_len = rc;
  364. state.dents_pos = 0;
  365. }
  366. // Parse next dirent64
  367. const entry_ptr: *align(1) std.os.linux.dirent64 = @ptrCast(&state.dents_buf[state.dents_pos]);
  368. state.dents_pos += entry_ptr.reclen;
  369. // Get name
  370. const name_ptr: [*]const u8 = @ptrCast(&entry_ptr.name);
  371. const name_len = std.mem.indexOfScalar(u8, name_ptr[0..256], 0) orelse continue;
  372. const name = name_ptr[0..name_len];
  373. // Skip . and ..
  374. if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) continue;
  375. const name_copy = allocator.dupe(u8, name) catch return api.makeNull();
  376. state.allocated_strings.append(name_copy) catch return api.makeNull();
  377. // Build full path
  378. const full_path = std.fmt.allocPrint(allocator, "{s}/{s}", .{ state.base_path, name }) catch return api.makeNull();
  379. state.allocated_strings.append(full_path) catch return api.makeNull();
  380. // Entry type from d_type
  381. const kind_str: []const u8 = switch (entry_ptr.type) {
  382. std.os.linux.DT.DIR => "directory",
  383. std.os.linux.DT.REG => "file",
  384. std.os.linux.DT.LNK => "symlink",
  385. else => "other",
  386. };
  387. // Stat for size, chmod, uid, gid
  388. var size: f64 = 0;
  389. var chmod: f64 = 0;
  390. var e_uid: f64 = 0;
  391. var e_gid: f64 = 0;
  392. // null-terminate name for statx
  393. var name_z_buf: [256]u8 = undefined;
  394. if (name.len < name_z_buf.len) {
  395. @memcpy(name_z_buf[0..name.len], name);
  396. name_z_buf[name.len] = 0;
  397. var statx_buf: std.os.linux.Statx = undefined;
  398. const src = std.os.linux.statx(
  399. state.fd,
  400. @ptrCast(name_z_buf[0 .. name.len + 1]),
  401. 0,
  402. std.os.linux.STATX.BASIC_STATS,
  403. &statx_buf,
  404. );
  405. if (src == 0) {
  406. size = @floatFromInt(statx_buf.size);
  407. chmod = @floatFromInt(statx_buf.mode & 0o7777);
  408. e_uid = @floatFromInt(statx_buf.uid);
  409. e_gid = @floatFromInt(statx_buf.gid);
  410. }
  411. }
  412. const mime = mimeFromName(name_copy);
  413. // Build object: name, path, type, size, mime, chmod, user, group
  414. // The runtime's hlObjectToValue will call entryObjDeinit after conversion,
  415. // so these objects are freed immediately upon consumption.
  416. const field_count: usize = 8;
  417. const fields = allocator.alloc(HlField, field_count) catch return api.makeNull();
  418. fields[0] = .{ .key = hlStr("name"), .value = api.makeString(name_copy) };
  419. fields[1] = .{ .key = hlStr("path"), .value = api.makeString(full_path) };
  420. fields[2] = .{ .key = hlStr("type"), .value = api.makeString(kind_str) };
  421. fields[3] = .{ .key = hlStr("size"), .value = api.makeNumber(size) };
  422. fields[4] = .{ .key = hlStr("mime"), .value = api.makeString(mime) };
  423. fields[5] = .{ .key = hlStr("chmod"), .value = api.makeNumber(chmod) };
  424. fields[6] = .{ .key = hlStr("user"), .value = api.makeNumber(e_uid) };
  425. fields[7] = .{ .key = hlStr("group"), .value = api.makeNumber(e_gid) };
  426. const obj = allocator.create(HlObject) catch return api.makeNull();
  427. obj.* = .{
  428. .fields = fields.ptr,
  429. .field_count = field_count,
  430. .deinit_fn = &entryObjDeinit,
  431. };
  432. return api.makeObject(obj);
  433. }
  434. }
  435. fn entryObjDeinit(obj: *HlObject) callconv(.c) void {
  436. allocator.free(obj.fields[0..obj.field_count]);
  437. allocator.destroy(obj);
  438. }
  439. fn dirIterDeinit(ctx: ?*anyopaque) callconv(.c) void {
  440. const state: *DirIterState = @ptrCast(@alignCast(ctx orelse return));
  441. _ = linux.close(state.fd);
  442. for (state.allocated_strings.items) |s| {
  443. allocator.free(s);
  444. }
  445. state.allocated_strings.deinit();
  446. allocator.free(@constCast(state.base_path));
  447. allocator.destroy(state);
  448. }
  449. // =========================================================================
  450. // Helpers
  451. // =========================================================================
  452. /// Raw open: >= 0 is the fd, < 0 is -errno.
  453. fn linuxOpenRc(path_z: [*:0]const u8, flags: linux.O, mode: u32) isize {
  454. const rc = linux.open(path_z, flags, mode);
  455. return @bitCast(rc);
  456. }
  457. fn linuxRead(fd: i32, buf: []u8) ?usize {
  458. const rc = linux.read(fd, buf.ptr, buf.len);
  459. if (@as(isize, @bitCast(rc)) <= 0) return null;
  460. return rc;
  461. }
  462. fn hlStr(comptime s: []const u8) HlString {
  463. return .{ .ptr = s.ptr, .len = s.len };
  464. }
  465. fn mimeFromName(name: []const u8) []const u8 {
  466. const ext = if (std.mem.lastIndexOfScalar(u8, name, '.')) |dot|
  467. name[dot..]
  468. else
  469. return "application/octet-stream";
  470. if (std.mem.eql(u8, ext, ".txt")) return "text/plain";
  471. if (std.mem.eql(u8, ext, ".html") or std.mem.eql(u8, ext, ".htm")) return "text/html";
  472. if (std.mem.eql(u8, ext, ".css")) return "text/css";
  473. if (std.mem.eql(u8, ext, ".js")) return "text/javascript";
  474. if (std.mem.eql(u8, ext, ".json")) return "application/json";
  475. if (std.mem.eql(u8, ext, ".xml")) return "application/xml";
  476. if (std.mem.eql(u8, ext, ".png")) return "image/png";
  477. if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) return "image/jpeg";
  478. if (std.mem.eql(u8, ext, ".gif")) return "image/gif";
  479. if (std.mem.eql(u8, ext, ".svg")) return "image/svg+xml";
  480. if (std.mem.eql(u8, ext, ".pdf")) return "application/pdf";
  481. if (std.mem.eql(u8, ext, ".zip")) return "application/zip";
  482. if (std.mem.eql(u8, ext, ".gz")) return "application/gzip";
  483. if (std.mem.eql(u8, ext, ".mp3")) return "audio/mpeg";
  484. if (std.mem.eql(u8, ext, ".mp4")) return "video/mp4";
  485. if (std.mem.eql(u8, ext, ".wasm")) return "application/wasm";
  486. if (std.mem.eql(u8, ext, ".hl")) return "text/x-hybriel";
  487. if (std.mem.eql(u8, ext, ".md")) return "text/markdown";
  488. if (std.mem.eql(u8, ext, ".yml") or std.mem.eql(u8, ext, ".yaml")) return "text/yaml";
  489. if (std.mem.eql(u8, ext, ".toml")) return "application/toml";
  490. if (std.mem.eql(u8, ext, ".csv")) return "text/csv";
  491. if (std.mem.eql(u8, ext, ".ts")) return "text/typescript";
  492. if (std.mem.eql(u8, ext, ".c") or std.mem.eql(u8, ext, ".h")) return "text/x-c";
  493. if (std.mem.eql(u8, ext, ".zig")) return "text/x-zig";
  494. if (std.mem.eql(u8, ext, ".rs")) return "text/x-rust";
  495. if (std.mem.eql(u8, ext, ".py")) return "text/x-python";
  496. if (std.mem.eql(u8, ext, ".sh")) return "text/x-shellscript";
  497. if (std.mem.eql(u8, ext, ".sql")) return "application/sql";
  498. if (std.mem.eql(u8, ext, ".webp")) return "image/webp";
  499. if (std.mem.eql(u8, ext, ".ico")) return "image/x-icon";
  500. if (std.mem.eql(u8, ext, ".woff2")) return "font/woff2";
  501. if (std.mem.eql(u8, ext, ".woff")) return "font/woff";
  502. if (std.mem.eql(u8, ext, ".ttf")) return "font/ttf";
  503. return "application/octet-stream";
  504. }
  505. // ── fs.watch: an inotify LOOP SOURCE (the dev-reload bell) ───────────────────
  506. // `watch(path)` returns an event-loop source whose wake_fd IS the inotify fd,
  507. // so the loop's own epoll_wait wakes on a save — no polling anywhere. Watches
  508. // the directory RECURSIVELY at registration (dot-dirs skipped); directories
  509. // created afterwards are picked up the next time the app is restarted, which
  510. // is the honest v1 for a dev tool.
  511. //
  512. // EXCLUSIONS (mission 266). `watch(path, exclude)` takes a second argument —
  513. // a path STRING or a LIST of them, the same either-shape the plugin surface
  514. // already uses in `hl_proc_spawn` — naming directories the descent must not
  515. // enter. One argument still means "watch everything": the argument is optional
  516. // and a null is the same as absent.
  517. //
  518. // This exists because a program that knows, at startup, about a directory it
  519. // will write to constantly (hl:web's session store is the first) had NO way to
  520. // say so. The only skip the walker possessed was "the name starts with a dot",
  521. // so the framework's advice was to RENAME the directory — a lecture standing
  522. // in for a fix. The dot-dir skip STAYS; `.git` is its reason and it is a good
  523. // one. This is a second, explicit reason to skip, not a replacement.
  524. const WatchState = struct {
  525. ifd: i32,
  526. buf: [4096]u8 align(4) = undefined,
  527. len: usize = 0,
  528. pos: usize = 0,
  529. };
  530. /// LEXICAL path normalisation — collapses `//` and resolves `.` / `..`
  531. /// without touching the filesystem. It must be lexical: an excluded directory
  532. /// need not exist yet at the moment the watch is armed (hl:web resolves its
  533. /// session dir before it creates it), and realpath(2) on a missing path fails.
  534. /// Both the watch root and every exclusion go through this, so the comparison
  535. /// below is between two paths written the same way.
  536. fn normalizePath(path: []const u8) ?[]u8 {
  537. const absolute = path.len > 0 and path[0] == '/';
  538. var parts = std.ArrayListUnmanaged([]const u8).empty;
  539. defer parts.deinit(allocator);
  540. var it = std.mem.tokenizeScalar(u8, path, '/');
  541. while (it.next()) |seg| {
  542. if (std.mem.eql(u8, seg, ".")) continue;
  543. if (std.mem.eql(u8, seg, "..")) {
  544. if (parts.items.len > 0 and !std.mem.eql(u8, parts.items[parts.items.len - 1], "..")) {
  545. _ = parts.pop();
  546. continue;
  547. }
  548. if (absolute) continue; // ".." above "/" is "/"
  549. }
  550. parts.append(allocator, seg) catch return null;
  551. }
  552. var out = std.ArrayListUnmanaged(u8).empty;
  553. defer out.deinit(allocator);
  554. for (parts.items, 0..) |seg, i| {
  555. if (absolute or i > 0) out.append(allocator, '/') catch return null;
  556. out.appendSlice(allocator, seg) catch return null;
  557. }
  558. if (out.items.len == 0) out.appendSlice(allocator, if (absolute) "/" else ".") catch return null;
  559. return allocator.dupe(u8, out.items) catch null;
  560. }
  561. /// Is `child` the directory `root`, or somewhere under it? Both must already
  562. /// be normalised. A bare `startsWith` would call `/a/sessions-old` a child of
  563. /// `/a/sessions`, which is the whole reason this is a function: the byte after
  564. /// the prefix has to BE a separator.
  565. fn pathIsInside(child: []const u8, root: []const u8) bool {
  566. if (root.len == 0) return false;
  567. if (std.mem.eql(u8, child, root)) return true;
  568. if (child.len <= root.len) return false;
  569. if (!std.mem.startsWith(u8, child, root)) return false;
  570. if (root[root.len - 1] == '/') return true; // root is "/" itself
  571. return child[root.len] == '/';
  572. }
  573. fn addWatchesRecursive(ifd: i32, dir_path: []const u8, depth: u32, excludes: []const []const u8) void {
  574. if (depth > 8) return;
  575. // The SECOND reason to skip a directory (mission 266). `dir_path` is
  576. // normalised: the root was normalised before the first call and every
  577. // child below is that root plus one plain name.
  578. for (excludes) |ex| if (pathIsInside(dir_path, ex)) return;
  579. const path_z = allocator.dupeZ(u8, dir_path) catch return;
  580. defer allocator.free(path_z);
  581. const mask: u32 = linux.IN.CLOSE_WRITE | linux.IN.CREATE | linux.IN.DELETE | linux.IN.MOVED_TO | linux.IN.MOVED_FROM;
  582. _ = linux.inotify_add_watch(ifd, path_z, mask);
  583. // The descent walks getdents64 directly, like hl_fs_list_dir does — this
  584. // plugin speaks raw syscalls throughout and never pulls in std.fs.
  585. const rc = linuxOpenRc(path_z, .{ .DIRECTORY = true }, 0);
  586. if (rc < 0) return;
  587. const fd: i32 = @intCast(rc);
  588. defer _ = linux.close(fd);
  589. var dents: [4096]u8 align(8) = undefined;
  590. while (true) {
  591. const got = linux.getdents64(fd, &dents, dents.len);
  592. if (@as(isize, @bitCast(got)) <= 0) break;
  593. var pos: usize = 0;
  594. while (pos < got) {
  595. const entry: *align(1) linux.dirent64 = @ptrCast(&dents[pos]);
  596. pos += entry.reclen;
  597. if (entry.type != linux.DT.DIR) continue;
  598. const name_ptr: [*]const u8 = @ptrCast(&entry.name);
  599. const name_len = std.mem.indexOfScalar(u8, name_ptr[0..256], 0) orelse continue;
  600. const name = name_ptr[0..name_len];
  601. // "." and ".." would recurse forever; every other dot-dir is noise
  602. // for a dev watcher (.git alone would cost thousands of watches).
  603. if (name.len == 0 or name[0] == '.') continue;
  604. const child = std.fmt.allocPrint(allocator, "{s}/{s}", .{ dir_path, name }) catch continue;
  605. defer allocator.free(child);
  606. addWatchesRecursive(ifd, child, depth + 1, excludes);
  607. }
  608. }
  609. }
  610. /// One exclusion, resolved against the script dir like every other hl:fs path
  611. /// and then normalised. Appends nothing on an empty string.
  612. fn appendExclusion(list: *std.ArrayListUnmanaged([]u8), raw: []const u8) bool {
  613. if (raw.len == 0) return true;
  614. const resolved = resolvePath(raw) orelse return false;
  615. defer allocator.free(resolved);
  616. const norm = normalizePath(resolved) orelse return false;
  617. list.append(allocator, norm) catch {
  618. allocator.free(norm);
  619. return false;
  620. };
  621. return true;
  622. }
  623. export fn hl_fs_watch(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  624. if (argc < 1 or argv[0].type != .hl_string) return api.makeError("hl:fs: watch(path) needs a path string");
  625. const raw = argv[0].data.string.ptr[0..argv[0].data.string.len];
  626. const resolved = resolvePath(raw) orelse return api.makeError("hl:fs: watch: could not resolve the path");
  627. defer allocator.free(resolved);
  628. const path = normalizePath(resolved) orelse return api.makeError("hl:fs: watch: could not resolve the path");
  629. defer allocator.free(path);
  630. // THE OPTIONAL SECOND ARGUMENT: absent or null → watch everything, exactly
  631. // as before. A string is one directory; an object with numeric keys is the
  632. // loader's list shape (`hl_proc_spawn` reads its argv list the same way).
  633. var excludes = std.ArrayListUnmanaged([]u8).empty;
  634. defer {
  635. for (excludes.items) |e| allocator.free(e);
  636. excludes.deinit(allocator);
  637. }
  638. if (argc >= 2) {
  639. switch (argv[1].type) {
  640. .hl_null => {},
  641. .hl_string => {
  642. const sv = argv[1].data.string;
  643. if (!appendExclusion(&excludes, sv.ptr[0..sv.len])) return api.makeError("hl:fs: out of memory");
  644. },
  645. .hl_object => {
  646. const obj_in = argv[1].data.object;
  647. for (obj_in.fields[0..obj_in.field_count]) |field| {
  648. if (field.value.type == .hl_null) continue;
  649. if (field.value.type != .hl_string) return api.makeError("hl:fs: watch(path, exclude): every exclusion must be a path string");
  650. const sv = field.value.data.string;
  651. if (!appendExclusion(&excludes, sv.ptr[0..sv.len])) return api.makeError("hl:fs: out of memory");
  652. }
  653. },
  654. else => return api.makeError("hl:fs: watch(path, exclude): exclude is a path string or a list of path strings"),
  655. }
  656. }
  657. const rc = linux.inotify_init1(linux.IN.NONBLOCK);
  658. const ifd: i32 = @intCast(@as(isize, @bitCast(rc)));
  659. if (ifd < 0) return api.makeError("hl:fs: watch: inotify unavailable");
  660. addWatchesRecursive(ifd, path, 0, excludes.items);
  661. const state = allocator.create(WatchState) catch return api.makeError("hl:fs: out of memory");
  662. state.* = .{ .ifd = ifd };
  663. const iter = allocator.create(HlIterator) catch return api.makeError("hl:fs: out of memory");
  664. iter.* = .{
  665. .context = @ptrCast(state),
  666. .next_fn = &watchTryNext, // non-blocking either way — event loop only
  667. .try_next_fn = &watchTryNext,
  668. .deinit_fn = &watchDeinit,
  669. .wake_fd = ifd,
  670. };
  671. return api.makeIterator(iter);
  672. }
  673. fn watchTryNext(ctx: ?*anyopaque) callconv(.c) HlValue {
  674. const st: *WatchState = @ptrCast(@alignCast(ctx orelse return api.makeNull()));
  675. if (st.pos >= st.len) {
  676. const r = linux.read(st.ifd, &st.buf, st.buf.len);
  677. const n: isize = @bitCast(r);
  678. if (n <= 0) return api.makeNull(); // EAGAIN: drained
  679. st.len = @intCast(n);
  680. st.pos = 0;
  681. }
  682. // one inotify_event: wd(i32) mask(u32) cookie(u32) len(u32) name[len]
  683. const base = st.pos;
  684. if (st.len - base < 16) {
  685. st.pos = st.len;
  686. return api.makeNull();
  687. }
  688. const name_len = std.mem.readInt(u32, st.buf[base + 12 ..][0..4], .little);
  689. const name_start = base + 16;
  690. var name: []const u8 = "";
  691. if (name_len > 0 and name_start + name_len <= st.len) {
  692. const raw_name = st.buf[name_start .. name_start + name_len];
  693. name = std.mem.sliceTo(raw_name, 0);
  694. }
  695. st.pos = name_start + name_len;
  696. const fields = allocator.alloc(api.HlField, 1) catch return api.makeNull();
  697. fields[0] = .{ .key = hlStr("name"), .value = api.makeString(allocator.dupe(u8, name) catch "") };
  698. const obj = allocator.create(api.HlObject) catch {
  699. allocator.free(fields);
  700. return api.makeNull();
  701. };
  702. obj.* = .{ .fields = fields.ptr, .field_count = 1, .deinit_fn = null };
  703. return api.makeObject(obj);
  704. }
  705. fn watchDeinit(ctx: ?*anyopaque) callconv(.c) void {
  706. const st: *WatchState = @ptrCast(@alignCast(ctx orelse return));
  707. _ = linux.close(st.ifd);
  708. allocator.destroy(st);
  709. }
  710. // =========================================================================
  711. // THE WRITE SURFACE (2026-08-28)
  712. //
  713. // hl:fs was READ-ONLY until this block: file, readFile, listDir, exists,
  714. // watch. It could report a file's `chmod` and never set one. Session
  715. // persistence is the first thing in this tree that has to put bytes on a
  716. // disk, and the rule when the framework can do something the language
  717. // cannot is that the LANGUAGE is missing it — so the capability lands here,
  718. // as an ordinary hl:fs surface any program can use, not as a private hook
  719. // the session store reaches through.
  720. //
  721. // Three calls, and the shape of each is a decision:
  722. //
  723. // writeFile(path, contents, mode?) ATOMIC. Writes a sibling temp file,
  724. // fsyncs it, then rename()s it over the target — so a reader sees
  725. // either the whole previous file or the whole new one, never a torn
  726. // one. A half-written session file that revives as garbage is a
  727. // silent logout, and the crash that produces it is exactly the crash
  728. // nobody can reproduce. rename(2) within one directory is atomic on
  729. // every filesystem Linux ships; that is the whole reason the temp
  730. // file is a SIBLING rather than in /tmp (a cross-device rename fails).
  731. // mkDir(path, mode?) recursive, and succeeds when the
  732. // directory is already there — the caller wants it to exist, not to
  733. // be the one who created it.
  734. // remove(path) IDEMPOTENT: true when the path is
  735. // gone on return, including when it was already gone. A sweep
  736. // deleting an expired file must not fail because something else
  737. // deleted it first.
  738. //
  739. // MODES ARE EXPLICIT AND DEFAULT TIGHT. hybrilior passes no mode anywhere,
  740. // so its session files land at whatever the umask says — 0644 on a shared
  741. // box, i.e. every session on the machine readable by every other tenant.
  742. // Here the default is 0600 for a file and 0700 for a directory, and a
  743. // caller who wants them looser has to say so. This is not the kind of bug
  744. // that shows up in a test; it shows up in someone else's logs.
  745. // =========================================================================
  746. fn makeWriteError(op: []const u8, path: []const u8, errno: usize) api.HlValue {
  747. 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 }) catch
  748. return api.makeError("fs: write failed");
  749. var v = api.makeError(msg);
  750. v.deinit_fn = &errDeinit;
  751. return v;
  752. }
  753. /// mkdir every missing component of `path`. Returns 0, or the errno of the
  754. /// first component that could not be created for a reason other than "it is
  755. /// already there".
  756. fn mkdirRecursive(path: []const u8, mode: u32) usize {
  757. if (path.len == 0) return 0;
  758. const buf = allocator.dupeZ(u8, path) catch return 12; // ENOMEM
  759. defer allocator.free(buf);
  760. // Walk the separators left to right, creating each prefix in turn. Index
  761. // 0 is skipped so a leading '/' is never itself a component to create.
  762. var i: usize = 1;
  763. while (i <= buf.len) : (i += 1) {
  764. const at_end = i == buf.len;
  765. if (!at_end and buf[i] != '/') continue;
  766. if (!at_end) buf[i] = 0;
  767. const rc: isize = @bitCast(linux.mkdir(buf.ptr, mode));
  768. if (!at_end) buf[i] = '/';
  769. if (rc < 0) {
  770. const errno: usize = @intCast(-rc);
  771. // EEXIST is the success case: the caller wants it to exist.
  772. if (errno != 17) return errno;
  773. }
  774. }
  775. return 0;
  776. }
  777. export fn hl_fs_mk_dir(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  778. if (argc < 1 or argv[0].type != .hl_string) return api.makeError("fs.mkDir expects a string path");
  779. const path_str = argv[0].data.string;
  780. const path = path_str.ptr[0..path_str.len];
  781. var mode: u32 = 0o700;
  782. if (argc >= 2 and argv[1].type == .hl_number) mode = @intFromFloat(argv[1].data.number);
  783. const resolved = resolvePath(path) orelse return api.makeError("fs.mkDir: out of memory");
  784. defer allocator.free(resolved);
  785. const errno = mkdirRecursive(resolved, mode);
  786. if (errno != 0) return makeWriteError("fs.mkDir", path, errno);
  787. return api.makeBool(true);
  788. }
  789. const write_usage = "fs.writeFile expects (String path, String or Bytes contents, Number mode?)";
  790. export fn hl_fs_write_file(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  791. if (argc < 2 or argv[0].type != .hl_string or argv[1].type != .hl_string) {
  792. return api.makeError(write_usage);
  793. }
  794. const path_str = argv[0].data.string;
  795. const body_str = argv[1].data.string;
  796. return writeWhole(path_str.ptr[0..path_str.len], body_str.ptr[0..body_str.len], modeArg(argc, argv));
  797. }
  798. /// hl_fs_write_file_hex(path, hex, mode?) — writeFile with a Bytes (ticket
  799. /// #88): a Bytes crosses the plugin boundary as its hex text (the ABI has no
  800. /// Bytes, as hl:proc's write_hex says), is decoded here and written raw.
  801. export fn hl_fs_write_file_hex(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  802. if (argc < 2 or argv[0].type != .hl_string or argv[1].type != .hl_string) {
  803. return api.makeError(write_usage);
  804. }
  805. const path_str = argv[0].data.string;
  806. const hex = argv[1].data.string.ptr[0..argv[1].data.string.len];
  807. const raw = allocator.alloc(u8, hex.len / 2) catch return api.makeError("fs.writeFile: out of memory");
  808. defer allocator.free(raw);
  809. _ = std.fmt.hexToBytes(raw, hex) catch return api.makeError("fs.writeFile: not a Bytes");
  810. return writeWhole(path_str.ptr[0..path_str.len], raw, modeArg(argc, argv));
  811. }
  812. fn modeArg(argc: u32, argv: [*]const HlValue) u32 {
  813. if (argc >= 3 and argv[2].type == .hl_number) return @intFromFloat(argv[2].data.number);
  814. return 0o600;
  815. }
  816. fn writeWhole(path: []const u8, body: []const u8, mode: u32) HlValue {
  817. const resolved = resolvePath(path) orelse return api.makeError("fs.writeFile: out of memory");
  818. defer allocator.free(resolved);
  819. // The temp name is a SIBLING (see the block comment): rename(2) is only
  820. // atomic within one filesystem, and /tmp is routinely a different one.
  821. // The pid keeps two processes writing the same path off each other's temp
  822. // file; the rename itself settles who wins the target.
  823. const pid = linux.getpid();
  824. const tmp = std.fmt.allocPrintSentinel(allocator, "{s}.hltmp{d}", .{ resolved, pid }, 0) catch
  825. return api.makeError("fs.writeFile: out of memory");
  826. defer allocator.free(tmp);
  827. const rc = linuxOpenRc(tmp.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, mode);
  828. if (rc < 0) return makeWriteError("fs.writeFile", path, @intCast(-rc));
  829. const fd: i32 = @intCast(rc);
  830. var wrote: usize = 0;
  831. while (wrote < body.len) {
  832. const n: isize = @bitCast(linux.write(fd, body.ptr + wrote, body.len - wrote));
  833. if (n <= 0) {
  834. _ = linux.close(fd);
  835. _ = linux.unlink(tmp.ptr);
  836. return makeWriteError("fs.writeFile", path, if (n < 0) @intCast(-n) else 5);
  837. }
  838. wrote += @intCast(n);
  839. }
  840. // fsync BEFORE the rename, or the rename can land while the bytes are
  841. // still only in the page cache — a power cut then leaves an EMPTY file
  842. // where a complete one used to be, which is worse than either outcome.
  843. _ = linux.fsync(fd);
  844. _ = linux.close(fd);
  845. // O_CREAT honours the mode only through the umask; say it outright so a
  846. // 0600 request is 0600 whatever the process umask happens to be.
  847. _ = linux.chmod(tmp.ptr, mode);
  848. const path_z = allocator.dupeZ(u8, resolved) catch return api.makeError("fs.writeFile: out of memory");
  849. defer allocator.free(path_z);
  850. const ren: isize = @bitCast(linux.rename(tmp.ptr, path_z.ptr));
  851. if (ren < 0) {
  852. _ = linux.unlink(tmp.ptr);
  853. return makeWriteError("fs.writeFile", path, @intCast(-ren));
  854. }
  855. return api.makeBool(true);
  856. }
  857. const append_usage = "fs.appendFile expects (String path, String or Bytes contents, Number mode?)";
  858. export fn hl_fs_append_file(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  859. if (argc < 2 or argv[0].type != .hl_string or argv[1].type != .hl_string) {
  860. return api.makeError(append_usage);
  861. }
  862. const path_str = argv[0].data.string;
  863. const body_str = argv[1].data.string;
  864. return appendWhole(path_str.ptr[0..path_str.len], body_str.ptr[0..body_str.len], modeArg(argc, argv));
  865. }
  866. /// hl_fs_append_file_hex(path, hex, mode?) — appendFile with a Bytes (ticket
  867. /// #88), the same hex-text crossing writeFile's hex sibling uses.
  868. export fn hl_fs_append_file_hex(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  869. if (argc < 2 or argv[0].type != .hl_string or argv[1].type != .hl_string) {
  870. return api.makeError(append_usage);
  871. }
  872. const path_str = argv[0].data.string;
  873. const hex = argv[1].data.string.ptr[0..argv[1].data.string.len];
  874. const raw = allocator.alloc(u8, hex.len / 2) catch return api.makeError("fs.appendFile: out of memory");
  875. defer allocator.free(raw);
  876. _ = std.fmt.hexToBytes(raw, hex) catch return api.makeError("fs.appendFile: not a Bytes");
  877. return appendWhole(path_str.ptr[0..path_str.len], raw, modeArg(argc, argv));
  878. }
  879. /// APPEND, NOT ATOMIC BY RENAME (writeFile's block comment above): an append
  880. /// MUTATES the file that is already there, so there is no whole "old" or
  881. /// "new" version to swap in — O_APPEND gives a narrower guarantee instead.
  882. /// The kernel positions a single write() at the file's CURRENT end and
  883. /// performs it as one operation, so two processes each appending a line never
  884. /// interleave their bytes mid-line and a reader never sees a torn append —
  885. /// which is why this is ONE write() of the whole body where writeFile's is
  886. /// (short-write retries aside). The file is created at `mode` (default 0600,
  887. /// same default as writeFile) if it is not there yet; O_APPEND on an existing
  888. /// file leaves its mode untouched, same as any other open of it.
  889. fn appendWhole(path: []const u8, body: []const u8, mode: u32) HlValue {
  890. const resolved = resolvePath(path) orelse return api.makeError("fs.appendFile: out of memory");
  891. defer allocator.free(resolved);
  892. const path_z = allocator.dupeZ(u8, resolved) catch return api.makeError("fs.appendFile: out of memory");
  893. defer allocator.free(path_z);
  894. const rc = linuxOpenRc(path_z.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true, .APPEND = true }, mode);
  895. if (rc < 0) return makeWriteError("fs.appendFile", path, @intCast(-rc));
  896. const fd: i32 = @intCast(rc);
  897. defer _ = linux.close(fd);
  898. var wrote: usize = 0;
  899. while (wrote < body.len) {
  900. const n: isize = @bitCast(linux.write(fd, body.ptr + wrote, body.len - wrote));
  901. if (n <= 0) {
  902. return makeWriteError("fs.appendFile", path, if (n < 0) @intCast(-n) else 5);
  903. }
  904. wrote += @intCast(n);
  905. }
  906. return api.makeBool(true);
  907. }
  908. export fn hl_fs_remove(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  909. if (argc < 1 or argv[0].type != .hl_string) return api.makeError("fs.remove expects a string path");
  910. const path_str = argv[0].data.string;
  911. const path = path_str.ptr[0..path_str.len];
  912. const resolved = resolvePath(path) orelse return api.makeError("fs.remove: out of memory");
  913. defer allocator.free(resolved);
  914. const path_z = allocator.dupeZ(u8, resolved) catch return api.makeError("fs.remove: out of memory");
  915. defer allocator.free(path_z);
  916. const rc: isize = @bitCast(linux.unlink(path_z.ptr));
  917. if (rc < 0) {
  918. const errno: usize = @intCast(-rc);
  919. // ENOENT is success: the caller asked for the path to be gone.
  920. if (errno == 2) return api.makeBool(true);
  921. return makeWriteError("fs.remove", path, errno);
  922. }
  923. return api.makeBool(true);
  924. }

Branches

Latest commits

  • 7bd0337ccalendar: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gates 78/0 + 18/0mre
  • ff41310ccalendar: Hybriel master 8efba065 (#126 memory, #48 lambda copy; audit: no & needed)mre
  • 14ba08c7antcolony#40: mission references point to the moved missionsmre
  • 99c73346antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 76edaa62calendar: Hybriel master ff51cf46 (re-vendor round, static workaround removed)mre
  • 90a3fc2cdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 6722b72ddeploy.sh: never send .git or .gitignore to Byrodinmre
  • be099807State of 2026-09-27, before the move to gitoriamre