Harden bundle promotion against symlink replacement

This commit is contained in:
2026-08-10 02:45:51 +00:00
parent 701b6726d7
commit b99bd38eb4
3 changed files with 535 additions and 284 deletions

View File

@@ -2,8 +2,9 @@
## Status And Audience ## Status And Audience
Complete. Stages 1 through 9 delivered the feature, and Stages 10 through 14 Remediation required. Stages 1 through 14 are complete. Stages 15 through 18
closed the gaps found during the post-implementation architecture review. address the remaining security, lifecycle-state, and native-platform validation
gaps identified by the second post-implementation review.
This plan is written for a GPT-5.6 Terra coding agent. Implement incomplete This plan is written for a GPT-5.6 Terra coding agent. Implement incomplete
stages in strict numerical order. Do not skip ahead, combine stages merely to stages in strict numerical order. Do not skip ahead, combine stages merely to
@@ -32,6 +33,10 @@ implementation sequence.
| Stage 12 | Complete | | Stage 12 | Complete |
| Stage 13 | Complete | | Stage 13 | Complete |
| Stage 14 | Complete | | Stage 14 | Complete |
| Stage 15 | Complete |
| Stage 16 | Pending |
| Stage 17 | Pending |
| Stage 18 | Pending |
After completing and validating a stage, update only that stage's row to After completing and validating a stage, update only that stage's row to
`Complete` and record any material deviation in the relevant stage section. `Complete` and record any material deviation in the relevant stage section.
@@ -39,13 +44,12 @@ Do not mark a stage complete while required tests or exit criteria remain.
## Working Rules ## Working Rules
Before Stage 10, read: Before Stage 15, read:
- `docs/development.md` and its task-specific references; - `docs/development.md` and its task-specific references;
- `docs/roadmap/notarius-extract-stage.md` completely; - `docs/roadmap/notarius-extract-stage.md` completely; and
- `../notarius/docs/consumers/dnd-pipeline.md` and its linked subprocess, - the focused Narratio documents, production code, and tests named by the
receipt, JSON-output, and lane-contract documentation; and current stage.
- the focused Narratio documents and tests named by the current stage.
For every incomplete stage: For every incomplete stage:
@@ -57,304 +61,289 @@ For every incomplete stage:
4. Keep tests offline and independent of real Notarius, PromptKit, LLM 4. Keep tests offline and independent of real Notarius, PromptKit, LLM
providers, credentials, network services, and mutable external state. providers, credentials, network services, and mutable external state.
5. Run the focused package tests listed for the stage, then `go test ./...`. 5. Run the focused package tests listed for the stage, then `go test ./...`.
6. Run `go vet ./...` and `go build ./cmd/narratio` whenever shared contracts, 6. Run `go vet ./...` and build `cmd/narratio` whenever shared contracts,
composition, CLI behavior, portability, or documentation examples change. composition, portability, or documentation examples change. Direct build
output to a temporary directory so validation does not leave an ignored
binary in the repository root.
7. Do not broaden the feature beyond the target architecture while fixing a 7. Do not broaden the feature beyond the target architecture while fixing a
remediation. Prefer a general runner or filesystem invariant when the defect remediation. Prefer a general manifest or filesystem invariant when the
is general, but avoid unrelated cleanup. defect is general, but avoid unrelated cleanup.
## Completed Stages 1-9 Summary ## Completed Stages 1-14 Summary
1. **Stage 1 — Shared contracts:** Added explicit stage self-skip semantics, 1. **Stage 1 — Shared contracts:** Added explicit stage self-skip semantics,
artifact source identity, contract metadata, external provenance, and artifact identities, contract metadata, provenance, and compatible manifest
backward-compatible manifest serialization. serialization.
2. **Stage 2 — Configuration and source policy:** Added strict optional 2. **Stage 2 — Configuration and source policy:** Added strict optional
Notarius configuration, deterministic defaults/path resolution, output-lane Notarius configuration, deterministic path resolution, lane contracts, and
contracts, and the `narratio.extraction.<key>` source family. the `narratio.extraction.<key>` source family.
3. **Stage 3 — Immutable promotion:** Added regular-file-only bundle copying, 3. **Stage 3 — Immutable promotion:** Added regular-file-only bundle copying,
symlink and special-file rejection, bounded permissions, syncing, cleanup, static symlink and special-file rejection, syncing, cleanup, and atomic
and atomic no-replace installation on Linux. no-replace installation on Linux.
4. **Stage 4 — Notarius adapter:** Added the subprocess boundary, exact CLI 4. **Stage 4 — Notarius adapter:** Added exact subprocess invocation, separate
invocation, separate receipt/diagnostic streams, bounded tolerant decoding, receipt and diagnostic streams, bounded tolerant decoding, confinement, and
path confinement, and generic receipt/index discovery. receipt/index discovery.
5. **Stage 5 — Extract execution:** Added final-trimmed transcript consumption, 5. **Stage 5 — Extract execution:** Added transcript consumption, required-lane
configured required-lane validation, immutable bundle promotion, checksums, validation, immutable promotion, checksums, outputs, provenance,
manifest-ready outputs, provenance, diagnostics, and invocation diagnostics, and invocation fingerprinting.
fingerprinting.
6. **Stage 6 — Lifecycle and resume:** Registered `extract` between `trim` and 6. **Stage 6 — Lifecycle and resume:** Registered `extract` between `trim` and
`render`, added full and single-stage CLI support, resume validation, force `render`, added CLI selection, resume validation, force behavior, and
ordering, and old-manifest compatibility. old-manifest compatibility.
7. **Stage 7 — Analyze integration:** Added shared manifest-backed extraction 7. **Stage 7 — Analyze integration:** Added shared manifest-backed extraction
catalog hydration and explicit required/optional Scriptorium input catalog hydration and explicit required/optional Scriptorium input
resolution without directory scanning or implicit inputs. resolution.
8. **Stage 8 — Publish and inspection integration:** Added explicit extraction 8. **Stage 8 — Publish and inspection integration:** Added explicit extraction
publish rules, metadata round-tripping, restore compatibility, and operator publish rules, metadata round-tripping, restore compatibility, and operator
availability reporting. availability reporting.
9. **Stage 9 — Documentation and examples:** Added maintained ten-lane and 9. **Stage 9 — Documentation and examples:** Added maintained complete and
subset examples, integration and maintainer documentation, operational subset examples, integration and maintainer documentation, operational
guidance, and repository-wide validation. guidance, and repository-wide validation.
10. **Stage 10 — Replacement invalidation:** Generalized runner semantics so
forced, failed, skipped, changed, and non-resumable upstream executions
invalidate succeeded downstream results without causing perpetual reruns
for identical self-skips.
11. **Stage 11 — Explicit-only publication:** Excluded the run-local Notarius
bundle from run-record uploads while retaining diagnostics and explicit
configured-lane publication.
12. **Stage 12 — Canonical management files:** Required the production receipt
and index to use the canonical index, manifest, rejection, and warning
filenames assumed by extraction, resume, and catalog code.
13. **Stage 13 — Platform promotion contract:** Added atomic no-replace
implementations for Linux, macOS, and Windows, explicit early failure on
unsupported systems, and platform-specific directory syncing.
14. **Stage 14 — Documentation reconciliation:** Aligned current-behavior,
operations, troubleshooting, integration, internal, and roadmap
documentation with the implemented lifecycle and publication semantics.
## Stage 10: Correct Downstream Invalidation For Every Replaced Stage Result ## Stage 15: Make Bundle Promotion Race-Safe Against Symlink Replacement
Fix runner semantics so a newly produced extraction result cannot coexist with Close the gap between the documented no-symlink promotion invariant and the
downstream stages that are still considered current from an older result. Make current path-based recursive copy. A source directory that is replaced after
the correction at the general stage-runner boundary rather than adding an inspection must never cause Narratio to traverse a symlink or copy data from
`extract`-specific exception. outside the originally opened source tree.
Implementation: Implementation:
1. In `internal/app`, capture the prior session-stage outcome before changing a 1. Refactor `internal/fileops` source traversal to anchor the copy to an opened
selected stage to running. Preserve enough prior state to distinguish a source root for the entire operation. Use Go's `os.Root` APIs, available at
reused result, a repeated identical self-skip, and a replaced result. the module's declared Go version, instead of recursively reopening source
2. Define replacement and invalidation behavior as follows: directories through unconstrained absolute paths.
- an already-succeeded stage skipped without execution does not invalidate 2. Reject a source root that is itself a symlink. After opening the root,
anything; compare the opened root identity with the identity inspected before opening;
- a resume-validation error still stops without mutating the succeeded stage fail if the source changed during that transition.
or downstream stages; 3. For every source entry:
- a non-resumable succeeded result is marked stale and invalidates succeeded - inspect it relative to the already opened parent root;
downstream stages before rerun, preserving the current behavior; - reject symlinks, link-like Windows reparse points exposed as links, and
- any forced execution invalidates succeeded downstream stages even if the every non-regular, non-directory file type;
execution later self-skips or fails; - open regular files relative to that root, compare the opened file identity
- a non-forced execution that changes a skipped, failed, stale, pending, or with the inspected identity, and copy only from the verified handle; and
absent stage to succeeded invalidates succeeded downstream stages; - open child directories relative to the parent root, verify the opened
- an executed failure invalidates succeeded downstream stages before the directory identity against the inspected identity, and recurse through the
failed manifest state is committed; and opened child root rather than its pathname.
- a self-skip invalidates succeeded downstream stages only when it replaces 4. Treat any identity mismatch, disappearing entry, replacement, or unsupported
a different effective outcome. Repeating the same stable skip reason with file type as a clean promotion failure. Preserve the source, remove the
no outputs must not make every disabled full-pipeline invocation rerun all temporary destination tree, and never install a partial destination.
downstream stages. 5. Keep the destination-side behavior unchanged: deterministic traversal,
3. Persist invalidation with the same session-manifest transition that makes the normalized permissions, file and directory syncing, sibling temporary tree,
upstream replacement observable. Do not leave a committed failed or skipped and atomic no-replace installation.
upstream result with succeeded downstream records that depend on its former 6. Keep support bounded to Linux, macOS, and Windows. Do not weaken the strict
output. no-symlink rule to permit links that happen to remain inside the source root.
4. Continue to use canonical stage order for invalidation. For `extract`, the 7. Keep test-only orchestration narrow. If deterministic replacement testing
downstream set remains `render`, `analyze`, `publish`, and `notify`. requires an internal seam between inspection and opening, inject only that
5. Recheck each precomputed stage decision immediately before acting on it. boundary; do not expose it publicly or turn file copying into a generic
When an earlier stage invalidates a later succeeded stage during the same filesystem framework.
full run, the later stage must execute instead of honoring its original skip
decision.
6. Use stable bounded stale reasons that distinguish forced replacement,
changed upstream result, upstream failure, and upstream self-skip where that
distinction is operationally useful.
7. Remove or consolidate the old success-only force invalidation path after the
new invariant owns all replacement cases. Do not invalidate stages earlier
than the replaced stage.
Tests: Tests:
- extend `internal/app/extract_lifecycle_test.go` with a full-plan or focused - retain the existing nested-copy, source-preservation, static symlink,
multi-stage fixture in which disabled `extract` and downstream `analyze` first special-file, cleanup, existing-destination, and install-collision tests;
complete, Notarius is enabled, and the next ordinary run executes both - deterministically replace an inspected child directory with a symlink before
`extract` and the previously succeeded downstream stages; it is opened, and prove promotion fails without copying an outside sentinel;
- a failed extract followed by a successful ordinary retry invalidates and - cover source-root replacement between initial inspection and root opening;
reruns previously succeeded downstream stages; - cover regular-file and directory identity mismatches without timing-based
- `run-stage extract --force` followed by `notarius_disabled` leaves downstream sleeps or probabilistic race loops;
succeeded stages stale; - prove every failure leaves the source and any concurrent destination intact
- forced extraction failure leaves downstream succeeded stages stale; and removes the temporary sibling; and
- repeated identical disabled self-skip does not repeatedly stale downstream - run the focused tests with the race detector.
stages;
- successful resume reuse does not invalidate downstream stages;
- resume-validation error preserves both the prior extract success and
downstream state; and
- forcing `render` still does not stale `extract`.
Exit criteria: Exit criteria:
- Enabling, retrying, forcing, failing, or disabling extraction cannot leave an - Recursive promotion cannot escape or switch away from the originally opened
incompatible succeeded analyze/publish result current. source tree through a symlink replacement race.
- Repeated unchanged disabled extraction remains inexpensive and stable. - Static and concurrent symlink replacement are both protected by durable,
- `go test ./internal/app ./internal/stage ./internal/manifest`, deterministic tests.
`go test ./...`, `go vet ./...`, and `go build ./cmd/narratio` pass. - `go test -count=1 ./internal/fileops`,
`go test -race -count=1 ./internal/fileops`, `go test ./...`, and
`go vet ./...` pass on the development platform.
## Stage 11: Exclude Run-Local Notarius Bundles From Implicit Publication ## Stage 16: Clear Superseded Session-Stage Result Payloads
Enforce the existing explicit-only publication boundary while preserving Make the session manifest describe only the current stage outcome. Historical
ordinary run-record diagnostics. successful result details belong to their immutable run manifests and must not
remain attached to a current stage record that is running, failed, skipped, or
newly succeeded without corresponding details.
Implementation: Implementation:
1. Update publish run-file collection so the canonical run-local 1. Centralize clearing of result-bearing `manifest.StageRecord` fields:
`extract/notarius-output/` subtree is never included in the run-record upload. `Outputs`, `Logs`, `GeneratedConfigs`, and `Metadata`.
Match the exact slash-normalized relative subtree; do not use a broad 2. Clear those fields when `MarkStageRunning` begins a replacement attempt.
substring rule that could suppress unrelated files. The runner already captures the prior outcome before this transition; do not
2. Keep the Notarius receipt and stderr files eligible for the existing run change self-skip comparison or downstream invalidation decisions.
archive. They are diagnostics, not the published bundle. Preserve current 3. Also make `MarkStageFailed` and `MarkStageSkipped` enforce the empty-result
audio exclusion and all unrelated run-file behavior. invariant themselves so callers cannot construct a failed or skipped record
3. Do not scan or upload the durable `artifacts/notarius/<run-id>/` directory. with inherited successful output details. Avoid runner-only cleanup that
A configured lane may still be uploaded only through an explicit leaves direct manifest transitions inconsistent.
`pipeline.publish.outputs` rule resolved through the artifact catalog. 4. Do not clear result details merely when `MarkStageStale` is called. Resume
4. Preserve locks, required/optional output handling, upload ordering, current validation must still be able to inspect the prior result before deciding to
manifest publication, and the final current-run pointer commit. rerun it, and stale records remain useful diagnostic state until execution
5. Update publish metadata so excluded staging-bundle files are not counted in actually begins.
`run_files_uploaded` or listed in `run_uploaded_paths`. 5. Ensure `MarkStageSucceeded` followed by result application cannot inherit
old logs, generated configurations, or metadata when the new successful
result omits those fields. Clearing at the running transition should own this
invariant; do not add scattered empty-map special cases.
6. Preserve immutable prior run manifests and durable Notarius bundles. This
stage changes the current session-stage record, not historical files or
retention policy.
7. Consolidate the existing runner self-skip cleanup helper if it becomes
redundant after manifest transitions own the invariant.
Tests: Tests:
- materialize a realistic current-run `extract/notarius-output/<external-run>/` - add manifest transition tests proving running, failed, and skipped records
tree containing index, management, lane, pipeline-wide, and unknown regular cannot retain prior outputs, logs, generated configurations, or metadata;
files, then prove none are uploaded under the run prefix; - prove `MarkStageStale` retains the prior details required for resume
- prove receipt and stderr diagnostics in the same extract directory remain validation;
ordinary run uploads; - change the forced-extraction-failure lifecycle fixture to first complete a
- prove no Notarius bundle member is uploaded when there is no explicit successful extraction, then force a failing replacement, and assert the
extraction publish rule; current extract record is failed with no inherited result payload;
- prove one explicit extraction rule uploads only its durable selected lane to - retain the assertion that succeeded downstream stages become stale;
the configured session destination; and - prove a successful replacement whose new result omits optional details does
- retain assertions for commit-marker order, locks, previous-cache publication, not inherit details from its predecessor; and
and unrelated run files. - retain resume-error, repeated-self-skip, and ordinary-retry coverage.
Exit criteria: Exit criteria:
- No run-local or durable Notarius bundle is published wholesale. - A current running, failed, or skipped session-stage record never advertises
- Explicit configured lane publication remains functional and auditable. result payload belonging to an earlier success.
- `go test ./internal/stage ./internal/app ./internal/artifacts` and - Historical run manifests and immutable extraction bundles remain available
`go test ./...` pass. for audit and recovery.
- `go test -count=1 ./internal/manifest ./internal/app ./internal/stage`,
focused lifecycle race tests, `go test ./...`, and `go vet ./...` pass.
## Stage 12: Enforce Canonical Notarius Management-File Semantics ## Stage 17: Establish Native Promotion Validation On Every Supported Platform
Make the adapter's accepted production contract agree with extract, resume, and Turn the Linux, macOS, and Windows promotion claim into a continuously verified
catalog invariants. contract. Cross-compilation remains useful but cannot substitute for executing
filesystem operations on each native platform.
Implementation: Implementation:
1. Re-read Notarius's current `run-result.md` and `json-output.md` before editing 1. Resolve the CI substrate and native runner labels in the open question below
and keep tolerant decoding of unknown fields within the supported schema. before editing workflow files. Follow the repository host's established
2. For `notarius.run-result.v1` using the production JSON output, require the workflow location and syntax; do not invent runner labels that cannot run.
receipt's logical `index_file` to be exactly `index.json`. A different safe 2. Add checked-in CI jobs that execute on native Linux, macOS, and Windows
relative path is semantically incompatible and must fail adapter discovery, runners. Each native job must run at least:
not produce a successful result that later becomes non-resumable. - `go test -count=1 ./internal/fileops`;
3. Require the production index management fields to be exactly: - the platform-specific no-replace test; and
- `manifest_file: manifest.json`; - the portable promotion conformance tests, including successful promotion,
- `rejected_file: rejected.json`; and collision preservation, cleanup, and the Stage 15 race-safe traversal
- `warnings_file: warnings.json`. cases that apply on that platform.
4. Retain all existing confinement, regular-file, symlink, size-limit, and 3. Ensure the jobs exercise the real platform implementation rather than a
tolerant unknown-field checks after the exact semantic checks. Do not tighten mocked rename function. A destination created before installation must win,
optional lane descriptor fields beyond Notarius's published contract. and the source and destination contents must prove that no replacement
5. Keep the canonical index invariant shared by stage output, resume validation, occurred.
and catalog hydration. Avoid introducing a second configurable or inferred 4. On Windows, ensure successful promotion exercises both `MoveFileEx` and the
management-path model. directory-sync path. Do not treat permission or ordinary I/O failures as
6. Return errors that identify the incompatible field and observed value without unsupported-operation success.
including transcript-derived payload content. 5. Retain deterministic cross-compilation of the fileops test binary for Linux,
Darwin, and Windows. Write compiled test binaries to a temporary or CI
Tests: artifact directory, never the repository root.
6. Keep unsupported-platform compilation coverage, but do not misrepresent an
- reject alternate but confined values such as `nested/index.json`, unexecuted cross-compiled test as native validation.
`./index.json`, and renamed management files; 7. Record any required runner-specific limitation in this stage and in the
- continue rejecting absolute paths, traversal, symlinks, and missing files; canonical contributor/CI documentation. Do not weaken production semantics
- continue accepting canonical documents with unknown optional fields; merely to accommodate an inadequately provisioned runner.
- prove a successful adapter result promotes to canonical `index.json`; and
- prove a successful extract record is immediately accepted by resume validation
and catalog hydration without normalization or path rewriting.
Exit criteria:
- Every adapter success satisfies the canonical management-file assumptions of
extraction, resume, and catalog code.
- `go test ./internal/adapters/notarius ./internal/stage ./internal/artifacts`
and `go test ./...` pass.
## Stage 13: Complete And Document The Atomic-Promotion Platform Contract
Replace the accidental Linux-only behavior with an explicit, tested support
boundary. Support Linux, macOS, and Windows; fail early and clearly on other
platforms until they gain an atomic no-replace primitive.
Implementation:
1. Keep Linux installation based on `renameat2(RENAME_NOREPLACE)`.
2. Add a Darwin implementation using `renamex_np(RENAME_EXCL)` from
`golang.org/x/sys/unix`.
3. Add a Windows implementation using the no-replace form of
`golang.org/x/sys/windows.MoveFileEx`. The sibling temporary directory keeps
the move on one volume; do not set the replace-existing flag.
4. Split directory-sync behavior by platform where necessary. Unsupported
directory syncing may be treated as best effort only for documented
platform-specific unsupported-operation errors; do not suppress ordinary I/O
or permission failures.
5. Add a small build-specific capability check so an unsupported platform fails
before copying the bundle into a temporary tree. Retain safe cleanup if any
later platform operation fails.
6. Preserve the core invariant on every supported platform: a concurrent actor
that creates the destination wins or causes a clean error; Narratio never
replaces that destination.
7. Document Linux, macOS, and Windows as the supported atomic-promotion
platforms and the explicit extraction limitation on other operating systems.
Do not imply that all of Narratio has a broader support guarantee than its
existing documentation establishes.
Tests and validation:
- retain the portable promotion conformance tests for successful nested copy,
source preservation, existing destination, cleanup, symlink rejection, and
install collision;
- add platform-specific no-replace tests that run on their native CI platform;
- cross-compile the fileops tests for Linux, Darwin, and Windows;
- confirm the unsupported-platform implementation returns its capability error
before creating a temporary sibling; and
- run `go test ./internal/fileops`, `go test ./...`, `go vet ./...`, and
`go build ./cmd/narratio` on the development platform.
Exit criteria:
- Directory promotion is functional and no-replace on Linux, macOS, and
Windows rather than merely compiling there.
- Unsupported platforms fail before expensive copying and have an explicit
documented boundary.
- All available native and cross-compilation checks pass.
## Stage 14: Reconcile Documentation And Perform Final Remediation Validation
Make current-behavior documentation match the corrected implementation and
close the remediation effort only after end-to-end verification.
Implementation:
1. Correct `docs/internal/stage-extract.md` to say that omitted or disabled
Notarius explicitly self-skips with `notarius_disabled`; do not describe the
manifest result as succeeded.
2. Replace the nonexistent `internal/stage/extract_resume_test.go` reference with
the actual focused test owner, or create that file only if tests were
intentionally reorganized during remediation.
3. Update operations, publish, and extraction documentation to state that:
- changed extraction outcomes stale affected downstream stages;
- repeated identical disabled self-skip does not cause perpetual reruns;
- the run-local Notarius bundle is excluded from run-record upload; and
- only explicit configured extraction lanes are published.
4. Update platform-support documentation from Stage 13 and troubleshooting for
unsupported atomic promotion.
5. Reconcile `docs/roadmap/notarius-extract-stage.md` with the remediated current
state. Mark the feature complete only if every original and remediation
acceptance criterion is satisfied.
6. Recheck maintained examples, internal links, stage inventories, command
examples, field names, schema identities, defaults, and paths against code.
7. Keep roadmap history concise; do not restore the former detailed completed
Stage 1-9 instructions to this file.
Validation: Validation:
- run focused lifecycle, adapter, fileops, catalog, analyze, publish, restore, - obtain a successful native Linux job;
and operator tests without live external services; - obtain a successful native macOS job;
- run focused race tests covering the changed extraction, artifact, publish, - obtain a successful native Windows job;
fileops, and runner paths; - retain successful Linux, Darwin, and Windows cross-compilation; and
- run `go test -count=1 ./...`; - confirm CI does not leave generated test binaries or application binaries in
- run `go vet ./...`; the worktree or commit them as artifacts of the source tree.
- run `go build ./cmd/narratio`;
- run the Stage 13 cross-compilation checks;
- run `git diff --check`; and
- confirm no secrets, private campaign content, generated binaries, or test
artifacts were added to the repository.
Exit criteria: Exit criteria:
- All post-implementation review findings are corrected or explicitly bounded - Atomic no-replace promotion and its directory-sync behavior execute
by the documented platform contract. successfully on native Linux, macOS, and Windows.
- Current documentation accurately describes stage outcomes, invalidation, - The checked-in CI configuration will rerun those tests on future changes.
publication, adapter compatibility, and supported platforms. - Stage 17 is not marked complete based solely on Linux execution and
- Every progress row is `Complete`, the repository-wide validation suite passes, cross-compilation.
and the target feature roadmap can truthfully remain complete.
## Stage 18: Reconcile Security Documentation And Perform Final Validation
Close the remediation only after the corrected filesystem and manifest
invariants are implemented and native platform evidence exists.
Implementation:
1. Reconcile `docs/roadmap/notarius-extract-stage.md` with the Stage 15 source
traversal mechanism and Stage 16 current-result semantics. Keep normative
security and manifest guarantees in their existing canonical owners.
2. Update focused internal or contributor documentation only where the new
mechanism or CI workflow changes maintained implementation guidance. Do not
duplicate volatile platform commands across multiple documents.
3. Recheck the extraction, workspace, manifest, operations, troubleshooting,
integration, and testing documentation for contradictions introduced by the
remediation.
4. Keep completed roadmap history concise. Mark the target feature roadmap and
this implementation plan complete only after all Stage 15 through 17 exit
criteria are satisfied.
5. Confirm that no secrets, private campaign material, generated binaries,
platform test binaries, or temporary bundle trees were added to the
repository.
Validation:
- run focused fileops, manifest, runner lifecycle, extraction, adapter,
artifact-catalog, analyze, publish, restore, and operator tests;
- run focused race tests covering fileops, manifest transitions, runner
lifecycle, extraction, artifacts, and publication;
- run `go test -count=1 ./...`;
- run `go vet ./...`;
- build `cmd/narratio` with `-o` targeting a temporary directory;
- rerun Linux, Darwin, and Windows fileops test cross-compilation;
- confirm the native CI jobs from Stage 17 are successful;
- run `git diff --check`; and
- verify the worktree contains no generated validation artifacts.
Exit criteria:
- The source traversal and current-manifest replacement gaps are closed by
focused regression tests.
- Native filesystem behavior substantiates the documented Linux, macOS, and
Windows support boundary.
- Current documentation matches the remediated implementation.
- Every progress row is `Complete`, the repository-wide validation suite
passes, and the target feature roadmap can truthfully remain complete.
## Open Questions ## Open Questions
None. The remediation stages adopt the long-term-maintainable defaults: general ### Which CI system and native runner labels are authoritative for this repository?
runner invalidation semantics, explicit-only bundle publication, exact current
Notarius management-file semantics, and atomic promotion support on Linux, **Recommended approach:** Use Gitea Actions with a checked-in workflow under
macOS, and Windows with an explicit early failure elsewhere. `.gitea/workflows/`, provided the repository owner supplies or confirms native
Linux, macOS, and Windows runner labels. The repository is hosted on Gitea, so
keeping the workflow with the authoritative repository minimizes mirrored
configuration and makes the platform contract visible beside the code. Stage
17 must remain pending until all three native jobs have actually run.
**Viable alternative:** Use an existing external CI service with real native
capacity, such as a maintained GitHub Actions mirror or Buildkite installation,
and check its workflow or pipeline definition into the conventional repository
location. This is appropriate when that service already owns release gating or
when native macOS and Windows runners are unavailable in Gitea. The alternative
must still produce repeatable native results for all three platforms;
cross-compilation or an undocumented one-time manual run is not sufficient.

View File

@@ -30,6 +30,19 @@ func PromoteDirectory(src, dst string) error {
} }
func promoteDirectory(src, dst string, install func(string, string) error) error { func promoteDirectory(src, dst string, install func(string, string) error) error {
return promoteDirectoryWithHooks(src, dst, install, sourceTraversalHooks{})
}
type sourceTraversalHooks struct {
afterRootInspect func()
afterEntryInspect func(string)
}
func promoteDirectoryWithHooks(
src, dst string,
install func(string, string) error,
hooks sourceTraversalHooks,
) error {
if strings.TrimSpace(src) == "" || strings.TrimSpace(dst) == "" { if strings.TrimSpace(src) == "" || strings.TrimSpace(dst) == "" {
return fmt.Errorf("source and destination directory paths are required") return fmt.Errorf("source and destination directory paths are required")
} }
@@ -76,7 +89,13 @@ func promoteDirectory(src, dst string, install func(string, string) error) error
} }
}() }()
if err := copyRegularTree(src, temporary); err != nil { sourceRoot, err := openVerifiedSourceRoot(src, sourceInfo, hooks)
if err != nil {
return err
}
defer func() { _ = sourceRoot.Close() }()
if err := copyRegularTree(sourceRoot, src, temporary, hooks); err != nil {
return err return err
} }
if err := os.Chmod(temporary, promotedDirectoryMode); err != nil { if err := os.Chmod(temporary, promotedDirectoryMode); err != nil {
@@ -96,62 +115,153 @@ func promoteDirectory(src, dst string, install func(string, string) error) error
return nil return nil
} }
func copyRegularTree(src, dst string) error { func openVerifiedSourceRoot(path string, inspected os.FileInfo, hooks sourceTraversalHooks) (*os.Root, error) {
entries, err := os.ReadDir(src) if hooks.afterRootInspect != nil {
hooks.afterRootInspect()
}
root, err := os.OpenRoot(path)
if err != nil { if err != nil {
return fmt.Errorf("read source directory %q: %w", src, err) return nil, fmt.Errorf("open source directory %q: %w", path, err)
}
verified := false
defer func() {
if !verified {
_ = root.Close()
}
}()
opened, err := root.Stat(".")
if err != nil {
return nil, fmt.Errorf("inspect opened source directory %q: %w", path, err)
}
if !opened.IsDir() || !os.SameFile(inspected, opened) {
return nil, fmt.Errorf("source directory %q changed while being opened", path)
}
current, err := os.Lstat(path)
if err != nil {
return nil, fmt.Errorf("reinspect source directory %q: %w", path, err)
}
if current.Mode()&os.ModeSymlink != 0 || !current.IsDir() || !os.SameFile(opened, current) {
return nil, fmt.Errorf("source directory %q changed while being opened", path)
}
verified = true
return root, nil
}
func copyRegularTree(src *os.Root, sourcePath, dst string, hooks sourceTraversalHooks) error {
directory, err := src.Open(".")
if err != nil {
return fmt.Errorf("open source directory %q for traversal: %w", sourcePath, err)
}
defer func() { _ = directory.Close() }()
entries, err := directory.ReadDir(-1)
if err != nil {
return fmt.Errorf("read source directory %q: %w", sourcePath, err)
} }
sort.Slice(entries, func(i, j int) bool { sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name() return entries[i].Name() < entries[j].Name()
}) })
for _, entry := range entries { for _, entry := range entries {
sourcePath := filepath.Join(src, entry.Name()) entryPath := filepath.Join(sourcePath, entry.Name())
destinationPath := filepath.Join(dst, entry.Name()) destinationPath := filepath.Join(dst, entry.Name())
info, err := os.Lstat(sourcePath) info, err := src.Lstat(entry.Name())
if err != nil { if err != nil {
return fmt.Errorf("inspect source entry %q: %w", sourcePath, err) return fmt.Errorf("inspect source entry %q: %w", entryPath, err)
} }
switch { switch {
case info.Mode().IsRegular(): case info.Mode().IsRegular():
if err := copyRegularFile(sourcePath, destinationPath, info); err != nil { if hooks.afterEntryInspect != nil {
hooks.afterEntryInspect(entryPath)
}
if err := copyRegularFile(src, entry.Name(), entryPath, destinationPath, info); err != nil {
return err return err
} }
case info.IsDir(): case info.IsDir():
if err := os.Mkdir(destinationPath, promotedDirectoryMode); err != nil { if hooks.afterEntryInspect != nil {
return fmt.Errorf("create destination directory %q: %w", destinationPath, err) hooks.afterEntryInspect(entryPath)
} }
if err := copyRegularTree(sourcePath, destinationPath); err != nil { if err := copyRegularDirectory(src, entry.Name(), entryPath, destinationPath, info, hooks); err != nil {
return err return err
} }
if err := os.Chmod(destinationPath, promotedDirectoryMode); err != nil {
return fmt.Errorf("set destination directory permissions %q: %w", destinationPath, err)
}
if err := syncDirectory(destinationPath); err != nil {
return fmt.Errorf("sync destination directory %q: %w", destinationPath, err)
}
default: default:
return fmt.Errorf("source entry %q has unsupported file type %s", sourcePath, info.Mode().Type()) return fmt.Errorf("source entry %q has unsupported file type %s", entryPath, info.Mode().Type())
} }
} }
return nil return nil
} }
func copyRegularFile(src, dst string, inspected os.FileInfo) error { func copyRegularDirectory(
in, err := os.Open(src) parent *os.Root,
name, sourcePath, dst string,
inspected os.FileInfo,
hooks sourceTraversalHooks,
) error {
child, err := parent.OpenRoot(name)
if err != nil { if err != nil {
return fmt.Errorf("open source file %q: %w", src, err) return fmt.Errorf("open source directory %q: %w", sourcePath, err)
}
defer func() { _ = child.Close() }()
opened, err := child.Stat(".")
if err != nil {
return fmt.Errorf("inspect opened source directory %q: %w", sourcePath, err)
}
if !opened.IsDir() || !os.SameFile(inspected, opened) {
return fmt.Errorf("source directory %q changed while being copied", sourcePath)
}
current, err := parent.Lstat(name)
if err != nil {
return fmt.Errorf("reinspect source directory %q: %w", sourcePath, err)
}
if current.Mode()&os.ModeSymlink != 0 || !current.IsDir() || !os.SameFile(opened, current) {
return fmt.Errorf("source directory %q changed while being copied", sourcePath)
}
if err := os.Mkdir(dst, promotedDirectoryMode); 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 {
return fmt.Errorf("set destination directory permissions %q: %w", dst, err)
}
if err := syncDirectory(dst); err != nil {
return fmt.Errorf("sync destination directory %q: %w", dst, err)
}
return nil
}
func copyRegularFile(
root *os.Root,
name, sourcePath, dst string,
inspected os.FileInfo,
) error {
in, err := root.Open(name)
if err != nil {
return fmt.Errorf("open source file %q: %w", sourcePath, err)
} }
defer func() { _ = in.Close() }() defer func() { _ = in.Close() }()
openedInfo, err := in.Stat() opened, err := in.Stat()
if err != nil { if err != nil {
return fmt.Errorf("inspect opened source file %q: %w", src, err) return fmt.Errorf("inspect opened source file %q: %w", sourcePath, err)
} }
if !openedInfo.Mode().IsRegular() || !os.SameFile(inspected, openedInfo) { if !opened.Mode().IsRegular() || !os.SameFile(inspected, opened) {
return fmt.Errorf("source file %q changed while being copied", src) return fmt.Errorf("source file %q changed while being copied", sourcePath)
}
current, err := root.Lstat(name)
if err != nil {
return fmt.Errorf("reinspect source file %q: %w", sourcePath, err)
}
if current.Mode()&os.ModeSymlink != 0 || !current.Mode().IsRegular() || !os.SameFile(opened, current) {
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, promotedFileMode)
@@ -166,7 +276,7 @@ func copyRegularFile(src, dst string, inspected os.FileInfo) error {
}() }()
if _, err := io.Copy(out, in); err != nil { if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("copy source file %q: %w", src, err) return fmt.Errorf("copy source file %q: %w", sourcePath, err)
} }
if err := out.Chmod(promotedFileMode); err != nil { if err := out.Chmod(promotedFileMode); err != nil {
return fmt.Errorf("set destination file permissions %q: %w", dst, err) return fmt.Errorf("set destination file permissions %q: %w", dst, err)

View File

@@ -159,6 +159,151 @@ func TestPromoteDirectoryRejectsSymlinksWithoutFollowingThem(t *testing.T) {
assertFileBytes(t, filepath.Join(externalDirectory, "secret.txt"), []byte("secret")) assertFileBytes(t, filepath.Join(externalDirectory, "secret.txt"), []byte("secret"))
} }
func TestPromoteDirectoryRejectsSymlinkSourceRoot(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "target")
src := filepath.Join(root, "source")
dst := filepath.Join(root, "destination")
mustWriteFile(t, filepath.Join(target, "value.txt"), []byte("outside"), 0o644)
if err := os.Symlink(target, src); err != nil {
t.Skipf("Symlink() unavailable: %v", err)
}
if err := PromoteDirectory(src, dst); err == nil {
t.Fatal("PromoteDirectory() error = nil, want source-root symlink rejection")
}
assertFileBytes(t, filepath.Join(target, "value.txt"), []byte("outside"))
assertPathMissing(t, dst)
assertNoMatchingTempDirectories(t, root, ".destination.tmp-")
}
func TestPromoteDirectoryRejectsSourceRootReplacementBeforeOpen(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
preserved := filepath.Join(root, "source-preserved")
replacement := filepath.Join(root, "replacement")
dst := filepath.Join(root, "destination")
mustWriteFile(t, filepath.Join(src, "value.txt"), []byte("original"), 0o644)
mustWriteFile(t, filepath.Join(replacement, "value.txt"), []byte("replacement"), 0o644)
err := promoteDirectoryWithHooks(src, dst, renameDirectoryNoReplace, sourceTraversalHooks{
afterRootInspect: func() {
if err := os.Rename(src, preserved); err != nil {
t.Fatalf("Rename(original source) error = %v", err)
}
if err := os.Rename(replacement, src); err != nil {
t.Fatalf("Rename(replacement source) error = %v", err)
}
},
})
if err == nil {
t.Fatal("promoteDirectoryWithHooks() error = nil, want source identity failure")
}
assertFileBytes(t, filepath.Join(preserved, "value.txt"), []byte("original"))
assertFileBytes(t, filepath.Join(src, "value.txt"), []byte("replacement"))
assertPathMissing(t, dst)
assertNoMatchingTempDirectories(t, root, ".destination.tmp-")
}
func TestPromoteDirectoryRejectsInspectedDirectorySymlinkReplacement(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
child := filepath.Join(src, "child")
preserved := filepath.Join(src, "child-preserved")
outside := filepath.Join(root, "outside")
dst := filepath.Join(root, "destination")
mustWriteFile(t, filepath.Join(child, "value.txt"), []byte("original"), 0o644)
mustWriteFile(t, filepath.Join(outside, "sentinel.txt"), []byte("outside"), 0o644)
replaced := false
err := promoteDirectoryWithHooks(src, dst, renameDirectoryNoReplace, sourceTraversalHooks{
afterEntryInspect: func(path string) {
if replaced || path != child {
return
}
replaced = true
if err := os.Rename(child, preserved); err != nil {
t.Fatalf("Rename(inspected child) error = %v", err)
}
if err := os.Symlink(filepath.Join("..", "outside"), child); err != nil {
t.Skipf("Symlink() unavailable: %v", err)
}
if err := os.Mkdir(dst, 0o755); err != nil {
t.Fatalf("Mkdir(concurrent destination) error = %v", err)
}
mustWriteFile(t, filepath.Join(dst, "value.txt"), []byte("concurrent"), 0o644)
},
})
if err == nil {
t.Fatal("promoteDirectoryWithHooks() error = nil, want symlink replacement failure")
}
assertFileBytes(t, filepath.Join(preserved, "value.txt"), []byte("original"))
assertFileBytes(t, filepath.Join(outside, "sentinel.txt"), []byte("outside"))
assertFileBytes(t, filepath.Join(dst, "value.txt"), []byte("concurrent"))
assertPathMissing(t, filepath.Join(dst, "sentinel.txt"))
assertNoMatchingTempDirectories(t, root, ".destination.tmp-")
}
func TestPromoteDirectoryRejectsInspectedFileIdentityMismatch(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
file := filepath.Join(src, "value.txt")
preserved := filepath.Join(src, "value-preserved.txt")
dst := filepath.Join(root, "destination")
mustWriteFile(t, file, []byte("original"), 0o644)
replaced := false
err := promoteDirectoryWithHooks(src, dst, renameDirectoryNoReplace, sourceTraversalHooks{
afterEntryInspect: func(path string) {
if replaced || path != file {
return
}
replaced = true
if err := os.Rename(file, preserved); err != nil {
t.Fatalf("Rename(inspected file) error = %v", err)
}
mustWriteFile(t, file, []byte("replacement"), 0o644)
},
})
if err == nil {
t.Fatal("promoteDirectoryWithHooks() error = nil, want file identity failure")
}
assertFileBytes(t, preserved, []byte("original"))
assertFileBytes(t, file, []byte("replacement"))
assertPathMissing(t, dst)
assertNoMatchingTempDirectories(t, root, ".destination.tmp-")
}
func TestPromoteDirectoryRejectsInspectedDirectoryIdentityMismatch(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
child := filepath.Join(src, "child")
preserved := filepath.Join(src, "child-preserved")
dst := filepath.Join(root, "destination")
mustWriteFile(t, filepath.Join(child, "value.txt"), []byte("original"), 0o644)
replaced := false
err := promoteDirectoryWithHooks(src, dst, renameDirectoryNoReplace, sourceTraversalHooks{
afterEntryInspect: func(path string) {
if replaced || path != child {
return
}
replaced = true
if err := os.Rename(child, preserved); err != nil {
t.Fatalf("Rename(inspected directory) error = %v", err)
}
mustWriteFile(t, filepath.Join(child, "value.txt"), []byte("replacement"), 0o644)
},
})
if err == nil {
t.Fatal("promoteDirectoryWithHooks() error = nil, want directory identity failure")
}
assertFileBytes(t, filepath.Join(preserved, "value.txt"), []byte("original"))
assertFileBytes(t, filepath.Join(child, "value.txt"), []byte("replacement"))
assertPathMissing(t, dst)
assertNoMatchingTempDirectories(t, root, ".destination.tmp-")
}
func TestPromoteDirectoryDoesNotReplaceDestinationCreatedBeforeInstall(t *testing.T) { func TestPromoteDirectoryDoesNotReplaceDestinationCreatedBeforeInstall(t *testing.T) {
root := t.TempDir() root := t.TempDir()
src := filepath.Join(root, "source") src := filepath.Join(root, "source")
@@ -217,6 +362,13 @@ func assertFileBytes(t *testing.T, path string, want []byte) {
} }
} }
func assertPathMissing(t *testing.T, path string) {
t.Helper()
if _, err := os.Lstat(path); !os.IsNotExist(err) {
t.Fatalf("Lstat(%q) error = %v, want not exist", path, err)
}
}
func treeLayout(t *testing.T, root string) []string { func treeLayout(t *testing.T, root string) []string {
t.Helper() t.Helper()
var layout []string var layout []string