Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}