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