bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
81 lines
1.9 KiB
Go
81 lines
1.9 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package lockfile
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/vfs"
|
|
)
|
|
|
|
// safeIDChars strips path-traversal chars from app IDs.
|
|
var safeIDChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
|
|
|
// ErrHeld signals retryable contention; callers errors.Is to distinguish from real failures.
|
|
var ErrHeld = errors.New("lockfile: lock already held")
|
|
|
|
type LockFile struct {
|
|
path string
|
|
file *os.File
|
|
}
|
|
|
|
func New(path string) *LockFile {
|
|
return &LockFile{path: path}
|
|
}
|
|
|
|
// ForSubscribe sanitises appID against path traversal before forming the lock filename.
|
|
func ForSubscribe(appID string) (*LockFile, error) {
|
|
if appID == "" {
|
|
return nil, fmt.Errorf("app ID must not be empty")
|
|
}
|
|
dir := filepath.Join(core.GetConfigDir(), "locks")
|
|
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
|
return nil, fmt.Errorf("create lock dir: %w", err)
|
|
}
|
|
safe := safeIDChars.ReplaceAllString(appID, "_")
|
|
name := filepath.Base(fmt.Sprintf("subscribe_%s.lock", safe))
|
|
path := filepath.Join(dir, name)
|
|
return New(path), nil
|
|
}
|
|
|
|
// TryLock acquires an exclusive non-blocking lock; auto-released on process exit.
|
|
func (l *LockFile) TryLock() error {
|
|
if l.file != nil {
|
|
return fmt.Errorf("%w: %s", ErrHeld, l.path)
|
|
}
|
|
f, err := vfs.OpenFile(l.path, os.O_CREATE|os.O_RDWR, 0600)
|
|
if err != nil {
|
|
return fmt.Errorf("open lock file: %w", err)
|
|
}
|
|
if err := tryLockFile(f); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
l.file = f
|
|
return nil
|
|
}
|
|
|
|
// Unlock keeps the file on disk to avoid inode-reuse races between unlock and competing open+flock.
|
|
func (l *LockFile) Unlock() error {
|
|
if l.file == nil {
|
|
return nil
|
|
}
|
|
err := unlockFile(l.file)
|
|
closeErr := l.file.Close()
|
|
l.file = nil
|
|
if err != nil {
|
|
return fmt.Errorf("unlock file: %w", err)
|
|
}
|
|
return closeErr
|
|
}
|
|
|
|
func (l *LockFile) Path() string {
|
|
return l.path
|
|
}
|