Bound external result file reads
This commit is contained in:
@@ -41,6 +41,9 @@ Run fails for:
|
||||
- invalid processed transcript JSON (`segments` array required);
|
||||
- invalid report JSON when reporting is enabled.
|
||||
|
||||
Processed transcript JSON is limited to 64 MiB and optional report JSON to 16
|
||||
MiB. Both must be regular files without symlinked path components.
|
||||
|
||||
Failure results still include output/log/config/exit metadata for diagnostics.
|
||||
|
||||
## Deterministic Behavior
|
||||
|
||||
@@ -46,6 +46,9 @@ Run behavior:
|
||||
- `run` exit code `2` is mapped to `ValidationFailed=true`;
|
||||
- successful subprocess still fails if output file is missing or empty.
|
||||
|
||||
Each artifact result is limited to 64 MiB and must be a regular file without
|
||||
symlinked path components.
|
||||
|
||||
Render behavior:
|
||||
- subprocess errors propagate;
|
||||
- output file must exist and be non-empty.
|
||||
|
||||
@@ -41,6 +41,8 @@ Invocation fails on:
|
||||
- empty render output files.
|
||||
|
||||
When report paths are provided/enabled, report files must parse as JSON.
|
||||
Each Seriatim JSON or rendered-text result is limited to 64 MiB and must be a
|
||||
regular file without symlinked path components.
|
||||
|
||||
## Deterministic Behavior
|
||||
- argument ordering is deterministic per command construction.
|
||||
|
||||
@@ -34,10 +34,11 @@ successful. Command and post-publish policy remains owned by `internal/app`.
|
||||
## Confined Reads
|
||||
|
||||
`ReadRegularFileUnderRoot` is the no-follow, bounded read primitive for a
|
||||
caller-selected root and relative file path. It verifies the root and every
|
||||
ancestor through directory handles, admits only a stable regular-file handle,
|
||||
and lets the caller enforce its own byte limit and access policy. Credential
|
||||
mode policy and environment precedence remain owned by `internal/app`.
|
||||
caller-selected root and relative file path; `ReadRegularFile` is its
|
||||
path-based convenience wrapper. They verify every ancestor through directory
|
||||
handles and admit only a stable regular-file handle. Callers enforce their own
|
||||
byte limits and access policy. Credential mode policy and environment
|
||||
precedence remain owned by `internal/app`.
|
||||
|
||||
## Replacement Contract
|
||||
|
||||
|
||||
@@ -307,6 +307,11 @@ On POSIX, provision a credential directory as `0700` and credential files as
|
||||
`0600`; Narratio rejects group- or other-readable configured credential paths.
|
||||
On Windows, restrict the directory and files with ACLs to the credential owner.
|
||||
|
||||
External adapter results are individually bounded before Narratio validates or
|
||||
materializes them. These per-file limits do not reserve disk space: prevent hard
|
||||
disk exhaustion with filesystem, service, container, or volume quotas sized for
|
||||
the session workload.
|
||||
|
||||
## Cleanup
|
||||
|
||||
Session-scoped cleanup:
|
||||
|
||||
@@ -22,7 +22,7 @@ All stages are pending when this plan is created.
|
||||
| 4 | Add confined destination and download/install capabilities | COR-003, DUP-003, TST-003 | Completed |
|
||||
| 5 | Confine recursive cleanup and replace sentinel locks | RSK-003 | Completed |
|
||||
| 6 | Harden API-key file acquisition | RSK-010 | Completed |
|
||||
| 7 | Bound and verify external result acquisition | RSK-013, TST-007 | Pending |
|
||||
| 7 | Bound and verify external result acquisition | RSK-013, TST-007 | Completed |
|
||||
| 8 | Terminate owned subprocess trees | RSK-011 | Pending |
|
||||
| 9 | Redact and cap subprocess diagnostics | RSK-012 | Pending |
|
||||
| 10 | Confine publish archive reads | COR-005 | Pending |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
65
internal/fileops/read_confined_test.go
Normal file
65
internal/fileops/read_confined_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
19
internal/stage/external_result.go
Normal file
19
internal/stage/external_result.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user