aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-rpc/src/composer_runtime.rs
blob: afa7c6e1ba9efb00348bf36ddee164374f0c17b5 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
//! 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";

/// Names the Composer checkout that stands in for the bundle, for development.
const OVERRIDE_ENV: &str = "SHIRABE_COMPOSER_PHP_DIR";

/// The path the Composer PHP runtime's files sit under in the worker: the checkout `OVERRIDE_ENV`
/// names, or else the bundle, either inside this executable or in the directory it was extracted
/// to. A bundle the worker cannot read in place is unpacked under `cache_dir`, Composer's cache
/// directory; the process answers with the path it resolved first, so a later call's `cache_dir`
/// no longer moves the runtime.
pub fn base_path(cache_dir: &std::path::Path) -> anyhow::Result<String> {
    static BASE: std::sync::OnceLock<Result<String, String>> = std::sync::OnceLock::new();
    BASE.get_or_init(|| resolve(cache_dir).map_err(|e| format!("{e:#}")))
        .clone()
        .map_err(|e| anyhow::anyhow!(e))
}

fn resolve(cache_dir: &std::path::Path) -> anyhow::Result<String> {
    if let Some(directory) = override_directory()? {
        return path_to_string(directory);
    }
    if worker_opens_bundle()? {
        return Ok(format!("phar://{ALIAS}"));
    }
    path_to_string(extract_into(&cache_dir.join("runtime"))?)
}

fn path_to_string(directory: std::path::PathBuf) -> anyhow::Result<String> {
    directory.into_os_string().into_string().map_err(|path| {
        anyhow::anyhow!("the Composer PHP runtime path {path:?} is not valid UTF-8")
    })
}

/// The checkout that stands in for the bundle, if `OVERRIDE_ENV` names one. Both the worker and
/// the Rust side read the runtime from there instead.
fn override_directory() -> anyhow::Result<Option<std::path::PathBuf>> {
    let Some(directory) = shirabe_php_shim::getenv(OVERRIDE_ENV) else {
        return Ok(None);
    };
    let directory = std::path::PathBuf::from(directory);
    if !directory.join("vendor/autoload.php").is_file() {
        return Err(shirabe_php_shim::RuntimeException::new(format!(
            "{OVERRIDE_ENV} points at {}, which has no vendor/autoload.php; install the \
             checkout's dependencies or unset it to use the runtime the executable carries",
            directory.display()
        ))
        .into());
    }
    Ok(Some(directory))
}

/// 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:?}"
        )),
    }
}

/// One of the runtime's files, at a path a reader in this process can open: the one in the
/// checkout `OVERRIDE_ENV` names, or the file written out of the bundle on its own. `base_path()`
/// answers for the worker and can name a path inside this executable that only its phar stream
/// wrapper opens.
pub fn local_file(path: &str) -> anyhow::Result<LocalFile> {
    if let Some(directory) = override_directory()? {
        return Ok(LocalFile {
            path: directory.join(path),
            _directory: None,
        });
    }

    let directory = tempfile::tempdir()?;
    let archive = directory.path().join("bundle.phar");
    std::fs::write(&archive, BUNDLE)?;
    let unpacked = directory.path().join("unpacked");
    shirabe_php_shim::Phar::new(&archive)?.extract_to(&unpacked, Some(&[path]), true)?;
    std::fs::remove_file(&archive)?;

    Ok(LocalFile {
        path: unpacked.join(path),
        _directory: Some(directory),
    })
}

/// A file of the Composer PHP runtime on the local filesystem. One written out of the bundle
/// lives in a temporary directory that this handle removes again.
#[derive(Debug)]
pub struct LocalFile {
    path: std::path::PathBuf,
    _directory: Option<tempfile::TempDir>,
}

impl LocalFile {
    pub fn path(&self) -> &std::path::Path {
        &self.path
    }
}

/// Unpacks the bundle into a content-addressed directory under `root`, so that a worker that
/// cannot read the bundle in place gets the same files from the filesystem.
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)
}

#[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;
        }

        let cache = tempfile::tempdir().unwrap();
        assert_eq!(base_path(cache.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));
    }
}