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