Share bounded regular file read mechanics
This commit is contained in:
@@ -6,7 +6,8 @@ module and package documentation boundary. Its implemented public primitives are
|
|||||||
- immutable semantic `Path` construction and strict JSON Pointer parsing;
|
- immutable semantic `Path` construction and strict JSON Pointer parsing;
|
||||||
- detached provenance and diagnostic record types with empty `Metadata` queries;
|
- detached provenance and diagnostic record types with empty `Metadata` queries;
|
||||||
- canonical, control-safe diagnostic rendering and structured `LoadError` access;
|
- canonical, control-safe diagnostic rendering and structured `LoadError` access;
|
||||||
- opaque file, byte, reader, and no-input source construction and bounded reads;
|
- opaque file, byte, reader, and no-input source construction in `source.go`,
|
||||||
|
with shared regular-file checks and bounded read mechanics in `source_file.go`;
|
||||||
- a parser-independent ordered tree in `internal/document`;
|
- a parser-independent ordered tree in `internal/document`;
|
||||||
- the sole upstream YAML integration in `internal/yaml`, which translates validated
|
- the sole upstream YAML integration in `internal/yaml`, which translates validated
|
||||||
input into that owned tree and exposes no parser types outside its boundary;
|
input into that owned tree and exposes no parser types outside its boundary;
|
||||||
|
|||||||
@@ -120,7 +120,14 @@ small cross-entry regression only if a source path lacks equivalent coverage.
|
|||||||
|
|
||||||
## Stage 3 — Share bounded regular-file read mechanics
|
## Stage 3 — Share bounded regular-file read mechanics
|
||||||
|
|
||||||
Status: pending.
|
Status: complete.
|
||||||
|
|
||||||
|
Completion: `source_file.go` owns regular-file preflight/open/recheck/close and
|
||||||
|
bounded reads, with private failure kinds and stat/open-only optional-missing
|
||||||
|
classification. Callers retain containment, metadata, budgets, accounting, and
|
||||||
|
limit diagnostics. Existing filesystem, FIFO, budget, and reader tests pass;
|
||||||
|
a private regression covers nondeterministic filesystem failure phases. Full
|
||||||
|
offline baseline and race suites passed with Go 1.26.7 on Linux/amd64.
|
||||||
|
|
||||||
Evidence: `readSource`, `importTraversal.readImport`, and `readProfileFile` repeat
|
Evidence: `readSource`, `importTraversal.readImport`, and `readProfileFile` repeat
|
||||||
preflight stat, open, post-open stat, regular-file checks, size checks, and bounded
|
preflight stat, open, post-open stat, regular-file checks, size checks, and bounded
|
||||||
|
|||||||
44
imports.go
44
imports.go
@@ -3,7 +3,6 @@ package configkit
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@@ -382,47 +381,12 @@ func (s *importTraversal) readImport(relative, sourceID string, optional bool) (
|
|||||||
if limit < 0 {
|
if limit < 0 {
|
||||||
limit = 0
|
limit = 0
|
||||||
}
|
}
|
||||||
preflight, err := s.root.Stat(relative)
|
data, failure := readRegularFile(relative, s.root.Stat, s.root.Open, limit)
|
||||||
if err != nil {
|
if failure != nil {
|
||||||
if optional && errors.Is(err, fs.ErrNotExist) {
|
if optional && failure.missingTarget {
|
||||||
return nil, true, nil
|
return nil, true, nil
|
||||||
}
|
}
|
||||||
value := classifySourceError(err, sourceID)
|
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
if !preflight.Mode().IsRegular() {
|
|
||||||
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
file, err := s.root.Open(relative)
|
|
||||||
if err != nil {
|
|
||||||
if optional && errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil, true, nil
|
|
||||||
}
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
defer func() { _ = file.Close() }()
|
|
||||||
info, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() {
|
|
||||||
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
if info.Size() > limit {
|
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
|
||||||
if err != nil {
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
if int64(len(data)) > limit {
|
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
|
||||||
return nil, false, &value
|
return nil, false, &value
|
||||||
}
|
}
|
||||||
s.usedBytes += int64(len(data))
|
s.usedBytes += int64(len(data))
|
||||||
|
|||||||
39
profile.go
39
profile.go
@@ -3,7 +3,6 @@ package configkit
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -122,41 +121,9 @@ func readProfileFile(root *os.Root, relative, sourceID string, remaining int64)
|
|||||||
if limit < 0 {
|
if limit < 0 {
|
||||||
limit = 0
|
limit = 0
|
||||||
}
|
}
|
||||||
preflight, err := root.Stat(relative)
|
data, failure := readRegularFile(relative, root.Stat, root.Open, limit)
|
||||||
if err != nil {
|
if failure != nil {
|
||||||
value := classifySourceError(err, sourceID)
|
value := sourceReadDiagnostic(failure, sourceID, CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit")
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
if !preflight.Mode().IsRegular() {
|
|
||||||
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
file, err := root.Open(relative)
|
|
||||||
if err != nil {
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
defer func() { _ = file.Close() }()
|
|
||||||
info, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() {
|
|
||||||
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
if info.Size() > limit {
|
|
||||||
value := importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
|
||||||
if err != nil {
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
if int64(len(data)) > limit {
|
|
||||||
value := importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
|
||||||
return nil, &value
|
return nil, &value
|
||||||
}
|
}
|
||||||
return data, nil
|
return data, nil
|
||||||
|
|||||||
47
source.go
47
source.go
@@ -125,10 +125,7 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
|
|||||||
}
|
}
|
||||||
info := &SourceInfo{ID: sourceID, Label: source.label, Layer: LayerBase}
|
info := &SourceInfo{ID: sourceID, Label: source.label, Layer: LayerBase}
|
||||||
|
|
||||||
var (
|
var input io.Reader
|
||||||
input io.Reader
|
|
||||||
close func() error
|
|
||||||
)
|
|
||||||
switch source.kind {
|
switch source.kind {
|
||||||
case sourceFile:
|
case sourceFile:
|
||||||
absolute, err := filepath.Abs(source.path)
|
absolute, err := filepath.Abs(source.path)
|
||||||
@@ -141,32 +138,11 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
|
|||||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
||||||
}
|
}
|
||||||
info.Path = canonical
|
info.Path = canonical
|
||||||
preflight, err := os.Stat(canonical)
|
data, failure := readRegularFile(canonical, os.Stat, os.Open, limit)
|
||||||
if err != nil {
|
if failure != nil {
|
||||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
|
||||||
}
|
}
|
||||||
if !preflight.Mode().IsRegular() {
|
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
||||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")}
|
|
||||||
}
|
|
||||||
file, err := os.Open(canonical)
|
|
||||||
if err != nil {
|
|
||||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
|
||||||
}
|
|
||||||
close = file.Close
|
|
||||||
stat, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
_ = file.Close()
|
|
||||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
|
||||||
}
|
|
||||||
if !stat.Mode().IsRegular() {
|
|
||||||
_ = file.Close()
|
|
||||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")}
|
|
||||||
}
|
|
||||||
if stat.Size() > limit {
|
|
||||||
_ = file.Close()
|
|
||||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, sourceID, "source exceeds the configured byte limit")}
|
|
||||||
}
|
|
||||||
input = file
|
|
||||||
case sourceBytes:
|
case sourceBytes:
|
||||||
input = bytes.NewReader(source.data)
|
input = bytes.NewReader(source.data)
|
||||||
case sourceReader:
|
case sourceReader:
|
||||||
@@ -177,16 +153,9 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
|
|||||||
default:
|
default:
|
||||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceInvalid, sourceID, "source is invalid")}
|
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceInvalid, sourceID, "source is invalid")}
|
||||||
}
|
}
|
||||||
if close != nil {
|
data, failure := readBoundedSource(input, limit)
|
||||||
defer func() { _ = close() }()
|
if failure != nil {
|
||||||
}
|
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
|
||||||
|
|
||||||
data, err := io.ReadAll(io.LimitReader(input, limit+1))
|
|
||||||
if err != nil {
|
|
||||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
|
||||||
}
|
|
||||||
if int64(len(data)) > limit {
|
|
||||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, sourceID, "source exceeds the configured byte limit")}
|
|
||||||
}
|
}
|
||||||
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
75
source_file.go
Normal file
75
source_file.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sourceReadFailureKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
sourceReadIO sourceReadFailureKind = iota
|
||||||
|
sourceReadNotRegular
|
||||||
|
sourceReadLimit
|
||||||
|
)
|
||||||
|
|
||||||
|
// sourceReadFailure never escapes the private source boundary. missingTarget is
|
||||||
|
// set only for preflight/open failures; a later missing-file error is not optional.
|
||||||
|
type sourceReadFailure struct {
|
||||||
|
kind sourceReadFailureKind
|
||||||
|
cause error
|
||||||
|
missingTarget bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRegularFile borrows stat/open operations so root-bound callers retain their
|
||||||
|
// containment policy. Preflight rejects FIFOs before open can block. The opened
|
||||||
|
// handle is checked again and always closed; read-only close errors are ignored.
|
||||||
|
func readRegularFile(name string, stat func(string) (fs.FileInfo, error), open func(string) (*os.File, error), limit int64) ([]byte, *sourceReadFailure) {
|
||||||
|
info, err := stat(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &sourceReadFailure{cause: err, missingTarget: errors.Is(err, fs.ErrNotExist)}
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return nil, &sourceReadFailure{kind: sourceReadNotRegular}
|
||||||
|
}
|
||||||
|
file, err := open(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &sourceReadFailure{cause: err, missingTarget: errors.Is(err, fs.ErrNotExist)}
|
||||||
|
}
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
info, err = file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, &sourceReadFailure{cause: err}
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return nil, &sourceReadFailure{kind: sourceReadNotRegular}
|
||||||
|
}
|
||||||
|
if info.Size() > limit {
|
||||||
|
return nil, &sourceReadFailure{kind: sourceReadLimit}
|
||||||
|
}
|
||||||
|
return readBoundedSource(file, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBoundedSource(input io.Reader, limit int64) ([]byte, *sourceReadFailure) {
|
||||||
|
data, err := io.ReadAll(io.LimitReader(input, limit+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, &sourceReadFailure{cause: err}
|
||||||
|
}
|
||||||
|
if int64(len(data)) > limit {
|
||||||
|
return nil, &sourceReadFailure{kind: sourceReadLimit}
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceReadDiagnostic(failure *sourceReadFailure, sourceID string, limitCode Code, limitMessage string) Diagnostic {
|
||||||
|
switch failure.kind {
|
||||||
|
case sourceReadNotRegular:
|
||||||
|
return sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
||||||
|
case sourceReadLimit:
|
||||||
|
return sourceDiagnostic(limitCode, sourceID, limitMessage)
|
||||||
|
default:
|
||||||
|
return classifySourceError(failure.cause, sourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
source_file_test.go
Normal file
48
source_file_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Files disappearing between filesystem operations are not deterministic with
|
||||||
|
// real concurrent deletion. Exercise the private seam that controls optionality.
|
||||||
|
func TestRegularFileMissingFailurePhases(t *testing.T) {
|
||||||
|
name := filepath.Join(t.TempDir(), "input.yml")
|
||||||
|
if err := os.WriteFile(name, []byte("{}"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
stat func(string) (fs.FileInfo, error)
|
||||||
|
open func(string) (*os.File, error)
|
||||||
|
missing bool
|
||||||
|
}{
|
||||||
|
{"preflight", func(string) (fs.FileInfo, error) { return nil, fs.ErrNotExist }, os.Open, true},
|
||||||
|
{"open", os.Stat, func(string) (*os.File, error) { return nil, fs.ErrNotExist }, true},
|
||||||
|
{"post-open stat", os.Stat, func(name string) (*os.File, error) {
|
||||||
|
file, err := os.Open(name)
|
||||||
|
if err == nil {
|
||||||
|
err = file.Close()
|
||||||
|
}
|
||||||
|
return file, err
|
||||||
|
}, false},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
data, failure := readRegularFile(name, test.stat, test.open, 16)
|
||||||
|
if data != nil || failure == nil || failure.kind != sourceReadIO || failure.missingTarget != test.missing {
|
||||||
|
t.Fatalf("data = %v, failure = %#v", data, failure)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_, failure := readBoundedSource(missingDuringRead{}, 16)
|
||||||
|
if failure == nil || failure.kind != sourceReadIO || failure.missingTarget {
|
||||||
|
t.Fatalf("late read failure = %#v", failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type missingDuringRead struct{}
|
||||||
|
|
||||||
|
func (missingDuringRead) Read([]byte) (int, error) { return 0, fs.ErrNotExist }
|
||||||
Reference in New Issue
Block a user