zlh-agent/internal/files/files.go
2026-03-07 20:59:27 +00:00

1022 lines
24 KiB
Go

package files
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
"zlh-agent/internal/mods"
"zlh-agent/internal/state"
)
const (
MaxListEntries = 200
MaxReadSize = 2 * 1024 * 1024
MaxWriteSize = 1 * 1024 * 1024
binaryProbeLen = 8192
shadowDirName = ".zlh-shadow"
metadataFileName = ".zlh_metadata.json"
shadowCleanupEvery = 6 * time.Hour
shadowRetention = 7 * 24 * time.Hour
shadowOriginalName = "original"
shadowMetadataName = "metadata.json"
MaxModUploadSize = 250 * 1024 * 1024
MaxDataPackSize = 100 * 1024 * 1024
)
var (
ErrInvalidPath = errors.New("invalid path")
ErrPathEscape = errors.New("path outside runtime root")
ErrForbiddenPath = errors.New("path not allowed")
ErrNotFile = errors.New("path is not a file")
ErrNotDir = errors.New("path is not a directory")
ErrTooLarge = errors.New("file exceeds 2MB limit")
ErrWriteTooLarge = errors.New("file exceeds 1MB limit")
ErrBinaryFile = errors.New("binary file")
ErrDeleteDenied = errors.New("delete not allowed for this path")
ErrWriteDenied = errors.New("write not allowed for this path")
ErrUploadDenied = errors.New("upload denied")
ErrAlreadyExists = errors.New("file already exists")
shadowCleanupOnce sync.Once
)
type Entry struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Size int64 `json:"size"`
Modified string `json:"modified"`
}
type StatResponse struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Size int64 `json:"size"`
Modified string `json:"modified"`
IsWritable bool `json:"isWritable"`
HasBackup bool `json:"hasBackup"`
Source *string `json:"source"`
}
type ListResponse struct {
Path string `json:"path"`
Entries []Entry `json:"entries"`
Limit int `json:"limit"`
Truncated bool `json:"truncated"`
}
type ReadResponse struct {
Path string `json:"path"`
Size int64 `json:"size"`
Content string `json:"content"`
Truncated bool `json:"truncated"`
}
type shadowMetadata struct {
Path string `json:"path"`
CreatedAt string `json:"created_at"`
LastModifiedAt string `json:"last_modified_at"`
}
type Meta struct {
Source string `json:"source"`
UploadedAt time.Time `json:"uploaded_at"`
}
func RuntimeRoot(cfg *state.Config) string {
return mods.ResolveServerRoot(cfg)
}
func NormalizeVisiblePath(rel string) (string, error) {
return normalizeAndCheckVisibleRel(rel)
}
func StartShadowCleanup() {
shadowCleanupOnce.Do(func() {
go func() {
ticker := time.NewTicker(shadowCleanupEvery)
defer ticker.Stop()
cleanupShadowRootFromConfig()
for range ticker.C {
cleanupShadowRootFromConfig()
}
}()
})
}
func ResolveWorldPath(root, rel string) (string, string, error) {
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return "", "", err
}
normalizedRel, err := normalizeRelativePath(rel)
if err != nil {
return "", "", err
}
if isHiddenInternalPath(normalizedRel) {
return "", "", ErrForbiddenPath
}
joined := filepath.Join(resolvedRoot, filepath.FromSlash(normalizedRel))
resolvedPath, err := filepath.EvalSymlinks(joined)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", normalizedRel, os.ErrNotExist
}
return "", "", err
}
resolvedPath = filepath.Clean(resolvedPath)
if !withinRoot(resolvedRoot, resolvedPath) {
return "", "", ErrPathEscape
}
finalRel, err := filepath.Rel(resolvedRoot, resolvedPath)
if err != nil {
return "", "", err
}
if finalRel == "." {
finalRel = ""
}
finalRel = filepath.ToSlash(finalRel)
if isHiddenInternalPath(finalRel) {
return "", "", ErrForbiddenPath
}
return resolvedPath, finalRel, nil
}
func List(root, rel string) (ListResponse, error) {
resolvedPath, responsePath, err := ResolveWorldPath(root, rel)
if err != nil {
return ListResponse{}, err
}
info, err := os.Stat(resolvedPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ListResponse{}, os.ErrNotExist
}
return ListResponse{}, err
}
if !info.IsDir() {
return ListResponse{}, ErrNotDir
}
entries, err := os.ReadDir(resolvedPath)
if err != nil {
return ListResponse{}, err
}
sort.Slice(entries, func(i, j int) bool {
leftIsDir := dirEntryIsDir(entries[i])
rightIsDir := dirEntryIsDir(entries[j])
if leftIsDir != rightIsDir {
return leftIsDir
}
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
})
respEntries := make([]Entry, 0, min(len(entries), MaxListEntries))
truncated := len(entries) > MaxListEntries
for _, entry := range entries {
if len(respEntries) >= MaxListEntries {
break
}
if isHiddenInternalName(entry.Name()) {
continue
}
childRel := entry.Name()
if responsePath != "" {
childRel = filepath.ToSlash(filepath.Join(responsePath, entry.Name()))
}
childPath, childRespPath, err := ResolveWorldPath(root, childRel)
if err != nil {
continue
}
childInfo, err := os.Stat(childPath)
if err != nil {
continue
}
entryType := "file"
if childInfo.IsDir() {
entryType = "dir"
}
respEntries = append(respEntries, Entry{
Name: entry.Name(),
Path: childRespPath,
Type: entryType,
Size: childInfo.Size(),
Modified: childInfo.ModTime().UTC().Format(time.RFC3339),
})
}
return ListResponse{
Path: responsePath,
Entries: respEntries,
Limit: MaxListEntries,
Truncated: truncated,
}, nil
}
func Stat(root, rel string) (StatResponse, error) {
resolvedPath, responsePath, err := ResolveWorldPath(root, rel)
if err != nil {
return StatResponse{}, err
}
info, err := os.Stat(resolvedPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return StatResponse{}, os.ErrNotExist
}
return StatResponse{}, err
}
entryType := "file"
if info.IsDir() {
entryType = "dir"
}
name := filepath.Base(resolvedPath)
if responsePath == "" {
name = filepath.Base(filepath.Clean(root))
}
return StatResponse{
Name: name,
Path: responsePath,
Type: entryType,
Size: info.Size(),
Modified: info.ModTime().UTC().Format(time.RFC3339),
IsWritable: isWritablePath(root, responsePath),
HasBackup: hasBackup(root, responsePath),
Source: sourceForPath(root, responsePath),
}, nil
}
func Read(root, rel string) (ReadResponse, error) {
resolvedPath, responsePath, err := ResolveWorldPath(root, rel)
if err != nil {
return ReadResponse{}, err
}
info, err := os.Stat(resolvedPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ReadResponse{}, os.ErrNotExist
}
return ReadResponse{}, err
}
if info.IsDir() {
return ReadResponse{}, ErrNotFile
}
if info.Size() > MaxReadSize {
return ReadResponse{}, ErrTooLarge
}
data, err := os.ReadFile(resolvedPath)
if err != nil {
return ReadResponse{}, err
}
if isBinary(data) {
return ReadResponse{}, ErrBinaryFile
}
return ReadResponse{
Path: responsePath,
Size: int64(len(data)),
Content: string(data),
Truncated: false,
}, nil
}
func OpenDownload(root, rel string) (*os.File, os.FileInfo, string, error) {
resolvedPath, _, err := ResolveWorldPath(root, rel)
if err != nil {
return nil, nil, "", err
}
info, err := os.Stat(resolvedPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil, "", os.ErrNotExist
}
return nil, nil, "", err
}
if info.IsDir() {
return nil, nil, "", ErrNotFile
}
file, err := os.Open(resolvedPath)
if err != nil {
return nil, nil, "", err
}
return file, info, filepath.Base(resolvedPath), nil
}
func Delete(root, rel string) (string, error) {
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return "", err
}
normalizedRel, err := normalizeAndCheckVisibleRel(rel)
if err != nil {
return "", err
}
if !isAllowedDelete(normalizedRel) {
return "", ErrDeleteDenied
}
resolvedPath, err := resolveExistingNoFollowFinal(resolvedRoot, normalizedRel)
if err != nil {
return "", err
}
info, err := os.Lstat(resolvedPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", os.ErrNotExist
}
return "", err
}
if info.IsDir() {
return "", ErrDeleteDenied
}
if info.Mode()&os.ModeSymlink != 0 {
targetPath, err := filepath.EvalSymlinks(resolvedPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", err
}
if err == nil && !withinRoot(resolvedRoot, filepath.Clean(targetPath)) {
return "", ErrPathEscape
}
}
if err := os.Remove(resolvedPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", os.ErrNotExist
}
return "", err
}
return normalizedRel, nil
}
func Write(root, rel string, data []byte) (bool, error) {
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return false, err
}
normalizedRel, err := normalizeAndCheckVisibleRel(rel)
if err != nil {
return false, err
}
if !isAllowedWrite(normalizedRel) {
return false, ErrWriteDenied
}
if len(data) > MaxWriteSize {
return false, ErrWriteTooLarge
}
if isBinary(data) {
return false, ErrBinaryFile
}
resolvedPath, err := resolveExistingNoFollowFinal(resolvedRoot, normalizedRel)
if err != nil {
return false, err
}
info, err := os.Lstat(resolvedPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, os.ErrNotExist
}
return false, err
}
if info.IsDir() {
return false, ErrWriteDenied
}
if info.Mode()&os.ModeSymlink != 0 {
return false, ErrWriteDenied
}
backupCreated, err := ensureShadow(resolvedRoot, normalizedRel, resolvedPath)
if err != nil {
return false, err
}
if err := writeAtomic(resolvedPath, data, info.Mode().Perm()); err != nil {
return false, err
}
if err := updateShadowMetadata(resolvedRoot, normalizedRel, backupCreated); err != nil {
return false, err
}
return backupCreated, nil
}
func Revert(root, rel string) (string, error) {
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return "", err
}
normalizedRel, err := normalizeAndCheckVisibleRel(rel)
if err != nil {
return "", err
}
if !isAllowedWrite(normalizedRel) {
return "", ErrWriteDenied
}
resolvedPath, err := resolveForRestore(resolvedRoot, normalizedRel)
if err != nil {
return "", err
}
if info, err := os.Lstat(resolvedPath); err == nil {
if info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return "", ErrWriteDenied
}
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
}
shadowDir := shadowPath(resolvedRoot, normalizedRel)
originalPath := filepath.Join(shadowDir, shadowOriginalName)
originalInfo, err := os.Stat(originalPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", os.ErrNotExist
}
return "", err
}
if originalInfo.IsDir() {
return "", os.ErrNotExist
}
data, err := os.ReadFile(originalPath)
if err != nil {
return "", err
}
if err := writeAtomic(resolvedPath, data, originalInfo.Mode().Perm()); err != nil {
return "", err
}
if err := os.RemoveAll(shadowDir); err != nil {
return "", err
}
return normalizedRel, nil
}
func Upload(root, rel string, r io.Reader, sizeLimit int64, overwrite bool) (int64, bool, error) {
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return 0, false, err
}
normalizedRel, err := normalizeAndCheckVisibleRel(rel)
if err != nil {
return 0, false, err
}
allowedLimit, ok := uploadLimitForPath(normalizedRel)
if !ok {
return 0, false, ErrUploadDenied
}
if sizeLimit <= 0 || sizeLimit > allowedLimit {
sizeLimit = allowedLimit
}
resolvedPath, err := resolveUploadTarget(resolvedRoot, normalizedRel)
if err != nil {
return 0, false, err
}
overwritten := false
if info, err := os.Lstat(resolvedPath); err == nil {
if info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return 0, false, ErrUploadDenied
}
if !overwrite {
return 0, false, ErrAlreadyExists
}
overwritten = true
} else if !errors.Is(err, os.ErrNotExist) {
return 0, false, err
}
dir := filepath.Dir(resolvedPath)
tmp, err := os.CreateTemp(dir, ".zlh-upload-*")
if err != nil {
return 0, false, err
}
tmpPath := tmp.Name()
defer func() {
_ = os.Remove(tmpPath)
}()
limited := io.LimitReader(r, sizeLimit+1)
written, err := io.Copy(tmp, limited)
if closeErr := tmp.Close(); err == nil && closeErr != nil {
err = closeErr
}
if err != nil {
return 0, false, err
}
if written > sizeLimit {
return 0, false, ErrWriteTooLarge
}
if err := os.Chmod(tmpPath, 0o644); err != nil {
return 0, false, err
}
if err := os.Rename(tmpPath, resolvedPath); err != nil {
return 0, false, err
}
if err := updateMetadata(resolvedRoot, normalizedRel); err != nil {
return 0, false, err
}
return written, overwritten, nil
}
func shadowPath(root, rel string) string {
sum := sha256.Sum256([]byte(rel))
return filepath.Join(root, shadowDirName, hex.EncodeToString(sum[:]))
}
func normalizeRelativePath(rel string) (string, error) {
rel = strings.TrimSpace(rel)
if rel == "" || rel == "." {
return "", nil
}
if filepath.IsAbs(rel) || strings.HasPrefix(rel, "/") {
return "", ErrInvalidPath
}
for _, r := range rel {
if r == 0 || r < 32 || r == 127 {
return "", ErrInvalidPath
}
}
cleaned := filepath.Clean(rel)
if cleaned == "." {
return "", nil
}
if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
return "", ErrInvalidPath
}
return filepath.ToSlash(cleaned), nil
}
func withinRoot(root, target string) bool {
rel, err := filepath.Rel(root, target)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
func dirEntryIsDir(entry os.DirEntry) bool {
if entry.IsDir() {
return true
}
if entry.Type()&os.ModeSymlink != 0 {
if info, err := entry.Info(); err == nil {
return info.IsDir()
}
}
return false
}
func isBinary(data []byte) bool {
if len(data) == 0 {
return false
}
probe := data
if len(probe) > binaryProbeLen {
probe = probe[:binaryProbeLen]
}
if bytes.IndexByte(probe, 0) >= 0 {
return true
}
if !utf8.Valid(probe) {
return true
}
return false
}
func isAllowedDelete(rel string) bool {
parts := strings.Split(rel, "/")
if len(parts) != 2 {
return false
}
switch parts[0] {
case "mods-removed", "mods-uploaded":
return parts[1] != ""
case "logs":
return strings.HasSuffix(parts[1], ".log") || strings.HasSuffix(parts[1], ".log.gz")
default:
return false
}
}
func isAllowedWrite(rel string) bool {
parts := strings.Split(rel, "/")
if len(parts) == 1 {
return parts[0] == "server.properties"
}
if len(parts) != 2 || parts[0] != "config" {
return false
}
ext := strings.ToLower(filepath.Ext(parts[1]))
switch ext {
case ".toml", ".json", ".properties":
return true
default:
return false
}
}
func uploadLimitForPath(rel string) (int64, bool) {
parts := strings.Split(rel, "/")
switch {
case len(parts) == 2 && parts[0] == "mods" && strings.HasSuffix(strings.ToLower(parts[1]), ".jar"):
return MaxModUploadSize, true
case len(parts) == 3 && parts[0] == "world" && parts[1] == "datapacks" && strings.HasSuffix(strings.ToLower(parts[2]), ".zip"):
return MaxDataPackSize, true
default:
return 0, false
}
}
func isWritablePath(root, rel string) bool {
if !isAllowedWrite(rel) {
return false
}
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return false
}
resolvedPath, err := resolveExistingNoFollowFinal(resolvedRoot, rel)
if err != nil {
return false
}
info, err := os.Lstat(resolvedPath)
if err != nil {
return false
}
if info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return false
}
return true
}
func hasBackup(root, rel string) bool {
if rel == "" {
return false
}
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return false
}
originalPath := filepath.Join(shadowPath(resolvedRoot, rel), shadowOriginalName)
info, err := os.Stat(originalPath)
if err != nil {
return false
}
return !info.IsDir()
}
func sourceForPath(root, rel string) *string {
if rel == "" {
return nil
}
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return nil
}
meta, err := loadMetadata(resolvedRoot)
if err != nil {
return nil
}
entry, ok := meta[rel]
if !ok || strings.TrimSpace(entry.Source) == "" {
return nil
}
source := entry.Source
return &source
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func normalizeAndCheckVisibleRel(rel string) (string, error) {
normalizedRel, err := normalizeRelativePath(rel)
if err != nil {
return "", err
}
if isHiddenInternalPath(normalizedRel) {
return "", ErrForbiddenPath
}
return normalizedRel, nil
}
func isShadowPath(rel string) bool {
if rel == "" {
return false
}
return rel == shadowDirName || strings.HasPrefix(rel, shadowDirName+"/")
}
func isHiddenInternalPath(rel string) bool {
if rel == "" {
return false
}
return isShadowPath(rel) || rel == metadataFileName
}
func isHiddenInternalName(name string) bool {
return name == shadowDirName || name == metadataFileName
}
func resolveExistingNoFollowFinal(resolvedRoot, rel string) (string, error) {
candidate := filepath.Join(resolvedRoot, filepath.FromSlash(rel))
parentPath := filepath.Dir(candidate)
resolvedParent, err := filepath.EvalSymlinks(parentPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", os.ErrNotExist
}
return "", err
}
resolvedParent = filepath.Clean(resolvedParent)
if !withinRoot(resolvedRoot, resolvedParent) {
return "", ErrPathEscape
}
resolvedPath := filepath.Clean(filepath.Join(resolvedParent, filepath.Base(candidate)))
if !withinRoot(resolvedRoot, resolvedPath) {
return "", ErrPathEscape
}
return resolvedPath, nil
}
func resolveForRestore(resolvedRoot, rel string) (string, error) {
candidate := filepath.Join(resolvedRoot, filepath.FromSlash(rel))
parentPath := filepath.Dir(candidate)
resolvedParent, err := filepath.EvalSymlinks(parentPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", os.ErrNotExist
}
return "", err
}
resolvedParent = filepath.Clean(resolvedParent)
if !withinRoot(resolvedRoot, resolvedParent) {
return "", ErrPathEscape
}
resolvedPath := filepath.Clean(filepath.Join(resolvedParent, filepath.Base(candidate)))
if !withinRoot(resolvedRoot, resolvedPath) {
return "", ErrPathEscape
}
return resolvedPath, nil
}
func resolveUploadTarget(resolvedRoot, rel string) (string, error) {
candidate := filepath.Join(resolvedRoot, filepath.FromSlash(rel))
parentPath := filepath.Dir(candidate)
resolvedParent, err := filepath.EvalSymlinks(parentPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", os.ErrNotExist
}
return "", err
}
resolvedParent = filepath.Clean(resolvedParent)
if !withinRoot(resolvedRoot, resolvedParent) {
return "", ErrPathEscape
}
parentInfo, err := os.Stat(resolvedParent)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", os.ErrNotExist
}
return "", err
}
if !parentInfo.IsDir() {
return "", ErrUploadDenied
}
resolvedPath := filepath.Clean(filepath.Join(resolvedParent, filepath.Base(candidate)))
if !withinRoot(resolvedRoot, resolvedPath) {
return "", ErrPathEscape
}
return resolvedPath, nil
}
func ensureShadow(resolvedRoot, rel, resolvedPath string) (bool, error) {
shadowDir := shadowPath(resolvedRoot, rel)
originalPath := filepath.Join(shadowDir, shadowOriginalName)
if _, err := os.Stat(originalPath); err == nil {
return false, nil
} else if !errors.Is(err, os.ErrNotExist) {
return false, err
}
if err := os.MkdirAll(shadowDir, 0o755); err != nil {
return false, err
}
data, err := os.ReadFile(resolvedPath)
if err != nil {
return false, err
}
info, err := os.Stat(resolvedPath)
if err != nil {
return false, err
}
if err := os.WriteFile(originalPath, data, info.Mode().Perm()); err != nil {
return false, err
}
now := time.Now().UTC()
meta := shadowMetadata{
Path: rel,
CreatedAt: now.Format(time.RFC3339),
LastModifiedAt: now.Format(time.RFC3339),
}
if err := writeShadowMetadataFile(filepath.Join(shadowDir, shadowMetadataName), meta); err != nil {
return false, err
}
return true, nil
}
func updateShadowMetadata(resolvedRoot, rel string, backupCreated bool) error {
shadowDir := shadowPath(resolvedRoot, rel)
metaPath := filepath.Join(shadowDir, shadowMetadataName)
now := time.Now().UTC()
meta := shadowMetadata{
Path: rel,
CreatedAt: now.Format(time.RFC3339),
LastModifiedAt: now.Format(time.RFC3339),
}
if !backupCreated {
if existing, err := readShadowMetadata(metaPath); err == nil {
meta = existing
if strings.TrimSpace(meta.Path) == "" {
meta.Path = rel
}
if strings.TrimSpace(meta.CreatedAt) == "" {
meta.CreatedAt = now.Format(time.RFC3339)
}
meta.LastModifiedAt = now.Format(time.RFC3339)
}
}
return writeShadowMetadataFile(metaPath, meta)
}
func writeShadowMetadataFile(path string, meta shadowMetadata) error {
data, err := json.Marshal(meta)
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(path, data, 0o644)
}
func readShadowMetadata(path string) (shadowMetadata, error) {
data, err := os.ReadFile(path)
if err != nil {
return shadowMetadata{}, err
}
var meta shadowMetadata
if err := json.Unmarshal(data, &meta); err != nil {
return shadowMetadata{}, err
}
return meta, nil
}
func writeAtomic(dest string, data []byte, mode os.FileMode) error {
dir := filepath.Dir(dest)
tmp, err := os.CreateTemp(dir, ".zlh-write-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer func() {
_ = os.Remove(tmpPath)
}()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpPath, mode); err != nil {
return err
}
return os.Rename(tmpPath, dest)
}
func cleanupShadowRootFromConfig() {
cfg, err := state.LoadConfig()
if err != nil {
return
}
cleanupShadowRoot(RuntimeRoot(cfg))
}
func cleanupShadowRoot(root string) {
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return
}
base := filepath.Join(resolvedRoot, shadowDirName)
entries, err := os.ReadDir(base)
if err != nil {
return
}
now := time.Now().UTC()
for _, entry := range entries {
if !entry.IsDir() {
continue
}
shadowDir := filepath.Join(base, entry.Name())
meta, err := readShadowMetadata(filepath.Join(shadowDir, shadowMetadataName))
if err != nil {
continue
}
ts, err := time.Parse(time.RFC3339, strings.TrimSpace(meta.LastModifiedAt))
if err != nil {
continue
}
if now.Sub(ts) > shadowRetention {
_ = os.RemoveAll(shadowDir)
}
}
}
func loadMetadata(root string) (map[string]Meta, error) {
path := filepath.Join(root, metadataFileName)
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return map[string]Meta{}, nil
}
return nil, err
}
var meta map[string]Meta
if err := json.Unmarshal(data, &meta); err != nil {
return nil, err
}
if meta == nil {
meta = map[string]Meta{}
}
return meta, nil
}
func writeMetadata(root string, data map[string]Meta) error {
payload, err := json.Marshal(data)
if err != nil {
return err
}
payload = append(payload, '\n')
target := filepath.Join(root, metadataFileName)
tmp, err := os.CreateTemp(root, ".zlh-metadata-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer func() {
_ = os.Remove(tmpPath)
}()
if _, err := tmp.Write(payload); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpPath, 0o644); err != nil {
return err
}
return os.Rename(tmpPath, target)
}
func updateMetadata(root, rel string) error {
meta, err := loadMetadata(root)
if err != nil {
return err
}
meta[rel] = Meta{
Source: "user",
UploadedAt: time.Now().UTC(),
}
return writeMetadata(root, meta)
}