Skip to main content

vivacity_core/
binproxy.rs

1//! vendor/bin proxies: byte-for-byte port of
2//! `BinaryInstaller::generateUnixyProxyCode` (docs/reference/BinaryInstaller.php).
3//! Three shapes: PHP target with shebang (anti-shebang stream wrapper for
4//! PHP<8, special phpunit hack), bare PHP target, non-PHP target (sh proxy).
5//! Parity is held by the differential test against the proxies generated by
6//! Composer in the Laravel fixture (tests/fixtures_binproxy.rs).
7
8use crate::error::{Error, Result};
9use crate::pathutil::{find_shortest_path, find_shortest_path_code, normalize_path};
10use std::path::Path;
11
12/// Generates the content of the proxy `link` (vendor/bin/<name>) to the binary
13/// `bin` (absolute, inside vendor/ or outside it with composer/installers),
14/// reading the target's header. Relative paths are those of
15/// `BinaryInstaller::installUnixyProxyBinaries` (findShortestPath from the
16/// proxy file).
17pub fn proxy_content(vendor_dir: &Path, link: &Path, bin: &Path) -> Result<String> {
18    let mut head = [0u8; 500];
19    let n = {
20        use std::io::Read as _;
21        let mut f = std::fs::File::open(bin).map_err(Error::io(bin))?;
22        f.read(&mut head).map_err(Error::io(bin))?
23    };
24    let head = String::from_utf8_lossy(&head[..n]);
25
26    let link_s = link.to_string_lossy();
27    let bin_s = bin.to_string_lossy();
28    let vendor_s = vendor_dir.to_string_lossy();
29    let bin_path = find_shortest_path(&link_s, &bin_s, false);
30    let bin_exported = find_shortest_path_code(&link_s, &bin_s, false, true);
31    let autoload_exported =
32        find_shortest_path_code(&link_s, &format!("{vendor_s}/autoload.php"), false, true);
33
34    match php_header(&head) {
35        Some(PhpHeader { shebang }) => {
36            let proxy_code = shebang
37                .clone()
38                .unwrap_or_else(|| "#!/usr/bin/env php".to_owned());
39            let is_phpunit = normalize_path(&bin_s)
40                == normalize_path(&format!("{vendor_s}/phpunit/phpunit/phpunit"));
41            let mut globals = String::from("$GLOBALS['_composer_bin_dir'] = __DIR__;\n");
42            globals.push_str(&format!(
43                "$GLOBALS['_composer_autoload_path'] = {autoload_exported};\n"
44            ));
45            if is_phpunit {
46                globals.push_str(&format!(
47                    "$GLOBALS['__PHPUNIT_ISOLATION_EXCLUDE_LIST'] = $GLOBALS['__PHPUNIT_ISOLATION_BLACKLIST'] = array(realpath({bin_exported}));\n"
48                ));
49            }
50            // Stream wrapper only if the target does not start directly
51            // with `<?php` (shebang or whitespace in front).
52            let needs_stream = shebang.is_some() || !head.starts_with("<?php");
53            let (stream_hint, stream_code) = if needs_stream {
54                (
55                    " using a stream wrapper to prevent the shebang from being output on PHP<8\n *"
56                        .to_owned(),
57                    stream_proxy_code(&bin_exported, is_phpunit),
58                )
59            } else {
60                (String::new(), String::new())
61            };
62            Ok(format!(
63                "{proxy_code}\n<?php\n\n/**\n * Proxy PHP file generated by Composer\n *\n * This file includes the referenced bin path ({bin_path})\n *{stream_hint}\n * @generated\n */\n\nnamespace Composer;\n\n{globals}\n{stream_code}\nreturn include {bin_exported};\n"
64            ))
65        }
66        None => Ok(sh_proxy(&bin_path)),
67    }
68}
69
70struct PhpHeader {
71    shebang: Option<String>,
72}
73
74/// Composer's regex: `^(#!.*\r?\n)?[\r\n\t ]*<\?php`.
75fn php_header(head: &str) -> Option<PhpHeader> {
76    let (shebang, rest) = if head.starts_with("#!") {
77        let end = head.find('\n')?;
78        (
79            Some(head[..end].trim_end_matches('\r').to_owned()),
80            &head[end + 1..],
81        )
82    } else {
83        (None, head)
84    };
85    let trimmed = rest.trim_start_matches(['\r', '\n', '\t', ' ']);
86    trimmed
87        .starts_with("<?php")
88        .then_some(PhpHeader { shebang })
89}
90
91fn stream_proxy_code(bin_exported: &str, is_phpunit: bool) -> String {
92    let hack1 = if is_phpunit {
93        "'phpvfscomposer://'."
94    } else {
95        ""
96    };
97    let hack2 = if is_phpunit {
98        "\n                $data = str_replace('__DIR__', var_export(dirname($this->realpath), true), $data);\n                $data = str_replace('__FILE__', var_export($this->realpath, true), $data);"
99    } else {
100        ""
101    };
102    format!(
103        r#"if (PHP_VERSION_ID < 80000) {{
104    if (!class_exists('Composer\BinProxyWrapper')) {{
105        /**
106         * @internal
107         */
108        final class BinProxyWrapper
109        {{
110            private $handle;
111            private $position;
112            private $realpath;
113
114            public function stream_open($path, $mode, $options, &$opened_path)
115            {{
116                // get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
117                $opened_path = substr($path, 17);
118                $this->realpath = realpath($opened_path) ?: $opened_path;
119                $opened_path = {hack1}$this->realpath;
120                $this->handle = fopen($this->realpath, $mode);
121                $this->position = 0;
122
123                return (bool) $this->handle;
124            }}
125
126            public function stream_read($count)
127            {{
128                $data = fread($this->handle, $count);
129
130                if ($this->position === 0) {{
131                    $data = preg_replace('{{^#!.*\r?\n}}', '', $data);
132                }}{hack2}
133
134                $this->position += strlen($data);
135
136                return $data;
137            }}
138
139            public function stream_cast($castAs)
140            {{
141                return $this->handle;
142            }}
143
144            public function stream_close()
145            {{
146                fclose($this->handle);
147            }}
148
149            public function stream_lock($operation)
150            {{
151                return $operation ? flock($this->handle, $operation) : true;
152            }}
153
154            public function stream_seek($offset, $whence)
155            {{
156                if (0 === fseek($this->handle, $offset, $whence)) {{
157                    $this->position = ftell($this->handle);
158                    return true;
159                }}
160
161                return false;
162            }}
163
164            public function stream_tell()
165            {{
166                return $this->position;
167            }}
168
169            public function stream_eof()
170            {{
171                return feof($this->handle);
172            }}
173
174            public function stream_stat()
175            {{
176                return array();
177            }}
178
179            public function stream_set_option($option, $arg1, $arg2)
180            {{
181                return true;
182            }}
183
184            public function url_stat($path, $flags)
185            {{
186                $path = substr($path, 17);
187                if (file_exists($path)) {{
188                    return stat($path);
189                }}
190
191                return false;
192            }}
193        }}
194    }}
195
196    if (
197        (function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
198        || (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
199    ) {{
200        return include("phpvfscomposer://" . {bin_exported});
201    }}
202}}
203"#
204    )
205}
206
207fn sh_proxy(bin_path: &str) -> String {
208    let dir = bin_path.rsplit_once('/').map(|(d, _)| d).unwrap_or(".");
209    let file = bin_path
210        .rsplit_once('/')
211        .map(|(_, f)| f)
212        .unwrap_or(bin_path);
213    // ProcessExecutor::escape on a simple path = single quotes.
214    format!(
215        r#"#!/usr/bin/env sh
216
217# Support bash to support `source` with fallback on $0 if this does not run with bash
218# https://stackoverflow.com/a/35006505/6512
219selfArg="$BASH_SOURCE"
220if [ -z "$selfArg" ]; then
221    selfArg="$0"
222fi
223
224self=$(realpath "$selfArg" 2> /dev/null)
225if [ -z "$self" ]; then
226    self="$selfArg"
227fi
228
229dir=$(cd "${{self%[/\\]*}}" > /dev/null; cd '{dir}' && pwd)
230
231if [ -d /proc/cygdrive ]; then
232    case $(which php) in
233        $(readlink -n /proc/cygdrive)/*)
234            # We are in Cygwin using Windows php, so the path must be translated
235            dir=$(cygpath -m "$dir");
236            ;;
237    esac
238fi
239
240export COMPOSER_RUNTIME_BIN_DIR="$(cd "${{self%[/\\]*}}" > /dev/null; pwd)"
241
242# If bash is sourcing this file, we have to source the target as well
243bashSource="$BASH_SOURCE"
244if [ -n "$bashSource" ]; then
245    if [ "$bashSource" != "$0" ]; then
246        source "${{dir}}/{file}" "$@"
247        return
248    fi
249fi
250
251exec "${{dir}}/{file}" "$@"
252"#
253    )
254}
255
256/// `BinaryInstaller::generateWindowsProxyCode`: a `.bat` whose target, for a
257/// `php` caller, is the NEIGHBOURING unixy proxy (`%~dp0/<name>` =
258/// `basename($link, '.bat')`) — the one that sets the
259/// `$GLOBALS['_composer_*']` and includes the real binary. Any other caller
260/// (`call` for a real `.bat`/`.exe` target, a non-php shebang such as `sh`,
261/// or a shebang carrying arguments such as `php -dfoo`) targets the real
262/// binary via `findShortestPath`. Composer wraps that path in
263/// `trim(ProcessExecutor::escape(...), '"\'')`, which is the bare path again
264/// for any path without embedded quotes — the simplification kept here.
265/// Verified byte-for-byte against native Composer 2.10.3 on the psr/log +
266/// monolog + nikic/php-parser fixture, and by real execution under a
267/// Windows PHP (tests/fixtures_binproxy.rs).
268pub fn windows_proxy_content(link_bat: &Path, link_name: &str, bin: &Path) -> Result<String> {
269    let caller = windows_binary_caller(bin)?;
270    let target = if caller == "php" {
271        link_name.to_owned()
272    } else {
273        let link_s = link_bat.to_string_lossy();
274        let bin_s = bin.to_string_lossy();
275        find_shortest_path(&link_s, &bin_s, false)
276    };
277    Ok(format!(
278        "@ECHO OFF\r\n\
279         setlocal DISABLEDELAYEDEXPANSION\r\n\
280         SET BIN_TARGET=%~dp0/{target}\r\n\
281         SET COMPOSER_RUNTIME_BIN_DIR=%~dp0\r\n\
282         {caller} \"%BIN_TARGET%\" %*\r\n"
283    ))
284}
285
286/// `BinaryInstaller::determineBinaryCaller`: `call` for a `.bat` or `.exe`
287/// target (`substr($bin, -4)` — case-sensitive, and NOT `.cmd`); otherwise
288/// the shebang interpreter — everything after the last path segment,
289/// arguments included (`#!/usr/bin/env php -dfoo` → `php -dfoo`); otherwise
290/// `php`.
291pub fn windows_binary_caller(bin: &Path) -> Result<String> {
292    let bin_s = bin.to_string_lossy();
293    if bin_s.ends_with(".bat") || bin_s.ends_with(".exe") {
294        return Ok("call".to_owned());
295    }
296    // fgets($handle): the first line, unbounded, as bytes.
297    let mut line = Vec::new();
298    {
299        use std::io::BufRead as _;
300        let f = std::fs::File::open(bin).map_err(Error::io(bin))?;
301        let mut reader = std::io::BufReader::new(f);
302        reader
303            .read_until(b'\n', &mut line)
304            .map_err(Error::io(bin))?;
305    }
306    let line = String::from_utf8_lossy(&line);
307    Ok(shebang_caller(&line).unwrap_or_else(|| "php".to_owned()))
308}
309
310/// The regex `{^#!/(?:usr/bin/env )?(?:[^/]+/)*(.+)$}m` of
311/// `determineBinaryCaller`, applied to the first line: everything after the
312/// last `/` is kept — arguments included — then `trim()`ed. Two details of
313/// the reference are preserved: the shebang must start with `#!/` (a bare
314/// `#!php` falls through to the `php` default), and the capture must be
315/// non-empty, so on a line ending in `/` the backtracked capture keeps its
316/// final `<segment>/`.
317fn shebang_caller(line: &str) -> Option<String> {
318    // `$` with the `m` flag matches before a final `\n`; a `\r` stays in the
319    // capture and is removed by trim() below.
320    let line = line.strip_suffix('\n').unwrap_or(line);
321    let rest = line.strip_prefix("#!/")?;
322    let rest = rest.strip_prefix("usr/bin/env ").unwrap_or(rest);
323    // `(?:[^/]+/)*(.+)`: drop leading `<segment>/` pairs while a non-empty
324    // capture remains — greedy with backtracking, like PCRE.
325    let mut capture = rest;
326    loop {
327        match capture.find('/') {
328            Some(i) if i > 0 && i + 1 < capture.len() => capture = &capture[i + 1..],
329            _ => break,
330        }
331    }
332    if capture.is_empty() {
333        return None; // `(.+)` cannot match: no shebang interpreter
334    }
335    // PHP trim() default character set.
336    Some(
337        capture
338            .trim_matches([' ', '\t', '\n', '\r', '\0', '\x0B'])
339            .to_owned(),
340    )
341}
342
343/// The resolved `bin-compat`: `Full` writes the `.bat` proxy in addition to
344/// the unixy proxy (`BinaryInstaller::installFullBinaries`), `Proxy` writes
345/// the unixy proxy alone (`installUnixyProxyBinaries`) — which is what
346/// Composer produces on plain Linux/macOS.
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum BinCompat {
349    Full,
350    Proxy,
351}
352
353/// `Config::get('bin-compat')` + the resolution in
354/// `BinaryInstaller::installBinaries` (2.10.3): the value comes from
355/// `COMPOSER_BIN_COMPAT` (`?:` in `Config::get`, so `""` and `"0"` fall
356/// through) else `config.bin-compat` of the root composer.json else the
357/// global `COMPOSER_HOME/config.json` (`Config::merge` layers) else
358/// `"auto"`, and resolves to `Full` iff it is `"full"`, or `"auto"` on
359/// Windows or WSL (`Platform::isWindows() ||
360/// Platform::isWindowsSubsystemForLinux()`).
361pub fn resolve_bin_compat(root_manifest: &serde_json::Value) -> Result<BinCompat> {
362    let env = std::env::var("COMPOSER_BIN_COMPAT").ok();
363    let global = crate::layout::global_config_value("bin-compat");
364    resolve_bin_compat_with(
365        env.as_deref(),
366        root_manifest,
367        global.as_ref(),
368        cfg!(windows) || is_windows_subsystem_for_linux(),
369    )
370}
371
372/// Pure core of [`resolve_bin_compat`], for tests: `env` is the
373/// `COMPOSER_BIN_COMPAT` override, `global` the `config.bin-compat` of
374/// the global config.json, `windows_or_wsl` the platform predicate.
375/// An unknown value is refused with Composer's own message; the deprecated
376/// `"symlink"` is accepted and behaves like `"proxy"` (Composer deprecation-
377/// warns then takes the non-full branch — vivacity never symlinks anyway).
378pub fn resolve_bin_compat_with(
379    env: Option<&str>,
380    root_manifest: &serde_json::Value,
381    global: Option<&serde_json::Value>,
382    windows_or_wsl: bool,
383) -> Result<BinCompat> {
384    // PHP `?:`: an empty string and "0" are falsy.
385    let env = env.filter(|v| !v.is_empty() && *v != "0");
386    let config = root_manifest
387        .get("config")
388        .and_then(|c| c.get("bin-compat"))
389        .and_then(serde_json::Value::as_str)
390        .or_else(|| global.and_then(serde_json::Value::as_str));
391    let value = env.or(config).unwrap_or("auto");
392    match value {
393        "full" => Ok(BinCompat::Full),
394        "auto" if windows_or_wsl => Ok(BinCompat::Full),
395        "auto" | "proxy" | "symlink" => Ok(BinCompat::Proxy),
396        other => Err(Error::Unsupported(format!(
397            "Invalid value for 'bin-compat': {other}. Expected auto, full or proxy"
398        ))),
399    }
400}
401
402/// `Platform::isWindowsSubsystemForLinux` (2.10.3): never on Windows itself;
403/// otherwise `/proc/version` readable and containing "microsoft"
404/// (case-insensitive), and not inside a container — Docker/Podman running
405/// inside WSL must not count as WSL. The reference also bails out under
406/// PHP's `open_basedir`, which has no analog here.
407fn is_windows_subsystem_for_linux() -> bool {
408    if cfg!(windows) {
409        return false;
410    }
411    let Ok(version) = std::fs::read_to_string("/proc/version") else {
412        return false;
413    };
414    version.to_ascii_lowercase().contains("microsoft") && !is_docker()
415}
416
417/// `Platform::isDocker` (2.10.3): the container marker files, then the
418/// cgroup/mountinfo markers.
419fn is_docker() -> bool {
420    if [
421        "/.dockerenv",
422        "/run/.containerenv",
423        "/var/run/.containerenv",
424    ]
425    .iter()
426    .any(|p| Path::new(p).exists())
427    {
428        return true;
429    }
430    ["/proc/self/mountinfo", "/proc/1/cgroup"].iter().any(|p| {
431        std::fs::read_to_string(p).is_ok_and(|data| {
432            data.contains("/var/lib/docker/") || data.contains("/io.containerd.snapshotter")
433        })
434    })
435}
436
437/// Installs a package's proxies (laid out in `package_dir`) into vendor/bin
438/// (0755), following the resolved [`BinCompat`] exactly as
439/// `BinaryInstaller::installBinaries` does: `Full` (bin-compat `"full"`, or
440/// `"auto"` on Windows/WSL) goes through [`install_full_binaries`]; `Proxy`
441/// writes the unixy proxy alone. vivacity always writes proxies (never
442/// symlinks), which is Composer's own proxy mode.
443pub fn install_binaries(
444    vendor_dir: &Path,
445    package_dir: &Path,
446    bins: &[&str],
447    compat: BinCompat,
448) -> Result<()> {
449    let bin_dir = vendor_dir.join("bin");
450    std::fs::create_dir_all(&bin_dir).map_err(Error::io(&bin_dir))?;
451    for bin in bins {
452        let bin = bin.trim_start_matches("./");
453        let target = package_dir.join(bin);
454        let link_name = bin.rsplit_once('/').map(|(_, f)| f).unwrap_or(bin);
455        let link = bin_dir.join(link_name);
456        if !target.exists() {
457            continue; // binary declared but missing from the dist: Composer skips it too
458        }
459        match compat {
460            BinCompat::Full => install_full_binaries(vendor_dir, &link, link_name, &target)?,
461            BinCompat::Proxy => install_unixy_proxy(vendor_dir, &link, &target)?,
462        }
463        // `chmod($binPath, 0777 & ~umask())`: the package's own binary is
464        // made executable (a dist extracted without its modes gets them
465        // here; a mirrored path package too).
466        #[cfg(unix)]
467        {
468            use std::os::unix::fs::PermissionsExt;
469            let mode = umask_mode_0777(&bin_dir)?;
470            std::fs::set_permissions(&target, std::fs::Permissions::from_mode(mode))
471                .map_err(Error::io(&target))?;
472        }
473    }
474    Ok(())
475}
476
477/// `0777 & ~umask()` without libc: the mode a file created with 0777 gets.
478#[cfg(unix)]
479fn umask_mode_0777(dir: &Path) -> Result<u32> {
480    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
481    let probe = dir.join(format!(".vivacity-umask-{}", std::process::id()));
482    let f = std::fs::OpenOptions::new()
483        .write(true)
484        .create_new(true)
485        .mode(0o777)
486        .open(&probe)
487        .map_err(Error::io(&probe))?;
488    let mode = f
489        .metadata()
490        .map_err(Error::io(&probe))?
491        .permissions()
492        .mode()
493        & 0o777;
494    drop(f);
495    let _ = std::fs::remove_file(&probe);
496    Ok(mode)
497}
498
499/// `BinaryInstaller::installFullBinaries`: a real `.bat` target
500/// (`substr($binPath, -4)`, case-sensitive) gets ONLY the windows proxy, at
501/// the link itself; any other target gets the unixy proxy plus a
502/// `<name>.bat` — which is SKIPPED when it already exists (Composer:
503/// "Skipped installation of bin <bin>.bat proxy for package <name>: a .bat
504/// proxy was already installed").
505fn install_full_binaries(
506    vendor_dir: &Path,
507    link: &Path,
508    link_name: &str,
509    target: &Path,
510) -> Result<()> {
511    let bat = if target.to_string_lossy().ends_with(".bat") {
512        link.to_path_buf()
513    } else {
514        install_unixy_proxy(vendor_dir, link, target)?;
515        link.with_file_name(format!("{link_name}.bat"))
516    };
517    if !bat.exists() {
518        let content = windows_proxy_content(&bat, link_name, target)?;
519        std::fs::write(&bat, content).map_err(Error::io(&bat))?;
520        set_executable(&bat)?;
521    }
522    Ok(())
523}
524
525/// `BinaryInstaller::installUnixyProxyBinaries`.
526fn install_unixy_proxy(vendor_dir: &Path, link: &Path, target: &Path) -> Result<()> {
527    let content = proxy_content(vendor_dir, link, target)?;
528    std::fs::write(link, content).map_err(Error::io(link))?;
529    set_executable(link)
530}
531
532/// `Silencer::call('chmod', $link, 0777 & ~umask())` — 0755 under the usual
533/// umask; a no-op on Windows.
534fn set_executable(link: &Path) -> Result<()> {
535    #[cfg(unix)]
536    {
537        use std::os::unix::fs::PermissionsExt as _;
538        std::fs::set_permissions(link, std::fs::Permissions::from_mode(0o755))
539            .map_err(Error::io(link))?;
540    }
541    #[cfg(not(unix))]
542    let _ = link;
543    Ok(())
544}