// 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/signal" "os/user" "path/filepath" "sort" "strconv" "strings" "syscall" "time" "hamlab/internal/priv" "hamlab/internal/thumbs" ) 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 Preview string `json:"preview,omitempty"` // "image" | "video": the Files tab can show a thumbnail of it } 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) OpenAt(ctx context.Context, path string, offset int64) (io.ReadCloser, error) // Open from a byte offset: a range request, a player seeking 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 Move(ctx context.Context, from, to string, progress io.Writer) error // Rename, or across filesystems a copy and a removal; long, so callers run it as a task 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" } if e.Type == "file" || (e.Type == "link" && !e.Broken) { e.Preview = thumbs.Kind(name) } 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) } func (l Local) OpenAt(ctx context.Context, path string, offset int64) (io.ReadCloser, error) { f, err := l.Open(ctx, path) if err != nil { return nil, err } if offset > 0 { if _, err := f.(*os.File).Seek(offset, io.SeekStart); err != nil { f.Close() return nil, err } } return f, nil } // 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 := priv.Wrap(append([]string{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) { return s.open(ctx, "read", path) } func (s Sudo) OpenAt(ctx context.Context, path string, offset int64) (io.ReadCloser, error) { return s.open(ctx, "read", "--offset", strconv.FormatInt(offset, 10), path) } func (s Sudo) open(ctx context.Context, args ...string) (io.ReadCloser, error) { c := s.cmd(ctx, args...) 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) } // Move as root: the helper does the copying and talks on stdout. func (s Sudo) Move(ctx context.Context, from, to string, progress io.Writer) error { c := s.cmd(ctx, "move", from, to) c.Stdout = progress 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 } // MoveCmd is the command a task runs for a long move: this binary's helper, as root when // the backend is. func MoveCmd(ctx context.Context, fsys FS, self, from, to string) *exec.Cmd { if s, ok := fsys.(Sudo); ok { return s.cmd(ctx, "move", from, to) } return exec.CommandContext(ctx, self, "fs", "move", 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] var offset int64 if op == "read" && len(args) >= 4 && args[1] == "--offset" { // read --offset N offset, _ = strconv.ParseInt(args[2], 10, 64) args = append([]string{"read"}, args[3:]...) } 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.OpenAt(ctx, p, offset) 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", "move": from, err := clean(1) if err != nil { return fail(err) } to, err := clean(2) if err != nil { return fail(err) } if op == "move" { // A cancelled task is a SIGTERM: stop copying and take the partial copy away. mctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT) defer stop() if err := l.Move(mctx, from, to, stdout); err != nil { return fail(err) } return 0 } 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() }