From 6fc167da45079ac774ea67f3b1d6fe00ca9f31dd Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 4 Oct 2026 20:57:44 +0200 Subject: [PATCH] docs: say what an embedded Run() can reach, and stop claiming nothing is shared The embedding section promises its contract "as measured rather than intended". Two of its statements were not what the code does, and a third capability was not mentioned at all. Each correction below is a measurement against origin/main through the public `Run` and nothing else. "Nothing is shared between calls" is true of the Ruby heap and false of the process. A script that assigns to `ENV` and calls `Dir.chdir` changes them for the host Go program and they stay changed after `Run` returns: a host whose PATH was 860 bytes and whose cwd was its build directory came back with a 13-byte PATH and a cwd of /private/tmp. The bullet immediately below it already documented that `require_relative` resolves against the *process* working directory, so the README contained the fact that contradicted it; one `Run` can therefore plant a file a later `Run` loads. The bullet is now scoped to the heap, which is the part that holds -- a global, a constant and a String monkey-patch really are gone in the next call. `exit`, `exit!` and `abort` make `Run` return nil, so an embedder cannot tell "finished" from "stopped": `abort "fatal"` writes its message to `out` and reports success, and `exit 3` loses the 3. Only a Ruby exception becomes a Go error. Said explicitly, with the reason it cannot be worked around today -- the CLI recovers the status through `internal/vm`, which is unreachable. `Kernel#fork` exists and runs the block in the same process, returning a synthetic pid while `Process.pid` is unchanged, so the "child" mutates the parent's globals, ENV and cwd. The README said only that `Process.fork` does not exist, which reads as "fork is unavailable" -- but `fork { }` is the spelling almost every program uses, and code that forks to isolate or to drop privileges gets neither. Added as a divergence row and cross-referenced from the Process section. Finally a "Run is not a sandbox" subsection. This is not a defect report: MRI is not sandboxed either and conformance is the goal. It is worth stating because MRI is normally a CLI a person runs on their own code whereas `Run` is a function someone links into a server. It also records the two things that are deliberately out of reach -- no path from Ruby to the host's Go state, and the host's standard descriptors are not exposed, with IO.for_fd(1) raising EBADF where MRI writes to the real descriptor -- because those are properties worth not losing by accident. The new test file pins all of it, in the spirit of #765: the prose cannot drift from the code silently, and each failure is the signal to rewrite the bullet. The raise-becomes-an-error twin is there on purpose -- without it the exit-returns-nil test would also pass if `Run` swallowed every error. Refs #768. Co-Authored-By: Claude Opus 5 --- README.md | 59 ++++++++++++++-- readme_embedding_contract_test.go | 114 ++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 readme_embedding_contract_test.go diff --git a/README.md b/README.md index 95610f6e..345b328e 100644 --- a/README.md +++ b/README.md @@ -352,7 +352,10 @@ JRuby**): (`getrlimit`/`setrlimit` with the `RLIMIT_*` constants), plus `pid`/`ppid`/`uid`/`gid`/`euid`/`egid`. There is **no `Process.fork`** (`Process.respond_to?(:fork)` is `false`) — Go's runtime cannot be forked - safely; `spawn` is the supported way to start a child. + safely; `spawn` is the supported way to start a child. **`Kernel#fork` is a + different story**: `fork { ... }` *does* exist and runs the block in the same + process — see the table under *Known divergences*, because code that forks to + isolate or to drop privileges gets neither here. - **Pattern matching (`case`/`in`):** value, variable-binding, class/constant, array (incl. splat and nested), hash (`deconstruct_keys`, `**rest`/`**nil`), find (`[*pre, x, *post]`), pin (`^x`) and alternative (`a | b`) patterns; @@ -662,6 +665,7 @@ Otherwise: | magic encoding comments | `# encoding: ascii-8bit` is not honoured — a literal still reports `UTF-8`, where MRI reports `ASCII-8BIT` | | `pp` | neither `Kernel#pp` nor `require "pp"` exists (`require "prettyprint"` does work) | | `Process.fork` | does not exist — Go's runtime cannot be forked safely | +| `Kernel#fork` | **exists, and does not fork.** `fork { ... }` runs the block **in the same process**, so the “child” mutates the parent's globals, `ENV` and working directory, and returns a synthetic pid (`100001`, counting up) while `Process.pid` is unchanged. Under MRI the block runs in a real child and the parent is untouched. A `SystemExit` raised inside the block unwinds the whole program | | `BEGIN { }` / `END { }` | do not parse (`parse error: unexpected "{" after statement`). Everything else the front-end was known to refuse now parses — see [go-ruby-parser](https://github.com/go-ruby-parser/parser) | | `Thread#backtrace` for another thread | raises `NotImplementedError`; rbgo keeps one frame stack per VM, not per thread | @@ -788,9 +792,24 @@ The contract, as measured rather than intended: returns an error whose message is `ArgumentError: bad`, and writes nothing to `out`. A syntax error comes back the same way, with a line number: `parse error at line 1: ...`. -- **Every call is a fresh VM.** A global set in one `Run` is gone in the next, and - nothing is shared between calls. There is no exported way to hold a VM open, - pre-load code into it, call a Ruby method from Go, or pass Go values in. +- **Every call is a fresh VM — but the same process.** The Ruby heap is not + shared: a global, a constant and a `String` monkey-patch set in one `Run` are + all gone in the next. **Process-global state is shared**, with the host and + with every other `Run`: `ENV`, the working directory, open files, signal + handlers and the filesystem. A script that runs `ENV["PATH"] = "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/attacker/bin"` + and `Dir.chdir("/tmp")` changes them *for the host Go program*, and they stay + changed after `Run` returns — measured: a host whose `PATH` was 860 bytes and + whose cwd was its build directory came back with a 13-byte `PATH` and a cwd of + `/private/tmp`. Because `require_relative` resolves against that same working + directory (next bullet), one `Run` can also write a file that a later `Run` + loads. There is no exported way to hold a VM open, pre-load code into it, call + a Ruby method from Go, or pass Go values in. +- **A program that exits is reported as success.** `exit`, `exit!` and `abort` + stop the program and `Run` returns **`nil`**, so an embedder cannot tell + “finished” from “stopped”: `abort "fatal"` writes `fatal` to `out` and reports + no error, and `exit 3` loses the 3. Only a Ruby *exception* becomes a Go error. + The `rbgo` CLI recovers the status through `internal/vm`, which is unreachable + from outside, so today there is no way for an embedder to obtain it. - **`require_relative` resolves against the process working directory**, not against a script, because `Run` has no file to anchor to. From a different working directory the same program fails with @@ -799,6 +818,38 @@ The contract, as measured rather than intended: The VM stays alive after `Run` returns as long as something still references it — which is what lets an event-driven embedded program keep running. +### `Run` is not a sandbox + +Conformance is the goal, and MRI is not sandboxed either — so neither is this. +That is the intended behaviour and not a defect, but it is worth stating plainly, +because MRI is normally a CLI a person invokes on their own code, whereas `Run` +is a function someone links into a server. **Do not pass untrusted Ruby to it.** +Measured, through the one public function and nothing else: + +- **A shell.** `system`, backticks, `%x{}`, `IO.popen` and `Process.spawn` all + spawn real processes as the host's uid, and anything with a shell + metacharacter goes to `/bin/sh -c`, so pipes, `;`, `$(...)`, globs and + redirection all work. +- **The whole filesystem**, read and write, wherever the host's uid can reach: + `File.read("/etc/passwd")` returns it, `Dir.glob("~/.ssh/*")` lists 34 entries + here, and reads and writes outside the working directory are unrestricted. + `File.symlink` works, so a readable path can be pointed anywhere. +- **The network.** A script opened a listening `TCPServer`, connected to it with + `TCPSocket` and exchanged bytes, all inside one `Run`. +- **Any Ruby file on disk**: `require` and `load` take absolute paths. +- **The host's environment and working directory**, as described above. + +Two things it does *not* reach, deliberately: there is **no path from Ruby to the +host's Go state** (no `RubyVM`, `ObjectSpace` is a stub, `fiddle` does not load, +a `.so` is refused, and CGO is 0), and the host's standard descriptors are **not** +exposed — `IO.for_fd(1)` raises `Errno::EBADF` where MRI writes to the real +descriptor, so a script cannot write outside the `out` you passed. + +There is also no call-depth limit yet: runaway recursion overflows the **Go** +stack, which is a fatal runtime error that `recover()` cannot contain, so it +takes the host process with it. Tracked in +[#768](https://github.com/go-embedded-ruby/ruby/issues/768). + ## WebAssembly **WASI (`GOOS=wasip1 GOARCH=wasm`) works and is gated in CI.** The interpreter diff --git a/readme_embedding_contract_test.go b/readme_embedding_contract_test.go new file mode 100644 index 00000000..bf82781b --- /dev/null +++ b/readme_embedding_contract_test.go @@ -0,0 +1,114 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package ruby_test + +import ( + "bytes" + "os" + "strings" + "testing" + + ruby "github.com/go-embedded-ruby/ruby" +) + +// The README's "Embedding in a Go program" section states its contract "as +// measured rather than intended". These tests are that measurement, so the +// prose cannot drift away from the code silently: each one fails if the +// behaviour changes, which is the signal to rewrite the bullet rather than the +// test. They are deliberately about the embedding API only -- the one exported +// function -- because that is all the section describes. + +// A program that stops itself is reported as success. README: "exit, exit! and +// abort stop the program and Run returns nil". +func TestREADMEExitIsReportedAsSuccess(t *testing.T) { + for _, tc := range []struct { + name, src, wantOut string + }{ + {"abort", `puts "before"` + "\n" + `abort "fatal"` + "\n" + `puts "after"`, "fatal"}, + {"exit", `puts "before"` + "\n" + `exit 3` + "\n" + `puts "after"`, ""}, + {"exit!", `puts "before"` + "\n" + `exit!(42)` + "\n" + `puts "after"`, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + var out bytes.Buffer + if err := ruby.Run(tc.src, &out); err != nil { + t.Fatalf("Run returned %v; the README says a program that exits reports no error", err) + } + got := out.String() + if !strings.Contains(got, "before") { + t.Fatalf("output %q does not contain %q: the program did not run at all", got, "before") + } + if strings.Contains(got, "after") { + t.Fatalf("output %q contains %q: the program was not stopped", got, "after") + } + if tc.wantOut != "" && !strings.Contains(got, tc.wantOut) { + t.Fatalf("output %q does not contain %q", got, tc.wantOut) + } + }) + } +} + +// A Ruby exception, by contrast, does become a Go error -- the half of the +// contract that holds. Without this twin the test above would also pass if Run +// swallowed every error, so it is what makes the one above mean something. +func TestREADMERaiseBecomesAnError(t *testing.T) { + var out bytes.Buffer + err := ruby.Run(`raise ArgumentError, "bad"`, &out) + if err == nil { + t.Fatal("Run returned nil for a raise; the README says a Ruby exception becomes a Go error") + } + if got := err.Error(); !strings.Contains(got, "ArgumentError") || !strings.Contains(got, "bad") { + t.Fatalf("error %q does not name the class and message", got) + } +} + +// The Ruby heap is not shared between calls. README: "a global, a constant and a +// String monkey-patch set in one Run are all gone in the next". +func TestREADMERubyHeapIsNotSharedBetweenCalls(t *testing.T) { + var first bytes.Buffer + if err := ruby.Run(`$leak = "x"; LEAKC = "y"; class String; def hijacked?; true; end; end`, &first); err != nil { + t.Fatalf("first Run: %v", err) + } + var second bytes.Buffer + if err := ruby.Run(`puts $leak.inspect +puts defined?(LEAKC).inspect +puts "".respond_to?(:hijacked?)`, &second); err != nil { + t.Fatalf("second Run: %v", err) + } + if got, want := second.String(), "nil\nnil\nfalse\n"; got != want { + t.Fatalf("second Run saw %q, want %q: state leaked across calls", got, want) + } +} + +// Process-global state IS shared, with the host and with every other Run. +// README: a script that assigns to ENV changes it for the host Go program, and +// it stays changed after Run returns. The cwd behaves the same way; it is not +// exercised here because changing the test binary's working directory would +// disturb every other test in the package. +func TestREADMEProcessEnvIsSharedWithTheHost(t *testing.T) { + const key = "RBGO_README_CONTRACT_PROBE" + t.Setenv(key, "set-by-the-host") // restored by the framework + + var out bytes.Buffer + if err := ruby.Run(`ENV[`+rubyStr(key)+`] = "set-inside-run"`, &out); err != nil { + t.Fatalf("Run: %v", err) + } + if got := os.Getenv(key); got != "set-inside-run" { + t.Fatalf("host sees %q after Run, want %q: the README says ENV is shared with the host", got, "set-inside-run") + } + + // And a later Run sees it too, which is what makes the sharing matter. + var second bytes.Buffer + if err := ruby.Run(`puts ENV[`+rubyStr(key)+`].inspect`, &second); err != nil { + t.Fatalf("second Run: %v", err) + } + if got, want := second.String(), "\"set-inside-run\"\n"; got != want { + t.Fatalf("second Run saw %q, want %q", got, want) + } +} + +// rubyStr renders a Go string as a Ruby string literal. Spelled out rather than +// using %q inside a fmt.Sprintf of Ruby source, so the generated program is +// obvious at the call site. +func rubyStr(s string) string { return `"` + s + `"` }