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.
262 lines
8.3 KiB
Go
262 lines
8.3 KiB
Go
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))
|
|
}
|