Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ this package implements, and `cmd/selfupdate/` for a complete, runnable
consumer (this module's own reference CLI, which updates itself from this
repository's GitHub releases using nothing but the public API below).

`github.com/strongo/cli-helpers/daemonlifecycle` supplies the narrow
OS-sensitive layer shared by CLI daemons: owner-only state paths and
cancellable advisory file locks. Process launching, lifecycle state, and
recovery policy remain consumer-owned.

## Safety guarantees

- **A managed install is never overwritten directly.** `Classify`
Expand Down
91 changes: 91 additions & 0 deletions daemonlifecycle/daemonlifecycle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package daemonlifecycle

import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
)

func TestOwnerOnlyPathAndLockJourney(t *testing.T) {
dir := filepath.Join(t.TempDir(), "state")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := ProtectOwnerOnly(dir); err != nil {
t.Fatal(err)
}
if err := ValidateOwnerOnly(dir); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "lifecycle.lock")
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
t.Fatal(err)
}
defer func() { _ = file.Close() }()
if err := ProtectOwnerOnly(path); err != nil {
t.Fatal(err)
}
if err := ValidateOwnerOnly(path); err != nil {
t.Fatal(err)
}
if err := Lock(context.Background(), file, time.Millisecond); err != nil {
t.Fatal(err)
}
competitor, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
t.Fatal(err)
}
defer func() { _ = competitor.Close() }()
if locked, err := TryLock(competitor); err != nil || locked {
t.Fatalf("competing lock = %t, %v; want busy", locked, err)
}
if err := Unlock(file); err != nil {
t.Fatal(err)
}
}

func TestLockCancellationRetryAndError(t *testing.T) {
path := filepath.Join(t.TempDir(), "lifecycle.lock")
owner, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
t.Fatal(err)
}
defer func() { _ = owner.Close() }()
if locked, err := TryLock(owner); err != nil || !locked {
t.Fatalf("owner lock = %t, %v", locked, err)
}
competitor, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
t.Fatal(err)
}
defer func() { _ = competitor.Close() }()
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if err := Lock(cancelled, competitor, time.Millisecond); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled Lock error = %v", err)
}
go func() {
time.Sleep(5 * time.Millisecond)
_ = Unlock(owner)
}()
if err := Lock(context.Background(), competitor, time.Millisecond); err != nil {
t.Fatal(err)
}
if err := Unlock(competitor); err != nil {
t.Fatal(err)
}
closed, err := os.OpenFile(filepath.Join(t.TempDir(), "closed.lock"), os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
t.Fatal(err)
}
if err := closed.Close(); err != nil {
t.Fatal(err)
}
if err := Lock(context.Background(), closed, 0); err == nil {
t.Fatal("Lock accepted a closed file")
}
}
7 changes: 7 additions & 0 deletions daemonlifecycle/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Package daemonlifecycle provides the small cross-platform primitives shared
// by CLI daemons: owner-only state paths and advisory file locking.
//
// It deliberately does not own a daemon's state machine, process launching, or
// recovery policy. Those remain product decisions. This package only keeps the
// security- and OS-sensitive mechanics identical across CLI implementations.
package daemonlifecycle
37 changes: 37 additions & 0 deletions daemonlifecycle/lock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package daemonlifecycle

import (
"context"
"os"
"time"
)

// TryLock obtains an exclusive non-blocking advisory lock on file.
func TryLock(file *os.File) (bool, error) { return tryLock(file) }

// Lock waits for an exclusive advisory lock, polling at interval until ctx is
// cancelled. A non-positive interval uses 25 milliseconds.
func Lock(ctx context.Context, file *os.File, interval time.Duration) error {
if interval <= 0 {
interval = 25 * time.Millisecond
}
for {
locked, err := TryLock(file)
if err != nil {
return err
}
if locked {
return nil
}
timer := time.NewTimer(interval)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}

// Unlock releases a lock obtained with TryLock or Lock.
func Unlock(file *os.File) error { return unlock(file) }
20 changes: 20 additions & 0 deletions daemonlifecycle/lock_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//go:build !windows

package daemonlifecycle

import (
"errors"
"os"

"golang.org/x/sys/unix"
)

func tryLock(file *os.File) (bool, error) {
err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB)
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
return false, nil
}
return err == nil, err
}

func unlock(file *os.File) error { return unix.Flock(int(file.Fd()), unix.LOCK_UN) }
22 changes: 22 additions & 0 deletions daemonlifecycle/lock_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//go:build windows

package daemonlifecycle

import (
"errors"
"os"

"golang.org/x/sys/windows"
)

func tryLock(file *os.File) (bool, error) {
err := windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, new(windows.Overlapped))
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
return false, nil
}
return err == nil, err
}

func unlock(file *os.File) error {
return windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, new(windows.Overlapped))
}
64 changes: 64 additions & 0 deletions daemonlifecycle/privacy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package daemonlifecycle

import (
"fmt"
"os"
)

// ProtectOwnerOnly replaces path's access policy with one granting access only
// to the current user. Directories keep inheritable permissions for children.
func ProtectOwnerOnly(path string) error {
if err := protectOwnerOnly(path); err != nil {
return fmt.Errorf("protect %s for current user: %w", path, err)
}
return nil
}

// ProtectOwnerOnlyFile replaces an already-open file's access policy with one
// granting access only to the current user. Using the handle avoids changing a
// different path if an attacker replaces a directory entry after it is opened.
func ProtectOwnerOnlyFile(file *os.File) error {
if file == nil {
return fmt.Errorf("protect current-user access: nil file")
}
if err := protectOwnerOnlyFile(file); err != nil {
return fmt.Errorf("protect %s for current user: %w", file.Name(), err)
}
return nil
}

// ValidateOwnerOnly verifies that path is a regular file or directory whose
// effective access policy grants access only to the current user.
func ValidateOwnerOnly(path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || (!info.Mode().IsRegular() && !info.IsDir()) {
return fmt.Errorf("path is not a regular file or directory: %s", path)
}
if err := validateOwnerOnly(path, info); err != nil {
return fmt.Errorf("validate current-user access for %s: %w", path, err)
}
return nil
}

// ValidateOwnerOnlyFile verifies the effective policy of an already-open
// regular file. Consumers that make security decisions after opening a lock or
// state file should prefer this handle-based form.
func ValidateOwnerOnlyFile(file *os.File) error {
if file == nil {
return fmt.Errorf("validate current-user access: nil file")
}
info, err := file.Stat()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("open handle is not a regular file: %s", file.Name())
}
if err := validateOwnerOnlyFile(file, info); err != nil {
return fmt.Errorf("validate current-user access for %s: %w", file.Name(), err)
}
return nil
}
49 changes: 49 additions & 0 deletions daemonlifecycle/privacy_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//go:build !windows

package daemonlifecycle

import (
"fmt"
"os"
"syscall"
)

func protectOwnerOnly(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
mode := os.FileMode(0o600)
if info.IsDir() {
mode = 0o700
}
return os.Chmod(path, mode)
}

func protectOwnerOnlyFile(file *os.File) error { return file.Chmod(0o600) }

func validateOwnerOnly(_ string, info os.FileInfo) error {
return validateUnixOwnerOnly(info)
}

func validateOwnerOnlyFile(_ *os.File, info os.FileInfo) error {
return validateUnixOwnerOnly(info)
}

func validateUnixOwnerOnly(info os.FileInfo) error {
want := os.FileMode(0o600)
if info.IsDir() {
want = 0o700
}
if info.Mode().Perm() != want {
return fmt.Errorf("mode is %o, want %o", info.Mode().Perm(), want)
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || stat.Uid != uint32(os.Getuid()) {
return fmt.Errorf("path is not owned by the current user")
}
if !info.IsDir() && stat.Nlink != 1 {
return fmt.Errorf("regular file has %d links, want one", stat.Nlink)
}
return nil
}
Loading