diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index cbf2aa0..ab59089 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -5,14 +5,56 @@ on: push: branches: - dockerize + - claude/csharp-rewrite + pull_request: + env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: + test: + name: Build image and run HTTP-level tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image for tests (amd64, loaded into docker) + uses: docker/build-push-action@v6 + with: + context: csharp + file: csharp/Dockerfile + load: true + platforms: linux/amd64 + tags: hgresume-csharp:test + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Run HTTP-level tests against the image + working-directory: csharp + env: + # The test fixture drives a container via this CLI (docker on the runner); it starts/stops + # the pre-built image itself, so we skip the fixture's own build step. + HGRESUME_PODMAN: docker + HGRESUME_IMAGE: hgresume-csharp:test + HGRESUME_SKIP_BUILD: '1' + HGRESUME_PORT: '8034' + run: dotnet test test/HgResume.HttpTests/HgResume.HttpTests.csproj --logger "console;verbosity=normal" + build: - name: Push docker image to Github packages + name: Build and push docker image to GitHub packages + needs: test runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - name: Checkout @@ -21,10 +63,16 @@ jobs: - name: Set Version run: | echo "VERSION=v$(date --rfc-3339=date)" >> ${GITHUB_ENV} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to the Container registry + # Fork PRs get a read-only GITHUB_TOKEN; skip login/push so the job still builds without a 403. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} @@ -36,14 +84,19 @@ jobs: uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # For a pull request this produces the tag `pr-`, so the image can be pulled and + # tested in a real environment: `docker pull ghcr.io/:pr-`. tags: | type=ref,event=branch - ${{ env.VERSION }} - - name: Build and push Docker image - uses: docker/build-push-action@v5 + type=ref,event=pr + type=raw,value=${{ env.VERSION }},enable=${{ github.event_name != 'pull_request' }} + + - name: Build and push Docker image (C#) + uses: docker/build-push-action@v6 with: - context: . - push: true + context: csharp + file: csharp/Dockerfile + push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file + labels: ${{ steps.meta.outputs.labels }} diff --git a/csharp/.dockerignore b/csharp/.dockerignore new file mode 100644 index 0000000..ad2f5aa --- /dev/null +++ b/csharp/.dockerignore @@ -0,0 +1,10 @@ +# Build context is this directory (`csharp/`); the Dockerfile only COPY src/. +# Keep the ~20 MB HttpTests fixture tree (and the rest of the harness) out of every build. +test/ +**/bin/ +**/obj/ +*.md +*.slnx +run-tests.sh +run-tests.ps1 +.gitignore diff --git a/csharp/.gitignore b/csharp/.gitignore new file mode 100644 index 0000000..222be3c --- /dev/null +++ b/csharp/.gitignore @@ -0,0 +1,9 @@ +bin/ +obj/ +*.user +.idea/ +.vs/ + +# Bundled Mercurial dropped into the SendReceive test project by SIL.Chorus.Mercurial at build time +test/HgResume.SendReceiveTests/Mercurial/ +test/HgResume.SendReceiveTests/MercurialExtensions/ diff --git a/csharp/Dockerfile b/csharp/Dockerfile new file mode 100644 index 0000000..0b95999 --- /dev/null +++ b/csharp/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 + +# ---- build ------------------------------------------------------------------- +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY src/HgResume.Api/HgResume.Api.csproj src/HgResume.Api/ +RUN dotnet restore src/HgResume.Api/HgResume.Api.csproj +COPY src/ src/ +RUN dotnet publish src/HgResume.Api/HgResume.Api.csproj -c Release -o /app /p:UseAppHost=false + +# ---- runtime ----------------------------------------------------------------- +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +# mercurial: required for all hg operations (shelled out to, same as the PHP app). +RUN apt-get update \ + && apt-get install -y --no-install-recommends mercurial \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Run as root so we can bind port 80 and freely read/write the repo + cache volumes +# (auth is handled by the surrounding platform, matching the PHP www-data deployment model). +USER root + +RUN mkdir -p /var/cache/hgresume /var/vcs/public /var/vcs/private + +ENV ASPNETCORE_URLS=http://+:80 +ENV HGRESUME_CACHE_PATH=/var/cache/hgresume +ENV HGRESUME_REPO_PATHS="/var/vcs/public;/var/vcs/private" +ENV HGRESUME_MAINTENANCE_FILE=/var/cache/hgresume/maintenance_message.txt + +EXPOSE 80 +VOLUME /var/cache/hgresume +VOLUME /var/vcs/public + +WORKDIR /app +COPY --from=build /app . +ENTRYPOINT ["dotnet", "HgResume.Api.dll"] diff --git a/csharp/HgResume.slnx b/csharp/HgResume.slnx new file mode 100644 index 0000000..38414f1 --- /dev/null +++ b/csharp/HgResume.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/csharp/README.md b/csharp/README.md new file mode 100644 index 0000000..6d0c197 --- /dev/null +++ b/csharp/README.md @@ -0,0 +1,67 @@ +# hgresume — C# / ASP.NET Core rewrite + +A drop-in, wire-compatible reimplementation of the PHP `hgresume` API (see `../api`) that provides a +server-side REST API for **resumable Mercurial bundle transfer**. It is a faithful port of the +`dockerize` (`c905288`) PHP lineage: same endpoints, same `X-HgR-*` header protocol, same status-code +mapping, and the same shell-out-to-`hg` behaviour — so existing Chorus clients work unchanged. + +Authentication is intentionally **not** implemented here; it is handled by the surrounding platform +(reverse proxy / gateway), matching how the container is deployed. + +## Layout + +- `src/HgResume.Api/` — the ASP.NET Core app (net10.0). + - `RestDispatcher` — routes on the last path segment (`/api/v03/`), binds query/body params + (including `baseHashes[]`), and writes the `X-HgR-*` response contract. + - `HgResumeApi` — push/pull/getRevisions/finish*/isAvailable, faithful to the PHP state machine. + - `HgRunner` — shells out to `hg` (incoming/unbundle/bundle `-t v1`/log/branches/tip), parsing stdout verbatim. + - `AsyncRunner` — runs long hg commands in the background and signals completion via a `.async_run` + file, so a later HTTP request can observe the result (this is what makes transfers resumable). + - `BundleHelper` — per-transaction state + metadata (stored as JSON). +- `test/HgResume.HttpTests/` — HTTP-level xUnit tests ported from `api/test/HgResumeApi_Test.php`. They + drive the **running container** over HTTP (via a podman-managed fixture) and assert on the protocol. +- `Dockerfile` — multi-stage `dotnet/sdk:10.0` → `dotnet/aspnet:10.0`, installs `mercurial`. + Listens on port 80 and exposes the same `/var/cache/hgresume` and `/var/vcs/public` volumes as the + PHP image, so it is a drop-in replacement in the existing `docker-compose.yaml`. +- `.dockerignore` — keeps `test/` (including large fixture zips) out of the image build context. + +## Configuration (environment variables) + +| Variable | Default | Purpose | +|---|---|---| +| `HGRESUME_CACHE_PATH` | `/var/cache/hgresume` | bundle + transaction cache | +| `HGRESUME_REPO_PATHS` | `/var/vcs/public;/var/vcs/private` | `;`-separated repo search paths | +| `HGRESUME_MAINTENANCE_FILE` | `/maintenance_message.txt` | non-empty file ⇒ 503 maintenance mode | +| `HGRESUME_MAX_REQUEST_BODY_SIZE` | `30000000` | max request body bytes (Kestrel; raise for whole-bundle pushes) | +| `ASPNETCORE_URLS` | `http://+:80` | listen address | + +## Build & run + +```bash +podman build -t hgresume-csharp:test -f Dockerfile . +podman run -d --name hgresume -p 8034:80 \ + -v /path/to/repos:/var/vcs/public \ + hgresume-csharp:test +curl -i http://localhost:8034/api/v03/isAvailable +``` + +## Tests + +The HTTP-level suite builds the image, runs it in a container, seeds fixture repos by extracting +zips on the host and `podman cp`-ing them in, and exercises the protocol end-to-end: + +```bash +./run-tests.sh # or: pwsh ./run-tests.ps1 +``` + +Useful env overrides: `HGRESUME_IMAGE`, `HGRESUME_PORT`, `HGRESUME_SKIP_BUILD`, and +`HGRESUME_BASE_URL` + `HGRESUME_CONTAINER` (to run the tests against an already-running container). + +## CI + +`.github/workflows/docker-image.yml` builds this image and, on a pull request, pushes it to GHCR tagged +`pr-` (multi-arch amd64/arm64) so it can be pulled and tested in a real environment: + +```bash +docker pull ghcr.io/sillsdev/hgresume:pr- +``` diff --git a/csharp/run-tests.ps1 b/csharp/run-tests.ps1 new file mode 100644 index 0000000..64fafb1 --- /dev/null +++ b/csharp/run-tests.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +# Builds the C# hgresume image with podman, then runs the HTTP-level test suite against a container. +# The test fixture starts/stops the container itself; this script just builds the image first. +param( + [string]$Image = "hgresume-csharp:test", + [string]$Port = "8034", + [switch]$SkipBuild +) + +$ErrorActionPreference = "Stop" +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +Push-Location $here +try { + if (-not $SkipBuild) { + Write-Host "==> Building image $Image" -ForegroundColor Cyan + podman build -t $Image -f Dockerfile . + } + + $env:HGRESUME_IMAGE = $Image + $env:HGRESUME_PORT = $Port + $env:HGRESUME_SKIP_BUILD = "1" # already built above + + Write-Host "==> Running HTTP-level tests against the image" -ForegroundColor Cyan + dotnet test test/HgResume.HttpTests/HgResume.HttpTests.csproj --logger "console;verbosity=normal" +} +finally { + Pop-Location +} diff --git a/csharp/run-tests.sh b/csharp/run-tests.sh new file mode 100644 index 0000000..0ad04fa --- /dev/null +++ b/csharp/run-tests.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Builds the C# hgresume image with podman, then runs the HTTP-level test suite against a container. +# The test fixture starts/stops the container itself; this script just builds the image first. +set -euo pipefail + +IMAGE="${HGRESUME_IMAGE:-hgresume-csharp:test}" +PORT="${HGRESUME_PORT:-8034}" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$here" + +if [ "${1:-}" != "--skip-build" ]; then + echo "==> Building image $IMAGE" + podman build -t "$IMAGE" -f Dockerfile . +fi + +export HGRESUME_IMAGE="$IMAGE" +export HGRESUME_PORT="$PORT" +export HGRESUME_SKIP_BUILD=1 + +echo "==> Running HTTP-level tests against the image" +dotnet test test/HgResume.HttpTests/HgResume.HttpTests.csproj --logger "console;verbosity=normal" diff --git a/csharp/src/HgResume.Api/ApiConfig.cs b/csharp/src/HgResume.Api/ApiConfig.cs new file mode 100644 index 0000000..094b0ad --- /dev/null +++ b/csharp/src/HgResume.Api/ApiConfig.cs @@ -0,0 +1,53 @@ +namespace HgResume.Api; + +/// +/// Runtime configuration. Mirrors api/src/config.php (CACHE_PATH, repoSearchPaths, API_VERSION) +/// plus the maintenance-message file location (PHP: SourcePath . "/maintenance_message.txt"). +/// All values are overridable via environment variables so the container and tests can point +/// at alternate paths. +/// +public sealed class ApiConfig +{ + public const int ApiVersion = 3; // PHP: define('API_VERSION', 3) + + /// Kestrel's stock default (30_000_000). PHP deployments had an analogous post_max_size. + public const long DefaultMaxRequestBodySize = 30_000_000; + + public required string CachePath { get; init; } + public required IReadOnlyList RepoSearchPaths { get; init; } + public required string MaintenanceFilePath { get; init; } + + /// + /// Max pushBundleChunk (and any other) request body in bytes. Maps to + /// KestrelServerLimits.MaxRequestBodySize. Oversize bodies get a bare 413 before the dispatcher. + /// + public required long MaxRequestBodySize { get; init; } + + public static ApiConfig FromEnvironment() + { + string cache = Env("HGRESUME_CACHE_PATH", "/var/cache/hgresume"); + string repos = Env("HGRESUME_REPO_PATHS", "/var/vcs/public;/var/vcs/private"); + string maintenance = Env("HGRESUME_MAINTENANCE_FILE", Path.Combine(cache, "maintenance_message.txt")); + + return new ApiConfig + { + CachePath = cache, + RepoSearchPaths = repos + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + MaintenanceFilePath = maintenance, + MaxRequestBodySize = EnvLong("HGRESUME_MAX_REQUEST_BODY_SIZE", DefaultMaxRequestBodySize), + }; + } + + private static string Env(string name, string fallback) + { + string? v = Environment.GetEnvironmentVariable(name); + return string.IsNullOrWhiteSpace(v) ? fallback : v; + } + + private static long EnvLong(string name, long fallback) + { + string? v = Environment.GetEnvironmentVariable(name); + return long.TryParse(v, out var n) && n > 0 ? n : fallback; + } +} diff --git a/csharp/src/HgResume.Api/AsyncRunner.cs b/csharp/src/HgResume.Api/AsyncRunner.cs new file mode 100644 index 0000000..fcd0cea --- /dev/null +++ b/csharp/src/HgResume.Api/AsyncRunner.cs @@ -0,0 +1,139 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text; + +namespace HgResume.Api; + +/// +/// Mirrors api/src/AsyncRunner.php. Runs a long hg command in the background and signals completion +/// through a lock/output file so that a later (separate) HTTP request can observe the result — this +/// is what makes push/pull resumable and stateless. +/// +/// The PHP original launched a detached shell that piped output into "<path>.async_run" and used +/// GNU /usr/bin/time to append an "AsyncCompleted:" marker. We instead run the process in-process on +/// a background task (kept alive via a static registry so it survives the originating request) and, +/// on completion, write the combined stdout+stderr followed by an "AsyncCompleted" marker. When the +/// process exits non-zero we also append "Command exited with non-zero status <code>", preserving +/// BundleHelper.BundleOutputHasErrors detection without depending on GNU time. +/// +public sealed class AsyncRunner +{ + private const string CompletedMarker = "AsyncCompleted"; + + // Keep background tasks (and the Process they capture) alive until they finish. + private static readonly ConcurrentDictionary Running = new(); + + private readonly string _lockFile; + + public AsyncRunner(string runFilePath) + { + _lockFile = runFilePath + ".async_run"; + } + + /// Launches the command in the background. Returns immediately. + public void Run(string workingDir, string program, params string[] args) + { + // touch the lock file so IsRunning() is true immediately (matches PHP `touch`) + File.WriteAllText(_lockFile, string.Empty); + + var psi = new ProcessStartInfo + { + FileName = program, + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + + var proc = Process.Start(psi) + ?? throw new AsyncRunnerException($"failed to start process '{program}'"); + + string lockFile = _lockFile; + // Assigned before the body can observe it: Task.Run queues to the thread pool and returns + // the Task before the delegate runs (except under a sync context that runs inline). + Task task = null!; + task = Task.Run(async () => + { + try + { + var stdoutTask = proc.StandardOutput.ReadToEndAsync(); + var stderrTask = proc.StandardError.ReadToEndAsync(); + await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false); + await proc.WaitForExitAsync().ConfigureAwait(false); + + var sb = new StringBuilder(); + sb.Append(stdoutTask.Result); + sb.Append(stderrTask.Result); + if (proc.ExitCode != 0) + { + sb.Append($"\nCommand exited with non-zero status {proc.ExitCode}\n"); + } + sb.Append($"\n{CompletedMarker}: done\n"); + File.WriteAllText(lockFile, sb.ToString()); + } + catch (Exception e) + { + // Ensure a completion marker is always written so pollers do not hang forever. + File.WriteAllText(lockFile, $"AsyncRunner error: {e.Message}\n{CompletedMarker}: error\n"); + } + finally + { + proc.Dispose(); + // Value-conditional remove so we never clear a newer run for the same lock path. + Running.TryRemove(KeyValuePair.Create(lockFile, task)); + } + }); + // Insert after Task.Run returns. A very fast command can finish (and TryRemove in finally) + // before that insert, which would otherwise leave a leaked entry for the process lifetime. + Running[lockFile] = task; + if (task.IsCompleted) + { + Running.TryRemove(KeyValuePair.Create(lockFile, task)); + } + } + + public bool IsRunning() => File.Exists(_lockFile); + + public bool IsComplete() + { + if (!File.Exists(_lockFile)) + { + throw new AsyncRunnerException($"Lock file '{_lockFile}' not found, process is not running"); + } + return ReadLockFile().Contains(CompletedMarker); + } + + public string GetOutput() + { + if (!IsComplete()) + { + throw new AsyncRunnerException($"Command on '{_lockFile}' not yet complete."); + } + return ReadLockFile(); + } + + public void CleanUp() + { + if (File.Exists(_lockFile)) File.Delete(_lockFile); + } + + /// Waits up to ~5s for the runner to complete. Mirrors PHP waitForIsComplete(). + public bool WaitForIsComplete() + { + for (int i = 0; i < 5; i++) + { + if (IsComplete()) return true; + Thread.Sleep(1000); + } + return false; + } + + private string ReadLockFile() + { + // Share read/write so we never contend with the background writer. + using var fs = new FileStream(_lockFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(fs, Encoding.UTF8); + return reader.ReadToEnd(); + } +} diff --git a/csharp/src/HgResume.Api/BundleHelper.cs b/csharp/src/HgResume.Api/BundleHelper.cs new file mode 100644 index 0000000..bf8bfb2 --- /dev/null +++ b/csharp/src/HgResume.Api/BundleHelper.cs @@ -0,0 +1,141 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace HgResume.Api; + +/// +/// Mirrors api/src/BundleHelper.php. Per-transaction state machine plus on-disk metadata. +/// Metadata is stored as JSON (the PHP original used serialize()); only in-flight transactions +/// are affected by the format, and clients recover from a lost transaction via RESET. +/// +public sealed class BundleHelper +{ + public const string State_Start = "Start"; + public const string State_Bundle = "Bundle"; + public const string State_Downloading = "Downloading"; + public const string State_Uploading = "Uploading"; + public const string State_Validating = "Validating"; + public const string State_Unbundle = "Unbundle"; + + private static readonly Regex AlphaNumeric = new("^[a-zA-Z0-9_\\-]+$", RegexOptions.Compiled); + + private readonly string _transactionId; + private readonly string _basePath; + + public BundleHelper(ApiConfig config, string id) + { + if (!ValidateAlphaNumeric(id)) + { + throw new ValidationException($"transId {id} did not validate as alpha numeric!"); + } + _transactionId = id; + _basePath = config.CachePath; + } + + private string GetBundleDir() + { + if (!Directory.Exists(_basePath)) + { + try + { + Directory.CreateDirectory(_basePath); + } + catch (Exception e) + { + throw new BundleHelperException($"Failed to create repo dir: {_basePath} ({e.Message})"); + } + } + return _basePath; + } + + public bool Exists() => File.Exists(BundleFileName); + + /// Removes the bundle file and the meta file. Always returns true (matches PHP). + public bool CleanUp() + { + if (File.Exists(BundleFileName)) File.Delete(BundleFileName); + if (File.Exists(MetaDataFileName)) File.Delete(MetaDataFileName); + return true; + } + + /// Checks hg output. Returns true if error indicators are found. Mirrors PHP verbatim. + public static bool BundleOutputHasErrors(string output) + { + return output.Contains("abort") + || output.Contains("invalid") + || output.Contains("exited with non-zero status 255"); + } + + public string GetBundleBaseFilePath() => Path.Combine(GetBundleDir(), _transactionId); + + public string BundleFileName => GetBundleBaseFilePath() + ".bundle"; + + private string MetaDataFileName => GetBundleBaseFilePath() + ".metadata"; + + public static bool ValidateAlphaNumeric(string? str) => str != null && AlphaNumeric.IsMatch(str); + + /// Start of window (aka offset). + public int GetOffset() + { + var metadata = GetMetadata(); + if (metadata.TryGetValue("offset", out var v) && int.TryParse(v, out var i)) + { + return i; + } + return 0; + } + + public void SetOffset(int val) + { + var metadata = GetMetadata(); + metadata["offset"] = val.ToString(); + SetMetadata(metadata); + } + + public string State + { + get + { + var result = GetProp("state"); + return string.IsNullOrEmpty(result) ? State_Start : result; + } + set => SetProp("state", value); + } + + private Dictionary GetMetadata() + { + if (!File.Exists(MetaDataFileName)) + { + return new Dictionary(); + } + string json = File.ReadAllText(MetaDataFileName, Encoding.UTF8); + if (string.IsNullOrWhiteSpace(json)) + { + return new Dictionary(); + } + return JsonSerializer.Deserialize>(json) + ?? new Dictionary(); + } + + private void SetMetadata(Dictionary arr) + { + GetBundleDir(); + File.WriteAllText(MetaDataFileName, JsonSerializer.Serialize(arr), Encoding.UTF8); + } + + public string GetProp(string key) + { + var metadata = GetMetadata(); + return metadata.TryGetValue(key, out var v) ? v : ""; + } + + public void SetProp(string key, string value) + { + var metadata = GetMetadata(); + metadata[key] = value; + SetMetadata(metadata); + } + + public bool HasProp(string key) => GetMetadata().ContainsKey(key); +} diff --git a/csharp/src/HgResume.Api/Exceptions.cs b/csharp/src/HgResume.Api/Exceptions.cs new file mode 100644 index 0000000..54b0725 --- /dev/null +++ b/csharp/src/HgResume.Api/Exceptions.cs @@ -0,0 +1,27 @@ +namespace HgResume.Api; + +// Mirrors api/src/HgExceptions.php +public class HgException : Exception +{ + public HgException(string message) : base(message) { } +} + +public class UnrelatedRepoException : HgException +{ + public UnrelatedRepoException(string message) : base(message) { } +} + +public class AsyncRunnerException : Exception +{ + public AsyncRunnerException(string message) : base(message) { } +} + +public class BundleHelperException : Exception +{ + public BundleHelperException(string message) : base(message) { } +} + +public class ValidationException : Exception +{ + public ValidationException(string message) : base(message) { } +} diff --git a/csharp/src/HgResume.Api/HgResume.Api.csproj b/csharp/src/HgResume.Api/HgResume.Api.csproj new file mode 100644 index 0000000..8570d09 --- /dev/null +++ b/csharp/src/HgResume.Api/HgResume.Api.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + enable + true + HgResume.Api + HgResume.Api + + + diff --git a/csharp/src/HgResume.Api/HgResumeApi.cs b/csharp/src/HgResume.Api/HgResumeApi.cs new file mode 100644 index 0000000..2fbf80e --- /dev/null +++ b/csharp/src/HgResume.Api/HgResumeApi.cs @@ -0,0 +1,452 @@ +using System.Text; + +namespace HgResume.Api; + +/// +/// Mirrors api/src/HgResumeApi.php. The public methods map 1:1 to the REST endpoints. All chunk +/// sizes/offsets are raw byte counts. Behaviour (state machine, status codes, response values) is a +/// faithful port of c905288. +/// +public sealed class HgResumeApi +{ + private readonly ApiConfig _config; + + public HgResumeApi(ApiConfig config) + { + _config = config; + } + + // ---- push ----------------------------------------------------------------------------------- + + public HgResumeResponse PushBundleChunk(string repoId, int bundleSize, int offset, byte[] data, string transId) + { + var availability = IsAvailable(); + if (availability.Code == HgResumeResponse.NOTAVAILABLE) + { + return availability; + } + + string repoPath = GetRepoPath(repoId); + if (string.IsNullOrEmpty(repoPath)) + { + return new HgResumeResponse(HgResumeResponse.UNKNOWNID); + } + var hg = new HgRunner(repoPath); + + if (offset < 0 || offset >= bundleSize) + { + return Fail("invalid offset"); + } + int dataSize = data.Length; + if (dataSize == 0) + { + return Fail("no data sent"); + } + if (dataSize > bundleSize - offset) + { + return Fail("data sent is larger than remaining bundle size"); + } + if (bundleSize < 0) + { + return Fail("negative bundle size"); + } + + var bundle = new BundleHelper(_config, transId); + switch (bundle.State) + { + case BundleHelper.State_Start: + bundle.State = BundleHelper.State_Uploading; + goto case BundleHelper.State_Uploading; + + case BundleHelper.State_Uploading: + // If the data sent falls before the start of window, mark it received and reply with the + // correct startOfWindow. Fail on overlap/mismatch after the window. + int startOfWindow = bundle.GetOffset(); + if (offset != startOfWindow) + { + if (offset < startOfWindow) + { + return new HgResumeResponse(HgResumeResponse.RECEIVED, new Dictionary + { + ["sow"] = startOfWindow.ToString(), + ["Note"] = "server received duplicate data", + }); + } + return new HgResumeResponse(HgResumeResponse.FAIL, new Dictionary + { + ["sow"] = startOfWindow.ToString(), + ["Error"] = $"data sent ({dataSize}) with offset ({offset}) falls after server's start of window ({startOfWindow})", + }); + } + + // write chunk data to bundle file (chunks arrive in order so offset == current length) + using (var fs = new FileStream(bundle.BundleFileName, FileMode.Append, FileAccess.Write)) + { + fs.Write(data, 0, data.Length); + } + + int newSow = offset + dataSize; + bundle.SetOffset(newSow); + + if (newSow != bundleSize) + { + // not the last chunk; expect more + return new HgResumeResponse(HgResumeResponse.RECEIVED, new Dictionary + { + ["transId"] = transId, + ["sow"] = newSow.ToString(), + }); + } + // got everything -> assemble/apply the bundle + goto case BundleHelper.State_Validating; + + case BundleHelper.State_Validating: + case BundleHelper.State_Unbundle: + return CompletePushBundle(bundle, hg, transId, bundleSize); + } + + return new HgResumeResponse(HgResumeResponse.FAIL); // unreachable, mirrors PHP returning null + } + + private HgResumeResponse CompletePushBundle(BundleHelper bundle, HgRunner hg, string transId, int bundleSize) + { + try + { + string bundleFilePath = bundle.BundleFileName; + switch (bundle.State) + { + case BundleHelper.State_Uploading: + bundle.State = BundleHelper.State_Validating; + hg.StartValidating(bundleFilePath); + goto case BundleHelper.State_Validating; + + case BundleHelper.State_Validating: + if (hg.FinishValidating(bundleFilePath)) + { + bundle.State = BundleHelper.State_Unbundle; + hg.Unbundle(bundleFilePath); + goto case BundleHelper.State_Unbundle; + } + return HgResumeResponse.PendingResponse(transId, "Verification in progress...", bundleSize); + + case BundleHelper.State_Unbundle: + var asyncRunner = new AsyncRunner(bundleFilePath); + if (asyncRunner.WaitForIsComplete()) + { + if (BundleHelper.BundleOutputHasErrors(asyncRunner.GetOutput())) + { + return new HgResumeResponse(HgResumeResponse.RESET, new Dictionary + { + ["transId"] = transId, + }); + } + bundle.CleanUp(); + asyncRunner.CleanUp(); + return new HgResumeResponse(HgResumeResponse.SUCCESS, new Dictionary + { + ["transId"] = transId, + }); + } + return HgResumeResponse.PendingResponse(transId, "Unpacking in progress...", bundleSize); + } + return new HgResumeResponse(HgResumeResponse.FAIL); + } + catch (UnrelatedRepoException e) + { + bundle.SetOffset(0); + return new HgResumeResponse(HgResumeResponse.FAIL, new Dictionary + { + ["Error"] = Truncate(e.Message), + ["transId"] = transId, + }); + } + catch (Exception e) + { + bundle.SetOffset(0); + return new HgResumeResponse(HgResumeResponse.RESET, new Dictionary + { + ["Error"] = Truncate(e.Message), + ["transId"] = transId, + }); + } + } + + // ---- pull ----------------------------------------------------------------------------------- + + public HgResumeResponse PullBundleChunk(string repoId, IReadOnlyList baseHashes, int offset, + int chunkSize, string transId) + => PullBundleChunkInternal(repoId, baseHashes, offset, chunkSize, transId, false); + + public HgResumeResponse PullBundleChunkInternal(string repoId, IReadOnlyList baseHashes, int offset, + int chunkSize, string transId, bool waitForBundleToFinish) + { + try + { + var availability = IsAvailable(); + if (availability.Code == HgResumeResponse.NOTAVAILABLE) + { + return availability; + } + + string repoPath = GetRepoPath(repoId); + if (string.IsNullOrEmpty(repoPath)) + { + return new HgResumeResponse(HgResumeResponse.UNKNOWNID); + } + if (offset < 0) + { + return Fail("invalid offset"); + } + + var hg = new HgRunner(repoPath); + if (!hg.IsValidBase(baseHashes)) + { + return Fail("invalid baseHash"); + } + + // If every requested baseHash is a branch tip then no pull is necessary + var sortedBase = baseHashes.OrderBy(h => h, StringComparer.Ordinal).ToList(); + var branchTips = hg.GetBranchTips(); + branchTips.Sort(StringComparer.Ordinal); + if (branchTips.Count == 0) + { + return new HgResumeResponse(HgResumeResponse.NOCHANGE); + } + if (sortedBase.Count == branchTips.Count) + { + bool areEqual = true; + for (int i = 0; i < sortedBase.Count; i++) + { + if (sortedBase[i] != branchTips[i]) areEqual = false; + } + if (areEqual) + { + return new HgResumeResponse(HgResumeResponse.NOCHANGE); + } + } + + var bundle = new BundleHelper(_config, transId); + string bundleFilename = bundle.BundleFileName; + var asyncRunner = new AsyncRunner(bundleFilename); + if (!bundle.Exists()) + { + // Client asked for offset > 0 but the bundle had to be created now -> cache expired. + if (offset > 0) + { + return new HgResumeResponse(HgResumeResponse.RESET, new Dictionary + { + ["Error"] = "Cannot request data for bundle that doesnt exist yet", + }); + } + // first pull request (offset == 0): make a new bundle + asyncRunner = waitForBundleToFinish + ? hg.MakeBundleAndWaitUntilFinished(sortedBase, bundleFilename) + : hg.MakeBundle(sortedBase, bundleFilename); + bundle.SetProp("tip", hg.GetTip()); + bundle.SetProp("repoId", repoId); + bundle.State = BundleHelper.State_Bundle; + } + + var response = new HgResumeResponse(HgResumeResponse.SUCCESS); + switch (bundle.State) + { + case BundleHelper.State_Bundle: + if (asyncRunner.IsComplete()) + { + if (BundleHelper.BundleOutputHasErrors(asyncRunner.GetOutput())) + { + return new HgResumeResponse(HgResumeResponse.FAIL, new Dictionary + { + ["Error"] = Truncate(asyncRunner.GetOutput()), + }); + } + bundle.State = BundleHelper.State_Downloading; + } + for (int i = 0; i < 7; i++) + { + if (CanGetChunkBelowBundleSize(bundleFilename, chunkSize, offset)) + { + byte[] data = GetChunk(bundleFilename, chunkSize, offset); + response.Values = new Dictionary + { + ["bundleSize"] = new FileInfo(bundleFilename).Length.ToString(), + ["chunkSize"] = data.Length.ToString(), + ["transId"] = transId, + }; + response.Content = data; + return response; // break out of loop and switch (PHP: break 2) + } + Thread.Sleep(2000); + } + response = new HgResumeResponse(HgResumeResponse.INPROGRESS); + break; + + case BundleHelper.State_Downloading: + byte[] chunk = GetChunk(bundleFilename, chunkSize, offset); + long size = new FileInfo(bundleFilename).Length; + response.Values = new Dictionary + { + ["bundleSize"] = size.ToString(), + ["chunkSize"] = chunk.Length.ToString(), + ["transId"] = transId, + }; + response.Content = chunk; + if (offset > size) + { + throw new ValidationException( + $"offset {offset} is greater than or equal to bundleSize {size}"); + } + break; + } + + return response; + } + catch (Exception e) + { + return Fail(Truncate(e.Message)); + } + } + + private static bool CanGetChunkBelowBundleSize(string filename, int chunkSize, int offset) + { + var fi = new FileInfo(filename); + return fi.Exists && offset + chunkSize < fi.Length; + } + + private static byte[] GetChunk(string filename, int chunkSize, int offset) + { + var fi = new FileInfo(filename); + if (!fi.Exists || offset >= fi.Length) + { + return Array.Empty(); + } + using var fs = new FileStream(filename, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + fs.Seek(offset, SeekOrigin.Begin); + int toRead = (int)Math.Min(chunkSize, fs.Length - offset); + var buffer = new byte[toRead]; + int read = 0; + while (read < toRead) + { + int n = fs.Read(buffer, read, toRead - read); + if (n == 0) break; + read += n; + } + if (read < toRead) Array.Resize(ref buffer, read); + return buffer; + } + + // ---- misc ----------------------------------------------------------------------------------- + + public HgResumeResponse GetRevisions(string repoId, int offset, int quantity) + { + var availability = IsAvailable(); + if (availability.Code == HgResumeResponse.NOTAVAILABLE) + { + return availability; + } + try + { + string repoPath = GetRepoPath(repoId); + if (string.IsNullOrEmpty(repoPath)) + { + return new HgResumeResponse(HgResumeResponse.UNKNOWNID); + } + var hg = new HgRunner(repoPath); + var revisionList = hg.GetRevisions(offset, quantity); + return new HgResumeResponse(HgResumeResponse.SUCCESS, new Dictionary(), + string.Join("|", revisionList)); + } + catch (Exception e) + { + return Fail(Truncate(e.Message)); + } + } + + public HgResumeResponse FinishPushBundle(string transId) + { + var bundle = new BundleHelper(_config, transId); + return bundle.CleanUp() + ? new HgResumeResponse(HgResumeResponse.SUCCESS) + : new HgResumeResponse(HgResumeResponse.FAIL); + } + + public HgResumeResponse FinishPullBundle(string transId) + { + var bundle = new BundleHelper(_config, transId); + if (bundle.HasProp("tip") && bundle.HasProp("repoId")) + { + string repoPath = GetRepoPath(bundle.GetProp("repoId")); + if (Directory.Exists(repoPath)) + { + var hg = new HgRunner(repoPath); + // check that the repo has not been updated since the pull started + if (bundle.GetProp("tip") != hg.GetTip()) + { + bundle.CleanUp(); + return new HgResumeResponse(HgResumeResponse.RESET); + } + } + } + return bundle.CleanUp() + ? new HgResumeResponse(HgResumeResponse.SUCCESS) + : new HgResumeResponse(HgResumeResponse.FAIL); + } + + public HgResumeResponse IsAvailable() + { + if (IsAvailableAsBool()) + { + return new HgResumeResponse(HgResumeResponse.SUCCESS); + } + string message = File.ReadAllText(_config.MaintenanceFilePath, Encoding.UTF8); + return new HgResumeResponse(HgResumeResponse.NOTAVAILABLE, new Dictionary(), message); + } + + private bool IsAvailableAsBool() + { + string file = _config.MaintenanceFilePath; + if (File.Exists(file) && new FileInfo(file).Length > 0) + { + return false; + } + return true; + } + + private string GetRepoPath(string repoId) + { + // Client-supplied repoId is joined onto each configured search root. Reject anything that is + // not a single path segment (e.g. "../other") so callers cannot escape those roots. + if (!IsSafeRepoId(repoId)) + { + return ""; + } + + foreach (var basePath in _config.RepoSearchPaths) + { + string possibleRepoPath = Path.Combine(basePath, repoId); + if (Directory.Exists(possibleRepoPath)) + { + return possibleRepoPath; + } + } + return ""; + } + + /// + /// True only when is a single directory name under a search root. + /// Uses Path.GetFileName: if stripping directory parts changes the value, the input had path data. + /// + private static bool IsSafeRepoId(string repoId) + { + if (string.IsNullOrEmpty(repoId) || repoId is "." or "..") + { + return false; + } + return Path.GetFileName(repoId) == repoId; + } + + private static HgResumeResponse Fail(string error) => + new(HgResumeResponse.FAIL, new Dictionary { ["Error"] = error }); + + private static string Truncate(string s) => s.Length > 1000 ? s.Substring(0, 1000) : s; +} diff --git a/csharp/src/HgResume.Api/HgResumeResponse.cs b/csharp/src/HgResume.Api/HgResumeResponse.cs new file mode 100644 index 0000000..476b9b5 --- /dev/null +++ b/csharp/src/HgResume.Api/HgResumeResponse.cs @@ -0,0 +1,60 @@ +using System.Text; + +namespace HgResume.Api; + +/// +/// Mirrors api/src/HgResumeResponse.php. +/// NOTE the const collision that exists in the PHP source: INPROGRESS and TIMEOUT are BOTH 9. +/// The PHP RestServer::mapHgResponse switch lists INPROGRESS (202) before TIMEOUT (408), so a +/// value of 9 always maps to 202 Accepted / "INPROGRESS" and the 408 branch is dead code. +/// PendingResponse() therefore produces a 202, and the v3 Chorus client reads its sow header and +/// resends the last 10 bytes as a poll. We reproduce that behaviour exactly. +/// +public sealed class HgResumeResponse +{ + public const int SUCCESS = 0; + public const int RECEIVED = 1; + public const int RESET = 3; + public const int UNAUTHORIZED = 4; + public const int FAIL = 5; + public const int UNKNOWNID = 6; + public const int NOCHANGE = 7; + public const int NOTAVAILABLE = 8; + public const int INPROGRESS = 9; + public const int TIMEOUT = 9; // identical to INPROGRESS in c905288 (see class remarks) + + public int Code { get; set; } + public Dictionary Values { get; set; } + public byte[] Content { get; set; } + public int Version { get; set; } + + public HgResumeResponse(int code, Dictionary? values = null, byte[]? content = null, + int version = ApiConfig.ApiVersion) + { + Code = code; + Values = values ?? new Dictionary(); + Content = content ?? Array.Empty(); + Version = version; + } + + public HgResumeResponse(int code, Dictionary values, string content, + int version = ApiConfig.ApiVersion) + : this(code, values, Encoding.UTF8.GetBytes(content), version) + { + } + + /// + /// Mirrors HgResumeResponse::PendingResponse. Returns a status the v3 client does not recognise + /// (mapped to 202) and shrinks the client's next window to 10 bytes via sow = bundleSize - 10. + /// + public static HgResumeResponse PendingResponse(string transId, string note, int bundleSize) + { + return new HgResumeResponse(TIMEOUT, new Dictionary + { + ["transId"] = transId, + ["Note"] = note, + // Make the client send only 10 bytes + ["sow"] = (bundleSize - 10).ToString(), + }); + } +} diff --git a/csharp/src/HgResume.Api/HgRunner.cs b/csharp/src/HgResume.Api/HgRunner.cs new file mode 100644 index 0000000..d256e02 --- /dev/null +++ b/csharp/src/HgResume.Api/HgRunner.cs @@ -0,0 +1,224 @@ +using System.Text.RegularExpressions; + +namespace HgResume.Api; + +/// +/// Mirrors api/src/HgRunner.php. All Mercurial operations shell out to the `hg` binary; stdout is +/// parsed verbatim (same templates/regexes as the PHP original) so behaviour matches byte-for-byte. +/// The PHP code chdir()'d into the repo before each call; we pass WorkingDirectory instead. +/// +public sealed class HgRunner +{ + private static readonly Regex UnknownParent = new("abort:.*unknown parent", RegexOptions.Compiled); + private static readonly Regex ParentMinusOne = new("parent:\\s*-1:", RegexOptions.Compiled); + private static readonly Regex NotAMercurialBundle = new("abort:.*not a Mercurial bundle", RegexOptions.Compiled); + + public string RepoPath { get; } + + public HgRunner(string repoPath) + { + if (!Directory.Exists(repoPath)) + { + throw new ValidationException($"repo '{repoPath}' doesn't exist!"); + } + RepoPath = repoPath; + } + + // ---- push side: two-phase validate then unbundle -------------------------------------------- + + public void StartValidating(string filepath) + { + if (!File.Exists(filepath)) + { + throw new HgException($"bundle file '{filepath}' is not a file!"); + } + // run hg incoming to make sure this bundle is related to the repo + GetValidationRunner(filepath).Run(RepoPath, "hg", "incoming", filepath); + } + + /// true if validation finished, otherwise false (still running) + public bool FinishValidating(string filepath) + { + var asyncRunner = GetValidationRunner(filepath); + if (asyncRunner.WaitForIsComplete()) + { + string output = asyncRunner.GetOutput(); + if (UnknownParent.IsMatch(output)) + { + throw new UnrelatedRepoException("Project is unrelated! (unrelated bundle pushed to repo)"); + } + if (ParentMinusOne.IsMatch(output)) + { + throw new UnrelatedRepoException("Project is unrelated! (unrelated bundle pushed to repo)"); + } + if (NotAMercurialBundle.IsMatch(output)) + { + throw new HgException("Project cannot be updated! (corrupt bundle pushed to repo)"); + } + return true; + } + return false; + } + + private static AsyncRunner GetValidationRunner(string filepath) => new(filepath + ".incoming"); + + public AsyncRunner Unbundle(string filepath) + { + if (!File.Exists(filepath)) + { + throw new HgException($"bundle file '{filepath}' is not a file!"); + } + var asyncRunner = new AsyncRunner(filepath); + asyncRunner.Run(RepoPath, "hg", "unbundle", filepath); + return asyncRunner; + } + + public AsyncRunner Update(string revision = "") + { + var asyncRunner = new AsyncRunner(Path.Combine(RepoPath, "hg_update")); + if (string.IsNullOrEmpty(revision)) + { + asyncRunner.Run(RepoPath, "hg", "update"); + } + else + { + asyncRunner.Run(RepoPath, "hg", "update", revision); + } + return asyncRunner; + } + + // ---- pull side: bundle creation ------------------------------------------------------------- + + public AsyncRunner MakeBundle(IReadOnlyList baseHashes, string bundleFilePath) + { + var args = new List { "bundle" }; + if (baseHashes.Count == 1 && baseHashes[0] == "0") + { + args.Add("--all"); + args.Add(bundleFilePath); + } + else + { + foreach (var hash in baseHashes) + { + args.Add("--base"); + args.Add(hash); + } + args.Add(bundleFilePath); + } + args.Add("-t"); + args.Add("v1"); + + var asyncRunner = new AsyncRunner(bundleFilePath); + asyncRunner.Run(RepoPath, "hg", args.ToArray()); + return asyncRunner; + } + + public AsyncRunner MakeBundleAndWaitUntilFinished(IReadOnlyList baseHashes, string bundleFilePath) + { + var asyncRunner = MakeBundle(baseHashes, bundleFilePath); + if (!asyncRunner.WaitForIsComplete()) + { + throw new HgException("Error: make bundle failed to complete"); + } + return asyncRunner; + } + + // ---- revision inspection -------------------------------------------------------------------- + + /// a baseHash (without branch information) + public string GetTip() + { + var revisionArray = GetRevisions(0, 1); + string first = revisionArray[0]; + int colon = first.IndexOf(':'); + return colon >= 0 ? first.Substring(0, colon) : first; + } + + public List GetBranchTips() + { + var (branches, _) = ProcessRunner.RunSync(RepoPath, "hg", "branches"); + var revisionArray = new List(); + foreach (var branch in branches) + { + string branchName; + if (branch.Length == 0) + { + branchName = "default"; + } + else + { + int space = branch.IndexOf(' '); + branchName = space >= 0 ? branch.Substring(0, space) : branch; + } + revisionArray.AddRange(GetRevisionsInternal(0, 1, branchName)); + } + var revisions = new List(); + foreach (var hashAndBranch in revisionArray) + { + int colon = hashAndBranch.IndexOf(':'); + revisions.Add(colon >= 0 ? hashAndBranch.Substring(0, colon) : hashAndBranch); + } + return revisions; + } + + /// Returns "hash:branch" pairs, e.g. 'fb7a8f23394d:default'. + public List GetRevisions(int offset, int quantity) => GetRevisionsInternal(offset, quantity, null); + + private List GetRevisionsInternal(int offset, int quantity, string? branch) + { + if (quantity < 1) + { + throw new ValidationException("quantity parameter much be larger than 0"); + } + // ':' is illegal in branch names (it is used in tags) so we use it to split hash and branch + string[] args = branch is null + ? new[] { "log", "--template", "{node|short}:{branches}\n" } + : new[] { "log", "-b", branch, "--template", "{node|short}:{branches}\n" }; + + var (output, _) = ProcessRunner.RunSync(RepoPath, "hg", args); + if (output.Count == 0) + { + var (tip, _) = ProcessRunner.RunSync(RepoPath, "hg", "tip", "--template", "{rev}:{branches}\n"); + if (tip.Count == 1 && tip[0].StartsWith("-1")) + { + // e.g. '-1:default' -> '0:default' to signal the empty repo + tip[0] = Regex.Replace(tip[0], "^-1", "0"); + return tip; + } + throw new HgException($"command 'hg log' failed!\n"); + } + return output.Skip(offset).Take(quantity).ToList(); + } + + public bool IsValidBase(IReadOnlyList hashes) + { + if (hashes.Count == 1 && hashes[0] == "0") + { + return true; // special case indicating revision 0 + } + int foundHash = 0; + const int q = 200; + int i = 0; + while (foundHash < hashes.Count) + { + var revisions = GetRevisions(i, q); + if (revisions.Count == 0) + { + return false; + } + foreach (var hashAndBranch in revisions) + { + int colon = hashAndBranch.IndexOf(':'); + string rev = colon >= 0 ? hashAndBranch.Substring(0, colon) : hashAndBranch; + if (hashes.Contains(rev)) + { + foundHash++; + if (foundHash >= hashes.Count) break; + } + } + i += q; + } + return true; + } +} diff --git a/csharp/src/HgResume.Api/ProcessRunner.cs b/csharp/src/HgResume.Api/ProcessRunner.cs new file mode 100644 index 0000000..492029e --- /dev/null +++ b/csharp/src/HgResume.Api/ProcessRunner.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; +using System.Text; + +namespace HgResume.Api; + +/// +/// Helpers for launching the external `hg` binary. The PHP original shelled out via exec()/`&` +/// and GNU /usr/bin/time; here we use System.Diagnostics.Process directly. +/// +public static class ProcessRunner +{ + /// + /// Runs a command synchronously (mirrors PHP exec()): returns stdout split into lines with the + /// trailing empty line removed, plus the exit code. stderr is discarded (PHP exec captured stdout). + /// + public static (List Lines, int ExitCode) RunSync(string workingDir, string program, + params string[] args) + { + var psi = new ProcessStartInfo + { + FileName = program, + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + + using var proc = Process.Start(psi) + ?? throw new HgException($"failed to start process '{program}'"); + // Read both streams to avoid pipe-buffer deadlock. + var stdoutTask = proc.StandardOutput.ReadToEndAsync(); + var stderrTask = proc.StandardError.ReadToEndAsync(); + proc.WaitForExit(); + string stdout = stdoutTask.GetAwaiter().GetResult(); + _ = stderrTask.GetAwaiter().GetResult(); + + var lines = stdout.Replace("\r\n", "\n").Split('\n').ToList(); + // PHP exec() drops the trailing newline / empty final element. + if (lines.Count > 0 && lines[^1].Length == 0) + { + lines.RemoveAt(lines.Count - 1); + } + return (lines, proc.ExitCode); + } +} diff --git a/csharp/src/HgResume.Api/Program.cs b/csharp/src/HgResume.Api/Program.cs new file mode 100644 index 0000000..3bf0aae --- /dev/null +++ b/csharp/src/HgResume.Api/Program.cs @@ -0,0 +1,22 @@ +using HgResume.Api; + +var builder = WebApplication.CreateBuilder(args); +var config = ApiConfig.FromEnvironment(); + +// Make the push body cap explicit (and env-overridable) rather than relying on Kestrel's implicit +// 30 MB default — oversize bodies are rejected with a bare 413 before RestDispatcher runs. +builder.WebHost.ConfigureKestrel(options => +{ + options.Limits.MaxRequestBodySize = config.MaxRequestBodySize; +}); + +var app = builder.Build(); + +var api = new HgResumeApi(config); +var dispatcher = new RestDispatcher(config, api); + +// Every request (any method, any path) is dispatched on its last path segment, so /api/v03/ +// keeps working exactly as the PHP app did. Auth is handled by the surrounding platform. +app.Run(dispatcher.HandleAsync); + +app.Run(); diff --git a/csharp/src/HgResume.Api/RestDispatcher.cs b/csharp/src/HgResume.Api/RestDispatcher.cs new file mode 100644 index 0000000..2c72668 --- /dev/null +++ b/csharp/src/HgResume.Api/RestDispatcher.cs @@ -0,0 +1,239 @@ +using System.Text; +using Microsoft.AspNetCore.Http; + +namespace HgResume.Api; + +/// +/// Mirrors api/src/RestServer.php. Dispatches on the last path segment, binds query/body params by +/// name (PHP-style, including baseHashes[] arrays), and writes the X-HgR-* response contract that the +/// Chorus client parses. Prefix-agnostic so /api/v03/<method> keeps working without hardcoding it. +/// +public sealed class RestDispatcher +{ + private readonly ApiConfig _config; + private readonly HgResumeApi _api; + + public RestDispatcher(ApiConfig config, HgResumeApi api) + { + _config = config; + _api = api; + } + + public async Task HandleAsync(HttpContext context) + { + string methodName = LastPathSegment(context.Request.Path.Value ?? ""); + byte[] body = await ReadBodyAsync(context.Request); + var query = context.Request.Query; + + // Always answer with the X-HgR-* contract. PHP RestServer::serverError did the same for + // missing params / unknown methods; without this catch, BundleHelper's ValidationException + // (empty/invalid transId) escapes as a bare ASP.NET 500 with no protocol headers. + HgResumeResponse response; + try + { + response = Dispatch(methodName, query, body); + } + catch (Exception e) + { + response = ServerError(Truncate(e.Message)); + } + + await SendResponseAsync(context, response); + } + + private HgResumeResponse Dispatch(string methodName, IQueryCollection query, byte[] body) + { + switch (methodName) + { + case "pushBundleChunk": + // PHP maps data <- postData (always present from the body); remaining params are required. + RequireParams(methodName, query, "repoId", "bundleSize", "offset", "transId"); + return _api.PushBundleChunk( + Str(query, "repoId"), + PhpInt(Str(query, "bundleSize")), + PhpInt(Str(query, "offset")), + body, + Str(query, "transId")); + + case "pullBundleChunk": + RequireParams(methodName, query, "repoId", "baseHashes", "offset", "chunkSize", "transId"); + return _api.PullBundleChunk( + Str(query, "repoId"), + BaseHashes(query), + PhpInt(Str(query, "offset")), + PhpInt(Str(query, "chunkSize")), + Str(query, "transId")); + + case "getRevisions": + RequireParams(methodName, query, "repoId", "offset", "quantity"); + return _api.GetRevisions( + Str(query, "repoId"), + PhpInt(Str(query, "offset")), + PhpInt(Str(query, "quantity"))); + + case "finishPushBundle": + RequireParams(methodName, query, "transId"); + return _api.FinishPushBundle(Str(query, "transId")); + + case "finishPullBundle": + RequireParams(methodName, query, "transId"); + return _api.FinishPullBundle(Str(query, "transId")); + + case "isAvailable": + return _api.IsAvailable(); + + default: + return ServerError($"Unknown method '{methodName}'"); + } + } + + /// + /// Mirrors RestServer::buildOrderedParamArray's required-param check. Presence (not non-emptiness) + /// is what PHP's array_key_exists tested; empty-but-present values still reach the API. + /// + private static void RequireParams(string method, IQueryCollection query, params string[] names) + { + foreach (var name in names) + { + bool present = name == "baseHashes" + ? HasBaseHashesKey(query) + : query.ContainsKey(name); + if (!present) + { + throw new ValidationException($"param {name} is required for method {method}"); + } + } + } + + private static bool HasBaseHashesKey(IQueryCollection query) + { + if (query.ContainsKey("baseHashes[]") || query.ContainsKey("baseHashes")) return true; + foreach (var key in query.Keys) + { + if (key.StartsWith("baseHashes[") && key.EndsWith("]")) return true; + } + return false; + } + + /// Mirrors RestServer::serverError — FAIL with Error header and body text. + private static HgResumeResponse ServerError(string msg) => + new(HgResumeResponse.FAIL, new Dictionary { ["Error"] = msg }, msg); + + private static string Truncate(string s) => s.Length > 1000 ? s.Substring(0, 1000) : s; + + private async Task SendResponseAsync(HttpContext context, HgResumeResponse response) + { + var (httpCode, hgrStatus) = MapHgResponse(response.Code); + + var res = context.Response; + res.StatusCode = httpCode; + res.Headers["X-HgR-Version"] = response.Version.ToString(); + res.Headers["X-HgR-Status"] = hgrStatus; + foreach (var kv in response.Values) + { + res.Headers["X-HgR-" + UcFirst(kv.Key)] = SanitizeHeader(kv.Value); + } + res.ContentType = "application/octet-stream"; + + if (response.Content.Length > 0) + { + // Set Content-Length explicitly (as the PHP RestServer did). The Chorus client reads the + // body with a hand-rolled reader that returns EMPTY when there is no Content-Length header + // (it never falls back to chunked/Transfer-Encoding), so relying on Kestrel's default + // chunked encoding silently breaks getRevisions and pullBundleChunk for the real client. + res.ContentLength = response.Content.Length; + await res.Body.WriteAsync(response.Content); + } + } + + // Mirrors RestServer::mapHgResponse. Because INPROGRESS and TIMEOUT share value 9 and INPROGRESS is + // listed first in the PHP switch, code 9 always maps to 202 / "INPROGRESS" (the 408 branch is dead). + private static (int HttpCode, string Status) MapHgResponse(int code) => code switch + { + HgResumeResponse.SUCCESS => (200, "SUCCESS"), + HgResumeResponse.RECEIVED => (202, "RECEIVED"), + HgResumeResponse.RESET => (400, "RESET"), + HgResumeResponse.UNAUTHORIZED => (401, "UNAUTHORIZED"), + HgResumeResponse.FAIL => (400, "FAIL"), + HgResumeResponse.UNKNOWNID => (400, "UNKNOWNID"), + HgResumeResponse.NOCHANGE => (304, "NOCHANGE"), + HgResumeResponse.NOTAVAILABLE => (503, "NOTAVAILABLE"), + HgResumeResponse.INPROGRESS => (202, "INPROGRESS"), // also covers TIMEOUT (== 9) + _ => throw new Exception($"Unknown response code {code}"), + }; + + private static string LastPathSegment(string path) + { + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + return segments.Length == 0 ? "" : segments[^1]; + } + + private static async Task ReadBodyAsync(HttpRequest request) + { + using var ms = new MemoryStream(); + await request.Body.CopyToAsync(ms); + return ms.ToArray(); + } + + private static string Str(IQueryCollection query, string key) + { + return query.TryGetValue(key, out var v) ? v.ToString() : ""; + } + + // Client emits baseHashes[]=a&baseHashes[]=b (PHP array style). Accept that, plus scalar and + // indexed forms defensively. + private static List BaseHashes(IQueryCollection query) + { + var result = new List(); + if (query.TryGetValue("baseHashes[]", out var bracketed)) + { + result.AddRange(bracketed.Where(s => s != null)!.Select(s => s!)); + } + if (result.Count == 0 && query.TryGetValue("baseHashes", out var scalar)) + { + result.AddRange(scalar.Where(s => s != null)!.Select(s => s!)); + } + if (result.Count == 0) + { + foreach (var kv in query) + { + if (kv.Key.StartsWith("baseHashes[") && kv.Key.EndsWith("]")) + { + result.AddRange(kv.Value.Where(s => s != null)!.Select(s => s!)); + } + } + } + return result; + } + + /// Uppercases the first character only, mirroring PHP ucfirst(). + private static string UcFirst(string s) + { + if (string.IsNullOrEmpty(s)) return s; + return char.ToUpperInvariant(s[0]) + s.Substring(1); + } + + private static string SanitizeHeader(string value) => value.Replace("\r", " ").Replace("\n", " "); + + /// + /// Lenient integer parse mirroring PHP intval() on a query string: leading sign + digits, else 0. + /// This makes a non-numeric bundleSize collapse to 0 (so offset 0 >= 0 -> FAIL, matching the + /// PHP invalid-bundleSize test). + /// + private static int PhpInt(string s) + { + if (string.IsNullOrEmpty(s)) return 0; + int i = 0; + var sb = new StringBuilder(); + if (s[0] == '+' || s[0] == '-') + { + sb.Append(s[0]); + i = 1; + } + for (; i < s.Length && char.IsDigit(s[i]); i++) + { + sb.Append(s[i]); + } + return int.TryParse(sb.ToString(), out var result) ? result : 0; + } +} diff --git a/csharp/test/HgResume.HttpTests/ApiClient.cs b/csharp/test/HgResume.HttpTests/ApiClient.cs new file mode 100644 index 0000000..0f5b688 --- /dev/null +++ b/csharp/test/HgResume.HttpTests/ApiClient.cs @@ -0,0 +1,81 @@ +using System.Net; +using System.Text; + +namespace HgResume.HttpTests; + +/// Parsed HTTP response, exposing the X-HgR-* protocol headers the Chorus client reads. +public sealed record ApiResponse(HttpStatusCode Http, IReadOnlyDictionary Headers, byte[] Content) +{ + public string Status => Hgr("status"); + public string Hgr(string name) => Headers.TryGetValue("x-hgr-" + name.ToLowerInvariant(), out var v) ? v : ""; + public int HgrInt(string name) => int.TryParse(Hgr(name), out var i) ? i : 0; + public string Text => Encoding.UTF8.GetString(Content); +} + +/// +/// Talks to a running hgresume server exactly like the Chorus client does: /api/v03/{method} with a +/// GET (no body) or POST text/plain (raw chunk), building the same query string (offset, chunkSize, +/// bundleSize, quantity, transId, repoId, baseHashes[]). Query values are intentionally NOT escaped +/// to match HgResumeApiParameters.BuildQueryString. +/// +public sealed class ApiClient +{ + private readonly HttpClient _http; + private readonly string _baseUrl; + + public ApiClient(string baseUrl) + { + _baseUrl = baseUrl.TrimEnd('/'); + _http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; + } + + private ApiResponse Send(HttpMethod verb, string method, string query, byte[]? body) + { + string url = $"{_baseUrl}/api/v03/{method}{query}"; + using var req = new HttpRequestMessage(verb, url); + if (body is not null) + { + var content = new ByteArrayContent(body); + content.Headers.TryAddWithoutValidation("Content-Type", "text/plain"); + req.Content = content; + } + using var resp = _http.Send(req, HttpCompletionOption.ResponseContentRead); + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var h in resp.Headers) headers[h.Key.ToLowerInvariant()] = string.Join(",", h.Value); + foreach (var h in resp.Content.Headers) headers[h.Key.ToLowerInvariant()] = string.Join(",", h.Value); + byte[] bytes = resp.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + return new ApiResponse(resp.StatusCode, headers, bytes); + } + + public ApiResponse IsAvailable() => Send(HttpMethod.Get, "isAvailable", "", null); + + public ApiResponse PushBundleChunk(string repoId, int bundleSize, int offset, byte[] data, string transId) + => PushBundleChunkRaw(repoId, bundleSize.ToString(), offset, data, transId); + + /// Push with the bundleSize sent verbatim (used to exercise a non-numeric bundleSize). + public ApiResponse PushBundleChunkRaw(string repoId, string bundleSize, int offset, byte[] data, string transId) + => Send(HttpMethod.Post, "pushBundleChunk", + $"?offset={offset}&bundleSize={bundleSize}&transId={transId}&repoId={repoId}", data); + + public ApiResponse PullBundleChunk(string repoId, IEnumerable baseHashes, int offset, int chunkSize, + string transId) + { + var sb = new StringBuilder($"?offset={offset}&chunkSize={chunkSize}&transId={transId}"); + foreach (var h in baseHashes) sb.Append($"&baseHashes[]={h}"); + sb.Append($"&repoId={repoId}"); + return Send(HttpMethod.Get, "pullBundleChunk", sb.ToString(), null); + } + + public ApiResponse GetRevisions(string repoId, int offset, int quantity) + => Send(HttpMethod.Get, "getRevisions", $"?offset={offset}&quantity={quantity}&repoId={repoId}", null); + + public ApiResponse FinishPushBundle(string transId) + => Send(HttpMethod.Get, "finishPushBundle", $"?transId={transId}", null); + + public ApiResponse FinishPullBundle(string transId) + => Send(HttpMethod.Get, "finishPullBundle", $"?transId={transId}", null); + + /// Raw GET against a method with an arbitrary query (or none) — for missing-param tests. + public ApiResponse GetRaw(string method, string query) + => Send(HttpMethod.Get, method, query, null); +} diff --git a/csharp/test/HgResume.HttpTests/AssemblyInfo.cs b/csharp/test/HgResume.HttpTests/AssemblyInfo.cs new file mode 100644 index 0000000..602aa81 --- /dev/null +++ b/csharp/test/HgResume.HttpTests/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using Xunit; + +// The tests share a single container with shared repo/cache state, so they must run serially. +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/csharp/test/HgResume.HttpTests/ContractFacts.cs b/csharp/test/HgResume.HttpTests/ContractFacts.cs new file mode 100644 index 0000000..0a3cd1b --- /dev/null +++ b/csharp/test/HgResume.HttpTests/ContractFacts.cs @@ -0,0 +1,52 @@ +using System.Net.Sockets; +using System.Text; +using Xunit; + +namespace HgResume.HttpTests; + +/// +/// Wire-contract regression tests that the higher-level ApiClient (which uses HttpClient) cannot catch, +/// because HttpClient transparently handles chunked transfer encoding. The real Chorus client uses a +/// hand-rolled response reader (Chorus WebResponseHelper.ReadResponseContent) that returns EMPTY content +/// when the response has no Content-Length header — so every body response MUST send Content-Length and +/// must NOT rely on chunked encoding, exactly as the PHP server did. +/// +[Collection("server")] +public sealed class ContractFacts +{ + private readonly ServerFixture _fx; + + public ContractFacts(ServerFixture fx) => _fx = fx; + + [Fact] + public void ResponseWithBody_SetsContentLength_AndIsNotChunked() + { + _fx.SeedRepo("sampleHgRepo2.zip"); + + var uri = new Uri(_fx.BaseUrl); + string raw = RawGet(uri.Host, uri.Port, + "/api/v03/getRevisions?offset=0&quantity=50&repoId=sampleHgRepo2"); + + int sep = raw.IndexOf("\r\n\r\n", StringComparison.Ordinal); + Assert.True(sep > 0, "malformed HTTP response: no header/body separator"); + string headers = raw[..sep].ToLowerInvariant(); + string body = raw[(sep + 4)..]; + + // Without Content-Length the Chorus client reads an empty body, which breaks getRevisions/pull. + Assert.Contains("content-length:", headers); + Assert.DoesNotContain("transfer-encoding: chunked", headers); + Assert.False(string.IsNullOrEmpty(body), "expected a non-empty revision list body"); + } + + private static string RawGet(string host, int port, string path) + { + using var client = new TcpClient(host, port); + using var stream = client.GetStream(); + string req = $"GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n"; + byte[] bytes = Encoding.ASCII.GetBytes(req); + stream.Write(bytes, 0, bytes.Length); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return Encoding.UTF8.GetString(ms.ToArray()); + } +} diff --git a/csharp/test/HgResume.HttpTests/HgResume.HttpTests.csproj b/csharp/test/HgResume.HttpTests/HgResume.HttpTests.csproj new file mode 100644 index 0000000..cdbbea2 --- /dev/null +++ b/csharp/test/HgResume.HttpTests/HgResume.HttpTests.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + diff --git a/csharp/test/HgResume.HttpTests/MiscFacts.cs b/csharp/test/HgResume.HttpTests/MiscFacts.cs new file mode 100644 index 0000000..6f07794 --- /dev/null +++ b/csharp/test/HgResume.HttpTests/MiscFacts.cs @@ -0,0 +1,149 @@ +using System.Text; +using Xunit; + +namespace HgResume.HttpTests; + +/// getRevisions, availability/maintenance, and wire-contract smoke checks. +[Collection("server")] +public sealed class MiscFacts +{ + private readonly ServerFixture _fx; + private ApiClient Api => _fx.Client; + + public MiscFacts(ServerFixture fx) => _fx = fx; + + [Fact] + public void GetRevisions_2BranchRepo_ReturnsTwoBranches() + { + _fx.SeedRepo("sample2branchHgRepo.zip"); + var r = Api.GetRevisions("sample2branchHgRepo", 0, 50); + Assert.Equal("SUCCESS", r.Status); + + var branches = new HashSet(); + foreach (var revision in r.Text.Split('|')) + { + var hashAndBranch = revision.Split(':'); + if (hashAndBranch.Length >= 2) branches.Add(hashAndBranch[1]); + } + Assert.Equal(2, branches.Count); + } + + [Fact] + public void GetRevisions_BogusId_UnknownCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + var r = Api.GetRevisions("fakeid", 0, 50); + Assert.Equal("UNKNOWNID", r.Status); + } + + [Theory] + [InlineData("../sampleHgRepo")] + [InlineData("..")] + [InlineData("foo/bar")] + [InlineData("sampleHgRepo/../sampleHgRepo")] + public void GetRevisions_PathTraversalRepoId_UnknownCode(string repoId) + { + // Even when a real repo exists under the search root, path segments must not escape it. + _fx.SeedRepo("sampleHgRepo.zip"); + var r = Api.GetRevisions(repoId, 0, 50); + Assert.Equal("UNKNOWNID", r.Status); + } + + [Fact] + public void PushBundleChunk_PathTraversalRepoId_UnknownCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + var r = Api.PushBundleChunk("../sampleHgRepo", 10000, 0, + Encoding.UTF8.GetBytes("chunkData"), nameof(PushBundleChunk_PathTraversalRepoId_UnknownCode)); + Assert.Equal("UNKNOWNID", r.Status); + } + + [Fact] + public void IsAvailable_NoMessageFile_SuccessCode() + { + _fx.ClearMaintenance(); + var r = Api.IsAvailable(); + Assert.Equal("SUCCESS", r.Status); + Assert.Equal(200, (int)r.Http); + } + + [Fact] + public void IsAvailable_MessageFileExists_NotAvailableWithMessage() + { + const string message = "Server is down for maintenance."; + try + { + _fx.SetMaintenance(message); + var r = Api.IsAvailable(); + Assert.Equal("NOTAVAILABLE", r.Status); + Assert.Equal(503, (int)r.Http); + Assert.Equal(message, r.Text); + } + finally + { + _fx.ClearMaintenance(); + } + } + + [Fact] + public void ResponseContract_HeadersAndContentType() + { + _fx.ClearMaintenance(); + var r = Api.IsAvailable(); + Assert.Equal("3", r.Hgr("version")); + Assert.Equal("SUCCESS", r.Hgr("status")); + Assert.Contains("application/octet-stream", r.Headers["content-type"]); + } + + [Fact] + public void FinishPushBundle_MissingTransId_FailWithProtocolHeaders() + { + // PHP RestServer::buildOrderedParamArray rejected this before the API ran. + var r = Api.GetRaw("finishPushBundle", ""); + Assert.Equal(400, (int)r.Http); + Assert.Equal("FAIL", r.Status); + Assert.Equal("3", r.Hgr("version")); + Assert.Contains("transId", r.Hgr("error")); + Assert.Contains("required", r.Hgr("error")); + } + + [Fact] + public void FinishPushBundle_EmptyTransId_FailWithProtocolHeaders() + { + // BundleHelper throws ValidationException for empty/non-alphanumeric transId; must not be a bare 500. + var r = Api.FinishPushBundle(""); + Assert.Equal(400, (int)r.Http); + Assert.Equal("FAIL", r.Status); + Assert.Equal("3", r.Hgr("version")); + Assert.False(string.IsNullOrEmpty(r.Hgr("error"))); + } + + [Fact] + public void FinishPullBundle_InvalidTransId_FailWithProtocolHeaders() + { + var r = Api.FinishPullBundle("bad.id"); + Assert.Equal(400, (int)r.Http); + Assert.Equal("FAIL", r.Status); + Assert.Equal("3", r.Hgr("version")); + Assert.Contains("alpha numeric", r.Hgr("error")); + } + + [Fact] + public void UnknownMethod_FailWithProtocolHeaders() + { + var r = Api.GetRaw("notARealMethod", ""); + Assert.Equal(400, (int)r.Http); + Assert.Equal("FAIL", r.Status); + Assert.Contains("Unknown method", r.Hgr("error")); + } + + [Fact] + public void GetRevisions_MissingRepoId_FailWithProtocolHeaders() + { + var r = Api.GetRaw("getRevisions", "?offset=0&quantity=50"); + Assert.Equal(400, (int)r.Http); + Assert.Equal("FAIL", r.Status); + Assert.Contains("repoId", r.Hgr("error")); + Assert.Contains("required", r.Hgr("error")); + } +} diff --git a/csharp/test/HgResume.HttpTests/Protocol.cs b/csharp/test/HgResume.HttpTests/Protocol.cs new file mode 100644 index 0000000..3e358a1 --- /dev/null +++ b/csharp/test/HgResume.HttpTests/Protocol.cs @@ -0,0 +1,110 @@ +namespace HgResume.HttpTests; + +/// +/// Client-side push/pull loops that mirror how Chorus's HgResumeTransport drives the protocol, +/// including the "poll" behaviour where a 202/INPROGRESS PendingResponse shrinks the window +/// (sow = bundleSize - 10) and the client resends the tail. +/// +public static class Protocol +{ + /// + /// Pushes an entire bundle in chunks, following RECEIVED (advance to sow) and INPROGRESS + /// (PendingResponse poll) until a terminal status. Returns the terminal response. + /// + public static ApiResponse PushEntireBundle(ApiClient client, string repoId, string transId, byte[] bundle, + int chunkSize = 50) + { + int bundleSize = bundle.Length; + int sow = 0; + for (int guard = 0; guard < 2000; guard++) + { + byte[] chunk = Slice(bundle, sow, chunkSize); + var r = client.PushBundleChunk(repoId, bundleSize, sow, chunk, transId); + switch ((int)r.Http) + { + case 200: + return r; // SUCCESS + case 202: + // RECEIVED advances sow to newSow; INPROGRESS/PendingResponse sets sow = bundleSize-10 + sow = r.HgrInt("sow"); + if (r.Status == "INPROGRESS") Thread.Sleep(300); + continue; + default: + return r; // FAIL / RESET / UNKNOWNID / NOTAVAILABLE + } + } + throw new Exception("PushEntireBundle did not terminate"); + } + + /// Follows INPROGRESS polls after an arbitrary push until a terminal (non-INPROGRESS) status. + public static ApiResponse SettlePush(ApiClient client, string repoId, string transId, byte[] bundle, + ApiResponse response) + { + int bundleSize = bundle.Length; + for (int guard = 0; guard < 120 && response.Status == "INPROGRESS"; guard++) + { + Thread.Sleep(500); + int sow = response.HgrInt("sow"); + response = client.PushBundleChunk(repoId, bundleSize, sow, Slice(bundle, sow, bundleSize - sow), transId); + } + return response; + } + + /// + /// Pulls until the assembled bundle reaches bundleSize, polling on INPROGRESS. Returns the assembled + /// bytes and the last non-INPROGRESS response (e.g. so callers can assert NOCHANGE/FAIL). + /// + public static (byte[] Assembled, ApiResponse Last) PullEntireBundle(ApiClient client, string repoId, + IReadOnlyList baseHashes, string transId, int chunkSize = 50) + { + int offset = 0; + int bundleSize = 1; // overwritten after the first successful response + using var ms = new MemoryStream(); + ApiResponse last = default!; + for (int guard = 0; guard < 100000 && ms.Length < bundleSize; guard++) + { + var r = client.PullBundleChunk(repoId, baseHashes, offset, chunkSize, transId); + last = r; + if (r.Status == "INPROGRESS") + { + Thread.Sleep(500); + continue; + } + if ((int)r.Http != 200) + { + return (ms.ToArray(), r); // NOCHANGE / FAIL / UNKNOWNID / RESET + } + bundleSize = r.HgrInt("bundleSize"); + ms.Write(r.Content); + offset += r.Content.Length; + chunkSize = r.HgrInt("chunkSize") > 0 ? r.HgrInt("chunkSize") : chunkSize; + } + return (ms.ToArray(), last); + } + + /// Issues a single pull, polling past INPROGRESS until a terminal response. + public static ApiResponse PullFirstChunk(ApiClient client, string repoId, IReadOnlyList baseHashes, + int offset, int chunkSize, string transId) + { + for (int guard = 0; guard < 400; guard++) + { + var r = client.PullBundleChunk(repoId, baseHashes, offset, chunkSize, transId); + if (r.Status == "INPROGRESS") + { + Thread.Sleep(500); + continue; + } + return r; + } + throw new Exception("pull did not produce a terminal response"); + } + + public static byte[] Slice(byte[] data, int offset, int length) + { + if (offset >= data.Length || length <= 0) return Array.Empty(); + int take = Math.Min(length, data.Length - offset); + var buf = new byte[take]; + Array.Copy(data, offset, buf, 0, take); + return buf; + } +} diff --git a/csharp/test/HgResume.HttpTests/PullFacts.cs b/csharp/test/HgResume.HttpTests/PullFacts.cs new file mode 100644 index 0000000..8e1f353 --- /dev/null +++ b/csharp/test/HgResume.HttpTests/PullFacts.cs @@ -0,0 +1,203 @@ +using Xunit; + +namespace HgResume.HttpTests; + +/// HTTP-level ports of the pull cases in api/test/HgResumeApi_Test.php. +[Collection("server")] +public sealed class PullFacts +{ + private readonly ServerFixture _fx; + private ApiClient Api => _fx.Client; + + public PullFacts(ServerFixture fx) => _fx = fx; + + [Fact] + public void PullBundleChunk_EmptyId_UnknownCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + string tx = nameof(PullBundleChunk_EmptyId_UnknownCode); + Api.FinishPullBundle(tx); + var r = Api.PullBundleChunk("", new[] { "" }, 0, 50, tx); + Assert.Equal("UNKNOWNID", r.Status); + } + + [Fact] + public void PullBundleChunk_BogusId_UnknownCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + string tx = nameof(PullBundleChunk_BogusId_UnknownCode); + Api.FinishPullBundle(tx); + var r = Api.PullBundleChunk("fakeid", new[] { "" }, 0, 50, tx); + Assert.Equal("UNKNOWNID", r.Status); + } + + [Fact] + public void PullBundleChunk_InvalidHash_FailCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + string tx = nameof(PullBundleChunk_InvalidHash_FailCode); + Api.FinishPullBundle(tx); + var r = Api.PullBundleChunk("sampleHgRepo", new[] { "fakehash" }, 0, 50, tx); + Assert.Equal("FAIL", r.Status); + } + + [Fact] + public void PullBundleChunk_ValidRequestButNoChanges_NoChangeCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + string tx = nameof(PullBundleChunk_ValidRequestButNoChanges_NoChangeCode); + Api.FinishPullBundle(tx); + string hash = _fx.FixtureText("sample.bundle.hash"); + var r = Api.PullBundleChunk("sampleHgRepo", new[] { hash }, 0, 50, tx); + Assert.Equal("NOCHANGE", r.Status); + } + + [Fact] + public void PullBundleChunk_OffsetZero_ValidData() + { + _fx.SeedRepo("sampleHgRepo2.zip"); + string tx = nameof(PullBundleChunk_OffsetZero_ValidData); + Api.FinishPullBundle(tx); + string hash = _fx.FixtureText("sample.bundle.hash"); + var r = Protocol.PullFirstChunk(Api, "sampleHgRepo2", new[] { hash }, 0, 50, tx); + Assert.Equal("SUCCESS", r.Status); + Assert.Equal(50, r.HgrInt("chunkSize")); + Assert.Equal(_fx.Fixture("sample.bundle").Length, r.HgrInt("bundleSize")); + } + + [Fact] + public void PullBundleChunk_OffsetGreaterThanZeroAndNoBundleCreated_ResetResponse() + { + _fx.SeedRepo("sampleHgRepo2.zip"); + string tx = nameof(PullBundleChunk_OffsetGreaterThanZeroAndNoBundleCreated_ResetResponse); + Api.FinishPullBundle(tx); + string hash = _fx.FixtureText("sample.bundle.hash"); + var r = Api.PullBundleChunk("sampleHgRepo2", new[] { hash }, 50, 50, tx); + Assert.Equal("RESET", r.Status); + } + + [Fact] + public void PullBundleChunk_OffsetEqualToBundleSize_SuccessCodeWithZeroChunkSize() + { + _fx.SeedRepo("sampleHgRepo2.zip"); + string tx = nameof(PullBundleChunk_OffsetEqualToBundleSize_SuccessCodeWithZeroChunkSize); + Api.FinishPullBundle(tx); + string hash = _fx.FixtureText("sample.bundle.hash"); + var first = Protocol.PullFirstChunk(Api, "sampleHgRepo2", new[] { hash }, 0, 50, tx); + Assert.Equal("SUCCESS", first.Status); + int bundleSize = first.HgrInt("bundleSize"); + // At offset == bundleSize the response is SUCCESS only once the transaction has flipped from the + // Bundle to the Downloading state; until then the server returns INPROGRESS (as the real client + // polls through). Poll rather than asserting SUCCESS on the first request, which is timing-flaky. + var r = Protocol.PullFirstChunk(Api, "sampleHgRepo2", new[] { hash }, bundleSize, 1000, tx); + Assert.Equal("SUCCESS", r.Status); + Assert.Equal(0, r.HgrInt("chunkSize")); + Assert.Empty(r.Content); + } + + [Fact] + public void PullBundleChunk_PullUntilFinished_AssembledBundleIsValid() + { + _fx.SeedRepo("sampleHgRepo2.zip"); + string tx = nameof(PullBundleChunk_PullUntilFinished_AssembledBundleIsValid); + Api.FinishPullBundle(tx); + string hash = _fx.FixtureText("sample.bundle.hash"); + var (assembled, _) = Protocol.PullEntireBundle(Api, "sampleHgRepo2", new[] { hash }, tx); + Assert.Equal(_fx.Fixture("sample.bundle"), assembled); + } + + [Fact] + public void PullBundleChunk_PullFromBaseRevisionUntilFinishedOnTwoBranchRepo_AssembledBundleIsValid() + { + _fx.SeedRepo("sample2branchHgRepo.zip"); + string tx = nameof(PullBundleChunk_PullFromBaseRevisionUntilFinishedOnTwoBranchRepo_AssembledBundleIsValid); + Api.FinishPullBundle(tx); + string hash = _fx.FixtureText("sample2branch.hash"); + var (assembled, _) = Protocol.PullEntireBundle(Api, "sample2branchHgRepo", new[] { hash }, tx); + Assert.Equal(_fx.Fixture("sample2branch.bundle"), assembled); + } + + [Fact] + public void PullBundleChunk_PullFromTwoBaseRevisionsUntilFinishedOnTwoBranchRepo_AssembledBundleIsValid() + { + _fx.SeedRepo("sample2branchHgRepo.zip"); + string tx = nameof(PullBundleChunk_PullFromTwoBaseRevisionsUntilFinishedOnTwoBranchRepo_AssembledBundleIsValid); + Api.FinishPullBundle(tx); + var hashes = _fx.FixtureText("sample2branch2base.hash").Split('|'); + var (assembled, _) = Protocol.PullEntireBundle(Api, "sample2branchHgRepo", hashes, tx); + Assert.Equal(_fx.Fixture("sample2branch2base.bundle"), assembled); + } + + [Fact] + public void PullBundleChunk_2BranchRepoNoChanges_ReturnsNoChange() + { + _fx.SeedRepo("sample2branchHgRepo.zip"); + string tx = nameof(PullBundleChunk_2BranchRepoNoChanges_ReturnsNoChange); + Api.FinishPullBundle(tx); + var hashes = _fx.FixtureText("sample2branch2tip.hash").Split('|'); + var r = Api.PullBundleChunk("sample2branchHgRepo", hashes, 0, 500, tx); + Assert.Equal("NOCHANGE", r.Status); + } + + [Fact] + public void PullBundleChunk_BaseHashIsZero_ReturnsEntireRepoAsBundle() + { + _fx.SeedRepo("sampleHgRepo2.zip"); + string tx = nameof(PullBundleChunk_BaseHashIsZero_ReturnsEntireRepoAsBundle); + Api.FinishPullBundle(tx); + var (assembled, _) = Protocol.PullEntireBundle(Api, "sampleHgRepo2", new[] { "0" }, tx); + Assert.Equal(_fx.Fixture("sample_entire.bundle"), assembled); + } + + [Fact] + public void PullBundleChunk_EmptyRepositoryReturnsNoChanges() + { + _fx.SeedRepo("emptyHgRepo.zip"); + string tx = nameof(PullBundleChunk_EmptyRepositoryReturnsNoChanges); + Api.FinishPullBundle(tx); + var r = Api.PullBundleChunk("emptyHgRepo", new[] { "0" }, 0, 50, tx); + Assert.Equal("NOCHANGE", r.Status); + } + + [Fact] + public void PullBundleChunk_LongMakeBundle_InProgressCode() + { + _fx.SeedRepo("sampleLargeBundleHgRepo.zip"); + string tx = nameof(PullBundleChunk_LongMakeBundle_InProgressCode); + Api.FinishPullBundle(tx); + var r = Api.PullBundleChunk("sampleLargeBundleHgRepo", new[] { "0" }, 0, 10000000, tx); + Assert.Equal("INPROGRESS", r.Status); + } + + [Fact] + public void PullBundleChunk_PullUntilFinishedThenRepoChanges_ResetReceivedFromFinishPullBundle() + { + _fx.SeedRepo("sampleHgRepo2.zip"); + string tx = nameof(PullBundleChunk_PullUntilFinishedThenRepoChanges_ResetReceivedFromFinishPullBundle); + Api.FinishPullBundle(tx); + string hash = _fx.FixtureText("sample.bundle.hash"); + + int offset = 0; + int chunkSize = 50; + int bundleSize = 1; + int ctr = 1; + using var ms = new MemoryStream(); + while (offset < bundleSize) + { + if (ctr == 3) + { + _fx.AddAndCommit("sampleHgRepo2", "fileToAdd.txt", "sample data to add"); + } + var r = Protocol.PullFirstChunk(Api, "sampleHgRepo2", new[] { hash }, offset, chunkSize, tx); + Assert.Equal("SUCCESS", r.Status); + bundleSize = r.HgrInt("bundleSize"); + chunkSize = r.HgrInt("chunkSize") > 0 ? r.HgrInt("chunkSize") : chunkSize; + ms.Write(r.Content); + offset += r.Content.Length; + ctr++; + } + Assert.Equal(_fx.Fixture("sample.bundle"), ms.ToArray()); + var finish = Api.FinishPullBundle(tx); + Assert.Equal("RESET", finish.Status); + } +} diff --git a/csharp/test/HgResume.HttpTests/PushFacts.cs b/csharp/test/HgResume.HttpTests/PushFacts.cs new file mode 100644 index 0000000..585f4e1 --- /dev/null +++ b/csharp/test/HgResume.HttpTests/PushFacts.cs @@ -0,0 +1,185 @@ +using System.Text; +using Xunit; + +namespace HgResume.HttpTests; + +/// HTTP-level ports of the push cases in api/test/HgResumeApi_Test.php. +[Collection("server")] +public sealed class PushFacts +{ + private readonly ServerFixture _fx; + private ApiClient Api => _fx.Client; + + public PushFacts(ServerFixture fx) => _fx = fx; + + private static byte[] B(string s) => Encoding.UTF8.GetBytes(s); + + [Fact] + public void PushBundleChunk_BogusId_UnknownCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + var r = Api.PushBundleChunk("fakeid", 10000, 0, B("chunkData"), nameof(PushBundleChunk_BogusId_UnknownCode)); + Assert.Equal("UNKNOWNID", r.Status); + } + + [Fact] + public void PushBundleChunk_EmptyId_UnknownCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + var r = Api.PushBundleChunk("", 10000, 0, B("chunkData"), nameof(PushBundleChunk_EmptyId_UnknownCode)); + Assert.Equal("UNKNOWNID", r.Status); + } + + [Fact] + public void PushBundleChunk_InvalidOffset_FailCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + var r = Api.PushBundleChunk("sampleHgRepo", 1000, 2000, B("chunkData"), nameof(PushBundleChunk_InvalidOffset_FailCode)); + Assert.Equal("FAIL", r.Status); + } + + [Fact] + public void PushBundleChunk_NoData_FailCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + var r = Api.PushBundleChunk("sampleHgRepo", 1000, 0, Array.Empty(), nameof(PushBundleChunk_NoData_FailCode)); + Assert.Equal("FAIL", r.Status); + } + + [Fact] + public void PushBundleChunk_InvalidBundleSize_FailCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + var r = Api.PushBundleChunkRaw("sampleHgRepo", "invalid", 0, B("someData"), nameof(PushBundleChunk_InvalidBundleSize_FailCode)); + Assert.Equal("FAIL", r.Status); + } + + [Fact] + public void PushBundleChunk_DataTooLarge_FailCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + var r = Api.PushBundleChunk("sampleHgRepo", 10, 0, B("someDataLargerThan 10 bytes"), nameof(PushBundleChunk_DataTooLarge_FailCode)); + Assert.Equal("FAIL", r.Status); + } + + [Fact] + public void PushBundleChunk_ChunkSent_ReceivedCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + string tx = nameof(PushBundleChunk_ChunkSent_ReceivedCode); + Api.FinishPushBundle(tx); + var r = Api.PushBundleChunk("sampleHgRepo", 100, 0, B("someChunkData"), tx); + Assert.Equal("RECEIVED", r.Status); + } + + [Fact] + public void PushBundleChunk_AllChunksSent_SuccessCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + string tx = nameof(PushBundleChunk_AllChunksSent_SuccessCode); + Api.FinishPushBundle(tx); + var r = Protocol.PushEntireBundle(Api, "sampleHgRepo", tx, _fx.Fixture("sample.bundle")); + Assert.Equal("SUCCESS", r.Status); + } + + [Fact] + public void PushBundleChunk_AllChunksSentButBadDataChunkSoBundleFails_ResetCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + string tx = nameof(PushBundleChunk_AllChunksSentButBadDataChunkSoBundleFails_ResetCode); + Api.FinishPushBundle(tx); + Assert.Equal("RECEIVED", Api.PushBundleChunk("sampleHgRepo", 15, 0, B("12345"), tx).Status); + Assert.Equal("RECEIVED", Api.PushBundleChunk("sampleHgRepo", 15, 5, B("1234"), tx).Status); + Assert.Equal("RECEIVED", Api.PushBundleChunk("sampleHgRepo", 15, 9, B("1234"), tx).Status); + var last = Api.PushBundleChunk("sampleHgRepo", 15, 13, B("12"), tx); + last = Protocol.SettlePush(Api, "sampleHgRepo", tx, B("123451234123412"), last); + Assert.Equal("RESET", last.Status); + } + + [Fact] + public void PushBundleChunk_RequestedOffsetNotEqualToSOW_FailCodeReturnsSOW() + { + _fx.SeedRepo("sampleHgRepo.zip"); + string tx = nameof(PushBundleChunk_RequestedOffsetNotEqualToSOW_FailCodeReturnsSOW); + Api.FinishPushBundle(tx); + Api.PushBundleChunk("sampleHgRepo", 15, 0, B("12345"), tx); + var r = Api.PushBundleChunk("sampleHgRepo", 15, 10, B("12345"), tx); + Assert.Equal("FAIL", r.Status); + Assert.Equal(5, r.HgrInt("sow")); + } + + [Fact] + public void PushBundleChunk_PushWithOffsetZeroButSOWGreaterThanZero_ReceivedCodeReturnsSOW() + { + _fx.SeedRepo("sampleHgRepo.zip"); + string tx = nameof(PushBundleChunk_PushWithOffsetZeroButSOWGreaterThanZero_ReceivedCodeReturnsSOW); + Api.FinishPushBundle(tx); + Api.PushBundleChunk("sampleHgRepo", 15, 0, B("12345"), tx); + var r = Api.PushBundleChunk("sampleHgRepo", 15, 0, B("12"), tx); + Assert.Equal("RECEIVED", r.Status); + Assert.Equal(5, r.HgrInt("sow")); + } + + [Fact] + public void PushBundleChunk_InitializedRepoWithZeroChangesets_BundleSuccessfullyApplied() + { + _fx.SeedRepo("emptyHgRepo.zip"); + string tx = nameof(PushBundleChunk_InitializedRepoWithZeroChangesets_BundleSuccessfullyApplied); + Api.FinishPushBundle(tx); + var r = Protocol.PushEntireBundle(Api, "emptyHgRepo", tx, _fx.Fixture("sample_entire.bundle")); + Assert.Equal("SUCCESS", r.Status); + } + + [Fact] + public void PushBundleChunk_PushOneChunkThenRepoChanges_PushContinuesSuccessfully() + { + _fx.SeedRepo("sampleHgRepo.zip"); + string tx = nameof(PushBundleChunk_PushOneChunkThenRepoChanges_PushContinuesSuccessfully); + Api.FinishPushBundle(tx); + + byte[] bundle = _fx.Fixture("sample.bundle"); + int bundleSize = bundle.Length; + int sow = 0; + bool changed = false; + ApiResponse r = default!; + for (int guard = 0; guard < 2000; guard++) + { + if (sow >= 50 && !changed) + { + _fx.AddAndCommit("sampleHgRepo", "fileToAdd.txt", "sample data to add"); + changed = true; + } + r = Api.PushBundleChunk("sampleHgRepo", bundleSize, sow, Protocol.Slice(bundle, sow, 50), tx); + if ((int)r.Http == 200) break; + if ((int)r.Http == 202) { sow = r.HgrInt("sow"); if (r.Status == "INPROGRESS") Thread.Sleep(300); continue; } + break; + } + Assert.Equal("SUCCESS", r.Status); + } + + // The PHP tests push the whole unrelated bundle in a single call (offset 0, full bundleSize), + // so we do the same here rather than chunking a 600KB bundle 50 bytes at a time. + [Fact] + public void PushBundleChunk_UnrelatedRepo1_FailCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + string tx = nameof(PushBundleChunk_UnrelatedRepo1_FailCode); + Api.FinishPushBundle(tx); + byte[] bundle = _fx.Fixture("unrelated.bundle"); + var r = Api.PushBundleChunk("sampleHgRepo", bundle.Length, 0, bundle, tx); + r = Protocol.SettlePush(Api, "sampleHgRepo", tx, bundle, r); + Assert.Equal("FAIL", r.Status); + } + + [Fact] + public void PushBundleChunk_UnrelatedRepo2_FailCode() + { + _fx.SeedRepo("sampleHgRepo.zip"); + string tx = nameof(PushBundleChunk_UnrelatedRepo2_FailCode); + Api.FinishPushBundle(tx); + byte[] bundle = _fx.Fixture("unrelated2.bundle"); + var r = Api.PushBundleChunk("sampleHgRepo", bundle.Length, 0, bundle, tx); + r = Protocol.SettlePush(Api, "sampleHgRepo", tx, bundle, r); + Assert.Equal("FAIL", r.Status); + } +} diff --git a/csharp/test/HgResume.HttpTests/ServerFixture.cs b/csharp/test/HgResume.HttpTests/ServerFixture.cs new file mode 100644 index 0000000..e755168 --- /dev/null +++ b/csharp/test/HgResume.HttpTests/ServerFixture.cs @@ -0,0 +1,211 @@ +using System.Diagnostics; +using System.IO.Compression; +using Xunit; + +namespace HgResume.HttpTests; + +/// +/// Shared fixture that runs the hgresume C# image in a container (via podman) and drives it over HTTP. +/// It seeds Mercurial repos into the repo volume by extracting fixtures on the host and +/// podman cp-ing them in, so the production image does not need unzip. +/// +/// Environment overrides: +/// HGRESUME_PODMAN container CLI (default "podman") +/// HGRESUME_IMAGE image to run (default "hgresume-csharp:test") +/// HGRESUME_PORT host port to publish (default "8034") +/// HGRESUME_SKIP_BUILD if set, do not build the image (assume it exists) +/// HGRESUME_BASE_URL reuse an already-running server at this URL (with HGRESUME_CONTAINER) +/// HGRESUME_CONTAINER name of the already-running container to exec/cp against +/// HGRESUME_KEEP if set, do not stop/remove the container on teardown +/// +public sealed class ServerFixture : IAsyncLifetime +{ + private readonly string _podman = Env("HGRESUME_PODMAN", "podman"); + private readonly string _image = Env("HGRESUME_IMAGE", "hgresume-csharp:test"); + private readonly string _port = Env("HGRESUME_PORT", "8034"); + private readonly string _dataDir = Path.Combine(AppContext.BaseDirectory, "data"); + // Set HGRESUME_REPO_OWNER (e.g. "www-data") to chown seeded repos when the server runs as a + // non-root user (the PHP/Apache reference image). Empty = leave ownership as-is (C# runs as root). + private readonly string _repoOwner = Env("HGRESUME_REPO_OWNER", ""); + // Where the server looks for the maintenance file. C# default is under the cache dir; the PHP app + // looks in its src dir (SourcePath . "/maintenance_message.txt"). + private readonly string _maintPath = Env("HGRESUME_MAINT_PATH", "/var/cache/hgresume/maintenance_message.txt"); + + private bool _startedByUs; + + public string ContainerName { get; private set; } = ""; + public string BaseUrl { get; private set; } = ""; + public ApiClient Client { get; private set; } = default!; + + public async Task InitializeAsync() + { + string? reuseUrl = Environment.GetEnvironmentVariable("HGRESUME_BASE_URL"); + if (!string.IsNullOrWhiteSpace(reuseUrl)) + { + BaseUrl = reuseUrl; + ContainerName = Env("HGRESUME_CONTAINER", "hgresumable"); + } + else + { + if (Environment.GetEnvironmentVariable("HGRESUME_SKIP_BUILD") is null) + { + string context = FindContextDir(); + Run(_podman, "build", "-t", _image, "-f", Path.Combine(context, "Dockerfile"), context); + } + + ContainerName = "hgresume-test-" + Environment.ProcessId; + // Clean up a stale container with the same name, if any. + TryRun(_podman, "rm", "-f", ContainerName); + Run(_podman, "run", "-d", "--name", ContainerName, "-p", $"{_port}:80", _image); + _startedByUs = true; + BaseUrl = $"http://localhost:{_port}"; + } + + Client = new ApiClient(BaseUrl); + await WaitForReadyAsync(); + } + + public Task DisposeAsync() + { + if (_startedByUs && Environment.GetEnvironmentVariable("HGRESUME_KEEP") is null) + { + TryRun(_podman, "logs", ContainerName); // surfaced in test output on failures + TryRun(_podman, "rm", "-f", ContainerName); + } + return Task.CompletedTask; + } + + private async Task WaitForReadyAsync() + { + var deadline = DateTime.UtcNow.AddSeconds(90); + Exception? last = null; + while (DateTime.UtcNow < deadline) + { + try + { + var r = Client.IsAvailable(); + if ((int)r.Http == 200) return; + } + catch (Exception e) + { + last = e; + } + await Task.Delay(500); + } + throw new Exception($"Server at {BaseUrl} did not become ready in time. Last error: {last?.Message}"); + } + + // ---- repo/maintenance seeding --------------------------------------------------------------- + + /// Extracts a fixture repo zip on the host into /var/vcs/public/<repoId>. Returns the repoId. + public string SeedRepo(string zipName) + { + string repoId = Path.GetFileNameWithoutExtension(zipName); + string localZip = Path.Combine(_dataDir, zipName); + if (!File.Exists(localZip)) throw new FileNotFoundException($"fixture not found: {localZip}"); + + string extractDir = Path.Combine(Path.GetTempPath(), "hgresume-seed-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(extractDir); + ZipFile.ExtractToDirectory(localZip, extractDir); + + Exec($"rm -rf /var/vcs/public/{repoId}"); + Run(_podman, "cp", extractDir, $"{ContainerName}:/var/vcs/public/{repoId}"); + if (!string.IsNullOrEmpty(_repoOwner)) + { + Exec($"chown -R {_repoOwner}:{_repoOwner} /var/vcs/public/{repoId}"); + } + } + finally + { + try { Directory.Delete(extractDir, recursive: true); } + catch { /* best-effort temp cleanup */ } + } + return repoId; + } + + public void RemoveRepo(string repoId) => Exec($"rm -rf /var/vcs/public/{repoId}"); + + /// Adds and commits a file into the given repo (mirrors the PHP addAndCheckInFile helper). + public void AddAndCommit(string repoId, string filename, string content) + { + string cmd = $"cd /var/vcs/public/{repoId} && printf '%s' '{content}' > {filename} && " + + $"hg --config ui.username=system add {filename} && " + + $"hg --config ui.username=system commit -m 'added {filename}'"; + if (!string.IsNullOrEmpty(_repoOwner)) + { + cmd += $" && chown -R {_repoOwner}:{_repoOwner} /var/vcs/public/{repoId}"; + } + Exec(cmd); + } + + public void SetMaintenance(string message) + => Exec($"printf '%s' '{message}' > {_maintPath}"); + + public void ClearMaintenance() + => Exec($"rm -f {_maintPath}"); + + public void Exec(string shellCommand) + => Run(_podman, "exec", ContainerName, "sh", "-lc", shellCommand); + + public byte[] Fixture(string name) => File.ReadAllBytes(Path.Combine(_dataDir, name)); + + public string FixtureText(string name) => File.ReadAllText(Path.Combine(_dataDir, name)).Trim(); + + // ---- process helpers ------------------------------------------------------------------------ + + private static string FindContextDir() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "Dockerfile")) && + Directory.Exists(Path.Combine(dir.FullName, "src"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + throw new Exception("could not locate csharp/ context dir (with Dockerfile) above the test output"); + } + + private static (int Code, string Out, string Err) Run(string exe, params string[] args) + { + var (code, so, se) = TryRun(exe, args); + if (code != 0) + { + throw new Exception($"`{exe} {string.Join(' ', args)}` failed ({code}).\nstdout:\n{so}\nstderr:\n{se}"); + } + return (code, so, se); + } + + private static (int Code, string Out, string Err) TryRun(string exe, params string[] args) + { + var psi = new ProcessStartInfo + { + FileName = exe, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + + using var proc = Process.Start(psi)!; + var so = proc.StandardOutput.ReadToEndAsync(); + var se = proc.StandardError.ReadToEndAsync(); + proc.WaitForExit(); + return (proc.ExitCode, so.GetAwaiter().GetResult(), se.GetAwaiter().GetResult()); + } + + private static string Env(string name, string fallback) + { + string? v = Environment.GetEnvironmentVariable(name); + return string.IsNullOrWhiteSpace(v) ? fallback : v; + } +} + +[CollectionDefinition("server")] +public sealed class ServerCollection : ICollectionFixture +{ +} diff --git a/csharp/test/HgResume.HttpTests/data/emptyHgRepo.zip b/csharp/test/HgResume.HttpTests/data/emptyHgRepo.zip new file mode 100644 index 0000000..a4da264 Binary files /dev/null and b/csharp/test/HgResume.HttpTests/data/emptyHgRepo.zip differ diff --git a/csharp/test/HgResume.HttpTests/data/sample.bundle b/csharp/test/HgResume.HttpTests/data/sample.bundle new file mode 100644 index 0000000..d239c8e Binary files /dev/null and b/csharp/test/HgResume.HttpTests/data/sample.bundle differ diff --git a/csharp/test/HgResume.HttpTests/data/sample.bundle.hash b/csharp/test/HgResume.HttpTests/data/sample.bundle.hash new file mode 100644 index 0000000..6d0920c --- /dev/null +++ b/csharp/test/HgResume.HttpTests/data/sample.bundle.hash @@ -0,0 +1 @@ +e0d330954fcc diff --git a/csharp/test/HgResume.HttpTests/data/sample2branch.bundle b/csharp/test/HgResume.HttpTests/data/sample2branch.bundle new file mode 100644 index 0000000..a56a724 Binary files /dev/null and b/csharp/test/HgResume.HttpTests/data/sample2branch.bundle differ diff --git a/csharp/test/HgResume.HttpTests/data/sample2branch.hash b/csharp/test/HgResume.HttpTests/data/sample2branch.hash new file mode 100644 index 0000000..4eed100 --- /dev/null +++ b/csharp/test/HgResume.HttpTests/data/sample2branch.hash @@ -0,0 +1 @@ +da48e222f3a8 \ No newline at end of file diff --git a/csharp/test/HgResume.HttpTests/data/sample2branch2base.bundle b/csharp/test/HgResume.HttpTests/data/sample2branch2base.bundle new file mode 100644 index 0000000..0ee6641 Binary files /dev/null and b/csharp/test/HgResume.HttpTests/data/sample2branch2base.bundle differ diff --git a/csharp/test/HgResume.HttpTests/data/sample2branch2base.hash b/csharp/test/HgResume.HttpTests/data/sample2branch2base.hash new file mode 100644 index 0000000..6926ed4 --- /dev/null +++ b/csharp/test/HgResume.HttpTests/data/sample2branch2base.hash @@ -0,0 +1 @@ +0ccc749b1674|e9878d5e821c diff --git a/csharp/test/HgResume.HttpTests/data/sample2branch2tip.hash b/csharp/test/HgResume.HttpTests/data/sample2branch2tip.hash new file mode 100644 index 0000000..819a003 --- /dev/null +++ b/csharp/test/HgResume.HttpTests/data/sample2branch2tip.hash @@ -0,0 +1 @@ +cd3ac2f18827|34c75fc02abb diff --git a/csharp/test/HgResume.HttpTests/data/sample2branchHgRepo.zip b/csharp/test/HgResume.HttpTests/data/sample2branchHgRepo.zip new file mode 100644 index 0000000..cb4e55b Binary files /dev/null and b/csharp/test/HgResume.HttpTests/data/sample2branchHgRepo.zip differ diff --git a/csharp/test/HgResume.HttpTests/data/sampleHgRepo.zip b/csharp/test/HgResume.HttpTests/data/sampleHgRepo.zip new file mode 100644 index 0000000..9b8f5d7 Binary files /dev/null and b/csharp/test/HgResume.HttpTests/data/sampleHgRepo.zip differ diff --git a/csharp/test/HgResume.HttpTests/data/sampleHgRepo2.zip b/csharp/test/HgResume.HttpTests/data/sampleHgRepo2.zip new file mode 100644 index 0000000..5a2d987 Binary files /dev/null and b/csharp/test/HgResume.HttpTests/data/sampleHgRepo2.zip differ diff --git a/csharp/test/HgResume.HttpTests/data/sampleLargeBundleHgRepo.zip b/csharp/test/HgResume.HttpTests/data/sampleLargeBundleHgRepo.zip new file mode 100644 index 0000000..f8a51c1 Binary files /dev/null and b/csharp/test/HgResume.HttpTests/data/sampleLargeBundleHgRepo.zip differ diff --git a/csharp/test/HgResume.HttpTests/data/sample_entire.bundle b/csharp/test/HgResume.HttpTests/data/sample_entire.bundle new file mode 100644 index 0000000..dffe2ab Binary files /dev/null and b/csharp/test/HgResume.HttpTests/data/sample_entire.bundle differ diff --git a/csharp/test/HgResume.HttpTests/data/unrelated.bundle b/csharp/test/HgResume.HttpTests/data/unrelated.bundle new file mode 100644 index 0000000..6dfa0bb Binary files /dev/null and b/csharp/test/HgResume.HttpTests/data/unrelated.bundle differ diff --git a/csharp/test/HgResume.HttpTests/data/unrelated2.bundle b/csharp/test/HgResume.HttpTests/data/unrelated2.bundle new file mode 100644 index 0000000..1230927 Binary files /dev/null and b/csharp/test/HgResume.HttpTests/data/unrelated2.bundle differ diff --git a/csharp/test/HgResume.SendReceiveTests/HgResume.SendReceiveTests.csproj b/csharp/test/HgResume.SendReceiveTests/HgResume.SendReceiveTests.csproj new file mode 100644 index 0000000..461f7ab --- /dev/null +++ b/csharp/test/HgResume.SendReceiveTests/HgResume.SendReceiveTests.csproj @@ -0,0 +1,43 @@ + + + + + net10.0 + enable + enable + false + true + + $(MSBuildProjectDirectory) + $(NoWarn);NU1701;NU1902;NU1903 + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/csharp/test/HgResume.SendReceiveTests/HgResumeServerFixture.cs b/csharp/test/HgResume.SendReceiveTests/HgResumeServerFixture.cs new file mode 100644 index 0000000..106b3e7 --- /dev/null +++ b/csharp/test/HgResume.SendReceiveTests/HgResumeServerFixture.cs @@ -0,0 +1,118 @@ +using System.Diagnostics; +using Xunit; + +namespace HgResume.SendReceiveTests; + +/// +/// Runs the C# hgresume image in a container (via podman/docker) and lets tests create empty server +/// repos with `hg init` — the stand-in for LexBox's project registration. Exposes the host:port the +/// Chorus resumable client points at. The project CODE must contain the substring "resumable" so +/// Chorus selects its resumable transport (RepositoryAddress.IsKnownResumableRepository). +/// +public sealed class HgResumeServerFixture : IAsyncLifetime +{ + private readonly string _cli = Env("HGRESUME_PODMAN", "podman"); + private readonly string _image = Env("HGRESUME_IMAGE", "hgresume-csharp:test"); + private readonly string _port = Env("HGRESUME_PORT", "8041"); + private readonly HttpClient _http = new(); + private string _container = ""; + + public string BaseUrl => $"localhost:{_port}"; + + public async Task InitializeAsync() + { + _container = "hgresume-sr-" + Environment.ProcessId; + TryRun(_cli, "rm", "-f", _container); + Run(_cli, "run", "-d", "--name", _container, "-p", $"{_port}:80", _image); + await WaitForReadyAsync(); + } + + public Task DisposeAsync() + { + if (Environment.GetEnvironmentVariable("HGRESUME_KEEP") is null) + { + TryRun(_cli, "rm", "-f", _container); + } + return Task.CompletedTask; + } + + /// Creates an empty hg repo on the server for the given project code. + public void InitServerRepo(string code) + { + Run(_cli, "exec", _container, "sh", "-lc", + $"rm -rf /var/vcs/public/{code} && hg init /var/vcs/public/{code}"); + } + + /// Returns the server's revision list ("hash:branch|...") for a repo, via the HTTP API. + public async Task GetServerRevisions(string code, int quantity = 50) + { + var resp = await _http.GetAsync($"http://{BaseUrl}/api/v03/getRevisions?offset=0&quantity={quantity}&repoId={code}"); + return await resp.Content.ReadAsStringAsync(); + } + + /// Server tip hash (first revision), or "" if the repo is empty. + public async Task GetServerTip(string code) + { + var revs = await GetServerRevisions(code, 1); + // format: ":|..."; empty repo returns "0:" + var first = revs.Split('|').FirstOrDefault() ?? ""; + return first.Split(':').FirstOrDefault() ?? ""; + } + + public string ContainerLogs() => TryRun(_cli, "logs", _container).Out; + + private async Task WaitForReadyAsync() + { + var deadline = DateTime.UtcNow.AddSeconds(90); + while (DateTime.UtcNow < deadline) + { + try + { + var r = await _http.GetAsync($"http://{BaseUrl}/api/v03/isAvailable"); + if ((int)r.StatusCode == 200) return; + } + catch + { + // not up yet + } + await Task.Delay(500); + } + throw new Exception($"hgresume container not ready at {BaseUrl}"); + } + + private static (int Code, string Out, string Err) Run(string exe, params string[] args) + { + var r = TryRun(exe, args); + if (r.Code != 0) + throw new Exception($"`{exe} {string.Join(' ', args)}` failed ({r.Code}).\n{r.Out}\n{r.Err}"); + return r; + } + + private static (int Code, string Out, string Err) TryRun(string exe, params string[] args) + { + var psi = new ProcessStartInfo + { + FileName = exe, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + using var proc = Process.Start(psi)!; + var so = proc.StandardOutput.ReadToEndAsync(); + var se = proc.StandardError.ReadToEndAsync(); + proc.WaitForExit(); + return (proc.ExitCode, so.GetAwaiter().GetResult(), se.GetAwaiter().GetResult()); + } + + private static string Env(string n, string d) + { + var v = Environment.GetEnvironmentVariable(n); + return string.IsNullOrWhiteSpace(v) ? d : v; + } +} + +[CollectionDefinition("hgresume-server")] +public sealed class HgResumeServerCollection : ICollectionFixture +{ +} diff --git a/csharp/test/HgResume.SendReceiveTests/MercurialService.cs b/csharp/test/HgResume.SendReceiveTests/MercurialService.cs new file mode 100644 index 0000000..bcf7267 --- /dev/null +++ b/csharp/test/HgResume.SendReceiveTests/MercurialService.cs @@ -0,0 +1,95 @@ +using Chorus.Model; +using Chorus.VcsDrivers; +using Chorus.VcsDrivers.Mercurial; +using SIL.Progress; +using Xunit.Abstractions; + +namespace HgResume.SendReceiveTests; + +/// +/// Drives the REAL Chorus resumable client (HgResumeTransport, via HgRepository) against our hgresume +/// container. This is the same transport LexBox's SendReceiveService exercises; we bypass LfMergeBridge +/// only because its FLEx `FixFwData` fixup step (a client-side .fwdata normaliser that never touches the +/// server) requires the full FieldWorks stack. The resumable protocol the server sees is identical. +/// +/// The repo URL must contain the substring "resumable" so Chorus picks its resumable transport +/// (RepositoryAddress.IsKnownResumableRepository). Our server ignores auth, but the Chorus client still +/// requires non-empty credentials, which we set via ServerSettingsModel.SaveUserSettings(). +/// +public class MercurialService +{ + private readonly ITestOutputHelper _output; + + public MercurialService(ITestOutputHelper output) => _output = output; + + private StringBuilderProgress NewProgress() => new() + { + ProgressIndicator = new NullProgressIndicator(), + ShowVerbose = true, + }; + + /// Commit any working-directory changes, then send/receive with the server (pull then push). + public string SendReceiveProject(SendReceiveParams p, SendReceiveAuth auth, string commitMessage = "Testing") + { + SaveCredentials(auth); + var progress = NewProgress(); + string repoUrl = RepoUrl(p); + + // Commit working changes (e.g. the fwdata) so there is a changeset to push. + HgRunner.Run("hg addremove", p.Dir, 120, progress); + HgRunner.Run($"""hg --config ui.username=LexBox commit -m "{commitMessage}" """, p.Dir, 120, progress); + + var repo = new HgRepository(p.Dir, progress); + var address = RepositoryAddress.Create("LexBox", repoUrl); + try + { + repo.Pull(address, repoUrl); + repo.Push(address, repoUrl); + } + catch (Exception e) + { + _output.WriteLine($"Send/Receive threw: {e}"); + _output.WriteLine("--- Chorus progress ---\n" + progress.Text); + throw new Exception($"Send/Receive failed: {e.Message}\n--- Chorus progress ---\n{progress.Text}", e); + } + + _output.WriteLine(progress.Text); + return progress.Text; + } + + /// + /// Clone the server repo into destDir (exercises the resumable pull path). Returns the actual + /// clone directory (Chorus may adjust it if destDir already exists). + /// + public string CloneProject(SendReceiveParams p, SendReceiveAuth auth, string destDir) + { + SaveCredentials(auth); + var progress = NewProgress(); + string repoUrl = RepoUrl(p); + var address = RepositoryAddress.Create("LexBox", repoUrl); + try + { + string clonedTo = HgRepository.Clone(address, destDir, progress); + _output.WriteLine(progress.Text); + return clonedTo; + } + catch (Exception e) + { + _output.WriteLine($"Clone threw: {e}"); + _output.WriteLine("--- Chorus progress ---\n" + progress.Text); + throw new Exception($"Clone failed: {e.Message}\n--- Chorus progress ---\n{progress.Text}", e); + } + } + + private static string RepoUrl(SendReceiveParams p) => $"http://{p.BaseUrl}/{p.Code}"; + + private static void SaveCredentials(SendReceiveAuth auth) + { + new ServerSettingsModel + { + Username = string.IsNullOrEmpty(auth.Username) ? "test" : auth.Username, + RememberPassword = false, + Password = string.IsNullOrEmpty(auth.Password) ? "test" : auth.Password, + }.SaveUserSettings(); + } +} diff --git a/csharp/test/HgResume.SendReceiveTests/ModifyProjectHelper.cs b/csharp/test/HgResume.SendReceiveTests/ModifyProjectHelper.cs new file mode 100644 index 0000000..b0cb1d1 --- /dev/null +++ b/csharp/test/HgResume.SendReceiveTests/ModifyProjectHelper.cs @@ -0,0 +1,47 @@ +namespace HgResume.SendReceiveTests; + +// Verbatim from LexBox backend/Testing/Services/ModifyProjectHelper.cs — byte-patches the fwdata's +// DateModified field so a subsequent send/receive has a real change to transfer. +public static class ModifyProjectHelper +{ + public static void ModifyProject(string projectFilePath) + { + using var fileStream = File.Open(projectFilePath, FileMode.Open, FileAccess.ReadWrite); + var position = FindPosition(fileStream, "DateModified val=\""u8); + if (position < 0) throw new Exception("could not find DateModified in fwdata"); + var timestampLength = "2023-08-16 09:28:29.436"u8.Length; + Span span = stackalloc byte[timestampLength]; + if (fileStream.Read(span) != timestampLength) + { + throw new Exception("unable to read data"); + } + + span[3] = span[3] == "2"u8[0] ? "3"u8[0] : "2"u8[0]; + + fileStream.Position -= timestampLength; + fileStream.Write(span); + fileStream.Flush(true); + } + + private static long FindPosition(Stream stream, ReadOnlySpan pattern) + { + int b; + int i = 0; + while ((b = stream.ReadByte()) != -1) + { + if (b == pattern[i]) + { + if (++i == pattern.Length) + { + return stream.Position - pattern.Length; + } + } + else + { + i = 0; + } + } + + return -1; + } +} diff --git a/csharp/test/HgResume.SendReceiveTests/SendReceiveModels.cs b/csharp/test/HgResume.SendReceiveTests/SendReceiveModels.cs new file mode 100644 index 0000000..a78a572 --- /dev/null +++ b/csharp/test/HgResume.SendReceiveTests/SendReceiveModels.cs @@ -0,0 +1,22 @@ +namespace HgResume.SendReceiveTests; + +// Trimmed copies of the LexBox test models (backend/Testing/Services), with the LexBox server/API +// coupling removed. Only the Resumable protocol is relevant here (that's what our hgresume serves). +public enum HgProtocol +{ + Hgweb, + Resumable +} + +public record SendReceiveAuth(string Username, string Password); + +public record ProjectPath(string Code, string Dir) +{ + public string FwDataFile { get; } = Path.Join(Dir, $"{Code}.fwdata"); +} + +public record SendReceiveParams(string ProjectCode, string BaseUrl, string Dir) : ProjectPath(ProjectCode, Dir) +{ + public SendReceiveParams(HgProtocol protocol, string baseUrl, ProjectPath project) + : this(project.Code, baseUrl, project.Dir) { } +} diff --git a/csharp/test/HgResume.SendReceiveTests/SendReceiveTests.cs b/csharp/test/HgResume.SendReceiveTests/SendReceiveTests.cs new file mode 100644 index 0000000..df7f5a3 --- /dev/null +++ b/csharp/test/HgResume.SendReceiveTests/SendReceiveTests.cs @@ -0,0 +1,157 @@ +using System.IO.Compression; +using Chorus.VcsDrivers.Mercurial; +using FluentAssertions; +using SIL.Progress; +using Xunit; +using Xunit.Abstractions; + +namespace HgResume.SendReceiveTests; + +/// +/// The main LexBox e2e send/receive tests (SendReceiveServiceTests.cs) adapted to run against our C# +/// hgresume container using the real Chorus resumable client. Auth/reset/hgweb-only cases are dropped; +/// LexBox project registration is replaced by `hg init` in the container. +/// +[Collection("hgresume-server")] +public class SendReceiveTests +{ + private static readonly string BasePath = Path.Join(Path.GetTempPath(), "hgresume_sr_tests"); + private static readonly SendReceiveAuth Auth = new("test", "test"); // server ignores auth + + private readonly ITestOutputHelper _output; + private readonly HgResumeServerFixture _server; + private readonly MercurialService _sr; + + public SendReceiveTests(ITestOutputHelper output, HgResumeServerFixture server) + { + _output = output; + _server = server; + _sr = new MercurialService(output); + } + + [Theory] + [InlineData(HgProtocol.Resumable)] + public async Task ModifyProjectData(HgProtocol protocol) + { + var code = NewCode(); + var project = InitLocalFlexProjectWithRepo(code); + _server.InitServerRepo(code); + + var srp = new SendReceiveParams(protocol, _server.BaseUrl, project); + + // Push the fresh project to the server + _sr.SendReceiveProject(srp, Auth); + + var tipAfterFirstPush = await _server.GetServerTip(code); + _output.WriteLine($"server tip after first push: {tipAfterFirstPush}"); + tipAfterFirstPush.Should().NotBeNullOrEmpty(); + tipAfterFirstPush.Should().NotBe("0", "the project should have been pushed to the server"); + + // Modify + new FileInfo(srp.FwDataFile).Length.Should().BeGreaterThan(0); + ModifyProjectHelper.ModifyProject(srp.FwDataFile); + + // Push changes + _sr.SendReceiveProject(srp, Auth, "Modify project data automated test"); + + // The push should have advanced the server tip + var tipAfterModify = await _server.GetServerTip(code); + _output.WriteLine($"server tip after modify: {tipAfterModify}"); + tipAfterModify.Should().NotBe(tipAfterFirstPush, "the modify should have produced a new commit on the server"); + } + + [Fact] + public async Task CloneProject() + { + // Push a project to the server first so there is something to clone. + var code = NewCode(); + var project = InitLocalFlexProjectWithRepo(code); + _server.InitServerRepo(code); + var srp = new SendReceiveParams(HgProtocol.Resumable, _server.BaseUrl, project); + _sr.SendReceiveProject(srp, Auth); + (await _server.GetServerTip(code)).Should().NotBe("0", "the project should have been pushed to the server"); + + // Clone it into a fresh directory over the resumable protocol. + var cloneDir = Path.Join(BasePath, $"{code}-clone"); + if (Directory.Exists(cloneDir)) Directory.Delete(cloneDir, true); + var cloneParams = new SendReceiveParams(HgProtocol.Resumable, _server.BaseUrl, new ProjectPath(code, cloneDir)); + var clonedTo = _sr.CloneProject(cloneParams, Auth, cloneDir); + + // The cloned working directory should contain the fwdata, byte-identical to what we pushed. + var clonedFwData = Path.Join(clonedTo, $"{code}.fwdata"); + File.Exists(clonedFwData).Should().BeTrue($"clone at {clonedTo} should contain the fwdata"); + new FileInfo(clonedFwData).Length.Should().Be(new FileInfo(project.FwDataFile).Length); + File.ReadAllBytes(clonedFwData).SequenceEqual(File.ReadAllBytes(project.FwDataFile)) + .Should().BeTrue("the cloned fwdata should match the pushed fwdata"); + } + + [Fact] + public async Task SendNewProject() + { + await SendNewProjectOfSize(25, 5); + } + + private async Task SendNewProjectOfSize(int totalSizeMb, int fileCount) + { + var code = NewCode(); + var project = InitLocalFlexProjectWithRepo(code); + _server.InitServerRepo(code); + + var srp = new SendReceiveParams(HgProtocol.Resumable, _server.BaseUrl, project); + + // add a bunch of large files as separate commits so the resumable push is large + var progress = new NullProgress(); + for (var i = 1; i <= fileCount; i++) + { + var fileName = $"test-file{i}.bin"; + WriteFile(Path.Combine(srp.Dir, fileName), totalSizeMb / fileCount); + HgRunner.Run($"hg add {fileName}", srp.Dir, 30, progress); + HgRunner.Run($"""hg commit -m "large file commit {i}" """, srp.Dir, 30, progress) + .ExitCode.Should().Be(0); + } + + var srResult = _sr.SendReceiveProject(srp, Auth); + _output.WriteLine(srResult); + + var tip = await _server.GetServerTip(code); + tip.Should().NotBeNullOrEmpty(); + tip.Should().NotBe("0", "the large project should have been pushed to the server"); + } + + // ---- helpers (adapted from LexBox IntegrationFixture / Utils) -------------------------------- + + private static string NewCode() + { + // MUST contain "resumable" so Chorus selects its resumable transport. + var shortId = Guid.NewGuid().ToString().Split('-')[0]; + return $"sr-resumable-{shortId}-dev-flex"; + } + + private ProjectPath InitLocalFlexProjectWithRepo(string code) + { + var dir = Path.Join(BasePath, code); + if (Directory.Exists(dir)) Directory.Delete(dir, true); + Directory.CreateDirectory(dir); + + string templateZip = Path.Combine(AppContext.BaseDirectory, "test-template-repo.zip"); + ZipFile.ExtractToDirectory(templateZip, dir); + File.Move(Path.Join(dir, "kevin-test-01.fwdata"), Path.Join(dir, $"{code}.fwdata")); + + var project = new ProjectPath(code, dir); + File.Exists(project.FwDataFile).Should().BeTrue(); + return project; + } + + private static void WriteFile(string path, int sizeMb) + { + var random = new Random(); + using var file = File.Open(path, FileMode.Create); + Span buffer = stackalloc byte[1024 * 1024]; + for (var i = 0; i < sizeMb; i++) + { + random.NextBytes(buffer); + file.Write(buffer); + } + file.Flush(true); + } +} diff --git a/csharp/test/HgResume.SendReceiveTests/VerifyHgWorking.cs b/csharp/test/HgResume.SendReceiveTests/VerifyHgWorking.cs new file mode 100644 index 0000000..1ecd967 --- /dev/null +++ b/csharp/test/HgResume.SendReceiveTests/VerifyHgWorking.cs @@ -0,0 +1,25 @@ +using Chorus; +using Chorus.VcsDrivers.Mercurial; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace HgResume.SendReceiveTests; + +// Mirrors LexBox's SendReceiveServiceTests.VerifyHgWorking: confirms the bundled Chorus Mercurial +// is present and usable in this project before we exercise send/receive. +public class VerifyHgWorking +{ + private readonly ITestOutputHelper _output; + + public VerifyHgWorking(ITestOutputHelper output) => _output = output; + + [Fact] + public void BundledMercurialIsUsable() + { + string hg = MercurialLocation.PathToHgExecutable; + _output.WriteLine("hg path: " + hg); + (File.Exists(hg) || File.Exists(hg + ".exe")).Should().BeTrue($"expected bundled hg at {hg}"); + HgRepository.GetEnvironmentReadinessMessage("en").Should().BeNull(); + } +} diff --git a/csharp/test/HgResume.SendReceiveTests/test-template-repo.zip b/csharp/test/HgResume.SendReceiveTests/test-template-repo.zip new file mode 100644 index 0000000..8a582de Binary files /dev/null and b/csharp/test/HgResume.SendReceiveTests/test-template-repo.zip differ