sdks/go: mirror Elydora-Go-SDK v2.0.1 under the /v2 module path
Rebuilt from the standalone f5341ed tree (durable managed-file transactions, Kiro IDE adapter, hook retry with a bounded chain lock, CLI version 2.0.1) with the Open-Source API trims re-applied: no admin events, members, webhooks, deep health, token rotation, or agent integration updates, and the mirror's freeze/unfreeze and registration validation contracts kept. The root quickstart now imports the /v2 path. Claude-Session: https://claude.ai/code/session_01QoUjdc7HGKTsgj5GhG8HvF
This commit is contained in:
1 parent
edf9d73c3e
commit
b00932d047
75 files changed
+8529
-443
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type kiroIdePaths struct {
|
||||
homeDirectory string
|
||||
workspaceRoot string
|
||||
kiroDirectory string
|
||||
hooksDirectory string
|
||||
configPath string
|
||||
legacyPath string
|
||||
}
|
||||
|
||||
type kiroIdeSources struct {
|
||||
paths *kiroIdePaths
|
||||
document *kiroIdeDocument
|
||||
legacy *kiroIdeLegacyDocument
|
||||
initialDirectories []kiroIdeDirectoryIdentity
|
||||
}
|
||||
|
||||
func resolveKiroIdePaths() (*kiroIdePaths, error) {
|
||||
workspace, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve Kiro IDE workspace: %w", err)
|
||||
}
|
||||
workspace, err = filepath.Abs(workspace)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve Kiro IDE workspace path: %w", err)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
kiroDirectory := filepath.Join(workspace, ".kiro")
|
||||
hooksDirectory := filepath.Join(kiroDirectory, "hooks")
|
||||
return &kiroIdePaths{
|
||||
homeDirectory: home, workspaceRoot: workspace,
|
||||
kiroDirectory: kiroDirectory, hooksDirectory: hooksDirectory,
|
||||
configPath: filepath.Join(hooksDirectory, kiroIdeConfigFile),
|
||||
legacyPath: filepath.Join(home, ".kiro", "hooks", kiroIdeLegacyFile),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func inspectKiroIdeWorkspace(paths *kiroIdePaths) error {
|
||||
exists, err := managedPhysicalDirectoryExists(
|
||||
paths.workspaceRoot,
|
||||
"Kiro IDE workspace",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("Kiro IDE workspace is missing: %s", paths.workspaceRoot)
|
||||
}
|
||||
if _, err := managedPhysicalDirectoryExists(
|
||||
paths.kiroDirectory,
|
||||
"Kiro IDE configuration directory",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = managedPhysicalDirectoryExists(
|
||||
paths.hooksDirectory,
|
||||
"Kiro IDE hooks directory",
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func inspectKiroIdeLegacyDirectories(paths *kiroIdePaths) error {
|
||||
kiroDirectory := filepath.Dir(filepath.Dir(paths.legacyPath))
|
||||
hooksDirectory := filepath.Dir(paths.legacyPath)
|
||||
for _, item := range []struct{ path, label string }{
|
||||
{kiroDirectory, "legacy Kiro IDE configuration directory"},
|
||||
{hooksDirectory, "legacy Kiro IDE hooks directory"},
|
||||
} {
|
||||
exists, err := managedPhysicalDirectoryExists(item.path, item.label)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%s is missing: %s", item.label, item.path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readKiroIdeSources() (*kiroIdeSources, error) {
|
||||
paths, err := resolveKiroIdePaths()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initialDirectories := make([]kiroIdeDirectoryIdentity, 0, 5)
|
||||
for _, item := range []struct{ path, label string }{
|
||||
{paths.workspaceRoot, "Kiro IDE workspace"},
|
||||
{paths.kiroDirectory, "Kiro IDE configuration directory"},
|
||||
{paths.hooksDirectory, "Kiro IDE hooks directory"},
|
||||
} {
|
||||
identity, err := snapshotKiroIdeDirectory(item.path, item.label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initialDirectories = append(initialDirectories, identity)
|
||||
}
|
||||
if err := inspectKiroIdeWorkspace(paths); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspaceSnapshot, err := readManagedFile(
|
||||
paths.configPath,
|
||||
"Kiro IDE hooks",
|
||||
maxManagedSourceBytes,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
document, err := parseKiroIdeDocument(paths.configPath, workspaceSnapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
legacySnapshot, err := readManagedFile(
|
||||
paths.legacyPath,
|
||||
"legacy Kiro IDE hook",
|
||||
maxManagedSourceBytes,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if legacySnapshot != nil {
|
||||
if err := inspectKiroIdeLegacyDirectories(paths); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range []struct{ path, label string }{
|
||||
{
|
||||
filepath.Dir(filepath.Dir(paths.legacyPath)),
|
||||
"legacy Kiro IDE configuration directory",
|
||||
},
|
||||
{filepath.Dir(paths.legacyPath), "legacy Kiro IDE hooks directory"},
|
||||
} {
|
||||
identity, err := snapshotKiroIdeDirectory(item.path, item.label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initialDirectories = append(initialDirectories, identity)
|
||||
}
|
||||
current, err := readManagedFile(
|
||||
paths.legacyPath,
|
||||
"legacy Kiro IDE hook",
|
||||
maxManagedSourceBytes,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !sameManagedSnapshot(current, legacySnapshot) {
|
||||
return nil, fmt.Errorf(
|
||||
"legacy Kiro IDE hook changed while reading: %s",
|
||||
paths.legacyPath,
|
||||
)
|
||||
}
|
||||
}
|
||||
legacy, err := parseKiroIdeLegacyDocument(paths.legacyPath, legacySnapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertKiroIdeDirectoryStates(
|
||||
initialDirectories,
|
||||
"Kiro IDE source read",
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &kiroIdeSources{
|
||||
paths: paths, document: document, legacy: legacy,
|
||||
initialDirectories: initialDirectories,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func prepareRenderedKiroIdeChange(
|
||||
rendered *kiroIdeRenderedDocument,
|
||||
) (*fileChange, error) {
|
||||
if rendered == nil || !rendered.changed {
|
||||
return nil, nil
|
||||
}
|
||||
return prepareSnapshotSourceChange(
|
||||
rendered.document.filePath,
|
||||
"Kiro IDE hooks",
|
||||
rendered.document.snapshot,
|
||||
rendered.next,
|
||||
0600,
|
||||
rendered.remove,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func readKiroIdeRuntimeConfig(path string) (map[string]any, bool, error) {
|
||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
||||
if err != nil || snapshot == nil {
|
||||
return nil, snapshot != nil, err
|
||||
}
|
||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
||||
return config, true, err
|
||||
}
|
||||
|
||||
func requireKiroIdeRuntimeString(
|
||||
config map[string]any,
|
||||
field string,
|
||||
configPath string,
|
||||
) (string, error) {
|
||||
value, ok := config[field].(string)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return "", fmt.Errorf(
|
||||
"Elydora runtime config %s is invalid: %s",
|
||||
field,
|
||||
configPath,
|
||||
)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validateKiroIdeRuntimeConfig(
|
||||
config map[string]any,
|
||||
expectedAgentID string,
|
||||
configPath string,
|
||||
) error {
|
||||
supported := stringSet(
|
||||
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
|
||||
)
|
||||
fields := make([]string, 0, len(config))
|
||||
for field := range config {
|
||||
fields = append(fields, field)
|
||||
}
|
||||
sort.Strings(fields)
|
||||
for _, field := range fields {
|
||||
if _, ok := supported[field]; !ok {
|
||||
return fmt.Errorf(
|
||||
`Elydora runtime config has unsupported field %q: %s`,
|
||||
field,
|
||||
configPath,
|
||||
)
|
||||
}
|
||||
}
|
||||
if _, err := requireKiroIdeRuntimeString(config, "org_id", configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireKiroIdeRuntimeString(config, "kid", configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
agentID, err := requireKiroIdeRuntimeString(config, "agent_id", configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !sameKiroIdeAgentID(agentID, expectedAgentID) ||
|
||||
config["agent_name"] != kiroIdeAgentKey {
|
||||
return fmt.Errorf(
|
||||
"Elydora runtime identity does not match Kiro IDE hooks: %s",
|
||||
configPath,
|
||||
)
|
||||
}
|
||||
if _, exists := config["token"]; exists {
|
||||
if _, err := requireKiroIdeRuntimeString(config, "token", configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
baseURL, err := requireKiroIdeRuntimeString(config, "base_url", configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
||||
return fmt.Errorf(
|
||||
"Elydora runtime config base URL is invalid at %s: %w",
|
||||
configPath,
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateKiroIdeRuntimeIdentity(agentDirectory, agentID string) error {
|
||||
runtimeRoot := filepath.Dir(agentDirectory)
|
||||
rootExists, err := managedPhysicalDirectoryExists(
|
||||
runtimeRoot,
|
||||
"Elydora runtime directory",
|
||||
)
|
||||
if err != nil || !rootExists {
|
||||
return err
|
||||
}
|
||||
directoryExists, err := managedPhysicalDirectoryExists(
|
||||
agentDirectory,
|
||||
"Elydora agent runtime directory",
|
||||
)
|
||||
if err != nil || !directoryExists {
|
||||
return err
|
||||
}
|
||||
configPath := filepath.Join(agentDirectory, "config.json")
|
||||
config, configExists, err := readKiroIdeRuntimeConfig(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
artifactExists := false
|
||||
for _, item := range []struct {
|
||||
path string
|
||||
label string
|
||||
limit int64
|
||||
}{
|
||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
||||
{filepath.Join(agentDirectory, kiroIdeGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
||||
{filepath.Join(agentDirectory, kiroIdeAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
||||
} {
|
||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
||||
if inspectErr != nil {
|
||||
return inspectErr
|
||||
}
|
||||
artifactExists = artifactExists || exists
|
||||
}
|
||||
if !configExists {
|
||||
if artifactExists {
|
||||
return fmt.Errorf(
|
||||
"Elydora runtime identity cannot be verified without config.json: %s",
|
||||
agentDirectory,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
configuredID, ok := config["agent_id"].(string)
|
||||
if !ok || config["agent_name"] != kiroIdeAgentKey ||
|
||||
!sameKiroIdeAgentID(configuredID, agentID) {
|
||||
return fmt.Errorf(
|
||||
"Elydora runtime config identity does not match Kiro IDE agent %s: %s",
|
||||
agentID,
|
||||
configPath,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func kiroIdeRuntimeFileMode(snapshot *managedFileSnapshot, expected uint32) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return true
|
||||
}
|
||||
return uint32(snapshot.mode.Perm()) == expected
|
||||
}
|
||||
|
||||
func kiroIdeRuntimeContractExists(contract kiroIdeRuntimeContract) (bool, error) {
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
agentDirectory := filepath.Dir(contract.guardPath)
|
||||
if !sameKiroIdePath(filepath.Dir(agentDirectory), runtimeRoot) ||
|
||||
!sameKiroIdeAgentID(filepath.Base(agentDirectory), contract.agentID) ||
|
||||
!sameKiroIdePath(
|
||||
contract.guardPath,
|
||||
filepath.Join(agentDirectory, kiroIdeGuardScript),
|
||||
) ||
|
||||
!sameKiroIdePath(
|
||||
contract.auditPath,
|
||||
filepath.Join(agentDirectory, kiroIdeAuditScript),
|
||||
) {
|
||||
return false, nil
|
||||
}
|
||||
rootExists, err := managedPhysicalDirectoryExists(
|
||||
runtimeRoot,
|
||||
"Elydora runtime directory",
|
||||
)
|
||||
if err != nil || !rootExists {
|
||||
return false, err
|
||||
}
|
||||
directoryExists, err := managedPhysicalDirectoryExists(
|
||||
agentDirectory,
|
||||
"Elydora agent runtime directory",
|
||||
)
|
||||
if err != nil || !directoryExists {
|
||||
return false, err
|
||||
}
|
||||
configPath := filepath.Join(agentDirectory, "config.json")
|
||||
keyPath := filepath.Join(agentDirectory, "private.key")
|
||||
configSnapshot, err := readManagedFile(
|
||||
configPath,
|
||||
"Elydora runtime config",
|
||||
maxRuntimeConfigBytes,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
guard, err := readManagedFile(
|
||||
contract.guardPath,
|
||||
"Elydora guard runtime",
|
||||
maxManagedSourceBytes,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
audit, err := readManagedFile(
|
||||
contract.auditPath,
|
||||
"Elydora audit runtime",
|
||||
maxManagedSourceBytes,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if configSnapshot == nil || key == nil || guard == nil || audit == nil {
|
||||
return false, nil
|
||||
}
|
||||
config, err := decodeStrictJSONObject(
|
||||
configSnapshot.contents,
|
||||
fmt.Sprintf("Elydora runtime config at %s", configPath),
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := validateKiroIdeRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
||||
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
|
||||
}
|
||||
if !kiroIdeRuntimeFileMode(configSnapshot, 0600) ||
|
||||
!kiroIdeRuntimeFileMode(key, 0600) ||
|
||||
!kiroIdeRuntimeFileMode(guard, 0700) ||
|
||||
!kiroIdeRuntimeFileMode(audit, 0700) {
|
||||
return false, nil
|
||||
}
|
||||
expectedGuard := []byte(generateGuardScript(
|
||||
kiroIdeAgentKey,
|
||||
contract.agentID,
|
||||
"",
|
||||
false,
|
||||
"",
|
||||
))
|
||||
expectedAudit := []byte(buildHookScriptWithOutput(
|
||||
kiroIdeAgentKey,
|
||||
contract.agentID,
|
||||
"",
|
||||
false,
|
||||
true,
|
||||
))
|
||||
return bytes.Equal(guard.contents, expectedGuard) &&
|
||||
bytes.Equal(audit.contents, expectedAudit), nil
|
||||
}
|
||||
|
||||
func kiroIdeRuntimeFilesExist(contracts []kiroIdeRuntimeContract) (bool, error) {
|
||||
for _, contract := range contracts {
|
||||
exists, err := kiroIdeRuntimeContractExists(contract)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if exists {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestKiroIdeRegistryFactoryAndRuntimeOwnership(t *testing.T) {
|
||||
entry := SupportedAgents[kiroIdeAgentKey]
|
||||
if entry.Name != "Kiro IDE" || entry.ConfigDir != ".kiro/hooks" ||
|
||||
entry.ConfigFile != kiroIdeConfigFile {
|
||||
t.Fatalf("Kiro IDE registry entry = %#v", entry)
|
||||
}
|
||||
plugin, ok := NewPlugin(kiroIdeAgentKey).(*KiroIdePlugin)
|
||||
if !ok || !plugin.ManagesGuardRuntime() {
|
||||
t.Fatalf("Kiro IDE plugin = %#v", NewPlugin(kiroIdeAgentKey))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKiroIdeInstallUsesWorkspaceV1ContractAndIsIdempotent(t *testing.T) {
|
||||
userHook := map[string]any{
|
||||
"name": "workspace-context", "trigger": "SessionStart",
|
||||
"action": map[string]any{"type": "agent", "prompt": "Read AGENTS.md"},
|
||||
"enabled": true,
|
||||
}
|
||||
existing := map[string]any{
|
||||
"version": "v1", "owner": "workspace", "hooks": []any{userHook},
|
||||
}
|
||||
raw, _ := json.Marshal(existing)
|
||||
fixture := prepareKiroIdeFixture(t, kiroIdeString(string(raw)), nil)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kiro IDE hooks: %v", err)
|
||||
}
|
||||
first, err := os.ReadFile(fixture.configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read Kiro IDE hooks: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("repeat Kiro IDE install: %v", err)
|
||||
}
|
||||
second, err := os.ReadFile(fixture.configPath)
|
||||
if err != nil || string(second) != string(first) {
|
||||
t.Fatalf("idempotent Kiro IDE source changed: %v", err)
|
||||
}
|
||||
document := readKiroIdeObject(t, fixture.configPath)
|
||||
if document["version"] != "v1" || document["owner"] != "workspace" {
|
||||
t.Fatalf("Kiro IDE root changed: %#v", document)
|
||||
}
|
||||
if !reflect.DeepEqual(requireArray(t, document["hooks"])[0], userHook) {
|
||||
t.Fatalf("user hook changed: %#v", requireArray(t, document["hooks"])[0])
|
||||
}
|
||||
for _, item := range []struct{ name, trigger string }{
|
||||
{kiroIdeGuardName, "PreToolUse"},
|
||||
{kiroIdeAuditName, "PostToolUse"},
|
||||
} {
|
||||
hook := findKiroIdeHook(t, document, item.name)
|
||||
if hook["trigger"] != item.trigger || hook["matcher"] != ".*" ||
|
||||
hook["timeout"] != float64(10) || hook["enabled"] != true ||
|
||||
requireObject(t, hook["action"])["type"] != "command" {
|
||||
t.Fatalf("%s hook = %#v", item.name, hook)
|
||||
}
|
||||
command := kiroIdeHookCommand(t, hook)
|
||||
if runtime.GOOS == "windows" && !strings.Contains(command, " -EncodedCommand ") {
|
||||
t.Fatalf("Windows Kiro IDE command is not encoded PowerShell: %q", command)
|
||||
}
|
||||
}
|
||||
runtimeConfig := readKiroIdeObject(t, fixture.runtimeConfig)
|
||||
if runtimeConfig["agent_name"] != kiroIdeAgentKey ||
|
||||
runtimeConfig["agent_id"] != kiroIdeTestAgentID ||
|
||||
runtimeConfig["org_id"] != "org-1" || runtimeConfig["kid"] != "kid-1" {
|
||||
t.Fatalf("runtime config = %#v", runtimeConfig)
|
||||
}
|
||||
key, err := os.ReadFile(fixture.privateKey)
|
||||
if err != nil || string(key) != kiroIdePrivateKey {
|
||||
t.Fatalf("private key = %q, %v", key, err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(fixture.homeDir, ".kiro", "hooks", kiroIdeConfigFile)); !os.IsNotExist(err) {
|
||||
t.Fatalf("global Kiro IDE hook file exists: %v", err)
|
||||
}
|
||||
assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir)
|
||||
assertNoKiroIdeTransactionArtifacts(t, fixture.workspace)
|
||||
}
|
||||
|
||||
func TestKiroIdeCommandsBlockAndPreserveNativePayload(t *testing.T) {
|
||||
fixture := prepareKiroIdeFixture(t, nil, nil)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kiro IDE hooks: %v", err)
|
||||
}
|
||||
document := readKiroIdeObject(t, fixture.configPath)
|
||||
cache := map[string]any{
|
||||
"status": "frozen", "cached_at": float64(time.Now().UnixMilli()),
|
||||
}
|
||||
writeKiroIdeJSON(t, filepath.Join(fixture.agentDir, "status-cache.json"), cache)
|
||||
payload := map[string]any{
|
||||
"hook_event_name": "preToolUse", "cwd": fixture.workspace,
|
||||
"session_id": "session-1", "tool_name": "execute_bash",
|
||||
"tool_input": map[string]any{"command": "go test ./..."},
|
||||
"future_field": map[string]any{"retained": true},
|
||||
}
|
||||
encoded, _ := json.Marshal(payload)
|
||||
exitCode, stdout, stderr := runKiroIdeCommand(
|
||||
t,
|
||||
kiroIdeHookCommand(t, findKiroIdeHook(t, document, kiroIdeGuardName)),
|
||||
fixture,
|
||||
encoded,
|
||||
)
|
||||
if exitCode != 2 || stdout != "" || !strings.Contains(stderr, "Tool execution blocked") {
|
||||
t.Fatalf("guard result = %d, %q, %q", exitCode, stdout, stderr)
|
||||
}
|
||||
|
||||
capturePath := filepath.Join(fixture.workspace, "captured-event.json")
|
||||
captureJSON, _ := json.Marshal(capturePath)
|
||||
captureScript := "const fs=require('node:fs');const chunks=[];" +
|
||||
"process.stdin.on('data',c=>chunks.push(c));" +
|
||||
"process.stdin.on('end',()=>fs.writeFileSync(" + string(captureJSON) + ",Buffer.concat(chunks)));\n"
|
||||
if err := os.WriteFile(fixture.auditPath, []byte(captureScript), 0700); err != nil {
|
||||
t.Fatalf("replace audit runtime with capture: %v", err)
|
||||
}
|
||||
payload["hook_event_name"] = "postToolUse"
|
||||
payload["tool_response"] = map[string]any{"success": true, "result": "ok"}
|
||||
encoded, _ = json.Marshal(payload)
|
||||
exitCode, stdout, stderr = runKiroIdeCommand(
|
||||
t,
|
||||
kiroIdeHookCommand(t, findKiroIdeHook(t, document, kiroIdeAuditName)),
|
||||
fixture,
|
||||
encoded,
|
||||
)
|
||||
if exitCode != 0 || stdout != "" || stderr != "" {
|
||||
t.Fatalf("audit result = %d, %q, %q", exitCode, stdout, stderr)
|
||||
}
|
||||
captured, err := os.ReadFile(capturePath)
|
||||
if err != nil || string(captured) != string(encoded) {
|
||||
t.Fatalf("captured Kiro IDE payload = %q, %v", captured, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKiroIdeStatusRequiresUniqueExactPairAndRuntimeIntegrity(t *testing.T) {
|
||||
fixture := prepareKiroIdeFixture(t, nil, nil)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kiro IDE hooks: %v", err)
|
||||
}
|
||||
assertStatus := func(installed, configured bool) {
|
||||
t.Helper()
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || status.Installed != installed ||
|
||||
status.HookConfigured != configured {
|
||||
t.Fatalf("Kiro IDE status = %#v, %v", status, err)
|
||||
}
|
||||
}
|
||||
assertStatus(true, true)
|
||||
document := readKiroIdeObject(t, fixture.configPath)
|
||||
findKiroIdeHook(t, document, kiroIdeGuardName)["enabled"] = false
|
||||
writeKiroIdeJSON(t, fixture.configPath, document)
|
||||
assertStatus(false, false)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("repair Kiro IDE hooks: %v", err)
|
||||
}
|
||||
document = readKiroIdeObject(t, fixture.configPath)
|
||||
document["hooks"] = append(
|
||||
requireArray(t, document["hooks"]),
|
||||
cloneKiroIdeObject(findKiroIdeHook(t, document, kiroIdeGuardName)),
|
||||
)
|
||||
writeKiroIdeJSON(t, fixture.configPath, document)
|
||||
assertStatus(false, false)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("repair duplicate Kiro IDE hooks: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.guardPath, []byte("tampered\n"), 0700); err != nil {
|
||||
t.Fatalf("tamper guard runtime: %v", err)
|
||||
}
|
||||
assertStatus(false, true)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("repair Kiro IDE runtime: %v", err)
|
||||
}
|
||||
validRuntimeConfig, err := os.ReadFile(fixture.runtimeConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("read valid runtime config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
||||
t.Fatalf("corrupt runtime config: %v", err)
|
||||
}
|
||||
if _, err := fixture.plugin.Status(); err == nil ||
|
||||
!strings.Contains(err.Error(), "parse Elydora runtime config") {
|
||||
t.Fatalf("malformed runtime status error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.runtimeConfig, validRuntimeConfig, 0600); err != nil {
|
||||
t.Fatalf("restore runtime config: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("repair runtime config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil {
|
||||
t.Fatalf("corrupt private key: %v", err)
|
||||
}
|
||||
if _, err := fixture.plugin.Status(); err == nil ||
|
||||
!strings.Contains(err.Error(), "canonical 32-byte") {
|
||||
t.Fatalf("invalid private key status error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKiroIdeStatusRejectsExtraOrphanAndStaleRuntimeCommands(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
mutate func(*testing.T, *kiroIdeFixture, map[string]any)
|
||||
}{
|
||||
{
|
||||
name: "extra complete pair",
|
||||
mutate: func(t *testing.T, fixture *kiroIdeFixture, document map[string]any) {
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve Node.js: %v", err)
|
||||
}
|
||||
agentDirectory := filepath.Join(fixture.homeDir, ".elydora", "agent-2")
|
||||
guard, _ := buildKiroIdeHook(
|
||||
kiroIdeGuardName,
|
||||
nodePath,
|
||||
filepath.Join(agentDirectory, kiroIdeGuardScript),
|
||||
)
|
||||
audit, _ := buildKiroIdeHook(
|
||||
kiroIdeAuditName,
|
||||
nodePath,
|
||||
filepath.Join(agentDirectory, kiroIdeAuditScript),
|
||||
)
|
||||
document["hooks"] = append(requireArray(t, document["hooks"]), guard, audit)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "orphan guard",
|
||||
mutate: func(t *testing.T, fixture *kiroIdeFixture, document map[string]any) {
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve Node.js: %v", err)
|
||||
}
|
||||
guard, _ := buildKiroIdeHook(
|
||||
kiroIdeGuardName,
|
||||
nodePath,
|
||||
filepath.Join(
|
||||
fixture.homeDir,
|
||||
".elydora",
|
||||
"agent-2",
|
||||
kiroIdeGuardScript,
|
||||
),
|
||||
)
|
||||
document["hooks"] = append(requireArray(t, document["hooks"]), guard)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "split agent pair",
|
||||
mutate: func(t *testing.T, fixture *kiroIdeFixture, document map[string]any) {
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve Node.js: %v", err)
|
||||
}
|
||||
audit, _ := buildKiroIdeHook(
|
||||
kiroIdeAuditName,
|
||||
nodePath,
|
||||
filepath.Join(
|
||||
fixture.homeDir,
|
||||
".elydora",
|
||||
"agent-2",
|
||||
kiroIdeAuditScript,
|
||||
),
|
||||
)
|
||||
hooks := requireArray(t, document["hooks"])
|
||||
for index, value := range hooks {
|
||||
if requireObject(t, value)["name"] == kiroIdeAuditName {
|
||||
hooks[index] = audit
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stale Node runtime",
|
||||
mutate: func(t *testing.T, fixture *kiroIdeFixture, document map[string]any) {
|
||||
name := "node"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "node.exe"
|
||||
}
|
||||
staleNode := filepath.Join(fixture.homeDir, "old-node", name)
|
||||
command, err := buildKiroIdeCommand(staleNode, fixture.guardPath)
|
||||
if err != nil {
|
||||
t.Fatalf("build stale Kiro IDE command: %v", err)
|
||||
}
|
||||
requireObject(
|
||||
t,
|
||||
findKiroIdeHook(t, document, kiroIdeGuardName)["action"],
|
||||
)["command"] = command
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fixture := prepareKiroIdeFixture(t, nil, nil)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kiro IDE hooks: %v", err)
|
||||
}
|
||||
document := readKiroIdeObject(t, fixture.configPath)
|
||||
testCase.mutate(t, fixture, document)
|
||||
writeKiroIdeJSON(t, fixture.configPath, document)
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || status.Installed || status.HookConfigured {
|
||||
t.Fatalf("mutated Kiro IDE status = %#v, %v", status, err)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("repair Kiro IDE command contract: %v", err)
|
||||
}
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || !status.Installed {
|
||||
t.Fatalf("repaired Kiro IDE status = %#v, %v", status, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKiroIdeStatusRejectsStaleWindowsPowerShellLauncher(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows PowerShell launcher contract")
|
||||
}
|
||||
fixture := prepareKiroIdeFixture(t, nil, nil)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kiro IDE hooks: %v", err)
|
||||
}
|
||||
document := readKiroIdeObject(t, fixture.configPath)
|
||||
guard := findKiroIdeHook(t, document, kiroIdeGuardName)
|
||||
action := requireObject(t, guard["action"])
|
||||
command := action["command"].(string)
|
||||
action["command"] = strings.Replace(
|
||||
command,
|
||||
codexPowerShellPath(),
|
||||
`C:\StaleWindows\System32\WindowsPowerShell\v1.0\powershell.exe`,
|
||||
1,
|
||||
)
|
||||
writeKiroIdeJSON(t, fixture.configPath, document)
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || status.Installed || status.HookConfigured {
|
||||
t.Fatalf("stale PowerShell status = %#v, %v", status, err)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("repair PowerShell launcher: %v", err)
|
||||
}
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || !status.Installed {
|
||||
t.Fatalf("repaired PowerShell status = %#v, %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKiroIdeRejectsMalformedContractsAndNameCollisionsBeforeWrites(t *testing.T) {
|
||||
for _, testCase := range []struct{ name, source, want string }{
|
||||
{"malformed", "{ malformed", "parse Kiro IDE hooks"},
|
||||
{"duplicate", `{"version":"v1","version":"v1","hooks":[]}`, "duplicate"},
|
||||
{"version", `{"version":"v0","hooks":[]}`, `version must be "v1"`},
|
||||
{"hooks", `{"version":"v1","hooks":null}`, `field "hooks" must be an array`},
|
||||
{"trigger", `{"version":"v1","hooks":[{"name":"bad","trigger":"FutureEvent","action":{"type":"command","command":"x"}}]}`, "unsupported trigger"},
|
||||
{"action", `{"version":"v1","hooks":[{"name":"bad","trigger":"PreToolUse","action":{"type":"future"}}]}`, "unsupported type"},
|
||||
{"timeout", `{"version":"v1","hooks":[{"name":"bad","trigger":"PreToolUse","timeout":1.5,"action":{"type":"command","command":"x"}}]}`, "non-negative integer"},
|
||||
{"collision", `{"version":"v1","hooks":[{"name":"elydora-guard","trigger":"PreToolUse","action":{"type":"command","command":"user-command"}}]}`, "conflicts with the Elydora contract"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fixture := prepareKiroIdeFixture(t, &testCase.source, nil)
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("install error = %v, want %q", err, testCase.want)
|
||||
}
|
||||
actual, readErr := os.ReadFile(fixture.configPath)
|
||||
if readErr != nil || string(actual) != testCase.source {
|
||||
t.Fatalf("invalid source changed: %q, %v", actual, readErr)
|
||||
}
|
||||
assertNoKiroIdeRuntimeFiles(t, fixture)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKiroIdeMigratesOnlyExactLegacyOwnership(t *testing.T) {
|
||||
fixture := prepareKiroIdeFixture(t, nil, nil)
|
||||
writeKiroIdeJSON(
|
||||
t,
|
||||
fixture.legacyPath,
|
||||
legacyKiroIdeGoDocument(fixture, kiroIdeTestAgentID),
|
||||
)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("migrate legacy Kiro IDE hook: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(fixture.legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("legacy Kiro IDE hook remains: %v", err)
|
||||
}
|
||||
|
||||
lookalike := legacyKiroIdeGoDocument(fixture, kiroIdeTestAgentID)
|
||||
lookalike["owner"] = "user"
|
||||
writeKiroIdeJSON(t, fixture.legacyPath, lookalike)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install with legacy lookalike: %v", err)
|
||||
}
|
||||
remainingLookalike := readKiroIdeObject(t, fixture.legacyPath)
|
||||
if remainingLookalike["owner"] != "user" || remainingLookalike["name"] != "Elydora Audit" {
|
||||
t.Fatalf("legacy ownership lookalike changed: %#v", remainingLookalike)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fixture.legacyPath, []byte("{ malformed"), 0600); err != nil {
|
||||
t.Fatalf("write malformed legacy hook: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err == nil ||
|
||||
!strings.Contains(err.Error(), "parse legacy Kiro IDE hook") {
|
||||
t.Fatalf("malformed legacy error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKiroIdeUninstallPreservesWorkspaceHooksAndExactAgentOwnership(t *testing.T) {
|
||||
userHook := map[string]any{
|
||||
"name": "workspace-context", "trigger": "SessionStart",
|
||||
"action": map[string]any{"type": "agent", "prompt": "Read AGENTS.md"},
|
||||
}
|
||||
existing := map[string]any{
|
||||
"version": "v1", "owner": "workspace", "hooks": []any{userHook},
|
||||
}
|
||||
raw, _ := json.Marshal(existing)
|
||||
fixture := prepareKiroIdeFixture(t, kiroIdeString(string(raw)), nil)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kiro IDE hooks: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Uninstall("agent-2"); err != nil {
|
||||
t.Fatalf("uninstall unrelated Kiro IDE agent: %v", err)
|
||||
}
|
||||
if len(requireArray(t, readKiroIdeObject(t, fixture.configPath)["hooks"])) != 3 {
|
||||
t.Fatal("unrelated uninstall changed Kiro IDE hooks")
|
||||
}
|
||||
if err := fixture.plugin.Uninstall(kiroIdeTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall Kiro IDE hooks: %v", err)
|
||||
}
|
||||
remaining := readKiroIdeObject(t, fixture.configPath)
|
||||
if remaining["owner"] != "workspace" ||
|
||||
!reflect.DeepEqual(requireArray(t, remaining["hooks"]), []any{userHook}) {
|
||||
t.Fatalf("workspace hooks after uninstall = %#v", remaining)
|
||||
}
|
||||
|
||||
owned := prepareKiroIdeFixture(t, nil, nil)
|
||||
if err := owned.plugin.Install(owned.config); err != nil {
|
||||
t.Fatalf("install owned Kiro IDE hooks: %v", err)
|
||||
}
|
||||
if err := owned.plugin.Uninstall(kiroIdeTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall owned Kiro IDE hooks: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(owned.configPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("owned Kiro IDE config remains: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
kiroIdeTestAgentID = "agent-1"
|
||||
kiroIdePrivateKey = "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws"
|
||||
)
|
||||
|
||||
type kiroIdeFixture struct {
|
||||
plugin *KiroIdePlugin
|
||||
config InstallConfig
|
||||
homeDir string
|
||||
workspace string
|
||||
configPath string
|
||||
legacyPath string
|
||||
agentDir string
|
||||
guardPath string
|
||||
auditPath string
|
||||
runtimeConfig string
|
||||
privateKey string
|
||||
}
|
||||
|
||||
func prepareKiroIdeFixture(t *testing.T, workspaceSource, legacySource *string) *kiroIdeFixture {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
home := filepath.Join(root, "home with spaces and 'quote %KIRO%")
|
||||
preparePrivateTransactionTestDirectory(t, home)
|
||||
workspace := filepath.Join(root, "workspace with spaces")
|
||||
for _, directory := range []string{home, workspace} {
|
||||
if err := os.MkdirAll(directory, 0700); err != nil {
|
||||
t.Fatalf("create Kiro IDE fixture directory: %v", err)
|
||||
}
|
||||
}
|
||||
configPath := filepath.Join(workspace, ".kiro", "hooks", kiroIdeConfigFile)
|
||||
legacyPath := filepath.Join(home, ".kiro", "hooks", kiroIdeLegacyFile)
|
||||
writeOptionalKiroIdeSource(t, configPath, workspaceSource)
|
||||
writeOptionalKiroIdeSource(t, legacyPath, legacySource)
|
||||
previous, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("read current directory: %v", err)
|
||||
}
|
||||
if err := os.Chdir(workspace); err != nil {
|
||||
t.Fatalf("enter Kiro IDE workspace: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.Chdir(previous); err != nil {
|
||||
t.Errorf("restore current directory: %v", err)
|
||||
}
|
||||
})
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
agentDir := filepath.Join(home, ".elydora", kiroIdeTestAgentID)
|
||||
guardPath := filepath.Join(agentDir, kiroIdeGuardScript)
|
||||
return &kiroIdeFixture{
|
||||
plugin: &KiroIdePlugin{},
|
||||
config: InstallConfig{
|
||||
AgentName: kiroIdeAgentKey, OrgID: "org-1", AgentID: kiroIdeTestAgentID,
|
||||
PrivateKey: kiroIdePrivateKey, KID: "kid-1", Token: "token-1",
|
||||
BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath,
|
||||
},
|
||||
homeDir: home, workspace: workspace, configPath: configPath,
|
||||
legacyPath: legacyPath, agentDir: agentDir, guardPath: guardPath,
|
||||
auditPath: filepath.Join(agentDir, kiroIdeAuditScript),
|
||||
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
||||
privateKey: filepath.Join(agentDir, "private.key"),
|
||||
}
|
||||
}
|
||||
|
||||
func kiroIdeString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func writeOptionalKiroIdeSource(t *testing.T, path string, source *string) {
|
||||
t.Helper()
|
||||
if source == nil {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
t.Fatalf("create source directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(*source), 0600); err != nil {
|
||||
t.Fatalf("write Kiro IDE source: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeKiroIdeJSON(t *testing.T, path string, value any) {
|
||||
t.Helper()
|
||||
encoded, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("encode Kiro IDE JSON: %v", err)
|
||||
}
|
||||
encoded = append(encoded, '\n')
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
t.Fatalf("create Kiro IDE JSON directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, encoded, 0600); err != nil {
|
||||
t.Fatalf("write Kiro IDE JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readKiroIdeObject(t *testing.T, path string) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func findKiroIdeHook(t *testing.T, document map[string]any, name string) map[string]any {
|
||||
t.Helper()
|
||||
for _, value := range requireArray(t, document["hooks"]) {
|
||||
hook := requireObject(t, value)
|
||||
if hook["name"] == name {
|
||||
return hook
|
||||
}
|
||||
}
|
||||
t.Fatalf("Kiro IDE hook %q is missing", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func kiroIdeHookCommand(t *testing.T, hook map[string]any) string {
|
||||
t.Helper()
|
||||
action := requireObject(t, hook["action"])
|
||||
command, ok := action["command"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("Kiro IDE command = %#v", action["command"])
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
func legacyKiroIdeGoDocument(fixture *kiroIdeFixture, agentID string) map[string]any {
|
||||
agentDirectory := filepath.Join(fixture.homeDir, ".elydora", agentID)
|
||||
return map[string]any{
|
||||
"name": "Elydora Audit",
|
||||
"hooks": map[string]any{
|
||||
"pre_tool_use": map[string]any{
|
||||
"command": "node " + filepath.Join(agentDirectory, kiroIdeGuardScript),
|
||||
"timeout_ms": 5000,
|
||||
},
|
||||
"post_tool_use": map[string]any{
|
||||
"command": "node " + filepath.Join(agentDirectory, kiroIdeAuditScript),
|
||||
"timeout_ms": 5000,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runKiroIdeCommand(
|
||||
t *testing.T,
|
||||
command string,
|
||||
fixture *kiroIdeFixture,
|
||||
input []byte,
|
||||
) (int, string, string) {
|
||||
t.Helper()
|
||||
var process *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
commandFile := filepath.Join(t.TempDir(), "run-kiroide-hook.cmd")
|
||||
if err := os.WriteFile(
|
||||
commandFile,
|
||||
[]byte("@echo off\r\n"+command+"\r\n"),
|
||||
0600,
|
||||
); err != nil {
|
||||
t.Fatalf("write Kiro IDE command file: %v", err)
|
||||
}
|
||||
process = exec.Command("cmd.exe", "/d", "/c", commandFile)
|
||||
} else {
|
||||
process = exec.Command("sh", "-c", command)
|
||||
}
|
||||
process.Dir = fixture.workspace
|
||||
process.Env = append(
|
||||
os.Environ(),
|
||||
"HOME="+fixture.homeDir,
|
||||
"USERPROFILE="+fixture.homeDir,
|
||||
)
|
||||
process.Stdin = bytes.NewReader(input)
|
||||
var stdout, stderr bytes.Buffer
|
||||
process.Stdout = &stdout
|
||||
process.Stderr = &stderr
|
||||
err := process.Run()
|
||||
if err == nil {
|
||||
return 0, stdout.String(), stderr.String()
|
||||
}
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
return exitError.ExitCode(), stdout.String(), stderr.String()
|
||||
}
|
||||
t.Fatalf("run Kiro IDE command: %v", err)
|
||||
return -1, stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
func assertNoKiroIdeRuntimeFiles(t *testing.T, fixture *kiroIdeFixture) {
|
||||
t.Helper()
|
||||
for _, path := range []string{
|
||||
fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.auditPath,
|
||||
} {
|
||||
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("runtime file exists at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoKiroIdeTransactionArtifacts(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(entry.Name(), ".tmp") ||
|
||||
strings.HasSuffix(entry.Name(), ".rollback") {
|
||||
t.Errorf("Kiro IDE transaction artifact remains at %s", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk Kiro IDE fixture: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func prepareKiroIdeInstallForTest(
|
||||
t *testing.T,
|
||||
fixture *kiroIdeFixture,
|
||||
) *kiroIdePreparedTransaction {
|
||||
t.Helper()
|
||||
sources, err := readKiroIdeSources()
|
||||
if err != nil {
|
||||
t.Fatalf("read Kiro IDE sources: %v", err)
|
||||
}
|
||||
paths, nodePath, err := preflightKiroIdeInstallation(fixture.config)
|
||||
if err != nil {
|
||||
t.Fatalf("preflight Kiro IDE installation: %v", err)
|
||||
}
|
||||
guard, err := buildKiroIdeHook(kiroIdeGuardName, nodePath, paths.guardPath)
|
||||
if err != nil {
|
||||
t.Fatalf("build Kiro IDE guard: %v", err)
|
||||
}
|
||||
audit, err := buildKiroIdeHook(kiroIdeAuditName, nodePath, paths.auditPath)
|
||||
if err != nil {
|
||||
t.Fatalf("build Kiro IDE audit: %v", err)
|
||||
}
|
||||
rendered, err := renderKiroIdeDocument(
|
||||
sources.document,
|
||||
append(withoutManagedKiroIdeHooks(sources.document.hooks, ""), guard, audit),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("render Kiro IDE hooks: %v", err)
|
||||
}
|
||||
prepared, err := prepareKiroIdeInstallation(fixture.config, sources, paths, rendered)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare Kiro IDE installation: %v", err)
|
||||
}
|
||||
return prepared
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type kiroIdeDirectoryIdentity struct {
|
||||
path string
|
||||
label string
|
||||
info os.FileInfo
|
||||
}
|
||||
|
||||
func snapshotKiroIdeDirectory(path, label string) (kiroIdeDirectoryIdentity, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return kiroIdeDirectoryIdentity{path: path, label: label}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return kiroIdeDirectoryIdentity{}, fmt.Errorf("inspect %s at %s: %w", label, path, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return kiroIdeDirectoryIdentity{}, fmt.Errorf("%s is not a physical directory: %s", label, path)
|
||||
}
|
||||
return kiroIdeDirectoryIdentity{path: path, label: label, info: info}, nil
|
||||
}
|
||||
|
||||
func (identity kiroIdeDirectoryIdentity) assertExpected(operation string) error {
|
||||
current, err := os.Lstat(identity.path)
|
||||
if os.IsNotExist(err) && identity.info == nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"%s changed during %s: %s: %w",
|
||||
identity.label,
|
||||
operation,
|
||||
identity.path,
|
||||
err,
|
||||
)
|
||||
}
|
||||
if identity.info == nil || current.Mode()&os.ModeSymlink != 0 || !current.IsDir() ||
|
||||
!os.SameFile(identity.info, current) {
|
||||
return fmt.Errorf(
|
||||
"%s changed during %s: %s",
|
||||
identity.label,
|
||||
operation,
|
||||
identity.path,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertKiroIdeDirectoryStates(
|
||||
directories []kiroIdeDirectoryIdentity,
|
||||
operation string,
|
||||
) error {
|
||||
for _, directory := range directories {
|
||||
if err := directory.assertExpected(operation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func establishKiroIdeDirectory(
|
||||
initial kiroIdeDirectoryIdentity,
|
||||
operation string,
|
||||
private bool,
|
||||
) (kiroIdeDirectoryIdentity, error) {
|
||||
if err := initial.assertExpected(operation); err != nil {
|
||||
return kiroIdeDirectoryIdentity{}, err
|
||||
}
|
||||
if initial.info == nil {
|
||||
if err := os.Mkdir(initial.path, 0700); err != nil {
|
||||
return kiroIdeDirectoryIdentity{}, fmt.Errorf(
|
||||
"%s changed during %s before Elydora could create it: %s: %w",
|
||||
initial.label,
|
||||
operation,
|
||||
initial.path,
|
||||
err,
|
||||
)
|
||||
}
|
||||
} else if private && runtime.GOOS != "windows" {
|
||||
if err := os.Chmod(initial.path, 0700); err != nil {
|
||||
return kiroIdeDirectoryIdentity{}, fmt.Errorf(
|
||||
"restrict %s at %s: %w",
|
||||
initial.label,
|
||||
initial.path,
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
current, err := snapshotKiroIdeDirectory(initial.path, initial.label)
|
||||
if err != nil {
|
||||
return kiroIdeDirectoryIdentity{}, err
|
||||
}
|
||||
if current.info == nil {
|
||||
return kiroIdeDirectoryIdentity{}, fmt.Errorf(
|
||||
"%s is missing after creation: %s",
|
||||
initial.label,
|
||||
initial.path,
|
||||
)
|
||||
}
|
||||
if initial.info != nil && !os.SameFile(initial.info, current.info) {
|
||||
return kiroIdeDirectoryIdentity{}, fmt.Errorf(
|
||||
"%s changed during %s: %s",
|
||||
initial.label,
|
||||
operation,
|
||||
initial.path,
|
||||
)
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func kiroIdePathInside(directory, candidate string) bool {
|
||||
relative, err := filepath.Rel(directory, candidate)
|
||||
if err != nil || filepath.IsAbs(relative) || relative == ".." {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
|
||||
return false
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return !strings.HasPrefix(strings.ToLower(relative), ".."+string(os.PathSeparator))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func guardedKiroIdeRename(
|
||||
rename renameFunc,
|
||||
directories []kiroIdeDirectoryIdentity,
|
||||
) renameFunc {
|
||||
if rename == nil {
|
||||
rename = atomicRenameNoReplace
|
||||
}
|
||||
return func(source, destination string) error {
|
||||
relevant := make([]kiroIdeDirectoryIdentity, 0, len(directories))
|
||||
for _, directory := range directories {
|
||||
if kiroIdePathInside(directory.path, source) ||
|
||||
kiroIdePathInside(directory.path, destination) {
|
||||
relevant = append(relevant, directory)
|
||||
}
|
||||
}
|
||||
for _, directory := range relevant {
|
||||
if err := directory.assertExpected("Kiro IDE transaction"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := rename(source, destination); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, directory := range relevant {
|
||||
if err := directory.assertExpected("Kiro IDE transaction"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func guardedKiroIdeTransactionOps(
|
||||
rename renameFunc,
|
||||
directories []kiroIdeDirectoryIdentity,
|
||||
) transactionFileOps {
|
||||
if rename != nil {
|
||||
return transactionFileOps{
|
||||
moveNoReplace: guardedKiroIdeRename(rename, directories),
|
||||
compensateNoReplace: guardedKiroIdeRename(atomicRenameNoReplace, directories),
|
||||
}
|
||||
}
|
||||
return transactionFileOps{
|
||||
moveNoReplace: guardedKiroIdeRename(atomicRenameNoReplace, directories),
|
||||
compensateNoReplace: guardedKiroIdeRename(atomicRenameNoReplace, directories),
|
||||
replaceWithBackup: guardedKiroIdeReplace(directories),
|
||||
production: true,
|
||||
}
|
||||
}
|
||||
|
||||
func guardedKiroIdeReplace(
|
||||
directories []kiroIdeDirectoryIdentity,
|
||||
) replaceWithBackupFunc {
|
||||
return func(target, replacement, backup string) error {
|
||||
relevant := make([]kiroIdeDirectoryIdentity, 0, len(directories))
|
||||
for _, directory := range directories {
|
||||
if kiroIdePathInside(directory.path, target) ||
|
||||
kiroIdePathInside(directory.path, replacement) ||
|
||||
kiroIdePathInside(directory.path, backup) {
|
||||
relevant = append(relevant, directory)
|
||||
}
|
||||
}
|
||||
if err := assertKiroIdeDirectoryStates(relevant, "Kiro IDE transaction"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := atomicReplaceWithBackup(target, replacement, backup); err != nil {
|
||||
return err
|
||||
}
|
||||
return assertKiroIdeDirectoryStates(relevant, "Kiro IDE transaction")
|
||||
}
|
||||
}
|
||||
|
||||
func kiroIdeDirectoryKey(path string) string {
|
||||
key := filepath.Clean(path)
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ToLower(key)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func kiroIdeInitialDirectory(
|
||||
prepared *kiroIdePreparedTransaction,
|
||||
path string,
|
||||
) (kiroIdeDirectoryIdentity, error) {
|
||||
key := kiroIdeDirectoryKey(path)
|
||||
for _, directory := range prepared.initialDirectories {
|
||||
if kiroIdeDirectoryKey(directory.path) == key {
|
||||
return directory, nil
|
||||
}
|
||||
}
|
||||
return kiroIdeDirectoryIdentity{}, fmt.Errorf(
|
||||
"Kiro IDE transaction is missing a directory snapshot for %s",
|
||||
path,
|
||||
)
|
||||
}
|
||||
|
||||
func appendKiroIdeDirectory(
|
||||
directories []kiroIdeDirectoryIdentity,
|
||||
directory kiroIdeDirectoryIdentity,
|
||||
) []kiroIdeDirectoryIdentity {
|
||||
key := kiroIdeDirectoryKey(directory.path)
|
||||
for index, existing := range directories {
|
||||
if kiroIdeDirectoryKey(existing.path) == key {
|
||||
directories[index] = directory
|
||||
return directories
|
||||
}
|
||||
}
|
||||
return append(directories, directory)
|
||||
}
|
||||
|
||||
func kiroIdeDirectoryPreconditions(
|
||||
directories []kiroIdeDirectoryIdentity,
|
||||
) []filePrecondition {
|
||||
conditions := make([]filePrecondition, 0, len(directories))
|
||||
for _, directory := range directories {
|
||||
if directory.info == nil {
|
||||
continue
|
||||
}
|
||||
conditions = append(conditions, filePrecondition{
|
||||
filePath: directory.path,
|
||||
label: directory.label,
|
||||
directory: directory.info,
|
||||
})
|
||||
}
|
||||
return conditions
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKiroIdeRollbackRestoresRuntimeWorkspaceAndLegacySources(t *testing.T) {
|
||||
original := `{"version":"v1","owner":"workspace","hooks":[]}`
|
||||
fixture := prepareKiroIdeFixture(t, &original, nil)
|
||||
writeKiroIdeJSON(
|
||||
t,
|
||||
fixture.legacyPath,
|
||||
legacyKiroIdeGoDocument(fixture, kiroIdeTestAgentID),
|
||||
)
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if sameKiroIdePath(source, fixture.legacyPath) &&
|
||||
strings.HasSuffix(destination, ".rollback") {
|
||||
return errors.New("injected Kiro IDE legacy cleanup failure")
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "injected Kiro IDE legacy cleanup failure") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
actual, readErr := os.ReadFile(fixture.configPath)
|
||||
if readErr != nil || string(actual) != original {
|
||||
t.Fatalf("workspace source after rollback = %q, %v", actual, readErr)
|
||||
}
|
||||
if _, err := os.Lstat(fixture.legacyPath); err != nil {
|
||||
t.Fatalf("legacy source was not restored: %v", err)
|
||||
}
|
||||
assertNoKiroIdeRuntimeFiles(t, fixture)
|
||||
assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir)
|
||||
assertNoKiroIdeTransactionArtifacts(t, fixture.workspace)
|
||||
}
|
||||
|
||||
func TestKiroIdePreparedInstallProtectsUnchangedSources(t *testing.T) {
|
||||
fixture := prepareKiroIdeFixture(t, nil, nil)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kiro IDE hooks: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.guardPath, []byte("repair me\n"), 0700); err != nil {
|
||||
t.Fatalf("damage guard runtime: %v", err)
|
||||
}
|
||||
prepared := prepareKiroIdeInstallForTest(t, fixture)
|
||||
concurrent := `{"version":"v1","owner":"concurrent","hooks":[]}`
|
||||
if err := os.WriteFile(fixture.configPath, []byte(concurrent), 0600); err != nil {
|
||||
t.Fatalf("write concurrent workspace source: %v", err)
|
||||
}
|
||||
err := commitKiroIdeInstallation(prepared, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during Kiro IDE installation") {
|
||||
t.Fatalf("prepared install error = %v", err)
|
||||
}
|
||||
actual, readErr := os.ReadFile(fixture.configPath)
|
||||
if readErr != nil || string(actual) != concurrent {
|
||||
t.Fatalf("concurrent workspace source changed: %q, %v", actual, readErr)
|
||||
}
|
||||
guard, readErr := os.ReadFile(fixture.guardPath)
|
||||
if readErr != nil || string(guard) != "repair me\n" {
|
||||
t.Fatalf("runtime changed after source race: %q, %v", guard, readErr)
|
||||
}
|
||||
assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir)
|
||||
assertNoKiroIdeTransactionArtifacts(t, fixture.workspace)
|
||||
}
|
||||
|
||||
func TestKiroIdeInstallRepairsRuntimeModesOnPOSIX(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX file modes are unavailable on Windows")
|
||||
}
|
||||
fixture := prepareKiroIdeFixture(t, nil, nil)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kiro IDE hooks: %v", err)
|
||||
}
|
||||
files := []struct {
|
||||
path string
|
||||
drift os.FileMode
|
||||
want os.FileMode
|
||||
}{
|
||||
{fixture.runtimeConfig, 0644, 0600},
|
||||
{fixture.privateKey, 0644, 0600},
|
||||
{fixture.guardPath, 0600, 0700},
|
||||
{fixture.auditPath, 0600, 0700},
|
||||
}
|
||||
for _, file := range files {
|
||||
if err := os.Chmod(file.path, file.drift); err != nil {
|
||||
t.Fatalf("drift runtime mode at %s: %v", file.path, err)
|
||||
}
|
||||
}
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || status.Installed || !status.HookConfigured {
|
||||
t.Fatalf("mode-drifted Kiro IDE status = %#v, %v", status, err)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("repair Kiro IDE runtime modes: %v", err)
|
||||
}
|
||||
for _, file := range files {
|
||||
info, err := os.Stat(file.path)
|
||||
if err != nil || info.Mode().Perm() != file.want {
|
||||
t.Fatalf("runtime mode at %s = %v, %v", file.path, infoMode(info), err)
|
||||
}
|
||||
}
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || !status.Installed || !status.HookConfigured {
|
||||
t.Fatalf("repaired Kiro IDE status = %#v, %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKiroIdePreparedInstallDetectsConcurrentModeChangesOnPOSIX(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX file modes are unavailable on Windows")
|
||||
}
|
||||
t.Run("changed private key", func(t *testing.T) {
|
||||
fixture := prepareKiroIdeFixture(t, nil, nil)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kiro IDE hooks: %v", err)
|
||||
}
|
||||
fixture.config.PrivateKey = base64.RawURLEncoding.EncodeToString(
|
||||
bytes.Repeat([]byte{12}, 32),
|
||||
)
|
||||
prepared := prepareKiroIdeInstallForTest(t, fixture)
|
||||
if err := os.Chmod(fixture.privateKey, 0644); err != nil {
|
||||
t.Fatalf("concurrently chmod private key: %v", err)
|
||||
}
|
||||
err := commitKiroIdeInstallation(prepared, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "private key changed during installation") {
|
||||
t.Fatalf("concurrent private key mode error = %v", err)
|
||||
}
|
||||
key, readErr := os.ReadFile(fixture.privateKey)
|
||||
if readErr != nil || string(key) != kiroIdePrivateKey {
|
||||
t.Fatalf("private key changed after mode race: %q, %v", key, readErr)
|
||||
}
|
||||
info, statErr := os.Stat(fixture.privateKey)
|
||||
if statErr != nil || info.Mode().Perm() != 0644 {
|
||||
t.Fatalf("private key mode after race = %v, %v", infoMode(info), statErr)
|
||||
}
|
||||
assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir)
|
||||
})
|
||||
t.Run("unchanged runtime config", func(t *testing.T) {
|
||||
fixture := prepareKiroIdeFixture(t, nil, nil)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kiro IDE hooks: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.guardPath, []byte("repair me\n"), 0700); err != nil {
|
||||
t.Fatalf("damage guard runtime: %v", err)
|
||||
}
|
||||
prepared := prepareKiroIdeInstallForTest(t, fixture)
|
||||
if err := os.Chmod(fixture.runtimeConfig, 0644); err != nil {
|
||||
t.Fatalf("concurrently chmod runtime config: %v", err)
|
||||
}
|
||||
err := commitKiroIdeInstallation(prepared, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "runtime config changed during Kiro IDE installation") {
|
||||
t.Fatalf("concurrent runtime config mode error = %v", err)
|
||||
}
|
||||
guard, readErr := os.ReadFile(fixture.guardPath)
|
||||
if readErr != nil || string(guard) != "repair me\n" {
|
||||
t.Fatalf("guard changed after mode race: %q, %v", guard, readErr)
|
||||
}
|
||||
assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir)
|
||||
})
|
||||
}
|
||||
|
||||
func infoMode(info os.FileInfo) os.FileMode {
|
||||
if info == nil {
|
||||
return 0
|
||||
}
|
||||
return info.Mode().Perm()
|
||||
}
|
||||
|
||||
func TestKiroIdePreparedInstallRejectsFreshWorkspaceDirectoryReplacement(t *testing.T) {
|
||||
fixture := prepareKiroIdeFixture(t, nil, nil)
|
||||
prepared := prepareKiroIdeInstallForTest(t, fixture)
|
||||
external := filepath.Join(filepath.Dir(fixture.homeDir), "external-workspace")
|
||||
if err := os.MkdirAll(external, 0700); err != nil {
|
||||
t.Fatalf("create external workspace directory: %v", err)
|
||||
}
|
||||
if err := os.Symlink(external, filepath.Join(fixture.workspace, ".kiro")); err != nil {
|
||||
t.Skipf("directory symbolic links unavailable: %v", err)
|
||||
}
|
||||
err := commitKiroIdeInstallation(prepared, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during Kiro IDE installation") {
|
||||
t.Fatalf("workspace replacement error = %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(external, "hooks", kiroIdeConfigFile)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("external workspace received hooks: %v", err)
|
||||
}
|
||||
assertNoKiroIdeRuntimeFiles(t, fixture)
|
||||
}
|
||||
|
||||
func TestKiroIdePreparedInstallRejectsFreshRuntimeDirectoryReplacements(t *testing.T) {
|
||||
for _, target := range []string{"root", "agent"} {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
fixture := prepareKiroIdeFixture(t, nil, nil)
|
||||
runtimeRoot := filepath.Join(fixture.homeDir, ".elydora")
|
||||
if target == "agent" {
|
||||
if err := os.Mkdir(runtimeRoot, 0700); err != nil {
|
||||
t.Fatalf("create physical runtime root: %v", err)
|
||||
}
|
||||
}
|
||||
prepared := prepareKiroIdeInstallForTest(t, fixture)
|
||||
external := filepath.Join(filepath.Dir(fixture.homeDir), "external-runtime-"+target)
|
||||
if err := os.MkdirAll(external, 0700); err != nil {
|
||||
t.Fatalf("create external runtime directory: %v", err)
|
||||
}
|
||||
linkPath := runtimeRoot
|
||||
if target == "agent" {
|
||||
linkPath = fixture.agentDir
|
||||
}
|
||||
if err := os.Symlink(external, linkPath); err != nil {
|
||||
t.Skipf("directory symbolic links unavailable: %v", err)
|
||||
}
|
||||
err := commitKiroIdeInstallation(prepared, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during Kiro IDE installation") {
|
||||
t.Fatalf("runtime %s replacement error = %v", target, err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(external, "private.key")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("external runtime received private key: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(external, kiroIdeGuardScript)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("external runtime received guard: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKiroIdePreparedInstallRejectsExistingWorkspaceIdentityReplacement(t *testing.T) {
|
||||
source := `{"version":"v1","owner":"workspace","hooks":[]}`
|
||||
fixture := prepareKiroIdeFixture(t, &source, nil)
|
||||
prepared := prepareKiroIdeInstallForTest(t, fixture)
|
||||
kiroDirectory := filepath.Join(fixture.workspace, ".kiro")
|
||||
backup := kiroDirectory + ".original"
|
||||
external := filepath.Join(filepath.Dir(fixture.homeDir), "external-existing-workspace")
|
||||
if err := os.MkdirAll(filepath.Join(external, "hooks"), 0700); err != nil {
|
||||
t.Fatalf("create external hooks directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(external, "hooks", kiroIdeConfigFile),
|
||||
[]byte(source),
|
||||
0600,
|
||||
); err != nil {
|
||||
t.Fatalf("write external matching source: %v", err)
|
||||
}
|
||||
if err := os.Rename(kiroDirectory, backup); err != nil {
|
||||
t.Fatalf("move physical Kiro directory: %v", err)
|
||||
}
|
||||
if err := os.Symlink(external, kiroDirectory); err != nil {
|
||||
_ = os.Rename(backup, kiroDirectory)
|
||||
t.Skipf("directory symbolic links unavailable: %v", err)
|
||||
}
|
||||
err := commitKiroIdeInstallation(prepared, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during Kiro IDE installation") {
|
||||
t.Fatalf("existing workspace replacement error = %v", err)
|
||||
}
|
||||
actual, readErr := os.ReadFile(filepath.Join(external, "hooks", kiroIdeConfigFile))
|
||||
if readErr != nil || string(actual) != source {
|
||||
t.Fatalf("external matching source changed: %q, %v", actual, readErr)
|
||||
}
|
||||
assertNoKiroIdeRuntimeFiles(t, fixture)
|
||||
}
|
||||
|
||||
func TestKiroIdeRejectsFileAndDirectoryLinksBeforeRuntimeWrites(t *testing.T) {
|
||||
for _, kind := range []string{"workspace-config", "workspace-directory", "runtime-directory"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
fixture := prepareKiroIdeFixture(t, nil, nil)
|
||||
external := filepath.Join(filepath.Dir(fixture.homeDir), "external-"+kind)
|
||||
if kind == "workspace-config" {
|
||||
if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0700); err != nil {
|
||||
t.Fatalf("create hooks directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(external, []byte(`{"version":"v1","hooks":[]}`), 0600); err != nil {
|
||||
t.Fatalf("write external config: %v", err)
|
||||
}
|
||||
if err := os.Symlink(external, fixture.configPath); err != nil {
|
||||
t.Skipf("file symbolic links unavailable: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := os.MkdirAll(external, 0700); err != nil {
|
||||
t.Fatalf("create external directory: %v", err)
|
||||
}
|
||||
linkPath := filepath.Join(fixture.workspace, ".kiro")
|
||||
if kind == "runtime-directory" {
|
||||
linkPath = filepath.Join(fixture.homeDir, ".elydora")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(linkPath), 0700); err != nil {
|
||||
t.Fatalf("create link parent: %v", err)
|
||||
}
|
||||
if err := os.Symlink(external, linkPath); err != nil {
|
||||
t.Skipf("directory symbolic links unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "physical") {
|
||||
t.Fatalf("linked %s error = %v", kind, err)
|
||||
}
|
||||
assertNoKiroIdeRuntimeFiles(t, fixture)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKiroIdeInstallSurvivesAdversarialWorkspaceLinkSwap(t *testing.T) {
|
||||
fixture := prepareKiroIdeFixture(t, nil, nil)
|
||||
kiroDirectory := filepath.Join(fixture.workspace, ".kiro")
|
||||
external := filepath.Join(filepath.Dir(fixture.homeDir), "external-link-swap")
|
||||
probe := filepath.Join(filepath.Dir(fixture.homeDir), "link-probe")
|
||||
if err := os.MkdirAll(external, 0700); err != nil {
|
||||
t.Fatalf("create external directory: %v", err)
|
||||
}
|
||||
if err := os.Symlink(external, probe); err != nil {
|
||||
t.Skipf("directory symbolic links unavailable: %v", err)
|
||||
}
|
||||
if err := os.Remove(probe); err != nil {
|
||||
t.Fatalf("remove link probe: %v", err)
|
||||
}
|
||||
swapped := false
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if !swapped && sameKiroIdePath(destination, fixture.configPath) &&
|
||||
strings.HasSuffix(source, ".tmp") {
|
||||
swapped = true
|
||||
backup := kiroDirectory + ".original"
|
||||
if err := os.Rename(kiroDirectory, backup); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(external, kiroDirectory); err != nil {
|
||||
_ = os.Rename(backup, kiroDirectory)
|
||||
return err
|
||||
}
|
||||
renameErr := os.Rename(source, destination)
|
||||
_ = os.Remove(kiroDirectory)
|
||||
restoreErr := os.Rename(backup, kiroDirectory)
|
||||
return errors.Join(renameErr, restoreErr)
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !swapped {
|
||||
t.Fatalf("link-swap install error = %v, swapped=%v", err, swapped)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(external, "hooks", kiroIdeConfigFile)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("external hook was written during link swap: %v", err)
|
||||
}
|
||||
assertNoKiroIdeRuntimeFiles(t, fixture)
|
||||
assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir)
|
||||
assertNoKiroIdeTransactionArtifacts(t, fixture.workspace)
|
||||
}
|
||||
|
||||
func TestKiroIdeInstallDetectsConcurrentSourceIdentityReplacement(t *testing.T) {
|
||||
source := `{"version":"v1","owner":"original","hooks":[]}`
|
||||
fixture := prepareKiroIdeFixture(t, &source, nil)
|
||||
mutated := false
|
||||
fixture.plugin.rename = func(stagedPath, destination string) error {
|
||||
if !mutated && strings.HasSuffix(stagedPath, ".tmp") {
|
||||
mutated = true
|
||||
external := fixture.configPath + ".external"
|
||||
if err := os.Rename(fixture.configPath, external); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(fixture.configPath, []byte(source), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.Rename(stagedPath, destination)
|
||||
}
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||
t.Fatalf("concurrent identity error = %v", err)
|
||||
}
|
||||
actual, readErr := os.ReadFile(fixture.configPath)
|
||||
if readErr != nil || string(actual) != source {
|
||||
t.Fatalf("replacement source changed: %q, %v", actual, readErr)
|
||||
}
|
||||
assertNoKiroIdeRuntimeFiles(t, fixture)
|
||||
assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type managedFileSnapshot struct {
|
||||
contents []byte
|
||||
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")
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user