Skip to content

Commit 129d021

Browse files
committed
Add xattr-based attribute support to LocalFileSystem
Store object attributes as extended attributes on the local filesystem, enabling put_opts and put_multipart_opts to persist attributes. Attributes are set on the staging file before atomic rename to preserve consistency. - Add xattr dependency (gated by fs feature) - Map standard attributes to user.* xattr namespace - Read attributes back in get_opts
1 parent 0fda34c commit 129d021

2 files changed

Lines changed: 222 additions & 21 deletions

File tree

Cargo.toml

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ description = "A generic object store interface for uniformly interacting with A
2525
keywords = ["object", "storage", "cloud"]
2626
repository = "https://github.com/apache/arrow-rs-object-store"
2727
rust-version = "1.85"
28-
include = ["src/**/*.rs", "README.md", "LICENSE.txt", "NOTICE.txt", "Cargo.toml"]
28+
include = [
29+
"src/**/*.rs",
30+
"README.md",
31+
"LICENSE.txt",
32+
"NOTICE.txt",
33+
"Cargo.toml",
34+
]
2935

3036
[package.metadata.docs.rs]
3137
all-features = true
@@ -46,10 +52,14 @@ url = "2.2"
4652
walkdir = { version = "2", optional = true }
4753

4854
# Cloud storage support
49-
base64 = { version = "0.22", default-features = false, features = ["std"], optional = true }
55+
base64 = { version = "0.22", default-features = false, features = [
56+
"std",
57+
], optional = true }
5058
form_urlencoded = { version = "1.2", optional = true }
5159
http-body-util = { version = "0.1.2", optional = true }
52-
httparse = { version = "1.8.0", default-features = false, features = ["std"], optional = true }
60+
httparse = { version = "1.8.0", default-features = false, features = [
61+
"std",
62+
], optional = true }
5363
hyper = { version = "1.2", default-features = false, optional = true }
5464
md-5 = { version = "0.11.0", default-features = false, optional = true }
5565
quick-xml = { version = "0.39.0", features = ["serialize", "overlapped-lists"], optional = true }
@@ -65,6 +75,9 @@ serde_urlencoded = { version = "0.7", optional = true }
6575
tokio = { version = "1.29.0", features = ["sync", "macros", "rt", "time", "io-util"], optional = true }
6676
tracing = { version = "0.1", optional = true }
6777

78+
[target.'cfg(target_family="unix")'.dependencies]
79+
xattr = { version = "1", optional = true }
80+
6881
[target.'cfg(target_family="unix")'.dev-dependencies]
6982
nix = { version = "0.31.1", features = ["fs"] }
7083

@@ -74,7 +87,7 @@ wasm-bindgen-futures = "0.4.18"
7487
futures-channel = {version = "0.3", features = ["sink"]}
7588

7689
[features]
77-
default = ["fs"]
90+
default = ["fs", "xattr"]
7891
cloud = ["serde", "serde_json", "quick-xml", "hyper", "reqwest", "reqwest/stream", "chrono/serde", "base64", "rand", "ring", "http-body-util", "form_urlencoded", "serde_urlencoded", "tokio"]
7992
azure = ["cloud", "httparse"]
8093
fs = ["walkdir", "tokio"]

src/local.rs

Lines changed: 205 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,20 @@ pub(crate) enum Error {
116116

117117
#[error("Upload aborted")]
118118
Aborted,
119+
120+
#[cfg(feature = "xattr")]
121+
#[error("Unable to set extended attribute on {}: {source}", path.display())]
122+
UnableToSetXattr {
123+
source: std::io::Error,
124+
path: PathBuf,
125+
},
126+
127+
#[cfg(feature = "xattr")]
128+
#[error("Unable to read extended attribute on {}: {source}", path.display())]
129+
UnableToReadXattr {
130+
source: std::io::Error,
131+
path: PathBuf,
132+
},
119133
}
120134

121135
impl From<Error> for super::Error {
@@ -325,6 +339,103 @@ fn is_valid_file_path(path: &Path) -> bool {
325339
}
326340
}
327341

342+
/// Sets extended attributes on a file from an Attributes collection
343+
#[cfg(feature = "xattr")]
344+
fn set_xattrs(path: &std::path::Path, attributes: &Attributes) -> Result<()> {
345+
use crate::Attribute;
346+
use std::borrow::Cow;
347+
348+
for (attr, value) in attributes {
349+
let name: Cow<'static, str> = match attr {
350+
Attribute::CacheControl => Cow::Borrowed("user.object_store.cache_control"),
351+
Attribute::ContentDisposition => Cow::Borrowed("user.object_store.content_disposition"),
352+
Attribute::ContentEncoding => Cow::Borrowed("user.object_store.content_encoding"),
353+
Attribute::ContentLanguage => Cow::Borrowed("user.object_store.content_language"),
354+
Attribute::ContentType => Cow::Borrowed("user.object_store.content_type"),
355+
Attribute::StorageClass => Cow::Borrowed("user.object_store.storage_class"),
356+
Attribute::Metadata(key) => Cow::Owned(format!("user.object_store.{key}")),
357+
};
358+
xattr::set(path, name.as_ref(), value.as_ref().as_bytes()).map_err(|source| {
359+
Error::UnableToSetXattr {
360+
source,
361+
path: path.into(),
362+
}
363+
})?;
364+
}
365+
Ok(())
366+
}
367+
368+
/// Reads extended attributes from a file and returns an Attributes collection
369+
#[cfg(feature = "xattr")]
370+
fn get_xattrs(path: &std::path::Path) -> Result<Attributes> {
371+
use crate::Attribute;
372+
373+
let mut attributes = Attributes::new();
374+
375+
let list = match xattr::list(path) {
376+
Ok(list) => list,
377+
Err(source) => {
378+
return Err(Error::UnableToReadXattr {
379+
source,
380+
path: path.into(),
381+
}
382+
.into());
383+
}
384+
};
385+
386+
for name in list {
387+
let name_str = match name.to_str() {
388+
Some(s) if s.starts_with("user.object_store.") => s,
389+
_ => continue,
390+
};
391+
392+
let value = match xattr::get(path, &name) {
393+
Ok(Some(v)) => match String::from_utf8(v) {
394+
Ok(s) => s,
395+
Err(_) => continue,
396+
},
397+
Ok(None) => continue,
398+
Err(source) => {
399+
return Err(Error::UnableToReadXattr {
400+
source,
401+
path: path.into(),
402+
}
403+
.into());
404+
}
405+
};
406+
407+
let key = &name_str["user.object_store.".len()..];
408+
let attr = match key {
409+
"cache_control" => Attribute::CacheControl,
410+
"content_disposition" => Attribute::ContentDisposition,
411+
"content_encoding" => Attribute::ContentEncoding,
412+
"content_language" => Attribute::ContentLanguage,
413+
"content_type" => Attribute::ContentType,
414+
"storage_class" => Attribute::StorageClass,
415+
key => Attribute::Metadata(key.to_string().into()),
416+
};
417+
attributes.insert(attr, value.into());
418+
}
419+
Ok(attributes)
420+
}
421+
422+
/// Returns an error if attributes are non-empty when xattr feature is disabled.
423+
#[cfg(not(feature = "xattr"))]
424+
fn set_xattrs(_path: &std::path::Path, attributes: &Attributes) -> Result<()> {
425+
if !attributes.is_empty() {
426+
return Err(super::Error::NotSupported {
427+
source: "Setting extended attributes requires the 'xattr' feature".into(),
428+
});
429+
}
430+
Ok(())
431+
}
432+
433+
/// No-op when xattr feature is disabled: returns empty attributes.
434+
#[cfg(not(feature = "xattr"))]
435+
fn get_xattrs(_path: &std::path::Path) -> Result<Attributes> {
436+
Ok(Attributes::new())
437+
}
438+
328439
#[async_trait]
329440
impl ObjectStore for LocalFileSystem {
330441
async fn put_opts(
@@ -340,13 +451,6 @@ impl ObjectStore for LocalFileSystem {
340451
});
341452
}
342453

343-
if !opts.attributes.is_empty() {
344-
return Err(crate::Error::NotImplemented {
345-
operation: "`put_opts` with `opts.attributes` specified".into(),
346-
implementer: self.to_string(),
347-
});
348-
}
349-
350454
let path = self.path_to_filesystem(location)?;
351455
maybe_spawn_blocking(move || {
352456
let (mut file, staging_path) = new_staged_upload(&path)?;
@@ -359,6 +463,9 @@ impl ObjectStore for LocalFileSystem {
359463
path: path.to_string_lossy().to_string(),
360464
})?;
361465
e_tag = Some(get_etag(&metadata));
466+
467+
set_xattrs(&staging_path, &opts.attributes)?;
468+
362469
match opts.mode {
363470
PutMode::Overwrite => {
364471
// For some fuse types of file systems, the file must be closed first
@@ -406,16 +513,9 @@ impl ObjectStore for LocalFileSystem {
406513
location: &Path,
407514
opts: PutMultipartOptions,
408515
) -> Result<Box<dyn MultipartUpload>> {
409-
if !opts.attributes.is_empty() {
410-
return Err(crate::Error::NotImplemented {
411-
operation: "`put_multipart_opts` with `opts.attributes` specified".into(),
412-
implementer: self.to_string(),
413-
});
414-
}
415-
416516
let dest = self.path_to_filesystem(location)?;
417517
let (file, src) = new_staged_upload(&dest)?;
418-
Ok(Box::new(LocalUpload::new(src, dest, file)))
518+
Ok(Box::new(LocalUpload::new(src, dest, file, opts.attributes)))
419519
}
420520

421521
async fn get_opts(&self, location: &Path, options: GetOptions) -> Result<GetResult> {
@@ -434,9 +534,11 @@ impl ObjectStore for LocalFileSystem {
434534
None => 0..meta.size,
435535
};
436536

537+
let attributes = get_xattrs(&path)?;
538+
437539
Ok(GetResult {
438540
payload: GetResultPayload::File(file, path),
439-
attributes: Attributes::default(),
541+
attributes,
440542
range,
441543
meta,
442544
})
@@ -835,6 +937,8 @@ struct LocalUpload {
835937
src: Option<PathBuf>,
836938
/// The next offset to write into the file
837939
offset: u64,
940+
/// Attributes to set on the file
941+
attributes: Attributes,
838942
}
839943

840944
#[derive(Debug)]
@@ -844,14 +948,15 @@ struct UploadState {
844948
}
845949

846950
impl LocalUpload {
847-
pub(crate) fn new(src: PathBuf, dest: PathBuf, file: File) -> Self {
951+
pub(crate) fn new(src: PathBuf, dest: PathBuf, file: File, attributes: Attributes) -> Self {
848952
Self {
849953
state: Arc::new(UploadState {
850954
dest,
851955
file: Mutex::new(file),
852956
}),
853957
src: Some(src),
854958
offset: 0,
959+
attributes,
855960
}
856961
}
857962
}
@@ -882,9 +987,13 @@ impl MultipartUpload for LocalUpload {
882987
async fn complete(&mut self) -> Result<PutResult> {
883988
let src = self.src.take().ok_or(Error::Aborted)?;
884989
let s = Arc::clone(&self.state);
990+
let attributes = std::mem::take(&mut self.attributes);
885991
maybe_spawn_blocking(move || {
886992
// Ensure no inflight writes
887993
let file = s.file.lock();
994+
995+
set_xattrs(&src, &attributes)?;
996+
888997
std::fs::rename(&src, &s.dest)
889998
.map_err(|source| Error::UnableToRenameFile { source })?;
890999
let metadata = file.metadata().map_err(|e| Error::Metadata {
@@ -1280,6 +1389,8 @@ mod tests {
12801389
copy_rename_nonexistent_object(&integration).await;
12811390
stream_get(&integration).await;
12821391
put_opts(&integration, false).await;
1392+
#[cfg(feature = "xattr")]
1393+
put_get_attributes(&integration).await;
12831394
}
12841395

12851396
#[test]
@@ -1914,3 +2025,80 @@ mod unix_test {
19142025
spawned.await.unwrap();
19152026
}
19162027
}
2028+
2029+
#[cfg(feature = "xattr")]
2030+
#[cfg(test)]
2031+
mod xattr_test {
2032+
use tempfile::TempDir;
2033+
2034+
use crate::local::LocalFileSystem;
2035+
use crate::{Attribute, Attributes, ObjectStore, ObjectStoreExt, Path, PutOptions};
2036+
2037+
#[tokio::test]
2038+
async fn test_put_get_attributes() {
2039+
let root = TempDir::new().unwrap();
2040+
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
2041+
2042+
let location = Path::from("test_file");
2043+
let data = "test data";
2044+
2045+
let mut attributes = Attributes::new();
2046+
attributes.insert(Attribute::ContentType, "text/plain".into());
2047+
attributes.insert(Attribute::CacheControl, "max-age=3600".into());
2048+
attributes.insert(Attribute::ContentDisposition, "inline".into());
2049+
attributes.insert(Attribute::ContentEncoding, "gzip".into());
2050+
attributes.insert(Attribute::ContentLanguage, "en-US".into());
2051+
attributes.insert(Attribute::StorageClass, "STANDARD".into());
2052+
attributes.insert(
2053+
Attribute::Metadata("custom_key".into()),
2054+
"custom_value".into(),
2055+
);
2056+
2057+
let opts = PutOptions {
2058+
attributes: attributes.clone(),
2059+
..Default::default()
2060+
};
2061+
2062+
integration
2063+
.put_opts(&location, data.into(), opts)
2064+
.await
2065+
.unwrap();
2066+
2067+
let result = integration.get(&location).await.unwrap();
2068+
assert_eq!(result.attributes, attributes);
2069+
2070+
let bytes = result.bytes().await.unwrap();
2071+
assert_eq!(bytes.as_ref(), data.as_bytes());
2072+
}
2073+
2074+
#[tokio::test]
2075+
async fn test_multipart_attributes() {
2076+
let root = TempDir::new().unwrap();
2077+
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
2078+
2079+
let location = Path::from("multipart_file");
2080+
2081+
let mut attributes = Attributes::new();
2082+
attributes.insert(Attribute::ContentType, "application/octet-stream".into());
2083+
attributes.insert(Attribute::Metadata("part_count".into()), "2".into());
2084+
2085+
let opts = crate::PutMultipartOptions {
2086+
attributes: attributes.clone(),
2087+
..Default::default()
2088+
};
2089+
2090+
let mut upload = integration
2091+
.put_multipart_opts(&location, opts)
2092+
.await
2093+
.unwrap();
2094+
upload.put_part("part1".into()).await.unwrap();
2095+
upload.put_part("part2".into()).await.unwrap();
2096+
upload.complete().await.unwrap();
2097+
2098+
let result = integration.get(&location).await.unwrap();
2099+
assert_eq!(result.attributes, attributes);
2100+
2101+
let bytes = result.bytes().await.unwrap();
2102+
assert_eq!(bytes.as_ref(), b"part1part2");
2103+
}
2104+
}

0 commit comments

Comments
 (0)