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.resolve(ref.URI) if err != nil { return nil, err } return readFileArtifactWithLimit(path, r.maxBytes) } func (r *restrictedFileReader) resolve(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 }