243 lines
6.0 KiB
Go
243 lines
6.0 KiB
Go
package artifact
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
|
"io"
|
|
"mime"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
ErrUnsupportedRefType = errors.New("unsupported artifact reference type")
|
|
ErrMissingInlineBody = errors.New("missing body for inline artifact")
|
|
ErrMissingFilePath = errors.New("missing file path for file artifact")
|
|
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")
|
|
)
|
|
|
|
// Reader resolves artifact references into actual artifacts.
|
|
type Reader interface {
|
|
Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error)
|
|
}
|
|
|
|
// CompositeReader routes artifact resolution based on the reference type.
|
|
type CompositeReader struct {
|
|
inlineReader *inlineReader
|
|
fileReader Reader
|
|
}
|
|
|
|
func NewCompositeReader() Reader {
|
|
return &CompositeReader{
|
|
inlineReader: &inlineReader{},
|
|
fileReader: &fileReader{},
|
|
}
|
|
}
|
|
|
|
func NewRestrictedCompositeReader(root string) (Reader, error) {
|
|
return NewRestrictedCompositeReaderWithLimit(root, 0)
|
|
}
|
|
|
|
func NewRestrictedCompositeReaderWithLimit(root string, maxBytes int64) (Reader, error) {
|
|
fileReader, err := newRestrictedFileReader(root, maxBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &CompositeReader{
|
|
inlineReader: &inlineReader{},
|
|
fileReader: fileReader,
|
|
}, nil
|
|
}
|
|
|
|
func (c *CompositeReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
switch ref.Type {
|
|
case domain.ArtifactRefInline:
|
|
return c.inlineReader.Read(ctx, ref)
|
|
case domain.ArtifactRefFile:
|
|
return c.fileReader.Read(ctx, ref)
|
|
default:
|
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedRefType, ref.Type)
|
|
}
|
|
}
|
|
|
|
type inlineReader struct{}
|
|
|
|
func (r *inlineReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
if ref.Body == "" {
|
|
return nil, ErrMissingInlineBody
|
|
}
|
|
|
|
body := []byte(ref.Body)
|
|
return &domain.Artifact{
|
|
ContentType: defaults.ContentTypeTextPlain,
|
|
Body: body,
|
|
Size: int64(len(body)),
|
|
Hash: fmt.Sprintf("%x", sha256.Sum256(body)),
|
|
URI: ref.URI,
|
|
}, nil
|
|
}
|
|
|
|
type fileReader struct{}
|
|
|
|
func (r *fileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
if ref.URI == "" {
|
|
return nil, ErrMissingFilePath
|
|
}
|
|
|
|
return readFileArtifact(ref.URI)
|
|
}
|
|
|
|
type deniedFileReader struct{}
|
|
|
|
func (r deniedFileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
if ref.URI == "" {
|
|
return nil, ErrMissingFilePath
|
|
}
|
|
return nil, ErrFileNotAllowed
|
|
}
|
|
|
|
type restrictedFileReader struct {
|
|
root string
|
|
maxBytes int64
|
|
}
|
|
|
|
func newRestrictedFileReader(root string, maxBytes int64) (Reader, 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 deniedFileReader{}, nil
|
|
}
|
|
absRoot, err := filepath.Abs(filepath.Clean(cleanRoot))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve artifact root: %w", err)
|
|
}
|
|
return &restrictedFileReader{root: absRoot, maxBytes: maxBytes}, nil
|
|
}
|
|
|
|
func (r *restrictedFileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
if ref.URI == "" {
|
|
return nil, ErrMissingFilePath
|
|
}
|
|
|
|
path, err := r.resolveLexicalPath(ref.URI)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return readFileArtifactWithLimit(path, r.maxBytes)
|
|
}
|
|
|
|
// resolveLexicalPath checks cleaned path containment without resolving symlinks.
|
|
func (r *restrictedFileReader) resolveLexicalPath(rawPath string) (string, error) {
|
|
cleanPath := filepath.Clean(strings.TrimSpace(rawPath))
|
|
var candidate string
|
|
if filepath.IsAbs(cleanPath) {
|
|
candidate = cleanPath
|
|
} else {
|
|
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 readFileArtifact(path string) (*domain.Artifact, error) {
|
|
return readFileArtifactWithLimit(path, 0)
|
|
}
|
|
|
|
func readFileArtifactWithLimit(path string, maxBytes int64) (*domain.Artifact, error) {
|
|
if maxBytes < 0 {
|
|
return nil, fmt.Errorf("file size limit must be greater than or equal to 0")
|
|
}
|
|
|
|
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)
|
|
}
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
|
}
|
|
if maxBytes > 0 && int64(len(data)) > maxBytes {
|
|
return nil, ErrFileTooLarge
|
|
}
|
|
|
|
contentType := mime.TypeByExtension(filepath.Ext(path))
|
|
if contentType == "" {
|
|
contentType = defaults.ContentTypeTextPlain
|
|
}
|
|
|
|
return &domain.Artifact{
|
|
Name: filepath.Base(path),
|
|
ContentType: contentType,
|
|
Body: data,
|
|
URI: path,
|
|
Size: int64(len(data)),
|
|
Hash: fmt.Sprintf("%x", sha256.Sum256(data)),
|
|
}, nil
|
|
}
|