Files
scriptorium/internal/adapter/http/artifact_reader.go

166 lines
4.4 KiB
Go

package httpadapter
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/scriptorium"
)
var (
ErrFileNotAllowed = errors.New("file artifact references are not allowed")
ErrFileOutsideRoot = errors.New("file artifact path is outside artifact root")
ErrFileTooLarge = errors.New("file artifact exceeds size limit")
)
const fallbackArtifactContentType = "text/plain"
// NewRestrictedArtifactReader creates the HTTP artifact reader for a rooted
// filesystem and optional byte limit. An empty root permits inline artifacts
// but denies file references; a zero limit permits artifacts of any size.
func NewRestrictedArtifactReader(root string, maxBytes int64) (scriptorium.ArtifactReader, error) {
if maxBytes < 0 {
return nil, fmt.Errorf("artifact size limit must be greater than or equal to 0")
}
cleanRoot := strings.TrimSpace(root)
if cleanRoot == "" {
return &restrictedArtifactReader{maxBytes: maxBytes}, nil
}
absRoot, err := filepath.Abs(filepath.Clean(cleanRoot))
if err != nil {
return nil, fmt.Errorf("resolve artifact root: %w", err)
}
return &restrictedArtifactReader{root: absRoot, maxBytes: maxBytes}, nil
}
type restrictedArtifactReader struct {
root string
maxBytes int64
}
var _ scriptorium.ArtifactReader = (*restrictedArtifactReader)(nil)
func (r *restrictedArtifactReader) Read(ctx context.Context, ref scriptorium.ArtifactRef) (*scriptorium.Artifact, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
switch ref.Type {
case scriptorium.ArtifactRefInline:
return readInlineArtifact(ref)
case scriptorium.ArtifactRefFile:
return r.readFileArtifact(ref)
default:
return nil, fmt.Errorf("unsupported artifact reference type %q", ref.Type)
}
}
func readInlineArtifact(ref scriptorium.ArtifactRef) (*scriptorium.Artifact, error) {
if ref.Body == "" {
return nil, errors.New("inline artifact body is required")
}
body := []byte(ref.Body)
return &scriptorium.Artifact{
ContentType: fallbackArtifactContentType,
Body: body,
Size: int64(len(body)),
Hash: artifactHash(body),
URI: ref.URI,
}, nil
}
func (r *restrictedArtifactReader) readFileArtifact(ref scriptorium.ArtifactRef) (*scriptorium.Artifact, error) {
if ref.URI == "" {
return nil, errors.New("file artifact path is required")
}
if r.root == "" {
return nil, ErrFileNotAllowed
}
path, err := r.resolveLexicalPath(ref.URI)
if err != nil {
return nil, err
}
return readArtifactFile(path, r.maxBytes)
}
// resolveLexicalPath checks cleaned path containment without resolving symlinks.
func (r *restrictedArtifactReader) resolveLexicalPath(rawPath string) (string, error) {
cleanPath := filepath.Clean(strings.TrimSpace(rawPath))
candidate := cleanPath
if !filepath.IsAbs(cleanPath) {
candidate = filepath.Join(r.root, cleanPath)
}
absCandidate, err := filepath.Abs(candidate)
if err != nil {
return "", fmt.Errorf("resolve artifact path: %w", err)
}
absCandidate = filepath.Clean(absCandidate)
rel, err := filepath.Rel(r.root, absCandidate)
if err != nil {
return "", fmt.Errorf("compare artifact path to root: %w", err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return "", ErrFileOutsideRoot
}
return absCandidate, nil
}
func readArtifactFile(path string, maxBytes int64) (*scriptorium.Artifact, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("failed to stat file %s: %w", path, err)
}
if maxBytes > 0 && info.Size() > maxBytes {
return nil, ErrFileTooLarge
}
var reader io.Reader = file
if maxBytes > 0 {
reader = io.LimitReader(file, maxBytes+1)
}
body, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
}
if maxBytes > 0 && int64(len(body)) > maxBytes {
return nil, ErrFileTooLarge
}
contentType := mime.TypeByExtension(filepath.Ext(path))
if contentType == "" {
contentType = fallbackArtifactContentType
}
return &scriptorium.Artifact{
Name: filepath.Base(path),
ContentType: contentType,
Body: body,
URI: path,
Size: int64(len(body)),
Hash: artifactHash(body),
}, nil
}
func artifactHash(body []byte) string {
return fmt.Sprintf("%x", sha256.Sum256(body))
}