diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 9fdd6e12d9..a243531893 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -1253,6 +1253,7 @@ async fn read_binary_file_context( let content = match read_file_as_binary(path).await { Ok(content) => content, Err(FileLoadError::DoesNotExist) => return Ok(BinaryFileReadResult::Missing), + Err(FileLoadError::FileTooLarge { .. }) => return Ok(BinaryFileReadResult::Missing), Err(FileLoadError::IOError(e)) => return Err(anyhow::anyhow!(e)), }; diff --git a/app/src/code/global_buffer_model.rs b/app/src/code/global_buffer_model.rs index 3a0373e60e..144052fea9 100644 --- a/app/src/code/global_buffer_model.rs +++ b/app/src/code/global_buffer_model.rs @@ -16,6 +16,7 @@ use warp_editor::content::buffer::{Buffer, ToBufferCharOffset}; use warp_editor::content::diff::{text_diff, TextDiff}; use warp_editor::content::edit::PreciseDelta; use warp_editor::content::version::BufferVersion; +use warp_files::MAX_EDITOR_FILE_SIZE; use warp_util::content_version::ContentVersion; use warp_util::file::{FileId, FileLoadError, FileSaveError}; use warp_util::host_id::HostId; @@ -491,6 +492,30 @@ impl GlobalBufferModel { is_initial_load: bool, ctx: &mut ModelContext, ) { + // Safety-net: reject content that exceeds the editor file size limit. + // The primary guard lives in FileModel (which checks file size before + // reading), but this catches content that arrives via other paths + // (e.g. remote buffers, auto-reload race) (APP-4519). + if content.len() as u64 > MAX_EDITOR_FILE_SIZE { + safe_error!( + safe: ("Refusing to populate buffer: content exceeds size limit"), + full: ("Refusing to populate buffer: content is {} bytes, limit is {} bytes", + content.len(), MAX_EDITOR_FILE_SIZE) + ); + ctx.emit(GlobalBufferModelEvent::FailedToLoad { + file_id, + error: Rc::new(FileLoadError::FileTooLarge { + path: self + .file_path(file_id) + .map(Path::to_path_buf) + .unwrap_or_default(), + size: content.len() as u64, + limit: MAX_EDITOR_FILE_SIZE, + }), + }); + return; + } + let Some(state) = self.buffers.get_mut(&file_id) else { return; }; @@ -1829,6 +1854,23 @@ impl GlobalBufferModel { content.len(), server_version, ); + // Reject oversized remote buffer content (APP-4519). + if content.len() as u64 > MAX_EDITOR_FILE_SIZE { + safe_error!( + safe: ("[remote-buffer] Content exceeds size limit"), + full: ("[remote-buffer] Content is {} bytes, limit is {} bytes", + content.len(), MAX_EDITOR_FILE_SIZE) + ); + ctx.emit(GlobalBufferModelEvent::FailedToLoad { + file_id, + error: Rc::new(FileLoadError::FileTooLarge { + path: std::path::PathBuf::new(), + size: content.len() as u64, + limit: MAX_EDITOR_FILE_SIZE, + }), + }); + return; + } let Some(state) = self.buffers.get_mut(&file_id) else { safe_error!( safe: ("[remote-buffer] Buffer state missing after OpenBuffer response"), diff --git a/crates/warp_files/src/lib.rs b/crates/warp_files/src/lib.rs index 2841fbe5dc..26b2a3388f 100644 --- a/crates/warp_files/src/lib.rs +++ b/crates/warp_files/src/lib.rs @@ -39,6 +39,13 @@ use watcher::{BulkFilesystemWatcher, BulkFilesystemWatcherEvent}; pub mod text_file_reader; pub use text_file_reader::{TextFileReadResult, TextFileSegment}; +/// Maximum file size (in bytes) that the editor will load into a buffer. +/// Files larger than this are rejected with `FileLoadError::FileTooLarge` +/// to prevent multi-gigabyte memory spikes from tree-sitter parsing and +/// SumTree allocation. 50 MiB is generous for source code and catches +/// accidental opens of binaries, logs, and data dumps. +pub const MAX_EDITOR_FILE_SIZE: u64 = 50 * 1024 * 1024; + #[derive(Debug)] pub enum FileModelEvent { FileLoaded { @@ -419,6 +426,20 @@ impl FileModel { let use_individual_watcher = watcher_type == WatcherType::Individual; let future = ctx.spawn( async move { + // Check file size before reading to avoid multi-gigabyte allocations + // for oversized files (APP-4519). + if let Ok(metadata) = async_fs::metadata(&file_path_buf).await { + if metadata.len() > MAX_EDITOR_FILE_SIZE { + return ( + file_id, + Err(FileLoadError::FileTooLarge { + path: file_path_buf, + size: metadata.len(), + limit: MAX_EDITOR_FILE_SIZE, + }), + ); + } + } let contents = async_fs::read_to_string(&file_path_buf) .await .map_err(FileLoadError::from); @@ -472,8 +493,15 @@ impl FileModel { } pub async fn read_content_for_file(file_path: &Path) -> Result { - if !Self::file_exists(file_path).await { - return Err(FileLoadError::DoesNotExist); + let metadata = async_fs::metadata(file_path) + .await + .map_err(|_| FileLoadError::DoesNotExist)?; + if metadata.len() > MAX_EDITOR_FILE_SIZE { + return Err(FileLoadError::FileTooLarge { + path: file_path.to_path_buf(), + size: metadata.len(), + limit: MAX_EDITOR_FILE_SIZE, + }); } async_fs::read_to_string(file_path) .await @@ -1109,6 +1137,18 @@ impl FileModel { async move { let mut res = Vec::new(); for file_path in matching_files { + // Skip files exceeding the editor size limit to avoid + // memory spikes during auto-reload (APP-4519). + let too_large = async_fs::metadata(&file_path) + .await + .is_ok_and(|m| m.len() > MAX_EDITOR_FILE_SIZE); + if too_large { + log::warn!( + "Skipping auto-reload for oversized file: {}", + file_path.display() + ); + continue; + } if let Ok(content) = async_fs::read_to_string(&file_path).await { res.push((file_path, content)); } diff --git a/crates/warp_util/src/file.rs b/crates/warp_util/src/file.rs index 0dd08f5d3a..7a3ebd43b3 100644 --- a/crates/warp_util/src/file.rs +++ b/crates/warp_util/src/file.rs @@ -22,6 +22,12 @@ pub enum FileSaveError { pub enum FileLoadError { #[error("File does not exist")] DoesNotExist, + #[error("File is too large to open in the editor ({size} bytes, limit is {limit} bytes)")] + FileTooLarge { + path: PathBuf, + size: u64, + limit: u64, + }, #[error("IO error when loading file.")] IOError(#[from] io::Error), }