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    /// Force lease recovery for an under-construction file.
913    ///
914    /// Returns `true` when the file is closed and stable. A `false` result
915    /// means recovery has started but has not completed yet; callers may poll
916    /// this method until it returns `true`.
917    ///
918    /// This operation fences the current writer for `src`. Callers must hold
919    /// exclusive recovery authority for the path before invoking it.
920    pub async fn recover_lease(&self, src: &str) -> Result<bool> {
921        let (link, resolved_path) = self.mount_table.resolve(src);
922        link.protocol
923            .recover_lease(&resolved_path)
924            .await
925            .map(|response| response.result)
926    }
927
928    /// Create a new directory at `path` with the given `permission`.
929    ///
930    /// `permission` is the raw octal value representing the Unix style permission. For example, to
931    /// set 755 (`rwxr-x-rx`) permissions, use 0o755.
932    ///
933    /// If `create_parent` is true, any missing parent directories will be created as well,
934    /// otherwise an error will be returned if the parent directory doesn't already exist.
935    pub async fn mkdirs(&self, path: &str, permission: u32, create_parent: bool) -> Result<()> {
936        let (link, resolved_path) = self.mount_table.resolve(path);
937        link.protocol
938            .mkdirs(&resolved_path, permission, create_parent)
939            .await
940            .map(|_| ())
941    }
942
943    async fn rename_internal(
944        &self,
945        src: &str,
946        dst: &str,
947        overwrite: bool,
948        move_to_trash: bool,
949    ) -> Result<()> {
950        let (src_link, src_resolved_path) = self.mount_table.resolve(src);
951        let (dst_link, dst_resolved_path) = self.mount_table.resolve(dst);
952        if src_link.viewfs_path == dst_link.viewfs_path {
953            src_link
954                .protocol
955                .rename(
956                    &src_resolved_path,
957                    &dst_resolved_path,
958                    overwrite,
959                    move_to_trash,
960                )
961                .await
962                .map(|_| ())
963        } else {
964            Err(HdfsError::InvalidArgument(
965                "Cannot rename across different name services".to_string(),
966            ))
967        }
968    }
969
970    /// Renames `src` to `dst`. Returns Ok(()) on success, and Err otherwise.
971    pub async fn rename(&self, src: &str, dst: &str, overwrite: bool) -> Result<()> {
972        self.rename_internal(src, dst, overwrite, false).await
973    }
974
975    /// Deletes the file or directory at `path`. If `recursive` is false and `path` is a non-empty
976    /// directory, this will fail. Returns `Ok(true)` if it was successfully deleted.
977    pub async fn delete(&self, path: &str, recursive: bool) -> Result<bool> {
978        let (link, resolved_path) = self.mount_table.resolve(path);
979        link.protocol
980            .delete(&resolved_path, recursive)
981            .await
982            .map(|r| r.result)
983    }
984
985    /// Moves a file or directory at `path` into the user's trash. Returns `Ok(Some(path))` if
986    /// moved, where `path` is the new location in the trash, or `Ok(None)` if the path is already
987    /// under trash.
988    pub async fn trash(&self, path: &str) -> Result<Option<String>> {
989        if path.is_empty() {
990            return Err(HdfsError::InvalidPath("Empty path".to_string()));
991        }
992
993        let src_abs = self.absolute_path(path);
994        if !self.trash_enabled(&src_abs).await? {
995            return Err(HdfsError::TrashNotEnabled);
996        }
997
998        let trash_root = self.trash_root_path();
999
1000        if Self::is_prefix_path(&trash_root, &src_abs) {
1001            return Ok(None);
1002        }
1003        if Self::is_prefix_path(&src_abs, &trash_root) {
1004            return Err(HdfsError::InvalidArgument(
1005                "Cannot move to trash because it contains the trash".to_string(),
1006            ));
1007        }
1008
1009        let _ = self.get_file_info(&src_abs).await?;
1010
1011        let (src_parent, src_name) = Self::split_parent_name(&src_abs)?;
1012        let trash_current = Self::join_paths(&trash_root, TRASH_CURRENT_DIR);
1013        let src_parent_rel = src_parent.trim_start_matches('/');
1014        let mut base_trash_path = if src_parent_rel.is_empty() {
1015            trash_current.clone()
1016        } else {
1017            Self::join_paths(&trash_current, src_parent_rel)
1018        };
1019        let mut trash_path = Self::join_paths(&base_trash_path, &src_name);
1020
1021        for attempt in 0..2 {
1022            let mut mkdirs_error: Option<HdfsError> = None;
1023            loop {
1024                match self
1025                    .mkdirs(&base_trash_path, TRASH_DIR_PERMISSION, true)
1026                    .await
1027                {
1028                    Ok(()) => break,
1029                    Err(err) => {
1030                        if let Some(ancestor) = self.non_dir_ancestor(&base_trash_path).await? {
1031                            let timestamp = Self::current_time_millis();
1032                            base_trash_path = base_trash_path.replacen(
1033                                &ancestor,
1034                                &format!("{ancestor}{timestamp}"),
1035                                1,
1036                            );
1037                            trash_path = Self::join_paths(&base_trash_path, &src_name);
1038                            continue;
1039                        }
1040                        mkdirs_error = Some(err);
1041                        break;
1042                    }
1043                }
1044            }
1045
1046            if let Some(err) = mkdirs_error {
1047                if attempt == 0 {
1048                    continue;
1049                }
1050                return Err(err);
1051            }
1052
1053            let unique_trash_path = self.ensure_unique_trash_path(trash_path.clone()).await?;
1054            match self
1055                .rename_internal(&src_abs, &unique_trash_path, false, true)
1056                .await
1057            {
1058                Ok(()) => return Ok(Some(unique_trash_path)),
1059                Err(_) if attempt == 0 => continue,
1060                Err(err) => return Err(err),
1061            }
1062        }
1063
1064        Err(HdfsError::OperationFailed(
1065            "Failed to move to trash after retry".to_string(),
1066        ))
1067    }
1068
1069    /// Sets the modified and access times for a file. Times should be in milliseconds from the epoch.
1070    pub async fn set_times(&self, path: &str, mtime: u64, atime: u64) -> Result<()> {
1071        let (link, resolved_path) = self.mount_table.resolve(path);
1072        link.protocol
1073            .set_times(&resolved_path, mtime, atime)
1074            .await?;
1075        Ok(())
1076    }
1077
1078    /// Optionally sets the owner and group for a file.
1079    pub async fn set_owner(
1080        &self,
1081        path: &str,
1082        owner: Option<&str>,
1083        group: Option<&str>,
1084    ) -> Result<()> {
1085        let (link, resolved_path) = self.mount_table.resolve(path);
1086        link.protocol
1087            .set_owner(&resolved_path, owner, group)
1088            .await?;
1089        Ok(())
1090    }
1091
1092    /// Sets permissions for a file. Permission should be an octal number reprenting the Unix style
1093    /// permission.
1094    ///
1095    /// For example, to set permissions to rwxr-xr-x, use 0o755.
1096    pub async fn set_permission(&self, path: &str, permission: u32) -> Result<()> {
1097        let (link, resolved_path) = self.mount_table.resolve(path);
1098        link.protocol
1099            .set_permission(&resolved_path, permission)
1100            .await?;
1101        Ok(())
1102    }
1103
1104    /// Sets the replication for a file.
1105    pub async fn set_replication(&self, path: &str, replication: u32) -> Result<bool> {
1106        let (link, resolved_path) = self.mount_table.resolve(path);
1107        let result = link
1108            .protocol
1109            .set_replication(&resolved_path, replication)
1110            .await?
1111            .result;
1112
1113        Ok(result)
1114    }
1115
1116    /// Gets a content summary for a file or directory rooted at `path`.
1117    pub async fn get_content_summary(&self, path: &str) -> Result<ContentSummary> {
1118        let (link, resolved_path) = self.mount_table.resolve(path);
1119        let result = link
1120            .protocol
1121            .get_content_summary(&resolved_path)
1122            .await?
1123            .summary;
1124
1125        Ok(result.into())
1126    }
1127
1128    /// Update ACL entries for file or directory at `path`. Existing entries will remain.
1129    pub async fn modify_acl_entries(&self, path: &str, acl_spec: Vec<AclEntry>) -> Result<()> {
1130        let (link, resolved_path) = self.mount_table.resolve(path);
1131        link.protocol
1132            .modify_acl_entries(&resolved_path, acl_spec)
1133            .await?;
1134
1135        Ok(())
1136    }
1137
1138    /// Remove specific ACL entries for file or directory at `path`.
1139    pub async fn remove_acl_entries(&self, path: &str, acl_spec: Vec<AclEntry>) -> Result<()> {
1140        let (link, resolved_path) = self.mount_table.resolve(path);
1141        link.protocol
1142            .remove_acl_entries(&resolved_path, acl_spec)
1143            .await?;
1144
1145        Ok(())
1146    }
1147
1148    /// Remove all default ACLs for file or directory at `path`.
1149    pub async fn remove_default_acl(&self, path: &str) -> Result<()> {
1150        let (link, resolved_path) = self.mount_table.resolve(path);
1151        link.protocol.remove_default_acl(&resolved_path).await?;
1152
1153        Ok(())
1154    }
1155
1156    /// Remove all ACL entries for file or directory at `path`.
1157    pub async fn remove_acl(&self, path: &str) -> Result<()> {
1158        let (link, resolved_path) = self.mount_table.resolve(path);
1159        link.protocol.remove_acl(&resolved_path).await?;
1160
1161        Ok(())
1162    }
1163
1164    /// Override all ACL entries for file or directory at `path`. If only access ACLs are provided,
1165    /// default ACLs are maintained. Likewise if only default ACLs are provided, access ACLs are
1166    /// maintained.
1167    pub async fn set_acl(&self, path: &str, acl_spec: Vec<AclEntry>) -> Result<()> {
1168        let (link, resolved_path) = self.mount_table.resolve(path);
1169        link.protocol.set_acl(&resolved_path, acl_spec).await?;
1170
1171        Ok(())
1172    }
1173
1174    /// Get the ACL status for the file or directory at `path`.
1175    pub async fn get_acl_status(&self, path: &str) -> Result<AclStatus> {
1176        let (link, resolved_path) = self.mount_table.resolve(path);
1177        Ok(link
1178            .protocol
1179            .get_acl_status(&resolved_path)
1180            .await?
1181            .result
1182            .into())
1183    }
1184
1185    /// Get all file statuses matching the glob `pattern`. Supports Hadoop-style globbing
1186    /// which only applies to individual components of a path.
1187    pub async fn glob_status(&self, pattern: &str) -> Result<Vec<FileStatus>> {
1188        // Expand any brace groups first
1189        let flattened = expand_glob(pattern.to_string())?;
1190
1191        let mut results: Vec<FileStatus> = Vec::new();
1192
1193        for flat in flattened.into_iter() {
1194            // Make the pattern absolute-ish. We keep the pattern as-is; components
1195            // will be split on '/'. An empty pattern yields no results.
1196            if flat.is_empty() {
1197                continue;
1198            }
1199
1200            let components = get_path_components(&flat);
1201
1202            // Candidate holds a path (fully built so far) and optionally a resolved FileStatus
1203            #[derive(Clone, Debug)]
1204            struct Candidate {
1205                path: String,
1206                status: Option<FileStatus>,
1207            }
1208
1209            // Start from the root placeholder
1210            let mut candidates: Vec<Candidate> = vec![Candidate {
1211                path: "/".to_string(),
1212                status: None,
1213            }];
1214
1215            for (idx, comp) in components.iter().enumerate() {
1216                if candidates.is_empty() {
1217                    break;
1218                }
1219
1220                let is_last = idx == components.len() - 1;
1221
1222                let unescaped = unescape_component(comp);
1223                let glob_pat = GlobPattern::new(comp)?;
1224
1225                if !is_last && !glob_pat.has_wildcard() {
1226                    // Optimization: just append the literal component to each candidate
1227                    for cand in candidates.iter_mut() {
1228                        if !cand.path.ends_with('/') {
1229                            cand.path.push('/');
1230                        }
1231                        cand.path.push_str(&unescaped);
1232                        // keep status as None (we'll resolve later if needed)
1233                    }
1234                    continue;
1235                }
1236
1237                let mut new_candidates: Vec<Candidate> = Vec::new();
1238
1239                for cand in candidates.into_iter() {
1240                    if glob_pat.has_wildcard() {
1241                        // List the directory represented by cand.path
1242                        let listing = match self.list_status(&cand.path, false).await {
1243                            Ok(listing) => listing,
1244                            Err(HdfsError::FileNotFound(_)) => continue,
1245                            Err(e) => return Err(e),
1246                        };
1247                        if listing.len() == 1 && listing[0].path == cand.path {
1248                            // listing corresponds to the candidate itself (file), skip
1249                            continue;
1250                        }
1251
1252                        for child in listing.into_iter() {
1253                            // If this is not the terminal component, only recurse into directories
1254                            if !is_last && !child.isdir {
1255                                continue;
1256                            }
1257
1258                            // child.path already contains the full path
1259                            // Extract the name portion to match against the glob pattern
1260                            let name = child
1261                                .path
1262                                .rsplit_once('/')
1263                                .map(|(_, n)| n)
1264                                .unwrap_or(child.path.as_str());
1265
1266                            if glob_pat.matches(name) {
1267                                new_candidates.push(Candidate {
1268                                    path: child.path.clone(),
1269                                    status: Some(child),
1270                                });
1271                            }
1272                        }
1273                    } else {
1274                        // Non-glob component: use get_file_info for exact path
1275                        let mut next_path = cand.path.clone();
1276                        if !next_path.ends_with('/') {
1277                            next_path.push('/');
1278                        }
1279                        next_path.push_str(&unescaped);
1280
1281                        match self.get_file_info(&next_path).await {
1282                            Ok(status) => {
1283                                if is_last || status.isdir {
1284                                    new_candidates.push(Candidate {
1285                                        path: status.path.clone(),
1286                                        status: Some(status),
1287                                    });
1288                                }
1289                            }
1290                            Err(HdfsError::FileNotFound(_)) => continue,
1291                            Err(e) => return Err(e),
1292                        }
1293                    }
1294                }
1295
1296                candidates = new_candidates;
1297            }
1298
1299            // Resolve any placeholder candidates (including root) and collect results
1300            for cand in candidates.into_iter() {
1301                let status = if let Some(s) = cand.status {
1302                    s
1303                } else {
1304                    // Try to resolve the path to a real FileStatus
1305                    match self.get_file_info(&cand.path).await {
1306                        Ok(s) => s,
1307                        Err(HdfsError::FileNotFound(_)) => continue,
1308                        Err(e) => return Err(e),
1309                    }
1310                };
1311
1312                results.push(status);
1313            }
1314        }
1315
1316        Ok(results)
1317    }
1318}
1319
1320impl Default for Client {
1321    /// Creates a new HDFS Client based on the fs.defaultFS setting. Panics if the config files fail to load,
1322    /// no defaultFS is defined, or the defaultFS is invalid.
1323    fn default() -> Self {
1324        ClientBuilder::new()
1325            .build()
1326            .expect("Failed to create default client")
1327    }
1328}
1329
1330pub(crate) struct DirListingIterator {
1331    path: String,
1332    resolved_path: String,
1333    link: MountLink,
1334    files_only: bool,
1335    partial_listing: VecDeque<HdfsFileStatusProto>,
1336    remaining: u32,
1337    last_seen: Vec<u8>,
1338}
1339
1340impl DirListingIterator {
1341    fn new(path: String, mount_table: &Arc<MountTable>, files_only: bool) -> Self {
1342        let (link, resolved_path) = mount_table.resolve(&path);
1343
1344        DirListingIterator {
1345            path,
1346            resolved_path,
1347            link: link.clone(),
1348            files_only,
1349            partial_listing: VecDeque::new(),
1350            remaining: 1,
1351            last_seen: Vec::new(),
1352        }
1353    }
1354
1355    async fn get_next_batch(&mut self) -> Result<bool> {
1356        let listing = self
1357            .link
1358            .protocol
1359            .get_listing(&self.resolved_path, self.last_seen.clone(), false)
1360            .await?;
1361
1362        if let Some(dir_list) = listing.dir_list {
1363            self.last_seen = dir_list
1364                .partial_listing
1365                .last()
1366                .map(|p| p.path.clone())
1367                .unwrap_or(Vec::new());
1368
1369            self.remaining = dir_list.remaining_entries;
1370
1371            self.partial_listing = dir_list
1372                .partial_listing
1373                .into_iter()
1374                .filter(|s| !self.files_only || s.file_type() != FileType::IsDir)
1375                .collect();
1376            Ok(!self.partial_listing.is_empty())
1377        } else {
1378            Err(HdfsError::FileNotFound(self.path.clone()))
1379        }
1380    }
1381
1382    pub async fn next(&mut self) -> Option<Result<FileStatus>> {
1383        if self.partial_listing.is_empty()
1384            && self.remaining > 0
1385            && let Err(error) = self.get_next_batch().await
1386        {
1387            self.remaining = 0;
1388            return Some(Err(error));
1389        }
1390        if let Some(next) = self.partial_listing.pop_front() {
1391            Some(Ok(FileStatus::from(next, &self.path)))
1392        } else {
1393            None
1394        }
1395    }
1396}
1397
1398pub struct ListStatusIterator {
1399    mount_table: Arc<MountTable>,
1400    recursive: bool,
1401    iters: Arc<tokio::sync::Mutex<Vec<DirListingIterator>>>,
1402}
1403
1404impl ListStatusIterator {
1405    fn new(path: String, mount_table: Arc<MountTable>, recursive: bool) -> Self {
1406        let initial = DirListingIterator::new(path.clone(), &mount_table, false);
1407
1408        ListStatusIterator {
1409            mount_table,
1410            recursive,
1411            iters: Arc::new(tokio::sync::Mutex::new(vec![initial])),
1412        }
1413    }
1414
1415    pub async fn next(&self) -> Option<Result<FileStatus>> {
1416        let mut next_file: Option<Result<FileStatus>> = None;
1417        let mut iters = self.iters.lock().await;
1418        while next_file.is_none() {
1419            if let Some(iter) = iters.last_mut() {
1420                if let Some(file_result) = iter.next().await {
1421                    if let Ok(file) = file_result {
1422                        // Return the directory as the next result, but start traversing into that directory
1423                        // next if we're doing a recursive listing
1424                        if file.isdir && self.recursive {
1425                            iters.push(DirListingIterator::new(
1426                                file.path.clone(),
1427                                &self.mount_table,
1428                                false,
1429                            ))
1430                        }
1431                        next_file = Some(Ok(file));
1432                    } else {
1433                        // Error, return that as the next element
1434                        next_file = Some(file_result)
1435                    }
1436                } else {
1437                    // We've exhausted this directory
1438                    iters.pop();
1439                }
1440            } else {
1441                // There's nothing left, just return None
1442                break;
1443            }
1444        }
1445
1446        next_file
1447    }
1448
1449    pub fn into_stream(self) -> BoxStream<'static, Result<FileStatus>> {
1450        let listing = stream::unfold(self, |state| async move {
1451            let next = state.next().await;
1452            next.map(|n| (n, state))
1453        });
1454        Box::pin(listing)
1455    }
1456}
1457
1458#[derive(Debug, Clone)]
1459pub struct FileStatus {
1460    pub path: String,
1461    pub length: usize,
1462    pub isdir: bool,
1463    pub permission: u16,
1464    pub owner: String,
1465    pub group: String,
1466    pub modification_time: u64,
1467    pub access_time: u64,
1468    pub replication: Option<u32>,
1469    pub blocksize: Option<u64>,
1470}
1471
1472impl FileStatus {
1473    fn from(value: HdfsFileStatusProto, base_path: &str) -> Self {
1474        let mut path = base_path.trim_end_matches("/").to_string();
1475        let relative_path = std::str::from_utf8(&value.path).unwrap();
1476        if !relative_path.is_empty() {
1477            path.push('/');
1478            path.push_str(relative_path);
1479        }
1480
1481        // Root path should be a slash
1482        if path.is_empty() {
1483            path.push('/');
1484        }
1485
1486        FileStatus {
1487            isdir: value.file_type() == FileType::IsDir,
1488            path,
1489            length: value.length as usize,
1490            permission: value.permission.perm as u16,
1491            owner: value.owner,
1492            group: value.group,
1493            modification_time: value.modification_time,
1494            access_time: value.access_time,
1495            replication: value.block_replication,
1496            blocksize: value.blocksize,
1497        }
1498    }
1499}
1500
1501#[derive(Debug)]
1502pub struct ContentSummary {
1503    pub length: u64,
1504    pub file_count: u64,
1505    pub directory_count: u64,
1506    pub quota: u64,
1507    pub space_consumed: u64,
1508    pub space_quota: u64,
1509}
1510
1511impl From<ContentSummaryProto> for ContentSummary {
1512    fn from(value: ContentSummaryProto) -> Self {
1513        ContentSummary {
1514            length: value.length,
1515            file_count: value.file_count,
1516            directory_count: value.directory_count,
1517            quota: value.quota,
1518            space_consumed: value.space_consumed,
1519            space_quota: value.space_quota,
1520        }
1521    }
1522}
1523
1524#[cfg(test)]
1525mod test {
1526    use std::sync::{Arc, LazyLock};
1527
1528    use tokio::runtime::Runtime;
1529    use url::Url;
1530
1531    use crate::{
1532        client::ClientBuilder,
1533        common::config::Configuration,
1534        hdfs::{protocol::NamenodeProtocol, proxy::NameServiceProxy},
1535    };
1536
1537    use super::{MountLink, MountTable};
1538
1539    static RT: LazyLock<Runtime> = LazyLock::new(|| Runtime::new().unwrap());
1540
1541    fn create_protocol(url: &str) -> Arc<NamenodeProtocol> {
1542        let proxy = NameServiceProxy::new(
1543            &Url::parse(url).unwrap(),
1544            Arc::new(Configuration::new(None, None).unwrap()),
1545            RT.handle().clone(),
1546            None,
1547            None,
1548        )
1549        .unwrap();
1550        Arc::new(NamenodeProtocol::new(proxy, RT.handle().clone()))
1551    }
1552
1553    #[test]
1554    fn test_default_fs() {
1555        assert!(
1556            ClientBuilder::new()
1557                .with_config(vec![("fs.defaultFS", "hdfs://test:9000")])
1558                .build()
1559                .is_ok()
1560        );
1561
1562        assert!(
1563            ClientBuilder::new()
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", "hdfs://test:9000")])
1573                .build()
1574                .is_ok()
1575        );
1576
1577        assert!(
1578            ClientBuilder::new()
1579                .with_url("hdfs://")
1580                .with_config(vec![("fs.defaultFS", "hdfs://")])
1581                .build()
1582                .is_err()
1583        );
1584
1585        assert!(
1586            ClientBuilder::new()
1587                .with_url("hdfs://")
1588                .with_config(vec![("fs.defaultFS", "viewfs://test")])
1589                .build()
1590                .is_err()
1591        );
1592    }
1593
1594    #[test]
1595    fn test_mount_link_resolve() {
1596        let protocol = create_protocol("hdfs://127.0.0.1:9000");
1597        let link = MountLink::new("/view", "/hdfs", protocol);
1598
1599        assert_eq!(link.resolve("/view/dir/file").unwrap(), "/hdfs/dir/file");
1600        assert_eq!(link.resolve("/view").unwrap(), "/hdfs");
1601        assert!(link.resolve("/hdfs/path").is_none());
1602    }
1603
1604    #[test]
1605    fn test_fallback_link() {
1606        let protocol = create_protocol("hdfs://127.0.0.1:9000");
1607        let link = MountLink::new("", "/hdfs", Arc::clone(&protocol));
1608
1609        assert_eq!(link.resolve("/path/to/file").unwrap(), "/hdfs/path/to/file");
1610        assert_eq!(link.resolve("/").unwrap(), "/hdfs/");
1611        assert_eq!(link.resolve("/hdfs/path").unwrap(), "/hdfs/hdfs/path");
1612
1613        let link = MountLink::new("", "", protocol);
1614        assert_eq!(link.resolve("/").unwrap(), "/");
1615    }
1616
1617    #[test]
1618    fn test_mount_table_resolve() {
1619        let link1 = MountLink::new(
1620            "/mount1",
1621            "/path1/nested",
1622            create_protocol("hdfs://127.0.0.1:9000"),
1623        );
1624        let link2 = MountLink::new(
1625            "/mount2",
1626            "/path2",
1627            create_protocol("hdfs://127.0.0.1:9001"),
1628        );
1629        let link3 = MountLink::new(
1630            "/mount3/nested",
1631            "/path3",
1632            create_protocol("hdfs://127.0.0.1:9002"),
1633        );
1634        let fallback = MountLink::new("/", "/path4", create_protocol("hdfs://127.0.0.1:9003"));
1635
1636        let mount_table = MountTable {
1637            mounts: vec![link1, link2, link3],
1638            fallback,
1639            home_dir: "/user/test".to_string(),
1640        };
1641
1642        // Exact mount path resolves to the exact HDFS path
1643        let (link, resolved) = mount_table.resolve("/mount1");
1644        assert_eq!(link.viewfs_path, "/mount1");
1645        assert_eq!(resolved, "/path1/nested");
1646
1647        // Trailing slash is treated the same
1648        let (link, resolved) = mount_table.resolve("/mount1/");
1649        assert_eq!(link.viewfs_path, "/mount1");
1650        assert_eq!(resolved, "/path1/nested/");
1651
1652        // Doesn't do partial matches on a directory name
1653        let (link, resolved) = mount_table.resolve("/mount12");
1654        assert_eq!(link.viewfs_path, "");
1655        assert_eq!(resolved, "/path4/mount12");
1656
1657        let (link, resolved) = mount_table.resolve("/mount3/file");
1658        assert_eq!(link.viewfs_path, "");
1659        assert_eq!(resolved, "/path4/mount3/file");
1660
1661        let (link, resolved) = mount_table.resolve("/mount3/nested/file");
1662        assert_eq!(link.viewfs_path, "/mount3/nested");
1663        assert_eq!(resolved, "/path3/file");
1664
1665        let (link, resolved) = mount_table.resolve("file");
1666        assert_eq!(link.viewfs_path, "");
1667        assert_eq!(resolved, "/path4/user/test/file");
1668
1669        let (link, resolved) = mount_table.resolve("dir/subdir");
1670        assert_eq!(link.viewfs_path, "");
1671        assert_eq!(resolved, "/path4/user/test/dir/subdir");
1672
1673        let mount_table = MountTable {
1674            mounts: vec![
1675                MountLink::new(
1676                    "/mount1",
1677                    "/path1/nested",
1678                    create_protocol("hdfs://127.0.0.1:9000"),
1679                ),
1680                MountLink::new(
1681                    "/mount2",
1682                    "/path2",
1683                    create_protocol("hdfs://127.0.0.1:9001"),
1684                ),
1685            ],
1686            fallback: MountLink::new("/", "/path4", create_protocol("hdfs://127.0.0.1:9003")),
1687            home_dir: "/mount1/user".to_string(),
1688        };
1689
1690        let (link, resolved) = mount_table.resolve("file");
1691        assert_eq!(link.viewfs_path, "/mount1");
1692        assert_eq!(resolved, "/path1/nested/user/file");
1693
1694        let (link, resolved) = mount_table.resolve("dir/subdir");
1695        assert_eq!(link.viewfs_path, "/mount1");
1696        assert_eq!(resolved, "/path1/nested/user/dir/subdir");
1697    }
1698
1699    #[test]
1700    fn test_io_runtime() {
1701        assert!(
1702            ClientBuilder::new()
1703                .with_url("hdfs://127.0.0.1:9000")
1704                .with_io_runtime(Runtime::new().unwrap())
1705                .build()
1706                .is_ok()
1707        );
1708
1709        let rt = Runtime::new().unwrap();
1710        assert!(
1711            ClientBuilder::new()
1712                .with_url("hdfs://127.0.0.1:9000")
1713                .with_io_runtime(rt.handle().clone())
1714                .build()
1715                .is_ok()
1716        );
1717    }
1718
1719    #[test]
1720    fn test_with_user_sets_relative_path_home_dir() {
1721        let client = ClientBuilder::new()
1722            .with_url("hdfs://127.0.0.1:9000")
1723            .with_user("alice")
1724            .build()
1725            .unwrap();
1726
1727        let (_, resolved) = client.mount_table.resolve("file");
1728        assert_eq!(resolved, "/user/alice/file");
1729    }
1730
1731    #[test]
1732    fn test_kerberos_credentials_set_principal_home_dir() {
1733        let client = ClientBuilder::new()
1734            .with_url("hdfs://127.0.0.1:9000")
1735            .with_config([("hadoop.security.authentication", "kerberos")])
1736            .with_kerberos_principal("alice@EXAMPLE.COM")
1737            .with_kerberos_cache("FILE:/run/krb5/alice.ccache")
1738            .build()
1739            .unwrap();
1740
1741        let (_, resolved) = client.mount_table.resolve("file");
1742        assert_eq!(resolved, "/user/alice/file");
1743    }
1744
1745    #[test]
1746    fn test_explicit_kerberos_credentials_require_principal() {
1747        let error = ClientBuilder::new()
1748            .with_url("hdfs://127.0.0.1:9000")
1749            .with_kerberos_keytab("client.keytab")
1750            .build()
1751            .unwrap_err();
1752
1753        assert!(error.to_string().contains("principal is required"));
1754    }
1755
1756    #[test]
1757    fn test_set_conf_dir() {
1758        assert!(
1759            ClientBuilder::new()
1760                .with_url("hdfs://127.0.0.1:9000")
1761                .with_config_dir("target/test")
1762                .build()
1763                .is_ok()
1764        )
1765    }
1766}