Skip to content

Issue/9 create on push - #23

Merged
Zefek merged 2 commits into
issue/7-validate-repository-pathfrom
issue/9-create-on-push
Sep 19, 2026
Merged

Zefek merged 2 commits into
issue/7-validate-repository-pathfrom
issue/9-create-on-push

Conversation

@Zefek

@Zefek Zefek commented Sep 19, 2026

Copy link
Copy Markdown
Owner

No description provided.

Zefek and others added 2 commits September 19, 2026 14:41
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.
@sonarqubecloud

Copy link
Copy Markdown

@Zefek
Zefek merged commit 6de7941 into issue/7-validate-repository-path Sep 19, 2026
2 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant