1022 lines
24 KiB
Go
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)
|
|
}
|