1// Package ns is a Plan 9 namespace: a private, mutable mount table over2// gno.land/p/moul/x/plan9/ninep file trees.3//4// This is the idea the rest of the suite exists for. Plan 9's leverage does5// not come from "everything is a file", it comes from every process owning its6// own name-to-resource mapping, so a name can be REPLACED and any service can7// be composed, shadowed, sandboxed or mocked without the program knowing. Gno8// has one global tree and no way to hold a view of it; an Ns is that view.9//10// Bind flags follow bind(2): MREPL replaces, MBEFORE splices the new directory11// in front of the old one, MAFTER behind it, and MCREATE (OR'd onto any of12// them) marks the union member that new files are created in.13//14// Two Plan 9 properties are load-bearing and reproduced deliberately:15//16// - A union is TOP LEVEL ONLY. Binding /a onto /b unions the two directories17// at /b; /b/c resolves in whichever member won the walk, and is not itself18// a union unless something is bound there too.19// - A bind captures a CHANNEL, not a name. The source is resolved once, at20// bind time, and the resulting file is what is stored. Rebinding the21// source afterwards does not retroactively move the target.22//23// ".." is removed lexically before resolution starts and never reaches a24// server, per "Lexical File Names in Plan 9, or Getting Dot-Dot Right".25//26// NOTICE. Plan 9 from Bell Labs is the work of the Computing Science Research27// Center at Bell Labs; the name and the marks are theirs, and the copyright is28// held by the Plan 9 Foundation (https://p9f.org). This package is not29// affiliated with, endorsed by, or sponsored by them, and contains no Plan 930// code: it borrows the vocabulary so that the design reads without a glossary,31// and it is an homage, asking what that ecosystem's spirit looks like on a32// chain. Full attribution: NOTICE.md at the root of moul/gno-contracts.33package ns3435import (36 "errors"37 "strings"3839 "gno.land/p/nt/avl/v0"4041 ninep "gno.land/p/moul/x/plan9/ninep/v0"42)4344// Flag is a bind(2) mount flag. The low two bits pick the mode; MCREATE is45// OR'd onto it.46type Flag uint324748// Mount flags, with Plan 9's values.49const (50 MREPL Flag = 0x0000 // replace the old file with the new one51 MBEFORE Flag = 0x0001 // union, new directory first52 MAFTER Flag = 0x0002 // union, new directory last53 MCREATE Flag = 0x0004 // creates in this union go here54)5556func (f Flag) mode() Flag { return f & 0x0003 }57func (f Flag) creates() bool { return f&MCREATE != 0 }5859// String renders the flag as the command-line letters bind(1) uses.60func (f Flag) String() string {61 s := ""62 switch f.mode() {63 case MBEFORE:64 s = "-b"65 case MAFTER:66 s = "-a"67 }68 if f.creates() {69 if s == "" {70 s = "-c"71 } else {72 s += "c"73 }74 }75 return s76}7778// Limits. Resolution costs one call per element per union member, and those79// calls may cross a realm boundary, so both are capped rather than trusted.80const (81 MaxUnion = 8 // members in one union82 MaxOps = 6483)8485var (86 // ErrNoRoot means the namespace has nothing bound at "/".87 ErrNoRoot = errors.New("no root in namespace")88 // ErrWideUnion means a bind would exceed MaxUnion members.89 ErrWideUnion = errors.New("union too wide")90 // ErrTooManyOps means the namespace has reached MaxOps bindings.91 ErrTooManyOps = errors.New("too many namespace operations")92 // ErrNotBound means unmount was asked to remove something that is not93 // bound at that name.94 ErrNotBound = errors.New("not bound")95)9697// member is one element of a union. A nil file is the placeholder standing for98// "whatever walking to this name would have found", which is how MBEFORE and99// MAFTER keep the original directory in the union.100type member struct {101 file ninep.File102 create bool103}104105type entry struct {106 members []member107}108109// Op records one bind or mount, in application order, so that String can print110// the namespace the way Plan 9's ns(1) does.111type Op struct {112 Verb string // "bind" or "mount"113 Flag Flag114 Source string115 Target string116}117118// Ns is one namespace: a mount table, a working directory, and the ordered119// list of operations that produced them.120type Ns struct {121 table *avl.Tree // cleaned target path -> *entry122 ops []Op123 cwd string124}125126// New returns a namespace whose root is the given file. The root is the one127// binding that cannot be expressed as a bind of something else, so it is128// installed directly.129func New(root ninep.File) *Ns {130 n := &Ns{table: avl.NewTree(), cwd: "/"}131 n.table.Set("/", &entry{members: []member{{file: root, create: true}}})132 return n133}134135// Cwd returns the working directory.136func (n *Ns) Cwd() string { return n.cwd }137138// Cd sets the working directory, which must resolve to a directory.139func (n *Ns) Cd(p string) error {140 abs := n.Abs(p)141 st, err := n.Stat(abs)142 if err != nil {143 return err144 }145 if !st.IsDir() {146 return ninep.ErrNotDir147 }148 n.cwd = abs149 return nil150}151152// Abs resolves p against the working directory.153func (n *Ns) Abs(p string) string { return ninep.Abs(n.cwd, p) }154155func (n *Ns) entry(p string) *entry {156 v := n.table.Get(p)157 if v == nil {158 return nil159 }160 return v.(*entry)161}162163// expand substitutes the walk result for the placeholder member.164func expand(ms []member, under []member) []member {165 out := []member{}166 for _, m := range ms {167 if m.file == nil {168 out = append(out, under...)169 continue170 }171 out = append(out, m)172 }173 return out174}175176// resolve walks p and returns the union found there, richest form: members177// still carry their create bit, and bound reports whether a mount entry178// applied at the final element.179func (n *Ns) resolve(p string) ([]member, error) {180 root := n.entry("/")181 if root == nil {182 return nil, ErrNoRoot183 }184 cur := expand(root.members, nil)185 if len(cur) == 0 {186 return nil, ErrNoRoot187 }188 elems := ninep.Elems(ninep.Clean(p))189 if len(elems) > ninep.MaxDepth {190 return nil, ninep.ErrTooDeep191 }192 prefix := ""193 for _, e := range elems {194 // First match wins. Walking INTO a union does not produce a union195 // of the members' subdirectories: in Plan 9 a union is top level196 // only, and a walk returns one channel.197 walked := []member{}198 for _, m := range cur {199 f, err := m.file.Walk(e)200 if err == nil {201 walked = append(walked, member{file: f})202 break203 }204 }205 prefix += "/" + e206 next := walked207 if ent := n.entry(prefix); ent != nil {208 next = expand(ent.members, walked)209 }210 if len(next) == 0 {211 return nil, ninep.ErrNotExist212 }213 cur = next214 }215 return cur, nil216}217218// Resolve returns every union member visible at p, in search order.219func (n *Ns) Resolve(p string) ([]ninep.File, error) {220 ms, err := n.resolve(n.Abs(p))221 if err != nil {222 return nil, err223 }224 out := []ninep.File{}225 for _, m := range ms {226 out = append(out, m.file)227 }228 return out, nil229}230231// Open returns the file a name resolves to: the first member of its union,232// which is the one a walk through this name would reach.233func (n *Ns) Open(p string) (ninep.File, error) {234 fs, err := n.Resolve(p)235 if err != nil {236 return nil, err237 }238 return fs[0], nil239}240241// Stat returns the entry for p, with the name replaced by the last element of242// the path as asked for, so that a union member's own name never leaks.243func (n *Ns) Stat(p string) (ninep.Stat, error) {244 f, err := n.Open(p)245 if err != nil {246 return ninep.Stat{}, err247 }248 st := f.Stat()249 st.Name = ninep.Base(n.Abs(p))250 return st, nil251}252253// ReadFile reads the whole file at p.254func (n *Ns) ReadFile(p string) (string, error) {255 f, err := n.Open(p)256 if err != nil {257 return "", err258 }259 return ninep.ReadAll(f)260}261262// ReadDir lists p. A union directory is the CONCATENATION of its members'263// contents, as in Plan 9, so duplicate names can appear and shadowing is264// visible. Pass unique to collapse them first-wins instead, which is the set265// of names a walk can actually reach.266func (n *Ns) ReadDir(p string, unique bool) ([]ninep.Stat, error) {267 ms, err := n.resolve(n.Abs(p))268 if err != nil {269 return nil, err270 }271 if len(ms) == 1 && !ms[0].file.Stat().IsDir() {272 return nil, ninep.ErrNotDir273 }274 out := []ninep.Stat{}275 seen := map[string]bool{}276 for _, m := range ms {277 ents, err := m.file.ReadDir()278 if err != nil {279 continue // a non-directory member contributes nothing280 }281 for _, e := range ents {282 if unique {283 if seen[e.Name] {284 continue285 }286 seen[e.Name] = true287 }288 out = append(out, e)289 }290 }291 return out, nil292}293294// CreateTarget returns the directory that a create at p should happen in.295//296// For a plain directory that is just the directory. For a union it is the297// first member carrying MCREATE, and if no member has it, creation is refused,298// which is bind(2)'s rule.299func (n *Ns) CreateTarget(p string) (ninep.Mutable, error) {300 abs := n.Abs(p)301 ms, err := n.resolve(abs)302 if err != nil {303 return nil, err304 }305 isUnion := n.entry(abs) != nil && len(ms) > 1306 for _, m := range ms {307 if isUnion && !m.create {308 continue309 }310 mu, ok := m.file.(ninep.Mutable)311 if !ok {312 continue // a read-only server, for instance a mounted realm313 }314 if !mu.Stat().IsDir() {315 continue316 }317 return mu, nil318 }319 if isUnion {320 return nil, ninep.ErrNoCreate321 }322 return nil, ninep.ErrPerm323}324325// Bind makes source visible at target, as bind(1) does. Both names are326// resolved in THIS namespace, and the source is resolved once: what is stored327// is the file it names today, not the name.328func (n *Ns) Bind(source, target string, flag Flag) error {329 return n.BindVerb("bind", source, target, flag)330}331332// BindVerb is Bind with the verb that String should print. Plan 9 spells the333// same operation "bind" or "mount" depending on whether the source is a name334// or a channel, and ns(1) echoes back whichever was used.335func (n *Ns) BindVerb(verb, source, target string, flag Flag) error {336 ms, err := n.resolve(n.Abs(source))337 if err != nil {338 return err339 }340 files := []ninep.File{}341 for _, m := range ms {342 files = append(files, m.file)343 }344 return n.graft(verb, source, target, flag, files)345}346347// Mount grafts a file tree that has no name in this namespace yet, which is348// how a service posted by another realm gets in. source is a label, used only349// when printing the namespace.350func (n *Ns) Mount(f ninep.File, source, target string, flag Flag) error {351 return n.graft("mount", source, target, flag, []ninep.File{f})352}353354func (n *Ns) graft(verb, source, target string, flag Flag, files []ninep.File) error {355 if len(n.ops) >= MaxOps {356 return ErrTooManyOps357 }358 abs := n.Abs(target)359 // bind(1) requires the target to exist: you can only rebind a name that360 // already resolves to something.361 if _, err := n.resolve(abs); err != nil {362 return err363 }364 add := []member{}365 for _, f := range files {366 add = append(add, member{file: f, create: flag.creates()})367 }368369 ent := n.entry(abs)370 var members []member371 switch {372 case ent == nil:373 // The name has never been bound: its current contents are the374 // placeholder. It does NOT carry MCREATE, which is why creating375 // in a freshly unioned directory is refused until some member is376 // bound with -c.377 members = []member{{file: nil}}378 default:379 members = ent.members380 }381 switch flag.mode() {382 case MBEFORE:383 members = append(add, members...)384 case MAFTER:385 members = append(members, add...)386 default: // MREPL: the old file is gone, not unioned387 members = add388 }389 if len(members) > MaxUnion {390 return ErrWideUnion391 }392 n.table.Set(abs, &entry{members: members})393 n.ops = append(n.ops, Op{Verb: verb, Flag: flag, Source: source, Target: abs})394 return nil395}396397// Unmount undoes bindings at target. With an empty source it removes every398// binding there, restoring the name to whatever it resolved to originally.399func (n *Ns) Unmount(source, target string) error {400 abs := n.Abs(target)401 ent := n.entry(abs)402 if ent == nil {403 return ErrNotBound404 }405 if source == "" {406 n.table.Remove(abs)407 n.dropOps(abs, "")408 return nil409 }410 ms, err := n.resolve(n.Abs(source))411 if err != nil {412 return err413 }414 kept := []member{}415 removed := false416 for _, m := range ent.members {417 drop := false418 for _, s := range ms {419 if m.file != nil && m.file == s.file {420 drop = true421 break422 }423 }424 if drop {425 removed = true426 continue427 }428 kept = append(kept, m)429 }430 if !removed {431 return ErrNotBound432 }433 if onlyPlaceholders(kept) {434 // Nothing but "whatever was here originally" is left, so the entry435 // is a no-op: drop it rather than leave a phantom binding behind.436 n.table.Remove(abs)437 } else {438 n.table.Set(abs, &entry{members: kept})439 }440 n.dropOps(abs, source)441 return nil442}443444func onlyPlaceholders(ms []member) bool {445 for _, m := range ms {446 if m.file != nil {447 return false448 }449 }450 return true451}452453func (n *Ns) dropOps(target, source string) {454 kept := []Op{}455 for _, op := range n.ops {456 if op.Target == target && (source == "" || op.Source == source) {457 continue458 }459 kept = append(kept, op)460 }461 n.ops = kept462}463464// Ops returns the bindings in application order.465func (n *Ns) Ops() []Op { return n.ops }466467// String prints the namespace the way Plan 9's ns(1) does: one line per468// binding, in the order they were applied, then the working directory.469func (n *Ns) String() string {470 var b strings.Builder471 for _, op := range n.ops {472 b.WriteString(op.Verb)473 if f := op.Flag.String(); f != "" {474 b.WriteString(" " + f)475 }476 b.WriteString(" " + quote(op.Source) + " " + quote(op.Target) + "\n")477 }478 b.WriteString("cd " + quote(n.cwd) + "\n")479 return b.String()480}481482// quote applies rc quoting: single quotes when the word contains a space or a483// quote, with an embedded quote doubled.484func quote(s string) string {485 if s != "" && !strings.ContainsAny(s, " \t'") {486 return s487 }488 return "'" + strings.ReplaceAll(s, "'", "''") + "'"489}490Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.