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).
73pub fn normalize_path(path: &str) -> String {
74    let path = path.replace('\\', "/");
75    let (absolute, rest) = if path.starts_with("//") && path.len() > 2 {
76        ("//", &path[2..])
77    } else if let Some(r) = path.strip_prefix('/') {
78        ("/", r)
79    } else {
80        ("", path.as_str())
81    };
82    let mut parts: Vec<&str> = Vec::new();
83    let mut up = false;
84    for chunk in rest.split('/') {
85        if chunk == ".." && (!absolute.is_empty() || up) {
86            parts.pop();
87            up = !(parts.is_empty() || parts.last() == Some(&".."));
88        } else if chunk != "." && !chunk.is_empty() {
89            parts.push(chunk);
90            up = chunk != "..";
91        }
92    }
93    format!("{absolute}{}", parts.join("/"))
94}
95
96/// PHP `dirname()` on a normalised Unix path.
97fn php_dirname(p: &str) -> String {
98    match p.rfind('/') {
99        None => ".".to_owned(),
100        Some(0) => "/".to_owned(),
101        Some(i) => p[..i].to_owned(),
102    }
103}
104
105/// PHP `basename()` on a normalised Unix path.
106fn php_basename(p: &str) -> &str {
107    p.rsplit('/').next().unwrap_or(p)
108}
109
110/// Loop of `findShortestPath(Code)`: walks `to` up until a prefix of `from`
111/// (whole-segment comparison), `/` or `.`. Composer requires absolute paths
112/// (exception otherwise); here a relative path stops at `.` and the caller
113/// returns `to` as is, without looping.
114fn common_path(from: &str, to: &str) -> String {
115    let mut common = to.to_owned();
116    while !format!("{from}/").starts_with(&format!("{common}/")) && common != "/" && common != "." {
117        common = php_dirname(&common);
118    }
119    common
120}
121
122/// `Filesystem::findShortestPath($from, $to, $directories, $preferRelative = false)`.
123/// Both paths must be absolute.
124pub fn find_shortest_path(from: &str, to: &str, directories: bool) -> String {
125    let mut from = normalize_path(from);
126    let to = normalize_path(to);
127    if directories {
128        from = format!("{}/dummy_file", from.trim_end_matches('/'));
129    }
130    if php_dirname(&from) == php_dirname(&to) {
131        return format!("./{}", php_basename(&to));
132    }
133    let common = common_path(&from, &to);
134    if !from.starts_with(&common) || common == "." {
135        return to;
136    }
137    let common = format!("{}/", common.trim_end_matches('/'));
138    let depth = from[common.len().min(from.len())..].matches('/').count();
139    if common == "/" && depth > 1 {
140        return to;
141    }
142    let result = format!(
143        "{}{}",
144        "../".repeat(depth),
145        &to[common.len().min(to.len())..]
146    );
147    if result.is_empty() {
148        "./".to_owned()
149    } else {
150        result
151    }
152}
153
154/// `Filesystem::findShortestPathCode($from, $to, $directories, $staticCode, $preferRelative = false)`:
155/// a PHP expression relative to `__DIR__`.
156pub fn find_shortest_path_code(
157    from: &str,
158    to: &str,
159    directories: bool,
160    static_code: bool,
161) -> String {
162    let from = normalize_path(from);
163    let to = normalize_path(to);
164    if from == to {
165        return if directories { "__DIR__" } else { "__FILE__" }.to_owned();
166    }
167    let common = common_path(&from, &to);
168    if !from.starts_with(&common) || common == "." {
169        return php_str(&to);
170    }
171    let common = format!("{}/", common.trim_end_matches('/'));
172    if to.starts_with(&format!("{from}/")) {
173        return format!("__DIR__ . {}", php_str(&to[from.len()..]));
174    }
175    let depth =
176        from[common.len().min(from.len())..].matches('/').count() + usize::from(directories);
177    if common == "/" && depth > 1 {
178        return php_str(&to);
179    }
180    let code = if static_code {
181        format!("__DIR__ . '{}'", "/..".repeat(depth))
182    } else {
183        format!("{}__DIR__{}", "dirname(".repeat(depth), ")".repeat(depth))
184    };
185    let rel = &to[common.len().min(to.len())..];
186    if rel.is_empty() {
187        code
188    } else {
189        format!("{code}.{}", php_str(&format!("/{rel}")))
190    }
191}
192
193/// `var_export()` of a string: single quotes, `\` and `'` escaped.
194pub fn php_str(s: &str) -> String {
195    let mut out = String::with_capacity(s.len() + 2);
196    out.push('\'');
197    for c in s.chars() {
198        match c {
199            '\'' => out.push_str("\\'"),
200            '\\' => out.push_str("\\\\"),
201            c => out.push(c),
202        }
203    }
204    out.push('\'');
205    out
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn canonicalize_has_no_verbatim_prefix() {
214        let tmp = tempfile::tempdir().expect("tmp");
215        let real = canonicalize(tmp.path()).expect("canonicalize");
216        let s = real.to_string_lossy().into_owned();
217        assert!(!s.starts_with(r"\\?\"), "verbatim prefix not stripped: {s}");
218        // The returned form must stay openable and normalize cleanly.
219        assert!(real.is_dir());
220        assert!(!normalize_path(&s).starts_with("//?/"), "{s}");
221    }
222
223    /// Beyond MAX_PATH, `canonicalize` returns the classic form (no `\\?\`)
224    /// and that form stays readable — it is what PHP `realpath()` would
225    /// return, hence what the generated files must contain.
226    #[cfg(windows)]
227    #[test]
228    fn canonicalize_strips_verbatim_beyond_max_path() {
229        let tmp = tempfile::tempdir().expect("tmp");
230        let mut deep = tmp.path().to_path_buf();
231        while deep.as_os_str().len() < 300 {
232            deep.push("abcdefghijklmnopqrstuvwxyz0123456789");
233        }
234        std::fs::create_dir_all(&deep).expect("mkdir deep");
235        std::fs::write(deep.join("f.txt"), b"x").expect("write");
236        let real = canonicalize(&deep).expect("canonicalize");
237        assert!(real.as_os_str().len() > 260, "{}", real.display());
238        assert!(
239            !real.to_string_lossy().starts_with(r"\\?\"),
240            "verbatim prefix beyond MAX_PATH: {}",
241            real.display()
242        );
243        assert!(std::fs::read(real.join("f.txt")).is_ok(), "unreadable form");
244    }
245
246    #[test]
247    fn normalize() {
248        assert_eq!(normalize_path("/a/b/../c/./d/"), "/a/c/d");
249        assert_eq!(normalize_path("app/"), "app");
250        assert_eq!(normalize_path("/a//b"), "/a/b");
251        assert_eq!(normalize_path("../x"), "../x");
252        assert_eq!(
253            normalize_path("/p/web/app/plugins/x/"),
254            "/p/web/app/plugins/x"
255        );
256    }
257
258    #[test]
259    fn shortest_paths_directories() {
260        assert_eq!(
261            find_shortest_path("/p/vendor/composer", "/p/vendor", true),
262            "../"
263        );
264        assert_eq!(
265            find_shortest_path("/p/vendor/composer", "/p", true),
266            "../../"
267        );
268        assert_eq!(find_shortest_path("/p/vendor", "/p/app", true), "../app");
269        assert_eq!(find_shortest_path("/p", "/p/app/x", true), "app/x");
270        assert_eq!(
271            find_shortest_path("/p/vendor/composer", "/p/vendor/a/b", true),
272            "../a/b"
273        );
274        assert_eq!(
275            find_shortest_path("/p/vendor/composer", "/p/vendor/composer/x", true),
276            "./x"
277        );
278        assert_eq!(
279            find_shortest_path("/p/vendor/composer", "/p/web/app/plugins/x", true),
280            "../../web/app/plugins/x"
281        );
282        assert_eq!(
283            find_shortest_path("/p/vendor/composer", "/q/x", true),
284            "/q/x"
285        );
286        assert_eq!(
287            find_shortest_path("/p/vendor/composer", "/p/vendor/composer", true),
288            "./"
289        );
290    }
291
292    #[test]
293    fn shortest_path_codes() {
294        assert_eq!(
295            find_shortest_path_code("/p/vendor/composer", "/p/vendor", true, true),
296            "__DIR__ . '/..'"
297        );
298        assert_eq!(
299            find_shortest_path_code("/p/vendor/composer", "/p", true, true),
300            "__DIR__ . '/../..'"
301        );
302        assert_eq!(
303            find_shortest_path_code("/p/vendor/composer", "/p/vendor", true, false),
304            "dirname(__DIR__)"
305        );
306        assert_eq!(
307            find_shortest_path_code("/p/vendor", "/p", true, false),
308            "dirname(__DIR__)"
309        );
310        assert_eq!(
311            find_shortest_path_code("/p/vendor", "/p/vendor/composer", true, false),
312            "__DIR__ . '/composer'"
313        );
314        assert_eq!(
315            find_shortest_path_code("/p/vendor/composer", "/p/vendor/composer", true, false),
316            "__DIR__"
317        );
318        assert_eq!(
319            find_shortest_path_code("/p/vendor/composer", "/p/web/x", true, true),
320            "__DIR__ . '/../..'.'/web/x'"
321        );
322    }
323}