ns_test.gno
9.96 Kb · 299 lines
1package ns
2
3import (
4 "strconv"
5 "strings"
6 "testing"
7
8 "gno.land/p/nt/testutils/v0"
9 "gno.land/p/nt/uassert/v0"
10
11 ninep "gno.land/p/moul/x/plan9/ninep/v0"
12 synfs "gno.land/p/moul/x/plan9/synfs/v0"
13)
14
15// probe is a tiny read-only server, standing in for another realm's tree.
16func probe(name, contents string) ninep.File {
17 t := synfs.New(name, "sys", func() int64 { return 1 })
18 t.Root().Add("value", func() string { return contents })
19 return t.Root()
20}
21
22func TestDemoNamespaceIsSeeded(t *testing.T) {
23 seedDemo()
24 out, err := Run(DemoKey, "ls -u /bin")
25 if err != nil {
26 t.Fatalf("ls: %v", err)
27 }
28 if out != "ls\nrc\n" {
29 t.Errorf("the demo /bin should be a union of two directories: %q", out)
30 }
31 if got := Namespace(DemoKey); !strings.Contains(got, "bind -ac /usr/glenda/bin /bin") {
32 t.Errorf("mount table: %q", got)
33 }
34 if got, _ := Run(DemoKey, "cat /tmp/greeting"); got != "hello from a namespace\n" {
35 t.Errorf("greeting: %q", got)
36 }
37}
38
39func TestRunIsReadOnly(t *testing.T) {
40 seedDemo()
41 if _, err := Run(DemoKey, "echo x > /tmp/f"); err == nil {
42 t.Fatal("Render's shell must refuse a write")
43 }
44 if _, err := Run(DemoKey, "rm /tmp/greeting"); err == nil {
45 t.Fatal("Render's shell must refuse a remove")
46 }
47 if _, err := Run("nobody", "ls /"); err == nil {
48 t.Fatal("an unknown namespace should not be created by a read")
49 }
50}
51
52func TestPostAndBind(cur realm, t *testing.T) {
53 services.Remove("probe")
54 Post(cross(cur), "probe", probe("probe", "42"))
55
56 found := false
57 for _, s := range Services() {
58 if s == "probe" {
59 found = true
60 }
61 }
62 if !found {
63 t.Fatalf("probe is not in /srv: %v", Services())
64 }
65
66 // A fresh namespace sees it through /srv without importing anything.
67 spaces.Remove("tester")
68 spaceFor("tester")
69 defer spaces.Remove("tester")
70 out, err := Run("tester", "cat /srv/probe/value")
71 if err != nil {
72 t.Fatalf("cat through /srv: %v", err)
73 }
74 if out != "42" {
75 t.Errorf("got %q, want 42", out)
76 }
77 services.Remove("probe")
78}
79
80func TestExecWritesToTheCallersOwnNamespace(cur realm, t *testing.T) {
81 alice := testutils.TestAddress("alice")
82 bob := testutils.TestAddress("bob")
83 spaces.Remove(alice.String())
84 spaces.Remove(bob.String())
85
86 testing.SetRealm(testing.NewUserRealm(alice))
87 Exec(cross(cur), "echo 'alice was here' > /tmp/note")
88
89 testing.SetRealm(testing.NewUserRealm(bob))
90 Exec(cross(cur), "echo 'bob was here' > /tmp/note")
91
92 got, err := Run(alice.String(), "cat /tmp/note")
93 if err != nil {
94 t.Fatalf("alice: %v", err)
95 }
96 if got != "alice was here\n" {
97 t.Errorf("alice's namespace: %q", got)
98 }
99 if got, _ = Run(bob.String(), "cat /tmp/note"); got != "bob was here\n" {
100 t.Errorf("bob's namespace: %q", got)
101 }
102
103 // Reset throws a namespace away; the next use rebuilds the default.
104 testing.SetRealm(testing.NewUserRealm(alice))
105 Reset(cross(cur))
106 if _, err := Run(alice.String(), "cat /tmp/note"); err == nil {
107 t.Error("the namespace should be gone after Reset")
108 }
109 spaces.Remove(alice.String())
110 spaces.Remove(bob.String())
111}
112
113func TestExecBindsIntoTheCallersNamespace(cur realm, t *testing.T) {
114 services.Remove("probe")
115 Post(cross(cur), "probe", probe("probe", "42"))
116 defer services.Remove("probe")
117
118 erin := testutils.TestAddress("erin")
119 spaces.Remove(erin.String())
120 testing.SetRealm(testing.NewUserRealm(erin))
121 Exec(cross(cur), "bind /srv/probe /dev")
122
123 if got := mustRun(t, erin.String(), "cat /dev/value"); got != "42" {
124 t.Errorf("read through the mount: %q", got)
125 }
126 if ns := Namespace(erin.String()); !strings.Contains(ns, "bind /srv/probe /dev") {
127 t.Errorf("mount table: %q", ns)
128 }
129 spaces.Remove(erin.String())
130}
131
132func TestSrvListingUsesThePostedName(cur realm, t *testing.T) {
133 services.Remove("aliased")
134 // The server calls its own root "probe"; /srv must show it as "aliased".
135 Post(cross(cur), "aliased", probe("probe", "x"))
136 defer services.Remove("aliased")
137
138 spaces.Remove("lister")
139 spaceFor("lister")
140 out := mustRun(t, "lister", "ls /srv")
141 if !strings.Contains(out, "aliased") {
142 t.Errorf("ls /srv: %q", out)
143 }
144 if strings.Contains(out, "probe") {
145 t.Errorf("/srv leaked the server's own root name: %q", out)
146 }
147 spaces.Remove("lister")
148}
149
150func mustRun(t *testing.T, key, line string) string {
151 t.Helper()
152 out, err := Run(key, line)
153 if err != nil {
154 t.Fatalf("%s: %v", line, err)
155 }
156 return out
157}
158
159// --- the boundary. Three properties the suite would be unsafe without. -------
160
161// A /srv name belongs to the realm that posted it: first come, first served,
162// and then locked.
163func TestPostRejectsASecondOwner(cur realm, t *testing.T) {
164 services.Remove("taken")
165 Post(cross(cur), "taken", probe("taken", "first"))
166 defer services.Remove("taken")
167
168 testing.SetRealm(testing.NewCodeRealm("gno.land/r/other/thing/v0"))
169 uassert.AbortsWithMessage(t, cur,
170 "post: taken is already posted by gno.land/r/moul/x/plan9/ns/v1",
171 func() { Post(cross(cur), "taken", probe("taken", "second")) })
172}
173
174// Grafting another realm's tree into your namespace gives you reads and
175// nothing else. ninep.File has no mutating method, so there is no route from a
176// mount to a write against the realm that posted it.
177func TestMountedTreesAreReadOnly(cur realm, t *testing.T) {
178 services.Remove("probe")
179 Post(cross(cur), "probe", probe("probe", "read me"))
180 defer services.Remove("probe")
181
182 frank := testutils.TestAddress("frank")
183 spaces.Remove(frank.String())
184 defer spaces.Remove(frank.String())
185
186 testing.SetRealm(testing.NewUserRealm(frank))
187 Exec(cross(cur), "bind /srv/probe /dev")
188 uassert.Equal(t, "read me", mustRun(t, frank.String(), "cat /dev/value"))
189 uassert.AbortsWithMessage(t, cur, "echo: read-only file server",
190 func() { Exec(cross(cur), "echo nope > /dev/value") })
191}
192
193// A command line stops at the first error and the error leaves the realm as an
194// abort, so a half-applied Exec reverts with its transaction instead of
195// leaving a namespace nobody asked for.
196func TestExecAbortsAtTheFirstError(cur realm, t *testing.T) {
197 grace := testutils.TestAddress("grace")
198 spaces.Remove(grace.String())
199 defer spaces.Remove(grace.String())
200
201 testing.SetRealm(testing.NewUserRealm(grace))
202 uassert.AbortsWithMessage(t, cur, "cat: file does not exist",
203 func() { Exec(cross(cur), "echo ok > /tmp/a; cat /absent; echo never > /tmp/b") })
204}
205
206func TestRenderUnknownCommand(t *testing.T) {
207 if got := Render("nope/x"); !strings.Contains(got, "404") {
208 t.Errorf("got %q", got)
209 }
210}
211
212// ?u= is the only caller-controlled part of every link the pages build, so a
213// value that is neither an address nor the demo key is refused outright.
214func TestRenderRefusesANonAddressNamespace(t *testing.T) {
215 out := Render("ns?u=x%29%20[evil](https://example)")
216 uassert.True(t, strings.Contains(out, "# 404"), out)
217 uassert.False(t, strings.Contains(out, "[evil](https://example)"), out)
218}
219
220// A file's contents are its owner's text. A line of backticks in one used to
221// close the hand-written fence around `cat`'s output, and everything after it
222// rendered as live markdown on the realm's own page.
223func TestCatOutputCannotCloseItsFence(cur realm, t *testing.T) {
224 who := testutils.TestAddress("fencer")
225 testing.SetRealm(testing.NewUserRealm(who))
226 Exec(cross(cur), "echo '```' > /tmp/f; echo '[claim](https://evil.example)' >> /tmp/f")
227 out := Render("cat/tmp/f?u=" + who.String())
228 // md.CodeBlock picks a fence longer than any run of backticks inside, so
229 // the user's three backticks and the link after them stay inside the block.
230 uassert.True(t, strings.Contains(out, "````\n```\n[claim](https://evil.example)\n````\n"), out)
231}
232
233// The command name and the rest of the path come from the URL: escaped in the
234// heading and in the unknown-command page.
235func TestPathDerivedTextIsEscaped(t *testing.T) {
236 for _, path := range []string{"x` [claim](https://evil.example) `", "cat/x` [claim](https://evil.example) `"} {
237 out := Render(path)
238 uassert.False(t, strings.Contains(out, "[claim](https://evil.example)"), out)
239 }
240}
241
242// A service name is chosen by whichever realm posts it, and ValidName refuses
243// only "", ".", ".." and "/": the /srv table escapes it as a cell.
244func TestServiceNameIsTableSafe(cur realm, t *testing.T) {
245 name := "x` | [claim](https://evil.example) |"
246 services.Set(name, &service{owner: "gno.land/r/someone/poster"})
247 defer services.Remove(name)
248 out := Render("")
249 uassert.False(t, strings.Contains(out, "[claim](https://evil.example)"), out)
250}
251
252// A path route reads what it names: the rest of the path is one quoted
253// argument, so a ";" in it is part of the file name and runs nothing.
254func TestPathRouteIsOneArgument(t *testing.T) {
255 // Unquoted, rc would cat /tmp/greeting and then run the second command;
256 // quoted, it looks for one file with a ";" in its name and finds none.
257 out := Render("cat/tmp/greeting;echo")
258 uassert.True(t, strings.Contains(out, "cat: file does not exist"), out)
259 uassert.Equal(t, "'/a;b'", quote("/a;b"))
260}
261
262// Namespaces and services are bounded, and the home page lists a fixed number.
263func TestNamespacesAndServicesAreBounded(cur realm, t *testing.T) {
264 var added []string
265 for i := 0; spaces.Size() < MaxNamespaces; i++ {
266 k := testutils.TestAddress("ns" + strconv.Itoa(i)).String()
267 if spaces.Get(k) == nil {
268 spaces.Set(k, newSpace(k))
269 added = append(added, k)
270 }
271 }
272 defer func() {
273 for _, k := range added {
274 spaces.Remove(k)
275 }
276 }()
277 uassert.PanicsContains(t, cur, "full at", func() { spaceFor(testutils.TestAddress("one-more").String()) })
278 uassert.Equal(t, HomeRecent, strings.Count(Render(""), "](/r/moul/x/plan9/ns/v1:ns?u="))
279}
280
281// /srv is bounded: once MaxServices names are posted, a new one is refused,
282// and re-posting is not how a realm gets around it.
283func TestServicesAreBounded(cur realm, t *testing.T) {
284 var added []string
285 for i := 0; services.Size() < MaxServices; i++ {
286 name := "svc" + strconv.Itoa(i)
287 if services.Get(name) == nil {
288 services.Set(name, &service{owner: "gno.land/r/someone/poster"})
289 added = append(added, name)
290 }
291 }
292 defer func() {
293 for _, n := range added {
294 services.Remove(n)
295 }
296 }()
297 testing.SetRealm(testing.NewCodeRealm("gno.land/r/someone/late"))
298 uassert.AbortsContains(t, cur, "/srv is full", func() { Post(cross(cur), "late", probe("late", "x")) })
299}