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 processed transcript JSON (`segments` array required);
|
||||||
- invalid report JSON when reporting is enabled.
|
- 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.
|
Failure results still include output/log/config/exit metadata for diagnostics.
|
||||||
|
|
||||||
## Deterministic Behavior
|
## Deterministic Behavior
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ Run behavior:
|
|||||||
- `run` exit code `2` is mapped to `ValidationFailed=true`;
|
- `run` exit code `2` is mapped to `ValidationFailed=true`;
|
||||||
- successful subprocess still fails if output file is missing or empty.
|
- 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:
|
Render behavior:
|
||||||
- subprocess errors propagate;
|
- subprocess errors propagate;
|
||||||
- output file must exist and be non-empty.
|
- output file must exist and be non-empty.
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ Invocation fails on:
|
|||||||
- empty render output files.
|
- empty render output files.
|
||||||
|
|
||||||
When report paths are provided/enabled, report files must parse as JSON.
|
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
|
## Deterministic Behavior
|
||||||
- argument ordering is deterministic per command construction.
|
- argument ordering is deterministic per command construction.
|
||||||
|
|||||||
@@ -34,10 +34,11 @@ successful. Command and post-publish policy remains owned by `internal/app`.
|
|||||||
## Confined Reads
|
## Confined Reads
|
||||||
|
|
||||||
`ReadRegularFileUnderRoot` is the no-follow, bounded read primitive for a
|
`ReadRegularFileUnderRoot` is the no-follow, bounded read primitive for a
|
||||||
caller-selected root and relative file path. It verifies the root and every
|
caller-selected root and relative file path; `ReadRegularFile` is its
|
||||||
ancestor through directory handles, admits only a stable regular-file handle,
|
path-based convenience wrapper. They verify every ancestor through directory
|
||||||
and lets the caller enforce its own byte limit and access policy. Credential
|
handles and admit only a stable regular-file handle. Callers enforce their own
|
||||||
mode policy and environment precedence remain owned by `internal/app`.
|
byte limits and access policy. Credential mode policy and environment
|
||||||
|
precedence remain owned by `internal/app`.
|
||||||
|
|
||||||
## Replacement Contract
|
## 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.
|
`0600`; Narratio rejects group- or other-readable configured credential paths.
|
||||||
On Windows, restrict the directory and files with ACLs to the credential owner.
|
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
|
## Cleanup
|
||||||
|
|
||||||
Session-scoped 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 |
|
| 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 |
|
| 5 | Confine recursive cleanup and replace sentinel locks | RSK-003 | Completed |
|
||||||
| 6 | Harden API-key file acquisition | RSK-010 | 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 |
|
| 8 | Terminate owned subprocess trees | RSK-011 | Pending |
|
||||||
| 9 | Redact and cap subprocess diagnostics | RSK-012 | Pending |
|
| 9 | Redact and cap subprocess diagnostics | RSK-012 | Pending |
|
||||||
| 10 | Confine publish archive reads | COR-005 | Pending |
|
| 10 | Confine publish archive reads | COR-005 | Pending |
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
"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.
|
// SubprocessRunnerConfig defines deterministic settings for Audita CLI execution.
|
||||||
type SubprocessRunnerConfig struct {
|
type SubprocessRunnerConfig struct {
|
||||||
Binary string
|
Binary string
|
||||||
@@ -375,9 +381,9 @@ func (r *SubprocessRunner) writeInvocationConfig(req PolishRequest, args []strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateProcessedOutput(path string) error {
|
func validateProcessedOutput(path string) error {
|
||||||
data, err := os.ReadFile(path)
|
data, err := readAuditaResult(path, MaxProcessedOutputBytes, "processed transcript")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read file: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload map[string]any
|
var payload map[string]any
|
||||||
@@ -406,9 +412,9 @@ func addSubprocessStreamHint(message string, runErr error) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateJSONFile(path string) error {
|
func validateJSONFile(path string) error {
|
||||||
data, err := os.ReadFile(path)
|
data, err := readAuditaResult(path, MaxReportOutputBytes, "report")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read file: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
var v any
|
var v any
|
||||||
if err := json.Unmarshal(data, &v); err != nil {
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
@@ -416,3 +422,11 @@ func validateJSONFile(path string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
"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 {
|
func decodeBoundedJSON(path string, limit int64, destination any) error {
|
||||||
inspected, err := os.Lstat(path)
|
data, err := fileops.ReadRegularFile(path, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("notarius JSON result exceeds or cannot be read within %d-byte limit: %w", limit, 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)
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(data, destination); err != nil {
|
if err := json.Unmarshal(data, destination); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
"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.
|
// SubprocessRunner invokes Scriptorium through its public CLI.
|
||||||
type SubprocessRunner struct{}
|
type SubprocessRunner struct{}
|
||||||
|
|
||||||
@@ -326,14 +329,11 @@ func writeInvocationConfig(path string, payload invocationPayload) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateNonEmptyOutput(path string) error {
|
func validateNonEmptyOutput(path string) error {
|
||||||
info, err := os.Stat(path)
|
data, err := fileops.ReadRegularFile(path, MaxOutputFileBytes)
|
||||||
if err != nil {
|
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() {
|
if len(data) == 0 {
|
||||||
return fmt.Errorf("path is a directory")
|
|
||||||
}
|
|
||||||
if info.Size() <= 0 {
|
|
||||||
return fmt.Errorf("file is empty")
|
return fmt.Errorf("file is empty")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,6 +13,9 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
"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.
|
// EnvConfig defines optional Seriatim environment tuning values.
|
||||||
type EnvConfig struct {
|
type EnvConfig struct {
|
||||||
OverlapWordRunGap *float64
|
OverlapWordRunGap *float64
|
||||||
@@ -636,9 +638,9 @@ func writeRenderInvocationConfig(req RenderRequest, args []string, binary string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateJSONFile(path string) error {
|
func validateJSONFile(path string) error {
|
||||||
data, err := os.ReadFile(path)
|
data, err := readSeriatimResult(path, "JSON output")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read file: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
var v any
|
var v any
|
||||||
if err := json.Unmarshal(data, &v); err != nil {
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
@@ -648,9 +650,9 @@ func validateJSONFile(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateJSONFileWithSegments(path string) error {
|
func validateJSONFileWithSegments(path string) error {
|
||||||
data, err := os.ReadFile(path)
|
data, err := readSeriatimResult(path, "transcript JSON output")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read file: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload map[string]any
|
var payload map[string]any
|
||||||
@@ -669,9 +671,9 @@ func validateJSONFileWithSegments(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateNonEmptyTextFile(path string) error {
|
func validateNonEmptyTextFile(path string) error {
|
||||||
data, err := os.ReadFile(path)
|
data, err := readSeriatimResult(path, "rendered text output")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read file: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return fmt.Errorf("file is empty")
|
return fmt.Errorf("file is empty")
|
||||||
@@ -684,3 +686,11 @@ func validateNonEmptyTextFile(path string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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/artifactmodel"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MaxResolvedArtifactBytes bounds externally generated artifact content before validation.
|
||||||
|
const MaxResolvedArtifactBytes int64 = 64 * 1024 * 1024
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase
|
ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase
|
||||||
ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
|
ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
|
||||||
@@ -445,9 +449,9 @@ func validateResolvedContent(path string, kind artifactContentKind) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateTranscriptSegmentsJSON(path string) error {
|
func validateTranscriptSegmentsJSON(path string) error {
|
||||||
data, err := os.ReadFile(path)
|
data, err := readResolvedArtifact(path, "transcript JSON")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read file: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
var payload map[string]any
|
var payload map[string]any
|
||||||
if err := json.Unmarshal(data, &payload); err != nil {
|
if err := json.Unmarshal(data, &payload); err != nil {
|
||||||
@@ -464,9 +468,9 @@ func validateTranscriptSegmentsJSON(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateJSONContent(path string) error {
|
func validateJSONContent(path string) error {
|
||||||
data, err := os.ReadFile(path)
|
data, err := readResolvedArtifact(path, "JSON artifact")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read file: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
var payload any
|
var payload any
|
||||||
if err := json.Unmarshal(data, &payload); err != nil {
|
if err := json.Unmarshal(data, &payload); err != nil {
|
||||||
@@ -476,15 +480,20 @@ func validateJSONContent(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateNonEmptyContent(path string) error {
|
func validateNonEmptyContent(path string) error {
|
||||||
info, err := os.Stat(path)
|
data, err := readResolvedArtifact(path, "text artifact")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stat file: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
if info.IsDir() {
|
if len(data) == 0 {
|
||||||
return fmt.Errorf("path is a directory")
|
|
||||||
}
|
|
||||||
if info.Size() <= 0 {
|
|
||||||
return fmt.Errorf("file is empty")
|
return fmt.Errorf("file is empty")
|
||||||
}
|
}
|
||||||
return nil
|
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"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MaxExtractionPayloadBytes bounds one validated Notarius lane or index payload.
|
||||||
|
const MaxExtractionPayloadBytes int64 = 64 * 1024 * 1024
|
||||||
|
|
||||||
const (
|
const (
|
||||||
extractStageName = "extract"
|
extractStageName = "extract"
|
||||||
extractionLaneKind = "notarius_lane"
|
extractionLaneKind = "notarius_lane"
|
||||||
@@ -131,7 +135,7 @@ func validExtractionPayload(bundleRoot, path, checksum string) bool {
|
|||||||
if err != nil || actual != checksum {
|
if err != nil || actual != checksum {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
body, err := os.ReadFile(path)
|
body, err := fileops.ReadRegularFile(path, MaxExtractionPayloadBytes)
|
||||||
return err == nil && json.Valid(body)
|
return err == nil && json.Valid(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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 keeps only a selected segment range.
|
||||||
BoundsTrimActionTrim = "trim"
|
BoundsTrimActionTrim = "trim"
|
||||||
// BoundsTrimActionNone indicates no trimming should be applied.
|
// BoundsTrimActionNone indicates no trimming should be applied.
|
||||||
@@ -36,9 +41,9 @@ type SessionBounds struct {
|
|||||||
|
|
||||||
// ParseSessionBoundsFile reads and parses one Scriptorium bounds output JSON file.
|
// ParseSessionBoundsFile reads and parses one Scriptorium bounds output JSON file.
|
||||||
func ParseSessionBoundsFile(path string) (SessionBounds, error) {
|
func ParseSessionBoundsFile(path string) (SessionBounds, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := readContractResult(path, MaxSessionBoundsFileBytes, "scriptorium bounds result")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SessionBounds{}, fmt.Errorf("read bounds file: %w", err)
|
return SessionBounds{}, err
|
||||||
}
|
}
|
||||||
var bounds SessionBounds
|
var bounds SessionBounds
|
||||||
if err := json.Unmarshal(data, &bounds); err != nil {
|
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) {
|
func loadTranscriptSegmentIDs(path string) (map[int]struct{}, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := readContractResult(path, MaxTranscriptFileBytes, "transcript result")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read transcript file: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload map[string]any
|
var payload map[string]any
|
||||||
@@ -158,6 +163,14 @@ func loadTranscriptSegmentIDs(path string) (map[int]struct{}, error) {
|
|||||||
return ids, nil
|
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) {
|
func parseJSONSegmentID(v any) (int, error) {
|
||||||
n, ok := v.(float64)
|
n, ok := v.(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -108,3 +108,14 @@ func ReadRegularFileUnderRoot(
|
|||||||
}
|
}
|
||||||
return content, nil
|
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 {
|
func requireNonEmptyFile(path string, label string) error {
|
||||||
if err := requireFile(path, label); err != nil {
|
data, err := readExternalResult(path, label)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
info, err := os.Stat(path)
|
if len(data) == 0 {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s %q stat failed: %w", label, path, err)
|
|
||||||
}
|
|
||||||
if info.Size() <= 0 {
|
|
||||||
return fmt.Errorf("%s %q is empty", label, path)
|
return fmt.Errorf("%s %q is empty", label, path)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -968,9 +965,9 @@ func resolveRenderDebugEnabled(global bool, perArtifact *bool) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateJSONFile(path string) error {
|
func validateJSONFile(path string) error {
|
||||||
data, err := os.ReadFile(path)
|
data, err := readExternalResult(path, "scriptorium artifact result")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read file: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
var payload any
|
var payload any
|
||||||
if err := json.Unmarshal(data, &payload); err != nil {
|
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 {
|
if info.Size() == 0 {
|
||||||
return "", fmt.Errorf("path %q must be non-empty", path)
|
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 {
|
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) {
|
if requireJSON && !json.Valid(data) {
|
||||||
return "", fmt.Errorf("path %q is not valid JSON", path)
|
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 {
|
if err := requireFile(path, "processed transcript"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(path)
|
data, err := readExternalResult(path, "audita processed transcript result")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read processed transcript: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload map[string]any
|
var payload map[string]any
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package stage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -130,9 +129,9 @@ func materializeRunLocalOutput(
|
|||||||
if canonicalPath == "" {
|
if canonicalPath == "" {
|
||||||
return artifacts.Ref{}, fmt.Errorf("canonical destination path is required")
|
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 {
|
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 {
|
if err := store.WriteFileAtomic(canonicalPath, data, fileops.WorkspaceFileMode); err != nil {
|
||||||
return artifacts.Ref{}, fmt.Errorf("materialize output to %q: %w", canonicalPath, err)
|
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 {
|
if err := requireFile(path, "raw transcript"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(path)
|
data, err := readExternalResult(path, "whisperx transcript result")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read transcript: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
var payload any
|
var payload any
|
||||||
if err := json.Unmarshal(data, &payload); err != nil {
|
if err := json.Unmarshal(data, &payload); err != nil {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package stage
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -389,9 +388,9 @@ func resolveTrimmedOutputPath(paths artifacts.SessionPaths, cfg *config.TrimConf
|
|||||||
}
|
}
|
||||||
|
|
||||||
func copyTranscript(store artifacts.Store, src, dst string) error {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("read source transcript: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
if err := store.WriteFileAtomic(dst, data, fileops.WorkspaceFileMode); err != nil {
|
if err := store.WriteFileAtomic(dst, data, fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write destination transcript: %w", err)
|
return fmt.Errorf("write destination transcript: %w", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user