feat: Add Support for Optimistic Cloud Sync (#273)

* feat: Add support for background sync

* refactor: Maintain single source of truth for command defn

* fix: Code review fixes

* fix: Code review fixes

* docs: Add corner case inline doc
This commit is contained in:
Abhisek Datta
2026-05-20 13:56:50 +05:30
committed by GitHub
parent 5872f5281f
commit 9b0e12f130
20 changed files with 868 additions and 6 deletions
+5 -4
View File
@@ -44,12 +44,12 @@ PMG intercepts every package install and checks it for malware **before** code e
## How PMG Works
PMG takes a defense in depth approach. Each install passes through three independent layers before code runs, plus an audit trail after.
PMG takes a defense in depth approach. Each install passes through the enabled protection layers before code runs, plus an audit trail after.
- **Transparent Interception** - PMG wraps `npm`, `pip`, and other package managers. Developers and AI agents use the same commands. No workflow changes.
- **Layer 1: Threat Intelligence** - PMG checks every package against [SafeDep's real-time threat intelligence](https://safedep.io) before install. Known-malicious packages never reach disk.
- **Layer 2: Policy (Dependency Cooldown)** - PMG blocks package versions published inside a configurable cooldown window, so freshly compromised versions cannot land before the ecosystem has had time to flag them.
- **Layer 3: Sandbox** - PMG runs installs inside OS-native sandboxes (macOS Seatbelt, Linux Bubblewrap), so install scripts cannot touch the system even if a threat slips past the first two layers.
- **Layer 3: Optional Sandbox** - When sandboxing is enabled and configured, PMG runs installs inside OS-native sandboxes (macOS Seatbelt, Linux Landlock by default, or Bubblewrap fallback) so install scripts have restricted system access even if a threat slips past the first two layers.
- **Audit Logging** - PMG logs every install (what, when, from where) for a verifiable audit trail.
## Quick Start
@@ -192,8 +192,9 @@ Protect CI workflows with one step. PMG analyzes every `npm install`,
- run: npm ci
```
By default you get malware blocking and dependency cooldown. Tune behavior
via inputs (`paranoid`, `sandbox`, `cooldown-days`, ...) or point
By default you get malware blocking and dependency cooldown. Sandbox isolation
is opt-in via the `sandbox` input. Tune behavior via inputs (`paranoid`,
`sandbox`, `cooldown-days`, ...) or point
`config-file` at a YAML in the repo. See
[docs/github-action.md](docs/github-action.md) for the full reference.
+7
View File
@@ -258,6 +258,13 @@ runs:
export_var SAFEDEP_TENANT_ID "$IN_TENANT_ID"
export_var PMG_CLOUD_ENABLED "true"
# PMG's opportunistic background auto-sync is designed for long-lived
# workstations. On an ephemeral runner the detached child can be
# torn down before it drains the WAL, and the WAL goes with it. Turn
# it off and rely on an explicit `pmg cloud sync` at job-end if the
# user wants events delivered.
export_var PMG_CLOUD_AUTO_SYNC_ENABLED "false"
if [ -n "$IN_ENDPOINT_ID" ]; then
export_var PMG_CLOUD_ENDPOINT_ID "$IN_ENDPOINT_ID"
else
+1
View File
@@ -11,6 +11,7 @@ func NewCloudCommand() *cobra.Command {
}
cmd.AddCommand(newSyncCommand())
cmd.AddCommand(newSyncBackgroundCommand())
cmd.AddCommand(newLoginCommand())
cmd.AddCommand(newLogoutCommand())
+31
View File
@@ -13,6 +13,12 @@ import (
"github.com/spf13/cobra"
)
// manualSyncLockTimeout caps how long `pmg cloud sync` waits to acquire the
// shared sync lock when an auto-sync child is already running. Long enough to
// let a normal background drain complete, short enough that a stuck process
// surfaces as a usefulerror rather than an indefinite hang.
const manualSyncLockTimeout = 30 * time.Second
var syncTimeout time.Duration
func newSyncCommand() *cobra.Command {
@@ -42,6 +48,30 @@ func runSync(cmd *cobra.Command, args []string) error {
WithHelp("Set 'cloud.enabled: true' in PMG config to enable cloud sync"))
}
lock := audit.NewSyncLock(cfg.CloudSyncLockPath())
lockCtx, lockCancel := context.WithTimeout(cmd.Context(), manualSyncLockTimeout)
defer lockCancel()
locked, err := lock.TryLockContext(lockCtx, 250*time.Millisecond)
if err != nil {
ui.ErrorExit(usefulerror.Useful().
Wrap(err).
WithCode(usefulerror.ErrCodeLifecycle).
WithHumanError("Failed to acquire cloud sync lock").
WithHelp("Another sync may be in progress; try again shortly"))
}
if !locked {
ui.ErrorExit(usefulerror.Useful().
WithCode(usefulerror.ErrCodeLifecycle).
WithHumanError("Another cloud sync is already in progress").
WithHelp("Wait for the in-progress sync to finish, then try again"))
}
defer func() {
if err := lock.Unlock(); err != nil {
log.Warnf("failed to release cloud sync lock: %v", err)
}
}()
ctx, cancel := context.WithTimeout(cmd.Context(), syncTimeout)
defer cancel()
@@ -60,6 +90,7 @@ func runSync(cmd *cobra.Command, args []string) error {
}()
synced, err := bundle.Sync(ctx)
recordLastSyncAttempt(cfg)
if err != nil {
ui.ErrorExit(usefulerror.Useful().
Wrap(err).
+99
View File
@@ -0,0 +1,99 @@
package cloud
import (
"context"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/analytics"
"github.com/safedep/pmg/internal/audit"
"github.com/spf13/cobra"
)
// newSyncBackgroundCommand returns the hidden subcommand invoked by the
// detached child that audit.MaybeSpawnBackgroundSync forks. Always exits 0:
// the parent has already returned to the user's shell by the time this runs,
// so a non-zero exit would only show up to whatever (init / launchd) reaped
// the orphaned process.
func newSyncBackgroundCommand() *cobra.Command {
cmd := &cobra.Command{
Use: audit.SyncBackgroundSubcommand,
Short: "Internal: drain the cloud sync WAL from a detached child process",
Hidden: true,
RunE: runSyncBackground,
}
return cmd
}
func runSyncBackground(cmd *cobra.Command, args []string) error {
audit.MarkBackgroundSyncChild()
cfg := config.Get()
if analytics.IsDisabled() {
log.Debugf("Auto-sync: telemetry disabled; exiting")
return nil
}
if !cfg.Config.Cloud.Enabled || !cfg.Config.Cloud.AutoSync.Enabled {
log.Debugf("Auto-sync: cloud or auto_sync disabled; exiting")
return nil
}
lock := audit.NewSyncLock(cfg.CloudSyncLockPath())
locked, err := lock.TryLock()
if err != nil {
log.Warnf("Auto-sync: failed to acquire lock: %v", err)
return nil
}
if !locked {
log.Debugf("Auto-sync: another sync is in progress; exiting")
return nil
}
defer func() {
if err := lock.Unlock(); err != nil {
log.Warnf("Auto-sync: failed to release lock: %v", err)
}
}()
// Re-check cooldown under the lock to close the TOCTOU window between the
// parent's pre-fork stat and our acquisition.
if !audit.SyncCooldownElapsed(cfg.CloudSyncLastRunPath(), cfg.Config.Cloud.AutoSync.MinInterval) {
log.Debugf("Auto-sync: cooldown still in effect under lock; exiting")
return nil
}
ctx, cancel := context.WithTimeout(cmd.Context(), cfg.Config.Cloud.AutoSync.Timeout)
defer cancel()
bundle, err := audit.NewSyncClientBundle(cfg)
if err != nil {
log.Warnf("Auto-sync: failed to initialize sync client: %v", err)
recordLastSyncAttempt(cfg)
return nil
}
defer func() {
if err := bundle.Close(); err != nil {
log.Warnf("Auto-sync: failed to close sync client: %v", err)
}
}()
synced, err := bundle.Sync(ctx)
recordLastSyncAttempt(cfg)
if err != nil {
log.Warnf("Auto-sync: sync failed after %d events: %v", synced, err)
return nil
}
log.Infof("Auto-sync: drained %d events to SafeDep Cloud", synced)
return nil
}
// recordLastSyncAttempt updates the cooldown timestamp on every attempt so a
// failing cloud endpoint does not cause every PMG invocation to retry.
func recordLastSyncAttempt(cfg *config.RuntimeConfig) {
if err := audit.WriteLastSyncAttempt(cfg.CloudSyncLastRunPath()); err != nil {
log.Warnf("failed to update cloud sync lastrun: %v", err)
}
}
+32
View File
@@ -5,12 +5,14 @@ import (
"sort"
"strconv"
"strings"
"time"
"github.com/safedep/dry/cloud"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/alias"
"github.com/safedep/pmg/internal/analytics"
"github.com/safedep/pmg/internal/audit"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/internal/version"
"github.com/safedep/pmg/sandbox/platform"
@@ -141,6 +143,8 @@ func executeSetupInfo() error {
cloudEntries["Endpoint ID"] = cfg.Config.Cloud.EndpointID
}
cloudEntries["Credentials"] = describeCloudCredentials()
cloudEntries["Auto Sync"] = describeAutoSync(cfg.Config.Cloud.AutoSync)
cloudEntries["Last Sync"] = describeLastSync(cfg.CloudSyncLastRunPath())
ui.PrintInfoSection("Cloud Sync", cloudEntries)
}
@@ -192,6 +196,34 @@ func tryResolveKeychainCredentials() (string, bool) {
return "keychain", true
}
func describeAutoSync(c config.CloudAutoSyncConfig) string {
if !c.Enabled {
return "disabled"
}
return fmt.Sprintf("enabled (every %s, timeout %s)", c.MinInterval, c.Timeout)
}
// describeLastSync renders a missing or unparseable timestamp as "never" so a
// fresh install does not look broken.
func describeLastSync(path string) string {
last := audit.ReadLastSyncAttempt(path)
if last.IsZero() {
return "never"
}
delta := time.Since(last)
switch {
case delta < time.Minute:
return "just now"
case delta < time.Hour:
return fmt.Sprintf("%d minutes ago", int(delta.Minutes()))
case delta < 24*time.Hour:
return fmt.Sprintf("%d hours ago", int(delta.Hours()))
default:
return fmt.Sprintf("%d days ago", int(delta.Hours()/24))
}
}
func tryResolveEnvCredentials() (string, bool) {
resolver, err := cloud.NewEnvCredentialResolver()
if err != nil {
+83
View File
@@ -0,0 +1,83 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCloudAutoSyncDefaults(t *testing.T) {
t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist")
initConfig()
c := Get().Config.Cloud.AutoSync
assert.True(t, c.Enabled, "auto_sync.enabled defaults to true")
assert.Equal(t, 15*time.Minute, c.MinInterval)
assert.Equal(t, 5*time.Minute, c.Timeout)
}
func TestCloudAutoSyncEnvOverridesDefaults(t *testing.T) {
t.Run("PMG_CLOUD_AUTO_SYNC_ENABLED=false flips the default", func(t *testing.T) {
t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist")
t.Setenv("PMG_CLOUD_AUTO_SYNC_ENABLED", "false")
initConfig()
assert.False(t, Get().Config.Cloud.AutoSync.Enabled)
})
t.Run("PMG_CLOUD_AUTO_SYNC_MIN_INTERVAL is parsed as duration", func(t *testing.T) {
t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist")
t.Setenv("PMG_CLOUD_AUTO_SYNC_MIN_INTERVAL", "2m")
initConfig()
assert.Equal(t, 2*time.Minute, Get().Config.Cloud.AutoSync.MinInterval)
})
t.Run("PMG_CLOUD_AUTO_SYNC_TIMEOUT is parsed as duration", func(t *testing.T) {
t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist")
t.Setenv("PMG_CLOUD_AUTO_SYNC_TIMEOUT", "30s")
initConfig()
assert.Equal(t, 30*time.Second, Get().Config.Cloud.AutoSync.Timeout)
})
}
func TestCloudAutoSyncConfigFileMerge(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
t.Setenv("PMG_CLOUD_AUTO_SYNC_ENABLED", "")
t.Setenv("PMG_CLOUD_AUTO_SYNC_MIN_INTERVAL", "")
t.Setenv("PMG_CLOUD_AUTO_SYNC_TIMEOUT", "")
configYAML := `cloud:
enabled: true
auto_sync:
enabled: false
min_interval: 1m
`
configPath := filepath.Join(tmpDir, "config.yml")
require.NoError(t, os.WriteFile(configPath, []byte(configYAML), 0o644))
initConfig()
c := Get().Config.Cloud
assert.True(t, c.Enabled)
assert.False(t, c.AutoSync.Enabled, "explicit auto_sync.enabled=false should win")
assert.Equal(t, time.Minute, c.AutoSync.MinInterval)
// Sibling field not set in file falls back to default.
assert.Equal(t, 5*time.Minute, c.AutoSync.Timeout)
}
func TestCloudSyncPathHelpers(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
initConfig()
cfg := Get()
assert.Equal(t, filepath.Join(tmpDir, "cloud-sync.db"), cfg.CloudSyncDBPath())
assert.Equal(t, filepath.Join(tmpDir, "cloud-sync.lock"), cfg.CloudSyncLockPath())
assert.Equal(t, filepath.Join(tmpDir, "cloud-sync.lastrun"), cfg.CloudSyncLastRunPath())
}
+39 -2
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"runtime"
"strconv"
"time"
_ "embed"
@@ -97,8 +98,19 @@ type Config struct {
// CloudConfig configures audit event sync to SafeDep Cloud.
type CloudConfig struct {
Enabled bool `mapstructure:"enabled"`
EndpointID string `mapstructure:"endpoint_id"`
Enabled bool `mapstructure:"enabled"`
EndpointID string `mapstructure:"endpoint_id"`
AutoSync CloudAutoSyncConfig `mapstructure:"auto_sync"`
}
// CloudAutoSyncConfig controls opportunistic background sync of the cloud
// audit WAL. When Enabled, PMG spawns a detached `pmg cloud sync-background`
// child at the end of each invocation, gated by a per-host cooldown so the
// sync does not fire on every command.
type CloudAutoSyncConfig struct {
Enabled bool `mapstructure:"enabled"`
MinInterval time.Duration `mapstructure:"min_interval"`
Timeout time.Duration `mapstructure:"timeout"`
}
type ProxyConfig struct {
@@ -198,6 +210,18 @@ func (r *RuntimeConfig) CloudSyncDBPath() string {
return filepath.Join(r.configDir, "cloud-sync.db")
}
// CloudSyncLockPath returns the path to the cross-process lock file that
// serializes manual `pmg cloud sync` and the auto-sync background child.
func (r *RuntimeConfig) CloudSyncLockPath() string {
return filepath.Join(r.configDir, "cloud-sync.lock")
}
// CloudSyncLastRunPath returns the path to the timestamp file recording the
// last sync attempt (success or failure) in Unix epoch seconds.
func (r *RuntimeConfig) CloudSyncLastRunPath() string {
return filepath.Join(r.configDir, "cloud-sync.lastrun")
}
// ConfigFilePath returns the path to the config file.
func (r *RuntimeConfig) ConfigFilePath() string {
return r.configFilePath
@@ -283,6 +307,11 @@ func DefaultConfig() RuntimeConfig {
},
Cloud: CloudConfig{
Enabled: false,
AutoSync: CloudAutoSyncConfig{
Enabled: true,
MinInterval: 15 * time.Minute,
Timeout: 5 * time.Minute,
},
},
Proxy: ProxyConfig{
Enabled: true,
@@ -303,6 +332,14 @@ func init() {
initConfig()
}
// Reload re-runs the initialization that runs at package init. Tests that
// mutate PMG_CONFIG_DIR via t.Setenv must call this so the resolved config
// directory reflects the new env, instead of the value computed when the
// package was first loaded.
func Reload() {
initConfig()
}
// initConfig should be idempotent and can be called multiple times.
// This is required for testing purposes.
func initConfig() {
+20
View File
@@ -167,3 +167,23 @@ cloud:
# Endpoint ID is not required. By default, it falls back to the machine's hostname.
# Set it only if you want to explicitly override the identifier for this endpoint.
endpoint_id: ""
# Auto sync drains the local cloud-sync WAL to SafeDep Cloud opportunistically
# at the end of each PMG invocation, gated by a per-host cooldown so it does
# not fire on every command. The drain runs as a short-lived detached child
# so the user-facing CLI returns immediately.
#
# Disable this in ephemeral environments (CI runners, throwaway VMs) where
# the detached child may be torn down before it finishes draining; in those
# environments, prefer an explicit `pmg cloud sync` at job-end.
auto_sync:
enabled: true
# Minimum gap between sync attempts. The lastrun timestamp is updated on
# every attempt (success or failure), so a failing cloud endpoint will not
# cause every PMG invocation to retry. Reduce this if you want a more
# aggressive retry cadence.
min_interval: 15m
# Hard timeout applied to a single background sync attempt.
timeout: 5m
+1
View File
@@ -53,6 +53,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-github/v74 v74.0.0 // indirect
+2
View File
@@ -89,6 +89,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+108
View File
@@ -0,0 +1,108 @@
package audit
import (
"os"
"os/exec"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/analytics"
)
// SyncBackgroundSubcommand is the cobra `Use` of the hidden child command
// that MaybeSpawnBackgroundSync forks. Shared with cmd/cloud so renaming the
// command can't desync the spawn args from the cobra registration.
const SyncBackgroundSubcommand = "sync-background"
// isBackgroundSyncChild gates MaybeSpawnBackgroundSync against a respawn
// chain when an early-exit child (e.g. losing the lock race) leaves
// cloud-sync.lastrun stale.
var isBackgroundSyncChild bool
func MarkBackgroundSyncChild() {
isBackgroundSyncChild = true
}
// detachedSpawner forks a detached child running `name` with `args`. Pulled
// behind a package var so tests can intercept without actually forking the
// test binary into the background.
type detachedSpawner func(name string, args ...string) error
var spawnDetached detachedSpawner = spawnDetachedExec
// MaybeSpawnBackgroundSync forks a detached `pmg cloud sync-background` child
// when auto-sync is enabled and the cooldown has elapsed. The call returns as
// soon as `cmd.Start()` finishes (~milliseconds), so it must only be called
// after audit.Close() has released the parent's SQLite handle on the WAL.
//
// All failures are logged and swallowed: auto-sync is opportunistic and must
// never affect the parent PMG invocation's exit behavior or user-visible
// output.
func MaybeSpawnBackgroundSync(cfg *config.RuntimeConfig) {
if cfg == nil {
return
}
if isBackgroundSyncChild {
return
}
if !cfg.Config.Cloud.Enabled || !cfg.Config.Cloud.AutoSync.Enabled {
return
}
if analytics.IsDisabled() {
return
}
if !SyncCooldownElapsed(cfg.CloudSyncLastRunPath(), cfg.Config.Cloud.AutoSync.MinInterval) {
log.Debugf("Auto-sync cooldown not elapsed; skipping spawn")
return
}
pmgPath, err := os.Executable()
if err != nil {
log.Warnf("Auto-sync: failed to resolve pmg binary path: %v", err)
return
}
// This may seem like a pollution of concerns because the internal audit package
// is aware of the CLI cmd layer. We mitigate the risk by having SSOT for sub-command
// definition. We gain simplicity of the API that can be plugged in to appropriate
// hook point in the main command handler.
if err := spawnDetached(pmgPath, "cloud", SyncBackgroundSubcommand); err != nil {
log.Warnf("Auto-sync: failed to spawn background sync child: %v", err)
}
}
// spawnDetachedExec is the production implementation of detachedSpawner. It
// redirects stdio to /dev/null, applies platform-specific detach attributes,
// and starts the child without waiting on it.
func spawnDetachedExec(name string, args ...string) error {
devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
if err != nil {
return err
}
// We hand the fd to exec, which dup's it onto the child's stdio; we can
// close our own copy as soon as Start returns.
defer func() {
if closeErr := devNull.Close(); closeErr != nil {
log.Warnf("Auto-sync: failed to close %s handle: %v", os.DevNull, closeErr)
}
}()
cmd := exec.Command(name, args...)
cmd.Stdin = devNull
cmd.Stdout = devNull
cmd.Stderr = devNull
applyDetachAttrs(cmd)
if err := cmd.Start(); err != nil {
return err
}
// On Unix, Setsid already orphans the child to init; Release just drops
// our os.Process reference so we don't accumulate a zombie if the parent
// process group is reused.
if err := cmd.Process.Release(); err != nil {
log.Warnf("Auto-sync: failed to release background sync child: %v", err)
}
return nil
}
+164
View File
@@ -0,0 +1,164 @@
package audit
import (
"os"
"strconv"
"sync"
"testing"
"time"
"github.com/safedep/pmg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// spawnRecorder is a detachedSpawner stub that captures invocations instead
// of actually forking. We swap it in via withMockSpawner.
type spawnRecorder struct {
mu sync.Mutex
calls []spawnCall
err error
}
type spawnCall struct {
name string
args []string
}
func (r *spawnRecorder) spawn(name string, args ...string) error {
r.mu.Lock()
defer r.mu.Unlock()
cp := make([]string, len(args))
copy(cp, args)
r.calls = append(r.calls, spawnCall{name: name, args: cp})
return r.err
}
func (r *spawnRecorder) callCount() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.calls)
}
func withMockSpawner(t *testing.T) *spawnRecorder {
t.Helper()
rec := &spawnRecorder{}
prev := spawnDetached
spawnDetached = rec.spawn
t.Cleanup(func() { spawnDetached = prev })
return rec
}
func newAutoSyncConfig(t *testing.T) *config.RuntimeConfig {
t.Helper()
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
// Make sure no PMG_DISABLE_TELEMETRY leak from the host environment can
// fool the analytics short-circuit in MaybeSpawnBackgroundSync.
t.Setenv("PMG_DISABLE_TELEMETRY", "false")
// Re-init the config so configDir picks up the tmpDir we just set.
// Without this, cfg.CloudSyncLastRunPath() points at the user's real
// config dir, which may not even exist in CI.
config.Reload()
t.Cleanup(config.Reload)
cfg := config.Get()
cfg.Config.Cloud.Enabled = true
cfg.Config.Cloud.AutoSync.Enabled = true
cfg.Config.Cloud.AutoSync.MinInterval = 15 * time.Minute
cfg.Config.Cloud.AutoSync.Timeout = time.Minute
cfg.Config.DisableTelemetry = false
return cfg
}
func TestMaybeSpawnBackgroundSyncSpawnsByDefault(t *testing.T) {
rec := withMockSpawner(t)
cfg := newAutoSyncConfig(t)
MaybeSpawnBackgroundSync(cfg)
require.Equal(t, 1, rec.callCount())
assert.Equal(t, []string{"cloud", "sync-background"}, rec.calls[0].args)
assert.NotEmpty(t, rec.calls[0].name, "spawned name should be a resolved binary path")
}
func TestMaybeSpawnBackgroundSyncShortCircuits(t *testing.T) {
t.Run("nil config", func(t *testing.T) {
rec := withMockSpawner(t)
MaybeSpawnBackgroundSync(nil)
assert.Equal(t, 0, rec.callCount())
})
t.Run("cloud disabled", func(t *testing.T) {
rec := withMockSpawner(t)
cfg := newAutoSyncConfig(t)
cfg.Config.Cloud.Enabled = false
MaybeSpawnBackgroundSync(cfg)
assert.Equal(t, 0, rec.callCount())
})
t.Run("auto_sync disabled", func(t *testing.T) {
rec := withMockSpawner(t)
cfg := newAutoSyncConfig(t)
cfg.Config.Cloud.AutoSync.Enabled = false
MaybeSpawnBackgroundSync(cfg)
assert.Equal(t, 0, rec.callCount())
})
t.Run("telemetry disabled via config", func(t *testing.T) {
rec := withMockSpawner(t)
cfg := newAutoSyncConfig(t)
cfg.Config.DisableTelemetry = true
MaybeSpawnBackgroundSync(cfg)
assert.Equal(t, 0, rec.callCount())
})
t.Run("we are the sync-background child", func(t *testing.T) {
rec := withMockSpawner(t)
cfg := newAutoSyncConfig(t)
t.Cleanup(func() { isBackgroundSyncChild = false })
MarkBackgroundSyncChild()
MaybeSpawnBackgroundSync(cfg)
assert.Equal(t, 0, rec.callCount())
})
}
func TestMaybeSpawnBackgroundSyncRespectsCooldown(t *testing.T) {
rec := withMockSpawner(t)
cfg := newAutoSyncConfig(t)
// Recent attempt → cooldown still in effect → no spawn.
require.NoError(t, WriteLastSyncAttempt(cfg.CloudSyncLastRunPath()))
MaybeSpawnBackgroundSync(cfg)
assert.Equal(t, 0, rec.callCount())
}
func TestMaybeSpawnBackgroundSyncSpawnsAfterCooldownElapses(t *testing.T) {
rec := withMockSpawner(t)
cfg := newAutoSyncConfig(t)
old := time.Now().Add(-time.Hour).Unix()
require.NoError(t, os.WriteFile(cfg.CloudSyncLastRunPath(),
[]byte(strconv.FormatInt(old, 10)), 0o600))
MaybeSpawnBackgroundSync(cfg)
assert.Equal(t, 1, rec.callCount())
}
func TestMaybeSpawnBackgroundSyncSwallowsSpawnError(t *testing.T) {
rec := withMockSpawner(t)
rec.err = os.ErrPermission
cfg := newAutoSyncConfig(t)
// We can't observe log.Warnf directly, but we can assert the function
// neither panics nor blocks when the underlying spawn fails.
assert.NotPanics(t, func() { MaybeSpawnBackgroundSync(cfg) })
assert.Equal(t, 1, rec.callCount(), "spawner should still be invoked once")
}
+16
View File
@@ -0,0 +1,16 @@
//go:build !windows
package audit
import (
"os/exec"
"syscall"
)
// applyDetachAttrs configures cmd so that the child process is fully
// detached from the parent's controlling terminal and process group. Setsid
// makes the child its own session leader, so the parent can exit immediately
// without the child receiving SIGHUP/SIGINT from the parent's shell.
func applyDetachAttrs(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !windows
package audit
import (
"os/exec"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApplyDetachAttrsSetsSetsidOnUnix(t *testing.T) {
cmd := exec.Command("true")
applyDetachAttrs(cmd)
require.NotNil(t, cmd.SysProcAttr, "applyDetachAttrs must populate SysProcAttr")
assert.True(t, cmd.SysProcAttr.Setsid, "Unix detach must set Setsid so child becomes a new session leader")
}
+20
View File
@@ -0,0 +1,20 @@
//go:build windows
package audit
import (
"os/exec"
"syscall"
"golang.org/x/sys/windows"
)
// applyDetachAttrs configures cmd so that the child process is fully
// detached from the parent's console and process group. DETACHED_PROCESS
// removes the inherited console; CREATE_NEW_PROCESS_GROUP isolates the
// child from Ctrl+C events sent to the parent's group.
func applyDetachAttrs(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP,
}
}
+21
View File
@@ -0,0 +1,21 @@
//go:build windows
package audit
import (
"os/exec"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
func TestApplyDetachAttrsSetsCreationFlagsOnWindows(t *testing.T) {
cmd := exec.Command("cmd.exe")
applyDetachAttrs(cmd)
require.NotNil(t, cmd.SysProcAttr, "applyDetachAttrs must populate SysProcAttr")
want := uint32(windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP)
assert.Equal(t, want, cmd.SysProcAttr.CreationFlags)
}
+52
View File
@@ -0,0 +1,52 @@
package audit
import (
"os"
"strconv"
"strings"
"time"
"github.com/gofrs/flock"
)
// NewSyncLock returns an unlocked file lock at the given path. The same path
// must be used by manual `pmg cloud sync` and the auto-sync background child
// so they serialize against each other.
func NewSyncLock(path string) *flock.Flock {
return flock.New(path)
}
// ReadLastSyncAttempt returns the timestamp of the most recent sync attempt
// (success or failure). A missing or unparseable file resolves to the zero
// time so callers can treat "never attempted" as "infinitely old".
func ReadLastSyncAttempt(path string) time.Time {
data, err := os.ReadFile(path)
if err != nil {
return time.Time{}
}
secs, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
if err != nil {
return time.Time{}
}
return time.Unix(secs, 0)
}
// WriteLastSyncAttempt records the current time as the latest sync attempt.
// Both the success and failure code paths must call this so a failing cloud
// endpoint does not trigger an attempt on every PMG invocation.
func WriteLastSyncAttempt(path string) error {
contents := []byte(strconv.FormatInt(time.Now().Unix(), 10))
return os.WriteFile(path, contents, 0o600)
}
// SyncCooldownElapsed reports whether enough time has passed since the last
// recorded sync attempt to allow a new one.
func SyncCooldownElapsed(path string, minInterval time.Duration) bool {
last := ReadLastSyncAttempt(path)
if last.IsZero() {
return true
}
return time.Since(last) >= minInterval
}
+142
View File
@@ -0,0 +1,142 @@
package audit
import (
"context"
"os"
"path/filepath"
"strconv"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReadLastSyncAttempt(t *testing.T) {
t.Run("missing file returns zero time", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "does-not-exist")
assert.True(t, ReadLastSyncAttempt(path).IsZero())
})
t.Run("empty file returns zero time", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "lastrun")
require.NoError(t, os.WriteFile(path, []byte(""), 0o600))
assert.True(t, ReadLastSyncAttempt(path).IsZero())
})
t.Run("unparseable contents return zero time", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "lastrun")
require.NoError(t, os.WriteFile(path, []byte("not-a-number"), 0o600))
assert.True(t, ReadLastSyncAttempt(path).IsZero())
})
t.Run("valid epoch with trailing whitespace is parsed", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "lastrun")
want := time.Unix(1700000000, 0)
require.NoError(t, os.WriteFile(path, []byte("1700000000\n"), 0o600))
assert.True(t, ReadLastSyncAttempt(path).Equal(want))
})
}
func TestWriteLastSyncAttempt(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "lastrun")
before := time.Now()
require.NoError(t, WriteLastSyncAttempt(path))
after := time.Now()
got := ReadLastSyncAttempt(path)
require.False(t, got.IsZero())
// Allow 1s slack on both ends to absorb second-level truncation by
// WriteLastSyncAttempt's Unix() serialization.
assert.False(t, got.Before(before.Add(-time.Second)), "got=%s before=%s", got, before)
assert.False(t, got.After(after.Add(time.Second)), "got=%s after=%s", got, after)
}
func TestSyncCooldownElapsed(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "lastrun")
t.Run("missing lastrun is treated as old enough", func(t *testing.T) {
assert.True(t, SyncCooldownElapsed(path, time.Hour))
})
t.Run("recent lastrun blocks", func(t *testing.T) {
require.NoError(t, WriteLastSyncAttempt(path))
assert.False(t, SyncCooldownElapsed(path, time.Hour))
})
t.Run("old lastrun allows", func(t *testing.T) {
old := time.Now().Add(-2 * time.Hour).Unix()
require.NoError(t, os.WriteFile(path, []byte(strconv.FormatInt(old, 10)), 0o600))
assert.True(t, SyncCooldownElapsed(path, time.Hour))
})
}
func TestSyncLockSerializes(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sync.lock")
first := NewSyncLock(path)
ok, err := first.TryLock()
require.NoError(t, err)
require.True(t, ok)
defer func() {
require.NoError(t, first.Unlock())
}()
t.Run("TryLock from a sibling lock fails while first is held", func(t *testing.T) {
second := NewSyncLock(path)
ok, err := second.TryLock()
require.NoError(t, err)
assert.False(t, ok)
})
t.Run("TryLockContext from a sibling times out while first is held", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
second := NewSyncLock(path)
ok, err := second.TryLockContext(ctx, 25*time.Millisecond)
// On context cancel TryLockContext can return either (false, nil) or
// (false, context.DeadlineExceeded) depending on timing; both satisfy
// the "did not acquire" assertion.
if err != nil {
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
assert.False(t, ok)
})
}
func TestSyncLockAllowsSecondAcquisitionAfterRelease(t *testing.T) {
path := filepath.Join(t.TempDir(), "sync.lock")
wg := sync.WaitGroup{}
wg.Add(1)
first := NewSyncLock(path)
ok, err := first.TryLock()
require.NoError(t, err)
require.True(t, ok)
go func() {
defer wg.Done()
// assert (not require) is safe from a goroutine. require.NoError
// calls t.FailNow which is not goroutine-safe.
time.Sleep(50 * time.Millisecond)
assert.NoError(t, first.Unlock())
}()
wg.Wait()
second := NewSyncLock(path)
ok, err = second.TryLock()
require.NoError(t, err)
require.True(t, ok)
require.NoError(t, second.Unlock())
}
+6
View File
@@ -166,6 +166,12 @@ func main() {
log.Warnf("failed to close eventlog: %v", err)
}
}()
// Defers run LIFO. The spawn must observe the parent's released SQLite
// handle, so we declare it BEFORE audit.Close's defer (it then runs AFTER
// audit.Close at exit time).
defer func() {
audit.MaybeSpawnBackgroundSync(config.Get())
}()
defer func() {
if err := audit.Close(); err != nil {
log.Warnf("failed to close audit system: %v", err)