This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 06d6818 fix(io): preserve Windows drive letter in local fs paths
(#398)
06d6818 is described below
commit 06d681874eef78a147ea1a8782fd81319cc05e91
Author: chaoyang <[email protected]>
AuthorDate: Sat Jun 20 15:37:24 2026 +0800
fix(io): preserve Windows drive letter in local fs paths (#398)
---
crates/paimon/src/catalog/factory.rs | 5 +-
crates/paimon/src/catalog/filesystem.rs | 6 +-
crates/paimon/src/io/file_io.rs | 95 +++++++++++++++++---------------
crates/paimon/src/io/storage.rs | 97 ++++++++++++++++++++++++++++-----
crates/paimon/src/spec/manifest.rs | 1 -
5 files changed, 142 insertions(+), 62 deletions(-)
diff --git a/crates/paimon/src/catalog/factory.rs
b/crates/paimon/src/catalog/factory.rs
index 7e8993c..c531ccd 100644
--- a/crates/paimon/src/catalog/factory.rs
+++ b/crates/paimon/src/catalog/factory.rs
@@ -114,7 +114,10 @@ impl CatalogFactory {
}
#[cfg(test)]
-#[cfg(not(windows))] // Skip on Windows due to path compatibility issues
+// Skip on Windows: these tests use a hardcoded POSIX warehouse
+// (`/tmp/test-warehouse`), which is not a valid absolute Windows path, so
+// `FileIO::from_path` cannot derive a `file://` URL from it. See #397.
+#[cfg(not(windows))]
mod tests {
use super::*;
diff --git a/crates/paimon/src/catalog/filesystem.rs
b/crates/paimon/src/catalog/filesystem.rs
index cedc625..2fe7bb7 100644
--- a/crates/paimon/src/catalog/filesystem.rs
+++ b/crates/paimon/src/catalog/filesystem.rs
@@ -463,7 +463,11 @@ fn fill_table_name(err: Error, identifier: &Identifier) ->
Error {
}
#[cfg(test)]
-#[cfg(not(windows))] // Skip on Windows due to path compatibility issues
+// Skip on Windows: these tests list directories, and opendal's `fs` lister
+// panics (`StripPrefixError`) when listing under a drive-rooted path with the
+// `root="/"` operator setup. Single-file ops work after the drive-letter fix;
+// directory listing needs the opendal root rework tracked in #397.
+#[cfg(not(windows))]
mod tests {
use super::*;
use tempfile::TempDir;
diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs
index 2144ed7..4a62c73 100644
--- a/crates/paimon/src/io/file_io.rs
+++ b/crates/paimon/src/io/file_io.rs
@@ -79,12 +79,10 @@ impl FileIO {
/// Reference:
<https://github.com/apache/paimon/blob/release-0.8.2/paimon-common/src/main/java/org/apache/paimon/fs/FileIO.java#L76>
pub fn new_input(&self, path: &str) -> crate::Result<InputFile> {
let (op, relative_path) = self.storage.create(path)?;
- let path = path.to_string();
- let relative_path_pos = path.len() - relative_path.len();
Ok(InputFile {
op,
- path,
- relative_path_pos,
+ path: path.to_string(),
+ relative_path: relative_path.into_owned(),
})
}
@@ -93,12 +91,10 @@ impl FileIO {
/// Reference:
<https://github.com/apache/paimon/blob/release-0.8.2/paimon-common/src/main/java/org/apache/paimon/fs/FileIO.java#L87>
pub fn new_output(&self, path: &str) -> Result<OutputFile> {
let (op, relative_path) = self.storage.create(path)?;
- let path = path.to_string();
- let relative_path_pos = path.len() - relative_path.len();
Ok(OutputFile {
op,
- path,
- relative_path_pos,
+ path: path.to_string(),
+ relative_path: relative_path.into_owned(),
})
}
@@ -107,9 +103,12 @@ impl FileIO {
/// Reference:
<https://github.com/apache/paimon/blob/release-0.8.2/paimon-common/src/main/java/org/apache/paimon/fs/FileIO.java#L97>
pub async fn get_status(&self, path: &str) -> Result<FileStatus> {
let (op, relative_path) = self.storage.create(path)?;
- let meta = op.stat(relative_path).await.context(IoUnexpectedSnafu {
- message: format!("Failed to get file status for '{path}'"),
- })?;
+ let meta = op
+ .stat(relative_path.as_ref())
+ .await
+ .context(IoUnexpectedSnafu {
+ message: format!("Failed to get file status for '{path}'"),
+ })?;
Ok(FileStatus {
size: meta.content_length(),
@@ -128,10 +127,15 @@ impl FileIO {
/// FIXME: how to handle large dir? Better to return a stream instead?
pub async fn list_status(&self, path: &str) -> Result<Vec<FileStatus>> {
let (op, relative_path) = self.storage.create(path)?;
+ // `relative_path` is a byte-suffix of `path` for object stores and
POSIX
+ // local paths, so this recovers the scheme/root prefix. For a Windows
+ // local path the relative form only swaps `\`->`/`
(length-preserving),
+ // so this is `""` and entries are reported in opendal's normalized
+ // `/C:/...` form — which still round-trips back through `create`.
let base_path = &path[..path.len() - relative_path.len()];
// Opendal list() expects directory path to end with `/`.
// use normalize_root to make sure it end with `/`.
- let list_path = normalize_root(relative_path);
+ let list_path = normalize_root(relative_path.as_ref());
let entries = op.list_with(&list_path).await.context(IoUnexpectedSnafu
{
message: format!("Failed to list files in '{path}'"),
@@ -161,8 +165,10 @@ impl FileIO {
/// List all files recursively under the given directory path.
pub async fn list_status_recursive(&self, path: &str) ->
Result<Vec<FileStatus>> {
let (op, relative_path) = self.storage.create(path)?;
+ // See `list_status`: `relative_path` is a byte-suffix of `path` except
+ // for Windows local paths, where it only swaps separators (same
length).
let base_path = &path[..path.len() - relative_path.len()];
- let list_path = normalize_root(relative_path);
+ let list_path = normalize_root(relative_path.as_ref());
let entries =
op.list_with(&list_path)
@@ -202,9 +208,11 @@ impl FileIO {
pub async fn exists(&self, path: &str) -> Result<bool> {
let (op, relative_path) = self.storage.create(path)?;
- op.exists(relative_path).await.context(IoUnexpectedSnafu {
- message: format!("Failed to check existence of '{path}'"),
- })
+ op.exists(relative_path.as_ref())
+ .await
+ .context(IoUnexpectedSnafu {
+ message: format!("Failed to check existence of '{path}'"),
+ })
}
/// Delete a file.
@@ -213,9 +221,11 @@ impl FileIO {
pub async fn delete_file(&self, path: &str) -> Result<()> {
let (op, relative_path) = self.storage.create(path)?;
- op.delete(relative_path).await.context(IoUnexpectedSnafu {
- message: format!("Failed to delete file '{path}'"),
- })?;
+ op.delete(relative_path.as_ref())
+ .await
+ .context(IoUnexpectedSnafu {
+ message: format!("Failed to delete file '{path}'"),
+ })?;
Ok(())
}
@@ -226,7 +236,7 @@ impl FileIO {
pub async fn delete_dir(&self, path: &str) -> Result<()> {
let (op, relative_path) = self.storage.create(path)?;
- op.remove_all(relative_path)
+ op.remove_all(relative_path.as_ref())
.await
.context(IoUnexpectedSnafu {
message: format!("Failed to delete directory '{path}'"),
@@ -243,7 +253,7 @@ impl FileIO {
pub async fn mkdirs(&self, path: &str) -> Result<()> {
let (op, relative_path) = self.storage.create(path)?;
// Opendal create_dir expects the path to end with `/` to indicate a
directory.
- let dir_path = normalize_root(relative_path);
+ let dir_path = normalize_root(relative_path.as_ref());
op.create_dir(&dir_path).await.context(IoUnexpectedSnafu {
message: format!("Failed to create directory '{path}'"),
})?;
@@ -270,7 +280,7 @@ impl FileIO {
let (_, relative_path_dst) = self.storage.create(dst)?;
op_src
- .rename(relative_path_src, relative_path_dst)
+ .rename(relative_path_src.as_ref(), relative_path_dst.as_ref())
.await
.context(IoUnexpectedSnafu {
message: format!("Failed to rename '{src}' to '{dst}'"),
@@ -288,7 +298,8 @@ fn status_path(base_path: &str, entry_path: &str) -> String
{
}
}
-fn looks_like_windows_drive_path(path: &str) -> bool {
+/// Whether `path` begins with a Windows drive specifier such as `C:\` or
`C:/`.
+pub(crate) fn looks_like_windows_drive_path(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
@@ -384,7 +395,9 @@ pub struct FileStatus {
pub struct InputFile {
op: Operator,
path: String,
- relative_path_pos: usize,
+ /// The opendal-relative path (see [`FileIO::new_input`]); not necessarily
a
+ /// suffix of `path`, since local paths are separator-normalized.
+ relative_path: String,
}
impl InputFile {
@@ -393,11 +406,11 @@ impl InputFile {
}
pub async fn exists(&self) -> crate::Result<bool> {
- Ok(self.op.exists(&self.path[self.relative_path_pos..]).await?)
+ Ok(self.op.exists(&self.relative_path).await?)
}
pub async fn metadata(&self) -> crate::Result<FileStatus> {
- let meta = self.op.stat(&self.path[self.relative_path_pos..]).await?;
+ let meta = self.op.stat(&self.relative_path).await?;
Ok(FileStatus {
size: meta.content_length(),
@@ -410,15 +423,11 @@ impl InputFile {
}
pub async fn read(&self) -> crate::Result<Bytes> {
- Ok(self
- .op
- .read(&self.path[self.relative_path_pos..])
- .await?
- .to_bytes())
+ Ok(self.op.read(&self.relative_path).await?.to_bytes())
}
pub async fn reader(&self) -> crate::Result<impl FileRead> {
- Ok(self.op.reader(&self.path[self.relative_path_pos..]).await?)
+ Ok(self.op.reader(&self.relative_path).await?)
}
}
@@ -426,7 +435,9 @@ impl InputFile {
pub struct OutputFile {
op: Operator,
path: String,
- relative_path_pos: usize,
+ /// The opendal-relative path (see [`FileIO::new_output`]); not
necessarily a
+ /// suffix of `path`, since local paths are separator-normalized.
+ relative_path: String,
}
impl OutputFile {
@@ -435,14 +446,14 @@ impl OutputFile {
}
pub async fn exists(&self) -> crate::Result<bool> {
- Ok(self.op.exists(&self.path[self.relative_path_pos..]).await?)
+ Ok(self.op.exists(&self.relative_path).await?)
}
pub fn to_input_file(self) -> InputFile {
InputFile {
op: self.op,
path: self.path,
- relative_path_pos: self.relative_path_pos,
+ relative_path: self.relative_path,
}
}
@@ -467,7 +478,7 @@ impl OutputFile {
}
async fn opendal_writer(&self) -> crate::Result<opendal::Writer> {
- Ok(self.op.writer(&self.path[self.relative_path_pos..]).await?)
+ Ok(self.op.writer(&self.relative_path).await?)
}
}
@@ -731,20 +742,14 @@ mod object_storage_path_test {
fn assert_relative_paths(file_io: &FileIO, path: &str,
expected_relative_path: &str) {
let input = file_io.new_input(path).unwrap();
assert_eq!(input.location(), path);
- assert_eq!(
- &input.path[input.relative_path_pos..],
- expected_relative_path
- );
+ assert_eq!(input.relative_path, expected_relative_path);
let output = file_io.new_output(path).unwrap();
assert_eq!(output.location(), path);
- assert_eq!(
- &output.path[output.relative_path_pos..],
- expected_relative_path
- );
+ assert_eq!(output.relative_path, expected_relative_path);
let (_op, relative_path) = file_io.storage.create(path).unwrap();
- assert_eq!(relative_path, expected_relative_path);
+ assert_eq!(relative_path.as_ref(), expected_relative_path);
let base_path = &path[..path.len() - relative_path.len()];
assert_eq!(format!("{base_path}{relative_path}"), path);
diff --git a/crates/paimon/src/io/storage.rs b/crates/paimon/src/io/storage.rs
index 59d2740..8ab67e4 100644
--- a/crates/paimon/src/io/storage.rs
+++ b/crates/paimon/src/io/storage.rs
@@ -15,6 +15,7 @@
// specific language governing permissions and limitations
// under the License.
+use std::borrow::Cow;
use std::collections::HashMap;
#[cfg(any(
feature = "storage-azdls",
@@ -184,10 +185,12 @@ impl Storage {
}
}
- pub(crate) fn create<'a>(&self, path: &'a str) -> crate::Result<(Operator,
&'a str)> {
+ pub(crate) fn create<'a>(&self, path: &'a str) -> crate::Result<(Operator,
Cow<'a, str>)> {
match self {
#[cfg(feature = "storage-memory")]
- Storage::Memory { op } => Ok((op.clone(),
Self::memory_relative_path(path)?)),
+ Storage::Memory { op } => {
+ Ok((op.clone(),
Cow::Borrowed(Self::memory_relative_path(path)?)))
+ }
#[cfg(feature = "storage-fs")]
Storage::LocalFs { op } => Ok((op.clone(),
Self::fs_relative_path(path)?)),
#[cfg(feature = "storage-oss")]
@@ -195,14 +198,14 @@ impl Storage {
let (bucket, relative_path) =
Self::bucket_and_relative_path(path, "OSS", &["oss"])?;
let op = Self::cached_oss_operator(config, operators, path,
&bucket)?;
- Ok((op, relative_path))
+ Ok((op, Cow::Borrowed(relative_path)))
}
#[cfg(feature = "storage-s3")]
Storage::S3 { config, operators } => {
let (bucket, relative_path) =
Self::bucket_and_relative_path(path, "S3", &["s3",
"s3a"])?;
let op = Self::cached_s3_operator(config, operators, path,
&bucket)?;
- Ok((op, relative_path))
+ Ok((op, Cow::Borrowed(relative_path)))
}
#[cfg(feature = "storage-cos")]
Storage::Cos { config, operators } => {
@@ -211,7 +214,7 @@ impl Storage {
let op = Self::cached_operator(operators, "COS", &bucket, || {
super::cos_config_build(config, path)
})?;
- Ok((op, relative_path))
+ Ok((op, Cow::Borrowed(relative_path)))
}
#[cfg(feature = "storage-azdls")]
Storage::Azdls { config, operators } => {
@@ -220,7 +223,7 @@ impl Storage {
let op = Self::cached_operator(operators, "Azure", &cache_key,
|| {
super::azdls_config_build(config, path)
})?;
- Ok((op, relative_path))
+ Ok((op, Cow::Borrowed(relative_path)))
}
#[cfg(feature = "storage-obs")]
Storage::Obs { config, operators } => {
@@ -229,7 +232,7 @@ impl Storage {
let op = Self::cached_operator(operators, "OBS", &bucket, || {
super::obs_config_build(config, path)
})?;
- Ok((op, relative_path))
+ Ok((op, Cow::Borrowed(relative_path)))
}
#[cfg(feature = "storage-gcs")]
Storage::Gcs { config, operators } => {
@@ -238,7 +241,7 @@ impl Storage {
let op = Self::cached_operator(operators, "GCS", &bucket, || {
super::gcs_config_build(config, path)
})?;
- Ok((op, relative_path))
+ Ok((op, Cow::Borrowed(relative_path)))
}
#[cfg(feature = "storage-hdfs")]
Storage::Hdfs { config, op } => {
@@ -254,7 +257,10 @@ impl Storage {
if guard.is_none() {
*guard = Some(super::hdfs_config_build(config, path)?);
}
- Ok((guard.as_ref().unwrap().clone(), relative_path))
+ Ok((
+ guard.as_ref().unwrap().clone(),
+ Cow::Borrowed(relative_path),
+ ))
}
}
}
@@ -270,15 +276,38 @@ impl Storage {
}
}
+ /// Turn an absolute local path into the relative path that opendal's `fs`
+ /// service joins onto its `/` root.
+ ///
+ /// On POSIX an absolute path `/tmp/wh` becomes `tmp/wh`: dropping the
single
+ /// leading separator lets opendal rebuild `/tmp/wh` from its `/` root.
+ ///
+ /// A bare drop-the-first-char would corrupt a Windows path such as
+ /// `C:\dir` into `:\dir` (the drive letter is lost — the historical source
+ /// of the "invalid filename" failures on Windows). Instead we keep the
+ /// drive specifier and only normalize separators to `/`, mirroring how
Java
+ /// Paimon's `Path` (modeled on Hadoop's) handles Windows paths. opendal
then
+ /// does `PathBuf::from("/").join("C:/dir")`, and because the argument
+ /// carries a drive prefix `Path::join` replaces the base, yielding the
real
+ /// `C:\dir` on Windows.
#[cfg(feature = "storage-fs")]
- fn fs_relative_path(path: &str) -> crate::Result<&str> {
+ fn fs_relative_path(path: &str) -> crate::Result<Cow<'_, str>> {
+ // A `file://` / `file:/` URL is already in scheme-relative form.
if let Some(stripped) = path.strip_prefix("file:/") {
- Ok(stripped)
- } else {
- path.get(1..).ok_or_else(|| error::Error::ConfigInvalid {
+ return Ok(if stripped.contains('\\') {
+ Cow::Owned(stripped.replace('\\', "/"))
+ } else {
+ Cow::Borrowed(stripped)
+ });
+ }
+ if super::looks_like_windows_drive_path(path) {
+ return Ok(Cow::Owned(path.replace('\\', "/")));
+ }
+ path.get(1..)
+ .map(Cow::Borrowed)
+ .ok_or_else(|| error::Error::ConfigInvalid {
message: format!("Invalid file path: {path}"),
})
- }
}
#[cfg(any(
@@ -398,3 +427,43 @@ impl Storage {
}
}
}
+
+#[cfg(all(test, feature = "storage-fs"))]
+mod fs_relative_path_tests {
+ use super::Storage;
+
+ fn rel(path: &str) -> String {
+ Storage::fs_relative_path(path).unwrap().into_owned()
+ }
+
+ #[test]
+ fn posix_absolute_path_drops_leading_separator() {
+ // opendal joins the result onto its `/` root, rebuilding `/tmp/wh`.
+ assert_eq!(rel("/tmp/wh"), "tmp/wh");
+ assert_eq!(rel("/tmp/wh/db.db/t"), "tmp/wh/db.db/t");
+ }
+
+ #[test]
+ fn file_scheme_is_stripped() {
+ assert_eq!(rel("file:/tmp/wh"), "tmp/wh");
+ // `file://` keeps the leading authority slash, matching prior
behavior.
+ assert_eq!(rel("file:///tmp/wh"), "//tmp/wh");
+ }
+
+ #[test]
+ fn windows_drive_path_keeps_drive_and_normalizes_separators() {
+ // The historical bug dropped the drive letter (`C:\wh` -> `:\wh`); we
+ // must keep it and only switch `\` to `/` so opendal's
+ // `PathBuf::from("/").join(..)` rebuilds the real `C:\wh` on Windows.
+ assert_eq!(rel(r"C:\Users\wh"), "C:/Users/wh");
+ assert_eq!(rel("C:/Users/wh"), "C:/Users/wh");
+ assert_eq!(rel(r"D:\a\b\c"), "D:/a/b/c");
+ }
+
+ #[test]
+ fn windows_mixed_separators_are_normalized() {
+ // make_path concatenates with `/`, so a Windows warehouse yields a
+ // mixed-separator path that must still normalize cleanly.
+ assert_eq!(rel(r"C:\Users\wh/db.db/t"), "C:/Users/wh/db.db/t");
+ }
+}
diff --git a/crates/paimon/src/spec/manifest.rs
b/crates/paimon/src/spec/manifest.rs
index bebd09e..80463e4 100644
--- a/crates/paimon/src/spec/manifest.rs
+++ b/crates/paimon/src/spec/manifest.rs
@@ -90,7 +90,6 @@ pub(crate) fn merge_active_entries(entries:
Vec<ManifestEntry>) -> Vec<ManifestE
}
#[cfg(test)]
-#[cfg(not(windows))] // Skip on Windows due to path compatibility issues
mod tests {
use super::*;
use crate::io::FileIO;