gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit9bfba36a9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre9bfba36a/plugins/fs/fs.zig

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

Branches

Latest commits

  • 9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • c7bd2645tickets: Hybriel master 73267707 (#122 fixed); compactNow workaround removed (#110 covered)mre
  • 2ab91ee9tickets: gate checks rows appear once (session sync); re-vendor to ff51cf46 stopped on hybriel#122, stays 837fe120mre
  • e01c2b1dtickets#24: installable app (manifest, service worker, offline list), own icon; gate waits for the hello's pongmre
  • 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
  • f12fa1bcState of 2026-09-27, before the move to gitoriamre