diff --git a/README.md b/README.md index 60ee291..bf9d60d 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,17 @@ This project was developed with the assistance of ChatGPT and GitHub Copilot. - **Directory-Aware History** Commands are stored with their execution directory context, allowing you to view history specific to directories. -- **Directory Path Updates** +- **Directory Path Updates** When you move or rename directories, you can update all related history entries: - Updates both exact path matches and subdirectory paths - Preserves your command history context when reorganizing your filesystem - Handles relative paths automatically +- **Disk Space Safety Policy** + - Warns when disk space falls below 10MB + - Blocks writes when disk space falls below 1MB (to prevent SQLite corruption) + - Handles edge cases gracefully (symlinks, deleted directories, in-memory databases) + - **Shell Context Tracking** Each command is stored with its execution context: - Hostname of the machine diff --git a/cmd/histree-core/main.go b/cmd/histree-core/main.go index 3328451..aa23b9e 100644 --- a/cmd/histree-core/main.go +++ b/cmd/histree-core/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "errors" "flag" "fmt" "io" @@ -64,16 +65,27 @@ func main() { os.Exit(1) } if err := handleAdd(db, *currentDir, *hostname, *processID, *exitCode); err != nil { + if errors.Is(err, histree.ErrInsufficientDiskSpace) { + fmt.Fprintf(os.Stderr, "Warning: %v\n", err) + return + } + fmt.Fprintf(os.Stderr, "Failed to add entry: %v\n", err) os.Exit(1) } + // Check for low disk space warning after successful write + if warning := db.CheckDiskSpaceWarning(); warning != nil { + fmt.Fprintf(os.Stderr, "Warning: Low disk space (%s remaining). History recording may fail soon.\n", + histree.FormatBytes(warning.AvailableBytes)) + } + case "get": if err := handleGet(db, *limit, *currentDir, histree.OutputFormat(*format)); err != nil { fmt.Fprintf(os.Stderr, "Failed to get entries: %v\n", err) os.Exit(1) } - + case "update-path": if *oldPath == "" || *newPath == "" { fmt.Fprintf(os.Stderr, "Error: both -old-path and -new-path parameters are required for update-path action\n") @@ -137,7 +149,7 @@ func handleUpdatePath(db *histree.DB, oldPath, newPath string) error { } oldPath = absOldPath } - + if !filepath.IsAbs(newPath) { absNewPath, err := filepath.Abs(newPath) if err != nil { @@ -145,17 +157,17 @@ func handleUpdatePath(db *histree.DB, oldPath, newPath string) error { } newPath = absNewPath } - + // Clean the paths to ensure consistent format oldPath = filepath.Clean(oldPath) newPath = filepath.Clean(newPath) - + // Update the paths in the database count, err := db.UpdatePaths(oldPath, newPath) if err != nil { return err } - + fmt.Printf("Updated %d entries: %s -> %s\n", count, oldPath, newPath) return nil } diff --git a/cmd/histree-core/main_test.go b/cmd/histree-core/main_test.go index 3069711..f603fb7 100644 --- a/cmd/histree-core/main_test.go +++ b/cmd/histree-core/main_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "errors" "os" "strings" "testing" @@ -119,6 +120,39 @@ func TestGetEntries(t *testing.T) { } } +func TestAddEntryErrorsWhenNoDiskSpace(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + histree.SetDiskSpaceChecker(func(string) (bool, error) { + return false, nil + }) + defer histree.SetDiskSpaceChecker(nil) + + entry := histree.HistoryEntry{ + Command: "should-not-be-inserted", + Directory: "/home/user", + Timestamp: time.Now().UTC(), + ExitCode: 0, + Hostname: "test-host", + ProcessID: 12345, + } + + err := db.AddEntry(&entry) + if !errors.Is(err, histree.ErrInsufficientDiskSpace) { + t.Fatalf("expected ErrInsufficientDiskSpace, got %v", err) + } + + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM history").Scan(&count); err != nil { + t.Fatalf("failed to count entries: %v", err) + } + + if count != 0 { + t.Fatalf("expected 0 entries when disk full, got %d", count) + } +} + // TestFormatVerboseWithTimezone tests that the FormatVerbose output // correctly converts UTC timestamps to local timezone func TestFormatVerboseWithTimezone(t *testing.T) { @@ -217,7 +251,7 @@ func TestUpdatePaths(t *testing.T) { // Define test paths oldPath := "/home/user/oldpath" newPath := "/home/user/newpath" - + // Create test entries with different paths entries := []histree.HistoryEntry{ { @@ -282,9 +316,9 @@ func TestUpdatePaths(t *testing.T) { // Check the expected path changes expectedDirs := []string{ - newPath, // oldPath should now be newPath - newPath + "/subdir", // oldPath/subdir should now be newPath/subdir - "/tmp", // Unrelated path should remain unchanged + newPath, // oldPath should now be newPath + newPath + "/subdir", // oldPath/subdir should now be newPath/subdir + "/tmp", // Unrelated path should remain unchanged } if len(updatedDirs) != len(expectedDirs) { diff --git a/pkg/histree/disk_test.go b/pkg/histree/disk_test.go new file mode 100644 index 0000000..72dadec --- /dev/null +++ b/pkg/histree/disk_test.go @@ -0,0 +1,253 @@ +package histree + +import ( + "os" + "path/filepath" + "testing" +) + +func TestHasSufficientDiskSpace_MemoryDB(t *testing.T) { + // In-memory database should always return true (skip disk check) + hasSpace, err := hasSufficientDiskSpace(":memory:") + if err != nil { + t.Fatalf("unexpected error for :memory: db: %v", err) + } + if !hasSpace { + t.Error("expected true for :memory: database") + } +} + +func TestHasSufficientDiskSpace_EmptyPath(t *testing.T) { + // Empty path should always return true (skip disk check) + hasSpace, err := hasSufficientDiskSpace("") + if err != nil { + t.Fatalf("unexpected error for empty path: %v", err) + } + if !hasSpace { + t.Error("expected true for empty path") + } +} + +func TestHasSufficientDiskSpace_NonExistentDirectory(t *testing.T) { + // Non-existent directory should find parent and check, or return true + tmpDir := t.TempDir() + nonExistentPath := filepath.Join(tmpDir, "does", "not", "exist", "test.db") + + hasSpace, err := hasSufficientDiskSpace(nonExistentPath) + if err != nil { + t.Fatalf("unexpected error for non-existent directory: %v", err) + } + // Should find tmpDir as parent and check disk space there + // Since tmpDir exists and likely has space, should return true + if !hasSpace { + t.Error("expected true for non-existent directory with existing parent") + } +} + +func TestHasSufficientDiskSpace_ExistingDirectory(t *testing.T) { + // Existing directory should work normally + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + hasSpace, err := hasSufficientDiskSpace(dbPath) + if err != nil { + t.Fatalf("unexpected error for existing directory: %v", err) + } + // tmpDir is a real directory with space, should return true + if !hasSpace { + t.Error("expected true for existing directory with space") + } +} + +func TestHasSufficientDiskSpace_Symlink(t *testing.T) { + // Test symlink resolution + tmpDir := t.TempDir() + realDir := filepath.Join(tmpDir, "real") + linkDir := filepath.Join(tmpDir, "link") + + if err := os.Mkdir(realDir, 0755); err != nil { + t.Fatalf("failed to create real directory: %v", err) + } + + if err := os.Symlink(realDir, linkDir); err != nil { + t.Skipf("symlinks not supported on this system: %v", err) + } + + dbPath := filepath.Join(linkDir, "test.db") + hasSpace, err := hasSufficientDiskSpace(dbPath) + if err != nil { + t.Fatalf("unexpected error for symlink path: %v", err) + } + if !hasSpace { + t.Error("expected true for symlink path") + } +} + +func TestHasSufficientDiskSpace_PathIsFile(t *testing.T) { + // If the "directory" is actually a file, should return error + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "somefile") + + // Create a file instead of a directory + if err := os.WriteFile(filePath, []byte("test"), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + // Try to use this file as if it were a directory containing a db + dbPath := filepath.Join(filePath, "test.db") + hasSpace, err := hasSufficientDiskSpace(dbPath) + + // filepath.Dir(dbPath) == filePath, which is a file not a directory + if err == nil { + t.Error("expected error when parent path is a file, not a directory") + } + if hasSpace { + t.Error("expected false when parent path is a file") + } +} + +func TestFindExistingParent(t *testing.T) { + tmpDir := t.TempDir() + + tests := []struct { + name string + dir string + expected string + }{ + { + name: "non-existent nested path", + dir: filepath.Join(tmpDir, "a", "b", "c"), + expected: tmpDir, + }, + { + name: "existing directory", + dir: tmpDir, + expected: filepath.Dir(tmpDir), // Parent of tmpDir + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := findExistingParent(tc.dir) + if tc.expected != "" && result == "" { + t.Errorf("expected to find parent, got empty string") + } + if result != "" { + // Verify the returned path exists + if _, err := os.Stat(result); err != nil { + t.Errorf("returned parent %q does not exist: %v", result, err) + } + } + }) + } +} + +func TestFindExistingParent_RootPath(t *testing.T) { + // Testing with absolute non-existent path + result := findExistingParent("/nonexistent/deeply/nested/path") + // Should eventually find "/" on Unix systems + if result == "" { + t.Log("findExistingParent returned empty for root-relative path (may be expected on some systems)") + } else { + if _, err := os.Stat(result); err != nil { + t.Errorf("returned parent %q does not exist: %v", result, err) + } + } +} + +func TestSetDiskSpaceChecker(t *testing.T) { + // Test that SetDiskSpaceChecker works correctly + originalChecker := diskSpaceCheckerFn + + // Set custom checker + customCalled := false + SetDiskSpaceChecker(func(path string) (bool, error) { + customCalled = true + return false, nil + }) + + // Verify custom checker is called + _, _ = checkDiskSpace("/some/path") + if !customCalled { + t.Error("custom checker was not called") + } + + // Reset to nil (should restore default) + SetDiskSpaceChecker(nil) + + // Verify default is restored + diskSpaceCheckerMu.RLock() + currentChecker := diskSpaceCheckerFn + diskSpaceCheckerMu.RUnlock() + + // Can't directly compare functions, but we can check it's not nil + if currentChecker == nil { + t.Error("checker should not be nil after reset") + } + + // Restore original for other tests + diskSpaceCheckerMu.Lock() + diskSpaceCheckerFn = originalChecker + diskSpaceCheckerMu.Unlock() +} + +func TestFormatBytes(t *testing.T) { + tests := []struct { + bytes uint64 + expected string + }{ + {0, "0 bytes"}, + {512, "512 bytes"}, + {1024, "1.0 KB"}, + {1536, "1.5 KB"}, + {1024 * 1024, "1.0 MB"}, + {1024 * 1024 * 10, "10.0 MB"}, + {1024 * 1024 * 1024, "1.0 GB"}, + {1024 * 1024 * 1024 * 2, "2.0 GB"}, + } + + for _, tc := range tests { + t.Run(tc.expected, func(t *testing.T) { + result := FormatBytes(tc.bytes) + if result != tc.expected { + t.Errorf("FormatBytes(%d) = %q, want %q", tc.bytes, result, tc.expected) + } + }) + } +} + +func TestCheckDiskSpaceWarning_MemoryDB(t *testing.T) { + // In-memory database should not produce warnings + db := &DB{path: ":memory:"} + warning := db.CheckDiskSpaceWarning() + if warning != nil { + t.Error("expected no warning for :memory: database") + } +} + +func TestCheckDiskSpaceWarning_EmptyPath(t *testing.T) { + // Empty path should not produce warnings + db := &DB{path: ""} + warning := db.CheckDiskSpaceWarning() + if warning != nil { + t.Error("expected no warning for empty path") + } +} + +func TestDiskSpaceThresholds(t *testing.T) { + // Verify threshold constants are sensible + if minFreeDiskBytes >= warnFreeDiskBytes { + t.Errorf("minFreeDiskBytes (%d) should be less than warnFreeDiskBytes (%d)", + minFreeDiskBytes, warnFreeDiskBytes) + } + + // Verify minimum is at least 1MB + if minFreeDiskBytes < 1024*1024 { + t.Errorf("minFreeDiskBytes (%d) should be at least 1MB", minFreeDiskBytes) + } + + // Verify warning threshold is reasonable (at least 5MB) + if warnFreeDiskBytes < 5*1024*1024 { + t.Errorf("warnFreeDiskBytes (%d) should be at least 5MB", warnFreeDiskBytes) + } +} diff --git a/pkg/histree/disk_unix.go b/pkg/histree/disk_unix.go new file mode 100644 index 0000000..d7c66c4 --- /dev/null +++ b/pkg/histree/disk_unix.go @@ -0,0 +1,45 @@ +//go:build !windows + +package histree + +import ( + "fmt" + "syscall" +) + +// Disk space thresholds for history recording. +// SQLite with WAL mode needs space for the main DB file, WAL file (.wal), and +// shared memory file (.shm). +const ( + // minFreeDiskBytes is the minimum required - below this, writes are blocked + minFreeDiskBytes = 1 * 1024 * 1024 // 1MB + + // warnFreeDiskBytes is the warning threshold - below this, a warning is shown + warnFreeDiskBytes = 10 * 1024 * 1024 // 10MB +) + +func platformHasSufficientDiskSpace(dir string) (bool, error) { + availableBytes, err := platformGetAvailableDiskSpace(dir) + if err != nil { + return false, err + } + return availableBytes >= minFreeDiskBytes, nil +} + +func platformGetAvailableDiskSpace(dir string) (uint64, error) { + var stat syscall.Statfs_t + if err := syscall.Statfs(dir, &stat); err != nil { + return 0, fmt.Errorf("failed to stat filesystem: %w", err) + } + + // Calculate available bytes: available blocks * block size + return uint64(stat.Bavail) * uint64(stat.Bsize), nil +} + +func platformShouldWarnDiskSpace(dir string) (bool, uint64, error) { + availableBytes, err := platformGetAvailableDiskSpace(dir) + if err != nil { + return false, 0, err + } + return availableBytes < warnFreeDiskBytes && availableBytes >= minFreeDiskBytes, availableBytes, nil +} diff --git a/pkg/histree/disk_windows.go b/pkg/histree/disk_windows.go new file mode 100644 index 0000000..bb11e1f --- /dev/null +++ b/pkg/histree/disk_windows.go @@ -0,0 +1,49 @@ +//go:build windows + +package histree + +import ( + "fmt" + "syscall" +) + +// Disk space thresholds for history recording. +// SQLite with WAL mode needs space for the main DB file, WAL file (.wal), and +// shared memory file (.shm). +const ( + // minFreeDiskBytes is the minimum required - below this, writes are blocked + minFreeDiskBytes = 1 * 1024 * 1024 // 1MB + + // warnFreeDiskBytes is the warning threshold - below this, a warning is shown + warnFreeDiskBytes = 10 * 1024 * 1024 // 10MB +) + +func platformHasSufficientDiskSpace(dir string) (bool, error) { + availableBytes, err := platformGetAvailableDiskSpace(dir) + if err != nil { + return false, err + } + return availableBytes >= minFreeDiskBytes, nil +} + +func platformGetAvailableDiskSpace(dir string) (uint64, error) { + pathPtr, err := syscall.UTF16PtrFromString(dir) + if err != nil { + return 0, fmt.Errorf("failed to encode path for disk query: %w", err) + } + + var freeBytesAvailable uint64 + if err := syscall.GetDiskFreeSpaceEx(pathPtr, &freeBytesAvailable, nil, nil); err != nil { + return 0, fmt.Errorf("failed to query free disk space: %w", err) + } + + return freeBytesAvailable, nil +} + +func platformShouldWarnDiskSpace(dir string) (bool, uint64, error) { + availableBytes, err := platformGetAvailableDiskSpace(dir) + if err != nil { + return false, 0, err + } + return availableBytes < warnFreeDiskBytes && availableBytes >= minFreeDiskBytes, availableBytes, nil +} diff --git a/pkg/histree/histree.go b/pkg/histree/histree.go index 6ab05c9..1ebec33 100644 --- a/pkg/histree/histree.go +++ b/pkg/histree/histree.go @@ -3,7 +3,11 @@ package histree import ( "database/sql" + "errors" "fmt" + "os" + "path/filepath" + "sync" "time" _ "github.com/mattn/go-sqlite3" @@ -39,10 +43,20 @@ type HistoryEntry struct { // DB represents a histree database connection type DB struct { *sql.DB + path string } // OpenDB initializes and returns a new database connection func OpenDB(dbPath string) (*DB, error) { + dir := filepath.Dir(dbPath) + if dir == "" { + dir = "." + } + + if err := ensureDirExists(dir); err != nil { + return nil, err + } + db, err := sql.Open("sqlite3", dbPath) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) @@ -58,7 +72,7 @@ func OpenDB(dbPath string) (*DB, error) { return nil, err } - return &DB{db}, nil + return &DB{DB: db, path: dbPath}, nil } // Close closes the database connection @@ -66,6 +80,74 @@ func (db *DB) Close() error { return db.DB.Close() } +// CheckDiskSpaceWarning checks if disk space is running low (but not yet critical). +// Returns a warning if space is below the warning threshold but above the critical threshold. +// Returns nil if there's plenty of space or if the check cannot be performed. +func (db *DB) CheckDiskSpaceWarning() *DiskSpaceWarning { + // Skip for in-memory databases + if db.path == ":memory:" || db.path == "" { + return nil + } + + dir := filepath.Dir(db.path) + if dir == "" { + dir = "." + } + + // Try to resolve symlinks + if resolvedDir, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolvedDir + } + + // Check if directory exists + if _, err := os.Stat(dir); err != nil { + return nil // Can't check, don't warn + } + + shouldWarn, availableBytes, err := platformShouldWarnDiskSpace(dir) + if err != nil { + return nil // Can't check, don't warn + } + + if shouldWarn { + return &DiskSpaceWarning{AvailableBytes: availableBytes} + } + return nil +} + +// ErrInsufficientDiskSpace indicates that no additional history entries can be recorded +// because the underlying filesystem has no free space available. +var ErrInsufficientDiskSpace = errors.New("insufficient disk space for history entry") + +// DiskSpaceWarning contains information about low disk space conditions. +type DiskSpaceWarning struct { + AvailableBytes uint64 +} + +// FormatBytes returns a human-readable string representation of bytes. +func FormatBytes(bytes uint64) string { + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + ) + switch { + case bytes >= GB: + return fmt.Sprintf("%.1f GB", float64(bytes)/GB) + case bytes >= MB: + return fmt.Sprintf("%.1f MB", float64(bytes)/MB) + case bytes >= KB: + return fmt.Sprintf("%.1f KB", float64(bytes)/KB) + default: + return fmt.Sprintf("%d bytes", bytes) + } +} + +var ( + diskSpaceCheckerMu sync.RWMutex + diskSpaceCheckerFn = hasSufficientDiskSpace +) + func setPragmas(db *sql.DB) error { _, err := db.Exec(` PRAGMA journal_mode = WAL; @@ -137,7 +219,16 @@ func createIndexes(tx *sql.Tx) error { // AddEntry adds a new command history entry to the database func (db *DB) AddEntry(entry *HistoryEntry) error { - _, err := db.Exec( + hasSpace, err := checkDiskSpace(db.path) + if err != nil { + return fmt.Errorf("failed to check disk space: %w", err) + } + + if !hasSpace { + return fmt.Errorf("%w: unable to record command history entry in %s", ErrInsufficientDiskSpace, db.path) + } + + if _, err := db.Exec( "INSERT INTO history (command, directory, timestamp, exit_code, hostname, process_id) VALUES (?, ?, ?, ?, ?, ?)", entry.Command, entry.Directory, @@ -145,12 +236,114 @@ func (db *DB) AddEntry(entry *HistoryEntry) error { entry.ExitCode, entry.Hostname, entry.ProcessID, - ) - if err != nil { + ); err != nil { return fmt.Errorf("failed to insert entry: %w", err) } + return nil } +func checkDiskSpace(dbPath string) (bool, error) { + diskSpaceCheckerMu.RLock() + checker := diskSpaceCheckerFn + diskSpaceCheckerMu.RUnlock() + return checker(dbPath) +} + +// SetDiskSpaceChecker allows tests to override the disk space check logic. +// Passing nil restores the default checker. +func SetDiskSpaceChecker(fn func(string) (bool, error)) { + diskSpaceCheckerMu.Lock() + defer diskSpaceCheckerMu.Unlock() + if fn == nil { + diskSpaceCheckerFn = hasSufficientDiskSpace + return + } + diskSpaceCheckerFn = fn +} + +func hasSufficientDiskSpace(dbPath string) (bool, error) { + // Skip disk space check for in-memory databases + if dbPath == ":memory:" || dbPath == "" { + return true, nil + } + + dir := filepath.Dir(dbPath) + if dir == "" { + dir = "." + } + + // Try to resolve symlinks to check the actual filesystem + if resolvedDir, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolvedDir + } + // If symlink resolution fails, continue with original path + + info, err := os.Stat(dir) + switch { + case err == nil: + if !info.IsDir() { + // If the path resolves to a file instead of a directory, proceeding would corrupt the + // database; surface an error immediately. + return false, fmt.Errorf("path %s is not a directory", dir) + } + case os.IsNotExist(err): + // Directory doesn't exist (possibly deleted after OpenDB). + // Try to find an existing parent directory to check disk space. + if parent := findExistingParent(dir); parent != "" { + dir = parent + } else { + // Cannot determine disk space - allow write to avoid blocking history + return true, nil + } + default: + // Other errors (permission denied, etc.) - allow write to avoid blocking history + // as history recording is not a critical operation + return true, nil + } + + hasSpace, err := platformHasSufficientDiskSpace(dir) + if err != nil { + // If we can't determine disk space, allow write rather than blocking + return true, nil + } + return hasSpace, nil +} + +// findExistingParent traverses up the directory tree to find an existing parent directory. +// Returns empty string if no existing parent can be found. +func findExistingParent(dir string) string { + for { + parent := filepath.Dir(dir) + if parent == dir { + // Reached root or can't go higher + break + } + if info, err := os.Stat(parent); err == nil && info.IsDir() { + return parent + } + dir = parent + } + return "" +} + +func ensureDirExists(dir string) error { + info, err := os.Stat(dir) + if err == nil { + if !info.IsDir() { + return fmt.Errorf("path %s is not a directory", dir) + } + return nil + } + + if os.IsNotExist(err) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + return nil + } + + return fmt.Errorf("failed to access directory %s: %w", dir, err) +} // UpdatePaths updates directory paths in history entries from oldPath to newPath func (db *DB) UpdatePaths(oldPath, newPath string) (int64, error) {