package downloader import ( "fmt" "path/filepath" "strings" ) // TaskPaths are fixed local names derived only from RequestID. type TaskPaths struct { Root string TasksDir string FilesDir string Metadata string Backup string Part string Completed string } // DeriveTaskPaths derives all local paths without accepting remote filenames // or Content-Disposition values. func DeriveTaskPaths(downloadsRoot, requestID string) (TaskPaths, error) { if downloadsRoot == "" { return TaskPaths{}, fmt.Errorf("%w: empty downloads root", ErrInvalidTask) } if !ValidRequestID(requestID) { return TaskPaths{}, fmt.Errorf("%w: invalid request_id", ErrInvalidTask) } absoluteRoot, err := filepath.Abs(downloadsRoot) if err != nil { return TaskPaths{}, fmt.Errorf("%w: resolve downloads root", ErrInvalidTask) } tasksDir := filepath.Join(absoluteRoot, "tasks") filesDir := filepath.Join(absoluteRoot, "files") paths := TaskPaths{ Root: absoluteRoot, TasksDir: tasksDir, FilesDir: filesDir, Metadata: filepath.Join(tasksDir, requestID+".json"), Backup: filepath.Join(tasksDir, requestID+".json.backup"), Part: filepath.Join(filesDir, requestID+".part"), Completed: filepath.Join(filesDir, requestID+".download"), } for _, candidate := range []string{ paths.TasksDir, paths.FilesDir, paths.Metadata, paths.Backup, paths.Part, paths.Completed, } { relative, relativeErr := filepath.Rel(absoluteRoot, candidate) if relativeErr != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { return TaskPaths{}, fmt.Errorf("%w: derived path escapes root", ErrInvalidTask) } } return paths, nil }