Issue/9 create on push - #23
Merged
Merged
Conversation
Creating a repository was the one step that still needed a shell on the server, which the home page said out loud in its empty state. AllowCreateOnPush closes that gap: a push to a name that does not exist creates the bare repository and the push completes. Default false, so 1.0.0 behaviour is unchanged. Creation runs after the Authorize hook, so authorization is what gates it, and both halves of a push count -- the ref advertisement as well as the POST, since a 404 on the advertisement means the POST never happens. git-upload-pack never creates anything. The name comes from GitRepositoryPath, a lock keyed on the resolved path serialises concurrent pushes to the same new name, a repository already reachable under the other .git suffix is not duplicated, an existing repository is never re-initialised, and a failed creation returns 500 and takes its own leftovers with it rather than letting the backend emit a reasonless 500. The created repository gets http.receivepack = true, a git-daemon-export-ok marker when ExportAll is false, and SafeDirectories applied to the git init so creation works under a service account. It also gets its HEAD pointed at the branch that was pushed: git init --bare writes HEAD -> refs/heads/master and receive-pack never revises it, so a repository created by pushing main would otherwise clone out empty. A HEAD that already resolves is left alone. Covered end to end against the real git client, including the negative cases: clone, unauthorized caller, malformed name and the option being off all leave the filesystem untouched. Refs #9
* Test project and a real clone/push in CI A green build proves the code compiles against .NET. It proves nothing about whether a clone still works after the next Git update, because what this library parses is the observable behaviour of git-http-backend, a binary upgraded on its own schedule. Only an end-to-end test against the real thing does that. Unit tests cover the code that is riskiest and least visible. CgiHeaderParser: both separator forms, a separator split across single-byte reads, a Status line with and without a reason phrase, a malformed Status falling back to 200, a header line with no colon, EOF before any separator, the MaxHeaderBytes guard, and body bytes arriving with the headers surfacing intact through ConcatStream. ConcatStream: prefix shorter than, equal to and longer than the read buffer, sync and async agreeing, and the inner stream surviving disposal. Environment construction: the CGI mapping, and SafeDirectories extending rather than overwriting a GIT_CONFIG_COUNT supplied through ExtraEnvironment -- the ordering comment states that guarantee, so a test now holds it. PopulateEnvironment became static and internal to make that reachable without starting a process. End to end against the real git client: clone, push, a push refused when http.receivepack is unset, a 6 MB incompressible file forcing genuine packfile streaming across the header/body boundary, protocol v2 engaging only when the Git-Protocol header is passed through, a non-ASCII repository name, 404 for an unknown repository, and no git-http-backend process outliving the request. The 403 case points BackendPath at a file that is not a runnable image, so a clean 403 rather than a 500 proves no process was started. ci.yml now runs dotnet test, and the job fails when a test fails. Tests needing the git client skip rather than fail when it is absent, so the suite stays usable on a machine without Git. Refs #11 * Stop building the invoker twice in the sample (#19) The sample logged which git-http-backend was found by constructing a second GitHttpBackendInvoker, which ran GitBackendLocator.Locate() again -- another git --exec-path process and another round of file-existence checks -- two files away from a comment saying the invoker is constructed once. MapGitHttpBackend gains an overload taking an invoker the caller already built. The invoker exposes the options it was constructed with, so the overload needs nothing else and HandleAsync reads the hook off the instance it was handed. The existing (prefix, options) overload keeps its signature and behaviour and now delegates to the new one, so nothing breaks for current callers. The sample builds one invoker, logs its BackendPath and maps it, which also means a bad backend path now fails before the log line rather than after it. Refs #14 * Issue/12 backup guidance (#20) * Document how to back up the repositories The value of hosting configuration in local bare repositories is history. In the deployment this is built for those repositories are deliberately not on GitHub, so that history exists in exactly one copy on one disk -- a disk failure does not lose a working tree, it loses every version ever recorded. The README never mentioned backups, so anyone following it landed in that position without being told. Adds a "Backing up" section: the mirror clone with remote update --prune, where to put it, the encryption-at-rest precondition for sending it offsite stated plainly, scheduling with Task Scheduler, restore as a plain git clone from the mirror, and verification with git fsck. Also what a mirror does not cover -- appsettings.json and the proxy configuration. Adds samples/Backup-GitRepositories.ps1, which walks the bare repositories under ProjectRoot, clones on the first run, updates afterwards, optionally runs fsck, and exits non-zero when any repository fails so a scheduled task reports the failure rather than quietly reporting success. The script is covered end to end rather than only documented, which is the same argument the section itself makes about backups: the first run clones, the second picks up a new commit, a clone from the mirror recovers the content, an empty project root is not a failure and a missing one is. Its git wrapper relaxes ErrorActionPreference around the native call, because git writes ordinary progress to stderr and redirecting that under 'Stop' would turn a successful clone into a terminating error. Nothing in the library grows a backup feature. Scheduling and storage are the host's job, and building them in would contradict the reason this project is small. Refs #12 * Lead with the single-file service, not the NuGet packages (#18) The README opened with a table of two NuGet libraries and a sample. The audience that most needs this project -- someone who wants a few bare repositories served over HTTP on a machine they already own, without standing up a forge -- read that and left. CI already builds exactly what that audience wants; only the framing was missing. The README now leads with the standalone server: why this and not Gitea (including when Gitea is the right answer), download and run, the intended loopback plus reverse-proxy topology stated explicitly with the reason Basic auth over a plaintext listener is sound only in it, Windows service installation end to end with a pointer to SafeDirectories for the ownership problem that follows from choosing a service account, and a worked example of the deployment this was written for -- configuration repositories cloned by CI runners over HTTPS with a token, secrets encrypted at rest. Backups link to their own section. The NuGet packages move to a section further down, for the reader embedding this in an existing application, which is a real use case but not the first one. The download had to become real. ci.yml uploads the published output with retention-days: 30, so "download the executable" was true for a month after each build and false afterwards. release-nuget.yml now publishes the self-contained single-file build and attaches it to the GitHub Release it already creates, so every download link in the README points at a release asset. That workflow was also still installing the .NET 10 SDK while the branch targets net11.0, which would have failed the moment a tag was pushed. The NuGet Description fields in both .csproj files are untouched: they are read on nuget.org, where the library framing is the correct one. Refs #10 * Honour forwarded headers behind a reverse proxy (#21) * Honour forwarded headers behind a reverse proxy The intended topology is Kestrel on loopback with a proxy terminating HTTPS in front, and nothing read the forwarded headers. Two consequences, both visible today: REMOTE_ADDR and every handler log line said 127.0.0.1 for every caller, so "which machine fetched this configuration" was unanswerable; and the home page built clone URLs from the incoming request, offering git clone http://... to someone who arrived over https://. The sample now wires ForwardedHeaders for X-Forwarded-For and X-Forwarded-Proto, registered before authentication so the authentication handler and the git handler both see the corrected values. Opt-in through Git:ForwardedHeaders:Enabled, off by default: trusting these headers when nothing is actually in front lets any client name its own source address, which does not blank the audit trail, it falsifies it. KnownProxies is populated rather than widened -- loopback unless configured -- and KnownNetworks is cleared so the set of addresses allowed to speak for someone else is a decision rather than a leftover default. Tested from where it matters, the address the library hands to git: the real client address and scheme arrive when the source is a known proxy, a forged header from anywhere else is ignored, and with the setting off nothing changes. appsettings.json is gitignored in this repository, so the setting and its spoofing caveat are documented in the README instead of shipped in the file. Refs #8 * Finish the merge of the invoker overload and the pipeline hooks Both sides of the merge changed how GitTestServer starts: #8 added configureBuilder / configurePipeline hooks inside StartAsync, #14 moved that body out into StartCoreAsync. The resolution kept the call sites from one side and the signature from the other, so the test project did not compile -- StartCoreAsync still took a single argument while being called with three, and the three-argument StartAsync kept an async modifier it can no longer have now that it is an expression body returning a Task. StartCoreAsync takes the two hooks, and configurePipeline runs before map so middleware registered by a test is in place ahead of the git endpoint -- without that ordering the forwarded-header tests would pass while testing nothing. The README kept both descriptions of the reverse-proxy topology: the one the restructure put near the top and the one that came in with the forwarded-header work, the latter landing after "Building from source" where it does not belong. Merged into a single section, keeping the concrete consequences from the second.
|
Zefek
added a commit
that referenced
this pull request
Sep 19, 2026
* Validate the repository path in one place The repository a request refers to was derived twice, by two different parsers: the library passed the routing catch-all straight through to git-http-backend, while the sample took the first path segment to decide authorization. Nothing guaranteed the two agreed, and where they can be made to disagree a caller is authorized for one repository while git serves another. GitRepositoryPath.TryParse is now the single parser. It rejects traversal, percent-encoded input, backslash and UNC separators, drive qualifiers, control characters and Windows trailing-dot aliasing, while leaving non-ASCII names working. MapGitHttpBackend calls it before the Authorize hook and returns 400 without starting a backend process; the sample's RepoFromPath is gone and its authorization decision now comes from the same function. Adds tests/GitHttpBackend.Tests and tests/GitHttpBackend.AspNetCore.Tests, both IsPackable=false. The unit tests pin every rejection and acceptance case above. The integration tests host real Kestrel and assert that a malformed path is refused before the Authorize hook runs, and that a valid one still reaches git. Tests needing the git client skip rather than fail when it is absent. Refs #7 * Issue/9 create on push (#23) * Optional create-on-push Creating a repository was the one step that still needed a shell on the server, which the home page said out loud in its empty state. AllowCreateOnPush closes that gap: a push to a name that does not exist creates the bare repository and the push completes. Default false, so 1.0.0 behaviour is unchanged. Creation runs after the Authorize hook, so authorization is what gates it, and both halves of a push count -- the ref advertisement as well as the POST, since a 404 on the advertisement means the POST never happens. git-upload-pack never creates anything. The name comes from GitRepositoryPath, a lock keyed on the resolved path serialises concurrent pushes to the same new name, a repository already reachable under the other .git suffix is not duplicated, an existing repository is never re-initialised, and a failed creation returns 500 and takes its own leftovers with it rather than letting the backend emit a reasonless 500. The created repository gets http.receivepack = true, a git-daemon-export-ok marker when ExportAll is false, and SafeDirectories applied to the git init so creation works under a service account. It also gets its HEAD pointed at the branch that was pushed: git init --bare writes HEAD -> refs/heads/master and receive-pack never revises it, so a repository created by pushing main would otherwise clone out empty. A HEAD that already resolves is left alone. Covered end to end against the real git client, including the negative cases: clone, unauthorized caller, malformed name and the option being off all leave the filesystem untouched. Refs #9 * Issue/11 tests and ci (#22) * Test project and a real clone/push in CI A green build proves the code compiles against .NET. It proves nothing about whether a clone still works after the next Git update, because what this library parses is the observable behaviour of git-http-backend, a binary upgraded on its own schedule. Only an end-to-end test against the real thing does that. Unit tests cover the code that is riskiest and least visible. CgiHeaderParser: both separator forms, a separator split across single-byte reads, a Status line with and without a reason phrase, a malformed Status falling back to 200, a header line with no colon, EOF before any separator, the MaxHeaderBytes guard, and body bytes arriving with the headers surfacing intact through ConcatStream. ConcatStream: prefix shorter than, equal to and longer than the read buffer, sync and async agreeing, and the inner stream surviving disposal. Environment construction: the CGI mapping, and SafeDirectories extending rather than overwriting a GIT_CONFIG_COUNT supplied through ExtraEnvironment -- the ordering comment states that guarantee, so a test now holds it. PopulateEnvironment became static and internal to make that reachable without starting a process. End to end against the real git client: clone, push, a push refused when http.receivepack is unset, a 6 MB incompressible file forcing genuine packfile streaming across the header/body boundary, protocol v2 engaging only when the Git-Protocol header is passed through, a non-ASCII repository name, 404 for an unknown repository, and no git-http-backend process outliving the request. The 403 case points BackendPath at a file that is not a runnable image, so a clean 403 rather than a 500 proves no process was started. ci.yml now runs dotnet test, and the job fails when a test fails. Tests needing the git client skip rather than fail when it is absent, so the suite stays usable on a machine without Git. Refs #11 * Stop building the invoker twice in the sample (#19) The sample logged which git-http-backend was found by constructing a second GitHttpBackendInvoker, which ran GitBackendLocator.Locate() again -- another git --exec-path process and another round of file-existence checks -- two files away from a comment saying the invoker is constructed once. MapGitHttpBackend gains an overload taking an invoker the caller already built. The invoker exposes the options it was constructed with, so the overload needs nothing else and HandleAsync reads the hook off the instance it was handed. The existing (prefix, options) overload keeps its signature and behaviour and now delegates to the new one, so nothing breaks for current callers. The sample builds one invoker, logs its BackendPath and maps it, which also means a bad backend path now fails before the log line rather than after it. Refs #14 * Issue/12 backup guidance (#20) * Document how to back up the repositories The value of hosting configuration in local bare repositories is history. In the deployment this is built for those repositories are deliberately not on GitHub, so that history exists in exactly one copy on one disk -- a disk failure does not lose a working tree, it loses every version ever recorded. The README never mentioned backups, so anyone following it landed in that position without being told. Adds a "Backing up" section: the mirror clone with remote update --prune, where to put it, the encryption-at-rest precondition for sending it offsite stated plainly, scheduling with Task Scheduler, restore as a plain git clone from the mirror, and verification with git fsck. Also what a mirror does not cover -- appsettings.json and the proxy configuration. Adds samples/Backup-GitRepositories.ps1, which walks the bare repositories under ProjectRoot, clones on the first run, updates afterwards, optionally runs fsck, and exits non-zero when any repository fails so a scheduled task reports the failure rather than quietly reporting success. The script is covered end to end rather than only documented, which is the same argument the section itself makes about backups: the first run clones, the second picks up a new commit, a clone from the mirror recovers the content, an empty project root is not a failure and a missing one is. Its git wrapper relaxes ErrorActionPreference around the native call, because git writes ordinary progress to stderr and redirecting that under 'Stop' would turn a successful clone into a terminating error. Nothing in the library grows a backup feature. Scheduling and storage are the host's job, and building them in would contradict the reason this project is small. Refs #12 * Lead with the single-file service, not the NuGet packages (#18) The README opened with a table of two NuGet libraries and a sample. The audience that most needs this project -- someone who wants a few bare repositories served over HTTP on a machine they already own, without standing up a forge -- read that and left. CI already builds exactly what that audience wants; only the framing was missing. The README now leads with the standalone server: why this and not Gitea (including when Gitea is the right answer), download and run, the intended loopback plus reverse-proxy topology stated explicitly with the reason Basic auth over a plaintext listener is sound only in it, Windows service installation end to end with a pointer to SafeDirectories for the ownership problem that follows from choosing a service account, and a worked example of the deployment this was written for -- configuration repositories cloned by CI runners over HTTPS with a token, secrets encrypted at rest. Backups link to their own section. The NuGet packages move to a section further down, for the reader embedding this in an existing application, which is a real use case but not the first one. The download had to become real. ci.yml uploads the published output with retention-days: 30, so "download the executable" was true for a month after each build and false afterwards. release-nuget.yml now publishes the self-contained single-file build and attaches it to the GitHub Release it already creates, so every download link in the README points at a release asset. That workflow was also still installing the .NET 10 SDK while the branch targets net11.0, which would have failed the moment a tag was pushed. The NuGet Description fields in both .csproj files are untouched: they are read on nuget.org, where the library framing is the correct one. Refs #10 * Honour forwarded headers behind a reverse proxy (#21) * Honour forwarded headers behind a reverse proxy The intended topology is Kestrel on loopback with a proxy terminating HTTPS in front, and nothing read the forwarded headers. Two consequences, both visible today: REMOTE_ADDR and every handler log line said 127.0.0.1 for every caller, so "which machine fetched this configuration" was unanswerable; and the home page built clone URLs from the incoming request, offering git clone http://... to someone who arrived over https://. The sample now wires ForwardedHeaders for X-Forwarded-For and X-Forwarded-Proto, registered before authentication so the authentication handler and the git handler both see the corrected values. Opt-in through Git:ForwardedHeaders:Enabled, off by default: trusting these headers when nothing is actually in front lets any client name its own source address, which does not blank the audit trail, it falsifies it. KnownProxies is populated rather than widened -- loopback unless configured -- and KnownNetworks is cleared so the set of addresses allowed to speak for someone else is a decision rather than a leftover default. Tested from where it matters, the address the library hands to git: the real client address and scheme arrive when the source is a known proxy, a forged header from anywhere else is ignored, and with the setting off nothing changes. appsettings.json is gitignored in this repository, so the setting and its spoofing caveat are documented in the README instead of shipped in the file. Refs #8 * Finish the merge of the invoker overload and the pipeline hooks Both sides of the merge changed how GitTestServer starts: #8 added configureBuilder / configurePipeline hooks inside StartAsync, #14 moved that body out into StartCoreAsync. The resolution kept the call sites from one side and the signature from the other, so the test project did not compile -- StartCoreAsync still took a single argument while being called with three, and the three-argument StartAsync kept an async modifier it can no longer have now that it is an expression body returning a Task. StartCoreAsync takes the two hooks, and configurePipeline runs before map so middleware registered by a test is in place ahead of the git endpoint -- without that ordering the forwarded-header tests would pass while testing nothing. The README kept both descriptions of the reverse-proxy topology: the one the restructure put near the top and the one that came in with the forwarded-header work, the latter landing after "Building from source" where it does not belong. Merged into a single section, keeping the concrete consequences from the second.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



No description provided.