From 6c4f691ea4e1e660262354e999ff03308edfe6f8 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sat, 7 Nov 2026 22:23:23 +0100 Subject: [PATCH 1/2] Array.new's size conversion, and ~ expansion's HOME rule (closes #901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both items of #901, plus a third divergence the second one exposed. ## 1. Array.new(2**63) raises RangeError, not ArgumentError Array.new(10**40) # MRI: RangeError: bignum too big to convert into 'long' # rbgo: ArgumentError: array size too big rb_ary_new2 takes a long, so NUM2LONG runs BEFORE any size test; ary_new's ArgumentError is only for a value that converted and is still out of range. The class matters: `rescue RangeError` could not catch it. The boundary is exactly LONG_MAX, measured on 4.0.7: 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 the identical *object.Bignum check with the right class 200 lines below (builtins.go:3331). One idiom, applied in one of the two places that needed it. ⛔ An existing row PINNED the divergence: array_residuals_wave10_test.go asserted ArgumentError for Array.new(10**40), under a comment reading "Verified against MRI Ruby 4.0". It is moved with its citation, and both sides of the boundary are now asserted so the row says WHERE the line is. ## 2. expand_path("~") answers when HOME is unset ENV.delete("HOME"); File.expand_path("~") # MRI: "/Users/…" (the passwd entry for the current uid) # rbgo: ArgumentError: couldn't find HOME environment -- expanding '~' dirHomeStr (dir.go) ALREADY encoded MRI's rule for Dir.home — $HOME, then getpwuid, raise only when neither answers. expand_path kept its own HOME-only copy, so Dir.home was right and expand_path was wrong for the same input. The fix is a call to the existing helper. ## 3. And HOME="" is a VALUE, not an absence (found by fixing 2) The first version of this change sent an EMPTY HOME to the passwd database too, and the repository's own TestFileExpandPathHomeGuards caught it. MRI's rb_default_home_dir branches on `if (!dir)` — getenv returning NULL — so only an ABSENT HOME reaches the fallback. Measured on 4.0.7: HOME Dir.home File.expand_path("~") "" "" ArgumentError: non-absolute home "relative/home" "relative/home" ArgumentError: non-absolute home unset passwd home passwd home So Dir.home answers getenv VERBATIM — empty or relative, unchecked — and expand_path is the one that demands an absolute path. ⛔ The seam could not express that rule: osUserHomeDir wrapped os.UserHomeDir, which reports an error for unset AND empty alike. It is now osLookupHome, with LookupEnv's (value, set) shape. The two seam tests moved with it rather than being relaxed, and a third row asserts that a SET HOME never reaches the passwd stub. ⛔ That also fixes a divergence nobody had filed: Dir.home with HOME="" answered the passwd home where MRI answers "". It is the same helper and the same distinction as item 2, so it could not be separated out. ⛔ TestFileExpandPathHomeGuards passed BEFORE this change for the wrong reason — right class, wrong message ("couldn't find HOME environment" where MRI says "non-absolute home"). The new test asserts the messages. ## Measured cmd/oracle against /opt/homebrew/opt/ruby@4.0/bin/ruby (ruby 4.0.7, +PRISM): six probes, all "2 engines AGREED, byte for byte". ⛔ The new test mutates the PROCESS environment (ENV["HOME"] in Ruby is setenv, and runSrc builds a VM in this process), so it pins and restores HOME with t.Setenv: a row leaving HOME empty would hand that to every later test in the package. Closes #901 Co-Authored-By: Claude Opus 5 --- internal/vm/array_residuals_wave10_test.go | 21 ++++- internal/vm/builtins.go | 19 +++- internal/vm/dir.go | 18 +++- internal/vm/dir_internal_test.go | 58 +++++++++--- internal/vm/expand_path_home_test.go | 104 +++++++++++++++++++++ internal/vm/file.go | 23 ++++- 6 files changed, 223 insertions(+), 20 deletions(-) create mode 100644 internal/vm/expand_path_home_test.go 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..90fde212 100644 --- a/internal/vm/dir.go +++ b/internal/vm/dir.go @@ -476,7 +476,19 @@ 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. +var osLookupHome = func() (string, bool) { return os.LookupEnv("HOME") } // 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 +506,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..56ece5ab --- /dev/null +++ b/internal/vm/expand_path_home_test.go @@ -0,0 +1,104 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "os" + "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 +` + for _, tc := range []struct{ name, src, want string }{ + {"an empty HOME is a non-absolute home, not an absent one", + pre + `ENV["HOME"] = "" +show`, `ArgumentError: non-absolute home`}, + + {"a relative HOME is rejected the same way", + pre + `ENV["HOME"] = "relative/home" +show`, `ArgumentError: non-absolute home`}, + + {"an absolute HOME is used", + pre + `ENV["HOME"] = "/an/absolute/home" +show`, `"/an/absolute/home"`}, + + // ⛔ The #901 row: this raised before. + {"an unset HOME falls back to the passwd home rather than raising", + `require "etc" +ENV.delete("HOME") +p File.expand_path("~") == Etc.getpwuid(Process.uid).dir`, `true`}, + + {"the tail is kept across the fallback", + `ENV.delete("HOME") +p File.expand_path("~/x").end_with?("/x")`, `true`}, + + // ⛔ HOME is read on EVERY call, not captured at startup. Without this + // control the row above cannot be attributed to the passwd lookup: a + // value remembered from process start would answer identically. + {"HOME is consulted live, so the fallback is attributable", + pre + `ENV["HOME"] = "/first/home" +show +ENV["HOME"] = "/second/home" +show`, "\"/first/home\"\n\"/second/home\""}, + + // Dir.home answers getenv verbatim — it does NOT apply expand_path's + // absoluteness test. Same helper, different contract. + {"Dir.home answers a set HOME verbatim, empty included", + `ENV["HOME"] = "" +p Dir.home +ENV["HOME"] = "relative/home" +p Dir.home`, "\"\"\n\"relative/home\""}, + + {"~user is unaffected", + `begin + File.expand_path("~no_such_user_rbgo_xyz_123") +rescue ArgumentError => e + puts e.message +end`, `user no_such_user_rbgo_xyz_123 doesn't exist`}, + } { + t.Run(tc.name, func(t *testing.T) { + 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") } From 0c757f49601ac61aca6ea0777c0e755bd458e4a5 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 8 Nov 2026 09:33:59 +0100 Subject: [PATCH 2/2] Keep os.UserHomeDir for an ABSENT HOME: on Windows it is not the variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught two things in the previous commit, and only the first was the test. ## 1. The regression, which darwin cannot see var osLookupHome = func() (string, bool) { return os.LookupEnv("HOME") } On Windows HOME is usually not set at all: MRI consults HOME, then HOMEDRIVE+HOMEPATH, then USERPROFILE (file.c rb_default_home_dir), and os.UserHomeDir is exactly that chain per platform. Reading HOME alone would send a Windows user with no HOME to the PASSWD lookup instead of USERPROFILE. On darwin the two agree, so nothing local could have shown it. os.UserHomeDir is back for the absent case. LookupEnv is kept only for the one thing it adds and the reason it was introduced: telling HOME SET-BUT-EMPTY from HOME ABSENT, which os.UserHomeDir reports identically. So the chain is now: HOME if set (verbatim, even "") -> os.UserHomeDir -> passwd -> raise. ## 2. The test asserted a POSIX concept on every platform expand_path_home_test.go:99: run: ArgumentError: can't find user for -1 Process.uid is -1 on Windows and Etc.getpwuid(-1) raises. The row asserted WHERE an absent HOME resolves to, which is not assertable cross-platform because MRI's chain differs there. Split in two, because the two halves are not equally portable: an unset HOME does not RAISE -> every platform an unset HOME resolves to the passwd home -> POSIX only, skipped on Windows file_path_internal_test.go already marks its ~user row the same way, with the same reason in a comment. ## Not ours: compile on every target (openbsd) That job also failed, and it is not this change. The "Vet (or build) every GOARCH of openbsd" step has conclusion NULL — it never completed — the job ran 63 minutes (21:23:38 -> 22:26:39) against ~18m37s for the same job on an earlier run, and its log blob is gone, which is what a cancellation leaves rather than a compile error. Runner acquired, the three preceding steps green. go test ./... green locally. Refs #901 Co-Authored-By: Claude Opus 5 --- internal/vm/dir.go | 20 ++++- internal/vm/expand_path_home_test.go | 109 ++++++++++++++++++--------- 2 files changed, 94 insertions(+), 35 deletions(-) diff --git a/internal/vm/dir.go b/internal/vm/dir.go index 90fde212..33530c59 100644 --- a/internal/vm/dir.go +++ b/internal/vm/dir.go @@ -488,7 +488,25 @@ func (vm *VM) registerDirInstance(cDir *RClass) { // // So Dir.home answers getenv verbatim — empty or relative, unchecked — and // expand_path is the one that additionally demands an absolute path. -var osLookupHome = func() (string, bool) { return os.LookupEnv("HOME") } +// ⛔ 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 diff --git a/internal/vm/expand_path_home_test.go b/internal/vm/expand_path_home_test.go index 56ece5ab..7bef9a40 100644 --- a/internal/vm/expand_path_home_test.go +++ b/internal/vm/expand_path_home_test.go @@ -6,6 +6,7 @@ package vm import ( "os" + "runtime" "testing" ) @@ -48,54 +49,94 @@ rescue => e puts "#{e.class}: #{e.message}" end ` - for _, tc := range []struct{ name, src, want string }{ - {"an empty HOME is a non-absolute home, not an absent one", - pre + `ENV["HOME"] = "" -show`, `ArgumentError: non-absolute home`}, - - {"a relative HOME is rejected the same way", - pre + `ENV["HOME"] = "relative/home" -show`, `ArgumentError: non-absolute home`}, - - {"an absolute HOME is used", - pre + `ENV["HOME"] = "/an/absolute/home" -show`, `"/an/absolute/home"`}, - - // ⛔ The #901 row: this raised before. - {"an unset HOME falls back to the passwd home rather than raising", - `require "etc" + // ⛔ 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`, `true`}, - - {"the tail is kept across the fallback", - `ENV.delete("HOME") -p File.expand_path("~/x").end_with?("/x")`, `true`}, - +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 row above cannot be attributed to the passwd lookup: a + // control the fallback row cannot be attributed to the passwd lookup: a // value remembered from process start would answer identically. - {"HOME is consulted live, so the fallback is attributable", - pre + `ENV["HOME"] = "/first/home" + { + name: "HOME is consulted live, so the fallback is attributable", + src: pre + `ENV["HOME"] = "/first/home" show ENV["HOME"] = "/second/home" -show`, "\"/first/home\"\n\"/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. - {"Dir.home answers a set HOME verbatim, empty included", - `ENV["HOME"] = "" + { + name: "Dir.home answers a set HOME verbatim, empty included", + src: `ENV["HOME"] = "" p Dir.home ENV["HOME"] = "relative/home" -p Dir.home`, "\"\"\n\"relative/home\""}, - - {"~user is unaffected", - `begin +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`, `user no_such_user_rbgo_xyz_123 doesn't exist`}, +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) }