package volume import ( "archive/zip" "fmt" "io" "os" "path/filepath" "strings" "time" ) // FileEntry represents a single file or directory in a volume listing. type FileEntry struct { Name string `json:"name"` IsDir bool `json:"is_dir"` Size int64 `json:"size"` ModTime time.Time `json:"mod_time"` } // ListDir returns the contents of a directory within a volume root. // The relativePath is validated to stay within rootPath. func ListDir(rootPath, relativePath string) ([]FileEntry, error) { absPath, err := safePath(rootPath, relativePath) if err != nil { return nil, err } info, err := os.Stat(absPath) if err != nil { if os.IsNotExist(err) { return []FileEntry{}, nil } return nil, fmt.Errorf("stat directory: %w", err) } if !info.IsDir() { return nil, fmt.Errorf("path is not a directory") } entries, err := os.ReadDir(absPath) if err != nil { return nil, fmt.Errorf("read directory: %w", err) } result := make([]FileEntry, 0, len(entries)) for _, e := range entries { info, err := e.Info() if err != nil { continue } result = append(result, FileEntry{ Name: e.Name(), IsDir: e.IsDir(), Size: info.Size(), ModTime: info.ModTime().UTC(), }) } return result, nil } // OpenFile opens a file within the volume root for reading. // The caller is responsible for closing the returned file. func OpenFile(rootPath, relativePath string) (*os.File, os.FileInfo, error) { absPath, err := safePath(rootPath, relativePath) if err != nil { return nil, nil, err } info, err := os.Stat(absPath) if err != nil { return nil, nil, fmt.Errorf("stat file: %w", err) } if info.IsDir() { return nil, nil, fmt.Errorf("path is a directory, use download as zip") } f, err := os.Open(absPath) if err != nil { return nil, nil, fmt.Errorf("open file: %w", err) } return f, info, nil } // WriteZip writes the contents of a directory (or the entire root) as a zip archive to w. func WriteZip(rootPath, relativePath string, w io.Writer) error { absPath, err := safePath(rootPath, relativePath) if err != nil { return err } info, err := os.Stat(absPath) if err != nil { return fmt.Errorf("stat path: %w", err) } if !info.IsDir() { return fmt.Errorf("path is not a directory") } zw := zip.NewWriter(w) defer zw.Close() return filepath.Walk(absPath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } rel, err := filepath.Rel(absPath, path) if err != nil { return err } if rel == "." { return nil } // Use forward slashes in zip entries. rel = filepath.ToSlash(rel) if info.IsDir() { _, err := zw.Create(rel + "/") return err } header, err := zip.FileInfoHeader(info) if err != nil { return err } header.Name = rel header.Method = zip.Deflate writer, err := zw.CreateHeader(header) if err != nil { return err } f, err := os.Open(path) if err != nil { return err } defer f.Close() _, err = io.Copy(writer, f) return err }) } // SaveFile writes uploaded content to a file within the volume root. func SaveFile(rootPath, relativePath string, r io.Reader) error { absPath, err := safePath(rootPath, relativePath) if err != nil { return err } // Ensure parent directory exists. dir := filepath.Dir(absPath) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create directory: %w", err) } f, err := os.Create(absPath) if err != nil { return fmt.Errorf("create file: %w", err) } defer f.Close() if _, err := io.Copy(f, r); err != nil { return fmt.Errorf("write file: %w", err) } return nil } // safePath resolves a relative path within rootPath and validates it doesn't escape. // Resolves symlinks to prevent symlink-based traversal attacks. // // The check used to be `strings.HasPrefix(absResolved, absRoot)` which has // a classic boundary bug: a sibling root at /data/vol10 would pass the // prefix test for /data/vol1. The fix enforces a separator boundary so // the only allowed cases are absResolved == absRoot OR absResolved begins // with absRoot + separator. // // For paths that don't yet exist (e.g. SaveFile creating a new file), // EvalSymlinks returns an error and we fall back to the lexical path. // In that case we walk every existing ancestor with EvalSymlinks too — // if any ancestor is a symlink that escapes the root, we reject. This // closes the prior gap where pre-planted symlinks could divert writes. func safePath(rootPath, relativePath string) (string, error) { if relativePath == "" { return rootPath, nil } // Clean and ensure no traversal. cleaned := filepath.Clean(relativePath) if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || strings.Contains(cleaned, string(filepath.Separator)+".."+string(filepath.Separator)) { return "", fmt.Errorf("path traversal not allowed") } absPath := filepath.Join(rootPath, cleaned) // Resolve the root path (follow symlinks in the root itself). absRoot, err := filepath.Abs(rootPath) if err != nil { return "", fmt.Errorf("resolve root: %w", err) } if realRoot, err := filepath.EvalSymlinks(absRoot); err == nil { absRoot = realRoot } // Resolve the target path. If the leaf doesn't exist (write path), // walk parent directories — any of which may already be a symlink. absResolved, err := filepath.Abs(absPath) if err != nil { return "", fmt.Errorf("resolve path: %w", err) } if realResolved, err := filepath.EvalSymlinks(absResolved); err == nil { absResolved = realResolved } else { // Leaf missing — resolve the deepest existing ancestor and // re-join the unresolved tail. This catches a pre-planted // symlink in any parent dir. An error here means an ancestor // could not be resolved (e.g. a symlink we cannot follow): we MUST // reject rather than fall back to the lexical path, which still // carries the absRoot prefix and would let a symlink ancestor that // escapes the root slip past the boundary check below. resolved, tailErr := resolveExistingAncestor(absResolved) if tailErr != nil { return "", fmt.Errorf("path traversal not allowed") } if resolved != "" { absResolved = resolved } } if absResolved != absRoot && !strings.HasPrefix(absResolved, absRoot+string(filepath.Separator)) { return "", fmt.Errorf("path traversal not allowed") } return absPath, nil } // resolveExistingAncestor walks p upward until it finds an existing // directory, resolves its symlinks, then rejoins the missing tail. // Returns ("", nil) when no ancestor exists (vanishingly rare). func resolveExistingAncestor(p string) (string, error) { tail := "" cur := p for { if cur == "" || cur == "/" || cur == filepath.VolumeName(cur)+string(filepath.Separator) { return "", nil } info, err := os.Lstat(cur) if err == nil { real, rerr := filepath.EvalSymlinks(cur) if rerr != nil { return "", rerr } _ = info if tail == "" { return real, nil } return filepath.Join(real, tail), nil } // Move one level up. parent := filepath.Dir(cur) if parent == cur { return "", nil } tail = filepath.Join(filepath.Base(cur), tail) cur = parent } }