fix(windows): avoid mutating lifecycle ACL trees

This commit is contained in:
2026-08-18 14:21:06 +02:00
parent afb0e21200
commit 824245d285
2 changed files with 13 additions and 85 deletions
+9 -49
View File
@@ -13,7 +13,6 @@ import (
"time" "time"
"github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
) )
var ( var (
@@ -50,36 +49,16 @@ type Transaction struct {
// Acquire obtains the shared lock used by backup, restore, Pi lifecycle and product updates. // Acquire obtains the shared lock used by backup, restore, Pi lifecycle and product updates.
func Acquire(installation config.Installation) (*Lock, error) { func Acquire(installation config.Installation) (*Lock, error) {
directory := installation.ControlDirectory() directory := installation.ControlDirectory()
parent := filepath.Dir(directory) if err := os.MkdirAll(directory, 0o700); err != nil {
// The lifecycle directory is also the restore staging parent. An existing Windows child return nil, fmt.Errorf("create lifecycle control directory: %w", err)
// must be protected before its parent: replacing the parent's inheritable ACL first can
// remove the child's inherited owner authority before the child receives its protected DACL.
// For a new installation the parent must instead exist before safe child creation.
controlInfo, controlErr := os.Lstat(directory)
if controlErr == nil {
if !controlInfo.IsDir() || controlInfo.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("lifecycle control directory is not a regular directory")
}
if err := ensurePrivateLifecycleDirectory(directory); err != nil {
return nil, fmt.Errorf("protect lifecycle control directory: %w", err)
}
if err := ensurePrivateLifecycleDirectory(parent); err != nil {
return nil, fmt.Errorf("protect lifecycle control parent: %w", err)
}
} else if errors.Is(controlErr, os.ErrNotExist) {
if err := ensurePrivateLifecycleDirectory(parent); err != nil {
return nil, fmt.Errorf("protect lifecycle control parent: %w", err)
}
if err := ensurePrivateLifecycleDirectory(directory); err != nil {
return nil, fmt.Errorf("protect lifecycle control directory: %w", err)
}
} else {
return nil, errors.New("lifecycle control directory is unavailable")
} }
info, err := os.Lstat(directory) info, err := os.Lstat(directory)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("lifecycle control directory is not a regular directory") return nil, errors.New("lifecycle control directory is not a regular directory")
} }
if err := os.Chmod(directory, 0o700); err != nil {
return nil, fmt.Errorf("protect lifecycle control directory: %w", err)
}
tokenBytes := make([]byte, 16) tokenBytes := make([]byte, 16)
if _, err := rand.Read(tokenBytes); err != nil { if _, err := rand.Read(tokenBytes); err != nil {
@@ -87,11 +66,11 @@ func Acquire(installation config.Installation) (*Lock, error) {
} }
token := hex.EncodeToString(tokenBytes) token := hex.EncodeToString(tokenBytes)
path := filepath.Join(directory, lockFileName) path := filepath.Join(directory, lockFileName)
file, err := safeio.CreateCanonicalNewPrivateFile(path) file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
return nil, ErrLocked
}
if err != nil { if err != nil {
if _, statErr := os.Lstat(path); statErr == nil {
return nil, ErrLocked
}
return nil, fmt.Errorf("acquire lifecycle lock: %w", err) return nil, fmt.Errorf("acquire lifecycle lock: %w", err)
} }
value := owner{Token: token, PID: os.Getpid(), CreatedAt: time.Now().UTC()} value := owner{Token: token, PID: os.Getpid(), CreatedAt: time.Now().UTC()}
@@ -107,25 +86,6 @@ func Acquire(installation config.Installation) (*Lock, error) {
return &Lock{path: path, token: token}, nil return &Lock{path: path, token: token}, nil
} }
// ensurePrivateLifecycleDirectory repairs an existing directory's protection or creates the
// final missing component with the platform's owner-only primitive. It deliberately does not
// use os.MkdirAll for the security-sensitive path: safeio validates every canonical ancestor.
func ensurePrivateLifecycleDirectory(path string) error {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
if err := safeio.EnsurePrivateDirectory(path); err != nil {
return err
}
} else if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return safeio.ErrUnsafeFile
} else {
if err := safeio.ProtectPrivateDirectory(path); err != nil {
return err
}
}
return safeio.ValidatePrivateDirectory(path)
}
// AcquireTransaction obtains a lifecycle lock and returns the capability required by callers // AcquireTransaction obtains a lifecycle lock and returns the capability required by callers
// that perform nested work inside the same non-reentrant transaction. // that perform nested work inside the same non-reentrant transaction.
func AcquireTransaction(installation config.Installation) (*Transaction, error) { func AcquireTransaction(installation config.Installation) (*Transaction, error) {
+4 -36
View File
@@ -7,37 +7,15 @@ import (
"testing" "testing"
"github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
) )
func TestLifecycleLockIsExclusivePerInstallationAndReusableAfterRelease(t *testing.T) { func TestLifecycleLockIsExclusivePerInstallationAndReusableAfterRelease(t *testing.T) {
root := lifecycleTestRoot(t) installation := config.Installation{ProjectDirectory: t.TempDir(), Path: filepath.Join(t.TempDir(), "thothii-installation.yaml")}
installation := config.Installation{ProjectDirectory: root, Path: filepath.Join(root, "thothii-installation.yaml")}
if err := os.MkdirAll(installation.ControlDirectory(), 0o755); err != nil {
t.Fatal(err)
}
first, err := Acquire(installation) first, err := Acquire(installation)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { _ = first.Release() }) t.Cleanup(func() { _ = first.Release() })
for name, path := range map[string]string{
"lifecycle parent": filepath.Dir(installation.ControlDirectory()),
"lifecycle directory": installation.ControlDirectory(),
"owner file": first.Path(),
} {
t.Run(name, func(t *testing.T) {
var err error
if name == "owner file" {
err = safeio.ValidatePrivateRegular(path)
} else {
err = safeio.ValidatePrivateDirectory(path)
}
if err != nil {
t.Fatalf("%s protection = %v", name, err)
}
})
}
if _, err := Acquire(installation); !errors.Is(err, ErrLocked) { if _, err := Acquire(installation); !errors.Is(err, ErrLocked) {
t.Fatalf("second Acquire() error = %v, want ErrLocked", err) t.Fatalf("second Acquire() error = %v, want ErrLocked", err)
@@ -55,8 +33,7 @@ func TestLifecycleLockIsExclusivePerInstallationAndReusableAfterRelease(t *testi
} }
func TestLifecycleLockReleaseDoesNotRemoveAnotherOwnersFile(t *testing.T) { func TestLifecycleLockReleaseDoesNotRemoveAnotherOwnersFile(t *testing.T) {
root := lifecycleTestRoot(t) installation := config.Installation{ProjectDirectory: t.TempDir(), Path: filepath.Join(t.TempDir(), "thothii-installation.yaml")}
installation := config.Installation{ProjectDirectory: root, Path: filepath.Join(root, "thothii-installation.yaml")}
lock, err := Acquire(installation) lock, err := Acquire(installation)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -73,7 +50,7 @@ func TestLifecycleLockReleaseDoesNotRemoveAnotherOwnersFile(t *testing.T) {
} }
func TestTransactionCapabilityIsInstallationBoundAndExpiresOnRelease(t *testing.T) { func TestTransactionCapabilityIsInstallationBoundAndExpiresOnRelease(t *testing.T) {
root := lifecycleTestRoot(t) root := t.TempDir()
installation := config.Installation{ProjectDirectory: root, Path: filepath.Join(root, "thothii-installation.yaml")} installation := config.Installation{ProjectDirectory: root, Path: filepath.Join(root, "thothii-installation.yaml")}
transaction, err := AcquireTransaction(installation) transaction, err := AcquireTransaction(installation)
if err != nil { if err != nil {
@@ -83,7 +60,7 @@ func TestTransactionCapabilityIsInstallationBoundAndExpiresOnRelease(t *testing.
t.Fatalf("Verify() active capability error = %v", err) t.Fatalf("Verify() active capability error = %v", err)
} }
otherRoot := lifecycleTestRoot(t) otherRoot := t.TempDir()
other := config.Installation{ProjectDirectory: otherRoot, Path: filepath.Join(otherRoot, "thothii-installation.yaml")} other := config.Installation{ProjectDirectory: otherRoot, Path: filepath.Join(otherRoot, "thothii-installation.yaml")}
if err := transaction.Verify(other); !errors.Is(err, ErrTransactionInstallation) { if err := transaction.Verify(other); !errors.Is(err, ErrTransactionInstallation) {
t.Fatalf("Verify() for another installation error = %v, want ErrTransactionInstallation", err) t.Fatalf("Verify() for another installation error = %v, want ErrTransactionInstallation", err)
@@ -95,12 +72,3 @@ func TestTransactionCapabilityIsInstallationBoundAndExpiresOnRelease(t *testing.
t.Fatalf("Verify() after Release() error = %v, want ErrTransactionInactive", err) t.Fatalf("Verify() after Release() error = %v, want ErrTransactionInactive", err)
} }
} }
func lifecycleTestRoot(t *testing.T) string {
t.Helper()
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
return root
}