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)) }