gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit68dcb60368dcb603deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre68dcb603/plugins/http/ws_common.zig

19.6 KB

  1. // Shared WebSocket core — RFC 6455 framing, transport-agnostic (decision D8).
  2. // Compiled into each HTTP plugin like http_common.zig; NOT a standalone .so.
  3. //
  4. // Responsibilities:
  5. // - Sec-WebSocket-Accept computation (SHA1 + base64 of key + RFC GUID)
  6. // - Frame encoding (unmasked, server → client) via a write callback
  7. // - Frame decoding + message assembly (client-masked inbound), fragmentation,
  8. // ping/pong surfacing, close handshake with codes, UTF-8 validation
  9. //
  10. // The per-protocol handshakes stay in each plugin: h1 = Upgrade/101 (http1.zig),
  11. // h2 = RFC 8441 extended CONNECT (future), h3 = RFC 9220 (future). Everything
  12. // after the handshake — the byte stream — goes through this module unchanged.
  13. //
  14. // Transport neutrality: encoding writes through `WriteFn` callbacks and decoding
  15. // consumes raw bytes via `Decoder.feed()`; the module never touches a socket.
  16. const std = @import("std");
  17. pub const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
  18. /// Write callback: return false on transport failure (peer gone).
  19. pub const WriteFn = *const fn (ctx: ?*anyopaque, data: []const u8) bool;
  20. pub const Opcode = enum(u4) {
  21. continuation = 0x0,
  22. text = 0x1,
  23. binary = 0x2,
  24. close = 0x8,
  25. ping = 0x9,
  26. pong = 0xA,
  27. _,
  28. pub fn isControl(self: Opcode) bool {
  29. return @intFromEnum(self) >= 0x8;
  30. }
  31. };
  32. // Close codes used by the core
  33. pub const CLOSE_NORMAL: u16 = 1000;
  34. pub const CLOSE_GOING_AWAY: u16 = 1001;
  35. pub const CLOSE_PROTOCOL_ERROR: u16 = 1002;
  36. pub const CLOSE_INVALID_PAYLOAD: u16 = 1007;
  37. pub const CLOSE_TOO_BIG: u16 = 1009;
  38. pub const CLOSE_NO_STATUS: u16 = 1005; // synthesized when close frame has no code — never sent on the wire
  39. /// Sec-WebSocket-Accept: base64(SHA1(key ++ GUID)). Output is always 28 chars.
  40. pub fn computeAcceptKey(key: []const u8, out: *[28]u8) []const u8 {
  41. var sha = std.crypto.hash.Sha1.init(.{});
  42. sha.update(key);
  43. sha.update(GUID);
  44. var digest: [20]u8 = undefined;
  45. sha.final(&digest);
  46. return std.base64.standard.Encoder.encode(out, &digest);
  47. }
  48. /// Encode a frame header into `buf` (needs >= 10 bytes). Returns header length.
  49. /// Server frames are unmasked (RFC 6455 §5.1).
  50. pub fn encodeFrameHeader(buf: *[10]u8, opcode: Opcode, payload_len: usize, fin: bool) usize {
  51. buf[0] = (if (fin) @as(u8, 0x80) else 0) | @as(u8, @intFromEnum(opcode));
  52. if (payload_len <= 125) {
  53. buf[1] = @intCast(payload_len);
  54. return 2;
  55. } else if (payload_len <= 65535) {
  56. buf[1] = 126;
  57. std.mem.writeInt(u16, buf[2..4], @intCast(payload_len), .big);
  58. return 4;
  59. } else {
  60. buf[1] = 127;
  61. std.mem.writeInt(u64, buf[2..10], payload_len, .big);
  62. return 10;
  63. }
  64. }
  65. /// Write one complete unmasked frame through the callback. True on success.
  66. pub fn writeFrame(write: WriteFn, ctx: ?*anyopaque, opcode: Opcode, payload: []const u8) bool {
  67. var hdr: [10]u8 = undefined;
  68. const hlen = encodeFrameHeader(&hdr, opcode, payload.len, true);
  69. if (!write(ctx, hdr[0..hlen])) return false;
  70. if (payload.len > 0 and !write(ctx, payload)) return false;
  71. return true;
  72. }
  73. /// Write a close frame with a code and optional reason (reason truncated to 123).
  74. pub fn writeClose(write: WriteFn, ctx: ?*anyopaque, code: u16, reason: []const u8) bool {
  75. var payload: [125]u8 = undefined;
  76. std.mem.writeInt(u16, payload[0..2], code, .big);
  77. const rlen = @min(reason.len, 123);
  78. @memcpy(payload[2 .. 2 + rlen], reason[0..rlen]);
  79. return writeFrame(write, ctx, .close, payload[0 .. 2 + rlen]);
  80. }
  81. /// A decoded inbound event. Payload slices are allocated with the decoder's
  82. /// allocator and owned by the CALLER (free after use).
  83. pub const Event = union(enum) {
  84. text: []u8,
  85. binary: []u8,
  86. ping: []u8, // payload to echo in the pong
  87. pong: []u8,
  88. close: Close,
  89. /// Protocol violation — caller should send a close frame with this code
  90. /// and drop the connection.
  91. protocol_error: u16,
  92. pub const Close = struct {
  93. code: u16, // CLOSE_NO_STATUS when the frame carried no code
  94. reason: []u8,
  95. };
  96. pub fn deinitPayload(self: *Event, alloc: std.mem.Allocator) void {
  97. switch (self.*) {
  98. .text, .binary, .ping, .pong => |p| alloc.free(p),
  99. .close => |cl| alloc.free(cl.reason),
  100. .protocol_error => {},
  101. }
  102. }
  103. };
  104. fn validCloseCode(code: u16) bool {
  105. return switch (code) {
  106. 1000...1003, 1007...1011 => true,
  107. 3000...4999 => true,
  108. else => false,
  109. };
  110. }
  111. /// Streaming frame decoder + message assembler. One per connection.
  112. /// feed() raw transport bytes, then loop next() until it returns null.
  113. pub const Decoder = struct {
  114. alloc: std.mem.Allocator,
  115. /// Client frames must be masked (server side). Set false for client use.
  116. require_masked: bool = true,
  117. max_message_size: usize = 16 * 1024 * 1024,
  118. buf: std.ArrayListUnmanaged(u8) = .empty, // raw incoming bytes
  119. read_pos: usize = 0,
  120. msg: std.ArrayListUnmanaged(u8) = .empty, // fragmented-message assembly
  121. msg_opcode: ?Opcode = null,
  122. failed: bool = false, // after a protocol error the stream is dead
  123. pub fn init(alloc: std.mem.Allocator) Decoder {
  124. return .{ .alloc = alloc };
  125. }
  126. pub fn deinit(self: *Decoder) void {
  127. self.buf.deinit(self.alloc);
  128. self.msg.deinit(self.alloc);
  129. }
  130. pub fn feed(self: *Decoder, bytes: []const u8) !void {
  131. // Compact consumed prefix before growing
  132. if (self.read_pos > 0) {
  133. const remaining = self.buf.items.len - self.read_pos;
  134. std.mem.copyForwards(u8, self.buf.items[0..remaining], self.buf.items[self.read_pos..]);
  135. self.buf.shrinkRetainingCapacity(remaining);
  136. self.read_pos = 0;
  137. }
  138. try self.buf.appendSlice(self.alloc, bytes);
  139. }
  140. fn fail(self: *Decoder, code: u16) Event {
  141. self.failed = true;
  142. return .{ .protocol_error = code };
  143. }
  144. /// Pop the next complete event, or null if more bytes are needed.
  145. pub fn next(self: *Decoder) !?Event {
  146. if (self.failed) return null;
  147. const data = self.buf.items[self.read_pos..];
  148. if (data.len < 2) return null;
  149. const b0 = data[0];
  150. const b1 = data[1];
  151. const fin = (b0 & 0x80) != 0;
  152. const rsv = b0 & 0x70;
  153. const opcode: Opcode = @enumFromInt(@as(u4, @truncate(b0 & 0x0F)));
  154. const masked = (b1 & 0x80) != 0;
  155. const len7: u8 = b1 & 0x7F;
  156. if (rsv != 0) return self.fail(CLOSE_PROTOCOL_ERROR); // no extensions negotiated
  157. switch (@intFromEnum(opcode)) {
  158. 0x0, 0x1, 0x2, 0x8, 0x9, 0xA => {},
  159. else => return self.fail(CLOSE_PROTOCOL_ERROR),
  160. }
  161. if (self.require_masked and !masked) return self.fail(CLOSE_PROTOCOL_ERROR);
  162. if (opcode.isControl() and (!fin or len7 > 125)) return self.fail(CLOSE_PROTOCOL_ERROR);
  163. var offset: usize = 2;
  164. var payload_len: u64 = len7;
  165. if (len7 == 126) {
  166. if (data.len < offset + 2) return null;
  167. payload_len = std.mem.readInt(u16, data[offset..][0..2], .big);
  168. offset += 2;
  169. if (payload_len <= 125) return self.fail(CLOSE_PROTOCOL_ERROR); // non-minimal encoding
  170. } else if (len7 == 127) {
  171. if (data.len < offset + 8) return null;
  172. payload_len = std.mem.readInt(u64, data[offset..][0..8], .big);
  173. offset += 8;
  174. if (payload_len <= 65535) return self.fail(CLOSE_PROTOCOL_ERROR);
  175. if (payload_len > (1 << 62)) return self.fail(CLOSE_PROTOCOL_ERROR); // MSB must be 0
  176. }
  177. if (payload_len > self.max_message_size or
  178. self.msg.items.len + payload_len > self.max_message_size)
  179. return self.fail(CLOSE_TOO_BIG);
  180. var mask: [4]u8 = .{ 0, 0, 0, 0 };
  181. if (masked) {
  182. if (data.len < offset + 4) return null;
  183. mask = data[offset..][0..4].*;
  184. offset += 4;
  185. }
  186. const plen: usize = @intCast(payload_len);
  187. if (data.len < offset + plen) return null; // frame incomplete
  188. // Unmask into an owned copy
  189. const payload = try self.alloc.alloc(u8, plen);
  190. errdefer self.alloc.free(payload);
  191. for (0..plen) |i| {
  192. payload[i] = data[offset + i] ^ mask[i % 4];
  193. }
  194. self.read_pos += offset + plen;
  195. switch (opcode) {
  196. .ping => return .{ .ping = payload },
  197. .pong => return .{ .pong = payload },
  198. .close => {
  199. if (plen == 0) return .{ .close = .{ .code = CLOSE_NO_STATUS, .reason = payload } };
  200. if (plen == 1) {
  201. self.alloc.free(payload);
  202. return self.fail(CLOSE_PROTOCOL_ERROR);
  203. }
  204. const code = std.mem.readInt(u16, payload[0..2], .big);
  205. if (!validCloseCode(code)) {
  206. self.alloc.free(payload);
  207. return self.fail(CLOSE_PROTOCOL_ERROR);
  208. }
  209. if (!std.unicode.utf8ValidateSlice(payload[2..])) {
  210. self.alloc.free(payload);
  211. return self.fail(CLOSE_INVALID_PAYLOAD);
  212. }
  213. const reason = try self.alloc.alloc(u8, plen - 2);
  214. @memcpy(reason, payload[2..]);
  215. self.alloc.free(payload);
  216. return .{ .close = .{ .code = code, .reason = reason } };
  217. },
  218. .text, .binary => {
  219. if (self.msg_opcode != null) {
  220. self.alloc.free(payload);
  221. return self.fail(CLOSE_PROTOCOL_ERROR); // new data frame while assembling
  222. }
  223. if (fin) {
  224. if (opcode == .text and !std.unicode.utf8ValidateSlice(payload)) {
  225. self.alloc.free(payload);
  226. return self.fail(CLOSE_INVALID_PAYLOAD);
  227. }
  228. return if (opcode == .text) .{ .text = payload } else .{ .binary = payload };
  229. }
  230. // First fragment
  231. self.msg_opcode = opcode;
  232. try self.msg.appendSlice(self.alloc, payload);
  233. self.alloc.free(payload);
  234. return self.next(); // more frames may already be buffered
  235. },
  236. .continuation => {
  237. const start_op = self.msg_opcode orelse {
  238. self.alloc.free(payload);
  239. return self.fail(CLOSE_PROTOCOL_ERROR); // continuation without a message
  240. };
  241. try self.msg.appendSlice(self.alloc, payload);
  242. self.alloc.free(payload);
  243. if (!fin) return self.next();
  244. // Message complete
  245. const full = try self.msg.toOwnedSlice(self.alloc);
  246. self.msg_opcode = null;
  247. if (start_op == .text and !std.unicode.utf8ValidateSlice(full)) {
  248. self.alloc.free(full);
  249. return self.fail(CLOSE_INVALID_PAYLOAD);
  250. }
  251. return if (start_op == .text) .{ .text = full } else .{ .binary = full };
  252. },
  253. _ => return self.fail(CLOSE_PROTOCOL_ERROR), // unreachable — filtered above
  254. }
  255. }
  256. };
  257. // ===========================================================================
  258. // Tests
  259. // ===========================================================================
  260. const t = std.testing;
  261. test "computeAcceptKey RFC 6455 sample" {
  262. var out: [28]u8 = undefined;
  263. const accept = computeAcceptKey("dGhlIHNhbXBsZSBub25jZQ==", &out);
  264. try t.expectEqualStrings("s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", accept);
  265. }
  266. fn maskedFrame(alloc: std.mem.Allocator, fin: bool, opcode: Opcode, payload: []const u8, mask: [4]u8) ![]u8 {
  267. var out = std.ArrayListUnmanaged(u8).empty;
  268. var hdr: [10]u8 = undefined;
  269. const hlen = encodeFrameHeader(&hdr, opcode, payload.len, fin);
  270. hdr[1] |= 0x80; // set mask bit
  271. try out.appendSlice(alloc, hdr[0..hlen]);
  272. try out.appendSlice(alloc, &mask);
  273. for (payload, 0..) |b, i| try out.append(alloc, b ^ mask[i % 4]);
  274. return out.toOwnedSlice(alloc);
  275. }
  276. test "decode masked text frame" {
  277. var dec = Decoder.init(t.allocator);
  278. defer dec.deinit();
  279. const frame = try maskedFrame(t.allocator, true, .text, "Hello", .{ 0x37, 0xfa, 0x21, 0x3d });
  280. defer t.allocator.free(frame);
  281. try dec.feed(frame);
  282. var ev = (try dec.next()).?;
  283. defer ev.deinitPayload(t.allocator);
  284. try t.expectEqualStrings("Hello", ev.text);
  285. try t.expectEqual(@as(?Event, null), try dec.next());
  286. }
  287. test "decode split across feeds" {
  288. var dec = Decoder.init(t.allocator);
  289. defer dec.deinit();
  290. const frame = try maskedFrame(t.allocator, true, .text, "chunked delivery", .{ 1, 2, 3, 4 });
  291. defer t.allocator.free(frame);
  292. try dec.feed(frame[0..3]);
  293. try t.expectEqual(@as(?Event, null), try dec.next());
  294. try dec.feed(frame[3..]);
  295. var ev = (try dec.next()).?;
  296. defer ev.deinitPayload(t.allocator);
  297. try t.expectEqualStrings("chunked delivery", ev.text);
  298. }
  299. test "fragmented message assembly" {
  300. var dec = Decoder.init(t.allocator);
  301. defer dec.deinit();
  302. const f1 = try maskedFrame(t.allocator, false, .text, "Hel", .{ 9, 8, 7, 6 });
  303. defer t.allocator.free(f1);
  304. const f2 = try maskedFrame(t.allocator, false, .continuation, "lo ", .{ 5, 4, 3, 2 });
  305. defer t.allocator.free(f2);
  306. const f3 = try maskedFrame(t.allocator, true, .continuation, "WS", .{ 1, 1, 1, 1 });
  307. defer t.allocator.free(f3);
  308. try dec.feed(f1);
  309. try dec.feed(f2);
  310. try t.expectEqual(@as(?Event, null), try dec.next());
  311. try dec.feed(f3);
  312. var ev = (try dec.next()).?;
  313. defer ev.deinitPayload(t.allocator);
  314. try t.expectEqualStrings("Hello WS", ev.text);
  315. }
  316. test "control frame interleaved with fragments" {
  317. var dec = Decoder.init(t.allocator);
  318. defer dec.deinit();
  319. const f1 = try maskedFrame(t.allocator, false, .text, "par", .{ 2, 2, 2, 2 });
  320. defer t.allocator.free(f1);
  321. const ping = try maskedFrame(t.allocator, true, .ping, "hb", .{ 3, 3, 3, 3 });
  322. defer t.allocator.free(ping);
  323. const f2 = try maskedFrame(t.allocator, true, .continuation, "tial", .{ 4, 4, 4, 4 });
  324. defer t.allocator.free(f2);
  325. try dec.feed(f1);
  326. try dec.feed(ping);
  327. var ev1 = (try dec.next()).?;
  328. defer ev1.deinitPayload(t.allocator);
  329. try t.expectEqualStrings("hb", ev1.ping);
  330. try dec.feed(f2);
  331. var ev2 = (try dec.next()).?;
  332. defer ev2.deinitPayload(t.allocator);
  333. try t.expectEqualStrings("partial", ev2.text);
  334. }
  335. test "unmasked client frame is a protocol error" {
  336. var dec = Decoder.init(t.allocator);
  337. defer dec.deinit();
  338. var hdr: [10]u8 = undefined;
  339. const hlen = encodeFrameHeader(&hdr, .text, 2, true);
  340. try dec.feed(hdr[0..hlen]);
  341. try dec.feed("hi");
  342. const ev = (try dec.next()).?;
  343. try t.expectEqual(CLOSE_PROTOCOL_ERROR, ev.protocol_error);
  344. try t.expectEqual(@as(?Event, null), try dec.next()); // stream dead
  345. }
  346. test "invalid UTF-8 in text is 1007" {
  347. var dec = Decoder.init(t.allocator);
  348. defer dec.deinit();
  349. const bad = [_]u8{ 0xC3, 0x28 }; // invalid 2-byte sequence
  350. const frame = try maskedFrame(t.allocator, true, .text, &bad, .{ 7, 7, 7, 7 });
  351. defer t.allocator.free(frame);
  352. try dec.feed(frame);
  353. const ev = (try dec.next()).?;
  354. try t.expectEqual(CLOSE_INVALID_PAYLOAD, ev.protocol_error);
  355. }
  356. test "close with code and reason" {
  357. var dec = Decoder.init(t.allocator);
  358. defer dec.deinit();
  359. var payload: [7]u8 = undefined;
  360. std.mem.writeInt(u16, payload[0..2], 1000, .big);
  361. @memcpy(payload[2..], "done!");
  362. const frame = try maskedFrame(t.allocator, true, .close, &payload, .{ 6, 6, 6, 6 });
  363. defer t.allocator.free(frame);
  364. try dec.feed(frame);
  365. var ev = (try dec.next()).?;
  366. defer ev.deinitPayload(t.allocator);
  367. try t.expectEqual(@as(u16, 1000), ev.close.code);
  368. try t.expectEqualStrings("done!", ev.close.reason);
  369. }
  370. test "close with invalid code is 1002" {
  371. var dec = Decoder.init(t.allocator);
  372. defer dec.deinit();
  373. var payload: [2]u8 = undefined;
  374. std.mem.writeInt(u16, payload[0..2], 1006, .big); // 1006 must never appear on the wire
  375. const frame = try maskedFrame(t.allocator, true, .close, &payload, .{ 1, 2, 3, 4 });
  376. defer t.allocator.free(frame);
  377. try dec.feed(frame);
  378. const ev = (try dec.next()).?;
  379. try t.expectEqual(CLOSE_PROTOCOL_ERROR, ev.protocol_error);
  380. }
  381. test "fragmented control frame is 1002" {
  382. var dec = Decoder.init(t.allocator);
  383. defer dec.deinit();
  384. const frame = try maskedFrame(t.allocator, false, .ping, "x", .{ 1, 2, 3, 4 });
  385. defer t.allocator.free(frame);
  386. try dec.feed(frame);
  387. const ev = (try dec.next()).?;
  388. try t.expectEqual(CLOSE_PROTOCOL_ERROR, ev.protocol_error);
  389. }
  390. test "continuation without message is 1002" {
  391. var dec = Decoder.init(t.allocator);
  392. defer dec.deinit();
  393. const frame = try maskedFrame(t.allocator, true, .continuation, "x", .{ 1, 2, 3, 4 });
  394. defer t.allocator.free(frame);
  395. try dec.feed(frame);
  396. const ev = (try dec.next()).?;
  397. try t.expectEqual(CLOSE_PROTOCOL_ERROR, ev.protocol_error);
  398. }
  399. test "new data frame while assembling is 1002" {
  400. var dec = Decoder.init(t.allocator);
  401. defer dec.deinit();
  402. const f1 = try maskedFrame(t.allocator, false, .text, "a", .{ 1, 2, 3, 4 });
  403. defer t.allocator.free(f1);
  404. const f2 = try maskedFrame(t.allocator, true, .text, "b", .{ 1, 2, 3, 4 });
  405. defer t.allocator.free(f2);
  406. try dec.feed(f1);
  407. try dec.feed(f2);
  408. const ev = (try dec.next()).?;
  409. try t.expectEqual(CLOSE_PROTOCOL_ERROR, ev.protocol_error);
  410. }
  411. test "oversize message is 1009" {
  412. var dec = Decoder.init(t.allocator);
  413. defer dec.deinit();
  414. dec.max_message_size = 8;
  415. const frame = try maskedFrame(t.allocator, true, .text, "123456789", .{ 1, 2, 3, 4 });
  416. defer t.allocator.free(frame);
  417. try dec.feed(frame);
  418. const ev = (try dec.next()).?;
  419. try t.expectEqual(CLOSE_TOO_BIG, ev.protocol_error);
  420. }
  421. test "16-bit extended length round trip" {
  422. var dec = Decoder.init(t.allocator);
  423. defer dec.deinit();
  424. const big = try t.allocator.alloc(u8, 300);
  425. defer t.allocator.free(big);
  426. @memset(big, 'A');
  427. const frame = try maskedFrame(t.allocator, true, .binary, big, .{ 9, 9, 9, 9 });
  428. defer t.allocator.free(frame);
  429. try dec.feed(frame);
  430. var ev = (try dec.next()).?;
  431. defer ev.deinitPayload(t.allocator);
  432. try t.expectEqual(@as(usize, 300), ev.binary.len);
  433. try t.expectEqual(@as(u8, 'A'), ev.binary[150]);
  434. }
  435. test "writeFrame emits parseable frame" {
  436. const Sink = struct {
  437. var buf: std.ArrayListUnmanaged(u8) = .empty;
  438. fn write(_: ?*anyopaque, data: []const u8) bool {
  439. buf.appendSlice(t.allocator, data) catch return false;
  440. return true;
  441. }
  442. };
  443. defer Sink.buf.deinit(t.allocator);
  444. try t.expect(writeFrame(&Sink.write, null, .text, "server says hi"));
  445. // Parse it back with an unmasked-tolerant decoder (client role)
  446. var dec = Decoder.init(t.allocator);
  447. dec.require_masked = false;
  448. defer dec.deinit();
  449. try dec.feed(Sink.buf.items);
  450. var ev = (try dec.next()).?;
  451. defer ev.deinitPayload(t.allocator);
  452. try t.expectEqualStrings("server says hi", ev.text);
  453. }
  454. test "writeClose encodes code and reason" {
  455. const Sink = struct {
  456. var buf: std.ArrayListUnmanaged(u8) = .empty;
  457. fn write(_: ?*anyopaque, data: []const u8) bool {
  458. buf.appendSlice(t.allocator, data) catch return false;
  459. return true;
  460. }
  461. };
  462. defer Sink.buf.deinit(t.allocator);
  463. try t.expect(writeClose(&Sink.write, null, 1000, "bye"));
  464. var dec = Decoder.init(t.allocator);
  465. dec.require_masked = false;
  466. defer dec.deinit();
  467. try dec.feed(Sink.buf.items);
  468. var ev = (try dec.next()).?;
  469. defer ev.deinitPayload(t.allocator);
  470. try t.expectEqual(@as(u16, 1000), ev.close.code);
  471. try t.expectEqualStrings("bye", ev.close.reason);
  472. }

Branches

Latest commits

  • 68dcb603deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 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