Skip to main content

vivacity_core/
pathutil.rs

1//! Exact ports of `Composer\Util\Filesystem`: `normalizePath`,
2//! `findShortestPath`, `findShortestPathCode` (Composer 2.10.3). They decide
3//! the paths written into installed.json/installed.php, the autoload files
4//! and the bin proxies; checked by tests/oracle_installers.rs.
5//! Unix paths only (no `C:`/`file://` prefix).
6
7/// `std::fs::canonicalize` without the Windows verbatim prefix (`\\?\`).
8/// PHP `realpath()` (and therefore Composer) returns classic Win32 paths
9/// (`C:\x`), and `normalize_path` would turn `\\?\C:\x` into `//?/C:/x` —
10/// a form neither the Win32 APIs nor the paths generated into the autoload
11/// files understand. Elsewhere: `std::fs::canonicalize` as-is.
12pub fn canonicalize(path: impl AsRef<std::path::Path>) -> std::io::Result<std::path::PathBuf> {
13    std::fs::canonicalize(path).map(strip_verbatim)
14}
15
16#[cfg(not(windows))]
17fn strip_verbatim(p: std::path::PathBuf) -> std::path::PathBuf {
18    p
19}
20
21/// `\\?\C:\x` → `C:\x`, `\\?\UNC\srv\share\x` → `\\srv\share\x`. The classic
22/// form is returned EVEN beyond MAX_PATH: Rust (≥1.58) converts back to
23/// verbatim in its own syscalls, and PHP (≥7.1) does the same on its side —
24/// PHP's `realpath()` in fact returns the classic long form, so that is the
25/// form that preserves parity (verified: install + autoload under a
26/// 307-character root, `LongPathsEnabled=0`). The verbatim fallback only
27/// covers shapes a re-stat cannot find again (reserved component, trailing
28/// dot/space… — paths classic Win32 would mangle).
29#[cfg(windows)]
30fn strip_verbatim(p: std::path::PathBuf) -> std::path::PathBuf {
31    use std::path::{Component, Prefix};
32    let mut comps = p.components();
33    let Some(Component::Prefix(prefix)) = comps.next() else {
34        return p;
35    };
36    let root = match prefix.kind() {
37        Prefix::VerbatimDisk(d) => format!("{}:\\", d as char),
38        Prefix::VerbatimUNC(server, share) => format!(
39            "\\\\{}\\{}",
40            server.to_string_lossy(),
41            share.to_string_lossy()
42        ),
43        _ => return p,
44    };
45    let mut out = std::path::PathBuf::from(root);
46    for c in comps {
47        if !matches!(c, Component::RootDir) {
48            out.push(c.as_os_str());
49        }
50    }
51    // The classic form must stay openable (path < MAX_PATH, no reserved
52    // component): otherwise keep the verbatim form.
53    if out.symlink_metadata().is_ok() {
54        out
55    } else {
56        p
57    }
58}
59
60/// `Filesystem::isAbsolutePath`: `/…`, `\…`, `C:/…`/`C:\…`, or a stream
61/// wrapper (`phar://…`). On Unix only `/` exists in practice; the
62/// `starts_with('/')` form of the test missed Windows drive-letter paths.
63pub fn is_absolute_path(path: &str) -> bool {
64    if path.starts_with('/') || path.starts_with('\\') || path.contains("://") {
65        return true;
66    }
67    let b = path.as_bytes();
68    b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'/' || b[2] == b'\\')
69}
70
71/// `Filesystem::normalizePath`: single slashes, `.`/`..` resolution, no
72/// trailing slash (except for the root).
73/// True when `normalize_path` would return `path` unchanged: absolute,
74/// forward slashes only, no empty / `.` / `..` segment, no trailing slash.
75pub fn is_normalized_absolute(path: &str) -> bool {
76    path.starts_with('/')
77        && !path.starts_with("//")
78        && !path.ends_with('/')
79        && !path.contains('\\')
80        && !path[1..]
81            .split('/')
82            .any(|seg| seg.is_empty() || seg == "." || seg == "..")
83}
84
85/// `normalize_path` without the allocation when there is nothing to do —
86/// the case of every scanned file (a normalised directory joined to a name).
87pub fn normalize_path_cow(path: &str) -> std::borrow::Cow<'_, str> {
88    if is_normalized_absolute(path) {
89        std::borrow::Cow::Borrowed(path)
90    } else {
91        std::borrow::Cow::Owned(normalize_path(path))
92    }
93}
94
95pub fn normalize_path(path: &str) -> String {
96    let path = path.replace('\\', "/");
97    let (absolute, rest) = if path.starts_with("//") && path.len() > 2 {
98        ("//", &path[2..])
99    } else if let Some(r) = path.strip_prefix('/') {
100        ("/", r)
101    } else {
102        ("", path.as_str())
103    };
104    let mut parts: Vec<&str> = Vec::new();
105    let mut up = false;
106    for chunk in rest.split('/') {
107        if chunk == ".." && (!absolute.is_empty() || up) {
108            parts.pop();
109            up = !(parts.is_empty() || parts.last() == Some(&".."));
110        } else if chunk != "." && !chunk.is_empty() {
111            parts.push(chunk);
112            up = chunk != "..";
113        }
114    }
115    format!("{absolute}{}", parts.join("/"))
116}
117
118/// PHP `dirname()` on a normalised Unix path.
119/// PHP `dirname()` on a normalised Unix path.
120pub fn php_dirname(p: &str) -> String {
121    match p.rfind('/') {
122        None => ".".to_owned(),
123        Some(0) => "/".to_owned(),
124        Some(i) => p[..i].to_owned(),
125    }
126}
127
128/// PHP `basename()` on a normalised Unix path.
129fn php_basename(p: &str) -> &str {
130    p.rsplit('/').next().unwrap_or(p)
131}
132
133/// Loop of `findShortestPath(Code)`: walks `to` up until a prefix of `from`
134/// (whole-segment comparison), `/` or `.`. Composer requires absolute paths
135/// (exception otherwise); here a relative path stops at `.` and the caller
136/// returns `to` as is, without looping.
137fn common_path(from: &str, to: &str) -> String {
138    let mut common = to.to_owned();
139    while !format!("{from}/").starts_with(&format!("{common}/")) && common != "/" && common != "." {
140        common = php_dirname(&common);
141    }
142    common
143}
144
145/// `Filesystem::findShortestPath($from, $to, $directories, $preferRelative = false)`.
146/// Both paths must be absolute.
147pub fn find_shortest_path(from: &str, to: &str, directories: bool) -> String {
148    find_shortest_path_with(from, to, directories, false)
149}
150
151/// `findShortestPath` with `$preferRelative`: when true, a path that only
152/// shares the root with `from` is still written relative (`../../..`),
153/// which is how a symlinked `path` package points at its source.
154pub fn find_shortest_path_with(
155    from: &str,
156    to: &str,
157    directories: bool,
158    prefer_relative: bool,
159) -> String {
160    let mut from = normalize_path(from);
161    let to = normalize_path(to);
162    if directories {
163        from = format!("{}/dummy_file", from.trim_end_matches('/'));
164    }
165    if php_dirname(&from) == php_dirname(&to) {
166        return format!("./{}", php_basename(&to));
167    }
168    let common = common_path(&from, &to);
169    if !from.starts_with(&common) || common == "." {
170        return to;
171    }
172    let common = format!("{}/", common.trim_end_matches('/'));
173    let depth = from[common.len().min(from.len())..].matches('/').count();
174    if !prefer_relative && common == "/" && depth > 1 {
175        return to;
176    }
177    let result = format!(
178        "{}{}",
179        "../".repeat(depth),
180        &to[common.len().min(to.len())..]
181    );
182    if result.is_empty() {
183        "./".to_owned()
184    } else {
185        result
186    }
187}
188
189/// `Filesystem::findShortestPathCode($from, $to, $directories, $staticCode, $preferRelative = false)`:
190/// a PHP expression relative to `__DIR__`.
191pub fn find_shortest_path_code(
192    from: &str,
193    to: &str,
194    directories: bool,
195    static_code: bool,
196) -> String {
197    let from = normalize_path(from);
198    let to = normalize_path(to);
199    if from == to {
200        return if directories { "__DIR__" } else { "__FILE__" }.to_owned();
201    }
202    let common = common_path(&from, &to);
203    if !from.starts_with(&common) || common == "." {
204        return php_str(&to);
205    }
206    let common = format!("{}/", common.trim_end_matches('/'));
207    if to.starts_with(&format!("{from}/")) {
208        return format!("__DIR__ . {}", php_str(&to[from.len()..]));
209    }
210    let depth =
211        from[common.len().min(from.len())..].matches('/').count() + usize::from(directories);
212    if common == "/" && depth > 1 {
213        return php_str(&to);
214    }
215    let code = if static_code {
216        format!("__DIR__ . '{}'", "/..".repeat(depth))
217    } else {
218        format!("{}__DIR__{}", "dirname(".repeat(depth), ")".repeat(depth))
219    };
220    let rel = &to[common.len().min(to.len())..];
221    if rel.is_empty() {
222        code
223    } else {
224        format!("{code}.{}", php_str(&format!("/{rel}")))
225    }
226}
227
228/// `var_export()` of a string: single quotes, `\` and `'` escaped.
229pub fn php_str(s: &str) -> String {
230    let mut out = String::with_capacity(s.len() + 2);
231    out.push('\'');
232    for c in s.chars() {
233        match c {
234            '\'' => out.push_str("\\'"),
235            '\\' => out.push_str("\\\\"),
236            c => out.push(c),
237        }
238    }
239    out.push('\'');
240    out
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn canonicalize_has_no_verbatim_prefix() {
249        let tmp = tempfile::tempdir().expect("tmp");
250        let real = canonicalize(tmp.path()).expect("canonicalize");
251        let s = real.to_string_lossy().into_owned();
252        assert!(!s.starts_with(r"\\?\"), "verbatim prefix not stripped: {s}");
253        // The returned form must stay openable and normalize cleanly.
254        assert!(real.is_dir());
255        assert!(!normalize_path(&s).starts_with("//?/"), "{s}");
256    }
257
258    /// Beyond MAX_PATH, `canonicalize` returns the classic form (no `\\?\`)
259    /// and that form stays readable — it is what PHP `realpath()` would
260    /// return, hence what the generated files must contain.
261    #[cfg(windows)]
262    #[test]
263    fn canonicalize_strips_verbatim_beyond_max_path() {
264        let tmp = tempfile::tempdir().expect("tmp");
265        let mut deep = tmp.path().to_path_buf();
266        while deep.as_os_str().len() < 300 {
267            deep.push("abcdefghijklmnopqrstuvwxyz0123456789");
268        }
269        std::fs::create_dir_all(&deep).expect("mkdir deep");
270        std::fs::write(deep.join("f.txt"), b"x").expect("write");
271        let real = canonicalize(&deep).expect("canonicalize");
272        assert!(real.as_os_str().len() > 260, "{}", real.display());
273        assert!(
274            !real.to_string_lossy().starts_with(r"\\?\"),
275            "verbatim prefix beyond MAX_PATH: {}",
276            real.display()
277        );
278        assert!(std::fs::read(real.join("f.txt")).is_ok(), "unreadable form");
279    }
280
281    #[test]
282    fn normalize() {
283        assert_eq!(normalize_path("/a/b/../c/./d/"), "/a/c/d");
284        assert_eq!(normalize_path("app/"), "app");
285        assert_eq!(normalize_path("/a//b"), "/a/b");
286        assert_eq!(normalize_path("../x"), "../x");
287        assert_eq!(
288            normalize_path("/p/web/app/plugins/x/"),
289            "/p/web/app/plugins/x"
290        );
291    }
292
293    #[test]
294    fn shortest_paths_directories() {
295        assert_eq!(
296            find_shortest_path("/p/vendor/composer", "/p/vendor", true),
297            "../"
298        );
299        assert_eq!(
300            find_shortest_path("/p/vendor/composer", "/p", true),
301            "../../"
302        );
303        assert_eq!(find_shortest_path("/p/vendor", "/p/app", true), "../app");
304        assert_eq!(find_shortest_path("/p", "/p/app/x", true), "app/x");
305        assert_eq!(
306            find_shortest_path("/p/vendor/composer", "/p/vendor/a/b", true),
307            "../a/b"
308        );
309        assert_eq!(
310            find_shortest_path("/p/vendor/composer", "/p/vendor/composer/x", true),
311            "./x"
312        );
313        assert_eq!(
314            find_shortest_path("/p/vendor/composer", "/p/web/app/plugins/x", true),
315            "../../web/app/plugins/x"
316        );
317        assert_eq!(
318            find_shortest_path("/p/vendor/composer", "/q/x", true),
319            "/q/x"
320        );
321        assert_eq!(
322            find_shortest_path("/p/vendor/composer", "/p/vendor/composer", true),
323            "./"
324        );
325    }
326
327    #[test]
328    fn shortest_path_codes() {
329        assert_eq!(
330            find_shortest_path_code("/p/vendor/composer", "/p/vendor", true, true),
331            "__DIR__ . '/..'"
332        );
333        assert_eq!(
334            find_shortest_path_code("/p/vendor/composer", "/p", true, true),
335            "__DIR__ . '/../..'"
336        );
337        assert_eq!(
338            find_shortest_path_code("/p/vendor/composer", "/p/vendor", true, false),
339            "dirname(__DIR__)"
340        );
341        assert_eq!(
342            find_shortest_path_code("/p/vendor", "/p", true, false),
343            "dirname(__DIR__)"
344        );
345        assert_eq!(
346            find_shortest_path_code("/p/vendor", "/p/vendor/composer", true, false),
347            "__DIR__ . '/composer'"
348        );
349        assert_eq!(
350            find_shortest_path_code("/p/vendor/composer", "/p/vendor/composer", true, false),
351            "__DIR__"
352        );
353        assert_eq!(
354            find_shortest_path_code("/p/vendor/composer", "/p/web/x", true, true),
355            "__DIR__ . '/../..'.'/web/x'"
356        );
357    }
358}