Files
mesh-catalog/modules/mesh-files/cmd/mesh-files/trash.go
T
jschoubben 5d98bcc0db
mesh/delivery-group group feat/900-node-files checking: composed check out of date: novox/mesh-controller@210f7cf5e64b was judged against trunk 611e2275cf7c, t…
mesh/merge-gate out of date: judged against trunk 3db85a0dde83, the trunk is now 8b53a3663244; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 3db85a0dde83, the trunk is now 8b53a3663244; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
mesh-files: the holder of node-files, reads and writes with trash, owned paths and an act log, so file work on a node needs no shell (issue 900, ADR 0394)
2026-10-16 14:50:03 +02:00

216 lines
6.1 KiB
Go

package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// TrashKeep is how long a deleted or replaced item stays in the trash (novox/hq ADR 0394 §4).
const TrashKeep = 7 * 24 * time.Hour
// trashMeta is the small JSON beside a trashed item: what it was, who it belonged to, and who put it there.
type trashMeta struct {
ID string `json:"id"`
Path string `json:"path"`
UID int `json:"uid"`
GID int `json:"gid"`
Mode uint32 `json:"mode"`
Kind string `json:"kind"`
Caller string `json:"caller"`
Reason string `json:"reason"`
Time time.Time `json:"time"`
}
// TrashItem is one item of the trash, as trash answers it.
type TrashItem struct {
ID string `json:"id"`
Path string `json:"path"`
Kind string `json:"kind"`
Owner string `json:"owner"`
Mode string `json:"mode"`
Caller string `json:"caller"`
Reason string `json:"reason"`
Time string `json:"time"`
Until string `json:"emptied-after"`
}
// TrashAnswer is what trash answers.
type TrashAnswer struct {
Items []TrashItem `json:"items"`
}
func (f *Files) trashDir() string { return filepath.Join(f.State, "trash") }
func newID() string {
b := make([]byte, 5)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
func (f *Files) id() string {
if f.NewID != nil {
return f.NewID()
}
return newID()
}
// toTrash moves a path into a folder of its own in the trash, with a note of what it was.
func (f *Files) toTrash(a Act, path string) (string, error) {
info, err := os.Lstat(path)
if err != nil {
return "", fmt.Errorf("%s cannot be put in the trash: %s", path, why(err))
}
id, err := f.ownerOf(path)
if err != nil {
return "", err
}
tid := f.id()
dir := filepath.Join(f.trashDir(), tid)
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("the trash cannot be written: %v", err)
}
if err := f.relocate(path, filepath.Join(dir, "item")); err != nil {
os.RemoveAll(dir)
return "", fmt.Errorf("%s cannot be put in the trash: %v", path, err)
}
meta, _ := json.Marshal(trashMeta{ID: tid, Path: path, UID: id.UID, GID: id.GID, Mode: permOf(info.Mode()),
Kind: typeOf(info.Mode()), Caller: a.Caller, Reason: a.Reason, Time: f.Now().UTC()})
if err := os.WriteFile(filepath.Join(dir, "meta.json"), meta, 0o600); err != nil {
return "", fmt.Errorf("%s is in the trash, but its note cannot be written: %v", path, err)
}
return tid, nil
}
func (f *Files) metaOf(id string) (trashMeta, error) {
var m trashMeta
raw, err := os.ReadFile(filepath.Join(f.trashDir(), id, "meta.json"))
if err != nil {
return m, err
}
return m, json.Unmarshal(raw, &m)
}
// entries are the ids in the trash.
func (f *Files) entries() []string {
dirs, err := os.ReadDir(f.trashDir())
if err != nil {
return nil
}
var ids []string
for _, d := range dirs {
if d.IsDir() {
ids = append(ids, d.Name())
}
}
return ids
}
// sweep empties what is older than TrashKeep. It runs on every write call and every trash list; a failure is
// said on stderr and tried again at the next one.
func (f *Files) sweep() {
for _, id := range f.entries() {
m, err := f.metaOf(id)
if err != nil || f.Now().Sub(m.Time) <= TrashKeep {
continue
}
if err := f.removeAll(filepath.Join(f.trashDir(), id)); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-files] the trash entry %s cannot be emptied: %v\n", id, err)
}
}
}
// Trash lists what the trash holds, oldest first.
func (f *Files) Trash() (TrashAnswer, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.sweep()
names := f.names()
ans := TrashAnswer{Items: []TrashItem{}}
var metas []trashMeta
for _, id := range f.entries() {
if m, err := f.metaOf(id); err == nil {
metas = append(metas, m)
}
}
sort.Slice(metas, func(i, j int) bool { return metas[i].Time.Before(metas[j].Time) })
for _, m := range metas {
owner := names.user(m.UID)
if owner == "" {
owner = fmt.Sprint(m.UID)
}
if g := names.group(m.GID); g != "" {
owner += ":" + g
}
ans.Items = append(ans.Items, TrashItem{ID: m.ID, Path: m.Path, Kind: m.Kind, Owner: owner,
Mode: fmt.Sprintf("%04o", m.Mode), Caller: m.Caller, Reason: m.Reason,
Time: m.Time.Format(time.RFC3339), Until: m.Time.Add(TrashKeep).Format(time.RFC3339)})
}
return ans, nil
}
var idShape = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
// restore puts an item back where it was. It refuses when something is there now.
func (f *Files) restore(id string) (trashMeta, error) {
if !idShape.MatchString(id) || strings.HasPrefix(id, ".") {
return trashMeta{}, fmt.Errorf("%q is not an id from trash", id)
}
m, err := f.metaOf(id)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return m, fmt.Errorf("the trash holds no %s (it may have been emptied after %d days)", id, int(TrashKeep.Hours()/24))
}
return m, err
}
if _, err := os.Lstat(m.Path); err == nil {
return m, fmt.Errorf("%s is there now: delete or move it first, then restore", m.Path)
}
if _, err := os.Stat(filepath.Dir(m.Path)); err != nil {
return m, fmt.Errorf("the folder of %s cannot be reached: %s", m.Path, why(err))
}
dir := filepath.Join(f.trashDir(), id)
if err := f.relocate(filepath.Join(dir, "item"), m.Path); err != nil {
return m, fmt.Errorf("%s cannot be restored: %v", m.Path, err)
}
if err := f.chownTo(m.Path, Ident{m.UID, m.GID}); err != nil {
return m, fmt.Errorf("%s is back, but its owner could not be set: %v", m.Path, err)
}
return m, f.removeAll(dir)
}
// Restore puts an item of the trash back where it was.
func (f *Files) Restore(a Act, id string) (ActAnswer, error) {
f.mu.Lock()
defer f.mu.Unlock()
if err := f.begin(a); err != nil {
return ActAnswer{}, err
}
id = strings.TrimSpace(id)
var hits []hit
if idShape.MatchString(id) {
if m, err := f.metaOf(id); err == nil {
var gerr error
if hits, gerr = f.guard(a, false, m.Path); gerr != nil {
return ActAnswer{}, gerr
}
}
}
m, err := f.restore(id)
if err != nil {
return ActAnswer{}, err
}
return f.finish(a, "restore", hits, ActAnswer{Done: "restored " + m.Path, Paths: []string{m.Path}})
}