sdks/go: mirror Elydora-Go-SDK v2.0.1 under the /v2 module path
Rebuilt from the standalone f5341ed tree (durable managed-file transactions, Kiro IDE adapter, hook retry with a bounded chain lock, CLI version 2.0.1) with the Open-Source API trims re-applied: no admin events, members, webhooks, deep health, token rotation, or agent integration updates, and the mirror's freeze/unfreeze and registration validation contracts kept. The root quickstart now imports the /v2 path. Claude-Session: https://claude.ai/code/session_01QoUjdc7HGKTsgj5GhG8HvF
This commit is contained in:
1 parent
edf9d73c3e
commit
b00932d047
75 files changed
+8529
-443
No files matched your search
@@ -129,7 +129,7 @@ Elydora-Open-Source/
|
|||||||
├── sdks/
|
├── sdks/
|
||||||
│ ├── node/ # @elydora/sdk — Node.js / TypeScript SDK
|
│ ├── node/ # @elydora/sdk — Node.js / TypeScript SDK
|
||||||
│ ├── python/ # elydora — Python SDK (sync + async)
|
│ ├── python/ # elydora — Python SDK (sync + async)
|
||||||
│ └── go/ # github.com/Elydora-Infrastructure/Elydora-Go-SDK
|
│ └── go/ # github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2
|
||||||
├── helm/
|
├── helm/
|
||||||
│ └── elydora/ # Kubernetes Helm chart
|
│ └── elydora/ # Kubernetes Helm chart
|
||||||
├── scripts/ # Key generation, migration, and utility scripts
|
├── scripts/ # Key generation, migration, and utility scripts
|
||||||
@@ -675,7 +675,7 @@ elydora keygen
|
|||||||
**Installation:**
|
**Installation:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get github.com/Elydora-Infrastructure/Elydora-Go-SDK
|
go get github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2
|
||||||
```
|
```
|
||||||
|
|
||||||
**Requirements:** Go 1.21+
|
**Requirements:** Go 1.21+
|
||||||
@@ -689,7 +689,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK"
|
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -31,3 +31,4 @@ Thumbs.db
|
|||||||
.env.local
|
.env.local
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
/elydora
|
||||||
+7
-3
@@ -5,7 +5,7 @@ Official Go SDK for the [Elydora](https://elydora.com) tamper-evident audit plat
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get github.com/Elydora-Infrastructure/Elydora-Go-SDK
|
go get github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Go 1.21+. The CLI uses focused TOML and JSONC parsers to preserve user-owned agent configuration.
|
Requires Go 1.21+. The CLI uses focused TOML and JSONC parsers to preserve user-owned agent configuration.
|
||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK"
|
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -63,7 +63,7 @@ The SDK includes a CLI for installing audit hooks into AI coding agents.
|
|||||||
Agent IDs map to one physical directory directly under `~/.elydora`; portable filename rules and physical directory/config checks apply before writes or recursive removal. Ambiguous uninstall discovery requires an explicit agent ID.
|
Agent IDs map to one physical directory directly under `~/.elydora`; portable filename rules and physical directory/config checks apply before writes or recursive removal. Ambiguous uninstall discovery requires an explicit agent ID.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora@latest
|
go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora@latest
|
||||||
|
|
||||||
elydora install \
|
elydora install \
|
||||||
--agent claudecode \
|
--agent claudecode \
|
||||||
@@ -76,6 +76,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.
|
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.
|
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.
|
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.
|
||||||
@@ -90,6 +92,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 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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ func prepareClineCLIEnvironment(t *testing.T) (string, string, string, []string)
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
homeDir := filepath.Join(root, "home")
|
homeDir := filepath.Join(root, "home")
|
||||||
|
preparePrivateCLITestHome(t, homeDir)
|
||||||
clineDir := filepath.Join(root, "cline-home")
|
clineDir := filepath.Join(root, "cline-home")
|
||||||
workspace := filepath.Join(root, "workspace")
|
workspace := filepath.Join(root, "workspace")
|
||||||
if err := os.MkdirAll(workspace, 0700); err != nil {
|
if err := os.MkdirAll(workspace, 0700); err != nil {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func prepareDroidCLIEnvironment(t *testing.T) (string, string, []string) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
homeDir := filepath.Join(root, "home with spaces and 'quote %DROID%")
|
homeDir := filepath.Join(root, "home with spaces and 'quote %DROID%")
|
||||||
|
preparePrivateCLITestHome(t, homeDir)
|
||||||
workspace := filepath.Join(root, "workspace with spaces")
|
workspace := filepath.Join(root, "workspace with spaces")
|
||||||
if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0700); err != nil {
|
if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0700); err != nil {
|
||||||
t.Fatalf("create Factory Droid CLI workspace: %v", err)
|
t.Fatalf("create Factory Droid CLI workspace: %v", err)
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora/plugins"
|
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "1.2.1"
|
const version = "2.0.1"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func prepareAugmentFixture(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
homeDir := filepath.Join(root, "home with spaces and 'quote")
|
homeDir := filepath.Join(root, "home with spaces and 'quote")
|
||||||
|
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||||
projectDir := filepath.Join(root, "project with spaces")
|
projectDir := filepath.Join(root, "project with spaces")
|
||||||
if err := os.MkdirAll(projectDir, 0700); err != nil {
|
if err := os.MkdirAll(projectDir, 0700); err != nil {
|
||||||
t.Fatalf("create project directory: %v", err)
|
t.Fatalf("create project directory: %v", err)
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mismatchAPI struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rejections int
|
||||||
|
delay time.Duration
|
||||||
|
staleHead bool
|
||||||
|
staleLock bool
|
||||||
|
liveLock bool
|
||||||
|
statePath string
|
||||||
|
operations []map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
const mismatchExpected = "Rxlf4j36C3KvIQ3hWuOkX698BR5iDypUFuB70JjEuvM"
|
||||||
|
|
||||||
|
func (api *mismatchAPI) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
||||||
|
api.mu.Lock()
|
||||||
|
defer api.mu.Unlock()
|
||||||
|
response.Header().Set("Content-Type", "application/json")
|
||||||
|
if request.Method != http.MethodPost || request.URL.Path != "/v1/operations" {
|
||||||
|
_ = json.NewEncoder(response).Encode(map[string]any{"agent": map[string]any{"status": "active"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var operation map[string]any
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&operation); err != nil {
|
||||||
|
http.Error(response, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.operations = append(api.operations, operation)
|
||||||
|
count := len(api.operations)
|
||||||
|
if api.delay > 0 {
|
||||||
|
api.mu.Unlock()
|
||||||
|
time.Sleep(api.delay)
|
||||||
|
api.mu.Lock()
|
||||||
|
}
|
||||||
|
if api.statePath != "" {
|
||||||
|
_ = os.WriteFile(api.statePath, []byte(`{"prev_chain_hash":"`+strings.Repeat("B", 43)+`"}`), 0o600)
|
||||||
|
}
|
||||||
|
if count > api.rejections {
|
||||||
|
response.WriteHeader(http.StatusAccepted)
|
||||||
|
_, _ = fmt.Fprintf(response, `{"receipt":{"seq_no":%d}}`, count)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expected := mismatchExpected
|
||||||
|
if api.rejections != 1 {
|
||||||
|
expected = strings.Repeat("A", 42) + fmt.Sprint(count)
|
||||||
|
}
|
||||||
|
response.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = fmt.Fprintf(
|
||||||
|
response,
|
||||||
|
`{"error":{"code":"PREV_HASH_MISMATCH","message":"Expected prev_chain_hash \"%s\", got \"x\"."}}`,
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runClaudeAuditAgainst(t *testing.T, api *mismatchAPI) (*claudeFixture, []map[string]any, time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
server := httptest.NewServer(api)
|
||||||
|
defer server.Close()
|
||||||
|
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
|
||||||
|
fixture.config.BaseURL = server.URL
|
||||||
|
fixture.config.Token = "ely_test_token"
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Claude hooks: %v", err)
|
||||||
|
}
|
||||||
|
settings := readClaudeTestObject(t, fixture.configPath)
|
||||||
|
audit := claudeTestManagedHandler(t, settings, "PostToolUse", claudeAuditScript, claudeAuditStatusMessage)
|
||||||
|
if api.staleHead {
|
||||||
|
api.mu.Lock()
|
||||||
|
api.statePath = filepath.Join(fixture.agentDir, "chain-state.json")
|
||||||
|
api.mu.Unlock()
|
||||||
|
}
|
||||||
|
if api.staleLock || api.liveLock {
|
||||||
|
lockPath := filepath.Join(fixture.agentDir, "chain-state.json.lock")
|
||||||
|
var owner []byte
|
||||||
|
if api.liveLock {
|
||||||
|
owner = []byte(fmt.Sprint(os.Getpid()))
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(lockPath, owner, 0o600); err != nil {
|
||||||
|
t.Fatalf("write stale lock: %v", err)
|
||||||
|
}
|
||||||
|
stale := time.Now().Add(-10 * time.Second)
|
||||||
|
if err := os.Chtimes(lockPath, stale, stale); err != nil {
|
||||||
|
t.Fatalf("age stale lock: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
exit, stdout, stderr := runClaudeHandler(t, audit, fixture, claudeOfficialPayload("PostToolUse"))
|
||||||
|
elapsed := time.Since(started)
|
||||||
|
if exit != 0 || stdout != "" || stderr != "" {
|
||||||
|
t.Fatalf("audit result = %d, %q, %q", exit, stdout, stderr)
|
||||||
|
}
|
||||||
|
api.mu.Lock()
|
||||||
|
defer api.mu.Unlock()
|
||||||
|
return fixture, api.operations, elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditRetriesRejectedChainHash(t *testing.T) {
|
||||||
|
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{rejections: 1})
|
||||||
|
if len(operations) != 2 || operations[1]["prev_chain_hash"] != mismatchExpected {
|
||||||
|
t.Fatalf("operations = %#v", operations)
|
||||||
|
}
|
||||||
|
if operations[0]["operation_id"] == operations[1]["operation_id"] || operations[0]["nonce"] == operations[1]["nonce"] {
|
||||||
|
t.Fatalf("retry reused identifiers: %#v", operations)
|
||||||
|
}
|
||||||
|
state, err := os.ReadFile(filepath.Join(fixture.agentDir, "chain-state.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read chain state: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(state), fmt.Sprint(operations[1]["chain_hash"])) {
|
||||||
|
t.Fatalf("chain state %s does not hold %v", state, operations[1]["chain_hash"])
|
||||||
|
}
|
||||||
|
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
|
||||||
|
if err != nil || !strings.Contains(string(log), "resynced to server: Rxlf") {
|
||||||
|
t.Fatalf("error log = %q, %v", log, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditStopsAfterFiveRejections(t *testing.T) {
|
||||||
|
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{rejections: 99})
|
||||||
|
if len(operations) != 5 || operations[4]["prev_chain_hash"] != strings.Repeat("A", 42)+"4" {
|
||||||
|
t.Fatalf("operations = %#v", operations)
|
||||||
|
}
|
||||||
|
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
|
||||||
|
if err != nil || !strings.Contains(string(log), "rejected prev_chain_hash 5 times") {
|
||||||
|
t.Fatalf("error log = %q, %v", log, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditStopsRetryingWhenBudgetSpent(t *testing.T) {
|
||||||
|
fixture, operations, elapsed := runClaudeAuditAgainst(t, &mismatchAPI{rejections: 99, delay: 2600 * time.Millisecond})
|
||||||
|
if elapsed > 7500*time.Millisecond {
|
||||||
|
t.Fatalf("hook ran %s", elapsed)
|
||||||
|
}
|
||||||
|
if len(operations) < 2 || len(operations) > 3 {
|
||||||
|
t.Fatalf("submissions = %d", len(operations))
|
||||||
|
}
|
||||||
|
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
|
||||||
|
if err != nil || !(strings.Contains(string(log), "aborted") || strings.Contains(string(log), "retry budget exhausted")) {
|
||||||
|
t.Fatalf("error log = %q, %v", log, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditDoesNotRegressAConcurrentlyAdvancedChainHead(t *testing.T) {
|
||||||
|
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{staleHead: true})
|
||||||
|
if len(operations) != 1 || operations[0]["prev_chain_hash"] != strings.Repeat("A", 43) {
|
||||||
|
t.Fatalf("operations = %#v", operations)
|
||||||
|
}
|
||||||
|
state, err := os.ReadFile(filepath.Join(fixture.agentDir, "chain-state.json"))
|
||||||
|
if err != nil || !strings.Contains(string(state), strings.Repeat("B", 43)) {
|
||||||
|
t.Fatalf("chain state = %s, %v", state, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditClearsAStaleChainStateLock(t *testing.T) {
|
||||||
|
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{staleLock: true})
|
||||||
|
if len(operations) != 1 {
|
||||||
|
t.Fatalf("operations = %#v", operations)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(fixture.agentDir, "chain-state.json.lock")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("stale lock still present: %v", err)
|
||||||
|
}
|
||||||
|
state, err := os.ReadFile(filepath.Join(fixture.agentDir, "chain-state.json"))
|
||||||
|
if err != nil || !strings.Contains(string(state), fmt.Sprint(operations[0]["chain_hash"])) {
|
||||||
|
t.Fatalf("chain state = %s, %v", state, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditDoesNotReclaimALiveOwnersLock(t *testing.T) {
|
||||||
|
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{liveLock: true})
|
||||||
|
if len(operations) != 1 {
|
||||||
|
t.Fatalf("operations = %#v", operations)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(fixture.agentDir, "chain-state.json.lock")); err != nil {
|
||||||
|
t.Fatalf("live lock was removed: %v", err)
|
||||||
|
}
|
||||||
|
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
|
||||||
|
if err != nil || !strings.Contains(string(log), "lock timed out") {
|
||||||
|
t.Fatalf("error log = %q, %v", log, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ func prepareClaudeFixture(t *testing.T, options claudeFixtureOptions) *claudeFix
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
homeDir := filepath.Join(root, "home with 'quote %CLAUDE_HOOK_EVENT%")
|
homeDir := filepath.Join(root, "home with 'quote %CLAUDE_HOOK_EVENT%")
|
||||||
|
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||||
projectDir := filepath.Join(root, "project with spaces")
|
projectDir := filepath.Join(root, "project with spaces")
|
||||||
if err := os.MkdirAll(projectDir, 0700); err != nil {
|
if err := os.MkdirAll(projectDir, 0700); err != nil {
|
||||||
t.Fatalf("create project directory: %v", err)
|
t.Fatalf("create project directory: %v", err)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ func prepareClineFixture(t *testing.T, options clineFixtureOptions) *clineFixtur
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
rootDir := t.TempDir()
|
rootDir := t.TempDir()
|
||||||
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
|
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
|
||||||
|
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||||
workspaceDir := filepath.Join(rootDir, "workspace")
|
workspaceDir := filepath.Join(rootDir, "workspace")
|
||||||
clineDir := filepath.Join(rootDir, "custom-cline-home")
|
clineDir := filepath.Join(rootDir, "custom-cline-home")
|
||||||
hooksDir := filepath.Join(clineDir, "hooks")
|
hooksDir := filepath.Join(clineDir, "hooks")
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type codexFixture struct {
|
|||||||
func prepareCodexFixture(t *testing.T, existingRaw string) *codexFixture {
|
func prepareCodexFixture(t *testing.T, existingRaw string) *codexFixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
homeDir := filepath.Join(t.TempDir(), "home with 'quote %ELYDORA_HOOK_PATH%")
|
homeDir := filepath.Join(t.TempDir(), "home with 'quote %ELYDORA_HOOK_PATH%")
|
||||||
|
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||||
agentDir := filepath.Join(homeDir, ".elydora", codexTestAgentID)
|
agentDir := filepath.Join(homeDir, ".elydora", codexTestAgentID)
|
||||||
configPath := filepath.Join(homeDir, ".codex", "hooks.json")
|
configPath := filepath.Join(homeDir, ".codex", "hooks.json")
|
||||||
guardPath := filepath.Join(agentDir, "guard.js")
|
guardPath := filepath.Join(agentDir, "guard.js")
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ func prepareCopilotFixture(t *testing.T, options copilotFixtureOptions) *copilot
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
rootDir := t.TempDir()
|
rootDir := t.TempDir()
|
||||||
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
|
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
|
||||||
|
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||||
projectDir := filepath.Join(rootDir, "project with spaces")
|
projectDir := filepath.Join(rootDir, "project with spaces")
|
||||||
copilotHome := filepath.Join(homeDir, "custom copilot")
|
copilotHome := filepath.Join(homeDir, "custom copilot")
|
||||||
agentDir := filepath.Join(homeDir, ".elydora", copilotTestAgentID)
|
agentDir := filepath.Join(homeDir, ".elydora", copilotTestAgentID)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ func prepareCursorFixture(t *testing.T, existingRaw *string, createGuard bool) *
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
rootDir := t.TempDir()
|
rootDir := t.TempDir()
|
||||||
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
|
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
|
||||||
|
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||||
agentDir := filepath.Join(homeDir, ".elydora", cursorTestAgentID)
|
agentDir := filepath.Join(homeDir, ".elydora", cursorTestAgentID)
|
||||||
configPath := filepath.Join(homeDir, ".cursor", "hooks.json")
|
configPath := filepath.Join(homeDir, ".cursor", "hooks.json")
|
||||||
guardPath := filepath.Join(agentDir, "guard.js")
|
guardPath := filepath.Join(agentDir, "guard.js")
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ func prepareDroidFixture(t *testing.T, options droidFixtureOptions) *droidFixtur
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
rootDir := t.TempDir()
|
rootDir := t.TempDir()
|
||||||
homeDir := filepath.Join(rootDir, "home with spaces and 'quote %DROID%")
|
homeDir := filepath.Join(rootDir, "home with spaces and 'quote %DROID%")
|
||||||
|
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||||
workspaceDir := filepath.Join(rootDir, "workspace with spaces")
|
workspaceDir := filepath.Join(rootDir, "workspace with spaces")
|
||||||
factoryDir := filepath.Join(homeDir, ".factory")
|
factoryDir := filepath.Join(homeDir, ".factory")
|
||||||
configPath := filepath.Join(factoryDir, "hooks.json")
|
configPath := filepath.Join(factoryDir, "hooks.json")
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ func prepareGeminiFixture(
|
|||||||
root,
|
root,
|
||||||
"home with 'quote $GEMINI_CWD %GEMINI_CWD%",
|
"home with 'quote $GEMINI_CWD %GEMINI_CWD%",
|
||||||
)
|
)
|
||||||
|
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||||
projectDir := filepath.Join(root, "project with spaces")
|
projectDir := filepath.Join(root, "project with spaces")
|
||||||
geminiHome := filepath.Join(homeDir, "custom gemini home")
|
geminiHome := filepath.Join(homeDir, "custom gemini home")
|
||||||
if options.useDefaultHome {
|
if options.useDefaultHome {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func prepareGrokFixture(t *testing.T, options grokFixtureOptions) *grokFixture {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
homeDir := filepath.Join(root, "home with 'quote %ELYDORA_HOOK_PATH%")
|
homeDir := filepath.Join(root, "home with 'quote %ELYDORA_HOOK_PATH%")
|
||||||
|
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||||
projectDir := filepath.Join(root, "project with spaces")
|
projectDir := filepath.Join(root, "project with spaces")
|
||||||
grokHome := filepath.Join(homeDir, "custom grok")
|
grokHome := filepath.Join(homeDir, "custom grok")
|
||||||
if options.useDefaultHome {
|
if options.useDefaultHome {
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ function signEd25519(privateKeyBase64url, data) {
|
|||||||
return base64urlEncode(signature);
|
return base64urlEncode(signature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_CHAIN_ATTEMPTS = 5;
|
||||||
|
const SUBMIT_BUDGET_MS = 4000;
|
||||||
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||||
|
|
||||||
function validateChainHash(value, source) {
|
function validateChainHash(value, source) {
|
||||||
@@ -188,6 +190,81 @@ function writeChainState(chainHash) {
|
|||||||
writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash });
|
writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lockOwnerAlive(lockPath) {
|
||||||
|
let owner;
|
||||||
|
try {
|
||||||
|
owner = Number.parseInt(fs.readFileSync(lockPath, 'utf-8'), 10);
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(owner) || owner <= 0) return false;
|
||||||
|
try {
|
||||||
|
process.kill(owner, 0);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (hasRuntimeErrorCode(error, 'ESRCH')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withChainLock(update) {
|
||||||
|
const lockPath = CHAIN_STATE_PATH + '.lock';
|
||||||
|
const deadline = performance.now() + 1000;
|
||||||
|
for (;;) {
|
||||||
|
let descriptor;
|
||||||
|
try {
|
||||||
|
descriptor = fs.openSync(lockPath, 'wx', 0o600);
|
||||||
|
fs.writeSync(descriptor, String(process.pid));
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasRuntimeErrorCode(error, 'EEXIST')) throw error;
|
||||||
|
if (lockOwnerAlive(lockPath)) {
|
||||||
|
if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath);
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let age = 0;
|
||||||
|
try { age = Date.now() - fs.statSync(lockPath).mtimeMs; } catch (statError) {
|
||||||
|
if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError;
|
||||||
|
}
|
||||||
|
if (age > 5000) {
|
||||||
|
const reclaimed = lockPath + '.' + process.pid + '.stale';
|
||||||
|
try {
|
||||||
|
fs.renameSync(lockPath, reclaimed);
|
||||||
|
fs.unlinkSync(reclaimed);
|
||||||
|
} catch (reclaimError) {
|
||||||
|
if (!hasRuntimeErrorCode(reclaimError, 'ENOENT')) throw reclaimError;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath);
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return update();
|
||||||
|
} finally {
|
||||||
|
let current = null;
|
||||||
|
try {
|
||||||
|
current = fs.lstatSync(lockPath);
|
||||||
|
} catch (statError) {
|
||||||
|
if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError;
|
||||||
|
}
|
||||||
|
const own = fs.fstatSync(descriptor);
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
if (current && current.ino === own.ino && current.dev === own.dev) fs.unlinkSync(lockPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceChainState(fromHash, toHash) {
|
||||||
|
return withChainLock(() => {
|
||||||
|
if (readChainState() !== fromHash) return false;
|
||||||
|
writeChainState(toHash);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Error logging
|
// Error logging
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -230,6 +307,41 @@ function resolveAuditBaseURL(config) {
|
|||||||
// Main hook logic
|
// Main hook logic
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function postOperation(baseUrl, token, eor, deadline) {
|
||||||
|
const remaining = deadline - performance.now();
|
||||||
|
if (remaining <= 0) throw new Error('Audit API retry budget exhausted');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), Math.min(5000, remaining));
|
||||||
|
try {
|
||||||
|
const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
||||||
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
const res = await fetch(baseUrl + '/v1/operations', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify(eor),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (res.ok) return { accepted: true };
|
||||||
|
if (res.status === 400) {
|
||||||
|
let errBody;
|
||||||
|
try {
|
||||||
|
errBody = await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('Audit API returned invalid error JSON: ' + err.message);
|
||||||
|
}
|
||||||
|
if (errBody.error && errBody.error.code === 'PREV_HASH_MISMATCH') {
|
||||||
|
const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/);
|
||||||
|
if (match) {
|
||||||
|
return { accepted: false, expected: validateChainHash(match[1], 'Audit API mismatch response') };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Audit API returned HTTP ' + res.status);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
// Read JSON from stdin
|
// Read JSON from stdin
|
||||||
@@ -304,91 +416,48 @@ async function main() {
|
|||||||
}
|
}
|
||||||
if (typeof token !== 'string') throw new Error('Agent config token must be a string');
|
if (typeof token !== 'string') throw new Error('Agent config token must be a string');
|
||||||
|
|
||||||
// Read chain state
|
// Build and submit, retrying from the server chain head on PREV_HASH_MISMATCH
|
||||||
const prevChainHash = readChainState();
|
|
||||||
|
|
||||||
// Build EOR
|
|
||||||
const operationId = uuidv7();
|
|
||||||
const issuedAt = Date.now();
|
|
||||||
const nonce = generateNonce();
|
|
||||||
|
|
||||||
const payload = NATIVE_PAYLOAD ? hookData : {
|
const payload = NATIVE_PAYLOAD ? hookData : {
|
||||||
tool_name: toolName,
|
tool_name: toolName,
|
||||||
tool_input: toolInput,
|
tool_input: toolInput,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const payloadHash = computePayloadHash(payload);
|
const payloadHash = computePayloadHash(payload);
|
||||||
const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt);
|
const deadline = performance.now() + SUBMIT_BUDGET_MS;
|
||||||
|
let prevChainHash = readChainState();
|
||||||
const eorWithoutSig = {
|
for (let attempt = 1; ; attempt += 1) {
|
||||||
op_version: '1.0',
|
const operationId = uuidv7();
|
||||||
operation_id: operationId,
|
const issuedAt = Date.now();
|
||||||
org_id: orgId,
|
const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt);
|
||||||
agent_id: agentId,
|
const eorWithoutSig = {
|
||||||
issued_at: issuedAt,
|
op_version: '1.0',
|
||||||
ttl_ms: 30000,
|
operation_id: operationId,
|
||||||
nonce: nonce,
|
org_id: orgId,
|
||||||
operation_type: 'ai.tool_use',
|
agent_id: agentId,
|
||||||
subject: { session_id: sessionId },
|
issued_at: issuedAt,
|
||||||
action: { tool: toolName },
|
ttl_ms: 30000,
|
||||||
payload: payload,
|
nonce: generateNonce(),
|
||||||
payload_hash: payloadHash,
|
operation_type: 'ai.tool_use',
|
||||||
prev_chain_hash: prevChainHash,
|
subject: { session_id: sessionId },
|
||||||
agent_pubkey_kid: kid,
|
action: { tool: toolName },
|
||||||
};
|
payload: payload,
|
||||||
|
payload_hash: payloadHash,
|
||||||
const canonical = jcsCanonicalise(eorWithoutSig);
|
prev_chain_hash: prevChainHash,
|
||||||
const signature = signEd25519(privateKey, Buffer.from(canonical, 'utf-8'));
|
agent_pubkey_kid: kid,
|
||||||
|
|
||||||
const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature });
|
|
||||||
|
|
||||||
// POST to /v1/operations (5s timeout)
|
|
||||||
// Only update local chain state on success to prevent desync
|
|
||||||
const url = baseUrl + '/v1/operations';
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
};
|
};
|
||||||
if (token) {
|
const signature = signEd25519(privateKey, Buffer.from(jcsCanonicalise(eorWithoutSig), 'utf-8'));
|
||||||
headers['Authorization'] = 'Bearer ' + token;
|
const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature });
|
||||||
}
|
const result = await postOperation(baseUrl, token, eor, deadline);
|
||||||
|
if (result.accepted) {
|
||||||
const res = await fetch(url, {
|
advanceChainState(prevChainHash, chainHash);
|
||||||
method: 'POST',
|
|
||||||
headers: headers,
|
|
||||||
body: JSON.stringify(eor),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
writeChainState(chainHash);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (res.status === 400) {
|
advanceChainState(prevChainHash, result.expected);
|
||||||
// Possible chain hash mismatch — try to resync from server response
|
logError(new Error('Chain hash resynced to server: ' + result.expected));
|
||||||
try {
|
if (attempt >= MAX_CHAIN_ATTEMPTS) {
|
||||||
const errBody = await res.json();
|
throw new Error('Audit API rejected prev_chain_hash ' + attempt + ' times');
|
||||||
if (errBody.error && errBody.error.code === 'PREV_HASH_MISMATCH') {
|
|
||||||
// Extract the expected hash from the error message
|
|
||||||
const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/);
|
|
||||||
if (match) {
|
|
||||||
const expectedHash = validateChainHash(match[1], 'Audit API mismatch response');
|
|
||||||
writeChainState(expectedHash);
|
|
||||||
logError(new Error('Chain hash resynced to server: ' + expectedHash));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logError(new Error('Failed to parse audit API error: ' + err.message));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
throw new Error('Audit API returned HTTP ' + res.status);
|
prevChainHash = readChainState();
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(err);
|
logError(err);
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ func prepareKimiFixture(t *testing.T, options kimiFixtureOptions) *kimiFixture {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
homeDir := filepath.Join(root, "home with spaces and 'quote %ELYDORA_HOOK_PATH%")
|
homeDir := filepath.Join(root, "home with spaces and 'quote %ELYDORA_HOOK_PATH%")
|
||||||
|
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||||
projectDir := filepath.Join(root, "project with spaces")
|
projectDir := filepath.Join(root, "project with spaces")
|
||||||
kimiHome := filepath.Join(homeDir, "custom kimi-code")
|
kimiHome := filepath.Join(homeDir, "custom kimi-code")
|
||||||
if options.useDefaultHome {
|
if options.useDefaultHome {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type kiroFixture struct {
|
|||||||
func prepareKiroFixture(t *testing.T, v2Raw, v3Raw string) *kiroFixture {
|
func prepareKiroFixture(t *testing.T, v2Raw, v3Raw string) *kiroFixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
homeDir := filepath.Join(t.TempDir(), "home with spaces")
|
homeDir := filepath.Join(t.TempDir(), "home with spaces")
|
||||||
|
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||||
agentDir := filepath.Join(homeDir, ".elydora", kiroTestAgentID)
|
agentDir := filepath.Join(homeDir, ".elydora", kiroTestAgentID)
|
||||||
guardPath := filepath.Join(agentDir, "guard.js")
|
guardPath := filepath.Join(agentDir, "guard.js")
|
||||||
v2Path := filepath.Join(homeDir, ".kiro", "agents", "elydora-audit.json")
|
v2Path := filepath.Join(homeDir, ".kiro", "agents", "elydora-audit.json")
|
||||||
|
|||||||
@@ -1,143 +1,128 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
// KiroIdePlugin manages the Elydora audit hook for Kiro IDE.
|
// KiroIdePlugin manages Kiro IDE 1.0 workspace hooks.
|
||||||
// It writes a .kiro/hooks/elydora-audit.kiro.hook JSON file in the home directory.
|
type KiroIdePlugin struct {
|
||||||
type KiroIdePlugin struct{}
|
rename renameFunc
|
||||||
|
}
|
||||||
|
|
||||||
func (p *KiroIdePlugin) configPath() (string, error) {
|
// ManagesGuardRuntime reports that Kiro IDE commits both generated runtimes,
|
||||||
home, err := os.UserHomeDir()
|
// 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 {
|
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 {
|
func (p *KiroIdePlugin) Install(config InstallConfig) error {
|
||||||
scriptPath, err := hookScriptPath(config.AgentID)
|
sources, err := readKiroIdeSources()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if config.HookScript != "" {
|
if err := requireAvailableKiroIdeHooks(sources.document.hooks); err != nil {
|
||||||
scriptPath = config.HookScript
|
return err
|
||||||
}
|
}
|
||||||
|
paths, nodePath, err := preflightKiroIdeInstallation(config)
|
||||||
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()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dir := filepath.Dir(hookFile)
|
hooks := withoutManagedKiroIdeHooks(sources.document.hooks, "")
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
guard, err := buildKiroIdeHook(kiroIdeGuardName, nodePath, paths.guardPath)
|
||||||
return fmt.Errorf("create directory %s: %w", dir, err)
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
audit, err := buildKiroIdeHook(kiroIdeAuditName, nodePath, paths.auditPath)
|
||||||
content := buildKiroIdeHookFile(scriptPath, guardPath)
|
if err != nil {
|
||||||
if err := os.WriteFile(hookFile, []byte(content), 0644); err != nil {
|
return err
|
||||||
return fmt.Errorf("write %s: %w", hookFile, err)
|
|
||||||
}
|
}
|
||||||
|
rendered, err := renderKiroIdeDocument(
|
||||||
fmt.Printf("Installed Elydora hook for Kiro IDE at %s\n", hookFile)
|
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
|
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 {
|
func (p *KiroIdePlugin) Uninstall(agentID string) error {
|
||||||
hookFile, err := p.configPath()
|
sources, err := readKiroIdeSources()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.Remove(hookFile); err != nil && !os.IsNotExist(err) {
|
rendered, err := renderKiroIdeDocument(
|
||||||
return fmt.Errorf("remove %s: %w", hookFile, err)
|
sources.document,
|
||||||
|
withoutManagedKiroIdeHooks(sources.document.hooks, agentID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
prepared, err := prepareKiroIdeUninstall(sources, rendered, agentID)
|
||||||
if agentID != "" {
|
if err != nil {
|
||||||
scriptPath, _ := hookScriptPath(agentID)
|
return err
|
||||||
if scriptPath != "" {
|
|
||||||
os.Remove(scriptPath)
|
|
||||||
}
|
|
||||||
gPath, _ := guardScriptPath(agentID)
|
|
||||||
if gPath != "" {
|
|
||||||
os.Remove(gPath)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if err := commitKiroIdeUninstall(prepared, p.rename); err != nil {
|
||||||
fmt.Println("Uninstalled Elydora hook for Kiro IDE.")
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Uninstalled Elydora hooks from Kiro IDE.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *KiroIdePlugin) Status() (PluginStatus, error) {
|
func (p *KiroIdePlugin) Status() (PluginStatus, error) {
|
||||||
hookFile, err := p.configPath()
|
sources, err := readKiroIdeSources()
|
||||||
if err != nil {
|
entry := SupportedAgents[kiroIdeAgentKey]
|
||||||
return PluginStatus{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
status := PluginStatus{
|
status := PluginStatus{
|
||||||
AgentName: "kiroide",
|
AgentName: kiroIdeAgentKey,
|
||||||
DisplayName: "Kiro IDE",
|
DisplayName: entry.Name,
|
||||||
ConfigPath: hookFile,
|
ConfigPath: sourcesPathOrEmpty(sources),
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
// Check if hook file exists and contains both pre_tool_use and post_tool_use
|
return status, err
|
||||||
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 := requireAvailableKiroIdeHooks(sources.document.hooks); err != nil {
|
||||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
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
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sourcesPathOrEmpty(sources *kiroIdeSources) string {
|
||||||
|
if sources == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return sources.paths.configPath
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ type managedFileSnapshot struct {
|
|||||||
contents []byte
|
contents []byte
|
||||||
info os.FileInfo
|
info os.FileInfo
|
||||||
mode os.FileMode
|
mode os.FileMode
|
||||||
|
identity string
|
||||||
}
|
}
|
||||||
|
|
||||||
func readManagedFile(
|
func readManagedFile(
|
||||||
@@ -51,6 +52,13 @@ func readManagedFile(
|
|||||||
file.Close(),
|
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))
|
raw, readErr := io.ReadAll(io.LimitReader(file, maximumBytes+1))
|
||||||
closeErr := file.Close()
|
closeErr := file.Close()
|
||||||
if readErr != nil || closeErr != nil {
|
if readErr != nil || closeErr != nil {
|
||||||
@@ -70,6 +78,7 @@ func readManagedFile(
|
|||||||
contents: raw,
|
contents: raw,
|
||||||
info: after,
|
info: after,
|
||||||
mode: after.Mode().Perm(),
|
mode: after.Mode().Perm(),
|
||||||
|
identity: identity,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ func prepareQwenFixture(t *testing.T, options qwenFixtureOptions) *qwenFixture {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
rootDir := t.TempDir()
|
rootDir := t.TempDir()
|
||||||
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
|
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
|
||||||
|
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||||
workspaceDir := filepath.Join(homeDir, "workspace")
|
workspaceDir := filepath.Join(homeDir, "workspace")
|
||||||
qwenDir := filepath.Join(homeDir, ".qwen")
|
qwenDir := filepath.Join(homeDir, ".qwen")
|
||||||
configPath := filepath.Join(qwenDir, "settings.json")
|
configPath := filepath.Join(qwenDir, "settings.json")
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{
|
|||||||
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
|
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
|
||||||
"grok": {Name: "Grok Build", ConfigDir: "~/.grok/hooks", ConfigFile: "elydora-audit.json"},
|
"grok": {Name: "Grok Build", ConfigDir: "~/.grok/hooks", ConfigFile: "elydora-audit.json"},
|
||||||
"kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro/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"},
|
"kimi": {Name: "Kimi Code", ConfigDir: "~/.kimi-code", ConfigFile: "config.toml"},
|
||||||
"opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"},
|
"opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"},
|
||||||
"copilot": {Name: "GitHub Copilot CLI", ConfigDir: "~/.copilot/hooks", ConfigFile: "elydora-audit.json"},
|
"copilot": {Name: "GitHub Copilot CLI", ConfigDir: "~/.copilot/hooks", ConfigFile: "elydora-audit.json"},
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ func WriteRuntimeFileAtomic(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
// GenerateHookScript atomically commits the agent config, private key, and
|
||||||
// self-contained Node.js audit runtime.
|
// self-contained Node.js audit runtime.
|
||||||
func GenerateHookScript(destPath string, config InstallConfig) error {
|
func GenerateHookScript(destPath string, config InstallConfig) error {
|
||||||
return generateHookScriptWithRename(destPath, config, os.Rename)
|
return generateHookScriptWithRename(destPath, config, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateHookScriptWithRename(
|
func generateHookScriptWithRename(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
func setRuntimeFilesTestHome(t *testing.T) string {
|
func setRuntimeFilesTestHome(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
|
preparePrivateTransactionTestDirectory(t, home)
|
||||||
t.Setenv("HOME", home)
|
t.Setenv("HOME", home)
|
||||||
t.Setenv("USERPROFILE", home)
|
t.Setenv("USERPROFILE", home)
|
||||||
return home
|
return home
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -12,12 +11,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type renameFunc func(source, destination string) error
|
type renameFunc func(source, destination string) error
|
||||||
|
type replaceWithBackupFunc func(target, replacement, backup string) error
|
||||||
|
|
||||||
type fileChange struct {
|
type fileChange struct {
|
||||||
filePath string
|
filePath string
|
||||||
label string
|
label string
|
||||||
original []byte
|
original []byte
|
||||||
originalInfo os.FileInfo
|
originalInfo os.FileInfo
|
||||||
|
originalID string
|
||||||
originalMode os.FileMode
|
originalMode os.FileMode
|
||||||
existed bool
|
existed bool
|
||||||
next []byte
|
next []byte
|
||||||
@@ -26,11 +27,40 @@ type fileChange struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type stagedChange struct {
|
type stagedChange struct {
|
||||||
change fileChange
|
change fileChange
|
||||||
temporaryPath string
|
temporaryPath string
|
||||||
rollbackPath string
|
temporaryInfo os.FileInfo
|
||||||
committedInfo os.FileInfo
|
rollbackPath string
|
||||||
committed bool
|
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) {
|
func readOptionalFile(path, label string) ([]byte, bool, error) {
|
||||||
@@ -80,7 +110,8 @@ func prepareSourceChange(
|
|||||||
(currentExists && !bytes.Equal(snapshot.contents, original)) {
|
(currentExists && !bytes.Equal(snapshot.contents, original)) {
|
||||||
return nil, fmt.Errorf("%s changed before update: %s", label, filePath)
|
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
|
return nil, nil
|
||||||
}
|
}
|
||||||
if !existed && (remove || next == nil) {
|
if !existed && (remove || next == nil) {
|
||||||
@@ -94,22 +125,52 @@ func prepareSourceChange(
|
|||||||
}
|
}
|
||||||
return &fileChange{
|
return &fileChange{
|
||||||
filePath: filePath, label: label, original: append([]byte(nil), original...),
|
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...),
|
next: append([]byte(nil), next...),
|
||||||
mode: mode, remove: remove,
|
mode: mode, remove: remove,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func snapshotIdentity(snapshot *managedFileSnapshot) string {
|
||||||
|
if snapshot == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return snapshot.identity
|
||||||
|
}
|
||||||
|
|
||||||
func writeChanges(
|
func writeChanges(
|
||||||
changes []*fileChange,
|
changes []*fileChange,
|
||||||
label string,
|
label string,
|
||||||
rename renameFunc,
|
rename renameFunc,
|
||||||
preconditions ...filePrecondition,
|
preconditions ...filePrecondition,
|
||||||
) error {
|
) 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))
|
filtered := make([]fileChange, 0, len(changes))
|
||||||
targets := map[string]bool{}
|
targets := map[string]bool{}
|
||||||
for _, change := range changes {
|
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
|
continue
|
||||||
}
|
}
|
||||||
target := filepath.Clean(change.filePath)
|
target := filepath.Clean(change.filePath)
|
||||||
@@ -128,17 +189,30 @@ func writeChanges(
|
|||||||
if len(filtered) == 0 {
|
if len(filtered) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if rename == nil {
|
|
||||||
rename = os.Rename
|
|
||||||
}
|
|
||||||
staged := make([]stagedChange, 0, len(filtered))
|
staged := make([]stagedChange, 0, len(filtered))
|
||||||
for _, change := range 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)
|
item, err := stageChange(change)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanupErrors := cleanupStaging(staged)
|
cleanupErrors := cleanupStaging(staged)
|
||||||
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), cleanupErrors, "cleanup failed")
|
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), cleanupErrors, "cleanup failed")
|
||||||
}
|
}
|
||||||
staged = append(staged, item)
|
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 {
|
if err := assertFilePreconditions(preconditions, label); err != nil {
|
||||||
cleanupErrors := cleanupStaging(staged)
|
cleanupErrors := cleanupStaging(staged)
|
||||||
@@ -146,18 +220,23 @@ func writeChanges(
|
|||||||
}
|
}
|
||||||
for index := range staged {
|
for index := range staged {
|
||||||
if err := assertFilePreconditions(preconditions, label); err != nil {
|
if err := assertFilePreconditions(preconditions, label); err != nil {
|
||||||
recoveryErrors := rollbackChanges(staged, rename)
|
recoveryErrors := rollbackChanges(staged, ops)
|
||||||
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
|
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
|
||||||
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
|
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
|
||||||
}
|
}
|
||||||
if err := commitChange(&staged[index], rename); err != nil {
|
if err := commitChange(&staged[index], ops); err != nil {
|
||||||
recoveryErrors := rollbackChanges(staged, rename)
|
recoveryErrors := rollbackChanges(staged, ops)
|
||||||
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
|
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
|
||||||
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
|
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := assertFilePreconditions(preconditions, label); err != nil {
|
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)...)
|
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
|
||||||
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
|
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
|
||||||
}
|
}
|
||||||
@@ -168,234 +247,6 @@ func writeChanges(
|
|||||||
return nil
|
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 {
|
func joinTransactionFailure(cause error, related []error, label string) error {
|
||||||
if len(related) == 0 {
|
if len(related) == 0 {
|
||||||
return cause
|
return cause
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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{},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ type filePrecondition struct {
|
|||||||
filePath string
|
filePath string
|
||||||
label string
|
label string
|
||||||
snapshot *managedFileSnapshot
|
snapshot *managedFileSnapshot
|
||||||
|
directory os.FileInfo
|
||||||
maximumSize int64
|
maximumSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +39,8 @@ func prepareSnapshotSourceChange(
|
|||||||
if original == nil && (remove || next == nil) {
|
if original == nil && (remove || next == nil) {
|
||||||
return nil, 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
|
return nil, nil
|
||||||
}
|
}
|
||||||
change := &fileChange{
|
change := &fileChange{
|
||||||
@@ -51,6 +53,7 @@ func prepareSnapshotSourceChange(
|
|||||||
if original != nil {
|
if original != nil {
|
||||||
change.original = append([]byte(nil), original.contents...)
|
change.original = append([]byte(nil), original.contents...)
|
||||||
change.originalInfo = original.info
|
change.originalInfo = original.info
|
||||||
|
change.originalID = original.identity
|
||||||
change.originalMode = original.mode
|
change.originalMode = original.mode
|
||||||
change.existed = true
|
change.existed = true
|
||||||
}
|
}
|
||||||
@@ -62,7 +65,9 @@ func sameManagedSnapshot(current, expected *managedFileSnapshot) bool {
|
|||||||
return current == expected
|
return current == expected
|
||||||
}
|
}
|
||||||
return bytes.Equal(current.contents, expected.contents) &&
|
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(
|
func assertFilePreconditions(
|
||||||
@@ -70,6 +75,27 @@ func assertFilePreconditions(
|
|||||||
operation string,
|
operation string,
|
||||||
) error {
|
) error {
|
||||||
for _, condition := range preconditions {
|
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
|
maximumSize := condition.maximumSize
|
||||||
if maximumSize <= 0 {
|
if maximumSize <= 0 {
|
||||||
maximumSize = maxManagedSourceBytes
|
maximumSize = maxManagedSourceBytes
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package plugins
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
func hardenPrivateTransactionDirectory(path string) error {
|
||||||
|
return os.Chmod(path, 0700)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ func TestQwenCLIInstallStatusAndUninstallEndToEnd(t *testing.T) {
|
|||||||
t.Fatalf("build Elydora CLI: %v\n%s", err, output)
|
t.Fatalf("build Elydora CLI: %v\n%s", err, output)
|
||||||
}
|
}
|
||||||
home := filepath.Join(root, "home")
|
home := filepath.Join(root, "home")
|
||||||
|
preparePrivateCLITestHome(t, home)
|
||||||
workspace := filepath.Join(home, "workspace")
|
workspace := filepath.Join(home, "workspace")
|
||||||
if err := os.MkdirAll(workspace, 0755); err != nil {
|
if err := os.MkdirAll(workspace, 0755); err != nil {
|
||||||
t.Fatalf("create workspace: %v", err)
|
t.Fatalf("create workspace: %v", err)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora/plugins"
|
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
|
||||||
)
|
)
|
||||||
|
|
||||||
type installedAgentRuntime struct {
|
type installedAgentRuntime struct {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-3
@@ -1,4 +1,4 @@
|
|||||||
module github.com/Elydora-Infrastructure/Elydora-Go-SDK
|
module github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2
|
||||||
|
|
||||||
go 1.21.0
|
go 1.21.0
|
||||||
|
|
||||||
@@ -6,7 +6,6 @@ require github.com/pelletier/go-toml/v2 v2.4.3
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
|
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
|
||||||
|
golang.org/x/sys v0.30.0
|
||||||
golang.org/x/term v0.29.0
|
golang.org/x/term v0.29.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/sys v0.30.0 // indirect
|
|
||||||
Reference in new issue
Block a user