diff --git a/internal/vm/array_residuals_wave10_test.go b/internal/vm/array_residuals_wave10_test.go index 587a4b2f..3833b5ee 100644 --- a/internal/vm/array_residuals_wave10_test.go +++ b/internal/vm/array_residuals_wave10_test.go @@ -122,7 +122,26 @@ func TestArrayInitialize(t *testing.T) { {`begin; Array.new(1,2,3); rescue ArgumentError=>e; puts e.message; end`, "wrong number of arguments (given 3, expected 0..2)\n"}, {`begin; Array.new(-1); rescue ArgumentError=>e; puts e.message; end`, "negative array size\n"}, - {`begin; Array.new(10**40); rescue ArgumentError=>e; puts e.message; end`, "array size too big\n"}, + + // ⛔ MOVED, not relaxed. This row read + // + // Array.new(10**40) rescue ArgumentError -> "array size too big" + // + // which is rbgo's own divergence written down as an expectation, under a + // comment saying "Verified against MRI Ruby 4.0". MRI raises RangeError + // there: rb_ary_new2 takes a long, so NUM2LONG runs BEFORE any size test, + // and ary_new's ArgumentError is only for a value that converted and is + // still out of range (#901). `rescue RangeError` could not catch it. + // + // The boundary is exactly LONG_MAX, measured on 4.0.7 — both sides are + // asserted here so the row says WHERE the line is, not just that there + // is one: + {`begin; Array.new(10**40); rescue RangeError=>e; puts e.message; end`, + "bignum too big to convert into 'long'\n"}, + {`begin; Array.new(2**63); rescue RangeError=>e; puts e.message; end`, + "bignum too big to convert into 'long'\n"}, + {`begin; Array.new(2**63-1); rescue ArgumentError=>e; puts e.message; end`, + "array size too big\n"}, {`p(Array.private_instance_methods.include?(:initialize))`, "true\n"}, }) } diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index d7983b86..5ea4aa37 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -3114,8 +3114,25 @@ func (vm *VM) bootstrap() { } } } + // ⛔ A size that does not fit a C long fails the CONVERSION, not the size + // test, and the two are different classes. MRI's rb_ary_new2 takes a long, + // so NUM2LONG runs first and raises RangeError; ary_new's ArgumentError + // ("array size too big") is only for a value that converted and is still + // out of range. This said ArgumentError, so `rescue RangeError` could not + // catch it (#901). + // + // Measured on 4.0.7, and the boundary is exactly LONG_MAX: + // + // 2**62 ArgumentError: array size too big + // 2**63-1 ArgumentError: array size too big + // 2**63 RangeError: bignum too big to convert into 'long' + // 10**40 RangeError + // + // Array#first already had this right 200 lines below (builtins.go:3331) + // with the identical *object.Bignum check — one idiom, applied in one of + // the two places that needed it. if _, isBig := args[0].(*object.Bignum); isBig { - raise("ArgumentError", "array size too big") + raise("RangeError", "bignum too big to convert into 'long'") } n := vm.toIntCoerce(args[0]) if n < 0 { diff --git a/internal/vm/dir.go b/internal/vm/dir.go index 621e60fa..33530c59 100644 --- a/internal/vm/dir.go +++ b/internal/vm/dir.go @@ -476,7 +476,37 @@ func (vm *VM) registerDirInstance(cDir *RClass) { // osUserHomeDir is a seam over os.UserHomeDir so the no-HOME error path is // testable without manipulating the process environment. -var osUserHomeDir = os.UserHomeDir +// ⛔ os.UserHomeDir CANNOT EXPRESS THE RULE, which is why this is LookupEnv. +// MRI's rb_default_home_dir branches on `if (!dir)` — getenv returning NULL — +// so an empty HOME is a VALUE and only an ABSENT one reaches the passwd +// database. os.UserHomeDir reports an error for both, so it folded the two +// together and `HOME=""` took the fallback. Measured on 4.0.7: +// +// HOME="" Dir.home -> "" expand_path("~") -> ArgumentError: non-absolute home +// HOME="relative/home" Dir.home -> "relative/home" expand_path("~") -> ArgumentError: non-absolute home +// HOME unset Dir.home -> the passwd home expand_path("~") -> the passwd home +// +// So Dir.home answers getenv verbatim — empty or relative, unchecked — and +// expand_path is the one that additionally demands an absolute path. +// ⛔ AND IT STILL HAS TO BE os.UserHomeDir WHEN HOME IS ABSENT, because on +// Windows HOME is usually not the variable: MRI consults HOME, then +// HOMEDRIVE+HOMEPATH, then USERPROFILE (file.c rb_default_home_dir), and +// os.UserHomeDir is exactly that chain per platform. A first version of this +// read HOME alone and so would have sent a Windows user with no HOME to the +// passwd lookup instead of USERPROFILE — a regression invisible on darwin, +// where the two agree. +// +// The distinction LookupEnv adds is only about HOME being SET-BUT-EMPTY, which +// is the one case os.UserHomeDir cannot report separately from absent. +var osLookupHome = func() (string, bool) { + if h, ok := os.LookupEnv("HOME"); ok { + return h, true + } + if h, err := os.UserHomeDir(); err == nil && h != "" { + return h, true + } + return "", false +} // osCurrentUserHome is a seam over the passwd-database lookup of the current // user's home directory (getpwuid), used as Dir.home's fallback when $HOME is @@ -494,7 +524,9 @@ var osCurrentUserHome = func() (string, error) { // and falling back to the passwd database when it is unset (MRI's // rb_default_home_dir), raising ArgumentError only when neither yields a home. func dirHomeStr() string { - if h, err := osUserHomeDir(); err == nil && h != "" { + // Set — even to "" or to a relative path — is answered verbatim: the + // absoluteness test belongs to expand_path, not here (see osLookupHome). + if h, ok := osLookupHome(); ok { return h } if h, err := osCurrentUserHome(); err == nil && h != "" { diff --git a/internal/vm/dir_internal_test.go b/internal/vm/dir_internal_test.go index 2578ec58..c5a88ee3 100644 --- a/internal/vm/dir_internal_test.go +++ b/internal/vm/dir_internal_test.go @@ -5,14 +5,21 @@ import ( "testing" ) -// TestDirHomeError covers dirHomeStr's no-HOME branch: when both $HOME -// (osUserHomeDir) and the passwd-database fallback (osCurrentUserHome) fail, -// dirHomeStr raises ArgumentError. Both seams are stubbed so the test does not -// touch the process environment or depend on the runner's passwd database. +// ⛔ These two tests stubbed osUserHomeDir, a seam over os.UserHomeDir, which +// reports an error BOTH for an unset HOME and for an empty one. That is the +// distinction MRI branches on (`if (!dir)` in rb_default_home_dir is NULL only), +// so the seam could not express the rule and `HOME=""` wrongly took the passwd +// fallback. The seam is now osLookupHome, with LookupEnv's (value, set) shape; +// these moved with it rather than being relaxed. + +// TestDirHomeError covers dirHomeStr's no-HOME branch: when HOME is UNSET and +// the passwd-database fallback (osCurrentUserHome) also fails, dirHomeStr raises +// ArgumentError. Both seams are stubbed so the test does not touch the process +// environment or depend on the runner's passwd database. func TestDirHomeError(t *testing.T) { - origHome, origCur := osUserHomeDir, osCurrentUserHome - defer func() { osUserHomeDir, osCurrentUserHome = origHome, origCur }() - osUserHomeDir = func() (string, error) { return "", errors.New("no home") } + origHome, origCur := osLookupHome, osCurrentUserHome + defer func() { osLookupHome, osCurrentUserHome = origHome, origCur }() + osLookupHome = func() (string, bool) { return "", false } osCurrentUserHome = func() (string, error) { return "", errors.New("no passwd home") } defer func() { @@ -25,14 +32,43 @@ func TestDirHomeError(t *testing.T) { } // TestDirHomePasswdFallback covers dirHomeStr falling back to the passwd -// database (osCurrentUserHome) when $HOME is unset, as MRI's rb_default_home_dir +// database (osCurrentUserHome) when $HOME is UNSET, as MRI's rb_default_home_dir // does. func TestDirHomePasswdFallback(t *testing.T) { - origHome, origCur := osUserHomeDir, osCurrentUserHome - defer func() { osUserHomeDir, osCurrentUserHome = origHome, origCur }() - osUserHomeDir = func() (string, error) { return "", errors.New("no home") } + origHome, origCur := osLookupHome, osCurrentUserHome + defer func() { osLookupHome, osCurrentUserHome = origHome, origCur }() + osLookupHome = func() (string, bool) { return "", false } osCurrentUserHome = func() (string, error) { return "/passwd/home", nil } if got := dirHomeStr(); got != "/passwd/home" { t.Fatalf("dirHomeStr fallback = %q, want /passwd/home", got) } } + +// ⛔ A HOME that is SET must NOT reach the passwd database, whatever it holds. +// This is the row the old seam could not express: with osUserHomeDir, `HOME=""` +// looked identical to no HOME at all and took the fallback, so Dir.home answered +// the passwd home where MRI answers "" — measured on +// `ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [arm64-darwin25]`: +// +// HOME="" Dir.home -> "" +// HOME="relative/home" Dir.home -> "relative/home" +// +// The passwd stub below would be a visible wrong answer if the fallback ran. +func TestDirHomeAnswersASetHomeVerbatim(t *testing.T) { + origHome, origCur := osLookupHome, osCurrentUserHome + defer func() { osLookupHome, osCurrentUserHome = origHome, origCur }() + osCurrentUserHome = func() (string, error) { return "/passwd/home", nil } + + for _, tc := range []struct{ name, home string }{ + {"empty", ""}, + {"relative", "relative/home"}, + {"absolute", "/an/absolute/home"}, + } { + t.Run(tc.name, func(t *testing.T) { + osLookupHome = func() (string, bool) { return tc.home, true } + if got := dirHomeStr(); got != tc.home { + t.Fatalf("dirHomeStr = %q, want %q verbatim (the passwd fallback must not run)", got, tc.home) + } + }) + } +} diff --git a/internal/vm/expand_path_home_test.go b/internal/vm/expand_path_home_test.go new file mode 100644 index 00000000..7bef9a40 --- /dev/null +++ b/internal/vm/expand_path_home_test.go @@ -0,0 +1,145 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "os" + "runtime" + "testing" +) + +// `~` expansion consults $HOME and falls back to the passwd database only when +// HOME is ABSENT. MRI's rb_default_home_dir branches on `if (!dir)` — getenv +// returning NULL — so an empty HOME is a value, not an absence, and +// expand_path's own absoluteness test is what rejects it. +// +// Measured on `ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM +// [arm64-darwin25]` through scripts/conformance/rubyspec/cmd/oracle: +// +// HOME Dir.home File.expand_path("~") +// "" "" ArgumentError: non-absolute home +// "relative/home" "relative/home" ArgumentError: non-absolute home +// unset passwd home passwd home +// +// ⛔ Two defects met here. expand_path RAISED for an unset HOME where MRI +// answers (#901), because it kept its own HOME-only copy of a rule dirHomeStr +// already encoded for Dir.home. And `HOME=""` reached the passwd fallback in +// BOTH, because the seam was os.UserHomeDir, which reports an error for unset +// and empty alike and so cannot express MRI's test. +// +// ⛔ The empty-HOME case used to raise with "couldn't find HOME environment -- +// expanding '~'" — the right CLASS for the wrong REASON, so the pre-existing +// guard test passed while the message disagreed with MRI. The messages are +// asserted here for that reason. +func TestTildeExpansionConsultsHomeThenThePasswdDatabase(t *testing.T) { + // ⛔ EVERY ROW BELOW MUTATES THE PROCESS ENVIRONMENT. `ENV["HOME"] = …` in + // Ruby is setenv, and runSrc builds a VM inside THIS process, so a row that + // left HOME empty or deleted would hand it to every test that runs after + // this one — in a package whose tests share one process. t.Setenv captures + // HOME now and restores it at cleanup whatever the rows did to it in + // between; it also makes this test refuse to run in parallel, which is + // correct for something that writes a global. + t.Setenv("HOME", os.Getenv("HOME")) + + const pre = `def show + p File.expand_path("~") +rescue => e + puts "#{e.class}: #{e.message}" +end +` + // ⛔ posix marks the rows that name the FALLBACK'S DESTINATION. The passwd + // database is a POSIX concept and Process.uid is -1 on Windows, where + // Etc.getpwuid(-1) raises "can't find user for -1" — which is how CI failed + // this test, not how the fix failed. MRI's chain differs there too (HOME, + // then HOMEDRIVE+HOMEPATH, then USERPROFILE), so what an absent HOME + // resolves TO is not assertable cross-platform; that an absent HOME does not + // RAISE is, and that row is not marked. file_path_internal_test.go marks its + // ~user row the same way, for the same reason. + for _, tc := range []struct { + name, src, want string + posix bool + }{ + { + name: "an empty HOME is a non-absolute home, not an absent one", + src: pre + `ENV["HOME"] = "" +show`, + want: `ArgumentError: non-absolute home`, + }, + { + name: "a relative HOME is rejected the same way", + src: pre + `ENV["HOME"] = "relative/home" +show`, + want: `ArgumentError: non-absolute home`, + }, + { + name: "an absolute HOME is used", + src: pre + `ENV["HOME"] = "/an/absolute/home" +show`, + want: `"/an/absolute/home"`, + }, + // ⛔ The #901 row, in the form that holds on every platform: an absent + // HOME must not RAISE. Where it resolves to is the posix row below. + { + name: "an unset HOME does not raise", + src: `ENV.delete("HOME") +p File.expand_path("~").is_a?(String)`, + want: `true`, + }, + { + name: "an unset HOME resolves to the passwd home", + src: `require "etc" +ENV.delete("HOME") +p File.expand_path("~") == Etc.getpwuid(Process.uid).dir`, + want: `true`, + posix: true, + }, + { + name: "the tail is kept across the fallback", + src: `ENV.delete("HOME") +p File.expand_path("~/x").end_with?("/x")`, + want: `true`, + posix: true, + }, + // ⛔ HOME is read on EVERY call, not captured at startup. Without this + // control the fallback row cannot be attributed to the passwd lookup: a + // value remembered from process start would answer identically. + { + name: "HOME is consulted live, so the fallback is attributable", + src: pre + `ENV["HOME"] = "/first/home" +show +ENV["HOME"] = "/second/home" +show`, + want: "\"/first/home\"\n\"/second/home\"", + }, + // Dir.home answers getenv verbatim — it does NOT apply expand_path's + // absoluteness test. Same helper, different contract. + { + name: "Dir.home answers a set HOME verbatim, empty included", + src: `ENV["HOME"] = "" +p Dir.home +ENV["HOME"] = "relative/home" +p Dir.home`, + want: "\"\"\n\"relative/home\"", + }, + { + name: "~user is unaffected", + src: `begin + File.expand_path("~no_such_user_rbgo_xyz_123") +rescue ArgumentError => e + puts e.message +end`, + want: `user no_such_user_rbgo_xyz_123 doesn't exist`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.posix && runtime.GOOS == "windows" { + t.Skip("the passwd database (Process.uid, Etc.getpwuid) is POSIX-only") + } + if got := runSrc(t, tc.src); got != tc.want { + t.Errorf("%s\n got %q\nwant %q", tc.src, got, tc.want) + } + }) + } +} diff --git a/internal/vm/file.go b/internal/vm/file.go index 80809944..1cf5fb52 100644 --- a/internal/vm/file.go +++ b/internal/vm/file.go @@ -1004,11 +1004,24 @@ func expandTildePath(p string) string { } else { name, tail = rest[:slash], rest[slash:] } - if name == "" { // "~" or "~/..." -> $HOME - home := os.Getenv("HOME") - if home == "" { - raise("ArgumentError", "couldn't find HOME environment -- expanding '~'") - } + if name == "" { // "~" or "~/..." -> $HOME, else the passwd database + // ⛔ $HOME ALONE IS NOT THE RULE. MRI's rb_default_home_dir falls back to + // the current user's passwd entry when HOME is unset, so expand_path("~") + // answers rather than raising — measured on 4.0.7: + // + // ENV["HOME"] = "/tmp/zz" -> "/tmp/zz" (read LIVE, not at startup) + // ENV.delete("HOME") -> "/Users/…" == Etc.getpwuid(Process.uid).dir + // + // The second line is why this used to raise (#901). The first is the + // control that makes the fallback attributable: HOME is consulted every + // call, so answering after a delete is the passwd lookup and not a value + // captured when the process started. + // + // ⛔ dirHomeStr (dir.go) already encoded all of this for Dir.home — $HOME, + // then getpwuid, raise only when neither yields one. This function kept + // its own HOME-only copy, so Dir.home was right and expand_path was + // wrong for the same input. One definition, two callers. + home := dirHomeStr() if !isAbsPath(toSlash(home)) { raise("ArgumentError", "non-absolute home") }