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 + `"` }