465 lines
16 KiB
Go
465 lines
16 KiB
Go
package worktree
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
storageworkspaces "github.com/kandev/kandev/internal/system/storage/workspaces"
|
|
)
|
|
|
|
const errWorkspaceOwnershipMarkerConflict = "workspace ownership marker conflicts with requested task root"
|
|
|
|
// CreateOwnedDirectoryLink creates a live directory reference below root.
|
|
// root is created only through real (non-symlink) ancestors, and name must be
|
|
// a single path segment. The caller owns root; this function never alters the
|
|
// target or an existing entry.
|
|
func CreateOwnedDirectoryLink(root, name, target string) (string, error) {
|
|
if !isOwnedDirectoryLinkPath(root, name) {
|
|
return "", fmt.Errorf("invalid owned link path")
|
|
}
|
|
canonicalTarget, err := canonicalDirectoryLinkTarget(target)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := mkdirOwned(root); err != nil {
|
|
return "", err
|
|
}
|
|
link, err := ownedDirectoryLinkPath(root, name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
release, err := acquireWorktreeTargetPath(context.Background(), link)
|
|
if err != nil {
|
|
return "", fmt.Errorf("acquire owned link lock: %w", err)
|
|
}
|
|
defer release()
|
|
return createOwnedDirectoryLinkLocked(root, name, link, canonicalTarget)
|
|
}
|
|
|
|
func createOwnedDirectoryLinkLocked(root, name, link, canonicalTarget string) (string, error) {
|
|
_, err := os.Lstat(link)
|
|
if err == nil {
|
|
return "", fmt.Errorf("owned link entry already exists: %s", name)
|
|
}
|
|
if !os.IsNotExist(err) {
|
|
return "", fmt.Errorf("inspect owned link entry: %w", err)
|
|
}
|
|
if err := createPlatformDirectoryLink(canonicalTarget, link); err != nil {
|
|
return "", fmt.Errorf("create directory link: %w", err)
|
|
}
|
|
if err := verifyCreatedOwnedDirectoryLink(root, link); err != nil {
|
|
return "", err
|
|
}
|
|
return link, nil
|
|
}
|
|
|
|
// RestoreOwnedDirectoryLink updates or recreates a live directory reference
|
|
// below root so it points at target, preserving the current link if the
|
|
// replacement cannot be completed safely.
|
|
func RestoreOwnedDirectoryLink(root, name, target string) error {
|
|
if !isOwnedDirectoryLinkPath(root, name) {
|
|
return fmt.Errorf("invalid owned link path")
|
|
}
|
|
if err := mkdirOwned(root); err != nil {
|
|
return err
|
|
}
|
|
link, err := ownedDirectoryLinkPath(root, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
release, err := acquireWorktreeTargetPath(context.Background(), link)
|
|
if err != nil {
|
|
return fmt.Errorf("acquire owned link lock: %w", err)
|
|
}
|
|
defer release()
|
|
return restoreOwnedDirectoryLinkLocked(root, name, link, target)
|
|
}
|
|
|
|
// RemoveOwnedDirectoryLink removes root/name only while it is still the
|
|
// directory link inspected under the per-link lock. A missing entry is already
|
|
// rolled back and is therefore treated as success.
|
|
func RemoveOwnedDirectoryLink(root, name string) error {
|
|
if !isOwnedDirectoryLinkPath(root, name) {
|
|
return fmt.Errorf("invalid owned link path")
|
|
}
|
|
link, err := ownedDirectoryLinkPath(root, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
release, err := acquireWorktreeTargetPath(context.Background(), link)
|
|
if err != nil {
|
|
return fmt.Errorf("acquire owned link lock: %w", err)
|
|
}
|
|
defer release()
|
|
|
|
info, err := os.Lstat(link)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("inspect owned link entry: %w", err)
|
|
}
|
|
if !isPlatformDirectoryLink(info, link) {
|
|
return fmt.Errorf("owned link entry is not a directory link: %s", name)
|
|
}
|
|
if err := removeInspectedDirectoryLink(link, info); err != nil {
|
|
return fmt.Errorf("remove owned link: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isOwnedDirectoryLinkPath(root, name string) bool {
|
|
return root != "" && filepath.IsAbs(root) && name != "" && filepath.Base(name) == name && name != "." && name != ".."
|
|
}
|
|
|
|
func canonicalDirectoryLinkTarget(target string) (string, error) {
|
|
canonicalTarget, err := filepath.EvalSymlinks(target)
|
|
if err != nil {
|
|
return "", fmt.Errorf("canonicalize link target: %w", err)
|
|
}
|
|
info, err := os.Stat(canonicalTarget)
|
|
if err != nil || !info.IsDir() {
|
|
return "", fmt.Errorf("link target is not a directory: %w", err)
|
|
}
|
|
return canonicalTarget, nil
|
|
}
|
|
|
|
func ownedDirectoryLinkPath(root, name string) (string, error) {
|
|
link := filepath.Join(root, name)
|
|
if filepath.Dir(link) != filepath.Clean(root) {
|
|
return "", fmt.Errorf("link escapes owned root")
|
|
}
|
|
return link, nil
|
|
}
|
|
|
|
func verifyCreatedOwnedDirectoryLink(root, link string) error {
|
|
if err := requireRealDir(root); err != nil {
|
|
_ = os.Remove(link)
|
|
return err
|
|
}
|
|
if err := requirePlatformDirectoryLink(link); err != nil {
|
|
_ = os.Remove(link)
|
|
return fmt.Errorf("owned link changed during creation")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// OwnedDirectoryLinkOwner identifies the task that is allowed to repoint a
|
|
// Kandev-owned directory link under a shared task root.
|
|
type OwnedDirectoryLinkOwner struct {
|
|
TaskID string
|
|
TaskDirName string
|
|
}
|
|
|
|
// OwnedDirectoryLinkResult describes the durable effect of EnsureOwnedDirectoryLink.
|
|
type OwnedDirectoryLinkResult struct {
|
|
Path string
|
|
Created bool
|
|
PriorTarget string
|
|
}
|
|
|
|
// EnsureOwnedDirectoryLink returns an existing matching live link, repoints an
|
|
// owned link whose target drifted, or creates the link. A non-link/collision
|
|
// still fails closed and is never removed; only a Kandev-owned directory link
|
|
// (a pointer, not content) is replaced when its target no longer matches.
|
|
//
|
|
// The existing link is matched by filesystem identity, not by resolved path.
|
|
// filepath.EvalSymlinks does not traverse a Windows junction — it returns the
|
|
// link's own normalized path — so a path comparison rejected every unchanged
|
|
// junction as a target mismatch, and each relaunch or resume of a task with
|
|
// workspace source links failed. os.Stat follows a junction and a Unix symlink
|
|
// alike, and os.SameFile compares volume and file index, which is also immune
|
|
// to 8.3 short paths and path case.
|
|
//
|
|
// Repoint-on-mismatch is deliberately scoped here to the Kandev-owned task root:
|
|
// the entry lives below a root built through real ancestors, and the entry is a
|
|
// directory link, so removing and recreating it is safe. This is distinct from
|
|
// IsSelfReferentialDirectoryLink, which stays report-only because it concerns
|
|
// entries inside a user's own repository.
|
|
//
|
|
// The full inspect, create, and repoint flow is serialized per owned-link path
|
|
// with the shared target-path lock used by other Kandev writers. Unix still
|
|
// stages a sibling temp link and renames it into place; Windows still removes
|
|
// and recreates, but only while that exclusive slot is held.
|
|
func EnsureOwnedDirectoryLink(root, name, target string, owner OwnedDirectoryLinkOwner) (OwnedDirectoryLinkResult, error) {
|
|
link, err := ownedDirectoryLinkPath(root, name)
|
|
if err != nil {
|
|
return OwnedDirectoryLinkResult{}, err
|
|
}
|
|
release, err := acquireWorktreeTargetPath(context.Background(), link)
|
|
if err != nil {
|
|
return OwnedDirectoryLinkResult{}, fmt.Errorf("acquire owned link lock: %w", err)
|
|
}
|
|
defer release()
|
|
canonicalTarget, err := canonicalDirectoryLinkTarget(target)
|
|
if err != nil {
|
|
return OwnedDirectoryLinkResult{}, err
|
|
}
|
|
info, err := os.Lstat(link)
|
|
if err == nil {
|
|
if !isPlatformDirectoryLink(info, link) {
|
|
return OwnedDirectoryLinkResult{}, fmt.Errorf("owned link entry already exists: %s", name)
|
|
}
|
|
actual, err := os.Stat(link)
|
|
if err != nil {
|
|
return OwnedDirectoryLinkResult{}, fmt.Errorf("resolve owned link: %w", err)
|
|
}
|
|
expected, err := os.Stat(canonicalTarget)
|
|
if err != nil {
|
|
return OwnedDirectoryLinkResult{}, fmt.Errorf("inspect link target: %w", err)
|
|
}
|
|
if os.SameFile(actual, expected) {
|
|
return OwnedDirectoryLinkResult{Path: link}, nil
|
|
}
|
|
priorTarget, err := platformDirectoryLinkTarget(link)
|
|
if err != nil {
|
|
return OwnedDirectoryLinkResult{}, fmt.Errorf("read owned link target: %w", err)
|
|
}
|
|
if err := ensureOwnedLinkRepointAllowed(root, owner); err != nil {
|
|
return OwnedDirectoryLinkResult{}, err
|
|
}
|
|
if err := repointOwnedDirectoryLink(root, name, link, info, priorTarget, canonicalTarget); err != nil {
|
|
return OwnedDirectoryLinkResult{}, err
|
|
}
|
|
return OwnedDirectoryLinkResult{Path: link, Created: true, PriorTarget: priorTarget}, nil
|
|
}
|
|
if !os.IsNotExist(err) {
|
|
return OwnedDirectoryLinkResult{}, fmt.Errorf("inspect owned link entry: %w", err)
|
|
}
|
|
if err := mkdirOwned(root); err != nil {
|
|
return OwnedDirectoryLinkResult{}, err
|
|
}
|
|
created, err := createOwnedDirectoryLinkLocked(root, name, link, canonicalTarget)
|
|
if err != nil {
|
|
return OwnedDirectoryLinkResult{}, err
|
|
}
|
|
return OwnedDirectoryLinkResult{Path: created, Created: true}, nil
|
|
}
|
|
|
|
func repointOwnedDirectoryLink(root, name, link string, inspected os.FileInfo, priorTarget, target string) error {
|
|
if err := replacePlatformDirectoryLink(link, inspected, target, priorTarget); err != nil {
|
|
return err
|
|
}
|
|
if err := verifyCreatedOwnedDirectoryLink(root, link); err != nil {
|
|
if restoreErr := restoreOwnedDirectoryLinkLocked(root, name, link, priorTarget); restoreErr != nil {
|
|
return errors.Join(err, restoreErr)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func restoreOwnedDirectoryLinkLocked(root, name, link, target string) error {
|
|
if target == "" {
|
|
return nil
|
|
}
|
|
canonicalTarget, err := canonicalDirectoryLinkTarget(target)
|
|
if err != nil {
|
|
return fmt.Errorf("restore prior directory link: %w", err)
|
|
}
|
|
info, err := os.Lstat(link)
|
|
if os.IsNotExist(err) {
|
|
if _, err := createOwnedDirectoryLinkLocked(root, name, link, canonicalTarget); err != nil {
|
|
return fmt.Errorf("restore prior directory link: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("restore prior directory link: inspect owned link entry: %w", err)
|
|
}
|
|
if !isPlatformDirectoryLink(info, link) {
|
|
return fmt.Errorf("restore prior directory link: owned link entry already exists: %s", name)
|
|
}
|
|
actual, err := os.Stat(link)
|
|
if err != nil {
|
|
return fmt.Errorf("restore prior directory link: resolve owned link: %w", err)
|
|
}
|
|
expected, err := os.Stat(canonicalTarget)
|
|
if err != nil {
|
|
return fmt.Errorf("restore prior directory link: inspect link target: %w", err)
|
|
}
|
|
if os.SameFile(actual, expected) {
|
|
return nil
|
|
}
|
|
currentTarget, err := platformDirectoryLinkTarget(link)
|
|
if err != nil {
|
|
return fmt.Errorf("restore prior directory link: read owned link target: %w", err)
|
|
}
|
|
if err := replacePlatformDirectoryLink(link, info, canonicalTarget, currentTarget); err != nil {
|
|
return fmt.Errorf("restore prior directory link: %w", err)
|
|
}
|
|
if err := verifyCreatedOwnedDirectoryLink(root, link); err != nil {
|
|
return fmt.Errorf("restore prior directory link: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureOwnedLinkRepointAllowed(root string, owner OwnedDirectoryLinkOwner) error {
|
|
marker, found, err := storageworkspaces.ReadOwnershipMarker(root)
|
|
if err != nil {
|
|
return fmt.Errorf("inspect workspace ownership marker: %w", err)
|
|
}
|
|
if !found {
|
|
return nil
|
|
}
|
|
if marker.TaskID != owner.TaskID || marker.TaskDirName != owner.TaskDirName {
|
|
return errors.New(errWorkspaceOwnershipMarkerConflict)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func revalidateInspectedDirectoryLink(link string, inspected os.FileInfo) error {
|
|
current, err := os.Lstat(link)
|
|
if err != nil {
|
|
return fmt.Errorf("re-inspect owned link: %w", err)
|
|
}
|
|
if !isPlatformDirectoryLink(current, link) || !os.SameFile(current, inspected) {
|
|
return fmt.Errorf("owned link entry changed during repoint: %s", filepath.Base(link))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func renameInspectedDirectoryLink(tempLink, link string, inspected os.FileInfo) error {
|
|
if err := revalidateInspectedDirectoryLink(link, inspected); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tempLink, link); err != nil {
|
|
return fmt.Errorf("repoint owned link: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IsSelfReferentialDirectoryLink reports whether root/name is a platform
|
|
// directory link whose target is root itself — the shape an earlier release
|
|
// planted inside a user's own repository for local-executor tasks, and which
|
|
// EnsureOwnedDirectoryLink then accepts as valid forever.
|
|
//
|
|
// It deliberately only reports. Such an entry cannot be shown to be
|
|
// Kandev-owned: Kandev writes no ownership marker into user-owned sources, and
|
|
// a user (or the repository itself) may keep a link of the same name and target
|
|
// on purpose, so removing it could destroy content that is not ours. Nor could
|
|
// a stat-then-remove sequence close the window between the check and the
|
|
// unlink. Callers surface it and leave removal to the user.
|
|
func IsSelfReferentialDirectoryLink(root, name string) (bool, error) {
|
|
if !isOwnedDirectoryLinkPath(root, name) {
|
|
return false, fmt.Errorf("invalid owned link path")
|
|
}
|
|
link, err := ownedDirectoryLinkPath(root, name)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
info, err := os.Lstat(link)
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("inspect owned link entry: %w", err)
|
|
}
|
|
if !isPlatformDirectoryLink(info, link) {
|
|
return false, nil
|
|
}
|
|
return linkTargetsRoot(root, link)
|
|
}
|
|
|
|
// linkTargetsRoot compares filesystem identity, not resolved path text.
|
|
// filepath.EvalSymlinks does not traverse a Windows junction — it returns the
|
|
// link's own normalized path — so a path comparison never matches. os.Stat
|
|
// follows a junction and a Unix symlink alike, and os.SameFile then compares
|
|
// volume and file index, which also absorbs 8.3 short paths and case.
|
|
func linkTargetsRoot(root, link string) (bool, error) {
|
|
linkInfo, err := os.Stat(link)
|
|
if err != nil {
|
|
return false, fmt.Errorf("resolve owned link: %w", err)
|
|
}
|
|
rootInfo, err := os.Stat(root)
|
|
if err != nil {
|
|
return false, fmt.Errorf("resolve owned root: %w", err)
|
|
}
|
|
return os.SameFile(linkInfo, rootInfo), nil
|
|
}
|
|
|
|
func mkdirOwned(root string) error {
|
|
parent := filepath.Dir(root)
|
|
if err := requireNoSymlinkAncestors(parent); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(parent, 0o755); err != nil {
|
|
return fmt.Errorf("create owned parent: %w", err)
|
|
}
|
|
if err := requireRealDir(parent); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Mkdir(root, 0o755); err != nil && !os.IsExist(err) {
|
|
return fmt.Errorf("create owned root: %w", err)
|
|
}
|
|
return requireRealDir(root)
|
|
}
|
|
|
|
// requireNoSymlinkAncestors checks every existing control-path component
|
|
// before MkdirAll can traverse it. It deliberately uses Lstat so a junction
|
|
// or symlink is rejected rather than resolved.
|
|
func requireNoSymlinkAncestors(path string) error {
|
|
clean := filepath.Clean(path)
|
|
volume := filepath.VolumeName(clean)
|
|
rest := strings.TrimPrefix(clean, volume)
|
|
rest = strings.TrimPrefix(rest, string(filepath.Separator))
|
|
current := volume + string(filepath.Separator)
|
|
for _, part := range strings.Split(rest, string(filepath.Separator)) {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
current = filepath.Join(current, part)
|
|
info, err := os.Lstat(current)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("inspect owned ancestor: %w", err)
|
|
}
|
|
if isPlatformDirectoryLink(info, current) {
|
|
return fmt.Errorf("owned control ancestor is symlink: %s", current)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requireRealDir(path string) error {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return fmt.Errorf("inspect owned path: %w", err)
|
|
}
|
|
if isPlatformDirectoryLink(info, path) || !info.IsDir() {
|
|
return fmt.Errorf("owned path is not a real directory: %s", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TaskRoot returns the canonical Kandev-owned root for a task directory name.
|
|
// It deliberately accepts only the same single-segment names used by worktree
|
|
// creation, preventing a persisted environment row from selecting another
|
|
// directory beneath (or outside) the configured tasks base.
|
|
func (m *Manager) TaskRoot(taskDirName string) (string, error) {
|
|
if taskDirName == "" || filepath.Base(taskDirName) != taskDirName || taskDirName == "." || taskDirName == ".." {
|
|
return "", fmt.Errorf("invalid task directory name")
|
|
}
|
|
base, err := m.config.ExpandedTasksBasePath()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
base, err = filepath.Abs(base)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := requireRealDir(base); err != nil {
|
|
return "", err
|
|
}
|
|
if err := requireNoSymlinkAncestors(base); err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(base, taskDirName), nil
|
|
}
|