Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <zip|url> install a ruby binary package
rb install <version|zip> install a ruby binary package
rb list list installed rubies
rb use <version> switch the active ruby
rb uninstall <version> remove an installed ruby
Expand All @@ -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
<https://cache.ruby-lang.org/pub/ruby/binaries/index.json> (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`;
Expand Down
60 changes: 59 additions & 1 deletion docs/test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Command surface and contracts:
| Command | Behavior | Exit |
|---|---|---|
| `rb setup` | Copy own exe to `<root>\bin\rb.exe` (skip if already running from there, case-insensitive), append that dir to user PATH | 0 |
| `rb install <zip\|url>` | 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 <version\|zip\|url>` | 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 <query>` | Resolve query (exact or case-insensitive substring; must be unambiguous), recreate the `current` junction, ensure PATH | 0 / 1 |
| `rb uninstall <query>` | Resolve; if active, delete the junction first and print a hint; delete the install dir recursively | 0 / 1 |
Expand Down Expand Up @@ -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 <version|tag>` 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 <query> to <name>` and `Installed <name>` on stdout, no
warning for a signed build, temp download deleted.
111. `signed: false` → `warning: <name> 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 '<q>' 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.
Expand Down
113 changes: 113 additions & 0 deletions src/rbmanager/BinaryIndex.cs
Original file line number Diff line number Diff line change
@@ -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<Build> Resolve(string query)
{
var page = new Uri(Url, UriKind.Absolute);
var builds = new List<Build>();
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<string> 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<Build> 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<Build> 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;
44 changes: 41 additions & 3 deletions src/rbmanager/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ private static int Usage()
usage: rb <command>

setup [--yes] copy rb onto PATH and set up the VC++ runtime
install <zip|url> install a ruby binary package from a zip file or URL
install <version|zip> install a ruby binary package resolved from the
binary index, or from a zip file or URL
list list installed rubies
use <version> switch the active ruby
uninstall <version> remove an installed ruby
Expand Down Expand Up @@ -88,10 +89,24 @@ private static async Task<int> Setup(bool assumeYes)

internal static async Task<int> 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} ...");
Expand All @@ -106,6 +121,7 @@ internal static async Task<int> 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))
Expand All @@ -129,6 +145,28 @@ internal static async Task<int> 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();
Expand Down
Loading
Loading