From 76c6197644a97ad4f6757b91921fabe123055991 Mon Sep 17 00:00:00 2001 From: ArchiMoebius Date: Fri, 20 Feb 2026 19:01:07 -0500 Subject: [PATCH 1/7] feat:acl with roles: admin, reader, writer --- README.md | 4 +- acl.zig | 66 ++++++++++++++++++++++++++++ build.zig | 28 +++++++++++- main.zig | 126 +++++++++++++++++++++++++++++++++++++++++++----------- 4 files changed, 195 insertions(+), 29 deletions(-) create mode 100755 acl.zig mode change 100644 => 100755 build.zig mode change 100644 => 100755 main.zig diff --git a/README.md b/README.md index c845620..e618909 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ If you need these, use MinIO or AWS. ## Quick Start ```bash -zig build -Doptimize=ReleaseFast +zig build -Doptimize=ReleaseFast -Dacl-list="admin:minioadmin:minioadmin" ./zig-out/bin/zs3 ``` @@ -146,8 +146,6 @@ Edit `main.zig`: const ctx = S3Context{ .allocator = allocator, .data_dir = "data", - .access_key = "minioadmin", - .secret_key = "minioadmin", }; const address = net.Address.parseIp4("0.0.0.0", 9000) diff --git a/acl.zig b/acl.zig new file mode 100755 index 0000000..44cd33d --- /dev/null +++ b/acl.zig @@ -0,0 +1,66 @@ +const std = @import("std"); + +pub const Role = enum { + Admin, + Reader, + Writer, +}; + +pub const Credential = struct { + access_key: []const u8, + secret_key: []const u8, + role: Role, +}; + +// Function to convert string to Role enum +pub fn stringToRole(role_str: []const u8) !Role { + if (std.mem.eql(u8, role_str, "admin")) { + return Role.Admin; + } else if (std.mem.eql(u8, role_str, "reader")) { + return Role.Reader; + } else if (std.mem.eql(u8, role_str, "writer")) { + return Role.Writer; + } else { + return error.BadCredentialRole; + } +} + +// Function to parse a single credential string: "role:access_key:secret_key" +pub fn parseCredential(cred_str: []const u8) !Credential { + if (std.mem.count(u8, cred_str, ":") != 2) { + return error.BadCredentialFormat; + } + + var itr = std.mem.tokenizeSequence(u8, cred_str, ":"); + + return Credential{ + .role = try stringToRole(itr.next().?), + .access_key = itr.next().?, + .secret_key = itr.next().?, + }; +} + +// Function to parse a list of credential strings +pub fn parseCredentials(allocator: std.mem.Allocator, input: []const u8) ![]Credential { + const count = std.mem.count(u8, input, ":"); + + if (count < 2) { + return error.BadCredentialInputFormat; + } + + var credentials = try std.ArrayList(Credential).initCapacity(allocator, count / 2); + + var itr = std.mem.tokenizeSequence(u8, input, ","); + + if (itr.peek() == null) { + const pc = try parseCredential(input); + try credentials.append(allocator, pc); + } else { + while (itr.next()) |record| { + const pc = try parseCredential(record); + try credentials.append(allocator, pc); + } + } + + return credentials.toOwnedSlice(allocator); +} diff --git a/build.zig b/build.zig old mode 100644 new mode 100755 index 2a1f1b1..2ed82a8 --- a/build.zig +++ b/build.zig @@ -1,10 +1,34 @@ const std = @import("std"); +const acl = @import("acl.zig"); -pub fn build(b: *std.Build) void { +pub fn build(b: *std.Build) !void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const strip = b.option(bool, "strip", "Strip debug symbols") orelse (optimize != .Debug); + const acl_list = b.option([]const u8, "acl-list", "Admin credentials") orelse "admin:minioadmin:minioadmin"; + const all_credentials = try acl.parseCredentials(b.allocator, acl_list); + + var credential_list = try std.ArrayList([]const u8).initCapacity(b.allocator, 20); + // No defer deinit here if we use toOwnedSlice later + + for (all_credentials) |cred| { + const role_name = switch (cred.role) { + .Admin => "admin", + .Reader => "reader", + .Writer => "writer", + }; + + const entry_str = try std.fmt.allocPrint(b.allocator, "{s}:{s}:{s}", .{ role_name, cred.access_key, cred.secret_key }); + try credential_list.append(b.allocator, entry_str); + } + + const cs = try credential_list.toOwnedSlice(b.allocator); + const joined_acl_list = try std.mem.join(b.allocator, ",", cs); + + const options = b.addOptions(); + options.addOption([]const u8, "acl_list", joined_acl_list); + const exe = b.addExecutable(.{ .name = "zs3", .root_module = b.createModule(.{ @@ -15,6 +39,8 @@ pub fn build(b: *std.Build) void { }), }); + exe.root_module.addOptions("build_options", options); + b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); diff --git a/main.zig b/main.zig old mode 100644 new mode 100755 index f2a0571..eea32b6 --- a/main.zig +++ b/main.zig @@ -3,6 +3,17 @@ const net = std.net; const posix = std.posix; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); +const build_options = @import("build_options"); +const acl = @import("acl.zig"); + +const HTTP_METHOD = enum { + GET, + POST, + PUT, + DELETE, + HEAD, + OPTIONS, +}; const MAX_HEADER_SIZE = 8 * 1024; const MAX_BODY_SIZE = 5 * 1024 * 1024 * 1024; @@ -969,6 +980,9 @@ pub fn main() !void { var bootstrap_count: usize = 0; var port: u16 = 9000; + const access_control_list = try acl.parseCredentials(allocator, build_options.acl_list); + defer allocator.free(access_control_list); + var args = std.process.args(); _ = args.skip(); // Skip program name while (args.next()) |arg| { @@ -1036,6 +1050,14 @@ pub fn main() !void { std.fs.cwd().makePath("data/.index") catch {}; } + // Use StringHashMap(V) where V is your value type + var access_control_map = std.StringHashMap(acl.Credential).init(allocator); + + for (access_control_list) |credential| { + // The key is the slice contents, not the pointer address + try access_control_map.put(credential.access_key, credential); + } + const address = net.Address.parseIp4("0.0.0.0", port) catch unreachable; var server = try address.listen(.{ .reuse_address = true }); defer server.deinit(); @@ -1049,8 +1071,7 @@ pub fn main() !void { var ctx = S3Context{ .allocator = allocator, .data_dir = "data", - .access_key = "minioadmin", - .secret_key = "minioadmin", + .access_control_map = access_control_map, .distributed = if (dist_ctx != null) &dist_ctx.? else null, }; @@ -1192,8 +1213,7 @@ fn eventLoopKqueue(allocator: Allocator, ctx: *const S3Context, server: *net.Ser const S3Context = struct { allocator: Allocator, data_dir: []const u8, - access_key: []const u8, - secret_key: []const u8, + access_control_map: std.StringHashMap(acl.Credential), distributed: ?*DistributedContext = null, // Optional distributed mode fn bucketPath(self: *const S3Context, allocator: Allocator, bucket: []const u8) ![]const u8 { @@ -1203,6 +1223,10 @@ const S3Context = struct { fn objectPath(self: *const S3Context, allocator: Allocator, bucket: []const u8, key: []const u8) ![]const u8 { return std.fs.path.join(allocator, &[_][]const u8{ self.data_dir, bucket, key }); } + + pub fn deinit(self: *S3Context) void { + self.access_control_map.deinit(); + } }; const Request = struct { @@ -1553,8 +1577,10 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo return; } + const acl_ctx = SigV4.verify(ctx, req, allocator); + // S3 API requires authentication - if (!SigV4.verify(ctx, req, allocator)) { + if (!acl_ctx.allow) { sendError(res, 403, "AccessDenied", "Invalid credentials"); return; } @@ -1575,20 +1601,20 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo // In distributed mode, use CAS for object storage if (ctx.distributed != null) { if (key.len > 0) { - if (std.mem.eql(u8, req.method, "PUT") and !hasQuery(req.query, "uploadId")) { + if (std.mem.eql(u8, req.method, "PUT") and !hasQuery(req.query, "uploadId") and acl_ctx.allowed("PUT")) { try handleDistributedPut(ctx, allocator, req, res, bucket, key); return; - } else if (std.mem.eql(u8, req.method, "GET")) { + } else if (std.mem.eql(u8, req.method, "GET") and acl_ctx.allowed("GET")) { try handleDistributedGet(ctx, allocator, req, res, bucket, key); return; - } else if (std.mem.eql(u8, req.method, "DELETE") and !hasQuery(req.query, "uploadId")) { + } else if (std.mem.eql(u8, req.method, "DELETE") and !hasQuery(req.query, "uploadId") and acl_ctx.allowed("DELETE")) { try handleDistributedDelete(ctx, allocator, res, bucket, key); return; - } else if (std.mem.eql(u8, req.method, "HEAD")) { + } else if (std.mem.eql(u8, req.method, "HEAD") and acl_ctx.allowed("HEAD")) { try handleDistributedHead(ctx, allocator, res, bucket, key); return; } - } else if (bucket.len > 0 and std.mem.eql(u8, req.method, "GET")) { + } else if (bucket.len > 0 and std.mem.eql(u8, req.method, "GET") and acl_ctx.allowed("GET")) { // Distributed LIST objects try handleDistributedList(ctx, allocator, req, res, bucket); return; @@ -1596,7 +1622,7 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo } // Standard S3 routing (standalone mode or bucket operations) - if (std.mem.eql(u8, req.method, "GET")) { + if (std.mem.eql(u8, req.method, "GET") and acl_ctx.allowed("GET")) { if (bucket.len == 0) { try handleListBuckets(ctx, allocator, res); } else if (key.len == 0) { @@ -1604,7 +1630,7 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo } else { try handleGetObject(ctx, allocator, req, res, bucket, key); } - } else if (std.mem.eql(u8, req.method, "PUT")) { + } else if (std.mem.eql(u8, req.method, "PUT") and acl_ctx.allowed("PUT")) { if (key.len == 0) { try handleCreateBucket(ctx, allocator, res, bucket); } else if (hasQuery(req.query, "uploadId")) { @@ -1612,7 +1638,7 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo } else { try handlePutObject(ctx, allocator, req, res, bucket, key); } - } else if (std.mem.eql(u8, req.method, "DELETE")) { + } else if (std.mem.eql(u8, req.method, "DELETE") and acl_ctx.allowed("DELETE")) { if (key.len == 0) { try handleDeleteBucket(ctx, allocator, res, bucket); } else if (hasQuery(req.query, "uploadId")) { @@ -1620,13 +1646,13 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo } else { try handleDeleteObject(ctx, allocator, res, bucket, key); } - } else if (std.mem.eql(u8, req.method, "HEAD")) { + } else if (std.mem.eql(u8, req.method, "HEAD") and acl_ctx.allowed("HEAD")) { if (key.len == 0) { try handleHeadBucket(ctx, allocator, res, bucket); } else { try handleHeadObject(ctx, allocator, res, bucket, key); } - } else if (std.mem.eql(u8, req.method, "POST")) { + } else if (std.mem.eql(u8, req.method, "POST") and acl_ctx.allowed("POST")) { if (hasQuery(req.query, "delete")) { try handleDeleteObjects(ctx, allocator, req, res, bucket); } else if (hasQuery(req.query, "uploads")) { @@ -1654,21 +1680,68 @@ pub const SigV4 = struct { signature: []const u8, }; - fn verify(ctx: *const S3Context, req: *const Request, allocator: Allocator) bool { - const auth_header = req.header("authorization") orelse return false; - const x_amz_date = req.header("x-amz-date") orelse return false; + const ACLCtx = struct { + allow: bool, + role: acl.Role, + + // Change return type to !bool to allow returning errors + fn allowed(self: *const ACLCtx, method: []const u8) bool { + switch (self.role) { + acl.Role.Admin => { + return true; + }, + acl.Role.Reader => { + const http_method = std.meta.stringToEnum(HTTP_METHOD, method) orelse { + return false; + }; + + switch (http_method) { + .GET => return true, + .HEAD => return true, + .OPTIONS => return true, + else => return false, + } + }, + acl.Role.Writer => { + const http_method = std.meta.stringToEnum(HTTP_METHOD, method) orelse { + return false; + }; + + switch (http_method) { + .PUT => return true, + .POST => return true, + .HEAD => return true, + .OPTIONS => return true, + else => return false, + } + }, + } + } + }; + + fn verify(ctx: *const S3Context, req: *const Request, allocator: Allocator) ACLCtx { + var acl_ctx = ACLCtx{ + .allow = false, + .role = undefined, + }; + + const auth_header = req.header("authorization") orelse return acl_ctx; + const x_amz_date = req.header("x-amz-date") orelse return acl_ctx; const x_amz_content_sha256 = req.header("x-amz-content-sha256") orelse "UNSIGNED-PAYLOAD"; - const parsed = parseAuthHeader(auth_header) orelse return false; + const parsed = parseAuthHeader(auth_header) orelse return acl_ctx; + const credential = ctx.access_control_map.get(parsed.access_key); - if (!std.mem.eql(u8, parsed.access_key, ctx.access_key)) return false; + if (credential == null) { + return acl_ctx; + } const canonical = buildCanonicalRequest( allocator, req, parsed.signed_headers, x_amz_content_sha256, - ) catch return false; + ) catch return acl_ctx; defer allocator.free(canonical); const string_to_sign = buildStringToSign( @@ -1678,20 +1751,23 @@ pub const SigV4 = struct { parsed.region, parsed.service, canonical, - ) catch return false; + ) catch return acl_ctx; defer allocator.free(string_to_sign); const calculated_sig = calculateSignature( allocator, - ctx.secret_key, + credential.?.secret_key, parsed.date, parsed.region, parsed.service, string_to_sign, - ) catch return false; + ) catch return acl_ctx; defer allocator.free(calculated_sig); - return std.mem.eql(u8, calculated_sig, parsed.signature); + acl_ctx.allow = std.mem.eql(u8, calculated_sig, parsed.signature); + acl_ctx.role = credential.?.role; + + return acl_ctx; } pub fn parseAuthHeader(header: []const u8) ?ParsedAuth { From 46b70661d5a3d92349ac08bad58f46139c9fa30e Mon Sep 17 00:00:00 2001 From: ArchiMoebius Date: Fri, 20 Feb 2026 19:42:46 -0500 Subject: [PATCH 2/7] PR feedback/fixes --- acl.zig | 44 +++++++++++++++++++-------------- build.zig | 21 ++-------------- main.zig | 74 ++++++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 90 insertions(+), 49 deletions(-) diff --git a/acl.zig b/acl.zig index 44cd33d..444c893 100755 --- a/acl.zig +++ b/acl.zig @@ -4,6 +4,7 @@ pub const Role = enum { Admin, Reader, Writer, + Unknown, }; pub const Credential = struct { @@ -27,40 +28,45 @@ pub fn stringToRole(role_str: []const u8) !Role { // Function to parse a single credential string: "role:access_key:secret_key" pub fn parseCredential(cred_str: []const u8) !Credential { - if (std.mem.count(u8, cred_str, ":") != 2) { - return error.BadCredentialFormat; - } + var itr = std.mem.splitScalar(u8, cred_str, ':'); + + const role_str = itr.next() orelse return error.BadCredentialFormat; + const access_key = itr.next() orelse return error.BadCredentialFormat; + const secret_key = itr.next() orelse return error.BadCredentialFormat; - var itr = std.mem.tokenizeSequence(u8, cred_str, ":"); + // Reject extra fields + if (itr.next() != null) return error.BadCredentialFormat; + + // Reject empty fields + if (role_str.len == 0 or access_key.len == 0 or secret_key.len == 0) + return error.BadCredentialFormat; return Credential{ - .role = try stringToRole(itr.next().?), - .access_key = itr.next().?, - .secret_key = itr.next().?, + .role = try stringToRole(role_str), + .access_key = access_key, + .secret_key = secret_key, }; } // Function to parse a list of credential strings pub fn parseCredentials(allocator: std.mem.Allocator, input: []const u8) ![]Credential { - const count = std.mem.count(u8, input, ":"); - - if (count < 2) { + if (input.len == 0) { return error.BadCredentialInputFormat; } - var credentials = try std.ArrayList(Credential).initCapacity(allocator, count / 2); + var credentials = std.ArrayListUnmanaged(Credential){}; + errdefer credentials.deinit(allocator); + + var itr = std.mem.splitScalar(u8, input, ','); + while (itr.next()) |record| { + if (record.len == 0) continue; - var itr = std.mem.tokenizeSequence(u8, input, ","); + const pc = try parseCredential(record); - if (itr.peek() == null) { - const pc = try parseCredential(input); try credentials.append(allocator, pc); - } else { - while (itr.next()) |record| { - const pc = try parseCredential(record); - try credentials.append(allocator, pc); - } } + if (credentials.items.len == 0) return error.BadCredentialInputFormat; + return credentials.toOwnedSlice(allocator); } diff --git a/build.zig b/build.zig index 2ed82a8..48125f5 100755 --- a/build.zig +++ b/build.zig @@ -7,27 +7,10 @@ pub fn build(b: *std.Build) !void { const strip = b.option(bool, "strip", "Strip debug symbols") orelse (optimize != .Debug); const acl_list = b.option([]const u8, "acl-list", "Admin credentials") orelse "admin:minioadmin:minioadmin"; - const all_credentials = try acl.parseCredentials(b.allocator, acl_list); - - var credential_list = try std.ArrayList([]const u8).initCapacity(b.allocator, 20); - // No defer deinit here if we use toOwnedSlice later - - for (all_credentials) |cred| { - const role_name = switch (cred.role) { - .Admin => "admin", - .Reader => "reader", - .Writer => "writer", - }; - - const entry_str = try std.fmt.allocPrint(b.allocator, "{s}:{s}:{s}", .{ role_name, cred.access_key, cred.secret_key }); - try credential_list.append(b.allocator, entry_str); - } - - const cs = try credential_list.toOwnedSlice(b.allocator); - const joined_acl_list = try std.mem.join(b.allocator, ",", cs); + _ = try acl.parseCredentials(b.allocator, acl_list); const options = b.addOptions(); - options.addOption([]const u8, "acl_list", joined_acl_list); + options.addOption([]const u8, "acl_list", acl_list); const exe = b.addExecutable(.{ .name = "zs3", diff --git a/main.zig b/main.zig index eea32b6..965737a 100755 --- a/main.zig +++ b/main.zig @@ -1074,6 +1074,7 @@ pub fn main() !void { .access_control_map = access_control_map, .distributed = if (dist_ctx != null) &dist_ctx.? else null, }; + defer ctx.deinit(); if (builtin.os.tag == .linux) { try eventLoopEpoll(allocator, &ctx, &server); @@ -1601,20 +1602,44 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo // In distributed mode, use CAS for object storage if (ctx.distributed != null) { if (key.len > 0) { - if (std.mem.eql(u8, req.method, "PUT") and !hasQuery(req.query, "uploadId") and acl_ctx.allowed("PUT")) { + if (std.mem.eql(u8, req.method, "PUT") and !hasQuery(req.query, "uploadId")) { + if (!acl_ctx.allowed("PUT")) { + sendError(res, 403, "AccessDenied", "Insufficient permissions"); + return; + } + try handleDistributedPut(ctx, allocator, req, res, bucket, key); return; - } else if (std.mem.eql(u8, req.method, "GET") and acl_ctx.allowed("GET")) { + } else if (std.mem.eql(u8, req.method, "GET")) { + if (!acl_ctx.allowed("GET")) { + sendError(res, 403, "AccessDenied", "Insufficient permissions"); + return; + } + try handleDistributedGet(ctx, allocator, req, res, bucket, key); return; - } else if (std.mem.eql(u8, req.method, "DELETE") and !hasQuery(req.query, "uploadId") and acl_ctx.allowed("DELETE")) { + } else if (std.mem.eql(u8, req.method, "DELETE") and !hasQuery(req.query, "uploadId")) { + if (!acl_ctx.allowed("DELETE")) { + sendError(res, 403, "AccessDenied", "Insufficient permissions"); + return; + } + try handleDistributedDelete(ctx, allocator, res, bucket, key); return; - } else if (std.mem.eql(u8, req.method, "HEAD") and acl_ctx.allowed("HEAD")) { + } else if (std.mem.eql(u8, req.method, "HEAD")) { + if (!acl_ctx.allowed("HEAD")) { + sendError(res, 403, "AccessDenied", "Insufficient permissions"); + return; + } + try handleDistributedHead(ctx, allocator, res, bucket, key); return; } - } else if (bucket.len > 0 and std.mem.eql(u8, req.method, "GET") and acl_ctx.allowed("GET")) { + } else if (bucket.len > 0 and std.mem.eql(u8, req.method, "GET")) { + if (!acl_ctx.allowed("GET")) { + sendError(res, 403, "AccessDenied", "Insufficient permissions"); + return; + } // Distributed LIST objects try handleDistributedList(ctx, allocator, req, res, bucket); return; @@ -1622,7 +1647,12 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo } // Standard S3 routing (standalone mode or bucket operations) - if (std.mem.eql(u8, req.method, "GET") and acl_ctx.allowed("GET")) { + if (std.mem.eql(u8, req.method, "GET")) { + if (!acl_ctx.allowed("GET")) { + sendError(res, 403, "AccessDenied", "Insufficient permissions"); + return; + } + if (bucket.len == 0) { try handleListBuckets(ctx, allocator, res); } else if (key.len == 0) { @@ -1630,7 +1660,12 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo } else { try handleGetObject(ctx, allocator, req, res, bucket, key); } - } else if (std.mem.eql(u8, req.method, "PUT") and acl_ctx.allowed("PUT")) { + } else if (std.mem.eql(u8, req.method, "PUT")) { + if (!acl_ctx.allowed("PUT")) { + sendError(res, 403, "AccessDenied", "Insufficient permissions"); + return; + } + if (key.len == 0) { try handleCreateBucket(ctx, allocator, res, bucket); } else if (hasQuery(req.query, "uploadId")) { @@ -1639,6 +1674,11 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo try handlePutObject(ctx, allocator, req, res, bucket, key); } } else if (std.mem.eql(u8, req.method, "DELETE") and acl_ctx.allowed("DELETE")) { + if (!acl_ctx.allowed("DELETE")) { + sendError(res, 403, "AccessDenied", "Insufficient permissions"); + return; + } + if (key.len == 0) { try handleDeleteBucket(ctx, allocator, res, bucket); } else if (hasQuery(req.query, "uploadId")) { @@ -1646,13 +1686,23 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo } else { try handleDeleteObject(ctx, allocator, res, bucket, key); } - } else if (std.mem.eql(u8, req.method, "HEAD") and acl_ctx.allowed("HEAD")) { + } else if (std.mem.eql(u8, req.method, "HEAD")) { + if (!acl_ctx.allowed("HEAD")) { + sendError(res, 403, "AccessDenied", "Insufficient permissions"); + return; + } + if (key.len == 0) { try handleHeadBucket(ctx, allocator, res, bucket); } else { try handleHeadObject(ctx, allocator, res, bucket, key); } - } else if (std.mem.eql(u8, req.method, "POST") and acl_ctx.allowed("POST")) { + } else if (std.mem.eql(u8, req.method, "POST")) { + if (!acl_ctx.allowed("POST")) { + sendError(res, 403, "AccessDenied", "Insufficient permissions"); + return; + } + if (hasQuery(req.query, "delete")) { try handleDeleteObjects(ctx, allocator, req, res, bucket); } else if (hasQuery(req.query, "uploads")) { @@ -1684,7 +1734,6 @@ pub const SigV4 = struct { allow: bool, role: acl.Role, - // Change return type to !bool to allow returning errors fn allowed(self: *const ACLCtx, method: []const u8) bool { switch (self.role) { acl.Role.Admin => { @@ -1715,6 +1764,9 @@ pub const SigV4 = struct { else => return false, } }, + acl.Role.Unknown => { + return false; + }, } } }; @@ -1722,7 +1774,7 @@ pub const SigV4 = struct { fn verify(ctx: *const S3Context, req: *const Request, allocator: Allocator) ACLCtx { var acl_ctx = ACLCtx{ .allow = false, - .role = undefined, + .role = acl.Role.Unknown, }; const auth_header = req.header("authorization") orelse return acl_ctx; From 3de73577e2a787a3ef8150cf2b403fa4b6e2ce29 Mon Sep 17 00:00:00 2001 From: ArchiMoebius Date: Fri, 20 Feb 2026 19:52:09 -0500 Subject: [PATCH 3/7] PR feedback/fixes --- main.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.zig b/main.zig index 965737a..eacacae 100755 --- a/main.zig +++ b/main.zig @@ -1673,7 +1673,7 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo } else { try handlePutObject(ctx, allocator, req, res, bucket, key); } - } else if (std.mem.eql(u8, req.method, "DELETE") and acl_ctx.allowed("DELETE")) { + } else if (std.mem.eql(u8, req.method, "DELETE")) { if (!acl_ctx.allowed("DELETE")) { sendError(res, 403, "AccessDenied", "Insufficient permissions"); return; From 8693bcf4e84fe5c742552d0f6ff7c245274da095 Mon Sep 17 00:00:00 2001 From: ArchiMoebius Date: Fri, 20 Feb 2026 20:35:21 -0500 Subject: [PATCH 4/7] PR feedback/fixes --- main.zig | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/main.zig b/main.zig index eacacae..9d65637 100755 --- a/main.zig +++ b/main.zig @@ -1050,10 +1050,19 @@ pub fn main() !void { std.fs.cwd().makePath("data/.index") catch {}; } - // Use StringHashMap(V) where V is your value type var access_control_map = std.StringHashMap(acl.Credential).init(allocator); for (access_control_list) |credential| { + if (credential.secret_key.len > 252) { + std.log.err("ACL credential '{s}' has a secret key exceeding 252 bytes; skipping", .{credential.secret_key}); + continue; + } + + if (credential.access_key.len > 252) { + std.log.err("ACL credential '{s}' has a access key exceeding 252 bytes; skipping", .{credential.access_key}); + continue; + } + // The key is the slice contents, not the pointer address try access_control_map.put(credential.access_key, credential); } @@ -1816,8 +1825,10 @@ pub const SigV4 = struct { ) catch return acl_ctx; defer allocator.free(calculated_sig); - acl_ctx.allow = std.mem.eql(u8, calculated_sig, parsed.signature); - acl_ctx.role = credential.?.role; + if (std.mem.eql(u8, calculated_sig, parsed.signature)) { + acl_ctx.allow = true; + acl_ctx.role = credential.?.role; + } return acl_ctx; } From 5d1c7f5de3c0eccd89993ec103fc220ed176c3ab Mon Sep 17 00:00:00 2001 From: ArchiMoebius Date: Sat, 21 Feb 2026 09:12:33 -0500 Subject: [PATCH 5/7] PR feedback/fixes --- main.zig | 49 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/main.zig b/main.zig index 9d65637..4b4e7a3 100755 --- a/main.zig +++ b/main.zig @@ -1021,7 +1021,7 @@ pub fn main() !void { } } - std.fs.cwd().makeDir("data") catch |err| switch (err) { + std.fs.cwd().makeDir(build_options.data_dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; @@ -1032,13 +1032,13 @@ pub fn main() !void { if (distributed_enabled) { // Generate or load node ID - const node_id = try getOrCreateNodeId(allocator, "data"); + const node_id = try getOrCreateNodeId(allocator, build_options.data_dir); const config = DistributedConfig{ .enabled = true, .node_id = node_id, .http_port = port, }; - dist_ctx = DistributedContext.init(allocator, "data", config); + dist_ctx = DistributedContext.init(allocator, build_options.data_dir, config); var id_hex: [40]u8 = undefined; bytesToHex(&node_id, &id_hex); @@ -1046,26 +1046,53 @@ pub fn main() !void { std.log.info("Known peers: {d}", .{dist_ctx.?.kademlia.peerCount()}); // Create .cas and .index directories - std.fs.cwd().makePath("data/.cas") catch {}; - std.fs.cwd().makePath("data/.index") catch {}; + const dot_cas_path = try std.fs.path.join(allocator, &[_][]const u8{ build_options.data_dir, ".cas" }); + defer allocator.free(dot_cas_path); + + std.fs.cwd().makeDir(dot_cas_path) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => { + return error.FailedDataDirDotCasCreation; + }, + }; + + const dot_index_path = try std.fs.path.join(allocator, &[_][]const u8{ build_options.data_dir, ".index" }); + defer allocator.free(dot_index_path); + + std.fs.cwd().makeDir(dot_index_path) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => { + return error.FailedDataDirDotCasCreation; + }, + }; } var access_control_map = std.StringHashMap(acl.Credential).init(allocator); + var ctx = S3Context{ + .allocator = allocator, + .data_dir = build_options.data_dir, + .access_control_map = undefined, + .distributed = if (dist_ctx != null) &dist_ctx.? else null, + }; + defer ctx.deinit(); + for (access_control_list) |credential| { if (credential.secret_key.len > 252) { - std.log.err("ACL credential '{s}' has a secret key exceeding 252 bytes; skipping", .{credential.secret_key}); + // Never log the secret_key itself... + std.log.err("ACL credential '{s}' has an secret key exceeding 252 bytes; skipping", .{credential.access_key}); continue; } if (credential.access_key.len > 252) { - std.log.err("ACL credential '{s}' has a access key exceeding 252 bytes; skipping", .{credential.access_key}); + std.log.err("ACL credential '{s}' has an access key exceeding 252 bytes; skipping", .{credential.access_key}); continue; } // The key is the slice contents, not the pointer address try access_control_map.put(credential.access_key, credential); } + ctx.access_control_map = access_control_map; const address = net.Address.parseIp4("0.0.0.0", port) catch unreachable; var server = try address.listen(.{ .reuse_address = true }); @@ -1077,14 +1104,6 @@ pub fn main() !void { std.log.info("S3 server listening on http://0.0.0.0:{d}", .{port}); } - var ctx = S3Context{ - .allocator = allocator, - .data_dir = "data", - .access_control_map = access_control_map, - .distributed = if (dist_ctx != null) &dist_ctx.? else null, - }; - defer ctx.deinit(); - if (builtin.os.tag == .linux) { try eventLoopEpoll(allocator, &ctx, &server); } else if (builtin.os.tag == .macos) { From 478ab155ad9ee8bda4d39f748bda7b40138f686d Mon Sep 17 00:00:00 2001 From: ArchiMoebius Date: Sat, 21 Feb 2026 09:49:35 -0500 Subject: [PATCH 6/7] PR feedback/fixes --- main.zig | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/main.zig b/main.zig index 4b4e7a3..b3603c2 100755 --- a/main.zig +++ b/main.zig @@ -1062,25 +1062,17 @@ pub fn main() !void { std.fs.cwd().makeDir(dot_index_path) catch |err| switch (err) { error.PathAlreadyExists => {}, else => { - return error.FailedDataDirDotCasCreation; + return error.FailedDataDirDotIndexCreation; }, }; } var access_control_map = std.StringHashMap(acl.Credential).init(allocator); - var ctx = S3Context{ - .allocator = allocator, - .data_dir = build_options.data_dir, - .access_control_map = undefined, - .distributed = if (dist_ctx != null) &dist_ctx.? else null, - }; - defer ctx.deinit(); - for (access_control_list) |credential| { if (credential.secret_key.len > 252) { // Never log the secret_key itself... - std.log.err("ACL credential '{s}' has an secret key exceeding 252 bytes; skipping", .{credential.access_key}); + std.log.err("ACL credential '{s}' has a secret key exceeding 252 bytes; skipping", .{credential.access_key}); continue; } @@ -1090,9 +1082,19 @@ pub fn main() !void { } // The key is the slice contents, not the pointer address - try access_control_map.put(credential.access_key, credential); + access_control_map.put(credential.access_key, credential) catch |err| { + access_control_map.deinit(); + return err; + }; } - ctx.access_control_map = access_control_map; + + var ctx = S3Context{ + .allocator = allocator, + .data_dir = build_options.data_dir, + .access_control_map = access_control_map, + .distributed = if (dist_ctx != null) &dist_ctx.? else null, + }; + defer ctx.deinit(); const address = net.Address.parseIp4("0.0.0.0", port) catch unreachable; var server = try address.listen(.{ .reuse_address = true }); From fffcd0997be8be02a0e686bbbfc1c39ceb2d8d63 Mon Sep 17 00:00:00 2001 From: ArchiMoebius Date: Sun, 1 Mar 2026 21:20:27 -0500 Subject: [PATCH 7/7] PR feedback/fixes --- main.zig | 149 ++++++++++++++++++++++++------------------------------- 1 file changed, 64 insertions(+), 85 deletions(-) diff --git a/main.zig b/main.zig index b3603c2..975bfcb 100755 --- a/main.zig +++ b/main.zig @@ -979,15 +979,19 @@ pub fn main() !void { var bootstrap_peers: [10][]const u8 = undefined; var bootstrap_count: usize = 0; var port: u16 = 9000; - - const access_control_list = try acl.parseCredentials(allocator, build_options.acl_list); - defer allocator.free(access_control_list); + var data_dir: []const u8 = build_options.data_dir; + var raw_acl_list: []const u8 = build_options.acl_list; + var show_help: bool = false; var args = std.process.args(); _ = args.skip(); // Skip program name while (args.next()) |arg| { if (std.mem.eql(u8, arg, "--distributed") or std.mem.eql(u8, arg, "-d")) { distributed_enabled = true; + } else if (std.mem.startsWith(u8, arg, "--data-dir=")) { + data_dir = arg[11..]; + } else if (std.mem.startsWith(u8, arg, "--acl=")) { + raw_acl_list = arg[6..]; } else if (std.mem.startsWith(u8, arg, "--bootstrap=")) { const peers_str = arg[12..]; var it = std.mem.splitScalar(u8, peers_str, ','); @@ -1000,28 +1004,48 @@ pub fn main() !void { } else if (std.mem.startsWith(u8, arg, "--port=")) { port = std.fmt.parseInt(u16, arg[7..], 10) catch 9000; } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { - std.debug.print( - \\zs3 - Distributed S3-compatible storage - \\ - \\Usage: zs3 [OPTIONS] - \\ - \\Options: - \\ --distributed, -d Enable distributed mode (peer-to-peer) - \\ --bootstrap=PEERS Comma-separated bootstrap peer addresses - \\ --port=PORT HTTP port (default: 9000) - \\ --help, -h Show this help - \\ - \\Examples: - \\ zs3 # Standalone mode - \\ zs3 --distributed # Distributed, auto-discover via mDNS - \\ zs3 -d --bootstrap=10.0.0.1:9000 # Distributed with bootstrap peer - \\ - , .{}); - return; - } + show_help = true; + } + } + + if (show_help) { + std.debug.print( + \\zs3 - Distributed S3-compatible storage + \\ + \\Usage: zs3 [OPTIONS] + \\ + \\Options: + \\ --distributed, -d + \\ Enable distributed mode (peer-to-peer) + \\ + \\ --bootstrap=PEERS + \\ Comma-separated bootstrap peer addresses + \\ + \\ --port={d} + \\ HTTP port to listen on + \\ + \\ --data-dir={s} + \\ The directory to store bucket data under + \\ + \\ --acl={s} + \\ The credentials for access + \\ + \\ --help, -h + \\ Show this help + \\ + \\Examples: + \\ zs3 # Standalone mode + \\ zs3 --distributed # Distributed, auto-discover via mDNS + \\ zs3 -d --bootstrap=10.0.0.1:9000 # Distributed with bootstrap peer + \\ + , .{ port, data_dir, raw_acl_list }); + return; } - std.fs.cwd().makeDir(build_options.data_dir) catch |err| switch (err) { + const access_control_list = try acl.parseCredentials(allocator, raw_acl_list); + defer allocator.free(access_control_list); + + std.fs.cwd().makeDir(data_dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; @@ -1032,13 +1056,13 @@ pub fn main() !void { if (distributed_enabled) { // Generate or load node ID - const node_id = try getOrCreateNodeId(allocator, build_options.data_dir); + const node_id = try getOrCreateNodeId(allocator, data_dir); const config = DistributedConfig{ .enabled = true, .node_id = node_id, .http_port = port, }; - dist_ctx = DistributedContext.init(allocator, build_options.data_dir, config); + dist_ctx = DistributedContext.init(allocator, data_dir, config); var id_hex: [40]u8 = undefined; bytesToHex(&node_id, &id_hex); @@ -1046,7 +1070,7 @@ pub fn main() !void { std.log.info("Known peers: {d}", .{dist_ctx.?.kademlia.peerCount()}); // Create .cas and .index directories - const dot_cas_path = try std.fs.path.join(allocator, &[_][]const u8{ build_options.data_dir, ".cas" }); + const dot_cas_path = try std.fs.path.join(allocator, &[_][]const u8{ data_dir, ".cas" }); defer allocator.free(dot_cas_path); std.fs.cwd().makeDir(dot_cas_path) catch |err| switch (err) { @@ -1056,7 +1080,7 @@ pub fn main() !void { }, }; - const dot_index_path = try std.fs.path.join(allocator, &[_][]const u8{ build_options.data_dir, ".index" }); + const dot_index_path = try std.fs.path.join(allocator, &[_][]const u8{ data_dir, ".index" }); defer allocator.free(dot_index_path); std.fs.cwd().makeDir(dot_index_path) catch |err| switch (err) { @@ -1068,29 +1092,29 @@ pub fn main() !void { } var access_control_map = std.StringHashMap(acl.Credential).init(allocator); + errdefer access_control_map.deinit(); for (access_control_list) |credential| { - if (credential.secret_key.len > 252) { + if (credential.secret_key.len > 252) { // 252 due to k_secret_buf.len and prefix of `AWS4` // Never log the secret_key itself... std.log.err("ACL credential '{s}' has a secret key exceeding 252 bytes; skipping", .{credential.access_key}); continue; } - if (credential.access_key.len > 252) { + if (credential.access_key.len > 252) { // 252 to match secret_key.len std.log.err("ACL credential '{s}' has an access key exceeding 252 bytes; skipping", .{credential.access_key}); continue; } // The key is the slice contents, not the pointer address access_control_map.put(credential.access_key, credential) catch |err| { - access_control_map.deinit(); return err; }; } var ctx = S3Context{ .allocator = allocator, - .data_dir = build_options.data_dir, + .data_dir = data_dir, .access_control_map = access_control_map, .distributed = if (dist_ctx != null) &dist_ctx.? else null, }; @@ -1611,10 +1635,14 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo const acl_ctx = SigV4.verify(ctx, req, allocator); // S3 API requires authentication - if (!acl_ctx.allow) { + if (!acl_ctx.authenticated) { sendError(res, 403, "AccessDenied", "Invalid credentials"); return; } + if (!acl_ctx.granted(req.method)) { + sendError(res, 403, "AccessDenied", "Insufficient permissions"); + return; + } var path_parts = std.mem.splitScalar(u8, path, '/'); const bucket = path_parts.next() orelse ""; @@ -1633,43 +1661,19 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo if (ctx.distributed != null) { if (key.len > 0) { if (std.mem.eql(u8, req.method, "PUT") and !hasQuery(req.query, "uploadId")) { - if (!acl_ctx.allowed("PUT")) { - sendError(res, 403, "AccessDenied", "Insufficient permissions"); - return; - } - try handleDistributedPut(ctx, allocator, req, res, bucket, key); return; } else if (std.mem.eql(u8, req.method, "GET")) { - if (!acl_ctx.allowed("GET")) { - sendError(res, 403, "AccessDenied", "Insufficient permissions"); - return; - } - try handleDistributedGet(ctx, allocator, req, res, bucket, key); return; } else if (std.mem.eql(u8, req.method, "DELETE") and !hasQuery(req.query, "uploadId")) { - if (!acl_ctx.allowed("DELETE")) { - sendError(res, 403, "AccessDenied", "Insufficient permissions"); - return; - } - try handleDistributedDelete(ctx, allocator, res, bucket, key); return; } else if (std.mem.eql(u8, req.method, "HEAD")) { - if (!acl_ctx.allowed("HEAD")) { - sendError(res, 403, "AccessDenied", "Insufficient permissions"); - return; - } - try handleDistributedHead(ctx, allocator, res, bucket, key); return; } } else if (bucket.len > 0 and std.mem.eql(u8, req.method, "GET")) { - if (!acl_ctx.allowed("GET")) { - sendError(res, 403, "AccessDenied", "Insufficient permissions"); - return; - } // Distributed LIST objects try handleDistributedList(ctx, allocator, req, res, bucket); return; @@ -1678,11 +1682,6 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo // Standard S3 routing (standalone mode or bucket operations) if (std.mem.eql(u8, req.method, "GET")) { - if (!acl_ctx.allowed("GET")) { - sendError(res, 403, "AccessDenied", "Insufficient permissions"); - return; - } - if (bucket.len == 0) { try handleListBuckets(ctx, allocator, res); } else if (key.len == 0) { @@ -1691,11 +1690,6 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo try handleGetObject(ctx, allocator, req, res, bucket, key); } } else if (std.mem.eql(u8, req.method, "PUT")) { - if (!acl_ctx.allowed("PUT")) { - sendError(res, 403, "AccessDenied", "Insufficient permissions"); - return; - } - if (key.len == 0) { try handleCreateBucket(ctx, allocator, res, bucket); } else if (hasQuery(req.query, "uploadId")) { @@ -1704,11 +1698,6 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo try handlePutObject(ctx, allocator, req, res, bucket, key); } } else if (std.mem.eql(u8, req.method, "DELETE")) { - if (!acl_ctx.allowed("DELETE")) { - sendError(res, 403, "AccessDenied", "Insufficient permissions"); - return; - } - if (key.len == 0) { try handleDeleteBucket(ctx, allocator, res, bucket); } else if (hasQuery(req.query, "uploadId")) { @@ -1717,22 +1706,12 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo try handleDeleteObject(ctx, allocator, res, bucket, key); } } else if (std.mem.eql(u8, req.method, "HEAD")) { - if (!acl_ctx.allowed("HEAD")) { - sendError(res, 403, "AccessDenied", "Insufficient permissions"); - return; - } - if (key.len == 0) { try handleHeadBucket(ctx, allocator, res, bucket); } else { try handleHeadObject(ctx, allocator, res, bucket, key); } } else if (std.mem.eql(u8, req.method, "POST")) { - if (!acl_ctx.allowed("POST")) { - sendError(res, 403, "AccessDenied", "Insufficient permissions"); - return; - } - if (hasQuery(req.query, "delete")) { try handleDeleteObjects(ctx, allocator, req, res, bucket); } else if (hasQuery(req.query, "uploads")) { @@ -1761,10 +1740,10 @@ pub const SigV4 = struct { }; const ACLCtx = struct { - allow: bool, + authenticated: bool, role: acl.Role, - fn allowed(self: *const ACLCtx, method: []const u8) bool { + fn granted(self: *const ACLCtx, method: []const u8) bool { switch (self.role) { acl.Role.Admin => { return true; @@ -1803,7 +1782,7 @@ pub const SigV4 = struct { fn verify(ctx: *const S3Context, req: *const Request, allocator: Allocator) ACLCtx { var acl_ctx = ACLCtx{ - .allow = false, + .authenticated = false, .role = acl.Role.Unknown, }; @@ -1847,7 +1826,7 @@ pub const SigV4 = struct { defer allocator.free(calculated_sig); if (std.mem.eql(u8, calculated_sig, parsed.signature)) { - acl_ctx.allow = true; + acl_ctx.authenticated = true; acl_ctx.role = credential.?.role; }