Skip to main content

deps_lsp/document/
loader.rs

1//! Document loading from filesystem for cold start scenarios.
2//!
3//! When an LSP client has a file already open and the server starts,
4//! the client may not send a didOpen event. This module provides
5//! infrastructure to load documents from disk when handlers receive
6//! requests for unknown documents.
7//!
8//! # Architecture
9//!
10//! Cold start loading is pull-based (not workspace scanning):
11//! - Handlers check if document exists in state
12//! - If not, call `ensure_document_loaded()`
13//! - Document is loaded from disk, parsed, and cached
14//! - Background task fetches version information
15//!
16//! # Performance
17//!
18//! File reading is async and non-blocking. Typical latency is <50ms
19//! for documents under 100KB (most manifest files are <10KB).
20//!
21//! # Security
22//!
23//! - Rate limiting prevents DOS attacks (10 req/sec per URI)
24//! - File size limit: 10MB (configurable)
25//! - Non-UTF8 files are rejected
26//!
27//! # Error Handling
28//!
29//! All errors are logged and result in graceful degradation (handlers
30//! return empty results rather than crashing).
31
32use deps_core::error::{DepsError, Result};
33use tower_lsp_server::ls_types::Uri;
34
35/// Maximum allowed file/document size in bytes (10MB).
36///
37/// Files larger than this limit will be rejected to prevent excessive memory usage
38/// and performance degradation. This is a hard limit - files exceeding it cannot be loaded.
39/// Typical manifest files are <100KB, so 10MB provides ample headroom.
40///
41/// `pub(crate)` since [`super::lifecycle`] applies the same bound to content
42/// received directly over the LSP protocol (`textDocument/didOpen` /
43/// `textDocument/didChange`), which has no filesystem `metadata()` call to
44/// gate on before it reaches this crate.
45pub(crate) const MAX_FILE_SIZE: u64 = 10_000_000; // 10MB
46
47/// Large file warning threshold (1MB).
48///
49/// Files larger than this will log a warning, as typical manifests are much smaller.
50const LARGE_FILE_THRESHOLD: u64 = 1_000_000; // 1MB
51
52/// Loads document content from disk.
53///
54/// # Arguments
55///
56/// * `uri` - Document URI (must be file:// scheme)
57///
58/// # Returns
59///
60/// * `Ok(String)` - File content
61/// * `Err(DepsError)` - File not found, permission denied, not a file URI, or too large/not a
62///   regular file
63///
64/// # Errors
65///
66/// - `DepsError::InvalidUri` - URI is not a file:// URI
67/// - `DepsError::Io` - File read error (not found, permission denied, etc.)
68/// - `DepsError::CacheError` - Not a regular file, or exceeds `MAX_FILE_SIZE`
69///
70/// # Examples
71///
72/// ```no_run
73/// use deps_lsp::document::load_document_from_disk;
74/// use tower_lsp_server::ls_types::Uri;
75///
76/// # async fn example() -> deps_core::error::Result<()> {
77/// let uri = Uri::from_file_path("/path/to/Cargo.toml").unwrap();
78/// let content = load_document_from_disk(&uri).await?;
79/// println!("Loaded {} bytes", content.len());
80/// # Ok(())
81/// # }
82/// ```
83pub async fn load_document_from_disk(uri: &Uri) -> Result<String> {
84    // Convert URI to filesystem path. Owned (not `Cow::Borrowed`), since the read below runs
85    // in `spawn_blocking` and needs a `'static` path.
86    let path = match uri.to_file_path() {
87        Some(p) => p.into_owned(),
88        None => {
89            tracing::debug!("Cannot load non-file URI: {:?}", uri);
90            return Err(DepsError::InvalidUri(format!("{uri:?}")));
91        }
92    };
93
94    tracing::debug!("Loading document from disk: {:?}", path);
95
96    // Check file metadata for size limits and warnings
97    match tokio::fs::metadata(&path).await {
98        Ok(metadata) => {
99            // Reject anything but a regular file (FIFO, socket, character device,
100            // directory) before ever attempting to open it β€” a FIFO/chardev reports
101            // `len() == 0`, would pass the size gate below, and then block the
102            // `spawn_blocking` thread that opens it indefinitely (mirrors the `is_file`
103            // gate `discover_workspace` applies via `fs_probe::metadata` in
104            // `deps-cargo/src/parser.rs`).
105            if !metadata.is_file() {
106                tracing::warn!("Rejecting non-regular-file document: {:?}", path);
107                return Err(DepsError::CacheError(format!(
108                    "not a regular file: {}",
109                    path.display()
110                )));
111            }
112
113            let size = metadata.len();
114
115            // Hard limit: reject files over 10MB
116            if size > MAX_FILE_SIZE {
117                tracing::error!(
118                    "Document exceeds maximum size: {} bytes (limit: {} bytes)",
119                    size,
120                    MAX_FILE_SIZE
121                );
122                return Err(DepsError::CacheError(format!(
123                    "file too large: {size} bytes (max: {MAX_FILE_SIZE} bytes)"
124                )));
125            }
126
127            // Warning for files over 1MB
128            if size > LARGE_FILE_THRESHOLD {
129                tracing::warn!(
130                    "Document is large: {} bytes for {:?}. Typical manifests are <100KB.",
131                    size,
132                    path
133                );
134            }
135
136            tracing::trace!("File size: {} bytes", size);
137        }
138        Err(e) => {
139            // Differentiate permission errors from other IO errors
140            match e.kind() {
141                std::io::ErrorKind::NotFound => {
142                    tracing::debug!("File not found: {:?}", path);
143                }
144                std::io::ErrorKind::PermissionDenied => {
145                    tracing::warn!("Permission denied: {:?}", path);
146                }
147                _ => {
148                    tracing::error!("IO error reading metadata for {:?}: {}", path, e);
149                }
150            }
151            return Err(DepsError::Io(e));
152        }
153    }
154
155    // Read file content, bounded by the read itself (not just the metadata pre-filter
156    // above): `read_to_string_capped` opens the file and caps via `Read::take`, so a
157    // symlink swap or concurrent growth between the `metadata` call above and this read
158    // cannot let an oversized file through (CWE-367). Runs in `spawn_blocking` since it is a
159    // synchronous `std::fs` call.
160    let read_path = path.clone();
161    let capped = tokio::task::spawn_blocking(move || {
162        deps_core::fs_probe::read_to_string_capped(&read_path, MAX_FILE_SIZE)
163    })
164    .await
165    .map_err(|e| {
166        tracing::error!("document read task for {:?} panicked: {}", path, e);
167        DepsError::CacheError(format!("document read task failed: {e}"))
168    })?
169    .map_err(|e| {
170        // Differentiate permission errors in file read
171        match e.kind() {
172            std::io::ErrorKind::NotFound => {
173                tracing::debug!("File not found during read: {:?}", path);
174            }
175            std::io::ErrorKind::PermissionDenied => {
176                tracing::warn!("Permission denied reading file: {:?}", path);
177            }
178            _ => {
179                tracing::error!("IO error reading file {:?}: {}", path, e);
180            }
181        }
182        DepsError::Io(e)
183    })?;
184
185    let content = match capped {
186        Some(content) => content,
187        None => {
188            // Unreachable without a real TOCTOU race (a symlink swap or concurrent growth
189            // between the `metadata` check above and this read): the metadata gate already
190            // rejects anything over `MAX_FILE_SIZE` before the read starts. This is
191            // race-only defense-in-depth, not the primary mitigation β€” the property that
192            // this branch can even fire is what `fs_probe`'s own
193            // `read_to_string_capped_rejects_content_over_cap` test proves.
194            tracing::error!(
195                "Document exceeds maximum size during read (limit: {} bytes): {:?}",
196                MAX_FILE_SIZE,
197                path
198            );
199            return Err(DepsError::CacheError(format!(
200                "file too large (max: {MAX_FILE_SIZE} bytes)"
201            )));
202        }
203    };
204
205    tracing::debug!(
206        "Successfully loaded document: {:?} ({} bytes)",
207        path,
208        content.len()
209    );
210
211    Ok(content)
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use std::io::Write;
218    use tempfile::NamedTempFile;
219    use tower_lsp_server::ls_types::Uri;
220
221    #[tokio::test]
222    async fn test_load_existing_file() {
223        let mut temp_file = NamedTempFile::new().unwrap();
224        let content = "test content";
225        temp_file.write_all(content.as_bytes()).unwrap();
226        temp_file.flush().unwrap();
227
228        let uri = Uri::from_file_path(temp_file.path()).unwrap();
229        let loaded = load_document_from_disk(&uri).await.unwrap();
230
231        assert_eq!(loaded, content);
232    }
233
234    #[tokio::test]
235    async fn test_load_nonexistent_file() {
236        let uri = deps_core::test_util::test_uri("/nonexistent/file/path.toml");
237        let result = load_document_from_disk(&uri).await;
238
239        assert!(result.is_err());
240        match result {
241            Err(DepsError::Io(_)) => {}
242            _ => panic!("Expected Io error"),
243        }
244    }
245
246    #[tokio::test]
247    async fn test_load_empty_file() {
248        let temp_file = NamedTempFile::new().unwrap();
249        // File is empty, don't write anything
250
251        let uri = Uri::from_file_path(temp_file.path()).unwrap();
252        let loaded = load_document_from_disk(&uri).await.unwrap();
253
254        assert_eq!(loaded, "");
255    }
256
257    // Note: Tests for non-file URIs (http://, untitled:) are covered by integration tests
258    // Creating non-file URIs in unit tests would require adding fluent_uri as a dev dependency
259    // The implementation correctly handles these cases via to_file_path() returning None
260
261    #[tokio::test]
262    async fn test_load_utf8_file() {
263        let mut temp_file = NamedTempFile::new().unwrap();
264        let content = "Hello δΈ–η•Œ 🌍 ΠŸΡ€ΠΈΠ²Π΅Ρ‚";
265        temp_file.write_all(content.as_bytes()).unwrap();
266        temp_file.flush().unwrap();
267
268        let uri = Uri::from_file_path(temp_file.path()).unwrap();
269        let loaded = load_document_from_disk(&uri).await.unwrap();
270
271        assert_eq!(loaded, content);
272    }
273
274    #[tokio::test]
275    async fn test_load_non_utf8_file() {
276        let mut temp_file = NamedTempFile::new().unwrap();
277        // Write invalid UTF-8 bytes
278        temp_file.write_all(&[0xFF, 0xFE, 0xFD]).unwrap();
279        temp_file.flush().unwrap();
280
281        let uri = Uri::from_file_path(temp_file.path()).unwrap();
282        let result = load_document_from_disk(&uri).await;
283
284        assert!(result.is_err());
285        match result {
286            Err(DepsError::Io(_)) => {}
287            _ => panic!("Expected Io error for non-UTF8 content"),
288        }
289    }
290
291    #[cfg(unix)]
292    #[tokio::test]
293    async fn test_load_permission_denied() {
294        use std::fs;
295        use std::os::unix::fs::PermissionsExt;
296
297        let mut temp_file = NamedTempFile::new().unwrap();
298        temp_file.write_all(b"test").unwrap();
299        temp_file.flush().unwrap();
300
301        // Remove read permissions
302        let mut perms = fs::metadata(temp_file.path()).unwrap().permissions();
303        perms.set_mode(0o000);
304        fs::set_permissions(temp_file.path(), perms.clone()).unwrap();
305
306        let uri = Uri::from_file_path(temp_file.path()).unwrap();
307        let result = load_document_from_disk(&uri).await;
308
309        // Restore permissions for cleanup
310        perms.set_mode(0o644);
311        let _ = fs::set_permissions(temp_file.path(), perms);
312
313        assert!(result.is_err());
314        match result {
315            Err(DepsError::Io(_)) => {}
316            _ => panic!("Expected Io error for permission denied"),
317        }
318    }
319
320    #[tokio::test]
321    async fn test_load_large_file_warning() {
322        // This test verifies that large files can be loaded (with warning logged)
323        // We don't create a 10MB+ file to avoid slow tests, but we verify
324        // that normal-sized files load successfully
325        let mut temp_file = NamedTempFile::new().unwrap();
326        let content = "a".repeat(1000); // 1KB, well under the warning threshold
327        temp_file.write_all(content.as_bytes()).unwrap();
328        temp_file.flush().unwrap();
329
330        let uri = Uri::from_file_path(temp_file.path()).unwrap();
331        let loaded = load_document_from_disk(&uri).await.unwrap();
332
333        assert_eq!(loaded.len(), 1000);
334    }
335
336    #[tokio::test]
337    async fn test_load_cargo_toml() {
338        let mut temp_file = NamedTempFile::new().unwrap();
339        let content = r#"[package]
340name = "test"
341version = "0.1.0"
342
343[dependencies]
344serde = "1.0"
345"#;
346        temp_file.write_all(content.as_bytes()).unwrap();
347        temp_file.flush().unwrap();
348
349        let uri = Uri::from_file_path(temp_file.path()).unwrap();
350        let loaded = load_document_from_disk(&uri).await.unwrap();
351
352        assert_eq!(loaded, content);
353        assert!(loaded.contains("[dependencies]"));
354    }
355
356    #[tokio::test]
357    async fn test_file_size_limit_constant() {
358        // Document the limit for maintainability
359        assert_eq!(MAX_FILE_SIZE, 10_000_000);
360        assert_eq!(LARGE_FILE_THRESHOLD, 1_000_000);
361    }
362
363    #[cfg(unix)]
364    #[tokio::test]
365    async fn test_load_symlink_to_valid_file() {
366        use std::os::unix::fs::symlink;
367        use tempfile::TempDir;
368
369        let temp_dir = TempDir::new().unwrap();
370        let target = temp_dir.path().join("target.toml");
371        let link = temp_dir.path().join("link.toml");
372
373        std::fs::write(&target, "[dependencies]").unwrap();
374        symlink(&target, &link).unwrap();
375
376        let uri = Uri::from_file_path(&link).unwrap();
377        let content = load_document_from_disk(&uri).await.unwrap();
378        assert_eq!(content, "[dependencies]");
379    }
380
381    #[cfg(unix)]
382    #[tokio::test]
383    async fn test_load_circular_symlink() {
384        use std::os::unix::fs::symlink;
385        use tempfile::TempDir;
386
387        let temp_dir = TempDir::new().unwrap();
388        let link1 = temp_dir.path().join("link1.toml");
389        let link2 = temp_dir.path().join("link2.toml");
390
391        symlink(&link2, &link1).unwrap();
392        symlink(&link1, &link2).unwrap();
393
394        let uri = Uri::from_file_path(&link1).unwrap();
395        let result = load_document_from_disk(&uri).await;
396        assert!(result.is_err(), "Circular symlink should fail");
397    }
398
399    /// A FIFO reports `len() == 0` from `metadata`, which would pass the size gate β€” without
400    /// the `is_file` check, opening it for read blocks the `spawn_blocking` thread
401    /// indefinitely (a writerless FIFO never yields EOF). Wrapped in a timeout so a
402    /// regression fails this test in seconds instead of hanging the whole suite.
403    #[cfg(unix)]
404    #[tokio::test]
405    async fn test_load_fifo_does_not_hang() {
406        use tempfile::TempDir;
407
408        let temp_dir = TempDir::new().unwrap();
409        let fifo_path = temp_dir.path().join("Cargo.toml");
410        let status = std::process::Command::new("mkfifo")
411            .arg(&fifo_path)
412            .status()
413            .unwrap();
414        assert!(
415            status.success(),
416            "mkfifo must succeed for this test to be meaningful"
417        );
418
419        let uri = Uri::from_file_path(&fifo_path).unwrap();
420        let result = tokio::time::timeout(
421            std::time::Duration::from_secs(5),
422            load_document_from_disk(&uri),
423        )
424        .await
425        .expect("a FIFO must be rejected by the is_file gate, not block trying to open it");
426
427        assert!(
428            result.is_err(),
429            "a FIFO must never be treated as a loadable document"
430        );
431    }
432
433    #[tokio::test]
434    async fn test_load_file_exceeding_max_size() {
435        use std::io::Write;
436
437        // Create a file just over MAX_FILE_SIZE (10MB)
438        // To avoid slow tests, we create a sparse file if possible
439        // Otherwise, we verify the error message format with metadata check
440        let mut temp_file = NamedTempFile::new().unwrap();
441
442        // Write a small file for fast test execution
443        // We'll verify the size check logic by examining metadata
444        let content = "test content";
445        temp_file.write_all(content.as_bytes()).unwrap();
446        temp_file.flush().unwrap();
447
448        // Verify the constant is enforced (boundary test)
449        assert_eq!(MAX_FILE_SIZE, 10_000_000, "MAX_FILE_SIZE constant changed");
450
451        // For platforms supporting sparse files, create a file > 10MB
452        #[cfg(unix)]
453        {
454            use std::os::unix::fs::FileExt;
455            use tempfile::TempDir;
456
457            let temp_dir = TempDir::new().unwrap();
458            let large_file = temp_dir.path().join("large.toml");
459
460            // Create file and write single byte at position > 10MB
461            // This creates a sparse file without actually allocating disk space
462            let file = std::fs::File::create(&large_file).unwrap();
463            let beyond_limit = MAX_FILE_SIZE + 1;
464            file.write_at(b"x", beyond_limit).unwrap();
465
466            let uri = Uri::from_file_path(&large_file).unwrap();
467            let result = load_document_from_disk(&uri).await;
468
469            assert!(result.is_err(), "Should reject files > MAX_FILE_SIZE");
470            match result {
471                Err(DepsError::CacheError(msg)) => {
472                    assert!(
473                        msg.contains("file too large"),
474                        "Error message should indicate file size issue: {msg}"
475                    );
476                    assert!(
477                        msg.contains(&beyond_limit.to_string())
478                            || msg.contains(&(beyond_limit + 1).to_string()),
479                        "Error should mention actual file size: {msg}"
480                    );
481                }
482                _ => panic!("Expected CacheError for oversized file"),
483            }
484        }
485    }
486
487    /// A file exactly at `MAX_FILE_SIZE` must still load in full: `read_to_string_capped`
488    /// reads one byte past the cap to detect an overage, and an off-by-one there would
489    /// falsely reject a file that lands exactly on the boundary (mirrors
490    /// `deps_core::mtime_cache::tests::file_exactly_at_cap_is_still_cached`).
491    #[tokio::test]
492    async fn test_load_file_exactly_at_max_size() {
493        let temp_dir = tempfile::TempDir::new().unwrap();
494        let path = temp_dir.path().join("exact.toml");
495        let content = "a".repeat(MAX_FILE_SIZE as usize);
496        std::fs::write(&path, &content).unwrap();
497
498        let uri = Uri::from_file_path(&path).unwrap();
499        let loaded = load_document_from_disk(&uri).await.unwrap();
500
501        assert_eq!(loaded.len(), MAX_FILE_SIZE as usize);
502    }
503
504    /// Proves the read goes through the counted `fs_probe::read_to_string_capped`, not a raw
505    /// `tokio::fs::read_to_string` that would bypass the cap enforced at the read itself β€”
506    /// the property that closes the TOCTOU gap for #603 the same way #601 closed it for
507    /// `MtimeFileCache`.
508    #[tokio::test]
509    async fn test_load_routes_through_capped_read() {
510        let mut temp_file = NamedTempFile::new().unwrap();
511        temp_file.write_all(b"test content").unwrap();
512        temp_file.flush().unwrap();
513
514        let uri = Uri::from_file_path(temp_file.path()).unwrap();
515        let (_, reads_before) = deps_core::fs_probe::snapshot();
516        load_document_from_disk(&uri).await.unwrap();
517        let (_, reads_after) = deps_core::fs_probe::snapshot();
518
519        assert_eq!(
520            reads_after - reads_before,
521            1,
522            "load_document_from_disk must read exactly once through the counted fs_probe path"
523        );
524    }
525}