From b5273441d0fa0441a85d4bb053689b6ae1943e69 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 28 Jun 2026 01:38:26 +0200 Subject: [PATCH 1/3] stdlib: tmpdir + Process + singleton_methods (Puppet-boot batch) Drive the `require "puppet"` boot further on a from-scratch, CGO=0, pure-Go stdlib, clearing three successive blockers in one round (each re-probed against the puppetlabs/puppet checkout and verified differentially against MRI 4.0.5). Blockers cleared: 1. `LoadError: cannot load such file -- tmpdir` (puppet/file_system/uniquefile.rb). Added the tmpdir standard library (internal/vm/tmpdir.go): Dir.tmpdir (TMPDIR/TMP/TEMP env precedence then /tmp) and Dir.mktmpdir in both the return-value and block forms, with default/String/[prefix, suffix] name handling, an explicit base directory, MRI-shaped "-- " names, collision retry, and recursive cleanup of the directory after the block (even on a non-local exit). Layers onto the existing Dir class; wired through providedFeatures. 2. `NameError: uninitialized constant Process` (puppet/util/monkey_patches.rb: `Process.maxgroups = 1024`). Added a Process module (internal/vm/process.go): identity queries (pid, ppid, uid/euid/gid/egid, groups), clock_gettime with all unit symbols and the CLOCK_REALTIME/CLOCK_MONOTONIC constants, and the maxgroups accessor (read returns the platform cap, assignment accepted-and- ignored, matching MRI so Puppet's rescue works). All via Go's os and time, CGO=0. 3. `NoMethodError: undefined method 'singleton_methods' for Class` (monkey_patches.rb: `Dir.singleton_methods.include?(:exists?)`). Added Object#singleton_methods: class receivers return their class methods (inherited by default, own-only with a false argument); plain objects return their per-object singleton methods; receivers with none return []. After this round `require "puppet"` advances to puppet/ssl/openssl_loader.rb, which `require "openssl"` (followed by `net/https`). That is the next round's target and a large blocker: a crypto module plus a socket layer the runtime does not yet have. Stopping here to bank the batch. Tests: MRI-differential behavioural tests for every new method plus internal seam-injection tests for the unreachable Go branches (tmpdir env precedence, mkdir error/collision/exhaustion paths, the Process.groups failure path, the realtime-clock branch). Full suite green under -race, 100.0% coverage, gofmt + go vet clean. No opcode/bytecode/prelude changes, so the frozen prelude is unchanged. Co-Authored-By: Claude Opus 4.8 --- internal/vm/builtins.go | 61 +++++- internal/vm/process.go | 137 ++++++++++++ internal/vm/require.go | 1 + .../vm/stdlib_puppet_batch_internal_test.go | 201 ++++++++++++++++++ internal/vm/stdlib_puppet_batch_test.go | 175 +++++++++++++++ internal/vm/tmpdir.go | 136 ++++++++++++ 6 files changed, 705 insertions(+), 6 deletions(-) create mode 100644 internal/vm/process.go create mode 100644 internal/vm/stdlib_puppet_batch_internal_test.go create mode 100644 internal/vm/stdlib_puppet_batch_test.go create mode 100644 internal/vm/tmpdir.go diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 9a5b8ad1..ee047fe6 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -190,6 +190,14 @@ func (vm *VM) bootstrap() { } return &object.Array{Elems: vm.methodNames(c, true)} }) + vm.cObject.define("singleton_methods", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + // Default includes singleton methods inherited from the receiver's + // superclasses (a class's class methods); a false argument restricts to the + // receiver's own. For a plain object the singleton methods are those on its + // per-object singleton class. + all := len(args) == 0 || args[0].Truthy() + return &object.Array{Elems: vm.singletonMethodNames(self, all)} + }) vm.cObject.define("frozen?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { return object.Bool(isFrozen(self)) }) @@ -287,12 +295,14 @@ func (vm *VM) bootstrap() { exc("SyntaxError", "ScriptError") exc("LoadError", "ScriptError") - vm.registerFile() // needs the exception hierarchy (Errno::ENOENT < StandardError) - vm.registerIO() // IO/StringIO + $stdout/$stderr/$stdin (needs IOError/EOFError) - vm.registerDir() // Dir (reuses the Errno module set up by registerFile) - vm.registerZlib() // needs the exception hierarchy (Zlib::Error < StandardError) - vm.registerFiber() // needs the exception hierarchy (FiberError < StandardError) - vm.registerThread() // needs StandardError/StopIteration (ThreadError, ClosedQueueError) + vm.registerFile() // needs the exception hierarchy (Errno::ENOENT < StandardError) + vm.registerIO() // IO/StringIO + $stdout/$stderr/$stdin (needs IOError/EOFError) + vm.registerDir() // Dir (reuses the Errno module set up by registerFile) + vm.registerTmpdir() // Dir.tmpdir / Dir.mktmpdir (layers onto Dir; require "tmpdir") + vm.registerProcess() // Process module — identity + clock_gettime + vm.registerZlib() // needs the exception hierarchy (Zlib::Error < StandardError) + vm.registerFiber() // needs the exception hierarchy (FiberError < StandardError) + vm.registerThread() // needs StandardError/StopIteration (ThreadError, ClosedQueueError) // Exception instance protocol: initialize stores @message; message/to_s // return it (or the class name when unset). @@ -3714,6 +3724,45 @@ func (vm *VM) methodNames(c *RClass, all bool) []object.Value { return out } +// singletonMethodNames returns the singleton-method names of self as sorted +// Symbols. For a class/module these are its class methods (def self.foo) — walked +// up the superclass chain when all is true, restricted to its own when false. For +// any other object they are the methods on its per-object singleton class, if it +// has one. +func (vm *VM) singletonMethodNames(self object.Value, all bool) []object.Value { + seen := map[string]bool{} + var names []string + add := func(n string) { + if !seen[n] { + seen[n] = true + names = append(names, n) + } + } + collect := func(tbl map[string]*Method) { + for n, m := range tbl { + if !m.undefined { + add(n) + } + } + } + if c, ok := self.(*RClass); ok { + collect(c.smethods) + if all { + for s := c.super; s != nil; s = s.super { + collect(s.smethods) + } + } + } else if sc := vm.objSingleton(self); sc != nil { + collect(sc.methods) + } + sort.Strings(names) + out := make([]object.Value, len(names)) + for i, n := range names { + out[i] = object.Symbol(n) + } + return out +} + // constNameArg coerces a const_get/const_set/const_defined? name (a Symbol or // String) to its text, rejecting a name that does not begin with an uppercase // letter — as Ruby does. diff --git a/internal/vm/process.go b/internal/vm/process.go new file mode 100644 index 00000000..9b5127d4 --- /dev/null +++ b/internal/vm/process.go @@ -0,0 +1,137 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "os" + "time" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// processClockEpoch anchors CLOCK_MONOTONIC at the first reading so the value is +// a small, ever-increasing duration like MRI's monotonic clock (which is not +// tied to the wall clock). Go's time is internally monotonic but does not expose +// a raw counter, so subtracting a fixed start is the faithful equivalent. +var ( + processClockEpoch = time.Now() + processMonoNow = time.Now // seam for deterministic tests +) + +// registerProcess installs the Process module — the subset of MRI's Process that +// Puppet touches at boot and on the local-apply path: identity queries (pid, +// ppid, uid/euid/gid/egid, groups), the clock_gettime timer with its CLOCK_* +// constants, and the maxgroups accessor. Methods that need a real fork/exec +// model are deliberately left out; this is identity + timing, all CGO=0 via Go's +// os and time packages. +func (vm *VM) registerProcess() { + mod := newClass("Process", nil) + mod.isModule = true + vm.consts["Process"] = mod + def := func(name string, fn NativeFn) { mod.smethods[name] = &Method{name: name, owner: mod, native: fn} } + + def("pid", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Integer(os.Getpid()) + }) + def("ppid", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Integer(os.Getppid()) + }) + def("uid", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Integer(processUID()) + }) + def("euid", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Integer(processEUID()) + }) + def("gid", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Integer(processGID()) + }) + def("egid", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Integer(processEGID()) + }) + def("groups", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + gids, err := processGroups() + if err != nil { + return &object.Array{} + } + elems := make([]object.Value, len(gids)) + for i, g := range gids { + elems[i] = object.Integer(int64(g)) + } + return &object.Array{Elems: elems} + }) + // maxgroups is a platform tunable; reading it returns the conventional 16 cap + // and assigning it is accepted but ignored (the kernel limit is fixed), which + // matches MRI on the platforms we target. Puppet sets it inside a rescue. + def("maxgroups", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Integer(processMaxGroups) + }) + def("maxgroups=", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return args[0] // accepted-and-ignored; returns the assigned value + }) + + def("clock_gettime", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + clk := intArg(args[0]) + var d time.Duration + switch clk { + case clockMonotonic: + d = processMonoNow().Sub(processClockEpoch) + default: // CLOCK_REALTIME and any other clock -> wall clock since the epoch + now := processMonoNow() + d = time.Duration(now.UnixNano()) + } + return clockGettimeUnit(d, args) + }) + + mod.consts["CLOCK_REALTIME"] = object.Integer(clockRealtime) + vm.consts["Process::CLOCK_REALTIME"] = object.Integer(clockRealtime) + mod.consts["CLOCK_MONOTONIC"] = object.Integer(clockMonotonic) + vm.consts["Process::CLOCK_MONOTONIC"] = object.Integer(clockMonotonic) +} + +// Clock identifiers match the Linux/macOS values MRI exposes (CLOCK_REALTIME=0, +// CLOCK_MONOTONIC=6 on Darwin); only the relative ordering matters to callers, +// who pass the constant straight back. +const ( + clockRealtime = 0 + clockMonotonic = 6 + processMaxGroups = 16 +) + +// clockGettimeUnit converts a duration to the requested unit symbol (default +// :float_second), matching Process.clock_gettime's unit argument. +func clockGettimeUnit(d time.Duration, args []object.Value) object.Value { + unit := "float_second" + if len(args) > 1 { + if s, ok := args[1].(object.Symbol); ok { + unit = string(s) + } + } + switch unit { + case "nanosecond": + return object.Integer(d.Nanoseconds()) + case "microsecond": + return object.Integer(d.Microseconds()) + case "millisecond": + return object.Integer(d.Milliseconds()) + case "second": + return object.Integer(int64(d.Seconds())) + case "float_microsecond": + return object.Float(float64(d.Nanoseconds()) / 1e3) + case "float_millisecond": + return object.Float(float64(d.Nanoseconds()) / 1e6) + default: // float_second + return object.Float(d.Seconds()) + } +} + +// Identity seams over the os package so tests can drive every branch without +// depending on the host's actual uid/gid/groups. +var ( + processUID = os.Getuid + processEUID = os.Geteuid + processGID = os.Getgid + processEGID = os.Getegid + processGroups = os.Getgroups +) diff --git a/internal/vm/require.go b/internal/vm/require.go index 89186e00..b8d8db66 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -25,6 +25,7 @@ var providedFeatures = map[string]bool{ "stringio": true, "securerandom": true, "English": true, "ostruct": true, "benchmark": true, "forwardable": true, "delegate": true, "pathname": true, "uri": true, + "tmpdir": true, } // registerRequire installs Kernel#require and #require_relative — the runtime diff --git a/internal/vm/stdlib_puppet_batch_internal_test.go b/internal/vm/stdlib_puppet_batch_internal_test.go new file mode 100644 index 00000000..9bc20c0e --- /dev/null +++ b/internal/vm/stdlib_puppet_batch_internal_test.go @@ -0,0 +1,201 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// catchRaise runs fn, returning the RubyError class of any raise it triggers, or +// "" when fn returns normally. +func catchRaise(fn func()) (class string) { + defer func() { + if r := recover(); r != nil { + if re, ok := r.(RubyError); ok { + class = re.Class + } else { + panic(r) + } + } + }() + fn() + return "" +} + +// TestSystemTmpdirEnv covers the TMPDIR/TMP/TEMP precedence and the /tmp +// fallback, including the "set but not a directory" skip. +func TestSystemTmpdirEnv(t *testing.T) { + for _, name := range []string{"TMPDIR", "TMP", "TEMP"} { + t.Setenv(name, "") + } + // All unset -> /tmp fallback. + if got := systemTmpdir(); got != "/tmp" { + t.Fatalf("fallback: got %q, want /tmp", got) + } + // TMPDIR points at a real directory (with a trailing slash to exercise trim). + dir := t.TempDir() + t.Setenv("TMPDIR", dir+"/") + if got := systemTmpdir(); got != dir { + t.Fatalf("TMPDIR: got %q, want %q", got, dir) + } + // TMPDIR points at a non-directory -> skipped, falls through to TMP. + f := filepath.Join(dir, "afile") + if err := os.WriteFile(f, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + tmp := t.TempDir() + t.Setenv("TMPDIR", f) + t.Setenv("TMP", tmp) + if got := systemTmpdir(); got != tmp { + t.Fatalf("TMPDIR-nondir skip: got %q, want %q", got, tmp) + } +} + +// TestMakeTmpdirErrors covers makeTmpdir's non-collision error branches via os +// failures, plus the collision-retry and retry-exhaustion paths via the rand +// token seam. +func TestMakeTmpdirErrors(t *testing.T) { + // Missing base -> ENOENT. + if c := catchRaise(func() { makeTmpdir("/no-such-base-xyz", "p", "") }); c != "Errno::ENOENT" { + t.Fatalf("missing base: got %q, want Errno::ENOENT", c) + } + + // Unwritable base -> EACCES. Skip when running as root, where the write + // succeeds regardless of mode bits. + if os.Geteuid() != 0 { + ro := t.TempDir() + if err := os.Chmod(ro, 0o500); err != nil { + t.Fatal(err) + } + defer os.Chmod(ro, 0o700) + if c := catchRaise(func() { makeTmpdir(ro, "p", "") }); c != "Errno::EACCES" { + t.Fatalf("readonly base: got %q, want Errno::EACCES", c) + } + } + + // Collision then success: force a fixed token once (collides with a + // pre-created dir) then a fresh one. + base := t.TempDir() + stamp := time.Now().Format("20060102") + pid := os.Getpid() + collide := filepath.Join(base, "p"+stamp+"-"+itoaPid(pid)+"-aaaaaa") + if err := os.Mkdir(collide, 0o700); err != nil { + t.Fatal(err) + } + origNow, origPid, origTok := tmpNow, osGetpid, secureRandReadSwap(t) + tmpNow = func() time.Time { return mustParseDay(stamp) } + osGetpid = func() int { return pid } + defer func() { tmpNow, osGetpid = origNow, origPid; _ = origTok }() + + calls := 0 + secureRandRead = func(b []byte) (int, error) { + calls++ + if calls == 1 { + for i := range b { + b[i] = 0 // -> 'a' for every byte: token "aaaaaa", collides + } + } else { + for i := range b { + b[i] = byte(i + 1) // distinct token, succeeds + } + } + return len(b), nil + } + got := makeTmpdir(base, "p", "") + if calls < 2 { + t.Fatalf("expected a collision retry, got %d rand calls", calls) + } + if !dirExistsGo(got) { + t.Fatalf("makeTmpdir returned a non-existent dir %q", got) + } + + // Exhaustion: always return the colliding token -> EEXIST after the cap. + secureRandRead = func(b []byte) (int, error) { + for i := range b { + b[i] = 0 + } + return len(b), nil + } + if c := catchRaise(func() { makeTmpdir(base, "p", "") }); c != "Errno::EEXIST" { + t.Fatalf("exhaustion: got %q, want Errno::EEXIST", c) + } +} + +// TestProcessGroupsError covers the Process.groups failure branch via the seam. +func TestProcessGroupsError(t *testing.T) { + orig := processGroups + defer func() { processGroups = orig }() + processGroups = func() ([]int, error) { return nil, errors.New("boom") } + + vm := New(nil) + mod := vm.consts["Process"].(*RClass) + out := mod.smethods["groups"].native(vm, mod, nil, nil) + arr, ok := out.(*object.Array) + if !ok || len(arr.Elems) != 0 { + t.Fatalf("groups error: got %#v, want empty Array", out) + } +} + +// TestClockGettimeRealtimeBranch covers the default (non-monotonic) clock branch +// directly, which uses the wall clock. +func TestClockGettimeRealtimeBranch(t *testing.T) { + vm := New(nil) + mod := vm.consts["Process"].(*RClass) + out := mod.smethods["clock_gettime"].native(vm, mod, []object.Value{object.Integer(clockRealtime)}, nil) + if _, ok := out.(object.Float); !ok { + t.Fatalf("realtime clock: got %#v, want Float", out) + } +} + +// --- small helpers (kept package-local so the test stands alone) ------------- + +func itoaPid(p int) string { + if p == 0 { + return "0" + } + neg := p < 0 + if neg { + p = -p + } + var b [20]byte + i := len(b) + for p > 0 { + i-- + b[i] = byte('0' + p%10) + p /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} + +func mustParseDay(s string) time.Time { + tm, err := time.Parse("20060102", s) + if err != nil { + panic(err) + } + return tm +} + +func dirExistsGo(p string) bool { + fi, err := os.Stat(p) + return err == nil && fi.IsDir() +} + +// secureRandReadSwap saves the original secureRandRead and restores it at test +// end, returning a no-op token so the caller can defer through it. +func secureRandReadSwap(t *testing.T) struct{} { + orig := secureRandRead + t.Cleanup(func() { secureRandRead = orig }) + return struct{}{} +} diff --git a/internal/vm/stdlib_puppet_batch_test.go b/internal/vm/stdlib_puppet_batch_test.go new file mode 100644 index 00000000..a35e9f76 --- /dev/null +++ b/internal/vm/stdlib_puppet_batch_test.go @@ -0,0 +1,175 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm_test + +import ( + "strings" + "testing" +) + +// TestTmpdirStdlib exercises the tmpdir standard library (Dir.tmpdir, +// Dir.mktmpdir in both block and return-value forms, prefix/suffix and explicit +// base arguments). Each behaviour is verified against MRI 4.0.5. +func TestTmpdirStdlib(t *testing.T) { + tests := []struct{ name, src, want string }{ + // require returns true the first time, false thereafter. + {"require_first", `p require "tmpdir"`, "true\n"}, + {"require_second", `require "tmpdir"; p require "tmpdir"`, "false\n"}, + // Dir.tmpdir is a String. + {"tmpdir_class", `require "tmpdir"; p Dir.tmpdir.class`, "String\n"}, + // No-block form creates a real directory with the default "d" prefix. + {"mktmpdir_default", `require "tmpdir" +d = Dir.mktmpdir +ok = Dir.exist?(d) && File.basename(d).start_with?("d") +Dir.rmdir(d) +p ok`, "true\n"}, + // String prefix. + {"mktmpdir_prefix", `require "tmpdir" +d = Dir.mktmpdir("pre") +ok = File.basename(d).start_with?("pre") +Dir.rmdir(d) +p ok`, "true\n"}, + // [prefix, suffix] array. + {"mktmpdir_prefix_suffix", `require "tmpdir" +d = Dir.mktmpdir(["pre", ".suf"]) +b = File.basename(d) +ok = b.start_with?("pre") && b.end_with?(".suf") +Dir.rmdir(d) +p ok`, "true\n"}, + // nil prefix falls back to the default. + {"mktmpdir_nil_prefix", `require "tmpdir" +d = Dir.mktmpdir(nil) +ok = File.basename(d).start_with?("d") +Dir.rmdir(d) +p ok`, "true\n"}, + // Explicit base directory. + {"mktmpdir_base", `require "tmpdir" +d = Dir.mktmpdir("z", "/tmp") +ok = d.start_with?("/tmp") +Dir.rmdir(d) +p ok`, "true\n"}, + // nil base falls back to the system tmpdir. + {"mktmpdir_nil_base", `require "tmpdir" +d = Dir.mktmpdir("z", nil) +ok = Dir.exist?(d) +Dir.rmdir(d) +p ok`, "true\n"}, + // Block form returns the block value. + {"mktmpdir_block_return", `require "tmpdir"; p Dir.mktmpdir { |d| 99 }`, "99\n"}, + // Block form removes the (possibly non-empty) directory afterwards. + {"mktmpdir_block_cleanup", `require "tmpdir" +saved = nil +Dir.mktmpdir { |d| File.write(File.join(d, "x"), "hi"); saved = d } +p Dir.exist?(saved)`, "false\n"}, + // Block form with explicit base. + {"mktmpdir_block_base", `require "tmpdir" +saved = nil +Dir.mktmpdir("z", "/tmp") { |d| saved = d } +p saved.start_with?("/tmp")`, "true\n"}, + // Array prefix with only one element (no suffix). + {"mktmpdir_array_one", `require "tmpdir" +d = Dir.mktmpdir(["only"]) +ok = File.basename(d).start_with?("only") +Dir.rmdir(d) +p ok`, "true\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := runProg(t, tt.src, nil) + if err != nil { + t.Fatalf("src=%q: %v", tt.src, err) + } + if out != tt.want { + t.Fatalf("src=%q: got %q, want %q", tt.src, out, tt.want) + } + }) + } +} + +// TestTmpdirError covers the error path: a missing base raises Errno::ENOENT, +// matching MRI. +func TestTmpdirError(t *testing.T) { + class, _ := evalErr(t, `require "tmpdir"; Dir.mktmpdir(nil, "/no-such-base-dir-xyz")`) + if class != "Errno::ENOENT" { + t.Fatalf("got %q, want Errno::ENOENT", class) + } +} + +// TestProcessStdlib exercises the Process module (identity queries, clock_gettime +// with each unit, the maxgroups accessor and the CLOCK_* constants). Class-level +// assertions avoid depending on the host's actual ids; verified against MRI. +func TestProcessStdlib(t *testing.T) { + tests := []struct{ name, src, want string }{ + {"pid", `p Process.pid.class`, "Integer\n"}, + {"ppid", `p Process.ppid.class`, "Integer\n"}, + {"uid", `p Process.uid.class`, "Integer\n"}, + {"euid", `p Process.euid.class`, "Integer\n"}, + {"gid", `p Process.gid.class`, "Integer\n"}, + {"egid", `p Process.egid.class`, "Integer\n"}, + {"groups", `p Process.groups.class`, "Array\n"}, + {"maxgroups", `p Process.maxgroups`, "16\n"}, + {"maxgroups_set", `p(Process.maxgroups = 1024)`, "1024\n"}, + {"clock_realtime_const", `p Process::CLOCK_REALTIME`, "0\n"}, + {"clock_monotonic_const", `p Process::CLOCK_MONOTONIC`, "6\n"}, + {"clock_default", `p Process.clock_gettime(Process::CLOCK_MONOTONIC).class`, "Float\n"}, + {"clock_realtime", `p Process.clock_gettime(Process::CLOCK_REALTIME).class`, "Float\n"}, + {"clock_float_second", `p Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second).class`, "Float\n"}, + {"clock_second", `p Process.clock_gettime(Process::CLOCK_MONOTONIC, :second).class`, "Integer\n"}, + {"clock_millisecond", `p Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond).class`, "Integer\n"}, + {"clock_microsecond", `p Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond).class`, "Integer\n"}, + {"clock_nanosecond", `p Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond).class`, "Integer\n"}, + {"clock_float_millisecond", `p Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond).class`, "Float\n"}, + {"clock_float_microsecond", `p Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_microsecond).class`, "Float\n"}, + // Monotonic time advances non-negatively (sanity, not exactness). + {"clock_nonneg", `p(Process.clock_gettime(Process::CLOCK_MONOTONIC) >= 0)`, "true\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := runProg(t, tt.src, nil) + if err != nil { + t.Fatalf("src=%q: %v", tt.src, err) + } + if out != tt.want { + t.Fatalf("src=%q: got %q, want %q", tt.src, out, tt.want) + } + }) + } +} + +// TestSingletonMethods covers Object#singleton_methods for class receivers (class +// methods, inherited by default, own-only with a false argument), plain-object +// receivers (per-object singleton methods) and receivers with none. Verified +// against MRI. +func TestSingletonMethods(t *testing.T) { + tests := []struct{ name, src, want string }{ + {"class_inherited", `class A; def self.am; end; end +class B < A; def self.bm; end; end +p B.singleton_methods.sort`, "[:am, :bm]\n"}, + {"class_own", `class A; def self.am; end; end +class B < A; def self.bm; end; end +p B.singleton_methods(false).sort`, "[:bm]\n"}, + {"class_own_false_arg_nil", `class C; def self.cm; end; end +p C.singleton_methods(nil).sort`, "[:cm]\n"}, + {"object_singleton", `o = Object.new +def o.foo; end +p o.singleton_methods`, "[:foo]\n"}, + {"object_none", `p Object.new.singleton_methods`, "[]\n"}, + {"immediate_none", `p 5.singleton_methods`, "[]\n"}, + {"builtin_none", `p [].singleton_methods`, "[]\n"}, + // The Puppet idiom that drove this: Dir.singleton_methods.include?(:exists?). + {"dir_include", `p Dir.singleton_methods.include?(:exist?)`, "true\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := runProg(t, tt.src, nil) + if err != nil { + t.Fatalf("src=%q: %v", tt.src, err) + } + if !strings.HasPrefix(out, "") || out != tt.want { + t.Fatalf("src=%q: got %q, want %q", tt.src, out, tt.want) + } + }) + } +} diff --git a/internal/vm/tmpdir.go b/internal/vm/tmpdir.go new file mode 100644 index 00000000..c2b1f8a2 --- /dev/null +++ b/internal/vm/tmpdir.go @@ -0,0 +1,136 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "fmt" + "os" + "path" + "strings" + "time" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerTmpdir installs the tmpdir standard library — the Dir.tmpdir and +// Dir.mktmpdir class methods added by `require "tmpdir"`. Dir already exists +// (registerDir); this layers the two extra singleton methods on top, matching +// MRI's stdlib/tmpdir.rb behaviour. It is wired through providedFeatures so the +// require returns true on the first call and false thereafter. +func (vm *VM) registerTmpdir() { + cDir := vm.consts["Dir"].(*RClass) + def := func(name string, fn NativeFn) { cDir.smethods[name] = &Method{name: name, owner: cDir, native: fn} } + + def("tmpdir", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(toSlash(systemTmpdir())) + }) + + def("mktmpdir", func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value { + prefix, suffix := tmpdirPrefixSuffix(args) + base := systemTmpdir() + if len(args) > 1 { + if _, isNil := args[1].(object.Nil); !isNil { + base = strArg(args[1]) + } + } + dir := makeTmpdir(base, prefix, suffix) + if blk == nil { + return object.NewString(toSlash(dir)) + } + // Block form yields the path and removes the tree afterwards, even on a + // non-local exit, returning the block's value (MRI semantics). + defer os.RemoveAll(dir) + return vm.callBlock(blk, []object.Value{object.NewString(toSlash(dir))}) + }) +} + +// systemTmpdir returns the first usable temporary directory, honouring the +// TMPDIR, TMP and TEMP environment variables before falling back to /tmp, like +// MRI's Dir.tmpdir. ENV in the runtime is the process environment, so the lookup +// is os.Getenv directly. +func systemTmpdir() string { + for _, name := range []string{"TMPDIR", "TMP", "TEMP"} { + if v := os.Getenv(name); v != "" { + if fi, err := os.Stat(v); err == nil && fi.IsDir() { + return strings.TrimRight(v, "/") + } + } + } + return "/tmp" +} + +// tmpdirPrefixSuffix decodes the first mktmpdir argument: nil or absent -> +// default prefix "d" and empty suffix; a String -> that prefix; a two-element +// Array [prefix, suffix] -> both parts. Matches MRI's prefix_suffix handling. +func tmpdirPrefixSuffix(args []object.Value) (prefix, suffix string) { + if len(args) == 0 { + return "d", "" + } + switch a := args[0].(type) { + case object.Nil: + return "d", "" + case *object.Array: + if len(a.Elems) > 0 { + prefix = strArg(a.Elems[0]) + } + if len(a.Elems) > 1 { + suffix = strArg(a.Elems[1]) + } + return prefix, suffix + default: + return strArg(args[0]), "" + } +} + +// tmpRandRetries bounds the unique-name attempts; a collision after this many +// tries indicates a broken temp directory rather than ordinary contention. +const tmpRandRetries = 100 + +// makeTmpdir creates a fresh directory under base named like MRI's +// "--", retrying on collision. It raises +// the same Errno errors as Dir.mkdir when the base is unwritable or missing. +func makeTmpdir(base, prefix, suffix string) string { + stamp := tmpNow().Format("20060102") + pid := osGetpid() + var lastErr error + for i := 0; i < tmpRandRetries; i++ { + name := fmt.Sprintf("%s%s-%d-%s%s", prefix, stamp, pid, tmpRandToken(), suffix) + full := path.Join(base, name) + err := os.Mkdir(full, 0o700) + if err == nil { + return full + } + if os.IsExist(err) { + lastErr = err + continue // name collision — try a new random token + } + if os.IsNotExist(err) { + raise("Errno::ENOENT", "No such file or directory @ dir_s_mkdir - %s", full) + } + raise("Errno::EACCES", "Permission denied @ dir_s_mkdir - %s", full) + } + raise("Errno::EEXIST", "File exists @ dir_s_mkdir - %s", lastErr) + return "" // unreachable: raise never returns +} + +// tmpRandToken returns a short lowercase-alphanumeric token, matching the look of +// MRI's randomised suffix (base-36-ish). It uses the crypto/rand seam so the +// caller never has to seed a PRNG. +func tmpRandToken() string { + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + b := secureBytes(6) + out := make([]byte, len(b)) + for i, c := range b { + out[i] = alphabet[int(c)%len(alphabet)] + } + return string(out) +} + +// Seams over the clock and pid so the (otherwise time/pid-dependent) name format +// can be asserted deterministically in tests. +var ( + tmpNow = time.Now + osGetpid = os.Getpid +) From 3bd27bed948d0d21d75bfb710c28ddeddfea0d9e Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 28 Jun 2026 07:10:15 +0200 Subject: [PATCH 2/3] test: make tmpdir tests portable on Windows The Windows CI lane failed three tmpdir tests that assumed Unix semantics: - TestMakeTmpdirErrors exercised the EACCES mapping by chmod'ing a directory read-only, which is a no-op on Windows (and under root). Replace it with deterministic error injection through a new osMkdir seam so the mapping is covered identically on every platform. - The mktmpdir_base / mktmpdir_block_base Ruby cases hardcoded "/tmp" as the explicit base, which does not exist on Windows. Use Dir.tmpdir as the base instead. No behaviour change; coverage stays at 100.0% across internal packages. Co-Authored-By: Claude Opus 4.8 --- .../vm/stdlib_puppet_batch_internal_test.go | 21 +++++++++---------- internal/vm/stdlib_puppet_batch_test.go | 13 +++++++----- internal/vm/tmpdir.go | 3 ++- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/internal/vm/stdlib_puppet_batch_internal_test.go b/internal/vm/stdlib_puppet_batch_internal_test.go index 9bc20c0e..7942bba5 100644 --- a/internal/vm/stdlib_puppet_batch_internal_test.go +++ b/internal/vm/stdlib_puppet_batch_internal_test.go @@ -68,18 +68,17 @@ func TestMakeTmpdirErrors(t *testing.T) { t.Fatalf("missing base: got %q, want Errno::ENOENT", c) } - // Unwritable base -> EACCES. Skip when running as root, where the write - // succeeds regardless of mode bits. - if os.Geteuid() != 0 { - ro := t.TempDir() - if err := os.Chmod(ro, 0o500); err != nil { - t.Fatal(err) + // Unwritable base -> EACCES. Inject a permission error through the mkdir + // seam so the mapping is exercised deterministically on every platform; a + // chmod-readonly directory is a no-op on Windows and a no-op under root. + func() { + orig := osMkdir + defer func() { osMkdir = orig }() + osMkdir = func(string, os.FileMode) error { return os.ErrPermission } + if c := catchRaise(func() { makeTmpdir(t.TempDir(), "p", "") }); c != "Errno::EACCES" { + t.Fatalf("permission error: got %q, want Errno::EACCES", c) } - defer os.Chmod(ro, 0o700) - if c := catchRaise(func() { makeTmpdir(ro, "p", "") }); c != "Errno::EACCES" { - t.Fatalf("readonly base: got %q, want Errno::EACCES", c) - } - } + }() // Collision then success: force a fixed token once (collides with a // pre-created dir) then a fresh one. diff --git a/internal/vm/stdlib_puppet_batch_test.go b/internal/vm/stdlib_puppet_batch_test.go index a35e9f76..af1dd8f6 100644 --- a/internal/vm/stdlib_puppet_batch_test.go +++ b/internal/vm/stdlib_puppet_batch_test.go @@ -44,10 +44,12 @@ d = Dir.mktmpdir(nil) ok = File.basename(d).start_with?("d") Dir.rmdir(d) p ok`, "true\n"}, - // Explicit base directory. + // Explicit base directory (Dir.tmpdir exists on every platform; a + // hardcoded "/tmp" does not on Windows). {"mktmpdir_base", `require "tmpdir" -d = Dir.mktmpdir("z", "/tmp") -ok = d.start_with?("/tmp") +base = Dir.tmpdir +d = Dir.mktmpdir("z", base) +ok = d.start_with?(base) Dir.rmdir(d) p ok`, "true\n"}, // nil base falls back to the system tmpdir. @@ -65,9 +67,10 @@ Dir.mktmpdir { |d| File.write(File.join(d, "x"), "hi"); saved = d } p Dir.exist?(saved)`, "false\n"}, // Block form with explicit base. {"mktmpdir_block_base", `require "tmpdir" +base = Dir.tmpdir saved = nil -Dir.mktmpdir("z", "/tmp") { |d| saved = d } -p saved.start_with?("/tmp")`, "true\n"}, +Dir.mktmpdir("z", base) { |d| saved = d } +p saved.start_with?(base)`, "true\n"}, // Array prefix with only one element (no suffix). {"mktmpdir_array_one", `require "tmpdir" d = Dir.mktmpdir(["only"]) diff --git a/internal/vm/tmpdir.go b/internal/vm/tmpdir.go index c2b1f8a2..f90c4b40 100644 --- a/internal/vm/tmpdir.go +++ b/internal/vm/tmpdir.go @@ -98,7 +98,7 @@ func makeTmpdir(base, prefix, suffix string) string { for i := 0; i < tmpRandRetries; i++ { name := fmt.Sprintf("%s%s-%d-%s%s", prefix, stamp, pid, tmpRandToken(), suffix) full := path.Join(base, name) - err := os.Mkdir(full, 0o700) + err := osMkdir(full, 0o700) if err == nil { return full } @@ -133,4 +133,5 @@ func tmpRandToken() string { var ( tmpNow = time.Now osGetpid = os.Getpid + osMkdir = os.Mkdir ) From 26a98f4f48c98c8728fbc5ec2f30f61e39c6d40d Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 28 Jun 2026 07:17:24 +0200 Subject: [PATCH 3/3] test: cover Process.groups success branch portably On Windows os.Getgroups is unavailable and the real call errors, so the Ruby-level `Process.groups` test only exercised the error branch there; the array-building success branch was covered solely by the real call on Unix, leaving the merged coverage at 99.9% on the windows-latest lane. Add a seam-injected success test that builds the group Array deterministically on every platform, restoring the 100% gate. Co-Authored-By: Claude Opus 4.8 --- .../vm/stdlib_puppet_batch_internal_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/vm/stdlib_puppet_batch_internal_test.go b/internal/vm/stdlib_puppet_batch_internal_test.go index 7942bba5..e9f2d1d2 100644 --- a/internal/vm/stdlib_puppet_batch_internal_test.go +++ b/internal/vm/stdlib_puppet_batch_internal_test.go @@ -143,6 +143,24 @@ func TestProcessGroupsError(t *testing.T) { } } +// TestProcessGroupsSuccess covers the success branch (the supplementary-group +// list is built into an Array) deterministically through the seam, so it is +// exercised on platforms where os.Getgroups is unavailable (e.g. Windows, where +// the real call errors and only the error branch would otherwise run). +func TestProcessGroupsSuccess(t *testing.T) { + orig := processGroups + defer func() { processGroups = orig }() + processGroups = func() ([]int, error) { return []int{0, 20}, nil } + + vm := New(nil) + mod := vm.consts["Process"].(*RClass) + out := mod.smethods["groups"].native(vm, mod, nil, nil) + arr, ok := out.(*object.Array) + if !ok || len(arr.Elems) != 2 || arr.Elems[0] != object.Integer(0) || arr.Elems[1] != object.Integer(20) { + t.Fatalf("groups success: got %#v, want [0, 20]", out) + } +} + // TestClockGettimeRealtimeBranch covers the default (non-monotonic) clock branch // directly, which uses the wall clock. func TestClockGettimeRealtimeBranch(t *testing.T) {