Skip to main content

hdfs_native/
client.rs

1use std::collections::{HashMap, VecDeque};
2use std::sync::{Arc, OnceLock};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use futures::stream::BoxStream;
6use futures::{StreamExt, stream};
7use tokio::runtime::{Handle, Runtime};
8use url::Url;
9
10use crate::acl::{AclEntry, AclStatus};
11use crate::common::config::{self, Configuration};
12use crate::ec::resolve_ec_policy;
13use crate::error::{HdfsError, Result};
14use crate::file::{FileReader, FileWriter};
15use crate::hdfs::crypto::FileCryptoCodec;
16use crate::hdfs::protocol::NamenodeProtocol;
17use crate::hdfs::proxy::NameServiceProxy;
18use crate::proto::hdfs::hdfs_file_status_proto::FileType;
19#[cfg(feature = "kms")]
20use crate::security::kms::KmsClient;
21use crate::security::user::User;
22
23use crate::glob::{GlobPattern, expand_glob, get_path_components, unescape_component};
24use crate::proto::hdfs::{ContentSummaryProto, FileEncryptionInfoProto, HdfsFileStatusProto};
25
26const TRASH_ROOT_DIR: &str = ".Trash";
27const TRASH_CURRENT_DIR: &str = "Current";
28const TRASH_DIR_PERMISSION: u32 = 0o700;
29
30#[derive(Clone)]
31pub struct WriteOptions {
32    /// Block size. Default is retrieved from the server.
33    pub block_size: Option<u64>,
34    /// Replication factor. Default is retrieved from the server.
35    pub replication: Option<u32>,
36    /// Unix file permission, defaults to 0o644, which is "rw-r--r--" as a Unix permission.
37    /// This is the raw octal value represented in base 10.
38    pub permission: u32,
39    /// Whether to overwrite the file, defaults to false. If true and the
40    /// file does not exist, it will result in an error.
41    pub overwrite: bool,
42    /// Whether to create any missing parent directories, defaults to true. If false
43    /// and the parent directory does not exist, an error will be returned.
44    pub create_parent: bool,
45}
46
47impl Default for WriteOptions {
48    fn default() -> Self {
49        Self {
50            block_size: None,
51            replication: None,
52            permission: 0o644,
53            overwrite: false,
54            create_parent: true,
55        }
56    }
57}
58
59impl AsRef<WriteOptions> for WriteOptions {
60    fn as_ref(&self) -> &WriteOptions {
61        self
62    }
63}
64
65impl WriteOptions {
66    /// Set the block_size for the new file
67    pub fn block_size(mut self, block_size: u64) -> Self {
68        self.block_size = Some(block_size);
69        self
70    }
71
72    /// Set the replication for the new file
73    pub fn replication(mut self, replication: u32) -> Self {
74        self.replication = Some(replication);
75        self
76    }
77
78    /// Set the raw octal permission value for the new file
79    pub fn permission(mut self, permission: u32) -> Self {
80        self.permission = permission;
81        self
82    }
83
84    /// Set whether to overwrite an existing file
85    pub fn overwrite(mut self, overwrite: bool) -> Self {
86        self.overwrite = overwrite;
87        self
88    }
89
90    /// Set whether to create all missing parent directories
91    pub fn create_parent(mut self, create_parent: bool) -> Self {
92        self.create_parent = create_parent;
93        self
94    }
95}
96
97#[derive(Debug, Clone)]
98struct MountLink {
99    viewfs_path: String,
100    hdfs_path: String,
101    protocol: Arc<NamenodeProtocol>,
102}
103
104impl MountLink {
105    fn new(viewfs_path: &str, hdfs_path: &str, protocol: Arc<NamenodeProtocol>) -> Self {
106        // We should never have an empty path, we always want things mounted at root ("/") by default.
107        Self {
108            viewfs_path: viewfs_path.trim_end_matches("/").to_string(),
109            hdfs_path: hdfs_path.trim_end_matches("/").to_string(),
110            protocol,
111        }
112    }
113    /// Convert a viewfs path into a name service path if it matches this link
114    fn resolve(&self, path: &str) -> Option<String> {
115        // Make sure we don't partially match the last component. It either needs to be an exact
116        // match to a viewfs path, or needs to match with a trailing slash
117        if path == self.viewfs_path {
118            Some(self.hdfs_path.clone())
119        } else {
120            path.strip_prefix(&format!("{}/", self.viewfs_path))
121                .map(|relative_path| format!("{}/{}", self.hdfs_path, relative_path))
122        }
123    }
124}
125
126#[derive(Debug)]
127struct MountTable {
128    mounts: Vec<MountLink>,
129    fallback: MountLink,
130    home_dir: String,
131}
132
133impl MountTable {
134    fn resolve(&self, src: &str) -> (&MountLink, String) {
135        let path = if src.starts_with('/') {
136            src.to_string()
137        } else {
138            format!("{}/{}", self.home_dir, src)
139        };
140
141        for link in self.mounts.iter() {
142            if let Some(resolved) = link.resolve(&path) {
143                return (link, resolved);
144            }
145        }
146        (&self.fallback, self.fallback.resolve(&path).unwrap())
147    }
148}
149
150fn build_home_dir(
151    scheme: &str,
152    host: Option<&str>,
153    config: &Configuration,
154    username: &str,
155) -> String {
156    let prefix = match scheme {
157        "hdfs" => config.get("dfs.user.home.dir.prefix"),
158        "viewfs" => {
159            host.and_then(|host| config.get(&format!("fs.viewfs.mounttable.{host}.homedir")))
160        }
161        _ => None,
162    }
163    .unwrap_or("/user");
164
165    let prefix = prefix.trim_end_matches('/');
166    if prefix.is_empty() {
167        format!("/{username}")
168    } else {
169        format!("{prefix}/{username}")
170    }
171}
172
173/// Holds either a [Runtime] or a [Handle] to an existing runtime for IO tasks
174#[derive(Debug)]
175pub enum IORuntime {
176    Runtime(Runtime),
177    Handle(Handle),
178}
179
180impl From<Runtime> for IORuntime {
181    fn from(value: Runtime) -> Self {
182        Self::Runtime(value)
183    }
184}
185
186impl From<Handle> for IORuntime {
187    fn from(value: Handle) -> Self {
188        Self::Handle(value)
189    }
190}
191
192impl IORuntime {
193    fn handle(&self) -> Handle {
194        match self {
195            Self::Runtime(runtime) => runtime.handle().clone(),
196            Self::Handle(handle) => handle.clone(),
197        }
198    }
199}
200
201/// Builds a new [Client] instance. Configs will be loaded with the following precedence:
202///
203/// - If method `ClientBuilder::with_config_dir` is invoked, configs will be loaded from `${config_dir}/{core,hdfs}-site.xml`
204/// - If the `HADOOP_CONF_DIR` environment variable is defined, configs will be loaded from `${HADOOP_CONF_DIR}/{core,hdfs}-site.xml`
205/// - If the `HADOOP_HOME` environment variable is defined, configs will be loaded from `${HADOOP_HOME}/etc/hadoop/{core,hdfs}-site.xml`
206/// - Otherwise no configs are defined
207///
208/// Finally, configs set by `with_config` will override the configs loaded above.
209///
210/// If no URL is defined, the `fs.defaultFS` config must be defined and is used as the URL.
211///
212/// # Examples
213///
214/// Create a new client with given config directory
215///
216/// ```rust,no_run
217/// # use hdfs_native::ClientBuilder;
218/// let client = ClientBuilder::new()
219///     .with_config_dir("/opt/hadoop/etc/hadoop")
220///     .build()
221///     .unwrap();
222/// ```
223///
224/// Create a client that acquires credentials directly from a keytab:
225///
226/// An explicit principal is required. A keytab without a supplied cache uses
227/// a native `MEMORY:` credential cache.
228///
229/// ```rust,no_run
230/// # use hdfs_native::ClientBuilder;
231/// let client = ClientBuilder::new()
232///     .with_url("hdfs://namenode.example.com:9000")
233///     .with_kerberos_principal("client@EXAMPLE.COM")
234///     .with_kerberos_keytab("/run/secrets/client.keytab")
235///     .build()
236///     .unwrap();
237/// ```
238///
239/// Create a new client with the environment variable
240///
241/// ```rust,no_run
242/// # use hdfs_native::ClientBuilder;
243/// unsafe { std::env::set_var("HADOOP_CONF_DIR", "/opt/hadoop/etc/hadoop") };
244/// let client = ClientBuilder::new()
245///     .build()
246///     .unwrap();
247/// ```
248///
249/// Create a new client using the fs.defaultFS config
250///
251/// ```rust
252/// # use hdfs_native::ClientBuilder;
253/// let client = ClientBuilder::new()
254///     .with_config(vec![("fs.defaultFS", "hdfs://127.0.0.1:9000")])
255///     .build()
256///     .unwrap();
257/// ```
258///
259/// Create a new client connecting to a specific URL:
260///
261/// ```rust
262/// # use hdfs_native::ClientBuilder;
263/// let client = ClientBuilder::new()
264///     .with_url("hdfs://127.0.0.1:9000")
265///     .build()
266///     .unwrap();
267/// ```
268///
269/// Create a new client using a dedicated tokio runtime for spawned tasks and IO operations
270///
271/// ```rust
272/// # use hdfs_native::ClientBuilder;
273/// let client = ClientBuilder::new()
274///     .with_url("hdfs://127.0.0.1:9000")
275///     .with_io_runtime(tokio::runtime::Runtime::new().unwrap())
276///     .build()
277///     .unwrap();
278/// ```
279///
280/// Create a client with an explicit Kerberos credential cache:
281///
282/// ```rust,no_run
283/// # use hdfs_native::ClientBuilder;
284/// let client = ClientBuilder::new()
285///     .with_url("hdfs://namenode.example.com:9000")
286///     .with_kerberos_principal("client@EXAMPLE.COM")
287///     .with_kerberos_cache("FILE:/run/krb5/client.ccache")
288///     .build()
289///     .unwrap();
290/// ```
291#[derive(Default)]
292pub struct ClientBuilder {
293    url: Option<String>,
294    config: Option<HashMap<String, String>>,
295    config_dir: Option<String>,
296    runtime: Option<IORuntime>,
297    user: Option<String>,
298    kerberos_principal: Option<String>,
299    kerberos_keytab: Option<String>,
300    kerberos_cache: Option<String>,
301}
302
303impl ClientBuilder {
304    /// Create a new [ClientBuilder]
305    pub fn new() -> Self {
306        Self::default()
307    }
308
309    /// Set the URL to connect to. Can be the address of a single NameNode, or a logical NameService
310    pub fn with_url(mut self, url: impl Into<String>) -> Self {
311        self.url = Some(url.into());
312        self
313    }
314
315    /// Set configs to use for the client. The provided configs will override any found in the config files loaded
316    pub fn with_config(
317        mut self,
318        config: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
319    ) -> Self {
320        self.config = Some(
321            config
322                .into_iter()
323                .map(|(k, v)| (k.into(), v.into()))
324                .collect(),
325        );
326        self
327    }
328
329    /// Set the configration directory path to read from. The provided path will override the one provided by environment variable.
330    pub fn with_config_dir(mut self, config_dir: impl Into<String>) -> Self {
331        self.config_dir = Some(config_dir.into());
332        self
333    }
334
335    /// Use a dedicated tokio runtime for spawned tasks and IO operations. Can either take ownership of a whole [Runtime]
336    /// or take a [Handle] to an externally owned runtime.
337    pub fn with_io_runtime(mut self, runtime: impl Into<IORuntime>) -> Self {
338        self.runtime = Some(runtime.into());
339        self
340    }
341
342    /// Set the effective user for the client. If not set, the client will detect user from environment variables `HADOOP_USER_NAME` or `HADOOP_PROXY_USER`.
343    pub fn with_user(mut self, user: impl Into<String>) -> Self {
344        self.user = Some(user.into());
345        self
346    }
347
348    /// Set the Kerberos principal used by this client.
349    pub fn with_kerberos_principal(mut self, principal: impl Into<String>) -> Self {
350        self.kerberos_principal = Some(principal.into());
351        self
352    }
353
354    /// Set the Kerberos keytab used by this client.
355    pub fn with_kerberos_keytab(mut self, keytab: impl Into<String>) -> Self {
356        self.kerberos_keytab = Some(keytab.into());
357        self
358    }
359
360    /// Set the Kerberos credential cache used by this client.
361    pub fn with_kerberos_cache(mut self, cache: impl Into<String>) -> Self {
362        self.kerberos_cache = Some(cache.into());
363        self
364    }
365
366    /// Create the [Client] instance from the provided settings
367    pub fn build(self) -> Result<Client> {
368        let config = Configuration::new(self.config_dir, self.config)?;
369        let url = if let Some(url) = self.url {
370            Url::parse(&url)?
371        } else {
372            Client::default_fs(&config)?
373        };
374
375        let kerberos_credentials = crate::security::KerberosCredentials::new(
376            self.kerberos_principal,
377            self.kerberos_keytab,
378            self.kerberos_cache,
379        )?
380        .map(crate::security::ClientAuth::new);
381
382        Client::build(&url, config, self.runtime, self.user, kerberos_credentials)
383    }
384}
385
386#[derive(Clone, Debug)]
387enum RuntimeHolder {
388    Custom(Arc<IORuntime>),
389    Default(Arc<OnceLock<Runtime>>),
390}
391
392impl RuntimeHolder {
393    fn new(rt: Option<IORuntime>) -> Self {
394        if let Some(rt) = rt {
395            Self::Custom(Arc::new(rt))
396        } else {
397            Self::Default(Arc::new(OnceLock::new()))
398        }
399    }
400
401    fn get_handle(&self) -> Handle {
402        match self {
403            Self::Custom(rt) => rt.handle().clone(),
404            Self::Default(rt) => match Handle::try_current() {
405                Ok(handle) => handle,
406                Err(_) => rt
407                    .get_or_init(|| Runtime::new().expect("Failed to create tokio runtime"))
408                    .handle()
409                    .clone(),
410            },
411        }
412    }
413}
414
415/// A client to a speicific NameNode, NameService, or Viewfs mount table
416#[derive(Clone, Debug)]
417pub struct Client {
418    mount_table: Arc<MountTable>,
419    config: Arc<Configuration>,
420    // Store the runtime used for spawning all internal tasks. If we are not created
421    // inside a tokio runtime, we will create our own to use.
422    rt_holder: RuntimeHolder,
423    // Built once at client construction from `hadoop.security.key.provider.path`.
424    // `None` means TDE is not configured; reads of encrypted files will error.
425    #[cfg(feature = "kms")]
426    kms_client: Option<Arc<KmsClient>>,
427}
428
429impl Client {
430    fn default_fs(config: &Configuration) -> Result<Url> {
431        let url = config
432            .get(config::DEFAULT_FS)
433            .ok_or(HdfsError::InvalidArgument(format!(
434                "No {} setting found",
435                config::DEFAULT_FS
436            )))?;
437        Ok(Url::parse(url)?)
438    }
439
440    fn build(
441        url: &Url,
442        config: Configuration,
443        rt: Option<IORuntime>,
444        user: Option<String>,
445        auth: Option<Arc<crate::security::ClientAuth>>,
446    ) -> Result<Self> {
447        let resolved_url = if !url.has_host() {
448            let default_url = Self::default_fs(&config)?;
449            if url.scheme() != default_url.scheme() || !default_url.has_host() {
450                return Err(HdfsError::InvalidArgument(
451                    "URL must contain a host".to_string(),
452                ));
453            }
454            default_url
455        } else {
456            url.clone()
457        };
458
459        let config = Arc::new(config);
460
461        let rt_holder = RuntimeHolder::new(rt);
462
463        let user_info = if config.security_enabled()
464            && let Some(principal) = auth
465                .as_deref()
466                .and_then(|auth| auth.credentials())
467                .and_then(|credentials| credentials.principal.as_deref())
468        {
469            User::get_user_info_from_principal(principal, user.clone())
470        } else {
471            User::get_user_info(user.clone(), config.security_enabled())
472        };
473        let username = user_info
474            .effective_user
475            .as_deref()
476            .or(user_info.real_user.as_deref())
477            .expect("User info must include a username");
478        let home_dir = build_home_dir(
479            resolved_url.scheme(),
480            resolved_url.host_str(),
481            config.as_ref(),
482            username,
483        );
484
485        let mount_table = match url.scheme() {
486            "hdfs" => {
487                let proxy = NameServiceProxy::new(
488                    &resolved_url,
489                    Arc::clone(&config),
490                    rt_holder.get_handle(),
491                    user.clone(),
492                    auth.clone(),
493                )?;
494                let protocol = Arc::new(NamenodeProtocol::new(proxy, rt_holder.get_handle()));
495
496                MountTable {
497                    mounts: Vec::new(),
498                    fallback: MountLink::new("/", "/", protocol),
499                    home_dir,
500                }
501            }
502            "viewfs" => Self::build_mount_table(
503                // Host is guaranteed to be present.
504                resolved_url.host_str().expect("URL must have a host"),
505                Arc::clone(&config),
506                rt_holder.get_handle(),
507                user.clone(),
508                auth.clone(),
509                home_dir,
510            )?,
511            _ => {
512                return Err(HdfsError::InvalidArgument(
513                    "Only `hdfs` and `viewfs` schemes are supported".to_string(),
514                ));
515            }
516        };
517
518        #[cfg(feature = "kms")]
519        let kms_client =
520            KmsClient::from_config(config.as_ref(), None, username.to_string(), auth.clone())?;
521
522        Ok(Self {
523            mount_table: Arc::new(mount_table),
524            config,
525            rt_holder,
526            #[cfg(feature = "kms")]
527            kms_client,
528        })
529    }
530
531    fn build_mount_table(
532        host: &str,
533        config: Arc<Configuration>,
534        handle: Handle,
535        effective_user: Option<String>,
536        auth: Option<Arc<crate::security::ClientAuth>>,
537        home_dir: String,
538    ) -> Result<MountTable> {
539        let mut mounts: Vec<MountLink> = Vec::new();
540        let mut fallback: Option<MountLink> = None;
541
542        for (viewfs_path, hdfs_url) in config.get_mount_table(host).iter() {
543            let url = Url::parse(hdfs_url)?;
544            if !url.has_host() {
545                return Err(HdfsError::InvalidArgument(
546                    "URL must contain a host".to_string(),
547                ));
548            }
549            if url.scheme() != "hdfs" {
550                return Err(HdfsError::InvalidArgument(
551                    "Only hdfs mounts are supported for viewfs".to_string(),
552                ));
553            }
554            let proxy = NameServiceProxy::new(
555                &url,
556                Arc::clone(&config),
557                handle.clone(),
558                effective_user.clone(),
559                auth.clone(),
560            )?;
561            let protocol = Arc::new(NamenodeProtocol::new(proxy, handle.clone()));
562
563            if let Some(prefix) = viewfs_path {
564                mounts.push(MountLink::new(prefix, url.path(), protocol));
565            } else {
566                if fallback.is_some() {
567                    return Err(HdfsError::InvalidArgument(
568                        "Multiple viewfs fallback links found".to_string(),
569                    ));
570                }
571                fallback = Some(MountLink::new("/", url.path(), protocol));
572            }
573        }
574
575        if let Some(fallback) = fallback {
576            // Sort the mount table from longest viewfs path to shortest. This makes sure more specific paths are considered first.
577            mounts.sort_by_key(|m| m.viewfs_path.chars().filter(|c| *c == '/').count());
578            mounts.reverse();
579
580            Ok(MountTable {
581                mounts,
582                fallback,
583                home_dir,
584            })
585        } else {
586            Err(HdfsError::InvalidArgument(
587                "No viewfs fallback mount found".to_string(),
588            ))
589        }
590    }
591
592    fn normalize_path(path: &str) -> String {
593        let mut normalized = if path.is_empty() {
594            "/".to_string()
595        } else {
596            path.to_string()
597        };
598        if !normalized.starts_with('/') {
599            normalized.insert(0, '/');
600        }
601        while normalized.len() > 1 && normalized.ends_with('/') {
602            normalized.pop();
603        }
604        normalized
605    }
606
607    fn join_paths(base: &str, suffix: &str) -> String {
608        if suffix.is_empty() {
609            return base.to_string();
610        }
611        let trimmed_base = if base.is_empty() { "/" } else { base };
612        let suffix = suffix.trim_start_matches('/');
613        if trimmed_base == "/" {
614            format!("/{suffix}")
615        } else {
616            format!("{}/{}", trimmed_base.trim_end_matches('/'), suffix)
617        }
618    }
619
620    fn is_prefix_path(parent: &str, child: &str) -> bool {
621        if parent == "/" {
622            return true;
623        }
624        child == parent || child.starts_with(&format!("{parent}/"))
625    }
626
627    fn current_time_millis() -> u64 {
628        SystemTime::now()
629            .duration_since(UNIX_EPOCH)
630            .unwrap_or_default()
631            .as_millis() as u64
632    }
633
634    fn absolute_path(&self, path: &str) -> String {
635        if path.starts_with('/') {
636            Self::normalize_path(path)
637        } else {
638            let home = self.mount_table.home_dir.trim_end_matches('/');
639            Self::normalize_path(&format!("{home}/{path}"))
640        }
641    }
642
643    fn trash_root_path(&self) -> String {
644        let home = Self::normalize_path(&self.mount_table.home_dir);
645        Self::join_paths(&home, TRASH_ROOT_DIR)
646    }
647
648    async fn trash_enabled(&self, path: &str) -> Result<bool> {
649        let (link, _) = self.mount_table.resolve(path);
650        let server_defaults = link.protocol.get_cached_server_defaults().await?;
651        Ok(server_defaults.trash_interval.unwrap_or_default() > 0)
652    }
653
654    fn split_parent_name(path: &str) -> Result<(String, String)> {
655        let normalized = Self::normalize_path(path);
656        if normalized == "/" {
657            return Err(HdfsError::InvalidArgument(
658                "Cannot move the root directory to trash".to_string(),
659            ));
660        }
661        let (parent, name) = normalized
662            .rsplit_once('/')
663            .expect("Normalized path always contains '/'");
664        let parent = if parent.is_empty() {
665            "/".to_string()
666        } else {
667            parent.to_string()
668        };
669        Ok((parent, name.to_string()))
670    }
671
672    async fn non_dir_ancestor(&self, path: &str) -> Result<Option<String>> {
673        let normalized = Self::normalize_path(path);
674        let mut current = "/".to_string();
675        for component in normalized.trim_start_matches('/').split('/') {
676            if component.is_empty() {
677                continue;
678            }
679            current = Self::join_paths(&current, component);
680            match self.get_file_info(&current).await {
681                Ok(status) => {
682                    if !status.isdir {
683                        return Ok(Some(current));
684                    }
685                }
686                Err(HdfsError::FileNotFound(_)) => return Ok(None),
687                Err(err) => return Err(err),
688            }
689        }
690        Ok(None)
691    }
692
693    async fn ensure_unique_trash_path(&self, path: String) -> Result<String> {
694        let base = path.clone();
695        let mut candidate = path;
696        loop {
697            match self.get_file_info(&candidate).await {
698                Ok(_) => {
699                    candidate = format!("{}{}", base, Self::current_time_millis());
700                }
701                Err(HdfsError::FileNotFound(_)) => return Ok(candidate),
702                Err(err) => return Err(err),
703            }
704        }
705    }
706
707    /// Retrieve the file status for the file at `path`.
708    pub async fn get_file_info(&self, path: &str) -> Result<FileStatus> {
709        let (link, resolved_path) = self.mount_table.resolve(path);
710        match link.protocol.get_file_info(&resolved_path).await?.fs {
711            Some(status) => Ok(FileStatus::from(status, path)),
712            None => Err(HdfsError::FileNotFound(path.to_string())),
713        }
714    }
715
716    /// Retrives a list of all files in directories located at `path`. Wrapper around `list_status_iter` that
717    /// returns Err if any part of the stream fails, or Ok if all file statuses were found successfully.
718    pub async fn list_status(&self, path: &str, recursive: bool) -> Result<Vec<FileStatus>> {
719        let iter = self.list_status_iter(path, recursive);
720        let statuses = iter
721            .into_stream()
722            .collect::<Vec<Result<FileStatus>>>()
723            .await;
724
725        let mut resolved_statues = Vec::<FileStatus>::with_capacity(statuses.len());
726        for status in statuses.into_iter() {
727            resolved_statues.push(status?);
728        }
729
730        Ok(resolved_statues)
731    }
732
733    /// Retrives an iterator of all files in directories located at `path`.
734    pub fn list_status_iter(&self, path: &str, recursive: bool) -> ListStatusIterator {
735        ListStatusIterator::new(path.to_string(), Arc::clone(&self.mount_table), recursive)
736    }
737
738    /// Opens a file reader for the file at `path`. Path should not include a scheme.
739    pub async fn read(&self, path: &str) -> Result<FileReader> {
740        let (link, resolved_path) = self.mount_table.resolve(path);
741        // Get all block locations. Length is actually a signed value, but the proto uses an unsigned value
742        let located_info = link
743            .protocol
744            .get_block_locations(&resolved_path, 0, i64::MAX as u64)
745            .await?;
746
747        if let Some(locations) = located_info.locations {
748            let ec_schema = if let Some(ec_policy) = locations.ec_policy.as_ref() {
749                Some(resolve_ec_policy(ec_policy)?)
750            } else {
751                None
752            };
753
754            let crypto = self
755                .build_crypto_codec(locations.file_encryption_info.as_ref())
756                .await?;
757
758            Ok(FileReader::new(
759                Arc::clone(&link.protocol),
760                locations,
761                ec_schema,
762                Arc::clone(&self.config),
763                self.rt_holder.get_handle(),
764                crypto,
765            ))
766        } else {
767            Err(HdfsError::FileNotFound(path.to_string()))
768        }
769    }
770
771    /// Build a [`FileCryptoCodec`] for a file in an HDFS encryption zone.
772    /// Returns `None` for files outside any zone. Returns an error if the file
773    /// is encrypted but no KMS is configured for this client.
774    async fn build_crypto_codec(
775        &self,
776        info: Option<&FileEncryptionInfoProto>,
777    ) -> Result<Option<Arc<FileCryptoCodec>>> {
778        let Some(info) = info else {
779            return Ok(None);
780        };
781        #[cfg(feature = "kms")]
782        {
783            let kms = self.kms_client.as_ref().ok_or_else(|| {
784                HdfsError::OperationFailed(
785                    "File is in an HDFS encryption zone but no KMS provider is configured \
786                     (set `hadoop.security.key.provider.path` in core-site.xml)"
787                        .to_string(),
788                )
789            })?;
790            let dek = kms.decrypt_edek(info).await?;
791            Ok(Some(Arc::new(FileCryptoCodec::new(info, dek)?)))
792        }
793        #[cfg(not(feature = "kms"))]
794        {
795            let _ = info;
796            Err(HdfsError::UnsupportedFeature(
797                "file is in an HDFS encryption zone; reading or writing it requires \
798                 building hdfs-native with the `kms` cargo feature"
799                    .to_string(),
800            ))
801        }
802    }
803
804    /// Opens a new file for writing. See [WriteOptions] for options and behavior for different
805    /// scenarios.
806    pub async fn create(
807        &self,
808        src: &str,
809        write_options: impl AsRef<WriteOptions>,
810    ) -> Result<FileWriter> {
811        let write_options = write_options.as_ref();
812
813        let (link, resolved_path) = self.mount_table.resolve(src);
814
815        let create_response = link
816            .protocol
817            .create(
818                &resolved_path,
819                write_options.permission,
820                write_options.overwrite,
821                write_options.create_parent,
822                write_options.replication,
823                write_options.block_size,
824            )
825            .await?;
826
827        match create_response.fs {
828            Some(status) => {
829                let crypto = match self
830                    .build_crypto_codec(status.file_encryption_info.as_ref())
831                    .await
832                {
833                    Ok(c) => c,
834                    Err(e) => {
835                        // The file already exists on the namenode but we can't
836                        // build a codec to write to it. Clean it up so the
837                        // caller doesn't see a zero-byte stub.
838                        let _ = self.delete(src, false).await;
839                        return Err(e);
840                    }
841                };
842
843                Ok(FileWriter::new(
844                    Arc::clone(&link.protocol),
845                    resolved_path,
846                    status,
847                    Arc::clone(&self.config),
848                    self.rt_holder.get_handle(),
849                    None,
850                    crypto,
851                ))
852            }
853            None => Err(HdfsError::FileNotFound(src.to_string())),
854        }
855    }
856
857    fn needs_new_block(class: &str, msg: &str) -> bool {
858        class == "java.lang.UnsupportedOperationException" && msg.contains("NEW_BLOCK")
859    }
860
861    /// Opens an existing file for appending. An Err will be returned if the file does not exist. If the
862    /// file is replicated, the current block will be appended to until it is full. If the file is erasure
863    /// coded, a new block will be created.
864    pub async fn append(&self, src: &str) -> Result<FileWriter> {
865        let (link, resolved_path) = self.mount_table.resolve(src);
866
867        // Assume the file is replicated and try to append to the current block. If the file is
868        // erasure coded, then try again by appending to a new block.
869        let append_response = match link.protocol.append(&resolved_path, false).await {
870            Err(HdfsError::RPCError(class, msg)) if Self::needs_new_block(&class, &msg) => {
871                link.protocol.append(&resolved_path, true).await?
872            }
873            resp => resp?,
874        };
875
876        match append_response.stat {
877            Some(status) => {
878                let crypto = match self
879                    .build_crypto_codec(status.file_encryption_info.as_ref())
880                    .await
881                {
882                    Ok(c) => c,
883                    Err(e) => {
884                        // Release the open lease the namenode granted for the
885                        // append we can no longer fulfill.
886                        let _ = link
887                            .protocol
888                            .complete(
889                                src,
890                                append_response.block.as_ref().map(|b| b.b.clone()),
891                                status.file_id,
892                            )
893                            .await;
894                        return Err(e);
895                    }
896                };
897
898                Ok(FileWriter::new(
899                    Arc::clone(&link.protocol),
900                    resolved_path,
901                    status,
902                    Arc::clone(&self.config),
903                    self.rt_holder.get_handle(),
904                    append_response.block,
905                    crypto,
906                ))
907            }
908            None => Err(HdfsError::FileNotFound(src.to_string())),
909        }
910    }
911
912    /// Create a new directory at `path` with the given `permission`.
913    ///
914    /// `permission` is the raw octal value representing the Unix style permission. For example, to
915    /// set 755 (`rwxr-x-rx`) permissions, use 0o755.
916    ///
917    /// If `create_parent` is true, any missing parent directories will be created as well,
918    /// otherwise an error will be returned if the parent directory doesn't already exist.
919    pub async fn mkdirs(&self, path: &str, permission: u32, create_parent: bool) -> Result<()> {
920        let (link, resolved_path) = self.mount_table.resolve(path);
921        link.protocol
922            .mkdirs(&resolved_path, permission, create_parent)
923            .await
924            .map(|_| ())
925    }
926
927    async fn rename_internal(
928        &self,
929        src: &str,
930        dst: &str,
931        overwrite: bool,
932        move_to_trash: bool,
933    ) -> Result<()> {
934        let (src_link, src_resolved_path) = self.mount_table.resolve(src);
935        let (dst_link, dst_resolved_path) = self.mount_table.resolve(dst);
936        if src_link.viewfs_path == dst_link.viewfs_path {
937            src_link
938                .protocol
939                .rename(
940                    &src_resolved_path,
941                    &dst_resolved_path,
942                    overwrite,
943                    move_to_trash,
944                )
945                .await
946                .map(|_| ())
947        } else {
948            Err(HdfsError::InvalidArgument(
949                "Cannot rename across different name services".to_string(),
950            ))
951        }
952    }
953
954    /// Renames `src` to `dst`. Returns Ok(()) on success, and Err otherwise.
955    pub async fn rename(&self, src: &str, dst: &str, overwrite: bool) -> Result<()> {
956        self.rename_internal(src, dst, overwrite, false).await
957    }
958
959    /// Deletes the file or directory at `path`. If `recursive` is false and `path` is a non-empty
960    /// directory, this will fail. Returns `Ok(true)` if it was successfully deleted.
961    pub async fn delete(&self, path: &str, recursive: bool) -> Result<bool> {
962        let (link, resolved_path) = self.mount_table.resolve(path);
963        link.protocol
964            .delete(&resolved_path, recursive)
965            .await
966            .map(|r| r.result)
967    }
968
969    /// Moves a file or directory at `path` into the user's trash. Returns `Ok(Some(path))` if
970    /// moved, where `path` is the new location in the trash, or `Ok(None)` if the path is already
971    /// under trash.
972    pub async fn trash(&self, path: &str) -> Result<Option<String>> {
973        if path.is_empty() {
974            return Err(HdfsError::InvalidPath("Empty path".to_string()));
975        }
976
977        let src_abs = self.absolute_path(path);
978        if !self.trash_enabled(&src_abs).await? {
979            return Err(HdfsError::TrashNotEnabled);
980        }
981
982        let trash_root = self.trash_root_path();
983
984        if Self::is_prefix_path(&trash_root, &src_abs) {
985            return Ok(None);
986        }
987        if Self::is_prefix_path(&src_abs, &trash_root) {
988            return Err(HdfsError::InvalidArgument(
989                "Cannot move to trash because it contains the trash".to_string(),
990            ));
991        }
992
993        let _ = self.get_file_info(&src_abs).await?;
994
995        let (src_parent, src_name) = Self::split_parent_name(&src_abs)?;
996        let trash_current = Self::join_paths(&trash_root, TRASH_CURRENT_DIR);
997        let src_parent_rel = src_parent.trim_start_matches('/');
998        let mut base_trash_path = if src_parent_rel.is_empty() {
999            trash_current.clone()
1000        } else {
1001            Self::join_paths(&trash_current, src_parent_rel)
1002        };
1003        let mut trash_path = Self::join_paths(&base_trash_path, &src_name);
1004
1005        for attempt in 0..2 {
1006            let mut mkdirs_error: Option<HdfsError> = None;
1007            loop {
1008                match self
1009                    .mkdirs(&base_trash_path, TRASH_DIR_PERMISSION, true)
1010                    .await
1011                {
1012                    Ok(()) => break,
1013                    Err(err) => {
1014                        if let Some(ancestor) = self.non_dir_ancestor(&base_trash_path).await? {
1015                            let timestamp = Self::current_time_millis();
1016                            base_trash_path = base_trash_path.replacen(
1017                                &ancestor,
1018                                &format!("{ancestor}{timestamp}"),
1019                                1,
1020                            );
1021                            trash_path = Self::join_paths(&base_trash_path, &src_name);
1022                            continue;
1023                        }
1024                        mkdirs_error = Some(err);
1025                        break;
1026                    }
1027                }
1028            }
1029
1030            if let Some(err) = mkdirs_error {
1031                if attempt == 0 {
1032                    continue;
1033                }
1034                return Err(err);
1035            }
1036
1037            let unique_trash_path = self.ensure_unique_trash_path(trash_path.clone()).await?;
1038            match self
1039                .rename_internal(&src_abs, &unique_trash_path, false, true)
1040                .await
1041            {
1042                Ok(()) => return Ok(Some(unique_trash_path)),
1043                Err(_) if attempt == 0 => continue,
1044                Err(err) => return Err(err),
1045            }
1046        }
1047
1048        Err(HdfsError::OperationFailed(
1049            "Failed to move to trash after retry".to_string(),
1050        ))
1051    }
1052
1053    /// Sets the modified and access times for a file. Times should be in milliseconds from the epoch.
1054    pub async fn set_times(&self, path: &str, mtime: u64, atime: u64) -> Result<()> {
1055        let (link, resolved_path) = self.mount_table.resolve(path);
1056        link.protocol
1057            .set_times(&resolved_path, mtime, atime)
1058            .await?;
1059        Ok(())
1060    }
1061
1062    /// Optionally sets the owner and group for a file.
1063    pub async fn set_owner(
1064        &self,
1065        path: &str,
1066        owner: Option<&str>,
1067        group: Option<&str>,
1068    ) -> Result<()> {
1069        let (link, resolved_path) = self.mount_table.resolve(path);
1070        link.protocol
1071            .set_owner(&resolved_path, owner, group)
1072            .await?;
1073        Ok(())
1074    }
1075
1076    /// Sets permissions for a file. Permission should be an octal number reprenting the Unix style
1077    /// permission.
1078    ///
1079    /// For example, to set permissions to rwxr-xr-x, use 0o755.
1080    pub async fn set_permission(&self, path: &str, permission: u32) -> Result<()> {
1081        let (link, resolved_path) = self.mount_table.resolve(path);
1082        link.protocol
1083            .set_permission(&resolved_path, permission)
1084            .await?;
1085        Ok(())
1086    }
1087
1088    /// Sets the replication for a file.
1089    pub async fn set_replication(&self, path: &str, replication: u32) -> Result<bool> {
1090        let (link, resolved_path) = self.mount_table.resolve(path);
1091        let result = link
1092            .protocol
1093            .set_replication(&resolved_path, replication)
1094            .await?
1095            .result;
1096
1097        Ok(result)
1098    }
1099
1100    /// Gets a content summary for a file or directory rooted at `path`.
1101    pub async fn get_content_summary(&self, path: &str) -> Result<ContentSummary> {
1102        let (link, resolved_path) = self.mount_table.resolve(path);
1103        let result = link
1104            .protocol
1105            .get_content_summary(&resolved_path)
1106            .await?
1107            .summary;
1108
1109        Ok(result.into())
1110    }
1111
1112    /// Update ACL entries for file or directory at `path`. Existing entries will remain.
1113    pub async fn modify_acl_entries(&self, path: &str, acl_spec: Vec<AclEntry>) -> Result<()> {
1114        let (link, resolved_path) = self.mount_table.resolve(path);
1115        link.protocol
1116            .modify_acl_entries(&resolved_path, acl_spec)
1117            .await?;
1118
1119        Ok(())
1120    }
1121
1122    /// Remove specific ACL entries for file or directory at `path`.
1123    pub async fn remove_acl_entries(&self, path: &str, acl_spec: Vec<AclEntry>) -> Result<()> {
1124        let (link, resolved_path) = self.mount_table.resolve(path);
1125        link.protocol
1126            .remove_acl_entries(&resolved_path, acl_spec)
1127            .await?;
1128
1129        Ok(())
1130    }
1131
1132    /// Remove all default ACLs for file or directory at `path`.
1133    pub async fn remove_default_acl(&self, path: &str) -> Result<()> {
1134        let (link, resolved_path) = self.mount_table.resolve(path);
1135        link.protocol.remove_default_acl(&resolved_path).await?;
1136
1137        Ok(())
1138    }
1139
1140    /// Remove all ACL entries for file or directory at `path`.
1141    pub async fn remove_acl(&self, path: &str) -> Result<()> {
1142        let (link, resolved_path) = self.mount_table.resolve(path);
1143        link.protocol.remove_acl(&resolved_path).await?;
1144
1145        Ok(())
1146    }
1147
1148    /// Override all ACL entries for file or directory at `path`. If only access ACLs are provided,
1149    /// default ACLs are maintained. Likewise if only default ACLs are provided, access ACLs are
1150    /// maintained.
1151    pub async fn set_acl(&self, path: &str, acl_spec: Vec<AclEntry>) -> Result<()> {
1152        let (link, resolved_path) = self.mount_table.resolve(path);
1153        link.protocol.set_acl(&resolved_path, acl_spec).await?;
1154
1155        Ok(())
1156    }
1157
1158    /// Get the ACL status for the file or directory at `path`.
1159    pub async fn get_acl_status(&self, path: &str) -> Result<AclStatus> {
1160        let (link, resolved_path) = self.mount_table.resolve(path);
1161        Ok(link
1162            .protocol
1163            .get_acl_status(&resolved_path)
1164            .await?
1165            .result
1166            .into())
1167    }
1168
1169    /// Get all file statuses matching the glob `pattern`. Supports Hadoop-style globbing
1170    /// which only applies to individual components of a path.
1171    pub async fn glob_status(&self, pattern: &str) -> Result<Vec<FileStatus>> {
1172        // Expand any brace groups first
1173        let flattened = expand_glob(pattern.to_string())?;
1174
1175        let mut results: Vec<FileStatus> = Vec::new();
1176
1177        for flat in flattened.into_iter() {
1178            // Make the pattern absolute-ish. We keep the pattern as-is; components
1179            // will be split on '/'. An empty pattern yields no results.
1180            if flat.is_empty() {
1181                continue;
1182            }
1183
1184            let components = get_path_components(&flat);
1185
1186            // Candidate holds a path (fully built so far) and optionally a resolved FileStatus
1187            #[derive(Clone, Debug)]
1188            struct Candidate {
1189                path: String,
1190                status: Option<FileStatus>,
1191            }
1192
1193            // Start from the root placeholder
1194            let mut candidates: Vec<Candidate> = vec![Candidate {
1195                path: "/".to_string(),
1196                status: None,
1197            }];
1198
1199            for (idx, comp) in components.iter().enumerate() {
1200                if candidates.is_empty() {
1201                    break;
1202                }
1203
1204                let is_last = idx == components.len() - 1;
1205
1206                let unescaped = unescape_component(comp);
1207                let glob_pat = GlobPattern::new(comp)?;
1208
1209                if !is_last && !glob_pat.has_wildcard() {
1210                    // Optimization: just append the literal component to each candidate
1211                    for cand in candidates.iter_mut() {
1212                        if !cand.path.ends_with('/') {
1213                            cand.path.push('/');
1214                        }
1215                        cand.path.push_str(&unescaped);
1216                        // keep status as None (we'll resolve later if needed)
1217                    }
1218                    continue;
1219                }
1220
1221                let mut new_candidates: Vec<Candidate> = Vec::new();
1222
1223                for cand in candidates.into_iter() {
1224                    if glob_pat.has_wildcard() {
1225                        // List the directory represented by cand.path
1226                        let listing = match self.list_status(&cand.path, false).await {
1227                            Ok(listing) => listing,
1228                            Err(HdfsError::FileNotFound(_)) => continue,
1229                            Err(e) => return Err(e),
1230                        };
1231                        if listing.len() == 1 && listing[0].path == cand.path {
1232                            // listing corresponds to the candidate itself (file), skip
1233                            continue;
1234                        }
1235
1236                        for child in listing.into_iter() {
1237                            // If this is not the terminal component, only recurse into directories
1238                            if !is_last && !child.isdir {
1239                                continue;
1240                            }
1241
1242                            // child.path already contains the full path
1243                            // Extract the name portion to match against the glob pattern
1244                            let name = child
1245                                .path
1246                                .rsplit_once('/')
1247                                .map(|(_, n)| n)
1248                                .unwrap_or(child.path.as_str());
1249
1250                            if glob_pat.matches(name) {
1251                                new_candidates.push(Candidate {
1252                                    path: child.path.clone(),
1253                                    status: Some(child),
1254                                });
1255                            }
1256                        }
1257                    } else {
1258                        // Non-glob component: use get_file_info for exact path
1259                        let mut next_path = cand.path.clone();
1260                        if !next_path.ends_with('/') {
1261                            next_path.push('/');
1262                        }
1263                        next_path.push_str(&unescaped);
1264
1265                        match self.get_file_info(&next_path).await {
1266                            Ok(status) => {
1267                                if is_last || status.isdir {
1268                                    new_candidates.push(Candidate {
1269                                        path: status.path.clone(),
1270                                        status: Some(status),
1271                                    });
1272                                }
1273                            }
1274                            Err(HdfsError::FileNotFound(_)) => continue,
1275                            Err(e) => return Err(e),
1276                        }
1277                    }
1278                }
1279
1280                candidates = new_candidates;
1281            }
1282
1283            // Resolve any placeholder candidates (including root) and collect results
1284            for cand in candidates.into_iter() {
1285                let status = if let Some(s) = cand.status {
1286                    s
1287                } else {
1288                    // Try to resolve the path to a real FileStatus
1289                    match self.get_file_info(&cand.path).await {
1290                        Ok(s) => s,
1291                        Err(HdfsError::FileNotFound(_)) => continue,
1292                        Err(e) => return Err(e),
1293                    }
1294                };
1295
1296                results.push(status);
1297            }
1298        }
1299
1300        Ok(results)
1301    }
1302}
1303
1304impl Default for Client {
1305    /// Creates a new HDFS Client based on the fs.defaultFS setting. Panics if the config files fail to load,
1306    /// no defaultFS is defined, or the defaultFS is invalid.
1307    fn default() -> Self {
1308        ClientBuilder::new()
1309            .build()
1310            .expect("Failed to create default client")
1311    }
1312}
1313
1314pub(crate) struct DirListingIterator {
1315    path: String,
1316    resolved_path: String,
1317    link: MountLink,
1318    files_only: bool,
1319    partial_listing: VecDeque<HdfsFileStatusProto>,
1320    remaining: u32,
1321    last_seen: Vec<u8>,
1322}
1323
1324impl DirListingIterator {
1325    fn new(path: String, mount_table: &Arc<MountTable>, files_only: bool) -> Self {
1326        let (link, resolved_path) = mount_table.resolve(&path);
1327
1328        DirListingIterator {
1329            path,
1330            resolved_path,
1331            link: link.clone(),
1332            files_only,
1333            partial_listing: VecDeque::new(),
1334            remaining: 1,
1335            last_seen: Vec::new(),
1336        }
1337    }
1338
1339    async fn get_next_batch(&mut self) -> Result<bool> {
1340        let listing = self
1341            .link
1342            .protocol
1343            .get_listing(&self.resolved_path, self.last_seen.clone(), false)
1344            .await?;
1345
1346        if let Some(dir_list) = listing.dir_list {
1347            self.last_seen = dir_list
1348                .partial_listing
1349                .last()
1350                .map(|p| p.path.clone())
1351                .unwrap_or(Vec::new());
1352
1353            self.remaining = dir_list.remaining_entries;
1354
1355            self.partial_listing = dir_list
1356                .partial_listing
1357                .into_iter()
1358                .filter(|s| !self.files_only || s.file_type() != FileType::IsDir)
1359                .collect();
1360            Ok(!self.partial_listing.is_empty())
1361        } else {
1362            Err(HdfsError::FileNotFound(self.path.clone()))
1363        }
1364    }
1365
1366    pub async fn next(&mut self) -> Option<Result<FileStatus>> {
1367        if self.partial_listing.is_empty()
1368            && self.remaining > 0
1369            && let Err(error) = self.get_next_batch().await
1370        {
1371            self.remaining = 0;
1372            return Some(Err(error));
1373        }
1374        if let Some(next) = self.partial_listing.pop_front() {
1375            Some(Ok(FileStatus::from(next, &self.path)))
1376        } else {
1377            None
1378        }
1379    }
1380}
1381
1382pub struct ListStatusIterator {
1383    mount_table: Arc<MountTable>,
1384    recursive: bool,
1385    iters: Arc<tokio::sync::Mutex<Vec<DirListingIterator>>>,
1386}
1387
1388impl ListStatusIterator {
1389    fn new(path: String, mount_table: Arc<MountTable>, recursive: bool) -> Self {
1390        let initial = DirListingIterator::new(path.clone(), &mount_table, false);
1391
1392        ListStatusIterator {
1393            mount_table,
1394            recursive,
1395            iters: Arc::new(tokio::sync::Mutex::new(vec![initial])),
1396        }
1397    }
1398
1399    pub async fn next(&self) -> Option<Result<FileStatus>> {
1400        let mut next_file: Option<Result<FileStatus>> = None;
1401        let mut iters = self.iters.lock().await;
1402        while next_file.is_none() {
1403            if let Some(iter) = iters.last_mut() {
1404                if let Some(file_result) = iter.next().await {
1405                    if let Ok(file) = file_result {
1406                        // Return the directory as the next result, but start traversing into that directory
1407                        // next if we're doing a recursive listing
1408                        if file.isdir && self.recursive {
1409                            iters.push(DirListingIterator::new(
1410                                file.path.clone(),
1411                                &self.mount_table,
1412                                false,
1413                            ))
1414                        }
1415                        next_file = Some(Ok(file));
1416                    } else {
1417                        // Error, return that as the next element
1418                        next_file = Some(file_result)
1419                    }
1420                } else {
1421                    // We've exhausted this directory
1422                    iters.pop();
1423                }
1424            } else {
1425                // There's nothing left, just return None
1426                break;
1427            }
1428        }
1429
1430        next_file
1431    }
1432
1433    pub fn into_stream(self) -> BoxStream<'static, Result<FileStatus>> {
1434        let listing = stream::unfold(self, |state| async move {
1435            let next = state.next().await;
1436            next.map(|n| (n, state))
1437        });
1438        Box::pin(listing)
1439    }
1440}
1441
1442#[derive(Debug, Clone)]
1443pub struct FileStatus {
1444    pub path: String,
1445    pub length: usize,
1446    pub isdir: bool,
1447    pub permission: u16,
1448    pub owner: String,
1449    pub group: String,
1450    pub modification_time: u64,
1451    pub access_time: u64,
1452    pub replication: Option<u32>,
1453    pub blocksize: Option<u64>,
1454}
1455
1456impl FileStatus {
1457    fn from(value: HdfsFileStatusProto, base_path: &str) -> Self {
1458        let mut path = base_path.trim_end_matches("/").to_string();
1459        let relative_path = std::str::from_utf8(&value.path).unwrap();
1460        if !relative_path.is_empty() {
1461            path.push('/');
1462            path.push_str(relative_path);
1463        }
1464
1465        // Root path should be a slash
1466        if path.is_empty() {
1467            path.push('/');
1468        }
1469
1470        FileStatus {
1471            isdir: value.file_type() == FileType::IsDir,
1472            path,
1473            length: value.length as usize,
1474            permission: value.permission.perm as u16,
1475            owner: value.owner,
1476            group: value.group,
1477            modification_time: value.modification_time,
1478            access_time: value.access_time,
1479            replication: value.block_replication,
1480            blocksize: value.blocksize,
1481        }
1482    }
1483}
1484
1485#[derive(Debug)]
1486pub struct ContentSummary {
1487    pub length: u64,
1488    pub file_count: u64,
1489    pub directory_count: u64,
1490    pub quota: u64,
1491    pub space_consumed: u64,
1492    pub space_quota: u64,
1493}
1494
1495impl From<ContentSummaryProto> for ContentSummary {
1496    fn from(value: ContentSummaryProto) -> Self {
1497        ContentSummary {
1498            length: value.length,
1499            file_count: value.file_count,
1500            directory_count: value.directory_count,
1501            quota: value.quota,
1502            space_consumed: value.space_consumed,
1503            space_quota: value.space_quota,
1504        }
1505    }
1506}
1507
1508#[cfg(test)]
1509mod test {
1510    use std::sync::{Arc, LazyLock};
1511
1512    use tokio::runtime::Runtime;
1513    use url::Url;
1514
1515    use crate::{
1516        client::ClientBuilder,
1517        common::config::Configuration,
1518        hdfs::{protocol::NamenodeProtocol, proxy::NameServiceProxy},
1519    };
1520
1521    use super::{MountLink, MountTable};
1522
1523    static RT: LazyLock<Runtime> = LazyLock::new(|| Runtime::new().unwrap());
1524
1525    fn create_protocol(url: &str) -> Arc<NamenodeProtocol> {
1526        let proxy = NameServiceProxy::new(
1527            &Url::parse(url).unwrap(),
1528            Arc::new(Configuration::new(None, None).unwrap()),
1529            RT.handle().clone(),
1530            None,
1531            None,
1532        )
1533        .unwrap();
1534        Arc::new(NamenodeProtocol::new(proxy, RT.handle().clone()))
1535    }
1536
1537    #[test]
1538    fn test_default_fs() {
1539        assert!(
1540            ClientBuilder::new()
1541                .with_config(vec![("fs.defaultFS", "hdfs://test:9000")])
1542                .build()
1543                .is_ok()
1544        );
1545
1546        assert!(
1547            ClientBuilder::new()
1548                .with_config(vec![("fs.defaultFS", "hdfs://")])
1549                .build()
1550                .is_err()
1551        );
1552
1553        assert!(
1554            ClientBuilder::new()
1555                .with_url("hdfs://")
1556                .with_config(vec![("fs.defaultFS", "hdfs://test:9000")])
1557                .build()
1558                .is_ok()
1559        );
1560
1561        assert!(
1562            ClientBuilder::new()
1563                .with_url("hdfs://")
1564                .with_config(vec![("fs.defaultFS", "hdfs://")])
1565                .build()
1566                .is_err()
1567        );
1568
1569        assert!(
1570            ClientBuilder::new()
1571                .with_url("hdfs://")
1572                .with_config(vec![("fs.defaultFS", "viewfs://test")])
1573                .build()
1574                .is_err()
1575        );
1576    }
1577
1578    #[test]
1579    fn test_mount_link_resolve() {
1580        let protocol = create_protocol("hdfs://127.0.0.1:9000");
1581        let link = MountLink::new("/view", "/hdfs", protocol);
1582
1583        assert_eq!(link.resolve("/view/dir/file").unwrap(), "/hdfs/dir/file");
1584        assert_eq!(link.resolve("/view").unwrap(), "/hdfs");
1585        assert!(link.resolve("/hdfs/path").is_none());
1586    }
1587
1588    #[test]
1589    fn test_fallback_link() {
1590        let protocol = create_protocol("hdfs://127.0.0.1:9000");
1591        let link = MountLink::new("", "/hdfs", Arc::clone(&protocol));
1592
1593        assert_eq!(link.resolve("/path/to/file").unwrap(), "/hdfs/path/to/file");
1594        assert_eq!(link.resolve("/").unwrap(), "/hdfs/");
1595        assert_eq!(link.resolve("/hdfs/path").unwrap(), "/hdfs/hdfs/path");
1596
1597        let link = MountLink::new("", "", protocol);
1598        assert_eq!(link.resolve("/").unwrap(), "/");
1599    }
1600
1601    #[test]
1602    fn test_mount_table_resolve() {
1603        let link1 = MountLink::new(
1604            "/mount1",
1605            "/path1/nested",
1606            create_protocol("hdfs://127.0.0.1:9000"),
1607        );
1608        let link2 = MountLink::new(
1609            "/mount2",
1610            "/path2",
1611            create_protocol("hdfs://127.0.0.1:9001"),
1612        );
1613        let link3 = MountLink::new(
1614            "/mount3/nested",
1615            "/path3",
1616            create_protocol("hdfs://127.0.0.1:9002"),
1617        );
1618        let fallback = MountLink::new("/", "/path4", create_protocol("hdfs://127.0.0.1:9003"));
1619
1620        let mount_table = MountTable {
1621            mounts: vec![link1, link2, link3],
1622            fallback,
1623            home_dir: "/user/test".to_string(),
1624        };
1625
1626        // Exact mount path resolves to the exact HDFS path
1627        let (link, resolved) = mount_table.resolve("/mount1");
1628        assert_eq!(link.viewfs_path, "/mount1");
1629        assert_eq!(resolved, "/path1/nested");
1630
1631        // Trailing slash is treated the same
1632        let (link, resolved) = mount_table.resolve("/mount1/");
1633        assert_eq!(link.viewfs_path, "/mount1");
1634        assert_eq!(resolved, "/path1/nested/");
1635
1636        // Doesn't do partial matches on a directory name
1637        let (link, resolved) = mount_table.resolve("/mount12");
1638        assert_eq!(link.viewfs_path, "");
1639        assert_eq!(resolved, "/path4/mount12");
1640
1641        let (link, resolved) = mount_table.resolve("/mount3/file");
1642        assert_eq!(link.viewfs_path, "");
1643        assert_eq!(resolved, "/path4/mount3/file");
1644
1645        let (link, resolved) = mount_table.resolve("/mount3/nested/file");
1646        assert_eq!(link.viewfs_path, "/mount3/nested");
1647        assert_eq!(resolved, "/path3/file");
1648
1649        let (link, resolved) = mount_table.resolve("file");
1650        assert_eq!(link.viewfs_path, "");
1651        assert_eq!(resolved, "/path4/user/test/file");
1652
1653        let (link, resolved) = mount_table.resolve("dir/subdir");
1654        assert_eq!(link.viewfs_path, "");
1655        assert_eq!(resolved, "/path4/user/test/dir/subdir");
1656
1657        let mount_table = MountTable {
1658            mounts: vec![
1659                MountLink::new(
1660                    "/mount1",
1661                    "/path1/nested",
1662                    create_protocol("hdfs://127.0.0.1:9000"),
1663                ),
1664                MountLink::new(
1665                    "/mount2",
1666                    "/path2",
1667                    create_protocol("hdfs://127.0.0.1:9001"),
1668                ),
1669            ],
1670            fallback: MountLink::new("/", "/path4", create_protocol("hdfs://127.0.0.1:9003")),
1671            home_dir: "/mount1/user".to_string(),
1672        };
1673
1674        let (link, resolved) = mount_table.resolve("file");
1675        assert_eq!(link.viewfs_path, "/mount1");
1676        assert_eq!(resolved, "/path1/nested/user/file");
1677
1678        let (link, resolved) = mount_table.resolve("dir/subdir");
1679        assert_eq!(link.viewfs_path, "/mount1");
1680        assert_eq!(resolved, "/path1/nested/user/dir/subdir");
1681    }
1682
1683    #[test]
1684    fn test_io_runtime() {
1685        assert!(
1686            ClientBuilder::new()
1687                .with_url("hdfs://127.0.0.1:9000")
1688                .with_io_runtime(Runtime::new().unwrap())
1689                .build()
1690                .is_ok()
1691        );
1692
1693        let rt = Runtime::new().unwrap();
1694        assert!(
1695            ClientBuilder::new()
1696                .with_url("hdfs://127.0.0.1:9000")
1697                .with_io_runtime(rt.handle().clone())
1698                .build()
1699                .is_ok()
1700        );
1701    }
1702
1703    #[test]
1704    fn test_with_user_sets_relative_path_home_dir() {
1705        let client = ClientBuilder::new()
1706            .with_url("hdfs://127.0.0.1:9000")
1707            .with_user("alice")
1708            .build()
1709            .unwrap();
1710
1711        let (_, resolved) = client.mount_table.resolve("file");
1712        assert_eq!(resolved, "/user/alice/file");
1713    }
1714
1715    #[test]
1716    fn test_kerberos_credentials_set_principal_home_dir() {
1717        let client = ClientBuilder::new()
1718            .with_url("hdfs://127.0.0.1:9000")
1719            .with_config([("hadoop.security.authentication", "kerberos")])
1720            .with_kerberos_principal("alice@EXAMPLE.COM")
1721            .with_kerberos_cache("FILE:/run/krb5/alice.ccache")
1722            .build()
1723            .unwrap();
1724
1725        let (_, resolved) = client.mount_table.resolve("file");
1726        assert_eq!(resolved, "/user/alice/file");
1727    }
1728
1729    #[test]
1730    fn test_explicit_kerberos_credentials_require_principal() {
1731        let error = ClientBuilder::new()
1732            .with_url("hdfs://127.0.0.1:9000")
1733            .with_kerberos_keytab("client.keytab")
1734            .build()
1735            .unwrap_err();
1736
1737        assert!(error.to_string().contains("principal is required"));
1738    }
1739
1740    #[test]
1741    fn test_set_conf_dir() {
1742        assert!(
1743            ClientBuilder::new()
1744                .with_url("hdfs://127.0.0.1:9000")
1745                .with_config_dir("target/test")
1746                .build()
1747                .is_ok()
1748        )
1749    }
1750}