gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit94716fd294716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre94716fd2/plugins/fs/fs.zig

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

Branches

Latest commits

  • 94716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre
  • 186079b0tracker mission 028 (code order) 1/5 move: every root .hl except project.hl into lib/ (styles.hl into components/), import paths only; gates 342/0, 32/0, 52/0; real-copy pages identicalmre
  • 4f47f181tracker: report 027mre
  • dc1d4be4tracker mission 027: Hybriel master 06617221 vendored (plugin allocator fixes 3a781359 + 413f60e4); real copy RSS through first-start jobs + 400 loads flat ~2.55 GB (190aa11d 2.3 -> 5.6 GB), page times <= 1.1x; gates 342/0, 32/0, 52/0mre
  • 84e1b3e1tracker: reports 025 + 026mre
  • dc40d859tracker#31 (mission 026): duplicate titles merged — the 68 type+tmdbId pairs held by 157 records were the old tracker's (all migrated); merge.hl repair job (own clock, before the TMDB jobs) keeps one keeper per title (follows/watches > old short id > oldest), moves follows, watches, seasons, cast, credits, timelines, tombstones the rest (mergedInto, never deleted), slugs + short ids 301 to the keeper; stray seasons merged into their listed twin (Reacher S3 watches) or linked when watched; search import re-checks before its put; deploy.sh waits up to 90 s for 200; real copy 68 -> 0 dup ids, az5b2 follows/watches equal; gates 342/0, 32/0, 52/0mre
  • 2667da05tracker#30 (mission 025): /my/ pages from slim cached title cards, episode rows and watch sets (after the jobs /my/series 1.8 s -> 0.06 s, /my/unwatched 4.1 -> 0.18 s); timeline page shows its name once; franchise widget under the poster/title; movies with TV leftovers (First Contact) go through the details repair; tools/count-tmdb-ids.hl; gates 327/0, 52/0, 32/0mre
  • 3909810dantcolony#40: mission references in README/STATUS/docs point to the moved missionsmre
  • e63b1d28antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 1cda451dtracker: Hybriel master 190aa11d (#127 both shapes, GC correctness fc838894) — conductor adopts despite /my/series 2.4x after jobs (memory 8.0 → 1.7 GB boot); see reports/071mre
  • c1fa2f2etracker (mission 071): Hybriel master 190aa11d measured on the real copy vs the live binary 8590df63 — NOT adopted (after the first-start jobs /my/series 2.4x slower, /series 1.6x, RSS swings 7.4-12.2 GB; fresh it is flat at 1.7-2.2 GB and /my/unwatched faster), vendor stays 8efba065, candidate kept in .scratch/w071/vendor-190aa11d; tests/kinds.mjs: collection seed off (its TMDB request broke check 1 in 1 of 4 runs); tests/realdata-071.sh + realdata-071-bench.mjs + tools/realdata-071-table.py; README + STATUS (numbers, how to repeat); gates 325/0, 32/0, 50/0mre
  • 8081350atracker docs (mission 070): README (summary, Config HL_GC_BYTES — kept at Hybriel's default, the 256 MiB setting is taken out of docker-compose.yml again: the jobs grew to 12+ GB with it too, see STATUS), Test (three gates), Deploy (first start ~50 min: kinds then seed, restart once after collections done, memory numbers), Vendored Hybriel 8efba065 + #48 audit, Files; STATUS mission 070 entry (merges, migrated counts, lambda audit, gates, RSS old vs new, how to repeat, open points); docs/kinds.md + docs/franchises.md job order; tests/realdata-070-*.sh, tools/count-migrated.hl, tools/ref-params.py, tools/lambda-audit.pymre
  • 1ad19c8ctracker: re-vendor Hybriel master 8efba065 (#126 GC by bytes, #48 lambda parameters copy) (mission 070): bin/hybriel sha256 50361e95…, plugins core crypto data fetch fs http http1 mpackdb proc smtp time web; lambda audit: 13 lambdas change a passed record/list (11 through a local alias), no caller relies on it — unchanged; 319 read-only lambda parameters get & (no copy per call: /my/schedule 2.35 → 0.46 s, /my/unwatched 13.7 → 5.7 s on the real copy); /my/unwatched one merge sort instead of n² inserts; docker-compose HL_GC_BYTES=268435456; deploy.sh runs kinds.mjs + franchises.mjs too (default ports 8700–8710); tests/realdata-070.mjs; gates browser 325/0, kinds 32/0, franchises 50/0mre
  • 46b21389tracker (mission 070, conductor): the sync never destroys migrated data — the one-time summary step MOVES a copied summary to migratedSummary (marker summaries-moved.txt) instead of clearing it; a migrated record's first title/genres/homepage/tagline TMDB replaces → migratedTitle/migratedGenres/migratedHomepage/migratedTagline, a migrated season's/episode's title/summary → migratedTitle/migratedSummary (set once); tests/peek-shows.hl prints them; gate 325/0mre
  • 749019b0Merge t19 (tracker#19 franchises + timelines) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs/faketmdb.mjs, both sides kept; franchise/timeline pages get #20's typed heading (Franchise | …, Timeline | …), their title links via titlePath (/movies|/series|/shows); the collection seed waits for repair, kinds and credits too; franchises.mjs URLs + 2 new checks; gates browser 323/0, kinds 32/0, franchises 50/0mre
  • daf49feaMerge t20 (tracker#20 typed headings + #21 series/shows split) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/search.hl/components/search.hl/browser.mjs/faketmdb.mjs, both sides kept; clock order backfill → repair → kinds → credits; withDetailsFields stores tmdbType + kind; gates: browser.mjs URLs → /series|/movies, typed h1 selectors; kinds.mjs repair/credits off, fixture name = seed name; browser 323/0, kinds 32/0mre
  • 20e09d89Merge t18 (tracker#18 delta sync) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs, both sides kept; pageShowOf summary = summaryOfmre
  • f83571c3tracker#18 (mission 067): daily sync by change lists — TMDB /tv|movie/changes (since the stored day, paged) + TVmaze /updates/shows → only our changed titles (followed: full step, unfollowed: light step — changed seasons, no TVmaze), full walk on first run / gap > 14 days / failed list; show record refreshed (title, tmdbSummary, tagline, status, genres …; renamed titles re-indexed); summary = the creator's own text (page: summary > tmdbSummary > tvmazeSummary), one-time clear of copied summaries (9,647 on the live copy); gate 261, tests/realdata-018*.mjs, README + STATUSmre
  • 1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre
  • f2b00674Merge t26 (tracker#26 + #28) into main (mission 062): short ids for every new person (castPersonId, guest route), guest stars stored on the title and created as people only when opened (/person/tmdb/<id>?show=<id> → 302), lean watch/follow clicks (showRow a small object, cast/crew from the slug, watches cached per user, face rows only after a season toggle); gate 311, tests/realdata-062.mjs, README + STATUSmre