mesh/merge-gate pass: builds claude-code, claude-licence-manager, restic, sudo → ace, g14, novox, shanks; no bus step; every machine composes with the chan…
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery delivered
The managed settings apply to every account's claude, so a key-helper script under the agent's home would run as the operator, and through sudo as root: the command is now carried whole in the root-owned managed settings and reads the key only as the agent. The helper reads only a regular file, bounded, without blocking, and a request it does not answer fails in time instead of holding every later one; a directory entry it answers that is not a name is refused. The guard's record is matched on its binary in the query, a half-named agent account is not taken as the operator's, the kept API key stays until the agent's home holds one, restic refuses to restore beside a directory others may write, and the agent-home check cleans a path before judging it.
620 lines
18 KiB
Go
620 lines
18 KiB
Go
package main
|
|
|
|
// The agent's home, touched as the agent's account (novox/hq ADR 0266).
|
|
//
|
|
// On a machine that names an agent account of its own — the control node — agents run as that account, not
|
|
// as the operator's, so that no agent there can become root without a person. Its home is its own, 0700:
|
|
// the node's tool runtime, which runs this module as the operator's account, can neither read nor write it.
|
|
// And this module must: the licence token and the account the agent is logged in as, the items registered
|
|
// at the home scope, and the home's own items the status and the removal tools read.
|
|
//
|
|
// So every path under that home is touched by a helper running as the agent's account: this module's own
|
|
// binary, as the plugin carries it under the managed directory (root's, so the agent cannot change it),
|
|
// started once through `sudo -n -u <agent>` and kept, speaking one JSON request and one JSON answer per line
|
|
// on its stdin and stdout. Once, not per file: a credentials file is looked at every five seconds, and sudo
|
|
// says every start in the journal. **The escalation goes down, never up**: the runtime's account may become
|
|
// the agent's; nothing here lets the agent's become anything.
|
|
//
|
|
// Everything else — the module's state, the managed directory, a machine whose agents run as the operator —
|
|
// is the plain filesystem, as before. Files routes each path; with no agent account of its own it routes none.
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// HelperArg is the argument that makes this binary the helper (as the agent's account).
|
|
const HelperArg = "home-fs"
|
|
|
|
// Files is the filesystem as this module touches it: a path under the agent's home through the helper when
|
|
// the agent's account is not this process's, every other path as it is.
|
|
type Files struct {
|
|
// Home is the agent's home and Account its login; Helper starts the helper, nil for none.
|
|
Home string
|
|
Account string
|
|
Helper *Helper
|
|
}
|
|
|
|
// fsys is the module's: plain until the launch says whose home the agent's is.
|
|
var fsys = &Files{}
|
|
|
|
// FilesFor is the filesystem for these paths: through the helper when the agent's account is another than the
|
|
// one this process runs as.
|
|
func FilesFor(p Paths) *Files {
|
|
if p.Agent == "" || p.Home == "" {
|
|
return &Files{}
|
|
}
|
|
if me, err := user.Current(); err == nil && me.Username == p.Agent {
|
|
return &Files{}
|
|
}
|
|
return &Files{Home: filepath.Clean(p.Home), Account: p.Agent, Helper: &Helper{Account: p.Agent, Exe: HelperPath()}}
|
|
}
|
|
|
|
// HelperPath is the helper's binary: the guard's copy of this module's binary in the plugin, root's and
|
|
// readable by every account, so the agent's account runs exactly what the mesh placed.
|
|
func HelperPath() string {
|
|
return filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir, "guard")
|
|
}
|
|
|
|
// through says whether a path is touched as the agent.
|
|
func (f *Files) through(path string) bool {
|
|
if f == nil || f.Helper == nil || f.Home == "" {
|
|
return false
|
|
}
|
|
path = filepath.Clean(path)
|
|
return path == f.Home || strings.HasPrefix(path, f.Home+string(os.PathSeparator))
|
|
}
|
|
|
|
// ---- the operations ---------------------------------------------------------------------------------------
|
|
|
|
func (f *Files) ReadFile(path string) ([]byte, error) {
|
|
if !f.through(path) {
|
|
return os.ReadFile(path)
|
|
}
|
|
a, err := f.Helper.Do(Op{Op: "read", Path: path})
|
|
return a.Data, err
|
|
}
|
|
|
|
func (f *Files) WriteFile(path string, data []byte, mode os.FileMode) error {
|
|
if !f.through(path) {
|
|
return os.WriteFile(path, data, mode)
|
|
}
|
|
_, err := f.Helper.Do(Op{Op: "write", Path: path, Data: data, Mode: uint32(mode.Perm())})
|
|
return err
|
|
}
|
|
|
|
// CreateExclusive writes a file that must not be there yet.
|
|
func (f *Files) CreateExclusive(path string, data []byte, mode os.FileMode) error {
|
|
if !f.through(path) {
|
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, werr := file.Write(data)
|
|
return errors.Join(werr, file.Close())
|
|
}
|
|
_, err := f.Helper.Do(Op{Op: "create", Path: path, Data: data, Mode: uint32(mode.Perm())})
|
|
return err
|
|
}
|
|
|
|
func (f *Files) Rename(from, to string) error {
|
|
if f.through(from) != f.through(to) {
|
|
return fmt.Errorf("%s cannot be moved to %s: one is the agent's home and the other is not", from, to)
|
|
}
|
|
if !f.through(from) {
|
|
return os.Rename(from, to)
|
|
}
|
|
_, err := f.Helper.Do(Op{Op: "rename", Path: from, To: to})
|
|
return err
|
|
}
|
|
|
|
func (f *Files) Remove(path string) error {
|
|
if !f.through(path) {
|
|
return os.Remove(path)
|
|
}
|
|
_, err := f.Helper.Do(Op{Op: "remove", Path: path})
|
|
return err
|
|
}
|
|
|
|
func (f *Files) RemoveAll(path string) error {
|
|
if !f.through(path) {
|
|
return os.RemoveAll(path)
|
|
}
|
|
_, err := f.Helper.Do(Op{Op: "remove-all", Path: path})
|
|
return err
|
|
}
|
|
|
|
func (f *Files) MkdirAll(path string, mode os.FileMode) error {
|
|
if !f.through(path) {
|
|
return os.MkdirAll(path, mode)
|
|
}
|
|
_, err := f.Helper.Do(Op{Op: "mkdir", Path: path, Mode: uint32(mode.Perm())})
|
|
return err
|
|
}
|
|
|
|
func (f *Files) Chmod(path string, mode os.FileMode) error {
|
|
if !f.through(path) {
|
|
return os.Chmod(path, mode)
|
|
}
|
|
_, err := f.Helper.Do(Op{Op: "chmod", Path: path, Mode: uint32(mode.Perm())})
|
|
return err
|
|
}
|
|
|
|
func (f *Files) Stat(path string) (fs.FileInfo, error) {
|
|
if !f.through(path) {
|
|
return os.Stat(path)
|
|
}
|
|
a, err := f.Helper.Do(Op{Op: "stat", Path: path})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return a.Info.info(), nil
|
|
}
|
|
|
|
func (f *Files) Lstat(path string) (fs.FileInfo, error) {
|
|
if !f.through(path) {
|
|
return os.Lstat(path)
|
|
}
|
|
a, err := f.Helper.Do(Op{Op: "lstat", Path: path})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return a.Info.info(), nil
|
|
}
|
|
|
|
func (f *Files) ReadDir(path string) ([]fs.DirEntry, error) {
|
|
if !f.through(path) {
|
|
return os.ReadDir(path)
|
|
}
|
|
a, err := f.Helper.Do(Op{Op: "readdir", Path: path})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]fs.DirEntry, 0, len(a.Entries))
|
|
for _, e := range a.Entries {
|
|
// A name is one entry of path, never a way out of it: the helper runs as the agent's account, so what it
|
|
// answers is the agent's word, and a name joined to path is read as the runtime's when it leaves the home.
|
|
if e == nil || !entryName(e.Name) {
|
|
return nil, fmt.Errorf("readdir %s as %s: the helper answered an entry that is not a name in it", path,
|
|
f.Helper.Account)
|
|
}
|
|
out = append(out, fs.FileInfoToDirEntry(e.info()))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// WalkDir is filepath.WalkDir, through the helper under the agent's home: lexical order, a directory before
|
|
// what is in it, a symbolic link reported and never followed.
|
|
func (f *Files) WalkDir(root string, fn fs.WalkDirFunc) error {
|
|
if !f.through(root) {
|
|
return filepath.WalkDir(root, fn)
|
|
}
|
|
info, err := f.Lstat(root)
|
|
if err != nil {
|
|
err = fn(root, nil, err)
|
|
} else {
|
|
err = f.walk(root, fs.FileInfoToDirEntry(info), fn)
|
|
}
|
|
if err == filepath.SkipDir || err == filepath.SkipAll {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// entryName says whether name is one entry of a directory: not empty, not . or .., and no separator.
|
|
func entryName(name string) bool {
|
|
return name != "" && name != "." && name != ".." && !strings.ContainsRune(name, '/') && !strings.ContainsRune(name, 0)
|
|
}
|
|
|
|
func (f *Files) walk(path string, d fs.DirEntry, fn fs.WalkDirFunc) error {
|
|
if err := fn(path, d, nil); err != nil || !d.IsDir() {
|
|
if err == filepath.SkipDir && d.IsDir() {
|
|
err = nil
|
|
}
|
|
return err
|
|
}
|
|
entries, err := f.ReadDir(path)
|
|
if err != nil {
|
|
if err = fn(path, d, err); err != nil {
|
|
if err == filepath.SkipDir {
|
|
err = nil
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
for _, e := range entries {
|
|
if err := f.walk(filepath.Join(path, e.Name()), e, fn); err != nil {
|
|
if err == filepath.SkipDir {
|
|
break
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- the wire ---------------------------------------------------------------------------------------------
|
|
|
|
// Op is one request to the helper.
|
|
type Op struct {
|
|
Op string `json:"op"`
|
|
Path string `json:"path"`
|
|
To string `json:"to,omitempty"`
|
|
Data []byte `json:"data,omitempty"`
|
|
Mode uint32 `json:"mode,omitempty"`
|
|
}
|
|
|
|
// Answer is the helper's to one Op. Err is said by its kind, so the caller's os.IsNotExist and errors.Is
|
|
// read it as they would read the filesystem's own.
|
|
type Answer struct {
|
|
Data []byte `json:"data,omitempty"`
|
|
Info *Info `json:"info,omitempty"`
|
|
Entries []*Info `json:"entries,omitempty"`
|
|
Err string `json:"err,omitempty"`
|
|
Kind string `json:"kind,omitempty"` // not-exist | exist | permission
|
|
}
|
|
|
|
// Info is a file's information as the wire carries it.
|
|
type Info struct {
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
Mode uint32 `json:"mode"`
|
|
ModTime time.Time `json:"modTime"`
|
|
}
|
|
|
|
func infoOf(i fs.FileInfo) *Info {
|
|
return &Info{Name: i.Name(), Size: i.Size(), Mode: uint32(i.Mode()), ModTime: i.ModTime()}
|
|
}
|
|
|
|
func (i *Info) info() fs.FileInfo {
|
|
if i == nil {
|
|
return fileInfo{}
|
|
}
|
|
return fileInfo{*i}
|
|
}
|
|
|
|
type fileInfo struct{ Info }
|
|
|
|
func (i fileInfo) Name() string { return i.Info.Name }
|
|
func (i fileInfo) Size() int64 { return i.Info.Size }
|
|
func (i fileInfo) Mode() fs.FileMode { return fs.FileMode(i.Info.Mode) }
|
|
func (i fileInfo) ModTime() time.Time { return i.Info.ModTime }
|
|
func (i fileInfo) IsDir() bool { return i.Mode().IsDir() }
|
|
func (i fileInfo) Sys() any { return nil }
|
|
|
|
func errorOf(op, path string, a Answer) error {
|
|
if a.Err == "" {
|
|
return nil
|
|
}
|
|
var kind error
|
|
switch a.Kind {
|
|
case "not-exist":
|
|
kind = fs.ErrNotExist
|
|
case "exist":
|
|
kind = fs.ErrExist
|
|
case "permission":
|
|
kind = fs.ErrPermission
|
|
default:
|
|
return errors.New(a.Err)
|
|
}
|
|
// The kind itself, unwrapped: os.IsNotExist compares rather than unwraps.
|
|
return &fs.PathError{Op: op, Path: path, Err: kind}
|
|
}
|
|
|
|
// ---- the client -------------------------------------------------------------------------------------------
|
|
|
|
// Helper is the helper as this module keeps it: started at the first request, restarted once when it is gone.
|
|
type Helper struct {
|
|
Account string
|
|
Exe string
|
|
// Start is how it is started; nil is through sudo as the account. A test starts it in the process.
|
|
Start func() (io.WriteCloser, io.Reader, func(), error)
|
|
|
|
mu sync.Mutex
|
|
in io.WriteCloser
|
|
out *bufio.Reader
|
|
close func()
|
|
}
|
|
|
|
// helperAnswerWithin bounds how long one request may take. The helper's own reads cannot block (serveOne), so
|
|
// a helper this slow has gone wrong: it is stopped and the request fails, said, never retried, so no request
|
|
// holds every later one behind it.
|
|
var helperAnswerWithin = 30 * time.Second
|
|
|
|
// errHelperSlow is a helper that did not answer in time.
|
|
var errHelperSlow = errors.New("the helper did not answer in time")
|
|
|
|
// Do answers one request, starting the helper when it is not running.
|
|
func (h *Helper) Do(op Op) (Answer, error) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
var last error
|
|
for try := 0; try < 2; try++ {
|
|
if h.in == nil {
|
|
if err := h.start(); err != nil {
|
|
return Answer{}, err
|
|
}
|
|
}
|
|
a, err := h.roundTrip(op)
|
|
if err == nil {
|
|
return a, errorOf(op.Op, op.Path, a)
|
|
}
|
|
last = err
|
|
h.stop()
|
|
if errors.Is(err, errHelperSlow) {
|
|
break
|
|
}
|
|
}
|
|
return Answer{}, fmt.Errorf("%s %s as %s: the helper did not answer: %w", op.Op, op.Path, h.Account, last)
|
|
}
|
|
|
|
func (h *Helper) roundTrip(op Op) (Answer, error) {
|
|
raw, err := json.Marshal(op)
|
|
if err != nil {
|
|
return Answer{}, err
|
|
}
|
|
type read struct {
|
|
line []byte
|
|
err error
|
|
}
|
|
done := make(chan read, 1)
|
|
in, out := h.in, h.out
|
|
go func() {
|
|
if _, err := in.Write(append(raw, '\n')); err != nil {
|
|
done <- read{err: err}
|
|
return
|
|
}
|
|
line, err := readLine(out, helperAnswerMax)
|
|
done <- read{line, err}
|
|
}()
|
|
var line []byte
|
|
select {
|
|
case r := <-done:
|
|
if r.err != nil {
|
|
return Answer{}, r.err
|
|
}
|
|
line = r.line
|
|
case <-time.After(helperAnswerWithin):
|
|
// The goroutine ends when stop closes the pipes and the helper goes.
|
|
return Answer{}, fmt.Errorf("%w (%s)", errHelperSlow, helperAnswerWithin)
|
|
}
|
|
var a Answer
|
|
if err := json.Unmarshal(line, &a); err != nil {
|
|
return Answer{}, fmt.Errorf("the helper said something that is not an answer: %w", err)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// helperAnswerMax bounds one answer of the helper: far above the largest file this module reads from a home
|
|
// (an item is at most 256 KiB), so an answer past it is a helper gone wrong, not a file.
|
|
const helperAnswerMax = 16 << 20
|
|
|
|
// readLine reads one line of at most max bytes; a longer one is an error and the helper is started again.
|
|
func readLine(r *bufio.Reader, max int) ([]byte, error) {
|
|
var line []byte
|
|
for {
|
|
chunk, err := r.ReadSlice('\n')
|
|
line = append(line, chunk...)
|
|
if len(line) > max {
|
|
return nil, fmt.Errorf("the helper's answer is larger than %d bytes", max)
|
|
}
|
|
if err == bufio.ErrBufferFull {
|
|
continue
|
|
}
|
|
return line, err
|
|
}
|
|
}
|
|
|
|
func (h *Helper) start() error {
|
|
start := h.Start
|
|
if start == nil {
|
|
start = h.sudo
|
|
}
|
|
in, out, closer, err := start()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h.in, h.out, h.close = in, bufio.NewReaderSize(out, 1<<20), closer
|
|
return nil
|
|
}
|
|
|
|
func (h *Helper) stop() {
|
|
if h.in != nil {
|
|
_ = h.in.Close()
|
|
}
|
|
if h.close != nil {
|
|
h.close()
|
|
}
|
|
h.in, h.out, h.close = nil, nil, nil
|
|
}
|
|
|
|
// sudo starts the helper as the agent's account: the runtime's account may become it (the sudo module's
|
|
// rule for the operator's account names every account), and nothing the agent's account holds lets it back.
|
|
func (h *Helper) sudo() (io.WriteCloser, io.Reader, func(), error) {
|
|
if _, err := os.Stat(h.Exe); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("the helper that touches %s's home is not placed yet (%s): the managed "+
|
|
"directory is written first, and the next render reaches the home", h.Account, h.Exe)
|
|
}
|
|
cmd := exec.Command("sudo", "-n", "-u", h.Account, "--", h.Exe, HelperArg)
|
|
cmd.Dir = "/"
|
|
// One language for what sudo says when it refuses, so its words are read the same on every machine.
|
|
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
|
|
in, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
out, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
var stderr strings.Builder
|
|
cmd.Stderr = &limited{w: &stderr, left: 4096}
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("the helper could not be started as %s: %w", h.Account, err)
|
|
}
|
|
return in, out, func() {
|
|
done := make(chan struct{})
|
|
go func() { _ = cmd.Wait(); close(done) }()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
// sudo passes a SIGTERM on to the helper; a SIGKILL would end sudo alone and leave it running.
|
|
_ = cmd.Process.Signal(syscall.SIGTERM)
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
_ = cmd.Process.Kill()
|
|
<-done
|
|
}
|
|
}
|
|
if s := strings.TrimSpace(stderr.String()); s != "" {
|
|
say("the helper as %s said: %s", h.Account, s)
|
|
}
|
|
}, nil
|
|
}
|
|
|
|
type limited struct {
|
|
w io.Writer
|
|
left int
|
|
}
|
|
|
|
func (l *limited) Write(p []byte) (int, error) {
|
|
n := len(p)
|
|
if l.left <= 0 {
|
|
return n, nil
|
|
}
|
|
if len(p) > l.left {
|
|
p = p[:l.left]
|
|
}
|
|
l.left -= len(p)
|
|
_, _ = l.w.Write(p)
|
|
return n, nil
|
|
}
|
|
|
|
// ---- the helper -------------------------------------------------------------------------------------------
|
|
|
|
// helperReadMax bounds a file the helper reads: its answer, as JSON, stays within helperAnswerMax.
|
|
const helperReadMax = 10 << 20
|
|
|
|
// readRegular reads a regular file of at most helperReadMax bytes. Opened without blocking and judged by what was
|
|
// opened, so a FIFO, a device or a link to one in the agent's home answers an error at once instead of holding
|
|
// the helper, and every request behind it, or filling its memory.
|
|
func readRegular(path string) ([]byte, error) {
|
|
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil, fmt.Errorf("%s is not a regular file (%s)", path, info.Mode().Type())
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(f, helperReadMax+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) > helperReadMax {
|
|
return nil, fmt.Errorf("%s is larger than %d bytes", path, helperReadMax)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// ServeHelper is the helper: one answer per request, until its input ends. It runs as the agent's account and
|
|
// does only what that account may, on its own home: the account's permissions are the boundary.
|
|
func ServeHelper(in io.Reader, out io.Writer) error {
|
|
dec := json.NewDecoder(bufio.NewReaderSize(in, 1<<20))
|
|
enc := json.NewEncoder(out)
|
|
for {
|
|
var op Op
|
|
if err := dec.Decode(&op); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if err := enc.Encode(serveOne(op)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
func serveOne(op Op) Answer {
|
|
if !filepath.IsAbs(op.Path) || (op.To != "" && !filepath.IsAbs(op.To)) {
|
|
return Answer{Err: "a path must be absolute"}
|
|
}
|
|
mode := os.FileMode(op.Mode).Perm()
|
|
var a Answer
|
|
var err error
|
|
switch op.Op {
|
|
case "read":
|
|
a.Data, err = readRegular(op.Path)
|
|
case "write":
|
|
err = os.WriteFile(op.Path, op.Data, mode)
|
|
case "create":
|
|
var f *os.File
|
|
if f, err = os.OpenFile(op.Path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode); err == nil {
|
|
_, werr := f.Write(op.Data)
|
|
err = errors.Join(werr, f.Close())
|
|
}
|
|
case "rename":
|
|
err = os.Rename(op.Path, op.To)
|
|
case "remove":
|
|
err = os.Remove(op.Path)
|
|
case "remove-all":
|
|
err = os.RemoveAll(op.Path)
|
|
case "mkdir":
|
|
err = os.MkdirAll(op.Path, mode)
|
|
case "chmod":
|
|
err = os.Chmod(op.Path, mode)
|
|
case "stat", "lstat":
|
|
var i fs.FileInfo
|
|
if op.Op == "stat" {
|
|
i, err = os.Stat(op.Path)
|
|
} else {
|
|
i, err = os.Lstat(op.Path)
|
|
}
|
|
if err == nil {
|
|
a.Info = infoOf(i)
|
|
}
|
|
case "readdir":
|
|
var entries []fs.DirEntry
|
|
entries, err = os.ReadDir(op.Path)
|
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
|
for _, e := range entries {
|
|
if i, ierr := e.Info(); ierr == nil {
|
|
a.Entries = append(a.Entries, infoOf(i))
|
|
}
|
|
}
|
|
default:
|
|
return Answer{Err: "no such operation: " + op.Op}
|
|
}
|
|
if err != nil {
|
|
a = Answer{Err: err.Error()}
|
|
switch {
|
|
case errors.Is(err, fs.ErrNotExist):
|
|
a.Kind = "not-exist"
|
|
case errors.Is(err, fs.ErrExist):
|
|
a.Kind = "exist"
|
|
case errors.Is(err, fs.ErrPermission):
|
|
a.Kind = "permission"
|
|
}
|
|
}
|
|
return a
|
|
}
|