blob: ed90a9b095b748a19138bc48c5b6133033dc28cf [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
use super::backend::*;
use super::core::Manager;
use super::core::is_not_found;
use super::core::is_sftp_failure;
use super::core::parse_sftp_error;
use super::lister::SftpLister;
use super::writer::SftpWriter;
use bytes::BytesMut;
use fastpool::bounded;
use opendal_core::raw::oio::ReadStream as _;
use opendal_core::raw::*;
use opendal_core::*;
use openssh_sftp_client::Error as SftpClientError;
use openssh_sftp_client::error::SftpErrorKind;
use openssh_sftp_client::file::File;
use std::error::Error as StdError;
use std::io::SeekFrom;
use std::sync::Arc;
use std::sync::Mutex;
use tokio::io::AsyncSeekExt;
const SFTP_READ_CHUNK_SIZE: usize = 2 * 1024 * 1024;
pub struct SftpReadStream {
/// Keep the connection alive while data stream is alive.
_conn: bounded::Object<Manager>,
file: File,
size: Option<usize>,
read: usize,
buf: BytesMut,
}
impl SftpReadStream {
pub fn new(conn: bounded::Object<Manager>, file: File, size: Option<u64>) -> Self {
Self {
_conn: conn,
file,
size: size.map(|v| v as usize),
read: 0,
buf: BytesMut::new(),
}
}
}
impl oio::ReadStream for SftpReadStream {
async fn read(&mut self) -> Result<Buffer> {
if self.read >= self.size.unwrap_or(usize::MAX) {
return Ok(Buffer::new());
}
let size = if let Some(size) = self.size {
(size - self.read).min(SFTP_READ_CHUNK_SIZE)
} else {
SFTP_READ_CHUNK_SIZE
};
self.buf.reserve(size);
let Some(bytes) = self
.file
.read(size as u32, self.buf.split_off(0))
.await
.map_err(parse_sftp_error)?
else {
return Ok(Buffer::new());
};
self.read += bytes.len();
self.buf = bytes;
let bs = self.buf.split();
Ok(Buffer::from(bs.freeze()))
}
}
/// Reader returned by this backend.
///
/// On the first read, the reader opens a file handle and tries a positioned
/// read (seek + read). If the server supports it, the handle is cached for the
/// reader's lifetime and all subsequent reads go through the positioned path.
/// If the server does not support positioned reads, the result is cached
/// service-wide and the reader falls back to streaming.
pub struct SftpReader {
backend: SftpBackend,
path: String,
/// Lazily resolved read strategy for this reader.
///
/// This intentionally uses std synchronization instead of an async once
/// cell. A race can duplicate the first open/probe, but that does not
/// affect correctness, and we prefer std tools when they are sufficient.
positioned: Arc<Mutex<Option<PositionedReadStrategy>>>,
}
#[derive(Clone)]
enum PositionedReadStrategy {
Supported(Arc<SftpReaderHandle>),
Unsupported,
}
impl PositionedReadStrategy {
fn handle(&self) -> Option<Arc<SftpReaderHandle>> {
match self {
Self::Supported(handle) => Some(handle.clone()),
Self::Unsupported => None,
}
}
}
impl SftpReader {
pub(super) fn new(backend: SftpBackend, path: &str, _: OpRead) -> Self {
Self {
backend,
path: path.to_string(),
positioned: Arc::new(Mutex::new(None)),
}
}
async fn open_stream(
&self,
range: BytesRange,
) -> Result<(RpRead, Box<dyn oio::ReadStreamDyn>)> {
let (client, mut file) = self.open_file().await?;
if range.offset() != 0 {
file.seek(SeekFrom::Start(range.offset()))
.await
.map_err(new_std_io_error)?;
}
let stream = SftpReadStream::new(client, file, range.size());
Ok((
RpRead::default(),
Box::new(stream) as Box<dyn oio::ReadStreamDyn>,
))
}
async fn open_file(&self) -> Result<(bounded::Object<Manager>, File)> {
let backend = &self.backend;
let path = self.path.as_str();
let client = backend.core.connect().await?;
let mut fs = client.fs();
fs.set_cwd(&backend.core.root);
let path = fs.canonicalize(path).await.map_err(parse_sftp_error)?;
let file = client
.open(path.as_path())
.await
.map_err(parse_sftp_error)?;
Ok((client, file))
}
/// Return a cached positioned-read handle for this reader, or `None`
/// if the server does not support positioned reads.
///
/// On the very first call across the entire service, this opens a file
/// and attempts a positioned read to detect support. The file handle is
/// reused for subsequent reads when supported. The detection result is
/// cached in `SftpCore::positioned_read_support` so all future readers
/// skip the probe entirely.
async fn positioned_handle(&self) -> Result<Option<Arc<SftpReaderHandle>>> {
// Fast path: a previous reader already determined positioned reads
// are not supported, so no file needs to be opened.
if self
.backend
.core
.positioned_read_support
.get()
.is_some_and(|&supported| !supported)
{
return Ok(None);
}
let cached = { self.positioned.lock().expect("mutex poisoned").clone() };
if let Some(strategy) = cached {
return Ok(strategy.handle());
}
let (client, file) = self.open_file().await?;
let handle = Arc::new(SftpReaderHandle::new(client, file));
// If a previous reader already resolved support, respect that.
let strategy = if let Some(&supported) = self.backend.core.positioned_read_support.get() {
if supported {
PositionedReadStrategy::Supported(handle)
} else {
PositionedReadStrategy::Unsupported
}
} else {
// First reader across the service: probe by trying a real
// positioned read on the file we already opened.
match probe_positioned_read(&handle).await {
Ok(()) => {
// Probe succeeded: positioned reads work. Cache the
// result service-wide so other readers skip the probe.
let _ = self.backend.core.positioned_read_support.set(true);
PositionedReadStrategy::Supported(handle)
}
Err(err) if is_positioned_read_unsupported(&err) => {
// Positioned reads are not supported. Cache the result
// service-wide so other readers skip the probe.
let _ = self.backend.core.positioned_read_support.set(false);
PositionedReadStrategy::Unsupported
}
Err(err) => return Err(err),
}
};
*self.positioned.lock().expect("mutex poisoned") = Some(strategy.clone());
Ok(strategy.handle())
}
}
pub struct SftpReaderHandle {
/// Keep the connection alive while the file handle is cached by `SftpReader`.
_conn: bounded::Object<Manager>,
file: File,
}
impl SftpReaderHandle {
pub(super) fn new(conn: bounded::Object<Manager>, file: File) -> Self {
Self { _conn: conn, file }
}
}
async fn read_positioned(handle: &SftpReaderHandle, offset: u64, size: usize) -> Result<Buffer> {
if size == 0 {
return Ok(Buffer::new());
}
let mut file = handle.file.clone();
file.seek(SeekFrom::Start(offset))
.await
.map_err(new_std_io_error)?;
match file
.read(size as u32, BytesMut::with_capacity(size))
.await
.map_err(parse_sftp_error)?
{
Some(bytes) => Ok(Buffer::from(bytes.freeze())),
None => Ok(Buffer::new()),
}
}
/// Probe whether positioned reads work by seeking to the end of the file and
/// attempting a small read. A server that supports positioned reads will
/// succeed (returning empty at EOF); a server that does not support seek
/// will return an error we classify as "unsupported".
async fn probe_positioned_read(handle: &SftpReaderHandle) -> Result<()> {
let mut file = handle.file.clone();
let len = file.metadata().await.map_err(parse_sftp_error)?.len();
let probe_offset = len.unwrap_or(0);
file.seek(SeekFrom::Start(probe_offset))
.await
.map_err(new_std_io_error)?;
// A small read to confirm the data path works after seeking.
let _ = file
.read(1u32, BytesMut::with_capacity(1))
.await
.map_err(parse_sftp_error)?;
Ok(())
}
fn is_positioned_read_unsupported(err: &Error) -> bool {
if err.kind() == ErrorKind::Unsupported {
return true;
}
err.source()
.and_then(|source| source.downcast_ref::<SftpClientError>())
.is_some_and(|source| {
matches!(
source,
SftpClientError::UnsupportedSftpProtocol { .. }
| SftpClientError::SftpError(SftpErrorKind::OpUnsupported, _)
)
})
}
impl oio::Read for SftpReader {
async fn open(&self, range: BytesRange) -> Result<(RpRead, Box<dyn oio::ReadStreamDyn>)> {
if let Some(handle) = self.positioned_handle().await? {
let stream = SftpPositionedReadStream {
handle,
offset: range.offset(),
remaining: range.size(),
};
Ok((
RpRead::default(),
Box::new(stream) as Box<dyn oio::ReadStreamDyn>,
))
} else {
self.open_stream(range).await
}
}
async fn read(&self, range: BytesRange) -> Result<(RpRead, Buffer)> {
if let Some(handle) = self.positioned_handle().await? {
let size = range.size().ok_or_else(|| {
Error::new(ErrorKind::Unsupported, "read requires a bounded range")
})?;
let mut offset = range.offset();
let mut remaining = size;
let mut bufs = Vec::new();
while remaining > 0 {
let read_size = remaining.min(SFTP_READ_CHUNK_SIZE as u64) as usize;
let buf = read_positioned(&handle, offset, read_size).await?;
if buf.len() > read_size {
return Err(Error::new(
ErrorKind::Unexpected,
"reader got unexpected data size",
)
.with_context("expect", read_size)
.with_context("actual", buf.len()));
}
if buf.is_empty() {
return Err(Error::new(
ErrorKind::RangeNotSatisfied,
"range exceeds content length",
)
.with_context("offset", offset)
.with_context("remaining", remaining));
}
let n = buf.len() as u64;
offset += n;
remaining -= n;
bufs.push(buf);
}
Ok((RpRead::default(), bufs.into_iter().flatten().collect()))
} else {
let expected = range.size().ok_or_else(|| {
Error::new(ErrorKind::Unsupported, "read requires a bounded range")
})?;
let (rp, mut stream) = self.open_stream(range).await?;
let buffer = stream.read_all().await?;
if buffer.len() as u64 != expected {
return Err(
Error::new(ErrorKind::Unexpected, "reader got unexpected data size")
.with_context("expect", expected)
.with_context("actual", buffer.len() as u64),
);
}
Ok((rp, buffer))
}
}
}
struct SftpPositionedReadStream {
handle: Arc<SftpReaderHandle>,
offset: u64,
remaining: Option<u64>,
}
impl oio::ReadStream for SftpPositionedReadStream {
async fn read(&mut self) -> Result<Buffer> {
if self.remaining == Some(0) {
return Ok(Buffer::new());
}
let read_size = self
.remaining
.map(|remaining| remaining.min(SFTP_READ_CHUNK_SIZE as u64) as usize)
.unwrap_or(SFTP_READ_CHUNK_SIZE);
let buf = read_positioned(&self.handle, self.offset, read_size).await?;
if buf.len() > read_size {
return Err(
Error::new(ErrorKind::Unexpected, "reader got unexpected data size")
.with_context("expect", read_size)
.with_context("actual", buf.len()),
);
}
if buf.is_empty() {
if let Some(remaining) = self.remaining {
return Err(Error::new(
ErrorKind::RangeNotSatisfied,
"range exceeds content length",
)
.with_context("offset", self.offset)
.with_context("remaining", remaining));
}
return Ok(Buffer::new());
}
let n = buf.len() as u64;
self.offset += n;
if let Some(remaining) = &mut self.remaining {
*remaining -= n;
}
Ok(buf)
}
}
pub struct SftpLazyWriter {
backend: SftpBackend,
ctx: OperationContext,
path: String,
op: OpWrite,
inner: Option<SftpWriter>,
}
impl SftpLazyWriter {
pub(super) fn new(
backend: SftpBackend,
ctx: OperationContext,
path: &str,
op: OpWrite,
) -> Self {
Self {
backend,
ctx,
path: path.to_string(),
op,
inner: None,
}
}
async fn inner(&mut self) -> Result<&mut SftpWriter> {
if self.inner.is_none() {
if let Some((dir, _)) = self.path.rsplit_once('/') {
self.backend
.create_dir(&self.ctx, dir, OpCreateDir::default())
.await?;
}
let client = self.backend.core.connect().await?;
let mut fs = client.fs();
fs.set_cwd(&self.backend.core.root);
let path = fs
.canonicalize(&self.path)
.await
.map_err(parse_sftp_error)?;
let mut option = client.options();
if self.op.if_not_exists() {
option.create_new(true);
} else {
option.create(true);
}
if self.op.append() {
option.append(true);
} else {
option.write(true).truncate(true);
}
let res = option.open(&path).await;
let file = match res {
Ok(f) => f,
Err(e) if self.op.if_not_exists() && is_sftp_failure(&e) => {
if fs.metadata(&path).await.is_ok() {
return Err(Error::new(
ErrorKind::ConditionNotMatch,
"file already exists, doesn't match the condition if_not_exists",
)
.set_source(e));
}
return Err(parse_sftp_error(e));
}
Err(e) => return Err(parse_sftp_error(e)),
};
self.inner = Some(SftpWriter::new(file));
}
Ok(self.inner.as_mut().expect("writer must be initialized"))
}
}
impl oio::Write for SftpLazyWriter {
async fn write(&mut self, bs: Buffer) -> Result<()> {
self.inner().await?.write(bs).await
}
async fn close(&mut self) -> Result<Metadata> {
self.inner().await?.close().await
}
async fn abort(&mut self) -> Result<()> {
self.inner().await?.abort().await
}
}
pub struct SftpLazyLister {
backend: SftpBackend,
path: String,
inner: Option<Option<SftpLister>>,
}
impl SftpLazyLister {
pub(super) fn new(backend: SftpBackend, path: &str) -> Self {
Self {
backend,
path: path.to_string(),
inner: None,
}
}
}
impl oio::List for SftpLazyLister {
async fn next(&mut self) -> Result<Option<oio::Entry>> {
if self.inner.is_none() {
let client = self.backend.core.connect().await?;
let mut fs = client.fs();
fs.set_cwd(&self.backend.core.root);
let file_path = format!("./{}", self.path);
self.inner = Some(match fs.open_dir(&file_path).await {
Ok(dir) => Some(SftpLister::new(dir.read_dir(), self.path.clone())),
Err(e) if is_not_found(&e) => None,
Err(e) => return Err(parse_sftp_error(e)),
});
}
match self.inner.as_mut().expect("lister must be initialized") {
Some(lister) => lister.next().await,
None => Ok(None),
}
}
}