rc.gno
12.87 Kb · 563 lines
1// Package rc is a small shell over a Plan 9 namespace.
2//
3// It exists because a namespace you cannot inspect is a namespace you cannot
4// trust. Everything here operates on a gno.land/p/moul/x/plan9/ns namespace
5// and returns text, so one realm's Render becomes a file browser and one
6// transaction becomes a shell command:
7//
8// Exec("bind -a /srv/dev /dev; echo hello > /tmp/greeting")
9//
10// The mode split is the security model. A Shell in ReadOnly mode refuses every
11// mutating command, which is what lets a realm expose the shell through
12// Render, where mutating anything would be a bug, while the same code backs a
13// crossing Exec that may write.
14//
15// Quoting follows rc: single quotes, with ” inside a quoted string standing
16// for one literal quote. Commands are separated by ';' or newlines, and
17// quoting is respected when splitting them.
18//
19// On the first error the run STOPS and returns it. A realm should let that
20// error panic, so a half-applied command line reverts with the transaction
21// rather than leaving the namespace in a state nobody asked for.
22//
23// NOTICE. Plan 9 from Bell Labs is the work of the Computing Science Research
24// Center at Bell Labs; the name and the marks are theirs, and the copyright is
25// held by the Plan 9 Foundation (https://p9f.org). This package is not
26// affiliated with, endorsed by, or sponsored by them, and contains no Plan 9
27// code: it borrows the vocabulary so that the design reads without a glossary,
28// and it is an homage, asking what that ecosystem's spirit looks like on a
29// chain. Full attribution: NOTICE.md at the root of moul/gno-contracts.
30package rc
31
32import (
33 "errors"
34 "strconv"
35 "strings"
36
37 memfs "gno.land/p/moul/x/plan9/memfs/v0"
38 ninep "gno.land/p/moul/x/plan9/ninep/v0"
39 ns "gno.land/p/moul/x/plan9/ns/v0"
40)
41
42// Mode decides whether mutating commands are allowed.
43type Mode uint8
44
45// Shell modes.
46const (
47 ReadOnly Mode = 0
48 ReadWrite Mode = 1
49)
50
51var (
52 // ErrReadOnly is returned when a mutating command runs in ReadOnly mode.
53 ErrReadOnly = errors.New("read-only shell")
54 // ErrUsage reports a malformed command line.
55 ErrUsage = errors.New("usage")
56 // ErrUnknown reports a command this shell does not have.
57 ErrUnknown = errors.New("command not found")
58 // ErrQuote reports an unterminated quoted string.
59 ErrQuote = errors.New("unterminated '")
60)
61
62// MaxCommands bounds one run, so a single call cannot loop the VM.
63const MaxCommands = 32
64
65// Shell runs commands against one namespace.
66type Shell struct {
67 ns *ns.Ns
68 mode Mode
69 now func() int64
70}
71
72// New returns a shell over n. now supplies the block height stamped on writes
73// and may be nil in ReadOnly mode.
74func New(n *ns.Ns, mode Mode, now func() int64) *Shell {
75 return &Shell{ns: n, mode: mode, now: now}
76}
77
78// Ns returns the namespace the shell operates on.
79func (s *Shell) Ns() *ns.Ns { return s.ns }
80
81func (s *Shell) clock() int64 {
82 if s.now == nil {
83 return 0
84 }
85 return s.now()
86}
87
88func (s *Shell) writable() error {
89 if s.mode != ReadWrite {
90 return ErrReadOnly
91 }
92 return nil
93}
94
95// Run executes a command line and returns its output. Execution stops at the
96// first error.
97func (s *Shell) Run(line string) (string, error) {
98 cmds, err := splitCommands(line)
99 if err != nil {
100 return "", err
101 }
102 if len(cmds) > MaxCommands {
103 return "", errors.New("too many commands in one line")
104 }
105 var out strings.Builder
106 for _, c := range cmds {
107 toks, err := tokenize(c)
108 if err != nil {
109 return out.String(), err
110 }
111 if len(toks) == 0 {
112 continue
113 }
114 text, err := s.run1(toks)
115 out.WriteString(text)
116 if err != nil {
117 return out.String(), errors.New(toks[0] + ": " + err.Error())
118 }
119 }
120 return out.String(), nil
121}
122
123func (s *Shell) run1(toks []string) (string, error) {
124 cmd, args := toks[0], toks[1:]
125 switch cmd {
126 case "help":
127 return help, nil
128 case "pwd":
129 return s.ns.Cwd() + "\n", nil
130 case "cd":
131 return "", s.cd(args)
132 case "ns":
133 return s.ns.String(), nil
134 case "ls":
135 return s.ls(args)
136 case "cat":
137 return s.cat(args)
138 case "stat":
139 return s.stat(args)
140 case "walk":
141 return s.walk(args)
142 case "bind", "mount":
143 return "", s.bind(cmd, args)
144 case "unmount", "unbind":
145 return "", s.unmount(args)
146 case "mkdir":
147 return "", s.mkdir(args)
148 case "rm":
149 return "", s.rm(args)
150 case "echo":
151 return s.echo(args)
152 }
153 return "", ErrUnknown
154}
155
156const help = `bind [-a|-b|-c] new old make new visible at old (-b before, -a after, -c creates)
157cat file... print files
158cd [dir] change directory
159echo [-n] word... [>|>> file]
160ls [-l] [-u] [path...] list; -l long, -u collapse union duplicates
161mkdir path... create directories
162mount synonym for bind
163ns print the namespace
164pwd print the working directory
165rm path... remove files and empty directories
166stat path... print the 9P stat of each path
167unmount [new] old undo a binding
168walk path show how each element of path resolves
169`
170
171func (s *Shell) cd(args []string) error {
172 if len(args) == 0 {
173 return s.ns.Cd("/")
174 }
175 if len(args) > 1 {
176 return ErrUsage
177 }
178 return s.ns.Cd(args[0])
179}
180
181func (s *Shell) ls(args []string) (string, error) {
182 long, unique, paths := false, false, []string{}
183 for _, a := range args {
184 if strings.HasPrefix(a, "-") && len(a) > 1 {
185 for _, r := range a[1:] {
186 switch r {
187 case 'l':
188 long = true
189 case 'u':
190 unique = true
191 default:
192 return "", ErrUsage
193 }
194 }
195 continue
196 }
197 paths = append(paths, a)
198 }
199 if len(paths) == 0 {
200 paths = []string{s.ns.Cwd()}
201 }
202 var out strings.Builder
203 for i, p := range paths {
204 st, err := s.ns.Stat(p)
205 if err != nil {
206 return out.String(), err
207 }
208 if len(paths) > 1 {
209 if i > 0 {
210 out.WriteString("\n")
211 }
212 out.WriteString(s.ns.Abs(p) + ":\n")
213 }
214 if !st.IsDir() {
215 out.WriteString(entryLine(st, long))
216 continue
217 }
218 ents, err := s.ns.ReadDir(p, unique)
219 if err != nil {
220 return out.String(), err
221 }
222 for _, e := range ents {
223 out.WriteString(entryLine(e, long))
224 }
225 }
226 return out.String(), nil
227}
228
229func entryLine(st ninep.Stat, long bool) string {
230 if long {
231 return st.Line() + "\n"
232 }
233 name := st.Name
234 if st.IsDir() && name != "/" {
235 name += "/"
236 }
237 return name + "\n"
238}
239
240func (s *Shell) cat(args []string) (string, error) {
241 if len(args) == 0 {
242 return "", ErrUsage
243 }
244 var out strings.Builder
245 for _, p := range args {
246 data, err := s.ns.ReadFile(p)
247 if err != nil {
248 return out.String(), err
249 }
250 out.WriteString(data)
251 }
252 return out.String(), nil
253}
254
255func (s *Shell) stat(args []string) (string, error) {
256 if len(args) == 0 {
257 args = []string{s.ns.Cwd()}
258 }
259 var out strings.Builder
260 for _, p := range args {
261 st, err := s.ns.Stat(p)
262 if err != nil {
263 return out.String(), err
264 }
265 members, err := s.ns.Resolve(p)
266 if err != nil {
267 return out.String(), err
268 }
269 out.WriteString(s.ns.Abs(p) + " " + st.Qid.String() +
270 " " + st.Mode.String() +
271 " uid=" + st.Uid +
272 " length=" + strconv.FormatInt(st.Length, 10) +
273 " mtime=" + strconv.FormatInt(st.Mtime, 10) +
274 " union=" + strconv.Itoa(len(members)) + "\n")
275 }
276 return out.String(), nil
277}
278
279// walk shows the resolution of each element, which is where a namespace stops
280// being magic: the union width column says exactly when a bind took effect.
281func (s *Shell) walk(args []string) (string, error) {
282 if len(args) != 1 {
283 return "", ErrUsage
284 }
285 abs := s.ns.Abs(args[0])
286 var out strings.Builder
287 prefix := "/"
288 report := func(p string) error {
289 st, err := s.ns.Stat(p)
290 if err != nil {
291 return err
292 }
293 members, err := s.ns.Resolve(p)
294 if err != nil {
295 return err
296 }
297 out.WriteString(pad(p, 24) + " " + st.Qid.String() + " " +
298 st.Mode.String() + " union=" + strconv.Itoa(len(members)) + "\n")
299 return nil
300 }
301 if err := report("/"); err != nil {
302 return out.String(), err
303 }
304 for _, e := range ninep.Elems(abs) {
305 prefix = ninep.Join(prefix, e)
306 if err := report(prefix); err != nil {
307 return out.String(), err
308 }
309 }
310 return out.String(), nil
311}
312
313func pad(s string, n int) string {
314 for len(s) < n {
315 s += " "
316 }
317 return s
318}
319
320func (s *Shell) bind(verb string, args []string) error {
321 if err := s.writable(); err != nil {
322 return err
323 }
324 flag := ns.MREPL
325 rest := []string{}
326 for _, a := range args {
327 if strings.HasPrefix(a, "-") && len(a) > 1 {
328 for _, r := range a[1:] {
329 switch r {
330 case 'b':
331 flag = flag&^0x3 | ns.MBEFORE
332 case 'a':
333 flag = flag&^0x3 | ns.MAFTER
334 case 'c':
335 flag |= ns.MCREATE
336 default:
337 return ErrUsage
338 }
339 }
340 continue
341 }
342 rest = append(rest, a)
343 }
344 if len(rest) != 2 {
345 return ErrUsage
346 }
347 return s.ns.BindVerb(verb, rest[0], rest[1], flag)
348}
349
350func (s *Shell) unmount(args []string) error {
351 if err := s.writable(); err != nil {
352 return err
353 }
354 switch len(args) {
355 case 1:
356 return s.ns.Unmount("", args[0])
357 case 2:
358 return s.ns.Unmount(args[0], args[1])
359 }
360 return ErrUsage
361}
362
363func (s *Shell) mkdir(args []string) error {
364 if err := s.writable(); err != nil {
365 return err
366 }
367 if len(args) == 0 {
368 return ErrUsage
369 }
370 for _, p := range args {
371 abs := s.ns.Abs(p)
372 target, err := s.ns.CreateTarget(ninep.Dir(abs))
373 if err != nil {
374 return err
375 }
376 if _, err := target.Create(ninep.Base(abs), ninep.DirPerm, s.clock()); err != nil {
377 return err
378 }
379 }
380 return nil
381}
382
383func (s *Shell) rm(args []string) error {
384 if err := s.writable(); err != nil {
385 return err
386 }
387 if len(args) == 0 {
388 return ErrUsage
389 }
390 for _, p := range args {
391 abs := s.ns.Abs(p)
392 name := ninep.Base(abs)
393 members, err := s.ns.Resolve(ninep.Dir(abs))
394 if err != nil {
395 return err
396 }
397 found, removed := false, false
398 for _, m := range members {
399 if _, err := m.Walk(name); err != nil {
400 continue
401 }
402 found = true
403 mu, ok := m.(ninep.Mutable)
404 if !ok {
405 continue // a read-only server: try the next union member
406 }
407 if err := mu.Remove(name); err != nil {
408 return err
409 }
410 removed = true
411 break
412 }
413 switch {
414 case removed:
415 case found:
416 return ninep.ErrReadOnly
417 default:
418 return ninep.ErrNotExist
419 }
420 }
421 return nil
422}
423
424func (s *Shell) echo(args []string) (string, error) {
425 newline := true
426 words := []string{}
427 redirect, appendTo, dest := false, false, ""
428 i := 0
429 for i < len(args) {
430 a := args[i]
431 switch {
432 case a == "-n" && len(words) == 0 && !redirect:
433 newline = false
434 case a == ">" || a == ">>":
435 if i+1 >= len(args) {
436 return "", ErrUsage
437 }
438 redirect, appendTo, dest = true, a == ">>", args[i+1]
439 i++
440 default:
441 words = append(words, a)
442 }
443 i++
444 }
445 text := strings.Join(words, " ")
446 if newline {
447 text += "\n"
448 }
449 if !redirect {
450 return text, nil
451 }
452 if err := s.writable(); err != nil {
453 return "", err
454 }
455 return "", s.writeTo(dest, text, appendTo)
456}
457
458// writeTo creates or opens dest and writes text, honouring the namespace's
459// MCREATE rules when the file has to be created.
460func (s *Shell) writeTo(dest, text string, appendTo bool) error {
461 abs := s.ns.Abs(dest)
462 f, err := s.ns.Open(abs)
463 if err == ninep.ErrNotExist {
464 target, terr := s.ns.CreateTarget(ninep.Dir(abs))
465 if terr != nil {
466 return terr
467 }
468 created, cerr := target.Create(ninep.Base(abs), ninep.FilePerm, s.clock())
469 if cerr != nil {
470 return cerr
471 }
472 f = created
473 } else if err != nil {
474 return err
475 }
476 mu, ok := f.(ninep.Mutable)
477 if !ok {
478 return ninep.ErrReadOnly
479 }
480 off := int64(0)
481 if appendTo {
482 off = mu.Stat().Length
483 } else if err := mu.Truncate(0, s.clock()); err != nil {
484 return err
485 }
486 _, err = mu.Write(off, text, s.clock())
487 return err
488}
489
490// splitCommands splits on ';' and newlines, respecting rc quoting.
491func splitCommands(line string) ([]string, error) {
492 out := []string{}
493 var b strings.Builder
494 quoted := false
495 for i := 0; i < len(line); i++ {
496 c := line[i]
497 if c == '\'' {
498 quoted = !quoted
499 }
500 if !quoted && (c == ';' || c == '\n') {
501 out = append(out, b.String())
502 b.Reset()
503 continue
504 }
505 b.WriteByte(c)
506 }
507 if quoted {
508 return nil, ErrQuote
509 }
510 out = append(out, b.String())
511 return out, nil
512}
513
514// tokenize splits one command into words using rc's quoting rules.
515func tokenize(s string) ([]string, error) {
516 toks := []string{}
517 i := 0
518 for i < len(s) {
519 for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
520 i++
521 }
522 if i >= len(s) {
523 break
524 }
525 var b strings.Builder
526 quoted := false
527 for i < len(s) {
528 c := s[i]
529 if c == '\'' {
530 if quoted && i+1 < len(s) && s[i+1] == '\'' {
531 b.WriteByte('\'')
532 i += 2
533 continue
534 }
535 quoted = !quoted
536 i++
537 continue
538 }
539 if !quoted && (c == ' ' || c == '\t') {
540 break
541 }
542 b.WriteByte(c)
543 i++
544 }
545 if quoted {
546 return nil, ErrQuote
547 }
548 toks = append(toks, b.String())
549 }
550 return toks, nil
551}
552
553// NewMemShell is the convenience a realm or a test wants: a fresh ram root,
554// a namespace over it, and a shell. It exists here rather than in ns so that
555// ns keeps no dependency on a particular file server.
556func NewMemShell(uid string, mode Mode, now func() int64) (*Shell, *memfs.FS) {
557 h := int64(0)
558 if now != nil {
559 h = now()
560 }
561 fs := memfs.New(uid, h)
562 return New(ns.New(fs.Root()), mode, now), fs
563}