ns.gno
15.00 Kb · 452 lines
1// Package ns is a Plan 9 namespace server for gno.land.
2//
3// Every account gets a private, persistent namespace: its own RAM root plus a
4// mount table it alone controls. Realms publish file trees into /srv, accounts
5// bind those trees wherever they like, and a read-only rc shell renders the
6// whole thing in gnoweb.
7//
8// This is the part of Plan 9 that gno does not otherwise have. The chain has a
9// single global tree of realm paths that looks the same to everybody; here a
10// name means what YOU bound it to. Composing two realms that were never
11// written to work together stops being a redeploy and becomes a transaction:
12//
13// gnokey maketx call -pkgpath gno.land/r/moul/x/plan9/ns/v1 -func Exec \
14// -args 'bind -ac /srv/dev /dev; echo hello > /tmp/greeting'
15//
16// SECURITY. Mounted trees are READ-ONLY by construction: ninep.File has no
17// mutating method, so grafting a foreign realm's tree into your namespace
18// cannot be turned into a write against that realm. Writes only ever reach a
19// memfs tree this realm created for you. A crossing write method would mint
20// THIS realm's frame for the callee, which is the confused-deputy shape that
21// r/gov/dao's Executor relies on deliberately and p/nt/grc20's Teller refuses
22// deliberately; it is out of scope for v0. See moul/gno-contracts#136.
23//
24// NOTICE. Plan 9 from Bell Labs is the work of the Computing Science Research
25// Center at Bell Labs; the name and the marks are theirs, and the copyright is
26// held by the Plan 9 Foundation (https://p9f.org). This realm is not
27// affiliated with, endorsed by, or sponsored by them, and contains no Plan 9
28// code: it borrows the vocabulary so that the design reads without a glossary,
29// and it is an homage, asking what that ecosystem's spirit looks like on a
30// chain. Full attribution: NOTICE.md at the root of moul/gno-contracts.
31package ns
32
33import (
34 "chain/runtime"
35 "errors"
36 "strconv"
37 "strings"
38
39 "gno.land/p/moul/kit/ui/v0"
40 "gno.land/p/moul/md/v0"
41 "gno.land/p/nt/avl/v0"
42
43 "gno.land/p/moul/realmpath/v0"
44
45 memfs "gno.land/p/moul/x/plan9/memfs/v0"
46 ninep "gno.land/p/moul/x/plan9/ninep/v0"
47 nspkg "gno.land/p/moul/x/plan9/ns/v0"
48 rc "gno.land/p/moul/x/plan9/rc/v0"
49)
50
51// DemoKey names the namespace gnoweb browses when no ?u= is given. It is a
52// plain string rather than an address so it can never collide with one.
53const DemoKey = "demo"
54
55// The bounds on what callers can create. Any realm may Post a service and any
56// account gets a namespace on its first Exec; the home page lists both.
57const (
58 MaxServices = 64
59 MaxNamespaces = 1000
60 HomeRecent = 50 // namespaces the home page lists
61)
62
63type service struct {
64 file ninep.File
65 owner string // pkgpath of the realm that posted it
66 addr address // that realm's address
67 since int64 // block height of the posting
68}
69
70type space struct {
71 fs *memfs.FS
72 ns *nspkg.Ns
73}
74
75var (
76 services = avl.NewTree() // name -> *service
77 spaces = avl.NewTree() // address string (or DemoKey) -> *space
78)
79
80func init() {
81 seedDemo()
82}
83
84// ---------------------------------------------------------------- /srv
85
86// srvDir is the synthetic directory that makes posted services reachable by
87// name. It is written out by hand rather than built with synfs because its
88// contents change as realms post, and because implementing ninep.File
89// directly is meant to look easy: four methods, no state of its own.
90type srvDir struct{}
91
92func (d *srvDir) Stat() ninep.Stat {
93 return ninep.Stat{
94 Qid: ninep.Qid{Type: ninep.QTDIR, Path: 1},
95 Mode: ninep.DMDIR | 0555,
96 Mtime: runtime.ChainHeight(),
97 Name: "srv",
98 Uid: "sys",
99 Gid: "sys",
100 Muid: "sys",
101 }
102}
103
104func (d *srvDir) Walk(name string) (ninep.File, error) {
105 if !ninep.ValidName(name) {
106 return nil, ninep.ErrBadName
107 }
108 v := services.Get(name)
109 if v == nil {
110 return nil, ninep.ErrNotExist
111 }
112 return v.(*service).file, nil
113}
114
115func (d *srvDir) Read(off, count int64) (string, error) { return "", ninep.ErrIsDir }
116
117func (d *srvDir) ReadDir() ([]ninep.Stat, error) {
118 out := []ninep.Stat{}
119 services.Iterate("", "", func(k string, v any) bool {
120 st := v.(*service).file.Stat()
121 st.Name = k // the posted name, not whatever the server calls its root
122 out = append(out, st)
123 return false
124 })
125 return out, nil
126}
127
128var srvRoot = &srvDir{}
129
130// ---------------------------------------------------------------- posting
131
132// Post publishes a file tree under name in /srv, where any account can bind
133// it. The posting realm is recorded and is the only one that may Unpost.
134//
135// Names are first come, first served, which is fine for an experiment and
136// would not be for anything else.
137func Post(cur realm, name string, f ninep.File) {
138 if !cur.IsCurrent() {
139 panic("post: cur is not the caller's live realm")
140 }
141 if !ninep.ValidName(name) {
142 panic("post: " + ninep.ErrBadName.Error())
143 }
144 if f == nil {
145 panic("post: nil file server")
146 }
147 prev := cur.Previous()
148 if services.Get(name) == nil && services.Size() >= MaxServices {
149 panic("post: /srv is full at " + strconv.Itoa(MaxServices) + " services")
150 }
151 if existing := services.Get(name); existing != nil {
152 if existing.(*service).owner != prev.PkgPath() {
153 panic("post: " + name + " is already posted by " + existing.(*service).owner)
154 }
155 }
156 services.Set(name, &service{
157 file: f,
158 owner: prev.PkgPath(),
159 addr: prev.Address(),
160 since: runtime.ChainHeight(),
161 })
162}
163
164// Unpost withdraws a service. Only the realm that posted it may do so.
165func Unpost(cur realm, name string) {
166 if !cur.IsCurrent() {
167 panic("unpost: cur is not the caller's live realm")
168 }
169 v := services.Get(name)
170 if v == nil {
171 panic("unpost: " + ninep.ErrNotExist.Error())
172 }
173 if v.(*service).owner != cur.Previous().PkgPath() {
174 panic("unpost: " + name + " belongs to " + v.(*service).owner)
175 }
176 services.Remove(name)
177}
178
179// Services lists the posted service names, in order.
180func Services() []string {
181 out := []string{}
182 services.Iterate("", "", func(k string, _ any) bool {
183 out = append(out, k)
184 return false
185 })
186 return out
187}
188
189// ---------------------------------------------------------------- namespaces
190
191// newSpace builds the default namespace, which is this chain's /lib/namespace:
192// a private ram root, the mount points that Plan 9 requires to exist before
193// anything can be bound onto them, /srv mounted, and /dev bound from it when a
194// device server has been posted.
195func newSpace(owner string) *space {
196 now := runtime.ChainHeight()
197 fs := memfs.New(owner, now)
198 fs.MkdirAll("/srv", now)
199 fs.MkdirAll("/dev", now)
200 fs.MkdirAll("/tmp", now)
201
202 n := nspkg.New(fs.Root())
203 n.Mount(srvRoot, "#s", "/srv", nspkg.MREPL)
204 if services.Has("dev") {
205 n.Bind("/srv/dev", "/dev", nspkg.MREPL)
206 }
207 return &space{fs: fs, ns: n}
208}
209
210func spaceFor(key string) *space {
211 if v := spaces.Get(key); v != nil {
212 return v.(*space)
213 }
214 if spaces.Size() >= MaxNamespaces {
215 panic("ns: full at " + strconv.Itoa(MaxNamespaces) + " namespaces")
216 }
217 sp := newSpace(key)
218 spaces.Set(key, sp)
219 return sp
220}
221
222func peek(key string) *space {
223 if v := spaces.Get(key); v != nil {
224 return v.(*space)
225 }
226 return nil
227}
228
229// seedDemo builds the namespace gnoweb shows by default. It is deliberately
230// reproducible: an example test resets it before rendering.
231func seedDemo() {
232 spaces.Remove(DemoKey)
233 sp := spaceFor(DemoKey)
234 now := runtime.ChainHeight()
235 sp.fs.WriteFile("/tmp/greeting", "hello from a namespace\n", now)
236 sp.fs.MkdirAll("/usr/glenda/bin", now)
237 sp.fs.WriteFile("/usr/glenda/bin/rc", "#!/bin/rc\n", now)
238 sp.fs.MkdirAll("/bin", now)
239 sp.fs.WriteFile("/bin/ls", "system ls\n", now)
240 sp.ns.Bind("/usr/glenda/bin", "/bin", nspkg.MAFTER|nspkg.MCREATE)
241 sp.ns.Cd("/usr/glenda")
242}
243
244// ResetDemo rebuilds the demo namespace. Anyone may call it: it is a demo, and
245// the alternative is a demo that the first visitor ruins for everybody.
246func ResetDemo(cur realm) {
247 if !cur.IsCurrent() {
248 panic("resetdemo: cur is not the caller's live realm")
249 }
250 seedDemo()
251}
252
253// ---------------------------------------------------------------- shell
254
255// Exec runs a command line against the CALLER's namespace and returns its
256// output. The namespace belongs to cur.Previous().Address(), so a user gets
257// theirs and a realm gets its own.
258//
259// An error panics, so a half-applied command line reverts with the
260// transaction rather than leaving a namespace nobody asked for.
261func Exec(cur realm, line string) string {
262 if !cur.IsCurrent() {
263 panic("exec: cur is not the caller's live realm")
264 }
265 key := cur.Previous().Address().String()
266 sp := spaceFor(key)
267 sh := rc.New(sp.ns, rc.ReadWrite, runtime.ChainHeight)
268 out, err := sh.Run(line)
269 if err != nil {
270 panic(err.Error())
271 }
272 return out
273}
274
275// Reset discards the caller's namespace, so the next use rebuilds the default.
276func Reset(cur realm) {
277 if !cur.IsCurrent() {
278 panic("reset: cur is not the caller's live realm")
279 }
280 spaces.Remove(cur.Previous().Address().String())
281}
282
283// Run executes a READ-ONLY command line against key's namespace. It is the
284// query side of Exec: no transaction, no writes, safe from Render.
285func Run(key, line string) (string, error) {
286 sp := peek(key)
287 if sp == nil {
288 return "", errors.New("no namespace for " + key)
289 }
290 sh := rc.New(sp.ns, rc.ReadOnly, runtime.ChainHeight)
291 return sh.Run(line)
292}
293
294// Namespace returns key's mount table, in ns(1) format.
295func Namespace(key string) string {
296 sp := peek(key)
297 if sp == nil {
298 return ""
299 }
300 return sp.ns.String()
301}
302
303// Keys lists the namespaces that exist, in order.
304func Keys() []string {
305 out := []string{}
306 spaces.Iterate("", "", func(k string, _ any) bool {
307 out = append(out, k)
308 return false
309 })
310 return out
311}
312
313// ---------------------------------------------------------------- render
314
315// Render browses a namespace.
316//
317// Render("") overview, posted services, how to drive it
318// Render("ns?u=<key>") the mount table
319// Render("ls/bin?u=<key>") a directory listing (ls -l)
320// Render("cat/tmp/greeting") a file
321// Render("stat/bin") the 9P stat, with the union width
322// Render("walk/bin/rc") how each element of a path resolves
323// Render("rc?c=<command>") any read-only rc command line
324//
325// ?u= selects the namespace; it defaults to the demo one.
326func Render(path string) string {
327 req := realmpath.Parse(path)
328 key := req.Query.Get("u")
329 if key == "" {
330 key = DemoKey
331 }
332 // A namespace is keyed by the address that created it, or is the demo one.
333 // Anything else names no namespace, and refusing it here is also what keeps
334 // a crafted ?u= out of the links and code spans every page builds from it.
335 if key != DemoKey && !address(key).IsValid() {
336 return "# 404\n\nno namespace by that name. `?u=` takes an address.\n"
337 }
338 parts := req.PathParts()
339 if len(parts) == 0 || parts[0] == "" {
340 return renderHome(key)
341 }
342
343 cmd := parts[0]
344 rest := "/" + strings.Join(parts[1:], "/")
345 var line string
346 switch cmd {
347 case "ns":
348 return renderCmd(key, "ns", "ns")
349 case "ls":
350 line = "ls -l " + quote(rest)
351 case "cat":
352 line = "cat " + quote(rest)
353 case "stat":
354 line = "stat " + quote(rest)
355 case "walk":
356 line = "walk " + quote(rest)
357 case "rc":
358 line = req.Query.Get("c")
359 if line == "" {
360 line = "help"
361 }
362 default:
363 return "# 404\n\nunknown command " + md.InlineCode(cmd) + ". Try `ls`, `cat`, `stat`, `walk`, `ns` or `rc?c=...`.\n"
364 }
365 return renderCmd(key, line, cmd+" "+rest)
366}
367
368func renderCmd(key, line, title string) string {
369 var b strings.Builder
370 b.WriteString("# " + ui.Inline(title) + "\n\n")
371 b.WriteString("namespace: `" + key + "`\n\n")
372 out, err := Run(key, line)
373 if err != nil {
374 b.WriteString(md.CodeBlock(err.Error()))
375 } else if out == "" {
376 b.WriteString("_(no output)_\n")
377 } else {
378 // The output is a user's file contents: a line of backticks in it
379 // would close a hand-written fence, and md.CodeBlock picks one it cannot.
380 b.WriteString(md.CodeBlock(strings.TrimSuffix(out, "\n")))
381 }
382 b.WriteString("\n[namespace](/r/moul/x/plan9/ns/v1:ns?u=" + key + ") · [root](/r/moul/x/plan9/ns/v1:ls?u=" + key +
383 ") · [home](/r/moul/x/plan9/ns/v1)\n")
384 return b.String()
385}
386
387func renderHome(key string) string {
388 var b strings.Builder
389 b.WriteString("# plan9: namespaces for gno\n\n")
390 b.WriteString("A Plan 9 namespace server. Every account owns a private mount table over ")
391 b.WriteString("[9P-shaped](https://9p.io/sys/doc/9.html) file trees: realms post trees ")
392 b.WriteString("into `/srv`, you `bind` them where you want them, and a name means what ")
393 b.WriteString("*you* bound it to.\n\n")
394
395 b.WriteString("## /srv\n\n")
396 if services.Size() == 0 {
397 b.WriteString("_No service is posted yet._\n\n")
398 } else {
399 b.WriteString("| name | posted by | since |\n|---|---|---|\n")
400 services.Iterate("", "", func(k string, v any) bool {
401 s := v.(*service)
402 b.WriteString("| " + ui.Cell(k) + " | [`" + s.owner + "`](" +
403 strings.TrimPrefix(s.owner, "gno.land") + ") | " +
404 strconv.FormatInt(s.since, 10) + " |\n")
405 return false
406 })
407 b.WriteString("\n")
408 }
409
410 b.WriteString("## The demo namespace\n\n")
411 b.WriteString(md.CodeBlock(strings.TrimSuffix(Namespace(DemoKey), "\n")) + "\n")
412 b.WriteString("`/bin` is a union: the system `/bin` first, then `/usr/glenda/bin`, ")
413 b.WriteString("with `-c` so new files land in the second. Listing it shows both members ")
414 b.WriteString("because a Plan 9 union is a concatenation, so shadowing stays visible.\n\n")
415 b.WriteString("- [ns](/r/moul/x/plan9/ns/v1:ns) · [ls /](/r/moul/x/plan9/ns/v1:ls) · [ls /bin](/r/moul/x/plan9/ns/v1:ls/bin) · ")
416 b.WriteString("[cat /tmp/greeting](/r/moul/x/plan9/ns/v1:cat/tmp/greeting) · [walk /bin/rc](/r/moul/x/plan9/ns/v1:walk/bin/rc)\n")
417 b.WriteString("- any read-only command: [`rc?c=ls -l /srv`](/r/moul/x/plan9/ns/v1:rc?c=ls%20-l%20/srv)\n\n")
418
419 b.WriteString("## Your own namespace\n\n")
420 b.WriteString("```\n")
421 b.WriteString("gnokey maketx call -pkgpath gno.land/r/moul/x/plan9/ns/v1 \\\n")
422 b.WriteString(" -func Exec -args 'bind -ac /srv/dev /dev; echo hi > /tmp/f'\n")
423 b.WriteString("```\n\n")
424 b.WriteString("Then browse it with `?u=<your address>`. `Reset` throws it away.\n\n")
425
426 b.WriteString("## Namespaces\n\n")
427 keys := Keys()
428 if len(keys) > HomeRecent {
429 b.WriteString(strconv.Itoa(len(keys)) + " namespaces, the first " + strconv.Itoa(HomeRecent) + " listed.\n\n")
430 keys = keys[:HomeRecent]
431 }
432 for _, k := range keys {
433 b.WriteString("- [`" + k + "`](/r/moul/x/plan9/ns/v1:ns?u=" + k + ")\n")
434 }
435 b.WriteString("\nDesign and analysis: ")
436 b.WriteString("[moul/gno-contracts#136](https://github.com/moul/gno-contracts/issues/136).\n")
437 b.WriteString("\n_Not affiliated with Plan 9. Plan 9 from Bell Labs is the ")
438 b.WriteString("work of the Computing Science Research Center at Bell Labs; the name ")
439 b.WriteString("and the marks are theirs, and the copyright is held by the ")
440 b.WriteString("[Plan 9 Foundation](https://p9f.org). This realm borrows the ")
441 b.WriteString("vocabulary and none of the code: it is an homage, asking what that ")
442 b.WriteString("ecosystem's spirit looks like on a chain._\n")
443 return b.String()
444}
445
446// quote wraps a path for rc if it needs it.
447func quote(s string) string {
448 // Always quoted: the path comes from the URL, and a route like
449 // cat/tmp/f;echo must read one file, not run a second command. The shell
450 // is read-only either way, but a path is data.
451 return "'" + strings.ReplaceAll(s, "'", "''") + "'"
452}