Migrate ordinary I/O from confined file helpers
This commit is contained in:
@@ -237,7 +237,7 @@ replacement.
|
||||
|
||||
## Stage 2 — Migrate Ordinary I/O And Isolate Credential Handling
|
||||
|
||||
**Status: Pending**
|
||||
**Status: Completed**
|
||||
|
||||
### Goal
|
||||
|
||||
|
||||
@@ -425,7 +425,7 @@ func validateJSONFile(path string) error {
|
||||
}
|
||||
|
||||
func readAuditaResult(path string, limit int64, category string) ([]byte, error) {
|
||||
data, err := fileops.ReadRegularFile(path, limit)
|
||||
data, err := fileops.ReadFileBounded(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)
|
||||
}
|
||||
|
||||
@@ -671,7 +671,7 @@ func sumWarningOccurrences(values []WarningSummary) (int, error) {
|
||||
}
|
||||
|
||||
func decodeBoundedJSON(path string, limit int64, destination any) error {
|
||||
data, err := fileops.ReadRegularFile(path, limit)
|
||||
data, err := fileops.ReadFileBounded(path, limit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notarius JSON result exceeds or cannot be read within %d-byte limit: %w", limit, err)
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ func writeInvocationConfig(path string, payload invocationPayload) error {
|
||||
}
|
||||
|
||||
func validateNonEmptyOutput(path string) error {
|
||||
data, err := fileops.ReadRegularFile(path, MaxOutputFileBytes)
|
||||
data, err := fileops.ReadFileBounded(path, MaxOutputFileBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scriptorium artifact output exceeds or cannot be read within %d-byte limit: %w", MaxOutputFileBytes, err)
|
||||
}
|
||||
|
||||
@@ -692,7 +692,7 @@ func validateNonEmptyTextFile(path string) error {
|
||||
}
|
||||
|
||||
func readSeriatimResult(path, category string) ([]byte, error) {
|
||||
data, err := fileops.ReadRegularFile(path, MaxOutputFileBytes)
|
||||
data, err := fileops.ReadFileBounded(path, MaxOutputFileBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seriatim %s exceeds or cannot be read within %d-byte limit: %w", category, MaxOutputFileBytes, err)
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ func openDiagnosticFile(path string) (*os.File, error) {
|
||||
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(path)); err != nil {
|
||||
return nil, fmt.Errorf("create log directory for %q: %w", path, err)
|
||||
}
|
||||
file, err := fileops.OpenFileConfined(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileops.WorkspaceFileMode)
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileops.WorkspaceFileMode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open log file %q: %w", path, err)
|
||||
}
|
||||
|
||||
@@ -163,15 +163,15 @@ func TestRunRejectsSymlinkDiagnosticWithoutTruncatingTarget(t *testing.T) {
|
||||
StdoutLogPath: stdoutPath,
|
||||
StderrLogPath: filepath.Join(dir, "stderr.log"),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "symbolic link") {
|
||||
t.Fatalf("Run() error = %v, want symbolic-link rejection", err)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
data, readErr := os.ReadFile(targetPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadFile(target) error = %v", readErr)
|
||||
}
|
||||
if string(data) != original {
|
||||
t.Fatalf("target content = %q, want %q", data, original)
|
||||
if string(data) == original {
|
||||
t.Fatal("diagnostic target was not replaced")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,14 @@ func loadSecretsFromConfig(cfg *config.Config, logger *slog.Logger) (*secretsLoa
|
||||
}
|
||||
resolvedDir = filepath.Clean(resolvedDir)
|
||||
|
||||
if err := fileops.ValidateConfinedDirectory(resolvedDir, validateSecretDirectory); err != nil {
|
||||
directoryInfo, err := os.Lstat(resolvedDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API-key loader: validate secrets env_dir: %w", err)
|
||||
}
|
||||
if directoryInfo.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("API-key loader: secrets env_dir must not be a symbolic link")
|
||||
}
|
||||
if err := validateSecretDirectory(directoryInfo); err != nil {
|
||||
return nil, fmt.Errorf("API-key loader: validate secrets env_dir: %w", err)
|
||||
}
|
||||
|
||||
@@ -57,6 +64,13 @@ func loadSecretsFromConfig(cfg *config.Config, logger *slog.Logger) (*secretsLoa
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API-key loader: read secrets env_dir %q: %w", resolvedDir, err)
|
||||
}
|
||||
directoryInfo, err = os.Lstat(resolvedDir)
|
||||
if err != nil || directoryInfo.Mode()&os.ModeSymlink != 0 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("symbolic links are not allowed")
|
||||
}
|
||||
return nil, fmt.Errorf("API-key loader: validate secrets env_dir: %w", err)
|
||||
}
|
||||
|
||||
stats := &secretsLoadStats{Dir: resolvedDir}
|
||||
for _, entry := range entries {
|
||||
@@ -107,15 +121,23 @@ func validateSecretFile(info os.FileInfo) error {
|
||||
}
|
||||
|
||||
func readAPIKeyFile(directory, name string) (string, error) {
|
||||
content, err := fileops.ReadRegularFileUnderRoot(
|
||||
directory,
|
||||
name,
|
||||
APIKeyFileByteLimit,
|
||||
validateSecretDirectory,
|
||||
validateSecretFile,
|
||||
)
|
||||
path := filepath.Join(directory, name)
|
||||
info, err := os.Lstat(path)
|
||||
if err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("API-key loader: read secret file %q with %d-byte limit: symbolic links are not allowed", name, APIKeyFileByteLimit)
|
||||
}
|
||||
if err == nil {
|
||||
err = validateSecretFile(info)
|
||||
}
|
||||
if err == nil {
|
||||
content, readErr := fileops.ReadFileBounded(path, APIKeyFileByteLimit)
|
||||
if readErr == nil {
|
||||
return strings.TrimRight(string(content), "\r\n"), nil
|
||||
}
|
||||
err = readErr
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("API-key loader: read secret file %q with %d-byte limit: %w", name, APIKeyFileByteLimit, err)
|
||||
}
|
||||
return strings.TrimRight(string(content), "\r\n"), nil
|
||||
return "", fmt.Errorf("API-key loader: read secret file %q with %d-byte limit", name, APIKeyFileByteLimit)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"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/pathsafe"
|
||||
)
|
||||
@@ -85,7 +85,7 @@ func InspectAnalyzeEvidence(
|
||||
return nonCurrent("configured artifact manifest identity is incompatible; regenerate the artifact")
|
||||
}
|
||||
|
||||
file, err := fileops.OpenConfinedRegularFile(paths.Root, configuredPath)
|
||||
file, err := os.Open(filepath.Join(paths.Root, filepath.FromSlash(configuredPath)))
|
||||
if err != nil {
|
||||
return nonCurrent("configured artifact output is missing or unsafe; regenerate the artifact")
|
||||
}
|
||||
|
||||
@@ -104,30 +104,6 @@ func TestInspectAnalyzeEvidenceRejectsMissingAndUnsafeFiles(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{name: "symlink leaf", alter: func(t *testing.T, paths SessionPaths, outputPath string) {
|
||||
if err := os.Remove(outputPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(paths.Root, "target.md")
|
||||
if err := os.WriteFile(target, []byte("recap\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, outputPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{name: "symlink ancestor", alter: func(t *testing.T, paths SessionPaths, outputPath string) {
|
||||
if err := os.RemoveAll(paths.ArtifactsDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "session_recap.md"), []byte("recap\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, paths.ArtifactsDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -136,7 +112,7 @@ func TestInspectAnalyzeEvidenceRejectsMissingAndUnsafeFiles(t *testing.T) {
|
||||
outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md")
|
||||
test.alter(t, paths, outputPath)
|
||||
got := InspectAnalyzeEvidence(paths, m, "session_recap", ConfiguredArtifactDefinition{OutputPath: "artifacts/session_recap.md"})
|
||||
if got.State != AnalyzeEvidenceNonCurrent || !strings.Contains(got.Reason, "missing or unsafe") {
|
||||
if got.State != AnalyzeEvidenceNonCurrent {
|
||||
t.Fatalf("evidence = %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -497,7 +497,7 @@ func validateNonEmptyContent(path string) error {
|
||||
}
|
||||
|
||||
func readResolvedArtifact(path, category string) ([]byte, error) {
|
||||
data, err := fileops.ReadRegularFile(path, MaxResolvedArtifactBytes)
|
||||
data, err := fileops.ReadFileBounded(path, MaxResolvedArtifactBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolved %s exceeds or cannot be read within %d-byte limit: %w", category, MaxResolvedArtifactBytes, err)
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ func inspectExtractionPayload(root, path, checksum string) (ExtractionEvidenceSt
|
||||
if actual != checksum {
|
||||
return ExtractionEvidenceObsolete, "extract output checksum does not match durable bytes", 0
|
||||
}
|
||||
body, err := fileops.ReadRegularFile(path, MaxExtractionPayloadBytes)
|
||||
body, err := fileops.ReadFileBounded(path, MaxExtractionPayloadBytes)
|
||||
if err != nil || !json.Valid(body) {
|
||||
return ExtractionEvidenceObsolete, "extract output is not valid JSON", 0
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func ResolveExtractionInputIdentity(paths SessionPaths, m *manifest.Manifest) (E
|
||||
if err != nil {
|
||||
return ExtractionInputIdentity{}, fmt.Errorf("resolve trimmed transcript path: %w", err)
|
||||
}
|
||||
bytes, err := fileops.ReadRegularFile(path, MaxResolvedArtifactBytes)
|
||||
bytes, err := fileops.ReadFileBounded(path, MaxResolvedArtifactBytes)
|
||||
if err != nil {
|
||||
return ExtractionInputIdentity{}, fmt.Errorf("read trimmed transcript: %w", err)
|
||||
}
|
||||
|
||||
@@ -207,7 +207,10 @@ func (s *LocalStore) AcquireSessionLockForContext(ctx context.Context, campaign,
|
||||
}
|
||||
|
||||
func (s *LocalStore) acquireSessionLockForPaths(paths SessionPaths) (*LockHandle, error) {
|
||||
f, err := fileops.OpenFileConfined(paths.LockPath, os.O_CREATE|os.O_RDWR, fileops.WorkspaceFileMode)
|
||||
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(paths.LockPath)); err != nil {
|
||||
return nil, fmt.Errorf("create lock directory %q: %w", filepath.Dir(paths.LockPath), err)
|
||||
}
|
||||
f, err := os.OpenFile(paths.LockPath, os.O_CREATE|os.O_RDWR, fileops.WorkspaceFileMode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acquire lock %q: %w", paths.LockPath, err)
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
@@ -107,11 +107,16 @@ func ResolvePreparedInput(paths SessionPaths, m *manifest.Manifest, sourceID str
|
||||
return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q manifest checksum is required", descriptor.SourceID)
|
||||
}
|
||||
|
||||
file, err := fileops.OpenConfinedRegularFile(rootPath, relativePath)
|
||||
file, err := os.Open(filepath.Join(rootPath, filepath.FromSlash(relativePath)))
|
||||
if err != nil {
|
||||
return PreparedInputIdentity{}, fmt.Errorf("open prepared input source %q: %w", descriptor.SourceID, err)
|
||||
}
|
||||
digest := sha256.New()
|
||||
info, statErr := file.Stat()
|
||||
if statErr != nil || !info.Mode().IsRegular() {
|
||||
_ = file.Close()
|
||||
return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q is not a regular file", descriptor.SourceID)
|
||||
}
|
||||
size, readErr := io.Copy(digest, file)
|
||||
closeErr := file.Close()
|
||||
if readErr != nil {
|
||||
|
||||
@@ -130,22 +130,6 @@ func TestResolvePreparedInputRejectsInvalidCanonicalFile(t *testing.T) {
|
||||
},
|
||||
wantErr: "open prepared input source",
|
||||
},
|
||||
{
|
||||
name: "symlink",
|
||||
mutate: func(t *testing.T, path string) {
|
||||
outside := filepath.Join(t.TempDir(), "outside.json")
|
||||
if err := os.WriteFile(outside, []byte("outside\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(outside) error = %v", err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("Remove() error = %v", err)
|
||||
}
|
||||
if err := os.Symlink(outside, path); err != nil {
|
||||
t.Fatalf("Symlink() error = %v", err)
|
||||
}
|
||||
},
|
||||
wantErr: "not a regular file",
|
||||
},
|
||||
{
|
||||
name: "directory",
|
||||
mutate: func(t *testing.T, path string) {
|
||||
|
||||
@@ -57,7 +57,7 @@ func cacheIdentityEligible(req S3MaterializeRequest) bool {
|
||||
}
|
||||
|
||||
func loadAudioCacheIdentity(path string) (audioCacheIdentity, bool, error) {
|
||||
data, err := fileops.ReadRegularFile(path, 64*1024)
|
||||
data, err := fileops.ReadFileBounded(path, 64*1024)
|
||||
if os.IsNotExist(err) {
|
||||
return audioCacheIdentity{}, false, nil
|
||||
}
|
||||
@@ -99,11 +99,7 @@ func materializeVerifiedCachedAudio(ctx context.Context, req S3MaterializeReques
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
relativePath, err := cacheRelativePath(req.CacheRoot, cachePath)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
source, err := fileops.OpenConfinedRegularFile(req.CacheRoot, relativePath)
|
||||
source, err := os.Open(cachePath)
|
||||
if err != nil {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func resolvePartyInput(input ResolvedInputFile) (ResolvedParty, error) {
|
||||
path = filepath.Join(filepath.Dir(configPath), path)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
raw, err := fileops.ReadRegularFile(path, maxPartyDocumentBytes)
|
||||
raw, err := fileops.ReadFileBounded(path, maxPartyDocumentBytes)
|
||||
if err != nil {
|
||||
return ResolvedParty{}, fmt.Errorf("read party input %q: %w", path, err)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -386,7 +385,7 @@ func loadPipelineProfileOverlays(
|
||||
}
|
||||
seenPaths[normalized] = profile.name
|
||||
resolved := filepath.Join(rootDir, filepath.FromSlash(normalized))
|
||||
file, err := fileops.OpenConfinedRegularFile(rootDir, normalized)
|
||||
file, err := os.Open(resolved)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s path %q beneath root pipeline directory: %w", label, raw, err)
|
||||
}
|
||||
@@ -473,7 +472,7 @@ func loadPipelineImports(rootPath string, declared []string) ([]loadedPipelineIm
|
||||
return nil, fmt.Errorf("composition.imports[%d] path %q imports the root pipeline itself", index, raw)
|
||||
}
|
||||
|
||||
file, err := fileops.OpenConfinedRegularFile(rootDir, normalized)
|
||||
file, err := os.Open(resolved)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open composition.imports[%d] path %q beneath root pipeline directory: %w", index, raw, err)
|
||||
}
|
||||
|
||||
@@ -181,19 +181,7 @@ func TestLoadPipelineCompositionRejectsUnsafeOrInvalidImports(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(dir, "directory.yml"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, want: "regular file"},
|
||||
{name: "symlink file", imports: []string{"link.yml"}, setup: func(t *testing.T, dir string) {
|
||||
writePipelineSource(t, dir, "target.yml", "workspace:\n root: /work\n")
|
||||
if err := os.Symlink("target.yml", filepath.Join(dir, "link.yml")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
}, want: "not a regular file"},
|
||||
{name: "symlink directory", imports: []string{"linked/one.yml"}, setup: func(t *testing.T, dir string) {
|
||||
writePipelineSource(t, dir, "actual/one.yml", "workspace:\n root: /work\n")
|
||||
if err := os.Symlink("actual", filepath.Join(dir, "linked")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
}, want: "not a regular directory"},
|
||||
}, want: "is a directory"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -148,7 +148,7 @@ func TestLoadPipelineProfilesValidateEveryDeclaredOverlay(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(dir, "profiles/testing.yml"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, want: "regular file"},
|
||||
}, want: "is a directory"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -164,7 +164,7 @@ func loadTranscriptSegmentIDs(path string) (map[int]struct{}, error) {
|
||||
}
|
||||
|
||||
func readContractResult(path string, limit int64, category string) ([]byte, error) {
|
||||
data, err := fileops.ReadRegularFile(path, limit)
|
||||
data, err := fileops.ReadFileBounded(path, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s exceeds or cannot be read within %d-byte limit: %w", category, limit, err)
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenFileConfinedRejectsNonRegularLeaf(t *testing.T) {
|
||||
leaf := filepath.Join(t.TempDir(), "diagnostic.log")
|
||||
if err := os.Mkdir(leaf, 0o700); err != nil {
|
||||
t.Fatalf("Mkdir() error = %v", err)
|
||||
}
|
||||
file, err := OpenFileConfined(leaf, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, WorkspaceFileMode)
|
||||
if file != nil {
|
||||
_ = file.Close()
|
||||
t.Fatal("OpenFileConfined() returned a file for a directory")
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "not a regular file") {
|
||||
t.Fatalf("OpenFileConfined() error = %v, want regular-file rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFileConfinedRejectsSymlinkWithoutTruncatingTarget(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating symlinks requires privileges that are not available on every Windows runner")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target.log")
|
||||
const original = "outside content"
|
||||
if err := os.WriteFile(target, []byte(original), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(target) error = %v", err)
|
||||
}
|
||||
leaf := filepath.Join(dir, "diagnostic.log")
|
||||
if err := os.Symlink(target, leaf); err != nil {
|
||||
t.Fatalf("Symlink() error = %v", err)
|
||||
}
|
||||
|
||||
file, err := OpenFileConfined(leaf, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, WorkspaceFileMode)
|
||||
if file != nil {
|
||||
_ = file.Close()
|
||||
t.Fatal("OpenFileConfined() returned a file for a symbolic link")
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "symbolic link") {
|
||||
t.Fatalf("OpenFileConfined() error = %v, want symbolic-link rejection", err)
|
||||
}
|
||||
data, readErr := os.ReadFile(target)
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadFile(target) error = %v", readErr)
|
||||
}
|
||||
if string(data) != original {
|
||||
t.Fatalf("target content = %q, want %q", data, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFileConfinedDoesNotFollowLeafReplacedBeforeTruncate(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating symlinks requires privileges that are not available on every Windows runner")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target.log")
|
||||
const original = "unrelated content"
|
||||
if err := os.WriteFile(target, []byte(original), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(target) error = %v", err)
|
||||
}
|
||||
leaf := filepath.Join(dir, "diagnostic.log")
|
||||
if err := os.WriteFile(leaf, []byte("old diagnostic"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(leaf) error = %v", err)
|
||||
}
|
||||
|
||||
file, err := openFileConfined(leaf, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, WorkspaceFileMode, func() error {
|
||||
if err := os.Remove(leaf); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(filepath.Base(target), leaf)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("openFileConfined() error = %v", err)
|
||||
}
|
||||
if _, err := file.WriteString("new diagnostic"); err != nil {
|
||||
_ = file.Close()
|
||||
t.Fatalf("WriteString() error = %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
|
||||
targetData, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(target) error = %v", err)
|
||||
}
|
||||
if string(targetData) != original {
|
||||
t.Fatalf("target content = %q, want %q", targetData, original)
|
||||
}
|
||||
leafData, err := os.ReadFile(leaf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(leaf) error = %v", err)
|
||||
}
|
||||
if string(leafData) != "new diagnostic" {
|
||||
t.Fatalf("leaf content = %q, want replacement diagnostic", leafData)
|
||||
}
|
||||
info, err := os.Lstat(leaf)
|
||||
if err != nil {
|
||||
t.Fatalf("Lstat(leaf) error = %v", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
t.Fatalf("leaf mode = %v, want regular file", info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFileConfinedRejectsLeafReplacedBeforeExistingFileOpen(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating symlinks requires privileges that are not available on every Windows runner")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target.lock")
|
||||
const original = "unrelated lock content"
|
||||
if err := os.WriteFile(target, []byte(original), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(target) error = %v", err)
|
||||
}
|
||||
leaf := filepath.Join(dir, "session.lock")
|
||||
if err := os.WriteFile(leaf, []byte("old lock"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(leaf) error = %v", err)
|
||||
}
|
||||
|
||||
file, err := openFileConfined(leaf, os.O_RDWR|os.O_CREATE, WorkspaceFileMode, func() error {
|
||||
if err := os.Remove(leaf); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(filepath.Base(target), leaf)
|
||||
})
|
||||
if file != nil {
|
||||
_ = file.Close()
|
||||
t.Fatal("openFileConfined() returned a raced symbolic-link target")
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "changed while being opened") {
|
||||
t.Fatalf("openFileConfined() error = %v, want identity-change rejection", err)
|
||||
}
|
||||
targetData, readErr := os.ReadFile(target)
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadFile(target) error = %v", readErr)
|
||||
}
|
||||
if string(targetData) != original {
|
||||
t.Fatalf("target content = %q, want %q", targetData, original)
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
// OpenConfinedRegularFile opens a declared file beneath root without following
|
||||
// symlinked ancestors or leaf entries. The returned descriptor remains valid if
|
||||
// the pathname is later replaced.
|
||||
func OpenConfinedRegularFile(rootPath, relativePath string) (*os.File, error) {
|
||||
rootPath = filepath.Clean(strings.TrimSpace(rootPath))
|
||||
if rootPath == "" || rootPath == "." {
|
||||
return nil, fmt.Errorf("source root is required")
|
||||
}
|
||||
relativePath, err := pathsafe.NormalizeRelativeDestination(relativePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("source path: %w", err)
|
||||
}
|
||||
|
||||
root, err := openConfinedDirectory(rootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open source root: %w", err)
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
parts := strings.Split(filepath.FromSlash(relativePath), string(filepath.Separator))
|
||||
parent := root
|
||||
for _, part := range parts[:len(parts)-1] {
|
||||
child, err := openConfinedChild(parent, part, false, 0)
|
||||
if err != nil {
|
||||
if parent != root {
|
||||
_ = parent.Close()
|
||||
}
|
||||
return nil, fmt.Errorf("open source ancestor %q: %w", part, err)
|
||||
}
|
||||
if parent != root {
|
||||
_ = parent.Close()
|
||||
}
|
||||
parent = child
|
||||
}
|
||||
if parent != root {
|
||||
defer parent.Close()
|
||||
}
|
||||
|
||||
name := parts[len(parts)-1]
|
||||
declared, err := parent.Lstat(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect source %q: %w", relativePath, err)
|
||||
}
|
||||
if declared.Mode()&os.ModeSymlink != 0 || !declared.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("source %q is not a regular file", relativePath)
|
||||
}
|
||||
|
||||
file, err := parent.Open(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open source %q: %w", relativePath, err)
|
||||
}
|
||||
opened, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("inspect opened source %q: %w", relativePath, err)
|
||||
}
|
||||
current, err := parent.Lstat(name)
|
||||
if err != nil || current.Mode()&os.ModeSymlink != 0 || !current.Mode().IsRegular() ||
|
||||
!opened.Mode().IsRegular() || !os.SameFile(opened, declared) || !os.SameFile(opened, current) {
|
||||
_ = file.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reinspect source %q: %w", relativePath, err)
|
||||
}
|
||||
return nil, fmt.Errorf("source %q changed while being opened", relativePath)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ValidateConfinedDirectory opens path without following symbolic links and
|
||||
// applies validate to the opened directory's metadata.
|
||||
func ValidateConfinedDirectory(path string, validate func(os.FileInfo) error) error {
|
||||
root, err := openConfinedDirectory(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = root.Close() }()
|
||||
info, err := root.Stat(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect root directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("root path is not a directory")
|
||||
}
|
||||
if validate != nil {
|
||||
return validate(info)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadRegularFileUnderRoot reads a bounded regular file after opening root and
|
||||
// every relative-path ancestor without following symbolic links. validateRoot
|
||||
// and validateFile may enforce caller-owned access policy while their handles
|
||||
// are still verified.
|
||||
func ReadRegularFileUnderRoot(
|
||||
rootPath, relativePath string,
|
||||
maxBytes int64,
|
||||
validateRoot, validateFile func(os.FileInfo) error,
|
||||
) ([]byte, error) {
|
||||
if maxBytes < 0 {
|
||||
return nil, fmt.Errorf("maximum byte count must not be negative")
|
||||
}
|
||||
if filepath.IsAbs(relativePath) {
|
||||
return nil, fmt.Errorf("relative file path must not be absolute")
|
||||
}
|
||||
parts, err := relativePathParts(relativePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid relative file path: %w", err)
|
||||
}
|
||||
root, err := openConfinedDirectory(rootPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = root.Close() }()
|
||||
rootInfo, err := root.Stat(".")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect root directory: %w", err)
|
||||
}
|
||||
if validateRoot != nil {
|
||||
if err := validateRoot(rootInfo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, part := range parts[:len(parts)-1] {
|
||||
child, err := openConfinedChild(root, part, false, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = root.Close()
|
||||
root = child
|
||||
}
|
||||
|
||||
name := parts[len(parts)-1]
|
||||
inspected, err := root.Lstat(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect file %q: %w", name, err)
|
||||
}
|
||||
if inspected.Mode()&os.ModeSymlink != 0 || !inspected.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("file %q is not a regular file", name)
|
||||
}
|
||||
file, err := root.Open(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open file %q: %w", name, err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
opened, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect opened file %q: %w", name, err)
|
||||
}
|
||||
current, err := root.Lstat(name)
|
||||
if err != nil || current.Mode()&os.ModeSymlink != 0 || !current.Mode().IsRegular() || !os.SameFile(opened, current) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reinspect file %q: %w", name, err)
|
||||
}
|
||||
return nil, fmt.Errorf("file %q changed while being opened", name)
|
||||
}
|
||||
if validateFile != nil {
|
||||
if err := validateFile(opened); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
content, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file %q: %w", name, err)
|
||||
}
|
||||
if int64(len(content)) > maxBytes {
|
||||
return nil, fmt.Errorf("file %q exceeds %d-byte limit", name, maxBytes)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -166,11 +166,7 @@ func (invalidAnalyzeOutputRunner) RunArtifact(_ context.Context, req scriptorium
|
||||
if err := os.MkdirAll(filepath.Dir(req.OutputPath), 0o755); err != nil {
|
||||
return scriptorium.ArtifactResult{}, err
|
||||
}
|
||||
target := req.OutputPath + ".target"
|
||||
if err := os.WriteFile(target, []byte("unsafe\n"), 0o644); err != nil {
|
||||
return scriptorium.ArtifactResult{}, err
|
||||
}
|
||||
if err := os.Symlink(target, req.OutputPath); err != nil {
|
||||
if err := os.Mkdir(req.OutputPath, 0o755); err != nil {
|
||||
return scriptorium.ArtifactResult{}, err
|
||||
}
|
||||
return scriptorium.ArtifactResult{OutputPath: req.OutputPath, CommandMode: scriptorium.CommandModeRun}, nil
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -13,7 +14,6 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
@@ -263,7 +263,7 @@ func hashAnalyzeInputFile(paths artifacts.SessionPaths, path string) (string, in
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("input path is outside session root: %w", err)
|
||||
}
|
||||
file, err := fileops.OpenConfinedRegularFile(root, relative)
|
||||
file, err := os.Open(target)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
@@ -272,6 +272,10 @@ func hashAnalyzeInputFile(paths artifacts.SessionPaths, path string) (string, in
|
||||
_ = file.Close()
|
||||
return "", 0, fmt.Errorf("inspect input: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
_ = file.Close()
|
||||
return "", 0, fmt.Errorf("input is not a regular file")
|
||||
}
|
||||
digest := sha256.New()
|
||||
size, readErr := io.Copy(digest, io.LimitReader(file, artifacts.MaxResolvedArtifactBytes+1))
|
||||
closeErr := file.Close()
|
||||
|
||||
@@ -182,21 +182,6 @@ func TestResolveAnalyzeInputIdentitiesRejectsUnsafePathsAndFileTypes(t *testing.
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "symlink", sourceID: "narratio.transcript.final_trimmed",
|
||||
setup: func(t *testing.T, env *Env, m *manifest.Manifest) {
|
||||
t.Helper()
|
||||
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json")
|
||||
outside := filepath.Join(t.TempDir(), "outside.json")
|
||||
writeAnalyzeFile(t, outside, `{"segments":[]}`)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "manifest path outside session", sourceID: artifactpolicy.SourceInputPlayers,
|
||||
setup: func(t *testing.T, _ *Env, m *manifest.Manifest) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
const MaxExternalResultFileBytes int64 = 64 * 1024 * 1024
|
||||
|
||||
func readExternalResult(path, category string) ([]byte, error) {
|
||||
data, err := fileops.ReadRegularFile(path, MaxExternalResultFileBytes)
|
||||
data, err := fileops.ReadFileBounded(path, MaxExternalResultFileBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s exceeds or cannot be read within %d-byte limit: %w", category, MaxExternalResultFileBytes, err)
|
||||
}
|
||||
|
||||
@@ -373,7 +373,7 @@ func checksumRegularFile(path string, requireJSON bool) (string, error) {
|
||||
if info.Size() == 0 {
|
||||
return "", fmt.Errorf("path %q must be non-empty", path)
|
||||
}
|
||||
data, err := fileops.ReadRegularFile(path, artifacts.MaxExtractionPayloadBytes)
|
||||
data, err := fileops.ReadFileBounded(path, artifacts.MaxExtractionPayloadBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("notarius lane result exceeds or cannot be read within %d-byte limit: %w", artifacts.MaxExtractionPayloadBytes, err)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func materializeExtractReferenceSnapshots(
|
||||
}
|
||||
destinationOwners[destination] = identity.SourceID
|
||||
|
||||
file, err := fileops.OpenConfinedRegularFile(paths.Root, identity.Path)
|
||||
file, err := os.Open(filepath.Join(paths.Root, filepath.FromSlash(identity.Path)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open reference %q source %q for snapshot: %w", identity.Selector, identity.SourceID, err)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func verifyExtractReferenceSnapshots(snapshotRoot string, references resolvedExt
|
||||
continue
|
||||
}
|
||||
filename := filepath.Base(filepath.FromSlash(identity.Path))
|
||||
file, err := fileops.OpenConfinedRegularFile(snapshotRoot, filename)
|
||||
file, err := os.Open(filepath.Join(snapshotRoot, filename))
|
||||
if err != nil {
|
||||
return fmt.Errorf("reopen reference %q source %q snapshot: %w", identity.Selector, identity.SourceID, err)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
@@ -106,7 +105,7 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
return nil, fmt.Errorf("publish: run id is required")
|
||||
}
|
||||
|
||||
runManifestFile, err := fileops.OpenConfinedRegularFile(runRoot, filepath.Base(runManifestPath))
|
||||
runManifestFile, err := openPublishOutputSource(filepath.Join(runRoot, filepath.Base(runManifestPath)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: open run manifest: %w", err)
|
||||
}
|
||||
@@ -420,7 +419,14 @@ func openPublishOutputSource(localPath string) (*os.File, error) {
|
||||
if path == "" || path == "." {
|
||||
return nil, fmt.Errorf("local path is required")
|
||||
}
|
||||
return fileops.OpenConfinedRegularFile(filepath.Dir(path), filepath.Base(path))
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("source is not a regular file")
|
||||
}
|
||||
return os.Open(path)
|
||||
}
|
||||
|
||||
func publishOutputRemoteKey(runPrefix, destination string) string {
|
||||
@@ -913,7 +919,7 @@ func collectPublishPreviousFiles(ctx context.Context, previousDir string) ([]pub
|
||||
return nil, nil, fmt.Errorf("previous directory is not a regular directory")
|
||||
}
|
||||
|
||||
manifestFile, err := fileops.OpenConfinedRegularFile(previousDir, "manifest.json")
|
||||
manifestFile, err := openPublishOutputSource(filepath.Join(previousDir, "manifest.json"))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil, nil
|
||||
@@ -1005,7 +1011,7 @@ func openPublishRunFiles(runRoot string, files []publishUploadFile, manifestFile
|
||||
file := manifestFile
|
||||
if candidate.SourceRelativePath != "manifest.json" {
|
||||
var err error
|
||||
file, err = fileops.OpenConfinedRegularFile(runRoot, candidate.SourceRelativePath)
|
||||
file, err = openPublishOutputSource(filepath.Join(runRoot, filepath.FromSlash(candidate.SourceRelativePath)))
|
||||
if err != nil {
|
||||
closePublishSources(sources)
|
||||
return nil, err
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
@@ -239,7 +238,7 @@ func TestPublishRejectsDeclaredLeafSymlinkBeforeUploading(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := (publishStage{}).Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), `source "logs/audita.stderr.log" is not a regular file`) {
|
||||
if err == nil || !strings.Contains(err.Error(), "not a regular file") {
|
||||
t.Fatalf("Run() error = %v, want rejected symlink", err)
|
||||
}
|
||||
assertNoPublishUploads(t, env.ObjectStore.(*storage.FakeBackend), "outside secret")
|
||||
@@ -261,13 +260,13 @@ func TestPublishRejectsSymlinkedRunManifestBeforeUploading(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := (publishStage{}).Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), `source "manifest.json" is not a regular file`) {
|
||||
if err == nil || !strings.Contains(err.Error(), "not a regular file") {
|
||||
t.Fatalf("Run() error = %v, want rejected run manifest symlink", err)
|
||||
}
|
||||
assertNoPublishUploads(t, env.ObjectStore.(*storage.FakeBackend), "outside")
|
||||
}
|
||||
|
||||
func TestPublishRejectsSymlinkedSourceAncestorBeforeUploading(t *testing.T) {
|
||||
func TestPublishUsesOrdinarySourceAncestorPathSemantics(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation requires privileges on Windows")
|
||||
}
|
||||
@@ -282,11 +281,9 @@ func TestPublishRejectsSymlinkedSourceAncestorBeforeUploading(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (publishStage{}).Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), "source ancestor") {
|
||||
t.Fatalf("Run() error = %v, want rejected symlinked ancestor", err)
|
||||
if _, err := (publishStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
assertNoPublishUploads(t, env.ObjectStore.(*storage.FakeBackend), "outside secret")
|
||||
}
|
||||
|
||||
func TestPublishRejectsDeclaredSourceReplacement(t *testing.T) {
|
||||
@@ -304,7 +301,7 @@ func TestPublishRejectsDeclaredSourceReplacement(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeStageTestFile(t, target, "replacement\n")
|
||||
manifestFile, err := fileops.OpenConfinedRegularFile(runRoot, "manifest.json")
|
||||
manifestFile, err := os.Open(filepath.Join(runRoot, "manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -327,7 +324,7 @@ func TestPublishRejectsDeclaredDirectoryBeforeUploading(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := (publishStage{}).Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), `source "logs/audita.stderr.log" is not a regular file`) {
|
||||
if err == nil || !strings.Contains(err.Error(), "not a regular file") {
|
||||
t.Fatalf("Run() error = %v, want rejected directory", err)
|
||||
}
|
||||
assertNoPublishUploads(t, env.ObjectStore.(*storage.FakeBackend), "")
|
||||
@@ -377,7 +374,7 @@ func TestPublishRejectsDeclaredPreviousSymlinkBeforeUploading(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := (publishStage{}).Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), `source "artifacts/session_recap.md" is not a regular file`) {
|
||||
if err == nil || !strings.Contains(err.Error(), "not a regular file") {
|
||||
t.Fatalf("Run() error = %v, want rejected previous symlink", err)
|
||||
}
|
||||
assertNoPublishUploads(t, env.ObjectStore.(*storage.FakeBackend), "outside previous secret")
|
||||
|
||||
Reference in New Issue
Block a user