1package rc23import (4 "strings"5 "testing"67 memfs "gno.land/p/moul/x/plan9/memfs/v0"8 ninep "gno.land/p/moul/x/plan9/ninep/v0"9 ns "gno.land/p/moul/x/plan9/ns/v0"10 synfs "gno.land/p/moul/x/plan9/synfs/v0"11)1213func clock(h int64) func() int64 { return func() int64 { return h } }1415func shell(t *testing.T, files ...string) (*Shell, *memfs.FS) {16 t.Helper()17 sh, fs := NewMemShell("glenda", ReadWrite, clock(100))18 for _, f := range files {19 if err := fs.WriteFile(f, "contents of "+f+"\n", 100); err != nil {20 t.Fatalf("seed %s: %v", f, err)21 }22 }23 return sh, fs24}2526func run(t *testing.T, sh *Shell, line string) string {27 t.Helper()28 out, err := sh.Run(line)29 if err != nil {30 t.Fatalf("%s: %v", line, err)31 }32 return out33}3435func TestTokenize(t *testing.T) {36 tests := []struct {37 name string38 in string39 want []string40 }{41 {"plain", "ls -l /dev", []string{"ls", "-l", "/dev"}},42 {"extra spaces", " ls /dev ", []string{"ls", "/dev"}},43 {"quoted", "echo 'hello world'", []string{"echo", "hello world"}},44 {"quote inside quote", "echo 'it''s'", []string{"echo", "it's"}},45 {"empty quoted word", "echo ''", []string{"echo", ""}},46 {"adjacent", "echo a'b c'd", []string{"echo", "ab cd"}},47 {"empty line", "", []string{}},48 }49 for _, tt := range tests {50 got, err := tokenize(tt.in)51 if err != nil {52 t.Errorf("%s: %v", tt.name, err)53 continue54 }55 if len(got) != len(tt.want) {56 t.Errorf("%s: got %v, want %v", tt.name, got, tt.want)57 continue58 }59 for i := range got {60 if got[i] != tt.want[i] {61 t.Errorf("%s: token %d got %q, want %q", tt.name, i, got[i], tt.want[i])62 }63 }64 }65 if _, err := tokenize("echo 'unterminated"); err != ErrQuote {66 t.Errorf("unterminated quote: got %v", err)67 }68}6970func TestSplitCommands(t *testing.T) {71 got, err := splitCommands("pwd; ls /\necho 'a;b'")72 if err != nil {73 t.Fatalf("%v", err)74 }75 want := []string{"pwd", " ls /", "echo 'a;b'"}76 if len(got) != len(want) {77 t.Fatalf("got %v", got)78 }79 for i := range want {80 if got[i] != want[i] {81 t.Errorf("%d: got %q, want %q", i, got[i], want[i])82 }83 }84}8586func TestPwdAndCd(t *testing.T) {87 sh, _ := shell(t, "/usr/glenda/lib/profile")88 if got := run(t, sh, "pwd"); got != "/\n" {89 t.Errorf("pwd: %q", got)90 }91 run(t, sh, "cd /usr/glenda")92 if got := run(t, sh, "pwd"); got != "/usr/glenda\n" {93 t.Errorf("pwd after cd: %q", got)94 }95 if got := run(t, sh, "cat lib/profile"); got != "contents of /usr/glenda/lib/profile\n" {96 t.Errorf("relative cat: %q", got)97 }98 run(t, sh, "cd")99 if got := run(t, sh, "pwd"); got != "/\n" {100 t.Errorf("bare cd should go to the root: %q", got)101 }102}103104func TestLs(t *testing.T) {105 sh, _ := shell(t, "/bin/ls", "/bin/rc", "/tmp/x")106 if got := run(t, sh, "ls /"); got != "bin/\ntmp/\n" {107 t.Errorf("ls /: %q", got)108 }109 if got := run(t, sh, "ls /bin"); got != "ls\nrc\n" {110 t.Errorf("ls /bin: %q", got)111 }112 long := run(t, sh, "ls -l /bin")113 if !strings.Contains(long, "-rw-r--r-- glenda") {114 t.Errorf("ls -l: %q", long)115 }116 if got := run(t, sh, "ls /bin/ls"); got != "ls\n" {117 t.Errorf("ls of a file: %q", got)118 }119 if _, err := sh.Run("ls -Z"); err == nil {120 t.Error("an unknown flag should fail")121 }122}123124func TestLsUnionAndUnique(t *testing.T) {125 sh, fs := shell(t)126 fs.WriteFile("/bin/ls", "system\n", 100)127 fs.WriteFile("/usr/glenda/bin/ls", "mine\n", 100)128 run(t, sh, "bind -b /usr/glenda/bin /bin")129130 if got := run(t, sh, "ls /bin"); got != "ls\nls\n" {131 t.Errorf("a union lists both, as Plan 9 does: %q", got)132 }133 if got := run(t, sh, "ls -u /bin"); got != "ls\n" {134 t.Errorf("ls -u: %q", got)135 }136 if got := run(t, sh, "cat /bin/ls"); got != "mine\n" {137 t.Errorf("the member bound before wins the walk: %q", got)138 }139}140141func TestCatErrors(t *testing.T) {142 sh, _ := shell(t, "/bin/ls")143 if _, err := sh.Run("cat /bin"); err == nil {144 t.Error("cat of a directory should fail")145 }146 if _, err := sh.Run("cat /absent"); err == nil {147 t.Error("cat of a missing file should fail")148 }149 if _, err := sh.Run("cat"); err == nil {150 t.Error("cat with no argument should fail")151 }152}153154func TestEchoAndRedirect(t *testing.T) {155 sh, _ := shell(t)156 if got := run(t, sh, "echo hello world"); got != "hello world\n" {157 t.Errorf("echo: %q", got)158 }159 if got := run(t, sh, "echo -n hello"); got != "hello" {160 t.Errorf("echo -n: %q", got)161 }162 run(t, sh, "echo hello > /greeting")163 if got := run(t, sh, "cat /greeting"); got != "hello\n" {164 t.Errorf("after redirect: %q", got)165 }166 run(t, sh, "echo again >> /greeting")167 if got := run(t, sh, "cat /greeting"); got != "hello\nagain\n" {168 t.Errorf("after append: %q", got)169 }170 run(t, sh, "echo replaced > /greeting")171 if got := run(t, sh, "cat /greeting"); got != "replaced\n" {172 t.Errorf("a plain redirect truncates: %q", got)173 }174 if _, err := sh.Run("echo hi >"); err == nil {175 t.Error("a redirect with no target should fail")176 }177}178179func TestMkdirAndRm(t *testing.T) {180 sh, _ := shell(t)181 run(t, sh, "mkdir /tmp")182 run(t, sh, "echo x > /tmp/f")183 if got := run(t, sh, "ls /tmp"); got != "f\n" {184 t.Errorf("ls /tmp: %q", got)185 }186 if _, err := sh.Run("rm /tmp"); err == nil || err.Error() != "rm: "+ninep.ErrNotEmpty.Error() {187 t.Errorf("rm of a non-empty directory: got %v", err)188 }189 run(t, sh, "rm /tmp/f")190 run(t, sh, "rm /tmp")191 if got := run(t, sh, "ls /"); got != "" {192 t.Errorf("root should be empty: %q", got)193 }194 if _, err := sh.Run("rm /absent"); err == nil {195 t.Error("rm of a missing file should fail")196 }197}198199func TestBindAndNs(t *testing.T) {200 sh, _ := shell(t, "/bin/ls", "/usr/glenda/bin/rc")201 run(t, sh, "bind -a /usr/glenda/bin /bin")202 if got := run(t, sh, "ls /bin"); got != "ls\nrc\n" {203 t.Errorf("ls after bind: %q", got)204 }205 want := "bind -a /usr/glenda/bin /bin\ncd /\n"206 if got := run(t, sh, "ns"); got != want {207 t.Errorf("ns:\ngot %q\nwant %q", got, want)208 }209 run(t, sh, "unmount /usr/glenda/bin /bin")210 if got := run(t, sh, "ls /bin"); got != "ls\n" {211 t.Errorf("after unmount: %q", got)212 }213 if got := run(t, sh, "ns"); got != "cd /\n" {214 t.Errorf("ns after unmount: %q", got)215 }216}217218func TestMountIsASynonym(t *testing.T) {219 sh, _ := shell(t, "/bin/ls", "/srv/dev/height")220 run(t, sh, "mkdir /dev")221 run(t, sh, "mount /srv/dev /dev")222 if got := run(t, sh, "ls /dev"); got != "height\n" {223 t.Errorf("ls /dev: %q", got)224 }225 if !strings.HasPrefix(run(t, sh, "ns"), "mount /srv/dev /dev\n") {226 t.Errorf("ns should record the verb used: %q", run(t, sh, "ns"))227 }228}229230func TestCreateInAUnionNeedsTheFlag(t *testing.T) {231 sh, _ := shell(t, "/bin/ls", "/usr/glenda/bin/rc")232 run(t, sh, "bind -a /usr/glenda/bin /bin")233 if _, err := sh.Run("echo x > /bin/new"); err == nil {234 t.Error("creating in a union with no -c should be refused")235 }236 run(t, sh, "bind -ac /usr/glenda/bin /bin")237 run(t, sh, "echo x > /bin/new")238 if got := run(t, sh, "cat /usr/glenda/bin/new"); got != "x\n" {239 t.Errorf("the create should land in the -c member: %q", got)240 }241}242243func TestReadOnlyModeRefusesWrites(t *testing.T) {244 sh, fs := NewMemShell("glenda", ReadOnly, clock(1))245 fs.WriteFile("/bin/ls", "x\n", 1)246247 // Reads still work.248 if got := run(t, sh, "ls /bin"); got != "ls\n" {249 t.Errorf("ls: %q", got)250 }251 if got := run(t, sh, "cat /bin/ls"); got != "x\n" {252 t.Errorf("cat: %q", got)253 }254 if got := run(t, sh, "echo hi"); got != "hi\n" {255 t.Errorf("echo without a redirect is not a write: %q", got)256 }257 for _, line := range []string{258 "mkdir /tmp",259 "rm /bin/ls",260 "bind /bin /bin",261 "unmount /bin",262 "echo hi > /f",263 } {264 _, err := sh.Run(line)265 if err == nil {266 t.Errorf("%q should be refused in ReadOnly mode", line)267 continue268 }269 if !strings.Contains(err.Error(), ErrReadOnly.Error()) {270 t.Errorf("%q: got %v, want a read-only error", line, err)271 }272 }273}274275func TestStopsAtTheFirstError(t *testing.T) {276 sh, _ := shell(t)277 out, err := sh.Run("echo one; cat /absent; echo three")278 if err == nil {279 t.Fatal("expected an error")280 }281 if out != "one\n" {282 t.Errorf("output should stop at the failure: %q", out)283 }284 if !strings.HasPrefix(err.Error(), "cat: ") {285 t.Errorf("the error should name the command: %v", err)286 }287}288289func TestUnknownCommand(t *testing.T) {290 sh, _ := shell(t)291 if _, err := sh.Run("nosuchthing"); err == nil {292 t.Error("expected an error")293 }294}295296func TestStatAndWalkShowTheUnion(t *testing.T) {297 sh, fs := shell(t)298 fs.WriteFile("/bin/ls", "a\n", 100)299 fs.WriteFile("/usr/glenda/bin/rc", "b\n", 100)300 run(t, sh, "bind -a /usr/glenda/bin /bin")301302 st := run(t, sh, "stat /bin")303 if !strings.Contains(st, "union=2") {304 t.Errorf("stat should report the union width: %q", st)305 }306 w := run(t, sh, "walk /bin/rc")307 lines := strings.Split(strings.TrimSpace(w), "\n")308 if len(lines) != 3 {309 t.Fatalf("walk should report /, /bin and /bin/rc: %q", w)310 }311 if !strings.Contains(lines[1], "union=2") {312 t.Errorf("the union should appear at /bin: %q", lines[1])313 }314 if !strings.Contains(lines[2], "union=1") {315 t.Errorf("a union is top level only, /bin/rc is not one: %q", lines[2])316 }317}318319func TestReadOnlyServerMountedInAWritableNamespace(t *testing.T) {320 // This is the cross-realm shape: a synthetic, read-only tree grafted into321 // a namespace whose root is writable. Reads work; writes are refused322 // where the server is read-only and still allowed elsewhere.323 sh, fs := shell(t)324 fs.MkdirAll("/dev", 100)325 tr := synfs.New("dev", "sys", clock(100))326 tr.Root().Add("height", func() string { return "100" })327 if err := sh.Ns().Mount(tr.Root(), "#dev", "/dev", ns.MREPL); err != nil {328 t.Fatalf("mount: %v", err)329 }330331 if got := run(t, sh, "cat /dev/height"); got != "100" {332 t.Errorf("read through the mount: %q", got)333 }334 if got := run(t, sh, "ls /dev"); got != "height\n" {335 t.Errorf("ls /dev: %q", got)336 }337 if _, err := sh.Run("echo x > /dev/height"); err == nil {338 t.Error("writing to a read-only server should be refused")339 }340 if _, err := sh.Run("rm /dev/height"); err == nil || err.Error() != "rm: "+ninep.ErrReadOnly.Error() {341 t.Errorf("rm on a read-only server: got %v", err)342 }343 // The rest of the namespace is unaffected.344 run(t, sh, "echo fine > /ok")345 if got := run(t, sh, "cat /ok"); got != "fine\n" {346 t.Errorf("got %q", got)347 }348}349350func TestHelpListsEveryCommand(t *testing.T) {351 sh, _ := shell(t)352 out := run(t, sh, "help")353 for _, cmd := range []string{"bind", "cat", "cd", "echo", "ls", "mkdir", "mount", "ns", "pwd", "rm", "stat", "unmount", "walk"} {354 if !strings.Contains(out, cmd) {355 t.Errorf("help does not mention %q", cmd)356 }357 }358}359Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.