char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somsorcery-ssh: get rid of tautological tests3ee940a

main
13.3 KiB349 linesraw
1const std = @import("std");
2const shlex = @import("shlex");
3
4pub fn main(init: std.process.Init) !void {
5    const allocator = init.gpa;
6    const io = init.io;
7
8    const cmd = init.environ_map.get("SSH_ORIGINAL_COMMAND") orelse
9        return launchTui(allocator, io, init.environ_map, &.{});
10    const tokens = shlex.split(allocator, cmd, false, true) catch |err| {
11        std.log.err("failed to parse SSH_ORIGINAL_COMMAND: {}", .{err});
12        std.process.exit(1);
13    };
14    defer {
15        for (tokens) |token| allocator.free(token);
16        allocator.free(tokens);
17    }
18
19    if (tokens.len == 0) return launchTui(allocator, io, init.environ_map, tokens);
20
21    const is_receive_pack = std.mem.eql(u8, tokens[0], "git-receive-pack");
22    const is_upload_pack = std.mem.eql(u8, tokens[0], "git-upload-pack");
23    if (!is_receive_pack and !is_upload_pack) {
24        return launchTui(allocator, io, init.environ_map, tokens);
25    }
26    if (tokens.len != 2) {
27        std.log.err("invalid git command: {s}", .{cmd});
28        std.process.exit(1);
29    }
30
31    const home = init.environ_map.get("HOME") orelse {
32        std.log.err("HOME not set", .{});
33        std.process.exit(1);
34    };
35    const repo_path = try expandPath(allocator, home, tokens[1]);
36    defer allocator.free(repo_path);
37
38    if (!isUnder(repo_path, home)) {
39        std.log.err("repo path must be inside HOME: {s}", .{tokens[1]});
40        std.process.exit(1);
41    }
42
43    if (is_upload_pack) {
44        return std.process.replace(io, .{
45            .argv = &.{ "git-upload-pack", repo_path },
46        });
47    }
48
49    var created_repo = false;
50    if (is_receive_pack) {
51        std.Io.Dir.accessAbsolute(io, repo_path, .{}) catch {
52            std.log.info("auto-initializing bare repo at {s}", .{repo_path});
53            std.Io.Dir.createDirPath(.cwd(), io, repo_path) catch |err| {
54                std.log.err("failed to create repo directory: {}", .{err});
55                std.process.exit(1);
56            };
57            const result = try std.process.run(allocator, io, .{
58                .argv = &.{ "git", "init", "--bare", repo_path },
59            });
60            defer allocator.free(result.stdout);
61            defer allocator.free(result.stderr);
62            if (result.term != .exited or result.term.exited != 0) {
63                std.log.err("git init --bare failed: {s}", .{result.stderr});
64                std.process.exit(1);
65            }
66            created_repo = true;
67        };
68    }
69
70    var receive_pack = try std.process.spawn(io, .{
71        .argv = &.{ "git-receive-pack", repo_path },
72    });
73    const term = try receive_pack.wait(io);
74    if (term != .exited) std.process.exit(1);
75    if (term.exited != 0) std.process.exit(term.exited);
76
77    if (created_repo) try fixHead(allocator, io, repo_path);
78    if (is_receive_pack) refreshAfterPush(allocator, io, init.environ_map, repo_path);
79}
80
81fn launchTui(
82    allocator: std.mem.Allocator,
83    io: std.Io,
84    environ: *const std.process.Environ.Map,
85    tokens: []const []const u8,
86) !void {
87    const tui = environ.get("SORCERY_SSH_TUI") orelse "sorcery-ssh-tui";
88    const argv = try allocator.alloc([]const u8, tokens.len + 1);
89    defer allocator.free(argv);
90    argv[0] = tui;
91    for (tokens, argv[1..]) |token, *arg| arg.* = token;
92    const err = std.process.replace(io, .{ .argv = argv });
93    return switch (err) {
94        error.FileNotFound => printWelcome(io, environ),
95        else => err,
96    };
97}
98
99fn printWelcome(io: std.Io, environ: *const std.process.Environ.Map) !void {
100    var buffer: [1024]u8 = undefined;
101    var stdout = std.Io.File.stdout().writer(io, &buffer);
102    try writeWelcome(&stdout.interface, environ.get("SORCERY_INSTANCE_NAME") orelse "this Sorcery instance");
103    try stdout.interface.flush();
104}
105
106fn writeWelcome(writer: *std.Io.Writer, instance: []const u8) !void {
107    try writer.print(
108        \\welcome to sorcery-ssh on {s}!
109        \\
110        \\usage:
111        \\  ssh git@{s} describe USER/REPOSITORY
112        \\  ssh git@{s} describe USER/REPOSITORY DESCRIPTION...
113        \\
114        \\install sorcery-ssh-tui for interactive repository management.
115        \\
116    , .{ instance, instance, instance });
117}
118
119fn refreshAfterPush(
120    allocator: std.mem.Allocator,
121    io: std.Io,
122    environ: *const std.process.Environ.Map,
123    repo_path: []const u8,
124) void {
125    const repositories_raw = environ.get("SORCERY_REPOSITORIES") orelse {
126        std.log.warn("SORCERY_REPOSITORIES not set; site refresh skipped", .{});
127        return;
128    };
129    const repositories = std.fs.path.resolve(allocator, &.{repositories_raw}) catch |err| {
130        std.log.warn("failed to resolve SORCERY_REPOSITORIES: {}", .{err});
131        return;
132    };
133    defer allocator.free(repositories);
134
135    const coordinates = repositoryCoordinates(repositories, repo_path) orelse return;
136    const socket = environ.get("SORCERY_SOCKET") orelse {
137        std.log.warn("SORCERY_SOCKET not set; site refresh skipped", .{});
138        return;
139    };
140    const token_file = environ.get("SORCERY_REFRESH_TOKEN_FILE") orelse {
141        std.log.warn("SORCERY_REFRESH_TOKEN_FILE not set; site refresh skipped", .{});
142        return;
143    };
144    const token_alloc = std.Io.Dir.readFileAlloc(
145        .cwd(),
146        io,
147        token_file,
148        allocator,
149        .limited(4096),
150    ) catch |err| {
151        std.log.warn("failed to read refresh token: {}", .{err});
152        return;
153    };
154    defer allocator.free(token_alloc);
155    const token = std.mem.trimEnd(u8, token_alloc, "\r\n");
156    if (token.len == 0) {
157        std.log.warn("refresh token must contain visible ASCII without whitespace", .{});
158        return;
159    }
160    for (token) |byte| {
161        if (!std.ascii.isPrint(byte) or std.ascii.isWhitespace(byte)) {
162            std.log.warn("refresh token must contain visible ASCII without whitespace", .{});
163            return;
164        }
165    }
166
167    refresh(io, socket, token, coordinates.user, coordinates.repo) catch |err| {
168        std.log.warn("site refresh failed: {}", .{err});
169        return;
170    };
171
172    if (environ.get("SORCERY_CLONE_URL_BASE")) |base| {
173        std.debug.print(
174            "sorcery: {s}/{s}/{s}/\n",
175            .{ std.mem.trimEnd(u8, base, "/"), coordinates.user, coordinates.repo },
176        );
177    }
178}
179
180const Coordinates = struct {
181    user: []const u8,
182    repo: []const u8,
183};
184
185fn repositoryCoordinates(root: []const u8, path: []const u8) ?Coordinates {
186    if (!isUnder(path, root)) return null;
187    const offset = if (std.mem.eql(u8, root, &.{std.fs.path.sep})) root.len else root.len + 1;
188    var parts = std.mem.splitScalar(u8, path[offset..], std.fs.path.sep);
189    const user = parts.next() orelse return null;
190    const repo_raw = parts.next() orelse return null;
191    if (user.len == 0 or repo_raw.len == 0 or parts.next() != null) return null;
192    const repo = if (std.mem.endsWith(u8, repo_raw, ".git"))
193        repo_raw[0 .. repo_raw.len - ".git".len]
194    else
195        repo_raw;
196    if (repo.len == 0) return null;
197    return .{ .user = user, .repo = repo };
198}
199
200/// Whether `path` is strictly below `root`; `root` itself does not count.
201fn isUnder(path: []const u8, root: []const u8) bool {
202    if (std.mem.eql(u8, root, &.{std.fs.path.sep})) {
203        return path.len > 1 and path[0] == std.fs.path.sep;
204    }
205    return std.mem.startsWith(u8, path, root) and
206        path.len > root.len and path[root.len] == std.fs.path.sep;
207}
208
209fn refresh(
210    io: std.Io,
211    socket_path: []const u8,
212    token: []const u8,
213    user: []const u8,
214    repo: []const u8,
215) !void {
216    const address = try std.Io.net.UnixAddress.init(socket_path);
217    const stream = try address.connect(io);
218    defer stream.close(io);
219
220    var write_buffer: [1024]u8 = undefined;
221    var stream_writer = stream.writer(io, &write_buffer);
222    const writer = &stream_writer.interface;
223    try writer.writeAll("POST /-/refresh/");
224    try writeUrlComponent(writer, user);
225    try writer.writeByte('/');
226    try writeUrlComponent(writer, repo);
227    try writer.writeAll(" HTTP/1.1\r\nHost: sorcery\r\nAuthorization: Bearer ");
228    try writer.writeAll(token);
229    try writer.writeAll("\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
230    try writer.flush();
231
232    var read_buffer: [1024]u8 = undefined;
233    var stream_reader = stream.reader(io, &read_buffer);
234    const status = try stream_reader.interface.takeDelimiter('\n') orelse return error.EmptyResponse;
235    if (!std.mem.startsWith(u8, status, "HTTP/1.1 200 ") and
236        !std.mem.startsWith(u8, status, "HTTP/1.0 200 "))
237    {
238        return error.RefreshRejected;
239    }
240}
241
242fn writeUrlComponent(writer: *std.Io.Writer, component: []const u8) !void {
243    const hex = "0123456789ABCDEF";
244    for (component) |byte| {
245        if (std.ascii.isAlphanumeric(byte) or std.mem.indexOfScalar(u8, "-._~", byte) != null) {
246            try writer.writeByte(byte);
247        } else {
248            try writer.writeAll(&.{ '%', hex[byte >> 4], hex[byte & 0xf] });
249        }
250    }
251}
252
253fn fixHead(allocator: std.mem.Allocator, io: std.Io, repo_path: []const u8) !void {
254    const head_result = try std.process.run(allocator, io, .{
255        .argv = &.{ "git", "--git-dir", repo_path, "rev-parse", "--verify", "--quiet", "HEAD" },
256    });
257    defer allocator.free(head_result.stdout);
258    defer allocator.free(head_result.stderr);
259    if (head_result.term == .exited and head_result.term.exited == 0) return;
260    if (head_result.term != .exited or head_result.term.exited != 1) {
261        std.log.err("failed to resolve repository HEAD: {s}", .{head_result.stderr});
262        return error.GitCommandFailed;
263    }
264
265    const refs_result = try std.process.run(allocator, io, .{
266        .argv = &.{ "git", "--git-dir", repo_path, "for-each-ref", "--format=%(refname)", "refs/heads/" },
267    });
268    defer allocator.free(refs_result.stdout);
269    defer allocator.free(refs_result.stderr);
270    if (refs_result.term != .exited or refs_result.term.exited != 0) {
271        std.log.err("failed to list repository branches: {s}", .{refs_result.stderr});
272        return error.GitCommandFailed;
273    }
274
275    var refs = std.mem.tokenizeScalar(u8, refs_result.stdout, '\n');
276    var sole_ref: ?[]const u8 = null;
277    var main_ref: ?[]const u8 = null;
278    var ref_count: usize = 0;
279    while (refs.next()) |ref| {
280        sole_ref = ref;
281        ref_count += 1;
282        if (std.mem.eql(u8, ref, "refs/heads/main")) main_ref = ref;
283    }
284
285    const head_ref = main_ref orelse if (ref_count == 1) sole_ref else null;
286    if (head_ref) |ref| {
287        const set_head_result = try std.process.run(allocator, io, .{
288            .argv = &.{ "git", "--git-dir", repo_path, "symbolic-ref", "HEAD", ref },
289        });
290        defer allocator.free(set_head_result.stdout);
291        defer allocator.free(set_head_result.stderr);
292        if (set_head_result.term != .exited or set_head_result.term.exited != 0) {
293            std.log.err("failed to set repository HEAD: {s}", .{set_head_result.stderr});
294            return error.GitCommandFailed;
295        }
296    } else if (ref_count > 1) {
297        std.log.warn("HEAD remains unresolved because the repository has multiple branches and no main branch", .{});
298    }
299}
300
301// im pretty sure this is what git expand user path does. wordexp from posix is overkill
302fn expandPath(allocator: std.mem.Allocator, home: []const u8, path: []const u8) ![]const u8 {
303    if (std.mem.startsWith(u8, path, "~/")) {
304        return std.fs.path.resolve(allocator, &.{ home, path[2..] });
305    } else if (std.mem.eql(u8, path, "~")) {
306        return allocator.dupe(u8, home);
307    } else {
308        return std.fs.path.resolve(allocator, &.{ home, path });
309    }
310}
311
312test "pushed paths normalise into HOME or are rejected" {
313    const home = "/home/git";
314    for ([_][]const u8{ "x", "~/x", "/home/git/x", "~/a/../x", "/home/git/../git/x" }) |path| {
315        const expanded = try expandPath(std.testing.allocator, home, path);
316        defer std.testing.allocator.free(expanded);
317        try std.testing.expectEqualStrings("/home/git/x", expanded);
318        try std.testing.expect(isUnder(expanded, home));
319    }
320    for ([_][]const u8{ "~", ".", "../x", "~/../x", "/etc/passwd", "/home/gitolite/x", "/home" }) |path| {
321        const expanded = try expandPath(std.testing.allocator, home, path);
322        defer std.testing.allocator.free(expanded);
323        try std.testing.expect(!isUnder(expanded, home));
324    }
325}
326
327test "refresh url components escape everything outside the unreserved set" {
328    var output = std.Io.Writer.Allocating.init(std.testing.allocator);
329    defer output.deinit();
330    try writeUrlComponent(&output.writer, "a b/c?d#\u{e9}-._~");
331    try std.testing.expectEqualStrings("a%20b%2Fc%3Fd%23%C3%A9-._~", output.written());
332}
333
334test "repository coordinates require exactly user and repo under root" {
335    const expectEqualStrings = std.testing.expectEqualStrings;
336    const root = "/home/git/public";
337
338    const plain = repositoryCoordinates(root, "/home/git/public/char/sorcery").?;
339    try expectEqualStrings("char", plain.user);
340    try expectEqualStrings("sorcery", plain.repo);
341
342    const dotted = repositoryCoordinates(root, "/home/git/public/char/sorcery.git").?;
343    try expectEqualStrings("sorcery", dotted.repo);
344
345    try std.testing.expect(repositoryCoordinates(root, "/home/git/private/char/sorcery") == null);
346    try std.testing.expect(repositoryCoordinates(root, "/home/git/public/char") == null);
347    try std.testing.expect(repositoryCoordinates(root, "/home/git/public/char/sorcery/extra") == null);
348    try std.testing.expect(repositoryCoordinates(root, "/home/git/publicity/char/sorcery") == null);
349}