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
21 changes: 20 additions & 1 deletion internal/vm/array_residuals_wave10_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
})
}
Expand Down
19 changes: 18 additions & 1 deletion internal/vm/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 34 additions & 2 deletions internal/vm/dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 != "" {
Expand Down
58 changes: 47 additions & 11 deletions internal/vm/dir_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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)
}
})
}
}
145 changes: 145 additions & 0 deletions internal/vm/expand_path_home_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
23 changes: 18 additions & 5 deletions internal/vm/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Loading