Make ordinary workspaces group shareable
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
58
internal/fileops/modes.go
Normal 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
|
||||
}
|
||||
87
internal/fileops/modes_posix_test.go
Normal file
87
internal/fileops/modes_posix_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user