package util import ( "errors" "fmt" "os" "path/filepath" "strings" "syscall" ) type PermissionCheck struct { Path string `json:"path"` Required string `json:"required"` Exists bool `json:"exists"` Writable bool `json:"writable"` OwnerUID int `json:"owner_uid"` OwnerGID int `json:"owner_gid"` Mode string `json:"mode"` Error string `json:"error,omitempty"` ErrorCode string `json:"error_code,omitempty"` } func CheckPathPermissions(path, required string) PermissionCheck { result := PermissionCheck{ Path: path, Required: required, } if strings.ContainsRune(path, '\x00') { result.Writable = false result.Error = "invalid path" result.ErrorCode = "permissions_invalid_path" return result } cleanPath := filepath.Clean(path) linkInfo, linkErr := os.Lstat(cleanPath) if linkErr != nil { result.Writable = false result.Error = linkErr.Error() result.ErrorCode = MapDiagnosticErrorCode(linkErr) return result } if linkInfo.Mode()&os.ModeSymlink != 0 { result.Writable = false result.Error = "symlink paths are not supported" result.ErrorCode = "permissions_unsupported_type" return result } info, err := os.Stat(cleanPath) if err != nil { result.Writable = false result.Error = err.Error() result.ErrorCode = MapDiagnosticErrorCode(err) return result } result.Exists = true if stat, ok := info.Sys().(*syscall.Stat_t); ok { result.OwnerUID = int(stat.Uid) result.OwnerGID = int(stat.Gid) } result.Mode = fmt.Sprintf("%04o", info.Mode().Perm()) if !info.IsDir() && !info.Mode().IsRegular() { result.Writable = false result.Error = "unsupported file type" result.ErrorCode = "permissions_unsupported_type" return result } if strings.Contains(required, "w") { if info.IsDir() { probeFile, probeErr := os.CreateTemp(cleanPath, "permcheck-*") if probeErr != nil { result.Writable = false result.Error = probeErr.Error() result.ErrorCode = MapDiagnosticErrorCode(probeErr) return result } if closeErr := probeFile.Close(); closeErr != nil { result.Writable = false result.Error = closeErr.Error() result.ErrorCode = MapDiagnosticErrorCode(closeErr) return result } if removeErr := os.Remove(probeFile.Name()); removeErr != nil { result.Writable = false result.Error = removeErr.Error() result.ErrorCode = MapDiagnosticErrorCode(removeErr) return result } result.Writable = true return result } file, openErr := os.OpenFile(cleanPath, os.O_WRONLY, 0) // #nosec G304 -- cleanPath is normalized, existence-checked, non-symlink, and regular-file validated above. if openErr != nil { result.Writable = false result.Error = openErr.Error() result.ErrorCode = MapDiagnosticErrorCode(openErr) return result } if closeErr := file.Close(); closeErr != nil { result.Writable = false result.Error = closeErr.Error() result.ErrorCode = MapDiagnosticErrorCode(closeErr) return result } result.Writable = true return result } result.Writable = false return result } func MapDiagnosticErrorCode(err error) string { switch { case err == nil: return "" case os.IsNotExist(err): return "permissions_missing_path" case errors.Is(err, syscall.EROFS): return "permissions_readonly" case errors.Is(err, syscall.EACCES) || os.IsPermission(err): return "permissions_write_denied" default: return "permissions_write_failed" } } func MapSaveErrorCode(err error) (string, bool) { switch { case err == nil: return "", false case IsSQLiteReadOnlyError(err): return "permissions_db_readonly", true case IsSQLiteLockedError(err): return "permissions_db_locked", true case errors.Is(err, syscall.EROFS): return "permissions_readonly", true case errors.Is(err, syscall.EACCES) || os.IsPermission(err): return "permissions_write_denied", true case strings.Contains(strings.ToLower(err.Error()), "permission denied"): return "permissions_write_denied", true default: return "", false } } func IsSQLiteReadOnlyError(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "readonly") || strings.Contains(msg, "read-only") || strings.Contains(msg, "attempt to write a readonly database") || strings.Contains(msg, "sqlite_readonly") } func IsSQLiteLockedError(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "database is locked") || strings.Contains(msg, "sqlite_busy") || strings.Contains(msg, "database locked") }