Files tab: browser with breadcrumbs/sort/filter, text editor, image preview, streamed upload with progress, download, mkdir/touch/rename/move/chmod/delete, open-in-terminal; root toggle via sudo-reexecuted 'hamlab fs' helper
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
38d4ee09ae
commit
63b020d556
+252
-2
@@ -8,9 +8,12 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,6 +23,7 @@ import (
|
||||
"hamlab/internal/auth"
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/cron"
|
||||
"hamlab/internal/files"
|
||||
"hamlab/internal/metrics"
|
||||
"hamlab/internal/services"
|
||||
"hamlab/internal/storage"
|
||||
@@ -43,6 +47,8 @@ type Server struct {
|
||||
tasks *tasks.Manager
|
||||
updates *updates.Manager
|
||||
storage *storage.Manager
|
||||
fsLocal files.FS
|
||||
fsSudo files.FS // nil without passwordless sudo
|
||||
version string
|
||||
started time.Time
|
||||
user string
|
||||
@@ -51,9 +57,9 @@ type Server struct {
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, ui fs.FS, version string) *Server {
|
||||
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, fsLocal, fsSudo files.FS, ui fs.FS, version string) *Server {
|
||||
host, _ := os.Hostname()
|
||||
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, ui: ui, version: version,
|
||||
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, ui: ui, version: version,
|
||||
started: time.Now(), host: host, user: os.Getenv("USER")}
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
s.user = u.Username
|
||||
@@ -102,6 +108,17 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
m.HandleFunc("GET /api/updates/changelog", s.auth(s.updatesChangelog))
|
||||
m.HandleFunc("POST /api/power/{action}", s.auth(s.power))
|
||||
m.HandleFunc("POST /api/updates/install", s.auth(s.updatesInstall))
|
||||
m.HandleFunc("GET /api/files", s.auth(s.filesList))
|
||||
m.HandleFunc("GET /api/files/stat", s.auth(s.filesStat))
|
||||
m.HandleFunc("GET /api/files/read", s.auth(s.filesRead))
|
||||
m.HandleFunc("GET /api/files/download", s.auth(s.filesRead))
|
||||
m.HandleFunc("PUT /api/files/write", s.auth(s.filesWrite))
|
||||
m.HandleFunc("PUT /api/files/upload", s.auth(s.filesUpload))
|
||||
m.HandleFunc("POST /api/files/mkdir", s.auth(s.filesMkdir))
|
||||
m.HandleFunc("POST /api/files/touch", s.auth(s.filesTouch))
|
||||
m.HandleFunc("POST /api/files/rename", s.auth(s.filesRename))
|
||||
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
|
||||
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
|
||||
m.HandleFunc("GET /api/storage", s.auth(s.storageOverview))
|
||||
m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart))
|
||||
m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest))
|
||||
@@ -597,6 +614,239 @@ func (s *Server) power(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// ---- files ----
|
||||
|
||||
// fsFor picks the backend: ?sudo=1 asks for root, which needs passwordless sudo.
|
||||
func (s *Server) fsFor(w http.ResponseWriter, r *http.Request) (files.FS, bool) {
|
||||
if r.URL.Query().Get("sudo") == "1" {
|
||||
if s.fsSudo == nil {
|
||||
jsonErr(w, 403, "root access needs passwordless sudo on the host")
|
||||
return nil, false
|
||||
}
|
||||
return s.fsSudo, true
|
||||
}
|
||||
return s.fsLocal, true
|
||||
}
|
||||
|
||||
func fsErr(w http.ResponseWriter, err error) {
|
||||
code := 400
|
||||
switch {
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
code = 404
|
||||
case errors.Is(err, os.ErrPermission), strings.Contains(err.Error(), "permission denied"):
|
||||
code = 403
|
||||
}
|
||||
msg := err.Error()
|
||||
if pe, ok := err.(*os.PathError); ok { // "open /x: permission denied" -> "permission denied"
|
||||
msg = pe.Err.Error()
|
||||
}
|
||||
jsonErr(w, code, msg)
|
||||
}
|
||||
|
||||
func cleanQuery(w http.ResponseWriter, r *http.Request, key string) (string, bool) {
|
||||
p, err := files.Clean(r.URL.Query().Get(key))
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return "", false
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
func (s *Server) filesList(w http.ResponseWriter, r *http.Request) {
|
||||
fsys, ok := s.fsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, ok := cleanQuery(w, r, "path")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
l, err := fsys.List(r.Context(), p)
|
||||
if err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, 200, l)
|
||||
}
|
||||
|
||||
func (s *Server) filesStat(w http.ResponseWriter, r *http.Request) {
|
||||
fsys, ok := s.fsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, ok := cleanQuery(w, r, "path")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, err := fsys.Stat(r.Context(), p)
|
||||
if err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, 200, in)
|
||||
}
|
||||
|
||||
// filesRead streams a file. /download adds an attachment header; ?max= caps the bytes.
|
||||
func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
|
||||
fsys, ok := s.fsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, ok := cleanQuery(w, r, "path")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, err := fsys.Stat(r.Context(), p)
|
||||
if err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
if in.Type == "dir" {
|
||||
jsonErr(w, 400, "is a directory")
|
||||
return
|
||||
}
|
||||
if mx, _ := strconv.ParseInt(r.URL.Query().Get("max"), 10, 64); mx > 0 && in.Size > mx {
|
||||
jsonErr(w, 413, "file too large")
|
||||
return
|
||||
}
|
||||
f, err := fsys.Open(r.Context(), p)
|
||||
if err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
name := filepath.Base(p)
|
||||
ct := mime.TypeByExtension(filepath.Ext(name))
|
||||
if ct == "" {
|
||||
if in.Text {
|
||||
ct = "text/plain; charset=utf-8"
|
||||
} else {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if strings.HasSuffix(r.URL.Path, "/download") {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(name))
|
||||
} else {
|
||||
w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(name))
|
||||
}
|
||||
if in.Size > 0 {
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(in.Size, 10))
|
||||
}
|
||||
_, _ = io.Copy(w, f)
|
||||
}
|
||||
|
||||
func (s *Server) filesWrite(w http.ResponseWriter, r *http.Request) {
|
||||
fsys, ok := s.fsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, ok := cleanQuery(w, r, "path")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := fsys.Write(r.Context(), p, io.LimitReader(r.Body, 8<<20)); err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
log.Printf("files: wrote %s (from %s)", p, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// filesUpload streams the body into <path>/<name>.
|
||||
func (s *Server) filesUpload(w http.ResponseWriter, r *http.Request) {
|
||||
fsys, ok := s.fsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
dir, ok := cleanQuery(w, r, "path")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
name := r.URL.Query().Get("name")
|
||||
if name == "" || name != filepath.Base(name) || name == "." || name == ".." {
|
||||
jsonErr(w, 400, "bad file name")
|
||||
return
|
||||
}
|
||||
dst := filepath.Join(dir, name)
|
||||
if err := fsys.Write(r.Context(), dst, r.Body); err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
log.Printf("files: uploaded %s (from %s)", dst, clientIP(r))
|
||||
w.WriteHeader(201)
|
||||
}
|
||||
|
||||
func (s *Server) filesOp(w http.ResponseWriter, r *http.Request, fn func(fsys files.FS, body map[string]any, p string) error) {
|
||||
fsys, ok := s.fsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
p, err := files.Clean(str(body["path"]))
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := fn(fsys, body, p); err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
func str(v any) string { s, _ := v.(string); return s }
|
||||
|
||||
func (s *Server) filesMkdir(w http.ResponseWriter, r *http.Request) {
|
||||
s.filesOp(w, r, func(fsys files.FS, _ map[string]any, p string) error {
|
||||
log.Printf("files: mkdir %s (from %s)", p, clientIP(r))
|
||||
return fsys.Mkdir(r.Context(), p)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) filesTouch(w http.ResponseWriter, r *http.Request) {
|
||||
s.filesOp(w, r, func(fsys files.FS, _ map[string]any, p string) error {
|
||||
log.Printf("files: create %s (from %s)", p, clientIP(r))
|
||||
return fsys.Touch(r.Context(), p)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) filesRename(w http.ResponseWriter, r *http.Request) {
|
||||
s.filesOp(w, r, func(fsys files.FS, body map[string]any, from string) error {
|
||||
to, err := files.Clean(str(body["to"]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("files: rename %s -> %s (from %s)", from, to, clientIP(r))
|
||||
return fsys.Rename(r.Context(), from, to)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) filesDelete(w http.ResponseWriter, r *http.Request) {
|
||||
s.filesOp(w, r, func(fsys files.FS, body map[string]any, p string) error {
|
||||
rec, _ := body["recursive"].(bool)
|
||||
log.Printf("files: delete %s recursive=%v (from %s)", p, rec, clientIP(r))
|
||||
return fsys.Delete(r.Context(), p, rec)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) filesChmod(w http.ResponseWriter, r *http.Request) {
|
||||
s.filesOp(w, r, func(fsys files.FS, body map[string]any, p string) error {
|
||||
perm, err := files.ParsePerm(str(body["mode"]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("files: chmod %04o %s (from %s)", perm, p, clientIP(r))
|
||||
return fsys.Chmod(r.Context(), p, perm)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- storage (Disks tab) ----
|
||||
|
||||
func (s *Server) updatesInstall(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
// 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)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
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 <op> …` 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 <list|stat|read|write|mkdir|touch|rename|delete|chmod> …"))
|
||||
}
|
||||
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() }
|
||||
Reference in New Issue
Block a user