╭ changed ────╮ │build.zig.zon│ ╰─────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 3 .version = "0.0.0", │ │ 4 .dependencies = .{ │ │ 5 .xit = .{ │ │ 6 - .url = "git+https://github.com/xit-vcs/xit#0bb37004c8808936df2a1d4e1573d7ca5e52fff7",│ │ 7 - .hash = "xit-0.0.0-Ub3nDLJKHQAm3Vl4GDWOc0ijZmg7CbkFUIn_G74vaMzT", │ │ 6 + .url = "git+https://github.com/xit-vcs/xit#3cdb97c108b48fa9fd0501ed1b910da5839e7acc",│ │ 7 + .hash = "xit-0.0.0-Ub3nDCJQHQAAmpC-ESpredoNkuBEUG7NjhCF9yo8Zn6x", │ │ 8 }, │ │ 9 }, │ │10 .paths = .{ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────╮ │src/event.zig│ ╰─────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2229 } │ │2230 try remove(.{ .server = .{ .users_dir = users_dir } }, .user, .xit, user_repo_opts, io, allocator, &user_repo, &Grant.idOf(repo_id, user_id), .grant, author);│ │2231 } │ │2232 + │ │2233 +// add `branch` to the protected branches of the repo `repo_id` in its owner's │ │2234 +// user repo, or take it out when `protect` is false. taking out a branch that │ │2235 +// isn't protected writes nothing. │ │2236 +pub fn protectBranch( │ │2237 + io: std.Io, │ │2238 + allocator: std.mem.Allocator, │ │2239 + users_dir: []const u8, │ │2240 + owner_id: *const [event_id_size]u8, │ │2241 + repo_id: *const [event_id_size]u8, │ │2242 + branch: []const u8, │ │2243 + protect: bool, │ │2244 + author: CommitAuthor, │ │2245 +) !void { │ │2246 + var user_repo = (try openUserRepo(io, allocator, users_dir, owner_id)) orelse return error.NotFound; │ │2247 + defer user_repo.deinit(io, allocator); │ │2248 + var arena = std.heap.ArenaAllocator.init(allocator); │ │2249 + defer arena.deinit(); │ │2250 + const aa = arena.allocator(); │ │2251 + const moment = (try userMoment(&user_repo)) orelse return error.NotFound; │ │2252 + const repo = (try Repo.readById(UserDB, user_repo_opts.hash, moment, &arena, repo_id)) orelse return error.NotFound; │ │2253 + if (repo.removed) return error.NotFound; │ │2254 │ │2255 + var branches: std.ArrayList(u8) = .empty; │ │2256 + var count: usize = 0; │ │2257 + var held = false; │ │2258 + for (try repo.event.protectedBranchNames(aa)) |name| { │ │2259 + if (std.mem.eql(u8, name, branch)) { │ │2260 + held = true; │ │2261 + if (!protect) continue; │ │2262 + } │ │2263 + if (branches.items.len > 0) try branches.append(aa, '\n'); │ │2264 + try branches.appendSlice(aa, name); │ │2265 + count += 1; │ │2266 + } │ │2267 + if (protect) { │ │2268 + if (held) return error.BranchProtected; │ │2269 + if (count >= Repo.protected_branch_max_count) return error.TooManyProtectedBranches; │ │2270 + if (branches.items.len > 0) try branches.append(aa, '\n'); │ │2271 + try branches.appendSlice(aa, branch); │ │2272 + } else if (!held) return; │ │2273 + │ │2274 + var event = repo.event; │ │2275 + event.protected_branches = branches.items; │ │2276 + try consume(.{ .server = .{ .users_dir = users_dir } }, .user, .xit, user_repo_opts, io, allocator, &user_repo, events_ref, &[_]EventWithId{.{ │ │2277 + .id = std.fmt.bytesToHex(repo_id.*, .lower), │ │2278 + .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), │ │2279 + .author = author, │ │2280 + .event = .{ .repo = event }, │ │2281 + }}); │ │2282 +} │ │2283 + │ │2284 // create a user and their user repo, returning the new user's id │ │2285 pub fn createUser( │ │2286 io: std.Io, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────╮ │src/event/Repo.zig│ ╰──────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │12 discuss_role: ?Role = .read, │ │13 issue_role: ?Role = .read, │ │14 patch_role: ?Role = null, │ │15 +// newline-separated names of the branches a push may not delete or force push│ │16 +protected_branches: []const u8 = "", │ │17 │ │18 // what the db stores: the event's data plus the commit-derived fields │ │19 pub const Record = struct { │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────╮ │51 }; │ │52 │ │53 pub const name_max_len = 32; │ │54 +// how many branches a repo may protect, so the branches tab stays one short page │ │55 +pub const protected_branch_max_count = 50; │ │56 + │ │57 +// the protected branch names │ │58 +pub fn protectedBranchNames(self: Self, allocator: std.mem.Allocator) ![]const []const u8 {│ │59 + var names: std.ArrayList([]const u8) = .empty; │ │60 + var lines = std.mem.splitScalar(u8, self.protected_branches, '\n'); │ │61 + while (lines.next()) |line| { │ │62 + if (line.len > 0) try names.append(allocator, line); │ │63 + } │ │64 + return try names.toOwnedSlice(allocator); │ │65 +} │ │66 │ │67 // the role a kind's tab needs for a write, or null when the tab is off or the │ │68 // kind has no tab │ ╰───────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/serve_ssh.zig│ ╰─────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │386 } │ │387 │ │388 const owner_repo = evt.parseOwnerRepoPath(repo_identity) orelse return writeError(sess, "repo path must be <owner>:<repo>"); │ │389 - var author_arena = std.heap.ArenaAllocator.init(allocator); │ │390 - defer author_arena.deinit(); │ │391 - const author = switch (try authorizeRepoKey(io, &author_arena, handler.admin_repo_path, handler.server.users_dir, owner_repo.owner, owner_repo.name, parsed.service, &sess.fingerprint)) { │ │392 - .allowed => |user| user, │ │389 + var allowed_arena = std.heap.ArenaAllocator.init(allocator); │ │390 + defer allowed_arena.deinit(); │ │391 + const allowed = switch (try authorizeRepoKey(io, &allowed_arena, handler.admin_repo_path, handler.server.users_dir, owner_repo.owner, owner_repo.name, parsed.service, &sess.fingerprint)) {│ │392 + .allowed => |value| value, │ │393 .create => |creator| return runCreatingPush(handler, sess, &creator, owner_repo.name, protocol_version, &reader.interface, &writer.interface), │ │394 .denied => return switch (parsed.service) { │ │395 .upload_pack => writeError(sess, "unauthorized: this SSH key cannot read this repo"), │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │414 var sideband = progress.Sideband{ .writer = &writer.interface, .sess = sess }; │ │415 try repo.uploadPack(io, allocator, &reader.interface, &writer.interface, .{ .protocol_version = protocol_version }, &sideband); │ │416 }, │ │417 - .receive_pack => try push.receivePackAndConsume(repo.self_repo_opts, io, allocator, repo, &reader.interface, &writer.interface, .{ .protocol_version = protocol_version }, author, handler.server, handler.err, sess), │ │417 + .receive_pack => try push.receivePackAndConsume(repo.self_repo_opts, io, allocator, repo, &reader.interface, &writer.interface, .{ .protocol_version = protocol_version, .protected_branches = allowed.protected_branches }, allowed.author, handler.server, handler.err, sess),│ │418 }, │ │419 } │ │420 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │547 return .{ .service = service, .dir = try allocator.dupe(u8, dir_token) }; │ │548 } │ │549 │ │550 -const RepoAuthorization = union(enum) { allowed: ?evt.CommitAuthor, create: RepoCreator, denied, not_found };│ │550 +const RepoAuthorization = union(enum) { allowed: Allowed, create: RepoCreator, denied, not_found }; │ │551 + │ │552 +// a key allowed into an existing repo │ │553 +const Allowed = struct { │ │554 + // null when the repo's base role let the key in │ │555 + author: ?evt.CommitAuthor, │ │556 + protected_branches: []const []const u8, │ │557 +}; │ │558 │ │559 // the owner of a repo a push may create │ │560 const RepoCreator = struct { │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │592 .upload_pack => .read, │ │593 .receive_pack => .write, │ │594 }; │ │595 + const protected_branches = try repo.repo.event.protectedBranchNames(arena.allocator()); │ │596 // skip the key lookups when the base role is enough │ │597 if (repo.role) |base| { │ │590 - if (base.atLeast(min_role)) return .{ .allowed = null }; │ │598 + if (base.atLeast(min_role)) return .{ .allowed = .{ .author = null, .protected_branches = protected_branches } };│ │599 } │ │600 │ │601 // a key can be shared, so a holder whose role falls short doesn't end the search │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │603 for (try evt.User.readIdsByFingerprint(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, fingerprint)) |holder_id| { │ │604 const seen = (try evt.readRepoById(io, allocator, arena, users_dir, &owner_id, &repo.event_id, holder_id)) orelse return .not_found; │ │605 const role = seen.role orelse continue; │ │598 - if (role.atLeast(min_role)) return .{ .allowed = try commitAuthor(moment, arena, &holder_id) }; │ │606 + if (role.atLeast(min_role)) return .{ .allowed = .{ .author = try commitAuthor(moment, arena, &holder_id), .protected_branches = protected_branches } };│ │607 } │ │608 return .denied; │ │609 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │102 }; │ │103 }, │ │104 .repo => switch (route) { │ │105 - .repo_files, .repo_commits, .repo_diff, .repo_refs, .repo_issues, .repo_patches, .repo_discussions, .repo_events, .repo_undo, .repo_settings, .repo_members, .repo_repo_new, .repo_user_new, .repo_user => .{ .repo = try Repo.init(arena, session, route) }, │ │105 + .repo_files, .repo_commits, .repo_diff, .repo_refs, .repo_issues, .repo_patches, .repo_discussions, .repo_events, .repo_undo, .repo_settings, .repo_members, .repo_branches, .repo_repo_new, .repo_user_new, .repo_user => .{ .repo = try Repo.init(arena, session, route) },│ │106 else => return error.UnexpectedRoute, │ │107 }, │ │108 .fork => switch (route) { │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯