Harden update history persistence root

This commit is contained in:
rcourtman 2026-03-29 14:39:11 +01:00
parent b398c9c405
commit 6b45fd0502
3 changed files with 72 additions and 12 deletions

View file

@ -156,6 +156,10 @@ manager: when `internal/updates/manager.go` enumerates already-owned extract,
temp, backup, or restore directories, it must rejoin discovered entry names
through the shared storage-path helper instead of rebuilding raw
`filepath.Join(dir, entry.Name())` paths.
That same storage boundary also governs update-history persistence:
`internal/updates/history.go` must normalize its owned data directory and
resolve the fixed `update-history.jsonl` leaf through the shared storage-path
helper instead of joining raw caller-provided directory strings.
That same boundary also governs install.sh rollback restore targets:
`adapter_installsh.go` may not hardcode `/etc/pulse` for rollback safety
backups or config restore, and must derive the rollback config directory

View file

@ -7,11 +7,12 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/oklog/ulid/v2"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rs/zerolog/log"
)
@ -19,11 +20,13 @@ import (
type UpdateStatusType string
const (
StatusInProgress UpdateStatusType = "in_progress"
StatusSuccess UpdateStatusType = "success"
StatusFailed UpdateStatusType = "failed"
StatusRolledBack UpdateStatusType = "rolled_back"
StatusCancelled UpdateStatusType = "cancelled"
StatusInProgress UpdateStatusType = "in_progress"
StatusSuccess UpdateStatusType = "success"
StatusFailed UpdateStatusType = "failed"
StatusRolledBack UpdateStatusType = "rolled_back"
StatusCancelled UpdateStatusType = "cancelled"
defaultUpdateHistoryDataDir = "/var/lib/pulse"
updateHistoryFileName = "update-history.jsonl"
)
// InitiatedBy represents who triggered the update
@ -90,19 +93,37 @@ type UpdateHistory struct {
maxCache int
}
func resolveUpdateHistoryLogPath(dataDir string) (string, string, error) {
normalizedDir := strings.TrimSpace(dataDir)
if normalizedDir == "" {
normalizedDir = defaultUpdateHistoryDataDir
}
normalizedDir, err := securityutil.NormalizeStorageDir(normalizedDir)
if err != nil {
return "", "", fmt.Errorf("normalize update history directory: %w", err)
}
logPath, err := securityutil.JoinStorageLeaf(normalizedDir, updateHistoryFileName)
if err != nil {
return "", "", fmt.Errorf("resolve update history log path: %w", err)
}
return normalizedDir, logPath, nil
}
// NewUpdateHistory creates a new update history manager
func NewUpdateHistory(dataDir string) (*UpdateHistory, error) {
if dataDir == "" {
dataDir = "/var/lib/pulse"
resolvedDataDir, logPath, err := resolveUpdateHistoryLogPath(dataDir)
if err != nil {
return nil, err
}
// Ensure directory exists
if err := os.MkdirAll(dataDir, 0755); err != nil {
return nil, fmt.Errorf("create update history data directory %q: %w", dataDir, err)
if err := os.MkdirAll(resolvedDataDir, 0755); err != nil {
return nil, fmt.Errorf("create update history data directory %q: %w", resolvedDataDir, err)
}
logPath := filepath.Join(dataDir, "update-history.jsonl")
h := &UpdateHistory{
logPath: logPath,
cache: make([]UpdateHistoryEntry, 0, 100),

View file

@ -28,6 +28,41 @@ func TestNewUpdateHistory(t *testing.T) {
}
})
t.Run("canonicalizes explicit data dir", func(t *testing.T) {
rawDataDir := filepath.Join(t.TempDir(), "subdir", "..", "nested", ".")
resolvedDir, logPath, err := resolveUpdateHistoryLogPath(" " + rawDataDir + " ")
if err != nil {
t.Fatalf("resolveUpdateHistoryLogPath() error = %v", err)
}
expectedDir := filepath.Clean(rawDataDir)
if resolvedDir != expectedDir {
t.Fatalf("resolved dir = %q, want %q", resolvedDir, expectedDir)
}
expectedLogPath := filepath.Join(expectedDir, updateHistoryFileName)
if logPath != expectedLogPath {
t.Fatalf("log path = %q, want %q", logPath, expectedLogPath)
}
})
t.Run("whitespace data dir uses default path", func(t *testing.T) {
resolvedDir, logPath, err := resolveUpdateHistoryLogPath(" \t ")
if err != nil {
t.Fatalf("resolveUpdateHistoryLogPath() error = %v", err)
}
if resolvedDir != defaultUpdateHistoryDataDir {
t.Fatalf("resolved dir = %q, want %q", resolvedDir, defaultUpdateHistoryDataDir)
}
expectedLogPath := filepath.Join(defaultUpdateHistoryDataDir, updateHistoryFileName)
if logPath != expectedLogPath {
t.Fatalf("log path = %q, want %q", logPath, expectedLogPath)
}
})
t.Run("initializes with empty cache", func(t *testing.T) {
tmpDir := t.TempDir()
h, err := NewUpdateHistory(tmpDir)