diff --git a/internal/vm/errno.go b/internal/vm/errno.go index 374f7f83..e2200416 100644 --- a/internal/vm/errno.go +++ b/internal/vm/errno.go @@ -1,39 +1,207 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + package vm -import "syscall" +import ( + "strconv" + "strings" + "syscall" +) + +// This file holds the Errno name table behind Ruby's Errno::Exxx classes and the +// strerror text SystemCallError#message reports. +// +// MRI builds the table in Init_syserr (ruby/ruby v3_4_0 error.c:4204): +// +// rb_eNOERROR = setup_syserr(0, "NOERROR"); +// #define defined_error(name, num) set_syserr((num), (name)); +// #define undefined_error(name) rb_define_const(rb_mErrno, (name), rb_eNOERROR); +// #include "known_errors.inc" +// +// Two rules follow from set_syserr (error.c:3064), and rbgo reproduces both: +// +// - One CLASS PER ERRNO NUMBER, not per name. A name whose number already has a +// class becomes a second constant naming that same class, which is why +// Errno::EWOULDBLOCK.equal?(Errno::EAGAIN) is true wherever the two numbers +// agree, and why the class keeps the name that was registered first. +// - A name the platform's does not define is still a CONSTANT: MRI's +// undefined_error binds it to Errno::NOERROR (errno 0). So Errno.constants is +// the whole known_errors list on every platform — 158 entries — and only the +// numbers differ. -// errnoNumbers maps each registered Errno::Exxx name to its platform errno -// number, taken from the host's syscall table so Errno::ENOENT::Errno matches the -// value the host MRI reports (these differ across platforms — EAGAIN is 11 on -// Linux, 35 on the BSDs/macOS). SystemCallError#errno reads the value back off -// the class's Errno constant. +// errnoNumbers maps an Errno::Exxx name to its platform errno number. The values +// come from the host's syscall table rather than being fixed here, because they +// genuinely differ across platforms (EAGAIN is 11 on Linux and 35 on the +// BSDs/macOS) and Errno::EAGAIN::Errno must match the host MRI's. +// +// The set is the names Go's portable syscall package defines on EVERY GOOS rbgo +// builds for (darwin, linux, windows, wasip1). Go exposes the rest only under a +// build tag, so from rbgo's point of view they are undefined names and take +// MRI's undefined_error treatment below. On a platform whose libc does define +// one — macOS has EAUTH at 80 — rbgo reports 0 where MRI reports the real +// number; see issue: per-platform errno tables need a per-platform MRI witness. var errnoNumbers = map[string]int64{ - "ENOENT": int64(syscall.ENOENT), - "EEXIST": int64(syscall.EEXIST), - "EACCES": int64(syscall.EACCES), - "ENOTDIR": int64(syscall.ENOTDIR), - "EISDIR": int64(syscall.EISDIR), - "EPERM": int64(syscall.EPERM), - "EINVAL": int64(syscall.EINVAL), - "EAGAIN": int64(syscall.EAGAIN), - "EBADF": int64(syscall.EBADF), - "ESRCH": int64(syscall.ESRCH), - "EIO": int64(syscall.EIO), - "ENOSPC": int64(syscall.ENOSPC), - "EROFS": int64(syscall.EROFS), - "ENXIO": int64(syscall.ENXIO), - "ENOTEMPTY": int64(syscall.ENOTEMPTY), - "ECONNREFUSED": int64(syscall.ECONNREFUSED), - "ECONNRESET": int64(syscall.ECONNRESET), - "ETIMEDOUT": int64(syscall.ETIMEDOUT), - "EPIPE": int64(syscall.EPIPE), - "ELOOP": int64(syscall.ELOOP), - "ENAMETOOLONG": int64(syscall.ENAMETOOLONG), - "EADDRINUSE": int64(syscall.EADDRINUSE), - "EINTR": int64(syscall.EINTR), - "ECHILD": int64(syscall.ECHILD), - "ENOMEM": int64(syscall.ENOMEM), - "EXDEV": int64(syscall.EXDEV), - "EMFILE": int64(syscall.EMFILE), - "ENFILE": int64(syscall.ENFILE), + "E2BIG": int64(syscall.E2BIG), + "EACCES": int64(syscall.EACCES), + "EADDRINUSE": int64(syscall.EADDRINUSE), + "EADDRNOTAVAIL": int64(syscall.EADDRNOTAVAIL), + "EAFNOSUPPORT": int64(syscall.EAFNOSUPPORT), + "EAGAIN": int64(syscall.EAGAIN), + "EALREADY": int64(syscall.EALREADY), + "EBADF": int64(syscall.EBADF), + "EBADMSG": int64(syscall.EBADMSG), + "EBUSY": int64(syscall.EBUSY), + "ECANCELED": int64(syscall.ECANCELED), + "ECHILD": int64(syscall.ECHILD), + "ECONNABORTED": int64(syscall.ECONNABORTED), + "ECONNREFUSED": int64(syscall.ECONNREFUSED), + "ECONNRESET": int64(syscall.ECONNRESET), + "EDEADLK": int64(syscall.EDEADLK), + "EDESTADDRREQ": int64(syscall.EDESTADDRREQ), + "EDOM": int64(syscall.EDOM), + "EDQUOT": int64(syscall.EDQUOT), + "EEXIST": int64(syscall.EEXIST), + "EFAULT": int64(syscall.EFAULT), + "EFBIG": int64(syscall.EFBIG), + "EHOSTUNREACH": int64(syscall.EHOSTUNREACH), + "EIDRM": int64(syscall.EIDRM), + "EILSEQ": int64(syscall.EILSEQ), + "EINPROGRESS": int64(syscall.EINPROGRESS), + "EINTR": int64(syscall.EINTR), + "EINVAL": int64(syscall.EINVAL), + "EIO": int64(syscall.EIO), + "EISCONN": int64(syscall.EISCONN), + "EISDIR": int64(syscall.EISDIR), + "ELOOP": int64(syscall.ELOOP), + "EMFILE": int64(syscall.EMFILE), + "EMLINK": int64(syscall.EMLINK), + "EMSGSIZE": int64(syscall.EMSGSIZE), + "EMULTIHOP": int64(syscall.EMULTIHOP), + "ENAMETOOLONG": int64(syscall.ENAMETOOLONG), + "ENETDOWN": int64(syscall.ENETDOWN), + "ENETRESET": int64(syscall.ENETRESET), + "ENETUNREACH": int64(syscall.ENETUNREACH), + "ENFILE": int64(syscall.ENFILE), + "ENOBUFS": int64(syscall.ENOBUFS), + "ENODEV": int64(syscall.ENODEV), + "ENOENT": int64(syscall.ENOENT), + "ENOEXEC": int64(syscall.ENOEXEC), + "ENOLCK": int64(syscall.ENOLCK), + "ENOLINK": int64(syscall.ENOLINK), + "ENOMEM": int64(syscall.ENOMEM), + "ENOMSG": int64(syscall.ENOMSG), + "ENOPROTOOPT": int64(syscall.ENOPROTOOPT), + "ENOSPC": int64(syscall.ENOSPC), + "ENOSYS": int64(syscall.ENOSYS), + "ENOTCONN": int64(syscall.ENOTCONN), + "ENOTDIR": int64(syscall.ENOTDIR), + "ENOTEMPTY": int64(syscall.ENOTEMPTY), + "ENOTRECOVERABLE": int64(syscall.ENOTRECOVERABLE), + "ENOTSOCK": int64(syscall.ENOTSOCK), + "ENOTSUP": int64(syscall.ENOTSUP), + "ENOTTY": int64(syscall.ENOTTY), + "ENXIO": int64(syscall.ENXIO), + "EOPNOTSUPP": int64(syscall.EOPNOTSUPP), + "EOVERFLOW": int64(syscall.EOVERFLOW), + "EOWNERDEAD": int64(syscall.EOWNERDEAD), + "EPERM": int64(syscall.EPERM), + "EPIPE": int64(syscall.EPIPE), + "EPROTO": int64(syscall.EPROTO), + "EPROTONOSUPPORT": int64(syscall.EPROTONOSUPPORT), + "EPROTOTYPE": int64(syscall.EPROTOTYPE), + "ERANGE": int64(syscall.ERANGE), + "EROFS": int64(syscall.EROFS), + "ESPIPE": int64(syscall.ESPIPE), + "ESRCH": int64(syscall.ESRCH), + "ESTALE": int64(syscall.ESTALE), + "ETIMEDOUT": int64(syscall.ETIMEDOUT), + "ETXTBSY": int64(syscall.ETXTBSY), + "EXDEV": int64(syscall.EXDEV), + // NOERROR is errno 0. MRI registers it first, so every name the platform does + // not define (errnoUndefinedNames) binds to this very class. + "NOERROR": 0, +} + +// errnoUndefinedNames are the remaining Errno constants of MRI's known_errors +// list. Go's portable syscall package carries no number for them, so they are +// registered the way MRI's undefined_error does it: as extra constants naming +// Errno::NOERROR. Keeping them means Errno.constants has MRI's full 158 entries, +// which core/exception/errno_spec.rb and system_call_error_spec.rb both lean on +// (the latter takes Errno.constants.size as an errno number no class can claim). +var errnoUndefinedNames = []string{ + "EADV", "EAUTH", "EBADARCH", "EBADE", + "EBADEXEC", "EBADFD", "EBADMACHO", "EBADR", + "EBADRPC", "EBADRQC", "EBADSLT", "EBFONT", + "ECAPMODE", "ECHRNG", "ECOMM", "EDEADLOCK", + "EDEVERR", "EDOOFUS", "EDOTDOT", "EFTYPE", + "EHOSTDOWN", "EHWPOISON", "EIPSEC", "EISNAM", + "EKEYEXPIRED", "EKEYREJECTED", "EKEYREVOKED", "EL2HLT", + "EL2NSYNC", "EL3HLT", "EL3RST", "ELAST", + "ELIBACC", "ELIBBAD", "ELIBEXEC", "ELIBMAX", + "ELIBSCN", "ELNRNG", "EMEDIUMTYPE", "ENAVAIL", + "ENEEDAUTH", "ENOANO", "ENOATTR", "ENOCSI", + "ENODATA", "ENOKEY", "ENOMEDIUM", "ENONET", + "ENOPKG", "ENOPOLICY", "ENOSR", "ENOSTR", + "ENOTBLK", "ENOTCAPABLE", "ENOTNAM", "ENOTUNIQ", + "EPFNOSUPPORT", "EPROCLIM", "EPROCUNAVAIL", "EPROGMISMATCH", + "EPROGUNAVAIL", "EPWROFF", "EQFULL", "EREMCHG", + "EREMOTE", "EREMOTEIO", "ERESTART", "ERFKILL", + "ERPCMISMATCH", "ESHLIBVERS", "ESHUTDOWN", "ESOCKTNOSUPPORT", + "ESRMNT", "ESTRPIPE", "ETIME", "ETOOMANYREFS", + "EUCLEAN", "EUNATCH", "EUSERS", "EXFULL", +} + +// errnoAliases are names Go's portable syscall package does not expose that are +// nevertheless NOT undefined: the platform defines them as a spelling of another +// errno. POSIX explicitly permits EWOULDBLOCK and EAGAIN to be the same value, +// and every platform rbgo builds for makes them so — darwin and the BSDs at 35, +// Linux at 11, Winsock's WSAEWOULDBLOCK at Go's syscall.EWOULDBLOCK, and WASI, +// whose errno enum has no separate EWOULDBLOCK at all. Registering EWOULDBLOCK +// as a second name for EAGAIN's number reproduces what MRI's set_syserr does +// with them, which core/exception/errno_spec.rb asserts directly. +var errnoAliases = map[string]string{"EWOULDBLOCK": "EAGAIN"} + +// errnoStrerror returns the message MRI's syserr_initialize builds from +// strerror(errno) (ruby/ruby v3_4_0 error.c:3128). Go's syscall.Errno.Error() +// carries the same table with a lower-case first letter ("invalid argument"), so +// the leading letter is restored; a number the table does not name comes back in +// Go's "errno N" placeholder form, which is replaced by the one the C library +// produces. Witnessed against MRI 4.0.5 on darwin: 22 -> "Invalid argument", +// 0 -> "Undefined error: 0", 2**28 -> "Unknown error: 268435456". +// +// The placeholder wording is darwin's. glibc words the same two "Unknown error +// 268435456" (no colon) and "Success" for errno 0, so rbgo reports darwin's +// phrasing on Linux; no spec asserts the text (ruby/spec only requires it to be +// [[:graph:]]+), and settling it properly needs a Linux MRI witness — see the +// per-platform errno issue. +func errnoStrerror(n int64) string { + if s, ok := errnoUnknownText(syscall.Errno(n).Error(), n); ok { + return s + } + return capitalizeASCII(syscall.Errno(n).Error()) +} + +// errnoUnknownText recognises Go's "errno N" placeholder — what +// syscall.Errno.Error() returns for a number its table does not name — and +// substitutes the C library's wording for it. errno 0 is "Undefined error: 0" +// rather than "Unknown error: 0"; both spellings are the host MRI's. +func errnoUnknownText(goText string, n int64) (string, bool) { + if !strings.HasPrefix(goText, "errno ") { + return "", false + } + if n == 0 { + return "Undefined error: 0", true + } + return "Unknown error: " + strconv.FormatInt(n, 10), true +} + +// capitalizeASCII upper-cases a leading ASCII letter, leaving everything else +// (including a non-ASCII first byte) untouched. Go's errno strings are ASCII. +func capitalizeASCII(s string) string { + if s == "" || s[0] < 'a' || s[0] > 'z' { + return s + } + return string(s[0]-('a'-'A')) + s[1:] } diff --git a/internal/vm/exception.go b/internal/vm/exception.go index c8f5c81b..374f1528 100644 --- a/internal/vm/exception.go +++ b/internal/vm/exception.go @@ -237,20 +237,7 @@ func (vm *VM) registerExceptionMethods(cException *RClass) { return getIvar(self, "@value") }) - // SystemCallError#errno: the platform errno of the Errno::* subclass — its - // class-level Errno constant (Errno::ENOENT::Errno). nil for a bare - // SystemCallError with no errno. - vm.consts["SystemCallError"].(*RClass).define("errno", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { - // Walk the class ancestry up to (but not including) Object, whose constant - // table is the top level and holds the unrelated Errno *module*. Each - // Errno::Exxx carries its own integer Errno constant. - for c := vm.classOf(self); c != nil && c != vm.cObject; c = c.super { - if e, ok := c.consts["Errno"]; ok { - return e - } - } - return object.NilV - }) + vm.registerSystemCallError() vm.registerBacktraceLocation() vm.registerThreadBacktrace() @@ -266,9 +253,14 @@ func (vm *VM) exceptionMessageArg(v object.Value) string { } // exceptionInspect renders Exception#inspect (see the method comment above). +// The message is taken through rb_obj_as_string(exc) — MRI's exc_inspect +// (ruby/ruby v3_4_0 error.c:1838) dispatches #to_s rather than reading the +// stored message, so a subclass that overrides #to_s changes what #inspect +// reports; a #to_s that does not return a String falls back to the object's own +// identity representation, as rb_any_to_s does. func (vm *VM) exceptionInspect(self object.Value) string { cls := vm.classOf(self).name - msg := vm.exceptionMessageText(self) + msg := vm.objAsString(self) if msg == "" { return cls } @@ -529,3 +521,159 @@ func sliceBacktraceFrames(vm *VM, full []object.Value, args []object.Value) ([]o } return res.(*object.Array).Elems, true } + +// errnoIvar holds a SystemCallError's error number. MRI stores it under the +// hidden id_errno (no @), so it does not show up in #instance_variables; rbgo has +// no hidden-ivar slot, so it follows the @__name__ convention used for the +// backtrace and cause. +const errnoIvar = "@__errno__" + +// registerSystemCallError installs SystemCallError's own protocol: #initialize +// (which both builds the message and decides which Errno::Exxx class the object +// ends up being), #errno and the .=== that makes `rescue Errno::EINVAL` match by +// error number rather than by class. Sources: ruby/ruby v3_4_0 error.c — +// syserr_initialize (error.c:3104), syserr_errno (error.c:3153) and syserr_eqq +// (error.c:3168). +func (vm *VM) registerSystemCallError() { + cls := vm.consts["SystemCallError"].(*RClass) + + cls.define("initialize", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + vm.syserrInitialize(self, args) + return object.NilV + }) + + // SystemCallError#errno is MRI's rb_attr_get(self, id_errno): the value + // #initialize stored, which is the argument AS GIVEN (SystemCallError.new("x", + // 2.9).errno is 2.9, even though the class lookup truncated it to 2). It is + // nil for a generic SystemCallError built with no error number. An Errno::Exxx + // raised internally never ran #initialize, so its class's own Errno constant + // is the fallback. + cls.define("errno", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + if e := getIvar(self, errnoIvar); e != object.NilV { + return e + } + return vm.classErrno(vm.classOf(self)) + }) + + // SystemCallError.=== matches by ERROR NUMBER, not by class: a generic + // SystemCallError matches any SystemCallError, and an Errno::Exxx matches + // anything whose #errno equals its own Errno constant. That is what lets a + // `rescue Errno::EINVAL` catch a SystemCallError.new("foo", EINVAL) that was + // never given EINVAL's class. + cls.smethods["==="] = &Method{name: "===", owner: cls, native: func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) != 1 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 1)", len(args)) + } + return object.Bool(vm.syserrEqq(self.(*RClass), args[0])) + }} +} + +// classErrno reads the Errno constant a class carries (Errno::ENOENT::Errno), +// walking up the ancestry — so a user subclass of Errno::ENOENT inherits it — and +// stopping before Object, whose constant table is the top level and holds the +// unrelated Errno *module*. It returns nil when no ancestor carries one. +func (vm *VM) classErrno(c *RClass) object.Value { + for ; c != nil && c != vm.cObject; c = c.super { + if e, ok := c.consts["Errno"]; ok { + return e + } + } + return object.NilV +} + +// syserrEqq is MRI's syserr_eqq (ruby/ruby v3_4_0 error.c:3168): a non- +// SystemCallError that does not even answer #errno never matches; a generic +// SystemCallError receiver matches every SystemCallError; otherwise the +// argument's #errno is compared with the receiver's own Errno constant. +func (vm *VM) syserrEqq(recv *RClass, exc object.Value) bool { + generic := vm.consts["SystemCallError"].(*RClass) + if !classIsA(vm.classOf(exc), generic) { + if !vm.respondsTo(exc, "errno") { + return false + } + } else if recv == generic { + return true + } + num := getIvar(exc, errnoIvar) + if num == object.NilV { + num = vm.send(exc, "errno", nil, nil) + } + e := vm.classErrno(recv) + if a, ok := num.(object.Integer); ok { + b, ok := e.(object.Integer) + return ok && a == b + } + return vm.send(num, "==", []object.Value{e}, nil).Truthy() +} + +// syserrInitialize is MRI's syserr_initialize (ruby/ruby v3_4_0 error.c:3104). +// Two shapes share one method: +// +// - On SystemCallError itself: (msg, errno = nil, func = nil), except that a +// lone Integer argument IS the errno. When the number has a registered class +// the object BECOMES an instance of it — MRI rewrites the receiver's class in +// place (RBASIC_SET_CLASS), which is why SystemCallError.new(Errno::EINVAL:: +// Errno).instance_of?(Errno::EINVAL) holds. +// - On a subclass (Errno::EINVAL, or a user subclass of one): (msg = nil, +// func = nil), with the error number taken from the class's Errno constant. +// +// The message is then strerror(errno) — "unknown error" when there is no number — +// with " @ " and " - " appended when those were given, so +// Errno::EINVAL.new("custom", "loc").message is +// "Invalid argument @ loc - custom". +func (vm *VM) syserrInitialize(self object.Value, args []object.Value) { + var mesg, errVal, fn object.Value = object.NilV, object.NilV, object.NilV + if vm.classOf(self) == vm.consts["SystemCallError"].(*RClass) { + if len(args) < 1 || len(args) > 3 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 1..3)", len(args)) + } + mesg, errVal, fn = args[0], argAt(args, 1), argAt(args, 2) + // A single Integer argument is the errno, not the message. MRI tests + // FIXNUM_P, so a Float or a String stays the message (and a Float then + // fails StringValue below, as it does in MRI). + if i, ok := args[0].(object.Integer); ok && len(args) == 1 { + mesg, errVal = object.NilV, i + } + if !object.IsNil(errVal) { + vm.becomeErrnoClass(self, coerceInt(vm, errVal)) + } + } else { + if len(args) > 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 0..2)", len(args)) + } + mesg, fn = argAt(args, 0), argAt(args, 1) + errVal = vm.classErrno(vm.classOf(self)) + } + + msg := "unknown error" + if !object.IsNil(errVal) { + msg = errnoStrerror(coerceInt(vm, errVal)) + } + if !object.IsNil(mesg) { + // StringValue(mesg) first: a Symbol message is a TypeError even though the + // location is appended before it in the result. + text := vm.coerceFormatString(mesg) + if !object.IsNil(fn) { + msg += " @ " + vm.send(fn, "to_s", nil, nil).ToS() + } + msg += " - " + text + } + setIvar(self, "@message", object.NewString(msg)) + setIvar(self, errnoIvar, errVal) +} + +// becomeErrnoClass rewrites self's class to the Errno::Exxx registered for errno +// number n, the way MRI's syserr_initialize does with RBASIC_SET_CLASS. A number +// no class claims leaves the object a generic SystemCallError, and a receiver +// that is not a plain object is MRI's "invalid instance type" TypeError. +func (vm *VM) becomeErrnoClass(self object.Value, n int64) { + c := vm.errnoClass(n) + if c == nil { + return + } + o, ok := self.(*RObject) + if !ok { + raise("TypeError", "invalid instance type") + } + o.class = c +} diff --git a/internal/vm/file.go b/internal/vm/file.go index a746c7b8..6b033f4c 100644 --- a/internal/vm/file.go +++ b/internal/vm/file.go @@ -6,6 +6,7 @@ import ( "os/user" "path" // always '/'-separated, as Ruby's File is — not path/filepath "path/filepath" // OS-native, only for symlink resolution (File.realpath) + "sort" "strings" "syscall" stdtime "time" @@ -40,24 +41,7 @@ func (vm *VM) registerFile() { errno := newClass("Errno", nil) errno.isModule = true vm.consts["Errno"] = errno - // Each Errno::Exxx is a SystemCallError subclass, registered both scoped (for - // `rescue Errno::ENOENT`) and flat (so an internal raise resolves the name). - // The set covers the common POSIX errnos that file/IO code and libraries such - // as Puppet rescue; an internal raise still uses the name string directly. - for _, name := range []string{ - "ENOENT", "EEXIST", "EACCES", "ENOTDIR", "EISDIR", "EPERM", "EINVAL", - "EAGAIN", "EBADF", "ESRCH", "EIO", "ENOSPC", "EROFS", "ENXIO", "ENOTEMPTY", - "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "EPIPE", "ELOOP", "ENAMETOOLONG", - "EADDRINUSE", "EINTR", "ECHILD", "ENOMEM", "EXDEV", "EMFILE", "ENFILE", - } { - c := newClass("Errno::"+name, syscallErr) - // Each Errno::Exxx carries its platform errno number as the class constant - // Errno::ENOENT::Errno (2 on this host), which SystemCallError#errno reads - // back. Values come from the host syscall table so they match the host MRI. - c.consts["Errno"] = object.IntValue(errnoNumbers[name]) - errno.consts[name] = c - vm.consts["Errno::"+name] = c - } + vm.registerErrnoClasses(syscallErr, errno) cFile := newClass("File", vm.cObject) vm.consts["File"] = cFile @@ -208,22 +192,24 @@ func (vm *VM) registerFile() { def("exist?", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { oneArg(args) - _, err := os.Stat(pathArg(vm, args[0])) + _, err := os.Stat(vm.statPathArg(args[0])) return object.Bool(err == nil) }) def("file?", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { oneArg(args) - fi, err := os.Stat(pathArg(vm, args[0])) + fi, err := os.Stat(vm.statPathArg(args[0])) return object.Bool(err == nil && fi.Mode().IsRegular()) }) def("directory?", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - fi, err := os.Stat(pathArg(vm, args[0])) + oneArg(args) + fi, err := os.Stat(vm.statPathArg(args[0])) return object.Bool(err == nil && fi.IsDir()) }) // File.symlink? reports whether the path is a symbolic link. Like MRI it uses // lstat (does not follow the link) and returns false for a missing path or a // non-symlink, rather than raising. def("symlink?", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + oneArg(args) fi, err := os.Lstat(pathArg(vm, args[0])) return object.Bool(err == nil && fi.Mode()&os.ModeSymlink != 0) }) @@ -247,18 +233,19 @@ func (vm *VM) registerFile() { // Errno::ENOENT is raised. An optional second argument is the base directory // a relative path is resolved against. def("realpath", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - p := vm.fileExpand(vm.filePathArg(args[0]), args[1:], true) - resolved, err := filepath.EvalSymlinks(p) + raw := vm.filePathArg(args[0]) + p := vm.fileExpand(raw, args[1:], true) + resolved, err := realpathResolve(p, true, strings.HasSuffix(raw, "/")) if err != nil { - raiseRealpathErr(p) + raiseRealpathErr(err, p) } - return object.NewString(toSlash(resolved)) + return object.NewString(resolved) }) // File.read / File.write / File.binread / File.binwrite are the same class // methods as IO's, installed on both tables by registerIOClassMethods once the // IO class exists (registerIO runs after registerFile). def("size", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - p := pathArg(vm, args[0]) + p := vm.statPathArg(args[0]) fi, err := os.Stat(p) if err != nil { raise("Errno::ENOENT", "No such file or directory @ rb_file_s_stat - %s", p) @@ -398,6 +385,7 @@ func (vm *VM) registerFile() { // false for a missing path rather than raising (MRI's File.). access := func(want int) NativeFn { return func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + oneArg(args) p := pathArg(vm, args[0]) fi, err := osStat(p) if err != nil { @@ -412,6 +400,7 @@ func (vm *VM) registerFile() { // The *_real? predicates consult the process's real (not effective) identity. realAccess := func(want int) NativeFn { return func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + oneArg(args) p := pathArg(vm, args[0]) fi, err := osStat(p) if err != nil { @@ -430,14 +419,16 @@ func (vm *VM) registerFile() { // return their falsey value for a missing path rather than raising (unlike // File.size, which raises Errno::ENOENT). def("size?", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - fi, err := os.Stat(pathArg(vm, args[0])) + oneArg(args) + fi, err := os.Stat(vm.statPathArg(args[0])) if err != nil || fi.Size() == 0 { return object.NilV } return object.IntValue(fi.Size()) }) zero := func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - fi, err := os.Stat(pathArg(vm, args[0])) + oneArg(args) + fi, err := os.Stat(vm.statPathArg(args[0])) return object.Bool(err == nil && fi.Size() == 0) } def("zero?", zero) @@ -449,7 +440,8 @@ func (vm *VM) registerFile() { // missing path (MRI's File.pipe?/socket?/…). statTest wraps the stat-and-test. statTest := func(pred func(*FileStat) bool) NativeFn { return func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - p := pathArg(vm, args[0]) + oneArg(args) + p := vm.statPathArg(args[0]) fi, err := os.Stat(p) if err != nil { return object.Bool(false) @@ -477,7 +469,8 @@ func (vm *VM) registerFile() { // filestat_windows.go), so world_writable? is nil on Windows as MRI reports. worldPerm := func(bit int64) NativeFn { return func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - fi, err := os.Stat(pathArg(vm, args[0])) + oneArg(args) + fi, err := os.Stat(vm.statPathArg(args[0])) if err != nil { return object.NilV } @@ -496,8 +489,8 @@ func (vm *VM) registerFile() { if len(args) != 2 { raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) } - fi1, err1 := os.Stat(pathArg(vm, args[0])) - fi2, err2 := os.Stat(pathArg(vm, args[1])) + fi1, err1 := os.Stat(vm.statPathArg(args[0])) + fi2, err2 := os.Stat(vm.statPathArg(args[1])) return object.Bool(err1 == nil && err2 == nil && os.SameFile(fi1, fi2)) }) @@ -574,7 +567,14 @@ func (vm *VM) registerFile() { // symlink is resolved where possible, and an absent leaf is joined onto the // resolved directory. A missing intermediate directory raises Errno::ENOENT. def("realdirpath", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - return object.NewString(realdirpath(vm.fileExpand(vm.filePathArg(args[0]), args[1:], true))) + raw := vm.filePathArg(args[0]) + resolved, err := realpathResolve(vm.fileExpand(raw, args[1:], true), false, strings.HasSuffix(raw, "/")) + if err != nil { + // realdirpath IS the emulation, so every failure keeps realpath_rec's + // own name and the component that failed. + raise(err.class, "%s @ realpath_rec - %s", err.message, err.path) + } + return object.NewString(resolved) }) // File.path returns the string (or #to_path) form of its argument unchanged — // no expansion, matching MRI. @@ -602,45 +602,19 @@ func raiseLinkErr(err error, marker, src string) { raise("Errno::ENOENT", "No such file or directory @ %s - %s", marker, src) } -// realdirpath resolves an already-expanded absolute path's symlinks, tolerating a -// non-existent leaf: if the whole path resolves it is returned, otherwise the -// parent directory is resolved and the leaf re-joined. A missing parent raises -// Errno::ENOENT. -func realdirpath(p string) string { - resolved, err := filepath.EvalSymlinks(filepath.FromSlash(p)) - if err == nil { - return toSlash(resolved) - } - // A symlink loop is Errno::ELOOP even though realdirpath tolerates an absent - // leaf — the loop is a hard resolution failure, not a missing final component. - if isSymlinkLoop(p) { - raise("Errno::ELOOP", "Too many levels of symbolic links @ realpath_rec - %s", p) - } - dir, base := rubyDirname(p), rubyBasename(p) - resolvedDir, err := filepath.EvalSymlinks(filepath.FromSlash(dir)) - if err != nil { - raise("Errno::ENOENT", "No such file or directory @ realpath_rec - %s", dir) - } - return toSlash(filepath.Join(resolvedDir, base)) -} - -// isSymlinkLoop reports whether resolving p fails with ELOOP (a symlink cycle). -// filepath.EvalSymlinks returns a bare "too many links" error that does NOT wrap -// syscall.ELOOP, so the real errno is recovered by re-stating the path (os.Stat -// follows the link and surfaces the kernel's ELOOP). -func isSymlinkLoop(p string) bool { - _, err := osStat(filepath.FromSlash(p)) - return errors.Is(err, syscall.ELOOP) -} - -// raiseRealpathErr maps a File.realpath EvalSymlinks failure to the MRI errno: a -// symbolic-link loop is Errno::ELOOP, anything else (a missing component) is -// Errno::ENOENT. -func raiseRealpathErr(p string) { - if isSymlinkLoop(p) { - raise("Errno::ELOOP", "Too many levels of symbolic links @ realpath_rec - %s", p) +// raiseRealpathErr reports a File.realpath failure with MRI's wording. MRI does +// NOT reach its own emulation first there: rb_check_realpath_internal +// (ruby/ruby v3_4_0 file.c:4554) calls realpath(3) and, when that fails with +// anything but ENOTDIR (or an ENOENT on a path that does exist), raises +// rb_sys_fail_path against the WHOLE argument under its own function name. Only +// ENOTDIR falls through to the emulation, which is why a path that walks through +// a regular file is the one File.realpath error that names realpath_rec and the +// offending component instead. +func raiseRealpathErr(err *realpathErr, arg string) { + if err.class == "Errno::ENOTDIR" { + raise(err.class, "%s @ realpath_rec - %s", err.message, err.path) } - raise("Errno::ENOENT", "No such file or directory @ realpath_rec - %s", p) + raise(err.class, "%s @ rb_check_realpath_internal - %s", err.message, arg) } // oneArg enforces the single-argument arity MRI's one-path File predicates check @@ -994,3 +968,111 @@ func timeArgUnixOrNow(v object.Value) int64 { } return timeArgUnix(v) } + +// registerErrnoClasses installs the Errno::Exxx hierarchy under the Errno module +// the way MRI's Init_syserr does (ruby/ruby v3_4_0 error.c:4204, set_syserr at +// error.c:3064): one SystemCallError subclass PER ERRNO NUMBER, and a constant +// per name — so several names can denote one class. Each class carries its number +// as its own Errno constant (Errno::ENOENT::Errno), which SystemCallError#errno +// and SystemCallError.=== read back. Every class is registered under the flat +// "Errno::ENOENT" key too, so the internal raise() resolves it by name. +// +// Registration order fixes which name a shared class takes (MRI names it after +// the first constant registered for the number), so the numbered names are +// installed in sorted order before the aliases, matching MRI's alphabetically +// generated known_errors.inc. +func (vm *VM) registerErrnoClasses(syscallErr, errno *RClass) { + byNumber := make(map[int64]*RClass, len(errnoNumbers)) + bind := func(name string, c *RClass) { + errno.consts[name] = c + vm.consts["Errno::"+name] = c + } + names := make([]string, 0, len(errnoNumbers)) + for name := range errnoNumbers { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + n := errnoNumbers[name] + c, seen := byNumber[n] + if !seen { + c = newClass("Errno::"+name, syscallErr) + c.consts["Errno"] = object.IntValue(n) + byNumber[n] = c + } + bind(name, c) + } + // A name the platform spells differently for an errno it already has (only + // EWOULDBLOCK today) becomes a second constant on that very class, which is + // what makes Errno::EWOULDBLOCK.equal?(Errno::EAGAIN) hold. + for name, of := range errnoAliases { + if c, ok := errno.consts[of].(*RClass); ok { + bind(name, c) + } + } + // MRI's undefined_error: a name with no number on this platform is still a + // constant, naming Errno::NOERROR. + noerror := errno.consts["NOERROR"].(*RClass) + for _, name := range errnoUndefinedNames { + bind(name, noerror) + } +} + +// errnoClass returns the Errno::Exxx class registered for errno number n, or nil +// when no class claims it — MRI's st_lookup(syserr_tbl, n) (ruby/ruby v3_4_0 +// error.c:3080), which is what decides whether SystemCallError.new(n) becomes a +// specific Errno subclass or stays a generic SystemCallError. The lookup reads +// the classes' own Errno constants rather than a parallel table, so it cannot +// fall out of step with the constants Ruby code sees. +func (vm *VM) errnoClass(n int64) *RClass { + mod, ok := vm.consts["Errno"].(*RClass) + if !ok { + return nil + } + for _, v := range mod.consts { + c, ok := v.(*RClass) + if !ok { + continue + } + if e, ok := c.consts["Errno"].(object.Integer); ok && int64(e) == n { + return c + } + } + return nil +} + +// statPathArg resolves an argument of the stat-predicate family the way MRI's +// rb_stat does (ruby/ruby v3_4_0 file.c:1304): an OPEN STREAM is accepted as +// well as a path, and any object that answers #to_io is converted first — +// +// tmp = rb_check_convert_type_with_id(file, T_FILE, "IO", idTo_io); +// if (!NIL_P(tmp)) { ... fstat_without_gvl(fptr, st); } +// else { FilePathValue(file); ... stat_without_gvl(...); } +// +// so File.directory?(io), File.size(io) and FileTest.directory?(to_io_object) +// all work. MRI fstats the descriptor; rbgo's File streams are buffered by +// path, so the stream's path is what gets stated — the same answer except for a +// stream whose file has since been unlinked. +// +// Only the rb_stat family takes this: File.readable?/writable?/executable? and +// the *_real? predicates go through rb_eaccess, which calls FilePathValue +// directly and so rejects an IO, and symlink?/ftype lstat a path. +func (vm *VM) statPathArg(v object.Value) string { + if o, ok := v.(*IOObj); ok { + return o.path + } + if vm.respondsToDynamic(v, "to_io") { + // rb_check_convert_type_with_id does not silently ignore a #to_io that + // answers something that is not an IO: it is the "can't convert X to IO + // (X#to_io gives Y)" TypeError, the same shape every rb_convert_type + // failure takes. + r := vm.send(v, "to_io", nil, nil) + o, ok := r.(*IOObj) + if !ok { + raise("TypeError", "can't convert %s to IO (%s#to_io gives %s)", + vm.classOf(v).name, vm.classOf(v).name, vm.classOf(r).name) + } + return o.path + } + return pathArg(vm, v) +} diff --git a/internal/vm/file_links_internal_test.go b/internal/vm/file_links_internal_test.go index ebf94096..03731739 100644 --- a/internal/vm/file_links_internal_test.go +++ b/internal/vm/file_links_internal_test.go @@ -7,6 +7,7 @@ package vm import ( "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -72,8 +73,19 @@ func TestFileRealdirpath(t *testing.T) { if err != nil { t.Fatal(err) } - if got := runFS(t, `p File.realdirpath("`+slash(f)+`")`); got != "\""+slash(canon)+"\"\n" { - t.Errorf("realdirpath(existing): got %q want %q", got, slash(canon)) + // Go's filepath.EvalSymlinks is the oracle for symlink resolution, but on + // Windows it also expands an 8.3 short name (C:/Users/RUNNER~1 -> + // C:/Users/runneradmin) and realpath_rec does not. Whether MRI expands it + // there is unwitnessed — there is no Windows MRI on this host — so the two + // canonicalisers are only compared where they agree by construction, and + // Windows asserts the properties that hold either way. See issue #636. + res := strings.TrimSpace(runFS(t, `p File.realdirpath("`+slash(f)+`")`)) + if runtime.GOOS == "windows" { + if !strings.HasPrefix(res, `"`) || !strings.HasSuffix(res, `/file"`) || strings.Contains(res, `\`) { + t.Errorf("realdirpath(existing) on windows: got %s, want a forward-slashed path naming the file", res) + } + } else if want := "\"" + slash(canon) + "\""; res != want { + t.Errorf("realdirpath(existing): got %s want %s", res, want) } // Leaf missing: the parent resolves and the leaf is rejoined. leaf := slash(real) + "/newleaf" diff --git a/internal/vm/filestat.go b/internal/vm/filestat.go index 467f3669..dab2eb2a 100644 --- a/internal/vm/filestat.go +++ b/internal/vm/filestat.go @@ -7,6 +7,9 @@ package vm import ( "io/fs" "os" + "reflect" + "strconv" + "strings" "github.com/go-embedded-ruby/ruby/internal/object" ) @@ -20,6 +23,14 @@ type statFields struct { uid, gid, ino, dev, nlink, blksize int64 rdev, blocks int64 hasSys bool + // atime/ctime/btime are the stat struct's access, status-change and birth + // times in whole seconds, filled by statTimestamps rather than by the + // build-tagged statSys: the FIELDS are spelled differently per platform + // (Linux's Atim vs the BSDs' Atimespec) but reading them is one shared rule, + // so it lives in the shared file where the POSIX lanes can cover it. + // hasBtime is false where the platform has no creation time at all. + atime, ctime, btime int64 + hasBtime bool } // FileStat is the Ruby File::Stat value: a thin shell over Go's fs.FileInfo plus @@ -31,9 +42,52 @@ type FileStat struct { path string } -func (s *FileStat) ToS() string { return "#" } -func (s *FileStat) Inspect() string { return "#" } -func (s *FileStat) Truthy() bool { return true } +func (s *FileStat) ToS() string { return "#" } +func (s *FileStat) Truthy() bool { return true } + +// Inspect renders File::Stat#inspect, MRI's rb_stat_inspect (ruby/ruby v3_4_0 +// file.c:1080): the member list in that exact order, dev and rdev in +// hexadecimal, mode in octal with a leading zero, and the rest through their +// own inspect form. birthtime appears only where the platform has a creation +// time, which is MRI's HAVE_STRUCT_STAT_ST_BIRTHTIMESPEC guard. +// +// It is the NATIVE form rather than a dispatch through the Ruby accessors +// because rb_stat_inspect calls the C member functions directly: a subclass +// that overrides #uid does not change what #inspect prints. One implementation +// also means Kernel#p, which reaches this method, prints exactly what +// stat.inspect returns. +func (s *FileStat) Inspect() string { + var b strings.Builder + b.WriteString("# len("#") + return b.String() +} // sysExtract is the seam over the build-tagged statSys, so a test can swap in a // stub and drive both the real-Sys and missing-Sys branches identically on every @@ -62,7 +116,67 @@ var ( // newFileStat builds a FileStat from an fs.FileInfo, extracting the POSIX fields. func newFileStat(fi fs.FileInfo, path string) *FileStat { - return &FileStat{fi: fi, sys: sysExtract(fi), path: path} + return &FileStat{fi: fi, sys: statTimestamps(fi, sysExtract(fi)), path: path} +} + +// statTimestamps fills the access/status-change/birth times of a stat struct +// that statSys cannot name portably. MRI reads st_atime, st_ctime and +// st_birthtime straight off struct stat (ruby/ruby v3_4_0 file.c +// rb_file_s_atime / stat_atime), but Go's syscall.Stat_t spells them +// differently on each platform — Atim/Ctim/Mtim on Linux, Atimespec/Ctimespec/ +// Birthtimespec on the BSDs and macOS, and Windows has no such struct at all — +// so naming a field in shared code does not compile. Reflection reads whichever +// one the platform provides, and leaves the value at the modification time when +// there is none, which is what rbgo reported for every platform before. +func statTimestamps(fi fs.FileInfo, sf statFields) statFields { + mtime := fi.ModTime().Unix() + sys := fi.Sys() + sf.atime, sf.ctime = mtime, mtime + if sec, ok := statTimeField(sys, "Atim", "Atimespec"); ok { + sf.atime = sec + } + if sec, ok := statTimeField(sys, "Ctim", "Ctimespec"); ok { + sf.ctime = sec + } + // The creation time exists on the BSDs and macOS (Birthtimespec) and on + // Windows; Linux's struct stat has none without statx, and MRI raises + // NotImplementedError there, so its absence is recorded rather than faked. + if sec, ok := statTimeField(sys, "Birthtimespec", "Btim", "CreationTime"); ok { + sf.btime, sf.hasBtime = sec, true + } + return sf +} + +// statTimeField reads the whole seconds of the first timespec field of sys (a +// pointer to the platform's stat struct) that carries one of the given names. It +// works by reflection because the field names are platform-specific; a field +// that is not a struct with an integer Sec member is ignored, so a platform +// whose Sys() is some unrelated value simply reports nothing. +func statTimeField(sys any, names ...string) (int64, bool) { + if sys == nil { + return 0, false + } + v := reflect.ValueOf(sys) + for v.Kind() == reflect.Pointer { + if v.IsNil() { + return 0, false + } + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return 0, false + } + for _, name := range names { + f := v.FieldByName(name) + if !f.IsValid() || f.Kind() != reflect.Struct { + continue + } + sec := f.FieldByName("Sec") + if sec.IsValid() && sec.CanInt() { + return sec.Int(), true + } + } + return 0, false } // modeBits returns the full MRI-style st_mode: the permission and setuid/setgid/ @@ -83,6 +197,21 @@ func (s *FileStat) modeBits() int64 { return bits | int64(s.ifmt()) } +// blocksValue is File::Stat#blocks: the number of 512-byte blocks allocated, or +// nil on a platform with no POSIX stat behind it (Windows), which is what MRI +// reports where HAVE_STRUCT_STAT_ST_BLOCKS is undefined. +// +// It exists so the accessor and #inspect read ONE rule. They had already +// drifted: #inspect formatted the raw field and printed "blocks=0" on Windows +// where #blocks answers nil, which is the kind of disagreement a member-by-member +// comparison catches and a smoke test does not. +func (s *FileStat) blocksValue() object.Value { + if !s.sys.hasSys { + return object.NilV + } + return object.IntValue(s.sys.blocks) +} + // ifmt returns the POSIX S_IFMT type bits for the file's kind (the high-order // part of st_mode), matching the constants MRI exposes through File::Stat#mode. func (s *FileStat) ifmt() int { @@ -208,11 +337,13 @@ func devPart(s *FileStat, dev int64, part func(int64) int64) object.Value { // the rest of rbgo's Time surface). func statTime(unix int64) *Time { return unixTime(unix) } -// osStat / osLstat are seams over os.Stat / os.Lstat so the missing-path error -// branch is reachable without depending on real filesystem state. +// osStat / osLstat / osReadlink are seams over os.Stat / os.Lstat / os.Readlink +// so the missing-path and unreadable-link error branches are reachable without +// depending on real filesystem state. var ( - osStat = os.Stat - osLstat = os.Lstat + osStat = os.Stat + osLstat = os.Lstat + osReadlink = os.Readlink ) // statOrRaise stats path (following symlinks when follow is true), raising @@ -340,14 +471,17 @@ func (vm *VM) registerFileStat() { d("mtime", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { return statTime(self(v).fi.ModTime().Unix()) }) - // ctime/atime: Go's fs.FileInfo exposes only ModTime portably, so both report - // the modification time (whole-second). Puppet reads mtime; ctime/atime are - // provided for completeness and never raise. + // File::Stat#inspect — see FileStat.Inspect, which Kernel#p reaches too. + d("inspect", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).Inspect()) + }) + // ctime/atime are the stat struct's own st_ctime / st_atime (statTimestamps), + // falling back to the modification time on a platform that reports neither. d("ctime", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { - return statTime(self(v).fi.ModTime().Unix()) + return statTime(self(v).sys.ctime) }) d("atime", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { - return statTime(self(v).fi.ModTime().Unix()) + return statTime(self(v).sys.atime) }) d("<=>", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { other, ok := args[0].(*FileStat) @@ -364,10 +498,6 @@ func (vm *VM) registerFileStat() { return object.IntValue(0) } }) - d("inspect", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { - return object.NewString(self(v).Inspect()) - }) - // Special mode bits, read straight off the file mode. d("setuid?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { return object.Bool(self(v).fi.Mode()&fs.ModeSetuid != 0) @@ -409,11 +539,7 @@ func (vm *VM) registerFileStat() { // blocks is the number of 512-byte blocks allocated; nil where the platform // cannot report it (Windows), a non-negative Integer otherwise. d("blocks", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { - s := self(v) - if !s.sys.hasSys { - return object.NilV - } - return object.IntValue(s.sys.blocks) + return self(v).blocksValue() }) // dev_major / dev_minor / rdev_major / rdev_minor decompose dev / rdev; nil on // platforms without the POSIX device model (Windows), an Integer otherwise. @@ -429,12 +555,16 @@ func (vm *VM) registerFileStat() { d("rdev_minor", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { return devPart(self(v), self(v).sys.rdev, devMinor) }) - // birthtime: Go's portable stat surface (fs.FileInfo) does not expose the - // creation time, and it is unavailable on Linux without statx, so — as MRI does - // on unsupported platforms/filesystems — it raises NotImplementedError. - d("birthtime", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { - raise("NotImplementedError", "birthtime() function is unimplemented") - return object.NilV + // birthtime is the stat struct's creation time where the platform has one (the + // BSDs and macOS spell it st_birthtime). Linux's struct stat has none without + // statx, and MRI raises NotImplementedError there with exactly this message, + // so the absence is reported rather than faked. + d("birthtime", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + s := self(v) + if !s.sys.hasBtime { + raise("NotImplementedError", "birthtime() function is unimplemented") + } + return statTime(s.sys.btime) }) // File.stat / File.lstat class methods on the File class. @@ -445,6 +575,30 @@ func (vm *VM) registerFileStat() { return statOrRaise(pathArg(vm, args[0]), false) }} + // File#stat / File#lstat: the stat of the open stream's own path. MRI's + // rb_file_stat (ruby/ruby v3_4_0 file.c) fstats the descriptor, so it still + // answers for a file that has been unlinked while open; rbgo's File streams + // are buffered by path rather than held open on a descriptor, so the stat goes + // through the path and an unlinked file is Errno::ENOENT — the one case the + // two differ, tracked as a known gap rather than papered over. + statOfStream := func(follow bool) NativeFn { + return func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) != 0 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 0)", len(args)) + } + o := self.(*IOObj) + // Only the CLOSED check applies: MRI's rb_io_stat works on a + // read-only stream, so ioCheckOpen (which also refuses one whose + // write half is shut) is the wrong guard here. + if o.closed { + raise("IOError", "closed stream") + } + return statOrRaise(o.path, follow) + } + } + cFile.define("stat", statOfStream(true)) + cFile.define("lstat", statOfStream(false)) + vm.registerFileTest() // Dir includes Enumerable — deferred here because registerDir runs before the @@ -452,72 +606,44 @@ func (vm *VM) registerFileStat() { vm.includeDirEnumerable() } +// fileTestFunctions is MRI's define_filetest_function list (ruby/ruby v3_4_0 +// file.c Init_File): every name there is installed on BOTH FileTest and File as +// one and the same C function, so File.exist? and FileTest.exist? are the same +// method. rbgo defines them on File (registerFile); registerFileTest re-exports +// the very same Method records on FileTest rather than reimplementing them, so +// the two surfaces cannot drift and an alias pair such as zero?/empty? stays a +// genuine alias on both. +var fileTestFunctions = []string{ + "directory?", "exist?", "readable?", "readable_real?", "world_readable?", + "writable?", "writable_real?", "world_writable?", "executable?", + "executable_real?", "file?", "zero?", "empty?", "size?", "size", "owned?", + "grpowned?", "pipe?", "symlink?", "socket?", "blockdev?", "chardev?", + "setuid?", "setgid?", "sticky?", "identical?", +} + +// reexportSingletons installs src's singleton methods named in names onto dst, +// sharing the very same *Method record. Sharing is what makes an alias pair +// survive the copy: two names that resolve to one record on src still resolve to +// one record on dst, so File.method(:zero?) == File.method(:empty?) holds on +// FileTest too. A name src does not define is skipped rather than installing nil, +// which would turn a missing method into a crash at call time. +func reexportSingletons(dst, src *RClass, names []string) { + for _, name := range names { + if m, ok := src.smethods[name]; ok && m != nil { + dst.smethods[name] = m + } + } +} + // registerFileTest installs the FileTest module — the predicate surface Puppet -// reaches for widely (directory?/file?/exist?/readable?/…). Each predicate is a -// thin stat-and-test that returns false for a missing path rather than raising, -// matching MRI's FileTest. +// reaches for widely (directory?/file?/exist?/readable?/…) and that ruby/spec +// exercises through core/filetest. Each predicate is File's own method record +// (see fileTestFunctions), so the arity and #to_path coercion checks, the +// missing-path-is-false degradation and the alias identities are shared rather +// than duplicated. func (vm *VM) registerFileTest() { mod := newClass("FileTest", nil) mod.isModule = true vm.consts["FileTest"] = mod - sdef := func(name string, fn NativeFn) { mod.smethods[name] = &Method{name: name, owner: mod, native: fn} } - - // statOf stats path (following symlinks) returning nil for a missing path, so - // each predicate degrades to false rather than raising. - statOf := func(p string) *FileStat { - fi, err := osStat(p) - if err != nil { - return nil - } - return newFileStat(fi, p) - } - - sdef("exist?", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - _, err := osStat(pathArg(vm, args[0])) - return object.Bool(err == nil) - }) - mod.smethods["exists?"] = mod.smethods["exist?"] - sdef("directory?", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - s := statOf(pathArg(vm, args[0])) - return object.Bool(s != nil && s.fi.Mode()&fs.ModeDir != 0) - }) - sdef("file?", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - s := statOf(pathArg(vm, args[0])) - return object.Bool(s != nil && s.fi.Mode().IsRegular()) - }) - sdef("symlink?", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - fi, err := osLstat(pathArg(vm, args[0])) - return object.Bool(err == nil && fi.Mode()&fs.ModeSymlink != 0) - }) - sdef("zero?", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - s := statOf(pathArg(vm, args[0])) - return object.Bool(s != nil && s.fi.Size() == 0) - }) - sdef("size", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - p := pathArg(vm, args[0]) - s := statOf(p) - if s == nil { - raise("Errno::ENOENT", "No such file or directory @ rb_file_s_stat - %s", p) - } - return object.IntValue(s.fi.Size()) - }) - sdef("size?", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - s := statOf(pathArg(vm, args[0])) - if s == nil || s.fi.Size() == 0 { - return object.NilV - } - return object.IntValue(s.fi.Size()) - }) - sdef("readable?", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - s := statOf(pathArg(vm, args[0])) - return object.Bool(s != nil && s.accessible(4)) - }) - sdef("writable?", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - s := statOf(pathArg(vm, args[0])) - return object.Bool(s != nil && s.accessible(2)) - }) - sdef("executable?", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - s := statOf(pathArg(vm, args[0])) - return object.Bool(s != nil && s.accessible(1)) - }) + reexportSingletons(mod, vm.consts["File"].(*RClass), fileTestFunctions) } diff --git a/internal/vm/filestat_internal_test.go b/internal/vm/filestat_internal_test.go index 4df9b3d5..581f517f 100644 --- a/internal/vm/filestat_internal_test.go +++ b/internal/vm/filestat_internal_test.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "time" @@ -63,8 +64,10 @@ func TestFileStatPredicates(t *testing.T) { "[false, false, false, false]\n"}, // File::Stat.new is File.stat. {`s=File::Stat.new("` + f + `"); p s.file?`, "true\n"}, - // inspect renders the marker. - {`p File.stat("` + f + `").inspect`, "\"#\"\n"}, + // inspect renders MRI's member list (rb_stat_inspect); its layout and + // values are pinned in TestFileStatInspect, so here only the shape that + // distinguishes it from the old placeholder is asserted. + {`p File.stat("` + f + `").inspect.start_with?("# against a non-stat is nil; against an equal-mtime stat is 0. {`s=File.stat("` + f + `"); p [(s <=> 1), (s <=> s)]`, "[nil, 0]\n"}, // size?/zero? for an empty file: size? is nil, zero? true. @@ -198,10 +201,15 @@ func (f fakeInfo) IsDir() bool { return f.mode&fs.ModeDir != 0 } func (f fakeInfo) Sys() any { return nil } // TestFileStatToS covers the display markers and Truthy of a FileStat value. +// #to_s keeps the bare marker (MRI's File::Stat defines no #to_s), while +// #inspect is rb_stat_inspect's member list — see TestFileStatInspect. func TestFileStatToS(t *testing.T) { st := &FileStat{fi: fakeInfo{}} - if st.ToS() != "#" || st.Inspect() != "#" || !st.Truthy() { - t.Errorf("display: ToS=%q Inspect=%q Truthy=%v", st.ToS(), st.Inspect(), st.Truthy()) + if st.ToS() != "#" || !st.Truthy() { + t.Errorf("display: ToS=%q Truthy=%v", st.ToS(), st.Truthy()) + } + if !strings.HasPrefix(st.Inspect(), "# e; puts e.message; end +begin; ` + recv + `.` + m + `; rescue ArgumentError => e; puts e.message; end` + out := runFS(t, src) + if want := "wrong number of arguments (given 2, expected 1)"; !strings.Contains(out, want) { + t.Errorf("%s.%s with 2 args: got %q, want %q", recv, m, out, want) + } + if want := "wrong number of arguments (given 0, expected 1)"; !strings.Contains(out, want) { + t.Errorf("%s.%s with 0 args: got %q, want %q", recv, m, out, want) + } + } + } +} + +// TestStatPathArg covers rb_stat's argument resolution: an open stream, an +// object converted through #to_io, a #to_io that answers something other than an +// IO (MRI's conversion TypeError), and a plain path, which is the only shape the +// eaccess-based predicates accept. Expectations are MRI 4.0.5's. +func TestStatPathArg(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "f") + if err := os.WriteFile(f, []byte("rubinius"), 0o644); err != nil { + t.Fatal(err) + } + got := runFS(t, ` +io = File.open(`+rq(f)+`, "r") +o = Object.new; o.define_singleton_method(:to_io) { io } +p [File.size(io), File.size(o), File.file?(io), File.directory?(io), File.zero?(io)] +p [File.directory?(STDIN), FileTest.directory?(STDIN)] +bad = Object.new; def bad.to_io; 42; end +begin; File.directory?(bad); rescue TypeError => e; puts e.message; end +# The eaccess family does not go through rb_stat, but File#to_path means an +# open File still resolves there — MRI 4.0.5 answers true, not a TypeError. +p File.readable?(io) +p File.size(`+rq(f)+`) +io.close +`) + want := "[8, 8, true, false, false]\n" + + "[false, false]\n" + + "can't convert Object to IO (Object#to_io gives Integer)\n" + + "true\n" + + "8\n" + if got != want { + t.Errorf("got %q\nwant %q", got, want) + } +} diff --git a/internal/vm/fileutils.go b/internal/vm/fileutils.go index 9b3f7fb7..5972591d 100644 --- a/internal/vm/fileutils.go +++ b/internal/vm/fileutils.go @@ -5,9 +5,12 @@ package vm import ( + "errors" "io/fs" "os" "path/filepath" + "strings" + "syscall" "time" "github.com/go-embedded-ruby/ruby/internal/object" @@ -229,3 +232,195 @@ func fuWalk(root string) []string { } return out } + +// --- File.realpath / File.realdirpath ----------------------------------------- +// +// MRI resolves both through one recursive walk, realpath_rec (ruby/ruby v3_4_0 +// file.c:4324), driven by rb_check_realpath_emulate (file.c:4437). Go's +// filepath.EvalSymlinks is close but not close enough for two reasons the specs +// notice: it refuses a path whose LAST component is a dangling symlink (MRI's +// realdirpath answers the link's target, file.c:4390), and its errors carry +// neither MRI's errno nor the component that actually failed. + +// realpathResolving is the marker MRI puts in its loopcheck hash while a symlink +// is being followed (ID2SYM(resolving), file.c:4406); meeting it again means the +// link points at itself through some chain, which is ELOOP. It is deliberately +// not a valid path. +const realpathResolving = "\x00resolving" + +// realpathWalk carries the state realpath_rec threads through its recursion: the +// path resolved so far, the length of its ROOT (MRI's prefixlen — 1 for "/", 3 +// for "C:/", longer for a UNC share), the loop-check table mapping an +// already-resolved component to its answer, and whether an absent final +// component is an error (strict, i.e. File.realpath) or the answer +// (File.realdirpath). +// +// prefixLen is not decoration: MRI threads `*prefixlenp` through realpath_rec +// for exactly one reason — ".." must not be able to walk above the root, and on +// a platform where the root is more than one character ("C:/"), truncating to +// the last separator would eat the volume. +type realpathWalk struct { + resolved string + prefixLen int + loop map[string]string + strict bool +} + +// realpathErr is the failure realpath_rec reports: an errno class, MRI's message +// for it, and the component path the message names. It travels as an error rather +// than a raise so File.realpath can relabel it — MRI reaches realpath(3) first +// there and reports failures against the whole argument under a different +// function name (rb_check_realpath_internal, file.c:4554). +type realpathErr struct { + class, message, path string +} + +func (e *realpathErr) Error() string { return e.message + " - " + e.path } + +// realpathResolve walks p — an absolute, '/'-separated, already-expanded path — +// resolving every symlink, and returns the canonical path. With strict set, every +// component must exist; without it (File.realdirpath's RB_REALPATH_DIR), only the +// final component may be absent. trailingSep withdraws even that, which is +// realpath_rec's `*unresolved_firstsep` half of the +// `mode == RB_REALPATH_STRICT || !last || *unresolved_firstsep` test: MRI walks +// the path AS WRITTEN, so "dir/absent/" is an error where "dir/absent" is not. +// It is a separate argument because expanding the path (which the caller must do +// first, to apply the base directory) drops the separator. +func realpathResolve(p string, strict, trailingSep bool) (string, *realpathErr) { + root, rest := realpathRoot(p) + w := &realpathWalk{resolved: root, prefixLen: len(root), loop: map[string]string{}, strict: strict} + if err := w.walk(splitPathNames(rest), "", !trailingSep); err != nil { + return "", err + } + return w.resolved, nil +} + +// realpathRoot splits an absolute path into the root the walk starts from and +// the names that follow it — MRI's skipprefixroot (file.c), which is why +// realpath_rec carries a prefixlen at all. On POSIX the root is always "/" and +// nothing is consumed; on Windows it is the volume ("C:/") or the UNC share +// ("//host/share/"), and starting the walk at "/" instead would build "/C:" and +// then fail against a path no such filesystem has. +func realpathRoot(p string) (root, rest string) { + vol := toSlash(filepath.VolumeName(filepath.FromSlash(p))) + return vol + "/", p[len(vol):] +} + +// realpathRooted reports whether a symlink target names a root of its own, so +// resolving it restarts from there rather than continuing under the path +// resolved so far. A leading separator is the POSIX form; a volume name is the +// Windows one ("C:\dir", "\\host\share\dir"). +func realpathRooted(link string) bool { + return strings.HasPrefix(link, "/") || filepath.VolumeName(filepath.FromSlash(link)) != "" +} + +// splitPathNames splits an absolute path into its non-empty components, dropping +// the separators the walk does not need. +func splitPathNames(p string) []string { + out := make([]string, 0, 8) + for _, n := range strings.Split(p, "/") { + if n != "" { + out = append(out, n) + } + } + return out +} + +// walk is realpath_rec's loop over one run of names. fallback is the symlink +// whose target this run expands (empty at the top level): when resolving the +// target hits ENOENT but the symlink itself stats clean, MRI answers the symlink +// instead (file.c:4383). last says whether this run ends the whole path, which is +// what decides if its final component may be absent. +func (w *realpathWalk) walk(names []string, fallback string, last bool) *realpathErr { + for i, name := range names { + isLast := last && i == len(names)-1 + if name == "." { + continue + } + if name == ".." { + w.resolved = realpathParent(w.resolved, w.prefixLen) + continue + } + testpath := realpathJoin(w.resolved, name) + if prev, seen := w.loop[testpath]; seen { + if prev == realpathResolving { + return &realpathErr{"Errno::ELOOP", "Too many levels of symbolic links", testpath} + } + w.resolved = prev + continue + } + fi, err := osLstat(testpath) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return realpathSyscallErr(err, testpath) + } + if fallback != "" { + if _, serr := osStat(fallback); serr == nil { + w.resolved = fallback + return nil + } + } + if w.strict || !isLast { + return &realpathErr{"Errno::ENOENT", "No such file or directory", testpath} + } + w.resolved = testpath + return nil + } + if fi.Mode()&fs.ModeSymlink == 0 { + w.resolved = testpath + continue + } + link, lerr := osReadlink(testpath) + if lerr != nil { + return realpathSyscallErr(lerr, testpath) + } + w.loop[testpath] = realpathResolving + names := link + if realpathRooted(link) { + root, rest := realpathRoot(toSlash(link)) + w.resolved, w.prefixLen, names = root, len(root), rest + } + if err := w.walk(splitPathNames(toSlash(names)), testpath, isLast); err != nil { + return err + } + w.loop[testpath] = w.resolved + } + return nil +} + +// realpathSyscallErr turns a stat/readlink failure that is not ENOENT into the +// errno class and message MRI's rb_syserr_fail_path would report for it +// (ENOTDIR on a path that walks through a regular file, EACCES on an unreadable +// directory), falling back to the generic SystemCallError for anything else. +func realpathSyscallErr(err error, testpath string) *realpathErr { + var eno syscall.Errno + if errors.As(err, &eno) { + if name, ok := errnoClasses[int64(eno)]; ok { + return &realpathErr{"Errno::" + name, errnoStrerror(int64(eno)), testpath} + } + return &realpathErr{"SystemCallError", errnoStrerror(int64(eno)), testpath} + } + return &realpathErr{"Errno::ENOENT", "No such file or directory", testpath} +} + +// realpathParent drops the last component of an already-resolved absolute path, +// the way realpath_rec handles "..": it truncates back to the previous separator +// and never above the root, which prefixLen delimits (MRI's +// `if (*prefixlenp < RSTRING_LEN(*resolvedp))` guard). +func realpathParent(resolved string, prefixLen int) string { + i := strings.LastIndexByte(resolved, '/') + if i < prefixLen { + return resolved[:prefixLen] + } + return resolved[:i] +} + +// realpathJoin appends one component to an already-resolved absolute path without +// doubling the separator the root already ends with ("/" on POSIX, "C:/" on +// Windows). +func realpathJoin(resolved, name string) string { + if strings.HasSuffix(resolved, "/") { + return resolved + name + } + return resolved + "/" + name +} diff --git a/internal/vm/process_limits_internal_test.go b/internal/vm/process_limits_internal_test.go index 8c80ab4e..ab2517da 100644 --- a/internal/vm/process_limits_internal_test.go +++ b/internal/vm/process_limits_internal_test.go @@ -179,10 +179,19 @@ func TestProcessSysFailFallback(t *testing.T) { } }) // An errno that IS a syscall.Errno but is not one of the registered names - // takes the same fallback (the errno lookup misses). - withGetrlimit(t, func(int) (uint64, uint64, error) { return 0, 0, syscall.Errno(0) }, func() { + // takes the same fallback (the errno lookup misses). It has to be a number no + // name claims: errno 0 is not one, since MRI registers Errno::NOERROR for it + // (ruby/ruby v3_4_0 error.c:4206, and 4.0.5 reports + // SystemCallError.new(0).class == Errno::NOERROR), and so does rbgo. + withGetrlimit(t, func(int) (uint64, uint64, error) { return 0, 0, syscall.Errno(1 << 20) }, func() { if class, _ := evalErr(t, `Process.getrlimit(:CORE)`); class != "SystemCallError" { - t.Errorf("errno 0: got class %s want SystemCallError", class) + t.Errorf("unclaimed errno: got class %s want SystemCallError", class) + } + }) + // errno 0 now resolves to Errno::NOERROR, as it does in MRI. + withGetrlimit(t, func(int) (uint64, uint64, error) { return 0, 0, syscall.Errno(0) }, func() { + if class, _ := evalErr(t, `Process.getrlimit(:CORE)`); class != "Errno::NOERROR" { + t.Errorf("errno 0: got class %s want Errno::NOERROR", class) } }) } diff --git a/internal/vm/realpath_internal_test.go b/internal/vm/realpath_internal_test.go new file mode 100644 index 00000000..a104ab98 --- /dev/null +++ b/internal/vm/realpath_internal_test.go @@ -0,0 +1,280 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "syscall" + "testing" +) + +// realpathTree builds the symlink tree the resolver tests share and returns its +// root, already resolved — the root itself may sit under a symlinked temp +// directory (macOS puts /tmp behind /private/tmp), and every expectation here is +// about what the resolver does INSIDE the tree. +func realpathTree(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the POSIX symlink tree needs privileges on Windows") + } + base, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + mk := func(p string) string { return filepath.Join(base, p) } + if err := os.Mkdir(mk("d"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mk("d/f"), nil, 0o644); err != nil { + t.Fatal(err) + } + for _, l := range [][2]string{ + {mk("d/absent"), mk("dangling")}, // -> a name that does not exist + {mk("nodir/x"), mk("deep")}, // -> a name under a missing directory + {"loop_b", mk("loop_a")}, // a relative two-link cycle + {"loop_a", mk("loop_b")}, // + {mk("d"), mk("dlink")}, // -> a real directory, absolute + {"d", mk("rellink")}, // -> the same directory, relative + } { + if err := os.Symlink(l[0], l[1]); err != nil { + t.Fatal(err) + } + } + return base +} + +// TestRealpathResolve drives the resolver over a real tree. Every expectation is +// MRI 4.0.5's own answer for the same path (File.realpath / File.realdirpath), +// captured on darwin. +func TestRealpathResolve(t *testing.T) { + base := realpathTree(t) + j := func(p string) string { return base + "/" + p } + for _, c := range []struct { + name string + path string + strict, trail bool + want string + wantClass string + wantPath string + }{ + {name: "root", path: "/", want: "/"}, + {name: "plain file", path: j("d/f"), strict: true, want: j("d/f")}, + {name: "through a directory symlink", path: j("dlink/f"), strict: true, want: j("d/f")}, + {name: "through a relative directory symlink", path: j("rellink/f"), strict: true, want: j("d/f")}, + {name: "dot and dotdot", path: j("dlink/./../d/f"), strict: true, want: j("d/f")}, + {name: "dotdot past the root", path: "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/../../", want: "/"}, + // realdirpath answers a dangling link with its target; realpath refuses it. + {name: "dangling link, lenient", path: j("dangling"), want: j("d/absent")}, + {name: "dangling link, strict", path: j("dangling"), strict: true, + wantClass: "Errno::ENOENT", wantPath: j("d/absent")}, + // An absent LAST component is the answer; an absent one in the middle is not. + {name: "absent leaf", path: j("d/nope"), want: j("d/nope")}, + {name: "absent leaf, trailing separator", path: j("d/nope"), trail: true, + wantClass: "Errno::ENOENT", wantPath: j("d/nope")}, + {name: "absent leaf, strict", path: j("d/nope"), strict: true, + wantClass: "Errno::ENOENT", wantPath: j("d/nope")}, + {name: "absent directory in the middle", path: j("nodir/x"), + wantClass: "Errno::ENOENT", wantPath: j("nodir")}, + {name: "link to a name under an absent directory", path: j("deep"), + wantClass: "Errno::ENOENT", wantPath: j("nodir")}, + {name: "symlink cycle", path: j("loop_a"), + wantClass: "Errno::ELOOP", wantPath: j("loop_a")}, + // Walking through a regular file is ENOTDIR, not ENOENT. + {name: "file used as a directory", path: j("d/f/x"), + wantClass: "Errno::ENOTDIR", wantPath: j("d/f/x")}, + } { + got, err := realpathResolve(c.path, c.strict, c.trail) + if c.wantClass != "" { + if err == nil { + t.Errorf("%s: resolved to %q, want %s", c.name, got, c.wantClass) + continue + } + if err.class != c.wantClass || err.path != c.wantPath { + t.Errorf("%s: got %s at %q, want %s at %q", c.name, err.class, err.path, c.wantClass, c.wantPath) + } + continue + } + if err != nil { + t.Errorf("%s: %v", c.name, err) + } else if got != c.want { + t.Errorf("%s: got %q, want %q", c.name, got, c.want) + } + } +} + +// TestRealpathLoopCache covers the loop table's second role: a symlink already +// resolved once is answered from the table rather than walked again, which is the +// branch a single traversal never reaches. +func TestRealpathLoopCache(t *testing.T) { + base := realpathTree(t) + got, err := realpathResolve(base+"/dlink/../dlink/f", true, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := base + "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/d/f"; got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// TestRealpathFallback covers realpath_rec's fallback (ruby/ruby v3_4_0 +// file.c:4383): when a symlink's TARGET is missing but the symlink itself stats +// clean, the symlink's own path is the answer. A dangling link cannot produce +// that state on a real filesystem — stat follows the link and fails too — so the +// stat seam supplies it. +func TestRealpathFallback(t *testing.T) { + base := realpathTree(t) + defer func(orig func(string) (fs.FileInfo, error)) { osStat = orig }(osStat) + osStat = func(string) (fs.FileInfo, error) { return nil, nil } + got, err := realpathResolve(base+"/dangling", true, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := base + "/dangling"; got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// TestRealpathReadlinkFailure covers the readlink error branch: lstat says the +// component is a symlink but reading it fails, which a real filesystem only +// produces under a race. +func TestRealpathReadlinkFailure(t *testing.T) { + base := realpathTree(t) + defer func(orig func(string) (string, error)) { osReadlink = orig }(osReadlink) + osReadlink = func(string) (string, error) { return "", syscall.EACCES } + _, err := realpathResolve(base+"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/dlink/f", true, false) + if err == nil || err.class != "Errno::EACCES" { + t.Fatalf("got %v, want Errno::EACCES", err) + } +} + +// TestRealpathSyscallErr covers the three shapes realpathSyscallErr distinguishes: +// an errno with a registered class, an errno without one, and an error that is +// not a syscall.Errno at all. +func TestRealpathSyscallErr(t *testing.T) { + // The CLASS is portable; the MESSAGE is the platform's strerror, and Windows + // words it "The system cannot find the path specified." There is no Windows + // MRI on this host to witness what MRI prints there, so the text is asserted + // only where it was witnessed (MRI 4.0.5 on darwin) rather than relaxed to + // whatever the lane happens to return. + if e := realpathSyscallErr(syscall.ENOTDIR, "/p"); e.class != "Errno::ENOTDIR" { + t.Errorf("ENOTDIR: got class %s", e.class) + } else if runtime.GOOS == "darwin" && e.message != "Not a directory" { + t.Errorf("ENOTDIR: got message %q, want %q", e.message, "Not a directory") + } + if e := realpathSyscallErr(syscall.Errno(1<<20), "/p"); e.class != "SystemCallError" { + t.Errorf("unregistered errno: got %s", e.class) + } + if e := realpathSyscallErr(errors.New("not an errno"), "/p"); e.class != "Errno::ENOENT" { + t.Errorf("plain error: got %s", e.class) + } + if got := (&realpathErr{"Errno::ENOENT", "No such file or directory", "/p"}).Error(); got != "No such file or directory - /p" { + t.Errorf("Error(): got %q", got) + } +} + +// TestRealpathParentAndJoin pins the two path primitives at the root, where the +// separator handling differs from every other position — INCLUDING a root longer +// than one character, which is what a Windows volume ("C:/") is. Those cases are +// driven by passing the prefix length directly, so the Windows-shaped behaviour +// is covered on the POSIX lanes rather than only where filepath.VolumeName +// returns something. +func TestRealpathParentAndJoin(t *testing.T) { + for _, c := range []struct { + in string + prefix int + want string + }{ + {"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/a/b", 1, "/a"}, + {"/a", 1, "/"}, + {"/", 1, "/"}, + // A multi-character root: ".." must stop at the volume, not eat it. + {"C:/Users/x", 3, "C:/Users"}, + {"C:/Users", 3, "C:/"}, + {"C:/", 3, "C:/"}, + // A UNC share root is longer still. + {"//host/share/dir", 13, "//host/share/"}, + } { + if got := realpathParent(c.in, c.prefix); got != c.want { + t.Errorf("realpathParent(%q, %d) = %q, want %q", c.in, c.prefix, got, c.want) + } + } + for _, c := range []struct{ resolved, name, want string }{ + {"/", "a", "/a"}, + {"/a", "b", "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/a/b"}, + {"C:/", "a", "C:/a"}, + {"C:/a", "b", "C:/a/b"}, + } { + if got := realpathJoin(c.resolved, c.name); got != c.want { + t.Errorf("realpathJoin(%q, %q) = %q, want %q", c.resolved, c.name, got, c.want) + } + } +} + +// TestRealpathRoot covers the skipprefixroot split the walk starts from, and the +// test that decides whether a symlink target restarts it. On POSIX there is no +// volume, so the root is always "/" and nothing is consumed — which is exactly +// the property that has to hold for the Windows change to be a no-op here. +func TestRealpathRoot(t *testing.T) { + for _, in := range []string{"/", "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/a/b", "/a"} { + root, rest := realpathRoot(in) + if root != "/" || rest != in { + t.Errorf("realpathRoot(%q) = %q,%q — a POSIX path has no volume to consume", in, root, rest) + } + } + if realpathRooted("/abs") != true { + t.Errorf("a leading separator names a root") + } + if realpathRooted("rel/path") != false { + t.Errorf("a relative link does not name a root") + } + if runtime.GOOS != "windows" { + return + } + root, rest := realpathRoot("C:/Users/x") + if root != "C:/" || rest != "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/Users/x" { + t.Errorf("realpathRoot on a volume path = %q,%q", root, rest) + } + if !realpathRooted(`C:\dir`) { + t.Errorf("a volume-qualified link names a root") + } +} + +// TestRealpathRubySurface checks the two wordings MRI uses for the SAME failure, +// which is the part of File.realpath that is not the resolver: realpath(3) fails +// first there, so ENOENT and ELOOP name rb_check_realpath_internal and the whole +// argument, while ENOTDIR falls through to the emulation and names realpath_rec +// and the component. File.realdirpath is the emulation, so it always says +// realpath_rec. +func TestRealpathRubySurface(t *testing.T) { + base := realpathTree(t) + for _, c := range []struct{ src, want string }{ + {`File.realpath("` + base + `/d/nope")`, + "No such file or directory @ rb_check_realpath_internal - " + base + "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/d/nope"}, + {`File.realpath("` + base + `/loop_a")`, + "Too many levels of symbolic links @ rb_check_realpath_internal - " + base + "/loop_a"}, + {`File.realpath("` + base + `/d/f/x")`, + "Not a directory @ realpath_rec - " + base + "/d/f/x"}, + {`File.realdirpath("` + base + `/nodir/x")`, + "No such file or directory @ realpath_rec - " + base + "/nodir"}, + {`File.realdirpath("` + base + `/loop_a")`, + "Too many levels of symbolic links @ realpath_rec - " + base + "/loop_a"}, + } { + got := runFS(t, `begin; `+c.src+`; rescue SystemCallError => e; puts e.message; end`) + if got != c.want+"\n" { + t.Errorf("%s\n got=%q\nwant=%q", c.src, got, c.want+"\n") + } + } + // The resolving answers themselves, through the Ruby surface. + if got := runFS(t, `puts File.realdirpath("`+base+`/dangling")`); got != base+"/d/absent\n" { + t.Errorf("realdirpath of a dangling link: got %q", got) + } + if got := runFS(t, `puts File.realpath("f", "`+base+`/dlink")`); got != base+"/d/f\n" { + t.Errorf("realpath with a base directory: got %q", got) + } +} diff --git a/internal/vm/statdetail_internal_test.go b/internal/vm/statdetail_internal_test.go new file mode 100644 index 00000000..2e399c91 --- /dev/null +++ b/internal/vm/statdetail_internal_test.go @@ -0,0 +1,217 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + "time" +) + +// fakeTimespec/fakeStat stand in for a platform stat struct so statTimeField's +// name resolution can be driven on every GOOS, including the ones whose +// syscall.Stat_t has no such fields at all. +type fakeTimespec struct{ Sec, Nsec int64 } + +type fakeStat struct { + Atim fakeTimespec + Ctimespec fakeTimespec + Birthtimespec fakeTimespec + NotATime int64 +} + +// TestStatTimeField covers every decision in the reflection lookup: the Linux +// spelling, the BSD spelling, a name the struct does not carry, a name whose +// field is not a struct, a non-struct argument, a nil pointer and a nil value. +func TestStatTimeField(t *testing.T) { + st := &fakeStat{Atim: fakeTimespec{Sec: 11}, Ctimespec: fakeTimespec{Sec: 22}, Birthtimespec: fakeTimespec{Sec: 33}} + for _, c := range []struct { + names []string + want int64 + wantOk bool + comment string + }{ + {[]string{"Atim", "Atimespec"}, 11, true, "the Linux spelling, found first"}, + {[]string{"Ctim", "Ctimespec"}, 22, true, "the BSD spelling, found second"}, + {[]string{"Birthtimespec", "Btim"}, 33, true, "a creation time"}, + {[]string{"Mtim", "Mtimespec"}, 0, false, "a name the struct does not carry"}, + {[]string{"NotATime"}, 0, false, "a field that is not a timespec"}, + } { + got, ok := statTimeField(st, c.names...) + if got != c.want || ok != c.wantOk { + t.Errorf("%s: got %d,%v want %d,%v", c.comment, got, ok, c.want, c.wantOk) + } + } + if _, ok := statTimeField(nil, "Atim"); ok { + t.Errorf("a nil Sys() must report nothing") + } + if _, ok := statTimeField((*fakeStat)(nil), "Atim"); ok { + t.Errorf("a nil pointer must report nothing") + } + if _, ok := statTimeField(42, "Atim"); ok { + t.Errorf("a non-struct must report nothing") + } + // A struct with a Sec field that is not an integer is ignored too. + type oddTime struct{ Sec string } + if _, ok := statTimeField(&struct{ Atim oddTime }{}, "Atim"); ok { + t.Errorf("a non-integer Sec must report nothing") + } +} + +// stubInfo is an fs.FileInfo whose Sys() is whatever the test hands it, so +// statTimestamps' fallback can be driven without a real filesystem. +type stubInfo struct { + mod time.Time + sys any +} + +func (s stubInfo) Name() string { return "stub" } +func (s stubInfo) Size() int64 { return 0 } +func (s stubInfo) Mode() fs.FileMode { return 0o644 } +func (s stubInfo) ModTime() time.Time { return s.mod } +func (s stubInfo) IsDir() bool { return false } +func (s stubInfo) Sys() any { return s.sys } + +// TestStatTimestamps covers the two shapes statTimestamps must handle: a stat +// struct that carries the timestamps, and one that carries none — where the +// modification time stands in and no creation time is recorded, so +// File::Stat#birthtime raises the way MRI does on Linux. +func TestStatTimestamps(t *testing.T) { + mod := time.Unix(500, 0) + full := statTimestamps(stubInfo{mod: mod, sys: &fakeStat{ + Atim: fakeTimespec{Sec: 1}, Ctimespec: fakeTimespec{Sec: 2}, Birthtimespec: fakeTimespec{Sec: 3}, + }}, statFields{}) + if full.atime != 1 || full.ctime != 2 || full.btime != 3 || !full.hasBtime { + t.Errorf("full stat: %+v", full) + } + bare := statTimestamps(stubInfo{mod: mod, sys: nil}, statFields{}) + if bare.atime != 500 || bare.ctime != 500 { + t.Errorf("a stat without timestamps must fall back to mtime: %+v", bare) + } + if bare.hasBtime { + t.Errorf("a stat without a creation time must not claim one") + } +} + +// TestFileStatBirthtimeUnsupported drives the NotImplementedError branch through +// the Ruby surface by handing newFileStat a stat struct with no creation time — +// the state a Linux host is always in. +func TestFileStatBirthtimeUnsupported(t *testing.T) { + st := &FileStat{fi: stubInfo{mod: time.Unix(7, 0)}, sys: statTimestamps(stubInfo{mod: time.Unix(7, 0)}, statFields{})} + got := expectRaise(t, func() { + New(nil).send(st, "birthtime", nil, nil) + }) + if got != "NotImplementedError" { + t.Errorf("birthtime without a creation time raised %q", got) + } +} + +// TestFileStatInspect pins rb_stat_inspect's layout: the member order, dev/rdev +// in hex, mode in octal with a leading zero, and birthtime present only where +// the platform has one. The values are checked against the accessors, so the +// test cannot pass by agreeing with a wrong reading of the stat struct. +func TestFileStatInspect(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "f") + if err := os.WriteFile(f, []byte("rubinius"), 0o644); err != nil { + t.Fatal(err) + } + // MRI 4.0.5 on darwin builds the same string from the accessors; this is + // core/file/stat/inspect_spec.rb's own expectation, in Go. + out := runFS(t, ` +st = File.stat(`+rq(f)+`) +e = "#" +puts st.inspect == e ? "match" : "GOT #{st.inspect}\nWANT #{e}" +`) + if out != "match\n" { + t.Errorf("File::Stat#inspect does not match the member-by-member expectation:\n%s", out) + } + // Kernel#p reaches the same rendering, so the two cannot drift. + st, err := os.Stat(f) + if err != nil { + t.Fatal(err) + } + native := newFileStat(st, f).Inspect() + if got := runFS(t, `puts File.stat(`+rq(f)+`).inspect`); got != native+"\n" { + t.Errorf("Ruby #inspect %q differs from the native one %q", got, native) + } + // blocks and blksize come from the POSIX stat that Windows does not have: + // MRI answers nil for #blocks where HAVE_STRUCT_STAT_ST_BLOCKS is undefined, + // and rb_stat_inspect prints that nil. So the layout accepts either shape + // rather than asserting a number that only POSIX can produce. + if !regexp.MustCompile(`^#$`). + MatchString(strings.TrimSuffix(regexp.MustCompile(`, birthtime=[^>]*`).ReplaceAllString(native, ""), "")) { + t.Errorf("unexpected layout: %s", native) + } +} + +// TestFileStatAtimeIsNotMtime is the witness that atime is READ rather than +// substituted: File.utime sets the two apart, and MRI 4.0.5 reports them apart. +func TestFileStatAtimeIsNotMtime(t *testing.T) { + if runtime.GOOS == "windows" { + // Windows' Sys() is a *syscall.Win32FileAttributeData, whose LastAccessTime + // is a Filetime rather than a Sec-bearing timespec, so statTimeField finds + // nothing and atime falls back to mtime. MRI on Windows does report a real + // atime; there is no Windows MRI on this host to witness the right + // behaviour against, so the case is skipped rather than asserted loosely. + t.Skip("no Windows atime support and no Windows MRI witness — see the per-platform stat issue") + } + dir := t.TempDir() + f := filepath.Join(dir, "f") + if err := os.WriteFile(f, nil, 0o644); err != nil { + t.Fatal(err) + } + got := runFS(t, ` +File.utime(Time.utc(2000), Time.utc(2001), `+rq(f)+`) +st = File.stat(`+rq(f)+`) +p [st.atime.utc.to_s, st.mtime.utc.to_s] +`) + want := "[\"2000-01-01 00:00:00 UTC\", \"2001-01-01 00:00:00 UTC\"]\n" + if got != want { + t.Errorf("got %s want %s", got, want) + } +} + +// TestFileStreamStat covers File#stat / File#lstat: both answer for the open +// stream's own path, lstat does not follow a final symlink, neither takes an +// argument, and a closed stream is an IOError. Witnessed against MRI 4.0.5. +func TestFileStreamStat(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the symlink half needs privileges on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "target") + link := filepath.Join(dir, "link") + if err := os.WriteFile(target, []byte("abcd"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + got := runFS(t, ` +f = File.open(`+rq(link)+`) +p [f.stat.class, f.stat.size, f.stat.file?, f.stat.symlink?, f.lstat.symlink?] +begin; f.stat(1); rescue ArgumentError => e; puts e.message; end +f.close +begin; f.stat; rescue IOError => e; puts e.message; end +begin; f.lstat; rescue IOError => e; puts e.message; end +`) + want := "[File::Stat, 4, true, false, true]\n" + + "wrong number of arguments (given 1, expected 0)\n" + + "closed stream\nclosed stream\n" + if got != want { + t.Errorf("got %q\nwant %q", got, want) + } +} diff --git a/internal/vm/syserr_internal_test.go b/internal/vm/syserr_internal_test.go new file mode 100644 index 00000000..456e8752 --- /dev/null +++ b/internal/vm/syserr_internal_test.go @@ -0,0 +1,302 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "runtime" + "strconv" + "syscall" + "testing" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// errnoLit renders an errno number as the Ruby integer literal a test embeds in +// its source, so an expectation reads the host's number rather than hard-coding +// a platform-specific one. +func errnoLit(n int64) string { return strconv.FormatInt(n, 10) } + +// TestErrnoStrerror pins the message text MRI's syserr_initialize builds from +// strerror(). Two things are under test, and only one of them is portable. +// +// The RULE is portable: for an errno the platform's own table names, +// errnoStrerror is that text with its leading letter restored. Asserting it +// against syscall.Errno's table is not circular — the table is the input, and +// what is checked is that nothing else is done to it. +// +// The TEXTS are not portable, because the errno NUMBERS are not: 92 is EILSEQ +// on darwin and ENOPROTOOPT on Linux, and glibc and the BSDs word several +// messages differently. The literals below are MRI 4.0.5's own output on +// darwin, which is where the conformance gate runs and the only platform with +// an MRI witness on this host. +func TestErrnoStrerror(t *testing.T) { + for name, n := range errnoNumbers { + go_ := syscall.Errno(n).Error() + if _, unknown := errnoUnknownText(go_, n); unknown { + continue // the placeholder form is checked separately below + } + if got, want := errnoStrerror(n), capitalizeASCII(go_); got != want { + t.Errorf("errnoStrerror(%s=%d) = %q, want %q", name, n, got, want) + } + } + // The "unknown errno" wording is rbgo's own, and applies wherever the + // platform's table leaves Go's "errno N" placeholder — which is everywhere + // but Windows, whose FormatMessage names every number. It matches darwin's + // libc; glibc words the same two "Unknown error 268435456" (no colon) and + // "Success" for errno 0, a divergence noted in the per-platform errno issue. + for _, c := range []struct { + n int64 + want string + }{ + {0, "Undefined error: 0"}, + {1 << 28, "Unknown error: 268435456"}, + {-1, "Unknown error: -1"}, + } { + if _, unknown := errnoUnknownText(syscall.Errno(c.n).Error(), c.n); !unknown { + continue + } + if got := errnoStrerror(c.n); got != c.want { + t.Errorf("errnoStrerror(%d) = %q, want %q", c.n, got, c.want) + } + } + if runtime.GOOS != "darwin" { + t.Skip("the exact strerror texts are witnessed against MRI 4.0.5 on darwin only") + } + for _, c := range []struct { + name string + want string + }{ + {"EPERM", "Operation not permitted"}, + {"ENOENT", "No such file or directory"}, + {"EINVAL", "Invalid argument"}, + {"EILSEQ", "Illegal byte sequence"}, + } { + if got := errnoStrerror(errnoNumbers[c.name]); got != c.want { + t.Errorf("errnoStrerror(%s) = %q, want %q", c.name, got, c.want) + } + } +} + +// TestErrnoUnknownText drives errnoUnknownText's two decisions directly: whether +// the text is Go's "errno N" placeholder at all, and the separate wording errno 0 +// takes. A named errno must be left for the capitalising path. +func TestErrnoUnknownText(t *testing.T) { + if _, ok := errnoUnknownText("invalid argument", 22); ok { + t.Errorf("a named errno must not be treated as unknown") + } + if s, ok := errnoUnknownText("errno 0", 0); !ok || s != "Undefined error: 0" { + t.Errorf("errno 0: got %q %v", s, ok) + } + if s, ok := errnoUnknownText("errno 5000", 5000); !ok || s != "Unknown error: 5000" { + t.Errorf("errno 5000: got %q %v", s, ok) + } +} + +// TestCapitalizeASCII covers the three shapes the helper distinguishes: a +// lower-case ASCII lead (the only one it changes), an already-capital lead, and +// the empty string. +func TestCapitalizeASCII(t *testing.T) { + for _, c := range []struct{ in, want string }{ + {"invalid argument", "Invalid argument"}, + {"Already", "Already"}, + {"", ""}, + {"1 thing", "1 thing"}, + } { + if got := capitalizeASCII(c.in); got != c.want { + t.Errorf("capitalizeASCII(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestErrnoClassRegistration pins the three registration rules of MRI's +// Init_syserr: one class per number (so two names for one number are one class), +// a name with no number on this platform naming Errno::NOERROR, and every class +// carrying its number as its own Errno constant. +func TestErrnoClassRegistration(t *testing.T) { + vm := New(nil) + mod := vm.consts["Errno"].(*RClass) + if got, want := len(mod.consts), len(errnoNumbers)+len(errnoUndefinedNames)+len(errnoAliases); got != want { + t.Errorf("Errno has %d constants, want %d", got, want) + } + if mod.consts["EWOULDBLOCK"] != mod.consts["EAGAIN"] { + t.Errorf("EWOULDBLOCK must name EAGAIN's class") + } + noerror := mod.consts["NOERROR"].(*RClass) + for _, n := range errnoUndefinedNames { + if mod.consts[n] != noerror { + t.Fatalf("%s must name Errno::NOERROR", n) + } + } + for name, n := range errnoNumbers { + c := mod.consts[name].(*RClass) + if e, ok := c.consts["Errno"].(object.Integer); !ok || int64(e) != n { + t.Errorf("Errno::%s::Errno = %v, want %d", name, c.consts["Errno"], n) + } + if vm.consts["Errno::"+name] == nil { + t.Errorf("Errno::%s is not registered under its flat name", name) + } + } +} + +// TestErrnoClassLookup covers vm.errnoClass: a number a class claims, one no +// class claims, and a VM whose Errno constant is not a class at all (the guard +// that keeps the lookup from panicking if the module were ever shadowed). +func TestErrnoClassLookup(t *testing.T) { + vm := New(nil) + c := vm.errnoClass(errnoNumbers["EINVAL"]) + if c == nil || c != vm.consts["Errno::EINVAL"] { + t.Errorf("errnoClass(EINVAL) = %v, want Errno::EINVAL", c) + } + if got := vm.errnoClass(1 << 40); got != nil { + t.Errorf("errnoClass of an unclaimed number = %v, want nil", got) + } + // The lookup walks whatever the Errno module holds, so it has to survive a + // constant that is not a class, and a class with no Errno constant of its own + // — both of which a Ruby program can put there. + mod := vm.consts["Errno"].(*RClass) + mod.consts["NOT_A_CLASS"] = object.NewString("interloper") + mod.consts["NO_ERRNO"] = newClass("Errno::NO_ERRNO", nil) + if got := vm.errnoClass(errnoNumbers["EINVAL"]); got != vm.consts["Errno::EINVAL"] { + t.Errorf("a foreign constant must not disturb the lookup: got %v", got) + } + if got := vm.errnoClass(1 << 41); got != nil { + t.Errorf("errnoClass past the foreign constants = %v, want nil", got) + } + delete(mod.consts, "NOT_A_CLASS") + delete(mod.consts, "NO_ERRNO") + vm.consts["Errno"] = object.NewString("not a class") + if got := vm.errnoClass(2); got != nil { + t.Errorf("errnoClass with no Errno module = %v, want nil", got) + } +} + +// TestClassErrno covers the ancestry walk behind #errno: a class carrying its own +// Errno constant, a user subclass inheriting it, and a class outside the +// SystemCallError tree, which must NOT pick up the top-level Errno module that +// lives in Object's constant table. +func TestClassErrno(t *testing.T) { + vm := New(nil) + if e := vm.classErrno(vm.consts["Errno::EINVAL"].(*RClass)); e != object.IntValue(errnoNumbers["EINVAL"]) { + t.Errorf("classErrno(EINVAL) = %v", e) + } + sub := newClass("Sub", vm.consts["Errno::ENOENT"].(*RClass)) + if e := vm.classErrno(sub); e != object.IntValue(errnoNumbers["ENOENT"]) { + t.Errorf("a subclass must inherit its parent's Errno: got %v", e) + } + if e := vm.classErrno(vm.consts["SystemCallError"].(*RClass)); e != object.NilV { + t.Errorf("generic SystemCallError must have no Errno: got %v", e) + } + if e := vm.classErrno(vm.cObject); e != object.NilV { + t.Errorf("the walk must stop before Object's constant table: got %v", e) + } +} + +// TestBecomeErrnoClassGuards covers becomeErrnoClass's two non-rewriting paths: +// an errno no class claims leaves the receiver alone, and a receiver that is not +// a plain object is MRI's "invalid instance type" TypeError rather than a panic. +func TestBecomeErrnoClassGuards(t *testing.T) { + vm := New(nil) + o := &RObject{class: vm.consts["SystemCallError"].(*RClass), ivars: map[string]object.Value{}} + vm.becomeErrnoClass(o, 1<<40) + if o.class != vm.consts["SystemCallError"] { + t.Errorf("an unclaimed errno must leave the class alone, got %v", o.class) + } + defer func() { + r := recover() + e, ok := r.(RubyError) + if !ok || e.Class != "TypeError" || e.Message != "invalid instance type" { + t.Errorf("non-object receiver: got %v", r) + } + }() + vm.becomeErrnoClass(object.IntValue(3), errnoNumbers["EINVAL"]) +} + +// TestSyserrEqqNonException covers the two syserr_eqq branches that a Ruby-level +// spec cannot reach through the shim: an argument that is not a SystemCallError +// but does answer #errno (which MRI still compares), and one whose #errno is not +// an Integer, which falls through to a dispatched ==. +func TestSyserrEqqNonException(t *testing.T) { + vm := New(nil) + einval := vm.consts["Errno::EINVAL"].(*RClass) + out := runFS(t, ` +class Quacks; def errno; `+errnoLit(errnoNumbers["EINVAL"])+`; end; end +class QuacksFloat; def errno; `+errnoLit(errnoNumbers["EINVAL"])+`.0; end; end +class Mute; end +p Errno::EINVAL === Quacks.new +p Errno::EINVAL === QuacksFloat.new +p Errno::EINVAL === Mute.new +`) + if want := "true\ntrue\nfalse\n"; out != want { + t.Errorf("got %q, want %q", out, want) + } + if !vm.syserrEqq(vm.consts["SystemCallError"].(*RClass), vm.send(einval, "new", nil, nil)) { + t.Errorf("a generic SystemCallError must match any SystemCallError") + } +} + +// TestSyserrInitializeShapes drives SystemCallError#initialize through every +// branch of MRI's syserr_initialize from Ruby. Each expectation is the host MRI +// 4.0.5's own output, captured with the same expression. +func TestSyserrInitializeShapes(t *testing.T) { + // The message texts are the platform's strerror. darwin and glibc agree on + // all three used here (EPERM, ENOENT, EINVAL); Windows does not have POSIX + // errno numbers at all — Go's syscall spells them APPLICATION_ERROR+n — so + // there is no text to assert and no MRI on this host to witness one. + if runtime.GOOS == "windows" { + t.Skip("errno numbers and strerror texts are not POSIX on Windows, and there is no Windows MRI witness here") + } + einval := errnoLit(errnoNumbers["EINVAL"]) + for _, c := range []struct{ src, want string }{ + // Generic receiver: a lone Integer is the errno and rewrites the class. + {`e = SystemCallError.new(` + einval + `); p [e.class, e.message, e.errno]`, + `[Errno::EINVAL, "Invalid argument", ` + einval + "]\n"}, + // A lone non-Integer stays the message, and the object stays generic. + {`e = SystemCallError.new("m"); p [e.class, e.message, e.errno]`, + "[SystemCallError, \"unknown error - m\", nil]\n"}, + // nil message with an errno behaves as if the message were not passed. + {`p SystemCallError.new(nil, ` + einval + `).message`, "\"Invalid argument\"\n"}, + // The location argument is inserted before the custom message and is #to_s'd. + {`p SystemCallError.new("foo", 1, :not_a_string).message`, + "\"Operation not permitted @ not_a_string - foo\"\n"}, + // An errno no class claims leaves a generic SystemCallError. + {`e = SystemCallError.new(-1); p [e.class, e.message]`, + "[SystemCallError, \"Unknown error: -1\"]\n"}, + // Subclass receiver: (msg, func), errno from the class constant. + {`e = Errno::EINVAL.new; p [e.class, e.message, e.errno]`, + `[Errno::EINVAL, "Invalid argument", ` + einval + "]\n"}, + {`p Errno::EINVAL.new("custom message", "location").message`, + "\"Invalid argument @ location - custom message\"\n"}, + // A user subclass inherits the default message through its parent's Errno. + {`c = Class.new(Errno::ENOENT); p c.new("custom message").message`, + "\"No such file or directory - custom message\"\n"}, + // Arity, per receiver shape. + {`begin; SystemCallError.new; rescue ArgumentError => e; puts e.message; end`, + "wrong number of arguments (given 0, expected 1..3)\n"}, + {`begin; SystemCallError.new(1,2,3,4); rescue ArgumentError => e; puts e.message; end`, + "wrong number of arguments (given 4, expected 1..3)\n"}, + {`begin; Errno::EINVAL.new(1,2,3); rescue ArgumentError => e; puts e.message; end`, + "wrong number of arguments (given 3, expected 0..2)\n"}, + // Coercion failures, in MRI's order: the errno is converted first. + {`begin; SystemCallError.new(:foo, 1); rescue TypeError => e; puts e.message; end`, + "no implicit conversion of Symbol into String\n"}, + {`begin; SystemCallError.new("foo", "bar"); rescue TypeError => e; puts e.message; end`, + "no implicit conversion of String into Integer\n"}, + // A Float errno is truncated for the class lookup but stored as given. + {`e = SystemCallError.new("foo", 2.9); p [e.class, e.errno]`, "[Errno::ENOENT, 2.9]\n"}, + } { + if got := runFS(t, c.src); got != c.want { + t.Errorf("src=%q\n got=%q\nwant=%q", c.src, got, c.want) + } + } +} + +// TestSystemCallErrorEqqArity pins the single-argument arity of the .=== MRI +// defines with argc 1. +func TestSystemCallErrorEqqArity(t *testing.T) { + out := runFS(t, `begin; SystemCallError.===(1, 2); rescue ArgumentError => e; puts e.message; end`) + if want := "wrong number of arguments (given 2, expected 1)\n"; out != want { + t.Errorf("got %q, want %q", out, want) + } +} diff --git a/internal/vm/wave19_filedir_internal_test.go b/internal/vm/wave19_filedir_internal_test.go index 7d1361c9..cd22cda7 100644 --- a/internal/vm/wave19_filedir_internal_test.go +++ b/internal/vm/wave19_filedir_internal_test.go @@ -41,20 +41,38 @@ func TestRaiseRmdirErrClassification(t *testing.T) { } } -// TestRaiseRealpathErrClassification covers raiseRealpathErr and isSymlinkLoop by -// stubbing the osStat seam to surface a synthetic ELOOP / ENOENT, so both -// branches are exercised without creating a real symlink cycle. +// TestRaiseRealpathErrClassification covers raiseRealpathErr's relabelling: MRI +// reports a File.realpath ENOENT or ELOOP against the WHOLE argument under +// rb_check_realpath_internal, because realpath(3) failed before its emulation +// ran, and only ENOTDIR keeps realpath_rec's name and the failing component +// (ruby/ruby v3_4_0 file.c:4554). func TestRaiseRealpathErrClassification(t *testing.T) { - old := osStat - defer func() { osStat = old }() - - osStat = func(string) (os.FileInfo, error) { return nil, &os.PathError{Err: syscall.ELOOP} } - if got := expectRaise(t, func() { raiseRealpathErr("x") }); got != "Errno::ELOOP" { - t.Errorf("realpath loop raised %q, want Errno::ELOOP", got) - } - osStat = func(string) (os.FileInfo, error) { return nil, &os.PathError{Err: syscall.ENOENT} } - if got := expectRaise(t, func() { raiseRealpathErr("x") }); got != "Errno::ENOENT" { - t.Errorf("realpath missing raised %q, want Errno::ENOENT", got) + for _, c := range []struct { + err *realpathErr + wantClass, wantMsg string + }{ + {&realpathErr{"Errno::ELOOP", "Too many levels of symbolic links", "/comp"}, + "Errno::ELOOP", "Too many levels of symbolic links @ rb_check_realpath_internal - /arg"}, + {&realpathErr{"Errno::ENOENT", "No such file or directory", "/comp"}, + "Errno::ENOENT", "No such file or directory @ rb_check_realpath_internal - /arg"}, + {&realpathErr{"Errno::ENOTDIR", "Not a directory", "/comp"}, + "Errno::ENOTDIR", "Not a directory @ realpath_rec - /comp"}, + } { + var msg string + got := expectRaise(t, func() { + defer func() { + if r := recover(); r != nil { + if e, ok := r.(RubyError); ok { + msg = e.Message + } + panic(r) + } + }() + raiseRealpathErr(c.err, "/arg") + }) + if got != c.wantClass || msg != c.wantMsg { + t.Errorf("%v: raised %s/%q, want %s/%q", c.err.class, got, msg, c.wantClass, c.wantMsg) + } } }