// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Finding a repository as git finds one: from a starting directory, or //! where `GIT_DIR` says, with its git directory, its common directory, its //! working tree and its format, and refusing it when git would. //! //! git(1), gitrepository-layout(5), git-rev-parse(1) and git-config(1) //! (`core.bare`, `core.worktree`, `safe.directory`, //! `safe.bareRepository`) describe most of it. The rest was found by //! making repositories and asking `git rev-parse`: //! //! - **The walk** starts from the starting directory with its symbolic //! links resolved, as `getcwd` would give it, and goes up one directory //! at a time. In each, `.git` is tried first: a directory that is a git //! directory, or a file naming one (`gitdir: `). Then the //! directory itself, as the git directory of a bare repository. A `.git` //! that is neither is passed over; a `.git` file that is malformed, or //! names something that is not a git directory, stops the walk. //! - **A git directory** has a plausible `HEAD` — `ref:` and blank space //! then `refs/`, or forty hexadecimal digits first, or a symbolic link //! to something under `refs/` — and, in its common directory, `objects` //! (or `GIT_OBJECT_DIRECTORY`, which then stands for it everywhere) and //! `refs` directories. The common directory is `GIT_COMMON_DIR`, or what //! `commondir` names relative to the git directory, or the git //! directory itself; a `commondir` naming nothing makes it no git //! directory. //! - **`GIT_CEILING_DIRECTORIES`**: absolute paths, separated by colons; //! a relative one is ignored. Each is resolved through its symbolic //! links, and dropped if it cannot be, until an empty entry, after which //! each is taken as written but for one trailing slash. The walk never //! moves up into the nearest one above the starting directory; one //! equal to the starting directory changes nothing. //! - **Filesystem boundaries** stop the walk where the next directory up //! is on another device than the starting directory, after the //! ceilings are checked, unless `GIT_DISCOVERY_ACROSS_FILESYSTEM` is //! true. git's message names that next directory. //! - **Ownership**: the directory a repository was found in, its `.git`, //! and the git directory a `.git` file names must all belong to the //! user, or to `SUDO_UID` when the user is root, unless `safe.directory` //! in protected configuration names the directory (resolved through its //! symbolic links; `*` for all, `/*` at the end for everything under a //! directory, an empty value to forget the ones before). A relative //! value is warned about and ignored. A repository named by `GIT_DIR` //! is not checked. //! - **`safe.bareRepository`**: `explicit` refuses a repository found by //! the walk as its own git directory, unless it is a `.git` or under a //! `.git/worktrees/` or `.git/modules/`. //! - **The working tree** is `GIT_WORK_TREE` (relative to the starting //! directory), or `core.worktree` (relative to the git directory, and //! ignored under `GIT_COMMON_DIR`), or else the directory the `.git` was //! found in, or with `GIT_DIR` the starting directory. A repository with //! `core.bare` true has none of the defaults. A linked worktree reads //! `core.bare` and `core.worktree` only from `config.worktree`, or from //! the common `config` too with `extensions.worktreeConfig`. A //! repository found as its own git directory has no working tree //! unless `core.worktree` gives one, and is bare unless `core.bare` is //! false. //! - **Paths as git prints them**: git moves to the top of the working //! tree when it starts inside one, and keeps the git directory relative //! to where it is when it found it there: `.git` when the `.git` is in //! the directory it works from, `.` when it starts in the git directory, //! and otherwise absolute. `GIT_DIR` is kept as given until git moves. //! `Location` records both forms, so that `rev-parse` can print either. //! //! The library never reads the environment, the current directory or the //! process's user: they are all in `Options`. const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const Io = std.Io; const Diagnostics = @import("Diagnostics.zig"); const Config = @import("Config.zig"); const RepositoryFormat = @import("RepositoryFormat.zig"); const value = @import("config/value.zig"); /// The environment variables discovery reads, passed in. pub const Environment = struct { /// `GIT_DIR`: the git directory, relative to the starting directory; /// the walk is not made. git_dir: ?[]const u8 = null, /// `GIT_WORK_TREE`. work_tree: ?[]const u8 = null, /// `GIT_COMMON_DIR`. common_dir: ?[]const u8 = null, /// `GIT_OBJECT_DIRECTORY`. object_directory: ?[]const u8 = null, /// `GIT_CEILING_DIRECTORIES`. ceiling_directories: ?[]const u8 = null, /// `GIT_DISCOVERY_ACROSS_FILESYSTEM`, a boolean. discovery_across_filesystem: ?[]const u8 = null, /// `SUDO_UID`, which root may own repositories as. sudo_uid: ?[]const u8 = null, /// The variables as `env` has them. pub fn fromMap(env: *const std.process.Environ.Map) Environment { return .{ .git_dir = env.get("GIT_DIR"), .work_tree = env.get("GIT_WORK_TREE"), .common_dir = env.get("GIT_COMMON_DIR"), .object_directory = env.get("GIT_OBJECT_DIRECTORY"), .ceiling_directories = env.get("GIT_CEILING_DIRECTORIES"), .discovery_across_filesystem = env.get("GIT_DISCOVERY_ACROSS_FILESYSTEM"), .sudo_uid = env.get("SUDO_UID"), }; } }; /// What discovery asks of the operating system that `std.Io` does not /// answer: who owns a file and which device it is on. pub const FileSystem = struct { context: ?*anyopaque = null, vtable: *const VTable, pub const VTable = struct { /// The owner and device of the file at the absolute `path`, /// following symbolic links. identify: *const fn (context: ?*anyopaque, io: Io, path: []const u8) IdentifyError!Identity, }; /// Who owns a file, and where it is. pub const Identity = struct { owner: u32, device: u64, }; /// Why a file could not be identified. pub const IdentifyError = error{ FileNotFound, AccessDenied, NameTooLong, Unsupported, Unexpected } || Io.Cancelable; /// Answers from the system: `statx` on Linux; elsewhere every answer is /// `Unsupported`. pub const native: FileSystem = .{ .vtable = &native_vtable }; const native_vtable: VTable = .{ .identify = nativeIdentify }; fn nativeIdentify(_: ?*anyopaque, io: Io, path: []const u8) IdentifyError!Identity { _ = io; if (builtin.os.tag != .linux) return error.Unsupported; const linux = std.os.linux; var buffer: [std.fs.max_path_bytes + 1]u8 = undefined; if (path.len >= buffer.len) return error.NameTooLong; @memcpy(buffer[0..path.len], path); buffer[path.len] = 0; var st: linux.Statx = undefined; const rc = linux.statx(linux.AT.FDCWD, buffer[0..path.len :0], 0, .{ .UID = true }, &st); return switch (linux.errno(rc)) { .SUCCESS => .{ .owner = st.uid, .device = (@as(u64, st.dev_major) << 32) | st.dev_minor }, .NOENT, .NOTDIR => error.FileNotFound, .ACCES, .PERM => error.AccessDenied, .NAMETOOLONG => error.NameTooLong, else => error.Unexpected, }; } }; /// The effective user ID of this process, for `Ownership`, on systems /// that have one. pub fn effectiveUid() ?u32 { if (builtin.os.tag == .linux) return std.os.linux.geteuid(); return null; } /// Whose repositories are trusted. pub const Ownership = struct { /// The user running: git's effective user ID. uid: u32, }; /// How to find a repository. pub const Options = struct { /// The directory to start from: absolute, or relative to the /// process's working directory as `Io.Dir.cwd()` opens it. start: []const u8, environment: Environment = .{}, /// The system, global and command configuration, which alone may set /// `safe.directory` and `safe.bareRepository`; with none, neither is /// set. protected_config: ?*const Config = null, /// What `~` and `%(prefix)` mean in `safe.directory`. path: value.PathContext = .{}, /// Whose repositories are trusted; with none, ownership is not /// checked. ownership: ?Ownership = null, file_system: FileSystem = .native, /// Where to put what git warns of and carries on after, if anywhere. warnings: ?*std.ArrayList(Warning) = null, /// Where to put the git directory as git held it, when the /// repository's format is refused: `git config` names it as the git /// directory it ignores. Allocated with the allocator `discover` is /// given. refused_git_dir: ?*?[]u8 = null, }; /// Something git warns of during discovery and then carries on. pub const Warning = struct { kind: Kind, /// The value warned about, owned by the list's allocator; empty if /// there is none. text: []u8, pub const Kind = enum { /// "safe.directory '' not absolute". relative_safe_directory, /// "core.bare and core.worktree do not make sense". bare_and_worktree, }; }; /// How the git directory was found. pub const Found = enum { /// `GIT_DIR` named it. environment, /// A `.git` directory in the directory the walk was in. dot_git, /// A `.git` file naming it. git_file, /// The walk was in it, or below it: a bare repository, or the inside /// of a `.git`. git_dir, }; /// A repository as found: where its parts are, and its format. Every /// string is owned. pub const Location = struct { /// The starting directory, absolute, its symbolic links resolved. start: []u8, /// The directory git works from: the top of the working tree when the /// start is inside it, and otherwise the start. Absolute. work_dir: []u8, /// The git directory as git keeps it: relative to `work_dir`, or /// absolute. What `rev-parse --git-dir` starts from. git_dir: []u8, /// The common directory as git keeps it, the same way. common_dir: []u8, /// The git directory, absolute, its symbolic links resolved. git_dir_real: []u8, /// The common directory the same way, or as near as it can be made /// (`GIT_COMMON_DIR` may name one that does not exist). common_dir_real: []u8, /// The working tree's top, absolute; null when there is none. work_tree: ?[]u8, /// Where the start is in the working tree, with a trailing slash, or /// empty at the top; null when the start is not in it. prefix: ?[]u8, /// Whether `rev-parse --is-bare-repository` says true. bare: bool, /// Whether the start is in the git directory. inside_git_dir: bool, found: Found, /// `GIT_OBJECT_DIRECTORY` as given, which git reads relative to /// `work_dir`; null to use the common directory's `objects`. object_directory: ?[]u8, format: RepositoryFormat, /// The format's strings. format_strings: std.heap.ArenaAllocator, /// Frees everything. pub fn deinit(l: *Location, gpa: Allocator) void { gpa.free(l.start); gpa.free(l.work_dir); gpa.free(l.git_dir); gpa.free(l.common_dir); gpa.free(l.git_dir_real); gpa.free(l.common_dir_real); if (l.work_tree) |w| gpa.free(w); if (l.prefix) |p| gpa.free(p); if (l.object_directory) |o| gpa.free(o); l.format_strings.deinit(); l.* = undefined; } /// Whether this is a linked worktree's git directory, which has a /// common directory of its own. pub fn isLinkedWorktree(l: *const Location) bool { return !std.mem.eql(u8, l.git_dir_real, l.common_dir_real); } }; /// Why no repository was found, or the one found was refused, each in /// git's words. `Diagnostics` names the path. pub const Error = error{ /// The starting directory cannot be opened; the detail is why. StartUnreadable, /// "not a git repository (or any of the parent directories): .git": /// the walk reached the root, or a ceiling. NotARepository, /// "not a git repository (or any parent up to mount point )" /// and "Stopping at filesystem boundary /// (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).": the path is the /// directory on the other device. FilesystemBoundary, /// "bad boolean environment value '' for /// 'GIT_DISCOVERY_ACROSS_FILESYSTEM'"; the detail is the value. BadDiscoveryAcrossFilesystem, /// "not a git repository: ''": `GIT_DIR` names no git /// directory. GitDirInvalid, /// "invalid gitfile format: ". InvalidGitFile, /// "no path in gitfile: ". GitFileNoPath, /// "gitfile does not point to a valid repository: ". GitFileNotARepository, /// "failed to read : Success": an empty `commondir`. CommonDirEmpty, /// "detected dubious ownership in repository at ''". DubiousOwnership, /// "cannot use bare repository '' (safe.bareRepository is /// 'explicit')". BareRepositoryNotAllowed, /// `safe.bareRepository` is neither `all` nor `explicit`: "unable to /// parse 'safe.barerepository' from command-line config" or "bad /// config variable 'safe.barerepository' in file '' at line N". /// git crashes on one with no value; this is that too. BadBareRepositorySetting, /// "cannot chdir to '': No such file or directory": a relative /// `core.worktree` naming nothing; the detail is the value. WorkTreeUnreachable, /// "The empty string is not a valid path": an empty `GIT_WORK_TREE`. EmptyWorkTree, /// Ownership or filesystem boundaries cannot be checked here. FileSystemUnsupported, /// A file discovery had to read could not be, for a reason git does /// not stop at; the detail is why. Unreadable, } || RepositoryFormat.Error || Allocator.Error || Io.Cancelable; // -- pure parsers ---------------------------------------------------------------- /// Why a `.git` file names no path. pub const GitFileError = error{ InvalidGitFile, GitFileNoPath }; /// The path a `.git` file holds: after `gitdir: `, up to a NUL, with line /// ends at the end taken off. Nothing else is: blank space is part of the /// path. pub fn parseGitFile(contents: []const u8) GitFileError![]const u8 { const text = if (std.mem.findScalar(u8, contents, 0)) |nul| contents[0..nul] else contents; const prefix = "gitdir: "; if (!std.mem.startsWith(u8, text, prefix)) return error.InvalidGitFile; const path = std.mem.trimEnd(u8, text[prefix.len..], "\r\n"); if (path.len == 0) return error.GitFileNoPath; return path; } /// Whether a regular `HEAD` file's contents make a plausible `HEAD`: /// `ref:` and blank space then `refs/`, or forty hexadecimal digits first, /// whatever follows them. pub fn headIsPlausible(contents: []const u8) bool { if (std.mem.startsWith(u8, contents, "ref:")) { const rest = std.mem.trimStart(u8, contents[4..], " \t\n\r"); return std.mem.startsWith(u8, rest, "refs/"); } if (contents.len < 40) return false; for (contents[0..40]) |c| if (!std.ascii.isHex(c)) return false; return true; } /// Whether a `HEAD` that is a symbolic link is plausible: it points under /// `refs/`. pub fn symlinkHeadIsPlausible(target: []const u8) bool { return std.mem.startsWith(u8, target, "refs/"); } /// The trailing line ends `commondir` loses. pub fn parseCommonDir(contents: []const u8) []const u8 { return std.mem.trimEnd(u8, contents, "\r\n"); } /// One entry of `GIT_CEILING_DIRECTORIES`. pub const Ceiling = struct { path: []const u8, /// Whether to resolve its symbolic links: false after an empty entry. resolve: bool, }; /// Steps through `GIT_CEILING_DIRECTORIES`, skipping empty and relative /// entries. pub const CeilingIterator = struct { rest: ?[]const u8, resolve: bool = true, /// An iterator over `list`. pub fn init(list: []const u8) CeilingIterator { return .{ .rest = list }; } /// The next entry, or null. pub fn next(it: *CeilingIterator) ?Ceiling { while (it.rest) |rest| { const colon = std.mem.findScalar(u8, rest, ':'); const entry = if (colon) |c| rest[0..c] else rest; it.rest = if (colon) |c| rest[c + 1 ..] else null; if (entry.len == 0) { it.resolve = false; continue; } if (entry[0] != '/') continue; return .{ .path = entry, .resolve = it.resolve }; } return null; } }; /// Whether a repository found as its own git directory may be used when /// `safe.bareRepository` is `explicit`: it is a `.git`, or inside one's /// `worktrees/` or `modules/`. pub fn bareRepositoryExempt(path: []const u8) bool { if (std.mem.endsWith(u8, path, "/.git") or std.mem.eql(u8, path, ".git")) return true; for ([_][]const u8{ "/.git/worktrees/", "/.git/modules/" }) |needle| { if (std.mem.find(u8, path, needle)) |i| { if (path.len > i + needle.len) return true; } } return false; } /// Whether `child` is `parent` or under it; both absolute and normalized. pub fn isWithin(child: []const u8, parent: []const u8) bool { if (!std.mem.startsWith(u8, child, parent)) return false; if (child.len == parent.len) return true; if (std.mem.eql(u8, parent, "/")) return true; return child[parent.len] == '/'; } /// `path` made absolute against `base` and normalized without touching the /// filesystem: `.` and empty components dropped, `..` taking the one /// before it away. pub fn normalize(gpa: Allocator, base: []const u8, path: []const u8) Allocator.Error![]u8 { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); const whole = if (path.len != 0 and path[0] == '/') path else try std.mem.concat(gpa, u8, &.{ base, "/", path }); defer if (whole.ptr != path.ptr) gpa.free(whole); var it = std.mem.tokenizeScalar(u8, whole, '/'); while (it.next()) |part| { if (std.mem.eql(u8, part, ".")) continue; if (std.mem.eql(u8, part, "..")) { const slash = std.mem.findScalarLast(u8, out.items, '/') orelse 0; out.shrinkRetainingCapacity(slash); continue; } try out.append(gpa, '/'); try out.appendSlice(gpa, part); } if (out.items.len == 0) try out.append(gpa, '/'); return out.toOwnedSlice(gpa); } /// `target` relative to `base`, both absolute and normalized, as git /// writes one: `./` when they are the same, a run of `../` with its /// trailing slash when `target` is above `base`. pub fn relativePath(gpa: Allocator, target: []const u8, base: []const u8) Allocator.Error![]u8 { if (std.mem.eql(u8, target, base)) return gpa.dupe(u8, "./"); // The longest common directory. var common: usize = 0; var i: usize = 0; while (i <= target.len and i <= base.len) : (i += 1) { const te = i == target.len or target[i] == '/'; const be = i == base.len or base[i] == '/'; if (te and be) common = i; if (i == target.len or i == base.len or target[i] != base[i]) break; } var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); const base_rest = base[common..]; var ups = std.mem.tokenizeScalar(u8, base_rest, '/'); while (ups.next()) |_| try out.appendSlice(gpa, "../"); const target_rest = std.mem.trimStart(u8, target[common..], "/"); if (target_rest.len != 0) { try out.appendSlice(gpa, target_rest); } return out.toOwnedSlice(gpa); } // -- discovery ------------------------------------------------------------------- const Walker = struct { gpa: Allocator, io: Io, options: Options, diag: ?*Diagnostics, start: []const u8, fn fail(w: *Walker, path: ?[]const u8, comptime fmt: []const u8, args: anytype) void { const d = w.diag orelse return; d.reset(); if (path) |p| d.setPath(&.{p}); d.setDetail(fmt, args); } fn realPath(w: *Walker, path: []const u8) (Allocator.Error || Io.Cancelable)!?[]u8 { var buffer: [std.fs.max_path_bytes]u8 = undefined; const n = Io.Dir.cwd().realPathFile(w.io, path, &buffer) catch |err| switch (err) { error.Canceled => |e| return e, else => return null, }; return try w.gpa.dupe(u8, buffer[0..n]); } fn kind(w: *Walker, path: []const u8, follow: bool) Io.Cancelable!?Io.File.Kind { const st = Io.Dir.cwd().statFile(w.io, path, .{ .follow_symlinks = follow }) catch |err| switch (err) { error.Canceled => |e| return e, else => return null, }; return st.kind; } fn isDir(w: *Walker, path: []const u8) Io.Cancelable!bool { return (try w.kind(path, true)) == .directory; } /// `path` relative to the start, absolute. fn fromStart(w: *Walker, path: []const u8) Allocator.Error![]u8 { return normalize(w.gpa, w.start, path); } /// Whether `dir` (absolute) is a git directory; and if so, its common /// directory's real path. Error only for what stops git. fn gitDirectory(w: *Walker, dir: []const u8) Error!?[]u8 { const gpa = w.gpa; const head = try std.fs.path.join(gpa, &.{ dir, "HEAD" }); defer gpa.free(head); const head_kind = try w.kind(head, false) orelse return null; switch (head_kind) { .sym_link => { var buffer: [std.fs.max_path_bytes]u8 = undefined; const n = Io.Dir.cwd().readLink(w.io, head, &buffer) catch |err| switch (err) { error.Canceled => |e| return e, else => return null, }; if (!symlinkHeadIsPlausible(buffer[0..n])) return null; }, .file => { var buffer: [256]u8 = undefined; const contents = readSome(w.io, head, &buffer) catch |err| switch (err) { error.Canceled => |e| return e, else => return null, }; if (!headIsPlausible(contents)) return null; }, else => return null, } const common = (try w.commonDirectory(dir)) orelse return null; errdefer gpa.free(common); const objects = if (w.options.environment.object_directory) |o| try w.fromStart(o) else try std.fs.path.join(gpa, &.{ common, "objects" }); defer gpa.free(objects); if (!try w.isDir(objects)) { gpa.free(common); return null; } const refs = try std.fs.path.join(gpa, &.{ common, "refs" }); defer gpa.free(refs); if (!try w.isDir(refs)) { gpa.free(common); return null; } return common; } /// The common directory of the git directory `dir`, made absolute /// (resolved, when it is named by `commondir`); null if `commondir` /// names nothing. fn commonDirectory(w: *Walker, dir: []const u8) Error!?[]u8 { const gpa = w.gpa; if (w.options.environment.common_dir) |c| return try w.fromStart(c); const file = try std.fs.path.join(gpa, &.{ dir, "commondir" }); defer gpa.free(file); var buffer: [std.fs.max_path_bytes]u8 = undefined; const contents = readSome(w.io, file, &buffer) catch |err| switch (err) { error.Canceled => |e| return e, error.FileNotFound => return try gpa.dupe(u8, dir), else => return try gpa.dupe(u8, dir), }; const path = parseCommonDir(contents); if (path.len == 0) { w.fail(file, "", .{}); return error.CommonDirEmpty; } const joined = try normalize(gpa, dir, path); defer gpa.free(joined); return try w.realPath(joined); } }; fn readSome(io: Io, path: []const u8, buffer: []u8) ![]u8 { return Io.Dir.cwd().readFile(io, path, buffer); } /// What the walk or `GIT_DIR` settled on, before the format is read. const Candidate = struct { found: Found, /// Where it was found: the directory holding `.git`, or the git /// directory itself. Absolute and real. dir: []u8, /// The git directory, absolute: real for a `.git` file or the walk's /// own directory, and as joined for `.git`. git_dir: []u8, /// The common directory, absolute. common_dir: []u8, /// The `.git` file, for a `.git` file. git_file: ?[]u8 = null, fn deinit(c: *Candidate, gpa: Allocator) void { gpa.free(c.dir); gpa.free(c.git_dir); gpa.free(c.common_dir); if (c.git_file) |f| gpa.free(f); } }; /// Finds the repository `options` describes. pub fn discover(gpa: Allocator, io: Io, options: Options, diag: ?*Diagnostics) Error!Location { var start_buffer: [std.fs.max_path_bytes]u8 = undefined; const start_len = Io.Dir.cwd().realPathFile(io, options.start, &start_buffer) catch |err| switch (err) { error.Canceled => |e| return e, else => { if (diag) |d| { d.reset(); d.setPath(&.{options.start}); d.setDetail("{s}", .{Config.reason(err)}); } return error.StartUnreadable; }, }; const start = start_buffer[0..start_len]; var w: Walker = .{ .gpa = gpa, .io = io, .options = options, .diag = diag, .start = start }; if (!try w.isDir(start)) { w.fail(options.start, "{s}", .{Config.reason(error.NotDir)}); return error.StartUnreadable; } var candidate = if (options.environment.git_dir) |g| try explicit(&w, g) else try walk(&w); defer candidate.deinit(gpa); if (candidate.found != .environment) try checkSafety(&w, &candidate); return locate(&w, &candidate); } /// The candidate `GIT_DIR` names. fn explicit(w: *Walker, git_dir: []const u8) Error!Candidate { const gpa = w.gpa; const abs = try w.fromStart(git_dir); defer gpa.free(abs); if (try w.kind(abs, true) == .file) { const target = try readGitFile(w, abs, git_dir); defer gpa.free(target); const common = try w.gitDirectory(target) orelse { w.fail(git_dir, "", .{}); return error.GitFileNotARepository; }; errdefer gpa.free(common); const dir = try gpa.dupe(u8, target); errdefer gpa.free(dir); return .{ .found = .environment, .dir = dir, .git_dir = try gpa.dupe(u8, target), .common_dir = common, .git_file = try gpa.dupe(u8, abs) }; } const common = (if (git_dir.len == 0) null else try w.gitDirectory(abs)) orelse { w.fail(git_dir, "", .{}); return error.GitDirInvalid; }; errdefer gpa.free(common); const real = try w.realPath(abs) orelse try gpa.dupe(u8, abs); errdefer gpa.free(real); return .{ .found = .environment, .dir = real, .git_dir = try gpa.dupe(u8, real), .common_dir = common }; } /// Reads the `.git` file at `abs`, named `name` in messages, and returns /// the real path of the git directory it names, or fails as git does. fn readGitFile(w: *Walker, abs: []const u8, name: []const u8) Error![]u8 { const gpa = w.gpa; var buffer: [std.fs.max_path_bytes + 64]u8 = undefined; const contents = readSome(w.io, abs, &buffer) catch |err| switch (err) { error.Canceled => |e| return e, else => { w.fail(name, "", .{}); return error.InvalidGitFile; }, }; const path = parseGitFile(contents) catch |err| { w.fail(name, "", .{}); return err; }; const dir = std.fs.path.dirname(abs) orelse "/"; const joined = try normalize(gpa, dir, path); defer gpa.free(joined); return try w.realPath(joined) orelse { w.fail(name, "", .{}); return error.GitFileNotARepository; }; } /// Walks up from the start. fn walk(w: *Walker) Error!Candidate { const gpa = w.gpa; const env = w.options.environment; const across = if (env.discovery_across_filesystem) |v| value.parseBool(v) orelse { w.fail(null, "{s}", .{v}); return error.BadDiscoveryAcrossFilesystem; } else false; const ceiling = try nearestCeiling(w); defer if (ceiling) |c| gpa.free(c); var start_device: ?u64 = null; if (!across) start_device = (try identify(w, w.start)).device; var dir: []const u8 = w.start; while (true) { const dot_git = try std.fs.path.join(gpa, &.{ dir, ".git" }); defer gpa.free(dot_git); switch (try w.kind(dot_git, true) orelse .unknown) { .file => { const target = try readGitFile(w, dot_git, dot_git); defer gpa.free(target); const common = try w.gitDirectory(target) orelse { w.fail(dot_git, "", .{}); return error.GitFileNotARepository; }; errdefer gpa.free(common); const d = try gpa.dupe(u8, dir); errdefer gpa.free(d); const g = try gpa.dupe(u8, target); errdefer gpa.free(g); return .{ .found = .git_file, .dir = d, .git_dir = g, .common_dir = common, .git_file = try gpa.dupe(u8, dot_git) }; }, .directory => if (try w.gitDirectory(dot_git)) |common| { errdefer gpa.free(common); const d = try gpa.dupe(u8, dir); errdefer gpa.free(d); return .{ .found = .dot_git, .dir = d, .git_dir = try gpa.dupe(u8, dot_git), .common_dir = common }; }, else => {}, } if (try w.gitDirectory(dir)) |common| { errdefer gpa.free(common); const d = try gpa.dupe(u8, dir); errdefer gpa.free(d); return .{ .found = .git_dir, .dir = d, .git_dir = try gpa.dupe(u8, dir), .common_dir = common }; } if (std.mem.eql(u8, dir, "/")) break; const parent = std.fs.path.dirname(dir) orelse "/"; if (ceiling) |c| if (parent.len <= c.len) break; if (start_device) |device| { if ((try identify(w, parent)).device != device) { w.fail(parent, "", .{}); return error.FilesystemBoundary; } } dir = parent; } w.fail(null, "", .{}); return error.NotARepository; } fn identify(w: *Walker, path: []const u8) Error!FileSystem.Identity { const fs = w.options.file_system; return fs.vtable.identify(fs.context, w.io, path) catch |err| switch (err) { error.Canceled => |e| return e, error.Unsupported => { w.fail(path, "", .{}); return error.FileSystemUnsupported; }, else => { w.fail(path, "{s}", .{Config.reason(err)}); return error.Unreadable; }, }; } /// The nearest ceiling strictly above the start, if any. fn nearestCeiling(w: *Walker) Error!?[]u8 { const gpa = w.gpa; const list = w.options.environment.ceiling_directories orelse return null; var best: ?[]u8 = null; errdefer if (best) |b| gpa.free(b); var it: CeilingIterator = .init(list); while (it.next()) |c| { const path = if (c.resolve) try w.realPath(c.path) orelse continue else try gpa.dupe(u8, if (c.path.len > 1 and c.path[c.path.len - 1] == '/') c.path[0 .. c.path.len - 1] else c.path); const above = path.len < w.start.len and isWithin(w.start, path); if (above and (best == null or path.len > best.?.len)) { if (best) |b| gpa.free(b); best = path; } else gpa.free(path); } return best; } /// Refuses a repository the walk found that git would not trust. fn checkSafety(w: *Walker, c: *const Candidate) Error!void { const protected = w.options.protected_config; if (w.options.ownership) |own| { var paths: [3][]const u8 = undefined; var n: usize = 0; paths[n] = c.dir; n += 1; switch (c.found) { .dot_git => { paths[n] = c.git_dir; n += 1; }, .git_file => { paths[n] = c.git_file.?; paths[n + 1] = c.git_dir; n += 2; }, else => {}, } const sudo: ?u32 = if (own.uid == 0) if (w.options.environment.sudo_uid) |s| std.fmt.parseInt(u32, s, 10) catch null else null else null; var trusted = true; for (paths[0..n]) |p| { const owner = (try identify(w, p)).owner; if (owner != own.uid and (sudo == null or owner != sudo.?)) trusted = false; } if (!trusted and !try safeDirectory(w, c.dir)) { w.fail(c.dir, "", .{}); return error.DubiousOwnership; } } if (c.found == .git_dir) { var explicit_only = false; if (protected) |config| { var it = config.all("safe.bareRepository") catch return error.OutOfMemory; while (it.next()) |e| { if (!isProtected(e.scope)) continue; const v = e.value orelse { setOrigin(w, e); return error.BadBareRepositorySetting; }; if (std.mem.eql(u8, v, "all")) { explicit_only = false; } else if (std.mem.eql(u8, v, "explicit")) { explicit_only = true; } else { setOrigin(w, e); return error.BadBareRepositorySetting; } } } if (explicit_only and !bareRepositoryExempt(c.dir)) { w.fail(c.dir, "", .{}); return error.BareRepositoryNotAllowed; } } } fn isProtected(scope: Config.Scope) bool { return switch (scope) { .system, .global, .command => true, .local, .worktree => false, }; } fn setOrigin(w: *Walker, e: *const Config.Entry) void { const d = w.diag orelse return; d.reset(); if (e.origin.kind == .file) d.setPath(&.{e.origin.name}); d.line = if (e.origin.kind == .file) e.line else null; } /// Whether `safe.directory` names `dir`. fn safeDirectory(w: *Walker, dir: []const u8) Error!bool { const gpa = w.gpa; const config = w.options.protected_config orelse return false; var it = config.all("safe.directory") catch return error.OutOfMemory; var safe = false; while (it.next()) |e| { if (!isProtected(e.scope)) continue; const raw = e.value orelse continue; if (raw.len == 0) { safe = false; continue; } if (std.mem.eql(u8, raw, "*")) { safe = true; continue; } const expanded = value.expandPath(gpa, raw, w.options.path) catch |err| switch (err) { error.OutOfMemory => |x| return x, else => continue, }; defer gpa.free(expanded.path); var path = expanded.path; if (path.len == 0 or path[0] != '/') { if (w.options.warnings) |list| { const text = try gpa.dupe(u8, raw); errdefer gpa.free(text); try list.append(gpa, .{ .kind = .relative_safe_directory, .text = text }); } continue; } const under = std.mem.endsWith(u8, path, "/*"); if (under) path = path[0 .. path.len - 2]; const real = try w.realPath(if (path.len == 0) "/" else path) orelse continue; defer gpa.free(real); if (under) { if (isWithin(dir, real) and dir.len > real.len) safe = true; } else if (std.mem.eql(u8, real, dir)) safe = true; } return safe; } /// Reads the candidate's format and settles where everything is. fn locate(w: *Walker, c: *const Candidate) Error!Location { const gpa = w.gpa; const env = w.options.environment; // Resolved, since `GIT_DIR` and `GIT_COMMON_DIR` may each be spelled // through a symbolic link. const git_dir_real = try w.realPath(c.git_dir) orelse try gpa.dupe(u8, c.git_dir); errdefer gpa.free(git_dir_real); const common_dir_real = try w.realPath(c.common_dir) orelse try gpa.dupe(u8, c.common_dir); errdefer gpa.free(common_dir_real); // A linked worktree, or a git directory given a common directory of // another: its common `config` does not decide its working tree. const linked = !std.mem.eql(u8, git_dir_real, common_dir_real); // The git directory as git holds it while it reads the format: what // the format's messages name. const held: []u8 = switch (c.found) { .environment => if (c.git_file != null) try gpa.dupe(u8, c.git_dir) else try gpa.dupe(u8, env.git_dir.?), .dot_git => try gpa.dupe(u8, ".git"), .git_dir => try gpa.dupe(u8, "."), .git_file => try gpa.dupe(u8, c.git_dir), }; defer gpa.free(held); const held_common: []const u8 = if (env.common_dir) |cd| cd else if (linked) common_dir_real else held; // git reads the format once it has moved to where the repository was // found, so a relative GIT_COMMON_DIR is taken from there, though it // was checked from the start. const format_common = if (env.common_dir) |cd| try normalize(gpa, if (c.found == .environment) w.start else c.dir, cd) else try gpa.dupe(u8, c.common_dir); defer gpa.free(format_common); var reader: RepositoryFormat.Reader = .init(gpa); errdefer reader.deinit(); const format = readFormat(w, c, format_common, &reader, held, held_common, linked) catch |err| { if (w.options.refused_git_dir) |out| switch (err) { error.UnsupportedVersion, error.UnknownExtensions, error.V1OnlyExtensions => { if (out.*) |old| gpa.free(old); out.* = gpa.dupe(u8, held) catch null; }, else => {}, }; return err; }; // The working tree. var work_tree: ?[]u8 = null; errdefer if (work_tree) |t| gpa.free(t); var explicit_tree = false; const core_worktree = if (env.common_dir != null) null else format.worktree; if (format.bare == true and core_worktree != null) { if (w.options.warnings) |list| try list.append(gpa, .{ .kind = .bare_and_worktree, .text = try gpa.dupe(u8, "") }); } if (env.work_tree) |t| { if (t.len == 0) { w.fail(null, "", .{}); return error.EmptyWorkTree; } const abs = try w.fromStart(t); work_tree = try w.realPath(abs) orelse abs; if (work_tree.?.ptr != abs.ptr) gpa.free(abs); explicit_tree = true; } else if (core_worktree != null and format.bare != true) { const t = core_worktree.?; const relative = t.len == 0 or t[0] != '/'; const abs = try normalize(gpa, git_dir_real, t); if (try w.realPath(abs)) |real| { gpa.free(abs); work_tree = real; } else if (relative) { gpa.free(abs); w.fail(null, "{s}", .{t}); return error.WorkTreeUnreachable; } else work_tree = abs; explicit_tree = true; } else if (format.bare != true) switch (c.found) { .environment => work_tree = try gpa.dupe(u8, w.start), .dot_git, .git_file => work_tree = try gpa.dupe(u8, c.dir), .git_dir => {}, }; const bare = work_tree == null and (format.bare_anywhere orelse (c.found == .git_dir)); // Where the start is in it. var prefix: ?[]u8 = null; errdefer if (prefix) |p| gpa.free(p); if (work_tree) |t| if (isWithin(w.start, t)) { const rest = std.mem.trimStart(u8, w.start[t.len..], "/"); prefix = if (rest.len == 0) try gpa.dupe(u8, "") else try std.mem.concat(gpa, u8, &.{ rest, "/" }); }; const work_dir = try gpa.dupe(u8, if (prefix != null) work_tree.? else w.start); errdefer gpa.free(work_dir); const moved = !std.mem.eql(u8, work_dir, w.start); // The git directory as git keeps it. const plain_dot_git = c.found == .dot_git and !explicit_tree and format.bare != true; const git_dir: []u8 = switch (c.found) { .dot_git => if (plain_dot_git or std.mem.eql(u8, c.dir, w.start) and !moved) try gpa.dupe(u8, ".git") else try gpa.dupe(u8, c.git_dir), .git_dir => if (std.mem.eql(u8, c.dir, w.start) and !moved) try gpa.dupe(u8, ".") else try gpa.dupe(u8, c.dir), .git_file => try gpa.dupe(u8, c.git_dir), .environment => if (c.git_file != null or moved and !std.fs.path.isAbsolute(env.git_dir.?)) try gpa.dupe(u8, git_dir_real) else try gpa.dupe(u8, env.git_dir.?), }; errdefer gpa.free(git_dir); const common_dir: []u8 = if (env.common_dir) |cd| try gpa.dupe(u8, cd) else if (linked) try gpa.dupe(u8, common_dir_real) else try gpa.dupe(u8, git_dir); errdefer gpa.free(common_dir); const object_directory = if (env.object_directory) |o| try gpa.dupe(u8, o) else null; errdefer if (object_directory) |o| gpa.free(o); return .{ .start = try gpa.dupe(u8, w.start), .work_dir = work_dir, .git_dir = git_dir, .common_dir = common_dir, .git_dir_real = git_dir_real, .common_dir_real = common_dir_real, .work_tree = work_tree, .prefix = prefix, .bare = bare, .inside_git_dir = isWithin(w.start, git_dir_real), .found = c.found, .object_directory = object_directory, .format = format, .format_strings = reader.arena, }; } /// Reads the candidate's format from its configuration files. fn readFormat(w: *Walker, c: *const Candidate, common_dir: []const u8, reader: *RepositoryFormat.Reader, held: []const u8, held_common: []const u8, linked: bool) Error!RepositoryFormat { const gpa = w.gpa; const config_name = try std.fs.path.join(gpa, &.{ held_common, "config" }); defer gpa.free(config_name); { const config_path = try std.fs.path.join(gpa, &.{ common_dir, "config" }); defer gpa.free(config_path); if (try readWhole(w, config_path)) |contents| { defer gpa.free(contents); try reader.addFile(contents, config_name, if (linked) .common else .main, w.diag); } } if (reader.worktreeConfig()) { const wt_path = try std.fs.path.join(gpa, &.{ c.git_dir, "config.worktree" }); defer gpa.free(wt_path); const wt_name = try std.fs.path.join(gpa, &.{ held, "config.worktree" }); defer gpa.free(wt_name); if (try readWhole(w, wt_path)) |contents| { defer gpa.free(contents); try reader.addFile(contents, wt_name, .worktree, w.diag); } } return reader.finish(config_name, w.diag); } fn readWhole(w: *Walker, path: []const u8) Error!?[]u8 { return Io.Dir.cwd().readFileAlloc(w.io, path, w.gpa, .unlimited) catch |err| switch (err) { error.Canceled => |e| return e, error.OutOfMemory => |e| return e, else => null, }; } // -- tests ----------------------------------------------------------------------- const testing = std.testing; const fuzz_input = @import("testing/fuzz.zig"); test "a .git file is read as git reads it" { try testing.expectEqualStrings("../real.git", try parseGitFile("gitdir: ../real.git\n")); try testing.expectEqualStrings("../real.git", try parseGitFile("gitdir: ../real.git\r\n\n")); try testing.expectEqualStrings(" x ", try parseGitFile("gitdir: x \n")); try testing.expectEqualStrings("a", try parseGitFile("gitdir: a\x00junk\n")); try testing.expectEqualStrings("a\nmore", try parseGitFile("gitdir: a\nmore\n")); try testing.expectError(error.InvalidGitFile, parseGitFile("gitdir:../x")); try testing.expectError(error.InvalidGitFile, parseGitFile("GITDIR: x")); try testing.expectError(error.InvalidGitFile, parseGitFile("")); try testing.expectError(error.GitFileNoPath, parseGitFile("gitdir: \n")); } test "a HEAD is plausible as git finds it" { try testing.expect(headIsPlausible("ref: refs/heads/main\n")); try testing.expect(headIsPlausible("ref:refs/")); try testing.expect(headIsPlausible("ref:\n\t refs/x")); try testing.expect(!headIsPlausible("ref: refs")); try testing.expect(!headIsPlausible("ref:\x0brefs/x")); try testing.expect(!headIsPlausible(" ref: refs/x")); try testing.expect(headIsPlausible("0123456789abcdefABCDEF0123456789abcdef01 junk")); try testing.expect(!headIsPlausible("0123456789abcdefABCDEF0123456789abcdef0")); try testing.expect(!headIsPlausible("")); try testing.expect(symlinkHeadIsPlausible("refs/")); try testing.expect(!symlinkHeadIsPlausible("refs")); } test "ceilings are read as git reads them" { var it: CeilingIterator = .init("rel:/a::/b/:/c"); try testing.expectEqualDeep(Ceiling{ .path = "/a", .resolve = true }, it.next().?); try testing.expectEqualDeep(Ceiling{ .path = "/b/", .resolve = false }, it.next().?); try testing.expectEqualDeep(Ceiling{ .path = "/c", .resolve = false }, it.next().?); try testing.expectEqual(null, it.next()); } test "bare repositories inside a .git are exempt" { try testing.expect(bareRepositoryExempt("/x/.git")); try testing.expect(bareRepositoryExempt("/x/.git/worktrees/a")); try testing.expect(bareRepositoryExempt("/x/.git/modules/a/b")); try testing.expect(!bareRepositoryExempt("/x/.git/modules")); try testing.expect(!bareRepositoryExempt("/x/.git/foo")); try testing.expect(!bareRepositoryExempt("/x/worktrees/a")); } test "paths are made relative as git makes them" { const gpa = testing.allocator; const cases = [_][3][]const u8{ .{ "/a/b", "/a/b", "./" }, .{ "/a/b/.git", "/a/b", ".git" }, .{ "/a", "/a/b/c", "../../" }, .{ "/a/r", "/a/sl", "../r" }, .{ "/", "/a", "../" }, .{ "/a/b", "/", "a/b" }, .{ "/ab", "/a", "../ab" }, }; for (cases) |c| { const got = try relativePath(gpa, c[0], c[1]); defer gpa.free(got); try testing.expectEqualStrings(c[2], got); } const n = try normalize(gpa, "/a/b", "../c/./d//e/.."); defer gpa.free(n); try testing.expectEqualStrings("/a/c/d", n); } fn fuzzOne(_: void, smith: *testing.Smith) anyerror!void { var buffer: [512]u8 = undefined; const input = @import("testing/fuzz.zig").bytes(smith, &buffer); _ = parseGitFile(input) catch {}; _ = headIsPlausible(input); _ = parseCommonDir(input); _ = bareRepositoryExempt(input); var it: CeilingIterator = .init(input); while (it.next()) |c| try testing.expect(c.path.len != 0 and c.path[0] == '/'); } test "fuzz: .git files, HEAD, commondir and ceilings" { try fuzz_input.fuzz({}, fuzzOne, .{ .corpus = &.{ @embedFile("fuzz/gitfile"), @embedFile("fuzz/gitfile-submodule"), @embedFile("fuzz/head-symref"), @embedFile("fuzz/ceilings"), } }); } const fixture = @import("testing/fixture.zig"); /// A filesystem in which everything below `mount` is on a device of its /// own, and everything belongs to `owner`. const FakeFileSystem = struct { mount: []const u8, owner: u32, fn fileSystem(f: *FakeFileSystem) FileSystem { return .{ .context = f, .vtable = &.{ .identify = fakeIdentify } }; } fn fakeIdentify(context: ?*anyopaque, io: Io, path: []const u8) FileSystem.IdentifyError!FileSystem.Identity { _ = io; const f: *FakeFileSystem = @ptrCast(@alignCast(context.?)); return .{ .owner = f.owner, .device = if (isWithin(path, f.mount)) 2 else 1 }; } }; test "the walk stops at a filesystem boundary, naming the directory beyond it" { var repo = try fixture.Repo.init(testing.allocator, testing.io); defer repo.deinit(); try repo.tmp.dir.createDirPath(testing.io, "mnt/a/b"); const mount = try std.fs.path.join(testing.allocator, &.{ repo.path, "mnt" }); defer testing.allocator.free(mount); const start = try std.fs.path.join(testing.allocator, &.{ mount, "a/b" }); defer testing.allocator.free(start); var fake: FakeFileSystem = .{ .mount = mount, .owner = 0 }; var diag: Diagnostics = .{}; try testing.expectError(error.FilesystemBoundary, discover(testing.allocator, testing.io, .{ .start = start, .file_system = fake.fileSystem(), }, &diag)); try testing.expectEqualStrings(repo.path, diag.path().?); // Unless told to cross it. var found = try discover(testing.allocator, testing.io, .{ .start = start, .file_system = fake.fileSystem(), .environment = .{ .discovery_across_filesystem = "true" }, }, null); defer found.deinit(testing.allocator); try testing.expectEqualStrings("mnt/a/b/", found.prefix.?); } test "a repository someone else owns is refused unless safe.directory names it" { var repo = try fixture.Repo.init(testing.allocator, testing.io); defer repo.deinit(); var fake: FakeFileSystem = .{ .mount = "/nowhere", .owner = 4242 }; var diag: Diagnostics = .{}; try testing.expectError(error.DubiousOwnership, discover(testing.allocator, testing.io, .{ .start = repo.path, .file_system = fake.fileSystem(), .ownership = .{ .uid = 1 }, }, &diag)); try testing.expectEqualStrings(repo.path, diag.path().?); var config: Config = .init(testing.allocator); defer config.deinit(); const setting = try std.fmt.allocPrint(testing.allocator, "safe.directory={s}", .{repo.path}); defer testing.allocator.free(setting); try config.addSources(testing.io, Io.Dir.cwd(), .{ .command_line = &.{setting} }, .{}, null); var found = try discover(testing.allocator, testing.io, .{ .start = repo.path, .file_system = fake.fileSystem(), .ownership = .{ .uid = 1 }, .protected_config = &config, }, null); defer found.deinit(testing.allocator); // Root may use what sudo's user owns. var as_root = try discover(testing.allocator, testing.io, .{ .start = repo.path, .file_system = fake.fileSystem(), .ownership = .{ .uid = 0 }, .environment = .{ .sudo_uid = "4242" }, }, null); defer as_root.deinit(testing.allocator); }