Make ordinary workspaces group shareable

This commit is contained in:
2026-08-10 17:27:00 +00:00
parent a7ec195587
commit 0b40cf8026
30 changed files with 310 additions and 95 deletions

View File

@@ -16,6 +16,13 @@ consume those helpers instead of rebuilding relative paths.
`internal/pathsafe` and application cleanup helpers enforce confinement for
relative destinations and deletion targets.
`internal/fileops` owns the ordinary workspace mode contract. On POSIX,
`WorkspaceDirectoryMode` is setgid `02775` and `WorkspaceFileMode` is `0664`.
`EnsureWorkspaceDirectory` reapplies the directory mode after creation so a
restrictive umask cannot remove group access, while retaining existing ownership
and group. Credential paths are outside this contract; the platform-specific
operational requirements are in [Operations](../operations.md#workspace-permissions).
## Run-Local Stage Layout
`internal/stage/run_local.go` maps stage outputs and diagnostics into an
@@ -58,6 +65,8 @@ deletion scope belong in [CLI](../cli.md#clean) and
- campaign-aware session root is mandatory.
- manifest-driven stage state is durable across runs.
- cleanup guardrails prevent destructive root/out-of-scope deletion.
- ordinary workspace paths retain group-writable directory and file modes across
nested creation, replacement, and Notarius promotion.
## Implementation And Tests
@@ -65,10 +74,11 @@ deletion scope belong in [CLI](../cli.md#clean) and
`internal/artifacts/local.go`
- Run-local materialization: `internal/stage/run_local.go`
- Immutable bundle promotion: `internal/fileops/directory.go`
- Workspace modes: `internal/fileops/modes.go`
- Cleanup confinement: `internal/app/cleanup_targets.go`,
`internal/app/post_publish_cleanup.go`
- Tests: `internal/artifacts/paths_model_test.go`,
`internal/artifacts/local_test.go`, `internal/stage/run_local_test.go`,
`internal/fileops/directory_test.go`,
`internal/fileops/directory_test.go`, `internal/fileops/modes_posix_test.go`,
`internal/app/cleanup_targets_test.go`,
`internal/app/post_publish_cleanup_test.go`

View File

@@ -284,6 +284,26 @@ Cache layout (durable S3 audio cache):
- `{cache.root}/s3/{bucket}/...`
### Workspace Permissions
Ordinary Narratio workspace content is intentionally shareable with the
workspace group. On POSIX systems, Narratio-created workspace, spool, and cache
directories converge on setgid `02775`; ordinary files, including manifests,
transcripts, generated configuration, logs, reports, and Notarius artifacts,
converge on `0664`. Narratio explicitly applies these modes so a restrictive
caller umask does not remove group write or setgid. It does not change file or
directory ownership: the configured workspace's existing group is inherited.
Windows does not implement POSIX mode bits or setgid semantics. Configure the
workspace, spool, and cache locations with an ACL that grants the collaborating
group read/write access, and configure credential locations with an ACL limited
to the intended credential owner. Do not use POSIX mode displays as evidence of
Windows access control.
API keys are credentials, not ordinary workspace data. Store them outside the
shared workspace or in a separately restricted credential location; ordinary
workspace group access must never be treated as authorization to read keys.
## Cleanup
Session-scoped cleanup:

View File

@@ -180,9 +180,19 @@ only when explicitly configured, and only through the path-safety guardrails.
## Security, Privacy, And Diagnostics
Narratio handles private campaign material. Transcripts, prompts, generated
artifacts, reports, logs, manifests, and diagnostic files are potentially
sensitive.
Narratio distinguishes ordinary workspace data from credentials. Campaign and
session material—including manifests, transcripts, prompts, generated
configuration, logs, reports, diagnostics, and Notarius artifacts—is
intentionally shareable with the configured workspace group. API-key material
is sensitive and is not covered by the ordinary workspace-sharing policy.
On POSIX systems, Narratio-created ordinary workspace directories converge on
setgid `02775` and ordinary workspace files on `0664`, even when the caller's
umask is restrictive. This preserves the existing workspace group for nested
creation and atomic replacements without changing ownership. API-key storage
uses a separate restrictive contract. On Windows, POSIX mode bits and setgid
are not authoritative; operators must provide the equivalent shared-group and
credential-restricted ACLs described in [Operations](../operations.md#workspace-permissions).
Raw secrets must not be stored in pipeline, campaign, or session YAML or written
to manifests, logs, generated configuration, reports, publish metadata,

View File

@@ -16,7 +16,7 @@ All stages are pending when this plan is created.
| Stage | Summary | Primary findings | Status |
| ---: | --- | --- | --- |
| 1 | Align data classification and group workspace modes | RSK-004 | Pending |
| 1 | Align data classification and group workspace modes | RSK-004 | Completed |
| 2 | Enforce safe identifiers and fuzz path/source contracts | COR-002, TST-013 | Pending |
| 3 | Consolidate crash-durable atomic file replacement | RSK-002, DUP-001, DUP-005 | Pending |
| 4 | Add confined destination and download/install capabilities | COR-003, DUP-003, TST-003 | Pending |

View File

@@ -4,10 +4,10 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
// NoopRunner is a deterministic no-op audita adapter.
@@ -84,17 +84,17 @@ func materializePlaceholders(req PolishRequest) error {
"merged_transcript_path": req.MergedTranscriptPath,
"output_path": req.OutputProcessedPath,
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil {
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("audita noop/fake stdout placeholder\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("audita noop/fake stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("audita noop/fake stderr placeholder\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("audita noop/fake stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}
@@ -117,14 +117,14 @@ func writeJSONIfRequested(path string, payload any) error {
if path == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(path)); err != nil {
return fmt.Errorf("create parent directory %q: %w", filepath.Dir(path), err)
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal placeholder json for %q: %w", path, err)
}
if err := subprocess.WriteFileAtomic(path, data, 0o644); err != nil {
if err := subprocess.WriteFileAtomic(path, data, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write placeholder json %q: %w", path, err)
}
return nil

View File

@@ -11,6 +11,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
// SubprocessRunnerConfig defines deterministic settings for Audita CLI execution.
@@ -370,7 +371,7 @@ func (r *SubprocessRunner) writeInvocationConfig(req PolishRequest, args []strin
"credential_env_var": r.llmAPIKeyEnv,
"credential_present": credentialPresent,
}
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644)
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode)
}
func validateProcessedOutput(path string) error {

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
// NoopRunner is a deterministic no-op scriptorium adapter.
@@ -142,7 +143,7 @@ func (f *FakeRunner) RenderArtifact(ctx context.Context, req RenderArtifactReque
func materializeRunPlaceholders(req RunArtifactRequest) error {
if req.OutputPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputPath, []byte("scriptorium noop/fake run artifact\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.OutputPath, []byte("scriptorium noop/fake run artifact\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write run output %q: %w", req.OutputPath, err)
}
}
@@ -154,17 +155,17 @@ func materializeRunPlaceholders(req RunArtifactRequest) error {
"prompt_id": req.PromptID,
"output_path": req.OutputPath,
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil {
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake run stdout placeholder\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake run stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake run stderr placeholder\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake run stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}
@@ -173,7 +174,7 @@ func materializeRunPlaceholders(req RunArtifactRequest) error {
func materializeRenderPlaceholders(req RenderArtifactRequest) error {
if req.OutputPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputPath, []byte("{\"schema\":\"scriptorium.render.v1\",\"placeholder\":true}\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.OutputPath, []byte("{\"schema\":\"scriptorium.render.v1\",\"placeholder\":true}\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write render output %q: %w", req.OutputPath, err)
}
}
@@ -185,17 +186,17 @@ func materializeRenderPlaceholders(req RenderArtifactRequest) error {
"prompt_id": req.PromptID,
"output_path": req.OutputPath,
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil {
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake render stdout placeholder\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake render stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake render stderr placeholder\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake render stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}

View File

@@ -9,6 +9,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
// SubprocessRunner invokes Scriptorium through its public CLI.
@@ -321,7 +322,7 @@ func writeInvocationConfig(path string, payload invocationPayload) error {
"render_format": payload.RenderFormat,
"render_prompt_logged": payload.RenderPromptStore,
}
return subprocess.WriteYAMLAtomic(path, data, 0o644)
return subprocess.WriteYAMLAtomic(path, data, fileops.WorkspaceFileMode)
}
func validateNonEmptyOutput(path string) error {

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
// NoopRunner is a deterministic no-op seriatim adapter.
@@ -260,7 +261,7 @@ func (f *FakeRunner) Render(ctx context.Context, req RenderRequest) (RenderResul
func materializePlaceholders(req MergeRequest) error {
if req.OutputMergedTranscriptPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputMergedTranscriptPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.OutputMergedTranscriptPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write merged transcript %q: %w", req.OutputMergedTranscriptPath, err)
}
}
@@ -271,22 +272,22 @@ func materializePlaceholders(req MergeRequest) error {
"input_transcript_paths": req.InputTranscriptPaths,
"output_path": req.OutputMergedTranscriptPath,
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil {
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake stdout placeholder\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake stderr placeholder\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}
if req.ReportPath != "" {
if err := subprocess.WriteFileAtomic(req.ReportPath, []byte(`{"schema":"seriatim.report.v1","placeholder":true}`), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.ReportPath, []byte(`{"schema":"seriatim.report.v1","placeholder":true}`), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write report %q: %w", req.ReportPath, err)
}
}
@@ -295,7 +296,7 @@ func materializePlaceholders(req MergeRequest) error {
func materializeTrimPlaceholders(req TrimRequest) error {
if req.OutputTrimmedPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputTrimmedPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.OutputTrimmedPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write trimmed transcript %q: %w", req.OutputTrimmedPath, err)
}
}
@@ -308,17 +309,17 @@ func materializeTrimPlaceholders(req TrimRequest) error {
"output_path": req.OutputTrimmedPath,
"keep_selector": req.KeepSelector,
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil {
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake trim stdout placeholder\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake trim stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake trim stderr placeholder\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake trim stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}
@@ -327,7 +328,7 @@ func materializeTrimPlaceholders(req TrimRequest) error {
func materializeNormalizePlaceholders(req NormalizeRequest) error {
if req.OutputNormalizedPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputNormalizedPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.OutputNormalizedPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write normalized transcript %q: %w", req.OutputNormalizedPath, err)
}
}
@@ -343,22 +344,22 @@ func materializeNormalizePlaceholders(req NormalizeRequest) error {
if req.ReportPath != "" {
payload["report_path"] = req.ReportPath
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil {
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake normalize stdout placeholder\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake normalize stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake normalize stderr placeholder\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake normalize stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}
if req.ReportPath != "" {
if err := subprocess.WriteFileAtomic(req.ReportPath, []byte(`{"schema":"seriatim.report.v1","placeholder":true}`), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.ReportPath, []byte(`{"schema":"seriatim.report.v1","placeholder":true}`), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write report %q: %w", req.ReportPath, err)
}
}
@@ -367,7 +368,7 @@ func materializeNormalizePlaceholders(req NormalizeRequest) error {
func materializeRenderPlaceholders(req RenderRequest) error {
if req.OutputRenderedPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputRenderedPath, []byte("# Transcript\n\nRendered markdown placeholder.\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.OutputRenderedPath, []byte("# Transcript\n\nRendered markdown placeholder.\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write rendered transcript %q: %w", req.OutputRenderedPath, err)
}
}
@@ -384,17 +385,17 @@ func materializeRenderPlaceholders(req RenderRequest) error {
"include_segment_ids": req.IncludeSegmentIDs,
"include_metadata": req.IncludeMetadata,
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil {
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake render stdout placeholder\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake render stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake render stderr placeholder\n"), 0o644); err != nil {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake render stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}

View File

@@ -11,6 +11,7 @@ import (
"unicode/utf8"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
// EnvConfig defines optional Seriatim environment tuning values.
@@ -546,7 +547,7 @@ func (r *SubprocessRunner) writeMergeInvocationConfig(req MergeRequest, args []s
payload["coalesce_gap"] = *r.coalesceGap
}
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644)
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode)
}
func buildTrimArgs(req TrimRequest) []string {
@@ -598,7 +599,7 @@ func writeTrimInvocationConfig(req TrimRequest, args []string, binary string, ti
"output_path": req.OutputTrimmedPath,
"keep_selector": req.KeepSelector,
}
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644)
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode)
}
func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary string, timeout time.Duration, outputSchema string) error {
@@ -613,7 +614,7 @@ func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary
"output_schema": outputSchema,
"report_path": req.ReportPath,
}
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644)
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode)
}
func writeRenderInvocationConfig(req RenderRequest, args []string, binary string, timeout time.Duration, format string) error {
@@ -631,7 +632,7 @@ func writeRenderInvocationConfig(req RenderRequest, args []string, binary string
"include_segment_ids": req.IncludeSegmentIDs,
"include_metadata": req.IncludeMetadata,
}
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644)
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode)
}
func validateJSONFile(path string) error {

View File

@@ -12,6 +12,7 @@ import (
"strings"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gopkg.in/yaml.v3"
)
@@ -134,7 +135,7 @@ func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
return fmt.Errorf("create parent directory %q: %w", dir, err)
}
@@ -246,13 +247,17 @@ func logWriter(path string) (*os.File, io.Writer, error) {
}
func openLogFile(path string) (*os.File, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(path)); err != nil {
return nil, fmt.Errorf("create log directory for %q: %w", path, err)
}
f, err := os.Create(path)
if err != nil {
return nil, fmt.Errorf("open log file %q: %w", path, err)
}
if err := f.Chmod(fileops.WorkspaceFileMode); err != nil {
_ = f.Close()
return nil, fmt.Errorf("set log file permissions %q: %w", path, err)
}
return f, nil
}

View File

@@ -2,8 +2,9 @@ package whisperx
import (
"context"
"os"
"path/filepath"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
var minimalTranscriptJSON = []byte(`{"schema":"speaker_transcript.v1","segments":[]}`)
@@ -69,8 +70,8 @@ func writeMinimalJSON(path string) error {
if path == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(path)); err != nil {
return err
}
return os.WriteFile(path, minimalTranscriptJSON, 0o644)
return fileops.WriteFileAtomic(path, minimalTranscriptJSON, fileops.WorkspaceFileMode)
}

View File

@@ -15,6 +15,8 @@ import (
"path/filepath"
"strings"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
const defaultMaxResponseBytes int64 = 10 * 1024 * 1024
@@ -147,7 +149,7 @@ func (c *HTTPClient) Transcribe(ctx context.Context, req TranscribeRequest) (Tra
result.Duration = time.Since(start)
return result, fmt.Errorf("whisperx attempt %d returned invalid json: %w", attempt, err)
}
if err := writeFileAtomic(req.OutputRawTranscriptPath, body, 0o644); err != nil {
if err := writeFileAtomic(req.OutputRawTranscriptPath, body, fileops.WorkspaceFileMode); err != nil {
result.Duration = time.Since(start)
return result, fmt.Errorf("whisperx write transcript output %q: %w", req.OutputRawTranscriptPath, err)
}
@@ -281,7 +283,7 @@ func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
return fmt.Errorf("path is required")
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
return fmt.Errorf("create parent dir %q: %w", dir, err)
}

View File

@@ -10,6 +10,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
type effectiveLocks struct {
@@ -150,8 +151,11 @@ func writeLocalFile(path string, data []byte, force bool) error {
return fmt.Errorf("check output file %q: %w", cleaned, err)
}
}
if err := os.MkdirAll(filepath.Dir(cleaned), 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(cleaned)); err != nil {
return fmt.Errorf("create output directory: %w", err)
}
return os.WriteFile(cleaned, data, 0o644)
if err := os.WriteFile(cleaned, data, fileops.WorkspaceFileMode); err != nil {
return err
}
return os.Chmod(cleaned, fileops.WorkspaceFileMode)
}

View File

@@ -115,7 +115,7 @@ func executeRestoreDownloadAction(
}
}
if err := fileops.InstallDownloadedTempFile(tmpPath, safeLocalPath, 0o644); err != nil {
if err := fileops.InstallDownloadedTempFile(tmpPath, safeLocalPath, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("install file atomically: %w", err)
}
removeTmp = false
@@ -160,7 +160,7 @@ func downloadObjectToSiblingTemp(ctx context.Context, store storage.ObjectStore,
return "", fmt.Errorf("destination path is required")
}
dir := filepath.Dir(destPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
return "", fmt.Errorf("create destination directory: %w", err)
}
base := filepath.Base(destPath)

View File

@@ -9,6 +9,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
// RestoreReport is the durable restore diagnostic model.
@@ -236,7 +237,7 @@ func persistRestoreReport(store artifacts.Store, cfg *config.Config, report *Res
return "", fmt.Errorf("marshal restore report: %w", err)
}
payload = append(payload, '\n')
if err := store.WriteFileAtomic(reportPath, payload, 0o644); err != nil {
if err := store.WriteFileAtomic(reportPath, payload, fileops.WorkspaceFileMode); err != nil {
return "", fmt.Errorf("write restore report %q: %w", reportPath, err)
}
return reportPath, nil

View File

@@ -81,7 +81,7 @@ func (s *LocalStore) ensureLayout(paths SessionPaths) (SessionPaths, error) {
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
return SessionPaths{}, fmt.Errorf("ensure layout: create %q: %w", dir, err)
}
}
@@ -104,7 +104,7 @@ func (s *LocalStore) copyInputWithPaths(paths SessionPaths, sessionID, srcPath,
return Ref{}, fmt.Errorf("copy input: %w", err)
}
if err := fileops.CopyFileAtomic(srcPath, destAbs, 0o644); err != nil {
if err := fileops.CopyFileAtomic(srcPath, destAbs, fileops.WorkspaceFileMode); err != nil {
return Ref{}, fmt.Errorf("copy input %q -> %q: %w", srcPath, destAbs, err)
}
@@ -173,13 +173,18 @@ func (s *LocalStore) AcquireSessionLockFor(campaign, sessionID string) (*LockHan
}
func (s *LocalStore) acquireSessionLockForPaths(paths SessionPaths) (*LockHandle, error) {
f, err := os.OpenFile(paths.LockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
f, err := os.OpenFile(paths.LockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, fileops.WorkspaceFileMode)
if err != nil {
if errors.Is(err, os.ErrExist) {
return nil, fmt.Errorf("%w: %s", ErrLockConflict, paths.LockPath)
}
return nil, fmt.Errorf("acquire lock %q: %w", paths.LockPath, err)
}
if err := f.Chmod(fileops.WorkspaceFileMode); err != nil {
_ = f.Close()
_ = os.Remove(paths.LockPath)
return nil, fmt.Errorf("acquire lock %q: set permissions: %w", paths.LockPath, err)
}
metadata := "pid=" + strconv.Itoa(os.Getpid()) + "\nacquired_at=" + time.Now().UTC().Format(time.RFC3339Nano) + "\n"
if _, err := io.WriteString(f, metadata); err != nil {

View File

@@ -59,7 +59,7 @@ func MaterializeS3Audio(ctx context.Context, req S3MaterializeRequest) (S3Materi
if ok, err := validCachedAudio(cachePath, req.Object.Size); err != nil {
return S3MaterializeResult{}, err
} else if ok {
checksum, err := fileops.CopyFileAtomicWithChecksum(cachePath, req.DestPath, 0o644)
checksum, err := fileops.CopyFileAtomicWithChecksum(cachePath, req.DestPath, fileops.WorkspaceFileMode)
if err != nil {
return S3MaterializeResult{}, fmt.Errorf("materialize cached audio %q: %w", cachePath, err)
}
@@ -80,7 +80,7 @@ func MaterializeS3Audio(ctx context.Context, req S3MaterializeRequest) (S3Materi
return S3MaterializeResult{}, fmt.Errorf("validate downloaded audio %q: %w", spoolPath, err)
}
checksum, err := fileops.CopyFileAtomicWithChecksum(spoolPath, req.DestPath, 0o644)
checksum, err := fileops.CopyFileAtomicWithChecksum(spoolPath, req.DestPath, fileops.WorkspaceFileMode)
if err != nil {
return S3MaterializeResult{}, fmt.Errorf("materialize downloaded audio %q: %w", filepath.Base(req.DestPath), err)
}
@@ -89,7 +89,7 @@ func MaterializeS3Audio(ctx context.Context, req S3MaterializeRequest) (S3Materi
result.Downloaded = true
if result.CachePath != "" {
if _, err := fileops.CopyFileAtomicWithChecksum(spoolPath, result.CachePath, 0o644); err != nil {
if _, err := fileops.CopyFileAtomicWithChecksum(spoolPath, result.CachePath, fileops.WorkspaceFileMode); err != nil {
return S3MaterializeResult{}, fmt.Errorf("populate audio cache %q: %w", result.CachePath, err)
}
}
@@ -139,7 +139,7 @@ func downloadObjectAtomic(ctx context.Context, store storage.ObjectStore, key, d
return fmt.Errorf("destination path is required")
}
dir := filepath.Dir(destPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
return fmt.Errorf("create destination directory: %w", err)
}
base := filepath.Base(destPath)
@@ -162,7 +162,7 @@ func downloadObjectAtomic(ctx context.Context, store storage.ObjectStore, key, d
if err := store.Download(ctx, key, tmpPath); err != nil {
return err
}
if err := fileops.InstallDownloadedTempFile(tmpPath, destPath, 0o644); err != nil {
if err := fileops.InstallDownloadedTempFile(tmpPath, destPath, fileops.WorkspaceFileMode); err != nil {
return err
}
removeTmp = false

View File

@@ -10,11 +10,6 @@ import (
"strings"
)
const (
promotedDirectoryMode = 0o755
promotedFileMode = 0o644
)
// ErrAtomicDirectoryPromotionUnsupported indicates that the current operating
// system lacks the atomic no-replace primitive required by PromoteDirectory.
var ErrAtomicDirectoryPromotionUnsupported = errors.New("atomic no-replace directory promotion is unsupported")
@@ -98,7 +93,7 @@ func promoteDirectoryWithHooks(
if err := copyRegularTree(sourceRoot, src, temporary, hooks); err != nil {
return err
}
if err := os.Chmod(temporary, promotedDirectoryMode); err != nil {
if err := os.Chmod(temporary, WorkspaceDirectoryMode); err != nil {
return fmt.Errorf("set temporary root permissions: %w", err)
}
if err := syncDirectory(temporary); err != nil {
@@ -223,13 +218,13 @@ func copyRegularDirectory(
return fmt.Errorf("source directory %q changed while being copied", sourcePath)
}
if err := os.Mkdir(dst, promotedDirectoryMode); err != nil {
if err := os.Mkdir(dst, WorkspaceDirectoryMode); err != nil {
return fmt.Errorf("create destination directory %q: %w", dst, err)
}
if err := copyRegularTree(child, sourcePath, dst, hooks); err != nil {
return err
}
if err := os.Chmod(dst, promotedDirectoryMode); err != nil {
if err := os.Chmod(dst, WorkspaceDirectoryMode); err != nil {
return fmt.Errorf("set destination directory permissions %q: %w", dst, err)
}
if err := syncDirectory(dst); err != nil {
@@ -264,7 +259,7 @@ func copyRegularFile(
return fmt.Errorf("source file %q changed while being copied", sourcePath)
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, promotedFileMode)
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, WorkspaceFileMode)
if err != nil {
return fmt.Errorf("create destination file %q: %w", dst, err)
}
@@ -278,7 +273,7 @@ func copyRegularFile(
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("copy source file %q: %w", sourcePath, err)
}
if err := out.Chmod(promotedFileMode); err != nil {
if err := out.Chmod(WorkspaceFileMode); err != nil {
return fmt.Errorf("set destination file permissions %q: %w", dst, err)
}
if err := out.Sync(); err != nil {

View File

@@ -41,8 +41,8 @@ func TestPromoteDirectoryCopiesNestedRegularTree(t *testing.T) {
if err != nil {
t.Fatalf("Stat(%q) error = %v", path, err)
}
if got := info.Mode().Perm(); got != promotedDirectoryMode {
t.Fatalf("directory mode for %q = %o, want %o", path, got, promotedDirectoryMode)
if got := info.Mode().Perm(); got != WorkspaceDirectoryMode.Perm() {
t.Fatalf("directory mode for %q = %o, want %o", path, got, WorkspaceDirectoryMode.Perm())
}
}
for _, path := range []string{filepath.Join(dst, "a-first.txt"), filepath.Join(dst, "nested", "binary.dat"), filepath.Join(dst, "z-last.txt")} {
@@ -50,8 +50,8 @@ func TestPromoteDirectoryCopiesNestedRegularTree(t *testing.T) {
if err != nil {
t.Fatalf("Stat(%q) error = %v", path, err)
}
if got := info.Mode().Perm(); got != promotedFileMode {
t.Fatalf("file mode for %q = %o, want %o", path, got, promotedFileMode)
if got := info.Mode().Perm(); got != WorkspaceFileMode.Perm() {
t.Fatalf("file mode for %q = %o, want %o", path, got, WorkspaceFileMode.Perm())
}
}
}

View File

@@ -15,7 +15,7 @@ func WriteFileAtomic(dst string, data []byte, perm os.FileMode) error {
if strings.TrimSpace(dst) == "" {
return fmt.Errorf("destination path is required")
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil {
return fmt.Errorf("create destination directory: %w", err)
}
@@ -71,7 +71,7 @@ func CopyFileAtomicWithChecksum(src, dst string, perm os.FileMode) (string, erro
}
defer func() { _ = in.Close() }()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil {
return "", fmt.Errorf("create destination directory: %w", err)
}
@@ -115,7 +115,7 @@ func InstallDownloadedTempFile(tmpPath, dst string, perm os.FileMode) error {
if strings.TrimSpace(tmpPath) == "" || strings.TrimSpace(dst) == "" {
return fmt.Errorf("temp and destination paths are required")
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil {
return fmt.Errorf("create destination directory: %w", err)
}
if err := os.Chmod(tmpPath, perm); err != nil {

58
internal/fileops/modes.go Normal file
View File

@@ -0,0 +1,58 @@
package fileops
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// WorkspaceDirectoryMode is the POSIX mode for ordinary, shareable workspace
// directories. The set-group-ID bit preserves the configured workspace group
// for nested files and directories.
const WorkspaceDirectoryMode os.FileMode = os.ModeSetgid | 0o775
// WorkspaceFileMode is the POSIX mode for ordinary, shareable workspace files.
const WorkspaceFileMode os.FileMode = 0o664
// EnsureWorkspaceDirectory creates directory and makes every directory created
// for it conform to the ordinary workspace sharing contract. Existing
// directories retain their owner and group; only their mode is updated.
func EnsureWorkspaceDirectory(directory string) error {
if strings.TrimSpace(directory) == "" {
return fmt.Errorf("workspace directory is required")
}
directory = filepath.Clean(directory)
missing := make([]string, 0)
for current := directory; ; current = filepath.Dir(current) {
info, err := os.Lstat(current)
if err == nil {
if !info.IsDir() {
return fmt.Errorf("workspace directory %q is not a directory", current)
}
break
}
if !os.IsNotExist(err) {
return fmt.Errorf("inspect workspace directory %q: %w", current, err)
}
missing = append(missing, current)
parent := filepath.Dir(current)
if parent == current {
break
}
}
if err := os.MkdirAll(directory, WorkspaceDirectoryMode); err != nil {
return fmt.Errorf("create workspace directory %q: %w", directory, err)
}
for index := len(missing) - 1; index >= 0; index-- {
if err := os.Chmod(missing[index], WorkspaceDirectoryMode); err != nil {
return fmt.Errorf("set workspace directory permissions %q: %w", missing[index], err)
}
}
if err := os.Chmod(directory, WorkspaceDirectoryMode); err != nil {
return fmt.Errorf("set workspace directory permissions %q: %w", directory, err)
}
return nil
}

View File

@@ -0,0 +1,87 @@
//go:build !windows
package fileops
import (
"os"
"path/filepath"
"syscall"
"testing"
)
func TestWorkspaceModesOverrideRestrictiveUmask(t *testing.T) {
restoreUmask := syscall.Umask(0o077)
t.Cleanup(func() { syscall.Umask(restoreUmask) })
root := t.TempDir()
nested := filepath.Join(root, "campaign", "session", "artifacts")
if err := EnsureWorkspaceDirectory(nested); err != nil {
t.Fatalf("EnsureWorkspaceDirectory() error = %v", err)
}
for _, path := range []string{
filepath.Join(root, "campaign"),
filepath.Join(root, "campaign", "session"),
nested,
} {
assertWorkspaceDirectoryMode(t, path)
}
file := filepath.Join(nested, "result.json")
if err := WriteFileAtomic(file, []byte("first"), WorkspaceFileMode); err != nil {
t.Fatalf("WriteFileAtomic(first) error = %v", err)
}
if err := WriteFileAtomic(file, []byte("replacement"), WorkspaceFileMode); err != nil {
t.Fatalf("WriteFileAtomic(replacement) error = %v", err)
}
assertWorkspaceFileMode(t, file)
}
func TestPromoteDirectoryUsesWorkspaceModesUnderRestrictiveUmask(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
if err := os.MkdirAll(filepath.Join(src, "nested"), 0o755); err != nil {
t.Fatalf("MkdirAll(source) error = %v", err)
}
if err := os.WriteFile(filepath.Join(src, "nested", "result.json"), []byte("result"), 0o600); err != nil {
t.Fatalf("WriteFile(source) error = %v", err)
}
destinationParent := filepath.Join(root, "workspace", "artifacts", "notarius")
if err := EnsureWorkspaceDirectory(destinationParent); err != nil {
t.Fatalf("EnsureWorkspaceDirectory(destination parent) error = %v", err)
}
restoreUmask := syscall.Umask(0o077)
t.Cleanup(func() { syscall.Umask(restoreUmask) })
destination := filepath.Join(destinationParent, "run-1")
if err := PromoteDirectory(src, destination); err != nil {
t.Fatalf("PromoteDirectory() error = %v", err)
}
assertWorkspaceDirectoryMode(t, destination)
assertWorkspaceDirectoryMode(t, filepath.Join(destination, "nested"))
assertWorkspaceFileMode(t, filepath.Join(destination, "nested", "result.json"))
}
func assertWorkspaceDirectoryMode(t *testing.T, path string) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat(%q) error = %v", path, err)
}
if got, want := info.Mode().Perm(), WorkspaceDirectoryMode.Perm(); got != want {
t.Fatalf("directory mode for %q = %o, want %o", path, got, want)
}
if info.Mode()&os.ModeSetgid == 0 {
t.Fatalf("directory mode for %q does not include setgid: %v", path, info.Mode())
}
}
func assertWorkspaceFileMode(t *testing.T, path string) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat(%q) error = %v", path, err)
}
if got, want := info.Mode().Perm(), WorkspaceFileMode.Perm(); got != want {
t.Fatalf("file mode for %q = %o, want %o", path, got, want)
}
}

View File

@@ -8,6 +8,8 @@ import (
"path/filepath"
"strings"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
// Store persists manifests to and from durable storage.
@@ -90,7 +92,7 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
data = append(data, '\n')
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
return fmt.Errorf("save manifest: create directory %q: %w", dir, err)
}
@@ -117,6 +119,9 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
if err := tmp.Close(); err != nil {
return fmt.Errorf("save manifest: close temp file: %w", err)
}
if err := os.Chmod(tmpName, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("save manifest: set temp file permissions: %w", err)
}
if err := checkContext(ctx); err != nil {
return err
}
@@ -287,7 +292,7 @@ func normalizeRunManifest(m *RunManifest) {
func writeJSONAtomically(ctx context.Context, path, tempPattern string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
return fmt.Errorf("create directory %q: %w", dir, err)
}
@@ -314,6 +319,9 @@ func writeJSONAtomically(ctx context.Context, path, tempPattern string, data []b
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Chmod(tmpName, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("set temp file permissions: %w", err)
}
if err := checkContext(ctx); err != nil {
return err
}

View File

@@ -138,7 +138,7 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("extract: resolve durable bundle path: %w", err)
}
for _, directory := range []string{filepath.Dir(receiptPath), outputRoot, filepath.Dir(durableBundle)} {
if err := os.MkdirAll(directory, 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(directory); err != nil {
return nil, fmt.Errorf("extract: create directory %q: %w", directory, err)
}
}

View File

@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
@@ -248,7 +249,7 @@ func normalizeMergeInputs(
if runLayout.Enabled {
normalizedDir = filepath.Join(runLayout.ScratchDir, "normalized")
}
if err := os.MkdirAll(normalizedDir, 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(normalizedDir); err != nil {
return nil, nil, nil, nil, fmt.Errorf("merge: ensure normalized transcripts directory %q: %w", normalizedDir, err)
}

View File

@@ -15,6 +15,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/audio"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gopkg.in/yaml.v3"
)
@@ -424,10 +425,10 @@ func materializeS3AudioInputs(ctx context.Context, env *Env, m *manifest.Manifes
}
workAudioDir := filepath.Join(pathsWorkDirForManifest(env, m, sessionID), "audio")
if err := os.MkdirAll(spoolAudioDir, 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(spoolAudioDir); err != nil {
return s3AudioMaterializationStats{}, fmt.Errorf("create spool audio directory %q: %w", spoolAudioDir, err)
}
if err := os.MkdirAll(workAudioDir, 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(workAudioDir); err != nil {
return s3AudioMaterializationStats{}, fmt.Errorf("create work audio directory %q: %w", workAudioDir, err)
}
@@ -517,7 +518,7 @@ func clearManagedPreviousState(paths artifacts.SessionPaths) error {
if err := os.RemoveAll(previousDir); err != nil {
return err
}
return os.MkdirAll(previousDir, 0o755)
return fileops.EnsureWorkspaceDirectory(previousDir)
}
func pathsWorkDirForManifest(env *Env, m *manifest.Manifest, sessionID string) string {
@@ -594,7 +595,7 @@ func copyFileIfChanged(store artifacts.Store, src, dst string) (string, error) {
if err != nil {
return "", err
}
if err := store.WriteFileAtomic(dst, data, 0o644); err != nil {
if err := store.WriteFileAtomic(dst, data, fileops.WorkspaceFileMode); err != nil {
return "", err
}
return srcChecksum, nil
@@ -618,7 +619,7 @@ func writeBytesIfChanged(store artifacts.Store, dst string, data []byte) (string
}
}
if err := store.WriteFileAtomic(dst, data, 0o644); err != nil {
if err := store.WriteFileAtomic(dst, data, fileops.WorkspaceFileMode); err != nil {
return "", err
}
return targetChecksum, nil

View File

@@ -52,7 +52,7 @@ func hydratePreviousSessionArtifacts(
}
for _, record := range plan.Records {
if err := os.MkdirAll(filepath.Dir(record.LocalPath), 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(record.LocalPath)); err != nil {
return nil, fmt.Errorf("create previous-session path directory for %q: %w", record.LocalPath, err)
}
@@ -77,7 +77,7 @@ func hydratePreviousSessionArtifacts(
if err := env.ObjectStore.Download(ctx, record.RemoteKey, tmpPath); err != nil {
return fmt.Errorf("download previous-session object %q to temp file: %w", record.RemoteKey, err)
}
if err := fileops.InstallDownloadedTempFile(tmpPath, record.LocalPath, 0o644); err != nil {
if err := fileops.InstallDownloadedTempFile(tmpPath, record.LocalPath, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("install previous-session object %q at %q: %w", record.RemoteKey, record.LocalPath, err)
}
removeTmp = false

View File

@@ -8,6 +8,7 @@ import (
"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"
)
@@ -78,7 +79,7 @@ func resolveRunStageLayout(
layout.ConfigDir,
layout.ScratchDir,
} {
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
return runStageLayout{}, fmt.Errorf("create run-stage directory %q: %w", dir, err)
}
}
@@ -104,7 +105,7 @@ func runLocalPathForCanonical(layout runStageLayout, sessionPaths artifacts.Sess
if err != nil {
return "", fmt.Errorf("resolve run-local output path for %q: %w", cleanCanonical, err)
}
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(localPath)); err != nil {
return "", fmt.Errorf("create run-local output parent for %q: %w", localPath, err)
}
return localPath, nil
@@ -127,7 +128,7 @@ func materializeRunLocalOutput(
if err != nil {
return artifacts.Ref{}, fmt.Errorf("read run-local output %q: %w", srcPath, err)
}
if err := store.WriteFileAtomic(canonicalPath, data, 0o644); err != nil {
if err := store.WriteFileAtomic(canonicalPath, data, fileops.WorkspaceFileMode); err != nil {
return artifacts.Ref{}, fmt.Errorf("materialize output to %q: %w", canonicalPath, err)
}
checksum, err := store.Checksum(canonicalPath)

View File

@@ -13,6 +13,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/contracts"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
@@ -392,7 +393,7 @@ func copyTranscript(store artifacts.Store, src, dst string) error {
if err != nil {
return fmt.Errorf("read source transcript: %w", err)
}
if err := store.WriteFileAtomic(dst, data, 0o644); err != nil {
if err := store.WriteFileAtomic(dst, data, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write destination transcript: %w", err)
}
return nil