Skip to main content

wasmer_wasix/bin_factory/
mod.rs

1#![allow(clippy::result_large_err)]
2use std::{
3    collections::HashMap,
4    future::Future,
5    ops::Deref,
6    path::Path,
7    pin::Pin,
8    sync::{Arc, RwLock},
9};
10
11use anyhow::Context;
12use shared_buffer::OwnedBuffer;
13use virtual_fs::{AsyncReadExt, FileSystem};
14use wasmer::FunctionEnvMut;
15use wasmer_package::utils::from_bytes;
16
17mod binary_package;
18mod exec;
19
20pub use self::{
21    binary_package::*,
22    exec::{
23        import_package_mounts, package_command_by_name, run_exec, spawn_exec, spawn_exec_module,
24        spawn_exec_wasm, spawn_load_module,
25    },
26};
27use crate::{
28    Runtime, SpawnError, VIRTUAL_ROOT_FD, WasiEnv,
29    fs::Kind,
30    os::{
31        command::{Commands, VirtualCommand},
32        task::TaskJoinHandle,
33    },
34    runtime::module_cache::HashedModuleData,
35};
36
37#[derive(Debug, Clone)]
38pub struct BinFactory {
39    pub(crate) commands: Commands,
40    runtime: Arc<dyn Runtime + Send + Sync + 'static>,
41    pub(crate) local: Arc<RwLock<HashMap<String, Option<Arc<BinaryPackage>>>>>,
42}
43
44impl BinFactory {
45    pub fn new(runtime: Arc<dyn Runtime + Send + Sync + 'static>) -> BinFactory {
46        BinFactory {
47            commands: Commands::new_with_builtins(runtime.clone()),
48            runtime,
49            local: Arc::new(RwLock::new(HashMap::new())),
50        }
51    }
52
53    pub fn runtime(&self) -> &(dyn Runtime + Send + Sync) {
54        self.runtime.deref()
55    }
56
57    /// Register a builtin command.
58    pub fn register_builtin_command<C>(&mut self, cmd: C)
59    where
60        C: VirtualCommand + Send + Sync + 'static,
61    {
62        self.commands.register_command(cmd);
63    }
64
65    /// Register a builtin command at a custom path.
66    pub fn register_builtin_command_with_path<C, P>(&mut self, cmd: C, path: P)
67    where
68        C: VirtualCommand + Send + Sync + 'static,
69        P: Into<String>,
70    {
71        self.commands.register_command_with_path(cmd, path.into());
72    }
73
74    /// Register a builtin command behind an [`Arc`] at a custom path.
75    pub(crate) fn register_builtin_command_with_path_shared<P>(
76        &mut self,
77        cmd: Arc<dyn VirtualCommand + Send + Sync + 'static>,
78        path: P,
79    ) where
80        P: Into<String>,
81    {
82        self.commands
83            .register_command_with_path_shared(cmd, path.into());
84    }
85
86    /// Remove all registered builtin commands.
87    pub fn clear_builtin_commands(&mut self) {
88        self.commands.clear();
89    }
90
91    pub fn set_binary(&self, name: &str, binary: &Arc<BinaryPackage>) {
92        let mut cache = self.local.write().unwrap();
93        cache.insert(name.to_string(), Some(binary.clone()));
94    }
95
96    #[allow(clippy::await_holding_lock)]
97    pub async fn get_binary(
98        &self,
99        name: &str,
100        fs: Option<&dyn FileSystem>,
101    ) -> Option<Arc<BinaryPackage>> {
102        self.get_executable(name, fs)
103            .await
104            .and_then(|executable| match executable {
105                Executable::Wasm(_) | Executable::Script(_) => None,
106                Executable::BinaryPackage(pkg) => Some(pkg),
107            })
108    }
109
110    /// `invoked_as` is the path the guest spelled, for a script that must be
111    /// handed to its interpreter under that name rather than the path it
112    /// resolved to. `None` selects the resolved path, which is what a PATH
113    /// search produces on Unix.
114    ///
115    /// Every name here is looked up on the filesystem, builtin commands
116    /// included. A caller reaches those through [`Self::try_built_in`] before
117    /// it gets here, and only for the name it started with, so a shebang
118    /// naming a builtin interpreter (`#!/bin/sh` where `sh` is registered
119    /// rather than a file on disk) fails with
120    /// [`SpawnError::BinaryNotFound`]. Running that interpreter needs the
121    /// parent context `try_built_in` takes, which a spawn already under way
122    /// does not hold.
123    pub fn spawn<'a>(
124        &'a self,
125        name: String,
126        invoked_as: Option<String>,
127        env: WasiEnv,
128    ) -> Pin<Box<dyn Future<Output = Result<TaskJoinHandle, SpawnError>> + 'a>> {
129        Box::pin(async move {
130            let mut name = name;
131            let mut invoked_as = invoked_as;
132            // Held back until the interpreter is known to resolve. On
133            // `proc_exec4`'s non-vfork path this state is shared with a caller
134            // that keeps running when the spawn fails, and a shebang rewrite
135            // adds arguments that were never in its argv.
136            let mut rewritten_args: Option<Vec<String>> = None;
137
138            // A shebang is handled by the kernel on Unix. WASIX's binary factory
139            // fills that role for virtual filesystems, so resolve scripts here
140            // before trying to compile their bytes as WebAssembly. Each round
141            // is a filesystem lookup: see this method's docs for why a builtin
142            // interpreter is out of reach.
143            for _ in 0..MAX_SHEBANG_DEPTH {
144                let (resolved_name, executable) = self
145                    .get_executable_for_spawn(name.as_str(), &env)
146                    .await?
147                    .ok_or_else(|| SpawnError::BinaryNotFound {
148                        binary: name.clone(),
149                    })?;
150                name = resolved_name;
151
152                match executable {
153                    Executable::Wasm(bytes) => {
154                        let data = HashedModuleData::new(bytes.clone());
155                        commit_script_args(&env, rewritten_args);
156                        return spawn_exec_wasm(data, name.as_str(), env, &self.runtime).await;
157                    }
158                    Executable::BinaryPackage(pkg) => {
159                        {
160                            // Resolve the command first: a package without this
161                            // entrypoint is a failed spawn, not a rewrite.
162                            let cmd = package_command_by_name(&pkg, name.as_str())?;
163                            commit_script_args(&env, rewritten_args);
164                            env.prepare_spawn(cmd);
165                        }
166
167                        return spawn_exec(pkg.as_ref().clone(), name.as_str(), env, &self.runtime)
168                            .await;
169                    }
170                    Executable::Script(script) => {
171                        let script_path = invoked_as.unwrap_or_else(|| name.clone());
172                        let args = match &rewritten_args {
173                            Some(args) => args.clone(),
174                            None => env.state.args.lock().unwrap().clone(),
175                        };
176                        let (interpreter, args) = script_command(&script_path, script, &args);
177                        rewritten_args = Some(args);
178                        name = interpreter;
179                        // The next round resolves the interpreter, which the
180                        // shebang line named directly.
181                        invoked_as = Some(name.clone());
182                    }
183                }
184            }
185
186            Err(SpawnError::ShebangLoop)
187        })
188    }
189
190    pub fn try_built_in(
191        &self,
192        name: String,
193        parent_ctx: Option<&FunctionEnvMut<'_, WasiEnv>>,
194        builder: &mut Option<WasiEnv>,
195    ) -> Result<TaskJoinHandle, SpawnError> {
196        // We check for built in commands
197        if let Some(parent_ctx) = parent_ctx {
198            if self.commands.exists(name.as_str()) {
199                return self.commands.exec(parent_ctx, name.as_str(), builder);
200            }
201        } else if self.commands.exists(name.as_str()) {
202            tracing::warn!("builtin command without a parent ctx - {}", name);
203        }
204        Err(SpawnError::BinaryNotFound { binary: name })
205    }
206
207    // TODO: remove allow once BinFactory is refactored
208    // currently fine because a BinFactory is only used by a single process tree
209    #[allow(clippy::await_holding_lock)]
210    pub async fn get_executable(
211        &self,
212        name: &str,
213        fs: Option<&dyn FileSystem>,
214    ) -> Option<Executable> {
215        let name = name.to_string();
216
217        // Return early if the path is already cached
218        {
219            let cache = self.local.read().unwrap();
220            if let Some(data) = cache.get(&name) {
221                return data.clone().map(Executable::BinaryPackage);
222            }
223        }
224
225        let mut cache = self.local.write().unwrap();
226
227        // Check the cache again to avoid a race condition where the cache was populated inbetween the fast path and here
228        if let Some(data) = cache.get(&name) {
229            return data.clone().map(Executable::BinaryPackage);
230        }
231
232        // Check the filesystem for the file
233        if name.starts_with('/')
234            && let Some(fs) = fs
235        {
236            match load_executable_from_filesystem(fs, name.as_ref(), self.runtime()).await {
237                Ok(executable) => {
238                    if let Executable::BinaryPackage(pkg) = &executable {
239                        cache.insert(name, Some(pkg.clone()));
240                    }
241
242                    return Some(executable);
243                }
244                Err(e) => {
245                    tracing::warn!(
246                        path = name,
247                        error = &*e,
248                        "Unable to load the package from disk"
249                    );
250                }
251            }
252        }
253
254        // Do not negatively cache filesystem lookups: package managers and
255        // running guests can create executables after an earlier miss.
256        None
257    }
258
259    async fn get_executable_for_spawn(
260        &self,
261        name: &str,
262        env: &WasiEnv,
263    ) -> Result<Option<(String, Executable)>, SpawnError> {
264        if name.contains('/') {
265            let name = if name.starts_with('/') {
266                name.to_string()
267            } else {
268                env.state.fs.relative_path_to_absolute(name.to_string())
269            };
270            let executable = self.get_executable_from_wasi_fs(&name, env).await?;
271            return Ok(executable.map(|executable| (name, executable)));
272        }
273
274        for directory in executable_search_path(env) {
275            let path = if directory.is_empty() {
276                name.to_string()
277            } else {
278                format!("{}/{}", directory.trim_end_matches('/'), name)
279            };
280            let path = if path.starts_with('/') {
281                path
282            } else {
283                env.state.fs.relative_path_to_absolute(path)
284            };
285            if let Some(executable) = self.get_executable_from_wasi_fs(&path, env).await? {
286                return Ok(Some((path, executable)));
287            }
288        }
289
290        Ok(None)
291    }
292
293    async fn get_executable_from_wasi_fs(
294        &self,
295        path: &str,
296        env: &WasiEnv,
297    ) -> Result<Option<Executable>, SpawnError> {
298        if let Some(binary) = self.local.read().unwrap().get(path).cloned().flatten() {
299            return Ok(Some(Executable::BinaryPackage(binary)));
300        }
301
302        match load_executable_from_wasi_fs(env, Path::new(path), self.runtime()).await {
303            Ok(executable) => {
304                if let Executable::BinaryPackage(package) = &executable {
305                    self.local
306                        .write()
307                        .unwrap()
308                        .insert(path.to_string(), Some(package.clone()));
309                }
310                Ok(Some(executable))
311            }
312            // A script whose shebang cannot be used is not a missing file, and
313            // continuing the PATH walk would report the wrong error.
314            Err(error) if error.downcast_ref::<MalformedShebang>().is_some() => {
315                Err(SpawnError::InvalidShebang {
316                    path: path.to_string(),
317                })
318            }
319            Err(error) => {
320                tracing::debug!(path, error = &*error, "Unable to load executable");
321                Ok(None)
322            }
323        }
324    }
325}
326
327fn executable_search_path(env: &WasiEnv) -> Vec<String> {
328    env.state
329        .envs
330        .lock()
331        .unwrap()
332        .iter()
333        .rev()
334        .find_map(|entry| {
335            entry
336                .strip_prefix(b"PATH=")
337                .map(|path| String::from_utf8_lossy(path).into_owned())
338        })
339        .unwrap_or_else(|| "/usr/local/bin:/bin:/usr/bin".to_string())
340        .split(':')
341        .map(str::to_string)
342        .collect()
343}
344
345pub enum Executable {
346    Wasm(OwnedBuffer),
347    BinaryPackage(Arc<BinaryPackage>),
348    Script(Shebang),
349}
350
351/// Executables a single spawn may walk through: five nested scripts and the
352/// thing the last one names. Linux runs a chain of five and answers `ELOOP` at
353/// six, whatever the last one names.
354const MAX_SHEBANG_DEPTH: usize = 6;
355
356/// Linux reads the shebang out of a buffer of `BINPRM_BUF_SIZE` bytes and
357/// refuses to exec an interpreter path that the buffer truncated.
358const MAX_SHEBANG_LINE: usize = 256;
359
360#[derive(Debug)]
361pub struct Shebang {
362    interpreter: String,
363    argument: Option<String>,
364}
365
366/// A file that starts with `#!` is a script and nothing else: Linux commits to
367/// the script handler on those two bytes and never falls back to another
368/// format. A line this runtime cannot use is therefore ENOEXEC, not something
369/// to hand to the Wasm compiler.
370#[derive(Debug, thiserror::Error)]
371#[error("the shebang line cannot be used")]
372struct MalformedShebang;
373
374enum ShebangLine {
375    Absent,
376    Malformed,
377    Parsed(Shebang),
378}
379
380fn parse_shebang(bytes: &[u8]) -> ShebangLine {
381    if !bytes.starts_with(b"#!") {
382        return ShebangLine::Absent;
383    }
384
385    let window = &bytes[..bytes.len().min(MAX_SHEBANG_LINE)];
386    let line = match window.iter().position(|byte| *byte == b'\n') {
387        Some(end) => &window[..end],
388        // Without a newline the line ends where the file does, so anything
389        // reaching the end of the window is truncated.
390        None if bytes.len() < MAX_SHEBANG_LINE => window,
391        None => return ShebangLine::Malformed,
392    };
393
394    // WASIX resolves paths as UTF-8 strings. Linux takes them as bytes, so a
395    // path this runtime cannot represent is reported rather than ignored.
396    let Ok(line) = std::str::from_utf8(&line[2..]) else {
397        return ShebangLine::Malformed;
398    };
399    let line = line.trim_end_matches('\r').trim();
400
401    let (interpreter, argument) = line
402        .split_once(char::is_whitespace)
403        .map(|(interpreter, argument)| (interpreter, Some(argument.trim().to_string())))
404        .unwrap_or((line, None));
405
406    if interpreter.is_empty() {
407        return ShebangLine::Malformed;
408    }
409
410    ShebangLine::Parsed(Shebang {
411        interpreter: interpreter.to_string(),
412        argument: argument.filter(|argument| !argument.is_empty()),
413    })
414}
415
416/// Applies the argv a shebang rewrote, once the executable it named has been
417/// found. Nothing before this point may disturb the caller's argv.
418fn commit_script_args(env: &WasiEnv, args: Option<Vec<String>>) {
419    if let Some(args) = args {
420        *env.state.args.lock().unwrap() = args;
421    }
422}
423
424fn script_command(
425    script_name: &str,
426    script: Shebang,
427    original_args: &[String],
428) -> (String, Vec<String>) {
429    let user_args = original_args.iter().skip(1).cloned();
430
431    // As on Unix, whatever follows the interpreter on the shebang line is a
432    // single argument. `/usr/bin/env` needs no special case: it resolves like
433    // any other interpreter, and the `env` shipped by `wasmer/coreutils` does
434    // its own `-S` splitting.
435    let args = std::iter::once(script.interpreter.clone())
436        .chain(script.argument)
437        .chain(std::iter::once(script_name.to_string()))
438        .chain(user_args)
439        .collect();
440
441    (script.interpreter, args)
442}
443
444async fn load_executable_from_filesystem(
445    fs: &dyn FileSystem,
446    path: &Path,
447    rt: &(dyn Runtime + Send + Sync),
448) -> Result<Executable, anyhow::Error> {
449    let mut f = fs
450        .new_open_options()
451        .read(true)
452        .open(path)
453        .context("Unable to open the file")?;
454
455    // Fast path if the file is fully available in memory. This prevents a
456    // redundant copy and keeps executable classification in one place.
457    if let Some(buf) = f.as_owned_buffer() {
458        load_executable_from_buffer(buf, rt).await
459    } else {
460        let mut data = Vec::with_capacity(f.size() as usize);
461        f.read_to_end(&mut data).await.context("Read failed")?;
462        load_executable_from_buffer(OwnedBuffer::from_bytes(data), rt).await
463    }
464}
465
466async fn load_executable_from_wasi_fs(
467    env: &WasiEnv,
468    path: &Path,
469    rt: &(dyn Runtime + Send + Sync),
470) -> Result<Executable, anyhow::Error> {
471    // Resolving through the inode tree follows symlinks and reaches a file that
472    // only exists in memory, but it sees only what a preopen covers. Without a
473    // matching one the virtual root has nothing to offer, so fall back to
474    // opening the root filesystem, which is where executables were found
475    // before. That fallback does not follow symlinks.
476    let inode = match env.state.fs.get_inode_at_path(
477        &env.state.inodes,
478        VIRTUAL_ROOT_FD,
479        path.to_string_lossy().as_ref(),
480        true,
481    ) {
482        Ok(inode) => inode,
483        Err(error) => {
484            tracing::debug!(
485                %error,
486                path = path.to_string_lossy().as_ref(),
487                "Unable to resolve executable through the preopens",
488            );
489            return load_executable_from_filesystem(env.fs_root(), path, rt).await;
490        }
491    };
492
493    let (buffer, backing_path) = {
494        let kind = inode.read();
495        match &*kind {
496            Kind::File { handle, path, .. } => {
497                let buffer = handle
498                    .as_ref()
499                    .and_then(|handle| handle.read().unwrap().as_owned_buffer());
500                (buffer, path.clone())
501            }
502            _ => anyhow::bail!("Executable is not a regular file"),
503        }
504    };
505
506    if let Some(buffer) = buffer {
507        load_executable_from_buffer(buffer, rt).await
508    } else {
509        load_executable_from_filesystem(&env.state.fs.root_fs, &backing_path, rt).await
510    }
511}
512
513async fn load_executable_from_buffer(
514    buffer: OwnedBuffer,
515    rt: &(dyn Runtime + Send + Sync),
516) -> Result<Executable, anyhow::Error> {
517    if wasmer_package::utils::is_container(buffer.as_slice()) {
518        let bytes = buffer.clone().into_bytes();
519        if let Ok(container) = from_bytes(bytes) {
520            let package = BinaryPackage::from_webc(&container, rt)
521                .await
522                .context("Unable to load the package")?;
523            return Ok(Executable::BinaryPackage(Arc::new(package)));
524        }
525    }
526
527    match parse_shebang(buffer.as_slice()) {
528        ShebangLine::Parsed(script) => Ok(Executable::Script(script)),
529        ShebangLine::Malformed => Err(MalformedShebang.into()),
530        ShebangLine::Absent => Ok(Executable::Wasm(buffer)),
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use std::{path::Path, sync::Arc};
537
538    use virtual_fs::{AsyncWriteExt, FileSystem};
539    use wasmer::Engine;
540    use wasmer_wasix_types::wasi::Errno;
541
542    use super::{
543        Executable, MAX_SHEBANG_LINE, Shebang, ShebangLine, load_executable_from_wasi_fs,
544        parse_shebang, script_command,
545    };
546    use crate::{SpawnError, VIRTUAL_ROOT_FD, WasiEnvBuilder};
547
548    fn expect_parsed(line: ShebangLine) -> Shebang {
549        match line {
550            ShebangLine::Parsed(script) => script,
551            ShebangLine::Absent => panic!("expected a shebang"),
552            ShebangLine::Malformed => panic!("expected a usable shebang"),
553        }
554    }
555
556    #[test]
557    fn parses_env_shebang() {
558        let script = expect_parsed(parse_shebang(
559            b"#!/usr/bin/env node\nconsole.log('hello')\n",
560        ));
561        assert_eq!(script.interpreter, "/usr/bin/env");
562        assert_eq!(script.argument.as_deref(), Some("node"));
563    }
564
565    #[test]
566    fn parses_direct_shebang_with_crlf() {
567        let script = expect_parsed(parse_shebang(b"#!/bin/bash -e\r\necho hello\r\n"));
568        assert_eq!(script.interpreter, "/bin/bash");
569        assert_eq!(script.argument.as_deref(), Some("-e"));
570    }
571
572    #[test]
573    fn ignores_regular_files() {
574        assert!(matches!(
575            parse_shebang(b"console.log('hello')\n"),
576            ShebangLine::Absent
577        ));
578    }
579
580    #[test]
581    fn a_line_longer_than_the_kernel_buffer_is_rejected() {
582        // Linux fits the whole line, `#!` and newline included, in
583        // BINPRM_BUF_SIZE and refuses to exec a path the buffer truncated.
584        let fits = format!("#!/{}\n", "y".repeat(MAX_SHEBANG_LINE - 4));
585        assert_eq!(fits.len(), MAX_SHEBANG_LINE);
586        assert!(matches!(
587            parse_shebang(fits.as_bytes()),
588            ShebangLine::Parsed(_)
589        ));
590
591        let one_too_long = format!("#!/{}\n", "y".repeat(MAX_SHEBANG_LINE - 3));
592        assert_eq!(one_too_long.len(), MAX_SHEBANG_LINE + 1);
593        assert!(matches!(
594            parse_shebang(one_too_long.as_bytes()),
595            ShebangLine::Malformed
596        ));
597    }
598
599    #[test]
600    fn a_short_line_needs_no_newline() {
601        let Shebang {
602            interpreter,
603            argument,
604        } = expect_parsed(parse_shebang(b"#!/bin/echo hi"));
605        assert_eq!(interpreter, "/bin/echo");
606        assert_eq!(argument.as_deref(), Some("hi"));
607    }
608
609    #[test]
610    fn a_non_utf8_interpreter_is_rejected_rather_than_compiled() {
611        // Linux runs this: paths there are bytes. WASIX resolves paths as
612        // UTF-8, so the honest answer is ENOEXEC, never a Wasm compile error.
613        assert!(matches!(
614            parse_shebang(b"#!/usr/bin/int\xff\xfebad\n"),
615            ShebangLine::Malformed
616        ));
617    }
618
619    #[test]
620    fn an_empty_interpreter_is_rejected() {
621        assert!(matches!(parse_shebang(b"#!\n"), ShebangLine::Malformed));
622        assert!(matches!(parse_shebang(b"#!   \n"), ShebangLine::Malformed));
623    }
624
625    #[test]
626    fn script_keeps_the_path_the_caller_spelled() {
627        // execv("./tool", ...) on Linux hands the interpreter "./tool", not the
628        // path the kernel resolved it to.
629        let script = expect_parsed(parse_shebang(b"#!/bin/interp\n"));
630        let original = vec!["./tool".to_string(), "arg".to_string()];
631        let (interpreter, args) = script_command("./tool", script, &original);
632
633        assert_eq!(interpreter, "/bin/interp");
634        assert_eq!(args, ["/bin/interp", "./tool", "arg"]);
635    }
636
637    #[test]
638    fn env_shebang_execs_env_itself() {
639        let script = expect_parsed(parse_shebang(b"#!/usr/bin/env node\n"));
640        let original = vec!["next".to_string(), "dev".to_string()];
641        let (interpreter, args) = script_command("/workspace/.bin/next", script, &original);
642
643        assert_eq!(interpreter, "/usr/bin/env");
644        assert_eq!(
645            args,
646            ["/usr/bin/env", "node", "/workspace/.bin/next", "dev"]
647        );
648    }
649
650    #[test]
651    fn env_split_string_stays_one_argument() {
652        let script = expect_parsed(parse_shebang(b"#!/usr/bin/env -S node --no-warnings\n"));
653        let original = vec!["tool".to_string(), "input.js".to_string()];
654        let (interpreter, args) = script_command("/workspace/tool", script, &original);
655
656        assert_eq!(interpreter, "/usr/bin/env");
657        assert_eq!(
658            args,
659            [
660                "/usr/bin/env",
661                "-S node --no-warnings",
662                "/workspace/tool",
663                "input.js"
664            ]
665        );
666    }
667
668    #[test]
669    fn direct_shebang_inserts_optional_argument_before_script() {
670        let script = expect_parsed(parse_shebang(b"#!/bin/bash -e\n"));
671        let original = vec!["script".to_string(), "hello".to_string()];
672        let (interpreter, args) = script_command("/workspace/script", script, &original);
673
674        assert_eq!(interpreter, "/bin/bash");
675        assert_eq!(args, ["/bin/bash", "-e", "/workspace/script", "hello"]);
676    }
677
678    #[tokio::test]
679    async fn loads_shebang_through_relative_symlink() {
680        let mut builder = WasiEnvBuilder::new("test").engine(Engine::default());
681        builder.preopen_vfs_dirs(["/".to_string()]).unwrap();
682        let env = builder.build().unwrap();
683        let fs = &env.state.fs.root_fs;
684        fs.create_dir(Path::new("/bin")).unwrap();
685        fs.create_dir(Path::new("/pkg")).unwrap();
686
687        let mut target = fs
688            .new_open_options()
689            .create(true)
690            .write(true)
691            .open(Path::new("/pkg/next"))
692            .unwrap();
693        target
694            .write_all(b"#!/usr/bin/env node\nconsole.log('hello')\n")
695            .await
696            .unwrap();
697        fs.create_symlink(Path::new("../pkg/next"), Path::new("/bin/next"))
698            .unwrap();
699
700        let executable = load_executable_from_wasi_fs(&env, Path::new("/bin/next"), env.runtime())
701            .await
702            .unwrap();
703        assert!(matches!(executable, Executable::Script(_)));
704    }
705
706    #[tokio::test]
707    async fn loads_an_executable_outside_every_preopen() {
708        // An embedder that preopens only its own directory still gets to exec
709        // what it wrote elsewhere; the inode tree offers nothing there, since
710        // the root holds preopens alone.
711        let backing = virtual_fs::mem_fs::FileSystem::default();
712        backing.create_dir(Path::new("/app")).unwrap();
713        backing.create_dir(Path::new("/bin")).unwrap();
714
715        let mut builder = WasiEnvBuilder::new("test")
716            .engine(Engine::default())
717            .fs(Arc::new(backing) as Arc<dyn FileSystem + Send + Sync>);
718        builder.preopen_vfs_dirs(["/app".to_string()]).unwrap();
719        let env = builder.build().unwrap();
720        let fs = &env.state.fs.root_fs;
721
722        let mut target = fs
723            .new_open_options()
724            .create(true)
725            .write(true)
726            .open(Path::new("/bin/tool"))
727            .unwrap();
728        target.write_all(b"#!/bin/sh\necho hello\n").await.unwrap();
729
730        assert!(
731            env.state
732                .fs
733                .get_inode_at_path(&env.state.inodes, VIRTUAL_ROOT_FD, "/bin/tool", true)
734                .is_err(),
735            "the preopens should not cover this path, or the test proves nothing"
736        );
737
738        let executable = load_executable_from_wasi_fs(&env, Path::new("/bin/tool"), env.runtime())
739            .await
740            .unwrap();
741        assert!(matches!(executable, Executable::Script(_)));
742    }
743
744    #[tokio::test]
745    async fn a_failed_script_spawn_leaves_the_caller_argv_alone() {
746        // `proc_exec4` shares this state with a caller that keeps running when
747        // the spawn fails, so a shebang naming a missing interpreter must not
748        // leave the caller holding arguments it never had.
749        let backing = virtual_fs::mem_fs::FileSystem::default();
750        backing.create_dir(Path::new("/bin")).unwrap();
751
752        let mut builder = WasiEnvBuilder::new("tool")
753            .args(["one", "two"])
754            .engine(Engine::default())
755            .fs(Arc::new(backing) as Arc<dyn FileSystem + Send + Sync>);
756        builder.preopen_vfs_dirs(["/".to_string()]).unwrap();
757        let env = builder.build().unwrap();
758
759        let mut script = env
760            .state
761            .fs
762            .root_fs
763            .new_open_options()
764            .create(true)
765            .write(true)
766            .open(Path::new("/bin/tool"))
767            .unwrap();
768        script
769            .write_all(b"#!/bin/definitely-not-here\n")
770            .await
771            .unwrap();
772
773        let before = env.state.args.lock().unwrap().clone();
774        let result = env
775            .bin_factory
776            .clone()
777            .spawn("/bin/tool".to_string(), None, env.clone())
778            .await;
779
780        assert!(result.is_err(), "the interpreter does not exist");
781        assert_eq!(*env.state.args.lock().unwrap(), before);
782    }
783
784    #[tokio::test]
785    async fn a_shebang_cycle_reports_a_loop() {
786        // Linux answers ELOOP for a cycle and for a chain too deep to walk,
787        // rather than the EINVAL a bad ABI would get.
788        let backing = virtual_fs::mem_fs::FileSystem::default();
789        backing.create_dir(Path::new("/bin")).unwrap();
790
791        let mut builder = WasiEnvBuilder::new("tool")
792            .engine(Engine::default())
793            .fs(Arc::new(backing) as Arc<dyn FileSystem + Send + Sync>);
794        builder.preopen_vfs_dirs(["/".to_string()]).unwrap();
795        let env = builder.build().unwrap();
796
797        for (name, target) in [("a", "/bin/b"), ("b", "/bin/a")] {
798            let mut file = env
799                .state
800                .fs
801                .root_fs
802                .new_open_options()
803                .create(true)
804                .write(true)
805                .open(Path::new(&format!("/bin/{name}")))
806                .unwrap();
807            file.write_all(format!("#!{target}\n").as_bytes())
808                .await
809                .unwrap();
810        }
811
812        let result = env
813            .bin_factory
814            .clone()
815            .spawn("/bin/a".to_string(), None, env.clone())
816            .await;
817
818        assert!(matches!(result, Err(SpawnError::ShebangLoop)));
819        assert_eq!(
820            crate::syscalls::conv_spawn_err_to_errno(&SpawnError::ShebangLoop),
821            Errno::Loop
822        );
823    }
824
825    #[tokio::test]
826    async fn a_symlink_outside_every_preopen_is_not_followed() {
827        // Documents today's boundary rather than endorsing it: the fallback
828        // opens the root filesystem, which stops at the link itself. Widen this
829        // test if that ever changes.
830        let backing = virtual_fs::mem_fs::FileSystem::default();
831        backing.create_dir(Path::new("/app")).unwrap();
832        backing.create_dir(Path::new("/bin")).unwrap();
833        backing.create_dir(Path::new("/pkg")).unwrap();
834
835        let mut builder = WasiEnvBuilder::new("test")
836            .engine(Engine::default())
837            .fs(Arc::new(backing) as Arc<dyn FileSystem + Send + Sync>);
838        builder.preopen_vfs_dirs(["/app".to_string()]).unwrap();
839        let env = builder.build().unwrap();
840        let fs = &env.state.fs.root_fs;
841
842        let mut target = fs
843            .new_open_options()
844            .create(true)
845            .write(true)
846            .open(Path::new("/pkg/tool"))
847            .unwrap();
848        target.write_all(b"#!/bin/sh\necho hello\n").await.unwrap();
849        fs.create_symlink(Path::new("../pkg/tool"), Path::new("/bin/tool"))
850            .unwrap();
851
852        assert!(
853            load_executable_from_wasi_fs(&env, Path::new("/bin/tool"), env.runtime())
854                .await
855                .is_err()
856        );
857
858        // The file the link points at is reachable directly, so only the
859        // symlink hop is what fails.
860        let executable = load_executable_from_wasi_fs(&env, Path::new("/pkg/tool"), env.runtime())
861            .await
862            .unwrap();
863        assert!(matches!(executable, Executable::Script(_)));
864    }
865}