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 cannot4// trust. Everything here operates on a gno.land/p/moul/x/plan9/ns namespace5// and returns text, so one realm's Render becomes a file browser and one6// 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 every11// mutating command, which is what lets a realm expose the shell through12// Render, where mutating anything would be a bug, while the same code backs a13// crossing Exec that may write.14//15// Quoting follows rc: single quotes, with ” inside a quoted string standing16// for one literal quote. Commands are separated by ';' or newlines, and17// quoting is respected when splitting them.18//19// On the first error the run STOPS and returns it. A realm should let that20// error panic, so a half-applied command line reverts with the transaction21// 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 Research24// Center at Bell Labs; the name and the marks are theirs, and the copyright is25// held by the Plan 9 Foundation (https://p9f.org). This package is not26// affiliated with, endorsed by, or sponsored by them, and contains no Plan 927// 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 a29// chain. Full attribution: NOTICE.md at the root of moul/gno-contracts.30package rc3132import (33 "errors"34 "strconv"35 "strings"3637 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)4142// Mode decides whether mutating commands are allowed.43type Mode uint84445// Shell modes.46const (47 ReadOnly Mode = 048 ReadWrite Mode = 149)5051var (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)6162// MaxCommands bounds one run, so a single call cannot loop the VM.63const MaxCommands = 326465// Shell runs commands against one namespace.66type Shell struct {67 ns *ns.Ns68 mode Mode69 now func() int6470}7172// New returns a shell over n. now supplies the block height stamped on writes73// 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}7778// Ns returns the namespace the shell operates on.79func (s *Shell) Ns() *ns.Ns { return s.ns }8081func (s *Shell) clock() int64 {82 if s.now == nil {83 return 084 }85 return s.now()86}8788func (s *Shell) writable() error {89 if s.mode != ReadWrite {90 return ErrReadOnly91 }92 return nil93}9495// Run executes a command line and returns its output. Execution stops at the96// first error.97func (s *Shell) Run(line string) (string, error) {98 cmds, err := splitCommands(line)99 if err != nil {100 return "", err101 }102 if len(cmds) > MaxCommands {103 return "", errors.New("too many commands in one line")104 }105 var out strings.Builder106 for _, c := range cmds {107 toks, err := tokenize(c)108 if err != nil {109 return out.String(), err110 }111 if len(toks) == 0 {112 continue113 }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(), nil121}122123func (s *Shell) run1(toks []string) (string, error) {124 cmd, args := toks[0], toks[1:]125 switch cmd {126 case "help":127 return help, nil128 case "pwd":129 return s.ns.Cwd() + "\n", nil130 case "cd":131 return "", s.cd(args)132 case "ns":133 return s.ns.String(), nil134 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 "", ErrUnknown154}155156const help = `bind [-a|-b|-c] new old make new visible at old (-b before, -a after, -c creates)157cat file... print files158cd [dir] change directory159echo [-n] word... [>|>> file]160ls [-l] [-u] [path...] list; -l long, -u collapse union duplicates161mkdir path... create directories162mount synonym for bind163ns print the namespace164pwd print the working directory165rm path... remove files and empty directories166stat path... print the 9P stat of each path167unmount [new] old undo a binding168walk path show how each element of path resolves169`170171func (s *Shell) cd(args []string) error {172 if len(args) == 0 {173 return s.ns.Cd("/")174 }175 if len(args) > 1 {176 return ErrUsage177 }178 return s.ns.Cd(args[0])179}180181func (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 = true189 case 'u':190 unique = true191 default:192 return "", ErrUsage193 }194 }195 continue196 }197 paths = append(paths, a)198 }199 if len(paths) == 0 {200 paths = []string{s.ns.Cwd()}201 }202 var out strings.Builder203 for i, p := range paths {204 st, err := s.ns.Stat(p)205 if err != nil {206 return out.String(), err207 }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 continue217 }218 ents, err := s.ns.ReadDir(p, unique)219 if err != nil {220 return out.String(), err221 }222 for _, e := range ents {223 out.WriteString(entryLine(e, long))224 }225 }226 return out.String(), nil227}228229func entryLine(st ninep.Stat, long bool) string {230 if long {231 return st.Line() + "\n"232 }233 name := st.Name234 if st.IsDir() && name != "/" {235 name += "/"236 }237 return name + "\n"238}239240func (s *Shell) cat(args []string) (string, error) {241 if len(args) == 0 {242 return "", ErrUsage243 }244 var out strings.Builder245 for _, p := range args {246 data, err := s.ns.ReadFile(p)247 if err != nil {248 return out.String(), err249 }250 out.WriteString(data)251 }252 return out.String(), nil253}254255func (s *Shell) stat(args []string) (string, error) {256 if len(args) == 0 {257 args = []string{s.ns.Cwd()}258 }259 var out strings.Builder260 for _, p := range args {261 st, err := s.ns.Stat(p)262 if err != nil {263 return out.String(), err264 }265 members, err := s.ns.Resolve(p)266 if err != nil {267 return out.String(), err268 }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(), nil277}278279// walk shows the resolution of each element, which is where a namespace stops280// 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 "", ErrUsage284 }285 abs := s.ns.Abs(args[0])286 var out strings.Builder287 prefix := "/"288 report := func(p string) error {289 st, err := s.ns.Stat(p)290 if err != nil {291 return err292 }293 members, err := s.ns.Resolve(p)294 if err != nil {295 return err296 }297 out.WriteString(pad(p, 24) + " " + st.Qid.String() + " " +298 st.Mode.String() + " union=" + strconv.Itoa(len(members)) + "\n")299 return nil300 }301 if err := report("/"); err != nil {302 return out.String(), err303 }304 for _, e := range ninep.Elems(abs) {305 prefix = ninep.Join(prefix, e)306 if err := report(prefix); err != nil {307 return out.String(), err308 }309 }310 return out.String(), nil311}312313func pad(s string, n int) string {314 for len(s) < n {315 s += " "316 }317 return s318}319320func (s *Shell) bind(verb string, args []string) error {321 if err := s.writable(); err != nil {322 return err323 }324 flag := ns.MREPL325 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.MBEFORE332 case 'a':333 flag = flag&^0x3 | ns.MAFTER334 case 'c':335 flag |= ns.MCREATE336 default:337 return ErrUsage338 }339 }340 continue341 }342 rest = append(rest, a)343 }344 if len(rest) != 2 {345 return ErrUsage346 }347 return s.ns.BindVerb(verb, rest[0], rest[1], flag)348}349350func (s *Shell) unmount(args []string) error {351 if err := s.writable(); err != nil {352 return err353 }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 ErrUsage361}362363func (s *Shell) mkdir(args []string) error {364 if err := s.writable(); err != nil {365 return err366 }367 if len(args) == 0 {368 return ErrUsage369 }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 err375 }376 if _, err := target.Create(ninep.Base(abs), ninep.DirPerm, s.clock()); err != nil {377 return err378 }379 }380 return nil381}382383func (s *Shell) rm(args []string) error {384 if err := s.writable(); err != nil {385 return err386 }387 if len(args) == 0 {388 return ErrUsage389 }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 err396 }397 found, removed := false, false398 for _, m := range members {399 if _, err := m.Walk(name); err != nil {400 continue401 }402 found = true403 mu, ok := m.(ninep.Mutable)404 if !ok {405 continue // a read-only server: try the next union member406 }407 if err := mu.Remove(name); err != nil {408 return err409 }410 removed = true411 break412 }413 switch {414 case removed:415 case found:416 return ninep.ErrReadOnly417 default:418 return ninep.ErrNotExist419 }420 }421 return nil422}423424func (s *Shell) echo(args []string) (string, error) {425 newline := true426 words := []string{}427 redirect, appendTo, dest := false, false, ""428 i := 0429 for i < len(args) {430 a := args[i]431 switch {432 case a == "-n" && len(words) == 0 && !redirect:433 newline = false434 case a == ">" || a == ">>":435 if i+1 >= len(args) {436 return "", ErrUsage437 }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, nil451 }452 if err := s.writable(); err != nil {453 return "", err454 }455 return "", s.writeTo(dest, text, appendTo)456}457458// writeTo creates or opens dest and writes text, honouring the namespace's459// 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 terr467 }468 created, cerr := target.Create(ninep.Base(abs), ninep.FilePerm, s.clock())469 if cerr != nil {470 return cerr471 }472 f = created473 } else if err != nil {474 return err475 }476 mu, ok := f.(ninep.Mutable)477 if !ok {478 return ninep.ErrReadOnly479 }480 off := int64(0)481 if appendTo {482 off = mu.Stat().Length483 } else if err := mu.Truncate(0, s.clock()); err != nil {484 return err485 }486 _, err = mu.Write(off, text, s.clock())487 return err488}489490// splitCommands splits on ';' and newlines, respecting rc quoting.491func splitCommands(line string) ([]string, error) {492 out := []string{}493 var b strings.Builder494 quoted := false495 for i := 0; i < len(line); i++ {496 c := line[i]497 if c == '\'' {498 quoted = !quoted499 }500 if !quoted && (c == ';' || c == '\n') {501 out = append(out, b.String())502 b.Reset()503 continue504 }505 b.WriteByte(c)506 }507 if quoted {508 return nil, ErrQuote509 }510 out = append(out, b.String())511 return out, nil512}513514// tokenize splits one command into words using rc's quoting rules.515func tokenize(s string) ([]string, error) {516 toks := []string{}517 i := 0518 for i < len(s) {519 for i < len(s) && (s[i] == ' ' || s[i] == '\t') {520 i++521 }522 if i >= len(s) {523 break524 }525 var b strings.Builder526 quoted := false527 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 += 2533 continue534 }535 quoted = !quoted536 i++537 continue538 }539 if !quoted && (c == ' ' || c == '\t') {540 break541 }542 b.WriteByte(c)543 i++544 }545 if quoted {546 return nil, ErrQuote547 }548 toks = append(toks, b.String())549 }550 return toks, nil551}552553// 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 that555// 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), fs563}564Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.