diff --git a/GitLfsCache.Tests/GitLfsCache.Tests.csproj b/GitLfsCache.Tests/GitLfsCache.Tests.csproj index 9f924c8..33c6e1b 100644 --- a/GitLfsCache.Tests/GitLfsCache.Tests.csproj +++ b/GitLfsCache.Tests/GitLfsCache.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/GitLfsCache.Tests/Tool/AllowFlagTests.cs b/GitLfsCache.Tests/Tool/AllowFlagTests.cs new file mode 100644 index 0000000..f72c92e --- /dev/null +++ b/GitLfsCache.Tests/Tool/AllowFlagTests.cs @@ -0,0 +1,98 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.GitLfsCache.Tests.Tool; + +using System.Text; +using ktsu.GitLfsCache.Configuration; +using ktsu.GitLfsCache.Tool; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +[TestClass] +public class AllowFlagTests +{ + private const string ConfigFile = """ + { + "GitLfsCache": { + "Upstreams": { + "github": { "BaseUrl": "https://github.com", "Repositories": ["studio/**", "**"] }, + "ado": { "BaseUrl": "https://dev.azure.com/org", "Repositories": ["project/**", "other/**"] } + } + } + } + """; + + /// + /// Binds the options the way the tool does: a configuration file, then the flags over it. + /// + private static GitLfsCacheOptions Bind(params string[] allows) + { + Assert.IsTrue( + Program.TryParseAllows(allows, out Dictionary> allowLists, out string? invalid), + invalid); + + using MemoryStream file = new(Encoding.UTF8.GetBytes(ConfigFile)); + IConfiguration configuration = new ConfigurationBuilder().AddJsonStream(file).Build(); + + ServiceCollection services = new(); + services.AddOptions().Bind(configuration.GetSection(GitLfsCacheOptions.SectionName)); + services.PostConfigure(options => Program.ReplaceAllowLists(options, allowLists)); + + using ServiceProvider provider = services.BuildServiceProvider(); + return provider.GetRequiredService>().Value; + } + + private static void AssertRepositories(GitLfsCacheOptions options, string upstream, params string[] expected) => + CollectionAssert.AreEqual(expected, options.Upstreams[upstream].Repositories.ToArray()); + + [TestMethod] + public void Allow_FewerPatternsThanConfigured_ReplacesTheConfiguredList() + { + GitLfsCacheOptions options = Bind("github=only/**"); + + AssertRepositories(options, "github", "only/**"); + } + + [TestMethod] + public void Allow_Repeated_KeepsEveryPatternInOrder() + { + GitLfsCacheOptions options = Bind("github=a/**", "GitHub=b/**"); + + AssertRepositories(options, "github", "a/**", "b/**"); + } + + [TestMethod] + public void Allow_ForOneUpstream_LeavesTheOthersConfiguredList() + { + GitLfsCacheOptions options = Bind("github=only/**"); + + AssertRepositories(options, "ado", "project/**", "other/**"); + } + + [TestMethod] + public void Allow_ForAnUnconfiguredUpstream_AddsItWithThoseRepositories() + { + GitLfsCacheOptions options = Bind("gitlab=team/**"); + + AssertRepositories(options, "gitlab", "team/**"); + } + + [TestMethod] + public void NoAllow_KeepsTheConfiguredList() + { + GitLfsCacheOptions options = Bind(); + + AssertRepositories(options, "github", "studio/**", "**"); + } + + [TestMethod] + [DataRow("github")] + [DataRow("=studio/**")] + [DataRow("github=")] + public void TryParseAllows_Malformed_ReportsTheEntry(string entry) + { + Assert.IsFalse(Program.TryParseAllows([entry], out _, out string? invalid)); + Assert.AreEqual(entry, invalid); + } +} diff --git a/GitLfsCache.Tool/Program.cs b/GitLfsCache.Tool/Program.cs index 0cee8e2..94825bb 100644 --- a/GitLfsCache.Tool/Program.cs +++ b/GitLfsCache.Tool/Program.cs @@ -5,6 +5,7 @@ namespace ktsu.GitLfsCache.Tool; using System.CommandLine; using System.Security.Cryptography; using ktsu.Essentials; +using ktsu.GitLfsCache.Configuration; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.Configuration; @@ -134,14 +135,18 @@ private static async Task Main(string[] args) .ConfigureAwait(false); } - if (!TryApplyAllows(parseResult.GetValue(allow), overrides, out string? invalidAllow)) + if (!TryParseAllows( + parseResult.GetValue(allow), + out Dictionary> allowLists, + out string? invalidAllow)) { return await FailAsync( $"'{invalidAllow}' is not a valid allow entry. Use name=pattern, for example github=studio/**.") .ConfigureAwait(false); } - return await RunAsync(overrides, listenPort, configPath, cancellationToken).ConfigureAwait(false); + return await RunAsync(overrides, allowLists, listenPort, configPath, cancellationToken) + .ConfigureAwait(false); }); return await root.Parse(args) @@ -151,6 +156,7 @@ private static async Task Main(string[] args) private static async Task RunAsync( Dictionary overrides, + Dictionary> allowLists, int port, string? configPath, CancellationToken cancellationToken) @@ -176,6 +182,7 @@ private static async Task RunAsync( builder.Configuration["Kestrel:Endpoints:Http:Url"] = $"http://*:{port}"; builder.Services.AddGitLfsCache(builder.Configuration); + builder.Services.PostConfigure(options => ReplaceAllowLists(options, allowLists)); // Behind an ingress the request the proxy sees is not the URL the client used, so the scheme // and host from the forwarded headers are what make derived transfer URLs correct. Without @@ -279,23 +286,19 @@ private static bool TryApplyUpstreams( } /// - /// Binds every --allow flag to configuration. + /// Groups every --allow flag by the upstream it names. /// - /// - /// Indexed per upstream so repeating the flag appends rather than overwrites, which is what a - /// repeatable option has to do to be useful. - /// /// The flag values, or null when the flag was not given. - /// Configuration to add to. + /// The patterns given for each upstream, in the order they were typed. /// The first entry that could not be read, when one could not. /// when every entry was well formed. - private static bool TryApplyAllows( + internal static bool TryParseAllows( string[]? entries, - Dictionary overrides, + out Dictionary> allowLists, out string? invalid) { invalid = null; - Dictionary counts = new(StringComparer.OrdinalIgnoreCase); + allowLists = new(StringComparer.OrdinalIgnoreCase); foreach (string entry in entries ?? []) { @@ -305,15 +308,52 @@ private static bool TryApplyAllows( return false; } - int index = counts.TryGetValue(name, out int used) ? used : 0; - counts[name] = index + 1; + if (!allowLists.TryGetValue(name, out List? patterns)) + { + patterns = []; + allowLists[name] = patterns; + } - overrides[$"GitLfsCache:Upstreams:{name}:Repositories:{index}"] = pattern; + patterns.Add(pattern); } return true; } + /// + /// Makes each upstream named by --allow allow exactly the patterns given for it. + /// + /// + /// Not written as configuration keys like the other flags. Configuration merges arrays by index, + /// so Repositories:0 from the command line would replace only the first entry of a list from + /// a file or the environment and leave the rest in force: a file allowing studio/** and + /// ** plus --allow github=only/** would still allow everything. Assigning the list + /// after binding, as a post-configure step, is what makes the flag narrow the list rather than patch + /// it. Upstreams no flag names keep the list configuration gave them. + /// + /// The options as bound from configuration. + /// The patterns given for each upstream. + internal static void ReplaceAllowLists( + GitLfsCacheOptions options, + IReadOnlyDictionary> allowLists) + { + foreach ((string name, List patterns) in allowLists) + { + if (!options.Upstreams.TryGetValue(name, out UpstreamOptions? upstream)) + { + upstream = new UpstreamOptions(); + options.Upstreams[name] = upstream; + } + + upstream.Repositories.Clear(); + + foreach (string pattern in patterns) + { + upstream.Repositories.Add(pattern); + } + } + } + private static void ApplyDefaults(ConfigurationManager configuration, Dictionary overrides) { bool hasStore = overrides.ContainsKey("GitLfsCache:Store:Root") diff --git a/README.md b/README.md index 714a0c6..7547d25 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Nothing has to be passed as a flag. Configuration comes from three places, each 2. **A file named with `--config`**, which can live anywhere: `gitlfscache --config /etc/gitlfscache.json`. It is layered over the working-directory file rather than replacing it, so an explicit file only has to carry what differs. A path that does not exist is reported by name and the process exits rather than starting on defaults. 3. **Environment variables**, using `__` as the section separator (`GitLfsCache__Store__MaxSize`, `GitLfsCache__Upstreams__github__BaseUrl`). This is how the Kubernetes base configures everything. -The flags are a convenience over the same settings and win over all three, so `--max-size 3GB` beats a `--config` file asking for 9GB. +The flags are a convenience over the same settings and win over all three, so `--max-size 3GB` beats a `--config` file asking for 9GB. `--allow` replaces rather than adds to: when it names an upstream, that upstream allows exactly the patterns given on the command line, and any `Repositories` entries for it from the three sources above are discarded. Upstreams no `--allow` names keep their configured list. ```json {