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