TFTP server library for Zig
0

Configure Feed

Select the types of activity you want to include in your feed.

work

Jeffrey C. Ollie (May 17, 2026, 12:53 PM -0500) ab856713 5fbc3576

+240 -192
+1
flake.nix
··· 42 42 devShells = forAllSystems (pkgs: { 43 43 default = pkgs.mkShell { 44 44 packages = [ 45 + pkgs.reuse 45 46 pkgs.tftp-hpa 46 47 pkgs.uv 47 48 pkgs.zig_0_16
+175
src/lib/netascii.zig
··· 20 20 crlf, 21 21 }; 22 22 23 + const log = std.log.scoped(.netascii); 24 + 25 + pub const Decoder = switch (builtin.os.tag) { 26 + .windows => _Decoder(.crlf), 27 + else => _Decoder(.lf), 28 + // macOS before OS X would be .cr, but that is not supported by Zig 29 + }; 30 + 31 + pub fn _Decoder(comptime linesep: LineSeparator) type { 32 + return struct { 33 + const Self = @This(); 34 + 35 + interface: std.Io.Writer, 36 + child: *std.Io.Writer, 37 + 38 + pub fn init(child: *std.Io.Writer, buffer: []u8) Self { 39 + std.debug.assert(buffer.len >= 2); 40 + return .{ 41 + .interface = .{ 42 + .buffer = buffer, 43 + .vtable = &.{ 44 + .drain = VTable.drain, 45 + .flush = VTable.flush, 46 + }, 47 + }, 48 + .child = child, 49 + }; 50 + } 51 + 52 + const VTable = struct { 53 + fn drain(interface: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { 54 + const self: *Decoder = @fieldParentPtr("interface", interface); 55 + 56 + var count: usize = 0; 57 + for (data[0 .. data.len - 1]) |chunk| { 58 + try self.write(chunk, &count); 59 + } 60 + 61 + for (0..splat) |_| { 62 + try self.write(data[data.len - 1], &count); 63 + } 64 + 65 + return count; 66 + } 67 + 68 + fn flush(interface: *std.Io.Writer) std.Io.Writer.Error!void { 69 + const self: *Decoder = @fieldParentPtr("interface", interface); 70 + try self.flush(); 71 + } 72 + }; 73 + 74 + fn write(self: *Self, chunk: []const u8, count: *usize) std.Io.Writer.Error!void { 75 + var index: usize = 0; 76 + 77 + while (index < chunk.len) { 78 + const size = @min( 79 + self.interface.buffer.len - self.interface.end, 80 + chunk.len - index, 81 + ); 82 + 83 + @memcpy( 84 + self.interface.buffer[self.interface.end .. self.interface.end + size], 85 + chunk[index .. index + size], 86 + ); 87 + 88 + self.interface.end += size; 89 + index += size; 90 + count.* += size; 91 + 92 + if (self.interface.buffer.len == self.interface.end) { 93 + try self._write(false); 94 + } 95 + } 96 + } 97 + 98 + fn flush(self: *Self) std.Io.Writer.Error!void { 99 + try self._write(true); 100 + try self.child.flush(); 101 + } 102 + 103 + fn _write(self: *Self, finish: bool) std.Io.Writer.Error!void { 104 + const replacements = .{ 105 + .{ 106 + [_]u8{ std.ascii.control_code.cr, std.ascii.control_code.nul }, 107 + [_]u8{std.ascii.control_code.cr}, 108 + }, 109 + .{ 110 + [_]u8{ std.ascii.control_code.cr, std.ascii.control_code.lf }, 111 + switch (linesep) { 112 + .lf => [_]u8{std.ascii.control_code.lf}, 113 + .cr => [_]u8{std.ascii.control_code.cr}, 114 + .crlf => [_]u8{ std.ascii.control_code.cr, std.ascii.control_code.lf }, 115 + }, 116 + }, 117 + }; 118 + 119 + // If there's nothing in the buffer, do nothing. 120 + if (self.interface.end == 0) return; 121 + 122 + var index: usize = 0; 123 + 124 + outer: while (true) { 125 + inline for (replacements) |item| replacement: { 126 + const needle, const replacement = item; 127 + // If there's not enough data left in the buffer, skip this needle. 128 + if (index + needle.len > self.interface.end) break :replacement; 129 + const haystack = self.interface.buffer[index .. index + needle.len]; 130 + if (std.mem.eql(u8, haystack, &needle)) { 131 + index += needle.len; 132 + try self.child.writeAll(&replacement); 133 + continue :outer; 134 + } 135 + } 136 + if (index >= self.interface.end) { 137 + self.interface.end = 0; 138 + return; 139 + } 140 + switch (self.interface.buffer[index]) { 141 + std.ascii.control_code.cr => { 142 + // The bare CR occurred before the end of the buffer. 143 + // The subtraction is safe because we guaranteed that 144 + // `self.interface.end` is not zero above. 145 + if (index < self.interface.end - 1) { 146 + return error.WriteFailed; 147 + } 148 + // The bare CR occurred at end of the buffer and there 149 + // will be no more data. The subtraction is safe because 150 + // we guaranteed that `self.interface.end` is not zero 151 + // above. 152 + if (index == self.interface.end - 1 and finish) { 153 + return error.WriteFailed; 154 + } 155 + // The bare CR occurred at the end of the buffer and 156 + // there will be more data. 157 + self.interface.buffer[0] = std.ascii.control_code.cr; 158 + self.interface.end = 1; 159 + return; 160 + }, 161 + else => |byte| { 162 + index += 1; 163 + try self.child.writeByte(byte); 164 + }, 165 + } 166 + } 167 + } 168 + }; 169 + } 170 + 171 + test "decoder netascii cr 4" { 172 + const alloc = std.testing.allocator; 173 + var buf: std.Io.Writer.Allocating = .init(alloc); 174 + defer buf.deinit(); 175 + var decode_buf: [2]u8 = undefined; 176 + var decoder: _Decoder(.cr) = .init(&buf.writer, &decode_buf); 177 + try decoder.interface.writeAll("foo\x0d\x00\x0abar"); 178 + try decoder.interface.flush(); 179 + try testing.expectEqualStrings("foo\x0d\x0abar", buf.written()); 180 + } 181 + test "decoder netascii cr 5" { 182 + const alloc = std.testing.allocator; 183 + var buf: std.Io.Writer.Allocating = .init(alloc); 184 + defer buf.deinit(); 185 + var decode_buf: [2]u8 = undefined; 186 + var decoder: _Decoder(.cr) = .init(&buf.writer, &decode_buf); 187 + try testing.expectError(error.WriteFailed, decoder.interface.writeAll("foo\x0d\x0a\x0dbar")); 188 + } 189 + 190 + // test "decoder netascii cr 5" { 191 + // const alloc = std.testing.allocator; 192 + // var buf: std.Io.Writer.Allocating = .init(alloc); 193 + // defer buf.deinit(); 194 + // const output = &buf.writer; 195 + // var input: std.Io.Reader = .fixed("foo\x0d\x0a\x0dbar"); 196 + // try testing.expectError(error.UnescapedCR, _decode(&input, output, null, .cr)); 197 + // } 23 198 pub fn decode(input: *std.Io.Reader, output: *std.Io.Writer) DecodeErrors!usize { 24 199 switch (builtin.os.tag) { 25 200 .windows => return _decode(input, output, null, .crlf) catch |err| {
+2 -2
src/lib/packet.zig
··· 20 20 21 21 pub const Packet = union(Opcode) { 22 22 pub const BUFFER_LENGTH = std.math.maxInt(u16); 23 - pub const Buf = [BUFFER_LENGTH]u8; 23 + pub const Buffer = [BUFFER_LENGTH]u8; 24 24 pub const Options = @import("Options.zig"); 25 25 pub const Mode = @import("mode.zig").Mode; 26 26 pub const Opcode = @import("opcode.zig").Opcode; ··· 228 228 } 229 229 230 230 test "datagram decode 1" { 231 - var buf: Packet.Buf = undefined; 231 + var buf: Packet.Buffer = undefined; 232 232 const input = &[_]u8{ 0x0, 0x1, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x74, 0x78, 0x74, 0x0, 0x6e, 0x65, 0x74, 0x61, 0x73, 0x63, 0x69, 0x69, 0x0 }; 233 233 const packet: Packet = try .decode(input, &buf, .default); 234 234 try testing.expect(packet == .rrq);
+59 -27
src/main.zig
··· 1 1 const std = @import("std"); 2 2 const jtftp = @import("jtftp"); 3 3 4 + const log = std.log.scoped(.main); 5 + 4 6 pub fn main(init: std.process.Init) !void { 5 7 const io = init.io; 6 8 const alloc = init.gpa; 7 9 8 10 const addr = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:6969"); 9 - const socket = try addr.bind(io, .{ .mode = .dgram, .protocol = .udp }); 10 11 11 - var group: std.Io.Group = .init; 12 - defer group.cancel(io); 12 + var app: App = .{}; 13 13 14 - while (true) { 15 - var messages: [5]std.Io.net.IncomingMessage = @splat(.init); 16 - var buffer: [5 * jtftp.Packet.BUFFER_LENGTH]u8 = undefined; 17 - const err_, const count = socket.receiveManyTimeout(io, &messages, &buffer, .{}, .{ 18 - .duration = .{ 19 - .clock = .real, 20 - .raw = .fromMilliseconds(250), 21 - }, 22 - }); 14 + var server: jtftp.Server(App) = .init(&app, addr); 15 + 16 + try server.run(io, alloc); 17 + } 18 + 19 + pub const App = struct { 20 + pub const Write = struct { 21 + interface: std.Io.Writer.Allocating, 22 + filename: []const u8, 23 23 24 - for (messages[0..count]) |message| { 25 - const data = try alloc.dupe(u8, message.data); 26 - try group.concurrent(io, jtftp.handleIncomingMessage, .{ 27 - io, 28 - alloc, 29 - addr, 30 - message.from, 31 - data, 32 - }); 24 + pub fn init(alloc: std.mem.Allocator, filename: []const u8) !Write { 25 + return .{ 26 + .interface = .init(alloc), 27 + .filename = try alloc.dupe(u8, filename), 28 + }; 33 29 } 34 30 35 - if (err_) |err| { 36 - switch (err) { 37 - error.Timeout => {}, 38 - else => return err, 39 - } 31 + pub fn writer(self: *Write, _: std.Io, _: std.mem.Allocator) !*std.Io.Writer { 32 + return &self.interface.writer; 33 + } 34 + 35 + pub fn finish(self: *Write, _: std.Io) !void { 36 + log.warn("{s} finished, {d} bytes written", .{ self.filename, self.interface.written().len }); 37 + } 38 + 39 + pub fn deinit(self: *Write, _: std.Io, alloc: std.mem.Allocator) void { 40 + alloc.free(self.filename); 41 + self.interface.deinit(); 40 42 } 43 + }; 44 + 45 + pub fn writeTo(_: *const App, _: std.Io, alloc: std.mem.Allocator, filename: []const u8) !Write { 46 + if (std.mem.startsWith(u8, filename, "bad")) return error.PermissionDenied; 47 + return try .init(alloc, filename); 41 48 } 42 - } 49 + 50 + pub const Read = struct { 51 + interface: std.Io.Reader, 52 + filename: []const u8, 53 + 54 + pub fn init(alloc: std.mem.Allocator, filename: []const u8) !Read { 55 + return .{ 56 + .interface = .fixed(@embedFile("doc/rfc1350.txt")), 57 + .filename = try alloc.dupe(u8, filename), 58 + }; 59 + } 60 + 61 + pub fn reader(self: *Read, _: std.Io, _: std.mem.Allocator) !*std.Io.Reader { 62 + return &self.interface; 63 + } 64 + 65 + pub fn deinit(self: *Read, _: std.Io, alloc: std.mem.Allocator) void { 66 + alloc.free(self.filename); 67 + } 68 + }; 69 + 70 + pub fn readFrom(_: *const App, _: std.Io, alloc: std.mem.Allocator, filename: []const u8) !Read { 71 + if (std.mem.startsWith(u8, filename, "bad")) return error.PermissionDenied; 72 + return try .init(alloc, filename); 73 + } 74 + };
+3 -163
src/root.zig
··· 1 - //! By convention, root.zig is the root source file when making a library. 2 1 const std = @import("std"); 3 2 4 - const log = std.log.scoped(.jtftp); 5 - 6 - pub const Packet = @import("lib/packet.zig").Packet; 7 3 const netascii = @import("lib/netascii.zig"); 8 - 9 - pub fn handleIncomingMessage( 10 - io: std.Io, 11 - alloc: std.mem.Allocator, 12 - to: std.Io.net.IpAddress, 13 - remote: std.Io.net.IpAddress, 14 - data: []const u8, 15 - ) error{Canceled}!void { 16 - _handleIncomingMessage(io, alloc, to, remote, data) catch |err| switch (err) { 17 - // error.Canceled => return error.Canceled, 18 - else => |e| { 19 - log.warn("handle incoming message failed with {t}", .{e}); 20 - }, 21 - }; 22 - } 23 - 24 - fn _handleIncomingMessage( 25 - io: std.Io, 26 - alloc: std.mem.Allocator, 27 - to: std.Io.net.IpAddress, 28 - remote: std.Io.net.IpAddress, 29 - initial_data: []const u8, 30 - ) !void { 31 - defer alloc.free(initial_data); 32 - var local = to; 33 - local.setPort(0); 34 - const socket = try local.bind(io, .{ .mode = .dgram, .protocol = .udp }); 35 - 36 - var input_buf: [std.math.maxInt(u16)]u8 = undefined; 37 - const initial: Packet = try .decode(initial_data, &input_buf, .{}); 38 - 39 - log.info("initial {t} packet from {f}", .{ initial, remote }); 40 - 41 - switch (initial) { 42 - .wrq => |*wrq| { 43 - log.info("wrq: {s}", .{wrq.filename}); 44 - 45 - try handleWrite(io, alloc, socket, &remote, wrq); 46 - }, 47 - .rrq => |*rrq| { 48 - log.info("rrq: {s}", .{rrq.filename}); 49 - const packet: Packet = .{ 50 - .err = .{ 51 - .code = .access_violation, 52 - }, 53 - }; 54 - var buf: [std.math.maxInt(u16)]u8 = undefined; 55 - const d = try packet.encode(&buf); 56 - try socket.send(io, &remote, d); 57 - return; 58 - }, 59 - else => { 60 - const packet: Packet = .{ 61 - .err = .{ 62 - .code = .illegal_operation, 63 - }, 64 - }; 65 - var output_buf: [std.math.maxInt(u16)]u8 = undefined; 66 - const d = try packet.encode(&output_buf); 67 - try socket.send(io, &remote, d); 68 - return; 69 - }, 70 - } 71 - } 72 - 73 - fn handleWrite( 74 - io: std.Io, 75 - alloc: std.mem.Allocator, 76 - socket: std.Io.net.Socket, 77 - remote: *const std.Io.net.IpAddress, 78 - wrq: *const Packet.Wrq, 79 - ) !void { 80 - var acc: std.Io.Writer.Allocating = .init(alloc); 81 - var writer = &acc.writer; 82 - 83 - var block: u16 = 0; 84 - 85 - const total_timeout = (@as(i96, @min(1, wrq.options.timeout orelse 30))) * std.time.ns_per_s; 86 - const packet_timeout: std.Io.Timeout = .{ 87 - .duration = .{ 88 - .clock = .real, 89 - .raw = .fromNanoseconds(@divFloor(total_timeout, 5)), 90 - }, 91 - }; 92 - var last_received: std.Io.Timestamp = .now(io, .real); 93 - 94 - while (true) { 95 - { 96 - var output_buf: Packet.Buf = undefined; 97 - const packet: Packet = packet: { 98 - if (block == 0) { 99 - if (wrq.options.empty()) break :packet .{ 100 - .ack = .{ 101 - .block = 0, 102 - }, 103 - }; 104 - break :packet .{ 105 - .oack = .{ 106 - .options = wrq.options, 107 - }, 108 - }; 109 - } 110 - break :packet .{ 111 - .ack = .{ 112 - .block = block, 113 - }, 114 - }; 115 - }; 4 + pub const NetasciiDecoder = netascii.Decoder; 116 5 117 - try socket.send(io, remote, try packet.encode(&output_buf)); 118 - } 119 - 120 - var input_message_buf: Packet.Buf = undefined; 121 - const incoming_message = socket.receiveTimeout(io, &input_message_buf, packet_timeout) catch |err| switch (err) { 122 - error.Timeout => { 123 - const delta = last_received.untilNow(io, .real); 124 - if (delta.toNanoseconds() > total_timeout) return error.Timeout; 125 - continue; 126 - }, 127 - else => |e| return e, 128 - }; 129 - 130 - if (!remote.eql(&incoming_message.from)) return error.PacketFromInvalidSource; 131 - 132 - var input_packet_buf: Packet.Buf = undefined; 133 - const input_packet: Packet = try .decode(incoming_message.data, &input_packet_buf, .{ 134 - .mode = wrq.mode, 135 - .blocksize = wrq.options.blocksize, 136 - }); 137 - 138 - switch (input_packet) { 139 - .data => |data| { 140 - last_received = .now(io, .real); 141 - block = data.block; 142 - log.warn("received {d} bytes, last: {}", .{ data.data.len, data.last }); 143 - try writer.writeAll(data.data); 144 - if (data.last) { 145 - log.info("last packet received", .{}); 146 - const packet: Packet = .{ 147 - .ack = .{ 148 - .block = block, 149 - }, 150 - }; 151 - var output_buf: [std.math.maxInt(u16)]u8 = undefined; 152 - try socket.send(io, remote, try packet.encode(&output_buf)); 153 - return; 154 - } 155 - }, 156 - .rrq, 157 - .wrq, 158 - .ack, 159 - .err, 160 - .oack, 161 - => { 162 - log.err("illegal packet type", .{}); 163 - return error.IllegalPacketType; 164 - }, 165 - } 166 - } 167 - } 6 + const server = @import("lib/server.zig"); 7 + pub const Server = server.Server; 168 8 169 9 test { 170 10 _ = std.testing.refAllDecls(@This());