diff --git a/README.md b/README.md index 5ddf372..7631478 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ rather than after the tool. rbmanager remains the product name. ``` rb setup [--yes] copy rb onto PATH and set up the VC++ runtime -rb install install a ruby binary package +rb install install a ruby binary package rb list list installed rubies rb use switch the active ruby rb uninstall remove an installed ruby @@ -40,6 +40,18 @@ ship. It also checks for the VC++ 2015-2022 redistributable the official mswin packages depend on, and offers to download and install it (signature-verified, elevated); `--yes` skips the consent prompt. +`install` takes a version or a tag and resolves it through the binary +index published at + (regenerated +by ruby/actions after every package publish). `rb install 4.0.5` picks +that release, `rb install 4.0` the newest release of the series, and +`rb install ruby-dev` the newest master snapshot. A reissued release +resolves to its newest revision, and the superseded packages stay +reachable by their revisioned names such as `4.0.5-0`. The download is +verified against the sha256 recorded in the index. An unsigned build +(all dev snapshots are unsigned) installs with a warning. A zip path or +URL skips the index and installs directly. + `msvc` activates an installed Visual Studio (or Build Tools) MSVC toolchain for building C extension gems and runs the rest of the command line under it, as in `rb msvc gem install nokogiri`; diff --git a/docs/test-plan.md b/docs/test-plan.md index 24f8857..cae1e7f 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -30,7 +30,7 @@ Command surface and contracts: | Command | Behavior | Exit | |---|---|---| | `rb setup` | Copy own exe to `\bin\rb.exe` (skip if already running from there, case-insensitive), append that dir to user PATH | 0 | -| `rb install ` | If URL, download to `%TEMP%` first (deleted in `finally`). Zip must contain exactly one root dir named `ruby-*`; extract under `rubies`, inject the embedded `operating_system.rb` trust hook into `lib\ruby\site_ruby\rubygems\defaults\`, then switch `current` to it and ensure `current\bin` on user PATH. Refuse if already installed | 0 / 1 | +| `rb install ` | An argument that is not a URL and does not look like a zip path (no existing file, no path separator, no `.zip` suffix) is resolved through the binary index (see 4.13): pick the newest matching `x64-mswin64_140` build, download it, verify its `sha256`, warn on stderr when it is unsigned. If URL, download to `%TEMP%` first (deleted in `finally`). Zip must contain exactly one root dir named `ruby-*`; extract under `rubies`, inject the embedded `operating_system.rb` trust hook into `lib\ruby\site_ruby\rubygems\defaults\`, then switch `current` to it and ensure `current\bin` on user PATH. Refuse if already installed | 0 / 1 | | `rb list` | Installed names sorted, active one starred | 0 | | `rb use ` | Resolve query (exact or case-insensitive substring; must be unambiguous), recreate the `current` junction, ensure PATH | 0 / 1 | | `rb uninstall ` | Resolve; if active, delete the junction first and print a hint; delete the install dir recursively | 0 / 1 | @@ -432,6 +432,64 @@ values are whatever the build stamped in and the tests pin the shape. come from the same source tree at the same commit, so any divergence is AOT. +### 4.13 Program + BinaryIndex: install from the binary index + +`rb install ` resolves through +`https://cache.ruby-lang.org/pub/ruby/binaries/index.json` (generated by +`tool/update_binaries_index.rb` in ruby/actions; schema 1, resolution +through each build's `tags`, reissues distinguished by `revision`). The +seam is `RBMANAGER_INDEX_URL`, which accepts an http(s) URL, a `file://` +URL, or an absolute local path, so the Integration cases read the feed +from a file and download the zip from the loopback server. Selection is +order-independent: the newest match wins by numeric version, then +revision, then commit date, mirroring the revision-aware `Resolve` for +installed rubies. + +Unit (`BinaryIndexTests`): + +99. `Parse` on a page in the published feed's shape → every key of the + build populated, including the snake_case `commit_date` / + `published_at` mappings. +100. `Parse` with `schema: 2` → error naming the schema and telling the + user to upgrade rb. +101. A series tag (`4.0`, `4`) sits on every release of the series → + the highest version wins, in either feed order. +102. Two revisions of one version → the higher revision wins, in either + feed order. +103. A superseded revision resolves by its revisioned tag (`4.0.5-0`). +104. Two dev snapshots of one version → the newer commit date wins, in + either feed order. +105. A build of another platform never resolves, even on a tag match. +106. The full package name resolves alongside the tags, + case-insensitively. +107. No match → null. +108. A prerelease resolves only by its exact tag; `4.1` resolves + nothing when the series has only a prerelease. + +Integration (`InstallFromIndexTests`, Serial): + +110. Install by tag: resolved from the file feed, downloaded from the + loopback server, sha256 verified, installed and switched; + `Resolved to ` and `Installed ` on stdout, no + warning for a signed build, temp download deleted. +111. `signed: false` → `warning: is not code-signed` on stderr, + install still succeeds. +112. sha256 mismatch → error, nothing installed, temp download deleted. +113. A non-null `next` chains to the following page (relative to the + feed URL). +114. `RBMANAGER_INDEX_URL` accepts a `file://` URL. +115. `schema: 2` in the feed → the upgrade-rb error, nothing installed. +116. No matching build → `no binary package matches '' in the index`. +117. A missing zip path (`.zip` suffix or path separator) fails as a + missing file and never falls through to index resolution. + +Network (`BinaryIndexNetworkTests`, `Category=Network`, gated on +`RBMANAGER_TEST_NETWORK=1`): + +109. The published index parses, and `ruby-dev` resolves to an + `x64-mswin64_140` build with a well-formed sha256 and a + cache.ruby-lang.org URL. + ## 5. Execution plan Phased so each phase leaves the tree green. diff --git a/src/rbmanager/BinaryIndex.cs b/src/rbmanager/BinaryIndex.cs new file mode 100644 index 0000000..f563f09 --- /dev/null +++ b/src/rbmanager/BinaryIndex.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace RbManager; + +// Consumer of https://cache.ruby-lang.org/pub/ruby/binaries/index.json, +// the feed ruby/actions regenerates after every mswin package publish +// (tool/update_binaries_index.rb there). The feed is the resolution +// authority: builds are matched through their `tags` rather than by +// parsing `version`, whose syntax depends on the channel. +internal static class BinaryIndex +{ + internal const string Platform = "x64-mswin64_140"; + + private const string DefaultUrl = + "https://cache.ruby-lang.org/pub/ruby/binaries/index.json"; + + // RBMANAGER_INDEX_URL redirects the feed for tests; it accepts an + // http(s) URL, a file:// URL, or an absolute local path. + private static string Url => + Environment.GetEnvironmentVariable("RBMANAGER_INDEX_URL") is { Length: > 0 } url + ? url + : DefaultUrl; + + public static async Task Resolve(string query) + { + var page = new Uri(Url, UriKind.Absolute); + var builds = new List(); + while (true) + { + IndexPage index = Parse(await Fetch(page)); + builds.AddRange(index.Builds); + if (index.Next is null) break; + page = new Uri(page, index.Next); + } + return Pick(builds, query) ?? throw new InvalidOperationException( + $"no binary package matches '{query}' in the index"); + } + + private static async Task Fetch(Uri uri) + { + if (uri.IsFile) return await File.ReadAllTextAsync(uri.LocalPath); + using var http = new HttpClient(); + return await http.GetStringAsync(uri); + } + + internal static IndexPage Parse(string json) + { + IndexPage page = JsonSerializer.Deserialize(json, IndexJsonContext.Default.IndexPage) + ?? throw new InvalidOperationException("the binary index is empty"); + if (page.Schema != 1) + throw new InvalidOperationException( + $"the binary index has schema {page.Schema}, which this rb does not understand; upgrade rb"); + return page; + } + + // The newest match wins regardless of feed order: series tags like + // "4.0" sit on every 4.0.x release, and dev tags like "4.1-dev" on + // every snapshot of the series. Ordering by version, then reissue + // revision (SIGNING.md in ruby/actions), then commit date keeps this + // consistent with Program.Resolve's revision handling for installed + // rubies. + internal static Build? Pick(IEnumerable builds, string query) => + builds + .Where(b => b.Platform == Platform) + .Where(b => b.Tags.Contains(query, StringComparer.OrdinalIgnoreCase) || + string.Equals(b.Name, query, StringComparison.OrdinalIgnoreCase)) + .MaxBy(b => (NumericVersion(b.Version), b.Revision ?? 0, + b.CommitDate ?? b.PublishedAt ?? "", b.Commit ?? "")); + + // The numeric prefix of `version` ("4.1.0dev" and "4.1.0-rc1" both + // compare as 4.1.0). Channel suffixes never decide between two + // matches of one tag: a tag matches either releases or dev builds, + // never both. + private static Version NumericVersion(string version) + { + int end = 0; + while (end < version.Length && (char.IsAsciiDigit(version[end]) || version[end] == '.')) + end++; + return Version.Parse(version[..end].TrimEnd('.')); + } +} + +internal sealed record IndexPage +{ + public int Schema { get; init; } + public string? Next { get; init; } + public List Builds { get; init; } = []; +} + +// One build entry. Every key is always present in the feed, with null +// standing in where a key does not apply to the channel. +internal sealed record Build +{ + public required string Name { get; init; } + public required string Version { get; init; } + public required string Channel { get; init; } + public int? Revision { get; init; } + public required string[] Tags { get; init; } + public required string Platform { get; init; } + public required string Url { get; init; } + public required string Sha256 { get; init; } + public long Size { get; init; } + public string? Commit { get; init; } + public string? CommitDate { get; init; } + public string? PublishedAt { get; init; } + public bool Signed { get; init; } +} + +// Reflection-free serializer for NativeAOT. +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] +[JsonSerializable(typeof(IndexPage))] +internal sealed partial class IndexJsonContext : JsonSerializerContext; diff --git a/src/rbmanager/Program.cs b/src/rbmanager/Program.cs index 05bac5b..08ec49e 100644 --- a/src/rbmanager/Program.cs +++ b/src/rbmanager/Program.cs @@ -52,7 +52,8 @@ private static int Usage() usage: rb setup [--yes] copy rb onto PATH and set up the VC++ runtime - install install a ruby binary package from a zip file or URL + install install a ruby binary package resolved from the + binary index, or from a zip file or URL list list installed rubies use switch the active ruby uninstall remove an installed ruby @@ -88,10 +89,24 @@ private static async Task Setup(bool assumeYes) internal static async Task Install(string source) { + // Anything that is not a URL or a zip path is a version or tag to + // resolve through the binary index (BinaryIndex.cs). sha256 + // verification is only possible on this path; a direct URL + // carries no expected checksum. + string? sha256 = null; + if (!IsUrl(source) && !LooksLikeZipPath(source)) + { + Build build = await BinaryIndex.Resolve(source); + Console.WriteLine($"Resolved {source} to {build.Name}"); + if (!build.Signed) + Console.Error.WriteLine($"warning: {build.Name} is not code-signed"); + source = build.Url; + sha256 = build.Sha256; + } + string zip = source; string? downloaded = null; - if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - source.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + if (IsUrl(source)) { downloaded = Path.Combine(Path.GetTempPath(), Path.GetFileName(new Uri(source).LocalPath)); Console.WriteLine($"Downloading {source} ..."); @@ -106,6 +121,7 @@ internal static async Task Install(string source) try { + if (sha256 is not null) await VerifySha256(zip, sha256); string name = SingleRootDirectory(zip); string dest = Path.Combine(Rubies, name); if (Directory.Exists(dest)) @@ -129,6 +145,28 @@ internal static async Task Install(string source) } } + private static bool IsUrl(string source) => + source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + source.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + + // Tags in the index never contain a path separator or a .zip suffix, + // so those mark the argument as a zip path even when the file does + // not exist (a typo'd path must fail as a missing file, not as an + // unknown version). + private static bool LooksLikeZipPath(string source) => + File.Exists(source) || source.Contains('\\') || source.Contains('/') || + source.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); + + internal static async Task VerifySha256(string file, string expected) + { + await using var stream = File.OpenRead(file); + string actual = Convert.ToHexString( + await System.Security.Cryptography.SHA256.HashDataAsync(stream)); + if (!string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"sha256 mismatch for {Path.GetFileName(file)}: expected {expected}, got {actual.ToLowerInvariant()}"); + } + internal static int List() { string? current = CurrentTarget(); diff --git a/tests/rbmanager.Tests/BinaryIndexTests.cs b/tests/rbmanager.Tests/BinaryIndexTests.cs new file mode 100644 index 0000000..d76068c --- /dev/null +++ b/tests/rbmanager.Tests/BinaryIndexTests.cs @@ -0,0 +1,207 @@ +namespace RbManager.Tests; + +// Plan 4.13: BinaryIndex.Parse and BinaryIndex.Pick — pure logic against +// hand-built pages and builds, no filesystem, no network. +[Trait("Category", "Unit")] +public class BinaryIndexTests +{ + private static Build Make(string name, string version, string channel = "release", + int? revision = 0, string[]? tags = null, string platform = BinaryIndex.Platform, + string? commit = null, string? commitDate = null, string? publishedAt = null) => new() + { + Name = name, + Version = version, + Channel = channel, + Revision = revision, + Tags = tags ?? [], + Platform = platform, + Url = $"https://cache.ruby-lang.org/pub/ruby/binaries/mswin64/{name}.zip", + Sha256 = new string('0', 64), + Size = 1, + Commit = commit, + CommitDate = commitDate, + PublishedAt = publishedAt, + Signed = false, + }; + + [Fact] // case 99: the published feed's shape round-trips + public void Parse_PublishedShape_PopulatesEveryKey() + { + IndexPage page = BinaryIndex.Parse(""" + { + "schema": 1, + "next": null, + "builds": [ + { + "name": "ruby-4.1.0dev-20260821-e4462a9514-x64-mswin64_140", + "version": "4.1.0dev", + "channel": "dev", + "revision": null, + "tags": ["4.1-dev", "4.1-dev-20260821", + "4.1.0dev-20260821-e4462a9514", "ruby-dev"], + "platform": "x64-mswin64_140", + "url": "https://cache.ruby-lang.org/pub/ruby/binaries/mswin64/dev/ruby-4.1.0dev-20260821-e4462a9514-x64-mswin64_140.zip", + "sha256": "313063a97245ec48f125180346f589b43403ea8dc1afe98de0309f7e2baefd6b", + "size": 29021956, + "commit": "e4462a9514", + "commit_date": "2026-08-21", + "published_at": "2026-08-20", + "signed": false + } + ] + } + """); + + Assert.Equal(1, page.Schema); + Assert.Null(page.Next); + Build b = Assert.Single(page.Builds); + Assert.Equal("ruby-4.1.0dev-20260821-e4462a9514-x64-mswin64_140", b.Name); + Assert.Equal("4.1.0dev", b.Version); + Assert.Equal("dev", b.Channel); + Assert.Null(b.Revision); + Assert.Contains("ruby-dev", b.Tags); + Assert.Equal(BinaryIndex.Platform, b.Platform); + Assert.Equal("313063a97245ec48f125180346f589b43403ea8dc1afe98de0309f7e2baefd6b", b.Sha256); + Assert.Equal(29021956, b.Size); + Assert.Equal("2026-08-21", b.CommitDate); + Assert.Equal("2026-08-20", b.PublishedAt); + Assert.False(b.Signed); + } + + [Fact] // case 100 + public void Parse_UnsupportedSchema_Throws() + { + var ex = Assert.Throws( + () => BinaryIndex.Parse("""{"schema": 2, "next": null, "builds": []}""")); + Assert.Contains("schema 2", ex.Message); + Assert.Contains("upgrade rb", ex.Message); + } + + [Fact] // case 101: a series tag sits on every release of the series + public void Pick_SeriesTag_PicksHighestVersion_RegardlessOfOrder() + { + Build[] builds = + [ + Make("ruby-4.0.4-x64-mswin64_140", "4.0.4", tags: ["4.0.4", "4.0", "4"]), + Make("ruby-4.0.5-x64-mswin64_140", "4.0.5", tags: ["4.0.5", "4.0", "4"]), + ]; + + Assert.Equal("ruby-4.0.5-x64-mswin64_140", BinaryIndex.Pick(builds, "4.0")!.Name); + Assert.Equal("ruby-4.0.5-x64-mswin64_140", + BinaryIndex.Pick(builds.Reverse(), "4")!.Name); + } + + [Fact] // case 102: a reissue supersedes by revision, not by feed order + public void Pick_SameVersion_PicksHighestRevision() + { + Build[] builds = + [ + Make("ruby-4.0.5-x64-mswin64_140", "4.0.5", revision: 0, tags: ["4.0.5-0", "4.0.5"]), + Make("ruby-4.0.5-1-x64-mswin64_140", "4.0.5", revision: 1, + tags: ["4.0.5-1", "4.0.5", "4.0", "4"]), + ]; + + Assert.Equal("ruby-4.0.5-1-x64-mswin64_140", BinaryIndex.Pick(builds, "4.0.5")!.Name); + Assert.Equal("ruby-4.0.5-1-x64-mswin64_140", + BinaryIndex.Pick(builds.Reverse(), "4.0.5")!.Name); + } + + [Fact] // case 103: a superseded revision stays reachable by its exact tag + public void Pick_SupersededRevision_ByRevisionedTag() + { + Build[] builds = + [ + Make("ruby-4.0.5-x64-mswin64_140", "4.0.5", revision: 0, tags: ["4.0.5-0"]), + Make("ruby-4.0.5-1-x64-mswin64_140", "4.0.5", revision: 1, + tags: ["4.0.5-1", "4.0.5", "4.0", "4"]), + ]; + + Assert.Equal("ruby-4.0.5-x64-mswin64_140", BinaryIndex.Pick(builds, "4.0.5-0")!.Name); + } + + [Fact] // case 104: equal dev versions fall back to the commit date + public void Pick_DevSeries_PicksNewestSnapshot() + { + Build[] builds = + [ + Make("ruby-4.1.0dev-20260818-39d4744b68-x64-mswin64_140", "4.1.0dev", + channel: "dev", revision: null, tags: ["4.1-dev", "ruby-dev"], + commit: "39d4744b68", commitDate: "2026-08-18", publishedAt: "2026-08-18"), + Make("ruby-4.1.0dev-20260821-e4462a9514-x64-mswin64_140", "4.1.0dev", + channel: "dev", revision: null, tags: ["4.1-dev", "ruby-dev"], + commit: "e4462a9514", commitDate: "2026-08-21", publishedAt: "2026-08-20"), + ]; + + Assert.Equal("ruby-4.1.0dev-20260821-e4462a9514-x64-mswin64_140", + BinaryIndex.Pick(builds, "ruby-dev")!.Name); + Assert.Equal("ruby-4.1.0dev-20260821-e4462a9514-x64-mswin64_140", + BinaryIndex.Pick(builds.Reverse(), "4.1-dev")!.Name); + } + + [Fact] // case 105: other platforms never resolve, even on a tag match + public void Pick_ForeignPlatform_Filtered() + { + Build arm = Make("ruby-4.0.5-arm64-mswin64_140", "4.0.5", + tags: ["4.0.5", "4.0", "4"], platform: "arm64-mswin64_140"); + Build x64 = Make("ruby-4.0.5-x64-mswin64_140", "4.0.5", tags: ["4.0.5", "4.0", "4"]); + + Assert.Null(BinaryIndex.Pick([arm], "4.0.5")); + Assert.Equal(x64.Name, BinaryIndex.Pick([arm, x64], "4.0.5")!.Name); + } + + [Fact] // case 106: the full package name resolves alongside the tags + public void Pick_ExactName_Resolves() + { + Build b = Make("ruby-4.0.5-x64-mswin64_140", "4.0.5", tags: ["4.0.5", "4.0", "4"]); + + Assert.Equal(b.Name, BinaryIndex.Pick([b], "ruby-4.0.5-x64-mswin64_140")!.Name); + Assert.Equal(b.Name, BinaryIndex.Pick([b], "RUBY-4.0.5-X64-MSWIN64_140")!.Name); + } + + [Fact] // case 107 + public void Pick_NoMatch_ReturnsNull() + { + Build b = Make("ruby-4.0.5-x64-mswin64_140", "4.0.5", tags: ["4.0.5", "4.0", "4"]); + + Assert.Null(BinaryIndex.Pick([b], "3.9")); + } + + [Fact] // case 108: a prerelease tag never shadows the release of a series + public void Pick_PrereleaseExactTagOnly() + { + Build[] builds = + [ + Make("ruby-4.1.0-rc1-x64-mswin64_140", "4.1.0-rc1", channel: "prerelease", + tags: ["4.1.0-rc1"]), + Make("ruby-4.0.5-x64-mswin64_140", "4.0.5", tags: ["4.0.5", "4.0", "4"]), + ]; + + Assert.Equal("ruby-4.1.0-rc1-x64-mswin64_140", + BinaryIndex.Pick(builds, "4.1.0-rc1")!.Name); + Assert.Null(BinaryIndex.Pick(builds, "4.1")); + } +} + +// Opt-in: proves the published index still parses and resolves. Needs the +// network, so it is skipped unless RBMANAGER_TEST_NETWORK=1. +[Trait("Category", "Network")] +public class BinaryIndexNetworkTests +{ + [SkippableFact] // case 109 + public async Task PublishedIndex_ParsesAndResolvesRubyDev() + { + Skip.IfNot(Environment.GetEnvironmentVariable("RBMANAGER_TEST_NETWORK") == "1", + "set RBMANAGER_TEST_NETWORK=1 to run network tests"); + + using var http = new HttpClient(); + string json = await http.GetStringAsync( + "https://cache.ruby-lang.org/pub/ruby/binaries/index.json"); + + IndexPage page = BinaryIndex.Parse(json); + Build? build = BinaryIndex.Pick(page.Builds, "ruby-dev"); + Assert.NotNull(build); + Assert.Equal(BinaryIndex.Platform, build!.Platform); + Assert.Matches("^[0-9a-f]{64}$", build.Sha256); + Assert.StartsWith("https://cache.ruby-lang.org/", build.Url); + } +} diff --git a/tests/rbmanager.Tests/InstallFromIndexTests.cs b/tests/rbmanager.Tests/InstallFromIndexTests.cs new file mode 100644 index 0000000..f628a29 --- /dev/null +++ b/tests/rbmanager.Tests/InstallFromIndexTests.cs @@ -0,0 +1,191 @@ +using System.Security.Cryptography; +using RbManager.Tests.Support; + +namespace RbManager.Tests; + +// Plan 4.13: rb install through the binary index, with the +// feed redirected to a local file via RBMANAGER_INDEX_URL and the zip +// served by the loopback server. Serial (env vars + console). +[Trait("Category", "Integration")] +[Collection(Serial.Name)] +public class InstallFromIndexTests +{ + private const string DevName = "ruby-4.1.0dev-20260821-0123456789-x64-mswin64_140"; + + private static string Sha256Of(byte[] bytes) => + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + // One dev-channel entry in the published feed's exact shape. + private static string BuildJson(string name, string url, string sha256, bool signed) => $$""" + { + "name": "{{name}}", + "version": "4.1.0dev", + "channel": "dev", + "revision": null, + "tags": ["4.1-dev", "4.1-dev-20260821", "4.1.0dev-20260821-0123456789", "ruby-dev"], + "platform": "x64-mswin64_140", + "url": "{{url}}", + "sha256": "{{sha256}}", + "size": 1, + "commit": "0123456789", + "commit_date": "2026-08-21", + "published_at": "2026-08-21", + "signed": {{(signed ? "true" : "false")}} + } + """; + + private static string WriteIndex(RbSandbox sb, string fileName, string? next, + params string[] builds) + { + string path = Path.Combine(sb.Root, "_index", fileName); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + string nextJson = next is null ? "null" : $"\"{next}\""; + File.WriteAllText(path, $$""" + {"schema": 1, "next": {{nextJson}}, "builds": [{{string.Join(",", builds)}}]} + """); + return path; + } + + [Fact] // case 110 + public async Task InstallByTag_ResolvesDownloadsVerifiesAndInstalls() + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + byte[] zip = File.ReadAllBytes( + Zips.WriteRuby(Path.Combine(sb.Root, "_src", "pkg.zip"), DevName)); + using var server = new LoopbackZipServer(zip, $"/{DevName}.zip"); + using var env = new EnvScope(); + env.Set("RBMANAGER_INDEX_URL", WriteIndex(sb, "index.json", null, + BuildJson(DevName, server.ZipUrl, Sha256Of(zip), signed: true))); + + int rc = await Program.Install("4.1-dev"); + + Assert.Equal(0, rc); + Assert.True(Directory.Exists(Path.Combine(sb.Rubies, DevName))); + Assert.Equal(DevName, Program.CurrentTarget()); + Assert.Contains($"Resolved 4.1-dev to {DevName}", cap.Out); + Assert.Contains($"Installed {DevName}", cap.Out); + Assert.DoesNotContain("not code-signed", cap.Err); + Assert.False(File.Exists(Path.Combine(Path.GetTempPath(), $"{DevName}.zip"))); + } + + [Fact] // case 111: signed:false warns on stderr but installs anyway + public async Task InstallUnsignedBuild_WarnsAndInstalls() + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + byte[] zip = File.ReadAllBytes( + Zips.WriteRuby(Path.Combine(sb.Root, "_src", "pkg.zip"), DevName)); + using var server = new LoopbackZipServer(zip, $"/{DevName}.zip"); + using var env = new EnvScope(); + env.Set("RBMANAGER_INDEX_URL", WriteIndex(sb, "index.json", null, + BuildJson(DevName, server.ZipUrl, Sha256Of(zip), signed: false))); + + int rc = await Program.Install("ruby-dev"); + + Assert.Equal(0, rc); + Assert.True(Directory.Exists(Path.Combine(sb.Rubies, DevName))); + Assert.Contains($"warning: {DevName} is not code-signed", cap.Err); + } + + [Fact] // case 112: a checksum mismatch fails, installs nothing, cleans up + public async Task InstallShaMismatch_Throws_NothingInstalled() + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + byte[] zip = File.ReadAllBytes( + Zips.WriteRuby(Path.Combine(sb.Root, "_src", "pkg.zip"), DevName)); + using var server = new LoopbackZipServer(zip, $"/{DevName}.zip"); + using var env = new EnvScope(); + env.Set("RBMANAGER_INDEX_URL", WriteIndex(sb, "index.json", null, + BuildJson(DevName, server.ZipUrl, new string('0', 64), signed: true))); + + var ex = await Assert.ThrowsAsync( + () => Program.Install("4.1-dev")); + + Assert.Contains("sha256 mismatch", ex.Message); + Assert.False(Directory.Exists(Path.Combine(sb.Rubies, DevName))); + Assert.False(File.Exists(Path.Combine(Path.GetTempPath(), $"{DevName}.zip"))); + } + + [Fact] // case 113: a non-null next chains to the following page + public async Task InstallFromPaginatedIndex_FollowsNext() + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + byte[] zip = File.ReadAllBytes( + Zips.WriteRuby(Path.Combine(sb.Root, "_src", "pkg.zip"), DevName)); + using var server = new LoopbackZipServer(zip, $"/{DevName}.zip"); + using var env = new EnvScope(); + WriteIndex(sb, "page2.json", null, + BuildJson(DevName, server.ZipUrl, Sha256Of(zip), signed: true)); + env.Set("RBMANAGER_INDEX_URL", WriteIndex(sb, "index.json", "page2.json")); + + int rc = await Program.Install("4.1-dev"); + + Assert.Equal(0, rc); + Assert.True(Directory.Exists(Path.Combine(sb.Rubies, DevName))); + } + + [Fact] // case 114: RBMANAGER_INDEX_URL accepts a file:// URL + public async Task Resolve_FileUrlIndex_Works() + { + using var sb = new RbSandbox(); + using var env = new EnvScope(); + string index = WriteIndex(sb, "index.json", null, + BuildJson(DevName, "https://example.invalid/pkg.zip", new string('0', 64), + signed: true)); + env.Set("RBMANAGER_INDEX_URL", new Uri(index).AbsoluteUri); + + Build build = await BinaryIndex.Resolve("4.1-dev"); + + Assert.Equal(DevName, build.Name); + } + + [Fact] // case 115: a newer schema aborts before any resolution + public async Task InstallNewerSchema_Throws() + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + using var env = new EnvScope(); + string path = Path.Combine(sb.Root, "_index", "index.json"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, """{"schema": 2, "next": null, "builds": []}"""); + env.Set("RBMANAGER_INDEX_URL", path); + + var ex = await Assert.ThrowsAsync( + () => Program.Install("4.1-dev")); + + Assert.Contains("upgrade rb", ex.Message); + } + + [Fact] // case 116 + public async Task InstallUnknownVersion_Throws() + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + using var env = new EnvScope(); + env.Set("RBMANAGER_INDEX_URL", WriteIndex(sb, "index.json", null)); + + var ex = await Assert.ThrowsAsync( + () => Program.Install("3.9")); + + Assert.Equal("no binary package matches '3.9' in the index", ex.Message); + } + + [Fact] // case 117: a missing zip path fails as a file, not as a version + public async Task InstallMissingZipPath_DoesNotHitTheIndex() + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + using var env = new EnvScope(); + // A feed that would resolve anything makes a fall-through visible. + env.Set("RBMANAGER_INDEX_URL", Path.Combine(sb.Root, "_index", "absent.json")); + + await Assert.ThrowsAsync( + () => Program.Install("no-such-package.zip")); + // A missing intermediate directory surfaces as DirectoryNotFound. + await Assert.ThrowsAnyAsync( + () => Program.Install(Path.Combine(sb.Root, "nope", "pkg.zip"))); + } +}