Bound external result file reads

This commit is contained in:
2026-08-10 18:30:28 +00:00
parent 99b2e1cd81
commit ab5a7e8e3d
22 changed files with 215 additions and 84 deletions

View File

@@ -14,6 +14,12 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
// MaxProcessedOutputBytes bounds Audita's processed-transcript JSON result.
const MaxProcessedOutputBytes int64 = 64 * 1024 * 1024
// MaxReportOutputBytes bounds Audita's optional report JSON result.
const MaxReportOutputBytes int64 = 16 * 1024 * 1024
// SubprocessRunnerConfig defines deterministic settings for Audita CLI execution.
type SubprocessRunnerConfig struct {
Binary string
@@ -375,9 +381,9 @@ func (r *SubprocessRunner) writeInvocationConfig(req PolishRequest, args []strin
}
func validateProcessedOutput(path string) error {
data, err := os.ReadFile(path)
data, err := readAuditaResult(path, MaxProcessedOutputBytes, "processed transcript")
if err != nil {
return fmt.Errorf("read file: %w", err)
return err
}
var payload map[string]any
@@ -406,9 +412,9 @@ func addSubprocessStreamHint(message string, runErr error) string {
}
func validateJSONFile(path string) error {
data, err := os.ReadFile(path)
data, err := readAuditaResult(path, MaxReportOutputBytes, "report")
if err != nil {
return fmt.Errorf("read file: %w", err)
return err
}
var v any
if err := json.Unmarshal(data, &v); err != nil {
@@ -416,3 +422,11 @@ func validateJSONFile(path string) error {
}
return nil
}
func readAuditaResult(path string, limit int64, category string) ([]byte, error) {
data, err := fileops.ReadRegularFile(path, limit)
if err != nil {
return nil, fmt.Errorf("audita %s result exceeds or cannot be read within %d-byte limit: %w", category, limit, err)
}
return data, nil
}

View File

@@ -5,12 +5,12 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
@@ -388,32 +388,9 @@ func loadWarnings(path string) ([]WarningSummary, error) {
}
func decodeBoundedJSON(path string, limit int64, destination any) error {
inspected, err := os.Lstat(path)
data, err := fileops.ReadRegularFile(path, limit)
if err != nil {
return err
}
if inspected.Mode()&os.ModeSymlink != 0 || !inspected.Mode().IsRegular() {
return fmt.Errorf("path %q must be a regular file without symlinks", path)
}
file, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = file.Close() }()
opened, err := file.Stat()
if err != nil {
return err
}
if !opened.Mode().IsRegular() || !os.SameFile(inspected, opened) {
return fmt.Errorf("file %q changed before it could be read", path)
}
reader := io.LimitReader(file, limit+1)
data, err := io.ReadAll(reader)
if err != nil {
return err
}
if int64(len(data)) > limit {
return fmt.Errorf("file %q exceeds %d-byte limit", path, limit)
return fmt.Errorf("notarius JSON result exceeds or cannot be read within %d-byte limit: %w", limit, err)
}
if err := json.Unmarshal(data, destination); err != nil {
return err

View File

@@ -12,6 +12,9 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
// MaxOutputFileBytes bounds one Scriptorium artifact result.
const MaxOutputFileBytes int64 = 64 * 1024 * 1024
// SubprocessRunner invokes Scriptorium through its public CLI.
type SubprocessRunner struct{}
@@ -326,14 +329,11 @@ func writeInvocationConfig(path string, payload invocationPayload) error {
}
func validateNonEmptyOutput(path string) error {
info, err := os.Stat(path)
data, err := fileops.ReadRegularFile(path, MaxOutputFileBytes)
if err != nil {
return fmt.Errorf("stat file: %w", err)
return fmt.Errorf("scriptorium artifact output exceeds or cannot be read within %d-byte limit: %w", MaxOutputFileBytes, err)
}
if info.IsDir() {
return fmt.Errorf("path is a directory")
}
if info.Size() <= 0 {
if len(data) == 0 {
return fmt.Errorf("file is empty")
}
return nil

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"time"
@@ -14,6 +13,9 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
// MaxOutputFileBytes bounds each Seriatim JSON or rendered-text result.
const MaxOutputFileBytes int64 = 64 * 1024 * 1024
// EnvConfig defines optional Seriatim environment tuning values.
type EnvConfig struct {
OverlapWordRunGap *float64
@@ -636,9 +638,9 @@ func writeRenderInvocationConfig(req RenderRequest, args []string, binary string
}
func validateJSONFile(path string) error {
data, err := os.ReadFile(path)
data, err := readSeriatimResult(path, "JSON output")
if err != nil {
return fmt.Errorf("read file: %w", err)
return err
}
var v any
if err := json.Unmarshal(data, &v); err != nil {
@@ -648,9 +650,9 @@ func validateJSONFile(path string) error {
}
func validateJSONFileWithSegments(path string) error {
data, err := os.ReadFile(path)
data, err := readSeriatimResult(path, "transcript JSON output")
if err != nil {
return fmt.Errorf("read file: %w", err)
return err
}
var payload map[string]any
@@ -669,9 +671,9 @@ func validateJSONFileWithSegments(path string) error {
}
func validateNonEmptyTextFile(path string) error {
data, err := os.ReadFile(path)
data, err := readSeriatimResult(path, "rendered text output")
if err != nil {
return fmt.Errorf("read file: %w", err)
return err
}
if len(data) == 0 {
return fmt.Errorf("file is empty")
@@ -684,3 +686,11 @@ func validateNonEmptyTextFile(path string) error {
}
return nil
}
func readSeriatimResult(path, category string) ([]byte, error) {
data, err := fileops.ReadRegularFile(path, MaxOutputFileBytes)
if err != nil {
return nil, fmt.Errorf("seriatim %s exceeds or cannot be read within %d-byte limit: %w", category, MaxOutputFileBytes, err)
}
return data, nil
}

View File

@@ -10,9 +10,13 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
// MaxResolvedArtifactBytes bounds externally generated artifact content before validation.
const MaxResolvedArtifactBytes int64 = 64 * 1024 * 1024
const (
ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase
ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
@@ -445,9 +449,9 @@ func validateResolvedContent(path string, kind artifactContentKind) error {
}
func validateTranscriptSegmentsJSON(path string) error {
data, err := os.ReadFile(path)
data, err := readResolvedArtifact(path, "transcript JSON")
if err != nil {
return fmt.Errorf("read file: %w", err)
return err
}
var payload map[string]any
if err := json.Unmarshal(data, &payload); err != nil {
@@ -464,9 +468,9 @@ func validateTranscriptSegmentsJSON(path string) error {
}
func validateJSONContent(path string) error {
data, err := os.ReadFile(path)
data, err := readResolvedArtifact(path, "JSON artifact")
if err != nil {
return fmt.Errorf("read file: %w", err)
return err
}
var payload any
if err := json.Unmarshal(data, &payload); err != nil {
@@ -476,15 +480,20 @@ func validateJSONContent(path string) error {
}
func validateNonEmptyContent(path string) error {
info, err := os.Stat(path)
data, err := readResolvedArtifact(path, "text artifact")
if err != nil {
return fmt.Errorf("stat file: %w", err)
return err
}
if info.IsDir() {
return fmt.Errorf("path is a directory")
}
if info.Size() <= 0 {
if len(data) == 0 {
return fmt.Errorf("file is empty")
}
return nil
}
func readResolvedArtifact(path, category string) ([]byte, error) {
data, err := fileops.ReadRegularFile(path, MaxResolvedArtifactBytes)
if err != nil {
return nil, fmt.Errorf("resolved %s exceeds or cannot be read within %d-byte limit: %w", category, MaxResolvedArtifactBytes, err)
}
return data, nil
}

View File

@@ -7,9 +7,13 @@ import (
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
// MaxExtractionPayloadBytes bounds one validated Notarius lane or index payload.
const MaxExtractionPayloadBytes int64 = 64 * 1024 * 1024
const (
extractStageName = "extract"
extractionLaneKind = "notarius_lane"
@@ -131,7 +135,7 @@ func validExtractionPayload(bundleRoot, path, checksum string) bool {
if err != nil || actual != checksum {
return false
}
body, err := os.ReadFile(path)
body, err := fileops.ReadRegularFile(path, MaxExtractionPayloadBytes)
return err == nil && json.Valid(body)
}

View File

@@ -4,12 +4,17 @@ import (
"encoding/json"
"fmt"
"math"
"os"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
const (
// MaxSessionBoundsFileBytes bounds one Scriptorium bounds JSON result.
MaxSessionBoundsFileBytes int64 = 1 * 1024 * 1024
// MaxTranscriptFileBytes bounds a transcript JSON handoff used for bounds validation.
MaxTranscriptFileBytes int64 = 64 * 1024 * 1024
// BoundsTrimActionTrim keeps only a selected segment range.
BoundsTrimActionTrim = "trim"
// BoundsTrimActionNone indicates no trimming should be applied.
@@ -36,9 +41,9 @@ type SessionBounds struct {
// ParseSessionBoundsFile reads and parses one Scriptorium bounds output JSON file.
func ParseSessionBoundsFile(path string) (SessionBounds, error) {
data, err := os.ReadFile(path)
data, err := readContractResult(path, MaxSessionBoundsFileBytes, "scriptorium bounds result")
if err != nil {
return SessionBounds{}, fmt.Errorf("read bounds file: %w", err)
return SessionBounds{}, err
}
var bounds SessionBounds
if err := json.Unmarshal(data, &bounds); err != nil {
@@ -119,9 +124,9 @@ func normalizeBoundsTrimAction(raw string) (string, error) {
}
func loadTranscriptSegmentIDs(path string) (map[int]struct{}, error) {
data, err := os.ReadFile(path)
data, err := readContractResult(path, MaxTranscriptFileBytes, "transcript result")
if err != nil {
return nil, fmt.Errorf("read transcript file: %w", err)
return nil, err
}
var payload map[string]any
@@ -158,6 +163,14 @@ func loadTranscriptSegmentIDs(path string) (map[int]struct{}, error) {
return ids, nil
}
func readContractResult(path string, limit int64, category string) ([]byte, error) {
data, err := fileops.ReadRegularFile(path, limit)
if err != nil {
return nil, fmt.Errorf("%s exceeds or cannot be read within %d-byte limit: %w", category, limit, err)
}
return data, nil
}
func parseJSONSegmentID(v any) (int, error) {
n, ok := v.(float64)
if !ok {

View File

@@ -108,3 +108,14 @@ func ReadRegularFileUnderRoot(
}
return content, nil
}
// ReadRegularFile reads a bounded regular file without following symbolic links
// in its parent hierarchy. It is intended for externally produced results;
// callers own the limit and semantic validation contract.
func ReadRegularFile(path string, maxBytes int64) ([]byte, error) {
clean := filepath.Clean(path)
if clean == "." || filepath.Base(clean) == "." {
return nil, fmt.Errorf("file path is required")
}
return ReadRegularFileUnderRoot(filepath.Dir(clean), filepath.Base(clean), maxBytes, nil, nil)
}

View File

@@ -0,0 +1,65 @@
package fileops
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestReadRegularFileEnforcesLimitAndRegularFileIdentity(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "result.json")
if err := os.WriteFile(path, []byte("1234"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
data, err := ReadRegularFile(path, 4)
if err != nil {
t.Fatalf("ReadRegularFile(exact limit) error = %v", err)
}
if string(data) != "1234" {
t.Fatalf("data = %q, want exact file content", data)
}
if _, err := ReadRegularFile(path, 3); err == nil || !strings.Contains(err.Error(), "3-byte limit") {
t.Fatalf("ReadRegularFile(limit plus one) error = %v, want limit failure", err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("Remove(file) error = %v", err)
}
if err := os.Mkdir(path, 0o700); err != nil {
t.Fatalf("Mkdir(non-regular file) error = %v", err)
}
if _, err := ReadRegularFile(path, 4); err == nil || !strings.Contains(err.Error(), "not a regular file") {
t.Fatalf("ReadRegularFile(directory) error = %v, want regular-file failure", err)
}
}
func TestReadRegularFileRejectsSymlinkedPaths(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink behavior differs on windows")
}
root := t.TempDir()
outside := t.TempDir()
outsideFile := filepath.Join(outside, "result.json")
if err := os.WriteFile(outsideFile, []byte("outside"), 0o600); err != nil {
t.Fatalf("WriteFile(outside) error = %v", err)
}
leaf := filepath.Join(root, "result.json")
if err := os.Symlink(outsideFile, leaf); err != nil {
t.Fatalf("Symlink(leaf) error = %v", err)
}
if _, err := ReadRegularFile(leaf, 64); err == nil {
t.Fatal("ReadRegularFile(leaf symlink) error = nil, want rejection")
}
if err := os.Remove(leaf); err != nil {
t.Fatalf("Remove(leaf symlink) error = %v", err)
}
linkedParent := filepath.Join(root, "linked")
if err := os.Symlink(outside, linkedParent); err != nil {
t.Fatalf("Symlink(parent) error = %v", err)
}
if _, err := ReadRegularFile(filepath.Join(linkedParent, "result.json"), 64); err == nil {
t.Fatal("ReadRegularFile(symlinked parent) error = nil, want rejection")
}
}

View File

@@ -916,14 +916,11 @@ func deriveSessionVarValue(name string, session *config.SessionConfig) (string,
}
func requireNonEmptyFile(path string, label string) error {
if err := requireFile(path, label); err != nil {
data, err := readExternalResult(path, label)
if err != nil {
return err
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("%s %q stat failed: %w", label, path, err)
}
if info.Size() <= 0 {
if len(data) == 0 {
return fmt.Errorf("%s %q is empty", label, path)
}
return nil
@@ -968,9 +965,9 @@ func resolveRenderDebugEnabled(global bool, perArtifact *bool) bool {
}
func validateJSONFile(path string) error {
data, err := os.ReadFile(path)
data, err := readExternalResult(path, "scriptorium artifact result")
if err != nil {
return fmt.Errorf("read file: %w", err)
return err
}
var payload any
if err := json.Unmarshal(data, &payload); err != nil {

View File

@@ -0,0 +1,19 @@
package stage
import (
"fmt"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
// MaxExternalResultFileBytes bounds transcript and artifact files produced by
// external adapters before a stage validates or materializes them.
const MaxExternalResultFileBytes int64 = 64 * 1024 * 1024
func readExternalResult(path, category string) ([]byte, error) {
data, err := fileops.ReadRegularFile(path, MaxExternalResultFileBytes)
if err != nil {
return nil, fmt.Errorf("%s exceeds or cannot be read within %d-byte limit: %w", category, MaxExternalResultFileBytes, err)
}
return data, nil
}

View File

@@ -349,9 +349,9 @@ func checksumRegularFile(path string, requireJSON bool) (string, error) {
if info.Size() == 0 {
return "", fmt.Errorf("path %q must be non-empty", path)
}
data, err := os.ReadFile(path)
data, err := fileops.ReadRegularFile(path, artifacts.MaxExtractionPayloadBytes)
if err != nil {
return "", err
return "", fmt.Errorf("notarius lane result exceeds or cannot be read within %d-byte limit: %w", artifacts.MaxExtractionPayloadBytes, err)
}
if requireJSON && !json.Valid(data) {
return "", fmt.Errorf("path %q is not valid JSON", path)

View File

@@ -284,9 +284,9 @@ func validateProcessedTranscriptOutput(path string) error {
if err := requireFile(path, "processed transcript"); err != nil {
return err
}
data, err := os.ReadFile(path)
data, err := readExternalResult(path, "audita processed transcript result")
if err != nil {
return fmt.Errorf("read processed transcript: %w", err)
return err
}
var payload map[string]any

View File

@@ -2,7 +2,6 @@ package stage
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -130,9 +129,9 @@ func materializeRunLocalOutput(
if canonicalPath == "" {
return artifacts.Ref{}, fmt.Errorf("canonical destination path is required")
}
data, err := os.ReadFile(srcPath)
data, err := readExternalResult(srcPath, "run-local external output")
if err != nil {
return artifacts.Ref{}, fmt.Errorf("read run-local output %q: %w", srcPath, err)
return artifacts.Ref{}, err
}
if err := store.WriteFileAtomic(canonicalPath, data, fileops.WorkspaceFileMode); err != nil {
return artifacts.Ref{}, fmt.Errorf("materialize output to %q: %w", canonicalPath, err)

View File

@@ -321,9 +321,9 @@ func validateTranscriptJSONFile(path string) error {
if err := requireFile(path, "raw transcript"); err != nil {
return err
}
data, err := os.ReadFile(path)
data, err := readExternalResult(path, "whisperx transcript result")
if err != nil {
return fmt.Errorf("read transcript: %w", err)
return err
}
var payload any
if err := json.Unmarshal(data, &payload); err != nil {

View File

@@ -3,7 +3,6 @@ package stage
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@@ -389,9 +388,9 @@ func resolveTrimmedOutputPath(paths artifacts.SessionPaths, cfg *config.TrimConf
}
func copyTranscript(store artifacts.Store, src, dst string) error {
data, err := os.ReadFile(src)
data, err := readExternalResult(src, "seriatim trimmed transcript result")
if err != nil {
return fmt.Errorf("read source transcript: %w", err)
return err
}
if err := store.WriteFileAtomic(dst, data, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write destination transcript: %w", err)