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}