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

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}