aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-rpc/src/composer_runtime.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/shirabe-php-rpc/src/composer_runtime.rs')
-rw-r--r--crates/shirabe-php-rpc/src/composer_runtime.rs181
1 files changed, 181 insertions, 0 deletions
diff --git a/crates/shirabe-php-rpc/src/composer_runtime.rs b/crates/shirabe-php-rpc/src/composer_runtime.rs
new file mode 100644
index 00000000..5336a83f
--- /dev/null
+++ b/crates/shirabe-php-rpc/src/composer_runtime.rs
@@ -0,0 +1,181 @@
+//! The Composer PHP runtime the worker loads.
+//!
+//! See `docs/dev/composer-runtime-bundle.md`.
+
+use crate::PluginValue;
+use crate::call_function;
+
+const BUNDLE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/composer-runtime-bundle.phar"));
+
+/// Identifies the bundle by its contents; the sentinel entry holds the same string.
+const BUNDLE_ID: &str = env!("SHIRABE_COMPOSER_RUNTIME_BUNDLE_ID");
+
+/// The entry the worker reads back to tell a bundle it can use from one it cannot.
+const SENTINEL_PATH: &str = "shirabe/bundle-id";
+
+/// The alias `Phar::loadPhar` maps the bundle to in the worker. The bundle stores no alias of
+/// its own, so this name is the only one its stream paths answer to.
+const ALIAS: &str = "shirabe-composer-runtime.phar";
+
+/// The path the Composer PHP runtime's files sit under in the worker, either inside this
+/// executable or in the directory the bundle was extracted to.
+pub fn base_path() -> anyhow::Result<String> {
+ static BASE: std::sync::OnceLock<Result<String, String>> = std::sync::OnceLock::new();
+ BASE.get_or_init(|| resolve().map_err(|e| format!("{e:#}")))
+ .clone()
+ .map_err(|e| anyhow::anyhow!(e))
+}
+
+fn resolve() -> anyhow::Result<String> {
+ if worker_opens_bundle()? {
+ return Ok(format!("phar://{ALIAS}"));
+ }
+ let directory = extract()?;
+ directory.into_os_string().into_string().map_err(|path| {
+ anyhow::anyhow!("the extracted Composer PHP runtime path {path:?} is not valid UTF-8")
+ })
+}
+
+/// Whether the worker can read the bundle straight out of this executable. It cannot when its
+/// PHP has no phar extension, no zlib to inflate the entries, or a restriction on the phar stream
+/// wrapper.
+fn worker_opens_bundle() -> anyhow::Result<bool> {
+ let executable = std::env::current_exe()?;
+ let executable = executable.to_str().ok_or_else(|| {
+ anyhow::anyhow!("the path of this executable, {executable:?}, is not valid UTF-8")
+ })?;
+ let opened = call_function(
+ "__shirabe_open_runtime_bundle",
+ vec![
+ PluginValue::string(executable),
+ PluginValue::string(ALIAS),
+ PluginValue::string(SENTINEL_PATH),
+ PluginValue::string(BUNDLE_ID),
+ ],
+ )?
+ .map_err(|throw| anyhow::anyhow!("{throw}"))?;
+ match opened {
+ PluginValue::Bool(opened) => Ok(opened),
+ other => Err(anyhow::anyhow!(
+ "opening the Composer PHP runtime bundle did not answer with a bool: {other:?}"
+ )),
+ }
+}
+
+/// Unpacks the bundle into a content-addressed directory, so that a worker that cannot read the
+/// bundle in place gets the same files from the filesystem.
+fn extract() -> anyhow::Result<std::path::PathBuf> {
+ extract_into(&cache_directory()?.join("shirabe").join("runtime"))
+}
+
+fn extract_into(root: &std::path::Path) -> anyhow::Result<std::path::PathBuf> {
+ let destination = root.join(BUNDLE_ID);
+ if destination.is_dir() {
+ return Ok(destination);
+ }
+
+ std::fs::create_dir_all(root)?;
+ let staging = tempfile::tempdir_in(root)?;
+ let archive = staging.path().join("bundle.phar");
+ std::fs::write(&archive, BUNDLE)?;
+ let unpacked = staging.path().join("unpacked");
+ shirabe_php_shim::Phar::new(&archive)?.extract_to(&unpacked, None, true)?;
+ std::fs::remove_file(&archive)?;
+
+ if let Err(error) = std::fs::rename(&unpacked, &destination) {
+ // Losing the race against another process that unpacked the same bundle is not a
+ // failure: the directory is named after the contents that went into it.
+ if !destination.is_dir() {
+ return Err(error.into());
+ }
+ }
+ Ok(destination)
+}
+
+fn cache_directory() -> anyhow::Result<std::path::PathBuf> {
+ if let Some(directory) = std::env::var_os("XDG_CACHE_HOME")
+ && !directory.is_empty()
+ {
+ return Ok(std::path::PathBuf::from(directory));
+ }
+ let home = std::env::var_os("HOME")
+ .ok_or_else(|| anyhow::anyhow!("neither XDG_CACHE_HOME nor HOME is set"))?;
+ Ok(std::path::Path::new(&home).join(".cache"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use shirabe_symfony_process::PhpExecutableFinder;
+
+ fn sentinel_of(directory: &std::path::Path) -> String {
+ std::fs::read_to_string(directory.join(SENTINEL_PATH)).expect("no sentinel in the bundle")
+ }
+
+ /// PHP takes the first occurrence of the stub token in the file it opens, so no other copy of
+ /// it may precede the bundle in the executable.
+ #[test]
+ fn the_first_phar_in_this_executable_is_the_bundle() {
+ let executable = std::env::current_exe().expect("no path for this executable");
+ let unpacked = tempfile::tempdir().unwrap();
+ shirabe_php_shim::Phar::new(&executable)
+ .expect("this executable does not read back as a phar")
+ .extract_to(unpacked.path(), None, true)
+ .unwrap();
+
+ assert_eq!(sentinel_of(unpacked.path()), BUNDLE_ID);
+ }
+
+ #[test]
+ fn extracting_the_bundle_names_the_directory_after_it() {
+ let root = tempfile::tempdir().unwrap();
+ let directory = extract_into(root.path()).unwrap();
+
+ assert_eq!(directory, root.path().join(BUNDLE_ID));
+ assert_eq!(sentinel_of(&directory), BUNDLE_ID);
+ assert!(directory.join("vendor/autoload.php").is_file());
+ // A second call finds the unpacked bundle and leaves it alone.
+ assert_eq!(extract_into(root.path()).unwrap(), directory);
+ }
+
+ #[test]
+ fn the_worker_reads_the_bundle_out_of_this_executable() {
+ if PhpExecutableFinder::new().find(false).is_none() {
+ // No PHP in this environment; the worker cannot start.
+ return;
+ }
+
+ assert_eq!(base_path().unwrap(), format!("phar://{ALIAS}"));
+ }
+
+ /// The other half of `base_path`: a worker whose PHP cannot open the bundle is handed the
+ /// unpacked tree, and has to reach the same classes through it.
+ #[test]
+ fn the_worker_loads_the_composer_runtime_from_an_unpacked_bundle() {
+ if PhpExecutableFinder::new().find(false).is_none() {
+ // No PHP in this environment; the worker cannot start.
+ return;
+ }
+
+ let root = tempfile::tempdir().unwrap();
+ let autoload = extract_into(root.path())
+ .unwrap()
+ .join("vendor/autoload.php");
+ call_function(
+ "__shirabe_require",
+ vec![PluginValue::string(autoload.to_str().unwrap())],
+ )
+ .unwrap()
+ .unwrap();
+
+ // A class with no proxy stub, so that the answer is about the runtime and not about the
+ // stub autoloader.
+ let exists = call_function(
+ "class_exists",
+ vec![PluginValue::string(r"Composer\Util\Filesystem")],
+ )
+ .unwrap()
+ .unwrap();
+ assert_eq!(exists, PluginValue::Bool(true));
+ }
+}