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:
雷电芽衣 committed 2026-09-02 10:34:21 -04:00
1 parent edf9d73c3e
commit b00932d047
75 files changed
+8529 -443

No files matched your search

+1
View File
@@ -31,3 +31,4 @@ Thumbs.db
.env.local
*.pem
*.key
/elydora
+7 -3
View File
@@ -5,7 +5,7 @@ Official Go SDK for the [Elydora](https://elydora.com) tamper-evident audit plat
## Installation
```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.
@@ -19,7 +19,7 @@ import (
"fmt"
"log"
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK"
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2"
)
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.
```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 \
--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.
Durable managed-file transactions support Windows, Linux, and macOS. The CLI serializes writers across processes, records append-only recovery state under `~/.elydora/transactions`, stages transaction-owned files on each target filesystem, and completes pending recovery before accepting new writes. Windows requires protected owner-only DACLs, hard-link support, atomic `MoveFileEx` replacement, and writable directory handles with `FlushFileBuffers`; Linux filesystems require `renameat2` with `RENAME_NOREPLACE` and `RENAME_EXCHANGE`; macOS filesystems require `renamex_np` with `RENAME_EXCL` and `RENAME_SWAP`. Unsupported platforms and filesystems fail before managed targets change. Recovery and cleanup verify file and directory identities and retain changed or unexpected objects for manual recovery.
Claude Code installation writes exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` exec-form hooks to `$CLAUDE_CONFIG_DIR/settings.json` (`~/.claude/settings.json` by default). Elydora preserves unrelated user settings, keeps project, local, managed, plugin, skill, and agent sources unchanged, forwards native snake_case payloads, and uses exit code `2` for frozen or revoked agents. Installation validates the complete official hook schema and commits settings with all four runtime artifacts in one rollback-capable transaction. Run `/hooks` and `claude doctor` after installation to inspect the effective hook sources.
Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to `$CODEX_HOME/hooks.json` (`~/.codex/hooks.json` by default) and preserves the complete native event payload. A configured `CODEX_HOME` follows Codex's existing-directory canonicalization rule. User TOML, project, plugin, and managed sources remain unchanged and continue loading additively. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable update. Run `/hooks` after installation and approve both Elydora definition hashes.
@@ -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 IDE 1.0 installation writes an exact enabled `PreToolUse` guard and `PostToolUse` audit pair to the active workspace `.kiro/hooks/elydora-audit.json`. Both entries use Kiro's `version: "v1"` schema, `.*` matchers, ten-second timeouts, and platform-safe absolute commands. Elydora preserves unrelated workspace hooks, forwards the complete native event payload, and propagates frozen or revoked state through exit code `2`. Workspace hooks, exact legacy `~/.kiro/hooks/elydora-audit.kiro.hook` migration, generated runtimes, strict runtime metadata, and the canonical private key commit through one rollback-capable transaction. Confirm both entries in Kiro's Agent Hooks panel after installation.
Kimi installation follows home-directory evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default, and legacy `kimi-cli` uses `~/.kimi/config.toml`. Elydora installs exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks, preserves native snake_case payloads, and commits all selected TOML documents with the four runtime artifacts in one rollback-capable transaction. Windows commands use encoded PowerShell so paths containing spaces, apostrophes, percent signs, and environment-like text remain literal.
Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Grok's native camelCase JSON, return `{"decision":"deny","reason":"..."}` with exit code `2` for frozen or revoked agents, and use encoded PowerShell on Windows. Project hooks follow Grok's `/hooks-trust` workflow; Elydora leaves project, plugin, `hooks-paths`, Claude Code, and Cursor compatibility sources unchanged. Run `grok inspect --json` to inspect the effective hook inventory.
+1
View File
@@ -65,6 +65,7 @@ func prepareClineCLIEnvironment(t *testing.T) (string, string, string, []string)
t.Helper()
root := t.TempDir()
homeDir := filepath.Join(root, "home")
preparePrivateCLITestHome(t, homeDir)
clineDir := filepath.Join(root, "cline-home")
workspace := filepath.Join(root, "workspace")
if err := os.MkdirAll(workspace, 0700); err != nil {
+1
View File
@@ -14,6 +14,7 @@ func prepareDroidCLIEnvironment(t *testing.T) (string, string, []string) {
t.Helper()
root := t.TempDir()
homeDir := filepath.Join(root, "home with spaces and 'quote %DROID%")
preparePrivateCLITestHome(t, homeDir)
workspace := filepath.Join(root, "workspace with spaces")
if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0700); err != nil {
t.Fatalf("create Factory Droid CLI workspace: %v", err)
+2 -2
View File
@@ -8,10 +8,10 @@ import (
"sort"
"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() {
if len(os.Args) < 2 {
@@ -46,6 +46,7 @@ func prepareAugmentFixture(
t.Helper()
root := t.TempDir()
homeDir := filepath.Join(root, "home with spaces and 'quote")
preparePrivateTransactionTestDirectory(t, homeDir)
projectDir := filepath.Join(root, "project with spaces")
if err := os.MkdirAll(projectDir, 0700); err != nil {
t.Fatalf("create project directory: %v", err)
@@ -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()
root := t.TempDir()
homeDir := filepath.Join(root, "home with 'quote %CLAUDE_HOOK_EVENT%")
preparePrivateTransactionTestDirectory(t, homeDir)
projectDir := filepath.Join(root, "project with spaces")
if err := os.MkdirAll(projectDir, 0700); err != nil {
t.Fatalf("create project directory: %v", err)
@@ -54,6 +54,7 @@ func prepareClineFixture(t *testing.T, options clineFixtureOptions) *clineFixtur
t.Helper()
rootDir := t.TempDir()
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
preparePrivateTransactionTestDirectory(t, homeDir)
workspaceDir := filepath.Join(rootDir, "workspace")
clineDir := filepath.Join(rootDir, "custom-cline-home")
hooksDir := filepath.Join(clineDir, "hooks")
@@ -35,6 +35,7 @@ type codexFixture struct {
func prepareCodexFixture(t *testing.T, existingRaw string) *codexFixture {
t.Helper()
homeDir := filepath.Join(t.TempDir(), "home with 'quote %ELYDORA_HOOK_PATH%")
preparePrivateTransactionTestDirectory(t, homeDir)
agentDir := filepath.Join(homeDir, ".elydora", codexTestAgentID)
configPath := filepath.Join(homeDir, ".codex", "hooks.json")
guardPath := filepath.Join(agentDir, "guard.js")
@@ -72,6 +72,7 @@ func prepareCopilotFixture(t *testing.T, options copilotFixtureOptions) *copilot
t.Helper()
rootDir := t.TempDir()
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
preparePrivateTransactionTestDirectory(t, homeDir)
projectDir := filepath.Join(rootDir, "project with spaces")
copilotHome := filepath.Join(homeDir, "custom copilot")
agentDir := filepath.Join(homeDir, ".elydora", copilotTestAgentID)
@@ -48,6 +48,7 @@ func prepareCursorFixture(t *testing.T, existingRaw *string, createGuard bool) *
t.Helper()
rootDir := t.TempDir()
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
preparePrivateTransactionTestDirectory(t, homeDir)
agentDir := filepath.Join(homeDir, ".elydora", cursorTestAgentID)
configPath := filepath.Join(homeDir, ".cursor", "hooks.json")
guardPath := filepath.Join(agentDir, "guard.js")
@@ -71,6 +71,7 @@ func prepareDroidFixture(t *testing.T, options droidFixtureOptions) *droidFixtur
t.Helper()
rootDir := t.TempDir()
homeDir := filepath.Join(rootDir, "home with spaces and 'quote %DROID%")
preparePrivateTransactionTestDirectory(t, homeDir)
workspaceDir := filepath.Join(rootDir, "workspace with spaces")
factoryDir := filepath.Join(homeDir, ".factory")
configPath := filepath.Join(factoryDir, "hooks.json")
@@ -51,6 +51,7 @@ func prepareGeminiFixture(
root,
"home with 'quote $GEMINI_CWD %GEMINI_CWD%",
)
preparePrivateTransactionTestDirectory(t, homeDir)
projectDir := filepath.Join(root, "project with spaces")
geminiHome := filepath.Join(homeDir, "custom gemini home")
if options.useDefaultHome {
@@ -45,6 +45,7 @@ func prepareGrokFixture(t *testing.T, options grokFixtureOptions) *grokFixture {
t.Helper()
root := t.TempDir()
homeDir := filepath.Join(root, "home with 'quote %ELYDORA_HOOK_PATH%")
preparePrivateTransactionTestDirectory(t, homeDir)
projectDir := filepath.Join(root, "project with spaces")
grokHome := filepath.Join(homeDir, "custom grok")
if options.useDefaultHome {
+144 -75
View File
@@ -113,6 +113,8 @@ function signEd25519(privateKeyBase64url, data) {
return base64urlEncode(signature);
}
const MAX_CHAIN_ATTEMPTS = 5;
const SUBMIT_BUDGET_MS = 4000;
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
function validateChainHash(value, source) {
@@ -188,6 +190,81 @@ function writeChainState(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
// ---------------------------------------------------------------------------
@@ -230,6 +307,41 @@ function resolveAuditBaseURL(config) {
// 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() {
try {
// 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');
// Read chain state
const prevChainHash = readChainState();
// Build EOR
const operationId = uuidv7();
const issuedAt = Date.now();
const nonce = generateNonce();
// Build and submit, retrying from the server chain head on PREV_HASH_MISMATCH
const payload = NATIVE_PAYLOAD ? hookData : {
tool_name: toolName,
tool_input: toolInput,
session_id: sessionId,
};
const payloadHash = computePayloadHash(payload);
const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt);
const eorWithoutSig = {
op_version: '1.0',
operation_id: operationId,
org_id: orgId,
agent_id: agentId,
issued_at: issuedAt,
ttl_ms: 30000,
nonce: nonce,
operation_type: 'ai.tool_use',
subject: { session_id: sessionId },
action: { tool: toolName },
payload: payload,
payload_hash: payloadHash,
prev_chain_hash: prevChainHash,
agent_pubkey_kid: kid,
};
const canonical = jcsCanonicalise(eorWithoutSig);
const signature = signEd25519(privateKey, Buffer.from(canonical, 'utf-8'));
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',
const deadline = performance.now() + SUBMIT_BUDGET_MS;
let prevChainHash = readChainState();
for (let attempt = 1; ; attempt += 1) {
const operationId = uuidv7();
const issuedAt = Date.now();
const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt);
const eorWithoutSig = {
op_version: '1.0',
operation_id: operationId,
org_id: orgId,
agent_id: agentId,
issued_at: issuedAt,
ttl_ms: 30000,
nonce: generateNonce(),
operation_type: 'ai.tool_use',
subject: { session_id: sessionId },
action: { tool: toolName },
payload: payload,
payload_hash: payloadHash,
prev_chain_hash: prevChainHash,
agent_pubkey_kid: kid,
};
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
const res = await fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(eor),
signal: controller.signal,
});
if (res.ok) {
writeChainState(chainHash);
const signature = signEd25519(privateKey, Buffer.from(jcsCanonicalise(eorWithoutSig), 'utf-8'));
const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature });
const result = await postOperation(baseUrl, token, eor, deadline);
if (result.accepted) {
advanceChainState(prevChainHash, chainHash);
return;
}
if (res.status === 400) {
// Possible chain hash mismatch — try to resync from server response
try {
const errBody = await res.json();
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));
}
advanceChainState(prevChainHash, result.expected);
logError(new Error('Chain hash resynced to server: ' + result.expected));
if (attempt >= MAX_CHAIN_ATTEMPTS) {
throw new Error('Audit API rejected prev_chain_hash ' + attempt + ' times');
}
throw new Error('Audit API returned HTTP ' + res.status);
} finally {
clearTimeout(timeout);
prevChainHash = readChainState();
}
} catch (err) {
logError(err);
@@ -52,6 +52,7 @@ func prepareKimiFixture(t *testing.T, options kimiFixtureOptions) *kimiFixture {
t.Helper()
root := t.TempDir()
homeDir := filepath.Join(root, "home with spaces and 'quote %ELYDORA_HOOK_PATH%")
preparePrivateTransactionTestDirectory(t, homeDir)
projectDir := filepath.Join(root, "project with spaces")
kimiHome := filepath.Join(homeDir, "custom kimi-code")
if options.useDefaultHome {
@@ -29,6 +29,7 @@ type kiroFixture struct {
func prepareKiroFixture(t *testing.T, v2Raw, v3Raw string) *kiroFixture {
t.Helper()
homeDir := filepath.Join(t.TempDir(), "home with spaces")
preparePrivateTransactionTestDirectory(t, homeDir)
agentDir := filepath.Join(homeDir, ".elydora", kiroTestAgentID)
guardPath := filepath.Join(agentDir, "guard.js")
v2Path := filepath.Join(homeDir, ".kiro", "agents", "elydora-audit.json")
+92 -107
View File
@@ -1,143 +1,128 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
import "fmt"
// KiroIdePlugin manages the Elydora audit hook for Kiro IDE.
// It writes a .kiro/hooks/elydora-audit.kiro.hook JSON file in the home directory.
type KiroIdePlugin struct{}
// KiroIdePlugin manages Kiro IDE 1.0 workspace hooks.
type KiroIdePlugin struct {
rename renameFunc
}
func (p *KiroIdePlugin) configPath() (string, error) {
home, err := os.UserHomeDir()
// ManagesGuardRuntime reports that Kiro IDE commits both generated runtimes,
// runtime metadata, credentials, workspace hooks, and legacy migration in one
// transaction.
func (p *KiroIdePlugin) ManagesGuardRuntime() bool {
return true
}
// PreflightInstall validates every source and runtime identity before writes.
func (p *KiroIdePlugin) PreflightInstall(config InstallConfig) error {
sources, err := readKiroIdeSources()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
return err
}
return filepath.Join(home, ".kiro", "hooks", "elydora-audit.kiro.hook"), nil
if err := requireAvailableKiroIdeHooks(sources.document.hooks); err != nil {
return err
}
_, _, err = preflightKiroIdeInstallation(config)
return err
}
func (p *KiroIdePlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath(config.AgentID)
sources, err := readKiroIdeSources()
if err != nil {
return err
}
if config.HookScript != "" {
scriptPath = config.HookScript
if err := requireAvailableKiroIdeHooks(sources.document.hooks); err != nil {
return err
}
if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
guardPath := config.GuardScriptPath
if guardPath == "" {
guardPath, err = guardScriptPath(config.AgentID)
if err != nil {
return err
}
}
hookFile, err := p.configPath()
paths, nodePath, err := preflightKiroIdeInstallation(config)
if err != nil {
return err
}
dir := filepath.Dir(hookFile)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory %s: %w", dir, err)
hooks := withoutManagedKiroIdeHooks(sources.document.hooks, "")
guard, err := buildKiroIdeHook(kiroIdeGuardName, nodePath, paths.guardPath)
if err != nil {
return err
}
content := buildKiroIdeHookFile(scriptPath, guardPath)
if err := os.WriteFile(hookFile, []byte(content), 0644); err != nil {
return fmt.Errorf("write %s: %w", hookFile, err)
audit, err := buildKiroIdeHook(kiroIdeAuditName, nodePath, paths.auditPath)
if err != nil {
return err
}
fmt.Printf("Installed Elydora hook for Kiro IDE at %s\n", hookFile)
rendered, err := renderKiroIdeDocument(
sources.document,
append(hooks, guard, audit),
)
if err != nil {
return err
}
prepared, err := prepareKiroIdeInstallation(config, sources, paths, rendered)
if err != nil {
return err
}
if err := commitKiroIdeInstallation(prepared, p.rename); err != nil {
return err
}
fmt.Printf("Kiro IDE workspace hooks: %s\n", sources.paths.configPath)
fmt.Println("Kiro IDE verification: confirm both Elydora entries in the Agent Hooks panel.")
return nil
}
func buildKiroIdeHookFile(scriptPath, guardPath string) string {
hookConfig := map[string]interface{}{
"name": "Elydora Audit",
"hooks": map[string]interface{}{
"pre_tool_use": map[string]interface{}{
"command": "node " + guardPath,
"timeout_ms": 5000,
},
"post_tool_use": map[string]interface{}{
"command": "node " + scriptPath,
"timeout_ms": 5000,
},
},
}
encoded, _ := json.MarshalIndent(hookConfig, "", " ")
return string(encoded) + "\n"
}
func (p *KiroIdePlugin) Uninstall(agentID string) error {
hookFile, err := p.configPath()
sources, err := readKiroIdeSources()
if err != nil {
return err
}
if err := os.Remove(hookFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove %s: %w", hookFile, err)
rendered, err := renderKiroIdeDocument(
sources.document,
withoutManagedKiroIdeHooks(sources.document.hooks, agentID),
)
if err != nil {
return err
}
if agentID != "" {
scriptPath, _ := hookScriptPath(agentID)
if scriptPath != "" {
os.Remove(scriptPath)
}
gPath, _ := guardScriptPath(agentID)
if gPath != "" {
os.Remove(gPath)
}
prepared, err := prepareKiroIdeUninstall(sources, rendered, agentID)
if err != nil {
return err
}
fmt.Println("Uninstalled Elydora hook for Kiro IDE.")
if err := commitKiroIdeUninstall(prepared, p.rename); err != nil {
return err
}
fmt.Println("Uninstalled Elydora hooks from Kiro IDE.")
return nil
}
func (p *KiroIdePlugin) Status() (PluginStatus, error) {
hookFile, err := p.configPath()
if err != nil {
return PluginStatus{}, err
}
sources, err := readKiroIdeSources()
entry := SupportedAgents[kiroIdeAgentKey]
status := PluginStatus{
AgentName: "kiroide",
DisplayName: "Kiro IDE",
ConfigPath: hookFile,
AgentName: kiroIdeAgentKey,
DisplayName: entry.Name,
ConfigPath: sourcesPathOrEmpty(sources),
}
// Check if hook file exists and contains both pre_tool_use and post_tool_use
data, err := os.ReadFile(hookFile)
if err == nil {
var config map[string]interface{}
if json.Unmarshal(data, &config) == nil {
hooks, _ := config["hooks"].(map[string]interface{})
if hooks != nil {
_, hasPre := hooks["pre_tool_use"]
_, hasPost := hooks["post_tool_use"]
status.HookConfigured = hasPre && hasPost
// Extract hook script path from the post_tool_use command
if postHook, ok := hooks["post_tool_use"].(map[string]interface{}); ok {
if cmd, _ := postHook["command"].(string); cmd != "" {
scriptPath := extractPathFromNodeCommand(cmd)
if scriptPath != "" {
if _, err := os.Stat(scriptPath); err == nil {
status.HookScriptExists = true
}
}
}
}
}
}
if err != nil {
return status, err
}
status.Installed = status.HookConfigured && status.HookScriptExists
if err := requireAvailableKiroIdeHooks(sources.document.hooks); err != nil {
return status, err
}
contracts, err := kiroIdeRuntimeContracts(sources.document.hooks)
if err != nil {
return status, err
}
status.HookConfigured = len(contracts) > 0
if !status.HookConfigured {
return status, nil
}
status.HookScriptExists, err = kiroIdeRuntimeFilesExist(contracts)
if err != nil {
return status, err
}
status.Installed = status.HookScriptExists
return status, nil
}
func sourcesPathOrEmpty(sources *kiroIdeSources) string {
if sources == nil {
return ""
}
return sources.paths.configPath
}
@@ -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)
}
+192
View File
@@ -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
}
+450
View File
@@ -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
info os.FileInfo
mode os.FileMode
identity string
}
func readManagedFile(
@@ -51,6 +52,13 @@ func readManagedFile(
file.Close(),
)
}
identity, identityErr := transactionFileIdentity(file, after)
if identityErr != nil {
return nil, errors.Join(
fmt.Errorf("identify %s at %s: %w", label, filePath, identityErr),
file.Close(),
)
}
raw, readErr := io.ReadAll(io.LimitReader(file, maximumBytes+1))
closeErr := file.Close()
if readErr != nil || closeErr != nil {
@@ -70,6 +78,7 @@ func readManagedFile(
contents: raw,
info: after,
mode: after.Mode().Perm(),
identity: identity,
}, nil
}
@@ -58,6 +58,7 @@ func prepareQwenFixture(t *testing.T, options qwenFixtureOptions) *qwenFixture {
t.Helper()
rootDir := t.TempDir()
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
preparePrivateTransactionTestDirectory(t, homeDir)
workspaceDir := filepath.Join(homeDir, "workspace")
qwenDir := filepath.Join(homeDir, ".qwen")
configPath := filepath.Join(qwenDir, "settings.json")
+1 -1
View File
@@ -18,7 +18,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
"grok": {Name: "Grok Build", ConfigDir: "~/.grok/hooks", ConfigFile: "elydora-audit.json"},
"kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.json"},
"kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"},
"kiroide": {Name: "Kiro IDE", ConfigDir: ".kiro/hooks", ConfigFile: "elydora-audit.json"},
"kimi": {Name: "Kimi Code", ConfigDir: "~/.kimi-code", ConfigFile: "config.toml"},
"opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"},
"copilot": {Name: "GitHub Copilot CLI", ConfigDir: "~/.copilot/hooks", ConfigFile: "elydora-audit.json"},
+2 -2
View File
@@ -37,13 +37,13 @@ func WriteRuntimeFileAtomic(
if err != nil {
return err
}
return writeChanges([]*fileChange{change}, "write "+label, os.Rename)
return writeChanges([]*fileChange{change}, "write "+label, nil)
}
// GenerateHookScript atomically commits the agent config, private key, and
// self-contained Node.js audit runtime.
func GenerateHookScript(destPath string, config InstallConfig) error {
return generateHookScriptWithRename(destPath, config, os.Rename)
return generateHookScriptWithRename(destPath, config, nil)
}
func generateHookScriptWithRename(
@@ -12,6 +12,7 @@ import (
func setRuntimeFilesTestHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
preparePrivateTransactionTestDirectory(t, home)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
return home
+95 -244
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
@@ -12,12 +11,14 @@ import (
)
type renameFunc func(source, destination string) error
type replaceWithBackupFunc func(target, replacement, backup string) error
type fileChange struct {
filePath string
label string
original []byte
originalInfo os.FileInfo
originalID string
originalMode os.FileMode
existed bool
next []byte
@@ -26,11 +27,40 @@ type fileChange struct {
}
type stagedChange struct {
change fileChange
temporaryPath string
rollbackPath string
committedInfo os.FileInfo
committed bool
change fileChange
temporaryPath string
temporaryInfo os.FileInfo
rollbackPath string
originalCaptured bool
committedInfo os.FileInfo
committed bool
}
type transactionFileOps struct {
moveNoReplace renameFunc
compensateNoReplace renameFunc
replaceWithBackup replaceWithBackupFunc
production bool
}
func newTransactionFileOps(rename renameFunc) transactionFileOps {
if rename == nil {
rename = atomicRenameNoReplace
return transactionFileOps{
moveNoReplace: rename,
compensateNoReplace: rename,
replaceWithBackup: atomicReplaceWithBackup,
production: true,
}
}
return transactionFileOps{
moveNoReplace: rename,
compensateNoReplace: atomicRenameNoReplace,
}
}
func sameManagedFileMode(current, expected os.FileMode) bool {
return runtime.GOOS == "windows" || current.Perm() == expected.Perm()
}
func readOptionalFile(path, label string) ([]byte, bool, error) {
@@ -80,7 +110,8 @@ func prepareSourceChange(
(currentExists && !bytes.Equal(snapshot.contents, original)) {
return nil, fmt.Errorf("%s changed before update: %s", label, filePath)
}
if existed && !remove && bytes.Equal(original, next) {
if existed && !remove && bytes.Equal(original, next) &&
sameManagedFileMode(snapshot.mode, mode) {
return nil, nil
}
if !existed && (remove || next == nil) {
@@ -94,22 +125,52 @@ func prepareSourceChange(
}
return &fileChange{
filePath: filePath, label: label, original: append([]byte(nil), original...),
originalInfo: originalInfo, originalMode: originalMode, existed: existed,
originalInfo: originalInfo, originalID: snapshotIdentity(snapshot),
originalMode: originalMode, existed: existed,
next: append([]byte(nil), next...),
mode: mode, remove: remove,
}, nil
}
func snapshotIdentity(snapshot *managedFileSnapshot) string {
if snapshot == nil {
return ""
}
return snapshot.identity
}
func writeChanges(
changes []*fileChange,
label string,
rename renameFunc,
preconditions ...filePrecondition,
) error {
return writeChangesWithFileOps(
changes,
label,
newTransactionFileOps(rename),
preconditions...,
)
}
func writeChangesWithFileOps(
changes []*fileChange,
label string,
ops transactionFileOps,
preconditions ...filePrecondition,
) error {
if ops.production {
return writeDurableChanges(changes, label, ops, preconditions...)
}
if ops.moveNoReplace == nil {
return fmt.Errorf("%s is missing atomic file operations", label)
}
filtered := make([]fileChange, 0, len(changes))
targets := map[string]bool{}
for _, change := range changes {
if change == nil || (change.existed && !change.remove && bytes.Equal(change.original, change.next)) {
if change == nil || (change.existed && !change.remove &&
bytes.Equal(change.original, change.next) &&
sameManagedFileMode(change.originalMode, change.mode)) {
continue
}
target := filepath.Clean(change.filePath)
@@ -128,17 +189,30 @@ func writeChanges(
if len(filtered) == 0 {
return nil
}
if rename == nil {
rename = os.Rename
}
staged := make([]stagedChange, 0, len(filtered))
for _, change := range filtered {
if err := assertFilePreconditions(preconditions, label); err != nil {
cleanupErrors := cleanupStaging(staged)
return joinTransactionFailure(
fmt.Errorf("%s: %w", label, err),
cleanupErrors,
"cleanup failed",
)
}
item, err := stageChange(change)
if err != nil {
cleanupErrors := cleanupStaging(staged)
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), cleanupErrors, "cleanup failed")
}
staged = append(staged, item)
if err := assertFilePreconditions(preconditions, label); err != nil {
cleanupErrors := cleanupStaging(staged)
return joinTransactionFailure(
fmt.Errorf("%s: %w", label, err),
cleanupErrors,
"cleanup failed",
)
}
}
if err := assertFilePreconditions(preconditions, label); err != nil {
cleanupErrors := cleanupStaging(staged)
@@ -146,18 +220,23 @@ func writeChanges(
}
for index := range staged {
if err := assertFilePreconditions(preconditions, label); err != nil {
recoveryErrors := rollbackChanges(staged, rename)
recoveryErrors := rollbackChanges(staged, ops)
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
}
if err := commitChange(&staged[index], rename); err != nil {
recoveryErrors := rollbackChanges(staged, rename)
if err := commitChange(&staged[index], ops); err != nil {
recoveryErrors := rollbackChanges(staged, ops)
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
}
}
if err := assertFilePreconditions(preconditions, label); err != nil {
recoveryErrors := rollbackChanges(staged, rename)
recoveryErrors := rollbackChanges(staged, ops)
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
}
if err := assertCommittedChanges(staged); err != nil {
recoveryErrors := rollbackChanges(staged, ops)
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
}
@@ -168,234 +247,6 @@ func writeChanges(
return nil
}
func stageChange(change fileChange) (stagedChange, error) {
if err := assertFileUnchanged(change); err != nil {
return stagedChange{}, err
}
directory := filepath.Dir(change.filePath)
if err := ensureManagedDirectory(directory, "directory for "+change.label); err != nil {
return stagedChange{}, err
}
staged := stagedChange{change: change}
var err error
if change.remove {
staged.rollbackPath, err = reserveStagingPath(directory, filepath.Base(change.filePath), ".rollback")
} else {
staged.temporaryPath, err = writeStagedFile(
directory, filepath.Base(change.filePath), ".tmp", change.next, change.mode,
)
if err == nil && change.existed {
staged.rollbackPath, err = writeStagedFile(
directory, filepath.Base(change.filePath), ".rollback", change.original, change.originalMode,
)
}
}
if err != nil {
cleanupErrors := cleanupStaging([]stagedChange{staged})
return stagedChange{}, joinTransactionFailure(
fmt.Errorf("stage %s: %w", change.label, err), cleanupErrors, "cleanup failed",
)
}
return staged, nil
}
func writeStagedFile(
directory, basename, suffix string,
content []byte,
mode os.FileMode,
) (string, error) {
file, err := os.CreateTemp(directory, "."+basename+".*"+suffix)
if err != nil {
return "", err
}
path := file.Name()
failed := func(cause error) (string, error) {
return "", errors.Join(cause, file.Close(), removeOptionalFile(path))
}
if err := file.Chmod(mode); err != nil {
return failed(err)
}
written, err := file.Write(content)
if err != nil {
return failed(err)
}
if written != len(content) {
return failed(io.ErrShortWrite)
}
if err := file.Sync(); err != nil {
return failed(err)
}
if err := file.Close(); err != nil {
return "", errors.Join(err, removeOptionalFile(path))
}
return path, nil
}
func reserveStagingPath(directory, basename, suffix string) (string, error) {
file, err := os.CreateTemp(directory, "."+basename+".*"+suffix)
if err != nil {
return "", err
}
path := file.Name()
if err := file.Close(); err != nil {
return "", errors.Join(err, removeOptionalFile(path))
}
if err := os.Remove(path); err != nil {
return "", err
}
return path, nil
}
func assertFileUnchanged(change fileChange) error {
current, err := readManagedFile(change.filePath, change.label, maxManagedSourceBytes)
if err != nil {
return err
}
currentExists := current != nil
if currentExists != change.existed ||
(currentExists && (!bytes.Equal(current.contents, change.original) ||
!os.SameFile(current.info, change.originalInfo))) {
return fmt.Errorf("%s changed during installation: %s", change.label, change.filePath)
}
return nil
}
func commitChange(staged *stagedChange, rename renameFunc) error {
if err := assertFileUnchanged(staged.change); err != nil {
return err
}
if staged.change.remove {
if staged.rollbackPath == "" {
return fmt.Errorf("missing rollback path for %s", staged.change.label)
}
if err := rename(staged.change.filePath, staged.rollbackPath); err != nil {
return fmt.Errorf("remove %s at %s: %w", staged.change.label, staged.change.filePath, err)
}
} else {
if staged.temporaryPath == "" {
return fmt.Errorf("missing staged file for %s", staged.change.label)
}
if err := rename(staged.temporaryPath, staged.change.filePath); err != nil {
return fmt.Errorf("commit %s at %s: %w", staged.change.label, staged.change.filePath, err)
}
}
staged.committed = true
if !staged.change.remove {
current, err := readManagedFile(
staged.change.filePath,
staged.change.label,
maxManagedSourceBytes,
)
if err != nil {
return err
}
if current == nil || !bytes.Equal(current.contents, staged.change.next) {
return fmt.Errorf(
"%s changed immediately after commit: %s",
staged.change.label,
staged.change.filePath,
)
}
staged.committedInfo = current.info
}
return nil
}
func assertCommittedFileUnchanged(item *stagedChange) error {
current, err := readManagedFile(
item.change.filePath,
item.change.label,
maxManagedSourceBytes,
)
if err != nil {
return err
}
if item.change.remove {
if current != nil {
return fmt.Errorf(
"%s changed during transaction recovery: %s",
item.change.label,
item.change.filePath,
)
}
return nil
}
if current == nil || item.committedInfo == nil ||
!bytes.Equal(current.contents, item.change.next) ||
!os.SameFile(current.info, item.committedInfo) {
return fmt.Errorf(
"%s changed during transaction recovery: %s",
item.change.label,
item.change.filePath,
)
}
return nil
}
func rollbackChanges(staged []stagedChange, rename renameFunc) []error {
failures := make([]error, 0)
for index := len(staged) - 1; index >= 0; index-- {
item := &staged[index]
if !item.committed {
continue
}
if err := assertCommittedFileUnchanged(item); err != nil {
failures = append(failures, preserveRollbackFile(item, err))
continue
}
var err error
switch {
case item.change.remove:
err = rename(item.rollbackPath, item.change.filePath)
case item.change.existed:
err = rename(item.rollbackPath, item.change.filePath)
default:
err = removeOptionalFile(item.change.filePath)
}
if err != nil {
failure := fmt.Errorf(
"restore %s at %s: %w",
item.change.label,
item.change.filePath,
err,
)
failures = append(failures, preserveRollbackFile(item, failure))
}
}
return failures
}
func preserveRollbackFile(item *stagedChange, cause error) error {
if item.rollbackPath == "" {
return cause
}
path := item.rollbackPath
item.rollbackPath = ""
return fmt.Errorf("%w; original content preserved at %s", cause, path)
}
func cleanupStaging(staged []stagedChange) []error {
failures := make([]error, 0)
for _, item := range staged {
for _, path := range []string{item.temporaryPath, item.rollbackPath} {
if err := removeOptionalFile(path); err != nil {
failures = append(failures, err)
}
}
}
return failures
}
func removeOptionalFile(path string) error {
if path == "" {
return nil
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove staged file at %s: %w", path, err)
}
return nil
}
func joinTransactionFailure(cause error, related []error, label string) error {
if len(related) == 0 {
return cause
@@ -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
label string
snapshot *managedFileSnapshot
directory os.FileInfo
maximumSize int64
}
@@ -38,7 +39,8 @@ func prepareSnapshotSourceChange(
if original == nil && (remove || next == nil) {
return nil, nil
}
if original != nil && !remove && bytes.Equal(original.contents, next) {
if original != nil && !remove && bytes.Equal(original.contents, next) &&
sameManagedFileMode(original.mode, mode) {
return nil, nil
}
change := &fileChange{
@@ -51,6 +53,7 @@ func prepareSnapshotSourceChange(
if original != nil {
change.original = append([]byte(nil), original.contents...)
change.originalInfo = original.info
change.originalID = original.identity
change.originalMode = original.mode
change.existed = true
}
@@ -62,7 +65,9 @@ func sameManagedSnapshot(current, expected *managedFileSnapshot) bool {
return current == expected
}
return bytes.Equal(current.contents, expected.contents) &&
os.SameFile(current.info, expected.info)
current.identity == expected.identity &&
os.SameFile(current.info, expected.info) &&
sameManagedFileMode(current.mode, expected.mode)
}
func assertFilePreconditions(
@@ -70,6 +75,27 @@ func assertFilePreconditions(
operation string,
) error {
for _, condition := range preconditions {
if condition.directory != nil {
current, err := os.Lstat(condition.filePath)
if err != nil {
return fmt.Errorf(
"inspect %s at %s: %w",
condition.label,
condition.filePath,
err,
)
}
if current.Mode()&os.ModeSymlink != 0 || !current.IsDir() ||
!os.SameFile(condition.directory, current) {
return fmt.Errorf(
"%s changed during %s: %s",
condition.label,
operation,
condition.filePath,
)
}
continue
}
maximumSize := condition.maximumSize
if maximumSize <= 0 {
maximumSize = maxManagedSourceBytes
@@ -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))
}
+1
View File
@@ -45,6 +45,7 @@ func TestQwenCLIInstallStatusAndUninstallEndToEnd(t *testing.T) {
t.Fatalf("build Elydora CLI: %v\n%s", err, output)
}
home := filepath.Join(root, "home")
preparePrivateCLITestHome(t, home)
workspace := filepath.Join(home, "workspace")
if err := os.MkdirAll(workspace, 0755); err != nil {
t.Fatalf("create workspace: %v", err)
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"os"
"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 {
@@ -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
View File
@@ -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
@@ -6,7 +6,6 @@ require github.com/pelletier/go-toml/v2 v2.4.3
require (
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
golang.org/x/sys v0.30.0
golang.org/x/term v0.29.0
)
require golang.org/x/sys v0.30.0 // indirect