// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Read-side operation handlers: open_read and the body of the read() //! Handler trait method. use super::attrs::{apply_user_metadata_to_sftp_attrs, s3_attrs_to_sftp, timestamp_to_mtime}; use super::constants::limits::{MAX_READ_LEN, READ_CACHE_DISABLED}; use super::driver::SftpDriver; use super::errors::{SftpError, s3_error_to_sftp}; use super::paths::parse_s3_path; use super::state::HandleState; use crate::common::client::s3::StorageBackend; use crate::common::gateway::S3Action; use futures_util::StreamExt; use russh_sftp::protocol::{Data, Handle, StatusCode}; impl SftpDriver { /// Read-side OPEN: authorise GetObject, HEAD the object to capture /// size and mtime, allocate a File handle. Errors are mapped through /// s3_error_to_sftp so a missing object returns NoSuchFile and a /// permission failure as PermissionDenied. pub(super) async fn open_read(&mut self, id: u32, filename: &str) -> Result { let (bucket, key) = parse_s3_path(filename)?; let Some(object_key) = key else { return Err(SftpError::code(StatusCode::NoSuchFile)); }; if bucket.is_empty() { return Err(SftpError::code(StatusCode::NoSuchFile)); } self.authorize(&S3Action::GetObject, &bucket, Some(&object_key)).await?; // Fetch object metadata (size, last-modified) without downloading // the body. These are cached on the handle so READ can detect EOF // and FSTAT can answer without another backend call. let head = self .run_backend( "head_object", self.storage .head_object(&bucket, &object_key, self.access_key(), self.secret_key()), ) .await?; let size = head.content_length.unwrap_or(0).max(0) as u64; let mtime = timestamp_to_mtime(head.last_modified); let mut attrs = s3_attrs_to_sftp(size, mtime, false); if let Some(metadata) = head.metadata { apply_user_metadata_to_sftp_attrs(&mut attrs, &metadata); } let read_cache = self.new_read_cache(); let handle = self.allocate_handle(HandleState::File { bucket, key: object_key, size, attrs, read_cache, })?; Ok(Handle { id, handle }) } /// Body of the SSH_FXP_READ handler. Returns up to len bytes starting /// at offset, capped at MAX_READ_LEN and the cached object size. /// Zero-length requests are rejected with BadMessage at the boundary. /// Offsets at or past end-of-file return Eof without a network call. /// /// Cache-aware. When the requested bytes are already in the /// per-handle cached chunk, they are returned without a backend /// round trip. Otherwise a window-sized range is fetched from the /// backend, the cache is populated when the new chunk would not /// push the process-wide memory total past the configured /// ceiling, and the requested bytes are returned from the fetched /// data. When the populate call is skipped due to the memory /// ceiling, the read still completes from the fetched bytes. /// Only the caching step is dropped, at the cost of one backend /// call per FXP_READ. /// /// When read_cache_window is set to READ_CACHE_DISABLED the cache /// is bypassed entirely. The cache-hit probe always misses /// because the buffer is never populated, the fetch length equals /// the requested length, and try_populate_read_cache returns /// early without touching the process-wide accumulator. pub(super) async fn read_inner(&mut self, id: u32, handle: String, offset: u64, len: u32) -> Result { if len == 0 { // Reject zero-length reads at the boundary. The S3 range header // would otherwise underflow when calculating the inclusive end // offset. return Err(SftpError::code(StatusCode::BadMessage)); } // Cap the client-requested length to MAX_READ_LEN (256 KiB) to // bound the per-request memory allocation. let capped_len = len.min(MAX_READ_LEN); let (bucket, key, size) = self.with_handle_ref(&handle, |state| match state { HandleState::File { bucket, key, size, .. } => Ok((bucket.clone(), key.clone(), *size)), HandleState::Dir(_) | HandleState::Write { .. } => Err(SftpError::code(StatusCode::Failure)), })?; // Reading at or past EOF returns Eof without a backend call. // Clamp the read length to the remaining bytes. if offset >= size { return Err(SftpError::code(StatusCode::Eof)); } let remaining = size - offset; let actual_len = (capped_len as u64).min(remaining); // Cache-hit fast path. Probe the cache while only borrowing // the handle table. No backend call, no auth call, no await, // so cancellation cannot fire between the probe and the // return. let cached = self.with_handle_ref(&handle, |state| match state { HandleState::File { read_cache, .. } => Ok(read_cache.get(offset, actual_len).map(|s| s.to_vec())), _ => Err(SftpError::code(StatusCode::Failure)), })?; if let Some(data) = cached { return Ok(Data { id, data }); } // Cache miss. Authorise and fetch a window-sized range. The // fetch length is normally read_cache_window. Near EOF it // shrinks to the remaining bytes so a tail read does not // over-fetch past the object. The fetch length is also held // at or above actual_len so that when read_cache_window is // smaller than actual_len, or when read_cache_window is the // READ_CACHE_DISABLED sentinel (0), the backend call still // returns the bytes the client requested. self.authorize(&S3Action::GetObject, &bucket, Some(&key)).await?; let fetch_len = self.read_cache_window.max(actual_len).min(remaining); let window_bytes = self.fetch_object_range(&bucket, &key, offset, fetch_len).await?; if window_bytes.is_empty() { return Err(SftpError::code(StatusCode::Eof)); } // Slice the response from the front of the fetched bytes. // The remainder is offered to the cache below for reuse on // subsequent reads inside the same chunk. let response_len = actual_len.min(window_bytes.len() as u64) as usize; let data = window_bytes[..response_len].to_vec(); self.try_populate_read_cache(&handle, offset, window_bytes); Ok(Data { id, data }) } /// Issue one get_object_range backend call and drain the response /// body into a contiguous buffer. Each per-chunk await is wrapped /// in the same per-call deadline that bounds the outer /// get_object_range. A backend that returns a body and then stalls /// mid-stream returns Failure here rather than pinning the session /// task on body.next(). async fn fetch_object_range(&self, bucket: &str, key: &str, offset: u64, fetch_len: u64) -> Result, SftpError> { let out = self .run_backend( "get_object_range", self.storage .get_object_range(bucket, key, self.access_key(), self.secret_key(), offset, fetch_len), ) .await?; let Some(mut body) = out.body else { return Err(SftpError::code(StatusCode::Failure)); }; let mut buf = Vec::with_capacity(usize::try_from(fetch_len).unwrap_or(0)); loop { let chunk_timeout = std::time::Duration::from_secs(self.backend_op_timeout_secs); let next = match tokio::time::timeout(chunk_timeout, body.next()).await { Ok(next) => next, Err(_elapsed) => { return Err(s3_error_to_sftp( "get_object_stream", format!("stream chunk timed out after {} seconds", self.backend_op_timeout_secs), )); } }; let Some(chunk) = next else { break }; let bytes = chunk.map_err(|e| s3_error_to_sftp("get_object_stream", e))?; buf.extend_from_slice(&bytes); } Ok(buf) } /// Populate the per-handle read cache when the projected total /// memory across all live caches would stay at or below the /// configured ceiling. The check is a best-effort peek-then-add. /// Under concurrent populate calls from many sessions the /// projected total can briefly drift above the limit by at most /// (concurrent_populates * window_bytes). The limit is a soft /// cap. When the projected total exceeds the limit, the bytes /// are dropped without storing them, and a subsequent FXP_READ /// inside the same chunk-aligned range issues a fresh backend /// call instead of being served from cache. /// /// The accumulator load and the populate call run with no /// intervening await, so the snapshot is still valid when the /// populate call executes. fn try_populate_read_cache(&mut self, handle: &str, offset: u64, window_bytes: Vec) { if self.read_cache_window == READ_CACHE_DISABLED { return; } let cap_now = self.read_cache_in_use.load(std::sync::atomic::Ordering::Relaxed); let cache_state = match self.handles.get(handle) { Some(HandleState::File { read_cache, .. }) => read_cache.capacity() as u64, _ => return, }; let new_cap = window_bytes.capacity() as u64; let projected = cap_now.saturating_sub(cache_state).saturating_add(new_cap); if projected > self.read_cache_total_mem_limit { return; } if let Some(state) = self.handles.get_mut(handle) && let HandleState::File { read_cache, .. } = state { read_cache.populate(offset, window_bytes); } } } #[cfg(test)] mod tests { use super::super::constants::limits::READ_CACHE_DISABLED; use super::super::state::HandleState; use super::super::test_support::{ TEST_PART_SIZE, build_driver, build_driver_with_read_cache, build_driver_with_timeout, capture_tracing_at, file_handle, }; use crate::common::dummy_storage::{DummyBackend, DummyError}; use crate::common::gateway::with_test_auth_override; use russh_sftp::protocol::{FileAttributes, StatusCode}; use russh_sftp::server::Handler; use std::sync::Arc; use std::time::{Duration, Instant}; use tracing::Level; #[tokio::test] async fn read_with_len_zero_returns_bad_message_before_backend_call() { let backend = Arc::new(DummyBackend::new()); let mut driver = build_driver(backend, TEST_PART_SIZE); let handle_id = driver .allocate_handle(file_handle("b", "k", 100, FileAttributes::default())) .expect("allocate"); let err = driver .read(1, handle_id, 0, 0) .await .expect_err("len=0 must return BadMessage"); assert!(matches!(StatusCode::from(err), StatusCode::BadMessage)); } #[tokio::test] async fn read_at_offset_past_size_returns_eof_before_backend_call() { let backend = Arc::new(DummyBackend::new()); let mut driver = build_driver(backend.clone(), TEST_PART_SIZE); let handle_id = driver .allocate_handle(file_handle("b", "k", 10, FileAttributes::default())) .expect("allocate"); let err = driver .read(2, handle_id, 10, 4) .await .expect_err("offset==size must return Eof"); assert!(matches!(StatusCode::from(err), StatusCode::Eof)); } #[tokio::test] async fn read_normal_path_returns_bytes_from_backend() { let backend = Arc::new(DummyBackend::new()); backend.queue_get_object_range_bytes(b"hello".to_vec()); let mut driver = build_driver(backend, TEST_PART_SIZE); let handle_id = driver .allocate_handle(file_handle("b", "k", 5, FileAttributes::default())) .expect("allocate"); let data = with_test_auth_override(|_, _, _| true, driver.read(3, handle_id, 0, 1024)) .await .expect("read must succeed"); assert_eq!(data.data, b"hello".to_vec()); } /// Read past end-of-file is the spec-mandated SFTP termination /// signal. The handler must return Eof on the wire and stay silent /// in the log so a normal download burst does not generate one /// error-level event per file. #[tokio::test] async fn read_past_eof_emits_no_error_level_event() { let backend = Arc::new(DummyBackend::new()); let mut driver = build_driver(backend, TEST_PART_SIZE); let handle_id = driver .allocate_handle(file_handle("b", "k", 10, FileAttributes::default())) .expect("allocate"); let (result, captured) = capture_tracing_at(Level::ERROR, async { driver.read(11, handle_id, 10, 4).await }).await; let err = result.expect_err("offset==size must return Eof"); assert!(matches!(StatusCode::from(err), StatusCode::Eof)); assert!( !captured.contains("ERROR"), "Eof return must not produce an error-level event, captured: {captured}" ); assert!( !captured.contains("SFTP READ failed"), "Eof return must not log SFTP READ failed, captured: {captured}" ); } /// A non-Eof failure on the read path is operator-visible. The /// assertion below confirms a backend error produces an /// error-level event. #[tokio::test] async fn read_backend_failure_emits_error_level_event() { let backend = Arc::new(DummyBackend::new()); backend.queue_get_object_range_err(DummyError::Injected("backend exploded".into())); let mut driver = build_driver(Arc::clone(&backend), TEST_PART_SIZE); let handle_id = driver .allocate_handle(file_handle("b", "k", 1024, FileAttributes::default())) .expect("allocate"); let (result, captured) = capture_tracing_at(Level::ERROR, with_test_auth_override(|_, _, _| true, driver.read(12, handle_id, 0, 256))).await; let err = result.expect_err("backend error must propagate as Err"); assert!(!matches!(StatusCode::from(err), StatusCode::Eof), "backend error must not be Eof"); assert!( captured.contains("ERROR"), "non-Eof backend failure must produce an error-level event, captured: {captured}" ); assert!( captured.contains("SFTP READ failed"), "error-level event must carry the SFTP READ failed message, captured: {captured}" ); } /// run_backend wraps the outer get_object_range call in the per-call /// deadline, but the body iteration inside read_inner is a separate /// stream of awaits. A backend that returns the body and then stalls /// mid-stream pins the session task on body.next() until something /// else closes the connection. The per-chunk timeout closes that gap. /// This test queues a body that emits one chunk and stalls forever /// on the next .next() poll, runs read with a 1 s backend deadline, /// and asserts that the call returns Failure within the deadline plus /// a generous buffer rather than waiting on the outer 10 s guard. #[tokio::test(flavor = "current_thread")] async fn read_chunk_stall_returns_failure_within_deadline() { let backend = Arc::new(DummyBackend::new()); backend.queue_get_object_range_stalling_after_chunk(b"prefix".to_vec(), 4096); let timeout_secs: u64 = 1; let mut driver = build_driver_with_timeout(Arc::clone(&backend), TEST_PART_SIZE, timeout_secs); let handle_id = driver .allocate_handle(file_handle("b", "k", 4096, FileAttributes::default())) .expect("allocate"); let start = Instant::now(); let outcome = tokio::time::timeout( Duration::from_secs(10), with_test_auth_override(|_, _, _| true, driver.read(14, handle_id, 0, 4096)), ) .await; let elapsed = start.elapsed(); let inner = outcome.expect("per-chunk deadline must fire before the outer 10 s guard"); let err = inner.expect_err("stalled body must surface as Err"); assert!( !matches!(StatusCode::from(err), StatusCode::Eof), "stalled body must not be reported as Eof" ); assert!( elapsed < Duration::from_secs(timeout_secs + 4), "stalled body must time out within {} s, elapsed: {:?}", timeout_secs + 4, elapsed, ); } /// Sequential reads on the same handle are served from the cache /// after the first miss. The DummyBackend queues exactly one /// get_object_range response sized to the configured window. With /// the cache wired the driver consumes that one response on the /// first read. Subsequent reads inside the cached chunk are /// returned from the buffer without a second backend call. The /// queue is empty after the first response, so any second backend /// call would return NoSuchKey and fail the test. #[tokio::test] async fn sequential_reads_cache_hit_after_first_miss() { let window: u64 = 64 * 1024; let object_size: u64 = window; let payload: Vec = (0..object_size as usize).map(|i| i as u8).collect(); let backend = Arc::new(DummyBackend::new()); backend.queue_get_object_range_bytes(payload.clone()); let mut driver = build_driver_with_read_cache(Arc::clone(&backend), TEST_PART_SIZE, window, 1024 * 1024 * 1024); let handle_id = driver .allocate_handle(file_handle("b", "k", object_size, FileAttributes::default())) .expect("allocate"); let chunk: u32 = 8 * 1024; let mut offset: u64 = 0; let mut assembled: Vec = Vec::with_capacity(object_size as usize); let mut reads: u32 = 0; while offset < object_size { let data = with_test_auth_override(|_, _, _| true, driver.read(20 + reads, handle_id.clone(), offset, chunk)) .await .expect("read inside the cached window must succeed without a second backend call"); assert!(!data.data.is_empty(), "non-empty hit"); assembled.extend_from_slice(&data.data); offset += data.data.len() as u64; reads += 1; assert!(reads < 100, "loop guard: reads must terminate inside the window"); } assert_eq!(assembled, payload, "assembled bytes must match seed"); assert!(reads > 1, "test must drive more than one FXP_READ to exercise the cache"); } /// A read sequence that crosses two windows triggers exactly two /// backend calls. Two responses sized to the window are queued. /// Reads within window 1 are served from the buffer after the /// miss that fetched it. The boundary read at offset == window /// falls outside the cached chunk and triggers a second backend /// call to fetch window 2. #[tokio::test] async fn read_crossing_two_windows_triggers_two_backend_calls() { let window: u64 = 64 * 1024; let object_size: u64 = window * 2; let first_window: Vec = vec![0xAA_u8; window as usize]; let second_window: Vec = vec![0xBB_u8; window as usize]; let backend = Arc::new(DummyBackend::new()); backend.queue_get_object_range_bytes(first_window.clone()); backend.queue_get_object_range_bytes(second_window.clone()); let mut driver = build_driver_with_read_cache(Arc::clone(&backend), TEST_PART_SIZE, window, 1024 * 1024 * 1024); let handle_id = driver .allocate_handle(file_handle("b", "k", object_size, FileAttributes::default())) .expect("allocate"); // First read fetches from the backend and populates window 1. let r1 = with_test_auth_override(|_, _, _| true, driver.read(30, handle_id.clone(), 0, 1024)) .await .expect("first read must succeed"); assert!(r1.data.iter().all(|b| *b == 0xAA), "first read must come from window 1"); // Second read inside the cached chunk is served from the // buffer. No second backend call yet. let r2 = with_test_auth_override(|_, _, _| true, driver.read(31, handle_id.clone(), 1024, 1024)) .await .expect("mid-window read must succeed from cache"); assert!(r2.data.iter().all(|b| *b == 0xAA), "mid-window read still in window 1"); // Reading at offset == window falls outside the cached chunk // and triggers the second backend call. let r3 = with_test_auth_override(|_, _, _| true, driver.read(32, handle_id.clone(), window, 1024)) .await .expect("read at offset=window must succeed via second backend call"); assert!(r3.data.iter().all(|b| *b == 0xBB), "read at window boundary must come from window 2"); // A read inside the second cached chunk is served from the // buffer. The queue is empty by now, so any third backend // call would fail. let r4 = with_test_auth_override(|_, _, _| true, driver.read(33, handle_id, window + 1024, 1024)) .await .expect("mid-window-2 read must succeed from cache"); assert!(r4.data.iter().all(|b| *b == 0xBB), "mid-window-2 read still in window 2"); } /// A partial-hit FXP_READ at the window edge returns only the /// portion of the requested range that sits inside the cached /// chunk. The driver must not issue a backend call to make up /// the rest of the requested length on the same FXP_READ. The /// next FXP_READ from the client triggers the refresh. #[tokio::test] async fn partial_window_edge_hit_returns_short_read() { let window: u64 = 1024; let object_size: u64 = window * 2; let first_window: Vec = vec![0xCC_u8; window as usize]; let second_window: Vec = vec![0xDD_u8; window as usize]; let backend = Arc::new(DummyBackend::new()); backend.queue_get_object_range_bytes(first_window); backend.queue_get_object_range_bytes(second_window); let mut driver = build_driver_with_read_cache(Arc::clone(&backend), TEST_PART_SIZE, window, 1024 * 1024 * 1024); let handle_id = driver .allocate_handle(file_handle("b", "k", object_size, FileAttributes::default())) .expect("allocate"); // Populate window 1 with a full read. let _ = with_test_auth_override(|_, _, _| true, driver.read(40, handle_id.clone(), 0, window as u32)) .await .expect("populate window 1"); // Ask for 256 bytes starting 64 bytes before window end. Only // 64 bytes are in the window. The driver must return 64. let edge = with_test_auth_override(|_, _, _| true, driver.read(41, handle_id, window - 64, 256)) .await .expect("partial-hit read must succeed"); assert_eq!(edge.data.len(), 64, "partial hit must return only the in-window portion"); assert!(edge.data.iter().all(|b| *b == 0xCC), "partial hit bytes must come from window 1"); } /// With READ_CACHE_DISABLED set as the window value the cache is /// bypassed entirely. Each FXP_READ must hit the backend, and no /// buffer is retained between reads. Verified by queueing one /// backend response per expected FXP_READ; if any read short- /// circuited via the cache the queue would still hold a response /// at the end, and a subsequent read would return an extra /// backend payload. A separate assertion confirms the per-handle /// ReadCache buf stays at zero capacity across the read sequence. #[tokio::test] async fn read_cache_disabled_hits_backend_on_every_read() { let chunk_size: usize = 4 * 1024; let read_count: u32 = 5; let object_size: u64 = (chunk_size as u64) * (read_count as u64); let backend = Arc::new(DummyBackend::new()); for i in 0..read_count { let payload = vec![(i + 1) as u8; chunk_size]; backend.queue_get_object_range_bytes(payload); } let mut driver = build_driver_with_read_cache(Arc::clone(&backend), TEST_PART_SIZE, READ_CACHE_DISABLED, 1024 * 1024 * 1024); let handle_id = driver .allocate_handle(file_handle("b", "k", object_size, FileAttributes::default())) .expect("allocate"); for i in 0..read_count { let offset = (chunk_size as u64) * (i as u64); let data = with_test_auth_override(|_, _, _| true, driver.read(50 + i, handle_id.clone(), offset, chunk_size as u32)) .await .expect("each read must succeed via the backend"); assert_eq!(data.data.len(), chunk_size, "read must return full requested length"); let expected_byte = (i + 1) as u8; assert!( data.data.iter().all(|b| *b == expected_byte), "read {i} payload must come from the i-th queued backend response" ); let cap = driver.with_handle_ref(&handle_id, |state| match state { HandleState::File { read_cache, .. } => Ok(read_cache.capacity()), _ => Ok(usize::MAX), }); assert_eq!(cap.expect("handle present"), 0, "ReadCache buf must stay empty when disabled"); } } }