From 1943bd3e89e7b51954cf35bc31407145542dacad Mon Sep 17 00:00:00 2001 From: dentiny Date: Sat, 30 May 2026 10:04:48 +0000 Subject: [PATCH 1/2] feat(binding/go): expose all metata fields to golang --- bindings/c/include/opendal.h | 142 ++++++++--- bindings/c/src/lib.rs | 2 + bindings/c/src/metadata.rs | 235 +++++++++++++++--- bindings/go/metadata.go | 300 +++++++++++++++++++++- bindings/go/string_ownership_test.go | 358 +++++++++++++++++++++++++++ bindings/go/types.go | 7 + 6 files changed, 969 insertions(+), 75 deletions(-) diff --git a/bindings/c/include/opendal.h b/bindings/c/include/opendal.h index 493c62c12a7d..5a040ef92f21 100644 --- a/bindings/c/include/opendal.h +++ b/bindings/c/include/opendal.h @@ -220,6 +220,31 @@ typedef struct opendal_metadata { void *inner; } opendal_metadata; +/** + * \brief User metadata associated with a **path**. + */ +typedef struct opendal_metadata_user_metadata { + /** + * The pointer to the user metadata in the Rust code. + * Only touch this on judging whether it is NULL. + */ + void *inner; +} opendal_metadata_user_metadata; + +/** + * \brief A user metadata key-value pair. + */ +typedef struct opendal_metadata_user_metadata_pair { + /** + * The key of the user metadata. + */ + const char *key; + /** + * The value of the user metadata. + */ + const char *value; +} opendal_metadata_user_metadata_pair; + /** * \brief Used to access almost all OpenDAL APIs. It represents an * operator that provides the unified interfaces provided by OpenDAL. @@ -762,55 +787,93 @@ void opendal_operator_layers_free(struct opendal_operator_layers *ptr); */ void opendal_metadata_free(struct opendal_metadata *ptr); +/** + * \brief Return mode of the metadata: 0 for unknown, 1 for file, and 2 for dir. + */ +uint8_t opendal_metadata_mode(const struct opendal_metadata *self); + /** * \brief Return the content_length of the metadata - * - * # Example - * ```C - * // ... previously you wrote "Hello, World!" to path "/testpath" - * opendal_result_stat s = opendal_operator_stat(op, "/testpath"); - * assert(s.error == NULL); - * - * opendal_metadata *meta = s.meta; - * assert(opendal_metadata_content_length(meta) == 13); - * ``` */ uint64_t opendal_metadata_content_length(const struct opendal_metadata *self); /** * \brief Return whether the path represents a file - * - * # Example - * ```C - * // ... previously you wrote "Hello, World!" to path "/testpath" - * opendal_result_stat s = opendal_operator_stat(op, "/testpath"); - * assert(s.error == NULL); - * - * opendal_metadata *meta = s.meta; - * assert(opendal_metadata_is_file(meta)); - * ``` */ bool opendal_metadata_is_file(const struct opendal_metadata *self); /** * \brief Return whether the path represents a directory + */ +bool opendal_metadata_is_dir(const struct opendal_metadata *self); + +/** + * \brief Return whether this metadata is current. * - * # Example - * ```C - * // ... previously you wrote "Hello, World!" to path "/testpath" - * opendal_result_stat s = opendal_operator_stat(op, "/testpath"); - * assert(s.error == NULL); + * Returns 1 for current, 0 for not current, and 2 if unknown. + */ +uint8_t opendal_metadata_is_current(const struct opendal_metadata *self); + +/** + * \brief Return whether this metadata is deleted. + */ +bool opendal_metadata_is_deleted(const struct opendal_metadata *self); + +/** + * \brief Return the cache control of the metadata. * - * opendal_metadata *meta = s.meta; + * \note: The string is on heap, free it with opendal_string_free(). + */ +char *opendal_metadata_cache_control(const struct opendal_metadata *self); + +/** + * \brief Return the content disposition of the metadata. * - * // this is not a directory - * assert(!opendal_metadata_is_dir(meta)); - * ``` + * \note: The string is on heap, free it with opendal_string_free(). + */ +char *opendal_metadata_content_disposition(const struct opendal_metadata *self); + +/** + * \brief Return the content md5 of the metadata. * - * \todo This is not a very clear example. A clearer example will be added - * after we support opendal_operator_mkdir() + * \note: The string is on heap, free it with opendal_string_free(). */ -bool opendal_metadata_is_dir(const struct opendal_metadata *self); +char *opendal_metadata_content_md5(const struct opendal_metadata *self); + +/** + * \brief Return the content type of the metadata. + * + * \note: The string is on heap, free it with opendal_string_free(). + */ +char *opendal_metadata_content_type(const struct opendal_metadata *self); + +/** + * \brief Return the content encoding of the metadata. + * + * \note: The string is on heap, free it with opendal_string_free(). + */ +char *opendal_metadata_content_encoding(const struct opendal_metadata *self); + +/** + * \brief Return the etag of the metadata. + * + * \note: The string is on heap, free it with opendal_string_free(). + */ +char *opendal_metadata_etag(const struct opendal_metadata *self); + +/** + * \brief Return the version of the metadata. + * + * \note: The string is on heap, free it with opendal_string_free(). + */ +char *opendal_metadata_version(const struct opendal_metadata *self); + +/** + * \brief Return the user metadata of the metadata. + * + * \note: The returned user metadata is on heap, free it with opendal_metadata_user_metadata_free(). + */ +struct opendal_metadata_user_metadata *opendal_metadata_get_user_metadata(const struct opendal_metadata *self); /** * \brief Return the last_modified of the metadata, in milliseconds @@ -827,6 +890,21 @@ bool opendal_metadata_is_dir(const struct opendal_metadata *self); */ int64_t opendal_metadata_last_modified_ms(const struct opendal_metadata *self); +/** + * \brief Return the key-value pairs of the user metadata. + */ +const struct opendal_metadata_user_metadata_pair *opendal_metadata_user_metadata_pairs(const struct opendal_metadata_user_metadata *metadata); + +/** + * \brief Return the number of key-value pairs in the user metadata. + */ +uintptr_t opendal_metadata_user_metadata_len(const struct opendal_metadata_user_metadata *metadata); + +/** + * \brief Free the user metadata returned by opendal_metadata_user_metadata. + */ +void opendal_metadata_user_metadata_free(struct opendal_metadata_user_metadata *metadata); + /** * \brief Free the heap-allocated operator pointed by opendal_operator. * diff --git a/bindings/c/src/lib.rs b/bindings/c/src/lib.rs index 89fd461a3a10..5592288bdb09 100644 --- a/bindings/c/src/lib.rs +++ b/bindings/c/src/lib.rs @@ -44,6 +44,8 @@ pub use layer::opendal_operator_layers; mod metadata; pub use metadata::opendal_metadata; +pub use metadata::opendal_metadata_user_metadata; +pub use metadata::opendal_metadata_user_metadata_pair; mod operator; pub use operator::opendal_operator; diff --git a/bindings/c/src/metadata.rs b/bindings/c/src/metadata.rs index 8ace23f3892b..fe0b4254e2a5 100644 --- a/bindings/c/src/metadata.rs +++ b/bindings/c/src/metadata.rs @@ -16,7 +16,62 @@ // under the License. use ::opendal as core; -use std::ffi::c_void; +use std::collections::HashMap; +use std::ffi::{c_char, c_void, CString}; +use std::ptr; + +/// \brief A user metadata key-value pair. +#[repr(C)] +pub struct opendal_metadata_user_metadata_pair { + /// The key of the user metadata. + pub key: *const c_char, + /// The value of the user metadata. + pub value: *const c_char, +} + +struct opendal_metadata_user_metadata_inner { + pairs: Vec, + #[allow(dead_code)] + keys: Vec, + #[allow(dead_code)] + values: Vec, +} + +impl opendal_metadata_user_metadata_inner { + fn new(user_metadata: &HashMap) -> Self { + let mut keys = Vec::with_capacity(user_metadata.len()); + let mut values = Vec::with_capacity(user_metadata.len()); + let mut pairs = Vec::with_capacity(user_metadata.len()); + + for (key, value) in user_metadata { + keys.push( + CString::new(key.as_str()).expect("user metadata key should not contain nul"), + ); + values.push( + CString::new(value.as_str()).expect("user metadata value should not contain nul"), + ); + + pairs.push(opendal_metadata_user_metadata_pair { + key: keys.last().expect("key has just been pushed").as_ptr(), + value: values.last().expect("value has just been pushed").as_ptr(), + }); + } + + Self { + pairs, + keys, + values, + } + } +} + +/// \brief User metadata associated with a **path**. +#[repr(C)] +pub struct opendal_metadata_user_metadata { + /// The pointer to the user metadata in the Rust code. + /// Only touch this on judging whether it is NULL. + inner: *mut c_void, +} /// \brief Carries all metadata associated with a **path**. /// @@ -62,57 +117,132 @@ impl opendal_metadata { } } + fn optional_str(v: Option<&str>) -> *mut c_char { + match v { + Some(v) => CString::new(v) + .expect("metadata string should not contain nul") + .into_raw(), + None => ptr::null_mut(), + } + } + + /// \brief Return mode of the metadata: 0 for unknown, 1 for file, and 2 for dir. + #[no_mangle] + pub extern "C" fn opendal_metadata_mode(&self) -> u8 { + match self.deref().mode() { + core::EntryMode::Unknown => 0, + core::EntryMode::FILE => 1, + core::EntryMode::DIR => 2, + } + } + /// \brief Return the content_length of the metadata - /// - /// # Example - /// ```C - /// // ... previously you wrote "Hello, World!" to path "/testpath" - /// opendal_result_stat s = opendal_operator_stat(op, "/testpath"); - /// assert(s.error == NULL); - /// - /// opendal_metadata *meta = s.meta; - /// assert(opendal_metadata_content_length(meta) == 13); - /// ``` #[no_mangle] pub extern "C" fn opendal_metadata_content_length(&self) -> u64 { self.deref().content_length() } /// \brief Return whether the path represents a file - /// - /// # Example - /// ```C - /// // ... previously you wrote "Hello, World!" to path "/testpath" - /// opendal_result_stat s = opendal_operator_stat(op, "/testpath"); - /// assert(s.error == NULL); - /// - /// opendal_metadata *meta = s.meta; - /// assert(opendal_metadata_is_file(meta)); - /// ``` #[no_mangle] pub extern "C" fn opendal_metadata_is_file(&self) -> bool { self.deref().is_file() } /// \brief Return whether the path represents a directory + #[no_mangle] + pub extern "C" fn opendal_metadata_is_dir(&self) -> bool { + self.deref().is_dir() + } + + /// \brief Return whether this metadata is current. /// - /// # Example - /// ```C - /// // ... previously you wrote "Hello, World!" to path "/testpath" - /// opendal_result_stat s = opendal_operator_stat(op, "/testpath"); - /// assert(s.error == NULL); + /// Returns 1 for current, 0 for not current, and 2 if unknown. + #[no_mangle] + pub extern "C" fn opendal_metadata_is_current(&self) -> u8 { + match self.deref().is_current() { + Some(true) => 1, + Some(false) => 0, + None => 2, + } + } + + /// \brief Return whether this metadata is deleted. + #[no_mangle] + pub extern "C" fn opendal_metadata_is_deleted(&self) -> bool { + self.deref().is_deleted() + } + + /// \brief Return the cache control of the metadata. /// - /// opendal_metadata *meta = s.meta; + /// \note: The string is on heap, free it with opendal_string_free(). + #[no_mangle] + pub extern "C" fn opendal_metadata_cache_control(&self) -> *mut c_char { + Self::optional_str(self.deref().cache_control()) + } + + /// \brief Return the content disposition of the metadata. /// - /// // this is not a directory - /// assert(!opendal_metadata_is_dir(meta)); - /// ``` + /// \note: The string is on heap, free it with opendal_string_free(). + #[no_mangle] + pub extern "C" fn opendal_metadata_content_disposition(&self) -> *mut c_char { + Self::optional_str(self.deref().content_disposition()) + } + + /// \brief Return the content md5 of the metadata. /// - /// \todo This is not a very clear example. A clearer example will be added - /// after we support opendal_operator_mkdir() + /// \note: The string is on heap, free it with opendal_string_free(). #[no_mangle] - pub extern "C" fn opendal_metadata_is_dir(&self) -> bool { - self.deref().is_dir() + pub extern "C" fn opendal_metadata_content_md5(&self) -> *mut c_char { + Self::optional_str(self.deref().content_md5()) + } + + /// \brief Return the content type of the metadata. + /// + /// \note: The string is on heap, free it with opendal_string_free(). + #[no_mangle] + pub extern "C" fn opendal_metadata_content_type(&self) -> *mut c_char { + Self::optional_str(self.deref().content_type()) + } + + /// \brief Return the content encoding of the metadata. + /// + /// \note: The string is on heap, free it with opendal_string_free(). + #[no_mangle] + pub extern "C" fn opendal_metadata_content_encoding(&self) -> *mut c_char { + Self::optional_str(self.deref().content_encoding()) + } + + /// \brief Return the etag of the metadata. + /// + /// \note: The string is on heap, free it with opendal_string_free(). + #[no_mangle] + pub extern "C" fn opendal_metadata_etag(&self) -> *mut c_char { + Self::optional_str(self.deref().etag()) + } + + /// \brief Return the version of the metadata. + /// + /// \note: The string is on heap, free it with opendal_string_free(). + #[no_mangle] + pub extern "C" fn opendal_metadata_version(&self) -> *mut c_char { + Self::optional_str(self.deref().version()) + } + + /// \brief Return the user metadata of the metadata. + /// + /// \note: The returned user metadata is on heap, free it with opendal_metadata_user_metadata_free(). + #[no_mangle] + pub extern "C" fn opendal_metadata_get_user_metadata( + &self, + ) -> *mut opendal_metadata_user_metadata { + match self.deref().user_metadata() { + Some(user_metadata) => Box::into_raw(Box::new(opendal_metadata_user_metadata { + inner: Box::into_raw(Box::new(opendal_metadata_user_metadata_inner::new( + user_metadata, + ))) as _, + })), + None => ptr::null_mut(), + } } /// \brief Return the last_modified of the metadata, in milliseconds @@ -135,3 +265,40 @@ impl opendal_metadata { } } } + +impl opendal_metadata_user_metadata { + /// \brief Return the key-value pairs of the user metadata. + #[no_mangle] + pub unsafe extern "C" fn opendal_metadata_user_metadata_pairs( + metadata: *const Self, + ) -> *const opendal_metadata_user_metadata_pair { + if metadata.is_null() { + return ptr::null(); + } + + let inner = unsafe { &*((*metadata).inner as *mut opendal_metadata_user_metadata_inner) }; + inner.pairs.as_ptr() + } + + /// \brief Return the number of key-value pairs in the user metadata. + #[no_mangle] + pub unsafe extern "C" fn opendal_metadata_user_metadata_len(metadata: *const Self) -> usize { + if metadata.is_null() { + return 0; + } + + let inner = unsafe { &*((*metadata).inner as *mut opendal_metadata_user_metadata_inner) }; + inner.pairs.len() + } + + /// \brief Free the user metadata returned by opendal_metadata_user_metadata. + #[no_mangle] + pub unsafe extern "C" fn opendal_metadata_user_metadata_free(metadata: *mut Self) { + if !metadata.is_null() { + drop(unsafe { + Box::from_raw((*metadata).inner as *mut opendal_metadata_user_metadata_inner) + }); + drop(unsafe { Box::from_raw(metadata) }); + } + } +} diff --git a/bindings/go/metadata.go b/bindings/go/metadata.go index daff59d63c39..057815391327 100644 --- a/bindings/go/metadata.go +++ b/bindings/go/metadata.go @@ -27,15 +27,55 @@ import ( "github.com/jupiterrider/ffi" ) +// EntryMode indicates whether an entry is a file, directory, or unknown. +type EntryMode uint8 + +const ( + EntryModeUnknown EntryMode = iota + EntryModeFile + EntryModeDir +) + // Metadata represents essential information about a file or directory. // -// This struct contains basic attributes commonly used in file systems +// This struct contains attributes commonly used in file systems // and object storage systems. type Metadata struct { - contentLength uint64 - isFile bool - isDir bool - lastModified time.Time + cacheControl *string + contentDisposition *string + contentEncoding *string + contentLength uint64 + contentMD5 *string + contentType *string + etag *string + isCurrent *bool + isDeleted bool + isFile bool + isDir bool + lastModified time.Time + mode EntryMode + version *string + userMetadata map[string]string +} + +func optionalStringValue(v *string) (string, bool) { + if v == nil { + return "", false + } + return *v, true +} + +func boolPtrFromOptionalByte(v uint8) *bool { + switch v { + case 0: + b := false + return &b + case 1: + b := true + return &b + default: + return nil + } } func newMetadata(ctx context.Context, inner *opendalMetadata) *Metadata { @@ -46,16 +86,59 @@ func newMetadata(ctx context.Context, inner *opendalMetadata) *Metadata { lastModified = time.UnixMilli(ms) } + isCurrent := boolPtrFromOptionalByte(ffiMetaIsCurrent.symbol(ctx)(inner)) + defer ffiMetadataFree.symbol(ctx)(inner) return &Metadata{ - contentLength: ffiMetaContentLength.symbol(ctx)(inner), - isFile: ffiMetaIsFile.symbol(ctx)(inner), - isDir: ffiMetaIsDir.symbol(ctx)(inner), - lastModified: lastModified, + cacheControl: ffiMetaCacheControl.symbol(ctx)(inner), + contentDisposition: ffiMetaContentDisposition.symbol(ctx)(inner), + contentEncoding: ffiMetaContentEncoding.symbol(ctx)(inner), + contentLength: ffiMetaContentLength.symbol(ctx)(inner), + contentMD5: ffiMetaContentMD5.symbol(ctx)(inner), + contentType: ffiMetaContentType.symbol(ctx)(inner), + etag: ffiMetaEtag.symbol(ctx)(inner), + isCurrent: isCurrent, + isDeleted: ffiMetaIsDeleted.symbol(ctx)(inner), + isFile: ffiMetaIsFile.symbol(ctx)(inner), + isDir: ffiMetaIsDir.symbol(ctx)(inner), + lastModified: lastModified, + mode: ffiMetaMode.symbol(ctx)(inner), + version: ffiMetaVersion.symbol(ctx)(inner), + userMetadata: ffiMetaUserMetadata.symbol(ctx)(inner), } } +// CacheControl returns the cache control of the entry. +func (m *Metadata) CacheControl() (string, bool) { + return optionalStringValue(m.cacheControl) +} + +// ContentDisposition returns the content disposition of the entry. +func (m *Metadata) ContentDisposition() (string, bool) { + return optionalStringValue(m.contentDisposition) +} + +// ContentEncoding returns the content encoding of the entry. +func (m *Metadata) ContentEncoding() (string, bool) { + return optionalStringValue(m.contentEncoding) +} + +// ContentMD5 returns the content MD5 of the entry. +func (m *Metadata) ContentMD5() (string, bool) { + return optionalStringValue(m.contentMD5) +} + +// ContentType returns the content type of the entry. +func (m *Metadata) ContentType() (string, bool) { + return optionalStringValue(m.contentType) +} + +// ETag returns the ETag of the entry. +func (m *Metadata) ETag() (string, bool) { + return optionalStringValue(m.etag) +} + // ContentLength returns the size of the file in bytes. // // For directories, this value may not be meaningful and could be zero. @@ -73,6 +156,21 @@ func (m *Metadata) IsDir() bool { return m.isDir } +// IsCurrent returns whether this metadata represents the current version. +// +// The second return value is false when the service doesn't provide this information. +func (m *Metadata) IsCurrent() (bool, bool) { + if m.isCurrent == nil { + return false, false + } + return *m.isCurrent, true +} + +// IsDeleted returns whether this metadata represents a deleted object or version. +func (m *Metadata) IsDeleted() bool { + return m.isDeleted +} + // LastModified returns the time when the file or directory was last modified. // // The returned time is in UTC. @@ -80,6 +178,44 @@ func (m *Metadata) LastModified() time.Time { return m.lastModified } +// Mode returns the mode of the entry. +func (m *Metadata) Mode() EntryMode { + return m.mode +} + +// Version returns the version of the entry. +func (m *Metadata) Version() (string, bool) { + return optionalStringValue(m.version) +} + +// UserMetadata returns the user-defined metadata of the entry. +func (m *Metadata) UserMetadata() map[string]string { + if m.userMetadata == nil { + return nil + } + + metadata := make(map[string]string, len(m.userMetadata)) + for key, value := range m.userMetadata { + metadata[key] = value + } + return metadata +} + +var ffiMetaMode = newFFI(ffiOpts{ + sym: "opendal_metadata_mode", + rType: &ffi.TypeUint8, + aTypes: []*ffi.Type{&ffi.TypePointer}, +}, func(ctx context.Context, ffiCall ffiCall) func(m *opendalMetadata) EntryMode { + return func(m *opendalMetadata) EntryMode { + var mode uint8 + ffiCall( + unsafe.Pointer(&mode), + unsafe.Pointer(&m), + ) + return EntryMode(mode) + } +}) + var ffiMetaContentLength = newFFI(ffiOpts{ sym: "opendal_metadata_content_length", rType: &ffi.TypeUint64, @@ -95,6 +231,20 @@ var ffiMetaContentLength = newFFI(ffiOpts{ } }) +var ffiMetaCacheControl = newFFIMetadataString("opendal_metadata_cache_control") + +var ffiMetaContentDisposition = newFFIMetadataString("opendal_metadata_content_disposition") + +var ffiMetaContentMD5 = newFFIMetadataString("opendal_metadata_content_md5") + +var ffiMetaContentType = newFFIMetadataString("opendal_metadata_content_type") + +var ffiMetaContentEncoding = newFFIMetadataString("opendal_metadata_content_encoding") + +var ffiMetaEtag = newFFIMetadataString("opendal_metadata_etag") + +var ffiMetaVersion = newFFIMetadataString("opendal_metadata_version") + var ffiMetaIsFile = newFFI(ffiOpts{ sym: "opendal_metadata_is_file", rType: &ffi.TypeUint8, @@ -125,6 +275,36 @@ var ffiMetaIsDir = newFFI(ffiOpts{ } }) +var ffiMetaIsCurrent = newFFI(ffiOpts{ + sym: "opendal_metadata_is_current", + rType: &ffi.TypeUint8, + aTypes: []*ffi.Type{&ffi.TypePointer}, +}, func(ctx context.Context, ffiCall ffiCall) func(m *opendalMetadata) uint8 { + return func(m *opendalMetadata) uint8 { + var result uint8 + ffiCall( + unsafe.Pointer(&result), + unsafe.Pointer(&m), + ) + return result + } +}) + +var ffiMetaIsDeleted = newFFI(ffiOpts{ + sym: "opendal_metadata_is_deleted", + rType: &ffi.TypeUint8, + aTypes: []*ffi.Type{&ffi.TypePointer}, +}, func(ctx context.Context, ffiCall ffiCall) func(m *opendalMetadata) bool { + return func(m *opendalMetadata) bool { + var result uint8 + ffiCall( + unsafe.Pointer(&result), + unsafe.Pointer(&m), + ) + return result == 1 + } +}) + var ffiMetaLastModified = newFFI(ffiOpts{ sym: "opendal_metadata_last_modified_ms", rType: &ffi.TypeSint64, @@ -140,6 +320,108 @@ var ffiMetaLastModified = newFFI(ffiOpts{ } }) +var ffiMetaUserMetadata = newFFI(ffiOpts{ + sym: "opendal_metadata_get_user_metadata", + rType: &ffi.TypePointer, + aTypes: []*ffi.Type{&ffi.TypePointer}, +}, func(ctx context.Context, ffiCall ffiCall) func(m *opendalMetadata) map[string]string { + return func(m *opendalMetadata) map[string]string { + var metadata *opendalMetadataUserMetadata + ffiCall( + unsafe.Pointer(&metadata), + unsafe.Pointer(&m), + ) + if metadata == nil { + return nil + } + defer ffiMetaUserMetadataFree.symbol(ctx)(metadata) + + length := ffiMetaUserMetadataLen.symbol(ctx)(metadata) + if length == 0 { + return map[string]string{} + } + + pairsPtr := ffiMetaUserMetadataPairs.symbol(ctx)(metadata) + if pairsPtr == nil { + return map[string]string{} + } + + pairs := unsafe.Slice(pairsPtr, int(length)) + result := make(map[string]string, len(pairs)) + for _, pair := range pairs { + key := BytePtrToString(pair.key) + value := BytePtrToString(pair.value) + result[key] = value + } + return result + } +}) + +var ffiMetaUserMetadataPairs = newFFI(ffiOpts{ + sym: "opendal_metadata_user_metadata_pairs", + rType: &ffi.TypePointer, + aTypes: []*ffi.Type{&ffi.TypePointer}, +}, func(ctx context.Context, ffiCall ffiCall) func(m *opendalMetadataUserMetadata) *opendalMetadataUserMetadataPair { + return func(m *opendalMetadataUserMetadata) *opendalMetadataUserMetadataPair { + var result *opendalMetadataUserMetadataPair + ffiCall( + unsafe.Pointer(&result), + unsafe.Pointer(&m), + ) + return result + } +}) + +var ffiMetaUserMetadataLen = newFFI(ffiOpts{ + sym: "opendal_metadata_user_metadata_len", + rType: &ffi.TypeUint64, + aTypes: []*ffi.Type{&ffi.TypePointer}, +}, func(ctx context.Context, ffiCall ffiCall) func(m *opendalMetadataUserMetadata) uintptr { + return func(m *opendalMetadataUserMetadata) uintptr { + var length uint64 + ffiCall( + unsafe.Pointer(&length), + unsafe.Pointer(&m), + ) + return uintptr(length) + } +}) + +var ffiMetaUserMetadataFree = newFFI(ffiOpts{ + sym: "opendal_metadata_user_metadata_free", + rType: &ffi.TypeVoid, + aTypes: []*ffi.Type{&ffi.TypePointer}, +}, func(ctx context.Context, ffiCall ffiCall) func(m *opendalMetadataUserMetadata) { + return func(m *opendalMetadataUserMetadata) { + ffiCall( + nil, + unsafe.Pointer(&m), + ) + } +}) + +func newFFIMetadataString(sym string) *FFI[func(m *opendalMetadata) *string] { + _ = ffiStringFree + return newFFI(ffiOpts{ + sym: contextKey(sym), + rType: &ffi.TypePointer, + aTypes: []*ffi.Type{&ffi.TypePointer}, + }, func(ctx context.Context, ffiCall ffiCall) func(m *opendalMetadata) *string { + return func(m *opendalMetadata) *string { + var bytePtr *byte + ffiCall( + unsafe.Pointer(&bytePtr), + unsafe.Pointer(&m), + ) + if bytePtr == nil { + return nil + } + value := copyCStringAndFree(bytePtr, ffiStringFree.symbol(ctx)) + return &value + } + }) +} + var ffiMetadataFree = newFFI(ffiOpts{ sym: "opendal_metadata_free", rType: &ffi.TypeVoid, diff --git a/bindings/go/string_ownership_test.go b/bindings/go/string_ownership_test.go index 368efe037fdb..400ece17c305 100644 --- a/bindings/go/string_ownership_test.go +++ b/bindings/go/string_ownership_test.go @@ -171,10 +171,43 @@ func TestNewEntryCopiesAndFreesOwnedStrings(t *testing.T) { } metaFreed++ }) + ctx = context.WithValue(ctx, ffiMetaCacheControl.opts.sym, func(m *opendalMetadata) *string { + assertMetadataPointer(t, metaInner, m) + return nil + }) + ctx = context.WithValue(ctx, ffiMetaContentDisposition.opts.sym, func(m *opendalMetadata) *string { + assertMetadataPointer(t, metaInner, m) + return nil + }) + ctx = context.WithValue(ctx, ffiMetaContentEncoding.opts.sym, func(m *opendalMetadata) *string { + assertMetadataPointer(t, metaInner, m) + return nil + }) ctx = context.WithValue(ctx, ffiMetaContentLength.opts.sym, func(m *opendalMetadata) uint64 { assertMetadataPointer(t, metaInner, m) return 4096 }) + ctx = context.WithValue(ctx, ffiMetaContentMD5.opts.sym, func(m *opendalMetadata) *string { + assertMetadataPointer(t, metaInner, m) + return nil + }) + contentType := "text/plain" + ctx = context.WithValue(ctx, ffiMetaContentType.opts.sym, func(m *opendalMetadata) *string { + assertMetadataPointer(t, metaInner, m) + return &contentType + }) + ctx = context.WithValue(ctx, ffiMetaEtag.opts.sym, func(m *opendalMetadata) *string { + assertMetadataPointer(t, metaInner, m) + return nil + }) + ctx = context.WithValue(ctx, ffiMetaIsCurrent.opts.sym, func(m *opendalMetadata) uint8 { + assertMetadataPointer(t, metaInner, m) + return 2 + }) + ctx = context.WithValue(ctx, ffiMetaIsDeleted.opts.sym, func(m *opendalMetadata) bool { + assertMetadataPointer(t, metaInner, m) + return false + }) ctx = context.WithValue(ctx, ffiMetaIsFile.opts.sym, func(m *opendalMetadata) bool { assertMetadataPointer(t, metaInner, m) return true @@ -187,6 +220,18 @@ func TestNewEntryCopiesAndFreesOwnedStrings(t *testing.T) { assertMetadataPointer(t, metaInner, m) return 1700000000000 }) + ctx = context.WithValue(ctx, ffiMetaMode.opts.sym, func(m *opendalMetadata) EntryMode { + assertMetadataPointer(t, metaInner, m) + return EntryModeFile + }) + ctx = context.WithValue(ctx, ffiMetaVersion.opts.sym, func(m *opendalMetadata) *string { + assertMetadataPointer(t, metaInner, m) + return nil + }) + ctx = context.WithValue(ctx, ffiMetaUserMetadata.opts.sym, func(m *opendalMetadata) map[string]string { + assertMetadataPointer(t, metaInner, m) + return nil + }) entry := newEntry(ctx, entryInner) if entry.Name() != "file.txt" { @@ -217,11 +262,272 @@ func TestNewEntryCopiesAndFreesOwnedStrings(t *testing.T) { if meta.LastModified().UnixMilli() != 1700000000000 { t.Fatalf("Metadata().LastModified().UnixMilli() = %d, want 1700000000000", meta.LastModified().UnixMilli()) } + if meta.Mode() != EntryModeFile { + t.Fatalf("Metadata().Mode() = %d, want %d", meta.Mode(), EntryModeFile) + } + if contentType, ok := meta.ContentType(); !ok || contentType != "text/plain" { + t.Fatalf("Metadata().ContentType() = %q, %v, want text/plain, true", contentType, ok) + } + if _, ok := meta.CacheControl(); ok { + t.Fatal("Metadata().CacheControl() ok = true, want false") + } if metaFreed != 1 { t.Fatalf("newEntry() freed metadata %d times, want 1", metaFreed) } } +func TestNewMetadataCopiesAllFieldsAndFreesOwnedValues(t *testing.T) { + var freed []*byte + freeCString := func(ptr *byte) { + freed = append(freed, ptr) + } + + cacheControlPtr := mustBytePtrFromString(t, "max-age=60") + contentDispositionPtr := mustBytePtrFromString(t, "attachment") + contentEncodingPtr := mustBytePtrFromString(t, "gzip") + contentMD5Ptr := mustBytePtrFromString(t, "1B2M2Y8AsgTpgAmY7PhCfg==") + contentTypePtr := mustBytePtrFromString(t, "application/json") + etagPtr := mustBytePtrFromString(t, `"etag"`) + versionPtr := mustBytePtrFromString(t, "v42") + + metadataInner := &opendalMetadata{} + userMetadataInner := &opendalMetadataUserMetadata{} + pairs := []opendalMetadataUserMetadataPair{ + {key: mustBytePtrFromString(t, "foo"), value: mustBytePtrFromString(t, "bar")}, + {key: mustBytePtrFromString(t, "empty"), value: mustBytePtrFromString(t, "")}, + } + metadataFreed := 0 + userMetadataFreed := 0 + + ctx := context.Background() + ctx = context.WithValue(ctx, ffiStringFree.opts.sym, freeCString) + ctx = context.WithValue(ctx, ffiMetadataFree.opts.sym, func(m *opendalMetadata) { + assertMetadataPointer(t, metadataInner, m) + metadataFreed++ + }) + ctx = context.WithValue(ctx, ffiMetaCacheControl.opts.sym, ffiMetaCacheControl.withFunc(ctx, metadataStringFunc(t, metadataInner, cacheControlPtr))) + ctx = context.WithValue(ctx, ffiMetaContentDisposition.opts.sym, ffiMetaContentDisposition.withFunc(ctx, metadataStringFunc(t, metadataInner, contentDispositionPtr))) + ctx = context.WithValue(ctx, ffiMetaContentEncoding.opts.sym, ffiMetaContentEncoding.withFunc(ctx, metadataStringFunc(t, metadataInner, contentEncodingPtr))) + ctx = context.WithValue(ctx, ffiMetaContentLength.opts.sym, func(m *opendalMetadata) uint64 { + assertMetadataPointer(t, metadataInner, m) + return 4096 + }) + ctx = context.WithValue(ctx, ffiMetaContentMD5.opts.sym, ffiMetaContentMD5.withFunc(ctx, metadataStringFunc(t, metadataInner, contentMD5Ptr))) + ctx = context.WithValue(ctx, ffiMetaContentType.opts.sym, ffiMetaContentType.withFunc(ctx, metadataStringFunc(t, metadataInner, contentTypePtr))) + ctx = context.WithValue(ctx, ffiMetaEtag.opts.sym, ffiMetaEtag.withFunc(ctx, metadataStringFunc(t, metadataInner, etagPtr))) + ctx = context.WithValue(ctx, ffiMetaIsCurrent.opts.sym, func(m *opendalMetadata) uint8 { + assertMetadataPointer(t, metadataInner, m) + return 1 + }) + ctx = context.WithValue(ctx, ffiMetaIsDeleted.opts.sym, func(m *opendalMetadata) bool { + assertMetadataPointer(t, metadataInner, m) + return true + }) + ctx = context.WithValue(ctx, ffiMetaIsFile.opts.sym, func(m *opendalMetadata) bool { + assertMetadataPointer(t, metadataInner, m) + return false + }) + ctx = context.WithValue(ctx, ffiMetaIsDir.opts.sym, func(m *opendalMetadata) bool { + assertMetadataPointer(t, metadataInner, m) + return true + }) + ctx = context.WithValue(ctx, ffiMetaLastModified.opts.sym, func(m *opendalMetadata) int64 { + assertMetadataPointer(t, metadataInner, m) + return 1700000000000 + }) + ctx = context.WithValue(ctx, ffiMetaMode.opts.sym, func(m *opendalMetadata) EntryMode { + assertMetadataPointer(t, metadataInner, m) + return EntryModeDir + }) + ctx = context.WithValue(ctx, ffiMetaVersion.opts.sym, ffiMetaVersion.withFunc(ctx, metadataStringFunc(t, metadataInner, versionPtr))) + ctx = context.WithValue(ctx, ffiMetaUserMetadataPairs.opts.sym, ffiMetaUserMetadataPairs.withFunc(ctx, func(rValue unsafe.Pointer, aValues ...unsafe.Pointer) { + assertUserMetadataPointerFromArgs(t, userMetadataInner, aValues...) + *(**opendalMetadataUserMetadataPair)(rValue) = &pairs[0] + })) + ctx = context.WithValue(ctx, ffiMetaUserMetadataLen.opts.sym, ffiMetaUserMetadataLen.withFunc(ctx, func(rValue unsafe.Pointer, aValues ...unsafe.Pointer) { + assertUserMetadataPointerFromArgs(t, userMetadataInner, aValues...) + *(*uint64)(rValue) = uint64(len(pairs)) + })) + ctx = context.WithValue(ctx, ffiMetaUserMetadataFree.opts.sym, func(m *opendalMetadataUserMetadata) { + if m != userMetadataInner { + t.Fatalf("user metadata freed unexpected pointer: %p", m) + } + userMetadataFreed++ + }) + ctx = context.WithValue(ctx, ffiMetaUserMetadata.opts.sym, ffiMetaUserMetadata.withFunc(ctx, func(rValue unsafe.Pointer, aValues ...unsafe.Pointer) { + assertMetadataPointerFromArgs(t, metadataInner, aValues...) + *(**opendalMetadataUserMetadata)(rValue) = userMetadataInner + })) + + meta := newMetadata(ctx, metadataInner) + assertOptionalString(t, "CacheControl", meta.CacheControl, "max-age=60") + assertOptionalString(t, "ContentDisposition", meta.ContentDisposition, "attachment") + assertOptionalString(t, "ContentEncoding", meta.ContentEncoding, "gzip") + assertOptionalString(t, "ContentMD5", meta.ContentMD5, "1B2M2Y8AsgTpgAmY7PhCfg==") + assertOptionalString(t, "ContentType", meta.ContentType, "application/json") + assertOptionalString(t, "ETag", meta.ETag, `"etag"`) + assertOptionalString(t, "Version", meta.Version, "v42") + if meta.ContentLength() != 4096 { + t.Fatalf("ContentLength() = %d, want 4096", meta.ContentLength()) + } + current, ok := meta.IsCurrent() + if !ok || !current { + t.Fatalf("IsCurrent() = %v, %v, want true, true", current, ok) + } + if !meta.IsDeleted() { + t.Fatal("IsDeleted() = false, want true") + } + if meta.IsFile() { + t.Fatal("IsFile() = true, want false") + } + if !meta.IsDir() { + t.Fatal("IsDir() = false, want true") + } + if meta.LastModified().UnixMilli() != 1700000000000 { + t.Fatalf("LastModified().UnixMilli() = %d, want 1700000000000", meta.LastModified().UnixMilli()) + } + if meta.Mode() != EntryModeDir { + t.Fatalf("Mode() = %d, want %d", meta.Mode(), EntryModeDir) + } + assertStringMap(t, meta.UserMetadata(), map[string]string{"foo": "bar", "empty": ""}) + + userMetadata := meta.UserMetadata() + userMetadata["foo"] = "changed" + assertStringMap(t, meta.UserMetadata(), map[string]string{"foo": "bar", "empty": ""}) + + if metadataFreed != 1 { + t.Fatalf("metadata freed %d times, want 1", metadataFreed) + } + if userMetadataFreed != 1 { + t.Fatalf("user metadata freed %d times, want 1", userMetadataFreed) + } + assertFreedPointers(t, freed, cacheControlPtr, contentDispositionPtr, contentEncodingPtr, contentMD5Ptr, contentTypePtr, etagPtr, versionPtr) +} + +func TestNewMetadataOptionalValuesCanBeAbsent(t *testing.T) { + metadataInner := &opendalMetadata{} + metadataFreed := 0 + + ctx := context.Background() + ctx = context.WithValue(ctx, ffiMetadataFree.opts.sym, func(m *opendalMetadata) { + assertMetadataPointer(t, metadataInner, m) + metadataFreed++ + }) + ctx = context.WithValue(ctx, ffiMetaCacheControl.opts.sym, func(m *opendalMetadata) *string { + assertMetadataPointer(t, metadataInner, m) + return nil + }) + ctx = context.WithValue(ctx, ffiMetaContentDisposition.opts.sym, func(m *opendalMetadata) *string { + assertMetadataPointer(t, metadataInner, m) + return nil + }) + ctx = context.WithValue(ctx, ffiMetaContentEncoding.opts.sym, func(m *opendalMetadata) *string { + assertMetadataPointer(t, metadataInner, m) + return nil + }) + ctx = context.WithValue(ctx, ffiMetaContentLength.opts.sym, func(m *opendalMetadata) uint64 { + assertMetadataPointer(t, metadataInner, m) + return 0 + }) + ctx = context.WithValue(ctx, ffiMetaContentMD5.opts.sym, func(m *opendalMetadata) *string { + assertMetadataPointer(t, metadataInner, m) + return nil + }) + ctx = context.WithValue(ctx, ffiMetaContentType.opts.sym, func(m *opendalMetadata) *string { + assertMetadataPointer(t, metadataInner, m) + return nil + }) + ctx = context.WithValue(ctx, ffiMetaEtag.opts.sym, func(m *opendalMetadata) *string { + assertMetadataPointer(t, metadataInner, m) + return nil + }) + ctx = context.WithValue(ctx, ffiMetaIsCurrent.opts.sym, func(m *opendalMetadata) uint8 { + assertMetadataPointer(t, metadataInner, m) + return 2 + }) + ctx = context.WithValue(ctx, ffiMetaIsDeleted.opts.sym, func(m *opendalMetadata) bool { + assertMetadataPointer(t, metadataInner, m) + return false + }) + ctx = context.WithValue(ctx, ffiMetaIsFile.opts.sym, func(m *opendalMetadata) bool { + assertMetadataPointer(t, metadataInner, m) + return false + }) + ctx = context.WithValue(ctx, ffiMetaIsDir.opts.sym, func(m *opendalMetadata) bool { + assertMetadataPointer(t, metadataInner, m) + return false + }) + ctx = context.WithValue(ctx, ffiMetaLastModified.opts.sym, func(m *opendalMetadata) int64 { + assertMetadataPointer(t, metadataInner, m) + return -1 + }) + ctx = context.WithValue(ctx, ffiMetaMode.opts.sym, func(m *opendalMetadata) EntryMode { + assertMetadataPointer(t, metadataInner, m) + return EntryModeUnknown + }) + ctx = context.WithValue(ctx, ffiMetaVersion.opts.sym, func(m *opendalMetadata) *string { + assertMetadataPointer(t, metadataInner, m) + return nil + }) + ctx = context.WithValue(ctx, ffiMetaUserMetadata.opts.sym, func(m *opendalMetadata) map[string]string { + assertMetadataPointer(t, metadataInner, m) + return nil + }) + + meta := newMetadata(ctx, metadataInner) + if _, ok := meta.CacheControl(); ok { + t.Fatal("CacheControl() ok = true, want false") + } + if _, ok := meta.ContentDisposition(); ok { + t.Fatal("ContentDisposition() ok = true, want false") + } + if _, ok := meta.ContentEncoding(); ok { + t.Fatal("ContentEncoding() ok = true, want false") + } + if _, ok := meta.ContentMD5(); ok { + t.Fatal("ContentMD5() ok = true, want false") + } + if _, ok := meta.ContentType(); ok { + t.Fatal("ContentType() ok = true, want false") + } + if _, ok := meta.ETag(); ok { + t.Fatal("ETag() ok = true, want false") + } + if _, ok := meta.Version(); ok { + t.Fatal("Version() ok = true, want false") + } + if current, ok := meta.IsCurrent(); ok || current { + t.Fatalf("IsCurrent() = %v, %v, want false, false", current, ok) + } + if !meta.LastModified().IsZero() { + t.Fatalf("LastModified() = %v, want zero", meta.LastModified()) + } + if meta.UserMetadata() != nil { + t.Fatalf("UserMetadata() = %v, want nil", meta.UserMetadata()) + } + if metadataFreed != 1 { + t.Fatalf("metadata freed %d times, want 1", metadataFreed) + } +} + +func TestBoolPtrFromOptionalByte(t *testing.T) { + if got := boolPtrFromOptionalByte(2); got != nil { + t.Fatalf("boolPtrFromOptionalByte(2) = %v, want nil", *got) + } + for _, tc := range []struct { + value uint8 + want bool + }{ + {value: 0, want: false}, + {value: 1, want: true}, + } { + got := boolPtrFromOptionalByte(tc.value) + if got == nil || *got != tc.want { + t.Fatalf("boolPtrFromOptionalByte(%d) = %v, want %v", tc.value, got, tc.want) + } + } +} + func mustBytePtrFromString(t *testing.T, value string) *byte { t.Helper() @@ -232,6 +538,36 @@ func mustBytePtrFromString(t *testing.T, value string) *byte { return ptr } +func metadataStringFunc(t *testing.T, want *opendalMetadata, ptr *byte) func(rValue unsafe.Pointer, aValues ...unsafe.Pointer) { + t.Helper() + return func(rValue unsafe.Pointer, aValues ...unsafe.Pointer) { + assertMetadataPointerFromArgs(t, want, aValues...) + *(**byte)(rValue) = ptr + } +} + +func assertOptionalString(t *testing.T, name string, fn func() (string, bool), want string) { + t.Helper() + + got, ok := fn() + if !ok || got != want { + t.Fatalf("%s() = %q, %v, want %q, true", name, got, ok, want) + } +} + +func assertStringMap(t *testing.T, got, want map[string]string) { + t.Helper() + + if len(got) != len(want) { + t.Fatalf("map length = %d, want %d: %v", len(got), len(want), got) + } + for key, wantValue := range want { + if gotValue, ok := got[key]; !ok || gotValue != wantValue { + t.Fatalf("map[%q] = %q, %v, want %q, true", key, gotValue, ok, wantValue) + } + } +} + func assertOperatorInfoPointer(t *testing.T, want *opendalOperatorInfo, aValues ...unsafe.Pointer) { t.Helper() @@ -264,6 +600,28 @@ func assertMetadataPointer(t *testing.T, want *opendalMetadata, got *opendalMeta } } +func assertMetadataPointerFromArgs(t *testing.T, want *opendalMetadata, aValues ...unsafe.Pointer) { + t.Helper() + + if len(aValues) != 1 { + t.Fatalf("metadata getter received %d arguments, want 1", len(aValues)) + } + got := *(**opendalMetadata)(aValues[0]) + assertMetadataPointer(t, want, got) +} + +func assertUserMetadataPointerFromArgs(t *testing.T, want *opendalMetadataUserMetadata, aValues ...unsafe.Pointer) { + t.Helper() + + if len(aValues) != 1 { + t.Fatalf("user metadata getter received %d arguments, want 1", len(aValues)) + } + got := *(**opendalMetadataUserMetadata)(aValues[0]) + if got != want { + t.Fatalf("user metadata getter received %p, want %p", got, want) + } +} + func assertFreedPointers(t *testing.T, got []*byte, want ...*byte) { t.Helper() diff --git a/bindings/go/types.go b/bindings/go/types.go index 3d0239f1964b..a19e3f9935a3 100644 --- a/bindings/go/types.go +++ b/bindings/go/types.go @@ -286,6 +286,13 @@ type opendalHttpHeaderPair struct { type opendalMetadata struct{} +type opendalMetadataUserMetadata struct{} + +type opendalMetadataUserMetadataPair struct { + key *byte + value *byte +} + type opendalBytes struct { data *byte len uintptr From 4ff524375be78e724b43d928f653ace0ed1b6b83 Mon Sep 17 00:00:00 2001 From: dentinyhao Date: Sat, 30 May 2026 05:20:05 -0700 Subject: [PATCH 2/2] add metadata-focused stat behavior cases --- bindings/go/tests/behavior_tests/stat_test.go | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/bindings/go/tests/behavior_tests/stat_test.go b/bindings/go/tests/behavior_tests/stat_test.go index f59b71be129c..52d12e8c8848 100644 --- a/bindings/go/tests/behavior_tests/stat_test.go +++ b/bindings/go/tests/behavior_tests/stat_test.go @@ -22,6 +22,7 @@ package opendal_test import ( "fmt" "strings" + "time" "github.com/apache/opendal/bindings/go" "github.com/google/uuid" @@ -40,6 +41,17 @@ func testsStat(cap *opendal.Capability) []behaviorTest { testStatNotCleanedPath, testStatNotExist, testStatRoot, + testStatFileMetadata, + testStatDirMetadata, + } +} + +func assertOptionalMetaString(assert *require.Assertions, name string, accessor func() (string, bool)) { + value, ok := accessor() + if ok { + assert.NotEmpty(value, "%s reported as present must have a non-empty value", name) + } else { + assert.Empty(value, "%s reported as absent must return an empty value", name) } } @@ -141,3 +153,56 @@ func testStatRoot(assert *require.Assertions, op *opendal.Operator, fixture *fix assert.True(meta.IsDir()) } + +func testStatFileMetadata(assert *require.Assertions, op *opendal.Operator, fixture *fixture) { + path, content, size := fixture.NewFile() + + before := time.Now().Add(-time.Hour) + assert.Nil(op.Write(path, content), "write must succeed") + + meta, err := op.Stat(path) + assert.Nil(err, "stat must succeed") + + assert.True(meta.IsFile(), "written object must be a file") + assert.False(meta.IsDir(), "written object must not be a dir") + assert.Equal(opendal.EntryModeFile, meta.Mode(), "mode must report file") + assert.False(meta.IsDeleted(), "freshly written object must not be deleted") + assert.Equal(uint64(size), meta.ContentLength(), "content length must match written size") + + if lm := meta.LastModified(); !lm.IsZero() { + assert.False(lm.Before(before), "last_modified must be recent, got %v", lm) + assert.False(lm.After(time.Now().Add(time.Minute)), "last_modified must not be in the future, got %v", lm) + } + + assertOptionalMetaString(assert, "cache control", meta.CacheControl) + assertOptionalMetaString(assert, "content disposition", meta.ContentDisposition) + assertOptionalMetaString(assert, "content encoding", meta.ContentEncoding) + assertOptionalMetaString(assert, "content type", meta.ContentType) + assertOptionalMetaString(assert, "content md5", meta.ContentMD5) + assertOptionalMetaString(assert, "etag", meta.ETag) + assertOptionalMetaString(assert, "version", meta.Version) + + if isCurrent, ok := meta.IsCurrent(); ok { + assert.True(isCurrent, "a live object must be reported as the current version") + } + if um := meta.UserMetadata(); um != nil { + assert.Equal(um, meta.UserMetadata(), "user metadata accessor must return equal copies") + } +} + +func testStatDirMetadata(assert *require.Assertions, op *opendal.Operator, fixture *fixture) { + if !op.Info().GetFullCapability().CreateDir() { + return + } + + path := fixture.NewDirPath() + assert.Nil(op.CreateDir(path), "create dir must succeed") + + meta, err := op.Stat(path) + assert.Nil(err, "stat must succeed") + + assert.True(meta.IsDir(), "created object must be a dir") + assert.False(meta.IsFile(), "created object must not be a file") + assert.Equal(opendal.EntryModeDir, meta.Mode(), "mode must report dir") + assert.False(meta.IsDeleted(), "freshly created dir must not be deleted") +}