166 lines
4.4 KiB
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))
|
|
}
|