diff --git a/internal/backup/restore.go b/internal/backup/restore.go index ca5930a7..7e4df79c 100644 --- a/internal/backup/restore.go +++ b/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 } diff --git a/internal/backup/restore_test.go b/internal/backup/restore_test.go new file mode 100644 index 00000000..5ae81d14 --- /dev/null +++ b/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") +} + diff --git a/internal/backup/restore_unix.go b/internal/backup/restore_unix.go new file mode 100644 index 00000000..36be3271 --- /dev/null +++ b/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 +} + diff --git a/internal/backup/restore_windows.go b/internal/backup/restore_windows.go new file mode 100644 index 00000000..f3f94f47 --- /dev/null +++ b/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 +} + diff --git a/internal/backup/utils.go b/internal/backup/utils.go index 9f393def..091e0dee 100644 --- a/internal/backup/utils.go +++ b/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