gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commite2deed6de2deed6dgitoria#20: "Add code" only on the Code page of an empty repository, no collapsiblemree2deed6d/plugins/fs/fs.zig

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

Branches

Latest commits

  • e2deed6dgitoria#20: "Add code" only on the Code page of an empty repository, no collapsiblemre
  • 8bb97ffddeploy.sh: never send .git or .gitignore to Byrodinmre
  • fd981932State of 2026-09-27; bin/ no longer tracked (Hybriel commit is in README)mre
  • 4a2d7125initial commitmre