From 0b40cf80267b1b68ade802b986b25cdc1cd5190b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 10 Aug 2026 17:27:00 +0000 Subject: [PATCH] Make ordinary workspaces group shareable --- docs/internal/workspace.md | 12 ++- docs/operations.md | 20 +++++ docs/policy/architecture.md | 16 +++- docs/roadmap/implementation.md | 2 +- internal/adapters/audita/fake.go | 12 +-- internal/adapters/audita/subprocess.go | 3 +- internal/adapters/scriptorium/fake.go | 17 ++-- internal/adapters/scriptorium/subprocess.go | 3 +- internal/adapters/seriatim/fake.go | 37 ++++----- internal/adapters/seriatim/subprocess.go | 9 ++- internal/adapters/subprocess/run.go | 9 ++- internal/adapters/whisperx/fake.go | 7 +- internal/adapters/whisperx/http.go | 6 +- internal/app/remote_locks.go | 8 +- internal/app/restore_execute.go | 4 +- internal/app/restore_report.go | 3 +- internal/artifacts/local.go | 11 ++- internal/audio/s3_audio.go | 10 +-- internal/fileops/directory.go | 15 ++-- internal/fileops/directory_test.go | 8 +- internal/fileops/fileops.go | 6 +- internal/fileops/modes.go | 58 ++++++++++++++ internal/fileops/modes_posix_test.go | 87 +++++++++++++++++++++ internal/manifest/store.go | 12 ++- internal/stage/extract.go | 2 +- internal/stage/merge.go | 3 +- internal/stage/prepare.go | 11 +-- internal/stage/prepare_previous.go | 4 +- internal/stage/run_local.go | 7 +- internal/stage/trim.go | 3 +- 30 files changed, 310 insertions(+), 95 deletions(-) create mode 100644 internal/fileops/modes.go create mode 100644 internal/fileops/modes_posix_test.go diff --git a/docs/internal/workspace.md b/docs/internal/workspace.md index 81697d6..674744e 100644 --- a/docs/internal/workspace.md +++ b/docs/internal/workspace.md @@ -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` diff --git a/docs/operations.md b/docs/operations.md index 673f502..3bd2a40 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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: diff --git a/docs/policy/architecture.md b/docs/policy/architecture.md index 0fbacff..fd27f73 100644 --- a/docs/policy/architecture.md +++ b/docs/policy/architecture.md @@ -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, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index f5e0273..2431214 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 | diff --git a/internal/adapters/audita/fake.go b/internal/adapters/audita/fake.go index b159dda..b4f403a 100644 --- a/internal/adapters/audita/fake.go +++ b/internal/adapters/audita/fake.go @@ -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 diff --git a/internal/adapters/audita/subprocess.go b/internal/adapters/audita/subprocess.go index 9ae735f..c545c56 100644 --- a/internal/adapters/audita/subprocess.go +++ b/internal/adapters/audita/subprocess.go @@ -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 { diff --git a/internal/adapters/scriptorium/fake.go b/internal/adapters/scriptorium/fake.go index 51a0190..db1e249 100644 --- a/internal/adapters/scriptorium/fake.go +++ b/internal/adapters/scriptorium/fake.go @@ -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) } } diff --git a/internal/adapters/scriptorium/subprocess.go b/internal/adapters/scriptorium/subprocess.go index a6ab461..a9cca72 100644 --- a/internal/adapters/scriptorium/subprocess.go +++ b/internal/adapters/scriptorium/subprocess.go @@ -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 { diff --git a/internal/adapters/seriatim/fake.go b/internal/adapters/seriatim/fake.go index ce666db..267903d 100644 --- a/internal/adapters/seriatim/fake.go +++ b/internal/adapters/seriatim/fake.go @@ -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) } } diff --git a/internal/adapters/seriatim/subprocess.go b/internal/adapters/seriatim/subprocess.go index 124db7e..36ac87a 100644 --- a/internal/adapters/seriatim/subprocess.go +++ b/internal/adapters/seriatim/subprocess.go @@ -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 { diff --git a/internal/adapters/subprocess/run.go b/internal/adapters/subprocess/run.go index e6767e5..fcbb7bb 100644 --- a/internal/adapters/subprocess/run.go +++ b/internal/adapters/subprocess/run.go @@ -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 } diff --git a/internal/adapters/whisperx/fake.go b/internal/adapters/whisperx/fake.go index 509b967..178934e 100644 --- a/internal/adapters/whisperx/fake.go +++ b/internal/adapters/whisperx/fake.go @@ -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) } diff --git a/internal/adapters/whisperx/http.go b/internal/adapters/whisperx/http.go index 72e0b33..59a280c 100644 --- a/internal/adapters/whisperx/http.go +++ b/internal/adapters/whisperx/http.go @@ -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) } diff --git a/internal/app/remote_locks.go b/internal/app/remote_locks.go index 4fe65b5..a251649 100644 --- a/internal/app/remote_locks.go +++ b/internal/app/remote_locks.go @@ -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) } diff --git a/internal/app/restore_execute.go b/internal/app/restore_execute.go index c9ee029..7560828 100644 --- a/internal/app/restore_execute.go +++ b/internal/app/restore_execute.go @@ -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) diff --git a/internal/app/restore_report.go b/internal/app/restore_report.go index 20e8ad8..adf4c78 100644 --- a/internal/app/restore_report.go +++ b/internal/app/restore_report.go @@ -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 diff --git a/internal/artifacts/local.go b/internal/artifacts/local.go index f9f424b..2e3b800 100644 --- a/internal/artifacts/local.go +++ b/internal/artifacts/local.go @@ -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 { diff --git a/internal/audio/s3_audio.go b/internal/audio/s3_audio.go index 10348bd..0a4cf63 100644 --- a/internal/audio/s3_audio.go +++ b/internal/audio/s3_audio.go @@ -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 diff --git a/internal/fileops/directory.go b/internal/fileops/directory.go index 695e1c8..e7fa3f0 100644 --- a/internal/fileops/directory.go +++ b/internal/fileops/directory.go @@ -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 { diff --git a/internal/fileops/directory_test.go b/internal/fileops/directory_test.go index baa1ee2..8a4bcbf 100644 --- a/internal/fileops/directory_test.go +++ b/internal/fileops/directory_test.go @@ -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()) } } } diff --git a/internal/fileops/fileops.go b/internal/fileops/fileops.go index 9f5b25f..bca653f 100644 --- a/internal/fileops/fileops.go +++ b/internal/fileops/fileops.go @@ -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 { diff --git a/internal/fileops/modes.go b/internal/fileops/modes.go new file mode 100644 index 0000000..cabebf5 --- /dev/null +++ b/internal/fileops/modes.go @@ -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 +} diff --git a/internal/fileops/modes_posix_test.go b/internal/fileops/modes_posix_test.go new file mode 100644 index 0000000..02b8087 --- /dev/null +++ b/internal/fileops/modes_posix_test.go @@ -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) + } +} diff --git a/internal/manifest/store.go b/internal/manifest/store.go index 2ac34a0..89b73f3 100644 --- a/internal/manifest/store.go +++ b/internal/manifest/store.go @@ -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 } diff --git a/internal/stage/extract.go b/internal/stage/extract.go index ffaf47b..cf1fc9f 100644 --- a/internal/stage/extract.go +++ b/internal/stage/extract.go @@ -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) } } diff --git a/internal/stage/merge.go b/internal/stage/merge.go index ecb5044..5dca190 100644 --- a/internal/stage/merge.go +++ b/internal/stage/merge.go @@ -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) } diff --git a/internal/stage/prepare.go b/internal/stage/prepare.go index 33cf710..6d87dc5 100644 --- a/internal/stage/prepare.go +++ b/internal/stage/prepare.go @@ -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 diff --git a/internal/stage/prepare_previous.go b/internal/stage/prepare_previous.go index 5f948f0..c83e5c8 100644 --- a/internal/stage/prepare_previous.go +++ b/internal/stage/prepare_previous.go @@ -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 diff --git a/internal/stage/run_local.go b/internal/stage/run_local.go index 1de8d60..ab29b5b 100644 --- a/internal/stage/run_local.go +++ b/internal/stage/run_local.go @@ -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) diff --git a/internal/stage/trim.go b/internal/stage/trim.go index e2c8ca7..b6839a5 100644 --- a/internal/stage/trim.go +++ b/internal/stage/trim.go @@ -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