Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a0aa3f6
Fix post-install TUI Disable All action
tis24dev May 12, 2026
b785bb4
Fix post-install TUI and email delivery defaults
tis24dev May 12, 2026
70720c2
Fix post-install TUI, email fallback, and filesystem checks
tis24dev May 13, 2026
60aa62c
Make BASE_DIR runtime-detected
tis24dev May 13, 2026
d71c25d
fix: Deferred cleanup won't execute when close fails
tis24dev May 13, 2026
af0d664
fix restore UI EOF normalization
tis24dev May 13, 2026
8fd0377
fix restore cleanup after prepare errors & fix TFA prompt with nil lo…
tis24dev May 13, 2026
60b3aae
fix restore service cleanup on prepare abort
tis24dev May 13, 2026
f914454
fix PBS notification nil logger warnings
tis24dev May 13, 2026
cc7f161
fix mount guard double probe race
tis24dev May 13, 2026
01c9bd0
fix PVE storage subdir error propagation
tis24dev May 13, 2026
ce16834
fix PBS datastore subdir error propagation
tis24dev May 13, 2026
9bc2918
fsync PBS datastore config writes
tis24dev May 13, 2026
8069de2
Tighten ZFS mount path heuristics
tis24dev May 13, 2026
b27d88e
Fix datastore mode enforcement without backup user
tis24dev May 13, 2026
e3e7512
Preserve spaces in parsed config paths
tis24dev May 13, 2026
65b04ab
Remove partial restore files on write failures
tis24dev May 13, 2026
9bc0892
Sanitize email address headers
tis24dev May 13, 2026
4eec7ed
Clear stale log file handle on close failure
tis24dev May 13, 2026
70cc1b4
Escape email HTML status values
tis24dev May 13, 2026
1255294
Cancel PXAR workers on first error
tis24dev May 13, 2026
2b28afa
Return compressor start errors
tis24dev May 13, 2026
e6baa4e
Use form wrapper in password field test
tis24dev May 13, 2026
272c2e5
Rename rollback UI error logger
tis24dev May 13, 2026
e28d05c
Name network apply confirm timeout
tis24dev May 13, 2026
85cafb6
Make interface name detection tests deterministic
tis24dev May 13, 2026
0abff37
Normalize safeexec factory formatting
tis24dev May 13, 2026
874b0f8
Retry permission checks on close EIO
tis24dev May 13, 2026
66d9ce4
Defer temp symlink cleanup
tis24dev May 13, 2026
584a226
Avoid duplicate lock close logging
tis24dev May 13, 2026
3141652
Document lock close failure test setup
tis24dev May 13, 2026
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
28 changes: 28 additions & 0 deletions cmd/proxsave/base_dir_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package main

import (
"path/filepath"
"sync"
"testing"
)

func forceDetectedBaseDirForTest(t *testing.T, baseDir string) {
t.Helper()
origInfo := execInfo
origOnce := execInfoOnce
Comment on lines +11 to +12
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Critical: sync.Once must not be copied.

Lines 12 and 26 copy sync.Once by value, which is forbidden because sync.Once contains sync.noCopy to prevent exactly this. Copying synchronization primitives can cause race conditions and undefined behavior.

🔒 Proposed fix: use pointer to avoid copying
 func forceDetectedBaseDirForTest(t *testing.T, baseDir string) {
 	t.Helper()
 	origInfo := execInfo
-	origOnce := execInfoOnce
+	origOnce := &execInfoOnce

 	execPath := filepath.Join(baseDir, "build", "proxsave")
 	execInfo = ExecInfo{
 		ExecPath: execPath,
 		ExecDir:  filepath.Dir(execPath),
 		BaseDir:  baseDir,
 		HasBase:  true,
 	}
-	execInfoOnce = sync.Once{}
+	newOnce := sync.Once{}
+	newOnce.Do(func() {})
+	execInfoOnce = newOnce
-	execInfoOnce.Do(func() {})

 	t.Cleanup(func() {
 		execInfo = origInfo
-		execInfoOnce = origOnce
+		execInfoOnce = *origOnce
 	})
 }

Also applies to: 26-26

🧰 Tools
🪛 golangci-lint (2.12.2)

[error] 12-12: copylocks: assignment copies lock value to origOnce: sync.Once contains sync.noCopy

(govet)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/proxsave/base_dir_test.go` around lines 11 - 12, The test is copying a
sync.Once (execInfoOnce) which is forbidden; change the save/restore logic to
use pointers instead of copying the value: capture references (e.g., &execInfo
and &execInfoOnce or store the original pointer fields) so you don't copy the
sync.Once, and update the restore path to reassign the pointers back to execInfo
and execInfoOnce (or their underlying fields) rather than doing a value copy;
ensure all uses of origInfo/origOnce in this test use the pointer-backed
originals to avoid copying the sync.Once.


execPath := filepath.Join(baseDir, "build", "proxsave")
execInfo = ExecInfo{
ExecPath: execPath,
ExecDir: filepath.Dir(execPath),
BaseDir: baseDir,
HasBase: true,
}
execInfoOnce = sync.Once{}
execInfoOnce.Do(func() {})

t.Cleanup(func() {
execInfo = origInfo
execInfoOnce = origOnce
})
}
36 changes: 32 additions & 4 deletions cmd/proxsave/config_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,7 @@ func resolveInstallConfigPath(configPath string) (string, error) {
return configPath, nil
}

baseDir, ok := detectBaseDir()
if !ok {
return "", fmt.Errorf("unable to determine base directory for configuration")
}
baseDir, _ := detectedBaseDirOrFallback()
return filepath.Join(baseDir, configPath), nil
}

Expand All @@ -52,6 +49,37 @@ func setEnvValue(template, key, value string) string {
return utils.SetEnvValue(template, key, value)
}

func unsetEnvValue(template, key string) string {
key = strings.TrimSpace(key)
if key == "" {
return template
}

lines := strings.Split(template, "\n")
out := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if utils.IsComment(trimmed) {
out = append(out, line)
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) != 2 {
out = append(out, line)
continue
}
parsedKey := strings.TrimSpace(parts[0])
if fields := strings.Fields(parsedKey); len(fields) >= 2 && fields[0] == "export" {
parsedKey = fields[1]
}
if strings.EqualFold(parsedKey, key) {
continue
}
out = append(out, line)
}
return strings.Join(out, "\n")
}

func sanitizeEnvValue(value string) string {
value = strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\x00' {
Expand Down
14 changes: 2 additions & 12 deletions cmd/proxsave/decrypt_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,12 @@ func runDecryptWorkflowOnly(ctx context.Context, configPath string, bootstrap *l
return err
}

cfg, err := config.LoadConfig(configPath)
autoBaseDir, _ := detectedBaseDirOrFallback()
cfg, err := config.LoadConfigWithBaseDir(configPath, autoBaseDir)
if err != nil {
return fmt.Errorf("failed to load configuration: %w", err)
}

autoBaseDir, _ := detectBaseDir()
if cfg.BaseDir == "" {
if autoBaseDir == "" {
if _, err := os.Stat("/opt/proxsave"); err == nil {
autoBaseDir = "/opt/proxsave"
} else {
autoBaseDir = "/opt/proxmox-backup"
}
}
cfg.BaseDir = autoBaseDir
}
_ = os.Setenv("BASE_DIR", cfg.BaseDir)

logLevel := cfg.DebugLevel
Expand Down
3 changes: 2 additions & 1 deletion cmd/proxsave/encryption_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ type encryptionSetupResult struct {
}

func runInitialEncryptionSetupWithUI(ctx context.Context, configPath string, ui orchestrator.AgeSetupUI) (*encryptionSetupResult, error) {
cfg, err := config.LoadConfig(configPath)
baseDir, _ := detectedBaseDirOrFallback()
cfg, err := config.LoadConfigWithBaseDir(configPath, baseDir)
if err != nil {
return nil, fmt.Errorf("failed to reload configuration after install: %w", err)
}
Expand Down
3 changes: 3 additions & 0 deletions cmd/proxsave/encryption_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func TestRunInitialEncryptionSetupWithUIReloadsConfig(t *testing.T) {
}

baseDir := t.TempDir()
forceDetectedBaseDirForTest(t, baseDir)
configPath := filepath.Join(baseDir, "env", "backup.env")
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
t.Fatalf("MkdirAll: %v", err)
Expand Down Expand Up @@ -59,6 +60,7 @@ func TestRunInitialEncryptionSetupWithUIUsesProvidedUI(t *testing.T) {
}

baseDir := t.TempDir()
forceDetectedBaseDirForTest(t, baseDir)
configPath := filepath.Join(baseDir, "env", "backup.env")
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
t.Fatalf("MkdirAll: %v", err)
Expand Down Expand Up @@ -107,6 +109,7 @@ func TestRunInitialEncryptionSetupWithUIReusesExistingFileWithoutReportingWrite(
}

baseDir := t.TempDir()
forceDetectedBaseDirForTest(t, baseDir)
recipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt")
if err := os.MkdirAll(filepath.Dir(recipientPath), 0o700); err != nil {
t.Fatalf("MkdirAll: %v", err)
Expand Down
15 changes: 8 additions & 7 deletions cmd/proxsave/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -741,10 +741,11 @@ func TestFeaturesNeedNetwork(t *testing.T) {

func TestDisableNetworkFeaturesForRun(t *testing.T) {
cfg := &config.Config{
TelegramEnabled: true,
EmailEnabled: true,
EmailDeliveryMethod: "relay",
CloudEnabled: true,
TelegramEnabled: true,
EmailEnabled: true,
EmailDeliveryMethod: "relay",
EmailFallbackSendmail: true,
CloudEnabled: true,
}

// Mock bootstrap logger (nil is ok for this test)
Expand All @@ -757,9 +758,9 @@ func TestDisableNetworkFeaturesForRun(t *testing.T) {
t.Error("CloudEnabled should be disabled")
}

// Email with relay should be disabled
if cfg.EmailEnabled && cfg.EmailDeliveryMethod == "relay" {
t.Error("Email relay should be disabled")
// Email relay should switch to local sendmail fallback when enabled.
if !cfg.EmailEnabled || cfg.EmailDeliveryMethod != "sendmail" {
t.Errorf("Email relay should switch to sendmail fallback, enabled=%v method=%q", cfg.EmailEnabled, cfg.EmailDeliveryMethod)
}
}

Expand Down
51 changes: 40 additions & 11 deletions cmd/proxsave/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func runInstall(ctx context.Context, configPath string, bootstrap *logging.Boots
}
configPath = resolvedPath

baseDir := deriveBaseDirFromConfig(configPath)
baseDir, _ := detectedBaseDirOrFallback()
_ = os.Setenv("BASE_DIR", baseDir)

done := logging.DebugStartBootstrap(bootstrap, "install workflow (cli)", "config=%s base=%s", configPath, baseDir)
Expand Down Expand Up @@ -393,14 +393,6 @@ func printInstallFooter(installErr error, configPath, baseDir, telegramCode, per
fmt.Println()
}

func deriveBaseDirFromConfig(configPath string) string {
baseDir := filepath.Dir(filepath.Dir(configPath))
if baseDir == "" || baseDir == "." || baseDir == string(filepath.Separator) {
baseDir = "/opt/proxsave"
}
return baseDir
}

func cleanupTempConfig(tmpConfigPath string) {
if tmpConfigPath == "" {
return
Expand Down Expand Up @@ -461,6 +453,7 @@ func runConfigWizardCLI(ctx context.Context, reader *bufio.Reader, configPath, t
if skipConfigWizard {
return installConfigResult{SkipConfigWizard: true}, nil
}
template = config.RemoveRuntimeDerivedEnvKeys(template)

logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring secondary storage")
if template, err = configureSecondaryStorage(ctx, reader, template); err != nil {
Expand Down Expand Up @@ -497,6 +490,7 @@ func runConfigWizardCLI(ctx context.Context, reader *bufio.Reader, configPath, t
}

logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "writing configuration")
template = config.RemoveRuntimeDerivedEnvKeys(template)
if err := writeConfigFile(configPath, tmpConfigPath, template); err != nil {
return installConfigResult{}, err
}
Expand Down Expand Up @@ -750,20 +744,55 @@ func configureNotifications(ctx context.Context, reader *bufio.Reader, template
}

fmt.Println("\n--- Email ---")
enableEmail, err := promptYesNo(ctx, reader, "Enable email notifications (central relay)? [y/N]: ", false)
fmt.Println("Default email delivery uses the TIS24 cloud relay, with local sendmail as failover.")
fmt.Println("ProxSave does not collect raw SMTP settings; choose pmf only when Proxmox Notifications is configured.")
enableEmail, err := promptYesNo(ctx, reader, "Enable email notifications? [y/N]: ", false)
if err != nil {
return "", err
}
if enableEmail {
method, err := promptEmailDeliveryMethod(ctx, reader, "relay")
if err != nil {
return "", err
}
template = setEnvValue(template, "EMAIL_ENABLED", "true")
template = setEnvValue(template, "EMAIL_DELIVERY_METHOD", "relay")
template = setEnvValue(template, "EMAIL_DELIVERY_METHOD", method)
template = unsetEnvValue(template, "EMAIL_FALLBACK_PMF")
template = setEnvValue(template, "EMAIL_FALLBACK_SENDMAIL", "true")
} else {
template = setEnvValue(template, "EMAIL_ENABLED", "false")
}
return template, nil
}

func promptEmailDeliveryMethod(ctx context.Context, reader *bufio.Reader, defaultMethod string) (string, error) {
defaultMethod = config.NormalizeEmailDeliveryMethod(defaultMethod)
if defaultMethod != "relay" && defaultMethod != "sendmail" && defaultMethod != "pmf" {
defaultMethod = "relay"
}

fmt.Println("Email delivery methods:")
fmt.Println(" relay TIS24 cloud relay over outbound HTTPS (default)")
fmt.Println(" sendmail Local /usr/sbin/sendmail (fallback/default failover; requires a local MTA)")
fmt.Println(" pmf Proxmox Notifications via proxmox-mail-forward (SMTP lives in Proxmox)")
for {
resp, err := promptOptional(ctx, reader, fmt.Sprintf("Email delivery method [%s]: ", defaultMethod))
if err != nil {
return "", err
}
method := defaultMethod
if strings.TrimSpace(resp) != "" {
method = config.NormalizeEmailDeliveryMethod(resp)
}
switch method {
case "pmf", "relay", "sendmail":
return method, nil
default:
fmt.Println("Please enter 'pmf', 'relay', or 'sendmail'. Aliases like 'proxmox-notifications' are accepted for pmf.")
}
}
}

func configureEncryption(ctx context.Context, reader *bufio.Reader, template *string) (bool, error) {
fmt.Println("\n--- Encryption ---")
enableEncryption, err := promptYesNo(ctx, reader, "Enable backup encryption? [y/N]: ", false)
Expand Down
76 changes: 58 additions & 18 deletions cmd/proxsave/install_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,64 @@
"testing"
)

func TestDeriveBaseDirFromConfig(t *testing.T) {
tests := []struct {
name string
configPath string
want string
}{
{"typical", "/opt/proxsave/env/backup.env", "/opt/proxsave"},
{"root file fallback", "/backup.env", "/opt/proxsave"},
{"relative fallback", "backup.env", "/opt/proxsave"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := deriveBaseDirFromConfig(tt.configPath); got != tt.want {
t.Fatalf("deriveBaseDirFromConfig(%q) = %q, want %q", tt.configPath, got, tt.want)
}
})
}
func TestResolveBaseDirFromExecutablePath(t *testing.T) {
t.Run("standard proxsave build layout", func(t *testing.T) {
got, found := resolveBaseDirFromExecutablePath("/opt/proxsave/build/proxsave")
if !found || got != "/opt/proxsave" {
t.Fatalf("base dir = %q, found=%v; want /opt/proxsave true", got, found)
}
})

t.Run("standard legacy build layout", func(t *testing.T) {
got, found := resolveBaseDirFromExecutablePath("/opt/proxmox-backup/build/proxsave")
if !found || got != "/opt/proxmox-backup" {
t.Fatalf("base dir = %q, found=%v; want /opt/proxmox-backup true", got, found)
}
})

t.Run("symlink to installed binary", func(t *testing.T) {
root := t.TempDir()
base := filepath.Join(root, "proxsave")
build := filepath.Join(base, "build")
if err := os.MkdirAll(build, 0o755); err != nil {

Check warning on line 28 in cmd/proxsave/install_helpers_test.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/proxsave/install_helpers_test.go#L28

Detected file permissions that are set to more than `0600` (user/owner can read and write). Setting file permissions to higher than `0600` is most likely unnecessary and violates the principle of least privilege.

Check warning on line 28 in cmd/proxsave/install_helpers_test.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/proxsave/install_helpers_test.go#L28

The application was found setting directory permissions to overly permissive values.
t.Fatalf("MkdirAll: %v", err)
}
target := filepath.Join(build, "proxsave")
if err := os.WriteFile(target, []byte("#!/bin/sh\n"), 0o755); err != nil {

Check warning on line 32 in cmd/proxsave/install_helpers_test.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/proxsave/install_helpers_test.go#L32

The application was found setting file permissions to overly permissive values.
t.Fatalf("WriteFile: %v", err)
}
linkDir := filepath.Join(root, "bin")
if err := os.MkdirAll(linkDir, 0o755); err != nil {

Check warning on line 36 in cmd/proxsave/install_helpers_test.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/proxsave/install_helpers_test.go#L36

Detected file permissions that are set to more than `0600` (user/owner can read and write). Setting file permissions to higher than `0600` is most likely unnecessary and violates the principle of least privilege.

Check warning on line 36 in cmd/proxsave/install_helpers_test.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/proxsave/install_helpers_test.go#L36

The application was found setting directory permissions to overly permissive values.
t.Fatalf("MkdirAll link dir: %v", err)
}
link := filepath.Join(linkDir, "proxsave")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("Symlink: %v", err)
}

got, found := resolveBaseDirFromExecutablePath(link)
if !found || got != base {
t.Fatalf("base dir = %q, found=%v; want %q true", got, found, base)
}
})

t.Run("install marker", func(t *testing.T) {
base := t.TempDir()
if err := os.MkdirAll(filepath.Join(base, "configs"), 0o755); err != nil {

Check warning on line 52 in cmd/proxsave/install_helpers_test.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/proxsave/install_helpers_test.go#L52

Detected file permissions that are set to more than `0600` (user/owner can read and write). Setting file permissions to higher than `0600` is most likely unnecessary and violates the principle of least privilege.

Check warning on line 52 in cmd/proxsave/install_helpers_test.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/proxsave/install_helpers_test.go#L52

The application was found setting directory permissions to overly permissive values.
t.Fatalf("MkdirAll marker: %v", err)
}
got, found := resolveBaseDirFromExecutablePath(filepath.Join(base, "bin", "proxsave"))
if !found || got != base {
t.Fatalf("base dir = %q, found=%v; want %q true", got, found, base)
}
})

t.Run("fallback", func(t *testing.T) {
got, found := resolveBaseDirFromExecutablePath(filepath.Join(t.TempDir(), "proxsave"))
if found || got != "/opt/proxsave" {
t.Fatalf("base dir = %q, found=%v; want /opt/proxsave false", got, found)
}
})
}

func TestCleanupTempConfig(t *testing.T) {
Expand Down
Loading
Loading