Skip to main content

vivacity_core/
path_install.rs

1//! `Composer\Downloader\PathDownloader` (docs/reference/PathDownloader.php)
2//! for Linux and macOS: a `path` package is laid out as a symbolic link to
3//! its source — relative through `findShortestPath(..., preferRelative)`
4//! when `transport-options.relative` (the default), absolute otherwise — or
5//! as a mirror (`symlink: false`, or `COMPOSER_MIRROR_PATH_REPOS`) copied
6//! through the `ArchivableFilesFinder` rules (docs/reference/
7//! ArchivableFilesFinder.php, GitExcludeFilter.php, BaseExcludeFilter.php,
8//! symfony-finder-Glob.php) and Symfony's `Filesystem::mirror`/`copy`
9//! (docs/reference/symfony-Filesystem.php):
10//!
11//! - VCS directories (`.git`, `.svn`, `.hg`, …) are skipped at any depth —
12//!   directories only, a `.git` *file* is copied;
13//! - the root `.gitattributes` lines `<pattern> export-ignore` /
14//!   `-export-ignore` (exactly two fields) exclude/re-include, the pattern
15//!   through `Glob::toRegex`, matched at any depth unless it starts with `/`;
16//! - a symbolic link is recreated with its raw target when it points to a
17//!   file or an empty directory inside the source; a link to a non-empty
18//!   directory, a dangling link or a link leaving the source is dropped;
19//! - empty directories are kept; a copied file gets `0666 & ~umask` plus the
20//!   source's executable bits and the source's mtime.
21//!
22//! Windows (junctions) is out of scope: `scope` routes such locks to the
23//! Composer fallback there.
24
25use crate::error::{Error, Result};
26use crate::pathutil::{find_shortest_path_with, normalize_path};
27use serde_json::Value;
28use std::path::{Path, PathBuf};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Strategy {
32    Symlink,
33    Mirror,
34}
35
36/// `computeAllowedStrategies`: `COMPOSER_MIRROR_PATH_REPOS` (PHP truthiness:
37/// anything but empty and `0`) then `transport-options.symlink`.
38pub fn strategy(transport_options: Option<&Value>) -> Strategy {
39    let mut current = Strategy::Symlink;
40    if std::env::var("COMPOSER_MIRROR_PATH_REPOS").is_ok_and(|v| !v.is_empty() && v != "0") {
41        current = Strategy::Mirror;
42    }
43    match transport_options.and_then(|t| t.get("symlink")) {
44        Some(Value::Bool(true)) => current = Strategy::Symlink,
45        Some(Value::Bool(false)) => current = Strategy::Mirror,
46        _ => {}
47    }
48    current
49}
50
51/// `($transportOptions + ['relative' => true])['relative'] === true`: absent
52/// means relative, any present value other than `true` means absolute.
53fn relative(transport_options: Option<&Value>) -> bool {
54    match transport_options.and_then(|t| t.get("relative")) {
55        Some(v) => v == &Value::Bool(true),
56        None => true,
57    }
58}
59
60fn realpath(p: &Path) -> Option<PathBuf> {
61    std::fs::canonicalize(p).ok()
62}
63
64/// `getInstallOperationAppendix`: what follows the operation line —
65/// `: Source already present` when the install path already resolves to
66/// the source, else the strategy and the dist url as written.
67pub fn install_appendix(
68    project_dir: &Path,
69    install_path: &Path,
70    dist_url: &str,
71    transport_options: Option<&Value>,
72) -> Result<String> {
73    let real_url = realpath(&project_dir.join(dist_url))
74        .ok_or_else(|| Error::Refused(format!("Failed to realpath {dist_url}")))?;
75    if realpath(install_path).as_deref() == Some(real_url.as_path()) {
76        return Ok(": Source already present".to_owned());
77    }
78    Ok(match strategy(transport_options) {
79        Strategy::Symlink => format!(": Symlinking from {dist_url}"),
80        Strategy::Mirror => format!(": Mirroring from {dist_url}"),
81    })
82}
83
84/// `download`: the refusal to install a package inside its own source.
85pub fn check_not_inside_source(
86    project_dir: &Path,
87    install_path: &Path,
88    dist_url: &str,
89    package_name: &str,
90) -> Result<()> {
91    let real_url = realpath(&project_dir.join(dist_url))
92        .filter(|p| p.is_dir())
93        .ok_or_else(|| {
94            Error::Refused(format!(
95                "Source path \"{dist_url}\" is not found for package {package_name}"
96            ))
97        })?;
98    let Some(real_path) = realpath(install_path) else {
99        return Ok(());
100    };
101    if real_path == real_url {
102        return Ok(());
103    }
104    let inside =
105        format!("{}/", real_path.display()).starts_with(&format!("{}/", real_url.display()));
106    if inside {
107        return Err(Error::Refused(format!(
108            "Package {package_name} cannot install to \"{}\" inside its source at \"{}\"",
109            real_path.display(),
110            real_url.display()
111        )));
112    }
113    Ok(())
114}
115
116/// `install` after the CLI printed the operation line: the existing path
117/// is removed, then the link is created or the source mirrored. Nothing
118/// happens when the path already resolves to the source.
119pub fn install(
120    project_dir: &Path,
121    install_path: &Path,
122    dist_url: &str,
123    transport_options: Option<&Value>,
124) -> Result<()> {
125    let real_url = realpath(&project_dir.join(dist_url))
126        .ok_or_else(|| Error::Refused(format!("Failed to realpath {dist_url}")))?;
127    if realpath(install_path).as_deref() == Some(real_url.as_path()) {
128        return Ok(());
129    }
130    remove_path(install_path)?;
131    match strategy(transport_options) {
132        Strategy::Symlink => {
133            // `$absolutePath = cwd/$path`, `findShortestPath($absolutePath,
134            // $realUrl, false, true)`: the leaf does not exist any more, the
135            // path is composed lexically on the (real) vendor directory.
136            let target = if relative(transport_options) {
137                // `Platform::getCwd()` is the physical project directory;
138                // the install path hangs from it lexically.
139                let cwd = realpath(project_dir).unwrap_or_else(|| project_dir.to_path_buf());
140                let absolute = match install_path.strip_prefix(project_dir) {
141                    Ok(rel) => format!("{}/{}", cwd.display(), rel.display()),
142                    Err(_) => install_path.to_string_lossy().into_owned(),
143                };
144                find_shortest_path_with(&absolute, &real_url.to_string_lossy(), false, true)
145            } else {
146                real_url.to_string_lossy().into_owned()
147            };
148            if let Some(parent) = install_path.parent() {
149                std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
150            }
151            symlink(Path::new(&format!("{target}/")), install_path)?;
152        }
153        Strategy::Mirror => {
154            let real_url = PathBuf::from(normalize_path(&real_url.to_string_lossy()));
155            mirror(&real_url, install_path)?;
156        }
157    }
158    Ok(())
159}
160
161#[cfg(unix)]
162fn symlink(target: &Path, link: &Path) -> Result<()> {
163    std::os::unix::fs::symlink(target, link).map_err(Error::io(link))
164}
165
166#[cfg(not(unix))]
167fn symlink(_target: &Path, link: &Path) -> Result<()> {
168    Err(Error::Unsupported(format!(
169        "path repositories are not installed natively on this platform ({})",
170        link.display()
171    )))
172}
173
174/// `Filesystem::removeDirectory` on a package path: a symbolic link is
175/// unlinked (never followed), a directory removed, a missing path ignored.
176pub fn remove_path(path: &Path) -> Result<()> {
177    match std::fs::symlink_metadata(path) {
178        Ok(m) if m.file_type().is_symlink() => std::fs::remove_file(path).map_err(Error::io(path)),
179        Ok(m) if m.is_dir() => std::fs::remove_dir_all(path).map_err(Error::io(path)),
180        Ok(_) => std::fs::remove_file(path).map_err(Error::io(path)),
181        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
182        Err(e) => Err(Error::io(path)(e)),
183    }
184}
185
186/// `PathDownloader::remove`: true when the install path *is* the source
187/// (`, source is still present in <path>`): nothing is removed then.
188pub fn is_own_source(project_dir: &Path, install_path: &str, dist_url: &str) -> bool {
189    let abs = |p: &str| {
190        if crate::pathutil::is_absolute_path(p) {
191            normalize_path(p)
192        } else {
193            normalize_path(&format!("{}/{p}", project_dir.display()))
194        }
195    };
196    abs(install_path) == abs(dist_url)
197}
198
199// ---------------------------------------------------------------------------
200// Mirror: ArchivableFilesFinder + Symfony Filesystem::mirror
201
202const VCS_DIRS: &[&str] = &[
203    ".svn",
204    "_svn",
205    "CVS",
206    "_darcs",
207    ".arch-params",
208    ".monotone",
209    ".bzr",
210    ".git",
211    ".hg",
212];
213
214struct ExcludePattern {
215    regex: pcre2::bytes::Regex,
216    negate: bool,
217}
218
219/// `GitExcludeFilter`: the root `.gitattributes` only.
220fn git_exclude_patterns(source: &Path) -> Vec<ExcludePattern> {
221    let Ok(text) = std::fs::read_to_string(source.join(".gitattributes")) else {
222        return Vec::new();
223    };
224    let mut out = Vec::new();
225    for line in text.lines() {
226        let line = line.trim();
227        if line.is_empty() || line.starts_with('#') {
228            continue;
229        }
230        let parts: Vec<&str> = line.split_whitespace().collect();
231        let rule = match parts.as_slice() {
232            [p, "export-ignore"] => (*p).to_owned(),
233            [p, "-export-ignore"] => format!("!{p}"),
234            _ => continue,
235        };
236        if let Some(p) = generate_pattern(&rule) {
237            out.push(p);
238        }
239    }
240    out
241}
242
243/// `BaseExcludeFilter::generatePattern`.
244fn generate_pattern(rule: &str) -> Option<ExcludePattern> {
245    let (negate, rule) = match rule.strip_prefix('!') {
246        Some(r) => (true, r.trim_start_matches('!')),
247        None => (false, rule),
248    };
249    let prefix = match rule.find('/') {
250        Some(0) => "^/",
251        None => "/",
252        Some(i) if i == rule.len() - 1 => "/",
253        Some(_) => "",
254    };
255    let rule = rule.trim_matches('/');
256    let inner = glob_to_regex(rule);
257    let inner = &inner[2..inner.len() - 2];
258    let regex = pcre2::bytes::RegexBuilder::new()
259        .build(&format!("{prefix}{inner}(?=$|/)"))
260        .ok()?;
261    Some(ExcludePattern { regex, negate })
262}
263
264/// Symfony `Finder\Glob::toRegex($glob)` with the defaults
265/// (`strictLeadingDot`, `strictWildcardSlash`, delimiter `#`).
266pub fn glob_to_regex(glob: &str) -> String {
267    let bytes = glob.as_bytes();
268    let mut first_byte = true;
269    let mut escaping = false;
270    let mut in_curlies = 0usize;
271    let mut regex = String::new();
272    let mut i = 0;
273    while i < bytes.len() {
274        let car = bytes[i] as char;
275        if first_byte && car != '.' {
276            regex.push_str("(?=[^\\.])");
277        }
278        first_byte = car == '/';
279        if first_byte
280            && i + 2 < bytes.len()
281            && bytes[i + 1] == b'*'
282            && bytes[i + 2] == b'*'
283            && (i + 3 >= bytes.len() || bytes[i + 3] == b'/')
284        {
285            let mut piece = String::from("[^/]++/");
286            if i + 3 >= bytes.len() {
287                piece.push('?');
288            }
289            let piece = format!("(?=[^\\.]){piece}");
290            regex.push_str(&format!("/(?:{piece})*"));
291            i += 2 + usize::from(i + 3 < bytes.len());
292            i += 1;
293            escaping = false;
294            continue;
295        }
296        match car {
297            '#' | '.' | '(' | ')' | '|' | '+' | '^' | '$' => {
298                regex.push('\\');
299                regex.push(car);
300            }
301            '*' => regex.push_str(if escaping { "\\*" } else { "[^/]*" }),
302            '?' => regex.push_str(if escaping { "\\?" } else { "[^/]" }),
303            '{' => {
304                if escaping {
305                    regex.push_str("\\{");
306                } else {
307                    regex.push('(');
308                    in_curlies += 1;
309                }
310            }
311            '}' if in_curlies > 0 => {
312                if escaping {
313                    regex.push('}');
314                } else {
315                    regex.push(')');
316                    in_curlies -= 1;
317                }
318            }
319            ',' if in_curlies > 0 => regex.push(if escaping { ',' } else { '|' }),
320            '\\' => {
321                if escaping {
322                    regex.push_str("\\\\");
323                    escaping = false;
324                } else {
325                    escaping = true;
326                }
327                i += 1;
328                continue;
329            }
330            c => regex.push(c),
331        }
332        escaping = false;
333        i += 1;
334    }
335    format!("#^{regex}$#")
336}
337
338/// One entry the finder yields: its path under the source and what it is.
339struct Entry {
340    rel: PathBuf,
341    kind: EntryKind,
342}
343
344enum EntryKind {
345    /// A symbolic link, with its raw target.
346    Link(PathBuf),
347    /// An empty directory.
348    Dir,
349    File,
350}
351
352/// `ArchivableFilesFinder($sources, [])` as an iterator: what `mirror`
353/// receives, in traversal order.
354fn archivable_entries(source: &Path) -> Result<Vec<Entry>> {
355    let source_real = realpath(source).unwrap_or_else(|| source.to_path_buf());
356    let source_str = normalize_path(&source_real.to_string_lossy());
357    let patterns = git_exclude_patterns(source);
358    let mut out = Vec::new();
359    walk(source, source, &source_str, &patterns, &mut out)?;
360    Ok(out)
361}
362
363fn walk(
364    source: &Path,
365    dir: &Path,
366    source_str: &str,
367    patterns: &[ExcludePattern],
368    out: &mut Vec<Entry>,
369) -> Result<()> {
370    let mut names: Vec<std::ffi::OsString> = std::fs::read_dir(dir)
371        .map_err(Error::io(dir))?
372        .filter_map(|e| e.ok().map(|e| e.file_name()))
373        .collect();
374    names.sort();
375    for name in names {
376        let path = dir.join(&name);
377        let meta = std::fs::symlink_metadata(&path).map_err(Error::io(&path))?;
378        let is_link = meta.file_type().is_symlink();
379        // Finder::ignoreVCS: directories (a link to one included) by name.
380        let name_str = name.to_string_lossy();
381        if path.is_dir() && VCS_DIRS.contains(&name_str.as_ref()) {
382            continue;
383        }
384        // The custom filter: no realpath -> dropped; a link leaving the
385        // source -> dropped; the exclude patterns on the realpath's relative
386        // form.
387        let Some(real) = realpath(&path) else {
388            continue;
389        };
390        let real_str = normalize_path(&real.to_string_lossy());
391        if is_link && !real_str.starts_with(source_str) {
392            continue;
393        }
394        let relative = real_str.strip_prefix(source_str).unwrap_or(&real_str);
395        let mut exclude = false;
396        for p in patterns {
397            if p.regex.is_match(relative.as_bytes()).unwrap_or(false) {
398                exclude = !p.negate;
399            }
400        }
401        let rel = path.strip_prefix(source).unwrap_or(&path).to_path_buf();
402        if exclude {
403            // The filter sits on the flattened iteration: an excluded
404            // directory is still traversed, and a child re-included by a
405            // later `-export-ignore` rule is kept.
406            if path.is_dir() && !is_link {
407                walk(source, &path, source_str, patterns, out)?;
408            }
409            continue;
410        }
411        if path.is_dir() {
412            // `accept()`: a directory (a link to one included) only when
413            // empty; the finder never descends into a link.
414            let empty = std::fs::read_dir(&path)
415                .map(|mut rd| rd.next().is_none())
416                .unwrap_or(false);
417            if empty {
418                let kind = if is_link {
419                    EntryKind::Link(std::fs::read_link(&path).map_err(Error::io(&path))?)
420                } else {
421                    EntryKind::Dir
422                };
423                out.push(Entry { rel, kind });
424            } else if !is_link {
425                walk(source, &path, source_str, patterns, out)?;
426            }
427        } else if is_link {
428            out.push(Entry {
429                rel,
430                kind: EntryKind::Link(std::fs::read_link(&path).map_err(Error::io(&path))?),
431            });
432        } else {
433            out.push(Entry {
434                rel,
435                kind: EntryKind::File,
436            });
437        }
438    }
439    Ok(())
440}
441
442/// Symfony `Filesystem::mirror($originDir, $targetDir, $iterator)`.
443fn mirror(source: &Path, target: &Path) -> Result<()> {
444    std::fs::create_dir_all(target).map_err(Error::io(target))?;
445    for entry in archivable_entries(source)? {
446        let dest = target.join(&entry.rel);
447        let src = source.join(&entry.rel);
448        match entry.kind {
449            EntryKind::Link(raw_target) => {
450                if let Some(parent) = dest.parent() {
451                    std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
452                }
453                symlink(&raw_target, &dest)?;
454            }
455            EntryKind::Dir => {
456                std::fs::create_dir_all(&dest).map_err(Error::io(&dest))?;
457            }
458            EntryKind::File => copy_file(&src, &dest)?,
459        }
460    }
461    Ok(())
462}
463
464/// Symfony `Filesystem::copy`: a fresh file (`0666 & ~umask`), the source's
465/// executable bits added, the source's mtime.
466fn copy_file(src: &Path, dest: &Path) -> Result<()> {
467    if let Some(parent) = dest.parent() {
468        std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
469    }
470    let meta = std::fs::metadata(src).map_err(Error::io(src))?;
471    {
472        let mut from = std::fs::File::open(src).map_err(Error::io(src))?;
473        let mut to = std::fs::File::create(dest).map_err(Error::io(dest))?;
474        std::io::copy(&mut from, &mut to).map_err(Error::io(dest))?;
475    }
476    #[cfg(unix)]
477    {
478        use std::os::unix::fs::PermissionsExt;
479        let current = std::fs::metadata(dest)
480            .map_err(Error::io(dest))?
481            .permissions()
482            .mode();
483        let mode = current | (meta.permissions().mode() & 0o111);
484        std::fs::set_permissions(dest, std::fs::Permissions::from_mode(mode))
485            .map_err(Error::io(dest))?;
486    }
487    if let Ok(modified) = meta.modified() {
488        // `touch($target, filemtime($origin))`: whole seconds, atime too.
489        let modified = modified
490            .duration_since(std::time::UNIX_EPOCH)
491            .map(|d| std::time::UNIX_EPOCH + std::time::Duration::from_secs(d.as_secs()))
492            .unwrap_or(modified);
493        let times = std::fs::FileTimes::new()
494            .set_modified(modified)
495            .set_accessed(modified);
496        let f = std::fs::File::options()
497            .write(true)
498            .open(dest)
499            .map_err(Error::io(dest))?;
500        f.set_times(times).map_err(Error::io(dest))?;
501    }
502    Ok(())
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn glob_to_regex_like_symfony() {
511        // `php -r 'echo Symfony\Component\Finder\Glob::toRegex($g);'`
512        for (glob, regex) in [
513            ("*.md", "#^(?=[^\\.])[^/]*\\.md$#"),
514            ("docs", "#^(?=[^\\.])docs$#"),
515            (
516                "a/**/b",
517                "#^(?=[^\\.])a/(?:(?=[^\\.])[^/]++/)*(?=[^\\.])b$#",
518            ),
519            ("{a,b}.txt", "#^(?=[^\\.])(a|b)\\.txt$#"),
520            ("a/**", "#^(?=[^\\.])a/(?:(?=[^\\.])[^/]++/?)*$#"),
521            ("/docs", "#^(?=[^\\.])/(?=[^\\.])docs$#"),
522            (".hidden", "#^\\.hidden$#"),
523            ("a\\*b", "#^(?=[^\\.])a\\*b$#"),
524            ("x/*.php", "#^(?=[^\\.])x/(?=[^\\.])[^/]*\\.php$#"),
525        ] {
526            assert_eq!(glob_to_regex(glob), regex, "{glob}");
527        }
528    }
529
530    #[test]
531    fn exclude_patterns_like_composer() {
532        let p = generate_pattern("/docs").unwrap();
533        assert!(p.regex.is_match(b"/docs/guide.md").unwrap());
534        assert!(!p.regex.is_match(b"/src/docs").unwrap());
535        let p = generate_pattern("*.md").unwrap();
536        assert!(p.regex.is_match(b"/docs/guide.md").unwrap());
537        assert!(p.regex.is_match(b"/README.md").unwrap());
538        assert!(!p.regex.is_match(b"/README.md.txt").unwrap());
539        let p = generate_pattern("tests").unwrap();
540        assert!(p.regex.is_match(b"/src/tests/x.php").unwrap());
541        let p = generate_pattern("!README.md").unwrap();
542        assert!(p.negate);
543    }
544}