Skip to content

Commit 0fe78e7

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 8ef1aaa commit 0fe78e7

2 files changed

Lines changed: 211 additions & 17 deletions

File tree

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ serde_json = { version = "1.0", default-features = false, features = ["std"], op
6262
serde_urlencoded = { version = "0.7", optional = true }
6363
tokio = { version = "1.29.0", features = ["sync", "macros", "rt", "time", "io-util"] }
6464

65+
[target.'cfg(target_family="unix")'.dependencies]
66+
xattr = { version = "1", optional = true }
67+
6568
[target.'cfg(target_family="unix")'.dev-dependencies]
6669
nix = { version = "0.30.0", features = ["fs"] }
6770

src/local.rs

Lines changed: 208 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
// under the License.
1717

1818
//! An object store implementation for a local filesystem
19+
#[cfg(feature = "xattr")]
20+
use std::borrow::Cow;
1921
use std::fs::{File, Metadata, OpenOptions, metadata, symlink_metadata};
2022
use std::io::{ErrorKind, Read, Seek, SeekFrom, Write};
2123
use std::ops::Range;
@@ -33,6 +35,8 @@ use parking_lot::Mutex;
3335
use url::Url;
3436
use walkdir::{DirEntry, WalkDir};
3537

38+
#[cfg(feature = "xattr")]
39+
use crate::Attribute;
3640
use crate::{
3741
Attributes, GetOptions, GetResult, GetResultPayload, ListResult, MultipartUpload, ObjectMeta,
3842
ObjectStore, PutMode, PutMultipartOptions, PutOptions, PutPayload, PutResult, Result,
@@ -112,6 +116,20 @@ pub(crate) enum Error {
112116

113117
#[error("Upload aborted")]
114118
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+
},
115133
}
116134

117135
impl From<Error> for super::Error {
@@ -321,6 +339,98 @@ fn is_valid_file_path(path: &Path) -> bool {
321339
}
322340
}
323341

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

339-
if !opts.attributes.is_empty() {
340-
return Err(crate::Error::NotImplemented {
341-
operation: "`put_opts` with `opts.attributes` specified".into(),
342-
implementer: self.to_string(),
343-
});
344-
}
345-
346449
let path = self.path_to_filesystem(location)?;
347450
maybe_spawn_blocking(move || {
348451
let (mut file, staging_path) = new_staged_upload(&path)?;
@@ -355,6 +458,11 @@ impl ObjectStore for LocalFileSystem {
355458
path: path.to_string_lossy().to_string(),
356459
})?;
357460
e_tag = Some(get_etag(&metadata));
461+
462+
if !opts.attributes.is_empty() {
463+
set_xattrs(&staging_path, &opts.attributes)?;
464+
}
465+
358466
match opts.mode {
359467
PutMode::Overwrite => {
360468
// For some fuse types of file systems, the file must be closed first
@@ -402,16 +510,9 @@ impl ObjectStore for LocalFileSystem {
402510
location: &Path,
403511
opts: PutMultipartOptions,
404512
) -> Result<Box<dyn MultipartUpload>> {
405-
if !opts.attributes.is_empty() {
406-
return Err(crate::Error::NotImplemented {
407-
operation: "`put_multipart_opts` with `opts.attributes` specified".into(),
408-
implementer: self.to_string(),
409-
});
410-
}
411-
412513
let dest = self.path_to_filesystem(location)?;
413514
let (file, src) = new_staged_upload(&dest)?;
414-
Ok(Box::new(LocalUpload::new(src, dest, file)))
515+
Ok(Box::new(LocalUpload::new(src, dest, file, opts.attributes)))
415516
}
416517

417518
async fn get_opts(&self, location: &Path, options: GetOptions) -> Result<GetResult> {
@@ -429,9 +530,11 @@ impl ObjectStore for LocalFileSystem {
429530
None => 0..meta.size,
430531
};
431532

533+
let attributes = get_xattrs(&path)?;
534+
432535
Ok(GetResult {
433536
payload: GetResultPayload::File(file, path),
434-
attributes: Attributes::default(),
537+
attributes,
435538
range,
436539
meta,
437540
})
@@ -829,6 +932,8 @@ struct LocalUpload {
829932
src: Option<PathBuf>,
830933
/// The next offset to write into the file
831934
offset: u64,
935+
/// Attributes to set on the file
936+
attributes: Attributes,
832937
}
833938

834939
#[derive(Debug)]
@@ -838,14 +943,15 @@ struct UploadState {
838943
}
839944

840945
impl LocalUpload {
841-
pub(crate) fn new(src: PathBuf, dest: PathBuf, file: File) -> Self {
946+
pub(crate) fn new(src: PathBuf, dest: PathBuf, file: File, attributes: Attributes) -> Self {
842947
Self {
843948
state: Arc::new(UploadState {
844949
dest,
845950
file: Mutex::new(file),
846951
}),
847952
src: Some(src),
848953
offset: 0,
954+
attributes,
849955
}
850956
}
851957
}
@@ -876,9 +982,15 @@ impl MultipartUpload for LocalUpload {
876982
async fn complete(&mut self) -> Result<PutResult> {
877983
let src = self.src.take().ok_or(Error::Aborted)?;
878984
let s = Arc::clone(&self.state);
985+
let attributes = std::mem::take(&mut self.attributes);
879986
maybe_spawn_blocking(move || {
880987
// Ensure no inflight writes
881988
let file = s.file.lock();
989+
990+
if !attributes.is_empty() {
991+
set_xattrs(&src, &attributes)?;
992+
}
993+
882994
std::fs::rename(&src, &s.dest)
883995
.map_err(|source| Error::UnableToRenameFile { source })?;
884996
let metadata = file.metadata().map_err(|e| Error::Metadata {
@@ -1177,6 +1289,8 @@ mod tests {
11771289
copy_rename_nonexistent_object(&integration).await;
11781290
stream_get(&integration).await;
11791291
put_opts(&integration, false).await;
1292+
#[cfg(feature = "xattr")]
1293+
put_get_attributes(&integration).await;
11801294
}
11811295

11821296
#[test]
@@ -1811,3 +1925,80 @@ mod unix_test {
18111925
spawned.await.unwrap();
18121926
}
18131927
}
1928+
1929+
#[cfg(feature = "xattr")]
1930+
#[cfg(test)]
1931+
mod xattr_test {
1932+
use tempfile::TempDir;
1933+
1934+
use crate::local::LocalFileSystem;
1935+
use crate::{Attribute, Attributes, ObjectStore, ObjectStoreExt, Path, PutOptions};
1936+
1937+
#[tokio::test]
1938+
async fn test_put_get_attributes() {
1939+
let root = TempDir::new().unwrap();
1940+
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
1941+
1942+
let location = Path::from("test_file");
1943+
let data = "test data";
1944+
1945+
let mut attributes = Attributes::new();
1946+
attributes.insert(Attribute::ContentType, "text/plain".into());
1947+
attributes.insert(Attribute::CacheControl, "max-age=3600".into());
1948+
attributes.insert(Attribute::ContentDisposition, "inline".into());
1949+
attributes.insert(Attribute::ContentEncoding, "gzip".into());
1950+
attributes.insert(Attribute::ContentLanguage, "en-US".into());
1951+
attributes.insert(Attribute::StorageClass, "STANDARD".into());
1952+
attributes.insert(
1953+
Attribute::Metadata("custom_key".into()),
1954+
"custom_value".into(),
1955+
);
1956+
1957+
let opts = PutOptions {
1958+
attributes: attributes.clone(),
1959+
..Default::default()
1960+
};
1961+
1962+
integration
1963+
.put_opts(&location, data.into(), opts)
1964+
.await
1965+
.unwrap();
1966+
1967+
let result = integration.get(&location).await.unwrap();
1968+
assert_eq!(result.attributes, attributes);
1969+
1970+
let bytes = result.bytes().await.unwrap();
1971+
assert_eq!(bytes.as_ref(), data.as_bytes());
1972+
}
1973+
1974+
#[tokio::test]
1975+
async fn test_multipart_attributes() {
1976+
let root = TempDir::new().unwrap();
1977+
let integration = LocalFileSystem::new_with_prefix(root.path()).unwrap();
1978+
1979+
let location = Path::from("multipart_file");
1980+
1981+
let mut attributes = Attributes::new();
1982+
attributes.insert(Attribute::ContentType, "application/octet-stream".into());
1983+
attributes.insert(Attribute::Metadata("part_count".into()), "2".into());
1984+
1985+
let opts = crate::PutMultipartOptions {
1986+
attributes: attributes.clone(),
1987+
..Default::default()
1988+
};
1989+
1990+
let mut upload = integration
1991+
.put_multipart_opts(&location, opts)
1992+
.await
1993+
.unwrap();
1994+
upload.put_part("part1".into()).await.unwrap();
1995+
upload.put_part("part2".into()).await.unwrap();
1996+
upload.complete().await.unwrap();
1997+
1998+
let result = integration.get(&location).await.unwrap();
1999+
assert_eq!(result.attributes, attributes);
2000+
2001+
let bytes = result.bytes().await.unwrap();
2002+
assert_eq!(bytes.as_ref(), b"part1part2");
2003+
}
2004+
}

0 commit comments

Comments
 (0)