objc2_core_foundation/
url.rs

1#[allow(unused_imports)]
2use crate::{CFIndex, CFRetained, CFURL};
3
4/// [`Path`][std::path::Path] conversion.
5#[cfg(feature = "std")]
6#[cfg(unix)] // TODO: Use as_encoded_bytes/from_encoded_bytes_unchecked once in MSRV.
7impl CFURL {
8    #[inline]
9    fn from_path(
10        path: &std::path::Path,
11        is_directory: bool,
12        base: Option<&CFURL>,
13    ) -> Option<CFRetained<CFURL>> {
14        use std::os::unix::ffi::OsStrExt;
15
16        // CFURL expects to get a string with the system encoding, and will
17        // internally handle the different encodings, depending on if compiled
18        // for Apple platforms or Windows (which is very rare, but could
19        // technically happen).
20        let bytes = path.as_os_str().as_bytes();
21
22        // Never gonna happen, allocations can't be this large in Rust.
23        debug_assert!(bytes.len() < CFIndex::MAX as usize);
24        let len = bytes.len() as CFIndex;
25
26        if let Some(base) = base {
27            // The base URL must be a directory URL (have a trailing "/").
28            // If the path is absolute, this URL is ignored.
29            //
30            // TODO: Expose this publicly?
31            unsafe {
32                Self::from_file_system_representation_relative_to_base(
33                    None,
34                    bytes.as_ptr(),
35                    len,
36                    is_directory,
37                    Some(base),
38                )
39            }
40        } else {
41            unsafe {
42                Self::from_file_system_representation(None, bytes.as_ptr(), len, is_directory)
43            }
44        }
45    }
46
47    /// Create a file url from a [`Path`][std::path::Path].
48    ///
49    /// This is useful because a lot of CoreFoundation APIs use `CFURL` to
50    /// represent file-system paths as well.
51    ///
52    /// Non-unicode parts of the URL will be percent-encoded, and the url will
53    /// have the scheme `file://`.
54    ///
55    /// If the path is relative, it will be considered relative to the current
56    /// directory.
57    ///
58    /// Returns `None` when given an invalid path (such as a path containing
59    /// interior NUL bytes). The exact checks are not guaranteed.
60    ///
61    ///
62    /// # Examples
63    ///
64    /// ```
65    /// use std::path::Path;
66    /// use objc2_core_foundation::CFURL;
67    ///
68    /// // Absolute paths work as you'd expect.
69    /// let url = CFURL::from_file_path("/tmp/file.txt").unwrap();
70    /// assert_eq!(url.to_file_path().unwrap(), Path::new("/tmp/file.txt"));
71    ///
72    /// // Relative paths are relative to the current directory.
73    /// let url = CFURL::from_file_path("foo.txt").unwrap();
74    /// assert_eq!(url.to_file_path().unwrap(), std::env::current_dir().unwrap().join("foo.txt"));
75    ///
76    /// // Some invalid paths return `None`.
77    /// assert!(CFURL::from_file_path("").is_none());
78    /// // Another example of an invalid path containing interior NUL bytes.
79    /// assert!(CFURL::from_file_path("/a/\0a").is_none());
80    ///
81    /// // Trailing NUL bytes are stripped.
82    /// // NOTE: This only seems to work on some versions of CoreFoundation.
83    /// let url = CFURL::from_file_path("/a\0\0").unwrap();
84    /// assert_eq!(url.to_file_path().unwrap(), Path::new("/a"));
85    /// ```
86    #[inline]
87    #[doc(alias = "CFURLCreateFromFileSystemRepresentation")]
88    pub fn from_file_path<P: AsRef<std::path::Path>>(path: P) -> Option<CFRetained<CFURL>> {
89        Self::from_path(path.as_ref(), false, None)
90    }
91
92    /// Create a directory url from a [`Path`][std::path::Path].
93    ///
94    /// This differs from [`from_file_path`][Self::from_file_path] in that the
95    /// path is treated as a directory, which means that other normalization
96    /// rules are applied to it (to make it end with a `/`).
97    ///
98    ///
99    /// # Examples
100    ///
101    /// ```
102    /// use std::path::Path;
103    /// use objc2_core_foundation::CFURL;
104    ///
105    /// // Directory paths get trailing slashes appended
106    /// let url = CFURL::from_directory_path("/Library").unwrap();
107    /// assert_eq!(url.to_file_path().unwrap(), Path::new("/Library/"));
108    ///
109    /// // Unless they already have them.
110    /// let url = CFURL::from_directory_path("/Library/").unwrap();
111    /// assert_eq!(url.to_file_path().unwrap(), Path::new("/Library/"));
112    ///
113    /// // Similarly for relative paths.
114    /// let url = CFURL::from_directory_path("foo").unwrap();
115    /// assert_eq!(url.to_file_path().unwrap(), std::env::current_dir().unwrap().join("foo/"));
116    ///
117    /// // Various dots may be stripped.
118    /// let url = CFURL::from_directory_path("/Library/././.").unwrap();
119    /// assert_eq!(url.to_file_path().unwrap(), Path::new("/Library/"));
120    ///
121    /// // Though of course not if they have semantic meaning.
122    /// let url = CFURL::from_directory_path("/Library/..").unwrap();
123    /// assert_eq!(url.to_file_path().unwrap(), Path::new("/Library/.."));
124    /// ```
125    #[inline]
126    #[doc(alias = "CFURLCreateFromFileSystemRepresentation")]
127    pub fn from_directory_path<P: AsRef<std::path::Path>>(path: P) -> Option<CFRetained<CFURL>> {
128        Self::from_path(path.as_ref(), true, None)
129    }
130
131    /// Extract the path part of the URL as a [`PathBuf`][std::path::PathBuf].
132    ///
133    /// This will return a path regardless of whether the scheme is `file://`.
134    /// It is the responsibility of the caller to ensure that the URL is valid
135    /// to use as a file URL.
136    ///
137    ///
138    /// # Compatibility note
139    ///
140    /// This currently does not work for non-unicode paths (which are fairly
141    /// rare on macOS since HFS+ was been superseded by APFS).
142    ///
143    /// This also currently always returns absolute paths (it converts
144    /// relative URL paths to absolute), but that may change in the future.
145    ///
146    ///
147    /// # Examples
148    ///
149    /// ```
150    /// use std::path::Path;
151    /// use objc2_core_foundation::{CFURL, CFString};
152    ///
153    /// let url = CFURL::from_string(None, &CFString::from_str("file:///tmp/foo.txt"), None).unwrap();
154    /// assert_eq!(url.to_file_path().unwrap(), Path::new("/tmp/foo.txt"));
155    /// ```
156    ///
157    /// See also the examples in [`from_file_path`][Self::from_file_path].
158    #[doc(alias = "CFURLGetFileSystemRepresentation")]
159    pub fn to_file_path(&self) -> Option<std::path::PathBuf> {
160        use std::os::unix::ffi::OsStrExt;
161
162        const PATH_MAX: usize = 1024;
163
164        // TODO: if a path is relative with no base, how do we get that
165        // relative path out again (without adding current dir?).
166        //
167        // TODO: Should we do something to handle paths larger than PATH_MAX?
168        // What can we even do? (since it's impossible for us to tell why the
169        // conversion failed, so we can't know if we need to allocate, or if
170        // the URL just cannot be converted).
171        let mut buf = [0u8; PATH_MAX];
172        let result = unsafe {
173            self.file_system_representation(true, buf.as_mut_ptr(), buf.len() as CFIndex)
174        };
175        if !result {
176            return None;
177        }
178
179        // SAFETY: CF is guaranteed to null-terminate the buffer if
180        // the function succeeded.
181        let cstr = unsafe { core::ffi::CStr::from_bytes_until_nul(&buf).unwrap_unchecked() };
182
183        let path = std::ffi::OsStr::from_bytes(cstr.to_bytes());
184        Some(path.into())
185    }
186}
187
188/// String conversion.
189impl CFURL {
190    /// Create an URL from a `CFString`.
191    ///
192    /// Returns `None` if the URL is considered invalid by CoreFoundation. The
193    /// exact details of which strings are invalid URLs are considered an
194    /// implementation detail.
195    ///
196    /// Note in particular that not all strings that the URL spec considers
197    /// invalid are considered invalid by CoreFoundation too. If you need
198    /// spec-compliant parsing, consider the [`url`] crate instead.
199    ///
200    /// [`url`]: https://docs.rs/url/
201    ///
202    /// # Examples
203    ///
204    /// Construct and inspect a `CFURL`.
205    ///
206    /// ```
207    /// use objc2_core_foundation::{
208    ///     CFString, CFURL, CFURLCopyHostName, CFURLCopyScheme, CFURLCopyPath,
209    /// };
210    ///
211    /// let url = CFURL::from_string(None, &CFString::from_str("http://example.com/foo"), None).unwrap();
212    /// assert_eq!(url.string().to_string(), "http://example.com/foo");
213    /// assert_eq!(CFURLCopyScheme(&url).unwrap().to_string(), "http");
214    /// assert_eq!(CFURLCopyHostName(&url).unwrap().to_string(), "example.com");
215    /// assert_eq!(CFURLCopyPath(&url).unwrap().to_string(), "/foo");
216    /// ```
217    ///
218    /// Fail parsing certain strings.
219    ///
220    /// ```
221    /// use objc2_core_foundation::{CFString, CFURL};
222    ///
223    /// // Percent-encoding needs two characters.
224    /// assert_eq!(CFURL::from_string(None, &CFString::from_str("http://example.com/%A"), None), None);
225    ///
226    /// // Two hash-characters is disallowed.
227    /// assert_eq!(CFURL::from_string(None, &CFString::from_str("http://example.com/abc#a#b"), None), None);
228    /// ```
229    #[inline]
230    #[doc(alias = "CFURLCreateWithString")]
231    pub fn from_string(
232        allocator: Option<&crate::CFAllocator>,
233        url_string: &crate::CFString,
234        base_url: Option<&CFURL>,
235    ) -> Option<CFRetained<Self>> {
236        Self::__from_string(allocator, Some(url_string), base_url)
237    }
238
239    /// Create an URL from a string without checking it for validity.
240    ///
241    /// Returns `None` on some OS versions when the string contains interior
242    /// NUL bytes.
243    ///
244    /// # Safety
245    ///
246    /// The URL must be valid.
247    ///
248    /// Note that it is unclear whether this is actually a safety requirement,
249    /// or simply a correctness requirement. So we conservatively mark this
250    /// function as `unsafe`.
251    #[inline]
252    #[cfg(feature = "CFString")]
253    #[doc(alias = "CFURLCreateWithBytes")]
254    pub unsafe fn from_str_unchecked(s: &str) -> Option<CFRetained<Self>> {
255        let ptr = s.as_ptr();
256
257        // Never gonna happen, allocations can't be this large in Rust.
258        debug_assert!(s.len() < CFIndex::MAX as usize);
259        let len = s.len() as CFIndex;
260
261        let encoding = crate::CFStringBuiltInEncodings::EncodingUTF8;
262        // SAFETY: The pointer and length are valid, and the encoding is a
263        // superset of ASCII.
264        //
265        // Unlike `CFURLCreateWithString`, this does _not_ verify the URL at
266        // all, and thus we propagate the validity checks to the user. See
267        // also the source code for the checks:
268        // https://github.com/apple-oss-distributions/CF/blob/CF-1153.18/CFURL.c#L1882-L1970
269        unsafe { Self::with_bytes(None, ptr, len, encoding.0, None) }
270    }
271
272    /// Get the string-representation of the URL.
273    ///
274    /// The string may be overly sanitized (percent-encoded), do not rely on
275    /// this returning exactly the same string as was passed in
276    /// [`from_string`][Self::from_string].
277    #[doc(alias = "CFURLGetString")]
278    pub fn string(&self) -> CFRetained<crate::CFString> {
279        // URLs contain valid UTF-8, so this should only fail on allocation
280        // error.
281        self.__string().expect("failed getting string from CFURL")
282    }
283}
284
285#[cfg(unix)]
286#[cfg(test)]
287#[cfg(feature = "CFString")]
288#[cfg(feature = "std")]
289mod tests {
290    use std::ffi::OsStr;
291    use std::os::unix::ffi::OsStrExt;
292    use std::path::Path;
293    use std::{env::current_dir, string::ToString};
294
295    use crate::{CFString, CFURLPathStyle};
296
297    use super::*;
298
299    #[test]
300    fn from_string() {
301        let url =
302            CFURL::from_string(None, &CFString::from_str("https://example.com/xyz"), None).unwrap();
303        assert_eq!(url.to_file_path().unwrap(), Path::new("/xyz"));
304        assert_eq!(url.string().to_string(), "https://example.com/xyz");
305
306        // Invalid.
307        let url = CFURL::from_string(None, &CFString::from_str("\0"), None);
308        assert_eq!(url, None);
309
310        // Also invalid.
311        let url = CFURL::from_string(None, &CFString::from_str("http://example.com/%a"), None);
312        assert_eq!(url, None);
313
314        // Though using `from_str_unchecked` succeeds.
315        let url = unsafe { CFURL::from_str_unchecked("http://example.com/%a") }.unwrap();
316        assert_eq!(url.string().to_string(), "http://example.com/%25a");
317        assert_eq!(url.to_file_path().unwrap(), Path::new("/%a"));
318
319        let url = unsafe { CFURL::from_str_unchecked("/\0a\0") }.unwrap();
320        assert_eq!(url.string().to_string(), "/%00a%00");
321        assert_eq!(url.to_file_path(), None);
322    }
323
324    #[test]
325    fn to_string_may_extra_percent_encode() {
326        let url = CFURL::from_string(None, &CFString::from_str("["), None).unwrap();
327        assert_eq!(url.string().to_string(), "%5B");
328    }
329
330    #[test]
331    #[cfg(feature = "objc2")]
332    fn invalid_with_nul_bytes() {
333        // This is a bug in newer CF versions:
334        // https://github.com/swiftlang/swift-corelibs-foundation/issues/5200
335        let url = unsafe { CFURL::from_str_unchecked("a\0aaaaaa") };
336        if objc2::available!(macos = 12.0, ios = 15.0, watchos = 8.0, tvos = 15.0, ..) {
337            assert_eq!(url, None);
338        } else {
339            assert_eq!(url.unwrap().string().to_string(), "a%00aaaaaa");
340        }
341    }
342
343    #[test]
344    fn to_from_path() {
345        let url = CFURL::from_file_path("/").unwrap();
346        assert_eq!(url.to_file_path().unwrap(), Path::new("/"));
347
348        let url = CFURL::from_file_path("/abc/def").unwrap();
349        assert_eq!(url.to_file_path().unwrap(), Path::new("/abc/def"));
350
351        let url = CFURL::from_directory_path("/abc/def").unwrap();
352        assert_eq!(url.to_file_path().unwrap(), Path::new("/abc/def/"));
353
354        let url = CFURL::from_file_path("relative.txt").unwrap();
355        assert_eq!(
356            url.to_file_path(),
357            Some(current_dir().unwrap().join("relative.txt"))
358        );
359        assert_eq!(
360            url.absolute_url().unwrap().to_file_path(),
361            Some(current_dir().unwrap().join("relative.txt"))
362        );
363
364        let str = "/with space and wéird UTF-8 chars: 😀";
365        let url = CFURL::from_file_path(str).unwrap();
366        assert_eq!(url.to_file_path().unwrap(), Path::new(str));
367    }
368
369    #[test]
370    fn invalid_path() {
371        assert_eq!(CFURL::from_file_path(""), None);
372        assert_eq!(CFURL::from_file_path("/\0/a"), None);
373    }
374
375    #[test]
376    fn from_dir_strips_dot() {
377        let url = CFURL::from_directory_path("/Library/.").unwrap();
378        assert_eq!(url.to_file_path().unwrap(), Path::new("/Library/"));
379    }
380
381    /// Ensure that trailing NULs are completely stripped.
382    #[test]
383    #[cfg_attr(
384        not(target_os = "macos"),
385        ignore = "seems to work differently in the simulator"
386    )]
387    fn path_with_trailing_nul() {
388        let url = CFURL::from_file_path("/abc/def\0\0\0").unwrap();
389        assert_eq!(url.to_file_path().unwrap(), Path::new("/abc/def"));
390
391        let path = url
392            .file_system_path(CFURLPathStyle::CFURLPOSIXPathStyle)
393            .unwrap();
394        assert_eq!(path.to_string(), "/abc/def");
395        #[allow(deprecated)]
396        let path = url
397            .file_system_path(CFURLPathStyle::CFURLHFSPathStyle)
398            .unwrap();
399        assert!(path.to_string().ends_with(":abc:def")); // $DISK_NAME:abc:def
400        let path = url
401            .file_system_path(CFURLPathStyle::CFURLWindowsPathStyle)
402            .unwrap();
403        assert_eq!(path.to_string(), "\\abc\\def");
404    }
405
406    #[test]
407    fn path_with_base() {
408        let base = CFURL::from_directory_path("/abc/").unwrap();
409        let url = CFURL::from_path(Path::new("def"), false, Some(&base)).unwrap();
410        assert_eq!(url.to_file_path().unwrap(), Path::new("/abc/def"));
411        let url = CFURL::from_path(Path::new("def/"), false, Some(&base)).unwrap();
412        assert_eq!(url.to_file_path().unwrap(), Path::new("/abc/def/"));
413        let url = CFURL::from_path(Path::new("/def"), false, Some(&base)).unwrap();
414        assert_eq!(url.to_file_path().unwrap(), Path::new("/def"));
415    }
416
417    #[test]
418    fn path_invalid_utf8() {
419        // Non-root path.
420        let url = CFURL::from_file_path(OsStr::from_bytes(b"abc\xd4def/xyz")).unwrap();
421        assert_eq!(url.to_file_path().unwrap(), current_dir().unwrap()); // Huh?
422        assert!(url
423            .file_system_path(CFURLPathStyle::CFURLPOSIXPathStyle)
424            .is_none());
425        assert_eq!(url.string().to_string(), "abc%D4def/xyz");
426        assert_eq!(url.path().unwrap().to_string(), "abc%D4def/xyz");
427
428        // Root path.
429        // lone continuation byte (128) (invalid utf8)
430        let url = CFURL::from_file_path(OsStr::from_bytes(b"/\xf8a/b/c")).unwrap();
431        assert_eq!(
432            url.to_file_path().unwrap(),
433            OsStr::from_bytes(b"/\xf8a/b/c")
434        );
435        assert_eq!(url.string().to_string(), "file:///%F8a/b/c");
436        assert_eq!(url.path().unwrap().to_string(), "/%F8a/b/c");
437
438        // Joined paths
439        let url = CFURL::from_path(
440            Path::new(OsStr::from_bytes(b"sub\xd4/%D4")),
441            false,
442            Some(&url),
443        )
444        .unwrap();
445        assert_eq!(url.to_file_path(), None);
446        assert_eq!(url.string().to_string(), "sub%D4/%25D4");
447        assert_eq!(url.path().unwrap().to_string(), "sub%D4/%25D4");
448        let abs = url.absolute_url().unwrap();
449        assert_eq!(abs.to_file_path(), None);
450        assert_eq!(abs.string().to_string(), "file:///%F8a/b/sub%D4/%25D4");
451        assert_eq!(abs.path().unwrap().to_string(), "/%F8a/b/sub%D4/%25D4");
452    }
453
454    #[test]
455    fn path_percent_encoded() {
456        let url = CFURL::from_file_path("/%D4").unwrap();
457        assert_eq!(url.path().unwrap().to_string(), "/%25D4");
458        assert_eq!(url.to_file_path().unwrap(), Path::new("/%D4"));
459
460        let url = CFURL::from_file_path("/%invalid").unwrap();
461        assert_eq!(url.path().unwrap().to_string(), "/%25invalid");
462        assert_eq!(url.to_file_path().unwrap(), Path::new("/%invalid"));
463    }
464
465    #[test]
466    fn path_percent_encoded_eq() {
467        let normal = CFURL::from_file_path(OsStr::from_bytes(b"\xf8")).unwrap();
468        let percent = CFURL::from_file_path("%F8").unwrap();
469        // Not equal, even though the filesystem may consider these paths equal.
470        assert_ne!(normal, percent);
471    }
472
473    #[test]
474    #[allow(deprecated)]
475    #[ignore = "TODO: Crashes - is this unsound?"]
476    fn hfs_invalid_utf8() {
477        let url = CFURL::from_file_path(OsStr::from_bytes(b"\xd4")).unwrap();
478        assert!(url
479            .file_system_path(CFURLPathStyle::CFURLHFSPathStyle)
480            .is_none());
481    }
482}