Open repositories that belong to another user

The site reads repositories owned by the git user that serves them over ssh, as its own service user - which is exactly what keeps the site from being able to write to them. libgit2 refuses that pairing: it is the "dubious ownership" check git itself grew, which stops config and hooks being picked up out of a repository someone else planted in a shared directory. It refuses silently, which is the expensive part. Repository.IsValid returns false, every repository quietly stops being a repository, and the index comes up empty with nothing in the log to say why. The first deploy against a real /home/git spent a while on that. Owner validation is off now. Nothing here runs anything out of a repository - it reads objects and refs, never hooks - and the directory it scans is named in configuration by whoever deployed it, so the check has nothing left to protect. Two diagnostics, so the next thing like this is shorter. A root whose directories all fail to open now says so and says how many there were, rather than claiming there are no repositories: listing a directory needs r and reading into it needs x, and a root with one and not the other looks from in here exactly like an empty one. And a directory with a HEAD and an objects directory that still will not open names itself in the log. Worth writing down because it cost an hour: sudo -u is not a proxy for what the service can do. Supplementary groups granted by the unit are applied per process at service start and are not in the user database, so id and sudo -u both show a user with none of them. systemd-run --uid with the same properties is the test that answers the question being asked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-09-13 20:15 UTC
commit
f4327415caf0e4c25456a6f20cc5feb4ae361682
parent
dac630ab10
tree
browse at this commit

1 file changed +55 -2

Blog/Services/GitService.cs +55 -2

@@ -66,6 +66,23 @@ public sealed class GitOptions
66 66 /// </remarks>
67 67 public sealed partial class GitService(IOptions<GitOptions> options, ILogger<GitService> logger)
68 68 {
69 + static GitService()
70 + {
71 + // libgit2 refuses a repository owned by a different user than the process, the "dubious
72 + // ownership" check git itself grew: picking up config and hooks out of a repository
73 + // someone else planted in a shared directory is a way to be handed code to run.
74 + //
75 + // That is this deployment exactly, and on purpose. The repositories belong to the git user
76 + // that serves them over ssh, and the site reads them as its own service user, which is
77 + // what keeps the site from being able to write to them. Nothing here runs anything out of
78 + // a repository - it reads objects and refs - and the directory it looks in is named in
79 + // configuration by whoever deployed it, so there is nothing left for the check to protect.
80 + //
81 + // Leaving it on does not produce an error. Every repository quietly stops being a
82 + // repository and the index comes up empty, which is a long way to walk back to here.
83 + GlobalSettings.SetOwnerValidation(false);
84 + }
85 +
69 86 private readonly GitOptions _options = options.Value;
70 87
71 88 /// <summary>The scan path, with <c>~/</c> expanded.</summary>
@@ -87,6 +104,7 @@ public sealed partial class GitService(IOptions<GitOptions> options, ILogger<Git
87 104 }
88 105
89 106 var found = new List<GitRepoSummary>();
107 + var candidates = 0;
90 108
91 109 try
92 110 {
@@ -95,6 +113,8 @@ public sealed partial class GitService(IOptions<GitOptions> options, ILogger<Git
95 113 var name = Path.GetFileName(directory);
96 114 if (!IsRepositoryName(name)) continue;
97 115
116 + candidates++;
117 +
98 118 try
99 119 {
100 120 using var repository = Open(directory);
@@ -116,6 +136,25 @@ public sealed partial class GitService(IOptions<GitOptions> options, ILogger<Git
116 136 return new GitIndexView([], $"{Root} cannot be read by the user this site runs as.");
117 137 }
118 138
139 + if (found.Count == 0 && candidates > 0)
140 + {
141 + // The names came back and not one of them opened. Two things do this: a root that
142 + // can be listed but not read into (r without x), where even a stat on the HEAD
143 + // inside fails; and a repository libgit2 will not touch, which included one owned by
144 + // another user until the static constructor above. Neither leaves anything more
145 + // specific to say than how many there were.
146 + logger.LogWarning(
147 + "None of the {Candidates} directories under {Root} could be opened as a repository",
148 + candidates,
149 + Root);
150 +
151 + return new GitIndexView(
152 + [],
153 + $"Nothing under {Root} could be opened as a repository, though there "
154 + + $"{(candidates == 1 ? "is 1 directory" : $"are {candidates} directories")} there. "
155 + + "The user this site runs as most likely cannot read into them.");
156 + }
157 +
119 158 return new GitIndexView(
120 159 found.OrderBy(repository => repository.DisplayName, StringComparer.OrdinalIgnoreCase).ToArray(),
121 160 null);
@@ -279,12 +318,26 @@ public sealed partial class GitService(IOptions<GitOptions> options, ILogger<Git
279 318 /// Bare repositories are the point of the scan path, but a working checkout keeps its git
280 319 /// directory one level down — worth handling, since that is what a development root holds.
281 320 /// </summary>
282 - private static Repository? Open(string directory)
321 + private Repository? Open(string directory)
283 322 {
284 323 if (Repository.IsValid(directory)) return new Repository(directory);
285 324
286 325 var dotGit = Path.Combine(directory, ".git");
287 - return Repository.IsValid(dotGit) ? new Repository(dotGit) : null;
326 + if (Repository.IsValid(dotGit)) return new Repository(dotGit);
327 +
328 + // Not a repository, which for most directories under a scan path is unremarkable and
329 + // silent. One that has a HEAD and an objects directory and still will not open is not
330 + // unremarkable: something is stopping libgit2 reading a repository that is plainly there,
331 + // and an empty index with nothing in the log is no way to find out what.
332 + if (File.Exists(Path.Combine(directory, "HEAD")) && Directory.Exists(Path.Combine(directory, "objects")))
333 + {
334 + logger.LogWarning(
335 + "{Directory} looks like a bare repository, but libgit2 will not open it. Check that "
336 + + "the user this site runs as can read it",
337 + directory);
338 + }
339 +
340 + return null;
288 341 }
289 342
290 343 private GitRepoSummary Summarise(Repository repository, string name)