mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-10 07:06:53 +00:00
fe5690ca70
Co-authored-by: houseme <housemecn@gmail.com>
329 lines
14 KiB
Rust
329 lines
14 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed 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.
|
|
|
|
//! Attribute helpers and the do_stat dispatcher behind STAT, LSTAT, and
|
|
//! FSTAT. The free functions are pure conversions; the do_stat method
|
|
//! sits on SftpDriver and runs the bucket/object branching.
|
|
|
|
use super::constants::posix::{POSIX_DIR_MODE, POSIX_FILE_MODE};
|
|
use super::driver::SftpDriver;
|
|
use super::errors::{SftpError, is_not_found_error, s3_error_to_sftp};
|
|
use super::paths::parse_s3_path;
|
|
use crate::common::client::s3::StorageBackend;
|
|
use crate::common::gateway::S3Action;
|
|
use russh_sftp::protocol::{File, FileAttributes, StatusCode};
|
|
use s3s::dto::ListObjectsV2Input;
|
|
use std::collections::HashMap;
|
|
|
|
const SFTP_META_MTIME: &str = "mtime";
|
|
const SFTP_META_MODE: &str = "mode";
|
|
const SFTP_META_UID: &str = "uid";
|
|
const SFTP_META_GID: &str = "gid";
|
|
|
|
/// Build the SFTP FileAttributes struct returned by STAT, LSTAT, and
|
|
/// FSTAT. Callers are responsible for any clamping or conversion of the
|
|
/// mtime field. See timestamp_to_mtime for the conversion used when the
|
|
/// source is an s3s Timestamp.
|
|
pub(super) fn s3_attrs_to_sftp(size: u64, mtime: Option<u32>, is_dir: bool) -> FileAttributes {
|
|
let permissions = if is_dir { POSIX_DIR_MODE } else { POSIX_FILE_MODE };
|
|
FileAttributes {
|
|
size: Some(if is_dir { 0 } else { size }),
|
|
uid: Some(0),
|
|
gid: Some(0),
|
|
user: None,
|
|
group: None,
|
|
permissions: Some(permissions),
|
|
atime: mtime,
|
|
mtime,
|
|
}
|
|
}
|
|
|
|
fn parse_u32_metadata(metadata: &HashMap<String, String>, key: &str) -> Option<u32> {
|
|
metadata.get(key).and_then(|value| value.parse::<u32>().ok())
|
|
}
|
|
|
|
pub(super) fn sftp_attrs_to_user_metadata(attrs: &FileAttributes) -> Option<HashMap<String, String>> {
|
|
let mut metadata = HashMap::new();
|
|
if let Some(mtime) = attrs.mtime {
|
|
metadata.insert(SFTP_META_MTIME.to_string(), mtime.to_string());
|
|
}
|
|
if let Some(mode) = attrs.permissions {
|
|
metadata.insert(SFTP_META_MODE.to_string(), mode.to_string());
|
|
}
|
|
if let Some(uid) = attrs.uid {
|
|
metadata.insert(SFTP_META_UID.to_string(), uid.to_string());
|
|
}
|
|
if let Some(gid) = attrs.gid {
|
|
metadata.insert(SFTP_META_GID.to_string(), gid.to_string());
|
|
}
|
|
|
|
if metadata.is_empty() { None } else { Some(metadata) }
|
|
}
|
|
|
|
pub(super) fn apply_user_metadata_to_sftp_attrs(attrs: &mut FileAttributes, metadata: &HashMap<String, String>) {
|
|
if let Some(mtime) = parse_u32_metadata(metadata, SFTP_META_MTIME) {
|
|
attrs.mtime = Some(mtime);
|
|
attrs.atime = Some(mtime);
|
|
}
|
|
if let Some(mode) = parse_u32_metadata(metadata, SFTP_META_MODE) {
|
|
attrs.permissions = Some(mode);
|
|
}
|
|
if let Some(uid) = parse_u32_metadata(metadata, SFTP_META_UID) {
|
|
attrs.uid = Some(uid);
|
|
}
|
|
if let Some(gid) = parse_u32_metadata(metadata, SFTP_META_GID) {
|
|
attrs.gid = Some(gid);
|
|
}
|
|
}
|
|
|
|
/// Convert an s3s Timestamp into the u32 seconds field SFTPv3 expects.
|
|
/// Pre-1970 values clamp to 0. Post-2106 values clamp to u32::MAX. The
|
|
/// clamps prevent the i64-to-u32 cast from wrapping.
|
|
pub(super) fn timestamp_to_mtime(ts: Option<s3s::dto::Timestamp>) -> Option<u32> {
|
|
ts.map(|t| {
|
|
let odt: time::OffsetDateTime = t.into();
|
|
let secs = odt.unix_timestamp().clamp(0, u32::MAX as i64);
|
|
secs as u32
|
|
})
|
|
}
|
|
|
|
/// Build the ls -l style longname string for a directory entry. Delegates
|
|
/// to File::new in russh_sftp, which formats the line from the attributes
|
|
/// (type prefix "d" or "-", permission triple, size, timestamp). The
|
|
/// filename is sanitised before composition so a key containing CR or LF
|
|
/// cannot inject a forged second entry in clients that split longname
|
|
/// output on newline.
|
|
pub(super) fn generate_longname(filename: &str, attrs: &FileAttributes) -> String {
|
|
let safe = super::paths::sanitise_control_bytes(filename);
|
|
File::new(safe.as_ref(), attrs.clone()).longname
|
|
}
|
|
|
|
impl<S: StorageBackend + Send + Sync + 'static> SftpDriver<S> {
|
|
/// Resolve the attributes for raw_path. STAT and LSTAT both call do_stat
|
|
/// because the SFTP server has no symlink concept (S3 has no symlinks).
|
|
/// Root yields default directory attrs without a network call.
|
|
///
|
|
/// Bucket paths run authorize_operation(HeadBucket) followed by a
|
|
/// HeadBucket call. Success yields default directory attributes
|
|
/// (HeadBucket exposes neither size nor mtime).
|
|
///
|
|
/// Object paths run authorize_operation(HeadObject) followed by a
|
|
/// HeadObject call. Success yields file attributes built from
|
|
/// content_length (clamped non-negative) and last_modified (clamped to
|
|
/// the u32 range).
|
|
pub(super) async fn do_stat(&self, raw_path: &str) -> Result<FileAttributes, SftpError> {
|
|
let (bucket, key) = parse_s3_path(raw_path)?;
|
|
|
|
if bucket.is_empty() {
|
|
// Root. Every authenticated principal sees root as a directory.
|
|
return Ok(s3_attrs_to_sftp(0, None, true));
|
|
}
|
|
|
|
match key {
|
|
// Bucket-level path: input resolved to a bucket with no object
|
|
// component. HeadBucket returns 200 on existence or a backend
|
|
// error mapped by s3_error_to_sftp. Default directory attrs
|
|
// on success. Size and mtime are not returned by HeadBucket.
|
|
None => {
|
|
self.authorize(&S3Action::HeadBucket, &bucket, None).await?;
|
|
self.run_backend("head_bucket", self.storage.head_bucket(&bucket, self.access_key(), self.secret_key()))
|
|
.await?;
|
|
Ok(s3_attrs_to_sftp(0, None, true))
|
|
}
|
|
// Object path: try HeadObject first (the path may be a file).
|
|
// If HeadObject returns not-found, fall back to a directory
|
|
// check: list with prefix "{key}/" and max_keys=1. If any
|
|
// content or sub-prefix exists, this path is a directory and
|
|
// gets default directory attrs. S3 has no first-class
|
|
// directories, so both explicit markers (__XLDIR__) and
|
|
// implicit prefixes (objects exist under the prefix) must be
|
|
// detected. Without this fallback, sftp clients that STAT
|
|
// before OPENDIR (OpenSSH, FileZilla) fail to list
|
|
// sub-directories.
|
|
Some(object_key) => {
|
|
self.authorize(&S3Action::HeadObject, &bucket, Some(&object_key)).await?;
|
|
match self
|
|
.run_backend_with_err(
|
|
"head_object",
|
|
self.storage
|
|
.head_object(&bucket, &object_key, self.access_key(), self.secret_key()),
|
|
)
|
|
.await?
|
|
{
|
|
Ok(out) => {
|
|
let size = out.content_length.unwrap_or(0).max(0) as u64;
|
|
let mtime = timestamp_to_mtime(out.last_modified);
|
|
let mut attrs = s3_attrs_to_sftp(size, mtime, false);
|
|
if let Some(metadata) = out.metadata {
|
|
apply_user_metadata_to_sftp_attrs(&mut attrs, &metadata);
|
|
}
|
|
Ok(attrs)
|
|
}
|
|
Err(e) if is_not_found_error(&e) => {
|
|
// No object at this key. Check whether it is a
|
|
// directory by listing with the key as a prefix.
|
|
let prefix = format!("{object_key}/");
|
|
self.authorize(&S3Action::ListBucket, &bucket, Some(prefix.as_str())).await?;
|
|
let input = ListObjectsV2Input::builder()
|
|
.bucket(bucket.clone())
|
|
.prefix(Some(prefix))
|
|
.delimiter(Some("/".to_string()))
|
|
.max_keys(Some(1))
|
|
.build()
|
|
.map_err(|e| s3_error_to_sftp("build_list_objects", e))?;
|
|
let out = self
|
|
.run_backend(
|
|
"list_objects_v2",
|
|
self.storage.list_objects_v2(input, self.access_key(), self.secret_key()),
|
|
)
|
|
.await?;
|
|
|
|
let has_contents = out.contents.map(|c| !c.is_empty()).unwrap_or(false);
|
|
let has_prefixes = out.common_prefixes.map(|c| !c.is_empty()).unwrap_or(false);
|
|
if has_contents || has_prefixes {
|
|
Ok(s3_attrs_to_sftp(0, None, true))
|
|
} else {
|
|
tracing::debug!(
|
|
bucket = %bucket,
|
|
key = %object_key,
|
|
"STAT fallback: HeadObject not-found and list returned no contents or prefixes. Returning NoSuchFile",
|
|
);
|
|
Err(SftpError::code(StatusCode::NoSuchFile))
|
|
}
|
|
}
|
|
Err(e) => Err(s3_error_to_sftp("head_object", e)),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::sftp::constants::posix::POSIX_TYPE_MASK;
|
|
|
|
#[test]
|
|
fn s3_attrs_to_sftp_directory_has_dir_type_bit() {
|
|
use crate::constants::paths::{DIR_MODE, DIR_PERMISSIONS};
|
|
let attrs = s3_attrs_to_sftp(0, None, true);
|
|
let mode = attrs.permissions.unwrap();
|
|
assert_eq!(mode & POSIX_TYPE_MASK, DIR_MODE, "S_IFDIR bit must be set");
|
|
assert_eq!(mode & 0o777, DIR_PERMISSIONS);
|
|
assert!(attrs.is_dir());
|
|
}
|
|
|
|
#[test]
|
|
fn s3_attrs_to_sftp_file_has_regular_type_bit() {
|
|
use crate::constants::paths::{FILE_MODE, FILE_PERMISSIONS};
|
|
let attrs = s3_attrs_to_sftp(42, Some(1_700_000_000), false);
|
|
let mode = attrs.permissions.unwrap();
|
|
assert_eq!(mode & POSIX_TYPE_MASK, FILE_MODE, "S_IFREG bit must be set");
|
|
assert_eq!(mode & 0o777, FILE_PERMISSIONS);
|
|
assert_eq!(attrs.size, Some(42));
|
|
assert_eq!(attrs.mtime, Some(1_700_000_000));
|
|
assert!(attrs.is_regular());
|
|
}
|
|
|
|
#[test]
|
|
fn sftp_attrs_to_user_metadata_maps_only_present_open_attrs() {
|
|
let attrs = FileAttributes {
|
|
size: None,
|
|
uid: Some(1000),
|
|
gid: Some(1001),
|
|
user: None,
|
|
group: None,
|
|
permissions: Some(0o100640),
|
|
atime: None,
|
|
mtime: Some(1_777_992_333),
|
|
};
|
|
|
|
let metadata = sftp_attrs_to_user_metadata(&attrs).expect("present attrs produce metadata");
|
|
assert_eq!(metadata.get("mtime").map(String::as_str), Some("1777992333"));
|
|
assert_eq!(metadata.get("mode").map(String::as_str), Some("33184"));
|
|
assert_eq!(metadata.get("uid").map(String::as_str), Some("1000"));
|
|
assert_eq!(metadata.get("gid").map(String::as_str), Some("1001"));
|
|
assert!(!metadata.contains_key("size"), "object size is data-path state, not OPEN metadata");
|
|
}
|
|
|
|
#[test]
|
|
fn apply_user_metadata_to_sftp_attrs_overrides_defaults() {
|
|
let mut attrs = s3_attrs_to_sftp(42, Some(10), false);
|
|
let metadata = HashMap::from([
|
|
("mtime".to_string(), "1777992348".to_string()),
|
|
("mode".to_string(), "33152".to_string()),
|
|
("uid".to_string(), "501".to_string()),
|
|
("gid".to_string(), "20".to_string()),
|
|
]);
|
|
|
|
apply_user_metadata_to_sftp_attrs(&mut attrs, &metadata);
|
|
assert_eq!(attrs.mtime, Some(1_777_992_348));
|
|
assert_eq!(attrs.atime, Some(1_777_992_348));
|
|
assert_eq!(attrs.permissions, Some(0o100600));
|
|
assert_eq!(attrs.uid, Some(501));
|
|
assert_eq!(attrs.gid, Some(20));
|
|
}
|
|
|
|
#[test]
|
|
fn generate_longname_prefixes_d_for_directory() {
|
|
let attrs = s3_attrs_to_sftp(0, Some(0), true);
|
|
let line = generate_longname("mybucket", &attrs);
|
|
assert!(line.starts_with('d'), "dir longname must start with d, got {line}");
|
|
}
|
|
|
|
#[test]
|
|
fn generate_longname_prefixes_dash_for_file() {
|
|
let attrs = s3_attrs_to_sftp(100, Some(0), false);
|
|
let line = generate_longname("file.txt", &attrs);
|
|
assert!(line.starts_with('-'), "file longname must start with -, got {line}");
|
|
}
|
|
|
|
#[test]
|
|
fn generate_longname_strips_lf_in_filename() {
|
|
let attrs = s3_attrs_to_sftp(100, Some(0), false);
|
|
let line = generate_longname("evil\nfile.txt", &attrs);
|
|
assert!(!line.contains('\n'), "longname must not contain raw LF, got {line:?}");
|
|
assert!(
|
|
line.contains("evil?file.txt"),
|
|
"longname must include the sanitised filename, got {line:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn timestamp_conversion_handles_none() {
|
|
assert_eq!(timestamp_to_mtime(None), None);
|
|
}
|
|
|
|
#[test]
|
|
fn timestamp_to_mtime_clamps_negative_to_zero() {
|
|
let pre_epoch = s3s::dto::Timestamp::from(time::OffsetDateTime::from_unix_timestamp(-86400).expect("valid timestamp"));
|
|
assert_eq!(timestamp_to_mtime(Some(pre_epoch)), Some(0));
|
|
}
|
|
|
|
#[test]
|
|
fn timestamp_to_mtime_clamps_overflow_to_u32_max() {
|
|
let far_future = s3s::dto::Timestamp::from(
|
|
time::OffsetDateTime::from_unix_timestamp(u32::MAX as i64 + 86400).expect("valid timestamp"),
|
|
);
|
|
assert_eq!(timestamp_to_mtime(Some(far_future)), Some(u32::MAX));
|
|
}
|
|
|
|
#[test]
|
|
fn posix_mode_constants_match_documented_values() {
|
|
assert_eq!(POSIX_DIR_MODE, 0o040755);
|
|
assert_eq!(POSIX_FILE_MODE, 0o100644);
|
|
assert_eq!(POSIX_TYPE_MASK, 0o170000);
|
|
}
|
|
}
|