From eb41b991bf9f4feca3033ababdb43dedbc847f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 21 Jul 2026 19:02:57 -0400 Subject: [PATCH] feat(go): durable transaction engine and Kiro IDE 1.0 adapter Split the monolithic transaction into focused modules: append-only crash-recoverable journal under ~/.elydora/transactions with owner tokens and per-transaction namespaces, staged atomic publication (stage .pending, fsync, rename, fsync), platform-specific atomic replacement (MoveFileEx, renameat2, renamex_np), private-directory hardening per OS, lock-guarded recovery, and workspace cleanup that removes only transaction-created empty directories. Add the Kiro IDE 1.0 plugin: strict v1 hook schema with one enabled PreToolUse guard and PostToolUse audit pair bound to the active workspace, agent-to-workspace identity binding in runtime config, and exact migration of the legacy elydora-audit.kiro.hook contract. Gates: go test ./... and -race pass, go vet clean, govulncheck reports zero reachable vulnerabilities, git diff --check clean. --- AGENTS.md | 2 + README.md | 4 + cmd/elydora/cline_cli_test.go | 1 + cmd/elydora/droid_cli_test.go | 1 + .../plugins/augment_test_helpers_test.go | 1 + .../plugins/claudecode_test_helpers_test.go | 1 + .../plugins/cline_test_helpers_test.go | 1 + cmd/elydora/plugins/codex_test.go | 1 + .../plugins/copilot_test_helpers_test.go | 1 + .../plugins/cursor_test_helpers_test.go | 1 + .../plugins/droid_test_helpers_test.go | 1 + .../plugins/gemini_test_helpers_test.go | 1 + cmd/elydora/plugins/grok_test_helpers_test.go | 1 + cmd/elydora/plugins/kimi_test_helpers_test.go | 1 + cmd/elydora/plugins/kirocli_test.go | 1 + cmd/elydora/plugins/kiroide.go | 199 +++--- cmd/elydora/plugins/kiroide_command.go | 134 ++++ cmd/elydora/plugins/kiroide_contract.go | 460 +++++++++++++ cmd/elydora/plugins/kiroide_installation.go | 450 +++++++++++++ cmd/elydora/plugins/kiroide_io.go | 192 ++++++ cmd/elydora/plugins/kiroide_status.go | 277 ++++++++ cmd/elydora/plugins/kiroide_test.go | 450 +++++++++++++ .../plugins/kiroide_test_helpers_test.go | 271 ++++++++ cmd/elydora/plugins/kiroide_transaction.go | 258 +++++++ .../plugins/kiroide_transaction_test.go | 380 +++++++++++ cmd/elydora/plugins/managed_files.go | 9 + cmd/elydora/plugins/qwen_test_helpers_test.go | 1 + cmd/elydora/plugins/registry.go | 2 +- cmd/elydora/plugins/runtime_files.go | 4 +- cmd/elydora/plugins/runtime_files_test.go | 1 + cmd/elydora/plugins/transaction.go | 339 +++------- .../plugins/transaction_atomic_darwin.go | 48 ++ .../plugins/transaction_atomic_linux.go | 60 ++ .../plugins/transaction_atomic_unix.go | 18 + .../plugins/transaction_atomic_unsupported.go | 35 + .../plugins/transaction_atomic_windows.go | 145 ++++ .../plugins/transaction_boundary_test.go | 353 ++++++++++ cmd/elydora/plugins/transaction_cleanup.go | 487 ++++++++++++++ cmd/elydora/plugins/transaction_commit.go | 462 +++++++++++++ cmd/elydora/plugins/transaction_durable.go | 236 +++++++ .../plugins/transaction_durable_types.go | 140 ++++ .../plugins/transaction_identity_darwin.go | 17 + .../plugins/transaction_identity_linux.go | 17 + .../transaction_identity_unsupported.go | 12 + .../plugins/transaction_identity_windows.go | 22 + .../transaction_initialization_recovery.go | 122 ++++ ...nsaction_initialization_subprocess_test.go | 407 +++++++++++ cmd/elydora/plugins/transaction_journal.go | 342 ++++++++++ cmd/elydora/plugins/transaction_lock.go | 57 ++ cmd/elydora/plugins/transaction_lock_unix.go | 17 + .../plugins/transaction_lock_unsupported.go | 13 + .../plugins/transaction_lock_windows.go | 30 + .../plugins/transaction_precondition.go | 30 +- .../transaction_private_darwin_test.go | 32 + .../transaction_private_harden_darwin.go | 23 + .../transaction_private_harden_linux.go | 9 + .../plugins/transaction_private_unix.go | 78 +++ .../plugins/transaction_private_unix_test.go | 37 + .../transaction_private_unsupported.go | 21 + .../plugins/transaction_private_windows.go | 228 +++++++ .../transaction_private_windows_test.go | 47 ++ .../plugins/transaction_recovery_durable.go | 354 ++++++++++ cmd/elydora/plugins/transaction_stage.go | 218 ++++++ .../plugins/transaction_subprocess_test.go | 634 ++++++++++++++++++ .../plugins/transaction_test_home_test.go | 16 + cmd/elydora/plugins/transaction_workspace.go | 261 +++++++ cmd/elydora/qwen_e2e_test.go | 1 + .../transaction_test_home_unix_test.go | 18 + .../transaction_test_home_windows_test.go | 44 ++ go.mod | 3 +- 70 files changed, 8182 insertions(+), 358 deletions(-) create mode 100644 cmd/elydora/plugins/kiroide_command.go create mode 100644 cmd/elydora/plugins/kiroide_contract.go create mode 100644 cmd/elydora/plugins/kiroide_installation.go create mode 100644 cmd/elydora/plugins/kiroide_io.go create mode 100644 cmd/elydora/plugins/kiroide_status.go create mode 100644 cmd/elydora/plugins/kiroide_test.go create mode 100644 cmd/elydora/plugins/kiroide_test_helpers_test.go create mode 100644 cmd/elydora/plugins/kiroide_transaction.go create mode 100644 cmd/elydora/plugins/kiroide_transaction_test.go create mode 100644 cmd/elydora/plugins/transaction_atomic_darwin.go create mode 100644 cmd/elydora/plugins/transaction_atomic_linux.go create mode 100644 cmd/elydora/plugins/transaction_atomic_unix.go create mode 100644 cmd/elydora/plugins/transaction_atomic_unsupported.go create mode 100644 cmd/elydora/plugins/transaction_atomic_windows.go create mode 100644 cmd/elydora/plugins/transaction_boundary_test.go create mode 100644 cmd/elydora/plugins/transaction_cleanup.go create mode 100644 cmd/elydora/plugins/transaction_commit.go create mode 100644 cmd/elydora/plugins/transaction_durable.go create mode 100644 cmd/elydora/plugins/transaction_durable_types.go create mode 100644 cmd/elydora/plugins/transaction_identity_darwin.go create mode 100644 cmd/elydora/plugins/transaction_identity_linux.go create mode 100644 cmd/elydora/plugins/transaction_identity_unsupported.go create mode 100644 cmd/elydora/plugins/transaction_identity_windows.go create mode 100644 cmd/elydora/plugins/transaction_initialization_recovery.go create mode 100644 cmd/elydora/plugins/transaction_initialization_subprocess_test.go create mode 100644 cmd/elydora/plugins/transaction_journal.go create mode 100644 cmd/elydora/plugins/transaction_lock.go create mode 100644 cmd/elydora/plugins/transaction_lock_unix.go create mode 100644 cmd/elydora/plugins/transaction_lock_unsupported.go create mode 100644 cmd/elydora/plugins/transaction_lock_windows.go create mode 100644 cmd/elydora/plugins/transaction_private_darwin_test.go create mode 100644 cmd/elydora/plugins/transaction_private_harden_darwin.go create mode 100644 cmd/elydora/plugins/transaction_private_harden_linux.go create mode 100644 cmd/elydora/plugins/transaction_private_unix.go create mode 100644 cmd/elydora/plugins/transaction_private_unix_test.go create mode 100644 cmd/elydora/plugins/transaction_private_unsupported.go create mode 100644 cmd/elydora/plugins/transaction_private_windows.go create mode 100644 cmd/elydora/plugins/transaction_private_windows_test.go create mode 100644 cmd/elydora/plugins/transaction_recovery_durable.go create mode 100644 cmd/elydora/plugins/transaction_stage.go create mode 100644 cmd/elydora/plugins/transaction_subprocess_test.go create mode 100644 cmd/elydora/plugins/transaction_test_home_test.go create mode 100644 cmd/elydora/plugins/transaction_workspace.go create mode 100644 cmd/elydora/transaction_test_home_unix_test.go create mode 100644 cmd/elydora/transaction_test_home_windows_test.go diff --git a/AGENTS.md b/AGENTS.md index de69736..aabbc18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,8 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh - Cline CLI 3.0.46 discovers exact `PreToolUse` and `PostToolUse` file basenames across `~/Documents/Cline/Hooks`, `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default, workspace `.clinerules/hooks`, and workspace `.cline/hooks`; Elydora owns the `$CLINE_DIR/hooks` pair and keeps the other roots read-only. Commit both wrappers, generated guard and audit runtimes, strict config, and canonical private key through one rollback-capable transaction. Preserve official input byte-for-byte, emit pure JSON cancellation control for guard exit code `2`, reject linked managed paths, and require exact generated sources plus runtime identity for healthy status. Cline surfaces hook errors and continues the task, so wrapper stderr and runtime `error.log` are operational evidence. - Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Keep repository, Claude-compatible, inline, plugin, and policy hook sources additive; migrate only exact Elydora-owned entries from project `.github/hooks/hooks.json`. Resolve `disableAllHooks` through legacy user config, user settings, Claude repository settings, and Copilot repository settings in official precedence order; settings accept JSONC and trailing commas, while hook documents remain strict JSON. - Validate the complete GitHub Copilot CLI event and command, HTTP, and prompt handler schema before writes, then validate matchers with Node.js regular-expression semantics during install. Own one exact `preToolUse`, `postToolUse`, and `postToolUseFailure` triple, preserve native camelCase payloads, and propagate freeze and revocation through exit code `2`. Generate guard, config, private key, and audit runtimes with both hook documents in one rollback-capable transaction guarded by read-only settings snapshots. Healthy status requires the enabled three-event contract, strict runtime identity, canonical private key, and exact generated runtime sources. +- Resolve Kiro IDE 1.0 hooks through the active workspace `.kiro/hooks/elydora-audit.json`. Preserve unrelated workspace hooks, validate the strict `version: "v1"` schema, and own one exact enabled `PreToolUse` guard plus `PostToolUse` audit pair with `.*` matchers and ten-second timeouts. Use encoded PowerShell commands on Windows, preserve Kiro's complete native payload, and propagate frozen or revoked state through exit code `2`. +- Treat `~/.kiro/hooks/elydora-audit.kiro.hook` solely as exact legacy Go adapter migration input. Commit its removal, workspace hooks, guard, strict runtime config, canonical private key, and native-payload audit runtime through one rollback-capable transaction with unchanged-source preconditions. Healthy status requires a unique exact pair, physical workspace and runtime paths, matching runtime identity, canonical private key, and exact generated runtime sources; verify both entries in Kiro's Agent Hooks panel. - Factory Droid `droid@0.175.0` selects one complete user hook source in this order: `~/.factory/hooks.json`, legacy `~/.factory/hooks/hooks.json`, `~/.factory/settings.local.json` `hooks`, then `~/.factory/settings.json` `hooks`. Write the current root `{ "hooks": { ... } }` container, accept unknown future event arrays, preserve JSONC syntax, and remove exact Elydora entries from every discovered user source during migration or uninstall. - Resolve Factory `hooksDisabled` through system, project, nested-folder, and matching `settings.local.json` extension-only precedence before user settings. Treat system `allowManagedHooksOnly` as an installation gate, snapshot every read-only policy layer as a transaction precondition, and direct users to `/hooks` for local and organization-managed review. - Generate Factory Droid guard, strict config, canonical private key, and native-payload audit runtime with every affected hook document in one rollback-capable transaction. Windows commands use Factory's native PowerShell contract with explicit `$LASTEXITCODE` propagation. Healthy status requires one exact current `PreToolUse`/`PostToolUse` pair, strict runtime identity, physical files, and exact generated runtime sources. diff --git a/README.md b/README.md index 238e3c6..7300015 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ elydora install \ Credential options may be omitted in an interactive terminal; the CLI then reads the private key and optional API token without terminal echo. Credential files must contain one UTF-8 line of at most 64 KiB. Unix credential files require owner-only permissions such as `chmod 600`. Provider settings, `guard.js`, `config.json`, `private.key`, and `hook.js` commit as one rollback-capable transaction, and the generated scripts validate protected files at execution time. +Durable managed-file transactions support Windows, Linux, and macOS. The CLI serializes writers across processes, records append-only recovery state under `~/.elydora/transactions`, stages transaction-owned files on each target filesystem, and completes pending recovery before accepting new writes. Windows requires protected owner-only DACLs, hard-link support, atomic `MoveFileEx` replacement, and writable directory handles with `FlushFileBuffers`; Linux filesystems require `renameat2` with `RENAME_NOREPLACE` and `RENAME_EXCHANGE`; macOS filesystems require `renamex_np` with `RENAME_EXCL` and `RENAME_SWAP`. Unsupported platforms and filesystems fail before managed targets change. Recovery and cleanup verify file and directory identities and retain changed or unexpected objects for manual recovery. + Claude Code installation writes exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` exec-form hooks to `$CLAUDE_CONFIG_DIR/settings.json` (`~/.claude/settings.json` by default). Elydora preserves unrelated user settings, keeps project, local, managed, plugin, skill, and agent sources unchanged, forwards native snake_case payloads, and uses exit code `2` for frozen or revoked agents. Installation validates the complete official hook schema and commits settings with all four runtime artifacts in one rollback-capable transaction. Run `/hooks` and `claude doctor` after installation to inspect the effective hook sources. Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to `$CODEX_HOME/hooks.json` (`~/.codex/hooks.json` by default) and preserves the complete native event payload. A configured `CODEX_HOME` follows Codex's existing-directory canonicalization rule. User TOML, project, plugin, and managed sources remain unchanged and continue loading additively. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable update. Run `/hooks` after installation and approve both Elydora definition hashes. @@ -93,6 +95,8 @@ Cline 3.0.46 installation commits `guard.js`, `config.json`, `private.key`, `hoo Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`. +Kiro IDE 1.0 installation writes an exact enabled `PreToolUse` guard and `PostToolUse` audit pair to the active workspace `.kiro/hooks/elydora-audit.json`. Both entries use Kiro's `version: "v1"` schema, `.*` matchers, ten-second timeouts, and platform-safe absolute commands. Elydora preserves unrelated workspace hooks, forwards the complete native event payload, and propagates frozen or revoked state through exit code `2`. Workspace hooks, exact legacy `~/.kiro/hooks/elydora-audit.kiro.hook` migration, generated runtimes, strict runtime metadata, and the canonical private key commit through one rollback-capable transaction. Confirm both entries in Kiro's Agent Hooks panel after installation. + Kimi installation follows home-directory evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default, and legacy `kimi-cli` uses `~/.kimi/config.toml`. Elydora installs exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks, preserves native snake_case payloads, and commits all selected TOML documents with the four runtime artifacts in one rollback-capable transaction. Windows commands use encoded PowerShell so paths containing spaces, apostrophes, percent signs, and environment-like text remain literal. Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Grok's native camelCase JSON, return `{"decision":"deny","reason":"..."}` with exit code `2` for frozen or revoked agents, and use encoded PowerShell on Windows. Project hooks follow Grok's `/hooks-trust` workflow; Elydora leaves project, plugin, `hooks-paths`, Claude Code, and Cursor compatibility sources unchanged. Run `grok inspect --json` to inspect the effective hook inventory. diff --git a/cmd/elydora/cline_cli_test.go b/cmd/elydora/cline_cli_test.go index f5e9741..70dd84c 100644 --- a/cmd/elydora/cline_cli_test.go +++ b/cmd/elydora/cline_cli_test.go @@ -65,6 +65,7 @@ func prepareClineCLIEnvironment(t *testing.T) (string, string, string, []string) t.Helper() root := t.TempDir() homeDir := filepath.Join(root, "home") + preparePrivateCLITestHome(t, homeDir) clineDir := filepath.Join(root, "cline-home") workspace := filepath.Join(root, "workspace") if err := os.MkdirAll(workspace, 0700); err != nil { diff --git a/cmd/elydora/droid_cli_test.go b/cmd/elydora/droid_cli_test.go index cd1a381..52b5079 100644 --- a/cmd/elydora/droid_cli_test.go +++ b/cmd/elydora/droid_cli_test.go @@ -14,6 +14,7 @@ func prepareDroidCLIEnvironment(t *testing.T) (string, string, []string) { t.Helper() root := t.TempDir() homeDir := filepath.Join(root, "home with spaces and 'quote %DROID%") + preparePrivateCLITestHome(t, homeDir) workspace := filepath.Join(root, "workspace with spaces") if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0700); err != nil { t.Fatalf("create Factory Droid CLI workspace: %v", err) diff --git a/cmd/elydora/plugins/augment_test_helpers_test.go b/cmd/elydora/plugins/augment_test_helpers_test.go index d2c8080..b28549e 100644 --- a/cmd/elydora/plugins/augment_test_helpers_test.go +++ b/cmd/elydora/plugins/augment_test_helpers_test.go @@ -46,6 +46,7 @@ func prepareAugmentFixture( t.Helper() root := t.TempDir() homeDir := filepath.Join(root, "home with spaces and 'quote") + preparePrivateTransactionTestDirectory(t, homeDir) projectDir := filepath.Join(root, "project with spaces") if err := os.MkdirAll(projectDir, 0700); err != nil { t.Fatalf("create project directory: %v", err) diff --git a/cmd/elydora/plugins/claudecode_test_helpers_test.go b/cmd/elydora/plugins/claudecode_test_helpers_test.go index 05c452f..dc6103a 100644 --- a/cmd/elydora/plugins/claudecode_test_helpers_test.go +++ b/cmd/elydora/plugins/claudecode_test_helpers_test.go @@ -59,6 +59,7 @@ func prepareClaudeFixture(t *testing.T, options claudeFixtureOptions) *claudeFix t.Helper() root := t.TempDir() homeDir := filepath.Join(root, "home with 'quote %CLAUDE_HOOK_EVENT%") + preparePrivateTransactionTestDirectory(t, homeDir) projectDir := filepath.Join(root, "project with spaces") if err := os.MkdirAll(projectDir, 0700); err != nil { t.Fatalf("create project directory: %v", err) diff --git a/cmd/elydora/plugins/cline_test_helpers_test.go b/cmd/elydora/plugins/cline_test_helpers_test.go index 1b62626..e270a53 100644 --- a/cmd/elydora/plugins/cline_test_helpers_test.go +++ b/cmd/elydora/plugins/cline_test_helpers_test.go @@ -54,6 +54,7 @@ func prepareClineFixture(t *testing.T, options clineFixtureOptions) *clineFixtur t.Helper() rootDir := t.TempDir() homeDir := filepath.Join(rootDir, "home with spaces and 'quote") + preparePrivateTransactionTestDirectory(t, homeDir) workspaceDir := filepath.Join(rootDir, "workspace") clineDir := filepath.Join(rootDir, "custom-cline-home") hooksDir := filepath.Join(clineDir, "hooks") diff --git a/cmd/elydora/plugins/codex_test.go b/cmd/elydora/plugins/codex_test.go index 3fc181c..cb8381b 100644 --- a/cmd/elydora/plugins/codex_test.go +++ b/cmd/elydora/plugins/codex_test.go @@ -35,6 +35,7 @@ type codexFixture struct { func prepareCodexFixture(t *testing.T, existingRaw string) *codexFixture { t.Helper() homeDir := filepath.Join(t.TempDir(), "home with 'quote %ELYDORA_HOOK_PATH%") + preparePrivateTransactionTestDirectory(t, homeDir) agentDir := filepath.Join(homeDir, ".elydora", codexTestAgentID) configPath := filepath.Join(homeDir, ".codex", "hooks.json") guardPath := filepath.Join(agentDir, "guard.js") diff --git a/cmd/elydora/plugins/copilot_test_helpers_test.go b/cmd/elydora/plugins/copilot_test_helpers_test.go index 3c48b13..2f7cfb5 100644 --- a/cmd/elydora/plugins/copilot_test_helpers_test.go +++ b/cmd/elydora/plugins/copilot_test_helpers_test.go @@ -72,6 +72,7 @@ func prepareCopilotFixture(t *testing.T, options copilotFixtureOptions) *copilot t.Helper() rootDir := t.TempDir() homeDir := filepath.Join(rootDir, "home with spaces and 'quote") + preparePrivateTransactionTestDirectory(t, homeDir) projectDir := filepath.Join(rootDir, "project with spaces") copilotHome := filepath.Join(homeDir, "custom copilot") agentDir := filepath.Join(homeDir, ".elydora", copilotTestAgentID) diff --git a/cmd/elydora/plugins/cursor_test_helpers_test.go b/cmd/elydora/plugins/cursor_test_helpers_test.go index e9d8435..54f3d54 100644 --- a/cmd/elydora/plugins/cursor_test_helpers_test.go +++ b/cmd/elydora/plugins/cursor_test_helpers_test.go @@ -48,6 +48,7 @@ func prepareCursorFixture(t *testing.T, existingRaw *string, createGuard bool) * t.Helper() rootDir := t.TempDir() homeDir := filepath.Join(rootDir, "home with spaces and 'quote") + preparePrivateTransactionTestDirectory(t, homeDir) agentDir := filepath.Join(homeDir, ".elydora", cursorTestAgentID) configPath := filepath.Join(homeDir, ".cursor", "hooks.json") guardPath := filepath.Join(agentDir, "guard.js") diff --git a/cmd/elydora/plugins/droid_test_helpers_test.go b/cmd/elydora/plugins/droid_test_helpers_test.go index 2b78a04..b6c55d4 100644 --- a/cmd/elydora/plugins/droid_test_helpers_test.go +++ b/cmd/elydora/plugins/droid_test_helpers_test.go @@ -71,6 +71,7 @@ func prepareDroidFixture(t *testing.T, options droidFixtureOptions) *droidFixtur t.Helper() rootDir := t.TempDir() homeDir := filepath.Join(rootDir, "home with spaces and 'quote %DROID%") + preparePrivateTransactionTestDirectory(t, homeDir) workspaceDir := filepath.Join(rootDir, "workspace with spaces") factoryDir := filepath.Join(homeDir, ".factory") configPath := filepath.Join(factoryDir, "hooks.json") diff --git a/cmd/elydora/plugins/gemini_test_helpers_test.go b/cmd/elydora/plugins/gemini_test_helpers_test.go index d9297eb..e0dd1ee 100644 --- a/cmd/elydora/plugins/gemini_test_helpers_test.go +++ b/cmd/elydora/plugins/gemini_test_helpers_test.go @@ -51,6 +51,7 @@ func prepareGeminiFixture( root, "home with 'quote $GEMINI_CWD %GEMINI_CWD%", ) + preparePrivateTransactionTestDirectory(t, homeDir) projectDir := filepath.Join(root, "project with spaces") geminiHome := filepath.Join(homeDir, "custom gemini home") if options.useDefaultHome { diff --git a/cmd/elydora/plugins/grok_test_helpers_test.go b/cmd/elydora/plugins/grok_test_helpers_test.go index b611d34..7b74cb8 100644 --- a/cmd/elydora/plugins/grok_test_helpers_test.go +++ b/cmd/elydora/plugins/grok_test_helpers_test.go @@ -45,6 +45,7 @@ func prepareGrokFixture(t *testing.T, options grokFixtureOptions) *grokFixture { t.Helper() root := t.TempDir() homeDir := filepath.Join(root, "home with 'quote %ELYDORA_HOOK_PATH%") + preparePrivateTransactionTestDirectory(t, homeDir) projectDir := filepath.Join(root, "project with spaces") grokHome := filepath.Join(homeDir, "custom grok") if options.useDefaultHome { diff --git a/cmd/elydora/plugins/kimi_test_helpers_test.go b/cmd/elydora/plugins/kimi_test_helpers_test.go index 07712bc..578c1aa 100644 --- a/cmd/elydora/plugins/kimi_test_helpers_test.go +++ b/cmd/elydora/plugins/kimi_test_helpers_test.go @@ -52,6 +52,7 @@ func prepareKimiFixture(t *testing.T, options kimiFixtureOptions) *kimiFixture { t.Helper() root := t.TempDir() homeDir := filepath.Join(root, "home with spaces and 'quote %ELYDORA_HOOK_PATH%") + preparePrivateTransactionTestDirectory(t, homeDir) projectDir := filepath.Join(root, "project with spaces") kimiHome := filepath.Join(homeDir, "custom kimi-code") if options.useDefaultHome { diff --git a/cmd/elydora/plugins/kirocli_test.go b/cmd/elydora/plugins/kirocli_test.go index 7b6fc25..39f027d 100644 --- a/cmd/elydora/plugins/kirocli_test.go +++ b/cmd/elydora/plugins/kirocli_test.go @@ -29,6 +29,7 @@ type kiroFixture struct { func prepareKiroFixture(t *testing.T, v2Raw, v3Raw string) *kiroFixture { t.Helper() homeDir := filepath.Join(t.TempDir(), "home with spaces") + preparePrivateTransactionTestDirectory(t, homeDir) agentDir := filepath.Join(homeDir, ".elydora", kiroTestAgentID) guardPath := filepath.Join(agentDir, "guard.js") v2Path := filepath.Join(homeDir, ".kiro", "agents", "elydora-audit.json") diff --git a/cmd/elydora/plugins/kiroide.go b/cmd/elydora/plugins/kiroide.go index 4025e31..d5c0cae 100644 --- a/cmd/elydora/plugins/kiroide.go +++ b/cmd/elydora/plugins/kiroide.go @@ -1,143 +1,128 @@ package plugins -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" -) +import "fmt" -// KiroIdePlugin manages the Elydora audit hook for Kiro IDE. -// It writes a .kiro/hooks/elydora-audit.kiro.hook JSON file in the home directory. -type KiroIdePlugin struct{} +// KiroIdePlugin manages Kiro IDE 1.0 workspace hooks. +type KiroIdePlugin struct { + rename renameFunc +} -func (p *KiroIdePlugin) configPath() (string, error) { - home, err := os.UserHomeDir() +// ManagesGuardRuntime reports that Kiro IDE commits both generated runtimes, +// runtime metadata, credentials, workspace hooks, and legacy migration in one +// transaction. +func (p *KiroIdePlugin) ManagesGuardRuntime() bool { + return true +} + +// PreflightInstall validates every source and runtime identity before writes. +func (p *KiroIdePlugin) PreflightInstall(config InstallConfig) error { + sources, err := readKiroIdeSources() if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) + return err } - return filepath.Join(home, ".kiro", "hooks", "elydora-audit.kiro.hook"), nil + if err := requireAvailableKiroIdeHooks(sources.document.hooks); err != nil { + return err + } + _, _, err = preflightKiroIdeInstallation(config) + return err } func (p *KiroIdePlugin) Install(config InstallConfig) error { - scriptPath, err := hookScriptPath(config.AgentID) + sources, err := readKiroIdeSources() if err != nil { return err } - if config.HookScript != "" { - scriptPath = config.HookScript + if err := requireAvailableKiroIdeHooks(sources.document.hooks); err != nil { + return err } - - if err := GenerateHookScript(scriptPath, config); err != nil { - return fmt.Errorf("generate hook script: %w", err) - } - - guardPath := config.GuardScriptPath - if guardPath == "" { - guardPath, err = guardScriptPath(config.AgentID) - if err != nil { - return err - } - } - - hookFile, err := p.configPath() + paths, nodePath, err := preflightKiroIdeInstallation(config) if err != nil { return err } - dir := filepath.Dir(hookFile) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("create directory %s: %w", dir, err) + hooks := withoutManagedKiroIdeHooks(sources.document.hooks, "") + guard, err := buildKiroIdeHook(kiroIdeGuardName, nodePath, paths.guardPath) + if err != nil { + return err } - - content := buildKiroIdeHookFile(scriptPath, guardPath) - if err := os.WriteFile(hookFile, []byte(content), 0644); err != nil { - return fmt.Errorf("write %s: %w", hookFile, err) + audit, err := buildKiroIdeHook(kiroIdeAuditName, nodePath, paths.auditPath) + if err != nil { + return err } - - fmt.Printf("Installed Elydora hook for Kiro IDE at %s\n", hookFile) + rendered, err := renderKiroIdeDocument( + sources.document, + append(hooks, guard, audit), + ) + if err != nil { + return err + } + prepared, err := prepareKiroIdeInstallation(config, sources, paths, rendered) + if err != nil { + return err + } + if err := commitKiroIdeInstallation(prepared, p.rename); err != nil { + return err + } + fmt.Printf("Kiro IDE workspace hooks: %s\n", sources.paths.configPath) + fmt.Println("Kiro IDE verification: confirm both Elydora entries in the Agent Hooks panel.") return nil } -func buildKiroIdeHookFile(scriptPath, guardPath string) string { - hookConfig := map[string]interface{}{ - "name": "Elydora Audit", - "hooks": map[string]interface{}{ - "pre_tool_use": map[string]interface{}{ - "command": "node " + guardPath, - "timeout_ms": 5000, - }, - "post_tool_use": map[string]interface{}{ - "command": "node " + scriptPath, - "timeout_ms": 5000, - }, - }, - } - encoded, _ := json.MarshalIndent(hookConfig, "", " ") - return string(encoded) + "\n" -} - func (p *KiroIdePlugin) Uninstall(agentID string) error { - hookFile, err := p.configPath() + sources, err := readKiroIdeSources() if err != nil { return err } - if err := os.Remove(hookFile); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove %s: %w", hookFile, err) + rendered, err := renderKiroIdeDocument( + sources.document, + withoutManagedKiroIdeHooks(sources.document.hooks, agentID), + ) + if err != nil { + return err } - - if agentID != "" { - scriptPath, _ := hookScriptPath(agentID) - if scriptPath != "" { - os.Remove(scriptPath) - } - gPath, _ := guardScriptPath(agentID) - if gPath != "" { - os.Remove(gPath) - } + prepared, err := prepareKiroIdeUninstall(sources, rendered, agentID) + if err != nil { + return err } - - fmt.Println("Uninstalled Elydora hook for Kiro IDE.") + if err := commitKiroIdeUninstall(prepared, p.rename); err != nil { + return err + } + fmt.Println("Uninstalled Elydora hooks from Kiro IDE.") return nil } func (p *KiroIdePlugin) Status() (PluginStatus, error) { - hookFile, err := p.configPath() - if err != nil { - return PluginStatus{}, err - } - + sources, err := readKiroIdeSources() + entry := SupportedAgents[kiroIdeAgentKey] status := PluginStatus{ - AgentName: "kiroide", - DisplayName: "Kiro IDE", - ConfigPath: hookFile, + AgentName: kiroIdeAgentKey, + DisplayName: entry.Name, + ConfigPath: sourcesPathOrEmpty(sources), } - - // Check if hook file exists and contains both pre_tool_use and post_tool_use - data, err := os.ReadFile(hookFile) - if err == nil { - var config map[string]interface{} - if json.Unmarshal(data, &config) == nil { - hooks, _ := config["hooks"].(map[string]interface{}) - if hooks != nil { - _, hasPre := hooks["pre_tool_use"] - _, hasPost := hooks["post_tool_use"] - status.HookConfigured = hasPre && hasPost - - // Extract hook script path from the post_tool_use command - if postHook, ok := hooks["post_tool_use"].(map[string]interface{}); ok { - if cmd, _ := postHook["command"].(string); cmd != "" { - scriptPath := extractPathFromNodeCommand(cmd) - if scriptPath != "" { - if _, err := os.Stat(scriptPath); err == nil { - status.HookScriptExists = true - } - } - } - } - } - } + if err != nil { + return status, err } - - status.Installed = status.HookConfigured && status.HookScriptExists + if err := requireAvailableKiroIdeHooks(sources.document.hooks); err != nil { + return status, err + } + contracts, err := kiroIdeRuntimeContracts(sources.document.hooks) + if err != nil { + return status, err + } + status.HookConfigured = len(contracts) > 0 + if !status.HookConfigured { + return status, nil + } + status.HookScriptExists, err = kiroIdeRuntimeFilesExist(contracts) + if err != nil { + return status, err + } + status.Installed = status.HookScriptExists return status, nil } + +func sourcesPathOrEmpty(sources *kiroIdeSources) string { + if sources == nil { + return "" + } + return sources.paths.configPath +} diff --git a/cmd/elydora/plugins/kiroide_command.go b/cmd/elydora/plugins/kiroide_command.go new file mode 100644 index 0000000..b19dc64 --- /dev/null +++ b/cmd/elydora/plugins/kiroide_command.go @@ -0,0 +1,134 @@ +package plugins + +import ( + "fmt" + "path/filepath" + "runtime" + "strings" +) + +type kiroIdeRuntimeReference struct { + agentID string + scriptPath string +} + +func sameKiroIdePath(left, right string) bool { + left = filepath.Clean(left) + right = filepath.Clean(right) + if absolute, err := filepath.Abs(left); err == nil { + left = absolute + } + if absolute, err := filepath.Abs(right); err == nil { + right = absolute + } + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func sameKiroIdeAgentID(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func sameKiroIdeFileName(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func buildKiroIdeCommand(runtimePath, scriptPath string) (string, error) { + if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) { + return "", fmt.Errorf( + "Kiro IDE hook commands require absolute runtime and script paths", + ) + } + if runtime.GOOS == "windows" { + return codexWindowsCommand(runtimePath, scriptPath), nil + } + return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil +} + +func parseKiroIdeCommand(command string) (string, string, bool) { + if runtime.GOOS == "windows" { + return parseCodexWindowsCommand(command) + } + return parseCodexPOSIXCommand(command) +} + +func kiroIdeRuntimeReferenceForCommand( + command string, + scriptName string, +) (*kiroIdeRuntimeReference, error) { + runtimePath, scriptPath, ok := parseKiroIdeCommand(command) + if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) || + !isCodexNodeExecutable(runtimePath) || + !sameKiroIdeFileName(filepath.Base(scriptPath), scriptName) { + return nil, nil + } + agentDirectory := filepath.Dir(scriptPath) + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return nil, err + } + if !sameKiroIdePath(filepath.Dir(agentDirectory), runtimeRoot) { + return nil, nil + } + agentID := filepath.Base(agentDirectory) + if agentID == "" || agentID == "." || agentID == ".." { + return nil, nil + } + return &kiroIdeRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil +} + +func currentKiroIdeReference( + hook map[string]any, + nodePath string, +) (*kiroIdeRuntimeReference, error) { + reference, err := managedKiroIdeReference(hook) + if err != nil || reference == nil { + return reference, err + } + action := hook["action"].(map[string]any) + command := action["command"].(string) + expected, err := buildKiroIdeCommand(nodePath, reference.scriptPath) + if err != nil { + return nil, err + } + if command != expected { + return nil, nil + } + return reference, nil +} + +func legacyKiroIdeGoReference( + command any, + scriptName string, +) (*kiroIdeRuntimeReference, error) { + text, ok := command.(string) + if !ok || !strings.HasPrefix(text, "node ") { + return nil, nil + } + scriptPath := strings.TrimPrefix(text, "node ") + if !filepath.IsAbs(scriptPath) || + !sameKiroIdeFileName(filepath.Base(scriptPath), scriptName) { + return nil, nil + } + agentDirectory := filepath.Dir(scriptPath) + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return nil, err + } + if !sameKiroIdePath(filepath.Dir(agentDirectory), runtimeRoot) { + return nil, nil + } + agentID := filepath.Base(agentDirectory) + if agentID == "" || agentID == "." || agentID == ".." { + return nil, nil + } + return &kiroIdeRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil +} diff --git a/cmd/elydora/plugins/kiroide_contract.go b/cmd/elydora/plugins/kiroide_contract.go new file mode 100644 index 0000000..c0b00a2 --- /dev/null +++ b/cmd/elydora/plugins/kiroide_contract.go @@ -0,0 +1,460 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "reflect" +) + +const ( + kiroIdeAgentKey = "kiroide" + kiroIdeConfigFile = "elydora-audit.json" + kiroIdeLegacyFile = "elydora-audit.kiro.hook" + kiroIdeGuardScript = "guard.js" + kiroIdeAuditScript = "hook.js" + kiroIdeGuardName = "elydora-guard" + kiroIdeAuditName = "elydora-audit" + kiroIdeHookTimeout = float64(10) + kiroIdeGuardDescription = "Block tool use when the Elydora agent is frozen" + kiroIdeAuditDescription = "Record tool use in the Elydora audit trail" +) + +var kiroIdeTriggers = stringSet( + "SessionStart", + "Stop", + "PreToolUse", + "PostToolUse", + "PreTaskExec", + "PostTaskExec", + "UserPromptSubmit", + "PostFileCreate", + "PostFileSave", + "PostFileDelete", +) + +type kiroIdeDocument struct { + exists bool + filePath string + root map[string]any + hooks []map[string]any + snapshot *managedFileSnapshot +} + +type kiroIdeLegacyDocument struct { + filePath string + snapshot *managedFileSnapshot + contract *kiroIdeRuntimeContract +} + +type kiroIdeRenderedDocument struct { + document *kiroIdeDocument + changed bool + next []byte + remove bool +} + +type kiroIdeRuntimeContract struct { + agentID string + guardPath string + auditPath string +} + +type kiroIdeHookSpecification struct { + description string + trigger string + scriptName string +} + +func kiroIdeSpecification(name any) (kiroIdeHookSpecification, bool) { + switch name { + case kiroIdeGuardName: + return kiroIdeHookSpecification{ + kiroIdeGuardDescription, "PreToolUse", kiroIdeGuardScript, + }, true + case kiroIdeAuditName: + return kiroIdeHookSpecification{ + kiroIdeAuditDescription, "PostToolUse", kiroIdeAuditScript, + }, true + default: + return kiroIdeHookSpecification{}, false + } +} + +func cloneKiroIdeObject(value map[string]any) map[string]any { + clone := make(map[string]any, len(value)) + for key, item := range value { + clone[key] = item + } + return clone +} + +func validateKiroIdeAction(value any, label string) error { + action, ok := value.(map[string]any) + if !ok || action == nil { + return fmt.Errorf("%s action must be an object", label) + } + switch action["type"] { + case "command": + command, ok := action["command"].(string) + if !ok || command == "" { + return fmt.Errorf("%s command action requires a non-empty command", label) + } + case "agent": + prompt, ok := action["prompt"].(string) + if !ok || prompt == "" { + return fmt.Errorf("%s agent action requires a non-empty prompt", label) + } + default: + return fmt.Errorf( + `%s action has unsupported type %q`, + label, + fmt.Sprint(action["type"]), + ) + } + return nil +} + +func validateKiroIdeHook(value any, index int, label string) (map[string]any, error) { + hook, ok := value.(map[string]any) + if !ok || hook == nil { + return nil, fmt.Errorf("%s hooks[%d] must be an object", label, index) + } + hookLabel := fmt.Sprintf("%s hooks[%d]", label, index) + name, ok := hook["name"].(string) + if !ok || name == "" { + return nil, fmt.Errorf("%s requires a name", hookLabel) + } + trigger, ok := hook["trigger"].(string) + if !ok { + return nil, fmt.Errorf("%s requires a trigger", hookLabel) + } + if _, supported := kiroIdeTriggers[trigger]; !supported { + return nil, fmt.Errorf("%s has unsupported trigger %q", hookLabel, trigger) + } + if value, exists := hook["description"]; exists { + if _, ok := value.(string); !ok { + return nil, fmt.Errorf("%s description must be a string", hookLabel) + } + } + if value, exists := hook["matcher"]; exists { + if _, ok := value.(string); !ok { + return nil, fmt.Errorf("%s matcher must be a string", hookLabel) + } + } + if value, exists := hook["timeout"]; exists { + timeout, ok := value.(float64) + if !ok || math.IsNaN(timeout) || math.IsInf(timeout, 0) || + timeout < 0 || timeout > 9007199254740991 || math.Trunc(timeout) != timeout { + return nil, fmt.Errorf("%s timeout must be a non-negative integer", hookLabel) + } + } + if value, exists := hook["enabled"]; exists { + if _, ok := value.(bool); !ok { + return nil, fmt.Errorf("%s enabled must be a boolean", hookLabel) + } + } + if err := validateKiroIdeAction(hook["action"], hookLabel); err != nil { + return nil, err + } + return hook, nil +} + +func parseKiroIdeDocument( + filePath string, + snapshot *managedFileSnapshot, +) (*kiroIdeDocument, error) { + if snapshot == nil { + return &kiroIdeDocument{ + filePath: filePath, + root: map[string]any{}, + hooks: []map[string]any{}, + }, nil + } + label := fmt.Sprintf("Kiro IDE hooks at %s", filePath) + root, err := decodeStrictJSONObject(snapshot.contents, label) + if err != nil { + return nil, err + } + if root["version"] != "v1" { + return nil, fmt.Errorf(`Kiro IDE hooks version must be "v1": %s`, filePath) + } + values, ok := root["hooks"].([]any) + if !ok { + return nil, fmt.Errorf(`Kiro IDE hooks field "hooks" must be an array: %s`, filePath) + } + hooks := make([]map[string]any, 0, len(values)) + for index, value := range values { + hook, err := validateKiroIdeHook(value, index, "Kiro IDE hooks") + if err != nil { + return nil, err + } + hooks = append(hooks, hook) + } + return &kiroIdeDocument{ + exists: true, filePath: filePath, root: root, hooks: hooks, + snapshot: snapshot, + }, nil +} + +func exactKiroIdeKeys(value map[string]any, keys ...string) bool { + if len(value) != len(keys) { + return false + } + for _, key := range keys { + if _, exists := value[key]; !exists { + return false + } + } + return true +} + +func ownedKiroIdeReference( + hook map[string]any, +) (*kiroIdeRuntimeReference, error) { + specification, managed := kiroIdeSpecification(hook["name"]) + if !managed { + return nil, nil + } + action, ok := hook["action"].(map[string]any) + if !ok || action["type"] != "command" { + return nil, nil + } + command, ok := action["command"].(string) + if !ok { + return nil, nil + } + return kiroIdeRuntimeReferenceForCommand(command, specification.scriptName) +} + +func managedKiroIdeReference( + hook map[string]any, +) (*kiroIdeRuntimeReference, error) { + specification, managed := kiroIdeSpecification(hook["name"]) + if !managed || + !exactKiroIdeKeys( + hook, + "name", "description", "trigger", "matcher", "action", "timeout", "enabled", + ) || + hook["description"] != specification.description || + hook["trigger"] != specification.trigger || hook["matcher"] != ".*" || + hook["timeout"] != kiroIdeHookTimeout || hook["enabled"] != true { + return nil, nil + } + action, ok := hook["action"].(map[string]any) + if !ok || !exactKiroIdeKeys(action, "type", "command") || + action["type"] != "command" { + return nil, nil + } + return ownedKiroIdeReference(hook) +} + +func requireAvailableKiroIdeHooks(hooks []map[string]any) error { + for _, hook := range hooks { + if _, managed := kiroIdeSpecification(hook["name"]); !managed { + continue + } + reference, err := ownedKiroIdeReference(hook) + if err != nil { + return err + } + if reference == nil { + return fmt.Errorf( + "Kiro IDE hook name %q conflicts with the Elydora contract", + hook["name"], + ) + } + } + return nil +} + +func buildKiroIdeHook( + name string, + runtimePath string, + scriptPath string, +) (map[string]any, error) { + specification, managed := kiroIdeSpecification(name) + if !managed { + return nil, fmt.Errorf("unsupported Elydora Kiro IDE hook name %q", name) + } + command, err := buildKiroIdeCommand(runtimePath, scriptPath) + if err != nil { + return nil, err + } + return map[string]any{ + "name": name, "description": specification.description, + "trigger": specification.trigger, "matcher": ".*", + "action": map[string]any{"type": "command", "command": command}, + "timeout": kiroIdeHookTimeout, "enabled": true, + }, nil +} + +func withoutManagedKiroIdeHooks( + hooks []map[string]any, + agentID string, +) []map[string]any { + filtered := make([]map[string]any, 0, len(hooks)) + for _, hook := range hooks { + reference, err := ownedKiroIdeReference(hook) + remove := err == nil && reference != nil && + (agentID == "" || sameKiroIdeAgentID(reference.agentID, agentID)) + if !remove { + filtered = append(filtered, hook) + } + } + return filtered +} + +func entirelyManagedKiroIdeDocument(document *kiroIdeDocument) bool { + if document == nil || !document.exists || + !exactKiroIdeKeys(document.root, "version", "hooks") || + len(document.hooks) == 0 { + return false + } + for _, hook := range document.hooks { + reference, err := managedKiroIdeReference(hook) + if err != nil || reference == nil { + return false + } + } + return true +} + +func renderKiroIdeDocument( + document *kiroIdeDocument, + hooks []map[string]any, +) (*kiroIdeRenderedDocument, error) { + if document == nil { + return nil, fmt.Errorf("Kiro IDE rendering requires a hook document") + } + if !document.exists && len(hooks) == 0 { + return &kiroIdeRenderedDocument{document: document}, nil + } + if reflect.DeepEqual(hooks, document.hooks) { + return &kiroIdeRenderedDocument{document: document}, nil + } + if len(hooks) == 0 && entirelyManagedKiroIdeDocument(document) { + return &kiroIdeRenderedDocument{ + document: document, changed: true, remove: true, + }, nil + } + root := cloneKiroIdeObject(document.root) + root["version"] = "v1" + values := make([]any, 0, len(hooks)) + for _, hook := range hooks { + values = append(values, hook) + } + root["hooks"] = values + next, err := json.MarshalIndent(root, "", " ") + if err != nil { + return nil, fmt.Errorf("encode Kiro IDE hooks: %w", err) + } + next = append(next, '\n') + validation := &managedFileSnapshot{contents: next} + if _, err := parseKiroIdeDocument(document.filePath, validation); err != nil { + return nil, fmt.Errorf("validate rendered Kiro IDE hooks: %w", err) + } + return &kiroIdeRenderedDocument{ + document: document, + changed: !document.exists || !bytes.Equal(next, document.snapshot.contents), + next: next, + }, nil +} + +func kiroIdeRuntimeContracts( + hooks []map[string]any, +) ([]kiroIdeRuntimeContract, error) { + managedHooks := make([]map[string]any, 0, 2) + for _, hook := range hooks { + _, managed := kiroIdeSpecification(hook["name"]) + if managed { + managedHooks = append(managedHooks, hook) + } + } + if len(managedHooks) != 2 { + return []kiroIdeRuntimeContract{}, nil + } + nodePath, err := resolveNodeRuntime() + if err != nil { + return nil, err + } + var guard, audit *kiroIdeRuntimeReference + for _, hook := range managedHooks { + reference, err := currentKiroIdeReference(hook, nodePath) + if err != nil { + return nil, err + } + if reference == nil { + return []kiroIdeRuntimeContract{}, nil + } + if hook["name"] == kiroIdeGuardName { + if guard != nil { + return []kiroIdeRuntimeContract{}, nil + } + guard = reference + } else { + if audit != nil { + return []kiroIdeRuntimeContract{}, nil + } + audit = reference + } + } + if guard == nil || audit == nil || + !sameKiroIdeAgentID(guard.agentID, audit.agentID) { + return []kiroIdeRuntimeContract{}, nil + } + return []kiroIdeRuntimeContract{{ + agentID: guard.agentID, guardPath: guard.scriptPath, + auditPath: audit.scriptPath, + }}, nil +} + +func parseKiroIdeLegacyDocument( + filePath string, + snapshot *managedFileSnapshot, +) (*kiroIdeLegacyDocument, error) { + document := &kiroIdeLegacyDocument{filePath: filePath, snapshot: snapshot} + if snapshot == nil { + return document, nil + } + label := fmt.Sprintf("legacy Kiro IDE hook at %s", filePath) + root, err := decodeStrictJSONObject(snapshot.contents, label) + if err != nil { + return nil, err + } + if !exactKiroIdeKeys(root, "name", "hooks") || root["name"] != "Elydora Audit" { + return document, nil + } + hooks, ok := root["hooks"].(map[string]any) + if !ok || !exactKiroIdeKeys(hooks, "pre_tool_use", "post_tool_use") { + return document, nil + } + guard, ok := hooks["pre_tool_use"].(map[string]any) + if !ok || !exactKiroIdeKeys(guard, "command", "timeout_ms") || + guard["timeout_ms"] != float64(5000) { + return document, nil + } + audit, ok := hooks["post_tool_use"].(map[string]any) + if !ok || !exactKiroIdeKeys(audit, "command", "timeout_ms") || + audit["timeout_ms"] != float64(5000) { + return document, nil + } + guardReference, err := legacyKiroIdeGoReference(guard["command"], kiroIdeGuardScript) + if err != nil { + return nil, err + } + auditReference, err := legacyKiroIdeGoReference(audit["command"], kiroIdeAuditScript) + if err != nil { + return nil, err + } + if guardReference == nil || auditReference == nil || + !sameKiroIdeAgentID(guardReference.agentID, auditReference.agentID) { + return document, nil + } + document.contract = &kiroIdeRuntimeContract{ + agentID: guardReference.agentID, guardPath: guardReference.scriptPath, + auditPath: auditReference.scriptPath, + } + return document, nil +} diff --git a/cmd/elydora/plugins/kiroide_installation.go b/cmd/elydora/plugins/kiroide_installation.go new file mode 100644 index 0000000..08fc075 --- /dev/null +++ b/cmd/elydora/plugins/kiroide_installation.go @@ -0,0 +1,450 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type kiroIdeRuntimePaths struct { + runtimeRoot string + agentDirectory string + configPath string + keyPath string + guardPath string + auditPath string + initialDirectories []kiroIdeDirectoryIdentity +} + +type kiroIdePreparedTransaction struct { + operation string + paths *kiroIdePaths + runtimePaths *kiroIdeRuntimePaths + changes []*fileChange + preconditions []filePrecondition + removeLegacy bool + initialDirectories []kiroIdeDirectoryIdentity +} + +func validateKiroIdeInstallConfig(config InstallConfig) error { + for _, field := range []struct{ name, value string }{ + {"agent name", config.AgentName}, + {"organization ID", config.OrgID}, + {"agent ID", config.AgentID}, + {"key ID", config.KID}, + {"private key", config.PrivateKey}, + {"base URL", config.BaseURL}, + {"guard script path", config.GuardScriptPath}, + } { + if field.value == "" { + return fmt.Errorf("%s is required", field.name) + } + } + if config.AgentName != kiroIdeAgentKey { + return fmt.Errorf( + "Kiro IDE installation requires agent name %s", + kiroIdeAgentKey, + ) + } + if strings.TrimSpace(config.OrgID) == "" { + return fmt.Errorf("organization ID is required") + } + if strings.TrimSpace(config.KID) == "" { + return fmt.Errorf("key ID is required") + } + if config.Token != "" && strings.TrimSpace(config.Token) == "" { + return fmt.Errorf("token must contain a non-whitespace value when provided") + } + if err := validateManagedPrivateKey(config.PrivateKey); err != nil { + return err + } + return validateManagedBaseURL(config.BaseURL) +} + +func kiroIdeAgentPaths(config InstallConfig) (*kiroIdeRuntimePaths, error) { + if err := validateKiroIdeInstallConfig(config); err != nil { + return nil, err + } + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return nil, err + } + agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) + if err != nil { + return nil, err + } + paths := &kiroIdeRuntimePaths{ + runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, + configPath: filepath.Join(agentDirectory, "config.json"), + keyPath: filepath.Join(agentDirectory, "private.key"), + guardPath: filepath.Join(agentDirectory, kiroIdeGuardScript), + auditPath: filepath.Join(agentDirectory, kiroIdeAuditScript), + } + if !filepath.IsAbs(config.GuardScriptPath) || + !sameKiroIdePath(config.GuardScriptPath, paths.guardPath) { + return nil, fmt.Errorf( + "guard runtime must use the managed Elydora agent directory: %s", + paths.guardPath, + ) + } + if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || + !sameKiroIdePath(config.HookScript, paths.auditPath)) { + return nil, fmt.Errorf( + "audit runtime must use the managed Elydora agent directory: %s", + paths.auditPath, + ) + } + return paths, nil +} + +func preflightKiroIdeInstallation( + config InstallConfig, +) (*kiroIdeRuntimePaths, string, error) { + paths, err := kiroIdeAgentPaths(config) + if err != nil { + return nil, "", err + } + initialDirectories := make([]kiroIdeDirectoryIdentity, 0, 3) + for _, item := range []struct{ path, label string }{ + {filepath.Dir(paths.runtimeRoot), "home directory"}, + {paths.runtimeRoot, "Elydora runtime directory"}, + {paths.agentDirectory, "Elydora agent runtime directory"}, + } { + identity, err := snapshotKiroIdeDirectory(item.path, item.label) + if err != nil { + return nil, "", err + } + initialDirectories = append(initialDirectories, identity) + } + if initialDirectories[0].info == nil { + return nil, "", fmt.Errorf( + "home directory is missing: %s", + initialDirectories[0].path, + ) + } + if err := validateKiroIdeRuntimeIdentity( + paths.agentDirectory, + config.AgentID, + ); err != nil { + return nil, "", err + } + if err := assertKiroIdeDirectoryStates( + initialDirectories, + "Kiro IDE runtime preflight", + ); err != nil { + return nil, "", err + } + paths.initialDirectories = initialDirectories + nodePath, err := resolveNodeRuntime() + if err != nil { + return nil, "", err + } + return paths, nodePath, nil +} + +func buildKiroIdeRuntimeConfig(config InstallConfig) ([]byte, error) { + value := map[string]any{ + "org_id": config.OrgID, "agent_id": config.AgentID, + "kid": config.KID, "base_url": config.BaseURL, + "agent_name": kiroIdeAgentKey, + } + if config.Token != "" { + value["token"] = config.Token + } + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, fmt.Errorf("encode Elydora runtime config: %w", err) + } + encoded = append(encoded, '\n') + if len(encoded) > maxRuntimeConfigBytes { + return nil, fmt.Errorf( + "Elydora runtime config exceeds %d bytes after JSON encoding", + maxRuntimeConfigBytes, + ) + } + return encoded, nil +} + +func prepareKiroIdeFile( + filePath string, + label string, + contents []byte, + mode os.FileMode, + maximumBytes int64, +) (*fileChange, *filePrecondition, error) { + snapshot, err := readManagedFile(filePath, label, maximumBytes) + if err != nil { + return nil, nil, err + } + change, err := prepareSnapshotSourceChange( + filePath, + label, + snapshot, + contents, + mode, + false, + ) + if err != nil { + return nil, nil, err + } + if change != nil { + return change, nil, nil + } + condition := &filePrecondition{ + filePath: filePath, label: label, snapshot: snapshot, + maximumSize: maximumBytes, + } + return nil, condition, nil +} + +func appendKiroIdeChange( + prepared *kiroIdePreparedTransaction, + change *fileChange, + condition *filePrecondition, +) { + if change != nil { + prepared.changes = append(prepared.changes, change) + } + if condition != nil { + prepared.preconditions = append(prepared.preconditions, *condition) + } +} + +func prepareKiroIdeInstallation( + config InstallConfig, + sources *kiroIdeSources, + paths *kiroIdeRuntimePaths, + rendered *kiroIdeRenderedDocument, +) (*kiroIdePreparedTransaction, error) { + if sources == nil || paths == nil || rendered == nil { + return nil, fmt.Errorf("Kiro IDE installation requires prepared sources") + } + runtimeConfig, err := buildKiroIdeRuntimeConfig(config) + if err != nil { + return nil, err + } + prepared := &kiroIdePreparedTransaction{ + operation: "installation", paths: sources.paths, runtimePaths: paths, + initialDirectories: append( + append([]kiroIdeDirectoryIdentity(nil), sources.initialDirectories...), + paths.initialDirectories..., + ), + } + for _, item := range []struct { + path, label string + contents []byte + mode os.FileMode + maximum int64 + }{ + { + paths.guardPath, "Elydora guard runtime", + []byte(generateGuardScript(kiroIdeAgentKey, config.AgentID, "", false, "")), + 0700, maxManagedSourceBytes, + }, + {paths.configPath, "Elydora runtime config", runtimeConfig, 0600, maxRuntimeConfigBytes}, + {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600, maxProtectedSecretBytes}, + { + paths.auditPath, "Elydora audit runtime", + []byte(buildHookScriptWithOutput( + kiroIdeAgentKey, config.AgentID, "", false, true, + )), + 0700, maxManagedSourceBytes, + }, + } { + change, condition, err := prepareKiroIdeFile( + item.path, item.label, item.contents, item.mode, item.maximum, + ) + if err != nil { + return nil, err + } + appendKiroIdeChange(prepared, change, condition) + } + documentChange, err := prepareRenderedKiroIdeChange(rendered) + if err != nil { + return nil, err + } + if documentChange == nil { + prepared.preconditions = append(prepared.preconditions, filePrecondition{ + filePath: sources.document.filePath, label: "Kiro IDE hooks", + snapshot: sources.document.snapshot, + }) + } else { + prepared.changes = append(prepared.changes, documentChange) + } + removeLegacy := sources.legacy.contract != nil && + sameKiroIdeAgentID(sources.legacy.contract.agentID, config.AgentID) + if removeLegacy { + legacyChange, err := prepareSnapshotSourceChange( + sources.legacy.filePath, + "legacy Kiro IDE hook", + sources.legacy.snapshot, + nil, + 0600, + true, + ) + if err != nil { + return nil, err + } + if legacyChange == nil { + return nil, fmt.Errorf( + "legacy Kiro IDE hook disappeared before migration: %s", + sources.legacy.filePath, + ) + } + prepared.changes = append(prepared.changes, legacyChange) + prepared.removeLegacy = true + } else { + prepared.preconditions = append(prepared.preconditions, filePrecondition{ + filePath: sources.legacy.filePath, label: "legacy Kiro IDE hook", + snapshot: sources.legacy.snapshot, + }) + } + if err := assertKiroIdeDirectoryStates( + prepared.initialDirectories, + "Kiro IDE installation preparation", + ); err != nil { + return nil, err + } + return prepared, nil +} + +func prepareKiroIdeUninstall( + sources *kiroIdeSources, + rendered *kiroIdeRenderedDocument, + agentID string, +) (*kiroIdePreparedTransaction, error) { + prepared := &kiroIdePreparedTransaction{ + operation: "uninstall", paths: sources.paths, + initialDirectories: append( + []kiroIdeDirectoryIdentity(nil), + sources.initialDirectories..., + ), + } + documentChange, err := prepareRenderedKiroIdeChange(rendered) + if err != nil { + return nil, err + } + if documentChange == nil { + prepared.preconditions = append(prepared.preconditions, filePrecondition{ + filePath: sources.document.filePath, label: "Kiro IDE hooks", + snapshot: sources.document.snapshot, + }) + } else { + prepared.changes = append(prepared.changes, documentChange) + } + removeLegacy := sources.legacy.contract != nil && + (agentID == "" || sameKiroIdeAgentID(sources.legacy.contract.agentID, agentID)) + if removeLegacy { + change, err := prepareSnapshotSourceChange( + sources.legacy.filePath, + "legacy Kiro IDE hook", + sources.legacy.snapshot, + nil, + 0600, + true, + ) + if err != nil { + return nil, err + } + if change != nil { + prepared.changes = append(prepared.changes, change) + prepared.removeLegacy = true + } + } else { + prepared.preconditions = append(prepared.preconditions, filePrecondition{ + filePath: sources.legacy.filePath, label: "legacy Kiro IDE hook", + snapshot: sources.legacy.snapshot, + }) + } + if err := assertKiroIdeDirectoryStates( + prepared.initialDirectories, + "Kiro IDE uninstall preparation", + ); err != nil { + return nil, err + } + return prepared, nil +} + +func commitKiroIdeTransaction( + prepared *kiroIdePreparedTransaction, + rename renameFunc, +) error { + if prepared == nil { + return fmt.Errorf("Kiro IDE transaction is missing") + } + operation := "Kiro IDE " + prepared.operation + if err := assertKiroIdeDirectoryStates( + prepared.initialDirectories, + operation, + ); err != nil { + return err + } + workspaceChange := false + for _, change := range prepared.changes { + if change != nil && sameKiroIdePath(change.filePath, prepared.paths.configPath) { + workspaceChange = true + break + } + } + directories := make([]kiroIdeDirectoryIdentity, 0, len(prepared.initialDirectories)) + for _, directory := range prepared.initialDirectories { + if directory.info != nil { + directories = appendKiroIdeDirectory(directories, directory) + } + } + establish := func(path string, private bool) error { + if err := assertKiroIdeDirectoryStates(directories, operation); err != nil { + return err + } + initial, err := kiroIdeInitialDirectory(prepared, path) + if err != nil { + return err + } + current, err := establishKiroIdeDirectory(initial, operation, private) + if err != nil { + return err + } + directories = appendKiroIdeDirectory(directories, current) + return assertKiroIdeDirectoryStates(directories, operation) + } + if workspaceChange { + if err := establish(prepared.paths.kiroDirectory, false); err != nil { + return err + } + if err := establish(prepared.paths.hooksDirectory, false); err != nil { + return err + } + } + if prepared.runtimePaths != nil { + if err := establish(prepared.runtimePaths.runtimeRoot, true); err != nil { + return err + } + if err := establish(prepared.runtimePaths.agentDirectory, true); err != nil { + return err + } + } + preconditions := append([]filePrecondition(nil), prepared.preconditions...) + preconditions = append(preconditions, kiroIdeDirectoryPreconditions(directories)...) + return writeChangesWithFileOps( + prepared.changes, + operation, + guardedKiroIdeTransactionOps(rename, directories), + preconditions..., + ) +} + +func commitKiroIdeInstallation( + prepared *kiroIdePreparedTransaction, + rename renameFunc, +) error { + return commitKiroIdeTransaction(prepared, rename) +} + +func commitKiroIdeUninstall( + prepared *kiroIdePreparedTransaction, + rename renameFunc, +) error { + return commitKiroIdeTransaction(prepared, rename) +} diff --git a/cmd/elydora/plugins/kiroide_io.go b/cmd/elydora/plugins/kiroide_io.go new file mode 100644 index 0000000..19b58c6 --- /dev/null +++ b/cmd/elydora/plugins/kiroide_io.go @@ -0,0 +1,192 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" +) + +type kiroIdePaths struct { + homeDirectory string + workspaceRoot string + kiroDirectory string + hooksDirectory string + configPath string + legacyPath string +} + +type kiroIdeSources struct { + paths *kiroIdePaths + document *kiroIdeDocument + legacy *kiroIdeLegacyDocument + initialDirectories []kiroIdeDirectoryIdentity +} + +func resolveKiroIdePaths() (*kiroIdePaths, error) { + workspace, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("resolve Kiro IDE workspace: %w", err) + } + workspace, err = filepath.Abs(workspace) + if err != nil { + return nil, fmt.Errorf("resolve Kiro IDE workspace path: %w", err) + } + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("resolve home directory: %w", err) + } + kiroDirectory := filepath.Join(workspace, ".kiro") + hooksDirectory := filepath.Join(kiroDirectory, "hooks") + return &kiroIdePaths{ + homeDirectory: home, workspaceRoot: workspace, + kiroDirectory: kiroDirectory, hooksDirectory: hooksDirectory, + configPath: filepath.Join(hooksDirectory, kiroIdeConfigFile), + legacyPath: filepath.Join(home, ".kiro", "hooks", kiroIdeLegacyFile), + }, nil +} + +func inspectKiroIdeWorkspace(paths *kiroIdePaths) error { + exists, err := managedPhysicalDirectoryExists( + paths.workspaceRoot, + "Kiro IDE workspace", + ) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("Kiro IDE workspace is missing: %s", paths.workspaceRoot) + } + if _, err := managedPhysicalDirectoryExists( + paths.kiroDirectory, + "Kiro IDE configuration directory", + ); err != nil { + return err + } + _, err = managedPhysicalDirectoryExists( + paths.hooksDirectory, + "Kiro IDE hooks directory", + ) + return err +} + +func inspectKiroIdeLegacyDirectories(paths *kiroIdePaths) error { + kiroDirectory := filepath.Dir(filepath.Dir(paths.legacyPath)) + hooksDirectory := filepath.Dir(paths.legacyPath) + for _, item := range []struct{ path, label string }{ + {kiroDirectory, "legacy Kiro IDE configuration directory"}, + {hooksDirectory, "legacy Kiro IDE hooks directory"}, + } { + exists, err := managedPhysicalDirectoryExists(item.path, item.label) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("%s is missing: %s", item.label, item.path) + } + } + return nil +} + +func readKiroIdeSources() (*kiroIdeSources, error) { + paths, err := resolveKiroIdePaths() + if err != nil { + return nil, err + } + initialDirectories := make([]kiroIdeDirectoryIdentity, 0, 5) + for _, item := range []struct{ path, label string }{ + {paths.workspaceRoot, "Kiro IDE workspace"}, + {paths.kiroDirectory, "Kiro IDE configuration directory"}, + {paths.hooksDirectory, "Kiro IDE hooks directory"}, + } { + identity, err := snapshotKiroIdeDirectory(item.path, item.label) + if err != nil { + return nil, err + } + initialDirectories = append(initialDirectories, identity) + } + if err := inspectKiroIdeWorkspace(paths); err != nil { + return nil, err + } + workspaceSnapshot, err := readManagedFile( + paths.configPath, + "Kiro IDE hooks", + maxManagedSourceBytes, + ) + if err != nil { + return nil, err + } + document, err := parseKiroIdeDocument(paths.configPath, workspaceSnapshot) + if err != nil { + return nil, err + } + legacySnapshot, err := readManagedFile( + paths.legacyPath, + "legacy Kiro IDE hook", + maxManagedSourceBytes, + ) + if err != nil { + return nil, err + } + if legacySnapshot != nil { + if err := inspectKiroIdeLegacyDirectories(paths); err != nil { + return nil, err + } + for _, item := range []struct{ path, label string }{ + { + filepath.Dir(filepath.Dir(paths.legacyPath)), + "legacy Kiro IDE configuration directory", + }, + {filepath.Dir(paths.legacyPath), "legacy Kiro IDE hooks directory"}, + } { + identity, err := snapshotKiroIdeDirectory(item.path, item.label) + if err != nil { + return nil, err + } + initialDirectories = append(initialDirectories, identity) + } + current, err := readManagedFile( + paths.legacyPath, + "legacy Kiro IDE hook", + maxManagedSourceBytes, + ) + if err != nil { + return nil, err + } + if !sameManagedSnapshot(current, legacySnapshot) { + return nil, fmt.Errorf( + "legacy Kiro IDE hook changed while reading: %s", + paths.legacyPath, + ) + } + } + legacy, err := parseKiroIdeLegacyDocument(paths.legacyPath, legacySnapshot) + if err != nil { + return nil, err + } + if err := assertKiroIdeDirectoryStates( + initialDirectories, + "Kiro IDE source read", + ); err != nil { + return nil, err + } + return &kiroIdeSources{ + paths: paths, document: document, legacy: legacy, + initialDirectories: initialDirectories, + }, nil +} + +func prepareRenderedKiroIdeChange( + rendered *kiroIdeRenderedDocument, +) (*fileChange, error) { + if rendered == nil || !rendered.changed { + return nil, nil + } + return prepareSnapshotSourceChange( + rendered.document.filePath, + "Kiro IDE hooks", + rendered.document.snapshot, + rendered.next, + 0600, + rendered.remove, + ) +} diff --git a/cmd/elydora/plugins/kiroide_status.go b/cmd/elydora/plugins/kiroide_status.go new file mode 100644 index 0000000..4a3defd --- /dev/null +++ b/cmd/elydora/plugins/kiroide_status.go @@ -0,0 +1,277 @@ +package plugins + +import ( + "bytes" + "fmt" + "path/filepath" + "runtime" + "sort" + "strings" +) + +func readKiroIdeRuntimeConfig(path string) (map[string]any, bool, error) { + snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) + if err != nil || snapshot == nil { + return nil, snapshot != nil, err + } + label := fmt.Sprintf("Elydora runtime config at %s", path) + config, err := decodeStrictJSONObject(snapshot.contents, label) + return config, true, err +} + +func requireKiroIdeRuntimeString( + config map[string]any, + field string, + configPath string, +) (string, error) { + value, ok := config[field].(string) + if !ok || strings.TrimSpace(value) == "" { + return "", fmt.Errorf( + "Elydora runtime config %s is invalid: %s", + field, + configPath, + ) + } + return value, nil +} + +func validateKiroIdeRuntimeConfig( + config map[string]any, + expectedAgentID string, + configPath string, +) error { + supported := stringSet( + "org_id", "agent_id", "kid", "base_url", "token", "agent_name", + ) + fields := make([]string, 0, len(config)) + for field := range config { + fields = append(fields, field) + } + sort.Strings(fields) + for _, field := range fields { + if _, ok := supported[field]; !ok { + return fmt.Errorf( + `Elydora runtime config has unsupported field %q: %s`, + field, + configPath, + ) + } + } + if _, err := requireKiroIdeRuntimeString(config, "org_id", configPath); err != nil { + return err + } + if _, err := requireKiroIdeRuntimeString(config, "kid", configPath); err != nil { + return err + } + agentID, err := requireKiroIdeRuntimeString(config, "agent_id", configPath) + if err != nil { + return err + } + if !sameKiroIdeAgentID(agentID, expectedAgentID) || + config["agent_name"] != kiroIdeAgentKey { + return fmt.Errorf( + "Elydora runtime identity does not match Kiro IDE hooks: %s", + configPath, + ) + } + if _, exists := config["token"]; exists { + if _, err := requireKiroIdeRuntimeString(config, "token", configPath); err != nil { + return err + } + } + baseURL, err := requireKiroIdeRuntimeString(config, "base_url", configPath) + if err != nil { + return err + } + if err := validateManagedBaseURL(baseURL); err != nil { + return fmt.Errorf( + "Elydora runtime config base URL is invalid at %s: %w", + configPath, + err, + ) + } + return nil +} + +func validateKiroIdeRuntimeIdentity(agentDirectory, agentID string) error { + runtimeRoot := filepath.Dir(agentDirectory) + rootExists, err := managedPhysicalDirectoryExists( + runtimeRoot, + "Elydora runtime directory", + ) + if err != nil || !rootExists { + return err + } + directoryExists, err := managedPhysicalDirectoryExists( + agentDirectory, + "Elydora agent runtime directory", + ) + if err != nil || !directoryExists { + return err + } + configPath := filepath.Join(agentDirectory, "config.json") + config, configExists, err := readKiroIdeRuntimeConfig(configPath) + if err != nil { + return err + } + artifactExists := false + for _, item := range []struct { + path string + label string + limit int64 + }{ + {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, + {filepath.Join(agentDirectory, kiroIdeGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, kiroIdeAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, + {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, + {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, + } { + exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) + if inspectErr != nil { + return inspectErr + } + artifactExists = artifactExists || exists + } + if !configExists { + if artifactExists { + return fmt.Errorf( + "Elydora runtime identity cannot be verified without config.json: %s", + agentDirectory, + ) + } + return nil + } + configuredID, ok := config["agent_id"].(string) + if !ok || config["agent_name"] != kiroIdeAgentKey || + !sameKiroIdeAgentID(configuredID, agentID) { + return fmt.Errorf( + "Elydora runtime config identity does not match Kiro IDE agent %s: %s", + agentID, + configPath, + ) + } + return nil +} + +func kiroIdeRuntimeFileMode(snapshot *managedFileSnapshot, expected uint32) bool { + if runtime.GOOS == "windows" { + return true + } + return uint32(snapshot.mode.Perm()) == expected +} + +func kiroIdeRuntimeContractExists(contract kiroIdeRuntimeContract) (bool, error) { + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return false, err + } + agentDirectory := filepath.Dir(contract.guardPath) + if !sameKiroIdePath(filepath.Dir(agentDirectory), runtimeRoot) || + !sameKiroIdeAgentID(filepath.Base(agentDirectory), contract.agentID) || + !sameKiroIdePath( + contract.guardPath, + filepath.Join(agentDirectory, kiroIdeGuardScript), + ) || + !sameKiroIdePath( + contract.auditPath, + filepath.Join(agentDirectory, kiroIdeAuditScript), + ) { + return false, nil + } + rootExists, err := managedPhysicalDirectoryExists( + runtimeRoot, + "Elydora runtime directory", + ) + if err != nil || !rootExists { + return false, err + } + directoryExists, err := managedPhysicalDirectoryExists( + agentDirectory, + "Elydora agent runtime directory", + ) + if err != nil || !directoryExists { + return false, err + } + configPath := filepath.Join(agentDirectory, "config.json") + keyPath := filepath.Join(agentDirectory, "private.key") + configSnapshot, err := readManagedFile( + configPath, + "Elydora runtime config", + maxRuntimeConfigBytes, + ) + if err != nil { + return false, err + } + key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes) + if err != nil { + return false, err + } + guard, err := readManagedFile( + contract.guardPath, + "Elydora guard runtime", + maxManagedSourceBytes, + ) + if err != nil { + return false, err + } + audit, err := readManagedFile( + contract.auditPath, + "Elydora audit runtime", + maxManagedSourceBytes, + ) + if err != nil { + return false, err + } + if configSnapshot == nil || key == nil || guard == nil || audit == nil { + return false, nil + } + config, err := decodeStrictJSONObject( + configSnapshot.contents, + fmt.Sprintf("Elydora runtime config at %s", configPath), + ) + if err != nil { + return false, err + } + if err := validateKiroIdeRuntimeConfig(config, contract.agentID, configPath); err != nil { + return false, err + } + if err := validateManagedPrivateKey(string(key.contents)); err != nil { + return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err) + } + if !kiroIdeRuntimeFileMode(configSnapshot, 0600) || + !kiroIdeRuntimeFileMode(key, 0600) || + !kiroIdeRuntimeFileMode(guard, 0700) || + !kiroIdeRuntimeFileMode(audit, 0700) { + return false, nil + } + expectedGuard := []byte(generateGuardScript( + kiroIdeAgentKey, + contract.agentID, + "", + false, + "", + )) + expectedAudit := []byte(buildHookScriptWithOutput( + kiroIdeAgentKey, + contract.agentID, + "", + false, + true, + )) + return bytes.Equal(guard.contents, expectedGuard) && + bytes.Equal(audit.contents, expectedAudit), nil +} + +func kiroIdeRuntimeFilesExist(contracts []kiroIdeRuntimeContract) (bool, error) { + for _, contract := range contracts { + exists, err := kiroIdeRuntimeContractExists(contract) + if err != nil { + return false, err + } + if exists { + return true, nil + } + } + return false, nil +} diff --git a/cmd/elydora/plugins/kiroide_test.go b/cmd/elydora/plugins/kiroide_test.go new file mode 100644 index 0000000..4b6fd99 --- /dev/null +++ b/cmd/elydora/plugins/kiroide_test.go @@ -0,0 +1,450 @@ +package plugins + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + "time" +) + +func TestKiroIdeRegistryFactoryAndRuntimeOwnership(t *testing.T) { + entry := SupportedAgents[kiroIdeAgentKey] + if entry.Name != "Kiro IDE" || entry.ConfigDir != ".kiro/hooks" || + entry.ConfigFile != kiroIdeConfigFile { + t.Fatalf("Kiro IDE registry entry = %#v", entry) + } + plugin, ok := NewPlugin(kiroIdeAgentKey).(*KiroIdePlugin) + if !ok || !plugin.ManagesGuardRuntime() { + t.Fatalf("Kiro IDE plugin = %#v", NewPlugin(kiroIdeAgentKey)) + } +} + +func TestKiroIdeInstallUsesWorkspaceV1ContractAndIsIdempotent(t *testing.T) { + userHook := map[string]any{ + "name": "workspace-context", "trigger": "SessionStart", + "action": map[string]any{"type": "agent", "prompt": "Read AGENTS.md"}, + "enabled": true, + } + existing := map[string]any{ + "version": "v1", "owner": "workspace", "hooks": []any{userHook}, + } + raw, _ := json.Marshal(existing) + fixture := prepareKiroIdeFixture(t, kiroIdeString(string(raw)), nil) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro IDE hooks: %v", err) + } + first, err := os.ReadFile(fixture.configPath) + if err != nil { + t.Fatalf("read Kiro IDE hooks: %v", err) + } + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repeat Kiro IDE install: %v", err) + } + second, err := os.ReadFile(fixture.configPath) + if err != nil || string(second) != string(first) { + t.Fatalf("idempotent Kiro IDE source changed: %v", err) + } + document := readKiroIdeObject(t, fixture.configPath) + if document["version"] != "v1" || document["owner"] != "workspace" { + t.Fatalf("Kiro IDE root changed: %#v", document) + } + if !reflect.DeepEqual(requireArray(t, document["hooks"])[0], userHook) { + t.Fatalf("user hook changed: %#v", requireArray(t, document["hooks"])[0]) + } + for _, item := range []struct{ name, trigger string }{ + {kiroIdeGuardName, "PreToolUse"}, + {kiroIdeAuditName, "PostToolUse"}, + } { + hook := findKiroIdeHook(t, document, item.name) + if hook["trigger"] != item.trigger || hook["matcher"] != ".*" || + hook["timeout"] != float64(10) || hook["enabled"] != true || + requireObject(t, hook["action"])["type"] != "command" { + t.Fatalf("%s hook = %#v", item.name, hook) + } + command := kiroIdeHookCommand(t, hook) + if runtime.GOOS == "windows" && !strings.Contains(command, " -EncodedCommand ") { + t.Fatalf("Windows Kiro IDE command is not encoded PowerShell: %q", command) + } + } + runtimeConfig := readKiroIdeObject(t, fixture.runtimeConfig) + if runtimeConfig["agent_name"] != kiroIdeAgentKey || + runtimeConfig["agent_id"] != kiroIdeTestAgentID || + runtimeConfig["org_id"] != "org-1" || runtimeConfig["kid"] != "kid-1" { + t.Fatalf("runtime config = %#v", runtimeConfig) + } + key, err := os.ReadFile(fixture.privateKey) + if err != nil || string(key) != kiroIdePrivateKey { + t.Fatalf("private key = %q, %v", key, err) + } + if _, err := os.Lstat(filepath.Join(fixture.homeDir, ".kiro", "hooks", kiroIdeConfigFile)); !os.IsNotExist(err) { + t.Fatalf("global Kiro IDE hook file exists: %v", err) + } + assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir) + assertNoKiroIdeTransactionArtifacts(t, fixture.workspace) +} + +func TestKiroIdeCommandsBlockAndPreserveNativePayload(t *testing.T) { + fixture := prepareKiroIdeFixture(t, nil, nil) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro IDE hooks: %v", err) + } + document := readKiroIdeObject(t, fixture.configPath) + cache := map[string]any{ + "status": "frozen", "cached_at": float64(time.Now().UnixMilli()), + } + writeKiroIdeJSON(t, filepath.Join(fixture.agentDir, "status-cache.json"), cache) + payload := map[string]any{ + "hook_event_name": "preToolUse", "cwd": fixture.workspace, + "session_id": "session-1", "tool_name": "execute_bash", + "tool_input": map[string]any{"command": "go test ./..."}, + "future_field": map[string]any{"retained": true}, + } + encoded, _ := json.Marshal(payload) + exitCode, stdout, stderr := runKiroIdeCommand( + t, + kiroIdeHookCommand(t, findKiroIdeHook(t, document, kiroIdeGuardName)), + fixture, + encoded, + ) + if exitCode != 2 || stdout != "" || !strings.Contains(stderr, "Tool execution blocked") { + t.Fatalf("guard result = %d, %q, %q", exitCode, stdout, stderr) + } + + capturePath := filepath.Join(fixture.workspace, "captured-event.json") + captureJSON, _ := json.Marshal(capturePath) + captureScript := "const fs=require('node:fs');const chunks=[];" + + "process.stdin.on('data',c=>chunks.push(c));" + + "process.stdin.on('end',()=>fs.writeFileSync(" + string(captureJSON) + ",Buffer.concat(chunks)));\n" + if err := os.WriteFile(fixture.auditPath, []byte(captureScript), 0700); err != nil { + t.Fatalf("replace audit runtime with capture: %v", err) + } + payload["hook_event_name"] = "postToolUse" + payload["tool_response"] = map[string]any{"success": true, "result": "ok"} + encoded, _ = json.Marshal(payload) + exitCode, stdout, stderr = runKiroIdeCommand( + t, + kiroIdeHookCommand(t, findKiroIdeHook(t, document, kiroIdeAuditName)), + fixture, + encoded, + ) + if exitCode != 0 || stdout != "" || stderr != "" { + t.Fatalf("audit result = %d, %q, %q", exitCode, stdout, stderr) + } + captured, err := os.ReadFile(capturePath) + if err != nil || string(captured) != string(encoded) { + t.Fatalf("captured Kiro IDE payload = %q, %v", captured, err) + } +} + +func TestKiroIdeStatusRequiresUniqueExactPairAndRuntimeIntegrity(t *testing.T) { + fixture := prepareKiroIdeFixture(t, nil, nil) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro IDE hooks: %v", err) + } + assertStatus := func(installed, configured bool) { + t.Helper() + status, err := fixture.plugin.Status() + if err != nil || status.Installed != installed || + status.HookConfigured != configured { + t.Fatalf("Kiro IDE status = %#v, %v", status, err) + } + } + assertStatus(true, true) + document := readKiroIdeObject(t, fixture.configPath) + findKiroIdeHook(t, document, kiroIdeGuardName)["enabled"] = false + writeKiroIdeJSON(t, fixture.configPath, document) + assertStatus(false, false) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repair Kiro IDE hooks: %v", err) + } + document = readKiroIdeObject(t, fixture.configPath) + document["hooks"] = append( + requireArray(t, document["hooks"]), + cloneKiroIdeObject(findKiroIdeHook(t, document, kiroIdeGuardName)), + ) + writeKiroIdeJSON(t, fixture.configPath, document) + assertStatus(false, false) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repair duplicate Kiro IDE hooks: %v", err) + } + if err := os.WriteFile(fixture.guardPath, []byte("tampered\n"), 0700); err != nil { + t.Fatalf("tamper guard runtime: %v", err) + } + assertStatus(false, true) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repair Kiro IDE runtime: %v", err) + } + validRuntimeConfig, err := os.ReadFile(fixture.runtimeConfig) + if err != nil { + t.Fatalf("read valid runtime config: %v", err) + } + if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil { + t.Fatalf("corrupt runtime config: %v", err) + } + if _, err := fixture.plugin.Status(); err == nil || + !strings.Contains(err.Error(), "parse Elydora runtime config") { + t.Fatalf("malformed runtime status error = %v", err) + } + if err := os.WriteFile(fixture.runtimeConfig, validRuntimeConfig, 0600); err != nil { + t.Fatalf("restore runtime config: %v", err) + } + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repair runtime config: %v", err) + } + if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil { + t.Fatalf("corrupt private key: %v", err) + } + if _, err := fixture.plugin.Status(); err == nil || + !strings.Contains(err.Error(), "canonical 32-byte") { + t.Fatalf("invalid private key status error = %v", err) + } +} + +func TestKiroIdeStatusRejectsExtraOrphanAndStaleRuntimeCommands(t *testing.T) { + for _, testCase := range []struct { + name string + mutate func(*testing.T, *kiroIdeFixture, map[string]any) + }{ + { + name: "extra complete pair", + mutate: func(t *testing.T, fixture *kiroIdeFixture, document map[string]any) { + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js: %v", err) + } + agentDirectory := filepath.Join(fixture.homeDir, ".elydora", "agent-2") + guard, _ := buildKiroIdeHook( + kiroIdeGuardName, + nodePath, + filepath.Join(agentDirectory, kiroIdeGuardScript), + ) + audit, _ := buildKiroIdeHook( + kiroIdeAuditName, + nodePath, + filepath.Join(agentDirectory, kiroIdeAuditScript), + ) + document["hooks"] = append(requireArray(t, document["hooks"]), guard, audit) + }, + }, + { + name: "orphan guard", + mutate: func(t *testing.T, fixture *kiroIdeFixture, document map[string]any) { + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js: %v", err) + } + guard, _ := buildKiroIdeHook( + kiroIdeGuardName, + nodePath, + filepath.Join( + fixture.homeDir, + ".elydora", + "agent-2", + kiroIdeGuardScript, + ), + ) + document["hooks"] = append(requireArray(t, document["hooks"]), guard) + }, + }, + { + name: "split agent pair", + mutate: func(t *testing.T, fixture *kiroIdeFixture, document map[string]any) { + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js: %v", err) + } + audit, _ := buildKiroIdeHook( + kiroIdeAuditName, + nodePath, + filepath.Join( + fixture.homeDir, + ".elydora", + "agent-2", + kiroIdeAuditScript, + ), + ) + hooks := requireArray(t, document["hooks"]) + for index, value := range hooks { + if requireObject(t, value)["name"] == kiroIdeAuditName { + hooks[index] = audit + } + } + }, + }, + { + name: "stale Node runtime", + mutate: func(t *testing.T, fixture *kiroIdeFixture, document map[string]any) { + name := "node" + if runtime.GOOS == "windows" { + name = "node.exe" + } + staleNode := filepath.Join(fixture.homeDir, "old-node", name) + command, err := buildKiroIdeCommand(staleNode, fixture.guardPath) + if err != nil { + t.Fatalf("build stale Kiro IDE command: %v", err) + } + requireObject( + t, + findKiroIdeHook(t, document, kiroIdeGuardName)["action"], + )["command"] = command + }, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareKiroIdeFixture(t, nil, nil) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro IDE hooks: %v", err) + } + document := readKiroIdeObject(t, fixture.configPath) + testCase.mutate(t, fixture, document) + writeKiroIdeJSON(t, fixture.configPath, document) + status, err := fixture.plugin.Status() + if err != nil || status.Installed || status.HookConfigured { + t.Fatalf("mutated Kiro IDE status = %#v, %v", status, err) + } + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repair Kiro IDE command contract: %v", err) + } + status, err = fixture.plugin.Status() + if err != nil || !status.Installed { + t.Fatalf("repaired Kiro IDE status = %#v, %v", status, err) + } + }) + } +} + +func TestKiroIdeStatusRejectsStaleWindowsPowerShellLauncher(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows PowerShell launcher contract") + } + fixture := prepareKiroIdeFixture(t, nil, nil) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro IDE hooks: %v", err) + } + document := readKiroIdeObject(t, fixture.configPath) + guard := findKiroIdeHook(t, document, kiroIdeGuardName) + action := requireObject(t, guard["action"]) + command := action["command"].(string) + action["command"] = strings.Replace( + command, + codexPowerShellPath(), + `C:\StaleWindows\System32\WindowsPowerShell\v1.0\powershell.exe`, + 1, + ) + writeKiroIdeJSON(t, fixture.configPath, document) + status, err := fixture.plugin.Status() + if err != nil || status.Installed || status.HookConfigured { + t.Fatalf("stale PowerShell status = %#v, %v", status, err) + } + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repair PowerShell launcher: %v", err) + } + status, err = fixture.plugin.Status() + if err != nil || !status.Installed { + t.Fatalf("repaired PowerShell status = %#v, %v", status, err) + } +} + +func TestKiroIdeRejectsMalformedContractsAndNameCollisionsBeforeWrites(t *testing.T) { + for _, testCase := range []struct{ name, source, want string }{ + {"malformed", "{ malformed", "parse Kiro IDE hooks"}, + {"duplicate", `{"version":"v1","version":"v1","hooks":[]}`, "duplicate"}, + {"version", `{"version":"v0","hooks":[]}`, `version must be "v1"`}, + {"hooks", `{"version":"v1","hooks":null}`, `field "hooks" must be an array`}, + {"trigger", `{"version":"v1","hooks":[{"name":"bad","trigger":"FutureEvent","action":{"type":"command","command":"x"}}]}`, "unsupported trigger"}, + {"action", `{"version":"v1","hooks":[{"name":"bad","trigger":"PreToolUse","action":{"type":"future"}}]}`, "unsupported type"}, + {"timeout", `{"version":"v1","hooks":[{"name":"bad","trigger":"PreToolUse","timeout":1.5,"action":{"type":"command","command":"x"}}]}`, "non-negative integer"}, + {"collision", `{"version":"v1","hooks":[{"name":"elydora-guard","trigger":"PreToolUse","action":{"type":"command","command":"user-command"}}]}`, "conflicts with the Elydora contract"}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareKiroIdeFixture(t, &testCase.source, nil) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("install error = %v, want %q", err, testCase.want) + } + actual, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(actual) != testCase.source { + t.Fatalf("invalid source changed: %q, %v", actual, readErr) + } + assertNoKiroIdeRuntimeFiles(t, fixture) + }) + } +} + +func TestKiroIdeMigratesOnlyExactLegacyOwnership(t *testing.T) { + fixture := prepareKiroIdeFixture(t, nil, nil) + writeKiroIdeJSON( + t, + fixture.legacyPath, + legacyKiroIdeGoDocument(fixture, kiroIdeTestAgentID), + ) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("migrate legacy Kiro IDE hook: %v", err) + } + if _, err := os.Lstat(fixture.legacyPath); !os.IsNotExist(err) { + t.Fatalf("legacy Kiro IDE hook remains: %v", err) + } + + lookalike := legacyKiroIdeGoDocument(fixture, kiroIdeTestAgentID) + lookalike["owner"] = "user" + writeKiroIdeJSON(t, fixture.legacyPath, lookalike) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install with legacy lookalike: %v", err) + } + remainingLookalike := readKiroIdeObject(t, fixture.legacyPath) + if remainingLookalike["owner"] != "user" || remainingLookalike["name"] != "Elydora Audit" { + t.Fatalf("legacy ownership lookalike changed: %#v", remainingLookalike) + } + + if err := os.WriteFile(fixture.legacyPath, []byte("{ malformed"), 0600); err != nil { + t.Fatalf("write malformed legacy hook: %v", err) + } + if err := fixture.plugin.Install(fixture.config); err == nil || + !strings.Contains(err.Error(), "parse legacy Kiro IDE hook") { + t.Fatalf("malformed legacy error = %v", err) + } +} + +func TestKiroIdeUninstallPreservesWorkspaceHooksAndExactAgentOwnership(t *testing.T) { + userHook := map[string]any{ + "name": "workspace-context", "trigger": "SessionStart", + "action": map[string]any{"type": "agent", "prompt": "Read AGENTS.md"}, + } + existing := map[string]any{ + "version": "v1", "owner": "workspace", "hooks": []any{userHook}, + } + raw, _ := json.Marshal(existing) + fixture := prepareKiroIdeFixture(t, kiroIdeString(string(raw)), nil) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro IDE hooks: %v", err) + } + if err := fixture.plugin.Uninstall("agent-2"); err != nil { + t.Fatalf("uninstall unrelated Kiro IDE agent: %v", err) + } + if len(requireArray(t, readKiroIdeObject(t, fixture.configPath)["hooks"])) != 3 { + t.Fatal("unrelated uninstall changed Kiro IDE hooks") + } + if err := fixture.plugin.Uninstall(kiroIdeTestAgentID); err != nil { + t.Fatalf("uninstall Kiro IDE hooks: %v", err) + } + remaining := readKiroIdeObject(t, fixture.configPath) + if remaining["owner"] != "workspace" || + !reflect.DeepEqual(requireArray(t, remaining["hooks"]), []any{userHook}) { + t.Fatalf("workspace hooks after uninstall = %#v", remaining) + } + + owned := prepareKiroIdeFixture(t, nil, nil) + if err := owned.plugin.Install(owned.config); err != nil { + t.Fatalf("install owned Kiro IDE hooks: %v", err) + } + if err := owned.plugin.Uninstall(kiroIdeTestAgentID); err != nil { + t.Fatalf("uninstall owned Kiro IDE hooks: %v", err) + } + if _, err := os.Lstat(owned.configPath); !os.IsNotExist(err) { + t.Fatalf("owned Kiro IDE config remains: %v", err) + } +} diff --git a/cmd/elydora/plugins/kiroide_test_helpers_test.go b/cmd/elydora/plugins/kiroide_test_helpers_test.go new file mode 100644 index 0000000..32da57d --- /dev/null +++ b/cmd/elydora/plugins/kiroide_test_helpers_test.go @@ -0,0 +1,271 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const ( + kiroIdeTestAgentID = "agent-1" + kiroIdePrivateKey = "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws" +) + +type kiroIdeFixture struct { + plugin *KiroIdePlugin + config InstallConfig + homeDir string + workspace string + configPath string + legacyPath string + agentDir string + guardPath string + auditPath string + runtimeConfig string + privateKey string +} + +func prepareKiroIdeFixture(t *testing.T, workspaceSource, legacySource *string) *kiroIdeFixture { + t.Helper() + root := t.TempDir() + home := filepath.Join(root, "home with spaces and 'quote %KIRO%") + preparePrivateTransactionTestDirectory(t, home) + workspace := filepath.Join(root, "workspace with spaces") + for _, directory := range []string{home, workspace} { + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatalf("create Kiro IDE fixture directory: %v", err) + } + } + configPath := filepath.Join(workspace, ".kiro", "hooks", kiroIdeConfigFile) + legacyPath := filepath.Join(home, ".kiro", "hooks", kiroIdeLegacyFile) + writeOptionalKiroIdeSource(t, configPath, workspaceSource) + writeOptionalKiroIdeSource(t, legacyPath, legacySource) + previous, err := os.Getwd() + if err != nil { + t.Fatalf("read current directory: %v", err) + } + if err := os.Chdir(workspace); err != nil { + t.Fatalf("enter Kiro IDE workspace: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(previous); err != nil { + t.Errorf("restore current directory: %v", err) + } + }) + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + agentDir := filepath.Join(home, ".elydora", kiroIdeTestAgentID) + guardPath := filepath.Join(agentDir, kiroIdeGuardScript) + return &kiroIdeFixture{ + plugin: &KiroIdePlugin{}, + config: InstallConfig{ + AgentName: kiroIdeAgentKey, OrgID: "org-1", AgentID: kiroIdeTestAgentID, + PrivateKey: kiroIdePrivateKey, KID: "kid-1", Token: "token-1", + BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath, + }, + homeDir: home, workspace: workspace, configPath: configPath, + legacyPath: legacyPath, agentDir: agentDir, guardPath: guardPath, + auditPath: filepath.Join(agentDir, kiroIdeAuditScript), + runtimeConfig: filepath.Join(agentDir, "config.json"), + privateKey: filepath.Join(agentDir, "private.key"), + } +} + +func kiroIdeString(value string) *string { + return &value +} + +func writeOptionalKiroIdeSource(t *testing.T, path string, source *string) { + t.Helper() + if source == nil { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("create source directory: %v", err) + } + if err := os.WriteFile(path, []byte(*source), 0600); err != nil { + t.Fatalf("write Kiro IDE source: %v", err) + } +} + +func writeKiroIdeJSON(t *testing.T, path string, value any) { + t.Helper() + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatalf("encode Kiro IDE JSON: %v", err) + } + encoded = append(encoded, '\n') + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("create Kiro IDE JSON directory: %v", err) + } + if err := os.WriteFile(path, encoded, 0600); err != nil { + t.Fatalf("write Kiro IDE JSON: %v", err) + } +} + +func readKiroIdeObject(t *testing.T, path string) map[string]any { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var value map[string]any + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("decode %s: %v", path, err) + } + return value +} + +func findKiroIdeHook(t *testing.T, document map[string]any, name string) map[string]any { + t.Helper() + for _, value := range requireArray(t, document["hooks"]) { + hook := requireObject(t, value) + if hook["name"] == name { + return hook + } + } + t.Fatalf("Kiro IDE hook %q is missing", name) + return nil +} + +func kiroIdeHookCommand(t *testing.T, hook map[string]any) string { + t.Helper() + action := requireObject(t, hook["action"]) + command, ok := action["command"].(string) + if !ok { + t.Fatalf("Kiro IDE command = %#v", action["command"]) + } + return command +} + +func legacyKiroIdeGoDocument(fixture *kiroIdeFixture, agentID string) map[string]any { + agentDirectory := filepath.Join(fixture.homeDir, ".elydora", agentID) + return map[string]any{ + "name": "Elydora Audit", + "hooks": map[string]any{ + "pre_tool_use": map[string]any{ + "command": "node " + filepath.Join(agentDirectory, kiroIdeGuardScript), + "timeout_ms": 5000, + }, + "post_tool_use": map[string]any{ + "command": "node " + filepath.Join(agentDirectory, kiroIdeAuditScript), + "timeout_ms": 5000, + }, + }, + } +} + +func runKiroIdeCommand( + t *testing.T, + command string, + fixture *kiroIdeFixture, + input []byte, +) (int, string, string) { + t.Helper() + var process *exec.Cmd + if runtime.GOOS == "windows" { + commandFile := filepath.Join(t.TempDir(), "run-kiroide-hook.cmd") + if err := os.WriteFile( + commandFile, + []byte("@echo off\r\n"+command+"\r\n"), + 0600, + ); err != nil { + t.Fatalf("write Kiro IDE command file: %v", err) + } + process = exec.Command("cmd.exe", "/d", "/c", commandFile) + } else { + process = exec.Command("sh", "-c", command) + } + process.Dir = fixture.workspace + process.Env = append( + os.Environ(), + "HOME="+fixture.homeDir, + "USERPROFILE="+fixture.homeDir, + ) + process.Stdin = bytes.NewReader(input) + var stdout, stderr bytes.Buffer + process.Stdout = &stdout + process.Stderr = &stderr + err := process.Run() + if err == nil { + return 0, stdout.String(), stderr.String() + } + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return exitError.ExitCode(), stdout.String(), stderr.String() + } + t.Fatalf("run Kiro IDE command: %v", err) + return -1, stdout.String(), stderr.String() +} + +func assertNoKiroIdeRuntimeFiles(t *testing.T, fixture *kiroIdeFixture) { + t.Helper() + for _, path := range []string{ + fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.auditPath, + } { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("runtime file exists at %s: %v", path, err) + } + } +} + +func assertNoKiroIdeTransactionArtifacts(t *testing.T, root string) { + t.Helper() + if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) { + return + } + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if strings.HasSuffix(entry.Name(), ".tmp") || + strings.HasSuffix(entry.Name(), ".rollback") { + t.Errorf("Kiro IDE transaction artifact remains at %s", path) + } + return nil + }) + if err != nil { + t.Fatalf("walk Kiro IDE fixture: %v", err) + } +} + +func prepareKiroIdeInstallForTest( + t *testing.T, + fixture *kiroIdeFixture, +) *kiroIdePreparedTransaction { + t.Helper() + sources, err := readKiroIdeSources() + if err != nil { + t.Fatalf("read Kiro IDE sources: %v", err) + } + paths, nodePath, err := preflightKiroIdeInstallation(fixture.config) + if err != nil { + t.Fatalf("preflight Kiro IDE installation: %v", err) + } + guard, err := buildKiroIdeHook(kiroIdeGuardName, nodePath, paths.guardPath) + if err != nil { + t.Fatalf("build Kiro IDE guard: %v", err) + } + audit, err := buildKiroIdeHook(kiroIdeAuditName, nodePath, paths.auditPath) + if err != nil { + t.Fatalf("build Kiro IDE audit: %v", err) + } + rendered, err := renderKiroIdeDocument( + sources.document, + append(withoutManagedKiroIdeHooks(sources.document.hooks, ""), guard, audit), + ) + if err != nil { + t.Fatalf("render Kiro IDE hooks: %v", err) + } + prepared, err := prepareKiroIdeInstallation(fixture.config, sources, paths, rendered) + if err != nil { + t.Fatalf("prepare Kiro IDE installation: %v", err) + } + return prepared +} diff --git a/cmd/elydora/plugins/kiroide_transaction.go b/cmd/elydora/plugins/kiroide_transaction.go new file mode 100644 index 0000000..2561219 --- /dev/null +++ b/cmd/elydora/plugins/kiroide_transaction.go @@ -0,0 +1,258 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +type kiroIdeDirectoryIdentity struct { + path string + label string + info os.FileInfo +} + +func snapshotKiroIdeDirectory(path, label string) (kiroIdeDirectoryIdentity, error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return kiroIdeDirectoryIdentity{path: path, label: label}, nil + } + if err != nil { + return kiroIdeDirectoryIdentity{}, fmt.Errorf("inspect %s at %s: %w", label, path, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return kiroIdeDirectoryIdentity{}, fmt.Errorf("%s is not a physical directory: %s", label, path) + } + return kiroIdeDirectoryIdentity{path: path, label: label, info: info}, nil +} + +func (identity kiroIdeDirectoryIdentity) assertExpected(operation string) error { + current, err := os.Lstat(identity.path) + if os.IsNotExist(err) && identity.info == nil { + return nil + } + if err != nil { + return fmt.Errorf( + "%s changed during %s: %s: %w", + identity.label, + operation, + identity.path, + err, + ) + } + if identity.info == nil || current.Mode()&os.ModeSymlink != 0 || !current.IsDir() || + !os.SameFile(identity.info, current) { + return fmt.Errorf( + "%s changed during %s: %s", + identity.label, + operation, + identity.path, + ) + } + return nil +} + +func assertKiroIdeDirectoryStates( + directories []kiroIdeDirectoryIdentity, + operation string, +) error { + for _, directory := range directories { + if err := directory.assertExpected(operation); err != nil { + return err + } + } + return nil +} + +func establishKiroIdeDirectory( + initial kiroIdeDirectoryIdentity, + operation string, + private bool, +) (kiroIdeDirectoryIdentity, error) { + if err := initial.assertExpected(operation); err != nil { + return kiroIdeDirectoryIdentity{}, err + } + if initial.info == nil { + if err := os.Mkdir(initial.path, 0700); err != nil { + return kiroIdeDirectoryIdentity{}, fmt.Errorf( + "%s changed during %s before Elydora could create it: %s: %w", + initial.label, + operation, + initial.path, + err, + ) + } + } else if private && runtime.GOOS != "windows" { + if err := os.Chmod(initial.path, 0700); err != nil { + return kiroIdeDirectoryIdentity{}, fmt.Errorf( + "restrict %s at %s: %w", + initial.label, + initial.path, + err, + ) + } + } + current, err := snapshotKiroIdeDirectory(initial.path, initial.label) + if err != nil { + return kiroIdeDirectoryIdentity{}, err + } + if current.info == nil { + return kiroIdeDirectoryIdentity{}, fmt.Errorf( + "%s is missing after creation: %s", + initial.label, + initial.path, + ) + } + if initial.info != nil && !os.SameFile(initial.info, current.info) { + return kiroIdeDirectoryIdentity{}, fmt.Errorf( + "%s changed during %s: %s", + initial.label, + operation, + initial.path, + ) + } + return current, nil +} + +func kiroIdePathInside(directory, candidate string) bool { + relative, err := filepath.Rel(directory, candidate) + if err != nil || filepath.IsAbs(relative) || relative == ".." { + return false + } + if strings.HasPrefix(relative, ".."+string(os.PathSeparator)) { + return false + } + if runtime.GOOS == "windows" { + return !strings.HasPrefix(strings.ToLower(relative), ".."+string(os.PathSeparator)) + } + return true +} + +func guardedKiroIdeRename( + rename renameFunc, + directories []kiroIdeDirectoryIdentity, +) renameFunc { + if rename == nil { + rename = atomicRenameNoReplace + } + return func(source, destination string) error { + relevant := make([]kiroIdeDirectoryIdentity, 0, len(directories)) + for _, directory := range directories { + if kiroIdePathInside(directory.path, source) || + kiroIdePathInside(directory.path, destination) { + relevant = append(relevant, directory) + } + } + for _, directory := range relevant { + if err := directory.assertExpected("Kiro IDE transaction"); err != nil { + return err + } + } + if err := rename(source, destination); err != nil { + return err + } + for _, directory := range relevant { + if err := directory.assertExpected("Kiro IDE transaction"); err != nil { + return err + } + } + return nil + } +} + +func guardedKiroIdeTransactionOps( + rename renameFunc, + directories []kiroIdeDirectoryIdentity, +) transactionFileOps { + if rename != nil { + return transactionFileOps{ + moveNoReplace: guardedKiroIdeRename(rename, directories), + compensateNoReplace: guardedKiroIdeRename(atomicRenameNoReplace, directories), + } + } + return transactionFileOps{ + moveNoReplace: guardedKiroIdeRename(atomicRenameNoReplace, directories), + compensateNoReplace: guardedKiroIdeRename(atomicRenameNoReplace, directories), + replaceWithBackup: guardedKiroIdeReplace(directories), + production: true, + } +} + +func guardedKiroIdeReplace( + directories []kiroIdeDirectoryIdentity, +) replaceWithBackupFunc { + return func(target, replacement, backup string) error { + relevant := make([]kiroIdeDirectoryIdentity, 0, len(directories)) + for _, directory := range directories { + if kiroIdePathInside(directory.path, target) || + kiroIdePathInside(directory.path, replacement) || + kiroIdePathInside(directory.path, backup) { + relevant = append(relevant, directory) + } + } + if err := assertKiroIdeDirectoryStates(relevant, "Kiro IDE transaction"); err != nil { + return err + } + if err := atomicReplaceWithBackup(target, replacement, backup); err != nil { + return err + } + return assertKiroIdeDirectoryStates(relevant, "Kiro IDE transaction") + } +} + +func kiroIdeDirectoryKey(path string) string { + key := filepath.Clean(path) + if runtime.GOOS == "windows" { + return strings.ToLower(key) + } + return key +} + +func kiroIdeInitialDirectory( + prepared *kiroIdePreparedTransaction, + path string, +) (kiroIdeDirectoryIdentity, error) { + key := kiroIdeDirectoryKey(path) + for _, directory := range prepared.initialDirectories { + if kiroIdeDirectoryKey(directory.path) == key { + return directory, nil + } + } + return kiroIdeDirectoryIdentity{}, fmt.Errorf( + "Kiro IDE transaction is missing a directory snapshot for %s", + path, + ) +} + +func appendKiroIdeDirectory( + directories []kiroIdeDirectoryIdentity, + directory kiroIdeDirectoryIdentity, +) []kiroIdeDirectoryIdentity { + key := kiroIdeDirectoryKey(directory.path) + for index, existing := range directories { + if kiroIdeDirectoryKey(existing.path) == key { + directories[index] = directory + return directories + } + } + return append(directories, directory) +} + +func kiroIdeDirectoryPreconditions( + directories []kiroIdeDirectoryIdentity, +) []filePrecondition { + conditions := make([]filePrecondition, 0, len(directories)) + for _, directory := range directories { + if directory.info == nil { + continue + } + conditions = append(conditions, filePrecondition{ + filePath: directory.path, + label: directory.label, + directory: directory.info, + }) + } + return conditions +} diff --git a/cmd/elydora/plugins/kiroide_transaction_test.go b/cmd/elydora/plugins/kiroide_transaction_test.go new file mode 100644 index 0000000..d848073 --- /dev/null +++ b/cmd/elydora/plugins/kiroide_transaction_test.go @@ -0,0 +1,380 @@ +package plugins + +import ( + "bytes" + "encoding/base64" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestKiroIdeRollbackRestoresRuntimeWorkspaceAndLegacySources(t *testing.T) { + original := `{"version":"v1","owner":"workspace","hooks":[]}` + fixture := prepareKiroIdeFixture(t, &original, nil) + writeKiroIdeJSON( + t, + fixture.legacyPath, + legacyKiroIdeGoDocument(fixture, kiroIdeTestAgentID), + ) + fixture.plugin.rename = func(source, destination string) error { + if sameKiroIdePath(source, fixture.legacyPath) && + strings.HasSuffix(destination, ".rollback") { + return errors.New("injected Kiro IDE legacy cleanup failure") + } + return os.Rename(source, destination) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "injected Kiro IDE legacy cleanup failure") { + t.Fatalf("install error = %v", err) + } + actual, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(actual) != original { + t.Fatalf("workspace source after rollback = %q, %v", actual, readErr) + } + if _, err := os.Lstat(fixture.legacyPath); err != nil { + t.Fatalf("legacy source was not restored: %v", err) + } + assertNoKiroIdeRuntimeFiles(t, fixture) + assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir) + assertNoKiroIdeTransactionArtifacts(t, fixture.workspace) +} + +func TestKiroIdePreparedInstallProtectsUnchangedSources(t *testing.T) { + fixture := prepareKiroIdeFixture(t, nil, nil) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro IDE hooks: %v", err) + } + if err := os.WriteFile(fixture.guardPath, []byte("repair me\n"), 0700); err != nil { + t.Fatalf("damage guard runtime: %v", err) + } + prepared := prepareKiroIdeInstallForTest(t, fixture) + concurrent := `{"version":"v1","owner":"concurrent","hooks":[]}` + if err := os.WriteFile(fixture.configPath, []byte(concurrent), 0600); err != nil { + t.Fatalf("write concurrent workspace source: %v", err) + } + err := commitKiroIdeInstallation(prepared, nil) + if err == nil || !strings.Contains(err.Error(), "changed during Kiro IDE installation") { + t.Fatalf("prepared install error = %v", err) + } + actual, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(actual) != concurrent { + t.Fatalf("concurrent workspace source changed: %q, %v", actual, readErr) + } + guard, readErr := os.ReadFile(fixture.guardPath) + if readErr != nil || string(guard) != "repair me\n" { + t.Fatalf("runtime changed after source race: %q, %v", guard, readErr) + } + assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir) + assertNoKiroIdeTransactionArtifacts(t, fixture.workspace) +} + +func TestKiroIdeInstallRepairsRuntimeModesOnPOSIX(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX file modes are unavailable on Windows") + } + fixture := prepareKiroIdeFixture(t, nil, nil) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro IDE hooks: %v", err) + } + files := []struct { + path string + drift os.FileMode + want os.FileMode + }{ + {fixture.runtimeConfig, 0644, 0600}, + {fixture.privateKey, 0644, 0600}, + {fixture.guardPath, 0600, 0700}, + {fixture.auditPath, 0600, 0700}, + } + for _, file := range files { + if err := os.Chmod(file.path, file.drift); err != nil { + t.Fatalf("drift runtime mode at %s: %v", file.path, err) + } + } + status, err := fixture.plugin.Status() + if err != nil || status.Installed || !status.HookConfigured { + t.Fatalf("mode-drifted Kiro IDE status = %#v, %v", status, err) + } + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repair Kiro IDE runtime modes: %v", err) + } + for _, file := range files { + info, err := os.Stat(file.path) + if err != nil || info.Mode().Perm() != file.want { + t.Fatalf("runtime mode at %s = %v, %v", file.path, infoMode(info), err) + } + } + status, err = fixture.plugin.Status() + if err != nil || !status.Installed || !status.HookConfigured { + t.Fatalf("repaired Kiro IDE status = %#v, %v", status, err) + } +} + +func TestKiroIdePreparedInstallDetectsConcurrentModeChangesOnPOSIX(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX file modes are unavailable on Windows") + } + t.Run("changed private key", func(t *testing.T) { + fixture := prepareKiroIdeFixture(t, nil, nil) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro IDE hooks: %v", err) + } + fixture.config.PrivateKey = base64.RawURLEncoding.EncodeToString( + bytes.Repeat([]byte{12}, 32), + ) + prepared := prepareKiroIdeInstallForTest(t, fixture) + if err := os.Chmod(fixture.privateKey, 0644); err != nil { + t.Fatalf("concurrently chmod private key: %v", err) + } + err := commitKiroIdeInstallation(prepared, nil) + if err == nil || !strings.Contains(err.Error(), "private key changed during installation") { + t.Fatalf("concurrent private key mode error = %v", err) + } + key, readErr := os.ReadFile(fixture.privateKey) + if readErr != nil || string(key) != kiroIdePrivateKey { + t.Fatalf("private key changed after mode race: %q, %v", key, readErr) + } + info, statErr := os.Stat(fixture.privateKey) + if statErr != nil || info.Mode().Perm() != 0644 { + t.Fatalf("private key mode after race = %v, %v", infoMode(info), statErr) + } + assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir) + }) + t.Run("unchanged runtime config", func(t *testing.T) { + fixture := prepareKiroIdeFixture(t, nil, nil) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro IDE hooks: %v", err) + } + if err := os.WriteFile(fixture.guardPath, []byte("repair me\n"), 0700); err != nil { + t.Fatalf("damage guard runtime: %v", err) + } + prepared := prepareKiroIdeInstallForTest(t, fixture) + if err := os.Chmod(fixture.runtimeConfig, 0644); err != nil { + t.Fatalf("concurrently chmod runtime config: %v", err) + } + err := commitKiroIdeInstallation(prepared, nil) + if err == nil || !strings.Contains(err.Error(), "runtime config changed during Kiro IDE installation") { + t.Fatalf("concurrent runtime config mode error = %v", err) + } + guard, readErr := os.ReadFile(fixture.guardPath) + if readErr != nil || string(guard) != "repair me\n" { + t.Fatalf("guard changed after mode race: %q, %v", guard, readErr) + } + assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir) + }) +} + +func infoMode(info os.FileInfo) os.FileMode { + if info == nil { + return 0 + } + return info.Mode().Perm() +} + +func TestKiroIdePreparedInstallRejectsFreshWorkspaceDirectoryReplacement(t *testing.T) { + fixture := prepareKiroIdeFixture(t, nil, nil) + prepared := prepareKiroIdeInstallForTest(t, fixture) + external := filepath.Join(filepath.Dir(fixture.homeDir), "external-workspace") + if err := os.MkdirAll(external, 0700); err != nil { + t.Fatalf("create external workspace directory: %v", err) + } + if err := os.Symlink(external, filepath.Join(fixture.workspace, ".kiro")); err != nil { + t.Skipf("directory symbolic links unavailable: %v", err) + } + err := commitKiroIdeInstallation(prepared, nil) + if err == nil || !strings.Contains(err.Error(), "changed during Kiro IDE installation") { + t.Fatalf("workspace replacement error = %v", err) + } + if _, err := os.Lstat(filepath.Join(external, "hooks", kiroIdeConfigFile)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("external workspace received hooks: %v", err) + } + assertNoKiroIdeRuntimeFiles(t, fixture) +} + +func TestKiroIdePreparedInstallRejectsFreshRuntimeDirectoryReplacements(t *testing.T) { + for _, target := range []string{"root", "agent"} { + t.Run(target, func(t *testing.T) { + fixture := prepareKiroIdeFixture(t, nil, nil) + runtimeRoot := filepath.Join(fixture.homeDir, ".elydora") + if target == "agent" { + if err := os.Mkdir(runtimeRoot, 0700); err != nil { + t.Fatalf("create physical runtime root: %v", err) + } + } + prepared := prepareKiroIdeInstallForTest(t, fixture) + external := filepath.Join(filepath.Dir(fixture.homeDir), "external-runtime-"+target) + if err := os.MkdirAll(external, 0700); err != nil { + t.Fatalf("create external runtime directory: %v", err) + } + linkPath := runtimeRoot + if target == "agent" { + linkPath = fixture.agentDir + } + if err := os.Symlink(external, linkPath); err != nil { + t.Skipf("directory symbolic links unavailable: %v", err) + } + err := commitKiroIdeInstallation(prepared, nil) + if err == nil || !strings.Contains(err.Error(), "changed during Kiro IDE installation") { + t.Fatalf("runtime %s replacement error = %v", target, err) + } + if _, err := os.Lstat(filepath.Join(external, "private.key")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("external runtime received private key: %v", err) + } + if _, err := os.Lstat(filepath.Join(external, kiroIdeGuardScript)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("external runtime received guard: %v", err) + } + }) + } +} + +func TestKiroIdePreparedInstallRejectsExistingWorkspaceIdentityReplacement(t *testing.T) { + source := `{"version":"v1","owner":"workspace","hooks":[]}` + fixture := prepareKiroIdeFixture(t, &source, nil) + prepared := prepareKiroIdeInstallForTest(t, fixture) + kiroDirectory := filepath.Join(fixture.workspace, ".kiro") + backup := kiroDirectory + ".original" + external := filepath.Join(filepath.Dir(fixture.homeDir), "external-existing-workspace") + if err := os.MkdirAll(filepath.Join(external, "hooks"), 0700); err != nil { + t.Fatalf("create external hooks directory: %v", err) + } + if err := os.WriteFile( + filepath.Join(external, "hooks", kiroIdeConfigFile), + []byte(source), + 0600, + ); err != nil { + t.Fatalf("write external matching source: %v", err) + } + if err := os.Rename(kiroDirectory, backup); err != nil { + t.Fatalf("move physical Kiro directory: %v", err) + } + if err := os.Symlink(external, kiroDirectory); err != nil { + _ = os.Rename(backup, kiroDirectory) + t.Skipf("directory symbolic links unavailable: %v", err) + } + err := commitKiroIdeInstallation(prepared, nil) + if err == nil || !strings.Contains(err.Error(), "changed during Kiro IDE installation") { + t.Fatalf("existing workspace replacement error = %v", err) + } + actual, readErr := os.ReadFile(filepath.Join(external, "hooks", kiroIdeConfigFile)) + if readErr != nil || string(actual) != source { + t.Fatalf("external matching source changed: %q, %v", actual, readErr) + } + assertNoKiroIdeRuntimeFiles(t, fixture) +} + +func TestKiroIdeRejectsFileAndDirectoryLinksBeforeRuntimeWrites(t *testing.T) { + for _, kind := range []string{"workspace-config", "workspace-directory", "runtime-directory"} { + t.Run(kind, func(t *testing.T) { + fixture := prepareKiroIdeFixture(t, nil, nil) + external := filepath.Join(filepath.Dir(fixture.homeDir), "external-"+kind) + if kind == "workspace-config" { + if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0700); err != nil { + t.Fatalf("create hooks directory: %v", err) + } + if err := os.WriteFile(external, []byte(`{"version":"v1","hooks":[]}`), 0600); err != nil { + t.Fatalf("write external config: %v", err) + } + if err := os.Symlink(external, fixture.configPath); err != nil { + t.Skipf("file symbolic links unavailable: %v", err) + } + } else { + if err := os.MkdirAll(external, 0700); err != nil { + t.Fatalf("create external directory: %v", err) + } + linkPath := filepath.Join(fixture.workspace, ".kiro") + if kind == "runtime-directory" { + linkPath = filepath.Join(fixture.homeDir, ".elydora") + } + if err := os.MkdirAll(filepath.Dir(linkPath), 0700); err != nil { + t.Fatalf("create link parent: %v", err) + } + if err := os.Symlink(external, linkPath); err != nil { + t.Skipf("directory symbolic links unavailable: %v", err) + } + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "physical") { + t.Fatalf("linked %s error = %v", kind, err) + } + assertNoKiroIdeRuntimeFiles(t, fixture) + }) + } +} + +func TestKiroIdeInstallSurvivesAdversarialWorkspaceLinkSwap(t *testing.T) { + fixture := prepareKiroIdeFixture(t, nil, nil) + kiroDirectory := filepath.Join(fixture.workspace, ".kiro") + external := filepath.Join(filepath.Dir(fixture.homeDir), "external-link-swap") + probe := filepath.Join(filepath.Dir(fixture.homeDir), "link-probe") + if err := os.MkdirAll(external, 0700); err != nil { + t.Fatalf("create external directory: %v", err) + } + if err := os.Symlink(external, probe); err != nil { + t.Skipf("directory symbolic links unavailable: %v", err) + } + if err := os.Remove(probe); err != nil { + t.Fatalf("remove link probe: %v", err) + } + swapped := false + fixture.plugin.rename = func(source, destination string) error { + if !swapped && sameKiroIdePath(destination, fixture.configPath) && + strings.HasSuffix(source, ".tmp") { + swapped = true + backup := kiroDirectory + ".original" + if err := os.Rename(kiroDirectory, backup); err != nil { + return err + } + if err := os.Symlink(external, kiroDirectory); err != nil { + _ = os.Rename(backup, kiroDirectory) + return err + } + renameErr := os.Rename(source, destination) + _ = os.Remove(kiroDirectory) + restoreErr := os.Rename(backup, kiroDirectory) + return errors.Join(renameErr, restoreErr) + } + return os.Rename(source, destination) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !swapped { + t.Fatalf("link-swap install error = %v, swapped=%v", err, swapped) + } + if _, err := os.Lstat(filepath.Join(external, "hooks", kiroIdeConfigFile)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("external hook was written during link swap: %v", err) + } + assertNoKiroIdeRuntimeFiles(t, fixture) + assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir) + assertNoKiroIdeTransactionArtifacts(t, fixture.workspace) +} + +func TestKiroIdeInstallDetectsConcurrentSourceIdentityReplacement(t *testing.T) { + source := `{"version":"v1","owner":"original","hooks":[]}` + fixture := prepareKiroIdeFixture(t, &source, nil) + mutated := false + fixture.plugin.rename = func(stagedPath, destination string) error { + if !mutated && strings.HasSuffix(stagedPath, ".tmp") { + mutated = true + external := fixture.configPath + ".external" + if err := os.Rename(fixture.configPath, external); err != nil { + return err + } + if err := os.WriteFile(fixture.configPath, []byte(source), 0600); err != nil { + return err + } + } + return os.Rename(stagedPath, destination) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "changed during installation") { + t.Fatalf("concurrent identity error = %v", err) + } + actual, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(actual) != source { + t.Fatalf("replacement source changed: %q, %v", actual, readErr) + } + assertNoKiroIdeRuntimeFiles(t, fixture) + assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir) +} diff --git a/cmd/elydora/plugins/managed_files.go b/cmd/elydora/plugins/managed_files.go index 4d89ad3..b3f40d3 100644 --- a/cmd/elydora/plugins/managed_files.go +++ b/cmd/elydora/plugins/managed_files.go @@ -13,6 +13,7 @@ type managedFileSnapshot struct { contents []byte info os.FileInfo mode os.FileMode + identity string } func readManagedFile( @@ -51,6 +52,13 @@ func readManagedFile( file.Close(), ) } + identity, identityErr := transactionFileIdentity(file, after) + if identityErr != nil { + return nil, errors.Join( + fmt.Errorf("identify %s at %s: %w", label, filePath, identityErr), + file.Close(), + ) + } raw, readErr := io.ReadAll(io.LimitReader(file, maximumBytes+1)) closeErr := file.Close() if readErr != nil || closeErr != nil { @@ -70,6 +78,7 @@ func readManagedFile( contents: raw, info: after, mode: after.Mode().Perm(), + identity: identity, }, nil } diff --git a/cmd/elydora/plugins/qwen_test_helpers_test.go b/cmd/elydora/plugins/qwen_test_helpers_test.go index 7f2257b..a98c11c 100644 --- a/cmd/elydora/plugins/qwen_test_helpers_test.go +++ b/cmd/elydora/plugins/qwen_test_helpers_test.go @@ -58,6 +58,7 @@ func prepareQwenFixture(t *testing.T, options qwenFixtureOptions) *qwenFixture { t.Helper() rootDir := t.TempDir() homeDir := filepath.Join(rootDir, "home with spaces and 'quote") + preparePrivateTransactionTestDirectory(t, homeDir) workspaceDir := filepath.Join(homeDir, "workspace") qwenDir := filepath.Join(homeDir, ".qwen") configPath := filepath.Join(qwenDir, "settings.json") diff --git a/cmd/elydora/plugins/registry.go b/cmd/elydora/plugins/registry.go index 40feea6..ce6b1f5 100644 --- a/cmd/elydora/plugins/registry.go +++ b/cmd/elydora/plugins/registry.go @@ -18,7 +18,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{ "gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"}, "grok": {Name: "Grok Build", ConfigDir: "~/.grok/hooks", ConfigFile: "elydora-audit.json"}, "kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.json"}, - "kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"}, + "kiroide": {Name: "Kiro IDE", ConfigDir: ".kiro/hooks", ConfigFile: "elydora-audit.json"}, "kimi": {Name: "Kimi Code", ConfigDir: "~/.kimi-code", ConfigFile: "config.toml"}, "opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"}, "copilot": {Name: "GitHub Copilot CLI", ConfigDir: "~/.copilot/hooks", ConfigFile: "elydora-audit.json"}, diff --git a/cmd/elydora/plugins/runtime_files.go b/cmd/elydora/plugins/runtime_files.go index 57eddf5..cba6e08 100644 --- a/cmd/elydora/plugins/runtime_files.go +++ b/cmd/elydora/plugins/runtime_files.go @@ -37,13 +37,13 @@ func WriteRuntimeFileAtomic( if err != nil { return err } - return writeChanges([]*fileChange{change}, "write "+label, os.Rename) + return writeChanges([]*fileChange{change}, "write "+label, nil) } // GenerateHookScript atomically commits the agent config, private key, and // self-contained Node.js audit runtime. func GenerateHookScript(destPath string, config InstallConfig) error { - return generateHookScriptWithRename(destPath, config, os.Rename) + return generateHookScriptWithRename(destPath, config, nil) } func generateHookScriptWithRename( diff --git a/cmd/elydora/plugins/runtime_files_test.go b/cmd/elydora/plugins/runtime_files_test.go index 7267598..801768e 100644 --- a/cmd/elydora/plugins/runtime_files_test.go +++ b/cmd/elydora/plugins/runtime_files_test.go @@ -12,6 +12,7 @@ import ( func setRuntimeFilesTestHome(t *testing.T) string { t.Helper() home := t.TempDir() + preparePrivateTransactionTestDirectory(t, home) t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) return home diff --git a/cmd/elydora/plugins/transaction.go b/cmd/elydora/plugins/transaction.go index f984943..619f870 100644 --- a/cmd/elydora/plugins/transaction.go +++ b/cmd/elydora/plugins/transaction.go @@ -4,7 +4,6 @@ import ( "bytes" "errors" "fmt" - "io" "os" "path/filepath" "runtime" @@ -12,12 +11,14 @@ import ( ) type renameFunc func(source, destination string) error +type replaceWithBackupFunc func(target, replacement, backup string) error type fileChange struct { filePath string label string original []byte originalInfo os.FileInfo + originalID string originalMode os.FileMode existed bool next []byte @@ -26,11 +27,40 @@ type fileChange struct { } type stagedChange struct { - change fileChange - temporaryPath string - rollbackPath string - committedInfo os.FileInfo - committed bool + change fileChange + temporaryPath string + temporaryInfo os.FileInfo + rollbackPath string + originalCaptured bool + committedInfo os.FileInfo + committed bool +} + +type transactionFileOps struct { + moveNoReplace renameFunc + compensateNoReplace renameFunc + replaceWithBackup replaceWithBackupFunc + production bool +} + +func newTransactionFileOps(rename renameFunc) transactionFileOps { + if rename == nil { + rename = atomicRenameNoReplace + return transactionFileOps{ + moveNoReplace: rename, + compensateNoReplace: rename, + replaceWithBackup: atomicReplaceWithBackup, + production: true, + } + } + return transactionFileOps{ + moveNoReplace: rename, + compensateNoReplace: atomicRenameNoReplace, + } +} + +func sameManagedFileMode(current, expected os.FileMode) bool { + return runtime.GOOS == "windows" || current.Perm() == expected.Perm() } func readOptionalFile(path, label string) ([]byte, bool, error) { @@ -80,7 +110,8 @@ func prepareSourceChange( (currentExists && !bytes.Equal(snapshot.contents, original)) { return nil, fmt.Errorf("%s changed before update: %s", label, filePath) } - if existed && !remove && bytes.Equal(original, next) { + if existed && !remove && bytes.Equal(original, next) && + sameManagedFileMode(snapshot.mode, mode) { return nil, nil } if !existed && (remove || next == nil) { @@ -94,22 +125,52 @@ func prepareSourceChange( } return &fileChange{ filePath: filePath, label: label, original: append([]byte(nil), original...), - originalInfo: originalInfo, originalMode: originalMode, existed: existed, + originalInfo: originalInfo, originalID: snapshotIdentity(snapshot), + originalMode: originalMode, existed: existed, next: append([]byte(nil), next...), mode: mode, remove: remove, }, nil } +func snapshotIdentity(snapshot *managedFileSnapshot) string { + if snapshot == nil { + return "" + } + return snapshot.identity +} + func writeChanges( changes []*fileChange, label string, rename renameFunc, preconditions ...filePrecondition, ) error { + return writeChangesWithFileOps( + changes, + label, + newTransactionFileOps(rename), + preconditions..., + ) +} + +func writeChangesWithFileOps( + changes []*fileChange, + label string, + ops transactionFileOps, + preconditions ...filePrecondition, +) error { + if ops.production { + return writeDurableChanges(changes, label, ops, preconditions...) + } + if ops.moveNoReplace == nil { + return fmt.Errorf("%s is missing atomic file operations", label) + } filtered := make([]fileChange, 0, len(changes)) targets := map[string]bool{} for _, change := range changes { - if change == nil || (change.existed && !change.remove && bytes.Equal(change.original, change.next)) { + if change == nil || (change.existed && !change.remove && + bytes.Equal(change.original, change.next) && + sameManagedFileMode(change.originalMode, change.mode)) { continue } target := filepath.Clean(change.filePath) @@ -128,17 +189,30 @@ func writeChanges( if len(filtered) == 0 { return nil } - if rename == nil { - rename = os.Rename - } staged := make([]stagedChange, 0, len(filtered)) for _, change := range filtered { + if err := assertFilePreconditions(preconditions, label); err != nil { + cleanupErrors := cleanupStaging(staged) + return joinTransactionFailure( + fmt.Errorf("%s: %w", label, err), + cleanupErrors, + "cleanup failed", + ) + } item, err := stageChange(change) if err != nil { cleanupErrors := cleanupStaging(staged) return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), cleanupErrors, "cleanup failed") } staged = append(staged, item) + if err := assertFilePreconditions(preconditions, label); err != nil { + cleanupErrors := cleanupStaging(staged) + return joinTransactionFailure( + fmt.Errorf("%s: %w", label, err), + cleanupErrors, + "cleanup failed", + ) + } } if err := assertFilePreconditions(preconditions, label); err != nil { cleanupErrors := cleanupStaging(staged) @@ -146,18 +220,23 @@ func writeChanges( } for index := range staged { if err := assertFilePreconditions(preconditions, label); err != nil { - recoveryErrors := rollbackChanges(staged, rename) + recoveryErrors := rollbackChanges(staged, ops) recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...) return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed") } - if err := commitChange(&staged[index], rename); err != nil { - recoveryErrors := rollbackChanges(staged, rename) + if err := commitChange(&staged[index], ops); err != nil { + recoveryErrors := rollbackChanges(staged, ops) recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...) return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed") } } if err := assertFilePreconditions(preconditions, label); err != nil { - recoveryErrors := rollbackChanges(staged, rename) + recoveryErrors := rollbackChanges(staged, ops) + recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...) + return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed") + } + if err := assertCommittedChanges(staged); err != nil { + recoveryErrors := rollbackChanges(staged, ops) recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...) return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed") } @@ -168,234 +247,6 @@ func writeChanges( return nil } -func stageChange(change fileChange) (stagedChange, error) { - if err := assertFileUnchanged(change); err != nil { - return stagedChange{}, err - } - directory := filepath.Dir(change.filePath) - if err := ensureManagedDirectory(directory, "directory for "+change.label); err != nil { - return stagedChange{}, err - } - staged := stagedChange{change: change} - var err error - if change.remove { - staged.rollbackPath, err = reserveStagingPath(directory, filepath.Base(change.filePath), ".rollback") - } else { - staged.temporaryPath, err = writeStagedFile( - directory, filepath.Base(change.filePath), ".tmp", change.next, change.mode, - ) - if err == nil && change.existed { - staged.rollbackPath, err = writeStagedFile( - directory, filepath.Base(change.filePath), ".rollback", change.original, change.originalMode, - ) - } - } - if err != nil { - cleanupErrors := cleanupStaging([]stagedChange{staged}) - return stagedChange{}, joinTransactionFailure( - fmt.Errorf("stage %s: %w", change.label, err), cleanupErrors, "cleanup failed", - ) - } - return staged, nil -} - -func writeStagedFile( - directory, basename, suffix string, - content []byte, - mode os.FileMode, -) (string, error) { - file, err := os.CreateTemp(directory, "."+basename+".*"+suffix) - if err != nil { - return "", err - } - path := file.Name() - failed := func(cause error) (string, error) { - return "", errors.Join(cause, file.Close(), removeOptionalFile(path)) - } - if err := file.Chmod(mode); err != nil { - return failed(err) - } - written, err := file.Write(content) - if err != nil { - return failed(err) - } - if written != len(content) { - return failed(io.ErrShortWrite) - } - if err := file.Sync(); err != nil { - return failed(err) - } - if err := file.Close(); err != nil { - return "", errors.Join(err, removeOptionalFile(path)) - } - return path, nil -} - -func reserveStagingPath(directory, basename, suffix string) (string, error) { - file, err := os.CreateTemp(directory, "."+basename+".*"+suffix) - if err != nil { - return "", err - } - path := file.Name() - if err := file.Close(); err != nil { - return "", errors.Join(err, removeOptionalFile(path)) - } - if err := os.Remove(path); err != nil { - return "", err - } - return path, nil -} - -func assertFileUnchanged(change fileChange) error { - current, err := readManagedFile(change.filePath, change.label, maxManagedSourceBytes) - if err != nil { - return err - } - currentExists := current != nil - if currentExists != change.existed || - (currentExists && (!bytes.Equal(current.contents, change.original) || - !os.SameFile(current.info, change.originalInfo))) { - return fmt.Errorf("%s changed during installation: %s", change.label, change.filePath) - } - return nil -} - -func commitChange(staged *stagedChange, rename renameFunc) error { - if err := assertFileUnchanged(staged.change); err != nil { - return err - } - if staged.change.remove { - if staged.rollbackPath == "" { - return fmt.Errorf("missing rollback path for %s", staged.change.label) - } - if err := rename(staged.change.filePath, staged.rollbackPath); err != nil { - return fmt.Errorf("remove %s at %s: %w", staged.change.label, staged.change.filePath, err) - } - } else { - if staged.temporaryPath == "" { - return fmt.Errorf("missing staged file for %s", staged.change.label) - } - if err := rename(staged.temporaryPath, staged.change.filePath); err != nil { - return fmt.Errorf("commit %s at %s: %w", staged.change.label, staged.change.filePath, err) - } - } - staged.committed = true - if !staged.change.remove { - current, err := readManagedFile( - staged.change.filePath, - staged.change.label, - maxManagedSourceBytes, - ) - if err != nil { - return err - } - if current == nil || !bytes.Equal(current.contents, staged.change.next) { - return fmt.Errorf( - "%s changed immediately after commit: %s", - staged.change.label, - staged.change.filePath, - ) - } - staged.committedInfo = current.info - } - return nil -} - -func assertCommittedFileUnchanged(item *stagedChange) error { - current, err := readManagedFile( - item.change.filePath, - item.change.label, - maxManagedSourceBytes, - ) - if err != nil { - return err - } - if item.change.remove { - if current != nil { - return fmt.Errorf( - "%s changed during transaction recovery: %s", - item.change.label, - item.change.filePath, - ) - } - return nil - } - if current == nil || item.committedInfo == nil || - !bytes.Equal(current.contents, item.change.next) || - !os.SameFile(current.info, item.committedInfo) { - return fmt.Errorf( - "%s changed during transaction recovery: %s", - item.change.label, - item.change.filePath, - ) - } - return nil -} - -func rollbackChanges(staged []stagedChange, rename renameFunc) []error { - failures := make([]error, 0) - for index := len(staged) - 1; index >= 0; index-- { - item := &staged[index] - if !item.committed { - continue - } - if err := assertCommittedFileUnchanged(item); err != nil { - failures = append(failures, preserveRollbackFile(item, err)) - continue - } - var err error - switch { - case item.change.remove: - err = rename(item.rollbackPath, item.change.filePath) - case item.change.existed: - err = rename(item.rollbackPath, item.change.filePath) - default: - err = removeOptionalFile(item.change.filePath) - } - if err != nil { - failure := fmt.Errorf( - "restore %s at %s: %w", - item.change.label, - item.change.filePath, - err, - ) - failures = append(failures, preserveRollbackFile(item, failure)) - } - } - return failures -} - -func preserveRollbackFile(item *stagedChange, cause error) error { - if item.rollbackPath == "" { - return cause - } - path := item.rollbackPath - item.rollbackPath = "" - return fmt.Errorf("%w; original content preserved at %s", cause, path) -} - -func cleanupStaging(staged []stagedChange) []error { - failures := make([]error, 0) - for _, item := range staged { - for _, path := range []string{item.temporaryPath, item.rollbackPath} { - if err := removeOptionalFile(path); err != nil { - failures = append(failures, err) - } - } - } - return failures -} - -func removeOptionalFile(path string) error { - if path == "" { - return nil - } - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("remove staged file at %s: %w", path, err) - } - return nil -} - func joinTransactionFailure(cause error, related []error, label string) error { if len(related) == 0 { return cause diff --git a/cmd/elydora/plugins/transaction_atomic_darwin.go b/cmd/elydora/plugins/transaction_atomic_darwin.go new file mode 100644 index 0000000..8127df3 --- /dev/null +++ b/cmd/elydora/plugins/transaction_atomic_darwin.go @@ -0,0 +1,48 @@ +//go:build darwin + +package plugins + +import ( + "errors" + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +func atomicRenameNoReplace(source, destination string) error { + if err := unix.RenamexNp(source, destination, unix.RENAME_EXCL); err != nil { + return err + } + return syncAtomicRenameDirectories(source, destination) +} + +func isAtomicDestinationExists(err error) bool { + return errors.Is(err, unix.EEXIST) +} + +func atomicReplaceWithBackup(target, replacement, backup string) error { + if replacement != backup { + return fmt.Errorf("macOS atomic exchange requires the replacement to retain the backup") + } + if err := unix.RenamexNp(target, replacement, unix.RENAME_SWAP); err != nil { + return err + } + return syncAtomicRenameDirectories(target, replacement) +} + +func atomicReplacementUsesSeparateBackup() bool { + return false +} + +func transactionPlatformSupported() error { + return nil +} + +func syncTransactionDirectory(path string) error { + directory, err := os.Open(path) + if err != nil { + return err + } + return errors.Join(directory.Sync(), directory.Close()) +} diff --git a/cmd/elydora/plugins/transaction_atomic_linux.go b/cmd/elydora/plugins/transaction_atomic_linux.go new file mode 100644 index 0000000..bf3f1ee --- /dev/null +++ b/cmd/elydora/plugins/transaction_atomic_linux.go @@ -0,0 +1,60 @@ +//go:build linux + +package plugins + +import ( + "errors" + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +func atomicRenameNoReplace(source, destination string) error { + if err := unix.Renameat2( + unix.AT_FDCWD, + source, + unix.AT_FDCWD, + destination, + unix.RENAME_NOREPLACE, + ); err != nil { + return err + } + return syncAtomicRenameDirectories(source, destination) +} + +func isAtomicDestinationExists(err error) bool { + return errors.Is(err, unix.EEXIST) +} + +func atomicReplaceWithBackup(target, replacement, backup string) error { + if replacement != backup { + return fmt.Errorf("Linux atomic exchange requires the replacement to retain the backup") + } + if err := unix.Renameat2( + unix.AT_FDCWD, + target, + unix.AT_FDCWD, + replacement, + unix.RENAME_EXCHANGE, + ); err != nil { + return err + } + return syncAtomicRenameDirectories(target, replacement) +} + +func atomicReplacementUsesSeparateBackup() bool { + return false +} + +func transactionPlatformSupported() error { + return nil +} + +func syncTransactionDirectory(path string) error { + directory, err := os.Open(path) + if err != nil { + return err + } + return errors.Join(directory.Sync(), directory.Close()) +} diff --git a/cmd/elydora/plugins/transaction_atomic_unix.go b/cmd/elydora/plugins/transaction_atomic_unix.go new file mode 100644 index 0000000..038e3f1 --- /dev/null +++ b/cmd/elydora/plugins/transaction_atomic_unix.go @@ -0,0 +1,18 @@ +//go:build linux || darwin + +package plugins + +import ( + "errors" + "path/filepath" +) + +func syncAtomicRenameDirectories(source, destination string) error { + sourceDirectory := filepath.Dir(source) + destinationDirectory := filepath.Dir(destination) + destinationErr := syncTransactionDirectory(destinationDirectory) + if durablePathKey(sourceDirectory) == durablePathKey(destinationDirectory) { + return destinationErr + } + return errors.Join(destinationErr, syncTransactionDirectory(sourceDirectory)) +} diff --git a/cmd/elydora/plugins/transaction_atomic_unsupported.go b/cmd/elydora/plugins/transaction_atomic_unsupported.go new file mode 100644 index 0000000..9ccb0f1 --- /dev/null +++ b/cmd/elydora/plugins/transaction_atomic_unsupported.go @@ -0,0 +1,35 @@ +//go:build !darwin && !linux && !windows + +package plugins + +import ( + "fmt" + "runtime" +) + +func atomicRenameNoReplace(_, _ string) error { + return fmt.Errorf("atomic no-replace rename is unsupported on %s", runtime.GOOS) +} + +func isAtomicDestinationExists(error) bool { + return false +} + +func atomicReplaceWithBackup(_, _, _ string) error { + return fmt.Errorf("atomic file replacement is unsupported on %s", runtime.GOOS) +} + +func atomicReplacementUsesSeparateBackup() bool { + return false +} + +func transactionPlatformSupported() error { + return fmt.Errorf( + "durable file transactions support Windows, Linux, and macOS; current platform is %s", + runtime.GOOS, + ) +} + +func syncTransactionDirectory(string) error { + return transactionPlatformSupported() +} diff --git a/cmd/elydora/plugins/transaction_atomic_windows.go b/cmd/elydora/plugins/transaction_atomic_windows.go new file mode 100644 index 0000000..60ada64 --- /dev/null +++ b/cmd/elydora/plugins/transaction_atomic_windows.go @@ -0,0 +1,145 @@ +//go:build windows + +package plugins + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "golang.org/x/sys/windows" +) + +func atomicRenameNoReplace(source, destination string) error { + from, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + if err := windows.MoveFileEx(from, to, windows.MOVEFILE_WRITE_THROUGH); err != nil { + return err + } + return syncWindowsRenameDirectories(source, destination) +} + +func isAtomicDestinationExists(err error) bool { + return errors.Is(err, windows.ERROR_ALREADY_EXISTS) || + errors.Is(err, windows.ERROR_FILE_EXISTS) +} + +func atomicReplaceWithBackup(target, replacement, backup string) error { + backupPointer, err := windows.UTF16PtrFromString(backup) + if err != nil { + return err + } + targetPointer, err := windows.UTF16PtrFromString(target) + if err != nil { + return err + } + if err := ensureWindowsAtomicBackup( + target, + backup, + targetPointer, + backupPointer, + ); err != nil { + return err + } + replacementPointer, err := windows.UTF16PtrFromString(replacement) + if err != nil { + return err + } + deadline := time.Now().Add(2 * time.Second) + for { + err = windows.MoveFileEx( + replacementPointer, + targetPointer, + windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH, + ) + if err == nil { + return syncWindowsRenameDirectories(replacement, target) + } + if time.Now().After(deadline) || + !errors.Is(err, windows.ERROR_ACCESS_DENIED) && + !errors.Is(err, windows.ERROR_SHARING_VIOLATION) { + return err + } + time.Sleep(time.Millisecond) + } +} + +func ensureWindowsAtomicBackup( + target, backup string, + targetPointer, backupPointer *uint16, +) error { + err := windows.CreateHardLink(backupPointer, targetPointer, 0) + if err != nil && !errors.Is(err, windows.ERROR_ALREADY_EXISTS) && + !errors.Is(err, windows.ERROR_FILE_EXISTS) { + return err + } + if err != nil { + targetInfo, targetErr := os.Lstat(target) + backupInfo, backupErr := os.Lstat(backup) + if targetErr != nil || backupErr != nil || + targetInfo.Mode()&os.ModeSymlink != 0 || + backupInfo.Mode()&os.ModeSymlink != 0 || + !targetInfo.Mode().IsRegular() || !backupInfo.Mode().IsRegular() || + !os.SameFile(targetInfo, backupInfo) { + return errors.Join( + fmt.Errorf("atomic backup path is occupied by another object: %s", backup), + targetErr, + backupErr, + ) + } + } + if err := syncTransactionDirectory(filepath.Dir(backup)); err != nil { + return fmt.Errorf("flush atomic backup directory: %w", err) + } + return nil +} + +func atomicReplacementUsesSeparateBackup() bool { + return true +} + +func transactionPlatformSupported() error { + return nil +} + +func syncWindowsRenameDirectories(source, destination string) error { + destinationDirectory := filepath.Dir(destination) + destinationErr := syncTransactionDirectory(destinationDirectory) + if durablePathKey(filepath.Dir(source)) == durablePathKey(destinationDirectory) { + return destinationErr + } + return errors.Join(destinationErr, syncTransactionDirectory(filepath.Dir(source))) +} + +func syncTransactionDirectory(path string) error { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return err + } + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_WRITE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_WRITE_THROUGH, + 0, + ) + if err != nil { + return fmt.Errorf("open directory for durable flush at %s: %w", path, err) + } + flushErr := windows.FlushFileBuffers(handle) + closeErr := windows.CloseHandle(handle) + if flushErr != nil { + flushErr = fmt.Errorf("flush directory metadata at %s: %w", path, flushErr) + } + return errors.Join(flushErr, closeErr) +} diff --git a/cmd/elydora/plugins/transaction_boundary_test.go b/cmd/elydora/plugins/transaction_boundary_test.go new file mode 100644 index 0000000..4ab6d57 --- /dev/null +++ b/cmd/elydora/plugins/transaction_boundary_test.go @@ -0,0 +1,353 @@ +package plugins + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func sameTransactionTestPath(left, right string) bool { + left = filepath.Clean(left) + right = filepath.Clean(right) + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func prepareTransactionRemoval(t *testing.T, path, label string) *fileChange { + t.Helper() + original, existed, err := readOptionalFile(path, label) + if err != nil { + t.Fatalf("read removal source: %v", err) + } + change, err := prepareSourceChange(path, label, original, existed, nil, 0600, true) + if err != nil { + t.Fatalf("prepare removal: %v", err) + } + return change +} + +func requireTransactionContent(t *testing.T, path, expected string) { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if string(content) != expected { + t.Fatalf("content at %s = %q, want %q", path, content, expected) + } +} + +func TestTransactionRejectsReplacementAtOriginalCaptureBoundary(t *testing.T) { + for _, test := range []struct { + name string + change func(*testing.T, string) *fileChange + }{ + { + name: "update", + change: func(t *testing.T, path string) *fileChange { + change, err := prepareFileChange(path, "boundary file", []byte("next"), 0600) + if err != nil { + t.Fatalf("prepare update: %v", err) + } + return change + }, + }, + {name: "delete", change: func(t *testing.T, path string) *fileChange { + return prepareTransactionRemoval(t, path, "boundary file") + }}, + } { + t.Run(test.name, func(t *testing.T) { + directory := t.TempDir() + target := filepath.Join(directory, "managed.json") + external := filepath.Join(directory, "original.external") + if err := os.WriteFile(target, []byte("original"), 0600); err != nil { + t.Fatalf("write original: %v", err) + } + change := test.change(t, target) + replaced := false + ops := transactionFileOps{moveNoReplace: func(source, destination string) error { + if !replaced && sameTransactionTestPath(source, target) && + strings.HasSuffix(destination, ".rollback") { + replaced = true + if err := os.Rename(target, external); err != nil { + return err + } + if err := os.WriteFile(target, []byte("original"), 0600); err != nil { + return err + } + } + return atomicRenameNoReplace(source, destination) + }} + + err := writeChangesWithFileOps( + []*fileChange{change}, + "capture boundary transaction", + ops, + ) + if err == nil || !strings.Contains(err.Error(), "atomic capture boundary") { + t.Fatalf("transaction error = %v", err) + } + requireTransactionContent(t, target, "original") + requireTransactionContent(t, external, "original") + }) + } +} + +func TestTransactionCreatePreservesObjectAppearingAtCommitBoundary(t *testing.T) { + directory := t.TempDir() + target := filepath.Join(directory, "created.json") + change, err := prepareFileChange(target, "created file", []byte("managed"), 0600) + if err != nil { + t.Fatalf("prepare create: %v", err) + } + appeared := false + ops := transactionFileOps{moveNoReplace: func(source, destination string) error { + if !appeared && sameTransactionTestPath(destination, target) && + strings.HasSuffix(source, ".tmp") { + appeared = true + if err := os.WriteFile(target, []byte("concurrent"), 0600); err != nil { + return err + } + } + return atomicRenameNoReplace(source, destination) + }} + + err = writeChangesWithFileOps( + []*fileChange{change}, + "create boundary transaction", + ops, + ) + if err == nil || !strings.Contains(err.Error(), "without replacing another file") { + t.Fatalf("transaction error = %v", err) + } + requireTransactionContent(t, target, "concurrent") +} + +func TestTransactionRollbackRestorePreservesObjectAppearingAtBoundary(t *testing.T) { + directory := t.TempDir() + first := filepath.Join(directory, "first.json") + second := filepath.Join(directory, "second.json") + if err := os.WriteFile(first, []byte("first-original"), 0600); err != nil { + t.Fatalf("write original: %v", err) + } + firstChange, err := prepareFileChange(first, "first file", []byte("first-next"), 0600) + if err != nil { + t.Fatalf("prepare first change: %v", err) + } + secondChange, err := prepareFileChange(second, "second file", []byte("second-next"), 0600) + if err != nil { + t.Fatalf("prepare second change: %v", err) + } + appeared := false + ops := transactionFileOps{moveNoReplace: func(source, destination string) error { + if sameTransactionTestPath(destination, second) && strings.HasSuffix(source, ".tmp") { + return errors.New("injected second commit failure") + } + if !appeared && sameTransactionTestPath(destination, first) && + strings.HasSuffix(source, ".rollback") { + appeared = true + if err := os.WriteFile(first, []byte("concurrent"), 0600); err != nil { + return err + } + } + return atomicRenameNoReplace(source, destination) + }} + + err = writeChangesWithFileOps( + []*fileChange{firstChange, secondChange}, + "rollback restore boundary transaction", + ops, + ) + if err == nil || !strings.Contains(err.Error(), "original content preserved at") { + t.Fatalf("transaction error = %v", err) + } + requireTransactionContent(t, first, "concurrent") + requirePreservedRollback(t, directory) +} + +func TestTransactionRollbackRestoreRejectsReplacedSourceAtBoundary(t *testing.T) { + directory := t.TempDir() + first := filepath.Join(directory, "first.json") + second := filepath.Join(directory, "second.json") + external := filepath.Join(directory, "original.external") + if err := os.WriteFile(first, []byte("first-original"), 0600); err != nil { + t.Fatalf("write original: %v", err) + } + firstChange, err := prepareFileChange(first, "first file", []byte("first-next"), 0600) + if err != nil { + t.Fatalf("prepare first change: %v", err) + } + secondChange, err := prepareFileChange(second, "second file", []byte("second-next"), 0600) + if err != nil { + t.Fatalf("prepare second change: %v", err) + } + rollbackPath := "" + replaced := false + ops := transactionFileOps{moveNoReplace: func(source, destination string) error { + if sameTransactionTestPath(source, first) && strings.HasSuffix(destination, ".rollback") { + rollbackPath = destination + } + if sameTransactionTestPath(destination, second) && strings.HasSuffix(source, ".tmp") { + return errors.New("injected second commit failure") + } + if !replaced && sameTransactionTestPath(source, rollbackPath) && + sameTransactionTestPath(destination, first) { + replaced = true + if err := os.Rename(rollbackPath, external); err != nil { + return err + } + if err := os.WriteFile(rollbackPath, []byte("concurrent"), 0600); err != nil { + return err + } + } + return atomicRenameNoReplace(source, destination) + }} + + err = writeChangesWithFileOps( + []*fileChange{firstChange, secondChange}, + "rollback source boundary transaction", + ops, + ) + if err == nil || !strings.Contains(err.Error(), "atomic restore boundary") { + t.Fatalf("transaction error = %v", err) + } + if _, statErr := os.Lstat(first); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("target exists after rejected rollback source: %v", statErr) + } + requireTransactionContent(t, rollbackPath, "concurrent") + requireTransactionContent(t, external, "first-original") +} + +func TestTransactionRollbackDeletePreservesReplacementAtBoundary(t *testing.T) { + directory := t.TempDir() + first := filepath.Join(directory, "first.json") + second := filepath.Join(directory, "second.json") + external := filepath.Join(directory, "managed.external") + firstChange, err := prepareFileChange(first, "first file", []byte("first-next"), 0600) + if err != nil { + t.Fatalf("prepare first change: %v", err) + } + secondChange, err := prepareFileChange(second, "second file", []byte("second-next"), 0600) + if err != nil { + t.Fatalf("prepare second change: %v", err) + } + replaced := false + ops := transactionFileOps{moveNoReplace: func(source, destination string) error { + if sameTransactionTestPath(destination, second) && strings.HasSuffix(source, ".tmp") { + return errors.New("injected second commit failure") + } + if !replaced && sameTransactionTestPath(source, first) && + strings.HasSuffix(destination, ".discard") { + replaced = true + if err := os.Rename(first, external); err != nil { + return err + } + if err := os.WriteFile(first, []byte("concurrent"), 0600); err != nil { + return err + } + } + return atomicRenameNoReplace(source, destination) + }} + + err = writeChangesWithFileOps( + []*fileChange{firstChange, secondChange}, + "rollback delete boundary transaction", + ops, + ) + if err == nil || !strings.Contains(err.Error(), "atomic rollback boundary") { + t.Fatalf("transaction error = %v", err) + } + requireTransactionContent(t, first, "concurrent") + requireTransactionContent(t, external, "first-next") +} + +func TestTransactionCleanupPreservesReplacedRollbackObject(t *testing.T) { + directory := t.TempDir() + target := filepath.Join(directory, "managed.json") + external := filepath.Join(directory, "original.external") + if err := os.WriteFile(target, []byte("original"), 0600); err != nil { + t.Fatalf("write original: %v", err) + } + change, err := prepareFileChange(target, "managed file", []byte("next"), 0600) + if err != nil { + t.Fatalf("prepare update: %v", err) + } + rollbackPath := "" + replaced := false + ops := transactionFileOps{moveNoReplace: func(source, destination string) error { + if sameTransactionTestPath(source, target) && strings.HasSuffix(destination, ".rollback") { + rollbackPath = destination + } + if err := atomicRenameNoReplace(source, destination); err != nil { + return err + } + if !replaced && sameTransactionTestPath(destination, target) && + strings.HasSuffix(source, ".tmp") { + replaced = true + if err := os.Rename(rollbackPath, external); err != nil { + return err + } + if err := os.WriteFile(rollbackPath, []byte("concurrent"), 0600); err != nil { + return err + } + } + return nil + }} + + err = writeChangesWithFileOps( + []*fileChange{change}, + "cleanup boundary transaction", + ops, + ) + if err == nil || !strings.Contains(err.Error(), "changed during cleanup") { + t.Fatalf("transaction error = %v", err) + } + requireTransactionContent(t, target, "next") + requireTransactionContent(t, rollbackPath, "concurrent") + requireTransactionContent(t, external, "original") +} + +func TestDurableJournalStateRejectsOversizedPhysicalFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "state-00000000000000000001.json") + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + if err := file.Truncate(maxDurableJournalBytes + 1); err != nil { + t.Fatal(errors.Join(err, file.Close())) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + if _, err := readDurableJournalState(path); err == nil || + !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("oversized journal error = %v", err) + } +} + +func TestDurableJournalStateRejectsSymlink(t *testing.T) { + directory := t.TempDir() + target := filepath.Join(directory, "target.json") + link := filepath.Join(directory, "state-00000000000000000001.json") + if err := os.WriteFile(target, []byte("{}\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := readDurableJournalState(link); err == nil || + !strings.Contains(err.Error(), "not a physical file") { + t.Fatalf("journal symlink error = %v", err) + } +} + +func TestAtomicDestinationExistsRejectsUnrelatedFailure(t *testing.T) { + if isAtomicDestinationExists(os.ErrPermission) { + t.Fatal("permission failure was classified as an occupied destination") + } +} diff --git a/cmd/elydora/plugins/transaction_cleanup.go b/cmd/elydora/plugins/transaction_cleanup.go new file mode 100644 index 0000000..685007e --- /dev/null +++ b/cmd/elydora/plugins/transaction_cleanup.go @@ -0,0 +1,487 @@ +package plugins + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +type durableCleanupDisposition string + +const ( + cleanupCommitted durableCleanupDisposition = "committed" + cleanupRolledBack durableCleanupDisposition = "rolled_back" +) + +func finishCommittedDurableTransaction(journal *durableJournal) error { + if err := verifyCommittedDurableTransactionForCleanup(journal); err != nil { + return err + } + for _, workspace := range journal.Workspaces { + if err := cleanupDurableWorkspace(journal, workspace, cleanupCommitted); err != nil { + return err + } + } + return cleanupDurableJournalNamespace(journal) +} + +func verifyCommittedDurableTransactionForCleanup(journal *durableJournal) error { + for index := range journal.Entries { + entry := &journal.Entries[index] + wantTarget := entry.Next + if entry.Kind == durableDelete { + wantTarget = durableArtifact{} + } + if _, err := requireDurableArtifact(entry.Path, entry.Label, wantTarget); err != nil { + return err + } + switch entry.Kind { + case durableUpdate, durableDelete: + original, err := readDurableArtifact(entry.OriginalPath, "original "+entry.Label) + if err != nil { + return err + } + if original != nil && !ownedArtifactMatches(entry.Original, original) { + return fmt.Errorf("committed original changed; preserved at %s", entry.OriginalPath) + } + case durableCreate: + if _, err := requireDurableArtifact( + entry.NextPath, + "consumed staged "+entry.Label, + durableArtifact{}, + ); err != nil { + return err + } + default: + return fmt.Errorf("unknown durable change kind %q", entry.Kind) + } + } + return nil +} + +func cleanupRolledBackDurableTransaction(journal *durableJournal) error { + for index := range journal.Entries { + entry := &journal.Entries[index] + if durableEntryDefinitelyUnattempted(journal, index) { + continue + } + if journal.ActiveEntry != nil && *journal.ActiveEntry == index { + uncommitted, err := durableEntryHasUncommittedWorkspaceState(entry) + if err != nil { + return err + } + if uncommitted { + continue + } + } + if _, err := requireDurableArtifact(entry.Path, entry.Label, entry.Original); err != nil { + return fmt.Errorf("rollback verification failed for %s: %w", entry.Label, err) + } + } + for _, workspace := range journal.Workspaces { + if err := cleanupDurableWorkspace(journal, workspace, cleanupRolledBack); err != nil { + return err + } + } + return cleanupDurableJournalNamespace(journal) +} + +func durableEntryHasUncommittedWorkspaceState(entry *durableEntry) (bool, error) { + switch entry.Kind { + case durableCreate, durableUpdate: + return durableEntryHasStagedNext(entry) + case durableDelete: + original, err := readDurableArtifact(entry.OriginalPath, "captured deleted "+entry.Label) + return original == nil, err + default: + return false, fmt.Errorf("unknown durable change kind %q", entry.Kind) + } +} + +func cleanupDurableWorkspace( + journal *durableJournal, + workspace durableWorkspace, + disposition durableCleanupDisposition, +) error { + info, err := os.Lstat(workspace.Path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return errors.Join(fmt.Errorf("transaction workspace changed: %s", workspace.Path), err) + } + if err := verifyPrivateTransactionDirectory(workspace.Path); err != nil { + return err + } + if workspace.DirectoryID == "" { + return fmt.Errorf("transaction workspace identity is unavailable; preserved at %s", workspace.Path) + } + identity, identityErr := transactionDirectoryIdentity(workspace.Path) + if identityErr != nil || identity != workspace.DirectoryID { + return errors.Join( + fmt.Errorf("transaction workspace identity changed: %s", workspace.Path), + identityErr, + ) + } + marker, err := readManagedFile( + workspace.MarkerPath, + "transaction workspace owner", + maxDurableJournalBytes, + ) + wantMarker := journal.ID + ":" + workspace.OwnerToken + "\n" + if err != nil { + return errors.Join(fmt.Errorf("transaction workspace owner changed: %s", workspace.Path), err) + } + if marker == nil { + entries, readErr := os.ReadDir(workspace.Path) + if readErr != nil || len(entries) != 0 { + return errors.Join( + fmt.Errorf("transaction workspace owner changed: %s", workspace.Path), + readErr, + ) + } + identity, identityErr = transactionDirectoryIdentity(workspace.Path) + if identityErr != nil || identity != workspace.DirectoryID { + return errors.Join( + fmt.Errorf("transaction workspace changed before resumed removal: %s", workspace.Path), + identityErr, + ) + } + if err := os.Remove(workspace.Path); err != nil { + return fmt.Errorf("remove empty transaction workspace %s: %w", workspace.Path, err) + } + return syncTransactionDirectory(filepath.Dir(workspace.Path)) + } + if string(marker.contents) != wantMarker { + return fmt.Errorf("transaction workspace owner changed: %s", workspace.Path) + } + if err := cleanupInterruptedCapabilityProbe(workspace.Path); err != nil { + return fmt.Errorf("clean interrupted transaction capability probe: %w", err) + } + expected := expectedWorkspaceArtifacts(journal, workspace.Path, disposition) + entries, err := os.ReadDir(workspace.Path) + if err != nil { + return err + } + for _, item := range entries { + path := filepath.Join(workspace.Path, item.Name()) + if path == workspace.MarkerPath { + continue + } + allowed, known := expected[path] + if !known { + return fmt.Errorf("unexpected object preserved in transaction workspace: %s", path) + } + if err := removeExpectedOwnedArtifact(path, allowed); err != nil { + return err + } + } + identity, identityErr = transactionDirectoryIdentity(workspace.Path) + if identityErr != nil || identity != workspace.DirectoryID { + return errors.Join( + fmt.Errorf("transaction workspace changed before removal: %s", workspace.Path), + identityErr, + ) + } + if err := syncTransactionDirectory(workspace.Path); err != nil { + return fmt.Errorf("persist transaction workspace artifact cleanup at %s: %w", workspace.Path, err) + } + if err := removePhysicalOwnedFile(workspace.MarkerPath, marker.identity); err != nil { + return fmt.Errorf("remove transaction workspace owner at %s: %w", workspace.MarkerPath, err) + } + if err := syncTransactionDirectory(workspace.Path); err != nil { + return fmt.Errorf("persist transaction workspace owner cleanup at %s: %w", workspace.Path, err) + } + if err := os.Remove(workspace.Path); err != nil { + return fmt.Errorf("remove empty transaction workspace %s: %w", workspace.Path, err) + } + return syncTransactionDirectory(filepath.Dir(workspace.Path)) +} + +func expectedWorkspaceArtifacts( + journal *durableJournal, + workspace string, + disposition durableCleanupDisposition, +) map[string][]durableArtifact { + expected := make(map[string][]durableArtifact) + add := func(path string, artifact durableArtifact) { + if path != "" && filepath.Clean(filepath.Dir(path)) == filepath.Clean(workspace) { + expected[path] = append(expected[path], artifact) + } + } + for _, entry := range journal.Entries { + if filepath.Clean(entry.Workspace) != filepath.Clean(workspace) { + continue + } + if disposition == cleanupCommitted { + switch entry.Kind { + case durableUpdate, durableDelete: + add(entry.OriginalPath, entry.Original) + } + } else { + add(entry.NextPath, entry.Next) + add(entry.OriginalPath, entry.Next) + add(entry.OriginalPath, entry.Original) + add(entry.DiscardPath, entry.Next) + } + add(entry.NextPath, durableArtifact{}) + add(entry.OriginalPath, durableArtifact{}) + add(entry.DiscardPath, durableArtifact{}) + } + captures := make(map[string][]durableArtifact, len(expected)) + for path, artifacts := range expected { + captures[path+".cleanup"] = artifacts + } + for path, artifacts := range captures { + expected[path] = artifacts + } + return expected +} + +func removeExpectedOwnedArtifact(path string, allowed []durableArtifact) error { + current, err := readDurableArtifact(path, "transaction artifact") + if err != nil { + return fmt.Errorf("inspect owned transaction artifact at %s: %w", path, err) + } + if current == nil { + return nil + } + for _, expected := range allowed { + if ownedArtifactMatches(expected, current) { + if strings.HasSuffix(path, ".cleanup") { + return removePhysicalOwnedFile(path, current.identity) + } + return captureAndRemoveExpectedArtifact(path, allowed) + } + } + return fmt.Errorf("changed transaction artifact preserved at %s", path) +} + +func captureAndRemoveExpectedArtifact(path string, allowed []durableArtifact) error { + capture := path + ".cleanup" + occupied, err := readDurableArtifact(capture, "cleanup capture") + if err != nil || occupied != nil { + return errors.Join(fmt.Errorf("cleanup capture path is occupied: %s", capture), err) + } + if err := atomicRenameNoReplace(path, capture); err != nil { + return fmt.Errorf("atomically capture owned transaction artifact at %s: %w", path, err) + } + captured, err := readDurableArtifact(capture, "captured transaction artifact") + if err == nil { + for _, expected := range allowed { + if ownedArtifactMatches(expected, captured) { + return removePhysicalOwnedFile(capture, captured.identity) + } + } + } + restoreErr := atomicRenameNoReplace(capture, path) + return errors.Join( + fmt.Errorf("changed transaction artifact preserved at %s", capture), + err, + restoreErr, + ) +} + +func ownedArtifactMatches(expected durableArtifact, current *managedFileSnapshot) bool { + if current == nil || !expected.Exists || expected.Identity == "" { + return false + } + if current.identity != expected.Identity { + return false + } + return artifactDigest(current.contents) == expected.SHA256 && + sameManagedFileMode(current.mode, os.FileMode(expected.Mode)) +} + +func removePhysicalOwnedFile(path string, expectedIdentity ...string) error { + current, err := readManagedFile(path, "owned transaction artifact", maxDurableJournalBytes) + if err != nil { + return err + } + if current == nil { + return nil + } + if len(expectedIdentity) > 0 && expectedIdentity[0] != "" && + current.identity != expectedIdentity[0] { + return fmt.Errorf("owned transaction object changed identity; preserved at %s", path) + } + latest, err := os.Lstat(path) + if err != nil || latest.Mode()&os.ModeSymlink != 0 || + !latest.Mode().IsRegular() || !os.SameFile(current.info, latest) { + return errors.Join( + fmt.Errorf("owned transaction object changed before removal; preserved at %s", path), + err, + ) + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove owned transaction artifact at %s: %w", path, err) + } + return nil +} + +func cleanupDurableJournalNamespace(journal *durableJournal) error { + return cleanupOwnedJournalDirectory( + journal.JournalDir, + journal.ID, + journal.OwnerToken, + journal.Sequence, + ) +} + +func cleanupEmptyDurableJournal(directory string) error { + base := filepath.Base(directory) + if !strings.HasPrefix(base, "txn-") { + return fmt.Errorf("invalid empty journal namespace: %s", directory) + } + if err := verifyPrivateTransactionDirectory(directory); err != nil { + return err + } + entries, readErr := os.ReadDir(directory) + if readErr != nil { + return readErr + } + if len(entries) == 0 { + if err := os.Remove(directory); err != nil { + return fmt.Errorf("remove empty recovery namespace %s: %w", directory, err) + } + return syncTransactionDirectory(filepath.Dir(directory)) + } + type incompleteJournalFile struct { + path string + identity string + } + files := make([]incompleteJournalFile, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || entry.Name() != "owner" && !isDurableJournalPending(entry.Name()) { + return fmt.Errorf("invalid journal state preserved for manual recovery at %s", directory) + } + path := filepath.Join(directory, entry.Name()) + snapshot, err := readManagedFile(path, "incomplete transaction journal file", maxDurableJournalBytes) + if err != nil || snapshot == nil { + return errors.Join(fmt.Errorf("incomplete journal file changed: %s", path), err) + } + files = append(files, incompleteJournalFile{path: path, identity: snapshot.identity}) + } + for _, file := range files { + if err := removePhysicalOwnedFile(file.path, file.identity); err != nil { + return err + } + } + if err := syncTransactionDirectory(directory); err != nil { + return fmt.Errorf("persist incomplete journal file cleanup at %s: %w", directory, err) + } + if err := os.Remove(directory); err != nil { + return fmt.Errorf("remove incomplete recovery namespace %s: %w", directory, err) + } + return syncTransactionDirectory(filepath.Dir(directory)) +} + +func isDurableJournalPending(name string) bool { + _, pending := durableJournalPendingSequence(name) + return pending +} + +func cleanupOwnedJournalDirectory( + directory, id, ownerToken string, + latestSequence uint64, +) error { + wantOwner := id + ":" + ownerToken + "\n" + ownerPath := filepath.Join(directory, "owner") + owner, err := readManagedFile(ownerPath, "transaction journal owner", maxDurableJournalBytes) + if err != nil || owner == nil || string(owner.contents) != wantOwner { + return errors.Join(fmt.Errorf("journal owner changed; preserved at %s", directory), err) + } + entries, err := os.ReadDir(directory) + if err != nil { + return err + } + type journalFileRemoval struct { + path string + identity string + } + recoverableFiles := make([]journalFileRemoval, 0) + validStates := make([]journalFileRemoval, 0) + for _, entry := range entries { + if entry.Name() == "owner" { + continue + } + if entry.IsDir() { + return fmt.Errorf("unexpected journal object preserved at %s", filepath.Join(directory, entry.Name())) + } + path := filepath.Join(directory, entry.Name()) + if _, pending := durableJournalPendingSequence(entry.Name()); pending { + snapshot, err := inspectRecoverableJournalFile(path) + if err != nil { + return err + } + recoverableFiles = append( + recoverableFiles, + journalFileRemoval{path: path, identity: snapshot.identity}, + ) + continue + } + sequence, valid := durableJournalStateSequence(entry.Name()) + if !valid { + return fmt.Errorf("unexpected journal file preserved at %s", filepath.Join(directory, entry.Name())) + } + state, stateErr := readDurableJournalState(path) + if stateErr != nil { + if sequence > latestSequence { + snapshot, err := inspectRecoverableJournalFile(path) + if err != nil { + return err + } + recoverableFiles = append( + recoverableFiles, + journalFileRemoval{path: path, identity: snapshot.identity}, + ) + continue + } + return fmt.Errorf("invalid journal file preserved at %s: %w", path, stateErr) + } + if err := validateLoadedJournal(state, directory, entry.Name()); err != nil { + return fmt.Errorf("changed journal file preserved at %s: %w", path, err) + } + snapshot, err := readManagedFile(path, "transaction journal state", maxDurableJournalBytes) + if err != nil || snapshot == nil { + return errors.Join(fmt.Errorf("transaction journal state changed: %s", path), err) + } + validStates = append( + validStates, + journalFileRemoval{path: path, identity: snapshot.identity}, + ) + } + for _, file := range recoverableFiles { + if err := removePhysicalOwnedFile(file.path, file.identity); err != nil { + return err + } + } + for _, file := range validStates { + if err := removePhysicalOwnedFile(file.path, file.identity); err != nil { + return err + } + } + if err := syncTransactionDirectory(directory); err != nil { + return fmt.Errorf("persist transaction journal state cleanup at %s: %w", directory, err) + } + if err := removePhysicalOwnedFile(ownerPath, owner.identity); err != nil { + return err + } + if err := syncTransactionDirectory(directory); err != nil { + return fmt.Errorf("persist transaction journal owner cleanup at %s: %w", directory, err) + } + if err := os.Remove(directory); err != nil { + return err + } + return syncTransactionDirectory(filepath.Dir(directory)) +} + +func inspectRecoverableJournalFile(path string) (*managedFileSnapshot, error) { + snapshot, err := readManagedFile(path, "recoverable transaction journal state", maxDurableJournalBytes) + if err != nil || snapshot == nil { + return nil, errors.Join(fmt.Errorf("transaction journal state changed: %s", path), err) + } + return snapshot, nil +} diff --git a/cmd/elydora/plugins/transaction_commit.go b/cmd/elydora/plugins/transaction_commit.go new file mode 100644 index 0000000..a87a88e --- /dev/null +++ b/cmd/elydora/plugins/transaction_commit.go @@ -0,0 +1,462 @@ +package plugins + +import ( + "errors" + "fmt" + "path/filepath" +) + +func commitChange(item *stagedChange, ops transactionFileOps) error { + if err := assertFileUnchanged(item.change); err != nil { + return err + } + if item.change.existed { + if err := captureOriginalFile(item, ops); err != nil { + return err + } + } + if item.change.remove { + item.committed = true + return nil + } + if item.temporaryPath == "" || item.temporaryInfo == nil { + return fmt.Errorf("missing staged file for %s", item.change.label) + } + if err := ops.moveNoReplace(item.temporaryPath, item.change.filePath); err != nil { + failure := fmt.Errorf( + "commit %s at %s without replacing another file: %w", + item.change.label, + item.change.filePath, + err, + ) + return errors.Join(failure, compensateFailedCommit(item, ops)) + } + item.temporaryPath = "" + item.committedInfo = item.temporaryInfo + item.committed = true + if err := assertCommittedFileUnchanged(item); err != nil { + return fmt.Errorf("%w immediately after commit", err) + } + return nil +} + +func compensateFailedCommit(item *stagedChange, ops transactionFileOps) error { + if !item.originalCaptured { + return nil + } + restoreErr := ops.moveNoReplace(item.rollbackPath, item.change.filePath) + if restoreErr == nil { + return finishOriginalRestore(item, ops) + } + current, inspectErr := readManagedFile( + item.change.filePath, + item.change.label, + maxManagedSourceBytes, + ) + if inspectErr == nil && snapshotMatches( + current, + true, + item.change.original, + item.change.originalInfo, + item.change.originalMode, + ) { + item.rollbackPath = "" + item.originalCaptured = false + return nil + } + var fallbackErr error + if inspectErr == nil && current == nil { + compensate := ops.compensateNoReplace + if compensate == nil { + compensate = atomicRenameNoReplace + } + fallbackErr = compensate(item.rollbackPath, item.change.filePath) + if fallbackErr == nil { + return finishOriginalRestore(item, ops) + } + } + cause := errors.Join( + fmt.Errorf("restore %s after failed commit at %s", item.change.label, item.change.filePath), + restoreErr, + fallbackErr, + inspectErr, + ) + return preserveRollbackFile(item, cause) +} + +func captureOriginalFile(item *stagedChange, ops transactionFileOps) error { + if item.rollbackPath == "" { + return fmt.Errorf("missing rollback path for %s", item.change.label) + } + if err := ops.moveNoReplace(item.change.filePath, item.rollbackPath); err != nil { + return fmt.Errorf( + "capture original %s at %s: %w", + item.change.label, + item.change.filePath, + err, + ) + } + current, err := readManagedFile( + item.rollbackPath, + "captured "+item.change.label, + maxManagedSourceBytes, + ) + if err != nil || !snapshotMatches( + current, + true, + item.change.original, + item.change.originalInfo, + item.change.originalMode, + ) { + cause := err + if cause == nil { + cause = fmt.Errorf( + "%s changed at the atomic capture boundary: %s", + item.change.label, + item.change.filePath, + ) + } + return restoreUnexpectedCapture(item, current, cause, ops) + } + item.originalCaptured = true + return nil +} + +func restoreUnexpectedCapture( + item *stagedChange, + captured *managedFileSnapshot, + cause error, + ops transactionFileOps, +) error { + path := item.rollbackPath + if err := ops.moveNoReplace(path, item.change.filePath); err != nil { + item.rollbackPath = "" + return errors.Join( + cause, + fmt.Errorf( + "restore concurrently captured %s at %s: %w; captured object preserved at %s", + item.change.label, + item.change.filePath, + err, + path, + ), + ) + } + if captured != nil { + restored, err := readManagedFile( + item.change.filePath, + "restored concurrent "+item.change.label, + maxManagedSourceBytes, + ) + if err != nil || !snapshotMatches( + restored, + true, + captured.contents, + captured.info, + captured.mode, + ) { + if preserveErr := ops.moveNoReplace(item.change.filePath, path); preserveErr != nil { + item.rollbackPath = "" + return errors.Join( + cause, + err, + fmt.Errorf( + "concurrent %s changed while restoring: %w; objects left at %s and %s", + item.change.label, + preserveErr, + item.change.filePath, + path, + ), + ) + } + item.rollbackPath = "" + return errors.Join( + cause, + err, + fmt.Errorf("changed concurrent object preserved at %s", path), + ) + } + } + item.rollbackPath = "" + return cause +} + +func assertCommittedChanges(staged []stagedChange) error { + for index := range staged { + item := &staged[index] + if item.committed { + if err := assertCommittedFileUnchanged(item); err != nil { + return err + } + } + } + return nil +} + +func assertCommittedFileUnchanged(item *stagedChange) error { + current, err := readManagedFile( + item.change.filePath, + item.change.label, + maxManagedSourceBytes, + ) + if err != nil { + return err + } + if item.change.remove { + if current == nil { + return nil + } + } else if snapshotMatches( + current, + true, + item.change.next, + item.committedInfo, + item.change.mode, + ) { + return nil + } + return fmt.Errorf( + "%s changed during transaction recovery: %s", + item.change.label, + item.change.filePath, + ) +} + +func rollbackChanges(staged []stagedChange, ops transactionFileOps) []error { + failures := make([]error, 0) + for index := len(staged) - 1; index >= 0; index-- { + item := &staged[index] + if !item.committed && !item.originalCaptured { + continue + } + var err error + switch { + case item.committed && !item.change.remove: + err = rollbackWrittenFile(item, ops) + default: + err = restoreOriginalFile(item, ops) + } + if err != nil { + failures = append(failures, err) + } + } + return failures +} + +func rollbackWrittenFile(item *stagedChange, ops transactionFileOps) error { + discardPath, err := reserveStagingPath( + filepath.Dir(item.change.filePath), + filepath.Base(item.change.filePath), + ".discard", + ) + if err != nil { + return preserveOriginalAfterRollbackFailure( + item, + fmt.Errorf("reserve recovery path for %s: %w", item.change.label, err), + ) + } + if err := ops.moveNoReplace(item.change.filePath, discardPath); err != nil { + return preserveOriginalAfterRollbackFailure( + item, + fmt.Errorf("capture committed %s for rollback: %w", item.change.label, err), + ) + } + captured, readErr := readManagedFile( + discardPath, + "captured committed "+item.change.label, + maxManagedSourceBytes, + ) + if readErr != nil || !snapshotMatches( + captured, + true, + item.change.next, + item.committedInfo, + item.change.mode, + ) { + cause := readErr + if cause == nil { + cause = fmt.Errorf( + "%s changed at the atomic rollback boundary: %s", + item.change.label, + item.change.filePath, + ) + } + restoreErr := ops.moveNoReplace(discardPath, item.change.filePath) + if restoreErr != nil { + cause = errors.Join( + cause, + fmt.Errorf( + "restore concurrently captured %s: %w; captured object preserved at %s", + item.change.label, + restoreErr, + discardPath, + ), + ) + } else if captured != nil { + restored, inspectErr := readManagedFile( + item.change.filePath, + "restored concurrent "+item.change.label, + maxManagedSourceBytes, + ) + if inspectErr != nil || !snapshotMatches( + restored, + true, + captured.contents, + captured.info, + captured.mode, + ) { + preserveErr := ops.moveNoReplace(item.change.filePath, discardPath) + preservation := fmt.Errorf( + "changed concurrent object preserved at %s", + discardPath, + ) + if preserveErr != nil { + preservation = fmt.Errorf( + "preserve changed concurrent %s: %w; objects left at %s and %s", + item.change.label, + preserveErr, + item.change.filePath, + discardPath, + ) + } + cause = errors.Join( + cause, + inspectErr, + preservation, + ) + } + } + return preserveOriginalAfterRollbackFailure(item, cause) + } + + item.committed = false + var restoreErr error + if item.change.existed { + restoreErr = restoreOriginalFile(item, ops) + } + cleanupErr := removeVerifiedStagedFile( + discardPath, + "captured committed "+item.change.label, + item.change.next, + item.committedInfo, + item.change.mode, + ) + return errors.Join(restoreErr, cleanupErr) +} + +func restoreOriginalFile(item *stagedChange, ops transactionFileOps) error { + if !item.originalCaptured { + return nil + } + path := item.rollbackPath + if err := ops.moveNoReplace(path, item.change.filePath); err != nil { + cause := fmt.Errorf( + "restore %s at %s without replacing another file: %w", + item.change.label, + item.change.filePath, + err, + ) + current, inspectErr := readManagedFile( + path, + "rollback "+item.change.label, + maxManagedSourceBytes, + ) + if inspectErr == nil && snapshotMatches( + current, + true, + item.change.original, + item.change.originalInfo, + item.change.originalMode, + ) { + return preserveRollbackFile(item, cause) + } + return preserveChangedRollback(item, errors.Join(cause, inspectErr)) + } + return finishOriginalRestore(item, ops) +} + +func finishOriginalRestore(item *stagedChange, ops transactionFileOps) error { + current, err := readManagedFile( + item.change.filePath, + item.change.label, + maxManagedSourceBytes, + ) + if err == nil && snapshotMatches( + current, + true, + item.change.original, + item.change.originalInfo, + item.change.originalMode, + ) { + item.rollbackPath = "" + item.originalCaptured = false + item.committed = false + return nil + } + cause := err + if cause == nil { + cause = fmt.Errorf( + "rollback %s changed at the atomic restore boundary: %s", + item.change.label, + item.change.filePath, + ) + } + path := item.rollbackPath + if restoreErr := ops.moveNoReplace(item.change.filePath, path); restoreErr != nil { + item.rollbackPath = "" + item.originalCaptured = false + return errors.Join( + cause, + fmt.Errorf( + "preserve changed rollback object: %w; objects left at %s and %s", + restoreErr, + item.change.filePath, + path, + ), + ) + } + item.rollbackPath = "" + item.originalCaptured = false + return fmt.Errorf("%w; changed rollback object preserved at %s", cause, path) +} + +func preserveChangedRollback(item *stagedChange, cause error) error { + path := item.rollbackPath + item.rollbackPath = "" + item.originalCaptured = false + return fmt.Errorf("%w; changed rollback object preserved at %s", cause, path) +} + +func preserveOriginalAfterRollbackFailure(item *stagedChange, cause error) error { + if !item.change.existed || !item.originalCaptured { + return cause + } + return preserveRollbackFile(item, cause) +} + +func preserveRollbackFile(item *stagedChange, cause error) error { + if item.rollbackPath == "" { + return cause + } + path := item.rollbackPath + current, err := readManagedFile( + path, + "rollback "+item.change.label, + maxManagedSourceBytes, + ) + if err != nil || !snapshotMatches( + current, + true, + item.change.original, + item.change.originalInfo, + item.change.originalMode, + ) { + return preserveChangedRollback( + item, + errors.Join(cause, err, fmt.Errorf("rollback %s changed", item.change.label)), + ) + } + item.rollbackPath = "" + item.originalCaptured = false + return fmt.Errorf("%w; original content preserved at %s", cause, path) +} diff --git a/cmd/elydora/plugins/transaction_durable.go b/cmd/elydora/plugins/transaction_durable.go new file mode 100644 index 0000000..05e7ddb --- /dev/null +++ b/cmd/elydora/plugins/transaction_durable.go @@ -0,0 +1,236 @@ +package plugins + +import ( + "bytes" + "errors" + "fmt" + "path/filepath" + "runtime" + "strings" +) + +func writeDurableChanges( + changes []*fileChange, + label string, + ops transactionFileOps, + preconditions ...filePrecondition, +) (result error) { + if err := transactionPlatformSupported(); err != nil { + return fmt.Errorf("%s: %w", label, err) + } + if ops.moveNoReplace == nil || ops.replaceWithBackup == nil { + return fmt.Errorf("%s is missing durable atomic file operations", label) + } + stateRoot, err := durableTransactionStateRoot() + if err != nil { + return fmt.Errorf("%s: prepare durable transaction state: %w", label, err) + } + lock, err := acquireDurableTransactionLock(stateRoot) + if err != nil { + return fmt.Errorf("%s: %w", label, err) + } + defer func() { + result = errors.Join(result, lock.close()) + }() + if err := recoverPendingDurableTransactions(stateRoot, ops); err != nil { + return fmt.Errorf("%s: pending transaction recovery failed: %w", label, err) + } + filtered, err := filterDurableChanges(changes, label) + if err != nil { + return err + } + if err := assertFilePreconditions(preconditions, label); err != nil { + return fmt.Errorf("%s: %w", label, err) + } + if len(filtered) == 0 { + return nil + } + journal, err := createDurableJournal(stateRoot, label) + if err != nil { + return err + } + if err := initializeDurableLayout(journal, filtered); err != nil { + return preserveUnjournaledNamespace(journal, err) + } + if err := appendDurableJournal(journal, durablePhaseInitializing); err != nil { + return preserveUnjournaledNamespace(journal, err) + } + abort := func(cause error) error { + return abortDurableTransaction(journal, ops, cause) + } + if err := createDurableWorkspaces(journal); err != nil { + return abort(err) + } + if err := stageDurableEntries(journal, filtered); err != nil { + return abort(err) + } + if err := appendDurableJournal(journal, durablePhasePrepared); err != nil { + return abort(err) + } + if err := assertDurableOriginals(journal); err != nil { + return abort(err) + } + if err := assertFilePreconditions(preconditions, label); err != nil { + return abort(fmt.Errorf("%s: %w", label, err)) + } + for index := range journal.Entries { + if err := assertFilePreconditions(preconditions, label); err != nil { + return abort(fmt.Errorf("%s: %w", label, err)) + } + activeIndex := index + journal.ActiveEntry = &activeIndex + if err := appendDurableJournal(journal, durablePhaseCommitting); err != nil { + return abort(err) + } + if err := commitDurableEntry(&journal.Entries[index], ops); err != nil { + return abort(err) + } + journal.CompletedEntries = index + 1 + journal.ActiveEntry = nil + if err := appendDurableJournal(journal, durablePhaseCommitting); err != nil { + return abort(err) + } + } + if err := assertFilePreconditions(preconditions, label); err != nil { + return abort(fmt.Errorf("%s: %w", label, err)) + } + if err := verifyDurableJournalCommitted(journal); err != nil { + return abort(err) + } + if err := appendDurableJournal(journal, durablePhaseCommitted); err != nil { + return abort(err) + } + if err := finishCommittedDurableTransaction(journal); err != nil { + return fmt.Errorf( + "%s committed; durable cleanup failed: %w; recovery journal %s", + label, + err, + journal.JournalDir, + ) + } + return nil +} + +func filterDurableChanges(changes []*fileChange, label string) ([]fileChange, error) { + filtered := make([]fileChange, 0, len(changes)) + targets := make(map[string]bool) + for _, change := range changes { + if change == nil || change.existed && !change.remove && + bytes.Equal(change.original, change.next) && + sameManagedFileMode(change.originalMode, change.mode) { + continue + } + target, err := filepath.Abs(change.filePath) + if err != nil { + return nil, fmt.Errorf( + "%s cannot resolve target %s: %w", + label, + change.filePath, + err, + ) + } + target = filepath.Clean(target) + change.filePath = target + if runtime.GOOS == "windows" { + target = strings.ToLower(target) + } + if targets[target] { + return nil, fmt.Errorf("%s contains duplicate file target %s", label, change.filePath) + } + targets[target] = true + filtered = append(filtered, *change) + } + return filtered, nil +} + +func preserveUnjournaledNamespace(journal *durableJournal, cause error) error { + return fmt.Errorf( + "%w; transaction namespace preserved at %s", + cause, + journal.JournalDir, + ) +} + +func assertDurableOriginals(journal *durableJournal) error { + for _, entry := range journal.Entries { + if _, err := requireDurableArtifact(entry.Path, entry.Label, entry.Original); err != nil { + return fmt.Errorf("%s changed during installation: %w", entry.Label, err) + } + if entry.NextPath != "" { + if _, err := requireDurableArtifact( + entry.NextPath, + "staged "+entry.Label, + entry.Next, + ); err != nil { + return err + } + } + } + return nil +} + +func commitDurableEntry(entry *durableEntry, ops transactionFileOps) error { + if _, err := requireDurableArtifact(entry.Path, entry.Label, entry.Original); err != nil { + return fmt.Errorf("%s changed at commit boundary: %w", entry.Label, err) + } + switch entry.Kind { + case durableCreate: + if err := ops.moveNoReplace(entry.NextPath, entry.Path); err != nil { + return fmt.Errorf("create %s without replacement: %w", entry.Label, err) + } + case durableUpdate: + if _, err := requireDurableArtifact( + entry.NextPath, + "staged "+entry.Label, + entry.Next, + ); err != nil { + return err + } + if entry.OriginalPath != entry.NextPath { + if current, err := readDurableArtifact(entry.OriginalPath, "backup "+entry.Label); err != nil || current != nil { + return errors.Join(fmt.Errorf("backup path is occupied: %s", entry.OriginalPath), err) + } + } + if err := ops.replaceWithBackup(entry.Path, entry.NextPath, entry.OriginalPath); err != nil { + return fmt.Errorf("atomically replace %s: %w", entry.Label, err) + } + case durableDelete: + if err := ops.moveNoReplace(entry.Path, entry.OriginalPath); err != nil { + return fmt.Errorf("capture deleted %s: %w", entry.Label, err) + } + default: + return fmt.Errorf("unknown durable change kind %q", entry.Kind) + } + if err := verifyDurableEntryCommitted(entry); err != nil { + return fmt.Errorf("%s changed immediately after durable commit: %w", entry.Label, err) + } + return syncTransactionDirectory(filepath.Dir(entry.Path)) +} + +func verifyDurableEntryCommitted(entry *durableEntry) error { + wantTarget := entry.Next + if entry.Kind == durableDelete { + wantTarget = durableArtifact{} + } + if _, err := requireDurableArtifact(entry.Path, entry.Label, wantTarget); err != nil { + return err + } + switch entry.Kind { + case durableUpdate, durableDelete: + _, err := requireDurableArtifact(entry.OriginalPath, "original "+entry.Label, entry.Original) + return err + case durableCreate: + _, err := requireDurableArtifact(entry.NextPath, "consumed staged "+entry.Label, durableArtifact{}) + return err + } + return nil +} + +func verifyDurableJournalCommitted(journal *durableJournal) error { + for index := range journal.Entries { + if err := verifyDurableEntryCommitted(&journal.Entries[index]); err != nil { + return err + } + } + return nil +} diff --git a/cmd/elydora/plugins/transaction_durable_types.go b/cmd/elydora/plugins/transaction_durable_types.go new file mode 100644 index 0000000..4c0cf57 --- /dev/null +++ b/cmd/elydora/plugins/transaction_durable_types.go @@ -0,0 +1,140 @@ +package plugins + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" +) + +const durableTransactionVersion = 2 + +type durableTransactionPhase string + +const ( + durablePhaseInitializing durableTransactionPhase = "initializing" + durablePhasePrepared durableTransactionPhase = "prepared" + durablePhaseCommitting durableTransactionPhase = "committing" + durablePhaseCommitted durableTransactionPhase = "committed" + durablePhaseRollingBack durableTransactionPhase = "rolling_back" +) + +type durableChangeKind string + +const ( + durableCreate durableChangeKind = "create" + durableUpdate durableChangeKind = "update" + durableDelete durableChangeKind = "delete" +) + +type durableArtifact struct { + Exists bool `json:"exists"` + Identity string `json:"identity,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Mode uint32 `json:"mode,omitempty"` +} + +type durableWorkspace struct { + Path string `json:"path"` + MarkerPath string `json:"marker_path"` + OwnerToken string `json:"owner_token"` + DirectoryID string `json:"directory_id,omitempty"` +} + +type durableEntry struct { + Path string `json:"path"` + Label string `json:"label"` + Kind durableChangeKind `json:"kind"` + Workspace string `json:"workspace"` + NextPath string `json:"next_path,omitempty"` + OriginalPath string `json:"original_path,omitempty"` + DiscardPath string `json:"discard_path"` + Original durableArtifact `json:"original"` + Next durableArtifact `json:"next"` +} + +type durableJournal struct { + Version int `json:"version"` + ID string `json:"id"` + OwnerToken string `json:"owner_token"` + Label string `json:"label"` + Phase durableTransactionPhase `json:"phase"` + Sequence uint64 `json:"sequence"` + CompletedEntries int `json:"completed_entries"` + ActiveEntry *int `json:"active_entry,omitempty"` + JournalDir string `json:"journal_dir"` + Workspaces []durableWorkspace `json:"workspaces"` + Entries []durableEntry `json:"entries"` +} + +func artifactDigest(contents []byte) string { + digest := sha256.Sum256(contents) + return hex.EncodeToString(digest[:]) +} + +func artifactFromSnapshot(snapshot *managedFileSnapshot) durableArtifact { + if snapshot == nil { + return durableArtifact{} + } + return durableArtifact{ + Exists: true, + Identity: snapshot.identity, + SHA256: artifactDigest(snapshot.contents), + Mode: uint32(snapshot.mode.Perm()), + } +} + +func artifactFromChangeOriginal(change fileChange) durableArtifact { + if !change.existed { + return durableArtifact{} + } + return durableArtifact{ + Exists: true, + Identity: change.originalID, + SHA256: artifactDigest(change.original), + Mode: uint32(change.originalMode.Perm()), + } +} + +func artifactMatchesSnapshot(expected durableArtifact, current *managedFileSnapshot) bool { + if (current != nil) != expected.Exists { + return false + } + if current == nil { + return true + } + return current.identity == expected.Identity && + artifactDigest(current.contents) == expected.SHA256 && + sameManagedFileMode(current.mode, os.FileMode(expected.Mode)) +} + +func readDurableArtifact(path, label string) (*managedFileSnapshot, error) { + return readManagedFile(path, label, maxManagedSourceBytes) +} + +func requireDurableArtifact( + path, label string, + expected durableArtifact, +) (*managedFileSnapshot, error) { + current, err := readDurableArtifact(path, label) + if err != nil { + return nil, err + } + if !artifactMatchesSnapshot(expected, current) { + actual := artifactFromSnapshot(current) + return current, fmt.Errorf( + "%s identity, content, or mode changed at %s (expected id=%q hash=%q mode=%#o exists=%t; actual id=%q hash=%q mode=%#o exists=%t)", + label, + path, + expected.Identity, + expected.SHA256, + expected.Mode, + expected.Exists, + actual.Identity, + actual.SHA256, + actual.Mode, + actual.Exists, + ) + } + return current, nil +} diff --git a/cmd/elydora/plugins/transaction_identity_darwin.go b/cmd/elydora/plugins/transaction_identity_darwin.go new file mode 100644 index 0000000..2907527 --- /dev/null +++ b/cmd/elydora/plugins/transaction_identity_darwin.go @@ -0,0 +1,17 @@ +//go:build darwin + +package plugins + +import ( + "fmt" + "os" + "syscall" +) + +func transactionFileIdentity(_ *os.File, info os.FileInfo) (string, error) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "", fmt.Errorf("macOS file identity is unavailable") + } + return fmt.Sprintf("%x:%x", uint64(stat.Dev), stat.Ino), nil +} diff --git a/cmd/elydora/plugins/transaction_identity_linux.go b/cmd/elydora/plugins/transaction_identity_linux.go new file mode 100644 index 0000000..cd5383f --- /dev/null +++ b/cmd/elydora/plugins/transaction_identity_linux.go @@ -0,0 +1,17 @@ +//go:build linux + +package plugins + +import ( + "fmt" + "os" + "syscall" +) + +func transactionFileIdentity(_ *os.File, info os.FileInfo) (string, error) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "", fmt.Errorf("Linux file identity is unavailable") + } + return fmt.Sprintf("%x:%x", uint64(stat.Dev), stat.Ino), nil +} diff --git a/cmd/elydora/plugins/transaction_identity_unsupported.go b/cmd/elydora/plugins/transaction_identity_unsupported.go new file mode 100644 index 0000000..946147d --- /dev/null +++ b/cmd/elydora/plugins/transaction_identity_unsupported.go @@ -0,0 +1,12 @@ +//go:build !darwin && !linux && !windows + +package plugins + +import ( + "fmt" + "os" +) + +func transactionFileIdentity(_ *os.File, info os.FileInfo) (string, error) { + return fmt.Sprintf("unsupported:%T:%v", info.Sys(), info.Sys()), nil +} diff --git a/cmd/elydora/plugins/transaction_identity_windows.go b/cmd/elydora/plugins/transaction_identity_windows.go new file mode 100644 index 0000000..ea2c098 --- /dev/null +++ b/cmd/elydora/plugins/transaction_identity_windows.go @@ -0,0 +1,22 @@ +//go:build windows + +package plugins + +import ( + "fmt" + "os" + + "golang.org/x/sys/windows" +) + +func transactionFileIdentity(file *os.File, _ os.FileInfo) (string, error) { + var information windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle( + windows.Handle(file.Fd()), + &information, + ); err != nil { + return "", err + } + index := uint64(information.FileIndexHigh)<<32 | uint64(information.FileIndexLow) + return fmt.Sprintf("%x:%x", information.VolumeSerialNumber, index), nil +} diff --git a/cmd/elydora/plugins/transaction_initialization_recovery.go b/cmd/elydora/plugins/transaction_initialization_recovery.go new file mode 100644 index 0000000..92e682e --- /dev/null +++ b/cmd/elydora/plugins/transaction_initialization_recovery.go @@ -0,0 +1,122 @@ +package plugins + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +func recoverInitializingDurableTransaction(journal *durableJournal) error { + if journal.Phase != durablePhaseInitializing { + return nil + } + verifiedWorkspaces := make(map[string]bool, len(journal.Workspaces)) + for index := range journal.Workspaces { + workspace := &journal.Workspaces[index] + if workspace.DirectoryID == "" { + if err := cleanupUnpersistedDurableWorkspace(workspace.Path); err != nil { + return err + } + continue + } + if err := verifyPersistedDurableWorkspace(journal, workspace); err != nil { + return err + } + if err := cleanupInterruptedCapabilityProbe(workspace.Path); err != nil { + return fmt.Errorf("clean interrupted transaction capability probe: %w", err) + } + verifiedWorkspaces[durablePathKey(workspace.Path)] = true + } + for index := range journal.Entries { + entry := &journal.Entries[index] + if entry.NextPath == "" || entry.Next.Identity != "" { + continue + } + current, err := readDurableArtifact(entry.NextPath, "unpersisted staged "+entry.Label) + if err != nil || current == nil { + if err != nil { + return err + } + continue + } + if !verifiedWorkspaces[durablePathKey(entry.Workspace)] { + return fmt.Errorf( + "unpersisted staged transaction artifact preserved at %s", + entry.NextPath, + ) + } + if err := removePhysicalOwnedFile(entry.NextPath, current.identity); err != nil { + return err + } + if err := syncTransactionDirectory(entry.Workspace); err != nil { + return err + } + } + return nil +} + +func cleanupUnpersistedDurableWorkspace(path string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return errors.Join(fmt.Errorf("unpersisted transaction workspace changed: %s", path), err) + } + if err := verifyPrivateTransactionDirectory(path); err != nil { + return err + } + directoryID, err := transactionDirectoryIdentity(path) + if err != nil { + return err + } + entries, err := os.ReadDir(path) + if err != nil { + return err + } + if len(entries) > 1 || len(entries) == 1 && entries[0].Name() != "owner" { + return fmt.Errorf("unexpected object in unpersisted transaction workspace: %s", path) + } + if len(entries) == 1 { + ownerPath := filepath.Join(path, "owner") + owner, err := readManagedFile(ownerPath, "unpersisted workspace owner", maxDurableJournalBytes) + if err != nil || owner == nil { + return errors.Join(fmt.Errorf("unpersisted workspace owner changed: %s", ownerPath), err) + } + if err := removePhysicalOwnedFile(ownerPath, owner.identity); err != nil { + return err + } + } + latestID, err := transactionDirectoryIdentity(path) + if err != nil || latestID != directoryID { + return errors.Join(fmt.Errorf("unpersisted transaction workspace changed: %s", path), err) + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove unpersisted transaction workspace %s: %w", path, err) + } + return syncTransactionDirectory(filepath.Dir(path)) +} + +func verifyPersistedDurableWorkspace( + journal *durableJournal, + workspace *durableWorkspace, +) error { + if err := verifyPrivateTransactionDirectory(workspace.Path); err != nil { + return err + } + directoryID, err := transactionDirectoryIdentity(workspace.Path) + if err != nil || directoryID != workspace.DirectoryID { + return errors.Join(fmt.Errorf("transaction workspace identity changed: %s", workspace.Path), err) + } + marker, err := readManagedFile( + workspace.MarkerPath, + "transaction workspace owner", + maxDurableJournalBytes, + ) + wantMarker := journal.ID + ":" + workspace.OwnerToken + "\n" + if err != nil || marker == nil || string(marker.contents) != wantMarker { + return errors.Join(fmt.Errorf("transaction workspace owner changed: %s", workspace.Path), err) + } + return nil +} diff --git a/cmd/elydora/plugins/transaction_initialization_subprocess_test.go b/cmd/elydora/plugins/transaction_initialization_subprocess_test.go new file mode 100644 index 0000000..4aeb5cb --- /dev/null +++ b/cmd/elydora/plugins/transaction_initialization_subprocess_test.go @@ -0,0 +1,407 @@ +package plugins + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func prepareInitializingWorkerJournal(t *testing.T, target string) *durableJournal { + t.Helper() + change, err := prepareFileChange(target, "initialization crash target", []byte("next"), 0600) + if err != nil { + t.Fatal(err) + } + changes, err := filterDurableChanges([]*fileChange{change}, "initialization crash") + if err != nil { + t.Fatal(err) + } + stateRoot, err := durableTransactionStateRoot() + if err != nil { + t.Fatal(err) + } + journal, err := createDurableJournal(stateRoot, "initialization crash") + if err != nil { + t.Fatal(err) + } + if err := initializeDurableLayout(journal, changes); err != nil { + t.Fatal(err) + } + if err := appendDurableJournal(journal, durablePhaseInitializing); err != nil { + t.Fatal(err) + } + return journal +} + +func runDurableInitializationWorkspaceCrashWorker( + t *testing.T, + target string, + partialOwner bool, +) { + journal := prepareInitializingWorkerJournal(t, target) + workspace := journal.Workspaces[0] + if err := createPrivateTransactionDirectory(workspace.Path); err != nil { + t.Fatal(err) + } + if partialOwner { + writePartialDurableWorkerFile(t, workspace.MarkerPath, []byte(journal.ID+":")) + } + if err := syncTransactionDirectory(filepath.Dir(workspace.Path)); err != nil { + t.Fatal(err) + } + os.Exit(92) +} + +func runDurableInitializationStageCrashWorker( + t *testing.T, + target string, + partial bool, +) { + journal := prepareInitializingWorkerJournal(t, target) + if err := createDurableWorkspaces(journal); err != nil { + t.Fatal(err) + } + entry := journal.Entries[0] + if partial { + writePartialDurableWorkerFile(t, entry.NextPath, []byte("par")) + } else if err := writeOwnedFile(entry.NextPath, []byte("next"), 0600); err != nil { + t.Fatal(err) + } + if err := syncTransactionDirectory(entry.Workspace); err != nil { + t.Fatal(err) + } + os.Exit(93) +} + +func runDurableInitializationProbeCrashWorker(t *testing.T, target string) { + journal := prepareInitializingWorkerJournal(t, target) + workspace := &journal.Workspaces[0] + if err := createPrivateTransactionDirectory(workspace.Path); err != nil { + t.Fatal(err) + } + marker := []byte(journal.ID + ":" + workspace.OwnerToken + "\n") + if err := writeOwnedFile(workspace.MarkerPath, marker, 0600); err != nil { + t.Fatal(err) + } + identity, err := transactionDirectoryIdentity(workspace.Path) + if err != nil { + t.Fatal(err) + } + workspace.DirectoryID = identity + if err := appendDurableJournal(journal, durablePhaseInitializing); err != nil { + t.Fatal(err) + } + writePartialDurableWorkerFile( + t, + filepath.Join(workspace.Path, "probe-target"), + []byte("o"), + ) + os.Exit(94) +} + +func runDurableJournalOwnerCrashWorker(t *testing.T) { + stateRoot, err := durableTransactionStateRoot() + if err != nil { + t.Fatal(err) + } + id := strings.Repeat("a", 48) + directory := filepath.Join(stateRoot, "txn-"+id) + if err := createPrivateTransactionDirectory(directory); err != nil { + t.Fatal(err) + } + writePartialDurableWorkerFile(t, filepath.Join(directory, "owner"), []byte(id+":")) + if err := syncTransactionDirectory(stateRoot); err != nil { + t.Fatal(err) + } + os.Exit(95) +} + +func runDurableJournalPendingCrashWorker(t *testing.T, target string, partial bool) { + journal := prepareInitializingWorkerJournalWithoutState(t, target) + journal.Sequence = 1 + journal.Phase = durablePhaseInitializing + path := filepath.Join(journal.JournalDir, "state-00000000000000000001.pending") + if partial { + writePartialDurableWorkerFile(t, path, []byte("{")) + } else { + raw, err := json.Marshal(journal) + if err != nil { + t.Fatal(err) + } + if err := writeOwnedFile(path, append(raw, '\n'), 0600); err != nil { + t.Fatal(err) + } + } + if err := syncTransactionDirectory(journal.JournalDir); err != nil { + t.Fatal(err) + } + os.Exit(96) +} + +func prepareInitializingWorkerJournalWithoutState(t *testing.T, target string) *durableJournal { + t.Helper() + change, err := prepareFileChange(target, "pending journal target", []byte("next"), 0600) + if err != nil { + t.Fatal(err) + } + changes, err := filterDurableChanges([]*fileChange{change}, "pending journal") + if err != nil { + t.Fatal(err) + } + stateRoot, err := durableTransactionStateRoot() + if err != nil { + t.Fatal(err) + } + journal, err := createDurableJournal(stateRoot, "pending journal") + if err != nil { + t.Fatal(err) + } + if err := initializeDurableLayout(journal, changes); err != nil { + t.Fatal(err) + } + return journal +} + +func writePartialDurableWorkerFile(t *testing.T, path string, content []byte) { + t.Helper() + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write(content); err != nil { + t.Fatal(errors.Join(err, file.Close())) + } + if err := errors.Join(file.Sync(), file.Close()); err != nil { + t.Fatal(err) + } + if err := syncTransactionDirectory(filepath.Dir(path)); err != nil { + t.Fatal(err) + } +} + +func prepareBlockedCommittedWorkerJournal(t *testing.T, target string) *durableJournal { + t.Helper() + change, err := prepareFileChange(target, "cleanup marker target", []byte("committed"), 0600) + if err != nil { + t.Fatal(err) + } + ops := newTransactionFileOps(nil) + replace := ops.replaceWithBackup + blocker := "" + ops.replaceWithBackup = func(current, replacement, backup string) error { + if err := replace(current, replacement, backup); err != nil { + return err + } + blocker = filepath.Join(filepath.Dir(backup), "cleanup-blocker") + return os.WriteFile(blocker, []byte("block"), 0600) + } + err = writeChangesWithFileOps([]*fileChange{change}, "cleanup marker", ops) + if err == nil || !strings.Contains(err.Error(), "committed; durable cleanup failed") { + t.Fatalf("committed cleanup error = %v", err) + } + if err := os.Remove(blocker); err != nil { + t.Fatal(err) + } + journal := loadOnlyDurableWorkerJournal(t) + if journal.Phase != durablePhaseCommitted { + t.Fatalf("journal phase = %s", journal.Phase) + } + return journal +} + +func loadOnlyDurableWorkerJournal(t *testing.T) *durableJournal { + t.Helper() + stateRoot, err := durableTransactionStateRoot() + if err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(stateRoot) + if err != nil { + t.Fatal(err) + } + var directory string + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), "txn-") { + if directory != "" { + t.Fatal("multiple durable journals") + } + directory = filepath.Join(stateRoot, entry.Name()) + } + } + if directory == "" { + t.Fatal("durable journal is unavailable") + } + journal, err := loadDurableJournal(directory) + if err != nil { + t.Fatal(err) + } + return journal +} + +func runDurableWorkspaceMarkerCleanupCrashWorker(t *testing.T, target string) { + journal := prepareBlockedCommittedWorkerJournal(t, target) + workspace := journal.Workspaces[0] + marker, err := readManagedFile( + workspace.MarkerPath, + "transaction workspace owner", + maxDurableJournalBytes, + ) + if err != nil || marker == nil { + t.Fatal(errors.Join(fmt.Errorf("read workspace marker"), err)) + } + if err := removePhysicalOwnedFile(workspace.MarkerPath, marker.identity); err != nil { + t.Fatal(err) + } + if err := syncTransactionDirectory(workspace.Path); err != nil { + t.Fatal(err) + } + os.Exit(97) +} + +func runDurableJournalMarkerCleanupCrashWorker(t *testing.T, target string) { + journal := prepareBlockedCommittedWorkerJournal(t, target) + for _, workspace := range journal.Workspaces { + if err := cleanupDurableWorkspace(journal, workspace, cleanupCommitted); err != nil { + t.Fatal(err) + } + } + entries, err := os.ReadDir(journal.JournalDir) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if entry.Name() == "owner" { + continue + } + path := filepath.Join(journal.JournalDir, entry.Name()) + snapshot, err := readManagedFile(path, "journal state", maxDurableJournalBytes) + if err != nil || snapshot == nil { + t.Fatal(errors.Join(fmt.Errorf("read journal state"), err)) + } + if err := removePhysicalOwnedFile(path, snapshot.identity); err != nil { + t.Fatal(err) + } + } + ownerPath := filepath.Join(journal.JournalDir, "owner") + owner, err := readManagedFile(ownerPath, "journal owner", maxDurableJournalBytes) + if err != nil || owner == nil { + t.Fatal(errors.Join(fmt.Errorf("read journal owner"), err)) + } + if err := removePhysicalOwnedFile(ownerPath, owner.identity); err != nil { + t.Fatal(err) + } + if err := syncTransactionDirectory(journal.JournalDir); err != nil { + t.Fatal(err) + } + os.Exit(98) +} + +func runDurableTornJournalTailCrashWorker(t *testing.T, target string) { + journal := prepareBlockedCommittedWorkerJournal(t, target) + path := filepath.Join( + journal.JournalDir, + fmt.Sprintf("state-%020d.json", journal.Sequence+1), + ) + writePartialDurableWorkerFile(t, path, []byte("{")) + os.Exit(99) +} + +func TestDurableTransactionRecoversInitializationTerminationBoundaries(t *testing.T) { + for _, mode := range []string{ + "crash-init-workspace-empty", + "crash-init-workspace-owner", + "crash-init-stage-full", + "crash-init-stage-partial", + "crash-init-probe", + "crash-journal-owner", + "crash-journal-pending-full", + "crash-journal-pending-partial", + } { + t.Run(mode, func(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + if err := createPrivateTransactionDirectory(home); err != nil { + t.Fatal(err) + } + directory := t.TempDir() + target := filepath.Join(directory, "managed.json") + if err := os.WriteFile(target, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + requireDurableWorkerTermination(t, durableWorkerCommand(t, home, mode, target)) + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + if err := writeChanges(nil, "initialization restart recovery", nil); err != nil { + t.Fatal(err) + } + requireTransactionContent(t, target, "original") + requireNoDurableTransactionState(t, home, directory) + }) + } +} + +func TestDurableTransactionRecoversCleanupMarkerAndTornTailTermination(t *testing.T) { + for _, mode := range []string{ + "crash-workspace-marker-cleanup", + "crash-journal-marker-cleanup", + "crash-torn-journal-tail", + } { + t.Run(mode, func(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + if err := createPrivateTransactionDirectory(home); err != nil { + t.Fatal(err) + } + directory := t.TempDir() + target := filepath.Join(directory, "managed.json") + if err := os.WriteFile(target, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + requireDurableWorkerTermination(t, durableWorkerCommand(t, home, mode, target)) + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + if err := writeChanges(nil, "cleanup restart recovery", nil); err != nil { + t.Fatal(err) + } + requireTransactionContent(t, target, "committed") + requireNoDurableTransactionState(t, home, directory) + }) + } +} + +func TestDurableTransactionRecoversRelativeTargetFromDifferentWorkingDirectory(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + if err := createPrivateTransactionDirectory(home); err != nil { + t.Fatal(err) + } + directory := t.TempDir() + target := filepath.Join(directory, "managed.json") + if err := os.WriteFile(target, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + command := durableWorkerCommand(t, home, "crash-update", filepath.Base(target)) + command.Dir = directory + requireDurableWorkerTermination(t, command) + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + if err := writeChanges(nil, "relative target restart recovery", nil); err != nil { + t.Fatal(err) + } + requireTransactionContent(t, target, "original") + requireNoDurableTransactionState(t, home, directory) +} + +func requireDurableWorkerTermination(t *testing.T, command *exec.Cmd) { + t.Helper() + output, err := command.CombinedOutput() + if err == nil { + t.Fatalf("worker completed without termination: %s", output) + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("worker error = %v, output=%s", err, output) + } +} diff --git a/cmd/elydora/plugins/transaction_journal.go b/cmd/elydora/plugins/transaction_journal.go new file mode 100644 index 0000000..65042f6 --- /dev/null +++ b/cmd/elydora/plugins/transaction_journal.go @@ -0,0 +1,342 @@ +package plugins + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +const maxDurableJournalBytes = 8 * 1024 * 1024 + +func durableTransactionStateRoot() (string, error) { + root, err := AgentRuntimeRoot() + if err != nil { + return "", err + } + parent := filepath.Dir(root) + if _, statErr := os.Lstat(parent); errors.Is(statErr, os.ErrNotExist) { + if err := EnsurePrivateDirectory(parent); err != nil { + return "", err + } + } else if statErr != nil { + return "", statErr + } + if err := verifyTransactionNamespaceParent(parent); err != nil { + return "", err + } + if err := ensurePrivateTransactionDirectory(root); err != nil { + return "", err + } + if err := syncTransactionDirectory(filepath.Dir(root)); err != nil { + return "", fmt.Errorf("persist transaction runtime root %s: %w", root, err) + } + stateRoot := filepath.Join(root, "transactions") + if err := ensurePrivateTransactionDirectory(stateRoot); err != nil { + return "", err + } + if err := syncTransactionDirectory(root); err != nil { + return "", fmt.Errorf("persist transaction state root %s: %w", stateRoot, err) + } + return stateRoot, nil +} + +func randomTransactionToken() (string, error) { + buffer := make([]byte, 24) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return hex.EncodeToString(buffer), nil +} + +func createDurableJournal(stateRoot, label string) (*durableJournal, error) { + id, err := randomTransactionToken() + if err != nil { + return nil, fmt.Errorf("generate transaction ID: %w", err) + } + ownerToken, err := randomTransactionToken() + if err != nil { + return nil, fmt.Errorf("generate transaction owner token: %w", err) + } + directory := filepath.Join(stateRoot, "txn-"+id) + if err := createPrivateTransactionDirectory(directory); err != nil { + return nil, fmt.Errorf("create transaction journal namespace %s: %w", directory, err) + } + journal := &durableJournal{ + Version: durableTransactionVersion, ID: id, OwnerToken: ownerToken, + Label: label, Phase: durablePhaseInitializing, JournalDir: directory, + } + if err := writeOwnedFile( + filepath.Join(directory, "owner"), + []byte(id+":"+ownerToken+"\n"), + 0600, + ); err != nil { + return nil, fmt.Errorf( + "initialize transaction journal namespace %s: %w; recovery path preserved", + directory, + err, + ) + } + if err := syncTransactionDirectory(directory); err != nil { + return nil, fmt.Errorf("sync transaction journal namespace %s: %w", directory, err) + } + if err := syncTransactionDirectory(stateRoot); err != nil { + return nil, fmt.Errorf("persist transaction journal namespace %s: %w", directory, err) + } + return journal, nil +} + +func writeOwnedFile(path string, content []byte, mode os.FileMode) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if err != nil { + return err + } + written, writeErr := file.Write(content) + if writeErr == nil && written != len(content) { + writeErr = io.ErrShortWrite + } + return errors.Join(writeErr, file.Sync(), file.Close()) +} + +func appendDurableJournal(journal *durableJournal, phase durableTransactionPhase) error { + next, err := nextDurableJournalSequence(journal.JournalDir) + if err != nil { + return err + } + journal.Sequence = next + journal.Phase = phase + raw, err := json.MarshalIndent(journal, "", " ") + if err != nil { + return fmt.Errorf("encode transaction journal: %w", err) + } + raw = append(raw, '\n') + if len(raw) > maxDurableJournalBytes { + return fmt.Errorf( + "transaction journal state exceeds %d bytes", + maxDurableJournalBytes, + ) + } + path := filepath.Join( + journal.JournalDir, + fmt.Sprintf("state-%020d.json", journal.Sequence), + ) + pendingPath := filepath.Join( + journal.JournalDir, + fmt.Sprintf("state-%020d.pending", journal.Sequence), + ) + if err := writeOwnedFile(pendingPath, raw, 0600); err != nil { + return fmt.Errorf("stage transaction journal state at %s: %w", pendingPath, err) + } + if err := syncTransactionDirectory(journal.JournalDir); err != nil { + return fmt.Errorf("sync staged transaction journal state at %s: %w", pendingPath, err) + } + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + return errors.Join(fmt.Errorf("transaction journal state path is occupied: %s", path), err) + } + if err := os.Rename(pendingPath, path); err != nil { + return fmt.Errorf("publish transaction journal state at %s: %w", path, err) + } + if err := syncTransactionDirectory(journal.JournalDir); err != nil { + return fmt.Errorf("sync transaction journal state at %s: %w", path, err) + } + return nil +} + +func nextDurableJournalSequence(directory string) (uint64, error) { + entries, err := os.ReadDir(directory) + if err != nil { + return 0, err + } + var maximum uint64 + for _, entry := range entries { + sequence, state := durableJournalStateSequence(entry.Name()) + if !state { + sequence, state = durableJournalPendingSequence(entry.Name()) + } + if state && sequence > maximum { + maximum = sequence + } + } + if maximum == ^uint64(0) { + return 0, fmt.Errorf("transaction journal sequence is exhausted at %s", directory) + } + return maximum + 1, nil +} + +func loadDurableJournal(directory string) (*durableJournal, error) { + entries, err := os.ReadDir(directory) + if err != nil { + return nil, err + } + stateNames := make([]string, 0) + for _, entry := range entries { + if !entry.IsDir() && strings.HasPrefix(entry.Name(), "state-") && + strings.HasSuffix(entry.Name(), ".json") { + stateNames = append(stateNames, entry.Name()) + } + } + sort.Sort(sort.Reverse(sort.StringSlice(stateNames))) + var failures []error + for _, name := range stateNames { + journal, readErr := readDurableJournalState(filepath.Join(directory, name)) + if readErr == nil { + if validateErr := validateLoadedJournal(journal, directory, name); validateErr == nil { + return journal, nil + } else { + readErr = validateErr + } + } + failures = append(failures, readErr) + } + return nil, errors.Join( + fmt.Errorf("no valid transaction state in recovery namespace %s", directory), + errors.Join(failures...), + ) +} + +func readDurableJournalState(path string) (*durableJournal, error) { + snapshot, err := readManagedFile( + path, + "transaction journal state", + maxDurableJournalBytes, + ) + if err != nil { + return nil, err + } + if snapshot == nil { + return nil, os.ErrNotExist + } + decoder := json.NewDecoder(bytes.NewReader(snapshot.contents)) + decoder.DisallowUnknownFields() + var journal durableJournal + decodeErr := decoder.Decode(&journal) + var trailing any + if decodeErr == nil { + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + decodeErr = fmt.Errorf("transaction journal contains trailing data") + } + } + return &journal, decodeErr +} + +func validateLoadedJournal(journal *durableJournal, directory, name string) error { + if journal.Version != durableTransactionVersion || journal.ID == "" || + journal.OwnerToken == "" || + durablePathKey(journal.JournalDir) != durablePathKey(directory) { + return fmt.Errorf("invalid transaction journal identity at %s", directory) + } + expectedName := fmt.Sprintf("state-%020d.json", journal.Sequence) + if name != expectedName { + return fmt.Errorf("transaction journal sequence does not match %s", name) + } + if err := validateDurableJournalProgress(journal); err != nil { + return fmt.Errorf("invalid transaction journal progress at %s: %w", directory, err) + } + base := filepath.Base(directory) + if base != "txn-"+journal.ID { + return fmt.Errorf("transaction journal path does not match transaction ID: %s", directory) + } + owner, err := readManagedFile( + filepath.Join(directory, "owner"), + "transaction journal owner marker", + maxDurableJournalBytes, + ) + if err != nil || owner == nil || + string(owner.contents) != journal.ID+":"+journal.OwnerToken+"\n" { + return errors.Join(fmt.Errorf("transaction journal owner marker changed: %s", directory), err) + } + if err := verifyPrivateTransactionDirectory(directory); err != nil { + return err + } + for _, workspace := range journal.Workspaces { + if workspace.OwnerToken != journal.OwnerToken { + return fmt.Errorf("transaction workspace owner token mismatch: %s", workspace.Path) + } + if !filepath.IsAbs(workspace.Path) || + filepath.Base(workspace.Path) != ".elydora-txn-"+journal.ID || + filepath.Clean(workspace.MarkerPath) != filepath.Join(workspace.Path, "owner") { + return fmt.Errorf("invalid transaction workspace path: %s", workspace.Path) + } + } + workspacePaths := make(map[string]bool) + for _, workspace := range journal.Workspaces { + workspacePaths[durablePathKey(workspace.Path)] = true + } + for _, entry := range journal.Entries { + if !filepath.IsAbs(entry.Path) || !workspacePaths[durablePathKey(entry.Workspace)] || + durablePathKey(filepath.Dir(entry.Path)) != durablePathKey(filepath.Dir(entry.Workspace)) { + return fmt.Errorf("invalid durable transaction target: %s", entry.Path) + } + for _, artifactPath := range []string{entry.NextPath, entry.OriginalPath, entry.DiscardPath} { + if artifactPath != "" && + durablePathKey(filepath.Dir(artifactPath)) != durablePathKey(entry.Workspace) { + return fmt.Errorf("transaction artifact escapes workspace: %s", artifactPath) + } + } + } + return nil +} + +func validateDurableJournalProgress(journal *durableJournal) error { + if journal.CompletedEntries < 0 || journal.CompletedEntries > len(journal.Entries) { + return fmt.Errorf("completed entry count %d is outside transaction bounds", journal.CompletedEntries) + } + if journal.ActiveEntry != nil { + if *journal.ActiveEntry < 0 || *journal.ActiveEntry >= len(journal.Entries) { + return fmt.Errorf("active entry %d is outside transaction bounds", *journal.ActiveEntry) + } + if *journal.ActiveEntry != journal.CompletedEntries { + return fmt.Errorf( + "active entry %d does not follow %d completed entries", + *journal.ActiveEntry, + journal.CompletedEntries, + ) + } + } + switch journal.Phase { + case durablePhaseInitializing, durablePhasePrepared: + if journal.CompletedEntries != 0 || journal.ActiveEntry != nil { + return fmt.Errorf("phase %s contains commit progress", journal.Phase) + } + case durablePhaseCommitting, durablePhaseRollingBack: + case durablePhaseCommitted: + if journal.CompletedEntries != len(journal.Entries) || journal.ActiveEntry != nil { + return fmt.Errorf("committed phase is incomplete") + } + default: + return fmt.Errorf("unknown transaction phase %q", journal.Phase) + } + return nil +} + +func durableJournalStateSequence(name string) (uint64, bool) { + if !strings.HasPrefix(name, "state-") || !strings.HasSuffix(name, ".json") { + return 0, false + } + value := strings.TrimSuffix(strings.TrimPrefix(name, "state-"), ".json") + if len(value) != 20 { + return 0, false + } + sequence, err := strconv.ParseUint(value, 10, 64) + return sequence, err == nil +} + +func durableJournalPendingSequence(name string) (uint64, bool) { + if !strings.HasPrefix(name, "state-") || !strings.HasSuffix(name, ".pending") { + return 0, false + } + value := strings.TrimSuffix(strings.TrimPrefix(name, "state-"), ".pending") + if len(value) != 20 { + return 0, false + } + sequence, err := strconv.ParseUint(value, 10, 64) + return sequence, err == nil +} diff --git a/cmd/elydora/plugins/transaction_lock.go b/cmd/elydora/plugins/transaction_lock.go new file mode 100644 index 0000000..3585828 --- /dev/null +++ b/cmd/elydora/plugins/transaction_lock.go @@ -0,0 +1,57 @@ +package plugins + +import ( + "errors" + "fmt" + "os" +) + +type durableTransactionLock struct { + file *os.File + path string +} + +func acquireDurableTransactionLock(root string) (*durableTransactionLock, error) { + path := root + string(os.PathSeparator) + "lock" + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, fmt.Errorf("open transaction lock at %s: %w", path, err) + } + fail := func(cause error) (*durableTransactionLock, error) { + return nil, errors.Join(cause, file.Close()) + } + opened, err := file.Stat() + if err != nil { + return fail(fmt.Errorf("inspect open transaction lock at %s: %w", path, err)) + } + current, err := os.Lstat(path) + if err != nil { + return fail(fmt.Errorf("inspect transaction lock at %s: %w", path, err)) + } + if current.Mode()&os.ModeSymlink != 0 || !current.Mode().IsRegular() || + !os.SameFile(opened, current) { + return fail(fmt.Errorf("transaction lock is not a stable physical file: %s", path)) + } + if err := lockTransactionFile(file); err != nil { + return fail(fmt.Errorf("lock transactions at %s: %w", path, err)) + } + current, err = os.Lstat(path) + if err != nil || current.Mode()&os.ModeSymlink != 0 || + !current.Mode().IsRegular() || !os.SameFile(opened, current) { + return fail(errors.Join( + fmt.Errorf("transaction lock changed while acquiring: %s", path), + err, + unlockTransactionFile(file), + )) + } + return &durableTransactionLock{file: file, path: path}, nil +} + +func (lock *durableTransactionLock) close() error { + if lock == nil || lock.file == nil { + return nil + } + file := lock.file + lock.file = nil + return errors.Join(unlockTransactionFile(file), file.Close()) +} diff --git a/cmd/elydora/plugins/transaction_lock_unix.go b/cmd/elydora/plugins/transaction_lock_unix.go new file mode 100644 index 0000000..ce2e466 --- /dev/null +++ b/cmd/elydora/plugins/transaction_lock_unix.go @@ -0,0 +1,17 @@ +//go:build darwin || linux + +package plugins + +import ( + "os" + + "golang.org/x/sys/unix" +) + +func lockTransactionFile(file *os.File) error { + return unix.Flock(int(file.Fd()), unix.LOCK_EX) +} + +func unlockTransactionFile(file *os.File) error { + return unix.Flock(int(file.Fd()), unix.LOCK_UN) +} diff --git a/cmd/elydora/plugins/transaction_lock_unsupported.go b/cmd/elydora/plugins/transaction_lock_unsupported.go new file mode 100644 index 0000000..cf86a84 --- /dev/null +++ b/cmd/elydora/plugins/transaction_lock_unsupported.go @@ -0,0 +1,13 @@ +//go:build !darwin && !linux && !windows + +package plugins + +import "os" + +func lockTransactionFile(*os.File) error { + return transactionPlatformSupported() +} + +func unlockTransactionFile(*os.File) error { + return nil +} diff --git a/cmd/elydora/plugins/transaction_lock_windows.go b/cmd/elydora/plugins/transaction_lock_windows.go new file mode 100644 index 0000000..04b289a --- /dev/null +++ b/cmd/elydora/plugins/transaction_lock_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package plugins + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func lockTransactionFile(file *os.File) error { + return windows.LockFileEx( + windows.Handle(file.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK, + 0, + 1, + 0, + &windows.Overlapped{}, + ) +} + +func unlockTransactionFile(file *os.File) error { + return windows.UnlockFileEx( + windows.Handle(file.Fd()), + 0, + 1, + 0, + &windows.Overlapped{}, + ) +} diff --git a/cmd/elydora/plugins/transaction_precondition.go b/cmd/elydora/plugins/transaction_precondition.go index 02c9e67..6932f63 100644 --- a/cmd/elydora/plugins/transaction_precondition.go +++ b/cmd/elydora/plugins/transaction_precondition.go @@ -10,6 +10,7 @@ type filePrecondition struct { filePath string label string snapshot *managedFileSnapshot + directory os.FileInfo maximumSize int64 } @@ -38,7 +39,8 @@ func prepareSnapshotSourceChange( if original == nil && (remove || next == nil) { return nil, nil } - if original != nil && !remove && bytes.Equal(original.contents, next) { + if original != nil && !remove && bytes.Equal(original.contents, next) && + sameManagedFileMode(original.mode, mode) { return nil, nil } change := &fileChange{ @@ -51,6 +53,7 @@ func prepareSnapshotSourceChange( if original != nil { change.original = append([]byte(nil), original.contents...) change.originalInfo = original.info + change.originalID = original.identity change.originalMode = original.mode change.existed = true } @@ -62,7 +65,9 @@ func sameManagedSnapshot(current, expected *managedFileSnapshot) bool { return current == expected } return bytes.Equal(current.contents, expected.contents) && - os.SameFile(current.info, expected.info) + current.identity == expected.identity && + os.SameFile(current.info, expected.info) && + sameManagedFileMode(current.mode, expected.mode) } func assertFilePreconditions( @@ -70,6 +75,27 @@ func assertFilePreconditions( operation string, ) error { for _, condition := range preconditions { + if condition.directory != nil { + current, err := os.Lstat(condition.filePath) + if err != nil { + return fmt.Errorf( + "inspect %s at %s: %w", + condition.label, + condition.filePath, + err, + ) + } + if current.Mode()&os.ModeSymlink != 0 || !current.IsDir() || + !os.SameFile(condition.directory, current) { + return fmt.Errorf( + "%s changed during %s: %s", + condition.label, + operation, + condition.filePath, + ) + } + continue + } maximumSize := condition.maximumSize if maximumSize <= 0 { maximumSize = maxManagedSourceBytes diff --git a/cmd/elydora/plugins/transaction_private_darwin_test.go b/cmd/elydora/plugins/transaction_private_darwin_test.go new file mode 100644 index 0000000..f9976ff --- /dev/null +++ b/cmd/elydora/plugins/transaction_private_darwin_test.go @@ -0,0 +1,32 @@ +//go:build darwin + +package plugins + +import ( + "os/exec" + "strings" + "testing" +) + +func TestPrivateTransactionDirectoryRemovesMacOSACL(t *testing.T) { + path := t.TempDir() + if output, err := exec.Command( + "/bin/chmod", + "+a", + "everyone allow list,search", + path, + ).CombinedOutput(); err != nil { + t.Fatalf("add test ACL: %v: %s", err, output) + } + if err := verifyPrivateTransactionDirectory(path); err != nil { + t.Fatal(err) + } + output, err := exec.Command("/bin/ls", "-lde", path).CombinedOutput() + if err != nil { + t.Fatalf("inspect test ACL: %v: %s", err, output) + } + fields := strings.Fields(string(output)) + if len(fields) == 0 || strings.HasSuffix(fields[0], "+") { + t.Fatalf("ACL remains after hardening: %s", output) + } +} diff --git a/cmd/elydora/plugins/transaction_private_harden_darwin.go b/cmd/elydora/plugins/transaction_private_harden_darwin.go new file mode 100644 index 0000000..3cecfe4 --- /dev/null +++ b/cmd/elydora/plugins/transaction_private_harden_darwin.go @@ -0,0 +1,23 @@ +//go:build darwin + +package plugins + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +func hardenPrivateTransactionDirectory(path string) error { + output, err := exec.Command("/bin/chmod", "-N", path).CombinedOutput() + if err != nil { + return fmt.Errorf( + "remove inherited ACL from transaction namespace %s: %w: %s", + path, + err, + strings.TrimSpace(string(output)), + ) + } + return os.Chmod(path, 0700) +} diff --git a/cmd/elydora/plugins/transaction_private_harden_linux.go b/cmd/elydora/plugins/transaction_private_harden_linux.go new file mode 100644 index 0000000..6448f66 --- /dev/null +++ b/cmd/elydora/plugins/transaction_private_harden_linux.go @@ -0,0 +1,9 @@ +//go:build linux + +package plugins + +import "os" + +func hardenPrivateTransactionDirectory(path string) error { + return os.Chmod(path, 0700) +} diff --git a/cmd/elydora/plugins/transaction_private_unix.go b/cmd/elydora/plugins/transaction_private_unix.go new file mode 100644 index 0000000..a4dd5d9 --- /dev/null +++ b/cmd/elydora/plugins/transaction_private_unix.go @@ -0,0 +1,78 @@ +//go:build linux || darwin + +package plugins + +import ( + "errors" + "fmt" + "os" + "syscall" +) + +func createPrivateTransactionDirectory(path string) error { + if err := os.Mkdir(path, 0700); err != nil { + return err + } + return verifyPrivateTransactionDirectory(path) +} + +func ensurePrivateTransactionDirectory(path string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + if createErr := createPrivateTransactionDirectory(path); createErr == nil { + return nil + } else if !errors.Is(createErr, os.ErrExist) { + return createErr + } + info, err = os.Lstat(path) + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("transaction namespace is not a physical directory: %s", path) + } + return verifyPrivateTransactionDirectory(path) +} + +func verifyPrivateTransactionDirectory(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("transaction namespace is not owner-only: %s", path) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != uint32(os.Geteuid()) { + return fmt.Errorf("transaction namespace owner does not match effective user: %s", path) + } + if err := hardenPrivateTransactionDirectory(path); err != nil { + return err + } + info, err = os.Lstat(path) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() || + info.Mode().Perm() != 0700 { + return errors.Join(fmt.Errorf("transaction namespace is not owner-only: %s", path), err) + } + stat, ok = info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != uint32(os.Geteuid()) { + return fmt.Errorf("transaction namespace owner does not match effective user: %s", path) + } + return nil +} + +func verifyTransactionNamespaceParent(path string) error { + info, err := os.Lstat(path) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return errors.Join(fmt.Errorf("transaction namespace parent is not a physical directory: %s", path), err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != uint32(os.Geteuid()) { + return fmt.Errorf("transaction namespace parent owner does not match effective user: %s", path) + } + if info.Mode().Perm()&0022 != 0 { + return fmt.Errorf("transaction namespace parent is writable by another user: %s", path) + } + return nil +} diff --git a/cmd/elydora/plugins/transaction_private_unix_test.go b/cmd/elydora/plugins/transaction_private_unix_test.go new file mode 100644 index 0000000..f84d8cf --- /dev/null +++ b/cmd/elydora/plugins/transaction_private_unix_test.go @@ -0,0 +1,37 @@ +//go:build linux || darwin + +package plugins + +import ( + "os" + "strings" + "testing" +) + +func TestPrivateTransactionDirectoryRejectsDifferentEffectiveOwner(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("changing directory ownership requires an elevated test process") + } + path := t.TempDir() + if err := os.Chown(path, 1, -1); err != nil { + t.Fatal(err) + } + if err := verifyPrivateTransactionDirectory(path); err == nil || + !strings.Contains(err.Error(), "effective user") { + t.Fatalf("owner mismatch error = %v", err) + } +} + +func TestTransactionNamespaceParentRejectsDifferentEffectiveOwner(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("changing directory ownership requires an elevated test process") + } + path := t.TempDir() + if err := os.Chown(path, 1, -1); err != nil { + t.Fatal(err) + } + if err := verifyTransactionNamespaceParent(path); err == nil || + !strings.Contains(err.Error(), "effective user") { + t.Fatalf("parent owner mismatch error = %v", err) + } +} diff --git a/cmd/elydora/plugins/transaction_private_unsupported.go b/cmd/elydora/plugins/transaction_private_unsupported.go new file mode 100644 index 0000000..b9cda73 --- /dev/null +++ b/cmd/elydora/plugins/transaction_private_unsupported.go @@ -0,0 +1,21 @@ +//go:build !windows && !linux && !darwin + +package plugins + +import "fmt" + +func createPrivateTransactionDirectory(path string) error { + return fmt.Errorf("private transaction directories are unsupported at %s", path) +} + +func ensurePrivateTransactionDirectory(path string) error { + return fmt.Errorf("private transaction directories are unsupported at %s", path) +} + +func verifyPrivateTransactionDirectory(path string) error { + return fmt.Errorf("private transaction directories are unsupported at %s", path) +} + +func verifyTransactionNamespaceParent(path string) error { + return fmt.Errorf("private transaction namespace parents are unsupported at %s", path) +} diff --git a/cmd/elydora/plugins/transaction_private_windows.go b/cmd/elydora/plugins/transaction_private_windows.go new file mode 100644 index 0000000..150ac89 --- /dev/null +++ b/cmd/elydora/plugins/transaction_private_windows.go @@ -0,0 +1,228 @@ +//go:build windows + +package plugins + +import ( + "errors" + "fmt" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +const transactionFileAllAccess windows.ACCESS_MASK = 0x1f01ff + +const transactionParentMutationAccess windows.ACCESS_MASK = windows.FILE_WRITE_DATA | + windows.FILE_APPEND_DATA | + windows.DELETE | + windows.WRITE_DAC | + windows.WRITE_OWNER | + windows.GENERIC_WRITE | + windows.GENERIC_ALL | + 0x00000040 // FILE_DELETE_CHILD + +func createPrivateTransactionDirectory(path string) error { + descriptor, _, err := privateTransactionSecurityDescriptor() + if err != nil { + return err + } + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return err + } + attributes := windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + SecurityDescriptor: descriptor, + } + if err := windows.CreateDirectory(pointer, &attributes); err != nil { + return err + } + return verifyPrivateTransactionDirectory(path) +} + +func ensurePrivateTransactionDirectory(path string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + if createErr := createPrivateTransactionDirectory(path); createErr == nil { + return nil + } else if !errors.Is(createErr, os.ErrExist) && + !errors.Is(createErr, windows.ERROR_ALREADY_EXISTS) { + return createErr + } + info, err = os.Lstat(path) + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("transaction namespace is not a physical directory: %s", path) + } + descriptor, user, err := privateTransactionSecurityDescriptor() + if err != nil { + return err + } + dacl, _, err := descriptor.DACL() + if err != nil { + return err + } + if err := windows.SetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION| + windows.DACL_SECURITY_INFORMATION| + windows.PROTECTED_DACL_SECURITY_INFORMATION, + user, + nil, + dacl, + nil, + ); err != nil { + return fmt.Errorf("protect transaction namespace %s: %w", path, err) + } + return verifyPrivateTransactionDirectory(path) +} + +func privateTransactionSecurityDescriptor() (*windows.SECURITY_DESCRIPTOR, *windows.SID, error) { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return nil, nil, err + } + sid, err := user.User.Sid.Copy() + if err != nil { + return nil, nil, err + } + sidText := sid.String() + if sidText == "" { + return nil, nil, fmt.Errorf("resolve current Windows user SID") + } + descriptor, err := windows.SecurityDescriptorFromString( + "O:" + sidText + "D:P(A;OICI;FA;;;" + sidText + ")", + ) + return descriptor, sid, err +} + +func verifyPrivateTransactionDirectory(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("transaction namespace is not a physical directory: %s", path) + } + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return err + } + descriptor, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return fmt.Errorf("inspect transaction namespace DACL at %s: %w", path, err) + } + owner, _, err := descriptor.Owner() + if err != nil || owner == nil || !owner.Equals(user.User.Sid) { + return errors.Join(fmt.Errorf("transaction namespace owner changed: %s", path), err) + } + control, _, err := descriptor.Control() + if err != nil || control&windows.SE_DACL_PROTECTED == 0 { + return errors.Join(fmt.Errorf("transaction namespace DACL inherits access: %s", path), err) + } + dacl, _, err := descriptor.DACL() + if err != nil || dacl == nil || dacl.AceCount != 1 { + return errors.Join(fmt.Errorf("transaction namespace DACL is not owner-only: %s", path), err) + } + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, 0, &ace); err != nil || ace == nil { + return errors.Join(fmt.Errorf("inspect transaction namespace owner ACE: %s", path), err) + } + aceSID := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE || + ace.Header.AceFlags != windows.OBJECT_INHERIT_ACE|windows.CONTAINER_INHERIT_ACE || + ace.Mask != transactionFileAllAccess || !aceSID.Equals(user.User.Sid) { + return fmt.Errorf("transaction namespace DACL is not owner-only: %s", path) + } + return nil +} + +func verifyTransactionNamespaceParent(path string) error { + info, err := os.Lstat(path) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return errors.Join(fmt.Errorf("transaction namespace parent is not a physical directory: %s", path), err) + } + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return err + } + descriptor, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return fmt.Errorf("inspect transaction namespace parent owner at %s: %w", path, err) + } + owner, _, err := descriptor.Owner() + if err != nil || owner == nil || !owner.Equals(user.User.Sid) { + return errors.Join( + fmt.Errorf("transaction namespace parent owner does not match current user: %s", path), + err, + ) + } + if err := verifyTransactionNamespaceParentDACL(path, descriptor, user.User.Sid); err != nil { + return err + } + return nil +} + +func verifyTransactionNamespaceParentDACL( + path string, + descriptor *windows.SECURITY_DESCRIPTOR, + currentUser *windows.SID, +) error { + dacl, _, err := descriptor.DACL() + if err != nil || dacl == nil { + return errors.Join(fmt.Errorf("transaction namespace parent has no restrictive DACL: %s", path), err) + } + system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return err + } + administrators, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return err + } + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, index, &ace); err != nil || ace == nil { + return errors.Join(fmt.Errorf("inspect transaction namespace parent ACE at %s", path), err) + } + if ace.Header.AceType == windows.ACCESS_DENIED_ACE_TYPE { + continue + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return fmt.Errorf( + "transaction namespace parent has unsupported DACL ACE type %d: %s", + ace.Header.AceType, + path, + ) + } + if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 || + ace.Mask&transactionParentMutationAccess == 0 { + continue + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if !sid.IsValid() { + return fmt.Errorf("transaction namespace parent has invalid DACL SID: %s", path) + } + if !sid.Equals(currentUser) && !sid.Equals(system) && !sid.Equals(administrators) { + return fmt.Errorf( + "transaction namespace parent grants mutation access to untrusted SID %s: %s", + sid.String(), + path, + ) + } + } + return nil +} diff --git a/cmd/elydora/plugins/transaction_private_windows_test.go b/cmd/elydora/plugins/transaction_private_windows_test.go new file mode 100644 index 0000000..6e9bca4 --- /dev/null +++ b/cmd/elydora/plugins/transaction_private_windows_test.go @@ -0,0 +1,47 @@ +//go:build windows + +package plugins + +import ( + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +func TestTransactionNamespaceParentRejectsUntrustedMutationACE(t *testing.T) { + path := t.TempDir() + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + t.Fatal(err) + } + descriptor, err := windows.SecurityDescriptorFromString( + "O:" + user.User.Sid.String() + + "D:P(A;OICI;FA;;;" + user.User.Sid.String() + ")" + + "(A;;0x00000040;;;WD)", + ) + if err != nil { + t.Fatal(err) + } + dacl, _, err := descriptor.DACL() + if err != nil { + t.Fatal(err) + } + if err := windows.SetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION| + windows.DACL_SECURITY_INFORMATION| + windows.PROTECTED_DACL_SECURITY_INFORMATION, + user.User.Sid, + nil, + dacl, + nil, + ); err != nil { + t.Fatal(err) + } + if err := verifyTransactionNamespaceParent(path); err == nil || + !strings.Contains(err.Error(), "untrusted SID") { + t.Fatalf("untrusted parent DACL error = %v", err) + } +} diff --git a/cmd/elydora/plugins/transaction_recovery_durable.go b/cmd/elydora/plugins/transaction_recovery_durable.go new file mode 100644 index 0000000..9cc69fa --- /dev/null +++ b/cmd/elydora/plugins/transaction_recovery_durable.go @@ -0,0 +1,354 @@ +package plugins + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +func recoverPendingDurableTransactions( + stateRoot string, + ops transactionFileOps, +) error { + entries, err := os.ReadDir(stateRoot) + if err != nil { + return err + } + for _, entry := range entries { + if entry.Name() == "lock" { + continue + } + path := filepath.Join(stateRoot, entry.Name()) + if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "txn-") { + return fmt.Errorf("unexpected object in transaction state root: %s", path) + } + journal, loadErr := loadDurableJournal(path) + if loadErr != nil { + if cleanupErr := cleanupEmptyDurableJournal(path); cleanupErr == nil { + continue + } else { + return errors.Join(loadErr, cleanupErr) + } + } + if err := recoverInitializingDurableTransaction(journal); err != nil { + return fmt.Errorf( + "recover transaction initialization %s: %w; recovery journal %s", + journal.ID, + err, + journal.JournalDir, + ) + } + if journal.Phase == durablePhaseCommitted { + if err := finishCommittedDurableTransaction(journal); err != nil { + return fmt.Errorf( + "finish committed transaction %s: %w; recovery journal %s", + journal.ID, + err, + journal.JournalDir, + ) + } + continue + } + journalErr := appendDurableJournal(journal, durablePhaseRollingBack) + recoveryErr := rollbackDurableJournal(journal, ops) + if recoveryErr == nil { + recoveryErr = cleanupRolledBackDurableTransaction(journal) + } + if journalErr != nil || recoveryErr != nil { + return errors.Join( + fmt.Errorf("recover interrupted transaction %s", journal.ID), + journalErr, + recoveryErr, + ) + } + } + return nil +} + +func abortDurableTransaction( + journal *durableJournal, + ops transactionFileOps, + cause error, +) error { + if err := recoverInitializingDurableTransaction(journal); err != nil { + return errors.Join( + cause, + fmt.Errorf( + "durable initialization recovery failed: %w; recovery journal %s", + err, + journal.JournalDir, + ), + ) + } + journalErr := appendDurableJournal(journal, durablePhaseRollingBack) + recoveryErr := rollbackDurableJournal(journal, ops) + if recoveryErr == nil { + recoveryErr = cleanupRolledBackDurableTransaction(journal) + } + if journalErr == nil && recoveryErr == nil { + return cause + } + return errors.Join( + cause, + journalErr, + fmt.Errorf( + "durable recovery failed: %w; recovery journal %s", + recoveryErr, + journal.JournalDir, + ), + ) +} + +func rollbackDurableJournal(journal *durableJournal, ops transactionFileOps) error { + var failures []error + for index := len(journal.Entries) - 1; index >= 0; index-- { + entry := &journal.Entries[index] + if durableEntryDefinitelyUnattempted(journal, index) { + if err := verifyDurableEntryUnattempted(entry); err != nil { + failures = append(failures, err) + } + continue + } + allowUnattempted := journal.ActiveEntry != nil && *journal.ActiveEntry == index + var err error + switch entry.Kind { + case durableUpdate: + err = rollbackDurableUpdate(entry, ops, allowUnattempted) + case durableCreate: + err = rollbackDurableCreate(entry, ops, allowUnattempted) + case durableDelete: + err = rollbackDurableDelete(entry, ops, allowUnattempted) + default: + err = fmt.Errorf("unknown durable change kind %q", entry.Kind) + } + if err != nil { + failures = append(failures, err) + } + } + return errors.Join(failures...) +} + +func durableEntryDefinitelyUnattempted(journal *durableJournal, index int) bool { + if index < journal.CompletedEntries { + return false + } + return journal.ActiveEntry == nil || *journal.ActiveEntry != index +} + +func verifyDurableEntryUnattempted(entry *durableEntry) error { + for _, item := range []struct { + path string + label string + expected *durableArtifact + }{ + {entry.NextPath, "staged " + entry.Label, &entry.Next}, + {entry.DiscardPath, "rollback discard " + entry.Label, nil}, + } { + if item.path == "" { + continue + } + current, err := readDurableArtifact(item.path, item.label) + if err != nil { + return err + } + if current != nil && (item.expected == nil || !ownedArtifactMatches(*item.expected, current)) { + return fmt.Errorf("uncommitted transaction artifact changed; preserved at %s", item.path) + } + } + if entry.OriginalPath != "" && entry.OriginalPath != entry.NextPath { + current, err := readDurableArtifact(entry.OriginalPath, "uncommitted original "+entry.Label) + if err != nil || current != nil { + return errors.Join( + fmt.Errorf("uncommitted original path is occupied; preserved at %s", entry.OriginalPath), + err, + ) + } + } + return nil +} + +func durableEntryHasStagedNext(entry *durableEntry) (bool, error) { + if entry.NextPath == "" { + return false, nil + } + current, err := readDurableArtifact(entry.NextPath, "staged "+entry.Label) + if err != nil || current == nil { + return false, err + } + if !ownedArtifactMatches(entry.Next, current) { + return false, fmt.Errorf("staged transaction artifact changed; preserved at %s", entry.NextPath) + } + return true, nil +} + +func rollbackDurableUpdate( + entry *durableEntry, + ops transactionFileOps, + allowUnattempted bool, +) error { + target, err := readDurableArtifact(entry.Path, entry.Label) + if err != nil { + return err + } + if artifactMatchesSnapshot(entry.Original, target) { + return nil + } + if !artifactMatchesSnapshot(entry.Next, target) { + if allowUnattempted { + staged, stagedErr := durableEntryHasStagedNext(entry) + if stagedErr != nil { + return stagedErr + } + if staged { + return nil + } + } + return fmt.Errorf( + "%s changed during recovery; concurrent target preserved at %s", + entry.Label, + entry.Path, + ) + } + backup, err := readDurableArtifact(entry.OriginalPath, "captured original "+entry.Label) + if err != nil || backup == nil { + return errors.Join( + fmt.Errorf("captured original is unavailable at %s", entry.OriginalPath), + err, + ) + } + backupArtifact := artifactFromSnapshot(backup) + discard := entry.DiscardPath + if !atomicReplacementUsesSeparateBackup() { + discard = entry.OriginalPath + } + if err := ops.replaceWithBackup(entry.Path, entry.OriginalPath, discard); err != nil { + return fmt.Errorf("atomically restore %s: %w", entry.Label, err) + } + restored, restoredErr := readDurableArtifact(entry.Path, "restored "+entry.Label) + discarded, discardedErr := readDurableArtifact(discard, "discarded "+entry.Label) + if restoredErr != nil || discardedErr != nil || + !artifactMatchesSnapshot(backupArtifact, restored) || + !artifactMatchesSnapshot(entry.Next, discarded) { + reverseErr := ops.replaceWithBackup(entry.Path, discard, entry.OriginalPath) + return errors.Join( + fmt.Errorf("%s changed at atomic rollback boundary", entry.Label), + restoredErr, + discardedErr, + reverseErr, + ) + } + if !artifactMatchesSnapshot(entry.Original, restored) { + return fmt.Errorf( + "concurrent object restored for %s; expected original is unavailable; recovery paths %s and %s", + entry.Label, + entry.OriginalPath, + discard, + ) + } + return syncTransactionDirectory(filepath.Dir(entry.Path)) +} + +func rollbackDurableCreate( + entry *durableEntry, + ops transactionFileOps, + allowUnattempted bool, +) error { + target, err := readDurableArtifact(entry.Path, entry.Label) + if err != nil { + return err + } + if target == nil { + return nil + } + if !artifactMatchesSnapshot(entry.Next, target) { + if allowUnattempted { + staged, stagedErr := durableEntryHasStagedNext(entry) + if stagedErr != nil { + return stagedErr + } + if staged { + return nil + } + } + return fmt.Errorf( + "%s changed during recovery; concurrent target preserved at %s", + entry.Label, + entry.Path, + ) + } + if current, readErr := readDurableArtifact(entry.DiscardPath, "rollback discard "+entry.Label); readErr != nil || current != nil { + return errors.Join(fmt.Errorf("rollback discard path is occupied: %s", entry.DiscardPath), readErr) + } + if err := ops.moveNoReplace(entry.Path, entry.DiscardPath); err != nil { + return fmt.Errorf("capture created %s for rollback: %w", entry.Label, err) + } + captured, err := readDurableArtifact(entry.DiscardPath, "captured created "+entry.Label) + if err != nil || !artifactMatchesSnapshot(entry.Next, captured) { + restoreErr := ops.moveNoReplace(entry.DiscardPath, entry.Path) + return errors.Join( + fmt.Errorf("%s changed at atomic rollback boundary", entry.Label), + err, + restoreErr, + ) + } + return syncTransactionDirectory(filepath.Dir(entry.Path)) +} + +func rollbackDurableDelete( + entry *durableEntry, + ops transactionFileOps, + allowUnattempted bool, +) error { + target, err := readDurableArtifact(entry.Path, entry.Label) + if err != nil { + return err + } + if artifactMatchesSnapshot(entry.Original, target) { + return nil + } + original, err := readDurableArtifact(entry.OriginalPath, "captured deleted "+entry.Label) + if err != nil { + return err + } + if target != nil { + if allowUnattempted && original == nil { + return nil + } + return fmt.Errorf( + "%s changed during recovery; concurrent target preserved at %s", + entry.Label, + entry.Path, + ) + } + if original == nil { + if allowUnattempted { + return nil + } + return errors.Join( + fmt.Errorf("captured deleted object is unavailable at %s", entry.OriginalPath), + ) + } + capturedArtifact := artifactFromSnapshot(original) + if err := ops.moveNoReplace(entry.OriginalPath, entry.Path); err != nil { + return fmt.Errorf("restore deleted %s without replacement: %w", entry.Label, err) + } + restored, err := readDurableArtifact(entry.Path, "restored deleted "+entry.Label) + if err != nil || !artifactMatchesSnapshot(capturedArtifact, restored) { + preserveErr := ops.moveNoReplace(entry.Path, entry.OriginalPath) + return errors.Join( + fmt.Errorf("%s changed at atomic restore boundary", entry.Label), + err, + preserveErr, + ) + } + if !artifactMatchesSnapshot(entry.Original, restored) { + return fmt.Errorf( + "concurrent object restored for %s; expected original is unavailable; recovery path %s", + entry.Label, + entry.OriginalPath, + ) + } + return syncTransactionDirectory(filepath.Dir(entry.Path)) +} diff --git a/cmd/elydora/plugins/transaction_stage.go b/cmd/elydora/plugins/transaction_stage.go new file mode 100644 index 0000000..5c77936 --- /dev/null +++ b/cmd/elydora/plugins/transaction_stage.go @@ -0,0 +1,218 @@ +package plugins + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +func stageChange(change fileChange) (stagedChange, error) { + if err := assertFileUnchanged(change); err != nil { + return stagedChange{}, err + } + directory := filepath.Dir(change.filePath) + if err := ensureManagedDirectory(directory, "directory for "+change.label); err != nil { + return stagedChange{}, err + } + staged := stagedChange{change: change} + var err error + if !change.remove { + staged.temporaryPath, err = writeStagedFile( + directory, + filepath.Base(change.filePath), + ".tmp", + change.next, + change.mode, + ) + if err == nil { + var snapshot *managedFileSnapshot + snapshot, err = readManagedFile( + staged.temporaryPath, + "staged "+change.label, + maxManagedSourceBytes, + ) + if err == nil && snapshot == nil { + err = fmt.Errorf("staged %s disappeared: %s", change.label, staged.temporaryPath) + } + if err == nil { + staged.temporaryInfo = snapshot.info + } + } + } + if err == nil && change.existed { + staged.rollbackPath, err = reserveStagingPath( + directory, + filepath.Base(change.filePath), + ".rollback", + ) + } + if err != nil { + cleanupErrors := cleanupStaging([]stagedChange{staged}) + return stagedChange{}, joinTransactionFailure( + fmt.Errorf("stage %s: %w", change.label, err), + cleanupErrors, + "cleanup failed", + ) + } + return staged, nil +} + +func writeStagedFile( + directory, basename, suffix string, + content []byte, + mode os.FileMode, +) (string, error) { + file, err := os.CreateTemp(directory, "."+basename+".*"+suffix) + if err != nil { + return "", err + } + path := file.Name() + failed := func(cause error) (string, error) { + return "", errors.Join(cause, file.Close(), removeStagedPath(path)) + } + if err := file.Chmod(mode); err != nil { + return failed(err) + } + written, err := file.Write(content) + if err != nil { + return failed(err) + } + if written != len(content) { + return failed(io.ErrShortWrite) + } + if err := file.Sync(); err != nil { + return failed(err) + } + if err := file.Close(); err != nil { + return "", errors.Join(err, removeStagedPath(path)) + } + return path, nil +} + +func reserveStagingPath(directory, basename, suffix string) (string, error) { + file, err := os.CreateTemp(directory, "."+basename+".*"+suffix) + if err != nil { + return "", err + } + path := file.Name() + if err := file.Close(); err != nil { + return "", errors.Join(err, removeStagedPath(path)) + } + if err := os.Remove(path); err != nil { + return "", err + } + return path, nil +} + +func assertFileUnchanged(change fileChange) error { + current, err := readManagedFile(change.filePath, change.label, maxManagedSourceBytes) + if err != nil { + return err + } + if !snapshotMatches( + current, + change.existed, + change.original, + change.originalInfo, + change.originalMode, + ) { + return fmt.Errorf("%s changed during installation: %s", change.label, change.filePath) + } + return nil +} + +func snapshotMatches( + current *managedFileSnapshot, + expectedExists bool, + expectedContents []byte, + expectedInfo os.FileInfo, + expectedMode os.FileMode, +) bool { + if (current != nil) != expectedExists { + return false + } + if current == nil { + return true + } + return expectedInfo != nil && + bytes.Equal(current.contents, expectedContents) && + os.SameFile(current.info, expectedInfo) && + sameManagedFileMode(current.mode, expectedMode) +} + +func cleanupStaging(staged []stagedChange) []error { + failures := make([]error, 0) + for index := range staged { + item := &staged[index] + if err := removeVerifiedStagedFile( + item.temporaryPath, + "staged "+item.change.label, + item.change.next, + item.temporaryInfo, + item.change.mode, + ); err != nil { + failures = append(failures, err) + } + if err := removeVerifiedStagedFile( + item.rollbackPath, + "rollback "+item.change.label, + item.change.original, + capturedOriginalInfo(item), + item.change.originalMode, + ); err != nil { + failures = append(failures, err) + } + } + return failures +} + +func capturedOriginalInfo(item *stagedChange) os.FileInfo { + if !item.originalCaptured { + return nil + } + return item.change.originalInfo +} + +func removeVerifiedStagedFile( + path, label string, + expectedContents []byte, + expectedInfo os.FileInfo, + expectedMode os.FileMode, +) error { + if path == "" { + return nil + } + current, err := readManagedFile(path, label, maxManagedSourceBytes) + if err != nil { + return fmt.Errorf("%w; staged object preserved at %s", err, path) + } + if current == nil { + return nil + } + if expectedInfo == nil || !snapshotMatches( + current, + true, + expectedContents, + expectedInfo, + expectedMode, + ) { + return fmt.Errorf("%s changed during cleanup; staged object preserved at %s", label, path) + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove %s at %s: %w", label, path, err) + } + return nil +} + +func removeStagedPath(path string) error { + if path == "" { + return nil + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove staged file at %s: %w", path, err) + } + return nil +} diff --git a/cmd/elydora/plugins/transaction_subprocess_test.go b/cmd/elydora/plugins/transaction_subprocess_test.go new file mode 100644 index 0000000..302f7e9 --- /dev/null +++ b/cmd/elydora/plugins/transaction_subprocess_test.go @@ -0,0 +1,634 @@ +package plugins + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +const durableWorkerEnvironment = "ELYDORA_DURABLE_TRANSACTION_WORKER" + +func TestDurableTransactionSubprocessWorker(t *testing.T) { + mode := os.Getenv(durableWorkerEnvironment) + if mode == "" { + return + } + target := os.Getenv("ELYDORA_DURABLE_TARGET") + switch mode { + case "crash-update": + runDurableCrashWorker(t, target) + case "crash-multi-update": + runDurableMultiCrashWorker(t, target, os.Getenv("ELYDORA_DURABLE_SECOND_TARGET")) + case "crash-create": + runDurableCreateCrashWorker(t, target) + case "crash-delete": + runDurableDeleteCrashWorker(t, target) + case "crash-committed-cleanup": + runDurableCommittedCleanupCrashWorker(t, target) + case "crash-rollback-hardlink": + runDurableRollbackHardlinkCrashWorker(t) + case "crash-init-workspace-empty": + runDurableInitializationWorkspaceCrashWorker(t, target, false) + case "crash-init-workspace-owner": + runDurableInitializationWorkspaceCrashWorker(t, target, true) + case "crash-init-stage-full": + runDurableInitializationStageCrashWorker(t, target, false) + case "crash-init-stage-partial": + runDurableInitializationStageCrashWorker(t, target, true) + case "crash-init-probe": + runDurableInitializationProbeCrashWorker(t, target) + case "crash-journal-owner": + runDurableJournalOwnerCrashWorker(t) + case "crash-journal-pending-full": + runDurableJournalPendingCrashWorker(t, target, false) + case "crash-journal-pending-partial": + runDurableJournalPendingCrashWorker(t, target, true) + case "crash-workspace-marker-cleanup": + runDurableWorkspaceMarkerCleanupCrashWorker(t, target) + case "crash-journal-marker-cleanup": + runDurableJournalMarkerCleanupCrashWorker(t, target) + case "crash-torn-journal-tail": + runDurableTornJournalTailCrashWorker(t, target) + case "reader": + runDurableReaderWorker(t, target) + case "writer": + runDurableWriterWorker(t, target) + default: + t.Fatalf("unknown durable transaction worker %q", mode) + } +} + +func runDurableRollbackHardlinkCrashWorker(t *testing.T) { + stateRoot, err := durableTransactionStateRoot() + if err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(stateRoot) + if err != nil { + t.Fatal(err) + } + journalPath := "" + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), "txn-") { + if journalPath != "" { + t.Fatal("multiple recovery journals found") + } + journalPath = filepath.Join(stateRoot, entry.Name()) + } + } + if journalPath == "" { + t.Fatal("recovery journal is unavailable") + } + journal, err := loadDurableJournal(journalPath) + if err != nil || len(journal.Entries) != 1 { + t.Fatalf("load recovery journal: %v", err) + } + entry := journal.Entries[0] + if err := os.Link(entry.Path, entry.DiscardPath); err != nil { + t.Fatal(err) + } + if err := syncTransactionDirectory(filepath.Dir(entry.DiscardPath)); err != nil { + t.Fatal(err) + } + os.Exit(91) +} + +func runDurableMultiCrashWorker(t *testing.T, firstTarget, secondTarget string) { + changes := make([]*fileChange, 0, 2) + for _, item := range []struct { + path string + value string + }{{firstTarget, "first-next"}, {secondTarget, "second-next"}} { + change, err := prepareFileChange(item.path, "multi crash target", []byte(item.value), 0600) + if err != nil { + t.Fatal(err) + } + changes = append(changes, change) + } + ops := newTransactionFileOps(nil) + replace := ops.replaceWithBackup + commits := 0 + ops.replaceWithBackup = func(current, replacement, backup string) error { + if err := replace(current, replacement, backup); err != nil { + return err + } + commits++ + if commits == 2 { + os.Exit(87) + } + return nil + } + if err := writeChangesWithFileOps(changes, "multi crash transaction", ops); err != nil { + t.Fatal(err) + } + t.Fatal("multi crash worker completed without terminating") +} + +func runDurableCreateCrashWorker(t *testing.T, target string) { + change, err := prepareFileChange(target, "crash create target", []byte("created"), 0600) + if err != nil { + t.Fatal(err) + } + ops := newTransactionFileOps(nil) + move := ops.moveNoReplace + ops.moveNoReplace = func(source, destination string) error { + if err := move(source, destination); err != nil { + return err + } + if sameTransactionTestPath(destination, target) { + os.Exit(88) + } + return nil + } + if err := writeChangesWithFileOps([]*fileChange{change}, "crash create", ops); err != nil { + t.Fatal(err) + } + t.Fatal("create crash worker completed without terminating") +} + +func runDurableDeleteCrashWorker(t *testing.T, target string) { + change, err := prepareSourceChange( + target, + "crash delete target", + []byte("delete-original"), + true, + nil, + 0600, + true, + ) + if err != nil { + t.Fatal(err) + } + ops := newTransactionFileOps(nil) + move := ops.moveNoReplace + ops.moveNoReplace = func(source, destination string) error { + if err := move(source, destination); err != nil { + return err + } + if sameTransactionTestPath(source, target) { + os.Exit(89) + } + return nil + } + if err := writeChangesWithFileOps([]*fileChange{change}, "crash delete", ops); err != nil { + t.Fatal(err) + } + t.Fatal("delete crash worker completed without terminating") +} + +func runDurableCommittedCleanupCrashWorker(t *testing.T, target string) { + change, err := prepareFileChange(target, "committed cleanup target", []byte("committed"), 0600) + if err != nil { + t.Fatal(err) + } + ops := newTransactionFileOps(nil) + replace := ops.replaceWithBackup + blocker := "" + ops.replaceWithBackup = func(current, replacement, backup string) error { + if err := replace(current, replacement, backup); err != nil { + return err + } + blocker = filepath.Join(filepath.Dir(backup), "cleanup-blocker") + return os.WriteFile(blocker, []byte("block"), 0600) + } + err = writeChangesWithFileOps([]*fileChange{change}, "committed cleanup", ops) + if err == nil || !strings.Contains(err.Error(), "committed; durable cleanup failed") { + t.Fatalf("committed cleanup error = %v", err) + } + if err := os.Remove(blocker); err != nil { + t.Fatal(err) + } + os.Exit(90) +} + +func runDurableCrashWorker(t *testing.T, target string) { + change, err := prepareFileChange(target, "crash target", []byte("next"), 0600) + if err != nil { + t.Fatal(err) + } + ops := newTransactionFileOps(nil) + replace := ops.replaceWithBackup + absoluteTarget, err := filepath.Abs(target) + if err != nil { + t.Fatal(err) + } + ops.replaceWithBackup = func(current, replacement, backup string) error { + if err := replace(current, replacement, backup); err != nil { + return err + } + if sameTransactionTestPath(current, absoluteTarget) { + os.Exit(86) + } + return nil + } + if err := writeChangesWithFileOps([]*fileChange{change}, "crash transaction", ops); err != nil { + t.Fatal(err) + } + t.Fatal("crash worker completed without terminating") +} + +func runDurableReaderWorker(t *testing.T, target string) { + stop := os.Getenv("ELYDORA_DURABLE_STOP") + for { + if _, err := os.Lstat(stop); err == nil { + return + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + content, err := os.ReadFile(target) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + t.Fatalf("reader observed missing target: %v", err) + } + if _, statErr := os.Lstat(target); statErr != nil { + t.Fatalf("reader observed unavailable target: %v; stat=%v", err, statErr) + } + time.Sleep(time.Millisecond) + continue + } + if string(content) != "value-a" && string(content) != "value-b" { + t.Fatalf("reader observed partial value %q", content) + } + time.Sleep(time.Millisecond) + } +} + +func runDurableWriterWorker(t *testing.T, target string) { + value := os.Getenv("ELYDORA_DURABLE_VALUE") + ready := os.Getenv("ELYDORA_DURABLE_READY") + start := os.Getenv("ELYDORA_DURABLE_START") + change, err := prepareFileChange(target, "writer target", []byte(value), 0600) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(ready, []byte("ready"), 0600); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(15 * time.Second) + for { + if _, err := os.Lstat(start); err == nil { + break + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + if time.Now().After(deadline) { + t.Fatal("writer start barrier timed out") + } + time.Sleep(time.Millisecond) + } + if err := writeChanges([]*fileChange{change}, "subprocess writer", nil); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(23) + } +} + +func TestDurableTransactionRecoversAfterProcessTermination(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + if err := createPrivateTransactionDirectory(home); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + target := filepath.Join(t.TempDir(), "managed.json") + if err := os.WriteFile(target, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + command := durableWorkerCommand(t, home, "crash-update", target) + output, err := command.CombinedOutput() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 86 { + t.Fatalf("crash worker = %v, output=%s", err, output) + } + requireTransactionContent(t, target, "next") + if err := writeChanges(nil, "restart recovery", nil); err != nil { + t.Fatalf("recover interrupted transaction: %v", err) + } + requireTransactionContent(t, target, "original") + requireNoDurableTransactionState(t, home, filepath.Dir(target)) +} + +func TestDurableTransactionResumesWindowsRollbackHardlink(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows hardlink rollback state") + } + home := filepath.Join(t.TempDir(), "home") + if err := createPrivateTransactionDirectory(home); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + directory := t.TempDir() + target := filepath.Join(directory, "managed.json") + if err := os.WriteFile(target, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + crash := durableWorkerCommand(t, home, "crash-update", target) + output, err := crash.CombinedOutput() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 86 { + t.Fatalf("commit crash worker = %v, output=%s", err, output) + } + hardlinkCrash := durableWorkerCommand(t, home, "crash-rollback-hardlink", target) + output, err = hardlinkCrash.CombinedOutput() + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 91 { + t.Fatalf("rollback hardlink worker = %v, output=%s", err, output) + } + if err := writeChanges(nil, "hardlink restart recovery", nil); err != nil { + t.Fatalf("resume hardlink rollback: %v", err) + } + requireTransactionContent(t, target, "original") + requireNoDurableTransactionState(t, home, directory) +} + +func TestDurableTransactionRecoversCompletedAndActiveEntries(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + if err := createPrivateTransactionDirectory(home); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + directory := t.TempDir() + firstTarget := filepath.Join(directory, "first.json") + secondTarget := filepath.Join(directory, "second.json") + for path, value := range map[string]string{ + firstTarget: "first-original", secondTarget: "second-original", + } { + if err := os.WriteFile(path, []byte(value), 0600); err != nil { + t.Fatal(err) + } + } + command := durableWorkerCommand(t, home, "crash-multi-update", firstTarget) + command.Env = append(command.Env, "ELYDORA_DURABLE_SECOND_TARGET="+secondTarget) + output, err := command.CombinedOutput() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 87 { + t.Fatalf("multi crash worker = %v, output=%s", err, output) + } + requireTransactionContent(t, firstTarget, "first-next") + requireTransactionContent(t, secondTarget, "second-next") + if err := writeChanges(nil, "multi-entry restart recovery", nil); err != nil { + t.Fatalf("recover interrupted multi-entry transaction: %v", err) + } + requireTransactionContent(t, firstTarget, "first-original") + requireTransactionContent(t, secondTarget, "second-original") + requireNoDurableTransactionState(t, home, directory) +} + +func TestDurableTransactionRecoversCreateAndDeleteBoundaries(t *testing.T) { + for _, testCase := range []struct { + name, mode string + exitCode int + exists bool + }{ + {"create", "crash-create", 88, false}, + {"delete", "crash-delete", 89, true}, + } { + t.Run(testCase.name, func(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + if err := createPrivateTransactionDirectory(home); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + directory := t.TempDir() + target := filepath.Join(directory, "managed.json") + if testCase.exists { + if err := os.WriteFile(target, []byte("delete-original"), 0600); err != nil { + t.Fatal(err) + } + } + command := durableWorkerCommand(t, home, testCase.mode, target) + output, err := command.CombinedOutput() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != testCase.exitCode { + t.Fatalf("%s crash worker = %v, output=%s", testCase.name, err, output) + } + if err := writeChanges(nil, testCase.name+" restart recovery", nil); err != nil { + t.Fatalf("recover interrupted %s: %v", testCase.name, err) + } + if testCase.exists { + requireTransactionContent(t, target, "delete-original") + } else if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("created target remains after recovery: %v", err) + } + requireNoDurableTransactionState(t, home, directory) + }) + } +} + +func TestDurableTransactionFinishesCommittedCleanupAfterProcessExit(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + if err := createPrivateTransactionDirectory(home); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + directory := t.TempDir() + target := filepath.Join(directory, "managed.json") + if err := os.WriteFile(target, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + command := durableWorkerCommand(t, home, "crash-committed-cleanup", target) + output, err := command.CombinedOutput() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 90 { + t.Fatalf("committed cleanup worker = %v, output=%s", err, output) + } + requireTransactionContent(t, target, "committed") + if err := writeChanges(nil, "committed cleanup restart recovery", nil); err != nil { + t.Fatalf("finish committed cleanup: %v", err) + } + requireTransactionContent(t, target, "committed") + requireNoDurableTransactionState(t, home, directory) +} + +func TestDurableTransactionNeverExposesMissingUpdateToSubprocessReader(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + if err := createPrivateTransactionDirectory(home); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + directory := t.TempDir() + target := filepath.Join(directory, "managed.json") + stop := filepath.Join(directory, "stop") + if err := os.WriteFile(target, []byte("value-a"), 0600); err != nil { + t.Fatal(err) + } + reader := durableWorkerCommand(t, home, "reader", target) + var readerOutput bytes.Buffer + reader.Stdout = &readerOutput + reader.Stderr = &readerOutput + reader.Env = append(reader.Env, "ELYDORA_DURABLE_STOP="+stop) + if err := reader.Start(); err != nil { + t.Fatal(err) + } + for index := 0; index < 12; index++ { + value := "value-a" + if index%2 == 0 { + value = "value-b" + } + change, err := prepareFileChange(target, "reader target", []byte(value), 0600) + if err != nil { + t.Fatal(err) + } + if err := writeChanges([]*fileChange{change}, "reader visibility transaction", nil); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(stop, []byte("stop"), 0600); err != nil { + t.Fatal(err) + } + if err := reader.Wait(); err != nil { + t.Fatalf("reader process observed invalid state: %v: %s", err, readerOutput.String()) + } +} + +func TestDurableTransactionCapabilityGateLeavesTargetAndStateClean(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + if err := createPrivateTransactionDirectory(home); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + directory := t.TempDir() + target := filepath.Join(directory, "managed.json") + if err := os.WriteFile(target, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + change, err := prepareFileChange(target, "capability target", []byte("next"), 0600) + if err != nil { + t.Fatal(err) + } + err = writeChanges([]*fileChange{change}, "capability transaction", nil) + if err == nil { + requireTransactionContent(t, target, "next") + } else { + if !strings.Contains(err.Error(), "lacks required durable transaction primitives") { + t.Fatalf("capability gate error = %v", err) + } + requireTransactionContent(t, target, "original") + } + requireNoDurableTransactionState(t, home, directory) +} + +func TestDurableTransactionSerializesSubprocessWriters(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + if err := createPrivateTransactionDirectory(home); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + directory := t.TempDir() + target := filepath.Join(directory, "managed.json") + start := filepath.Join(directory, "start") + if err := os.WriteFile(target, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + type worker struct { + command *exec.Cmd + ready string + } + workers := make([]worker, 0, 2) + for index, value := range []string{"writer-one", "writer-two"} { + ready := filepath.Join(directory, fmt.Sprintf("ready-%d", index)) + command := durableWorkerCommand(t, home, "writer", target) + command.Env = append( + command.Env, + "ELYDORA_DURABLE_VALUE="+value, + "ELYDORA_DURABLE_READY="+ready, + "ELYDORA_DURABLE_START="+start, + ) + if err := command.Start(); err != nil { + t.Fatal(err) + } + workers = append(workers, worker{command: command, ready: ready}) + } + for _, item := range workers { + waitForDurableTestPath(t, item.ready) + } + if err := os.WriteFile(start, []byte("start"), 0600); err != nil { + t.Fatal(err) + } + exitCodes := make([]int, 0, 2) + for _, item := range workers { + err := item.command.Wait() + if err == nil { + exitCodes = append(exitCodes, 0) + continue + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatal(err) + } + exitCodes = append(exitCodes, exitErr.ExitCode()) + } + if !(exitCodes[0] == 0 && exitCodes[1] == 23 || exitCodes[0] == 23 && exitCodes[1] == 0) { + t.Fatalf("writer exit codes = %v", exitCodes) + } + content, err := os.ReadFile(target) + if err != nil || !strings.HasPrefix(string(content), "writer-") { + t.Fatalf("serialized target = %q, %v", content, err) + } +} + +func durableWorkerCommand(t *testing.T, home, mode, target string) *exec.Cmd { + t.Helper() + command := exec.Command(os.Args[0], "-test.run=^TestDurableTransactionSubprocessWorker$") + command.Env = append( + os.Environ(), + durableWorkerEnvironment+"="+mode, + "ELYDORA_DURABLE_TARGET="+target, + "HOME="+home, + "USERPROFILE="+home, + ) + return command +} + +func waitForDurableTestPath(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + for { + if _, err := os.Lstat(path); err == nil { + return + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %s", path) + } + time.Sleep(time.Millisecond) + } +} + +func requireNoDurableTransactionState(t *testing.T, home string, targetDirectories ...string) { + t.Helper() + stateRoot := filepath.Join(home, ".elydora", "transactions") + entries, err := os.ReadDir(stateRoot) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if entry.Name() != "lock" { + t.Fatalf("durable journal remains: %s", filepath.Join(stateRoot, entry.Name())) + } + } + for _, directory := range targetDirectories { + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".elydora-txn-") { + t.Fatalf("durable workspace remains: %s", filepath.Join(directory, entry.Name())) + } + } + } +} diff --git a/cmd/elydora/plugins/transaction_test_home_test.go b/cmd/elydora/plugins/transaction_test_home_test.go new file mode 100644 index 0000000..6b06602 --- /dev/null +++ b/cmd/elydora/plugins/transaction_test_home_test.go @@ -0,0 +1,16 @@ +package plugins + +import ( + "os" + "testing" +) + +func preparePrivateTransactionTestDirectory(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatalf("create private test directory %s: %v", path, err) + } + if err := ensurePrivateTransactionDirectory(path); err != nil { + t.Fatalf("protect private test directory %s: %v", path, err) + } +} diff --git a/cmd/elydora/plugins/transaction_workspace.go b/cmd/elydora/plugins/transaction_workspace.go new file mode 100644 index 0000000..d521877 --- /dev/null +++ b/cmd/elydora/plugins/transaction_workspace.go @@ -0,0 +1,261 @@ +package plugins + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" +) + +func initializeDurableLayout(journal *durableJournal, changes []fileChange) error { + directories := make(map[string]int) + for index, change := range changes { + directory := filepath.Dir(change.filePath) + if err := ensureManagedDirectory(directory, "directory for "+change.label); err != nil { + return err + } + key := durablePathKey(directory) + workspaceIndex, exists := directories[key] + if !exists { + workspacePath := filepath.Join(directory, ".elydora-txn-"+journal.ID) + workspaceIndex = len(journal.Workspaces) + directories[key] = workspaceIndex + journal.Workspaces = append(journal.Workspaces, durableWorkspace{ + Path: workspacePath, MarkerPath: filepath.Join(workspacePath, "owner"), + OwnerToken: journal.OwnerToken, + }) + } + workspace := journal.Workspaces[workspaceIndex].Path + entry := durableEntry{ + Path: change.filePath, Label: change.label, Workspace: workspace, + DiscardPath: filepath.Join(workspace, fmt.Sprintf("%04d.discard", index)), + Original: artifactFromChangeOriginal(change), + } + switch { + case change.remove: + entry.Kind = durableDelete + entry.OriginalPath = filepath.Join(workspace, fmt.Sprintf("%04d.original", index)) + default: + entry.NextPath = filepath.Join(workspace, fmt.Sprintf("%04d.next", index)) + entry.Next = durableArtifact{ + Exists: true, SHA256: artifactDigest(change.next), Mode: uint32(change.mode.Perm()), + } + if change.existed { + entry.Kind = durableUpdate + if atomicReplacementUsesSeparateBackup() { + entry.OriginalPath = filepath.Join( + workspace, + fmt.Sprintf("%04d.original", index), + ) + } else { + entry.OriginalPath = entry.NextPath + } + } else { + entry.Kind = durableCreate + } + } + journal.Entries = append(journal.Entries, entry) + } + sort.Slice(journal.Workspaces, func(left, right int) bool { + return durablePathKey(journal.Workspaces[left].Path) < + durablePathKey(journal.Workspaces[right].Path) + }) + return nil +} + +func durablePathKey(path string) string { + key := filepath.Clean(path) + if runtime.GOOS == "windows" { + return strings.ToLower(filepath.ToSlash(key)) + } + return key +} + +func createDurableWorkspaces(journal *durableJournal) error { + for index := range journal.Workspaces { + workspace := &journal.Workspaces[index] + if err := createPrivateTransactionDirectory(workspace.Path); err != nil { + return fmt.Errorf( + "create transaction workspace %s: %w; recovery namespace %s", + workspace.Path, + err, + journal.JournalDir, + ) + } + marker := []byte(journal.ID + ":" + workspace.OwnerToken + "\n") + if err := writeOwnedFile(workspace.MarkerPath, marker, 0600); err != nil { + return fmt.Errorf("write transaction workspace owner at %s: %w", workspace.Path, err) + } + if err := syncTransactionDirectory(workspace.Path); err != nil { + return fmt.Errorf("persist transaction workspace owner at %s: %w", workspace.Path, err) + } + if err := syncTransactionDirectory(filepath.Dir(workspace.Path)); err != nil { + return fmt.Errorf("persist transaction workspace at %s: %w", workspace.Path, err) + } + identity, err := transactionDirectoryIdentity(workspace.Path) + if err != nil { + return err + } + workspace.DirectoryID = identity + if err := appendDurableJournal(journal, durablePhaseInitializing); err != nil { + return fmt.Errorf("persist transaction workspace identity at %s: %w", workspace.Path, err) + } + if err := probeAtomicTransactionCapabilities(workspace.Path); err != nil { + return fmt.Errorf( + "filesystem at %s lacks required durable transaction primitives: %w", + workspace.Path, + err, + ) + } + } + return nil +} + +func transactionDirectoryIdentity(path string) (string, error) { + directory, err := os.Open(path) + if err != nil { + return "", err + } + info, statErr := directory.Stat() + if statErr != nil || !info.IsDir() { + return "", errors.Join( + fmt.Errorf("transaction workspace is not a directory: %s", path), + statErr, + directory.Close(), + ) + } + identity, identityErr := transactionFileIdentity(directory, info) + return identity, errors.Join(identityErr, directory.Close()) +} + +func stageDurableEntries(journal *durableJournal, changes []fileChange) error { + for index := range journal.Entries { + entry := &journal.Entries[index] + change := changes[index] + if entry.Kind == durableDelete { + continue + } + if err := writeOwnedFile(entry.NextPath, change.next, change.mode); err != nil { + return fmt.Errorf("stage %s at %s: %w", change.label, entry.NextPath, err) + } + snapshot, err := readDurableArtifact(entry.NextPath, "staged "+change.label) + if err != nil { + return err + } + entry.Next = artifactFromSnapshot(snapshot) + if entry.Next.SHA256 != artifactDigest(change.next) || + !sameManagedFileMode(snapshot.mode, change.mode) { + return fmt.Errorf("staged %s changed before commit: %s", change.label, entry.NextPath) + } + if err := appendDurableJournal(journal, durablePhaseInitializing); err != nil { + return fmt.Errorf("persist staged %s identity: %w", change.label, err) + } + } + return nil +} + +func probeAtomicTransactionCapabilities(workspace string) (result error) { + target := filepath.Join(workspace, "probe-target") + replacement := filepath.Join(workspace, "probe-replacement") + backup := replacement + if atomicReplacementUsesSeparateBackup() { + backup = filepath.Join(workspace, "probe-backup") + } + discard := filepath.Join(workspace, "probe-discard") + left := filepath.Join(workspace, "probe-left") + right := filepath.Join(workspace, "probe-right") + defer func() { + result = errors.Join( + result, + cleanupInterruptedCapabilityProbe(workspace), + ) + }() + for path, content := range map[string]string{target: "old", replacement: "new"} { + if err := writeOwnedFile(path, []byte(content), 0600); err != nil { + return err + } + } + if err := atomicReplaceWithBackup(target, replacement, backup); err != nil { + return err + } + installed, err := readDurableArtifact(target, "atomic replacement probe") + if err != nil || installed == nil || string(installed.contents) != "new" { + return errors.Join(fmt.Errorf("atomic replacement probe failed"), err) + } + old, err := readDurableArtifact(backup, "atomic backup probe") + if err != nil || old == nil || string(old.contents) != "old" { + return errors.Join(fmt.Errorf("atomic replacement backup probe failed"), err) + } + if err := atomicReplaceWithBackup(target, backup, discardPathForProbe(backup, discard)); err != nil { + return fmt.Errorf("atomic rollback probe failed: %w", err) + } + if err := writeOwnedFile(left, []byte("left"), 0600); err != nil { + return err + } + if err := writeOwnedFile(right, []byte("right"), 0600); err != nil { + return err + } + leftBefore, err := readDurableArtifact(left, "atomic no-replace source probe") + if err != nil || leftBefore == nil { + return errors.Join(fmt.Errorf("read atomic no-replace source probe"), err) + } + rightBefore, err := readDurableArtifact(right, "atomic no-replace destination probe") + if err != nil || rightBefore == nil { + return errors.Join(fmt.Errorf("read atomic no-replace destination probe"), err) + } + renameErr := atomicRenameNoReplace(left, right) + if renameErr == nil { + return fmt.Errorf("atomic no-replace probe replaced an existing file") + } + if !isAtomicDestinationExists(renameErr) { + return fmt.Errorf("atomic no-replace probe returned an unsupported error: %w", renameErr) + } + if _, err := requireDurableArtifact( + left, + "atomic no-replace source probe", + artifactFromSnapshot(leftBefore), + ); err != nil { + return err + } + if _, err := requireDurableArtifact( + right, + "atomic no-replace destination probe", + artifactFromSnapshot(rightBefore), + ); err != nil { + return err + } + return nil +} + +func discardPathForProbe(backup, discard string) string { + if atomicReplacementUsesSeparateBackup() { + return discard + } + return backup +} + +func cleanupInterruptedCapabilityProbe(workspace string) error { + var failures []error + for _, name := range []string{ + "probe-target", + "probe-replacement", + "probe-backup", + "probe-discard", + "probe-left", + "probe-right", + } { + path := filepath.Join(workspace, name) + snapshot, err := readManagedFile(path, "transaction capability probe", 64) + if err != nil { + failures = append(failures, err) + continue + } + if snapshot != nil { + failures = append(failures, removePhysicalOwnedFile(path, snapshot.identity)) + } + } + return errors.Join(errors.Join(failures...), syncTransactionDirectory(workspace)) +} diff --git a/cmd/elydora/qwen_e2e_test.go b/cmd/elydora/qwen_e2e_test.go index 5d04dc9..fce5c61 100644 --- a/cmd/elydora/qwen_e2e_test.go +++ b/cmd/elydora/qwen_e2e_test.go @@ -45,6 +45,7 @@ func TestQwenCLIInstallStatusAndUninstallEndToEnd(t *testing.T) { t.Fatalf("build Elydora CLI: %v\n%s", err, output) } home := filepath.Join(root, "home") + preparePrivateCLITestHome(t, home) workspace := filepath.Join(home, "workspace") if err := os.MkdirAll(workspace, 0755); err != nil { t.Fatalf("create workspace: %v", err) diff --git a/cmd/elydora/transaction_test_home_unix_test.go b/cmd/elydora/transaction_test_home_unix_test.go new file mode 100644 index 0000000..5d1f36e --- /dev/null +++ b/cmd/elydora/transaction_test_home_unix_test.go @@ -0,0 +1,18 @@ +//go:build !windows + +package main + +import ( + "os" + "testing" +) + +func preparePrivateCLITestHome(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatalf("create private CLI test home %s: %v", path, err) + } + if err := os.Chmod(path, 0700); err != nil { + t.Fatalf("protect CLI test home %s: %v", path, err) + } +} diff --git a/cmd/elydora/transaction_test_home_windows_test.go b/cmd/elydora/transaction_test_home_windows_test.go new file mode 100644 index 0000000..2f5b208 --- /dev/null +++ b/cmd/elydora/transaction_test_home_windows_test.go @@ -0,0 +1,44 @@ +//go:build windows + +package main + +import ( + "os" + "testing" + + "golang.org/x/sys/windows" +) + +func preparePrivateCLITestHome(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatalf("create private CLI test home %s: %v", path, err) + } + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + t.Fatalf("resolve CLI test user: %v", err) + } + descriptor, err := windows.SecurityDescriptorFromString( + "O:" + user.User.Sid.String() + "D:P(A;OICI;FA;;;" + user.User.Sid.String() + ")", + ) + if err != nil { + t.Fatalf("create CLI test home DACL: %v", err) + } + dacl, _, err := descriptor.DACL() + if err != nil { + t.Fatalf("read CLI test home DACL: %v", err) + } + if err := windows.SetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION| + windows.DACL_SECURITY_INFORMATION| + windows.PROTECTED_DACL_SECURITY_INFORMATION, + user.User.Sid, + nil, + dacl, + nil, + ); err != nil { + t.Fatalf("protect CLI test home %s: %v", path, err) + } +} diff --git a/go.mod b/go.mod index 7d91312..d92c18b 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require github.com/pelletier/go-toml/v2 v2.4.3 require ( github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a + golang.org/x/sys v0.30.0 golang.org/x/term v0.29.0 ) - -require golang.org/x/sys v0.30.0 // indirect