// Package files is the Files tab: browse, read, write, upload, download, mkdir, // rename, chmod and delete anywhere on the host. // // Two backends share one interface. Local runs in-process as the daemon's user. Sudo // re-executes this same binary as root (`sudo -n hamlab fs …`) for one operation at a // time, streaming file bodies over its stdin/stdout, so root access uses exactly the // same code and the same validation as the unprivileged path. package files import ( "context" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "os/user" "path/filepath" "sort" "strconv" "strings" "syscall" "time" ) type Entry struct { Name string `json:"name"` Type string `json:"type"` // dir | file | link | other Size int64 `json:"size"` Mode string `json:"mode"` // "-rw-r--r--" Perm string `json:"perm"` // "0644" Mtime int64 `json:"mtime"` User string `json:"user"` Group string `json:"group"` Target string `json:"target,omitempty"` // link target Broken bool `json:"broken,omitempty"` // dangling link } type Listing struct { Path string `json:"path"` Parent string `json:"parent,omitempty"` Entries []Entry `json:"entries"` } type Info struct { Entry Path string `json:"path"` Text bool `json:"text"` // looks like text (for the editor) } // FS is what the API talks to. type FS interface { List(ctx context.Context, path string) (Listing, error) Stat(ctx context.Context, path string) (Info, error) Open(ctx context.Context, path string) (io.ReadCloser, error) Write(ctx context.Context, path string, r io.Reader) error Mkdir(ctx context.Context, path string) error Touch(ctx context.Context, path string) error Rename(ctx context.Context, from, to string) error Delete(ctx context.Context, path string, recursive bool) error Chmod(ctx context.Context, path string, perm os.FileMode) error } // ---- path rules ---- // Clean rejects relative paths and normalises the rest. Everything else on the host is // reachable on purpose: this is a root-capable console, the gate is the login. func Clean(p string) (string, error) { if p == "" { return "", errors.New("path required") } if !strings.HasPrefix(p, "/") { return "", errors.New("path must be absolute") } c := filepath.Clean(p) if strings.ContainsRune(c, 0) { return "", errors.New("bad path") } return c, nil } // protected are never deleted or renamed as a whole, no matter who asks. var protected = map[string]bool{"/": true, "/bin": true, "/boot": true, "/dev": true, "/etc": true, "/home": true, "/lib": true, "/lib64": true, "/opt": true, "/proc": true, "/root": true, "/run": true, "/sbin": true, "/srv": true, "/sys": true, "/tmp": true, "/usr": true, "/var": true} func ParsePerm(s string) (os.FileMode, error) { v, err := strconv.ParseUint(strings.TrimPrefix(s, "0"), 8, 32) if err != nil || v > 0o7777 { return 0, errors.New("mode must be octal like 0644") } return os.FileMode(v), nil } // ---- Local ---- type Local struct{} var uidCache, gidCache = map[uint32]string{}, map[uint32]string{} func owner(st os.FileInfo) (string, string) { sys, ok := st.Sys().(*syscall.Stat_t) if !ok { return "", "" } u, ok := uidCache[sys.Uid] if !ok { u = strconv.Itoa(int(sys.Uid)) if x, err := user.LookupId(u); err == nil { u = x.Username } uidCache[sys.Uid] = u } g, ok := gidCache[sys.Gid] if !ok { g = strconv.Itoa(int(sys.Gid)) if x, err := user.LookupGroupId(g); err == nil { g = x.Name } gidCache[sys.Gid] = g } return u, g } func entryOf(dir, name string, lst os.FileInfo) Entry { e := Entry{Name: name, Size: lst.Size(), Mode: lst.Mode().String(), Perm: fmt.Sprintf("%04o", lst.Mode().Perm()), Mtime: lst.ModTime().UnixMilli()} e.User, e.Group = owner(lst) switch { case lst.Mode()&os.ModeSymlink != 0: e.Type = "link" full := filepath.Join(dir, name) e.Target, _ = os.Readlink(full) if st, err := os.Stat(full); err != nil { e.Broken = true } else if st.IsDir() { e.Type = "dir" // keep dir semantics for navigation; Target says it's a link } else { e.Size = st.Size() } case lst.IsDir(): e.Type = "dir" case lst.Mode().IsRegular(): e.Type = "file" default: e.Type = "other" } return e } func (Local) List(_ context.Context, path string) (Listing, error) { ents, err := os.ReadDir(path) if err != nil { return Listing{}, err } l := Listing{Path: path, Entries: make([]Entry, 0, len(ents))} if path != "/" { l.Parent = filepath.Dir(path) } for _, d := range ents { lst, err := d.Info() // Lstat if err != nil { continue } l.Entries = append(l.Entries, entryOf(path, d.Name(), lst)) } sort.Slice(l.Entries, func(i, j int) bool { a, b := l.Entries[i], l.Entries[j] if (a.Type == "dir") != (b.Type == "dir") { return a.Type == "dir" } return strings.ToLower(a.Name) < strings.ToLower(b.Name) }) return l, nil } func (Local) Stat(_ context.Context, path string) (Info, error) { lst, err := os.Lstat(path) if err != nil { return Info{}, err } in := Info{Entry: entryOf(filepath.Dir(path), filepath.Base(path), lst), Path: path} if in.Type == "file" && in.Size <= 8<<20 { if f, err := os.Open(path); err == nil { buf := make([]byte, 4096) n, _ := f.Read(buf) f.Close() in.Text = looksText(buf[:n]) } } return in, nil } func looksText(b []byte) bool { if len(b) == 0 { return true } ctrl := 0 for _, c := range b { if c == 0 { return false } if c < 32 && c != '\n' && c != '\r' && c != '\t' && c != '\f' && c != 0x1b { ctrl++ } } return ctrl*20 < len(b) } // Open refuses anything that isn't a regular file: devices stream forever (/dev/zero), // FIFOs block on open, sockets can't be read. /proc files are regular and still work. func (Local) Open(_ context.Context, path string) (io.ReadCloser, error) { st, err := os.Stat(path) if err != nil { return nil, err } if st.IsDir() { return nil, errors.New("is a directory") } if !st.Mode().IsRegular() { return nil, errors.New("not a regular file (device, pipe or socket)") } return os.Open(path) } // Write replaces the file's contents, keeping its mode; new files get 0644 (umask). func (Local) Write(_ context.Context, path string, r io.Reader) error { f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return err } if _, err := io.Copy(f, r); err != nil { f.Close() return err } return f.Close() } func (Local) Mkdir(_ context.Context, path string) error { return os.Mkdir(path, 0o755) } func (Local) Touch(_ context.Context, path string) error { f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { return err } return f.Close() } func (Local) Rename(_ context.Context, from, to string) error { if protected[from] { return errors.New("refusing to move " + from) } if _, err := os.Lstat(to); err == nil { return errors.New("destination exists") } return os.Rename(from, to) } func (Local) Delete(_ context.Context, path string, recursive bool) error { if protected[path] { return errors.New("refusing to delete " + path) } lst, err := os.Lstat(path) if err != nil { return err } if lst.IsDir() && recursive { return os.RemoveAll(path) } return os.Remove(path) // fails on a non-empty dir, by design } func (Local) Chmod(_ context.Context, path string, perm os.FileMode) error { return os.Chmod(path, perm) } // ---- Sudo: the same operations, run as root in a child hamlab ---- type Sudo struct{ Self string } func (s Sudo) cmd(ctx context.Context, args ...string) *exec.Cmd { full := append([]string{"sudo", "-n", s.Self, "fs"}, args...) return exec.CommandContext(ctx, full[0], full[1:]...) } func (s Sudo) runJSON(ctx context.Context, out any, args ...string) error { c := s.cmd(ctx, args...) var errb strings.Builder c.Stderr = &errb b, err := c.Output() if err != nil { if msg := strings.TrimSpace(errb.String()); msg != "" { return errors.New(msg) } return err } if out == nil { return nil } return json.Unmarshal(b, out) } func (s Sudo) List(ctx context.Context, path string) (Listing, error) { var l Listing err := s.runJSON(ctx, &l, "list", path) return l, err } func (s Sudo) Stat(ctx context.Context, path string) (Info, error) { var in Info err := s.runJSON(ctx, &in, "stat", path) return in, err } type cmdReader struct { io.ReadCloser cmd *exec.Cmd errb *strings.Builder } func (r *cmdReader) Close() error { r.ReadCloser.Close() err := r.cmd.Wait() if err != nil && r.errb.Len() > 0 { return errors.New(strings.TrimSpace(r.errb.String())) } return err } func (s Sudo) Open(ctx context.Context, path string) (io.ReadCloser, error) { c := s.cmd(ctx, "read", path) errb := &strings.Builder{} c.Stderr = errb out, err := c.StdoutPipe() if err != nil { return nil, err } if err := c.Start(); err != nil { return nil, err } // Fail early on "no such file" etc.: the helper stats before writing anything. head := make([]byte, 1) n, rerr := out.Read(head) if n == 0 && rerr != nil { _ = c.Wait() if msg := strings.TrimSpace(errb.String()); msg != "" { return nil, errors.New(msg) } if rerr != io.EOF { return nil, rerr } } return &cmdReader{ReadCloser: struct { io.Reader io.Closer }{io.MultiReader(strings.NewReader(string(head[:n])), out), out}, cmd: c, errb: errb}, nil } func (s Sudo) Write(ctx context.Context, path string, r io.Reader) error { c := s.cmd(ctx, "write", path) c.Stdin = r var errb strings.Builder c.Stderr = &errb if err := c.Run(); err != nil { if msg := strings.TrimSpace(errb.String()); msg != "" { return errors.New(msg) } return err } return nil } func (s Sudo) Mkdir(ctx context.Context, path string) error { return s.runJSON(ctx, nil, "mkdir", path) } func (s Sudo) Touch(ctx context.Context, path string) error { return s.runJSON(ctx, nil, "touch", path) } func (s Sudo) Rename(ctx context.Context, from, to string) error { return s.runJSON(ctx, nil, "rename", from, to) } func (s Sudo) Delete(ctx context.Context, path string, recursive bool) error { if recursive { return s.runJSON(ctx, nil, "delete", "-r", path) } return s.runJSON(ctx, nil, "delete", path) } func (s Sudo) Chmod(ctx context.Context, path string, perm os.FileMode) error { return s.runJSON(ctx, nil, "chmod", fmt.Sprintf("%04o", perm), path) } // Helper implements `hamlab fs …` for the Sudo backend. Returns the exit code. func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { fail := func(err error) int { fmt.Fprintln(stderr, err.Error()); return 1 } if len(args) < 2 { return fail(errors.New("usage: hamlab fs …")) } ctx := context.Background() var l Local op := args[0] clean := func(i int) (string, error) { if i >= len(args) { return "", errors.New("path required") } return Clean(args[i]) } switch op { case "list", "stat", "read", "write", "mkdir", "touch": p, err := clean(1) if err != nil { return fail(err) } switch op { case "list": out, err := l.List(ctx, p) if err != nil { return fail(err) } return emit(stdout, out, stderr) case "stat": out, err := l.Stat(ctx, p) if err != nil { return fail(err) } return emit(stdout, out, stderr) case "read": f, err := l.Open(ctx, p) if err != nil { return fail(err) } defer f.Close() if _, err := io.Copy(stdout, f); err != nil { return fail(err) } return 0 case "write": if err := l.Write(ctx, p, stdin); err != nil { return fail(err) } case "mkdir": if err := l.Mkdir(ctx, p); err != nil { return fail(err) } case "touch": if err := l.Touch(ctx, p); err != nil { return fail(err) } } case "rename": from, err := clean(1) if err != nil { return fail(err) } to, err := clean(2) if err != nil { return fail(err) } if err := l.Rename(ctx, from, to); err != nil { return fail(err) } case "delete": rec := false i := 1 if args[1] == "-r" { rec, i = true, 2 } p, err := clean(i) if err != nil { return fail(err) } if err := l.Delete(ctx, p, rec); err != nil { return fail(err) } case "chmod": perm, err := ParsePerm(args[1]) if err != nil { return fail(err) } p, err := clean(2) if err != nil { return fail(err) } if err := l.Chmod(ctx, p, perm); err != nil { return fail(err) } default: return fail(errors.New("unknown op " + op)) } fmt.Fprintln(stdout, "{}") return 0 } func emit(w io.Writer, v any, stderr io.Writer) int { if err := json.NewEncoder(w).Encode(v); err != nil { fmt.Fprintln(stderr, err.Error()) return 1 } return 0 } // ModTime helper for handlers. func Now() time.Time { return time.Now() }