gitoriaLog in with ident

antcolony

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Main branchmain3a4d0324antcolony#37: a too-long report gets up to 3 fix tries, finished work is never thrown away for lengthmremain/plugins/fs/fs.zig

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

Branches

Latest commits

  • 3a4d0324antcolony#37: a too-long report gets up to 3 fix tries, finished work is never thrown away for lengthmre
  • a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mre
  • c613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mre
  • 9062978ctracker: worker box sees /media/STORAGE/projects/old-tracker read-only (tracker#2 source data)mre
  • 7f9660eeState of 2026-09-27, before the move to gitoriamre