feat(go): durable transaction engine and Kiro IDE 1.0 adapter

Split the monolithic transaction into focused modules: append-only
crash-recoverable journal under ~/.elydora/transactions with owner
tokens and per-transaction namespaces, staged atomic publication
(stage .pending, fsync, rename, fsync), platform-specific atomic
replacement (MoveFileEx, renameat2, renamex_np), private-directory
hardening per OS, lock-guarded recovery, and workspace cleanup that
removes only transaction-created empty directories.

Add the Kiro IDE 1.0 plugin: strict v1 hook schema with one enabled
PreToolUse guard and PostToolUse audit pair bound to the active
workspace, agent-to-workspace identity binding in runtime config, and
exact migration of the legacy elydora-audit.kiro.hook contract.

Gates: go test ./... and -race pass, go vet clean, govulncheck reports
zero reachable vulnerabilities, git diff --check clean.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-21 19:02:57 -04:00
1 parent dd72793cdb
commit eb41b991bf
70 files changed
+8182 -358

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)
@@ -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")
+1
View File
@@ -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 {
@@ -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 {
+1
View File
@@ -29,6 +29,7 @@ type kiroFixture struct {
func prepareKiroFixture(t *testing.T, v2Raw, v3Raw string) *kiroFixture {
t.Helper()
homeDir := filepath.Join(t.TempDir(), "home with spaces")
preparePrivateTransactionTestDirectory(t, homeDir)
agentDir := filepath.Join(homeDir, ".elydora", kiroTestAgentID)
guardPath := filepath.Join(agentDir, "guard.js")
v2Path := filepath.Join(homeDir, ".kiro", "agents", "elydora-audit.json")
+92 -107
View File
@@ -1,143 +1,128 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
import "fmt"
// KiroIdePlugin manages the Elydora audit hook for Kiro IDE.
// It writes a .kiro/hooks/elydora-audit.kiro.hook JSON file in the home directory.
type KiroIdePlugin struct{}
// KiroIdePlugin manages Kiro IDE 1.0 workspace hooks.
type KiroIdePlugin struct {
rename renameFunc
}
func (p *KiroIdePlugin) configPath() (string, error) {
home, err := os.UserHomeDir()
// ManagesGuardRuntime reports that Kiro IDE commits both generated runtimes,
// runtime metadata, credentials, workspace hooks, and legacy migration in one
// transaction.
func (p *KiroIdePlugin) ManagesGuardRuntime() bool {
return true
}
// PreflightInstall validates every source and runtime identity before writes.
func (p *KiroIdePlugin) PreflightInstall(config InstallConfig) error {
sources, err := readKiroIdeSources()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
return err
}
return filepath.Join(home, ".kiro", "hooks", "elydora-audit.kiro.hook"), nil
if err := requireAvailableKiroIdeHooks(sources.document.hooks); err != nil {
return err
}
_, _, err = preflightKiroIdeInstallation(config)
return err
}
func (p *KiroIdePlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath(config.AgentID)
sources, err := readKiroIdeSources()
if err != nil {
return err
}
if config.HookScript != "" {
scriptPath = config.HookScript
if err := requireAvailableKiroIdeHooks(sources.document.hooks); err != nil {
return err
}
if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
guardPath := config.GuardScriptPath
if guardPath == "" {
guardPath, err = guardScriptPath(config.AgentID)
if err != nil {
return err
}
}
hookFile, err := p.configPath()
paths, nodePath, err := preflightKiroIdeInstallation(config)
if err != nil {
return err
}
dir := filepath.Dir(hookFile)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory %s: %w", dir, err)
hooks := withoutManagedKiroIdeHooks(sources.document.hooks, "")
guard, err := buildKiroIdeHook(kiroIdeGuardName, nodePath, paths.guardPath)
if err != nil {
return err
}
content := buildKiroIdeHookFile(scriptPath, guardPath)
if err := os.WriteFile(hookFile, []byte(content), 0644); err != nil {
return fmt.Errorf("write %s: %w", hookFile, err)
audit, err := buildKiroIdeHook(kiroIdeAuditName, nodePath, paths.auditPath)
if err != nil {
return err
}
fmt.Printf("Installed Elydora hook for Kiro IDE at %s\n", hookFile)
rendered, err := renderKiroIdeDocument(
sources.document,
append(hooks, guard, audit),
)
if err != nil {
return err
}
prepared, err := prepareKiroIdeInstallation(config, sources, paths, rendered)
if err != nil {
return err
}
if err := commitKiroIdeInstallation(prepared, p.rename); err != nil {
return err
}
fmt.Printf("Kiro IDE workspace hooks: %s\n", sources.paths.configPath)
fmt.Println("Kiro IDE verification: confirm both Elydora entries in the Agent Hooks panel.")
return nil
}
func buildKiroIdeHookFile(scriptPath, guardPath string) string {
hookConfig := map[string]interface{}{
"name": "Elydora Audit",
"hooks": map[string]interface{}{
"pre_tool_use": map[string]interface{}{
"command": "node " + guardPath,
"timeout_ms": 5000,
},
"post_tool_use": map[string]interface{}{
"command": "node " + scriptPath,
"timeout_ms": 5000,
},
},
}
encoded, _ := json.MarshalIndent(hookConfig, "", " ")
return string(encoded) + "\n"
}
func (p *KiroIdePlugin) Uninstall(agentID string) error {
hookFile, err := p.configPath()
sources, err := readKiroIdeSources()
if err != nil {
return err
}
if err := os.Remove(hookFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove %s: %w", hookFile, err)
rendered, err := renderKiroIdeDocument(
sources.document,
withoutManagedKiroIdeHooks(sources.document.hooks, agentID),
)
if err != nil {
return err
}
if agentID != "" {
scriptPath, _ := hookScriptPath(agentID)
if scriptPath != "" {
os.Remove(scriptPath)
}
gPath, _ := guardScriptPath(agentID)
if gPath != "" {
os.Remove(gPath)
}
prepared, err := prepareKiroIdeUninstall(sources, rendered, agentID)
if err != nil {
return err
}
fmt.Println("Uninstalled Elydora hook for Kiro IDE.")
if err := commitKiroIdeUninstall(prepared, p.rename); err != nil {
return err
}
fmt.Println("Uninstalled Elydora hooks from Kiro IDE.")
return nil
}
func (p *KiroIdePlugin) Status() (PluginStatus, error) {
hookFile, err := p.configPath()
if err != nil {
return PluginStatus{}, err
}
sources, err := readKiroIdeSources()
entry := SupportedAgents[kiroIdeAgentKey]
status := PluginStatus{
AgentName: "kiroide",
DisplayName: "Kiro IDE",
ConfigPath: hookFile,
AgentName: kiroIdeAgentKey,
DisplayName: entry.Name,
ConfigPath: sourcesPathOrEmpty(sources),
}
// Check if hook file exists and contains both pre_tool_use and post_tool_use
data, err := os.ReadFile(hookFile)
if err == nil {
var config map[string]interface{}
if json.Unmarshal(data, &config) == nil {
hooks, _ := config["hooks"].(map[string]interface{})
if hooks != nil {
_, hasPre := hooks["pre_tool_use"]
_, hasPost := hooks["post_tool_use"]
status.HookConfigured = hasPre && hasPost
// Extract hook script path from the post_tool_use command
if postHook, ok := hooks["post_tool_use"].(map[string]interface{}); ok {
if cmd, _ := postHook["command"].(string); cmd != "" {
scriptPath := extractPathFromNodeCommand(cmd)
if scriptPath != "" {
if _, err := os.Stat(scriptPath); err == nil {
status.HookScriptExists = true
}
}
}
}
}
}
if err != nil {
return status, err
}
status.Installed = status.HookConfigured && status.HookScriptExists
if err := requireAvailableKiroIdeHooks(sources.document.hooks); err != nil {
return status, err
}
contracts, err := kiroIdeRuntimeContracts(sources.document.hooks)
if err != nil {
return status, err
}
status.HookConfigured = len(contracts) > 0
if !status.HookConfigured {
return status, nil
}
status.HookScriptExists, err = kiroIdeRuntimeFilesExist(contracts)
if err != nil {
return status, err
}
status.Installed = status.HookScriptExists
return status, nil
}
func sourcesPathOrEmpty(sources *kiroIdeSources) string {
if sources == nil {
return ""
}
return sources.paths.configPath
}
+134
View File
@@ -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
}
+460
View File
@@ -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
}
+450
View File
@@ -0,0 +1,450 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type kiroIdeRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
initialDirectories []kiroIdeDirectoryIdentity
}
type kiroIdePreparedTransaction struct {
operation string
paths *kiroIdePaths
runtimePaths *kiroIdeRuntimePaths
changes []*fileChange
preconditions []filePrecondition
removeLegacy bool
initialDirectories []kiroIdeDirectoryIdentity
}
func validateKiroIdeInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != kiroIdeAgentKey {
return fmt.Errorf(
"Kiro IDE installation requires agent name %s",
kiroIdeAgentKey,
)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func kiroIdeAgentPaths(config InstallConfig) (*kiroIdeRuntimePaths, error) {
if err := validateKiroIdeInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &kiroIdeRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, kiroIdeGuardScript),
auditPath: filepath.Join(agentDirectory, kiroIdeAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameKiroIdePath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"guard runtime must use the managed Elydora agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameKiroIdePath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"audit runtime must use the managed Elydora agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightKiroIdeInstallation(
config InstallConfig,
) (*kiroIdeRuntimePaths, string, error) {
paths, err := kiroIdeAgentPaths(config)
if err != nil {
return nil, "", err
}
initialDirectories := make([]kiroIdeDirectoryIdentity, 0, 3)
for _, item := range []struct{ path, label string }{
{filepath.Dir(paths.runtimeRoot), "home directory"},
{paths.runtimeRoot, "Elydora runtime directory"},
{paths.agentDirectory, "Elydora agent runtime directory"},
} {
identity, err := snapshotKiroIdeDirectory(item.path, item.label)
if err != nil {
return nil, "", err
}
initialDirectories = append(initialDirectories, identity)
}
if initialDirectories[0].info == nil {
return nil, "", fmt.Errorf(
"home directory is missing: %s",
initialDirectories[0].path,
)
}
if err := validateKiroIdeRuntimeIdentity(
paths.agentDirectory,
config.AgentID,
); err != nil {
return nil, "", err
}
if err := assertKiroIdeDirectoryStates(
initialDirectories,
"Kiro IDE runtime preflight",
); err != nil {
return nil, "", err
}
paths.initialDirectories = initialDirectories
nodePath, err := resolveNodeRuntime()
if err != nil {
return nil, "", err
}
return paths, nodePath, nil
}
func buildKiroIdeRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID, "agent_id": config.AgentID,
"kid": config.KID, "base_url": config.BaseURL,
"agent_name": kiroIdeAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func prepareKiroIdeFile(
filePath string,
label string,
contents []byte,
mode os.FileMode,
maximumBytes int64,
) (*fileChange, *filePrecondition, error) {
snapshot, err := readManagedFile(filePath, label, maximumBytes)
if err != nil {
return nil, nil, err
}
change, err := prepareSnapshotSourceChange(
filePath,
label,
snapshot,
contents,
mode,
false,
)
if err != nil {
return nil, nil, err
}
if change != nil {
return change, nil, nil
}
condition := &filePrecondition{
filePath: filePath, label: label, snapshot: snapshot,
maximumSize: maximumBytes,
}
return nil, condition, nil
}
func appendKiroIdeChange(
prepared *kiroIdePreparedTransaction,
change *fileChange,
condition *filePrecondition,
) {
if change != nil {
prepared.changes = append(prepared.changes, change)
}
if condition != nil {
prepared.preconditions = append(prepared.preconditions, *condition)
}
}
func prepareKiroIdeInstallation(
config InstallConfig,
sources *kiroIdeSources,
paths *kiroIdeRuntimePaths,
rendered *kiroIdeRenderedDocument,
) (*kiroIdePreparedTransaction, error) {
if sources == nil || paths == nil || rendered == nil {
return nil, fmt.Errorf("Kiro IDE installation requires prepared sources")
}
runtimeConfig, err := buildKiroIdeRuntimeConfig(config)
if err != nil {
return nil, err
}
prepared := &kiroIdePreparedTransaction{
operation: "installation", paths: sources.paths, runtimePaths: paths,
initialDirectories: append(
append([]kiroIdeDirectoryIdentity(nil), sources.initialDirectories...),
paths.initialDirectories...,
),
}
for _, item := range []struct {
path, label string
contents []byte
mode os.FileMode
maximum int64
}{
{
paths.guardPath, "Elydora guard runtime",
[]byte(generateGuardScript(kiroIdeAgentKey, config.AgentID, "", false, "")),
0700, maxManagedSourceBytes,
},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600, maxRuntimeConfigBytes},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600, maxProtectedSecretBytes},
{
paths.auditPath, "Elydora audit runtime",
[]byte(buildHookScriptWithOutput(
kiroIdeAgentKey, config.AgentID, "", false, true,
)),
0700, maxManagedSourceBytes,
},
} {
change, condition, err := prepareKiroIdeFile(
item.path, item.label, item.contents, item.mode, item.maximum,
)
if err != nil {
return nil, err
}
appendKiroIdeChange(prepared, change, condition)
}
documentChange, err := prepareRenderedKiroIdeChange(rendered)
if err != nil {
return nil, err
}
if documentChange == nil {
prepared.preconditions = append(prepared.preconditions, filePrecondition{
filePath: sources.document.filePath, label: "Kiro IDE hooks",
snapshot: sources.document.snapshot,
})
} else {
prepared.changes = append(prepared.changes, documentChange)
}
removeLegacy := sources.legacy.contract != nil &&
sameKiroIdeAgentID(sources.legacy.contract.agentID, config.AgentID)
if removeLegacy {
legacyChange, err := prepareSnapshotSourceChange(
sources.legacy.filePath,
"legacy Kiro IDE hook",
sources.legacy.snapshot,
nil,
0600,
true,
)
if err != nil {
return nil, err
}
if legacyChange == nil {
return nil, fmt.Errorf(
"legacy Kiro IDE hook disappeared before migration: %s",
sources.legacy.filePath,
)
}
prepared.changes = append(prepared.changes, legacyChange)
prepared.removeLegacy = true
} else {
prepared.preconditions = append(prepared.preconditions, filePrecondition{
filePath: sources.legacy.filePath, label: "legacy Kiro IDE hook",
snapshot: sources.legacy.snapshot,
})
}
if err := assertKiroIdeDirectoryStates(
prepared.initialDirectories,
"Kiro IDE installation preparation",
); err != nil {
return nil, err
}
return prepared, nil
}
func prepareKiroIdeUninstall(
sources *kiroIdeSources,
rendered *kiroIdeRenderedDocument,
agentID string,
) (*kiroIdePreparedTransaction, error) {
prepared := &kiroIdePreparedTransaction{
operation: "uninstall", paths: sources.paths,
initialDirectories: append(
[]kiroIdeDirectoryIdentity(nil),
sources.initialDirectories...,
),
}
documentChange, err := prepareRenderedKiroIdeChange(rendered)
if err != nil {
return nil, err
}
if documentChange == nil {
prepared.preconditions = append(prepared.preconditions, filePrecondition{
filePath: sources.document.filePath, label: "Kiro IDE hooks",
snapshot: sources.document.snapshot,
})
} else {
prepared.changes = append(prepared.changes, documentChange)
}
removeLegacy := sources.legacy.contract != nil &&
(agentID == "" || sameKiroIdeAgentID(sources.legacy.contract.agentID, agentID))
if removeLegacy {
change, err := prepareSnapshotSourceChange(
sources.legacy.filePath,
"legacy Kiro IDE hook",
sources.legacy.snapshot,
nil,
0600,
true,
)
if err != nil {
return nil, err
}
if change != nil {
prepared.changes = append(prepared.changes, change)
prepared.removeLegacy = true
}
} else {
prepared.preconditions = append(prepared.preconditions, filePrecondition{
filePath: sources.legacy.filePath, label: "legacy Kiro IDE hook",
snapshot: sources.legacy.snapshot,
})
}
if err := assertKiroIdeDirectoryStates(
prepared.initialDirectories,
"Kiro IDE uninstall preparation",
); err != nil {
return nil, err
}
return prepared, nil
}
func commitKiroIdeTransaction(
prepared *kiroIdePreparedTransaction,
rename renameFunc,
) error {
if prepared == nil {
return fmt.Errorf("Kiro IDE transaction is missing")
}
operation := "Kiro IDE " + prepared.operation
if err := assertKiroIdeDirectoryStates(
prepared.initialDirectories,
operation,
); err != nil {
return err
}
workspaceChange := false
for _, change := range prepared.changes {
if change != nil && sameKiroIdePath(change.filePath, prepared.paths.configPath) {
workspaceChange = true
break
}
}
directories := make([]kiroIdeDirectoryIdentity, 0, len(prepared.initialDirectories))
for _, directory := range prepared.initialDirectories {
if directory.info != nil {
directories = appendKiroIdeDirectory(directories, directory)
}
}
establish := func(path string, private bool) error {
if err := assertKiroIdeDirectoryStates(directories, operation); err != nil {
return err
}
initial, err := kiroIdeInitialDirectory(prepared, path)
if err != nil {
return err
}
current, err := establishKiroIdeDirectory(initial, operation, private)
if err != nil {
return err
}
directories = appendKiroIdeDirectory(directories, current)
return assertKiroIdeDirectoryStates(directories, operation)
}
if workspaceChange {
if err := establish(prepared.paths.kiroDirectory, false); err != nil {
return err
}
if err := establish(prepared.paths.hooksDirectory, false); err != nil {
return err
}
}
if prepared.runtimePaths != nil {
if err := establish(prepared.runtimePaths.runtimeRoot, true); err != nil {
return err
}
if err := establish(prepared.runtimePaths.agentDirectory, true); err != nil {
return err
}
}
preconditions := append([]filePrecondition(nil), prepared.preconditions...)
preconditions = append(preconditions, kiroIdeDirectoryPreconditions(directories)...)
return writeChangesWithFileOps(
prepared.changes,
operation,
guardedKiroIdeTransactionOps(rename, directories),
preconditions...,
)
}
func commitKiroIdeInstallation(
prepared *kiroIdePreparedTransaction,
rename renameFunc,
) error {
return commitKiroIdeTransaction(prepared, rename)
}
func commitKiroIdeUninstall(
prepared *kiroIdePreparedTransaction,
rename renameFunc,
) error {
return commitKiroIdeTransaction(prepared, rename)
}
+192
View File
@@ -0,0 +1,192 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
)
type kiroIdePaths struct {
homeDirectory string
workspaceRoot string
kiroDirectory string
hooksDirectory string
configPath string
legacyPath string
}
type kiroIdeSources struct {
paths *kiroIdePaths
document *kiroIdeDocument
legacy *kiroIdeLegacyDocument
initialDirectories []kiroIdeDirectoryIdentity
}
func resolveKiroIdePaths() (*kiroIdePaths, error) {
workspace, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("resolve Kiro IDE workspace: %w", err)
}
workspace, err = filepath.Abs(workspace)
if err != nil {
return nil, fmt.Errorf("resolve Kiro IDE workspace path: %w", err)
}
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("resolve home directory: %w", err)
}
kiroDirectory := filepath.Join(workspace, ".kiro")
hooksDirectory := filepath.Join(kiroDirectory, "hooks")
return &kiroIdePaths{
homeDirectory: home, workspaceRoot: workspace,
kiroDirectory: kiroDirectory, hooksDirectory: hooksDirectory,
configPath: filepath.Join(hooksDirectory, kiroIdeConfigFile),
legacyPath: filepath.Join(home, ".kiro", "hooks", kiroIdeLegacyFile),
}, nil
}
func inspectKiroIdeWorkspace(paths *kiroIdePaths) error {
exists, err := managedPhysicalDirectoryExists(
paths.workspaceRoot,
"Kiro IDE workspace",
)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("Kiro IDE workspace is missing: %s", paths.workspaceRoot)
}
if _, err := managedPhysicalDirectoryExists(
paths.kiroDirectory,
"Kiro IDE configuration directory",
); err != nil {
return err
}
_, err = managedPhysicalDirectoryExists(
paths.hooksDirectory,
"Kiro IDE hooks directory",
)
return err
}
func inspectKiroIdeLegacyDirectories(paths *kiroIdePaths) error {
kiroDirectory := filepath.Dir(filepath.Dir(paths.legacyPath))
hooksDirectory := filepath.Dir(paths.legacyPath)
for _, item := range []struct{ path, label string }{
{kiroDirectory, "legacy Kiro IDE configuration directory"},
{hooksDirectory, "legacy Kiro IDE hooks directory"},
} {
exists, err := managedPhysicalDirectoryExists(item.path, item.label)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("%s is missing: %s", item.label, item.path)
}
}
return nil
}
func readKiroIdeSources() (*kiroIdeSources, error) {
paths, err := resolveKiroIdePaths()
if err != nil {
return nil, err
}
initialDirectories := make([]kiroIdeDirectoryIdentity, 0, 5)
for _, item := range []struct{ path, label string }{
{paths.workspaceRoot, "Kiro IDE workspace"},
{paths.kiroDirectory, "Kiro IDE configuration directory"},
{paths.hooksDirectory, "Kiro IDE hooks directory"},
} {
identity, err := snapshotKiroIdeDirectory(item.path, item.label)
if err != nil {
return nil, err
}
initialDirectories = append(initialDirectories, identity)
}
if err := inspectKiroIdeWorkspace(paths); err != nil {
return nil, err
}
workspaceSnapshot, err := readManagedFile(
paths.configPath,
"Kiro IDE hooks",
maxManagedSourceBytes,
)
if err != nil {
return nil, err
}
document, err := parseKiroIdeDocument(paths.configPath, workspaceSnapshot)
if err != nil {
return nil, err
}
legacySnapshot, err := readManagedFile(
paths.legacyPath,
"legacy Kiro IDE hook",
maxManagedSourceBytes,
)
if err != nil {
return nil, err
}
if legacySnapshot != nil {
if err := inspectKiroIdeLegacyDirectories(paths); err != nil {
return nil, err
}
for _, item := range []struct{ path, label string }{
{
filepath.Dir(filepath.Dir(paths.legacyPath)),
"legacy Kiro IDE configuration directory",
},
{filepath.Dir(paths.legacyPath), "legacy Kiro IDE hooks directory"},
} {
identity, err := snapshotKiroIdeDirectory(item.path, item.label)
if err != nil {
return nil, err
}
initialDirectories = append(initialDirectories, identity)
}
current, err := readManagedFile(
paths.legacyPath,
"legacy Kiro IDE hook",
maxManagedSourceBytes,
)
if err != nil {
return nil, err
}
if !sameManagedSnapshot(current, legacySnapshot) {
return nil, fmt.Errorf(
"legacy Kiro IDE hook changed while reading: %s",
paths.legacyPath,
)
}
}
legacy, err := parseKiroIdeLegacyDocument(paths.legacyPath, legacySnapshot)
if err != nil {
return nil, err
}
if err := assertKiroIdeDirectoryStates(
initialDirectories,
"Kiro IDE source read",
); err != nil {
return nil, err
}
return &kiroIdeSources{
paths: paths, document: document, legacy: legacy,
initialDirectories: initialDirectories,
}, nil
}
func prepareRenderedKiroIdeChange(
rendered *kiroIdeRenderedDocument,
) (*fileChange, error) {
if rendered == nil || !rendered.changed {
return nil, nil
}
return prepareSnapshotSourceChange(
rendered.document.filePath,
"Kiro IDE hooks",
rendered.document.snapshot,
rendered.next,
0600,
rendered.remove,
)
}
+277
View File
@@ -0,0 +1,277 @@
package plugins
import (
"bytes"
"fmt"
"path/filepath"
"runtime"
"sort"
"strings"
)
func readKiroIdeRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireKiroIdeRuntimeString(
config map[string]any,
field string,
configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"Elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateKiroIdeRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet(
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`Elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireKiroIdeRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireKiroIdeRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireKiroIdeRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameKiroIdeAgentID(agentID, expectedAgentID) ||
config["agent_name"] != kiroIdeAgentKey {
return fmt.Errorf(
"Elydora runtime identity does not match Kiro IDE hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireKiroIdeRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireKiroIdeRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"Elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateKiroIdeRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readKiroIdeRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, kiroIdeGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, kiroIdeAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != kiroIdeAgentKey ||
!sameKiroIdeAgentID(configuredID, agentID) {
return fmt.Errorf(
"Elydora runtime config identity does not match Kiro IDE agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func kiroIdeRuntimeFileMode(snapshot *managedFileSnapshot, expected uint32) bool {
if runtime.GOOS == "windows" {
return true
}
return uint32(snapshot.mode.Perm()) == expected
}
func kiroIdeRuntimeContractExists(contract kiroIdeRuntimeContract) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
if !sameKiroIdePath(filepath.Dir(agentDirectory), runtimeRoot) ||
!sameKiroIdeAgentID(filepath.Base(agentDirectory), contract.agentID) ||
!sameKiroIdePath(
contract.guardPath,
filepath.Join(agentDirectory, kiroIdeGuardScript),
) ||
!sameKiroIdePath(
contract.auditPath,
filepath.Join(agentDirectory, kiroIdeAuditScript),
) {
return false, nil
}
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
configSnapshot, err := readManagedFile(
configPath,
"Elydora runtime config",
maxRuntimeConfigBytes,
)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(
contract.guardPath,
"Elydora guard runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
audit, err := readManagedFile(
contract.auditPath,
"Elydora audit runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
if configSnapshot == nil || key == nil || guard == nil || audit == nil {
return false, nil
}
config, err := decodeStrictJSONObject(
configSnapshot.contents,
fmt.Sprintf("Elydora runtime config at %s", configPath),
)
if err != nil {
return false, err
}
if err := validateKiroIdeRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
}
if !kiroIdeRuntimeFileMode(configSnapshot, 0600) ||
!kiroIdeRuntimeFileMode(key, 0600) ||
!kiroIdeRuntimeFileMode(guard, 0700) ||
!kiroIdeRuntimeFileMode(audit, 0700) {
return false, nil
}
expectedGuard := []byte(generateGuardScript(
kiroIdeAgentKey,
contract.agentID,
"",
false,
"",
))
expectedAudit := []byte(buildHookScriptWithOutput(
kiroIdeAgentKey,
contract.agentID,
"",
false,
true,
))
return bytes.Equal(guard.contents, expectedGuard) &&
bytes.Equal(audit.contents, expectedAudit), nil
}
func kiroIdeRuntimeFilesExist(contracts []kiroIdeRuntimeContract) (bool, error) {
for _, contract := range contracts {
exists, err := kiroIdeRuntimeContractExists(contract)
if err != nil {
return false, err
}
if exists {
return true, nil
}
}
return false, nil
}
+450
View File
@@ -0,0 +1,450 @@
package plugins
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
)
func TestKiroIdeRegistryFactoryAndRuntimeOwnership(t *testing.T) {
entry := SupportedAgents[kiroIdeAgentKey]
if entry.Name != "Kiro IDE" || entry.ConfigDir != ".kiro/hooks" ||
entry.ConfigFile != kiroIdeConfigFile {
t.Fatalf("Kiro IDE registry entry = %#v", entry)
}
plugin, ok := NewPlugin(kiroIdeAgentKey).(*KiroIdePlugin)
if !ok || !plugin.ManagesGuardRuntime() {
t.Fatalf("Kiro IDE plugin = %#v", NewPlugin(kiroIdeAgentKey))
}
}
func TestKiroIdeInstallUsesWorkspaceV1ContractAndIsIdempotent(t *testing.T) {
userHook := map[string]any{
"name": "workspace-context", "trigger": "SessionStart",
"action": map[string]any{"type": "agent", "prompt": "Read AGENTS.md"},
"enabled": true,
}
existing := map[string]any{
"version": "v1", "owner": "workspace", "hooks": []any{userHook},
}
raw, _ := json.Marshal(existing)
fixture := prepareKiroIdeFixture(t, kiroIdeString(string(raw)), nil)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Kiro IDE hooks: %v", err)
}
first, err := os.ReadFile(fixture.configPath)
if err != nil {
t.Fatalf("read Kiro IDE hooks: %v", err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repeat Kiro IDE install: %v", err)
}
second, err := os.ReadFile(fixture.configPath)
if err != nil || string(second) != string(first) {
t.Fatalf("idempotent Kiro IDE source changed: %v", err)
}
document := readKiroIdeObject(t, fixture.configPath)
if document["version"] != "v1" || document["owner"] != "workspace" {
t.Fatalf("Kiro IDE root changed: %#v", document)
}
if !reflect.DeepEqual(requireArray(t, document["hooks"])[0], userHook) {
t.Fatalf("user hook changed: %#v", requireArray(t, document["hooks"])[0])
}
for _, item := range []struct{ name, trigger string }{
{kiroIdeGuardName, "PreToolUse"},
{kiroIdeAuditName, "PostToolUse"},
} {
hook := findKiroIdeHook(t, document, item.name)
if hook["trigger"] != item.trigger || hook["matcher"] != ".*" ||
hook["timeout"] != float64(10) || hook["enabled"] != true ||
requireObject(t, hook["action"])["type"] != "command" {
t.Fatalf("%s hook = %#v", item.name, hook)
}
command := kiroIdeHookCommand(t, hook)
if runtime.GOOS == "windows" && !strings.Contains(command, " -EncodedCommand ") {
t.Fatalf("Windows Kiro IDE command is not encoded PowerShell: %q", command)
}
}
runtimeConfig := readKiroIdeObject(t, fixture.runtimeConfig)
if runtimeConfig["agent_name"] != kiroIdeAgentKey ||
runtimeConfig["agent_id"] != kiroIdeTestAgentID ||
runtimeConfig["org_id"] != "org-1" || runtimeConfig["kid"] != "kid-1" {
t.Fatalf("runtime config = %#v", runtimeConfig)
}
key, err := os.ReadFile(fixture.privateKey)
if err != nil || string(key) != kiroIdePrivateKey {
t.Fatalf("private key = %q, %v", key, err)
}
if _, err := os.Lstat(filepath.Join(fixture.homeDir, ".kiro", "hooks", kiroIdeConfigFile)); !os.IsNotExist(err) {
t.Fatalf("global Kiro IDE hook file exists: %v", err)
}
assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir)
assertNoKiroIdeTransactionArtifacts(t, fixture.workspace)
}
func TestKiroIdeCommandsBlockAndPreserveNativePayload(t *testing.T) {
fixture := prepareKiroIdeFixture(t, nil, nil)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Kiro IDE hooks: %v", err)
}
document := readKiroIdeObject(t, fixture.configPath)
cache := map[string]any{
"status": "frozen", "cached_at": float64(time.Now().UnixMilli()),
}
writeKiroIdeJSON(t, filepath.Join(fixture.agentDir, "status-cache.json"), cache)
payload := map[string]any{
"hook_event_name": "preToolUse", "cwd": fixture.workspace,
"session_id": "session-1", "tool_name": "execute_bash",
"tool_input": map[string]any{"command": "go test ./..."},
"future_field": map[string]any{"retained": true},
}
encoded, _ := json.Marshal(payload)
exitCode, stdout, stderr := runKiroIdeCommand(
t,
kiroIdeHookCommand(t, findKiroIdeHook(t, document, kiroIdeGuardName)),
fixture,
encoded,
)
if exitCode != 2 || stdout != "" || !strings.Contains(stderr, "Tool execution blocked") {
t.Fatalf("guard result = %d, %q, %q", exitCode, stdout, stderr)
}
capturePath := filepath.Join(fixture.workspace, "captured-event.json")
captureJSON, _ := json.Marshal(capturePath)
captureScript := "const fs=require('node:fs');const chunks=[];" +
"process.stdin.on('data',c=>chunks.push(c));" +
"process.stdin.on('end',()=>fs.writeFileSync(" + string(captureJSON) + ",Buffer.concat(chunks)));\n"
if err := os.WriteFile(fixture.auditPath, []byte(captureScript), 0700); err != nil {
t.Fatalf("replace audit runtime with capture: %v", err)
}
payload["hook_event_name"] = "postToolUse"
payload["tool_response"] = map[string]any{"success": true, "result": "ok"}
encoded, _ = json.Marshal(payload)
exitCode, stdout, stderr = runKiroIdeCommand(
t,
kiroIdeHookCommand(t, findKiroIdeHook(t, document, kiroIdeAuditName)),
fixture,
encoded,
)
if exitCode != 0 || stdout != "" || stderr != "" {
t.Fatalf("audit result = %d, %q, %q", exitCode, stdout, stderr)
}
captured, err := os.ReadFile(capturePath)
if err != nil || string(captured) != string(encoded) {
t.Fatalf("captured Kiro IDE payload = %q, %v", captured, err)
}
}
func TestKiroIdeStatusRequiresUniqueExactPairAndRuntimeIntegrity(t *testing.T) {
fixture := prepareKiroIdeFixture(t, nil, nil)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Kiro IDE hooks: %v", err)
}
assertStatus := func(installed, configured bool) {
t.Helper()
status, err := fixture.plugin.Status()
if err != nil || status.Installed != installed ||
status.HookConfigured != configured {
t.Fatalf("Kiro IDE status = %#v, %v", status, err)
}
}
assertStatus(true, true)
document := readKiroIdeObject(t, fixture.configPath)
findKiroIdeHook(t, document, kiroIdeGuardName)["enabled"] = false
writeKiroIdeJSON(t, fixture.configPath, document)
assertStatus(false, false)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repair Kiro IDE hooks: %v", err)
}
document = readKiroIdeObject(t, fixture.configPath)
document["hooks"] = append(
requireArray(t, document["hooks"]),
cloneKiroIdeObject(findKiroIdeHook(t, document, kiroIdeGuardName)),
)
writeKiroIdeJSON(t, fixture.configPath, document)
assertStatus(false, false)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repair duplicate Kiro IDE hooks: %v", err)
}
if err := os.WriteFile(fixture.guardPath, []byte("tampered\n"), 0700); err != nil {
t.Fatalf("tamper guard runtime: %v", err)
}
assertStatus(false, true)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repair Kiro IDE runtime: %v", err)
}
validRuntimeConfig, err := os.ReadFile(fixture.runtimeConfig)
if err != nil {
t.Fatalf("read valid runtime config: %v", err)
}
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
t.Fatalf("corrupt runtime config: %v", err)
}
if _, err := fixture.plugin.Status(); err == nil ||
!strings.Contains(err.Error(), "parse Elydora runtime config") {
t.Fatalf("malformed runtime status error = %v", err)
}
if err := os.WriteFile(fixture.runtimeConfig, validRuntimeConfig, 0600); err != nil {
t.Fatalf("restore runtime config: %v", err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repair runtime config: %v", err)
}
if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil {
t.Fatalf("corrupt private key: %v", err)
}
if _, err := fixture.plugin.Status(); err == nil ||
!strings.Contains(err.Error(), "canonical 32-byte") {
t.Fatalf("invalid private key status error = %v", err)
}
}
func TestKiroIdeStatusRejectsExtraOrphanAndStaleRuntimeCommands(t *testing.T) {
for _, testCase := range []struct {
name string
mutate func(*testing.T, *kiroIdeFixture, map[string]any)
}{
{
name: "extra complete pair",
mutate: func(t *testing.T, fixture *kiroIdeFixture, document map[string]any) {
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js: %v", err)
}
agentDirectory := filepath.Join(fixture.homeDir, ".elydora", "agent-2")
guard, _ := buildKiroIdeHook(
kiroIdeGuardName,
nodePath,
filepath.Join(agentDirectory, kiroIdeGuardScript),
)
audit, _ := buildKiroIdeHook(
kiroIdeAuditName,
nodePath,
filepath.Join(agentDirectory, kiroIdeAuditScript),
)
document["hooks"] = append(requireArray(t, document["hooks"]), guard, audit)
},
},
{
name: "orphan guard",
mutate: func(t *testing.T, fixture *kiroIdeFixture, document map[string]any) {
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js: %v", err)
}
guard, _ := buildKiroIdeHook(
kiroIdeGuardName,
nodePath,
filepath.Join(
fixture.homeDir,
".elydora",
"agent-2",
kiroIdeGuardScript,
),
)
document["hooks"] = append(requireArray(t, document["hooks"]), guard)
},
},
{
name: "split agent pair",
mutate: func(t *testing.T, fixture *kiroIdeFixture, document map[string]any) {
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js: %v", err)
}
audit, _ := buildKiroIdeHook(
kiroIdeAuditName,
nodePath,
filepath.Join(
fixture.homeDir,
".elydora",
"agent-2",
kiroIdeAuditScript,
),
)
hooks := requireArray(t, document["hooks"])
for index, value := range hooks {
if requireObject(t, value)["name"] == kiroIdeAuditName {
hooks[index] = audit
}
}
},
},
{
name: "stale Node runtime",
mutate: func(t *testing.T, fixture *kiroIdeFixture, document map[string]any) {
name := "node"
if runtime.GOOS == "windows" {
name = "node.exe"
}
staleNode := filepath.Join(fixture.homeDir, "old-node", name)
command, err := buildKiroIdeCommand(staleNode, fixture.guardPath)
if err != nil {
t.Fatalf("build stale Kiro IDE command: %v", err)
}
requireObject(
t,
findKiroIdeHook(t, document, kiroIdeGuardName)["action"],
)["command"] = command
},
},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareKiroIdeFixture(t, nil, nil)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Kiro IDE hooks: %v", err)
}
document := readKiroIdeObject(t, fixture.configPath)
testCase.mutate(t, fixture, document)
writeKiroIdeJSON(t, fixture.configPath, document)
status, err := fixture.plugin.Status()
if err != nil || status.Installed || status.HookConfigured {
t.Fatalf("mutated Kiro IDE status = %#v, %v", status, err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repair Kiro IDE command contract: %v", err)
}
status, err = fixture.plugin.Status()
if err != nil || !status.Installed {
t.Fatalf("repaired Kiro IDE status = %#v, %v", status, err)
}
})
}
}
func TestKiroIdeStatusRejectsStaleWindowsPowerShellLauncher(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows PowerShell launcher contract")
}
fixture := prepareKiroIdeFixture(t, nil, nil)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Kiro IDE hooks: %v", err)
}
document := readKiroIdeObject(t, fixture.configPath)
guard := findKiroIdeHook(t, document, kiroIdeGuardName)
action := requireObject(t, guard["action"])
command := action["command"].(string)
action["command"] = strings.Replace(
command,
codexPowerShellPath(),
`C:\StaleWindows\System32\WindowsPowerShell\v1.0\powershell.exe`,
1,
)
writeKiroIdeJSON(t, fixture.configPath, document)
status, err := fixture.plugin.Status()
if err != nil || status.Installed || status.HookConfigured {
t.Fatalf("stale PowerShell status = %#v, %v", status, err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repair PowerShell launcher: %v", err)
}
status, err = fixture.plugin.Status()
if err != nil || !status.Installed {
t.Fatalf("repaired PowerShell status = %#v, %v", status, err)
}
}
func TestKiroIdeRejectsMalformedContractsAndNameCollisionsBeforeWrites(t *testing.T) {
for _, testCase := range []struct{ name, source, want string }{
{"malformed", "{ malformed", "parse Kiro IDE hooks"},
{"duplicate", `{"version":"v1","version":"v1","hooks":[]}`, "duplicate"},
{"version", `{"version":"v0","hooks":[]}`, `version must be "v1"`},
{"hooks", `{"version":"v1","hooks":null}`, `field "hooks" must be an array`},
{"trigger", `{"version":"v1","hooks":[{"name":"bad","trigger":"FutureEvent","action":{"type":"command","command":"x"}}]}`, "unsupported trigger"},
{"action", `{"version":"v1","hooks":[{"name":"bad","trigger":"PreToolUse","action":{"type":"future"}}]}`, "unsupported type"},
{"timeout", `{"version":"v1","hooks":[{"name":"bad","trigger":"PreToolUse","timeout":1.5,"action":{"type":"command","command":"x"}}]}`, "non-negative integer"},
{"collision", `{"version":"v1","hooks":[{"name":"elydora-guard","trigger":"PreToolUse","action":{"type":"command","command":"user-command"}}]}`, "conflicts with the Elydora contract"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareKiroIdeFixture(t, &testCase.source, nil)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v, want %q", err, testCase.want)
}
actual, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(actual) != testCase.source {
t.Fatalf("invalid source changed: %q, %v", actual, readErr)
}
assertNoKiroIdeRuntimeFiles(t, fixture)
})
}
}
func TestKiroIdeMigratesOnlyExactLegacyOwnership(t *testing.T) {
fixture := prepareKiroIdeFixture(t, nil, nil)
writeKiroIdeJSON(
t,
fixture.legacyPath,
legacyKiroIdeGoDocument(fixture, kiroIdeTestAgentID),
)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("migrate legacy Kiro IDE hook: %v", err)
}
if _, err := os.Lstat(fixture.legacyPath); !os.IsNotExist(err) {
t.Fatalf("legacy Kiro IDE hook remains: %v", err)
}
lookalike := legacyKiroIdeGoDocument(fixture, kiroIdeTestAgentID)
lookalike["owner"] = "user"
writeKiroIdeJSON(t, fixture.legacyPath, lookalike)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install with legacy lookalike: %v", err)
}
remainingLookalike := readKiroIdeObject(t, fixture.legacyPath)
if remainingLookalike["owner"] != "user" || remainingLookalike["name"] != "Elydora Audit" {
t.Fatalf("legacy ownership lookalike changed: %#v", remainingLookalike)
}
if err := os.WriteFile(fixture.legacyPath, []byte("{ malformed"), 0600); err != nil {
t.Fatalf("write malformed legacy hook: %v", err)
}
if err := fixture.plugin.Install(fixture.config); err == nil ||
!strings.Contains(err.Error(), "parse legacy Kiro IDE hook") {
t.Fatalf("malformed legacy error = %v", err)
}
}
func TestKiroIdeUninstallPreservesWorkspaceHooksAndExactAgentOwnership(t *testing.T) {
userHook := map[string]any{
"name": "workspace-context", "trigger": "SessionStart",
"action": map[string]any{"type": "agent", "prompt": "Read AGENTS.md"},
}
existing := map[string]any{
"version": "v1", "owner": "workspace", "hooks": []any{userHook},
}
raw, _ := json.Marshal(existing)
fixture := prepareKiroIdeFixture(t, kiroIdeString(string(raw)), nil)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Kiro IDE hooks: %v", err)
}
if err := fixture.plugin.Uninstall("agent-2"); err != nil {
t.Fatalf("uninstall unrelated Kiro IDE agent: %v", err)
}
if len(requireArray(t, readKiroIdeObject(t, fixture.configPath)["hooks"])) != 3 {
t.Fatal("unrelated uninstall changed Kiro IDE hooks")
}
if err := fixture.plugin.Uninstall(kiroIdeTestAgentID); err != nil {
t.Fatalf("uninstall Kiro IDE hooks: %v", err)
}
remaining := readKiroIdeObject(t, fixture.configPath)
if remaining["owner"] != "workspace" ||
!reflect.DeepEqual(requireArray(t, remaining["hooks"]), []any{userHook}) {
t.Fatalf("workspace hooks after uninstall = %#v", remaining)
}
owned := prepareKiroIdeFixture(t, nil, nil)
if err := owned.plugin.Install(owned.config); err != nil {
t.Fatalf("install owned Kiro IDE hooks: %v", err)
}
if err := owned.plugin.Uninstall(kiroIdeTestAgentID); err != nil {
t.Fatalf("uninstall owned Kiro IDE hooks: %v", err)
}
if _, err := os.Lstat(owned.configPath); !os.IsNotExist(err) {
t.Fatalf("owned Kiro IDE config remains: %v", err)
}
}
@@ -0,0 +1,271 @@
package plugins
import (
"bytes"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
const (
kiroIdeTestAgentID = "agent-1"
kiroIdePrivateKey = "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws"
)
type kiroIdeFixture struct {
plugin *KiroIdePlugin
config InstallConfig
homeDir string
workspace string
configPath string
legacyPath string
agentDir string
guardPath string
auditPath string
runtimeConfig string
privateKey string
}
func prepareKiroIdeFixture(t *testing.T, workspaceSource, legacySource *string) *kiroIdeFixture {
t.Helper()
root := t.TempDir()
home := filepath.Join(root, "home with spaces and 'quote %KIRO%")
preparePrivateTransactionTestDirectory(t, home)
workspace := filepath.Join(root, "workspace with spaces")
for _, directory := range []string{home, workspace} {
if err := os.MkdirAll(directory, 0700); err != nil {
t.Fatalf("create Kiro IDE fixture directory: %v", err)
}
}
configPath := filepath.Join(workspace, ".kiro", "hooks", kiroIdeConfigFile)
legacyPath := filepath.Join(home, ".kiro", "hooks", kiroIdeLegacyFile)
writeOptionalKiroIdeSource(t, configPath, workspaceSource)
writeOptionalKiroIdeSource(t, legacyPath, legacySource)
previous, err := os.Getwd()
if err != nil {
t.Fatalf("read current directory: %v", err)
}
if err := os.Chdir(workspace); err != nil {
t.Fatalf("enter Kiro IDE workspace: %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(previous); err != nil {
t.Errorf("restore current directory: %v", err)
}
})
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
agentDir := filepath.Join(home, ".elydora", kiroIdeTestAgentID)
guardPath := filepath.Join(agentDir, kiroIdeGuardScript)
return &kiroIdeFixture{
plugin: &KiroIdePlugin{},
config: InstallConfig{
AgentName: kiroIdeAgentKey, OrgID: "org-1", AgentID: kiroIdeTestAgentID,
PrivateKey: kiroIdePrivateKey, KID: "kid-1", Token: "token-1",
BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath,
},
homeDir: home, workspace: workspace, configPath: configPath,
legacyPath: legacyPath, agentDir: agentDir, guardPath: guardPath,
auditPath: filepath.Join(agentDir, kiroIdeAuditScript),
runtimeConfig: filepath.Join(agentDir, "config.json"),
privateKey: filepath.Join(agentDir, "private.key"),
}
}
func kiroIdeString(value string) *string {
return &value
}
func writeOptionalKiroIdeSource(t *testing.T, path string, source *string) {
t.Helper()
if source == nil {
return
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("create source directory: %v", err)
}
if err := os.WriteFile(path, []byte(*source), 0600); err != nil {
t.Fatalf("write Kiro IDE source: %v", err)
}
}
func writeKiroIdeJSON(t *testing.T, path string, value any) {
t.Helper()
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatalf("encode Kiro IDE JSON: %v", err)
}
encoded = append(encoded, '\n')
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("create Kiro IDE JSON directory: %v", err)
}
if err := os.WriteFile(path, encoded, 0600); err != nil {
t.Fatalf("write Kiro IDE JSON: %v", err)
}
}
func readKiroIdeObject(t *testing.T, path string) map[string]any {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var value map[string]any
if err := json.Unmarshal(raw, &value); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return value
}
func findKiroIdeHook(t *testing.T, document map[string]any, name string) map[string]any {
t.Helper()
for _, value := range requireArray(t, document["hooks"]) {
hook := requireObject(t, value)
if hook["name"] == name {
return hook
}
}
t.Fatalf("Kiro IDE hook %q is missing", name)
return nil
}
func kiroIdeHookCommand(t *testing.T, hook map[string]any) string {
t.Helper()
action := requireObject(t, hook["action"])
command, ok := action["command"].(string)
if !ok {
t.Fatalf("Kiro IDE command = %#v", action["command"])
}
return command
}
func legacyKiroIdeGoDocument(fixture *kiroIdeFixture, agentID string) map[string]any {
agentDirectory := filepath.Join(fixture.homeDir, ".elydora", agentID)
return map[string]any{
"name": "Elydora Audit",
"hooks": map[string]any{
"pre_tool_use": map[string]any{
"command": "node " + filepath.Join(agentDirectory, kiroIdeGuardScript),
"timeout_ms": 5000,
},
"post_tool_use": map[string]any{
"command": "node " + filepath.Join(agentDirectory, kiroIdeAuditScript),
"timeout_ms": 5000,
},
},
}
}
func runKiroIdeCommand(
t *testing.T,
command string,
fixture *kiroIdeFixture,
input []byte,
) (int, string, string) {
t.Helper()
var process *exec.Cmd
if runtime.GOOS == "windows" {
commandFile := filepath.Join(t.TempDir(), "run-kiroide-hook.cmd")
if err := os.WriteFile(
commandFile,
[]byte("@echo off\r\n"+command+"\r\n"),
0600,
); err != nil {
t.Fatalf("write Kiro IDE command file: %v", err)
}
process = exec.Command("cmd.exe", "/d", "/c", commandFile)
} else {
process = exec.Command("sh", "-c", command)
}
process.Dir = fixture.workspace
process.Env = append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
)
process.Stdin = bytes.NewReader(input)
var stdout, stderr bytes.Buffer
process.Stdout = &stdout
process.Stderr = &stderr
err := process.Run()
if err == nil {
return 0, stdout.String(), stderr.String()
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return exitError.ExitCode(), stdout.String(), stderr.String()
}
t.Fatalf("run Kiro IDE command: %v", err)
return -1, stdout.String(), stderr.String()
}
func assertNoKiroIdeRuntimeFiles(t *testing.T, fixture *kiroIdeFixture) {
t.Helper()
for _, path := range []string{
fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.auditPath,
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime file exists at %s: %v", path, err)
}
}
}
func assertNoKiroIdeTransactionArtifacts(t *testing.T, root string) {
t.Helper()
if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) {
return
}
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(entry.Name(), ".tmp") ||
strings.HasSuffix(entry.Name(), ".rollback") {
t.Errorf("Kiro IDE transaction artifact remains at %s", path)
}
return nil
})
if err != nil {
t.Fatalf("walk Kiro IDE fixture: %v", err)
}
}
func prepareKiroIdeInstallForTest(
t *testing.T,
fixture *kiroIdeFixture,
) *kiroIdePreparedTransaction {
t.Helper()
sources, err := readKiroIdeSources()
if err != nil {
t.Fatalf("read Kiro IDE sources: %v", err)
}
paths, nodePath, err := preflightKiroIdeInstallation(fixture.config)
if err != nil {
t.Fatalf("preflight Kiro IDE installation: %v", err)
}
guard, err := buildKiroIdeHook(kiroIdeGuardName, nodePath, paths.guardPath)
if err != nil {
t.Fatalf("build Kiro IDE guard: %v", err)
}
audit, err := buildKiroIdeHook(kiroIdeAuditName, nodePath, paths.auditPath)
if err != nil {
t.Fatalf("build Kiro IDE audit: %v", err)
}
rendered, err := renderKiroIdeDocument(
sources.document,
append(withoutManagedKiroIdeHooks(sources.document.hooks, ""), guard, audit),
)
if err != nil {
t.Fatalf("render Kiro IDE hooks: %v", err)
}
prepared, err := prepareKiroIdeInstallation(fixture.config, sources, paths, rendered)
if err != nil {
t.Fatalf("prepare Kiro IDE installation: %v", err)
}
return prepared
}
+258
View File
@@ -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)
}
+9
View File
@@ -13,6 +13,7 @@ type managedFileSnapshot struct {
contents []byte
info os.FileInfo
mode os.FileMode
identity string
}
func readManagedFile(
@@ -51,6 +52,13 @@ func readManagedFile(
file.Close(),
)
}
identity, identityErr := transactionFileIdentity(file, after)
if identityErr != nil {
return nil, errors.Join(
fmt.Errorf("identify %s at %s: %w", label, filePath, identityErr),
file.Close(),
)
}
raw, readErr := io.ReadAll(io.LimitReader(file, maximumBytes+1))
closeErr := file.Close()
if readErr != nil || closeErr != nil {
@@ -70,6 +78,7 @@ func readManagedFile(
contents: raw,
info: after,
mode: after.Mode().Perm(),
identity: identity,
}, nil
}
@@ -58,6 +58,7 @@ func prepareQwenFixture(t *testing.T, options qwenFixtureOptions) *qwenFixture {
t.Helper()
rootDir := t.TempDir()
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
preparePrivateTransactionTestDirectory(t, homeDir)
workspaceDir := filepath.Join(homeDir, "workspace")
qwenDir := filepath.Join(homeDir, ".qwen")
configPath := filepath.Join(qwenDir, "settings.json")
+1 -1
View File
@@ -18,7 +18,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
"grok": {Name: "Grok Build", ConfigDir: "~/.grok/hooks", ConfigFile: "elydora-audit.json"},
"kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.json"},
"kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"},
"kiroide": {Name: "Kiro IDE", ConfigDir: ".kiro/hooks", ConfigFile: "elydora-audit.json"},
"kimi": {Name: "Kimi Code", ConfigDir: "~/.kimi-code", ConfigFile: "config.toml"},
"opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"},
"copilot": {Name: "GitHub Copilot CLI", ConfigDir: "~/.copilot/hooks", ConfigFile: "elydora-audit.json"},
+2 -2
View File
@@ -37,13 +37,13 @@ func WriteRuntimeFileAtomic(
if err != nil {
return err
}
return writeChanges([]*fileChange{change}, "write "+label, os.Rename)
return writeChanges([]*fileChange{change}, "write "+label, nil)
}
// GenerateHookScript atomically commits the agent config, private key, and
// self-contained Node.js audit runtime.
func GenerateHookScript(destPath string, config InstallConfig) error {
return generateHookScriptWithRename(destPath, config, os.Rename)
return generateHookScriptWithRename(destPath, config, nil)
}
func generateHookScriptWithRename(
@@ -12,6 +12,7 @@ import (
func setRuntimeFilesTestHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
preparePrivateTransactionTestDirectory(t, home)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
return home
+95 -244
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
@@ -12,12 +11,14 @@ import (
)
type renameFunc func(source, destination string) error
type replaceWithBackupFunc func(target, replacement, backup string) error
type fileChange struct {
filePath string
label string
original []byte
originalInfo os.FileInfo
originalID string
originalMode os.FileMode
existed bool
next []byte
@@ -26,11 +27,40 @@ type fileChange struct {
}
type stagedChange struct {
change fileChange
temporaryPath string
rollbackPath string
committedInfo os.FileInfo
committed bool
change fileChange
temporaryPath string
temporaryInfo os.FileInfo
rollbackPath string
originalCaptured bool
committedInfo os.FileInfo
committed bool
}
type transactionFileOps struct {
moveNoReplace renameFunc
compensateNoReplace renameFunc
replaceWithBackup replaceWithBackupFunc
production bool
}
func newTransactionFileOps(rename renameFunc) transactionFileOps {
if rename == nil {
rename = atomicRenameNoReplace
return transactionFileOps{
moveNoReplace: rename,
compensateNoReplace: rename,
replaceWithBackup: atomicReplaceWithBackup,
production: true,
}
}
return transactionFileOps{
moveNoReplace: rename,
compensateNoReplace: atomicRenameNoReplace,
}
}
func sameManagedFileMode(current, expected os.FileMode) bool {
return runtime.GOOS == "windows" || current.Perm() == expected.Perm()
}
func readOptionalFile(path, label string) ([]byte, bool, error) {
@@ -80,7 +110,8 @@ func prepareSourceChange(
(currentExists && !bytes.Equal(snapshot.contents, original)) {
return nil, fmt.Errorf("%s changed before update: %s", label, filePath)
}
if existed && !remove && bytes.Equal(original, next) {
if existed && !remove && bytes.Equal(original, next) &&
sameManagedFileMode(snapshot.mode, mode) {
return nil, nil
}
if !existed && (remove || next == nil) {
@@ -94,22 +125,52 @@ func prepareSourceChange(
}
return &fileChange{
filePath: filePath, label: label, original: append([]byte(nil), original...),
originalInfo: originalInfo, originalMode: originalMode, existed: existed,
originalInfo: originalInfo, originalID: snapshotIdentity(snapshot),
originalMode: originalMode, existed: existed,
next: append([]byte(nil), next...),
mode: mode, remove: remove,
}, nil
}
func snapshotIdentity(snapshot *managedFileSnapshot) string {
if snapshot == nil {
return ""
}
return snapshot.identity
}
func writeChanges(
changes []*fileChange,
label string,
rename renameFunc,
preconditions ...filePrecondition,
) error {
return writeChangesWithFileOps(
changes,
label,
newTransactionFileOps(rename),
preconditions...,
)
}
func writeChangesWithFileOps(
changes []*fileChange,
label string,
ops transactionFileOps,
preconditions ...filePrecondition,
) error {
if ops.production {
return writeDurableChanges(changes, label, ops, preconditions...)
}
if ops.moveNoReplace == nil {
return fmt.Errorf("%s is missing atomic file operations", label)
}
filtered := make([]fileChange, 0, len(changes))
targets := map[string]bool{}
for _, change := range changes {
if change == nil || (change.existed && !change.remove && bytes.Equal(change.original, change.next)) {
if change == nil || (change.existed && !change.remove &&
bytes.Equal(change.original, change.next) &&
sameManagedFileMode(change.originalMode, change.mode)) {
continue
}
target := filepath.Clean(change.filePath)
@@ -128,17 +189,30 @@ func writeChanges(
if len(filtered) == 0 {
return nil
}
if rename == nil {
rename = os.Rename
}
staged := make([]stagedChange, 0, len(filtered))
for _, change := range filtered {
if err := assertFilePreconditions(preconditions, label); err != nil {
cleanupErrors := cleanupStaging(staged)
return joinTransactionFailure(
fmt.Errorf("%s: %w", label, err),
cleanupErrors,
"cleanup failed",
)
}
item, err := stageChange(change)
if err != nil {
cleanupErrors := cleanupStaging(staged)
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), cleanupErrors, "cleanup failed")
}
staged = append(staged, item)
if err := assertFilePreconditions(preconditions, label); err != nil {
cleanupErrors := cleanupStaging(staged)
return joinTransactionFailure(
fmt.Errorf("%s: %w", label, err),
cleanupErrors,
"cleanup failed",
)
}
}
if err := assertFilePreconditions(preconditions, label); err != nil {
cleanupErrors := cleanupStaging(staged)
@@ -146,18 +220,23 @@ func writeChanges(
}
for index := range staged {
if err := assertFilePreconditions(preconditions, label); err != nil {
recoveryErrors := rollbackChanges(staged, rename)
recoveryErrors := rollbackChanges(staged, ops)
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
}
if err := commitChange(&staged[index], rename); err != nil {
recoveryErrors := rollbackChanges(staged, rename)
if err := commitChange(&staged[index], ops); err != nil {
recoveryErrors := rollbackChanges(staged, ops)
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
}
}
if err := assertFilePreconditions(preconditions, label); err != nil {
recoveryErrors := rollbackChanges(staged, rename)
recoveryErrors := rollbackChanges(staged, ops)
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
}
if err := assertCommittedChanges(staged); err != nil {
recoveryErrors := rollbackChanges(staged, ops)
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
}
@@ -168,234 +247,6 @@ func writeChanges(
return nil
}
func stageChange(change fileChange) (stagedChange, error) {
if err := assertFileUnchanged(change); err != nil {
return stagedChange{}, err
}
directory := filepath.Dir(change.filePath)
if err := ensureManagedDirectory(directory, "directory for "+change.label); err != nil {
return stagedChange{}, err
}
staged := stagedChange{change: change}
var err error
if change.remove {
staged.rollbackPath, err = reserveStagingPath(directory, filepath.Base(change.filePath), ".rollback")
} else {
staged.temporaryPath, err = writeStagedFile(
directory, filepath.Base(change.filePath), ".tmp", change.next, change.mode,
)
if err == nil && change.existed {
staged.rollbackPath, err = writeStagedFile(
directory, filepath.Base(change.filePath), ".rollback", change.original, change.originalMode,
)
}
}
if err != nil {
cleanupErrors := cleanupStaging([]stagedChange{staged})
return stagedChange{}, joinTransactionFailure(
fmt.Errorf("stage %s: %w", change.label, err), cleanupErrors, "cleanup failed",
)
}
return staged, nil
}
func writeStagedFile(
directory, basename, suffix string,
content []byte,
mode os.FileMode,
) (string, error) {
file, err := os.CreateTemp(directory, "."+basename+".*"+suffix)
if err != nil {
return "", err
}
path := file.Name()
failed := func(cause error) (string, error) {
return "", errors.Join(cause, file.Close(), removeOptionalFile(path))
}
if err := file.Chmod(mode); err != nil {
return failed(err)
}
written, err := file.Write(content)
if err != nil {
return failed(err)
}
if written != len(content) {
return failed(io.ErrShortWrite)
}
if err := file.Sync(); err != nil {
return failed(err)
}
if err := file.Close(); err != nil {
return "", errors.Join(err, removeOptionalFile(path))
}
return path, nil
}
func reserveStagingPath(directory, basename, suffix string) (string, error) {
file, err := os.CreateTemp(directory, "."+basename+".*"+suffix)
if err != nil {
return "", err
}
path := file.Name()
if err := file.Close(); err != nil {
return "", errors.Join(err, removeOptionalFile(path))
}
if err := os.Remove(path); err != nil {
return "", err
}
return path, nil
}
func assertFileUnchanged(change fileChange) error {
current, err := readManagedFile(change.filePath, change.label, maxManagedSourceBytes)
if err != nil {
return err
}
currentExists := current != nil
if currentExists != change.existed ||
(currentExists && (!bytes.Equal(current.contents, change.original) ||
!os.SameFile(current.info, change.originalInfo))) {
return fmt.Errorf("%s changed during installation: %s", change.label, change.filePath)
}
return nil
}
func commitChange(staged *stagedChange, rename renameFunc) error {
if err := assertFileUnchanged(staged.change); err != nil {
return err
}
if staged.change.remove {
if staged.rollbackPath == "" {
return fmt.Errorf("missing rollback path for %s", staged.change.label)
}
if err := rename(staged.change.filePath, staged.rollbackPath); err != nil {
return fmt.Errorf("remove %s at %s: %w", staged.change.label, staged.change.filePath, err)
}
} else {
if staged.temporaryPath == "" {
return fmt.Errorf("missing staged file for %s", staged.change.label)
}
if err := rename(staged.temporaryPath, staged.change.filePath); err != nil {
return fmt.Errorf("commit %s at %s: %w", staged.change.label, staged.change.filePath, err)
}
}
staged.committed = true
if !staged.change.remove {
current, err := readManagedFile(
staged.change.filePath,
staged.change.label,
maxManagedSourceBytes,
)
if err != nil {
return err
}
if current == nil || !bytes.Equal(current.contents, staged.change.next) {
return fmt.Errorf(
"%s changed immediately after commit: %s",
staged.change.label,
staged.change.filePath,
)
}
staged.committedInfo = current.info
}
return nil
}
func assertCommittedFileUnchanged(item *stagedChange) error {
current, err := readManagedFile(
item.change.filePath,
item.change.label,
maxManagedSourceBytes,
)
if err != nil {
return err
}
if item.change.remove {
if current != nil {
return fmt.Errorf(
"%s changed during transaction recovery: %s",
item.change.label,
item.change.filePath,
)
}
return nil
}
if current == nil || item.committedInfo == nil ||
!bytes.Equal(current.contents, item.change.next) ||
!os.SameFile(current.info, item.committedInfo) {
return fmt.Errorf(
"%s changed during transaction recovery: %s",
item.change.label,
item.change.filePath,
)
}
return nil
}
func rollbackChanges(staged []stagedChange, rename renameFunc) []error {
failures := make([]error, 0)
for index := len(staged) - 1; index >= 0; index-- {
item := &staged[index]
if !item.committed {
continue
}
if err := assertCommittedFileUnchanged(item); err != nil {
failures = append(failures, preserveRollbackFile(item, err))
continue
}
var err error
switch {
case item.change.remove:
err = rename(item.rollbackPath, item.change.filePath)
case item.change.existed:
err = rename(item.rollbackPath, item.change.filePath)
default:
err = removeOptionalFile(item.change.filePath)
}
if err != nil {
failure := fmt.Errorf(
"restore %s at %s: %w",
item.change.label,
item.change.filePath,
err,
)
failures = append(failures, preserveRollbackFile(item, failure))
}
}
return failures
}
func preserveRollbackFile(item *stagedChange, cause error) error {
if item.rollbackPath == "" {
return cause
}
path := item.rollbackPath
item.rollbackPath = ""
return fmt.Errorf("%w; original content preserved at %s", cause, path)
}
func cleanupStaging(staged []stagedChange) []error {
failures := make([]error, 0)
for _, item := range staged {
for _, path := range []string{item.temporaryPath, item.rollbackPath} {
if err := removeOptionalFile(path); err != nil {
failures = append(failures, err)
}
}
}
return failures
}
func removeOptionalFile(path string) error {
if path == "" {
return nil
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove staged file at %s: %w", path, err)
}
return nil
}
func joinTransactionFailure(cause error, related []error, label string) error {
if len(related) == 0 {
return cause
@@ -0,0 +1,48 @@
//go:build darwin
package plugins
import (
"errors"
"fmt"
"os"
"golang.org/x/sys/unix"
)
func atomicRenameNoReplace(source, destination string) error {
if err := unix.RenamexNp(source, destination, unix.RENAME_EXCL); err != nil {
return err
}
return syncAtomicRenameDirectories(source, destination)
}
func isAtomicDestinationExists(err error) bool {
return errors.Is(err, unix.EEXIST)
}
func atomicReplaceWithBackup(target, replacement, backup string) error {
if replacement != backup {
return fmt.Errorf("macOS atomic exchange requires the replacement to retain the backup")
}
if err := unix.RenamexNp(target, replacement, unix.RENAME_SWAP); err != nil {
return err
}
return syncAtomicRenameDirectories(target, replacement)
}
func atomicReplacementUsesSeparateBackup() bool {
return false
}
func transactionPlatformSupported() error {
return nil
}
func syncTransactionDirectory(path string) error {
directory, err := os.Open(path)
if err != nil {
return err
}
return errors.Join(directory.Sync(), directory.Close())
}
@@ -0,0 +1,60 @@
//go:build linux
package plugins
import (
"errors"
"fmt"
"os"
"golang.org/x/sys/unix"
)
func atomicRenameNoReplace(source, destination string) error {
if err := unix.Renameat2(
unix.AT_FDCWD,
source,
unix.AT_FDCWD,
destination,
unix.RENAME_NOREPLACE,
); err != nil {
return err
}
return syncAtomicRenameDirectories(source, destination)
}
func isAtomicDestinationExists(err error) bool {
return errors.Is(err, unix.EEXIST)
}
func atomicReplaceWithBackup(target, replacement, backup string) error {
if replacement != backup {
return fmt.Errorf("Linux atomic exchange requires the replacement to retain the backup")
}
if err := unix.Renameat2(
unix.AT_FDCWD,
target,
unix.AT_FDCWD,
replacement,
unix.RENAME_EXCHANGE,
); err != nil {
return err
}
return syncAtomicRenameDirectories(target, replacement)
}
func atomicReplacementUsesSeparateBackup() bool {
return false
}
func transactionPlatformSupported() error {
return nil
}
func syncTransactionDirectory(path string) error {
directory, err := os.Open(path)
if err != nil {
return err
}
return errors.Join(directory.Sync(), directory.Close())
}
@@ -0,0 +1,18 @@
//go:build linux || darwin
package plugins
import (
"errors"
"path/filepath"
)
func syncAtomicRenameDirectories(source, destination string) error {
sourceDirectory := filepath.Dir(source)
destinationDirectory := filepath.Dir(destination)
destinationErr := syncTransactionDirectory(destinationDirectory)
if durablePathKey(sourceDirectory) == durablePathKey(destinationDirectory) {
return destinationErr
}
return errors.Join(destinationErr, syncTransactionDirectory(sourceDirectory))
}
@@ -0,0 +1,35 @@
//go:build !darwin && !linux && !windows
package plugins
import (
"fmt"
"runtime"
)
func atomicRenameNoReplace(_, _ string) error {
return fmt.Errorf("atomic no-replace rename is unsupported on %s", runtime.GOOS)
}
func isAtomicDestinationExists(error) bool {
return false
}
func atomicReplaceWithBackup(_, _, _ string) error {
return fmt.Errorf("atomic file replacement is unsupported on %s", runtime.GOOS)
}
func atomicReplacementUsesSeparateBackup() bool {
return false
}
func transactionPlatformSupported() error {
return fmt.Errorf(
"durable file transactions support Windows, Linux, and macOS; current platform is %s",
runtime.GOOS,
)
}
func syncTransactionDirectory(string) error {
return transactionPlatformSupported()
}
@@ -0,0 +1,145 @@
//go:build windows
package plugins
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"golang.org/x/sys/windows"
)
func atomicRenameNoReplace(source, destination string) error {
from, err := windows.UTF16PtrFromString(source)
if err != nil {
return err
}
to, err := windows.UTF16PtrFromString(destination)
if err != nil {
return err
}
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_WRITE_THROUGH); err != nil {
return err
}
return syncWindowsRenameDirectories(source, destination)
}
func isAtomicDestinationExists(err error) bool {
return errors.Is(err, windows.ERROR_ALREADY_EXISTS) ||
errors.Is(err, windows.ERROR_FILE_EXISTS)
}
func atomicReplaceWithBackup(target, replacement, backup string) error {
backupPointer, err := windows.UTF16PtrFromString(backup)
if err != nil {
return err
}
targetPointer, err := windows.UTF16PtrFromString(target)
if err != nil {
return err
}
if err := ensureWindowsAtomicBackup(
target,
backup,
targetPointer,
backupPointer,
); err != nil {
return err
}
replacementPointer, err := windows.UTF16PtrFromString(replacement)
if err != nil {
return err
}
deadline := time.Now().Add(2 * time.Second)
for {
err = windows.MoveFileEx(
replacementPointer,
targetPointer,
windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH,
)
if err == nil {
return syncWindowsRenameDirectories(replacement, target)
}
if time.Now().After(deadline) ||
!errors.Is(err, windows.ERROR_ACCESS_DENIED) &&
!errors.Is(err, windows.ERROR_SHARING_VIOLATION) {
return err
}
time.Sleep(time.Millisecond)
}
}
func ensureWindowsAtomicBackup(
target, backup string,
targetPointer, backupPointer *uint16,
) error {
err := windows.CreateHardLink(backupPointer, targetPointer, 0)
if err != nil && !errors.Is(err, windows.ERROR_ALREADY_EXISTS) &&
!errors.Is(err, windows.ERROR_FILE_EXISTS) {
return err
}
if err != nil {
targetInfo, targetErr := os.Lstat(target)
backupInfo, backupErr := os.Lstat(backup)
if targetErr != nil || backupErr != nil ||
targetInfo.Mode()&os.ModeSymlink != 0 ||
backupInfo.Mode()&os.ModeSymlink != 0 ||
!targetInfo.Mode().IsRegular() || !backupInfo.Mode().IsRegular() ||
!os.SameFile(targetInfo, backupInfo) {
return errors.Join(
fmt.Errorf("atomic backup path is occupied by another object: %s", backup),
targetErr,
backupErr,
)
}
}
if err := syncTransactionDirectory(filepath.Dir(backup)); err != nil {
return fmt.Errorf("flush atomic backup directory: %w", err)
}
return nil
}
func atomicReplacementUsesSeparateBackup() bool {
return true
}
func transactionPlatformSupported() error {
return nil
}
func syncWindowsRenameDirectories(source, destination string) error {
destinationDirectory := filepath.Dir(destination)
destinationErr := syncTransactionDirectory(destinationDirectory)
if durablePathKey(filepath.Dir(source)) == durablePathKey(destinationDirectory) {
return destinationErr
}
return errors.Join(destinationErr, syncTransactionDirectory(filepath.Dir(source)))
}
func syncTransactionDirectory(path string) error {
pointer, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
}
handle, err := windows.CreateFile(
pointer,
windows.GENERIC_WRITE,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_WRITE_THROUGH,
0,
)
if err != nil {
return fmt.Errorf("open directory for durable flush at %s: %w", path, err)
}
flushErr := windows.FlushFileBuffers(handle)
closeErr := windows.CloseHandle(handle)
if flushErr != nil {
flushErr = fmt.Errorf("flush directory metadata at %s: %w", path, flushErr)
}
return errors.Join(flushErr, closeErr)
}
@@ -0,0 +1,353 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func sameTransactionTestPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func prepareTransactionRemoval(t *testing.T, path, label string) *fileChange {
t.Helper()
original, existed, err := readOptionalFile(path, label)
if err != nil {
t.Fatalf("read removal source: %v", err)
}
change, err := prepareSourceChange(path, label, original, existed, nil, 0600, true)
if err != nil {
t.Fatalf("prepare removal: %v", err)
}
return change
}
func requireTransactionContent(t *testing.T, path, expected string) {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if string(content) != expected {
t.Fatalf("content at %s = %q, want %q", path, content, expected)
}
}
func TestTransactionRejectsReplacementAtOriginalCaptureBoundary(t *testing.T) {
for _, test := range []struct {
name string
change func(*testing.T, string) *fileChange
}{
{
name: "update",
change: func(t *testing.T, path string) *fileChange {
change, err := prepareFileChange(path, "boundary file", []byte("next"), 0600)
if err != nil {
t.Fatalf("prepare update: %v", err)
}
return change
},
},
{name: "delete", change: func(t *testing.T, path string) *fileChange {
return prepareTransactionRemoval(t, path, "boundary file")
}},
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
target := filepath.Join(directory, "managed.json")
external := filepath.Join(directory, "original.external")
if err := os.WriteFile(target, []byte("original"), 0600); err != nil {
t.Fatalf("write original: %v", err)
}
change := test.change(t, target)
replaced := false
ops := transactionFileOps{moveNoReplace: func(source, destination string) error {
if !replaced && sameTransactionTestPath(source, target) &&
strings.HasSuffix(destination, ".rollback") {
replaced = true
if err := os.Rename(target, external); err != nil {
return err
}
if err := os.WriteFile(target, []byte("original"), 0600); err != nil {
return err
}
}
return atomicRenameNoReplace(source, destination)
}}
err := writeChangesWithFileOps(
[]*fileChange{change},
"capture boundary transaction",
ops,
)
if err == nil || !strings.Contains(err.Error(), "atomic capture boundary") {
t.Fatalf("transaction error = %v", err)
}
requireTransactionContent(t, target, "original")
requireTransactionContent(t, external, "original")
})
}
}
func TestTransactionCreatePreservesObjectAppearingAtCommitBoundary(t *testing.T) {
directory := t.TempDir()
target := filepath.Join(directory, "created.json")
change, err := prepareFileChange(target, "created file", []byte("managed"), 0600)
if err != nil {
t.Fatalf("prepare create: %v", err)
}
appeared := false
ops := transactionFileOps{moveNoReplace: func(source, destination string) error {
if !appeared && sameTransactionTestPath(destination, target) &&
strings.HasSuffix(source, ".tmp") {
appeared = true
if err := os.WriteFile(target, []byte("concurrent"), 0600); err != nil {
return err
}
}
return atomicRenameNoReplace(source, destination)
}}
err = writeChangesWithFileOps(
[]*fileChange{change},
"create boundary transaction",
ops,
)
if err == nil || !strings.Contains(err.Error(), "without replacing another file") {
t.Fatalf("transaction error = %v", err)
}
requireTransactionContent(t, target, "concurrent")
}
func TestTransactionRollbackRestorePreservesObjectAppearingAtBoundary(t *testing.T) {
directory := t.TempDir()
first := filepath.Join(directory, "first.json")
second := filepath.Join(directory, "second.json")
if err := os.WriteFile(first, []byte("first-original"), 0600); err != nil {
t.Fatalf("write original: %v", err)
}
firstChange, err := prepareFileChange(first, "first file", []byte("first-next"), 0600)
if err != nil {
t.Fatalf("prepare first change: %v", err)
}
secondChange, err := prepareFileChange(second, "second file", []byte("second-next"), 0600)
if err != nil {
t.Fatalf("prepare second change: %v", err)
}
appeared := false
ops := transactionFileOps{moveNoReplace: func(source, destination string) error {
if sameTransactionTestPath(destination, second) && strings.HasSuffix(source, ".tmp") {
return errors.New("injected second commit failure")
}
if !appeared && sameTransactionTestPath(destination, first) &&
strings.HasSuffix(source, ".rollback") {
appeared = true
if err := os.WriteFile(first, []byte("concurrent"), 0600); err != nil {
return err
}
}
return atomicRenameNoReplace(source, destination)
}}
err = writeChangesWithFileOps(
[]*fileChange{firstChange, secondChange},
"rollback restore boundary transaction",
ops,
)
if err == nil || !strings.Contains(err.Error(), "original content preserved at") {
t.Fatalf("transaction error = %v", err)
}
requireTransactionContent(t, first, "concurrent")
requirePreservedRollback(t, directory)
}
func TestTransactionRollbackRestoreRejectsReplacedSourceAtBoundary(t *testing.T) {
directory := t.TempDir()
first := filepath.Join(directory, "first.json")
second := filepath.Join(directory, "second.json")
external := filepath.Join(directory, "original.external")
if err := os.WriteFile(first, []byte("first-original"), 0600); err != nil {
t.Fatalf("write original: %v", err)
}
firstChange, err := prepareFileChange(first, "first file", []byte("first-next"), 0600)
if err != nil {
t.Fatalf("prepare first change: %v", err)
}
secondChange, err := prepareFileChange(second, "second file", []byte("second-next"), 0600)
if err != nil {
t.Fatalf("prepare second change: %v", err)
}
rollbackPath := ""
replaced := false
ops := transactionFileOps{moveNoReplace: func(source, destination string) error {
if sameTransactionTestPath(source, first) && strings.HasSuffix(destination, ".rollback") {
rollbackPath = destination
}
if sameTransactionTestPath(destination, second) && strings.HasSuffix(source, ".tmp") {
return errors.New("injected second commit failure")
}
if !replaced && sameTransactionTestPath(source, rollbackPath) &&
sameTransactionTestPath(destination, first) {
replaced = true
if err := os.Rename(rollbackPath, external); err != nil {
return err
}
if err := os.WriteFile(rollbackPath, []byte("concurrent"), 0600); err != nil {
return err
}
}
return atomicRenameNoReplace(source, destination)
}}
err = writeChangesWithFileOps(
[]*fileChange{firstChange, secondChange},
"rollback source boundary transaction",
ops,
)
if err == nil || !strings.Contains(err.Error(), "atomic restore boundary") {
t.Fatalf("transaction error = %v", err)
}
if _, statErr := os.Lstat(first); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("target exists after rejected rollback source: %v", statErr)
}
requireTransactionContent(t, rollbackPath, "concurrent")
requireTransactionContent(t, external, "first-original")
}
func TestTransactionRollbackDeletePreservesReplacementAtBoundary(t *testing.T) {
directory := t.TempDir()
first := filepath.Join(directory, "first.json")
second := filepath.Join(directory, "second.json")
external := filepath.Join(directory, "managed.external")
firstChange, err := prepareFileChange(first, "first file", []byte("first-next"), 0600)
if err != nil {
t.Fatalf("prepare first change: %v", err)
}
secondChange, err := prepareFileChange(second, "second file", []byte("second-next"), 0600)
if err != nil {
t.Fatalf("prepare second change: %v", err)
}
replaced := false
ops := transactionFileOps{moveNoReplace: func(source, destination string) error {
if sameTransactionTestPath(destination, second) && strings.HasSuffix(source, ".tmp") {
return errors.New("injected second commit failure")
}
if !replaced && sameTransactionTestPath(source, first) &&
strings.HasSuffix(destination, ".discard") {
replaced = true
if err := os.Rename(first, external); err != nil {
return err
}
if err := os.WriteFile(first, []byte("concurrent"), 0600); err != nil {
return err
}
}
return atomicRenameNoReplace(source, destination)
}}
err = writeChangesWithFileOps(
[]*fileChange{firstChange, secondChange},
"rollback delete boundary transaction",
ops,
)
if err == nil || !strings.Contains(err.Error(), "atomic rollback boundary") {
t.Fatalf("transaction error = %v", err)
}
requireTransactionContent(t, first, "concurrent")
requireTransactionContent(t, external, "first-next")
}
func TestTransactionCleanupPreservesReplacedRollbackObject(t *testing.T) {
directory := t.TempDir()
target := filepath.Join(directory, "managed.json")
external := filepath.Join(directory, "original.external")
if err := os.WriteFile(target, []byte("original"), 0600); err != nil {
t.Fatalf("write original: %v", err)
}
change, err := prepareFileChange(target, "managed file", []byte("next"), 0600)
if err != nil {
t.Fatalf("prepare update: %v", err)
}
rollbackPath := ""
replaced := false
ops := transactionFileOps{moveNoReplace: func(source, destination string) error {
if sameTransactionTestPath(source, target) && strings.HasSuffix(destination, ".rollback") {
rollbackPath = destination
}
if err := atomicRenameNoReplace(source, destination); err != nil {
return err
}
if !replaced && sameTransactionTestPath(destination, target) &&
strings.HasSuffix(source, ".tmp") {
replaced = true
if err := os.Rename(rollbackPath, external); err != nil {
return err
}
if err := os.WriteFile(rollbackPath, []byte("concurrent"), 0600); err != nil {
return err
}
}
return nil
}}
err = writeChangesWithFileOps(
[]*fileChange{change},
"cleanup boundary transaction",
ops,
)
if err == nil || !strings.Contains(err.Error(), "changed during cleanup") {
t.Fatalf("transaction error = %v", err)
}
requireTransactionContent(t, target, "next")
requireTransactionContent(t, rollbackPath, "concurrent")
requireTransactionContent(t, external, "original")
}
func TestDurableJournalStateRejectsOversizedPhysicalFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "state-00000000000000000001.json")
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
t.Fatal(err)
}
if err := file.Truncate(maxDurableJournalBytes + 1); err != nil {
t.Fatal(errors.Join(err, file.Close()))
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
if _, err := readDurableJournalState(path); err == nil ||
!strings.Contains(err.Error(), "exceeds") {
t.Fatalf("oversized journal error = %v", err)
}
}
func TestDurableJournalStateRejectsSymlink(t *testing.T) {
directory := t.TempDir()
target := filepath.Join(directory, "target.json")
link := filepath.Join(directory, "state-00000000000000000001.json")
if err := os.WriteFile(target, []byte("{}\n"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if _, err := readDurableJournalState(link); err == nil ||
!strings.Contains(err.Error(), "not a physical file") {
t.Fatalf("journal symlink error = %v", err)
}
}
func TestAtomicDestinationExistsRejectsUnrelatedFailure(t *testing.T) {
if isAtomicDestinationExists(os.ErrPermission) {
t.Fatal("permission failure was classified as an occupied destination")
}
}
+487
View File
@@ -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
}
+462
View File
@@ -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)
}
+236
View File
@@ -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)
}
}
+342
View File
@@ -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
}
+57
View File
@@ -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))
}
+218
View File
@@ -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))
}