Browse Source

enhance: config restore with mount point handling #1419

0xJacky 3 months ago
parent
commit
bc0844ae8b

+ 118 - 6
internal/backup/restore.go

@@ -7,6 +7,7 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
+	"syscall"
 
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/0xJacky/Nginx-UI/settings"
@@ -345,9 +346,9 @@ func verifyHashes(restoreDir, nginxUIZipPath, nginxZipPath string) (bool, error)
 // parseHashInfo parses hash info from content string
 func parseHashInfo(content string) HashInfo {
 	info := HashInfo{}
-	lines := strings.Split(content, "\n")
+	lines := strings.SplitSeq(content, "\n")
 
-	for _, line := range lines {
+	for line := range lines {
 		line = strings.TrimSpace(line)
 		if line == "" {
 			continue
@@ -383,22 +384,31 @@ func restoreNginxConfigs(nginxBackupDir string) error {
 		return ErrNginxConfigDirEmpty
 	}
 
+	logger.Infof("Starting Nginx config restore from %s to %s", nginxBackupDir, destDir)
+
 	// Recursively clean destination directory preserving the directory structure
+	logger.Info("Cleaning destination directory before restore")
 	if err := cleanDirectoryPreservingStructure(destDir); err != nil {
+		logger.Errorf("Failed to clean directory %s: %v", destDir, err)
 		return cosy.WrapErrorWithParams(ErrCopyNginxConfigDir, "failed to clean directory: "+err.Error())
 	}
 
 	// Copy files from backup to nginx config directory
+	logger.Infof("Copying backup files to destination: %s", destDir)
 	if err := copyDirectory(nginxBackupDir, destDir); err != nil {
+		logger.Errorf("Failed to copy backup files: %v", err)
 		return err
 	}
 
+	logger.Info("Nginx config restore completed successfully")
 	return nil
 }
 
-// cleanDirectoryPreservingStructure removes all files and symlinks in a directory
-// but preserves the directory structure itself
+// cleanDirectoryPreservingStructure removes all files and subdirectories in a directory
+// but preserves the directory structure itself and handles mount points correctly.
 func cleanDirectoryPreservingStructure(dir string) error {
+	logger.Infof("Cleaning directory: %s", dir)
+
 	entries, err := os.ReadDir(dir)
 	if err != nil {
 		return err
@@ -406,12 +416,114 @@ func cleanDirectoryPreservingStructure(dir string) error {
 
 	for _, entry := range entries {
 		path := filepath.Join(dir, entry.Name())
-		err = os.RemoveAll(path)
-		if err != nil {
+
+		if err := removeOrClearPath(path, entry.IsDir()); err != nil {
 			return err
 		}
 	}
 
+	logger.Infof("Successfully cleaned directory: %s", dir)
+	return nil
+}
+
+// removeOrClearPath removes a path or clears it if it's a mount point
+func removeOrClearPath(path string, isDir bool) error {
+	// Try to remove the path first
+	err := os.RemoveAll(path)
+	if err == nil {
+		return nil
+	}
+
+	// Handle removal failures
+	if !isDeviceBusyError(err) {
+		return fmt.Errorf("failed to remove %s: %w", path, err)
+	}
+
+	// Device busy - check if it's a mount point or directory
+	if !isDir {
+		return fmt.Errorf("file is busy and cannot be removed: %s: %w", path, err)
+	}
+
+	logger.Warnf("Path is busy (mount point): %s, clearing contents only", path)
+	return clearDirectoryContents(path)
+}
+
+// isMountPoint checks if a path is a mount point by comparing device IDs
+// or checking /proc/mounts on Linux systems
+func isMountPoint(path string) bool {
+	if isDeviceDifferent(path) {
+		return true
+	}
+
+	return isInMountTable(path)
+}
+
+// isDeviceDifferent and isInMountTable are implemented in platform-specific files:
+// - restore_unix.go for Linux/Unix systems
+// - restore_windows.go for Windows systems
+
+// unescapeOctal converts octal escape sequences like \040 to their character equivalents
+func unescapeOctal(s string) string {
+	var result strings.Builder
+
+	for i := 0; i < len(s); i++ {
+		if char, skip := tryParseOctal(s, i); skip > 0 {
+			result.WriteByte(char)
+			i += skip - 1 // -1 because loop will increment
+			continue
+		}
+		result.WriteByte(s[i])
+	}
+
+	return result.String()
+}
+
+// tryParseOctal attempts to parse octal sequence at position i
+// returns (char, skip) where skip > 0 if successful
+func tryParseOctal(s string, i int) (byte, int) {
+	if s[i] != '\\' || i+3 >= len(s) {
+		return 0, 0
+	}
+
+	var char byte
+	if _, err := fmt.Sscanf(s[i:i+4], "\\%03o", &char); err == nil {
+		return char, 4
+	}
+
+	return 0, 0
+}
+
+// isDeviceBusyError checks if an error is a "device or resource busy" error
+func isDeviceBusyError(err error) bool {
+	if err == nil {
+		return false
+	}
+
+	if errno, ok := err.(syscall.Errno); ok && errno == syscall.EBUSY {
+		return true
+	}
+
+	errMsg := err.Error()
+	return strings.Contains(errMsg, "device or resource busy") ||
+		strings.Contains(errMsg, "resource busy")
+}
+
+// clearDirectoryContents removes all files and subdirectories within a directory
+// but preserves the directory itself. This is useful for cleaning mount points.
+func clearDirectoryContents(dir string) error {
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return err
+	}
+
+	for _, entry := range entries {
+		path := filepath.Join(dir, entry.Name())
+
+		if err := removeOrClearPath(path, entry.IsDir()); err != nil {
+			logger.Warnf("Failed to clear %s: %v, continuing", path, err)
+		}
+	}
+
 	return nil
 }
 

+ 289 - 0
internal/backup/restore_test.go

@@ -0,0 +1,289 @@
+package backup
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"syscall"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	cosylogger "github.com/uozi-tech/cosy/logger"
+)
+
+func init() {
+	// Initialize logging system to avoid nil pointer exceptions during tests
+	cosylogger.Init("debug")
+}
+
+// TestIsDeviceBusyError tests the device busy error detection
+func TestIsDeviceBusyError(t *testing.T) {
+	tests := []struct {
+		name     string
+		err      error
+		expected bool
+	}{
+		{
+			name:     "nil error",
+			err:      nil,
+			expected: false,
+		},
+		{
+			name:     "EBUSY syscall error",
+			err:      syscall.EBUSY,
+			expected: true,
+		},
+		{
+			name:     "device or resource busy string",
+			err:      fmt.Errorf("device or resource busy"),
+			expected: true,
+		},
+		{
+			name:     "resource busy string",
+			err:      fmt.Errorf("resource busy"),
+			expected: true,
+		},
+		{
+			name:     "other error",
+			err:      fmt.Errorf("permission denied"),
+			expected: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := isDeviceBusyError(tt.err)
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+// TestUnescapeOctal tests the octal escape sequence unescaping
+func TestUnescapeOctal(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected string
+	}{
+		{
+			name:     "no escape sequences",
+			input:    "/mnt/data",
+			expected: "/mnt/data",
+		},
+		{
+			name:     "space escape \\040",
+			input:    "/mnt/my\\040folder",
+			expected: "/mnt/my folder",
+		},
+		{
+			name:     "multiple escapes",
+			input:    "/mnt\\040test\\040dir",
+			expected: "/mnt test dir",
+		},
+		{
+			name:     "incomplete escape at end",
+			input:    "/mnt\\04",
+			expected: "/mnt\\04",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := unescapeOctal(tt.input)
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+// TestIsMountPoint tests mount point detection
+func TestIsMountPoint(t *testing.T) {
+	// Create a temporary directory for testing
+	tempDir, err := os.MkdirTemp("", "mount-test-*")
+	assert.NoError(t, err)
+	defer os.RemoveAll(tempDir)
+
+	// Create a subdirectory
+	subDir := filepath.Join(tempDir, "subdir")
+	err = os.MkdirAll(subDir, 0755)
+	assert.NoError(t, err)
+
+	// Test regular directory (should not be a mount point)
+	isMountResult := isMountPoint(subDir)
+	assert.False(t, isMountResult, "Regular subdirectory should not be detected as mount point")
+
+	// Test root directory
+	// Root is typically a mount point on Linux
+	rootIsMountResult := isMountPoint("/")
+	// We don't assert true here because it depends on the system
+	// But we verify the function doesn't panic
+	t.Logf("Root directory mount check result: %v", rootIsMountResult)
+
+	// Test non-existent path
+	nonExistentIsMountResult := isMountPoint("/non/existent/path")
+	assert.False(t, nonExistentIsMountResult, "Non-existent path should return false")
+}
+
+// TestClearDirectoryContents tests the directory contents clearing
+func TestClearDirectoryContents(t *testing.T) {
+	// Create a temporary directory structure
+	tempDir, err := os.MkdirTemp("", "clear-test-*")
+	assert.NoError(t, err)
+	defer os.RemoveAll(tempDir)
+
+	// Create files and subdirectories
+	testFile1 := filepath.Join(tempDir, "file1.txt")
+	testFile2 := filepath.Join(tempDir, "file2.txt")
+	subDir := filepath.Join(tempDir, "subdir")
+	subFile := filepath.Join(subDir, "subfile.txt")
+
+	err = os.WriteFile(testFile1, []byte("test content 1"), 0644)
+	assert.NoError(t, err)
+
+	err = os.WriteFile(testFile2, []byte("test content 2"), 0644)
+	assert.NoError(t, err)
+
+	err = os.MkdirAll(subDir, 0755)
+	assert.NoError(t, err)
+
+	err = os.WriteFile(subFile, []byte("sub content"), 0644)
+	assert.NoError(t, err)
+
+	// Verify files exist before clearing
+	assert.FileExists(t, testFile1)
+	assert.FileExists(t, testFile2)
+	assert.FileExists(t, subFile)
+	assert.DirExists(t, subDir)
+
+	// Clear directory contents
+	err = clearDirectoryContents(tempDir)
+	assert.NoError(t, err)
+
+	// Verify directory still exists
+	assert.DirExists(t, tempDir)
+
+	// Verify all contents are removed
+	entries, err := os.ReadDir(tempDir)
+	assert.NoError(t, err)
+	assert.Empty(t, entries, "Directory should be empty after clearing")
+}
+
+// TestClearDirectoryContentsWithNestedDirs tests clearing nested directory structures
+func TestClearDirectoryContentsWithNestedDirs(t *testing.T) {
+	tempDir, err := os.MkdirTemp("", "clear-nested-test-*")
+	assert.NoError(t, err)
+	defer os.RemoveAll(tempDir)
+
+	// Create nested structure: tempDir/level1/level2/level3
+	level1 := filepath.Join(tempDir, "level1")
+	level2 := filepath.Join(level1, "level2")
+	level3 := filepath.Join(level2, "level3")
+	
+	err = os.MkdirAll(level3, 0755)
+	assert.NoError(t, err)
+
+	// Add files at each level
+	err = os.WriteFile(filepath.Join(level1, "file1.txt"), []byte("level1"), 0644)
+	assert.NoError(t, err)
+	err = os.WriteFile(filepath.Join(level2, "file2.txt"), []byte("level2"), 0644)
+	assert.NoError(t, err)
+	err = os.WriteFile(filepath.Join(level3, "file3.txt"), []byte("level3"), 0644)
+	assert.NoError(t, err)
+
+	// Clear contents
+	err = clearDirectoryContents(tempDir)
+	assert.NoError(t, err)
+
+	// Verify root directory exists but is empty
+	assert.DirExists(t, tempDir)
+	entries, err := os.ReadDir(tempDir)
+	assert.NoError(t, err)
+	assert.Empty(t, entries)
+}
+
+// TestCleanDirectoryPreservingStructure tests the main cleaning function
+func TestCleanDirectoryPreservingStructure(t *testing.T) {
+	tempDir, err := os.MkdirTemp("", "clean-structure-test-*")
+	assert.NoError(t, err)
+	defer os.RemoveAll(tempDir)
+
+	// Create a complex directory structure
+	dir1 := filepath.Join(tempDir, "dir1")
+	dir2 := filepath.Join(tempDir, "dir2")
+	file1 := filepath.Join(tempDir, "file1.txt")
+	file2 := filepath.Join(dir1, "file2.txt")
+
+	err = os.MkdirAll(dir1, 0755)
+	assert.NoError(t, err)
+	err = os.MkdirAll(dir2, 0755)
+	assert.NoError(t, err)
+	err = os.WriteFile(file1, []byte("content1"), 0644)
+	assert.NoError(t, err)
+	err = os.WriteFile(file2, []byte("content2"), 0644)
+	assert.NoError(t, err)
+
+	// Clean the directory
+	err = cleanDirectoryPreservingStructure(tempDir)
+	assert.NoError(t, err)
+
+	// Verify root directory exists
+	assert.DirExists(t, tempDir)
+
+	// Verify all contents are removed
+	entries, err := os.ReadDir(tempDir)
+	assert.NoError(t, err)
+	assert.Empty(t, entries, "Directory should be empty after cleaning")
+}
+
+// TestCleanDirectoryPreservingStructureEmptyDir tests cleaning an already empty directory
+func TestCleanDirectoryPreservingStructureEmptyDir(t *testing.T) {
+	tempDir, err := os.MkdirTemp("", "clean-empty-test-*")
+	assert.NoError(t, err)
+	defer os.RemoveAll(tempDir)
+
+	// Clean already empty directory
+	err = cleanDirectoryPreservingStructure(tempDir)
+	assert.NoError(t, err)
+
+	// Verify directory still exists
+	assert.DirExists(t, tempDir)
+}
+
+// TestCleanDirectoryPreservingStructureWithSymlinks tests cleaning with symbolic links
+func TestCleanDirectoryPreservingStructureWithSymlinks(t *testing.T) {
+	tempDir, err := os.MkdirTemp("", "clean-symlink-test-*")
+	assert.NoError(t, err)
+	defer os.RemoveAll(tempDir)
+
+	// Create a target file
+	targetFile := filepath.Join(tempDir, "target.txt")
+	err = os.WriteFile(targetFile, []byte("target content"), 0644)
+	assert.NoError(t, err)
+
+	// Create a symlink
+	symlinkPath := filepath.Join(tempDir, "link.txt")
+	err = os.Symlink(targetFile, symlinkPath)
+	assert.NoError(t, err)
+
+	// Verify symlink exists
+	_, err = os.Lstat(symlinkPath)
+	assert.NoError(t, err)
+
+	// Clean directory
+	err = cleanDirectoryPreservingStructure(tempDir)
+	assert.NoError(t, err)
+
+	// Verify directory exists and is empty
+	assert.DirExists(t, tempDir)
+	entries, err := os.ReadDir(tempDir)
+	assert.NoError(t, err)
+	assert.Empty(t, entries)
+}
+
+// TestCleanDirectoryPreservingStructureNonExistent tests error handling for non-existent directory
+func TestCleanDirectoryPreservingStructureNonExistent(t *testing.T) {
+	nonExistentDir := "/tmp/non-existent-dir-12345"
+	
+	err := cleanDirectoryPreservingStructure(nonExistentDir)
+	assert.Error(t, err, "Should return error for non-existent directory")
+}
+

+ 48 - 0
internal/backup/restore_unix.go

@@ -0,0 +1,48 @@
+//go:build unix
+
+package backup
+
+import (
+	"bufio"
+	"os"
+	"path/filepath"
+	"strings"
+	"syscall"
+)
+
+// isDeviceDifferent checks if path is on a different device than its parent
+func isDeviceDifferent(path string) bool {
+	var pathStat, parentStat syscall.Stat_t
+	
+	if syscall.Stat(path, &pathStat) != nil {
+		return false
+	}
+	
+	if syscall.Stat(filepath.Dir(path), &parentStat) != nil {
+		return false
+	}
+	
+	return pathStat.Dev != parentStat.Dev
+}
+
+// isInMountTable checks if path is listed in /proc/mounts
+func isInMountTable(path string) bool {
+	file, err := os.Open("/proc/mounts")
+	if err != nil {
+		return false
+	}
+	defer file.Close()
+
+	cleanPath := filepath.Clean(path)
+	scanner := bufio.NewScanner(file)
+	
+	for scanner.Scan() {
+		fields := strings.Fields(scanner.Text())
+		if len(fields) >= 2 && unescapeOctal(fields[1]) == cleanPath {
+			return true
+		}
+	}
+
+	return false
+}
+

+ 16 - 0
internal/backup/restore_windows.go

@@ -0,0 +1,16 @@
+//go:build windows
+
+package backup
+
+// isDeviceDifferent always returns false on Windows
+// Windows mount points work differently and are not a concern for this use case
+func isDeviceDifferent(path string) bool {
+	return false
+}
+
+// isInMountTable always returns false on Windows
+// /proc/mounts doesn't exist on Windows
+func isInMountTable(path string) bool {
+	return false
+}
+

+ 2 - 2
internal/backup/utils.go

@@ -159,7 +159,7 @@ func copyDirectory(src, dst string) error {
 	}
 
 	// Create destination directory with same permissions as source
-	if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil {
+	if err := os.MkdirAll(dst, 0755); err != nil {
 		return err
 	}
 
@@ -192,7 +192,7 @@ func copyDirectory(src, dst string) error {
 
 		// Create directories with original permissions
 		if info.IsDir() {
-			return os.MkdirAll(targetPath, info.Mode())
+			return os.MkdirAll(targetPath, 0755)
 		}
 
 		// Copy regular files