diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 64c2c47e598..47973735619 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -1361,6 +1361,15 @@ async fn read_binary_file_context( Ok(content) => content, Err(FileLoadError::DoesNotExist) => return Ok(BinaryFileReadResult::NotFound), Err(FileLoadError::IOError(e)) => return Err(anyhow::anyhow!(e)), + Err(FileLoadError::TooLarge { + size_estimate, + limit_bytes, + }) => { + return Ok(BinaryFileReadResult::TooLarge { + size_bytes: size_estimate.unwrap_or(limit_bytes.saturating_add(1)) as usize, + limit_bytes: limit_bytes as usize, + }); + } }; let mime_type = from_path(path).first_or_octet_stream().to_string(); diff --git a/app/src/code/global_buffer_model.rs b/app/src/code/global_buffer_model.rs index 91b21f54d41..5620770392d 100644 --- a/app/src/code/global_buffer_model.rs +++ b/app/src/code/global_buffer_model.rs @@ -31,7 +31,7 @@ use super::buffer_location::{LocalOrRemotePath, SyncClock}; cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { use lsp::LspManagerModelEvent; - use warp_files::{FileModelEvent, FileModel}; + use warp_files::{FileModelEvent, FileModel, MAX_LOADABLE_FILE_SIZE_BYTES}; use warp_editor::content::text::IndentBehavior; use warp_editor::content::text::IndentUnit; use warp_editor::content::buffer::EditOrigin; @@ -493,6 +493,19 @@ impl GlobalBufferModel { let Some(state) = self.buffers.get_mut(&file_id) else { return; }; + #[cfg(feature = "local_fs")] + { + if content.len() as u64 > MAX_LOADABLE_FILE_SIZE_BYTES { + ctx.emit(GlobalBufferModelEvent::FailedToLoad { + file_id, + error: Rc::new(FileLoadError::TooLarge { + size_estimate: Some(content.len() as u64), + limit_bytes: MAX_LOADABLE_FILE_SIZE_BYTES, + }), + }); + return; + } + } let Some(buffer) = state.buffer.upgrade(ctx) else { self.cleanup_file_id(file_id, ctx); @@ -709,6 +722,20 @@ impl GlobalBufferModel { base_version, new_version, } => { + if self.buffers.get(id).is_some_and(|state| !state.is_loaded()) { + if let Some(state) = self.buffers.get_mut(id) { + state.set_initial_content_version(*new_version); + } + self.populate_buffer_with_read_content( + *id, + content, + *base_version, + *new_version, + true, + ctx, + ); + return; + } if let Some(buffer) = self.buffer_handle_for_id(*id, ctx) { if buffer.as_ref(ctx).version_match(base_version) { self.populate_buffer_with_read_content( diff --git a/app/src/code/global_buffer_model_tests.rs b/app/src/code/global_buffer_model_tests.rs index de24c375644..fa71a0df0d6 100644 --- a/app/src/code/global_buffer_model_tests.rs +++ b/app/src/code/global_buffer_model_tests.rs @@ -1,15 +1,23 @@ +use std::cell::RefCell; +use std::rc::Rc; + use lsp::LspManagerModel; use remote_server::proto::TextEdit; use repo_metadata::RepoMetadataModel; use repo_metadata::repositories::DetectedRepositories; use repo_metadata::watcher::DirectoryWatcher; -use warp_files::FileModel; +use warp_editor::content::buffer::Buffer; +use warp_files::{FileModel, FileModelEvent, MAX_LOADABLE_FILE_SIZE_BYTES}; use warp_util::content_version::ContentVersion; +use warp_util::file::{FileId, FileLoadError}; use warp_util::host_id::HostId; use warp_util::standardized_path::StandardizedPath; use warpui::{App, ModelHandle, SingletonEntity}; -use super::{BufferSource, CharOffsetEdit, GlobalBufferModel, PendingEditBatch}; +use super::{ + BufferSource, CharOffsetEdit, GlobalBufferModel, GlobalBufferModelEvent, InternalBufferState, + PendingEditBatch, +}; use crate::test_util::settings::initialize_settings_for_tests; // ── Test-only helpers on GlobalBufferModel ──────────────────────── @@ -122,6 +130,117 @@ fn test_path() -> StandardizedPath { StandardizedPath::try_new("/test/file.txt").unwrap() } +fn seed_local_buffer(app: &mut App, content: &str, loaded: bool) -> (FileId, ModelHandle) { + let buffer = app.add_model(|_| Buffer::default()); + let version = ContentVersion::new(); + if !content.is_empty() { + buffer.update(app, |buffer, ctx| { + buffer.replace_all(content, ctx); + buffer.set_version(version); + }); + } + + let file_id = FileId::new(); + gbm(app).update(app, |model, _| { + model.buffers.insert( + file_id, + InternalBufferState { + buffer: buffer.downgrade(), + latest_buffer_version: None, + pending_diff_parse: None, + source: BufferSource::Local { + base_content_version: loaded.then_some(version), + initial_content_version: loaded.then_some(version), + }, + }, + ); + }); + (file_id, buffer) +} + +#[test] +fn oversized_content_is_rejected_before_initial_and_fallback_population() { + App::test((), |mut app| async move { + init_app(&mut app); + app.add_singleton_model(GlobalBufferModel::new); + let (initial_id, initial_buffer) = seed_local_buffer(&mut app, "", false); + let (fallback_id, fallback_buffer) = seed_local_buffer(&mut app, "preserved", true); + let oversized = "x".repeat(MAX_LOADABLE_FILE_SIZE_BYTES as usize + 1); + let version = ContentVersion::new(); + let failed_file_ids = Rc::new(RefCell::new(Vec::new())); + let global_buffer = gbm(&app); + app.update(|ctx| { + let failed_file_ids = failed_file_ids.clone(); + ctx.subscribe_to_model(&global_buffer, move |_, event, _| { + if let GlobalBufferModelEvent::FailedToLoad { file_id, error } = event { + assert!(matches!(error.as_ref(), FileLoadError::TooLarge { .. })); + failed_file_ids.borrow_mut().push(*file_id); + } + }); + }); + + gbm(&app).update(&mut app, |model, ctx| { + model.populate_buffer_with_read_content( + initial_id, &oversized, version, version, true, ctx, + ); + model.populate_buffer_with_read_content( + fallback_id, + &oversized, + version, + version, + false, + ctx, + ); + }); + + app.read(|ctx| { + assert_eq!(initial_buffer.as_ref(ctx).text().into_string(), ""); + assert_eq!( + fallback_buffer.as_ref(ctx).text().into_string(), + "preserved" + ); + }); + let model = gbm(&app); + app.read(|ctx| { + assert!(!model.as_ref(ctx).buffer_loaded(initial_id)); + assert!(model.as_ref(ctx).buffer_loaded(fallback_id)); + }); + assert_eq!( + failed_file_ids.borrow().as_slice(), + &[initial_id, fallback_id] + ); + }) +} + +#[test] +fn first_file_update_after_load_failure_populates_buffer() { + App::test((), |mut app| async move { + init_app(&mut app); + app.add_singleton_model(GlobalBufferModel::new); + let (file_id, buffer) = seed_local_buffer(&mut app, "", false); + let base_version = ContentVersion::new(); + let new_version = ContentVersion::new(); + let event = FileModelEvent::FileUpdated { + id: file_id, + content: "now loadable".to_string(), + base_version, + new_version, + }; + let files = FileModel::handle(&app); + + gbm(&app).update(&mut app, |model, ctx| { + model.handle_file_model_events(files, &event, ctx); + }); + let global_buffer = gbm(&app); + + app.read(|ctx| { + assert_eq!(buffer.as_ref(ctx).text().into_string(), "now loadable"); + assert_eq!(buffer.as_ref(ctx).version(), new_version); + assert!(global_buffer.as_ref(ctx).buffer_loaded(file_id)); + }); + }) +} + // ── Pending edit batch: discard on server push ─────────────────── #[test] diff --git a/app/src/code/mod.rs b/app/src/code/mod.rs index 30915e8556e..2b76c9c9ed5 100644 --- a/app/src/code/mod.rs +++ b/app/src/code/mod.rs @@ -4,7 +4,7 @@ use std::ops::AddAssign; use pathfinder_geometry::rect::RectF; use warp_errors::{ErrorExt, register_error}; -use warp_util::file::FileSaveError; +use warp_util::file::{FileLoadError, FileSaveError}; use warpui::AppContext; use warpui::elements::DropTargetData; @@ -53,6 +53,42 @@ impl ErrorExt for ImmediateSaveError { } register_error!(ImmediateSaveError); +pub(crate) fn file_load_error_message(error: &FileLoadError) -> String { + match error { + FileLoadError::TooLarge { + size_estimate, + limit_bytes, + } => { + let limit = format_file_size(*limit_bytes); + match size_estimate { + Some(size) => format!( + "File is larger than the {limit} limit (reported size ~{}).", + format_file_size(*size) + ), + None => format!("File is larger than the {limit} limit."), + } + } + FileLoadError::DoesNotExist | FileLoadError::IOError(_) => { + "Failed to load file.".to_string() + } + } +} + +fn format_file_size(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut size = bytes as f64; + let mut unit_index = 0; + while size >= 1024.0 && unit_index < UNITS.len() - 1 { + size /= 1024.0; + unit_index += 1; + } + if unit_index == 0 { + format!("{bytes} {}", UNITS[unit_index]) + } else { + format!("{size:.1} {}", UNITS[unit_index]) + } +} + /// Trait to determine whether we should show the comment editor based on state held /// by the parent of the [`CodeEditorView`]. pub trait ShowCommentEditorProvider: Debug + 'static { @@ -171,3 +207,7 @@ impl DropTargetData for EditorTabBarDropTargetData { self } } + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/app/src/code/mod_tests.rs b/app/src/code/mod_tests.rs new file mode 100644 index 00000000000..f54c1754362 --- /dev/null +++ b/app/src/code/mod_tests.rs @@ -0,0 +1,29 @@ +use std::io; + +use super::{FileLoadError, file_load_error_message}; + +#[test] +fn file_load_error_message_describes_oversized_files() { + assert_eq!( + file_load_error_message(&FileLoadError::TooLarge { + size_estimate: Some(2 * 1024 * 1024 * 1024), + limit_bytes: 100 * 1024 * 1024, + }), + "File is larger than the 100.0 MiB limit (reported size ~2.0 GiB)." + ); + assert_eq!( + file_load_error_message(&FileLoadError::TooLarge { + size_estimate: None, + limit_bytes: 100 * 1024 * 1024, + }), + "File is larger than the 100.0 MiB limit." + ); +} + +#[test] +fn file_load_error_message_keeps_generic_io_failure_copy() { + assert_eq!( + file_load_error_message(&FileLoadError::IOError(io::Error::other("failure"))), + "Failed to load file." + ); +} diff --git a/app/src/code/view.rs b/app/src/code/view.rs index b293c8688e3..a6fbbaae1a4 100644 --- a/app/src/code/view.rs +++ b/app/src/code/view.rs @@ -10,6 +10,7 @@ use warp_core::features::FeatureFlag; use warp_core::ui::appearance::Appearance; use warp_core::ui::icons::ICON_DIMENSIONS; use warp_editor::render::element::VerticalExpansionBehavior; +use warp_util::file::FileLoadError; use warp_util::path::LineAndColumnArg; #[cfg(feature = "local_fs")] use warpui::clipboard::ClipboardContent; @@ -41,7 +42,10 @@ use crate::code::editor::view::CodeEditorRenderOptions; use crate::code::editor_management::CodeEditorStatus; use crate::code::global_buffer_model::GlobalBufferModel; use crate::code::local_code_editor::ShowFindReferencesCard; -use crate::code::{EditorTabBarDropTargetData, ImmediateSaveError, SaveOutcome, SaveStatus}; +use crate::code::{ + EditorTabBarDropTargetData, ImmediateSaveError, SaveOutcome, SaveStatus, + file_load_error_message, +}; use crate::editor::InteractionState; use crate::input::Vector2F; use crate::menu::{MenuItem, MenuItemFields}; @@ -516,7 +520,7 @@ impl CodeView { return; } log::warn!("Failed to load file. {err:?}"); - CodeView::display_load_failure(ctx.window_id(), ctx); + CodeView::display_load_failure(ctx.window_id(), err, ctx); } LocalCodeEditorEvent::SelectionAddedAsContext { relative_file_path, @@ -943,9 +947,13 @@ impl CodeView { } } - fn display_load_failure(window_id: WindowId, ctx: &mut ViewContext) { + fn display_load_failure( + window_id: WindowId, + error: &FileLoadError, + ctx: &mut ViewContext, + ) { ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { - let toast = DismissibleToast::error(String::from("Failed to load file.")) + let toast = DismissibleToast::error(file_load_error_message(error)) .with_object_id("failed_to_load_file".to_string()); toast_stack.add_ephemeral_toast(toast, window_id, ctx); }); diff --git a/app/src/code_review/code_review_view.rs b/app/src/code_review/code_review_view.rs index 29beae71810..9d8f99eb793 100644 --- a/app/src/code_review/code_review_view.rs +++ b/app/src/code_review/code_review_view.rs @@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet}; use std::mem; use std::ops::Range; use std::path::{Path, PathBuf}; +use std::rc::Rc; use std::sync::Arc; use std::time::Duration; @@ -27,6 +28,7 @@ use warp_editor::render::element::VerticalExpansionBehavior; use warp_editor::render::model::AutoScrollMode; use warp_editor::render::model::LineCount; use warp_util::content_version::ContentVersion; +use warp_util::file::FileLoadError; use warp_util::path::LineAndColumnArg; use warp_util::standardized_path::StandardizedPath; use warpui::clipboard::ClipboardContent; @@ -67,7 +69,6 @@ use crate::ai::agent::{ }; use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; use crate::appearance::Appearance; -use crate::code::ShowCommentEditorProvider; #[cfg(not(target_family = "wasm"))] use crate::code::ShowFindReferencesCard; use crate::code::buffer_location::LocalOrRemotePath; @@ -85,6 +86,7 @@ use crate::code::local_code_editor::{ LocalCodeEditorEvent, LocalCodeEditorView, render_unsaved_circle_with_tooltip, }; use crate::code::view::PendingSaveIntent; +use crate::code::{ShowCommentEditorProvider, file_load_error_message}; use crate::code_review::DiffSetScope; use crate::code_review::comments::{ AttachedReviewCommentTarget, CommentId, ReviewCommentBatch, ReviewCommentBatchEvent, @@ -3206,11 +3208,12 @@ impl CodeReviewView { ctx.notify(); } LocalCodeEditorEvent::FailedToSave { .. } => {} - LocalCodeEditorEvent::DelayedRenderingFlushed - | LocalCodeEditorEvent::FailedToLoad { .. } => { - // Mark the editor as loaded so we can render it. - // This is only relevant for global buffer mode. - self.mark_editor_loaded_for_file(file_location, ctx); + LocalCodeEditorEvent::DelayedRenderingFlushed => { + self.mark_editor_loaded_for_file(file_location, None, ctx); + ctx.notify(); + } + LocalCodeEditorEvent::FailedToLoad { error } => { + self.mark_editor_loaded_for_file(file_location, Some(error.clone()), ctx); ctx.notify(); } LocalCodeEditorEvent::SelectionAddedAsContext { @@ -3362,11 +3365,10 @@ impl CodeReviewView { }) } - /// Marks the editor for the given file location as loaded. - /// This is called when LocalCodeEditorEvent::DelayedRenderingFlushed or FailedToLoad fires. fn mark_editor_loaded_for_file( &mut self, file_location: &LocalOrRemotePath, + error: Option>, ctx: &mut ViewContext, ) { let Some(file_index) = self.file_state_index_for_location(file_location) else { @@ -3383,7 +3385,7 @@ impl CodeReviewView { if let Some((_, file_state)) = loaded_state.file_states.get_index_mut(file_index) && let Some(editor_state) = &mut file_state.editor_state { - editor_state.set_loaded(); + editor_state.set_load_result(error); } if self.all_editors_loaded() { @@ -3409,7 +3411,7 @@ impl CodeReviewView { .file_states .values() .filter_map(|file_state| file_state.editor_state.as_ref()) - .all(|editor_state| editor_state.is_loaded()) + .all(|editor_state| editor_state.load_finished()) } fn apply_diff_to_code_editor( @@ -5229,6 +5231,22 @@ impl CodeReviewView { theme, ); } + if let Some(error) = file + .editor_state + .as_ref() + .and_then(CodeReviewEditorState::load_error) + { + return Self::styled_file_content_container( + Text::new( + file_load_error_message(error), + appearance.monospace_font_family(), + appearance.monospace_font_size(), + ) + .with_color(remove_color(appearance)) + .finish(), + theme, + ); + } if file.file_diff.is_binary { Self::styled_file_content_container( diff --git a/app/src/code_review/editor_state.rs b/app/src/code_review/editor_state.rs index 3623dc45f4f..80467d086ea 100644 --- a/app/src/code_review/editor_state.rs +++ b/app/src/code_review/editor_state.rs @@ -1,3 +1,6 @@ +use std::rc::Rc; + +use warp_util::file::FileLoadError; use warpui::elements::MouseStateHandle; use warpui::{AppContext, ViewHandle}; @@ -7,9 +10,8 @@ pub struct CodeReviewEditorState { pub editor: ViewHandle, unsaved_changes_mouse_state: MouseStateHandle, pub(super) editor_mouse_state: MouseStateHandle, - /// Whether the buffer content has been loaded from disk (for global buffer mode). - /// This is set to true when LocalCodeEditorEvent::DelayedRenderingFlushed or FailedToLoad fires. - is_loaded: bool, + load_finished: bool, + load_error: Option>, } impl CodeReviewEditorState { @@ -19,7 +21,8 @@ impl CodeReviewEditorState { editor, unsaved_changes_mouse_state: MouseStateHandle::default(), editor_mouse_state: MouseStateHandle::default(), - is_loaded: false, + load_finished: false, + load_error: None, } } @@ -30,18 +33,22 @@ impl CodeReviewEditorState { editor, unsaved_changes_mouse_state: MouseStateHandle::default(), editor_mouse_state: MouseStateHandle::default(), - is_loaded: true, + load_finished: true, + load_error: None, } } - /// Returns whether the buffer content has been loaded. - pub fn is_loaded(&self) -> bool { - self.is_loaded + pub fn load_finished(&self) -> bool { + self.load_finished + } + + pub fn set_load_result(&mut self, error: Option>) { + self.load_finished = true; + self.load_error = error; } - /// Marks the editor as loaded. - pub fn set_loaded(&mut self) { - self.is_loaded = true; + pub fn load_error(&self) -> Option<&FileLoadError> { + self.load_error.as_deref() } pub fn editor(&self) -> &ViewHandle { diff --git a/crates/warp_files/src/lib.rs b/crates/warp_files/src/lib.rs index 8fff2109c21..16a1abea29b 100644 --- a/crates/warp_files/src/lib.rs +++ b/crates/warp_files/src/lib.rs @@ -15,7 +15,7 @@ use std::time::{Duration, SystemTime}; use async_channel::Sender; use futures::channel::oneshot; use futures::future::BoxFuture; -use futures::io::{AsyncBufReadExt, BufReader}; +use futures::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use futures::{FutureExt, StreamExt}; use notify_debouncer_full::notify::{RecursiveMode, WatchFilter}; use remote_server::manager::RemoteServerManager; @@ -32,6 +32,7 @@ use watcher::{BulkFilesystemWatcher, BulkFilesystemWatcherEvent}; pub mod text_file_reader; pub use text_file_reader::{TextFileReadResult, TextFileSegment}; +pub const MAX_LOADABLE_FILE_SIZE_BYTES: u64 = 100 * 1024 * 1024; #[derive(Debug)] pub enum FileModelEvent { @@ -447,9 +448,8 @@ impl FileModel { let file_path_buf = file_path.to_owned(); let future = ctx.spawn( async move { - let contents = async_fs::read_to_string(&file_path_buf) - .await - .map_err(FileLoadError::from); + let contents = + read_to_string_bounded(&file_path_buf, MAX_LOADABLE_FILE_SIZE_BYTES).await; (file_id, contents) }, move |me, (file_id, load_result), ctx| { @@ -462,17 +462,8 @@ impl FileModel { } match load_result { Ok(content) => { - let version = ContentVersion::new(); - me.set_version(file_id, version); - - // Only register an individual watcher if not using a repo subscription, - // and only record it once it has actually been registered. - if watch_individually && let Some(watch_path) = me.watch_path(file_id) { - me.register_individual_watcher(&watch_path, ctx); - if let Some(FileBackend::Local(file)) = me.file_state.get_mut(file_id) { - file.watcher_type = WatcherType::Individual(watch_path); - } - } + let version = + me.initialize_opened_local_file(file_id, watch_individually, ctx); ctx.emit(FileModelEvent::FileLoaded { content, @@ -481,6 +472,9 @@ impl FileModel { }); } Err(err) => { + if subscribe_to_updates && matches!(&err, FileLoadError::TooLarge { .. }) { + me.initialize_opened_local_file(file_id, watch_individually, ctx); + } ctx.emit(FileModelEvent::FailedToLoad { id: file_id, error: Rc::new(err), @@ -496,6 +490,23 @@ impl FileModel { file_id } + fn initialize_opened_local_file( + &mut self, + file_id: FileId, + watch_individually: bool, + ctx: &mut ModelContext, + ) -> ContentVersion { + let version = ContentVersion::new(); + self.set_version(file_id, version); + if watch_individually && let Some(watch_path) = self.watch_path(file_id) { + self.register_individual_watcher(&watch_path, ctx); + if let Some(FileBackend::Local(file)) = self.file_state.get_mut(file_id) { + file.watcher_type = WatcherType::Individual(watch_path); + } + } + version + } + /// The directory an individually-watched file is watched through. /// /// The parent directory is watched (rather than the file) so the watch survives editors that @@ -518,12 +529,13 @@ impl FileModel { } pub async fn read_content_for_file(file_path: &Path) -> Result { - if !Self::file_exists(file_path).await { - return Err(FileLoadError::DoesNotExist); + match read_to_string_bounded(file_path, MAX_LOADABLE_FILE_SIZE_BYTES).await { + Ok(content) => Ok(content), + Err(FileLoadError::IOError(err)) if err.kind() == io::ErrorKind::NotFound => { + Err(FileLoadError::DoesNotExist) + } + Err(err) => Err(err), } - async_fs::read_to_string(file_path) - .await - .map_err(FileLoadError::from) } /// Asynchronously reads specific lines from a file using BufReader. @@ -1141,43 +1153,32 @@ impl FileModel { } // Autoreload modified files. - ctx.spawn( - async move { - let mut res = Vec::new(); - for file_path in matching_files { - if let Ok(content) = async_fs::read_to_string(&file_path).await { - res.push((file_path, content)); + ctx.spawn(read_reload_contents(matching_files), move |me, res, ctx| { + for (file_path, content) in res { + let mut emitted_event = false; + for (file_id, file_state) in me.file_state.local_iter_mut() { + // Only set the new version of a file if it has opt-in to receiving updates. + if file_state.should_receive_update_for_path(&file_path) { + let new_version = ContentVersion::new(); + ctx.emit(FileModelEvent::FileUpdated { + id: *file_id, + content: content.clone(), + base_version: file_state.version.expect("Version should be some"), + new_version, + }); + emitted_event = true; + file_state.version = Some(new_version); } } - res - }, - move |me, res, ctx| { - for (file_path, content) in res { - let mut emitted_event = false; - for (file_id, file_state) in me.file_state.local_iter_mut() { - // Only set the new version of a file if it has opt-in to receiving updates. - if file_state.should_receive_update_for_path(&file_path) { - let new_version = ContentVersion::new(); - ctx.emit(FileModelEvent::FileUpdated { - id: *file_id, - content: content.clone(), - base_version: file_state.version.expect("Version should be some"), - new_version, - }); - emitted_event = true; - file_state.version = Some(new_version); - } - } - if !emitted_event { - log::warn!( - "{} is changed but there is no handler for the update event", - file_path.display() - ); - } + if !emitted_event { + log::warn!( + "{} is changed but there is no handler for the update event", + file_path.display() + ); } - }, - ); + } + }); } /// Falls back to individual file watchers for all files that were expecting to use the given repository. @@ -1223,6 +1224,59 @@ impl FileModel { } } +async fn read_to_string_bounded(file_path: &Path, max_bytes: u64) -> Result { + let file = async_fs::File::open(file_path).await?; + let metadata = file.metadata().await.ok(); + let size_estimate = metadata + .as_ref() + .filter(|metadata| metadata.is_file() && metadata.len() > max_bytes) + .map(|metadata| metadata.len()); + if size_estimate.is_some() { + return Err(FileLoadError::TooLarge { + size_estimate, + limit_bytes: max_bytes, + }); + } + + let capacity = metadata + .as_ref() + .map(|metadata| metadata.len().min(max_bytes.saturating_add(1)) as usize) + .unwrap_or_default(); + let bytes = read_bytes_bounded(file, max_bytes, capacity).await?; + + String::from_utf8(bytes) + .map_err(|err| FileLoadError::IOError(io::Error::new(io::ErrorKind::InvalidData, err))) +} +async fn read_bytes_bounded( + reader: impl futures::io::AsyncRead + Unpin, + max_bytes: u64, + capacity: usize, +) -> Result, FileLoadError> { + let mut bytes = Vec::with_capacity(capacity); + reader + .take(max_bytes.saturating_add(1)) + .read_to_end(&mut bytes) + .await?; + if bytes.len() as u64 > max_bytes { + Err(FileLoadError::TooLarge { + size_estimate: None, + limit_bytes: max_bytes, + }) + } else { + Ok(bytes) + } +} + +async fn read_reload_contents(file_paths: Vec) -> Vec<(PathBuf, String)> { + let mut contents = Vec::new(); + for file_path in file_paths { + if let Ok(content) = read_to_string_bounded(&file_path, MAX_LOADABLE_FILE_SIZE_BYTES).await + { + contents.push((file_path, content)); + } + } + contents +} impl Entity for FileModel { type Event = FileModelEvent; } diff --git a/crates/warp_files/src/lib_tests.rs b/crates/warp_files/src/lib_tests.rs index 2236c949d65..f57fea11bb6 100644 --- a/crates/warp_files/src/lib_tests.rs +++ b/crates/warp_files/src/lib_tests.rs @@ -16,6 +16,10 @@ enum TestFileModelEvent { content: String, _version: ContentVersion, }, + FileUpdated { + id: FileId, + content: String, + }, FileSaved, FailedToLoad(String), FailedToSave, @@ -39,15 +43,10 @@ impl From<&FileModelEvent> for TestFileModelEvent { error: err, } => TestFileModelEvent::FailedToLoad(format!("{err:?}")), FileModelEvent::FailedToSave { .. } => TestFileModelEvent::FailedToSave, - FileModelEvent::FileUpdated { .. } => { - // For now, we don't handle file updated events in tests - // This could be extended to include a FileUpdated variant in TestFileModelEvent if needed - TestFileModelEvent::FileLoaded { - id: event.file_id(), - content: String::new(), - _version: ContentVersion::new(), - } - } + FileModelEvent::FileUpdated { id, content, .. } => TestFileModelEvent::FileUpdated { + id: *id, + content: content.clone(), + }, } } } @@ -356,4 +355,175 @@ fn test_a_failed_open_registers_no_watcher() { }); } +#[test] +fn test_bounded_read_accepts_exact_limit_and_rejects_oversized_content() { + let directory = tempfile::tempdir().expect("temp dir"); + let exact_path = directory.path().join("exact.txt"); + std::fs::write(&exact_path, "1234").expect("write exact file"); + let oversized_path = directory.path().join("oversized.txt"); + std::fs::write(&oversized_path, "12345").expect("write oversized file"); + + assert_eq!( + block_on(read_to_string_bounded(&exact_path, 4)).expect("read exact-limit file"), + "1234" + ); + assert!(matches!( + block_on(read_to_string_bounded(&oversized_path, 4)), + Err(FileLoadError::TooLarge { + size_estimate: Some(5), + limit_bytes: 4 + }) + )); +} + +#[test] +fn test_bounded_stream_read_enforces_limit_without_metadata() { + assert_eq!( + block_on(read_bytes_bounded(futures::io::Cursor::new(b"1234"), 4, 0)) + .expect("read exact-limit stream"), + b"1234" + ); + assert!(matches!( + block_on(read_bytes_bounded(futures::io::Cursor::new(b"12345"), 4, 0)), + Err(FileLoadError::TooLarge { + size_estimate: None, + limit_bytes: 4 + }) + )); +} +#[test] +fn test_load_oversized_file_reports_too_large() { + App::test((), |mut app| async move { + let files = app.add_singleton_model(FileModel::new); + let receiver = setup_event_channel(&mut app, &files); + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("oversized.log"); + let file = std::fs::File::create(&path).expect("create file"); + file.set_len(MAX_LOADABLE_FILE_SIZE_BYTES + 1) + .expect("set sparse length"); + + files.update(&mut app, |model, ctx| { + model.open(&path, false, ctx); + }); + + match receiver.recv().await.expect("receive load result") { + TestFileModelEvent::FailedToLoad(error) => { + assert!(error.contains("TooLarge"), "unexpected error: {error}"); + } + event => panic!("expected oversized load failure, got {event:?}"), + } + }); +} + +#[test] +fn test_oversized_subscribed_file_reloads_after_shrinking() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| DetectedRepositories::default()); + let files = app.add_singleton_model(FileModel::new); + let receiver = setup_event_channel(&mut app, &files); + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("oversized.log"); + let file = std::fs::File::create(&path).expect("create file"); + file.set_len(MAX_LOADABLE_FILE_SIZE_BYTES + 1) + .expect("set sparse length"); + + let file_id = files.update(&mut app, |model, ctx| model.open(&path, true, ctx)); + assert!(matches!( + receiver.recv().await.expect("receive load failure"), + TestFileModelEvent::FailedToLoad(_) + )); + let stored_path = files.read(&app, |model, _| { + assert!(model.version(file_id).is_some()); + let stored_path = model.file_path(file_id).expect("stored path"); + assert_eq!( + model.registered_watch_path(file_id), + FileModel::watch_path_for(&stored_path).as_deref() + ); + stored_path + }); + + std::fs::write(&path, "now loadable").expect("shrink file"); + files.update(&mut app, |model, ctx| { + model.reload_file_paths(HashSet::from([stored_path.clone()]), ctx); + }); + + match receiver.recv().await.expect("receive file update") { + TestFileModelEvent::FileUpdated { id, content } => { + assert_eq!(id, file_id); + assert_eq!(content, "now loadable"); + } + event => panic!("expected update after shrinking oversized file, got {event:?}"), + } + }); +} + +#[test] +fn test_read_content_for_file_reports_too_large() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("oversized.log"); + let file = std::fs::File::create(&path).expect("create file"); + file.set_len(MAX_LOADABLE_FILE_SIZE_BYTES + 1) + .expect("set sparse length"); + + assert!(matches!( + block_on(FileModel::read_content_for_file(&path)), + Err(FileLoadError::TooLarge { + size_estimate: Some(size), + limit_bytes: MAX_LOADABLE_FILE_SIZE_BYTES + }) if size == MAX_LOADABLE_FILE_SIZE_BYTES + 1 + )); +} + +#[test] +fn test_reload_file_paths_skips_oversized_replacement() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| DetectedRepositories::default()); + let files = app.add_singleton_model(FileModel::new); + let receiver = setup_event_channel(&mut app, &files); + let directory = tempfile::tempdir().expect("temp dir"); + let small_path = directory.path().join("small.txt"); + let oversized_path = directory.path().join("oversized.txt"); + std::fs::write(&small_path, "small").expect("write small file"); + std::fs::write(&oversized_path, "small").expect("write initial oversized file"); + + let small_id = files.update(&mut app, |model, ctx| model.open(&small_path, true, ctx)); + await_load(&receiver).await; + let oversized_id = files.update(&mut app, |model, ctx| { + model.open(&oversized_path, true, ctx) + }); + await_load(&receiver).await; + let (stored_small_path, stored_oversized_path) = files.read(&app, |model, _| { + ( + model.file_path(small_id).expect("stored small path"), + model + .file_path(oversized_id) + .expect("stored oversized path"), + ) + }); + + std::fs::write(&small_path, "updated").expect("update small file"); + let file = std::fs::File::create(&oversized_path).expect("replace oversized file"); + file.set_len(MAX_LOADABLE_FILE_SIZE_BYTES + 1) + .expect("set sparse length"); + + files.update(&mut app, |model, ctx| { + model.reload_file_paths( + HashSet::from([stored_small_path.clone(), stored_oversized_path.clone()]), + ctx, + ); + }); + + match receiver.recv().await.expect("receive update") { + TestFileModelEvent::FileUpdated { id, content } => { + assert_eq!(id, small_id); + assert_eq!(content, "updated"); + } + event => panic!("expected small-file update, got {event:?}"), + } + assert!( + receiver.try_recv().is_err(), + "oversized file must not emit an update" + ); + }); +} static TEST_FILE_CONTENT: &[u8] = include_bytes!("../test_data/test_file.rs"); diff --git a/crates/warp_util/src/file.rs b/crates/warp_util/src/file.rs index b5f034c1a22..e37e865bec4 100644 --- a/crates/warp_util/src/file.rs +++ b/crates/warp_util/src/file.rs @@ -38,6 +38,11 @@ pub enum FileLoadError { DoesNotExist, #[error("IO error when loading file.")] IOError(#[from] io::Error), + #[error("File exceeds the {limit_bytes}-byte load limit")] + TooLarge { + size_estimate: Option, + limit_bytes: u64, + }, } #[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]