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

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}