diff --git a/README.md b/README.md index 7da12fa..2ed6149 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,15 @@ 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. +On ARM64 Windows `install` takes the arm64 build of a version or tag +and falls back to the x64 build, which runs under emulation, with a +warning when there is none. `use` makes the same choice among the +installed rubies. A full package name such as +`ruby-3.4.12-x64-mswin64_140` picks that build either way. + `list --remote` prints what the index currently offers for this -platform, newest first: the package name, the channel, and the tags +machine, newest first, with the arm64 builds above the x64 ones on +ARM64: the package name, the channel, the platform, and the tags `install` accepts for it. It is the counterpart of `list`, which shows what is installed, and it exists so that nothing outside rbmanager has to fetch and interpret the feed. @@ -74,7 +81,8 @@ install wins; `--vsver <2017|2019|2022|2026|latest>` (or the `RBMANAGER_VSVER` environment variable) pins a specific Visual Studio version, and `msvc --list` shows what is installed. Apart from `enable`, every word after `msvc` is the command to run, so future -`msvc` operations are spelled as flags. +`msvc` operations are spelled as flags. The environment targets x64, +so it cannot build extensions for an arm64 ruby. `version` identifies the running binary, in cargo's shape, as in `rbmanager 0.1.0 (9a1b2c3 2026-07-28)`. `--version` and `-V` print the diff --git a/docs/test-plan.md b/docs/test-plan.md index 407a958..7b0c01d 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -30,9 +30,9 @@ 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 ` | 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 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 build of the first platform the host runs (`arm64-mswin64_140` then `x64-mswin64_140` on ARM64, `x64-mswin64_140` elsewhere, see 4.15), warn on stderr when an ARM64 host falls back to x64, 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 use ` | Resolve query (exact or case-insensitive substring; must be unambiguous), on ARM64 let an arm64 match hide the x64 ones and warn as install does when landing on x64, 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 | | `rb msvc [--vsver ] [--] ` | Locate VsDevCmd via vswhere (narrowed to the requested VS product year, if any; `--vsver` > `RBMANAGER_VSVER` > newest), compute the env delta of activation and apply it to a `cmd /s /c` child (so `.cmd` shims resolve); removes `NoDefaultCurrentDirectoryInExePath`; propagates the child's exit code. Options are leading-only; `--` ends option parsing. `enable` is the only reserved word after `msvc` | child / 1 | | `rb msvc enable [--vsver ] [shell]` | Same delta printed as per-shell assignments plus an unset of `NoDefaultCurrentDirectoryInExePath`. Shell defaults to PowerShell; `cmd`/`bat` selects cmd syntax. `--vsver` parses on either side of `enable`. No toolchain: actionable warning on stderr | 0 / 1 | @@ -505,8 +505,8 @@ Unit (`BinaryIndexTests`): Integration (`ListRemoteTests`, Serial): -121. Name, channel and the `install` tags, newest first, in aligned - columns. +121. Name, channel, platform and the `install` tags, newest first, in + aligned columns. 122. An index with nothing for this platform → empty stdout, a note on stderr, exit 0. 123. `schema: 2` fails here as it does on install. @@ -516,6 +516,77 @@ E2E (`CliE2eTests`): 124. `--remote` is the only flag `list` takes, and it takes no argument; anything else is usage and exit 2. +### 4.15 ARM64 hosts + +The index carries `arm64-mswin64_140` builds next to the x64 ones, with +tags assigned per platform. An ARM64 host prefers its own platform and +falls back to x64, which runs there under emulation. Any other host sees +only x64, as before. The host comes from +`RuntimeInformation.OSArchitecture`, which reports the machine even to an +emulated x64 process. `RBMANAGER_OS_ARCH` (an `Architecture` name) +overrides it, and `RbSandbox`/`E2eSandbox` pin it to `X64` so the other +suites stay host-independent. + +Install and `list --remote` (`BinaryIndexTests` Unit, `ListRemoteTests` +and `InstallFromIndexTests` Integration, `PublishE2eTests` Publish): + +125. `PlatformsFor(Arm64)` is arm64 then x64. X64 and X86 get x64 only. +126. On ARM64 a tag picks the arm64 build even when an x64 build of the + same tag is newer. An x64 host picks the x64 one. +127. A tag with no arm64 build falls back to x64. +128. A full x64 package name picks that build on ARM64. +129. `Available` on ARM64 lists the arm64 builds first, each platform + newest first. +130. `rb list --remote` on ARM64 lists the arm64 builds above the x64 + ones, with the platform column. +131. `rb install ` installs the older arm64 snapshot on ARM64 and the + x64 one on x64, without a warning. +132. No arm64 build for the tag on ARM64 → the x64 build installs and + stderr carries `warning: no arm64-mswin64_140 build matches '', + falling back to x64-mswin64_140 under emulation`. +133. A full x64 name on ARM64 installs that build without the warning. +144. The win-x64 AOT exe lists the arm64 builds exactly when the machine + is ARM64, so emulation does not hide the host. +145. `Pick` ranks only the builds that match the query, so the version of + any other build is never parsed. +146. An `RBMANAGER_OS_ARCH` that names no `Architecture` member + (`arm64ec`, `99`) stops with an error naming the variable. + +`rb use` (`ResolveTests` and `ProgramLifecycleTests`, Integration): + +134. `Resolve(preferNative: true)` on ARM64 lets an arm64 match hide the + x64 ones, including a newer x64 version. +135. With no arm64 match it resolves the x64 ones as before. +136. A full x64 name still resolves to that build. +137. Two arm64 versions stay ambiguous, since the preference only orders + platforms. +138. On an x64 host, and without `preferNative` (what `uninstall` uses), + an arm64 and an x64 build of one version stay ambiguous. +139. `rb use` on ARM64 warns as install does only when it lands on x64 by + fallback, not for an arm64 match or a full x64 name. +147. When the matches span both platforms, the version the query spells + out beats the platform order: `3.4.1` picks an x64 3.4.1 over an + arm64 3.4.12. +148. Within one platform an exact version stays as ambiguous as before, + on x64 and ARM64 hosts alike. + +VC++ runtime (`VcRedistTests`, Unit). `RBMANAGER_VC_REDIST_KEY` now +stands in for the `Runtimes` key, with `x64` and `ARM64` subkeys under +it. + +140. `RuntimeOf` maps arm64 package names and the platform to `ARM64`, + everything else to `x64`. +141. `rb setup` on ARM64 is satisfied only when both runtimes are + registered. +142. With only the x64 runtime on ARM64, setup names `ARM64` as missing + and offers the x64 package. +143. `WarnIfMissing` checks an arm64 ruby against the ARM64 runtime and + an x64 ruby against the x64 one. +149. On ARM64 a `vcruntime140.dll` in the system directory does not stand + in for an unregistered runtime: setup still names `ARM64` as missing. +150. Nor does it for `IsInstalled` and `WarnIfMissing`, which warn for an + arm64 ruby. On x64 the DLL fallback is unchanged. + ## 5. Execution plan Phased so each phase leaves the tree green. diff --git a/src/rbmanager/BinaryIndex.cs b/src/rbmanager/BinaryIndex.cs index 2376e5f..4af1a15 100644 --- a/src/rbmanager/BinaryIndex.cs +++ b/src/rbmanager/BinaryIndex.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Serialization; @@ -10,7 +11,32 @@ namespace RbManager; // parsing `version`, whose syntax depends on the channel. internal static class BinaryIndex { - internal const string Platform = "x64-mswin64_140"; + internal const string X64 = "x64-mswin64_140"; + internal const string Arm64 = "arm64-mswin64_140"; + + // The platforms this machine runs, native first. ARM64 Windows runs + // x64 binaries under emulation, so x64 builds back up arm64 there. + internal static string[] Platforms => PlatformsFor(HostArchitecture()); + + internal static string[] PlatformsFor(Architecture host) => + host == Architecture.Arm64 ? [Arm64, X64] : [X64]; + + // OSArchitecture reports the machine rather than the process, so + // rb-x64.exe under emulation still sees Arm64. RBMANAGER_OS_ARCH + // stands in for it in tests. + private static Architecture HostArchitecture() + { + if (Environment.GetEnvironmentVariable("RBMANAGER_OS_ARCH") is not { Length: > 0 } arch) + return RuntimeInformation.OSArchitecture; + string name = Enum.GetNames() + .FirstOrDefault(n => n.Equals(arch, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException( + $"RBMANAGER_OS_ARCH must name an Architecture such as X64 or Arm64, not '{arch}'"); + return Enum.Parse(name); + } + + internal static bool IsOn(string name, string platform) => + name.EndsWith($"-{platform}", StringComparison.OrdinalIgnoreCase); private const string DefaultUrl = "https://cache.ruby-lang.org/pub/ruby/binaries/index.json"; @@ -23,10 +49,10 @@ internal static class BinaryIndex : DefaultUrl; public static async Task Resolve(string query) => - Pick(await FetchAll(), query) ?? throw new InvalidOperationException( + Pick(await FetchAll(), query, Platforms) ?? throw new InvalidOperationException( $"no binary package matches '{query}' in the index"); - public static async Task Available() => Available(await FetchAll()); + public static async Task Available() => Available(await FetchAll(), Platforms); private static async Task> FetchAll() { @@ -62,20 +88,27 @@ internal static IndexPage Parse(string json) return page; } - // The newest match wins regardless of feed order: series tags like + // The first of `platforms` with a match wins, even over a newer build + // of a later one, because the feed assigns tags per platform. Within + // it 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. - internal static Build? Pick(IEnumerable builds, string query) => + // every snapshot of the series. A full package name matches only its + // own build, so it is taken whatever its platform. + internal static Build? Pick(IEnumerable builds, string query, string[] platforms) => + Ordered(builds.Where(b => b.Tags.Contains(query, StringComparer.OrdinalIgnoreCase) || + string.Equals(b.Name, query, StringComparison.OrdinalIgnoreCase)), platforms) + .FirstOrDefault(); + + // Everything installable here, in the order Pick resolves, so a tag + // always installs the topmost line carrying it. + internal static Build[] Available(IEnumerable builds, string[] platforms) => + Ordered(builds, platforms).ToArray(); + + private static IEnumerable Ordered(IEnumerable builds, string[] platforms) => builds - .Where(b => b.Platform == Platform) - .Where(b => b.Tags.Contains(query, StringComparer.OrdinalIgnoreCase) || - string.Equals(b.Name, query, StringComparison.OrdinalIgnoreCase)) - .MaxBy(Rank); - - // Everything installable here, newest first. Same order Pick resolves - // in, so a tag always installs the topmost line carrying it. - internal static Build[] Available(IEnumerable builds) => - builds.Where(b => b.Platform == Platform).OrderByDescending(Rank).ToArray(); + .Where(b => platforms.Contains(b.Platform)) + .OrderBy(b => Array.IndexOf(platforms, b.Platform)) + .ThenByDescending(Rank); // Version, then reissue revision (SIGNING.md in ruby/actions), then // commit date, which keeps this consistent with Program.Resolve's diff --git a/src/rbmanager/Program.cs b/src/rbmanager/Program.cs index 0195de0..5f867b7 100644 --- a/src/rbmanager/Program.cs +++ b/src/rbmanager/Program.cs @@ -106,6 +106,7 @@ internal static async Task Install(string source) { Build build = await BinaryIndex.Resolve(source); Console.WriteLine($"Resolved {source} to {build.Name}"); + WarnIfEmulated(source, build.Name); if (!build.Signed) Console.Error.WriteLine($"warning: {build.Name} is not code-signed"); source = build.Url; @@ -144,7 +145,7 @@ internal static async Task Install(string source) Console.WriteLine($"Installed {name}"); Console.WriteLine("Open a new terminal to pick up PATH changes."); - VcRedist.WarnIfMissing(); + VcRedist.WarnIfMissing(name); return 0; } finally @@ -194,26 +195,41 @@ internal static async Task ListRemote() Build[] builds = await BinaryIndex.Available(); if (builds.Length == 0) { - Console.Error.WriteLine( - $"rb: the binary index offers no {BinaryIndex.Platform} builds"); + Console.Error.WriteLine("rb: the binary index offers no " + + $"{string.Join(" or ", BinaryIndex.Platforms)} builds"); return 0; } int name = builds.Max(b => b.Name.Length); int channel = builds.Max(b => b.Channel.Length); + int platform = builds.Max(b => b.Platform.Length); foreach (Build b in builds) Console.WriteLine($"{b.Name.PadRight(name)} {b.Channel.PadRight(channel)} " + - string.Join(", ", b.Tags)); + $"{b.Platform.PadRight(platform)} " + string.Join(", ", b.Tags)); return 0; } internal static int Use(string query) { - SwitchTo(Resolve(query)); + string name = Resolve(query, preferNative: true); + SwitchTo(name); Console.WriteLine($"Now using {CurrentTarget()}"); - VcRedist.WarnIfMissing(); + WarnIfEmulated(query, name); + VcRedist.WarnIfMissing(name); return 0; } + // For a query that had nothing on this machine's own platform. A full + // package name asked for that very build, so it is not a fallback. + private static void WarnIfEmulated(string query, string name) + { + string[] platforms = BinaryIndex.Platforms; + if (string.Equals(name, query, StringComparison.OrdinalIgnoreCase)) return; + if (platforms.Skip(1).FirstOrDefault(p => BinaryIndex.IsOn(name, p)) is { } fallback) + Console.Error.WriteLine( + $"warning: no {platforms[0]} build matches '{query}', " + + $"falling back to {fallback} under emulation"); + } + internal static int Uninstall(string query) { string name = Resolve(query); @@ -265,12 +281,13 @@ internal static string FormatVersion(string? informational, string? assembly, st private static IEnumerable InstalledRubies() => Directory.Exists(Rubies) ? Directory.EnumerateDirectories(Rubies).Order() : []; - internal static string Resolve(string query) + internal static string Resolve(string query, bool preferNative = false) { string[] matches = InstalledRubies() .Select(Path.GetFileName) .Where(n => n == query || n!.Contains(query, StringComparison.OrdinalIgnoreCase)) .ToArray()!; + if (preferNative) matches = FirstPlatformOnly(query, matches); return matches switch { [var single] => single!, @@ -280,6 +297,27 @@ internal static string Resolve(string query) }; } + // The installed counterpart of BinaryIndex.Pick's platform order: the + // first of BinaryIndex.Platforms with a match hides the matches on the + // others. Names on none of them stay, so they remain as ambiguous as + // before. Across platforms the version the query spells out comes + // first, or "3.4.1" would land on an arm64 3.4.12 instead of an x64 + // 3.4.1. + private static string[] FirstPlatformOnly(string query, string[] matches) + { + string[] platforms = BinaryIndex.Platforms; + string[] exact = matches + .Where(n => PackageName.Match(n) is { Success: true } m && + m.Groups["ver"].Value.Equals(query, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (exact.Length > 0 && platforms.Count(p => matches.Any(n => BinaryIndex.IsOn(n, p))) > 1) + matches = exact; + string? first = platforms.FirstOrDefault(p => matches.Any(n => BinaryIndex.IsOn(n, p))); + return matches + .Where(n => !platforms.Any(p => p != first && BinaryIndex.IsOn(n, p))) + .ToArray(); + } + // ruby-[-]-: a reissued release package // (SIGNING.md in ruby/actions) differs from the original only by the // numeric revision after the version. The revision never collides diff --git a/src/rbmanager/VcRedist.cs b/src/rbmanager/VcRedist.cs index 03bbc81..005e7b6 100644 --- a/src/rbmanager/VcRedist.cs +++ b/src/rbmanager/VcRedist.cs @@ -16,20 +16,26 @@ namespace RbManager; // https://bugs.ruby-lang.org/issues/22180 internal static partial class VcRedist { - private const string RuntimesSubKey = - @"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64"; + private const string RuntimesKey = @"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes"; // Microsoft's permalink to the latest 2015-2022 x64 redistributable. + // On ARM64 Windows it installs the ARM64 runtime alongside the x64 one. private const string InstallerUrl = "https://aka.ms/vs/17/release/vc_redist.x64.exe"; + // The Runtimes subkey ("x64" or "ARM64") a ruby package or platform needs. + internal static string RuntimeOf(string rubyOrPlatform) => + rubyOrPlatform.EndsWith(BinaryIndex.Arm64, StringComparison.OrdinalIgnoreCase) + ? "ARM64" + : "x64"; + // Registry probe, the documented way to detect the 2015-2022 runtime. - // RBMANAGER_VC_REDIST_KEY redirects it to an HKCU-relative scratch - // subkey so tests never depend on the host machine's real state. - internal static string? InstalledVersion() + // RBMANAGER_VC_REDIST_KEY redirects the Runtimes key to an HKCU-relative + // scratch subkey so tests never depend on the host machine's real state. + internal static string? InstalledVersion(string runtime) { if (Environment.GetEnvironmentVariable("RBMANAGER_VC_REDIST_KEY") is { Length: > 0 } sub) { - using var scratch = Registry.CurrentUser.OpenSubKey(sub); + using var scratch = Registry.CurrentUser.OpenSubKey($@"{sub}\{runtime}"); return VersionOf(scratch); } // The redist setup is a 32-bit process; probe both views so a key @@ -37,7 +43,7 @@ internal static partial class VcRedist foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 }) { using var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view); - using var key = hklm.OpenSubKey(RuntimesSubKey); + using var key = hklm.OpenSubKey($@"{RuntimesKey}\{runtime}"); if (VersionOf(key) is { } version) return version; } return null; @@ -61,23 +67,34 @@ internal static bool RuntimeDllExists() return File.Exists(dll); } - internal static bool IsInstalled() => InstalledVersion() is not null || RuntimeDllExists(); + // On ARM64 the x64 and ARM64 runtimes share the one vcruntime140.dll + // path in the system directory, so finding it there does not say which + // of them is installed. Only the Runtimes keys can tell. + private static bool DllMayStandIn => BinaryIndex.Platforms is [_]; + + internal static bool IsInstalled(string runtime) => + InstalledVersion(runtime) is not null || (DllMayStandIn && RuntimeDllExists()); public static async Task Ensure(bool assumeYes) { - if (InstalledVersion() is { } version) + // There is no ruby to ask yet, so cover every platform rb picks on + // this machine. On ARM64 that includes x64 for the fallback builds. + string[] runtimes = BinaryIndex.Platforms.Select(RuntimeOf).ToArray(); + string?[] versions = runtimes.Select(InstalledVersion).ToArray(); + if (versions.All(v => v is not null)) { - Console.WriteLine($"VC++ Redistributable {version} is already installed."); + Console.WriteLine($"VC++ Redistributable {versions[^1]} is already installed."); return 0; } - if (RuntimeDllExists()) + if (DllMayStandIn && RuntimeDllExists()) { Console.WriteLine("vcruntime140.dll is already present in the system directory."); return 0; } + string missing = string.Join(", ", runtimes.Where((_, i) => versions[i] is null)); Console.WriteLine($""" - The Microsoft Visual C++ Redistributable (x64) is not installed. + The Microsoft Visual C++ Redistributable ({missing}) is not installed. Ruby's official Windows packages rely on it; without vcruntime140.dll ruby.exe cannot start. Installing it affects the whole machine and prompts for administrator approval (UAC). Without administrator @@ -93,21 +110,22 @@ ruby.exe cannot start. Installing it affects the whole machine and return 1; } } - return await Install(); + return await Install(runtimes); } // Non-blocking companion for install/use: the selected ruby stays in // place, it just cannot start until the runtime shows up. - public static void WarnIfMissing() + public static void WarnIfMissing(string ruby) { - if (IsInstalled()) return; - Console.Error.WriteLine(""" - rb: warning: the Microsoft Visual C++ Redistributable (x64) was not found. + string runtime = RuntimeOf(ruby); + if (IsInstalled(runtime)) return; + Console.Error.WriteLine($""" + rb: warning: the Microsoft Visual C++ Redistributable ({runtime}) was not found. ruby.exe cannot start without vcruntime140.dll. Run `rb setup` to install it. """); } - private static async Task Install() + private static async Task Install(string[] runtimes) { string installer = Path.Combine(Path.GetTempPath(), "vc_redist.x64.exe"); Console.WriteLine($"Downloading {InstallerUrl} ..."); @@ -148,7 +166,15 @@ private static async Task Install() $"rb: vc_redist.x64.exe failed with exit code {code}"); return 1; } - Console.WriteLine(InstalledVersion() is { } installed + if (!DllMayStandIn && + runtimes.Where(r => InstalledVersion(r) is null).ToArray() is { Length: > 0 } missing) + { + Console.Error.WriteLine( + $"rb: the VC++ Redistributable ({string.Join(", ", missing)}) " + + "is still not registered after vc_redist.x64.exe"); + return 1; + } + Console.WriteLine(InstalledVersion("x64") is { } installed ? $"Installed VC++ Redistributable {installed}." : "Installed the VC++ Redistributable."); return 0; diff --git a/tests/rbmanager.Tests/BinaryIndexTests.cs b/tests/rbmanager.Tests/BinaryIndexTests.cs index 8654aef..d6bfc05 100644 --- a/tests/rbmanager.Tests/BinaryIndexTests.cs +++ b/tests/rbmanager.Tests/BinaryIndexTests.cs @@ -1,3 +1,5 @@ +using System.Runtime.InteropServices; + namespace RbManager.Tests; // Plan 4.13: BinaryIndex.Parse and BinaryIndex.Pick — pure logic against @@ -5,8 +7,11 @@ namespace RbManager.Tests; [Trait("Category", "Unit")] public class BinaryIndexTests { + private static readonly string[] X64Host = BinaryIndex.PlatformsFor(Architecture.X64); + private static readonly string[] Arm64Host = BinaryIndex.PlatformsFor(Architecture.Arm64); + private static Build Make(string name, string version, string channel = "release", - int? revision = 0, string[]? tags = null, string platform = BinaryIndex.Platform, + int? revision = 0, string[]? tags = null, string platform = BinaryIndex.X64, string? commit = null, string? commitDate = null, string? publishedAt = null) => new() { Name = name, @@ -60,7 +65,7 @@ public void Parse_PublishedShape_PopulatesEveryKey() Assert.Equal("dev", b.Channel); Assert.Null(b.Revision); Assert.Contains("ruby-dev", b.Tags); - Assert.Equal(BinaryIndex.Platform, b.Platform); + Assert.Equal(BinaryIndex.X64, b.Platform); Assert.Equal("313063a97245ec48f125180346f589b43403ea8dc1afe98de0309f7e2baefd6b", b.Sha256); Assert.Equal(29021956, b.Size); Assert.Equal("2026-08-21", b.CommitDate); @@ -87,9 +92,9 @@ public void Pick_SeriesTag_PicksHighestVersion_RegardlessOfOrder() 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, "4.0", X64Host)!.Name); Assert.Equal("ruby-4.0.5-x64-mswin64_140", - BinaryIndex.Pick(builds.Reverse(), "4")!.Name); + BinaryIndex.Pick(builds.Reverse(), "4", X64Host)!.Name); } [Fact] // case 102: a reissue supersedes by revision, not by feed order @@ -102,9 +107,10 @@ public void Pick_SameVersion_PicksHighestRevision() 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); + BinaryIndex.Pick(builds, "4.0.5", X64Host)!.Name); + Assert.Equal("ruby-4.0.5-1-x64-mswin64_140", + BinaryIndex.Pick(builds.Reverse(), "4.0.5", X64Host)!.Name); } [Fact] // case 103: a superseded revision stays reachable by its exact tag @@ -117,7 +123,8 @@ public void Pick_SupersededRevision_ByRevisionedTag() 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); + Assert.Equal("ruby-4.0.5-x64-mswin64_140", + BinaryIndex.Pick(builds, "4.0.5-0", X64Host)!.Name); } [Fact] // case 104: equal dev versions fall back to the commit date @@ -134,9 +141,9 @@ public void Pick_DevSeries_PicksNewestSnapshot() ]; Assert.Equal("ruby-4.1.0dev-20260821-e4462a9514-x64-mswin64_140", - BinaryIndex.Pick(builds, "ruby-dev")!.Name); + BinaryIndex.Pick(builds, "ruby-dev", X64Host)!.Name); Assert.Equal("ruby-4.1.0dev-20260821-e4462a9514-x64-mswin64_140", - BinaryIndex.Pick(builds.Reverse(), "4.1-dev")!.Name); + BinaryIndex.Pick(builds.Reverse(), "4.1-dev", X64Host)!.Name); } [Fact] // case 105: other platforms never resolve, even on a tag match @@ -146,8 +153,8 @@ public void Pick_ForeignPlatform_Filtered() 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); + Assert.Null(BinaryIndex.Pick([arm], "4.0.5", X64Host)); + Assert.Equal(x64.Name, BinaryIndex.Pick([arm, x64], "4.0.5", X64Host)!.Name); } [Fact] // case 106: the full package name resolves alongside the tags @@ -155,8 +162,73 @@ 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); + Assert.Equal(b.Name, BinaryIndex.Pick([b], "ruby-4.0.5-x64-mswin64_140", X64Host)!.Name); + Assert.Equal(b.Name, BinaryIndex.Pick([b], "RUBY-4.0.5-X64-MSWIN64_140", X64Host)!.Name); + } + + [Fact] // case 125: arm64 hosts add x64 after their own platform + public void PlatformsFor_Arm64PrefersItsOwnThenX64() + { + Assert.Equal([BinaryIndex.Arm64, BinaryIndex.X64], Arm64Host); + Assert.Equal([BinaryIndex.X64], X64Host); + Assert.Equal([BinaryIndex.X64], BinaryIndex.PlatformsFor(Architecture.X86)); + } + + // 3.4 has an arm64 build and a newer x64 one, 3.3 is x64 only. + private static readonly Build[] Mixed = + [ + Make("ruby-3.4.13-x64-mswin64_140", "3.4.13", tags: ["3.4.13", "3.4", "3"]), + Make("ruby-3.4.12-arm64-mswin64_140", "3.4.12", tags: ["3.4.12", "3.4", "3"], + platform: BinaryIndex.Arm64), + Make("ruby-3.3.11-x64-mswin64_140", "3.3.11", tags: ["3.3.11", "3.3", "3"]), + ]; + + [Fact] // case 126: tags are per platform, so arm64 wins over a newer x64 + public void Pick_Arm64Host_PrefersArm64OverNewerX64() + { + Assert.Equal("ruby-3.4.12-arm64-mswin64_140", + BinaryIndex.Pick(Mixed, "3.4", Arm64Host)!.Name); + Assert.Equal("ruby-3.4.12-arm64-mswin64_140", + BinaryIndex.Pick(Mixed.Reverse(), "3", Arm64Host)!.Name); + Assert.Equal("ruby-3.4.13-x64-mswin64_140", + BinaryIndex.Pick(Mixed, "3.4", X64Host)!.Name); + } + + [Fact] // case 127: a tag with no arm64 build falls back to x64 + public void Pick_Arm64Host_FallsBackToX64() + { + Assert.Equal("ruby-3.3.11-x64-mswin64_140", + BinaryIndex.Pick(Mixed, "3.3", Arm64Host)!.Name); + } + + [Fact] // case 128: a full package name bypasses the platform order + public void Pick_Arm64Host_FullX64NameIsTaken() + { + Assert.Equal("ruby-3.4.13-x64-mswin64_140", + BinaryIndex.Pick(Mixed, "ruby-3.4.13-x64-mswin64_140", Arm64Host)!.Name); + } + + [Fact] // case 145: only the matches are ranked, so another build's version never is + public void Pick_RanksOnlyTheMatches() + { + Build[] builds = [.. Mixed, Make("ruby-head-x64-mswin64_140", "head", tags: ["head"])]; + + Assert.Equal("ruby-3.4.12-arm64-mswin64_140", + BinaryIndex.Pick(builds, "3.4", Arm64Host)!.Name); + Assert.Equal("ruby-3.3.11-x64-mswin64_140", + BinaryIndex.Pick(builds, "ruby-3.3.11-x64-mswin64_140", X64Host)!.Name); + } + + [Fact] // case 129: arm64 lines first, each platform newest first + public void Available_Arm64Host_Arm64FirstThenX64() + { + Assert.Equal( + [ + "ruby-3.4.12-arm64-mswin64_140", + "ruby-3.4.13-x64-mswin64_140", + "ruby-3.3.11-x64-mswin64_140", + ], + BinaryIndex.Available(Mixed, Arm64Host).Select(b => b.Name).ToArray()); } [Fact] // case 119: the order `rb list --remote` prints, newest first @@ -175,7 +247,7 @@ public void Available_NewestFirst() "ruby-4.0.5-x64-mswin64_140", "ruby-4.0.4-x64-mswin64_140", ], - BinaryIndex.Available(builds).Select(b => b.Name).ToArray()); + BinaryIndex.Available(builds, X64Host).Select(b => b.Name).ToArray()); } [Fact] // case 120: builds this rb cannot install are not offered @@ -187,7 +259,7 @@ public void Available_ForeignPlatform_Filtered() Make("ruby-4.0.5-x64-mswin64_140", "4.0.5"), ]; - Build b = Assert.Single(BinaryIndex.Available(builds)); + Build b = Assert.Single(BinaryIndex.Available(builds, X64Host)); Assert.Equal("ruby-4.0.5-x64-mswin64_140", b.Name); } @@ -196,7 +268,7 @@ 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")); + Assert.Null(BinaryIndex.Pick([b], "3.9", X64Host)); } [Fact] // case 108: a prerelease tag never shadows the release of a series @@ -210,8 +282,8 @@ public void Pick_PrereleaseExactTagOnly() ]; 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")); + BinaryIndex.Pick(builds, "4.1.0-rc1", X64Host)!.Name); + Assert.Null(BinaryIndex.Pick(builds, "4.1", X64Host)); } } @@ -231,9 +303,10 @@ public async Task PublishedIndex_ParsesAndResolvesRubyDev() "https://cache.ruby-lang.org/pub/ruby/binaries/index.json"); IndexPage page = BinaryIndex.Parse(json); - Build? build = BinaryIndex.Pick(page.Builds, "ruby-dev"); + Build? build = BinaryIndex.Pick(page.Builds, "ruby-dev", + BinaryIndex.PlatformsFor(Architecture.X64)); Assert.NotNull(build); - Assert.Equal(BinaryIndex.Platform, build!.Platform); + Assert.Equal(BinaryIndex.X64, 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 index 0eab05e..62eeac3 100644 --- a/tests/rbmanager.Tests/InstallFromIndexTests.cs +++ b/tests/rbmanager.Tests/InstallFromIndexTests.cs @@ -16,19 +16,20 @@ 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) => $$""" + private static string BuildJson(string name, string url, string sha256, bool signed, + string platform = BinaryIndex.X64, string commitDate = "2026-08-21") => $$""" { "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", + "platform": "{{platform}}", "url": "{{url}}", "sha256": "{{sha256}}", "size": 1, "commit": "0123456789", - "commit_date": "2026-08-21", + "commit_date": "{{commitDate}}", "published_at": "2026-08-21", "signed": {{(signed ? "true" : "false")}} } @@ -173,6 +174,81 @@ public async Task InstallUnknownVersion_Throws() Assert.Equal("no binary package matches '3.9' in the index", ex.Message); } + // An older arm64 snapshot next to the x64 one: the arm64 host takes its + // own, an x64 host never sees it. + private const string ArmDevName = "ruby-4.1.0dev-20260820-0123456789-arm64-mswin64_140"; + + [Theory] // case 131 + [InlineData("Arm64", ArmDevName)] + [InlineData("X64", DevName)] + public async Task InstallByTag_PicksTheHostsOwnPlatform(string arch, string expected) + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + byte[] zip = File.ReadAllBytes( + Zips.WriteRuby(Path.Combine(sb.Root, "_src", "pkg.zip"), expected)); + using var server = new LoopbackZipServer(zip, $"/{expected}.zip"); + using var env = new EnvScope(); + env.Set("RBMANAGER_OS_ARCH", arch); + string UrlOf(string name) => + name == expected ? server.ZipUrl : "https://example.invalid/x.zip"; + env.Set("RBMANAGER_INDEX_URL", WriteIndex(sb, "index.json", null, + BuildJson(DevName, UrlOf(DevName), Sha256Of(zip), signed: true), + BuildJson(ArmDevName, UrlOf(ArmDevName), Sha256Of(zip), signed: true, + platform: BinaryIndex.Arm64, commitDate: "2026-08-20"))); + + int rc = await Program.Install("4.1-dev"); + + Assert.Equal(0, rc); + Assert.Equal(expected, Program.CurrentTarget()); + Assert.Equal("", cap.Err); + } + + [Fact] // case 132: no arm64 build for the tag installs x64 with a warning + public async Task InstallByTag_Arm64HostWithoutArm64Build_FallsBackAndWarns() + { + 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_OS_ARCH", "Arm64"); + 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.Equal(DevName, Program.CurrentTarget()); + Assert.Equal( + "warning: no arm64-mswin64_140 build matches '4.1-dev', " + + "falling back to x64-mswin64_140 under emulation", + cap.Err.TrimEnd()); + } + + [Fact] // case 133: a full x64 name is taken as asked, without the warning + public async Task InstallByFullName_Arm64Host_TakesX64Silently() + { + 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_OS_ARCH", "Arm64"); + env.Set("RBMANAGER_INDEX_URL", WriteIndex(sb, "index.json", null, + BuildJson(DevName, server.ZipUrl, Sha256Of(zip), signed: true), + BuildJson(ArmDevName, "https://example.invalid/x.zip", new string('0', 64), + signed: true, platform: BinaryIndex.Arm64))); + + int rc = await Program.Install(DevName); + + Assert.Equal(0, rc); + Assert.Equal(DevName, Program.CurrentTarget()); + Assert.Equal("", cap.Err); + } + [Fact] // case 117: a missing zip path fails as a file, not as a version public async Task InstallMissingZipPath_DoesNotHitTheIndex() { diff --git a/tests/rbmanager.Tests/ListRemoteTests.cs b/tests/rbmanager.Tests/ListRemoteTests.cs index 0681a53..96e69fd 100644 --- a/tests/rbmanager.Tests/ListRemoteTests.cs +++ b/tests/rbmanager.Tests/ListRemoteTests.cs @@ -13,7 +13,7 @@ public class ListRemoteTests private const string Rel = "ruby-4.0.5-x64-mswin64_140"; private static string BuildJson(string name, string version, string channel, - string[] tags, string platform = BinaryIndex.Platform, string? commitDate = null) => $$""" + string[] tags, string platform = BinaryIndex.X64, string? commitDate = null) => $$""" { "name": "{{name}}", "version": "{{version}}", @@ -41,7 +41,7 @@ private static void WriteIndex(RbSandbox sb, EnvScope env, params string[] build env.Set("RBMANAGER_INDEX_URL", path); } - [Fact] // case 121: name, channel and the tags `install` accepts + [Fact] // case 121: name, channel, platform and the tags `install` accepts public async Task ListsBuildsNewestFirstInColumns() { using var sb = new RbSandbox(); @@ -57,12 +57,51 @@ public async Task ListsBuildsNewestFirstInColumns() Assert.Equal(0, rc); Assert.Equal( [ - $"{Dev} dev ruby-dev, 4.1-dev", - $"{Rel} release 4.0.5, 4.0, 4", + $"{Dev} dev x64-mswin64_140 ruby-dev, 4.1-dev", + $"{Rel} release x64-mswin64_140 4.0.5, 4.0, 4", ], cap.OutLines); } + [Fact] // case 130: an arm64 host lists its own builds first, then x64 + public async Task Arm64Host_ListsArm64ThenX64() + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + using var env = new EnvScope(); + env.Set("RBMANAGER_OS_ARCH", "Arm64"); + WriteIndex(sb, env, + BuildJson("ruby-4.0.10-x64-mswin64_140", "4.0.10", "release", ["4.0.10", "4.0"]), + BuildJson("ruby-4.0.9-arm64-mswin64_140", "4.0.9", "release", ["4.0.9", "4.0"], + platform: BinaryIndex.Arm64)); + + int rc = await Program.ListRemote(); + + Assert.Equal(0, rc); + Assert.Equal( + [ + "ruby-4.0.9-arm64-mswin64_140 release arm64-mswin64_140 4.0.9, 4.0", + "ruby-4.0.10-x64-mswin64_140 release x64-mswin64_140 4.0.10, 4.0", + ], + cap.OutLines); + } + + [Theory] // case 146: an override naming no Architecture stops and names the variable + [InlineData("arm64ec")] + [InlineData("99")] + public async Task UnknownOsArchOverride_Throws(string arch) + { + using var sb = new RbSandbox(); + using var env = new EnvScope(); + WriteIndex(sb, env, BuildJson(Rel, "4.0.5", "release", ["4.0.5"])); + env.Set("RBMANAGER_OS_ARCH", arch); + + var ex = await Assert.ThrowsAsync(Program.ListRemote); + + Assert.Contains("RBMANAGER_OS_ARCH", ex.Message); + Assert.Contains($"'{arch}'", ex.Message); + } + [Fact] // case 122: an index with nothing for this platform is not an error public async Task NoBuildsForThePlatform_NotesOnStderr_Exit0() { @@ -77,7 +116,7 @@ public async Task NoBuildsForThePlatform_NotesOnStderr_Exit0() Assert.Equal(0, rc); Assert.Equal("", cap.Out); - Assert.Contains($"no {BinaryIndex.Platform} builds", cap.Err); + Assert.Contains($"no {BinaryIndex.X64} builds", cap.Err); } [Fact] // case 123: an index this rb does not understand fails here too diff --git a/tests/rbmanager.Tests/ProgramLifecycleTests.cs b/tests/rbmanager.Tests/ProgramLifecycleTests.cs index ebdc2cd..ce2daab 100644 --- a/tests/rbmanager.Tests/ProgramLifecycleTests.cs +++ b/tests/rbmanager.Tests/ProgramLifecycleTests.cs @@ -207,6 +207,28 @@ public void Use_Ambiguous_Throws_JunctionUnchanged() Assert.Equal(V405, Program.CurrentTarget()); } + [Theory] // case 139: use warns as install does when it lands on x64 + [InlineData("3.3", "ruby-3.3.11-x64-mswin64_140", true)] + [InlineData("3.4", "ruby-3.4.12-arm64-mswin64_140", false)] + [InlineData("ruby-3.4.12-x64-mswin64_140", "ruby-3.4.12-x64-mswin64_140", false)] + public void Use_Arm64Host_WarnsOnlyOnFallback(string query, string expected, bool warns) + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + using var env = new EnvScope(); + env.Set("RBMANAGER_OS_ARCH", "Arm64"); + sb.Seed("ruby-3.4.12-arm64-mswin64_140", "ruby-3.4.12-x64-mswin64_140", + "ruby-3.3.11-x64-mswin64_140"); + + Assert.Equal(0, Program.Use(query)); + + Assert.Equal(expected, Program.CurrentTarget()); + Assert.Equal(warns + ? $"warning: no arm64-mswin64_140 build matches '{query}', " + + "falling back to x64-mswin64_140 under emulation" + : "", cap.Err.TrimEnd()); + } + [Fact] // case 27 public void Uninstall_NonActive_RemovesDir_KeepsCurrent() { diff --git a/tests/rbmanager.Tests/PublishE2eTests.cs b/tests/rbmanager.Tests/PublishE2eTests.cs index 72a9e94..003d16e 100644 --- a/tests/rbmanager.Tests/PublishE2eTests.cs +++ b/tests/rbmanager.Tests/PublishE2eTests.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using RbManager.Tests.Support; namespace RbManager.Tests; @@ -46,6 +47,38 @@ public void Version_UnderAot_MatchesTheInProcessResolution() Assert.Equal(Program.SelfVersion(), r.Out.Trim()); } + // The fixture publishes win-x64, so on an ARM64 machine this runs the + // exe under emulation, where OSArchitecture must still report Arm64. + [SkippableFact] // case 144 + public void ListRemote_UnderAot_SeesTheMachineArchitecture() + { + Skip.If(_fx.ExePath is null, _fx.SkipReason); + using var sb = new E2eSandbox(); + sb.UseHostArchitecture(); + string index = Path.Combine(sb.Root, "_index", "index.json"); + Directory.CreateDirectory(Path.GetDirectoryName(index)!); + File.WriteAllText(index, $$""" + {"schema": 1, "next": null, + "builds": [{{Entry(BinaryIndex.Arm64)}}, {{Entry(BinaryIndex.X64)}}]} + """); + sb.UseIndex(index); + + RbResult r = sb.RunExe(_fx.ExePath!, "list", "--remote"); + + Assert.Equal(0, r.ExitCode); + Assert.Contains($"ruby-4.0.9-{BinaryIndex.X64}", r.Out); + Assert.Equal(RuntimeInformation.OSArchitecture == Architecture.Arm64, + r.Out.Contains($"ruby-4.0.9-{BinaryIndex.Arm64}")); + + static string Entry(string platform) => $$""" + {"name": "ruby-4.0.9-{{platform}}", "version": "4.0.9", "channel": "release", + "revision": 0, "tags": ["4.0.9"], "platform": "{{platform}}", + "url": "https://example.invalid/x.zip", "sha256": "{{new string('0', 64)}}", + "size": 1, "commit": null, "commit_date": null, "published_at": null, + "signed": false} + """; + } + [SkippableFact] // case 32: self-copy skip works on the self-contained exe public void Setup_FromCopiedExe_NoSelfCopyError() { diff --git a/tests/rbmanager.Tests/ResolveTests.cs b/tests/rbmanager.Tests/ResolveTests.cs index cc490c8..db12941 100644 --- a/tests/rbmanager.Tests/ResolveTests.cs +++ b/tests/rbmanager.Tests/ResolveTests.cs @@ -124,4 +124,97 @@ public void RevisionPreference_PrereleaseStaysAmbiguous() var ex = Assert.Throws(() => Program.Resolve("3.4.0")); Assert.Contains("is ambiguous", ex.Message); } + + // Plan 4.15: preferNative, which `use` passes, on an ARM64 host. + + private const string A3412 = "ruby-3.4.12-arm64-mswin64_140"; + private const string X3412 = "ruby-3.4.12-x64-mswin64_140"; + private const string X3413 = "ruby-3.4.13-x64-mswin64_140"; + private const string X3311 = "ruby-3.3.11-x64-mswin64_140"; + + private static EnvScope Arm64Host() + { + var env = new EnvScope(); + env.Set("RBMANAGER_OS_ARCH", "Arm64"); + return env; + } + + [Fact] // case 134: an arm64 match hides the x64 ones, even a newer version + public void PreferNative_Arm64Host_Arm64HidesX64() + { + using var sb = new RbSandbox(); + using var env = Arm64Host(); + sb.Seed(A3412, X3412, X3413); + Assert.Equal(A3412, Program.Resolve("3.4.12", preferNative: true)); + Assert.Equal(A3412, Program.Resolve("3.4", preferNative: true)); + } + + [Fact] // case 135: without an arm64 match the x64 ones resolve as before + public void PreferNative_Arm64Host_FallsBackToX64() + { + using var sb = new RbSandbox(); + using var env = Arm64Host(); + sb.Seed(A3412, X3311); + Assert.Equal(X3311, Program.Resolve("3.3", preferNative: true)); + } + + [Fact] // case 136: a full x64 name is still that build + public void PreferNative_Arm64Host_FullX64Name() + { + using var sb = new RbSandbox(); + using var env = Arm64Host(); + sb.Seed(A3412, X3412); + Assert.Equal(X3412, Program.Resolve(X3412, preferNative: true)); + } + + [Fact] // case 137: the preference never decides between arm64 builds + public void PreferNative_Arm64Host_Arm64VersionsStayAmbiguous() + { + using var sb = new RbSandbox(); + using var env = Arm64Host(); + sb.Seed(A3412, "ruby-3.4.11-arm64-mswin64_140", X3413); + var ex = Assert.Throws( + () => Program.Resolve("3.4.1", preferNative: true)); + Assert.Equal( + $"'3.4.1' is ambiguous: ruby-3.4.11-arm64-mswin64_140, {A3412}", ex.Message); + } + + [Fact] // case 147: across platforms the exact version beats the platform order + public void PreferNative_Arm64Host_ExactVersionBeatsPlatform() + { + const string x341 = "ruby-3.4.1-x64-mswin64_140"; + using var sb = new RbSandbox(); + using var env = Arm64Host(); + sb.Seed(A3412, x341, X3412); + Assert.Equal(x341, Program.Resolve("3.4.1", preferNative: true)); + Assert.Equal(A3412, Program.Resolve("3.4.12", preferNative: true)); + } + + [Fact] // case 148: within one platform an exact version is still ambiguous + public void PreferNative_SinglePlatform_ExactVersionStaysAmbiguous() + { + const string x341 = "ruby-3.4.1-x64-mswin64_140"; + using var sb = new RbSandbox(); + sb.Seed(x341, X3412); + Assert.Throws( + () => Program.Resolve("3.4.1", preferNative: true)); + + using var env = Arm64Host(); + var ex = Assert.Throws( + () => Program.Resolve("3.4.1", preferNative: true)); + Assert.Equal($"'3.4.1' is ambiguous: {x341}, {X3412}", ex.Message); + } + + [Fact] // case 138: x64 hosts, and uninstall's resolution, are unchanged + public void PreferNative_X64HostOrNotPreferred_StaysAmbiguous() + { + using var sb = new RbSandbox(); + sb.Seed(A3412, X3412); + Assert.Throws( + () => Program.Resolve("3.4.12", preferNative: true)); + + using var env = Arm64Host(); + var ex = Assert.Throws(() => Program.Resolve("3.4.12")); + Assert.Equal($"'3.4.12' is ambiguous: {A3412}, {X3412}", ex.Message); + } } diff --git a/tests/rbmanager.Tests/Support/E2eSandbox.cs b/tests/rbmanager.Tests/Support/E2eSandbox.cs index 7042162..0422452 100644 --- a/tests/rbmanager.Tests/Support/E2eSandbox.cs +++ b/tests/rbmanager.Tests/Support/E2eSandbox.cs @@ -6,8 +6,9 @@ namespace RbManager.Tests.Support; // does not touch the test process environment; the redirection is passed to // each child rb.exe invocation, so E2E tests run in parallel. The VC++ // Redistributable probe is redirected to a scratch subkey seeded as -// "installed" (and the DLL fallback to a nonexistent path), so children -// never consult the host machine's real state. +// "installed" (and the DLL fallback to a nonexistent path), and the host +// is pinned to x64, so children never consult the host machine's real +// state. internal sealed class E2eSandbox : IDisposable { private readonly TempDir _tmp = new("e2e"); @@ -20,16 +21,22 @@ internal sealed class E2eSandbox : IDisposable public E2eSandbox() { - using var key = Registry.CurrentUser.CreateSubKey(_vcSub, writable: true); - key.SetValue("Installed", 1, RegistryValueKind.DWord); - key.SetValue("Version", "v14.99.0.0"); + RbSandbox.SeedVcRedist(_vcSub); _extraEnv = new Dictionary { ["RBMANAGER_VC_REDIST_KEY"] = _vcSub, ["RBMANAGER_VCRUNTIME"] = Path.Combine(Root, "no-vcruntime140.dll"), + ["RBMANAGER_OS_ARCH"] = "X64", }; } + // Blanks the x64 pin, so the child reads the real OSArchitecture. + // Dropping the key would pass on whatever the test process inherited. + public void UseHostArchitecture() => _extraEnv["RBMANAGER_OS_ARCH"] = ""; + + // Points the child at a binary index file. + public void UseIndex(string path) => _extraEnv["RBMANAGER_INDEX_URL"] = path; + public RbResult Run(params string[] args) => RunExe(Rb.Exe, args); public RbResult RunExe(string exe, params string[] args) => Rb.RunExe(exe, Root, EnvKey, _extraEnv, args); diff --git a/tests/rbmanager.Tests/Support/RbSandbox.cs b/tests/rbmanager.Tests/Support/RbSandbox.cs index ad7a595..1b513e1 100644 --- a/tests/rbmanager.Tests/Support/RbSandbox.cs +++ b/tests/rbmanager.Tests/Support/RbSandbox.cs @@ -6,8 +6,9 @@ namespace RbManager.Tests.Support; // wired to RBMANAGER_ROOT, plus a scratch HKCU subkey wired to // RBMANAGER_ENV_KEY so UserPath.Ensure never touches the real user PATH. // The VC++ Redistributable probe is redirected to a scratch subkey seeded -// as "installed", so tests never depend on the host machine's real state. -// Only safe inside the Serial collection (it mutates process env vars). +// as "installed" for both runtimes, and the host is pinned to x64, so +// tests never depend on the host machine's real state. Only safe inside +// the Serial collection (it mutates process env vars). internal sealed class RbSandbox : IDisposable { private readonly TempDir _tmp = new("root"); @@ -23,15 +24,23 @@ public RbSandbox() { _regSub = $@"Software\rbmanager-tests\{Guid.NewGuid():N}"; _vcSub = $@"Software\rbmanager-tests\{Guid.NewGuid():N}"; - using (var key = Registry.CurrentUser.CreateSubKey(_vcSub, writable: true)) - { - key.SetValue("Installed", 1, RegistryValueKind.DWord); - key.SetValue("Version", "v14.99.0.0"); - } + SeedVcRedist(_vcSub); _env.Set("RBMANAGER_ROOT", Root); _env.Set("RBMANAGER_ENV_KEY", _regSub); _env.Set("RBMANAGER_VC_REDIST_KEY", _vcSub); _env.Set("RBMANAGER_VCRUNTIME", Path.Combine(Root, "no-vcruntime140.dll")); + _env.Set("RBMANAGER_OS_ARCH", "X64"); + } + + // The Runtimes key the x64 package leaves on ARM64 Windows. + internal static void SeedVcRedist(string sub) + { + foreach (string runtime in new[] { "x64", "ARM64" }) + { + using var key = Registry.CurrentUser.CreateSubKey($@"{sub}\{runtime}", writable: true); + key.SetValue("Installed", 1, RegistryValueKind.DWord); + key.SetValue("Version", "v14.99.0.0"); + } } // Creates rubies\ (with a bin subdir) without going through install. diff --git a/tests/rbmanager.Tests/VcRedistTests.cs b/tests/rbmanager.Tests/VcRedistTests.cs index d54d34d..9ed3292 100644 --- a/tests/rbmanager.Tests/VcRedistTests.cs +++ b/tests/rbmanager.Tests/VcRedistTests.cs @@ -5,21 +5,34 @@ namespace RbManager.Tests; // VC++ Redistributable detection and the rb setup consent flow. Registry // state is simulated through RBMANAGER_VC_REDIST_KEY (an HKCU scratch -// subkey) and the DLL fallback through RBMANAGER_VCRUNTIME, so nothing here -// reads the host machine's real installation state. The install path itself -// (download, signature check, elevation) is not driven end to end; the -// signature verifier is covered by the unsigned-rejection case here and by -// the opt-in Network test against the real installer. +// subkey standing in for the Runtimes key), the DLL fallback through +// RBMANAGER_VCRUNTIME and the host through RBMANAGER_OS_ARCH, so nothing +// here reads the host machine's real installation state. The install path +// itself (download, signature check, elevation) is not driven end to end; +// the signature verifier is covered by the unsigned-rejection case here +// and by the opt-in Network test against the real installer. [Trait("Category", "Unit")] [Collection(Serial.Name)] public class VcRedistTests { private const string Version = "v14.99.1.0"; - private static void SeedInstalled(ScratchRegistryKey key, int installed = 1) + private static void SeedInstalled(ScratchRegistryKey key, int installed = 1, + string runtime = "x64") { - key.Key.SetValue("Installed", installed, RegistryValueKind.DWord); - key.Key.SetValue("Version", Version); + using RegistryKey sub = key.Key.CreateSubKey(runtime); + sub.SetValue("Installed", installed, RegistryValueKind.DWord); + sub.SetValue("Version", Version); + } + + // The seams at the scratch key on a host of the given architecture. + private static EnvScope Redist(ScratchRegistryKey key, TempDir tmp, string arch = "X64") + { + var env = new EnvScope(); + env.Set("RBMANAGER_VC_REDIST_KEY", key.SubKeyPath); + env.Set("RBMANAGER_VCRUNTIME", Path.Combine(tmp.Path, "no-vcruntime140.dll")); + env.Set("RBMANAGER_OS_ARCH", arch); + return env; } // Points both seams at empty/nonexistent targets: a machine without the @@ -30,6 +43,7 @@ private static EnvScope MissingRedist(TempDir tmp) env.Set("RBMANAGER_VC_REDIST_KEY", $@"Software\rbmanager-tests\{Guid.NewGuid():N}"); env.Set("RBMANAGER_VCRUNTIME", Path.Combine(tmp.Path, "no-vcruntime140.dll")); + env.Set("RBMANAGER_OS_ARCH", "X64"); return env; } @@ -48,7 +62,8 @@ public void InstalledVersion_InstalledOne_ReturnsVersion() SeedInstalled(key); env.Set("RBMANAGER_VC_REDIST_KEY", key.SubKeyPath); - Assert.Equal(Version, VcRedist.InstalledVersion()); + Assert.Equal(Version, VcRedist.InstalledVersion("x64")); + Assert.Null(VcRedist.InstalledVersion("ARM64")); } [Fact] @@ -59,7 +74,7 @@ public void InstalledVersion_InstalledZero_ReturnsNull() SeedInstalled(key, installed: 0); env.Set("RBMANAGER_VC_REDIST_KEY", key.SubKeyPath); - Assert.Null(VcRedist.InstalledVersion()); + Assert.Null(VcRedist.InstalledVersion("x64")); } [Fact] @@ -69,7 +84,7 @@ public void InstalledVersion_MissingKey_ReturnsNull() env.Set("RBMANAGER_VC_REDIST_KEY", $@"Software\rbmanager-tests\{Guid.NewGuid():N}"); - Assert.Null(VcRedist.InstalledVersion()); + Assert.Null(VcRedist.InstalledVersion("x64")); } [Fact] @@ -77,10 +92,11 @@ public void InstalledVersion_NoVersionValue_ReturnsPlaceholder() { using var key = new ScratchRegistryKey(); using var env = new EnvScope(); - key.Key.SetValue("Installed", 1, RegistryValueKind.DWord); + using (RegistryKey sub = key.Key.CreateSubKey("x64")) + sub.SetValue("Installed", 1, RegistryValueKind.DWord); env.Set("RBMANAGER_VC_REDIST_KEY", key.SubKeyPath); - Assert.Equal("(unknown version)", VcRedist.InstalledVersion()); + Assert.Equal("(unknown version)", VcRedist.InstalledVersion("x64")); } [Fact] @@ -88,22 +104,31 @@ public void IsInstalled_RegistryMissing_FallsBackToDll() { using var tmp = new TempDir("vcr"); using var env = MissingRedist(tmp); - Assert.False(VcRedist.IsInstalled()); + Assert.False(VcRedist.IsInstalled("x64")); string dll = Path.Combine(tmp.Path, "vcruntime140.dll"); File.WriteAllBytes(dll, [0]); env.Set("RBMANAGER_VCRUNTIME", dll); - Assert.True(VcRedist.IsInstalled()); + Assert.True(VcRedist.IsInstalled("x64")); } + [Theory] // case 140: arm64 rubies need the ARM64 runtime, the rest x64 + [InlineData("ruby-3.4.12-arm64-mswin64_140", "ARM64")] + [InlineData(BinaryIndex.Arm64, "ARM64")] + [InlineData("ruby-3.4.12-x64-mswin64_140", "x64")] + [InlineData(BinaryIndex.X64, "x64")] + [InlineData("ruby-3.4.12-x86-mswin64_140", "x64")] + public void RuntimeOf_MapsPlatformToRuntimesKey(string ruby, string runtime) => + Assert.Equal(runtime, VcRedist.RuntimeOf(ruby)); + [Fact] public async Task Ensure_AlreadyInstalled_ReportsAndReturnsZero() { using var key = new ScratchRegistryKey(); - using var env = new EnvScope(); + using var tmp = new TempDir("vcr"); + using var env = Redist(key, tmp); using var cap = new ConsoleCapture(); SeedInstalled(key); - env.Set("RBMANAGER_VC_REDIST_KEY", key.SubKeyPath); int rc = await VcRedist.Ensure(assumeYes: false); @@ -111,6 +136,70 @@ public async Task Ensure_AlreadyInstalled_ReportsAndReturnsZero() Assert.Contains($"VC++ Redistributable {Version} is already installed", cap.Out); } + [Fact] // case 141: on ARM64 setup needs both runtimes + public async Task Ensure_Arm64Host_BothRuntimes_ReturnsZero() + { + using var key = new ScratchRegistryKey(); + using var tmp = new TempDir("vcr"); + using var env = Redist(key, tmp, "Arm64"); + using var cap = new ConsoleCapture(); + SeedInstalled(key); + SeedInstalled(key, runtime: "ARM64"); + + Assert.Equal(0, await VcRedist.Ensure(assumeYes: false)); + Assert.Contains("is already installed", cap.Out); + } + + [Fact] // case 142: an x64-only runtime on ARM64 offers the x64 package + public async Task Ensure_Arm64Host_MissingArm64Runtime_Offers() + { + using var key = new ScratchRegistryKey(); + using var tmp = new TempDir("vcr"); + using var env = Redist(key, tmp, "Arm64"); + using var cap = new ConsoleCapture(); + using var stdin = new StdinScope(""); + SeedInstalled(key); + + Assert.Equal(1, await VcRedist.Ensure(assumeYes: false)); + Assert.Contains("Visual C++ Redistributable (ARM64) is not installed", cap.Out); + Assert.Contains("vc_redist.x64.exe", cap.Out); + } + + [Fact] // case 149: on ARM64 the DLL does not stand in for the ARM64 runtime + public async Task Ensure_Arm64Host_DllPresent_StillOffersArm64() + { + using var key = new ScratchRegistryKey(); + using var tmp = new TempDir("vcr"); + using var env = Redist(key, tmp, "Arm64"); + using var cap = new ConsoleCapture(); + using var stdin = new StdinScope(""); + SeedInstalled(key); + string dll = Path.Combine(tmp.Path, "vcruntime140.dll"); + File.WriteAllBytes(dll, [0]); + env.Set("RBMANAGER_VCRUNTIME", dll); + + Assert.Equal(1, await VcRedist.Ensure(assumeYes: false)); + Assert.Contains("Visual C++ Redistributable (ARM64) is not installed", cap.Out); + Assert.DoesNotContain("already present", cap.Out); + } + + [Fact] // case 150: nor for either runtime of a ruby installed or used there + public void WarnIfMissing_Arm64Host_DllPresent_StillWarns() + { + using var key = new ScratchRegistryKey(); + using var tmp = new TempDir("vcr"); + using var env = Redist(key, tmp, "Arm64"); + using var cap = new ConsoleCapture(); + string dll = Path.Combine(tmp.Path, "vcruntime140.dll"); + File.WriteAllBytes(dll, [0]); + env.Set("RBMANAGER_VCRUNTIME", dll); + + Assert.False(VcRedist.IsInstalled("ARM64")); + Assert.False(VcRedist.IsInstalled("x64")); + VcRedist.WarnIfMissing("ruby-9.9.9-arm64-mswin64_140"); + Assert.Contains("Visual C++ Redistributable (ARM64) was not found", cap.Err); + } + [Fact] public async Task Ensure_DllPresentWithoutRegistry_ReturnsZero() { @@ -153,7 +242,7 @@ public void WarnIfMissing_Missing_WarnsToStderr() using var env = MissingRedist(tmp); using var cap = new ConsoleCapture(); - VcRedist.WarnIfMissing(); + VcRedist.WarnIfMissing("ruby-9.9.9-x64-mswin64_140"); Assert.Equal("", cap.Out); Assert.Contains("rb: warning:", cap.Err); @@ -169,12 +258,28 @@ public void WarnIfMissing_Installed_Silent() SeedInstalled(key); env.Set("RBMANAGER_VC_REDIST_KEY", key.SubKeyPath); - VcRedist.WarnIfMissing(); + VcRedist.WarnIfMissing("ruby-9.9.9-x64-mswin64_140"); Assert.Equal("", cap.Out); Assert.Equal("", cap.Err); } + [Fact] // case 143: an arm64 ruby is checked against the ARM64 runtime + public void WarnIfMissing_Arm64Ruby_ChecksArm64Runtime() + { + using var key = new ScratchRegistryKey(); + using var tmp = new TempDir("vcr"); + using var env = Redist(key, tmp, "Arm64"); + using var cap = new ConsoleCapture(); + SeedInstalled(key); + + VcRedist.WarnIfMissing("ruby-9.9.9-x64-mswin64_140"); + Assert.Equal("", cap.Err); + + VcRedist.WarnIfMissing("ruby-9.9.9-arm64-mswin64_140"); + Assert.Contains("Visual C++ Redistributable (ARM64) was not found", cap.Err); + } + [Fact] public void VerifyMicrosoftSignature_UnsignedFile_Throws() {