Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion internal/vm/fileutils.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
package vm

import (
"io/fs"
"os"
"path/filepath"
"time"

"github.com/go-embedded-ruby/ruby/internal/object"
Expand All @@ -24,6 +26,17 @@ var (
fuStat = os.Stat
fuChtimes = os.Chtimes
fuNow = time.Now
// fuWalkDir visits root and every descendant, invoking visit for each. It is a
// seam so a test can drive the error branch of the recursive operations.
fuWalkDir = func(root string, visit func(string)) error {
return filepath.WalkDir(root, func(p string, _ fs.DirEntry, err error) error {
if err != nil {
return err
}
visit(p)
return nil
})
}
)

// registerFileUtils installs the FileUtils module (require "fileutils"). The
Expand Down Expand Up @@ -151,15 +164,68 @@ func (vm *VM) registerFileUtils() {
return args[1]
})

// chown(user, group, list) sets ownership of each path, reusing File.chown's
// id coercion: a nil user/group leaves that id unchanged (POSIX -1) and it
// routes through the fileChown seam (a no-op on Windows, as in MRI). Puppet's
// FileSystem#replace_file calls this on the destination's existing owner when
// rewriting state.yaml / report files, so it must succeed rather than raise.
chownFn := func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
uid, gid := chownID(args[0]), chownID(args[1])
list := pathsOf(args[2])
for _, p := range list {
if err := fileChown(p, uid, gid); err != nil {
raise("Errno::ENOENT", "No such file or directory @ fileutils_chown - %s", p)
}
}
return args[2]
}
sdef("chown", chownFn)
// chown_R recurses into directories; the directory walk is shared with chmod_R.
sdef("chown_R", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
uid, gid := chownID(args[0]), chownID(args[1])
for _, p := range pathsOf(args[2]) {
for _, q := range fuWalk(p) {
if err := fileChown(q, uid, gid); err != nil {
raise("Errno::ENOENT", "No such file or directory @ fileutils_chown - %s", q)
}
}
}
return args[2]
})
// chmod_R recurses chmod into directories, as Puppet's report store driver does
// (FileUtils.chmod_R(0o750, dir)).
sdef("chmod_R", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
mode := os.FileMode(intArg(args[0]) & 0o7777)
for _, p := range pathsOf(args[1]) {
for _, q := range fuWalk(p) {
if err := fileChmod(q, mode); err != nil {
raise("Errno::ENOENT", "No such file or directory @ fileutils_chmod - %s", q)
}
}
}
return args[1]
})

// Genuinely platform-specific / stream operations: a clear NotImplementedError
// rather than a silently-wrong result.
notImpl := func(what string) NativeFn {
return func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value {
return raise("NotImplementedError", "FileUtils.%s not yet supported", what)
}
}
for _, m := range []string{"chown", "chown_R", "chmod_R", "copy_stream",
for _, m := range []string{"copy_stream",
"remove_entry_secure", "uptodate?", "ln", "ln_s", "ln_sf", "compare_file", "cp_r"} {
sdef(m, notImpl(m))
}
}

// fuWalk returns root followed by every path beneath it (files and
// directories), for the recursive FileUtils operations. A path that cannot be
// walked yields just itself, so the caller still attempts (and reports) the op.
func fuWalk(root string) []string {
var out []string
if err := fuWalkDir(root, func(p string) { out = append(out, p) }); err != nil {
return []string{root}
}
return out
}
110 changes: 109 additions & 1 deletion internal/vm/fileutils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func TestFileUtilsSuccess(t *testing.T) {

func TestFileUtilsNotImplemented(t *testing.T) {
vm := New(nil)
for _, m := range []string{"chown", "chown_R", "chmod_R", "copy_stream",
for _, m := range []string{"copy_stream",
"remove_entry_secure", "uptodate?", "ln", "ln_s", "ln_sf", "compare_file", "cp_r"} {
got := catchRaise(func() { callFU(t, vm, m, nil) })
if got != "NotImplementedError" {
Expand Down Expand Up @@ -208,6 +208,114 @@ func TestFileUtilsTouchErrors(t *testing.T) {
}()
}

// TestFileUtilsChown covers chown / chown_R / chmod_R through fake seams so the
// id coercion and recursive walk are exercised without real ownership changes
// (the fileChown seam is a Windows no-op, so the test drives both forms here).
func TestFileUtilsChown(t *testing.T) {
s := func(x string) object.Value { return object.NewString(x) }

// chown success: the fileChown seam records its calls; a nil user/group passes
// -1 (leave unchanged).
func() {
oc := fileChown
t.Cleanup(func() { fileChown = oc })
var calls [][3]any
fileChown = func(p string, uid, gid int) error {
calls = append(calls, [3]any{p, uid, gid})
return nil
}
vm := New(nil)
// chown(501, nil, "p") -> uid 501, gid -1.
r := callFU(t, vm, "chown", []object.Value{object.Integer(501), object.NilV, s("p")})
if r.ToS() != "p" {
t.Fatalf("chown return: %v", r)
}
if len(calls) != 1 || calls[0][1] != 501 || calls[0][2] != -1 {
t.Fatalf("chown calls: %v", calls)
}
}()

// chown_R recurses via the fuWalkDir seam.
func() {
oc, ow := fileChown, fuWalkDir
t.Cleanup(func() { fileChown, fuWalkDir = oc, ow })
var chowned []string
fileChown = func(p string, _, _ int) error { chowned = append(chowned, p); return nil }
fuWalkDir = func(root string, visit func(string)) error {
visit(root)
visit(root + "/child")
return nil
}
vm := New(nil)
callFU(t, vm, "chown_R", []object.Value{object.Integer(0), object.Integer(0), s("d")})
if len(chowned) != 2 || chowned[1] != "d/child" {
t.Fatalf("chown_R walked: %v", chowned)
}
}()

// chmod_R recurses via fuWalkDir and the fileChmod seam.
func() {
oc, ow := fileChmod, fuWalkDir
t.Cleanup(func() { fileChmod, fuWalkDir = oc, ow })
var chmodded []string
fileChmod = func(p string, _ os.FileMode) error { chmodded = append(chmodded, p); return nil }
fuWalkDir = func(root string, visit func(string)) error { visit(root); return nil }
vm := New(nil)
callFU(t, vm, "chmod_R", []object.Value{object.Integer(0o750), s("d")})
if len(chmodded) != 1 || chmodded[0] != "d" {
t.Fatalf("chmod_R: %v", chmodded)
}
}()
}

// TestFileUtilsChownErrors covers the Errno mapping when chown / chown_R /
// chmod_R fail, and the fuWalk fallback that yields the root on a walk error.
func TestFileUtilsChownErrors(t *testing.T) {
s := func(x string) object.Value { return object.NewString(x) }
perm := os.ErrPermission

cases := []struct {
name string
args []object.Value
}{
{"chown", []object.Value{object.Integer(1), object.Integer(1), s("p")}},
{"chown_R", []object.Value{object.Integer(1), object.Integer(1), s("p")}},
{"chmod_R", []object.Value{object.Integer(0o644), s("p")}},
}
for _, c := range cases {
oc, ocm, ow := fileChown, fileChmod, fuWalkDir
fileChown = func(string, int, int) error { return perm }
fileChmod = func(string, os.FileMode) error { return perm }
// A walk error makes fuWalk fall back to just the root.
fuWalkDir = func(string, func(string)) error { return perm }
vm := New(nil)
got := catchRaise(func() { callFU(t, vm, c.name, c.args) })
fileChown, fileChmod, fuWalkDir = oc, ocm, ow
if got != "Errno::ENOENT" {
t.Fatalf("%s error: got %q, want Errno::ENOENT", c.name, got)
}
}
}

// TestFuWalk covers the real filepath walk over a small tree (root + a child
// file), and the single-root fallback on a missing path.
func TestFuWalk(t *testing.T) {
dir := t.TempDir()
child := filepath.Join(dir, "f.txt")
if err := os.WriteFile(child, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
got := fuWalk(dir)
if len(got) != 2 {
t.Fatalf("fuWalk(%q)=%v, want root + child", dir, got)
}
// A non-existent path yields just itself.
miss := filepath.Join(dir, "nope")
if g := fuWalk(miss); len(g) != 1 || g[0] != miss {
t.Fatalf("fuWalk(missing)=%v", g)
}
}

// fakeFileInfo is a minimal os.FileInfo standing in for an existing file so the
// touch "exists" branch (Chtimes) is taken without any real filesystem state.
type fakeFileInfo struct{}
Expand Down
64 changes: 57 additions & 7 deletions internal/vm/yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,10 @@ import (
"github.com/go-embedded-ruby/ruby/internal/object"
)

// registerYAML installs a loadable shell for the YAML / Psych standard library
// (require "yaml"). A real YAML parser/emitter is a separate large subsystem; for
// now the module and its constant/error tree exist so load-time references
// resolve (Puppet requires yaml at load but only calls it from method bodies),
// while dump / load / safe_load / parse raise NotImplementedError if invoked.
// registerYAML installs the YAML / Psych standard library (require "yaml"). The
// module, its constant / error tree, the dump emitter and the load / safe_load /
// load_file parser are pure-Go; parse / parse_stream / dump_tags (the low-level
// node API) still raise NotImplementedError as Puppet does not call them.
func (vm *VM) registerYAML() {
psych := newClass("Psych", nil)
psych.isModule = true
Expand All @@ -35,10 +34,43 @@ func (vm *VM) registerYAML() {
return raise("NotImplementedError", "YAML %s not yet supported (pure-Go YAML pending)", what)
}
}
for _, m := range []string{"load", "safe_load", "load_file", "parse", "parse_stream", "dump_tags"} {
// The low-level node API (parse / parse_stream) and dump_tags are not used by
// Puppet's local persistence and remain unimplemented.
for _, m := range []string{"parse", "parse_stream", "dump_tags"} {
psych.smethods[m] = &Method{name: m, owner: psych, native: notImpl(m)}
}

// YAML.load(source[, ...]) / Psych.load parse a YAML document string to a tree
// of Ruby values (Hash / Array / String / Symbol / Integer / Float / true /
// false / nil / Time, and !ruby/object: instances). Leading keyword/positional
// options Psych accepts (permitted_classes, aliases, filename, …) are tolerated
// and ignored: this loader is already safe by construction (it instantiates
// only the classes named by !ruby/object: tags actually present), so safe_load
// shares the same implementation.
loadFn := func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
if len(args) == 0 {
raise("ArgumentError", "wrong number of arguments (given 0, expected 1..)")
}
return yamlLoad(vm, yamlSourceArg(args[0]))
}
psych.smethods["load"] = &Method{name: "load", owner: psych, native: loadFn}
psych.smethods["safe_load"] = &Method{name: "safe_load", owner: psych, native: loadFn}
psych.smethods["unsafe_load"] = &Method{name: "unsafe_load", owner: psych, native: loadFn}

// YAML.load_file(path[, ...]) reads the file and parses its contents.
loadFileFn := func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
if len(args) == 0 {
raise("ArgumentError", "wrong number of arguments (given 0, expected 1..)")
}
data, err := fuReadFile(strArg(args[0]))
if err != nil {
raise("Errno::ENOENT", "No such file or directory @ rb_sysopen - %s", strArg(args[0]))
}
return yamlLoad(vm, string(data))
}
psych.smethods["load_file"] = &Method{name: "load_file", owner: psych, native: loadFileFn}
psych.smethods["safe_load_file"] = &Method{name: "safe_load_file", owner: psych, native: loadFileFn}

// YAML.dump(obj[, io]) serialises a tree of plain values (Hash/Array/String/
// Symbol/Integer/Float/true/false/nil/Time) to a Psych-compatible document.
// With a second IO argument it writes the document there and returns the IO
Expand All @@ -51,11 +83,29 @@ func (vm *VM) registerYAML() {
if len(args) == 0 {
raise("ArgumentError", "wrong number of arguments (given 0, expected 1..)")
}
doc := object.NewString(yamlDump(args[0]))
doc := object.NewString(yamlDump(vm, args[0]))
if len(args) > 1 && args[1] != object.NilV {
vm.send(args[1], "write", []object.Value{doc}, nil)
return args[1]
}
return doc
}}

// Object#to_yaml (Psych installs this on Object) returns YAML.dump(self). It is
// what Puppet's report store terminus calls (`fh.print to_yaml`), so every
// object — including the full Puppet::Transaction::Report graph — serialises.
vm.cObject.methods["to_yaml"] = &Method{name: "to_yaml", owner: vm.cObject,
native: func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value {
return object.NewString(yamlDump(vm, self))
}}
}

// yamlSourceArg coerces YAML.load's first argument to a string: a String yields
// its contents, and any other value its to_s, so an IO-ish or symbol argument
// does not crash the loader (Puppet always passes a String).
func yamlSourceArg(v object.Value) string {
if s, ok := v.(*object.String); ok {
return s.Str()
}
return v.ToS()
}
Loading
Loading