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
59 changes: 55 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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"] = "/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
Expand All @@ -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
Expand Down
114 changes: 114 additions & 0 deletions readme_embedding_contract_test.go
Original file line number Diff line number Diff line change
@@ -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 + `"` }
Loading