wasmer_wasix/runners/
wasi_common.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4    sync::Arc,
5};
6
7use anyhow::{Context, Error};
8use tokio::runtime::Handle;
9use virtual_fs::{
10    FileSystem, OverlayFileSystem, RootFileSystemBuilder, TmpFileSystem, UnionFileSystem,
11};
12use webc::metadata::annotations::Wasi as WasiAnnotation;
13
14use crate::{
15    WasiEnvBuilder,
16    bin_factory::BinaryPackage,
17    capabilities::Capabilities,
18    fs::{WasiFsRoot, relative_path_hack::RelativeOrAbsolutePathHack},
19    journal::{DynJournal, DynReadableJournal, SnapshotTrigger},
20};
21
22pub const MAPPED_CURRENT_DIR_DEFAULT_PATH: &str = "/home";
23
24#[derive(Debug, Clone)]
25pub struct MappedCommand {
26    /// The new alias.
27    pub alias: String,
28    /// The original command.
29    pub target: String,
30}
31
32#[derive(Debug, Default, Clone)]
33pub(crate) struct CommonWasiOptions {
34    pub(crate) entry_function: Option<String>,
35    pub(crate) args: Vec<String>,
36    pub(crate) env: HashMap<String, String>,
37    pub(crate) forward_host_env: bool,
38    pub(crate) mapped_host_commands: Vec<MappedCommand>,
39    pub(crate) mounts: Vec<MountedDirectory>,
40    pub(crate) is_home_mapped: bool,
41    pub(crate) injected_packages: Vec<BinaryPackage>,
42    pub(crate) capabilities: Capabilities,
43    pub(crate) read_only_journals: Vec<Arc<DynReadableJournal>>,
44    pub(crate) writable_journals: Vec<Arc<DynJournal>>,
45    pub(crate) snapshot_on: Vec<SnapshotTrigger>,
46    pub(crate) snapshot_interval: Option<std::time::Duration>,
47    pub(crate) stop_running_after_snapshot: bool,
48    pub(crate) skip_stdio_during_bootstrap: bool,
49    pub(crate) current_dir: Option<PathBuf>,
50}
51
52impl CommonWasiOptions {
53    pub(crate) fn prepare_webc_env(
54        &self,
55        builder: &mut WasiEnvBuilder,
56        container_fs: Option<UnionFileSystem>,
57        wasi: &WasiAnnotation,
58        root_fs: Option<TmpFileSystem>,
59    ) -> Result<(), anyhow::Error> {
60        if let Some(ref entry_function) = self.entry_function {
61            builder.set_entry_function(entry_function);
62        }
63
64        let root_fs = root_fs.unwrap_or_else(|| {
65            let mapped_dirs = self
66                .mounts
67                .iter()
68                .map(|d| d.guest.as_str())
69                .collect::<Vec<_>>();
70            RootFileSystemBuilder::default().build_ext(&mapped_dirs)
71        });
72        let fs = prepare_filesystem(root_fs, &self.mounts, container_fs)?;
73
74        // TODO: What's a preopen for '.' supposed to mean anyway? Why do we need it?
75        if self.mounts.iter().all(|m| m.guest != ".") {
76            // The user hasn't mounted "." to anything, so let's map it to "/"
77            let path = builder.get_current_dir().unwrap_or(PathBuf::from("/"));
78            builder.add_map_dir(".", path)?;
79        }
80
81        builder.add_preopen_dir("/")?;
82
83        builder.set_fs_root(fs);
84
85        for pkg in &self.injected_packages {
86            builder.add_webc(pkg.clone());
87        }
88
89        let mapped_cmds = self
90            .mapped_host_commands
91            .iter()
92            .map(|c| (c.alias.as_str(), c.target.as_str()));
93        builder.add_mapped_commands(mapped_cmds);
94
95        self.populate_env(wasi, builder);
96        self.populate_args(wasi, builder);
97
98        *builder.capabilities_mut() = self.capabilities.clone();
99
100        #[cfg(feature = "journal")]
101        {
102            for journal in &self.read_only_journals {
103                builder.add_read_only_journal(journal.clone());
104            }
105            for journal in &self.writable_journals {
106                builder.add_writable_journal(journal.clone());
107            }
108            for trigger in &self.snapshot_on {
109                builder.add_snapshot_trigger(*trigger);
110            }
111            if let Some(interval) = self.snapshot_interval {
112                builder.with_snapshot_interval(interval);
113            }
114            builder.with_stop_running_after_snapshot(self.stop_running_after_snapshot);
115        }
116
117        Ok(())
118    }
119
120    fn populate_env(&self, wasi: &WasiAnnotation, builder: &mut WasiEnvBuilder) {
121        for item in wasi.env.as_deref().unwrap_or_default() {
122            // TODO(Michael-F-Bryan): Convert "wasi.env" in the webc crate from an
123            // Option<Vec<String>> to a HashMap<String, String> so we avoid this
124            // string.split() business
125            match item.split_once('=') {
126                Some((k, v)) => {
127                    builder.add_env(k, v);
128                }
129                None => {
130                    builder.add_env(item, String::new());
131                }
132            }
133        }
134
135        if self.forward_host_env {
136            builder.add_envs(std::env::vars());
137        }
138
139        builder.add_envs(self.env.clone());
140    }
141
142    fn populate_args(&self, wasi: &WasiAnnotation, builder: &mut WasiEnvBuilder) {
143        if let Some(main_args) = &wasi.main_args {
144            builder.add_args(main_args);
145        }
146
147        builder.add_args(&self.args);
148    }
149}
150
151// type ContainerFs =
152//     OverlayFileSystem<TmpFileSystem, [RelativeOrAbsolutePathHack<Arc<dyn FileSystem>>; 1]>;
153
154fn build_directory_mappings(
155    root_fs: &mut TmpFileSystem,
156    mounted_dirs: &[MountedDirectory],
157) -> Result<(), anyhow::Error> {
158    for dir in mounted_dirs {
159        let MountedDirectory {
160            guest: guest_path,
161            fs,
162        } = dir;
163        let mut guest_path = PathBuf::from(guest_path);
164        tracing::debug!(
165            guest=%guest_path.display(),
166            "Mounting",
167        );
168
169        if guest_path.is_relative() {
170            guest_path = apply_relative_path_mounting_hack(&guest_path);
171        }
172
173        let guest_path = root_fs
174            .canonicalize_unchecked(&guest_path)
175            .with_context(|| {
176                format!(
177                    "Unable to canonicalize guest path '{}'",
178                    guest_path.display()
179                )
180            })?;
181
182        if guest_path == Path::new("/") {
183            root_fs
184                .mount_directory_entries(&guest_path, fs, "/".as_ref())
185                .context("Unable to mount to root")?;
186        } else {
187            if let Some(parent) = guest_path.parent() {
188                create_dir_all(&*root_fs, parent).with_context(|| {
189                    format!("Unable to create the \"{}\" directory", parent.display())
190                })?;
191            }
192
193            TmpFileSystem::mount(root_fs, guest_path.clone(), fs, "/".into())
194                .with_context(|| format!("Unable to mount \"{}\"", guest_path.display()))?;
195        }
196    }
197
198    Ok(())
199}
200
201fn prepare_filesystem(
202    mut root_fs: TmpFileSystem,
203    mounted_dirs: &[MountedDirectory],
204    container_fs: Option<UnionFileSystem>,
205) -> Result<WasiFsRoot, Error> {
206    if !mounted_dirs.is_empty() {
207        build_directory_mappings(&mut root_fs, mounted_dirs)?;
208    }
209
210    // HACK(Michael-F-Bryan): The WebcVolumeFileSystem only accepts relative
211    // paths, but our Python executable will try to access its standard library
212    // with relative paths assuming that it is being run from the root
213    // directory (i.e. it does `open("lib/python3.6/io.py")` instead of
214    // `open("/lib/python3.6/io.py")`).
215    // Until the FileSystem trait figures out whether relative paths should be
216    // supported or not, we'll add an adapter that automatically retries
217    // operations using an absolute path if it failed using a relative path.
218
219    let fs = if let Some(container) = container_fs {
220        let container = RelativeOrAbsolutePathHack(container);
221        let fs = OverlayFileSystem::new(root_fs, [container]);
222        WasiFsRoot::Overlay(Arc::new(fs))
223    } else {
224        WasiFsRoot::Sandbox(root_fs)
225    };
226
227    Ok(fs)
228}
229
230/// HACK: We need this so users can mount host directories at relative paths.
231/// This assumes that the current directory when a runner starts will be "/", so
232/// instead of mounting to a relative path, we just mount to "/$path".
233///
234/// This isn't really a long-term solution because there is no guarantee what
235/// the current directory will be. The WASI spec also doesn't require the
236/// current directory to be part of the "main" filesystem at all, we really
237/// *should* be mounting to a relative directory but that isn't supported by our
238/// virtual fs layer.
239///
240/// See <https://github.com/wasmerio/wasmer/issues/3794> for more.
241fn apply_relative_path_mounting_hack(original: &Path) -> PathBuf {
242    debug_assert!(original.is_relative());
243
244    let root = Path::new("/");
245    let mapped_path = if original == Path::new(".") {
246        root.to_path_buf()
247    } else {
248        root.join(original)
249    };
250
251    tracing::debug!(
252        original_path=%original.display(),
253        remapped_path=%mapped_path.display(),
254        "Remapping a relative path"
255    );
256
257    mapped_path
258}
259
260fn create_dir_all(fs: &dyn FileSystem, path: &Path) -> Result<(), Error> {
261    if fs.metadata(path).is_ok() {
262        return Ok(());
263    }
264
265    if let Some(parent) = path.parent() {
266        create_dir_all(fs, parent)?;
267    }
268
269    fs.create_dir(path)?;
270
271    Ok(())
272}
273
274#[derive(Debug, Clone)]
275pub struct MountedDirectory {
276    pub guest: String,
277    pub fs: Arc<dyn FileSystem + Send + Sync>,
278}
279
280/// A directory that should be mapped from the host filesystem into a WASI
281/// instance (the "guest").
282///
283/// # Panics
284///
285/// Converting a [`MappedDirectory`] to a [`MountedDirectory`] requires enabling
286/// the `host-fs` feature flag. Using the [`From`] implementation without
287/// enabling this feature will result in a runtime panic.
288#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
289pub struct MappedDirectory {
290    /// The absolute path for a directory on the host filesystem.
291    pub host: std::path::PathBuf,
292    /// The absolute path specifying where the host directory should be mounted
293    /// inside the guest.
294    pub guest: String,
295}
296
297impl From<MappedDirectory> for MountedDirectory {
298    fn from(value: MappedDirectory) -> Self {
299        cfg_if::cfg_if! {
300            if #[cfg(feature = "host-fs")] {
301                let MappedDirectory { host, guest } = value;
302                let fs: Arc<dyn FileSystem + Send + Sync> =
303                    Arc::new(virtual_fs::host_fs::FileSystem::new(Handle::current(), host).unwrap());
304
305                MountedDirectory { guest, fs }
306            } else {
307                unreachable!("The `host-fs` feature needs to be enabled to map {value:?}")
308            }
309        }
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use std::time::SystemTime;
316
317    use tempfile::TempDir;
318    use virtual_fs::{DirEntry, FileType, Metadata};
319
320    use super::*;
321
322    const PYTHON: &[u8] = include_bytes!("../../../c-api/examples/assets/python-0.1.0.wasmer");
323
324    /// Fixes <https://github.com/wasmerio/wasmer/issues/3789>
325    #[tokio::test]
326    async fn mix_args_from_the_webc_and_user() {
327        let args = CommonWasiOptions {
328            args: vec!["extra".to_string(), "args".to_string()],
329            ..Default::default()
330        };
331        let mut builder = WasiEnvBuilder::new("program-name");
332        let mut annotations = WasiAnnotation::new("some-atom");
333        annotations.main_args = Some(vec![
334            "hard".to_string(),
335            "coded".to_string(),
336            "args".to_string(),
337        ]);
338
339        args.prepare_webc_env(&mut builder, None, &annotations, None)
340            .unwrap();
341
342        assert_eq!(
343            builder.get_args(),
344            [
345                // the program name from
346                "program-name",
347                // from the WEBC's annotations
348                "hard",
349                "coded",
350                "args",
351                // from the user
352                "extra",
353                "args",
354            ]
355        );
356    }
357
358    #[tokio::test]
359    async fn mix_env_vars_from_the_webc_and_user() {
360        let args = CommonWasiOptions {
361            env: vec![("EXTRA".to_string(), "envs".to_string())]
362                .into_iter()
363                .collect(),
364            ..Default::default()
365        };
366        let mut builder = WasiEnvBuilder::new("python");
367        let mut annotations = WasiAnnotation::new("python");
368        annotations.env = Some(vec!["HARD_CODED=env-vars".to_string()]);
369
370        args.prepare_webc_env(&mut builder, None, &annotations, None)
371            .unwrap();
372
373        assert_eq!(
374            builder.get_env(),
375            [
376                ("HARD_CODED".to_string(), b"env-vars".to_vec()),
377                ("EXTRA".to_string(), b"envs".to_vec()),
378            ]
379        );
380    }
381
382    fn unix_timestamp_nanos(instant: SystemTime) -> Option<u64> {
383        let duration = instant.duration_since(SystemTime::UNIX_EPOCH).ok()?;
384        Some(duration.as_nanos() as u64)
385    }
386
387    #[tokio::test]
388    #[cfg_attr(not(feature = "host-fs"), ignore)]
389    async fn python_use_case() {
390        let temp = TempDir::new().unwrap();
391        let sub_dir = temp.path().join("path").join("to");
392        std::fs::create_dir_all(&sub_dir).unwrap();
393        std::fs::write(sub_dir.join("file.txt"), b"Hello, World!").unwrap();
394        let mapping = [MountedDirectory::from(MappedDirectory {
395            guest: "/home".to_string(),
396            host: sub_dir,
397        })];
398        let container = wasmer_package::utils::from_bytes(PYTHON).unwrap();
399        let webc_fs = virtual_fs::WebcVolumeFileSystem::mount_all(&container);
400        let union_fs = UnionFileSystem::new();
401        union_fs
402            .mount("webc".to_string(), Path::new("/"), Box::new(webc_fs))
403            .unwrap();
404
405        let root_fs = RootFileSystemBuilder::default().build();
406        let fs = prepare_filesystem(root_fs, &mapping, Some(union_fs)).unwrap();
407
408        assert!(matches!(fs, WasiFsRoot::Overlay(_)));
409        if let WasiFsRoot::Overlay(overlay_fs) = &fs {
410            use virtual_fs::FileSystem;
411            assert!(
412                overlay_fs
413                    .metadata("/home/file.txt".as_ref())
414                    .unwrap()
415                    .is_file()
416            );
417            assert!(overlay_fs.metadata("lib".as_ref()).unwrap().is_dir());
418            assert!(
419                overlay_fs
420                    .metadata("lib/python3.6/collections/__init__.py".as_ref())
421                    .unwrap()
422                    .is_file()
423            );
424            assert!(
425                overlay_fs
426                    .metadata("lib/python3.6/encodings/__init__.py".as_ref())
427                    .unwrap()
428                    .is_file()
429            );
430        }
431    }
432
433    #[tokio::test]
434    #[cfg_attr(not(feature = "host-fs"), ignore)]
435    async fn convert_mapped_directory_to_mounted_directory() {
436        let temp = TempDir::new().unwrap();
437        let dir = MappedDirectory {
438            guest: "/mnt/dir".to_string(),
439            host: temp.path().to_path_buf(),
440        };
441        let contents = "Hello, World!";
442        let file_txt = temp.path().join("file.txt");
443        std::fs::write(&file_txt, contents).unwrap();
444        let metadata = std::fs::metadata(&file_txt).unwrap();
445
446        let got = MountedDirectory::from(dir);
447
448        let directory_contents: Vec<_> = got
449            .fs
450            .read_dir("/".as_ref())
451            .unwrap()
452            .map(|entry| entry.unwrap())
453            .collect();
454        assert_eq!(
455            directory_contents,
456            vec![DirEntry {
457                path: PathBuf::from("/file.txt"),
458                metadata: Ok(Metadata {
459                    ft: FileType::new_file(),
460                    // Note: Some timestamps aren't available on MUSL and will
461                    // default to zero.
462                    accessed: metadata
463                        .accessed()
464                        .ok()
465                        .and_then(unix_timestamp_nanos)
466                        .unwrap_or(0),
467                    created: metadata
468                        .created()
469                        .ok()
470                        .and_then(unix_timestamp_nanos)
471                        .unwrap_or(0),
472                    modified: metadata
473                        .modified()
474                        .ok()
475                        .and_then(unix_timestamp_nanos)
476                        .unwrap_or(0),
477                    len: contents.len() as u64,
478                })
479            }]
480        );
481    }
482}