diff options
| author | Melodi <62381002+Melodi17@users.noreply.github.com> | 2026-09-06 09:02:31 +1000 |
|---|---|---|
| committer | Melodi <62381002+Melodi17@users.noreply.github.com> | 2026-09-06 09:02:31 +1000 |
| commit | a9bc03fb01b5d1ac41bf5592f384d22df5425aa4 (patch) | |
| tree | 16ae13e19d2a3cd7488202c30790d09557559d70 /src/marlin.core/functional | |
| parent | c00992ffad24212c4398736efd6d707d4cb0cc0d (diff) | |
feat: vcs and secret management support
Diffstat (limited to 'src/marlin.core/functional')
| -rw-r--r-- | src/marlin.core/functional/GitBinaryClient.cs | 133 | ||||
| -rw-r--r-- | src/marlin.core/functional/GitCommandException.cs | 16 | ||||
| -rw-r--r-- | src/marlin.core/functional/IGitClient.cs | 69 | ||||
| -rw-r--r-- | src/marlin.core/functional/IGitCredential.cs | 18 | ||||
| -rw-r--r-- | src/marlin.core/functional/NoopAsyncDisposable.cs | 10 | ||||
| -rw-r--r-- | src/marlin.core/functional/SshKeyGitCredential.cs | 57 | ||||
| -rw-r--r-- | src/marlin.core/functional/TokenGitCredential.cs | 32 |
7 files changed, 270 insertions, 65 deletions
diff --git a/src/marlin.core/functional/GitBinaryClient.cs b/src/marlin.core/functional/GitBinaryClient.cs new file mode 100644 index 0000000..001f2c8 --- /dev/null +++ b/src/marlin.core/functional/GitBinaryClient.cs @@ -0,0 +1,133 @@ +namespace marlin.core.functional; + +using System.Diagnostics; + +public class GitBinaryClient : IGitClient +{ + private readonly IGitCredential? _credential; + + public GitBinaryClient(IGitCredential? credential = null) + { + this._credential = credential; + } + + public async Task<Dictionary<string, string>> GetRemoteRefs(string remote) + { + string stdout = await this.RunAsync( + authenticate: true, + "ls-remote", "--heads", "--tags", remote); + + return ParseRefs(stdout); + } + + public async Task Init(string repoPath) + { + Directory.CreateDirectory(repoPath); + await this.RunAsync(authenticate: false, "init", "--bare", repoPath); + } + + public async Task FetchTrees(string repoPath, string remote, IEnumerable<string> refs, bool skipBlobs = false) + { + var refList = refs as IReadOnlyCollection<string> ?? refs.ToArray(); + if (refList.Count == 0) + return; + + var args = new List<string> { "-C", repoPath, "fetch" }; + if (skipBlobs) + args.Add("--filter=blob:none"); + args.Add(remote); + args.AddRange(refList); + + await this.RunAsync(authenticate: true, args.ToArray()); + } + + public async Task<string[]> ListTree(string repoPath, string hash, string filterPath = ".") + { + string stdout = await this.RunAsync( + authenticate: false, + "-C", repoPath, "ls-tree", "-r", "--name-only", hash, "--", filterPath); + + return stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries); + } + + public Task<byte[]> CatFile(string repoPath, string hash, string fileName) + { + return this.RunRawAsync( + authenticate: false, + "-C", repoPath, "show", $"{hash}:{fileName}"); + } + + private Task<string> RunAsync(bool authenticate, params string[] arguments) => + this.RunAsync(authenticate, async stream => + { + using var reader = new StreamReader(stream); + return await reader.ReadToEndAsync(); + }, arguments); + + private Task<byte[]> RunRawAsync(bool authenticate, params string[] arguments) => + this.RunAsync(authenticate, async stream => + { + using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer); + return buffer.ToArray(); + }, arguments); + + private async Task<T> RunAsync<T>(bool authenticate, Func<Stream, Task<T>> readStdout, string[] arguments) + { + var psi = new ProcessStartInfo("git") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + foreach (string arg in arguments) + psi.ArgumentList.Add(arg); + + psi.Environment["GIT_TERMINAL_PROMPT"] = "0"; + + IAsyncDisposable? credentialScope = authenticate && this._credential != null + ? await this._credential.ApplyAsync(psi) + : null; + + try + { + using var process = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start git process."); + + Task<T> stdoutTask = readStdout(process.StandardOutput.BaseStream); + Task<string> stderrTask = process.StandardError.ReadToEndAsync(); + + await process.WaitForExitAsync(); + T stdout = await stdoutTask; + string stderr = await stderrTask; + + if (process.ExitCode != 0) + throw new GitCommandException(string.Join(' ', arguments), process.ExitCode, stderr); + + return stdout; + } + finally + { + if (credentialScope != null) + await credentialScope.DisposeAsync(); + } + } + + private static Dictionary<string, string> ParseRefs(string stdout) + { + var result = new Dictionary<string, string>(); + + foreach (string line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + string[] parts = line.Split('\t'); + if (parts.Length != 2) + continue; // ignore a malformed line rather than throw over one bad entry + + result[parts[1]] = parts[0]; + } + + return result; + } +} diff --git a/src/marlin.core/functional/GitCommandException.cs b/src/marlin.core/functional/GitCommandException.cs new file mode 100644 index 0000000..e2ee42d --- /dev/null +++ b/src/marlin.core/functional/GitCommandException.cs @@ -0,0 +1,16 @@ +namespace marlin.core.functional; + +public sealed class GitCommandException : Exception +{ + public string Command { get; } + public int ExitCode { get; } + public string StandardError { get; } + + public GitCommandException(string command, int exitCode, string standardError) + : base($"git {command} exited with code {exitCode}: {standardError.Trim()}") + { + this.Command = command; + this.ExitCode = exitCode; + this.StandardError = standardError; + } +} diff --git a/src/marlin.core/functional/IGitClient.cs b/src/marlin.core/functional/IGitClient.cs index 04dc12a..69cffa3 100644 --- a/src/marlin.core/functional/IGitClient.cs +++ b/src/marlin.core/functional/IGitClient.cs @@ -1,71 +1,10 @@ namespace marlin.core.functional; -using System.Diagnostics; - public interface IGitClient { Task<Dictionary<string, string>> GetRemoteRefs(string remote); -} - -public class GitBinaryClient : IGitClient -{ - public virtual async Task<Dictionary<string, string>> GetRemoteRefs(string remote) - { - var psi = this.CreateProcess(remote); - var proc = Process.Start(psi)!; - - string stdout = await proc.StandardOutput.ReadToEndAsync(); - await proc.WaitForExitAsync(); - - return ParseOutput(stdout); - } - - private Dictionary<string, string> ParseOutput(string stdout) - { - return stdout - .Split("\n", StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Split('\t')) - .ToDictionary(k => k[1], k => k[0]); - } - - protected virtual ProcessStartInfo CreateProcess(string remote) - { - var psi = new ProcessStartInfo("git", $"ls-remote --heads --tags {remote}") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - Environment = { ["GIT_TERMINAL_PROMPT"] = "0" } - }; - - return psi; - } - - /*private async Task SshAuth(string privateKeyPem) - { - string keyPath = Path.Combine(Path.GetTempPath(), $"key_{Guid.NewGuid():N}"); - await File.WriteAllTextAsync(keyPath, privateKeyPem); - File.SetUnixFileMode(keyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); - - try - { - psi.Environment["GIT_SSH_COMMAND"] = - $"ssh -i {keyPath} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"; - // run ls-remote against the ssh:// or git@ URL - } - finally - { - File.Delete(keyPath); - } - } - - private async Task BasicAuth(string accessToken) - { - psi.Environment["GIT_CONFIG_COUNT"] = "1"; - psi.Environment["GIT_CONFIG_KEY_0"] = "http.extraheader"; - psi.Environment["GIT_CONFIG_VALUE_0"] = - $"AUTHORIZATION: basic {Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{accessToken}"))}"; - - }*/ + Task Init(string repoPath); + Task FetchTrees(string repoPath, string remote, IEnumerable<string> refs, bool skipBlobs = false); + Task<string[]> ListTree(string repoPath, string hash, string filterPath = "."); + Task<byte[]> CatFile(string repoPath, string hash, string fileName); }
\ No newline at end of file diff --git a/src/marlin.core/functional/IGitCredential.cs b/src/marlin.core/functional/IGitCredential.cs new file mode 100644 index 0000000..cf96804 --- /dev/null +++ b/src/marlin.core/functional/IGitCredential.cs @@ -0,0 +1,18 @@ +namespace marlin.core.functional; + +using System.Diagnostics; + +/// <summary> +/// Supplies whatever git needs to authenticate a single invocation — an HTTP header, +/// an SSH key, etc. Credentials are applied per-process via environment variables only; +/// nothing is written to git config, argv, or any credential store. +/// </summary> +public interface IGitCredential +{ + /// <summary> + /// Configures <paramref name="psi"/> so the git process it starts can authenticate. + /// Returns a disposable that tears down anything ephemeral (e.g. a temp key file) + /// once that process has exited. + /// </summary> + Task<IAsyncDisposable> ApplyAsync(ProcessStartInfo psi); +} diff --git a/src/marlin.core/functional/NoopAsyncDisposable.cs b/src/marlin.core/functional/NoopAsyncDisposable.cs new file mode 100644 index 0000000..330a197 --- /dev/null +++ b/src/marlin.core/functional/NoopAsyncDisposable.cs @@ -0,0 +1,10 @@ +namespace marlin.core.functional; + +internal sealed class NoopAsyncDisposable : IAsyncDisposable +{ + public static readonly NoopAsyncDisposable Instance = new(); + + private NoopAsyncDisposable() { } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/src/marlin.core/functional/SshKeyGitCredential.cs b/src/marlin.core/functional/SshKeyGitCredential.cs new file mode 100644 index 0000000..9183a66 --- /dev/null +++ b/src/marlin.core/functional/SshKeyGitCredential.cs @@ -0,0 +1,57 @@ +namespace marlin.core.functional; + +using System.Diagnostics; + +/// <summary> +/// SSH auth via a private key. The key is written to a per-invocation temp file +/// (owner-read/write only) and deleted as soon as the git process that used it exits — +/// ssh has no clean way to take key material via stdin/env, so a short-lived file is +/// the least-bad option. +/// </summary> +public sealed class SshKeyGitCredential : IGitCredential +{ + private readonly string _privateKeyPem; + + public SshKeyGitCredential(string privateKeyPem) + { + this._privateKeyPem = privateKeyPem; + } + + public async Task<IAsyncDisposable> ApplyAsync(ProcessStartInfo psi) + { + string keyPath = Path.Combine(Path.GetTempPath(), $"marlin_{Guid.NewGuid():N}.key"); + await File.WriteAllTextAsync(keyPath, this._privateKeyPem); + + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(keyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + + psi.Environment["GIT_SSH_COMMAND"] = + $"ssh -i \"{keyPath}\" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"; + + return new TempKeyFile(keyPath); + } + + private sealed class TempKeyFile : IAsyncDisposable + { + private readonly string _path; + + public TempKeyFile(string path) + { + this._path = path; + } + + public ValueTask DisposeAsync() + { + try + { + File.Delete(this._path); + } + catch (IOException) + { + // best-effort cleanup; a leftover temp file is not worth failing the caller over + } + + return ValueTask.CompletedTask; + } + } +} diff --git a/src/marlin.core/functional/TokenGitCredential.cs b/src/marlin.core/functional/TokenGitCredential.cs new file mode 100644 index 0000000..5dfef66 --- /dev/null +++ b/src/marlin.core/functional/TokenGitCredential.cs @@ -0,0 +1,32 @@ +namespace marlin.core.functional; + +using System.Diagnostics; +using System.Text; + +/// <summary> +/// HTTPS auth via a bearer/basic token. Injected as an Authorization header for a +/// single git invocation through GIT_CONFIG_* env vars, so it never appears in argv +/// (visible via ps) or in git's own error output (which echoes the URL, not headers). +/// </summary> +public sealed class TokenGitCredential : IGitCredential +{ + private readonly string _username; + private readonly string _token; + + public TokenGitCredential(string token, string username = "x-access-token") + { + this._token = token; + this._username = username; + } + + public Task<IAsyncDisposable> ApplyAsync(ProcessStartInfo psi) + { + string header = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{this._username}:{this._token}")); + + psi.Environment["GIT_CONFIG_COUNT"] = "1"; + psi.Environment["GIT_CONFIG_KEY_0"] = "http.extraheader"; + psi.Environment["GIT_CONFIG_VALUE_0"] = $"AUTHORIZATION: basic {header}"; + + return Task.FromResult<IAsyncDisposable>(NoopAsyncDisposable.Instance); + } +} |
