blob: 47d8d65104b720b2638ee661e8ec33260e18ff6a [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.
//! An object store implementation for a local filesystem
use std::fs::{File, Metadata, OpenOptions, metadata, symlink_metadata};
use std::io::{ErrorKind, Read, Seek, SeekFrom, Write};
use std::ops::Range;
#[cfg(target_family = "unix")]
use std::os::unix::fs::FileExt;
#[cfg(target_family = "windows")]
use std::os::windows::fs::FileExt;
use std::sync::Arc;
use std::time::SystemTime;
use std::{collections::BTreeSet, io};
use std::{collections::VecDeque, path::PathBuf};
use async_trait::async_trait;
use bytes::Bytes;
use chrono::{DateTime, Utc};
use futures_util::{FutureExt, TryStreamExt};
use futures_util::{StreamExt, stream::BoxStream};
use parking_lot::Mutex;
use url::Url;
use walkdir::{DirEntry, WalkDir};
use crate::{
Attributes, GetOptions, GetResult, GetResultPayload, ListResult, MultipartUpload, ObjectMeta,
ObjectStore, PutMode, PutMultipartOptions, PutOptions, PutPayload, PutResult, Result,
UploadPart, maybe_spawn_blocking,
path::{Path, absolute_path_to_url},
util::{InvalidGetRange, merge_ranges},
};
use crate::{CopyMode, CopyOptions, RenameOptions, RenameTargetMode};
/// A specialized `Error` for filesystem object store-related errors
#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
#[error("Unable to walk dir: {}", source)]
UnableToWalkDir { source: walkdir::Error },
#[error("Unable to access metadata for {}: {}", path, source)]
Metadata {
source: Box<dyn std::error::Error + Send + Sync + 'static>,
path: String,
},
#[error("Unable to copy data to file: {}", source)]
UnableToCopyDataToFile { source: io::Error },
#[error("Unable to rename file: {}", source)]
UnableToRenameFile { source: io::Error },
#[error("Unable to create dir {}: {}", path.display(), source)]
UnableToCreateDir { source: io::Error, path: PathBuf },
#[error("Unable to create file {}: {}", path.display(), source)]
UnableToCreateFile { source: io::Error, path: PathBuf },
#[error("Unable to delete file {}: {}", path.display(), source)]
UnableToDeleteFile { source: io::Error, path: PathBuf },
#[error("Unable to open file {}: {}", path.display(), source)]
UnableToOpenFile { source: io::Error, path: PathBuf },
#[error("Unable to read data from file {}: {}", path.display(), source)]
UnableToReadBytes { source: io::Error, path: PathBuf },
#[error("Out of range of file {}, expected: {}, actual: {}", path.display(), expected, actual)]
OutOfRange {
path: PathBuf,
expected: u64,
actual: u64,
},
#[error("Requested range was invalid")]
InvalidRange { source: InvalidGetRange },
#[error("Unable to copy file from {} to {}: {}", from.display(), to.display(), source)]
UnableToCopyFile {
from: PathBuf,
to: PathBuf,
source: io::Error,
},
#[error("NotFound")]
NotFound { path: PathBuf, source: io::Error },
#[error("Error seeking file {}: {}", path.display(), source)]
Seek { source: io::Error, path: PathBuf },
#[error("Unable to convert URL \"{}\" to filesystem path", url)]
InvalidUrl { url: Url },
#[error("AlreadyExists")]
AlreadyExists { path: String, source: io::Error },
#[error("Unable to canonicalize filesystem root: {}", path.display())]
UnableToCanonicalize { path: PathBuf, source: io::Error },
#[error("Filenames containing trailing '/#\\d+/' are not supported: {}", path)]
InvalidPath { path: String },
#[error("Unable to sync data to disk for {}: {}", path.display(), source)]
UnableToSyncFile { source: io::Error, path: PathBuf },
#[error("Upload aborted")]
Aborted,
}
impl From<Error> for super::Error {
fn from(source: Error) -> Self {
match source {
Error::NotFound { path, source } => Self::NotFound {
path: path.to_string_lossy().to_string(),
source: source.into(),
},
Error::AlreadyExists { path, source } => Self::AlreadyExists {
path,
source: source.into(),
},
_ => Self::Generic {
store: "LocalFileSystem",
source: Box::new(source),
},
}
}
}
/// Explicitly close a file, checking for errors that would be silently ignored by Rust's `File::drop()`.
///
/// On network filesystems (e.g. NFS), `close()` can fail and indicate data loss.
fn close_file(file: File) -> std::result::Result<(), io::Error> {
#[cfg(target_family = "unix")]
{
use std::os::fd::IntoRawFd;
close_fd(file.into_raw_fd())
}
#[cfg(target_family = "windows")]
{
use std::os::windows::io::IntoRawHandle;
close_handle(file.into_raw_handle())
}
#[cfg(not(any(target_family = "unix", target_family = "windows")))]
{
drop(file);
Ok(())
}
}
#[cfg(target_family = "unix")]
fn close_fd(fd: std::os::fd::RawFd) -> std::result::Result<(), io::Error> {
nix::unistd::close(fd).map_err(|e| e.into())
}
#[cfg(target_family = "windows")]
fn close_handle(handle: std::os::windows::io::RawHandle) -> std::result::Result<(), io::Error> {
// SAFETY: `handle` must be an owned handle, except when testing invalid handle errors.
match unsafe { windows_sys::Win32::Foundation::CloseHandle(handle) } {
0 => Err(io::Error::last_os_error()),
_ => Ok(()),
}
}
/// Local filesystem storage providing an [`ObjectStore`] interface to files on
/// local disk. Can optionally be created with a directory prefix
///
/// # Path Semantics
///
/// This implementation follows the [file URI] scheme outlined in [RFC 3986]. In
/// particular paths are delimited by `/`
///
/// [file URI]: https://en.wikipedia.org/wiki/File_URI_scheme
/// [RFC 3986]: https://www.rfc-editor.org/rfc/rfc3986
///
/// # Path Semantics
///
/// [`LocalFileSystem`] will expose the path semantics of the underlying filesystem, which may
/// have additional restrictions beyond those enforced by [`Path`].
///
/// For example:
///
/// * Windows forbids certain filenames, e.g. `COM0`,
/// * Windows forbids folders with trailing `.`
/// * Windows forbids certain ASCII characters, e.g. `<` or `|`
/// * OS X forbids filenames containing `:`
/// * Leading `-` are discouraged on Unix systems where they may be interpreted as CLI flags
/// * Filesystems may have restrictions on the maximum path or path segment length
/// * Filesystem support for non-ASCII characters is inconsistent
///
/// Additionally some filesystems, such as NTFS, are case-insensitive, whilst others like
/// FAT don't preserve case at all. Further some filesystems support non-unicode character
/// sequences, such as unpaired UTF-16 surrogates, and [`LocalFileSystem`] will error on
/// encountering such sequences.
///
/// Finally, filenames matching the regex `/.*#\d+/`, e.g. `foo.parquet#123`, are not supported
/// by [`LocalFileSystem`] as they are used to provide atomic writes. Such files will be ignored
/// for listing operations, and attempting to address such a file will error.
///
/// # Tokio Compatibility
///
/// Tokio discourages performing blocking IO on a tokio worker thread, however,
/// no major operating systems have stable async file APIs. Therefore if called from
/// a tokio context, this will use [`tokio::runtime::Handle::spawn_blocking`] to dispatch
/// IO to a blocking thread pool, much like `tokio::fs` does under-the-hood.
///
/// If not called from a tokio context, this will perform IO on the current thread with
/// no additional complexity or overheads
///
/// # Symlinks
///
/// [`LocalFileSystem`] will follow symlinks as normal, however, it is worth noting:
///
/// * Broken symlinks will be silently ignored by listing operations
/// * No effort is made to prevent breaking symlinks when deleting files
/// * Symlinks that resolve to paths outside the root **will** be followed
/// * Mutating a file through one or more symlinks will mutate the underlying file
/// * Deleting a path that resolves to a symlink will only delete the symlink
///
/// # Cross-Filesystem Copy
///
/// [`LocalFileSystem::copy_opts`] is implemented using [`std::fs::hard_link`], and therefore
/// does not support copying across filesystem boundaries.
///
#[derive(Clone, Debug)]
pub struct LocalFileSystem {
config: Arc<Config>,
// if you want to delete empty directories when deleting files
automatic_cleanup: bool,
// if true, fsync written files and their parent directories after writes
fsync: bool,
}
#[derive(Debug)]
struct Config {
root: Url,
}
impl std::fmt::Display for LocalFileSystem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "LocalFileSystem({})", self.config.root)
}
}
impl Default for LocalFileSystem {
fn default() -> Self {
Self::new()
}
}
impl LocalFileSystem {
/// Create new filesystem storage with no prefix
pub fn new() -> Self {
Self {
config: Arc::new(Config {
root: Url::parse("file:///").unwrap(),
}),
automatic_cleanup: false,
fsync: false,
}
}
/// Create new filesystem storage with `prefix` applied to all paths
///
/// Returns an error if the path does not exist
///
pub fn new_with_prefix(prefix: impl AsRef<std::path::Path>) -> Result<Self> {
let path = std::fs::canonicalize(&prefix).map_err(|source| {
let path = prefix.as_ref().into();
Error::UnableToCanonicalize { source, path }
})?;
Ok(Self {
config: Arc::new(Config {
root: absolute_path_to_url(path)?,
}),
automatic_cleanup: false,
fsync: false,
})
}
/// Return an absolute filesystem path of the given file location
pub fn path_to_filesystem(&self, location: &Path) -> Result<PathBuf> {
self.config.path_to_filesystem(location)
}
/// Enable automatic cleanup of empty directories when deleting files
pub fn with_automatic_cleanup(mut self, automatic_cleanup: bool) -> Self {
self.automatic_cleanup = automatic_cleanup;
self
}
/// Enable `fsync` after writes for durability
///
/// When enabled, [`LocalFileSystem`] calls [`File::sync_all`] on written files and fsyncs
/// the affected parent directories before a write operation
/// ([`put_opts`](ObjectStore::put_opts), [`copy_opts`](ObjectStore::copy_opts),
/// [`rename_opts`](ObjectStore::rename_opts), and multipart upload completion) returns
/// success. This guarantees that both the file contents and the directory entries pointing
/// to them are durable on stable storage, matching the implicit durability contract of
/// remote object stores such as S3 or GCS.
///
/// This trades write throughput for durability and is **disabled by default**.
///
/// Note that directory fsync is only performed on Unix; on other platforms (e.g. Windows)
/// it is a no-op, as directories cannot be portably opened and synced.
pub fn with_fsync(mut self, fsync: bool) -> Self {
self.fsync = fsync;
self
}
}
impl Config {
/// Return an absolute filesystem path of the given location
fn prefix_to_filesystem(&self, location: &Path) -> Result<PathBuf> {
let mut url = self.root.clone();
url.path_segments_mut()
.expect("url path")
// technically not necessary as Path ignores empty segments
// but avoids creating paths with "//" which look odd in error messages.
.pop_if_empty()
.extend(location.parts());
url.to_file_path()
.map_err(|_| Error::InvalidUrl { url }.into())
}
/// Return an absolute filesystem path of the given file location
fn path_to_filesystem(&self, location: &Path) -> Result<PathBuf> {
if !is_valid_file_path(location) {
let path = location.as_ref().into();
let error = Error::InvalidPath { path };
return Err(error.into());
}
let path = self.prefix_to_filesystem(location)?;
#[cfg(target_os = "windows")]
let path = {
let path = path.to_string_lossy();
// Assume the first char is the drive letter and the next is a colon.
let mut out = String::new();
let drive = &path[..2]; // The drive letter and colon (e.g., "C:")
let filepath = &path[2..].replace(':', "%3A"); // Replace subsequent colons
out.push_str(drive);
out.push_str(filepath);
PathBuf::from(out)
};
Ok(path)
}
/// Resolves the provided absolute filesystem path to a [`Path`] prefix
fn filesystem_to_path(&self, location: &std::path::Path) -> Result<Path> {
Ok(Path::from_absolute_path_with_base(
location,
Some(&self.root),
)?)
}
}
fn is_valid_file_path(path: &Path) -> bool {
match path.filename() {
Some(p) => match p.split_once('#') {
Some((_, suffix)) if !suffix.is_empty() => {
// Valid if contains non-digits
!suffix.as_bytes().iter().all(|x| x.is_ascii_digit())
}
_ => true,
},
None => false,
}
}
#[async_trait]
impl ObjectStore for LocalFileSystem {
async fn put_opts(
&self,
location: &Path,
payload: PutPayload,
opts: PutOptions,
) -> Result<PutResult> {
if matches!(opts.mode, PutMode::Update(_)) {
return Err(crate::Error::NotImplemented {
operation: "`put_opts` with mode `PutMode::Update`".into(),
implementer: self.to_string(),
});
}
if !opts.attributes.is_empty() {
return Err(crate::Error::NotImplemented {
operation: "`put_opts` with `opts.attributes` specified".into(),
implementer: self.to_string(),
});
}
let path = self.path_to_filesystem(location)?;
let fsync = self.fsync;
maybe_spawn_blocking(move || {
let (mut file, staging_path) = new_staged_upload(&path, fsync)?;
let mut e_tag = None;
let err = match payload.iter().try_for_each(|x| file.write_all(x)) {
Ok(_) => {
let metadata = file.metadata().map_err(|e| Error::Metadata {
source: e.into(),
path: path.to_string_lossy().to_string(),
})?;
e_tag = Some(get_etag(&metadata));
// Atomically publish the staged file. When fsync is enabled the publish
// helpers flush the file's contents and the destination's parent directory to
// disk first, so a successful return is durable; the fsync calls are bundled
// into the helpers so a file-system modification can never be left unsynced.
match opts.mode {
PutMode::Overwrite => {
finish_staged_rename(file, &staging_path, &path, fsync).err()
}
PutMode::Create => {
finish_staged_hard_link(file, &staging_path, &path, fsync).err()
}
PutMode::Update(_) => unreachable!(),
}
}
Err(source) => Some(Error::UnableToCopyDataToFile { source }.into()),
};
if let Some(err) = err {
let _ = std::fs::remove_file(&staging_path); // Attempt to cleanup
return Err(err);
}
Ok(PutResult {
e_tag,
version: None,
extensions: Default::default(),
})
})
.await
}
async fn put_multipart_opts(
&self,
location: &Path,
opts: PutMultipartOptions,
) -> Result<Box<dyn MultipartUpload>> {
if !opts.attributes.is_empty() {
return Err(crate::Error::NotImplemented {
operation: "`put_multipart_opts` with `opts.attributes` specified".into(),
implementer: self.to_string(),
});
}
let dest = self.path_to_filesystem(location)?;
let (file, src) = new_staged_upload(&dest, self.fsync)?;
Ok(Box::new(LocalUpload::new(src, dest, file, self.fsync)))
}
async fn get_opts(&self, location: &Path, options: GetOptions) -> Result<GetResult> {
let location = location.clone();
let path = self.path_to_filesystem(&location)?;
maybe_spawn_blocking(move || {
let file = open_file(&path)?;
let metadata = open_metadata(&file, &path)?;
let meta = convert_metadata(metadata, location);
options.check_preconditions(&meta)?;
let range = match options.range {
Some(r) => r
.as_range(meta.size)
.map_err(|source| Error::InvalidRange { source })?,
None => 0..meta.size,
};
Ok(GetResult {
payload: GetResultPayload::File(file, path),
attributes: Attributes::default(),
range,
meta,
extensions: Default::default(),
})
})
.await
}
async fn get_ranges(&self, location: &Path, ranges: &[Range<u64>]) -> Result<Vec<Bytes>> {
let path = self.path_to_filesystem(location)?;
let ranges = ranges.to_vec();
maybe_spawn_blocking(move || {
// We do not read the metadata here, but error in `read_range` if necessary
let mut file = File::open(&path).map_err(|e| map_open_error(e, &path))?;
// Coalesce only contiguous/overlapping ranges (gap 0), so we never
// read bytes that weren't requested while still collapsing adjacent
// ranges into fewer reads.
let fetch_ranges = merge_ranges(&ranges, 0);
// Fast path: nothing coalesced, read each range directly in order.
if fetch_ranges.len() == ranges.len() {
return ranges
.iter()
.map(|r| read_range(&mut file, &path, r.clone()))
.collect();
}
let fetched = fetch_ranges
.iter()
.map(|r| read_range(&mut file, &path, r.clone()))
.collect::<Result<Vec<_>>>()?;
Ok(ranges
.iter()
.map(|range| {
let idx = fetch_ranges.partition_point(|v| v.start <= range.start) - 1;
let fetch_range = &fetch_ranges[idx];
let fetch_bytes = &fetched[idx];
let start = (range.start - fetch_range.start) as usize;
let end = (range.end - fetch_range.start) as usize;
fetch_bytes.slice(start..end.min(fetch_bytes.len()))
})
.collect())
})
.await
}
fn delete_stream(
&self,
locations: BoxStream<'static, Result<Path>>,
) -> BoxStream<'static, Result<Path>> {
let config = Arc::clone(&self.config);
let automatic_cleanup = self.automatic_cleanup;
locations
.map(move |location| {
let config = Arc::clone(&config);
maybe_spawn_blocking(move || {
let location = location?;
// `with_fsync` does not apply to standalone deletes; only create-mode rename
// fsyncs its internal source removal as part of the durable copy-and-delete.
Self::delete_location(config, automatic_cleanup, &location, false)?;
Ok(location)
})
})
.buffered(10)
.boxed()
}
fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result<ObjectMeta>> {
Self::list_with_maybe_offset(Arc::clone(&self.config), prefix, None)
}
fn list_with_offset(
&self,
prefix: Option<&Path>,
offset: &Path,
) -> BoxStream<'static, Result<ObjectMeta>> {
Self::list_with_maybe_offset(Arc::clone(&self.config), prefix, Some(offset))
}
async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result<ListResult> {
let config = Arc::clone(&self.config);
let prefix = prefix.cloned().unwrap_or_default();
let resolved_prefix = config.prefix_to_filesystem(&prefix)?;
maybe_spawn_blocking(move || {
let walkdir = WalkDir::new(&resolved_prefix)
.min_depth(1)
.max_depth(1)
.follow_links(true);
let mut common_prefixes = BTreeSet::new();
let mut objects = Vec::new();
for entry_res in walkdir.into_iter().map(convert_walkdir_result) {
if let Some(entry) = entry_res? {
let is_directory = entry.file_type().is_dir();
let entry_location = config.filesystem_to_path(entry.path())?;
if !is_directory && !is_valid_file_path(&entry_location) {
continue;
}
let mut parts = match entry_location.prefix_match(&prefix) {
Some(parts) => parts,
None => continue,
};
let common_prefix = match parts.next() {
Some(p) => p,
None => continue,
};
drop(parts);
if is_directory {
common_prefixes.insert(prefix.clone().join(common_prefix));
} else if let Some(metadata) = convert_entry(entry, entry_location)? {
objects.push(metadata);
}
}
}
Ok(ListResult {
common_prefixes: common_prefixes.into_iter().collect(),
objects,
extensions: Default::default(),
})
})
.await
}
async fn copy_opts(&self, from: &Path, to: &Path, options: CopyOptions) -> Result<()> {
let CopyOptions {
mode,
extensions: _,
} = options;
let from = self.path_to_filesystem(from)?;
let to = self.path_to_filesystem(to)?;
let fsync = self.fsync;
match mode {
CopyMode::Overwrite => {
let mut id = 0;
// In order to make this atomic we:
//
// - hard link to a hidden temporary file
// - atomically rename this temporary file into place
//
// This is necessary because hard_link returns an error if the destination already exists
maybe_spawn_blocking(move || {
loop {
let staged = staged_upload_path(&to, &id.to_string());
// Stage via a temporary hard link; the source is already durable so the
// staging link itself needs no fsync (the publish rename below fsyncs the
// shared parent directory).
match std::fs::hard_link(&from, &staged) {
// `rename` bundles in the fsync of `to`'s parent directory.
Ok(_) => match rename(&staged, &to, fsync) {
Ok(_) => return Ok(()),
Err(source) => {
let _ = std::fs::remove_file(&staged); // Attempt to clean up
return Err(Error::UnableToCopyFile { from, to, source }.into());
}
},
Err(source) => match source.kind() {
ErrorKind::AlreadyExists => id += 1,
ErrorKind::NotFound => match from.exists() {
true => create_parent_dirs(&to, source, fsync)?,
false => {
return Err(Error::NotFound { path: from, source }.into());
}
},
_ => {
return Err(Error::UnableToCopyFile { from, to, source }.into());
}
},
}
}
})
.await
}
CopyMode::Create => {
maybe_spawn_blocking(move || {
loop {
// The source is an existing object that is already durable, so no file
// sync is needed; `hard_link` bundles in the fsync of `to`'s parent dir.
match hard_link(&from, &to, fsync) {
Ok(_) => return Ok(()),
Err(source) => match source.kind() {
ErrorKind::AlreadyExists => {
return Err(Error::AlreadyExists {
path: to.to_str().unwrap().to_string(),
source,
}
.into());
}
ErrorKind::NotFound => match from.exists() {
true => create_parent_dirs(&to, source, fsync)?,
false => {
return Err(Error::NotFound { path: from, source }.into());
}
},
_ => {
return Err(Error::UnableToCopyFile { from, to, source }.into());
}
},
}
}
})
.await
}
}
}
async fn rename_opts(&self, from: &Path, to: &Path, options: RenameOptions) -> Result<()> {
let RenameOptions {
target_mode,
extensions,
} = options;
match target_mode {
// optimized implementation
RenameTargetMode::Overwrite => {
let from = self.path_to_filesystem(from)?;
let to = self.path_to_filesystem(to)?;
let fsync = self.fsync;
maybe_spawn_blocking(move || {
loop {
// Unlike multipart `complete`, there is no freshly written file to
// `sync_all` here: `from` is an existing, already-durable object and a
// rename only mutates directory entries. `rename` bundles in the fsync of
// both affected directories (destination, and source if it differs).
match rename(&from, &to, fsync) {
Ok(_) => return Ok(()),
Err(source) => match source.kind() {
ErrorKind::NotFound => match from.exists() {
true => create_parent_dirs(&to, source, fsync)?,
false => {
return Err(Error::NotFound { path: from, source }.into());
}
},
_ => {
return Err(Error::UnableToCopyFile { from, to, source }.into());
}
},
}
}
})
.await
}
// fall-back to copy & delete
RenameTargetMode::Create => {
self.copy_opts(
from,
to,
CopyOptions {
mode: CopyMode::Create,
extensions,
},
)
.await?;
let config = Arc::clone(&self.config);
let automatic_cleanup = self.automatic_cleanup;
let fsync = self.fsync;
let from = from.clone();
maybe_spawn_blocking(move || {
Self::delete_location(config, automatic_cleanup, &from, fsync)
})
.await?;
Ok(())
}
}
}
}
impl LocalFileSystem {
fn delete_location(
config: Arc<Config>,
automatic_cleanup: bool,
location: &Path,
fsync: bool,
) -> Result<()> {
let path = config.path_to_filesystem(location)?;
if let Err(e) = std::fs::remove_file(&path) {
Err(match e.kind() {
ErrorKind::NotFound => Error::NotFound { path, source: e }.into(),
_ => Error::UnableToDeleteFile { path, source: e }.into(),
})
} else {
if fsync {
fsync_parent_dir(&path).map_err(|source| Error::UnableToSyncFile {
source,
path: path.clone(),
})?;
}
if !automatic_cleanup {
return Ok(());
}
let root = &config.root;
let root = root
.to_file_path()
.map_err(|_| Error::InvalidUrl { url: root.clone() })?;
// here we will try to traverse up and delete an empty dir if possible until we reach the root or get an error
let mut parent = path.parent();
while let Some(loc) = parent {
if loc != root && std::fs::remove_dir(loc).is_ok() {
parent = loc.parent();
} else {
break;
}
}
Ok(())
}
}
fn list_with_maybe_offset(
config: Arc<Config>,
prefix: Option<&Path>,
maybe_offset: Option<&Path>,
) -> BoxStream<'static, Result<ObjectMeta>> {
let root_path = match prefix {
Some(prefix) => match config.prefix_to_filesystem(prefix) {
Ok(path) => path,
Err(e) => return futures_util::future::ready(Err(e)).into_stream().boxed(),
},
None => config.root.to_file_path().unwrap(),
};
let walkdir = WalkDir::new(root_path)
// Don't include the root directory itself
.min_depth(1)
.follow_links(true);
let maybe_offset = maybe_offset.cloned();
let s = walkdir.into_iter().flat_map(move |result_dir_entry| {
// Apply offset filter before proceeding, to reduce statx file system calls
// This matters for NFS mounts
if let (Some(offset), Ok(entry)) = (maybe_offset.as_ref(), result_dir_entry.as_ref()) {
let location = config.filesystem_to_path(entry.path());
match location {
Ok(path) if path <= *offset => return None,
Err(e) => return Some(Err(e)),
_ => {}
}
}
let entry = match convert_walkdir_result(result_dir_entry).transpose()? {
Ok(entry) => entry,
Err(e) => return Some(Err(e)),
};
if !entry.path().is_file() {
return None;
}
match config.filesystem_to_path(entry.path()) {
Ok(path) => match is_valid_file_path(&path) {
true => convert_entry(entry, path).transpose(),
false => None,
},
Err(e) => Some(Err(e)),
}
});
// If no tokio context, return iterator directly as no
// need to perform chunked spawn_blocking reads
if tokio::runtime::Handle::try_current().is_err() {
return futures_util::stream::iter(s).boxed();
}
// Otherwise list in batches of CHUNK_SIZE
const CHUNK_SIZE: usize = 1024;
let buffer = VecDeque::with_capacity(CHUNK_SIZE);
futures_util::stream::try_unfold((s, buffer), |(mut s, mut buffer)| async move {
if buffer.is_empty() {
(s, buffer) = tokio::task::spawn_blocking(move || {
for _ in 0..CHUNK_SIZE {
match s.next() {
Some(r) => buffer.push_back(r),
None => break,
}
}
(s, buffer)
})
.await?;
}
match buffer.pop_front() {
Some(Err(e)) => Err(e),
Some(Ok(meta)) => Ok(Some((meta, (s, buffer)))),
None => Ok(None),
}
})
.boxed()
}
}
/// Creates the parent directories of `path` or returns an error based on `source` if no parent
///
/// When `fsync` is true, every directory created here is fsynced, up to and including the first
/// pre-existing ancestor (whose entry list also changed), so the new directory entries are durable.
fn create_parent_dirs(path: &std::path::Path, source: io::Error, fsync: bool) -> Result<()> {
let parent = path.parent().ok_or_else(|| {
let path = path.to_path_buf();
Error::UnableToCreateFile { path, source }
})?;
// Record the deepest already-existing ancestor *before* creating any directories, so that
// afterwards we know exactly which directories are new and need to be fsynced.
let first_existing = fsync.then(|| {
let mut dir = parent;
while !dir.exists() {
match dir.parent() {
Some(p) => dir = p,
None => break,
}
}
dir.to_path_buf()
});
std::fs::create_dir_all(parent).map_err(|source| {
let path = parent.into();
Error::UnableToCreateDir { source, path }
})?;
if let Some(first_existing) = first_existing {
// Walk from `parent` up to `first_existing`, fsyncing each directory whose entries changed.
let mut dir = parent;
loop {
fsync_dir(dir).map_err(|source| Error::UnableToSyncFile {
source,
path: dir.into(),
})?;
if dir == first_existing {
break;
}
dir = match dir.parent() {
Some(p) => p,
None => break,
};
}
}
Ok(())
}
/// Renames `from` to `to`, then — when `fsync` is enabled — fsyncs the destination's parent
/// directory (and the source's too, if it differs) so the moved directory entries are durable.
///
/// The directory fsync is bundled in deliberately: every durable rename goes through here, so the
/// post-rename fsync can never be forgotten at an individual call site.
fn rename(from: &std::path::Path, to: &std::path::Path, fsync: bool) -> io::Result<()> {
std::fs::rename(from, to)?;
if fsync {
fsync_parent_dir(to)?;
// A cross-directory move also removes an entry from the source directory.
if from.parent() != to.parent() {
fsync_parent_dir(from)?;
}
}
Ok(())
}
/// Hard-links `original` to `link`, then — when `fsync` is enabled — fsyncs `link`'s parent
/// directory so the new directory entry is durable.
///
/// As with [`rename`], the directory fsync is bundled in so it cannot be forgotten at a call site.
fn hard_link(original: &std::path::Path, link: &std::path::Path, fsync: bool) -> io::Result<()> {
std::fs::hard_link(original, link)?;
if fsync {
fsync_parent_dir(link)?;
}
Ok(())
}
/// Durably publishes the freshly-written staging file `file` (located at `src`) to `dest` via a
/// rename.
///
/// When `fsync` is enabled, the file's contents are flushed before — and `dest`'s parent
/// directory after — the rename, so a successful return is durable. The file is always closed
/// before the rename (checking for close errors): required for NFS error detection and for some
/// FUSE filesystems (e.g. Blobfuse) that only commit the data on close.
fn finish_staged_rename(
file: File,
src: &std::path::Path,
dest: &std::path::Path,
fsync: bool,
) -> Result<()> {
sync_and_close(file, src, fsync)?;
rename(src, dest, fsync).map_err(|source| Error::UnableToRenameFile { source })?;
Ok(())
}
/// Like [`finish_staged_rename`] but publishes via a hard link (`PutMode::Create` semantics): the
/// staging file is linked to `dest` and then removed. Returns [`Error::AlreadyExists`] if `dest`
/// already exists.
fn finish_staged_hard_link(
file: File,
src: &std::path::Path,
dest: &std::path::Path,
fsync: bool,
) -> Result<()> {
sync_and_close(file, src, fsync)?;
match hard_link(src, dest, fsync) {
Ok(()) => {
let _ = std::fs::remove_file(src); // Attempt to cleanup
Ok(())
}
Err(source) => match source.kind() {
ErrorKind::AlreadyExists => Err(Error::AlreadyExists {
path: dest.to_str().unwrap().to_string(),
source,
}
.into()),
_ => Err(Error::UnableToRenameFile { source }.into()),
},
}
}
/// Flushes the freshly-written `file`'s contents to disk (when `fsync` is enabled) and then
/// closes it, checking for close errors that dropping the [`File`] would silently ignore.
fn sync_and_close(file: File, path: &std::path::Path, fsync: bool) -> Result<()> {
if fsync {
file.sync_all().map_err(|source| Error::UnableToSyncFile {
source,
path: path.into(),
})?;
}
close_file(file).map_err(|source| Error::UnableToCopyDataToFile { source })?;
Ok(())
}
/// Fsyncs the parent directory of `path` so a change to its directory entries (e.g. one just made
/// by a rename or hard link) is durable. A no-op when `path` has no parent.
fn fsync_parent_dir(path: &std::path::Path) -> io::Result<()> {
match path.parent() {
Some(parent) => fsync_dir(parent),
None => Ok(()),
}
}
/// Fsyncs `dir_path` so that changes to its directory entries are durable.
///
/// This is only meaningful on Unix; on other platforms (e.g. Windows) directories cannot be
/// portably opened as a [`File`] and synced, so this is a no-op.
fn fsync_dir(dir_path: &std::path::Path) -> io::Result<()> {
#[cfg(target_family = "unix")]
{
File::open(dir_path)?.sync_all()
}
#[cfg(not(target_family = "unix"))]
{
let _ = dir_path;
Ok(())
}
}
/// Generates a unique file path `{base}#{suffix}`, returning the opened `File` and `path`
///
/// Creates any directories if necessary, fsyncing them when `fsync` is enabled
fn new_staged_upload(base: &std::path::Path, fsync: bool) -> Result<(File, PathBuf)> {
let mut multipart_id = 1;
loop {
let suffix = multipart_id.to_string();
let path = staged_upload_path(base, &suffix);
let mut options = OpenOptions::new();
match options.read(true).write(true).create_new(true).open(&path) {
Ok(f) => return Ok((f, path)),
Err(source) => match source.kind() {
ErrorKind::AlreadyExists => multipart_id += 1,
ErrorKind::NotFound => create_parent_dirs(&path, source, fsync)?,
_ => return Err(Error::UnableToOpenFile { source, path }.into()),
},
}
}
}
/// Returns the unique upload for the given path and suffix
fn staged_upload_path(dest: &std::path::Path, suffix: &str) -> PathBuf {
let mut staging_path = dest.as_os_str().to_owned();
staging_path.push("#");
staging_path.push(suffix);
staging_path.into()
}
#[derive(Debug)]
struct LocalUpload {
/// The upload state
state: Arc<UploadState>,
/// The location of the temporary file
src: Option<PathBuf>,
/// The next offset to write into the file
offset: u64,
/// Whether to fsync the file and its parent directory on completion
fsync: bool,
}
#[derive(Debug)]
struct UploadState {
dest: PathBuf,
file: Mutex<Option<File>>,
}
impl LocalUpload {
pub(crate) fn new(src: PathBuf, dest: PathBuf, file: File, fsync: bool) -> Self {
Self {
state: Arc::new(UploadState {
dest,
file: Mutex::new(Some(file)),
}),
src: Some(src),
offset: 0,
fsync,
}
}
}
#[async_trait]
impl MultipartUpload for LocalUpload {
fn put_part(&mut self, data: PutPayload) -> UploadPart {
let offset = self.offset;
self.offset += data.content_length() as u64;
let s = Arc::clone(&self.state);
maybe_spawn_blocking(move || {
let mut guard = s.file.lock();
let file = guard.as_mut().ok_or(Error::Aborted)?;
file.seek(SeekFrom::Start(offset)).map_err(|source| {
let path = s.dest.clone();
Error::Seek { source, path }
})?;
data.iter()
.try_for_each(|x| file.write_all(x))
.map_err(|source| Error::UnableToCopyDataToFile { source })?;
Ok(())
})
.boxed()
}
async fn complete(&mut self) -> Result<PutResult> {
let src = self.src.take().ok_or(Error::Aborted)?;
let s = Arc::clone(&self.state);
let fsync = self.fsync;
maybe_spawn_blocking(move || {
// Ensure no inflight writes
let mut guard = s.file.lock();
let file = guard.take().ok_or(Error::Aborted)?;
let metadata = file.metadata().map_err(|e| Error::Metadata {
source: e.into(),
path: src.to_string_lossy().to_string(),
})?;
// Durably publish the freshly-written staging file: flush its contents, close it, then
// rename it into place and fsync the destination's parent directory (the fsync calls
// are bundled into the helper and only run when fsync is enabled).
finish_staged_rename(file, &src, &s.dest, fsync)?;
Ok(PutResult {
e_tag: Some(get_etag(&metadata)),
version: None,
extensions: Default::default(),
})
})
.await
}
async fn abort(&mut self) -> Result<()> {
let src = self.src.take().ok_or(Error::Aborted)?;
maybe_spawn_blocking(move || {
std::fs::remove_file(&src)
.map_err(|source| Error::UnableToDeleteFile { source, path: src })?;
Ok(())
})
.await
}
}
impl Drop for LocalUpload {
fn drop(&mut self) {
if let Some(src) = self.src.take() {
// Try to clean up intermediate file ignoring any error
match tokio::runtime::Handle::try_current() {
Ok(r) => drop(r.spawn_blocking(move || std::fs::remove_file(src))),
Err(_) => drop(std::fs::remove_file(src)),
};
}
}
}
pub(crate) fn chunked_stream(
mut file: File,
path: PathBuf,
range: Range<u64>,
chunk_size: usize,
) -> BoxStream<'static, Result<Bytes, super::Error>> {
futures_util::stream::once(async move {
let requested = range.end - range.start;
let (file, path) = maybe_spawn_blocking(move || {
file.seek(SeekFrom::Start(range.start as _))
.map_err(|err| map_seek_error(err, &file, &path, range.start))?;
Ok((file, path))
})
.await?;
let stream = futures_util::stream::try_unfold(
(file, path, requested),
move |(mut file, path, remaining)| {
maybe_spawn_blocking(move || {
if remaining == 0 {
return Ok(None);
}
let to_read = remaining.min(chunk_size as u64);
let cap = usize::try_from(to_read).map_err(|_e| Error::InvalidRange {
source: InvalidGetRange::TooLarge {
requested: to_read,
max: usize::MAX as u64,
},
})?;
let mut buffer = Vec::with_capacity(cap);
let read = (&mut file)
.take(to_read)
.read_to_end(&mut buffer)
.map_err(|e| Error::UnableToReadBytes {
source: e,
path: path.clone(),
})?;
Ok(Some((buffer.into(), (file, path, remaining - read as u64))))
})
},
);
Ok::<_, super::Error>(stream)
})
.try_flatten()
.boxed()
}
pub(crate) fn read_range(
file: &mut File,
path: &std::path::Path,
range: Range<u64>,
) -> Result<Bytes> {
let requested = range.end - range.start;
let mut buf = Vec::with_capacity(requested as usize);
#[cfg(any(target_family = "unix", target_family = "windows"))]
{
buf.resize(requested as usize, 0_u8);
let mut buf_slice = &mut buf[..];
let mut offset = range.start;
while !buf_slice.is_empty() {
#[cfg(target_family = "unix")]
let read_result = file.read_at(buf_slice, offset);
#[cfg(target_family = "windows")]
let read_result = file.seek_read(buf_slice, offset);
match read_result {
Ok(0) => break,
Ok(n) => {
let tmp = buf_slice;
buf_slice = &mut tmp[n..];
offset += n as u64;
}
// This error is recoverable
Err(e) if e.kind() == ErrorKind::Interrupted => {}
Err(source) => {
let error = Error::UnableToReadBytes {
source,
path: path.into(),
};
return Err(error.into());
}
}
}
// If we reached EOF before filling the buffer
if !buf_slice.is_empty() {
let metadata = open_metadata(file, path)?;
let file_len = metadata.len();
// If none of the range is satisfiable we should error, e.g. if the start offset is beyond the
// extents of the file, or if its at the end of the file and wants to read a non-empty range.
// if range.start > file_len || (range.start == file_len && !range.is_empty()) {
if range.start >= file_len {
return Err(Error::InvalidRange {
source: InvalidGetRange::StartTooLarge {
requested: range.start,
length: file_len,
},
}
.into());
}
let expected = range.end.min(file_len) - range.start;
let error = Error::OutOfRange {
path: path.into(),
expected,
actual: offset - range.start,
};
return Err(error.into());
}
}
#[cfg(all(not(windows), not(unix)))]
{
file.seek(SeekFrom::Start(range.start))
.map_err(|err| map_seek_error(err, file, path, range.start))?;
let read = file.take(requested).read_to_end(&mut buf).map_err(|err| {
// try to read metadata to give a better error in case of directory
if let Err(e) = open_metadata(file, path) {
return e;
}
Error::UnableToReadBytes {
source: err,
path: path.to_path_buf(),
}
})? as u64;
if read != requested {
let metadata = open_metadata(file, path)?;
let file_len = metadata.len();
if range.start >= file_len {
return Err(Error::InvalidRange {
source: InvalidGetRange::StartTooLarge {
requested: range.start,
length: file_len,
},
}
.into());
}
let expected = range.end.min(file_len) - range.start;
if read != expected {
return Err(Error::OutOfRange {
path: path.to_path_buf(),
expected,
actual: read,
}
.into());
}
}
}
Ok(buf.into())
}
fn open_file(path: &std::path::Path) -> Result<File, Error> {
File::open(path).map_err(|e| map_open_error(e, path))
}
fn open_metadata(file: &File, path: &std::path::Path) -> Result<Metadata, Error> {
let metadata = file.metadata().map_err(|e| map_open_error(e, path))?;
if metadata.is_dir() {
Err(Error::NotFound {
path: PathBuf::from(path),
source: io::Error::new(ErrorKind::NotFound, "is directory"),
})
} else {
Ok(metadata)
}
}
/// Translates errors from opening a file into a more specific [`Error`] when possible
fn map_open_error(source: io::Error, path: &std::path::Path) -> Error {
let path = PathBuf::from(path);
match source.kind() {
ErrorKind::NotFound => Error::NotFound { path, source },
_ => Error::UnableToOpenFile { path, source },
}
}
/// Translates errors from attempting to a file into a more specific [`Error`] when possible
fn map_seek_error(source: io::Error, file: &File, path: &std::path::Path, requested: u64) -> Error {
// if we can't seek, check if start is out of bounds to give
// a better error. Don't read metadata before to avoid
// an extra syscall in the common case
let m = match open_metadata(file, path) {
Err(e) => return e,
Ok(m) => m,
};
if requested >= m.len() {
return Error::InvalidRange {
source: InvalidGetRange::StartTooLarge {
requested,
length: m.len(),
},
};
}
Error::Seek {
source,
path: PathBuf::from(path),
}
}
fn convert_entry(entry: DirEntry, location: Path) -> Result<Option<ObjectMeta>> {
match entry.metadata() {
Ok(metadata) => Ok(Some(convert_metadata(metadata, location))),
Err(e) => {
if let Some(io_err) = e.io_error() {
if io_err.kind() == ErrorKind::NotFound {
return Ok(None);
}
}
Err(Error::Metadata {
source: e.into(),
path: location.to_string(),
})?
}
}
}
fn last_modified(metadata: &Metadata) -> DateTime<Utc> {
metadata
.modified()
.expect("Modified file time should be supported on this platform")
.into()
}
fn get_etag(metadata: &Metadata) -> String {
let inode = get_inode(metadata);
let size = metadata.len();
let mtime = metadata
.modified()
.ok()
.and_then(|mtime| mtime.duration_since(SystemTime::UNIX_EPOCH).ok())
.unwrap_or_default()
.as_micros();
// Use an ETag scheme based on that used by many popular HTTP servers
// <https://httpd.apache.org/docs/2.2/mod/core.html#fileetag>
// <https://stackoverflow.com/questions/47512043/how-etags-are-generated-and-configured>
format!("\"{inode:x}-{mtime:x}-{size:x}\"")
}
fn convert_metadata(metadata: Metadata, location: Path) -> ObjectMeta {
let last_modified = last_modified(&metadata);
ObjectMeta {
location,
last_modified,
size: metadata.len(),
e_tag: Some(get_etag(&metadata)),
version: None,
}
}
#[cfg(unix)]
/// We include the inode when available to yield an ETag more resistant to collisions
/// and as used by popular web servers such as [Apache](https://httpd.apache.org/docs/2.2/mod/core.html#fileetag)
fn get_inode(metadata: &Metadata) -> u64 {
std::os::unix::fs::MetadataExt::ino(metadata)
}
#[cfg(not(unix))]
/// On platforms where an inode isn't available, fallback to just relying on size and mtime
fn get_inode(_metadata: &Metadata) -> u64 {
0
}
/// Convert walkdir results and converts not-found errors into `None`.
/// Convert broken symlinks to `None`.
fn convert_walkdir_result(
res: std::result::Result<DirEntry, walkdir::Error>,
) -> Result<Option<DirEntry>> {
match res {
Ok(entry) => {
// To check for broken symlink: call symlink_metadata() - it does not traverse symlinks);
// if ok: check if entry is symlink; and try to read it by calling metadata().
match symlink_metadata(entry.path()) {
Ok(attr) => {
if attr.is_symlink() {
let target_metadata = metadata(entry.path());
match target_metadata {
Ok(_) => {
// symlink is valid
Ok(Some(entry))
}
Err(_) => {
// this is a broken symlink, return None
Ok(None)
}
}
} else {
Ok(Some(entry))
}
}
Err(_) => Ok(None),
}
}
Err(walkdir_err) => match walkdir_err.io_error() {
Some(io_err) => match io_err.kind() {
ErrorKind::NotFound => Ok(None),
_ => Err(Error::UnableToWalkDir {
source: walkdir_err,
}
.into()),
},
None => Err(Error::UnableToWalkDir {
source: walkdir_err,
}
.into()),
},
}
}
#[cfg(test)]
mod tests {
use std::fs;
use futures_util::TryStreamExt;
use tempfile::TempDir;
#[cfg(target_family = "unix")]
use std::os::unix::fs::PermissionsExt;
#[cfg(target_family = "unix")]
use tempfile::NamedTempFile;
use crate::{ObjectStoreExt, integration::*};
use super::*;
#[tokio::test]
#[cfg(target_family = "unix")]
async fn file_test() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
put_get_delete_list(&integration).await;
list_with_offset_exclusivity(&integration).await;
get_opts(&integration).await;
list_uses_directories_correctly(&integration).await;
list_with_delimiter(&integration).await;
rename_and_copy(&integration).await;
copy_if_not_exists(&integration).await;
copy_rename_nonexistent_object(&integration).await;
stream_get(&integration).await;
put_opts(&integration, false).await;
}
#[tokio::test]
#[cfg(target_family = "unix")]
async fn file_test_fsync() {
// Run the full integration suite with fsync enabled to ensure the durability code
// paths (file sync + directory fsync on put/copy/rename/multipart, including recursive
// directory creation) behave identically to the default.
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path())
.unwrap()
.with_fsync(true);
put_get_delete_list(&integration).await;
list_with_offset_exclusivity(&integration).await;
get_opts(&integration).await;
list_uses_directories_correctly(&integration).await;
list_with_delimiter(&integration).await;
rename_and_copy(&integration).await;
copy_if_not_exists(&integration).await;
copy_rename_nonexistent_object(&integration).await;
stream_get(&integration).await;
put_opts(&integration, false).await;
}
#[tokio::test]
async fn fsync_creates_nested_dirs() {
// Exercises the recursive directory fsync in `create_parent_dirs`: every directory
// component is newly created, so each must be synced up to the pre-existing root.
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path())
.unwrap()
.with_fsync(true);
let data = Bytes::from("arbitrary data");
// `put` (overwrite) into a deeply nested, non-existent directory tree
let location = Path::from("a/b/c/d/put_file");
integration
.put(&location, data.clone().into())
.await
.unwrap();
let read = integration
.get(&location)
.await
.unwrap()
.bytes()
.await
.unwrap();
assert_eq!(read, data);
// multipart upload into another nested tree
let location = Path::from("e/f/g/multipart_file");
let mut upload = integration.put_multipart(&location).await.unwrap();
upload.put_part(data.clone().into()).await.unwrap();
upload.complete().await.unwrap();
let read = integration
.get(&location)
.await
.unwrap()
.bytes()
.await
.unwrap();
assert_eq!(read, data);
}
#[tokio::test]
#[cfg(target_family = "unix")]
async fn fsync_rename_if_not_exists_propagates_source_delete_sync_error() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path())
.unwrap()
.with_fsync(true);
let source = Path::from("source_dir/source_file");
let dest = Path::from("dest_dir/dest_file");
integration.put(&source, "data".into()).await.unwrap();
let source_dir = root.path().join("source_dir");
let original_permissions = fs::metadata(&source_dir).unwrap().permissions();
// Allow unlinking the file from the directory, but prevent opening the
// directory for fsync. Without the source-parent fsync, rename succeeds;
// with it, the sync error is propagated after the source is deleted.
fs::set_permissions(&source_dir, fs::Permissions::from_mode(0o300)).unwrap();
let result = integration.rename_if_not_exists(&source, &dest).await;
fs::set_permissions(&source_dir, original_permissions).unwrap();
match result {
Err(crate::Error::Generic { source, .. }) => {
assert!(source.to_string().contains("Unable to sync data to disk"));
}
_ => panic!("expected source parent fsync to fail"),
}
let read = integration.get(&dest).await.unwrap().bytes().await.unwrap();
assert_eq!(read, Bytes::from("data"));
assert!(matches!(
integration.get(&source).await.unwrap_err(),
crate::Error::NotFound { .. }
));
}
#[test]
#[cfg(target_family = "unix")]
fn test_non_tokio() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
futures_executor::block_on(async move {
put_get_delete_list(&integration).await;
list_uses_directories_correctly(&integration).await;
list_with_delimiter(&integration).await;
// Can't use stream_get test as WriteMultipart uses a tokio JoinSet
let p = Path::from("manual_upload");
let mut upload = integration.put_multipart(&p).await.unwrap();
upload.put_part("123".into()).await.unwrap();
upload.put_part("45678".into()).await.unwrap();
let r = upload.complete().await.unwrap();
let get = integration.get(&p).await.unwrap();
assert_eq!(get.meta.e_tag.as_ref().unwrap(), r.e_tag.as_ref().unwrap());
let actual = get.bytes().await.unwrap();
assert_eq!(actual.as_ref(), b"12345678");
});
}
#[tokio::test]
async fn creates_dir_if_not_present() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
let location = Path::from("nested/file/test_file");
let data = Bytes::from("arbitrary data");
integration
.put(&location, data.clone().into())
.await
.unwrap();
let read_data = integration
.get(&location)
.await
.unwrap()
.bytes()
.await
.unwrap();
assert_eq!(&*read_data, data);
}
#[tokio::test]
async fn unknown_length() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
let location = Path::from("some_file");
let data = Bytes::from("arbitrary data");
integration
.put(&location, data.clone().into())
.await
.unwrap();
let read_data = integration
.get(&location)
.await
.unwrap()
.bytes()
.await
.unwrap();
assert_eq!(&*read_data, data);
}
#[tokio::test]
async fn range_request_start_beyond_end_of_file() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
let location = Path::from("some_file");
let data = Bytes::from("arbitrary data");
integration
.put(&location, data.clone().into())
.await
.unwrap();
integration
.get_range(&location, 100..200)
.await
.expect_err("Should error with start range beyond end of file");
}
#[tokio::test]
async fn range_request_beyond_end_of_file() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
let location = Path::from("some_file");
let data = Bytes::from("arbitrary data");
integration
.put(&location, data.clone().into())
.await
.unwrap();
let read_data = integration.get_range(&location, 0..100).await.unwrap();
assert_eq!(&*read_data, data);
}
#[tokio::test]
async fn get_ranges_coalesces() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
let location = Path::from("some_file");
// Position-dependent content so a misrouted slice is detectable.
let data: Bytes = (0..4096u32)
.map(|i| (i % 251) as u8)
.collect::<Vec<_>>()
.into();
integration
.put(&location, data.clone().into())
.await
.unwrap();
let cases: Vec<Vec<Range<u64>>> = vec![
vec![0..16, 16..32, 32..48], // adjacent -> coalesced
vec![0..16, 64..80, 1000..1016], // gapped -> not coalesced
vec![100..120, 0..50, 40..60, 0..50, 110..130], // out-of-order, overlapping, duplicate
vec![], // empty
];
for ranges in cases {
let got = integration.get_ranges(&location, &ranges).await.unwrap();
assert_eq!(got.len(), ranges.len());
for (range, bytes) in ranges.iter().zip(got) {
assert_eq!(
bytes,
data.slice(range.start as usize..range.end as usize),
"mismatch for range {range:?}"
);
}
}
}
#[tokio::test]
#[cfg(target_family = "unix")]
// Fails on github actions runner (which runs the tests as root)
#[ignore]
async fn bubble_up_io_errors() {
use std::{fs::set_permissions, os::unix::prelude::PermissionsExt};
let root = TempDir::new().unwrap();
// make non-readable
let metadata = root.path().metadata().unwrap();
let mut permissions = metadata.permissions();
permissions.set_mode(0o000);
set_permissions(root.path(), permissions).unwrap();
let store = LocalFileSystem::new_with_prefix(root.path()).unwrap();
let mut stream = store.list(None);
let mut any_err = false;
while let Some(res) = stream.next().await {
if res.is_err() {
any_err = true;
}
}
assert!(any_err);
// `list_with_delimiter
assert!(store.list_with_delimiter(None).await.is_err());
}
const NON_EXISTENT_NAME: &str = "nonexistentname";
#[tokio::test]
async fn get_nonexistent_location() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
let location = Path::from(NON_EXISTENT_NAME);
let err = get_nonexistent_object(&integration, Some(location))
.await
.unwrap_err();
if let crate::Error::NotFound { path, source } = err {
let source_variant = source.downcast_ref::<std::io::Error>();
assert!(
matches!(source_variant, Some(std::io::Error { .. }),),
"got: {source_variant:?}"
);
assert!(path.ends_with(NON_EXISTENT_NAME), "{}", path);
} else {
panic!("unexpected error type: {err:?}");
}
}
#[tokio::test]
async fn root() {
let integration = LocalFileSystem::new();
let canonical = std::path::Path::new("Cargo.toml").canonicalize().unwrap();
let url = Url::from_directory_path(&canonical).unwrap();
let path = Path::parse(url.path()).unwrap();
let roundtrip = integration.path_to_filesystem(&path).unwrap();
// Needed as on Windows canonicalize returns extended length path syntax
// C:\Users\circleci -> \\?\C:\Users\circleci
let roundtrip = roundtrip.canonicalize().unwrap();
assert_eq!(roundtrip, canonical);
integration.head(&path).await.unwrap();
}
#[tokio::test]
#[cfg(target_family = "windows")]
async fn test_list_root() {
let fs = LocalFileSystem::new();
let r = fs.list_with_delimiter(None).await.unwrap_err().to_string();
assert!(
r.contains("Unable to convert URL \"file:///\" to filesystem path"),
"{}",
r
);
}
#[tokio::test]
#[cfg(target_os = "linux")]
async fn test_list_root() {
let fs = LocalFileSystem::new();
fs.list_with_delimiter(None).await.unwrap();
}
#[cfg(target_family = "unix")]
async fn check_list(integration: &LocalFileSystem, prefix: Option<&Path>, expected: &[&str]) {
let result: Vec<_> = integration.list(prefix).try_collect().await.unwrap();
let mut strings: Vec<_> = result.iter().map(|x| x.location.as_ref()).collect();
strings.sort_unstable();
assert_eq!(&strings, expected)
}
#[tokio::test]
#[cfg(target_family = "unix")]
async fn test_symlink() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
let subdir = root.path().join("a");
std::fs::create_dir(&subdir).unwrap();
let file = subdir.join("file.parquet");
std::fs::write(file, "test").unwrap();
check_list(&integration, None, &["a/file.parquet"]).await;
integration
.head(&Path::from("a/file.parquet"))
.await
.unwrap();
// Follow out of tree symlink
let other = NamedTempFile::new().unwrap();
std::os::unix::fs::symlink(other.path(), root.path().join("test.parquet")).unwrap();
// Should return test.parquet even though out of tree
check_list(&integration, None, &["a/file.parquet", "test.parquet"]).await;
// Can fetch test.parquet
integration.head(&Path::from("test.parquet")).await.unwrap();
// Follow in tree symlink
std::os::unix::fs::symlink(&subdir, root.path().join("b")).unwrap();
check_list(
&integration,
None,
&["a/file.parquet", "b/file.parquet", "test.parquet"],
)
.await;
check_list(&integration, Some(&Path::from("b")), &["b/file.parquet"]).await;
// Can fetch through symlink
integration
.head(&Path::from("b/file.parquet"))
.await
.unwrap();
// Ignore broken symlink
std::os::unix::fs::symlink(root.path().join("foo.parquet"), root.path().join("c")).unwrap();
check_list(
&integration,
None,
&["a/file.parquet", "b/file.parquet", "test.parquet"],
)
.await;
let mut r = integration.list_with_delimiter(None).await.unwrap();
r.common_prefixes.sort_unstable();
assert_eq!(r.common_prefixes.len(), 2);
assert_eq!(r.common_prefixes[0].as_ref(), "a");
assert_eq!(r.common_prefixes[1].as_ref(), "b");
assert_eq!(r.objects.len(), 1);
assert_eq!(r.objects[0].location.as_ref(), "test.parquet");
let r = integration
.list_with_delimiter(Some(&Path::from("a")))
.await
.unwrap();
assert_eq!(r.common_prefixes.len(), 0);
assert_eq!(r.objects.len(), 1);
assert_eq!(r.objects[0].location.as_ref(), "a/file.parquet");
// Deleting a symlink doesn't delete the source file
integration
.delete(&Path::from("test.parquet"))
.await
.unwrap();
assert!(other.path().exists());
check_list(&integration, None, &["a/file.parquet", "b/file.parquet"]).await;
// Deleting through a symlink deletes both files
integration
.delete(&Path::from("b/file.parquet"))
.await
.unwrap();
check_list(&integration, None, &[]).await;
// Adding a file through a symlink creates in both paths
integration
.put(&Path::from("b/file.parquet"), vec![0, 1, 2].into())
.await
.unwrap();
check_list(&integration, None, &["a/file.parquet", "b/file.parquet"]).await;
}
#[tokio::test]
async fn invalid_path() {
let root = TempDir::new().unwrap();
let root = root.path().join("🙀");
std::fs::create_dir(root.clone()).unwrap();
// Invalid paths supported above root of store
let integration = LocalFileSystem::new_with_prefix(root.clone()).unwrap();
let directory = Path::from("directory");
let object = directory.clone().join("child.txt");
let data = Bytes::from("arbitrary");
integration.put(&object, data.clone().into()).await.unwrap();
integration.head(&object).await.unwrap();
let result = integration.get(&object).await.unwrap();
assert_eq!(result.bytes().await.unwrap(), data);
flatten_list_stream(&integration, None).await.unwrap();
flatten_list_stream(&integration, Some(&directory))
.await
.unwrap();
let result = integration
.list_with_delimiter(Some(&directory))
.await
.unwrap();
assert_eq!(result.objects.len(), 1);
assert!(result.common_prefixes.is_empty());
assert_eq!(result.objects[0].location, object);
let emoji = root.join("💀");
std::fs::write(emoji, "foo").unwrap();
// Can list illegal file
let mut paths = flatten_list_stream(&integration, None).await.unwrap();
paths.sort_unstable();
assert_eq!(
paths,
vec![
Path::parse("directory/child.txt").unwrap(),
Path::parse("💀").unwrap()
]
);
}
#[tokio::test]
async fn list_hides_incomplete_uploads() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
let location = Path::from("some_file");
let data = PutPayload::from("arbitrary data");
let mut u1 = integration.put_multipart(&location).await.unwrap();
u1.put_part(data.clone()).await.unwrap();
let mut u2 = integration.put_multipart(&location).await.unwrap();
u2.put_part(data).await.unwrap();
let list = flatten_list_stream(&integration, None).await.unwrap();
assert_eq!(list.len(), 0);
assert_eq!(
integration
.list_with_delimiter(None)
.await
.unwrap()
.objects
.len(),
0
);
}
#[tokio::test]
async fn test_path_with_offset() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
let root_path = root.path();
for i in 0..5 {
let filename = format!("test{i}.parquet");
let file = root_path.join(filename);
std::fs::write(file, "test").unwrap();
}
let filter_str = "test";
let filter = String::from(filter_str);
let offset_str = filter + "1";
let offset = Path::from(offset_str.clone());
// Use list_with_offset to retrieve files
let res = integration.list_with_offset(None, &offset);
let offset_paths: Vec<_> = res.map_ok(|x| x.location).try_collect().await.unwrap();
let mut offset_files: Vec<_> = offset_paths
.iter()
.map(|x| String::from(x.filename().unwrap()))
.collect();
// Check result with direct filesystem read
let files = fs::read_dir(root_path).unwrap();
let filtered_files = files
.filter_map(Result::ok)
.filter_map(|d| {
d.file_name().to_str().and_then(|f| {
if f.contains(filter_str) {
Some(String::from(f))
} else {
None
}
})
})
.collect::<Vec<_>>();
let mut expected_offset_files: Vec<_> = filtered_files
.iter()
.filter(|s| **s > offset_str)
.cloned()
.collect();
fn do_vecs_match<T: PartialEq>(a: &[T], b: &[T]) -> bool {
let matching = a.iter().zip(b.iter()).filter(|&(a, b)| a == b).count();
matching == a.len() && matching == b.len()
}
offset_files.sort();
expected_offset_files.sort();
// println!("Expected Offset Files: {:?}", expected_offset_files);
// println!("Actual Offset Files: {:?}", offset_files);
assert_eq!(offset_files.len(), expected_offset_files.len());
assert!(do_vecs_match(&expected_offset_files, &offset_files));
}
#[tokio::test]
async fn filesystem_filename_with_percent() {
let temp_dir = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
let filename = "L%3ABC.parquet";
std::fs::write(temp_dir.path().join(filename), "foo").unwrap();
let res: Vec<_> = integration.list(None).try_collect().await.unwrap();
assert_eq!(res.len(), 1);
assert_eq!(res[0].location.as_ref(), filename);
let res = integration.list_with_delimiter(None).await.unwrap();
assert_eq!(res.objects.len(), 1);
assert_eq!(res.objects[0].location.as_ref(), filename);
}
#[tokio::test]
async fn relative_paths() {
LocalFileSystem::new_with_prefix(".").unwrap();
LocalFileSystem::new_with_prefix("..").unwrap();
LocalFileSystem::new_with_prefix("../..").unwrap();
let integration = LocalFileSystem::new();
let path = Path::from_filesystem_path(".").unwrap();
integration.list_with_delimiter(Some(&path)).await.unwrap();
}
#[test]
fn test_valid_path() {
let cases = [
("foo#123/test.txt", true),
("foo#123/test#23.txt", true),
("foo#123/test#34", false),
("foo😁/test#34", false),
("foo/test#😁34", true),
];
for (case, expected) in cases {
let path = Path::parse(case).unwrap();
assert_eq!(is_valid_file_path(&path), expected);
}
}
#[tokio::test]
async fn test_intermediate_files() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
let a = Path::parse("foo#123/test.txt").unwrap();
integration.put(&a, "test".into()).await.unwrap();
let list = flatten_list_stream(&integration, None).await.unwrap();
assert_eq!(list, vec![a.clone()]);
std::fs::write(root.path().join("bar#123"), "test").unwrap();
// Should ignore file
let list = flatten_list_stream(&integration, None).await.unwrap();
assert_eq!(list, vec![a.clone()]);
let b = Path::parse("bar#123").unwrap();
let err = integration.get(&b).await.unwrap_err().to_string();
assert_eq!(
err,
"Generic LocalFileSystem error: Filenames containing trailing '/#\\d+/' are not supported: bar#123"
);
let c = Path::parse("foo#123.txt").unwrap();
integration.put(&c, "test".into()).await.unwrap();
let mut list = flatten_list_stream(&integration, None).await.unwrap();
list.sort_unstable();
assert_eq!(list, vec![c, a]);
}
#[tokio::test]
#[cfg(target_os = "windows")]
async fn filesystem_filename_with_colon() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
let path = Path::parse("file%3Aname.parquet").unwrap();
let location = Path::parse("file:name.parquet").unwrap();
integration.put(&location, "test".into()).await.unwrap();
let list = flatten_list_stream(&integration, None).await.unwrap();
assert_eq!(list, vec![path.clone()]);
let result = integration
.get(&location)
.await
.unwrap()
.bytes()
.await
.unwrap();
assert_eq!(result, Bytes::from("test"));
}
#[tokio::test]
async fn delete_dirs_automatically() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path())
.unwrap()
.with_automatic_cleanup(true);
let location = Path::from("nested/file/test_file");
let data = Bytes::from("arbitrary data");
integration
.put(&location, data.clone().into())
.await
.unwrap();
let read_data = integration
.get(&location)
.await
.unwrap()
.bytes()
.await
.unwrap();
assert_eq!(&*read_data, data);
assert!(fs::read_dir(root.path()).unwrap().count() > 0);
integration.delete(&location).await.unwrap();
assert!(fs::read_dir(root.path()).unwrap().count() == 0);
}
#[test]
#[cfg(target_family = "unix")]
fn test_close_file_detects_error_unix() {
let err = super::close_fd(-1).unwrap_err();
assert_eq!(err.raw_os_error(), Some(nix::libc::EBADF), "got: {err:?}");
}
#[test]
#[cfg(target_family = "windows")]
fn test_close_file_detects_error_windows() {
let err = super::close_handle(std::ptr::null_mut()).unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(windows_sys::Win32::Foundation::ERROR_INVALID_HANDLE as i32),
"got: {err:?}"
);
}
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(test)]
mod not_wasm_tests {
use std::time::Duration;
use tempfile::TempDir;
use crate::local::LocalFileSystem;
use crate::{ObjectStoreExt, Path, PutPayload};
#[tokio::test]
async fn test_cleanup_intermediate_files() {
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
let location = Path::from("some_file");
let data = PutPayload::from_static(b"hello");
let mut upload = integration.put_multipart(&location).await.unwrap();
upload.put_part(data).await.unwrap();
let file_count = std::fs::read_dir(root.path()).unwrap().count();
assert_eq!(file_count, 1);
drop(upload);
for _ in 0..100 {
tokio::time::sleep(Duration::from_millis(1)).await;
let file_count = std::fs::read_dir(root.path()).unwrap().count();
if file_count == 0 {
return;
}
}
panic!("Failed to cleanup file in 100ms")
}
}
#[cfg(target_family = "unix")]
#[cfg(test)]
mod unix_test {
use std::fs::OpenOptions;
use nix::sys::stat;
use nix::unistd;
use tempfile::TempDir;
use crate::local::LocalFileSystem;
use crate::{ObjectStoreExt, Path};
#[tokio::test]
async fn test_fifo() {
let filename = "some_file";
let root = TempDir::new().unwrap();
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
let path = root.path().join(filename);
unistd::mkfifo(&path, stat::Mode::S_IRWXU).unwrap();
// Need to open read and write side in parallel
let spawned =
tokio::task::spawn_blocking(|| OpenOptions::new().write(true).open(path).unwrap());
let location = Path::from(filename);
integration.head(&location).await.unwrap();
integration.get(&location).await.unwrap();
spawned.await.unwrap();
}
}