diff --git a/CHANGELOG.md b/CHANGELOG.md index d93da9b6836..62aa45ac7ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Blocks of changes will separated by version increments. Special thanks to @YaronWittenstein @penberg for their contributions. +- [#650](https://github.com/wasmerio/wasmer/issues/650) Implement `wasi::path_rename`, improve WASI FS public api, and allow open files to exist even when the underlying file is deleted - [#643](https://github.com/wasmerio/wasmer/issues/643) Implement `wasi::path_symlink` and improve WASI FS public api IO error reporting - [#608](https://github.com/wasmerio/wasmer/issues/608) Implement wasi syscalls `fd_allocate`, `fd_sync`, `fd_pread`, `path_link`, `path_filestat_set_times`; update WASI fs API in a WIP way; reduce coupling of WASI code to host filesystem; make debug messages from WASI more readable; improve rights-checking when calling syscalls; implement reference counting on inodes; misc bug fixes and improvements - [#616](https://github.com/wasmerio/wasmer/issues/616) Create the import object separately from instance instantiation in `runtime-c-api` diff --git a/Cargo.lock b/Cargo.lock index 049ee21d612..0c81bdc3cb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1516,6 +1516,7 @@ dependencies = [ "rustc_version 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)", "semver 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", "smallvec 0.6.10 (registry+https://github.com/rust-lang/crates.io-index)", + "structopt 0.2.18 (registry+https://github.com/rust-lang/crates.io-index)", "wabt 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", "wasmer-runtime-core 0.6.0", "wasmparser 0.35.1 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/Makefile b/Makefile index f6a1a4f92c8..a12d14fe591 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ generate-spectests: generate-emtests: WASM_EMSCRIPTEN_GENERATE_EMTESTS=1 cargo build -p wasmer-emscripten-tests --release -generate-wasitests: +generate-wasitests: wasitests-setup WASM_WASI_GENERATE_WASITESTS=1 cargo build -p wasmer-wasi-tests --release -vv \ && echo "formatting" \ && cargo fmt diff --git a/lib/wasi-tests/tests/wasitests/mod.rs b/lib/wasi-tests/tests/wasitests/mod.rs index c9309aea072..954d80bb4c3 100644 --- a/lib/wasi-tests/tests/wasitests/mod.rs +++ b/lib/wasi-tests/tests/wasitests/mod.rs @@ -16,6 +16,7 @@ mod fseek; mod hello; mod mapdir; mod path_link; +mod path_rename; mod path_symlink; mod quine; mod readlink; diff --git a/lib/wasi-tests/tests/wasitests/path_rename.rs b/lib/wasi-tests/tests/wasitests/path_rename.rs new file mode 100644 index 00000000000..9c1d3d7a08d --- /dev/null +++ b/lib/wasi-tests/tests/wasitests/path_rename.rs @@ -0,0 +1,14 @@ +#[test] +fn test_path_rename() { + assert_wasi_output!( + "../../wasitests/path_rename.wasm", + "path_rename", + vec![], + vec![( + "temp".to_string(), + ::std::path::PathBuf::from("wasitests/test_fs/temp") + ),], + vec![], + "../../wasitests/path_rename.out" + ); +} diff --git a/lib/wasi-tests/wasitests/path_rename.out b/lib/wasi-tests/wasitests/path_rename.out new file mode 100644 index 00000000000..7b44f57637a --- /dev/null +++ b/lib/wasi-tests/wasitests/path_rename.out @@ -0,0 +1,2 @@ +The original file does not still exist! +柴犬 diff --git a/lib/wasi-tests/wasitests/path_rename.rs b/lib/wasi-tests/wasitests/path_rename.rs new file mode 100644 index 00000000000..f60eff106c8 --- /dev/null +++ b/lib/wasi-tests/wasitests/path_rename.rs @@ -0,0 +1,56 @@ +// Args: +// mapdir: temp:wasitests/test_fs/temp + +use std::fs; +use std::io::{Read, Write}; +use std::path::PathBuf; + +fn main() { + #[cfg(not(target_os = "wasi"))] + let mut base = PathBuf::from("wasitests/test_fs"); + #[cfg(target_os = "wasi")] + let mut base = PathBuf::from("/"); + + let file_to_create = base.join("temp/path_rename_file.txt"); + let file_to_rename_to = base.join("temp/path_renamed_file.txt"); + + { + let mut f = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&file_to_create) + .unwrap(); + + // text from https://ja.wikipedia.org/wiki/柴犬 + let shiba_string = "「柴犬」という名前は中央高地で使われていたもので、文献上では、昭和初期の日本犬保存会の会誌「日本犬」で用いられている。一般的には、「柴」は小ぶりな雑木を指す。 +由来には諸説があり、 + + 柴藪を巧みにくぐり抜けて猟を助けることから + 赤褐色の毛色が枯れ柴に似ている(柴赤)ことから + 小さなものを表す古語の「柴」から + +の3つの説が代表的。"; + let shiba_bytes: Vec = shiba_string.bytes().collect(); + f.write_all(&shiba_bytes[..]).unwrap(); + } + + std::fs::rename(&file_to_create, &file_to_rename_to).unwrap(); + let mut file = fs::File::open(&file_to_rename_to).expect("Could not open file"); + if file_to_create.exists() { + println!("The original file still exists!"); + return; + } else { + println!("The original file does not still exist!"); + } + + let mut out_str = String::new(); + file.read_to_string(&mut out_str).unwrap(); + let mut test_str = String::new(); + let mut out_chars = out_str.chars(); + out_chars.next().unwrap(); + test_str.push(out_chars.next().unwrap()); + test_str.push(out_chars.next().unwrap()); + + println!("{}", test_str); + std::fs::remove_file(file_to_rename_to).unwrap(); +} diff --git a/lib/wasi-tests/wasitests/path_rename.wasm b/lib/wasi-tests/wasitests/path_rename.wasm new file mode 100755 index 00000000000..40197b5319b Binary files /dev/null and b/lib/wasi-tests/wasitests/path_rename.wasm differ diff --git a/lib/wasi/src/macros.rs b/lib/wasi/src/macros.rs index 819a4c91b89..e0e144a626f 100644 --- a/lib/wasi/src/macros.rs +++ b/lib/wasi/src/macros.rs @@ -17,3 +17,9 @@ macro_rules! wasi_try { wasi_try!(opt.ok_or($e)) }}; } + +macro_rules! get_input_str { + ($memory:expr, $data:expr, $len:expr) => {{ + wasi_try!($data.get_utf8_string($memory, $len), __WASI_EINVAL) + }}; +} diff --git a/lib/wasi/src/state/mod.rs b/lib/wasi/src/state/mod.rs index fb1c4f63ac8..dfcf5274109 100644 --- a/lib/wasi/src/state/mod.rs +++ b/lib/wasi/src/state/mod.rs @@ -106,7 +106,7 @@ pub enum Kind { }, } -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct Fd { pub rights: __wasi_rights_t, pub rights_inheriting: __wasi_rights_t, @@ -126,6 +126,8 @@ pub struct WasiFs { pub fd_map: HashMap, pub next_fd: Cell, inode_counter: Cell, + /// for fds still open after the file has been deleted + pub orphan_fds: HashMap, pub stdout: Box, pub stderr: Box, @@ -146,6 +148,7 @@ impl WasiFs { fd_map: HashMap::new(), next_fd: Cell::new(3), inode_counter: Cell::new(1024), + orphan_fds: HashMap::new(), stdin: Box::new(Stdin(io::stdin())), stdout: Box::new(Stdout(io::stdout())), @@ -683,6 +686,16 @@ impl WasiFs { self.fd_map.get(&fd).ok_or(__WASI_EBADF) } + /// gets either a normal inode or an orphaned inode + pub fn get_inodeval_mut(&mut self, fd: __wasi_fd_t) -> Result<&mut InodeVal, __wasi_errno_t> { + let inode = self.get_fd(fd)?.inode; + if let Some(iv) = self.inodes.get_mut(inode) { + Ok(iv) + } else { + self.orphan_fds.get_mut(&inode).ok_or(__WASI_EBADF) + } + } + pub fn filestat_fd(&self, fd: __wasi_fd_t) -> Result<__wasi_filestat_t, __wasi_errno_t> { let fd = self.get_fd(fd)?; @@ -818,8 +831,8 @@ impl WasiFs { /// all refences to the given inode have been removed from the filesystem /// /// returns true if the inode existed and was removed - pub unsafe fn remove_inode(&mut self, inode: Inode) -> bool { - self.inodes.remove(inode).is_some() + pub unsafe fn remove_inode(&mut self, inode: Inode) -> Option { + self.inodes.remove(inode) } fn create_virtual_root(&mut self) -> Inode { diff --git a/lib/wasi/src/state/types.rs b/lib/wasi/src/state/types.rs index 57ee29fdd6a..64f7d079392 100644 --- a/lib/wasi/src/state/types.rs +++ b/lib/wasi/src/state/types.rs @@ -158,6 +158,13 @@ pub trait WasiFile: std::fmt::Debug + Write + Read + Seek { fn sync_to_disk(&self) -> Result<(), WasiFsError> { panic!("Default implementation for compatibilty in the 0.6.X releases; this will be removed in 0.7.0. Please implement WasiFile::sync_to_disk for your type before then"); } + + /// Moves the file to a new location + /// NOTE: the signature of this function will change before stabilization + // TODO: stablizie this in 0.7.0 or 0.8.0 by removing default impl + fn rename_file(&self, _new_name: &std::path::Path) -> Result<(), WasiFsError> { + panic!("Default implementation for compatibilty in the 0.6.X releases; this will be removed in 0.7.0 or 0.8.0. Please implement WasiFile::rename_file for your type before then"); + } } pub trait WasiPath {} @@ -271,6 +278,10 @@ impl WasiFile for HostFile { fn sync_to_disk(&self) -> Result<(), WasiFsError> { self.inner.sync_all().map_err(Into::into) } + + fn rename_file(&self, new_name: &std::path::Path) -> Result<(), WasiFsError> { + std::fs::rename(&self.host_path, new_name).map_err(Into::into) + } } impl From for WasiFsError { diff --git a/lib/wasi/src/syscalls/mod.rs b/lib/wasi/src/syscalls/mod.rs index 0000cb6d6b3..8e403537a80 100644 --- a/lib/wasi/src/syscalls/mod.rs +++ b/lib/wasi/src/syscalls/mod.rs @@ -364,15 +364,14 @@ pub fn fd_allocate( /// - `__WASI_EISDIR` /// If `fd` is a directory /// - `__WASI_EBADF` -/// If `fd` is invalid or not open (TODO: consider __WASI_EINVAL) +/// If `fd` is invalid or not open pub fn fd_close(ctx: &mut Ctx, fd: __wasi_fd_t) -> __wasi_errno_t { debug!("wasi::fd_close"); let memory = ctx.memory(0); let state = get_wasi_state(ctx); - let fd_entry = wasi_try!(state.fs.get_fd(fd)).clone(); + let inode_val = wasi_try!(state.fs.get_inodeval_mut(fd)); - let inode_val = &mut state.fs.inodes[fd_entry.inode]; if inode_val.is_preopened { return __WASI_EACCES; } @@ -1303,7 +1302,7 @@ pub fn path_create_directory( if !has_rights(working_dir.rights, __WASI_RIGHT_PATH_CREATE_DIRECTORY) { return __WASI_EACCES; } - let path_string = wasi_try!(path.get_utf8_string(memory, path_len), __WASI_EINVAL); + let path_string = get_input_str!(memory, path, path_len); debug!("=> fd: {}, path: {}", fd, &path_string); let path = std::path::PathBuf::from(path_string); @@ -1407,8 +1406,7 @@ pub fn path_filestat_get( if !has_rights(root_dir.rights, __WASI_RIGHT_PATH_FILESTAT_GET) { return __WASI_EACCES; } - - let path_string = wasi_try!(path.get_utf8_string(memory, path_len).ok_or(__WASI_EINVAL)); + let path_string = get_input_str!(memory, path, path_len); debug!("=> base_fd: {}, path: {}", fd, &path_string); @@ -1459,6 +1457,7 @@ pub fn path_filestat_set_times( let memory = ctx.memory(0); let state = get_wasi_state(ctx); let fd_entry = wasi_try!(state.fs.get_fd(fd)).clone(); + let fd_inode = fd_entry.inode; if !has_rights(fd_entry.rights, __WASI_RIGHT_PATH_FILESTAT_SET_TIMES) { return __WASI_EACCES; } @@ -1469,7 +1468,7 @@ pub fn path_filestat_set_times( return __WASI_EINVAL; } - let path_string = wasi_try!(path.get_utf8_string(memory, path_len).ok_or(__WASI_EINVAL)); + let path_string = get_input_str!(memory, path, path_len); debug!("=> base_fd: {}, path: {}", fd, &path_string); let file_inode = wasi_try!(state.fs.get_inode_at_path( @@ -1482,7 +1481,7 @@ pub fn path_filestat_set_times( .get_stat_for_kind(&state.fs.inodes[file_inode].kind) .ok_or(__WASI_EIO)); - let inode = &mut state.fs.inodes[fd_entry.inode]; + let inode = &mut state.fs.inodes[fd_inode]; if fst_flags & __WASI_FILESTAT_SET_ATIM != 0 || fst_flags & __WASI_FILESTAT_SET_ATIM_NOW != 0 { let time_to_set = if fst_flags & __WASI_FILESTAT_SET_ATIM != 0 { @@ -1555,15 +1554,8 @@ pub fn path_link( } let memory = ctx.memory(0); let state = get_wasi_state(ctx); - let old_path_str = wasi_try!( - old_path.get_utf8_string(memory, old_path_len), - __WASI_EINVAL - ); - let new_path_str = wasi_try!( - new_path.get_utf8_string(memory, new_path_len), - __WASI_EINVAL - ); - + let old_path_str = get_input_str!(memory, old_path, old_path_len); + let new_path_str = get_input_str!(memory, new_path, new_path_len); let source_fd = wasi_try!(state.fs.get_fd(old_fd)); let target_fd = wasi_try!(state.fs.get_fd(new_fd)); debug!( @@ -1661,14 +1653,14 @@ pub fn path_open( // - __WASI_O_EXCL (fail if file exists) // - __WASI_O_TRUNC (truncate size to 0) - let working_dir = wasi_try!(state.fs.get_fd(dirfd)).clone(); + let working_dir = wasi_try!(state.fs.get_fd(dirfd)); + let working_dir_rights_inheriting = working_dir.rights_inheriting; // ASSUMPTION: open rights apply recursively if !has_rights(working_dir.rights, __WASI_RIGHT_PATH_OPEN) { return __WASI_EACCES; } - - let path_string = wasi_try!(path.get_utf8_string(memory, path_len).ok_or(__WASI_EINVAL)); + let path_string = get_input_str!(memory, path, path_len); debug!("=> fd: {}, path: {}", dirfd, &path_string); @@ -1680,13 +1672,13 @@ pub fn path_open( ); if let Ok(m) = maybe_inode { - dbg!(&state.fs.inodes[m]); + &state.fs.inodes[m]; } // TODO: traverse rights of dirs properly // COMMENTED OUT: WASI isn't giving appropriate rights here when opening // TODO: look into this; file a bug report if this is a bug - let adjusted_rights = /*fs_rights_base &*/ working_dir.rights_inheriting; + let adjusted_rights = /*fs_rights_base &*/ working_dir_rights_inheriting; let inode = if let Ok(inode) = maybe_inode { // Happy path, we found the file we're trying to open match &mut state.fs.inodes[inode].kind { @@ -1817,6 +1809,22 @@ pub fn path_open( __WASI_ESUCCESS } +/// ### `path_readlink()` +/// Read the value of a symlink +/// Inputs: +/// - `__wasi_fd_t dir_fd` +/// The base directory from which `path` is understood +/// - `const char *path` +/// Pointer to UTF-8 bytes that make up the path to the symlink +/// - `u32 path_len` +/// The number of bytes to read from `path` +/// - `u32 buf_len` +/// Space available pointed to by `buf` +/// Outputs: +/// - `char *buf` +/// Pointer to characters containing the path that the symlink points to +/// - `u32 buf_used` +/// The number of bytes written to `buf` pub fn path_readlink( ctx: &mut Ctx, dir_fd: __wasi_fd_t, @@ -1834,7 +1842,7 @@ pub fn path_readlink( if !has_rights(base_dir.rights, __WASI_RIGHT_PATH_READLINK) { return __WASI_EACCES; } - let path_str = wasi_try!(path.get_utf8_string(memory, path_len).ok_or(__WASI_EINVAL)); + let path_str = get_input_str!(memory, path, path_len); let inode = wasi_try!(state.fs.get_inode_at_path(dir_fd, path_str, false)); if let Kind::Symlink { relative_path, .. } = &state.fs.inodes[inode].kind { @@ -1875,7 +1883,7 @@ pub fn path_remove_directory( let memory = ctx.memory(0); let base_dir = wasi_try!(state.fs.fd_map.get(&fd), __WASI_EBADF); - let path_str = wasi_try!(path.get_utf8_string(memory, path_len), __WASI_EINVAL); + let path_str = get_input_str!(memory, path, path_len); let inode = wasi_try!(state.fs.get_inode_at_path(fd, path_str, false)); let (parent_inode, childs_name) = @@ -1927,6 +1935,21 @@ pub fn path_remove_directory( __WASI_ESUCCESS } +/// ### `path_rename()` +/// Rename a file or directory +/// Inputs: +/// - `__wasi_fd_t old_fd` +/// The base directory for `old_path` +/// - `const char* old_path` +/// Pointer to UTF8 bytes, the file to be renamed +/// - `u32 old_path_len` +/// The number of bytes to read from `old_path` +/// - `__wasi_fd_t new_fd` +/// The base directory for `new_path` +/// - `const char* new_path` +/// Pointer to UTF8 bytes, the new file name +/// - `u32 new_path_len` +/// The number of bytes to read from `new_path` pub fn path_rename( ctx: &mut Ctx, old_fd: __wasi_fd_t, @@ -1937,7 +1960,90 @@ pub fn path_rename( new_path_len: u32, ) -> __wasi_errno_t { debug!("wasi::path_rename"); - unimplemented!("wasi::path_rename") + let memory = ctx.memory(0); + let state = get_wasi_state(ctx); + let source_str = get_input_str!(memory, old_path, old_path_len); + let source_path = std::path::Path::new(source_str); + let target_str = get_input_str!(memory, new_path, new_path_len); + let target_path = std::path::Path::new(target_str); + + { + let source_fd = wasi_try!(state.fs.get_fd(old_fd)); + if !has_rights(source_fd.rights, __WASI_RIGHT_PATH_RENAME_SOURCE) { + return __WASI_EACCES; + } + let target_fd = wasi_try!(state.fs.get_fd(new_fd)); + if !has_rights(target_fd.rights, __WASI_RIGHT_PATH_RENAME_TARGET) { + return __WASI_EACCES; + } + } + + let (source_parent_inode, source_entry_name) = + wasi_try!(state.fs.get_parent_inode_at_path(old_fd, source_path, true)); + let (target_parent_inode, target_entry_name) = + wasi_try!(state.fs.get_parent_inode_at_path(new_fd, target_path, true)); + + let host_adjusted_target_path = match &state.fs.inodes[target_parent_inode].kind { + Kind::Dir { entries, path, .. } => { + if entries.contains_key(&target_entry_name) { + return __WASI_EEXIST; + } + let mut out_path = path.clone(); + // remove fd's own name which will be double counted + out_path.pop(); + out_path.push(target_path); + out_path + } + Kind::Root { .. } => return __WASI_ENOTCAPABLE, + Kind::Symlink { .. } | Kind::File { .. } | Kind::Buffer { .. } => { + unreachable!("Fatal internal logic error: parent of inode is not a directory") + } + }; + let source_entry = match &mut state.fs.inodes[source_parent_inode].kind { + Kind::Dir { entries, .. } => wasi_try!(entries.remove(&source_entry_name), __WASI_EINVAL), + Kind::Root { .. } => return __WASI_ENOTCAPABLE, + Kind::Symlink { .. } | Kind::File { .. } | Kind::Buffer { .. } => { + unreachable!("Fatal internal logic error: parent of inode is not a directory") + } + }; + + match &mut state.fs.inodes[source_entry].kind { + Kind::File { + handle, + ref mut path, + } => { + let result = if let Some(h) = handle { + h.rename_file(&host_adjusted_target_path) + .map_err(|e| e.into_wasi_err()) + } else { + let out = + std::fs::rename(&path, &host_adjusted_target_path).map_err(|_| __WASI_EIO); + *path = host_adjusted_target_path; + out + }; + // if the above operation failed we have to revert the previous change and then fail + if let Err(e) = result { + if let Kind::Dir { entries, .. } = &mut state.fs.inodes[source_parent_inode].kind { + entries.insert(source_entry_name, source_entry); + return e; + } + } + } + Kind::Dir { path, .. } => unimplemented!("wasi::path_rename on Directories"), + Kind::Buffer { .. } => {} + Kind::Symlink { .. } => {} + Kind::Root { .. } => unreachable!("The root can not be moved"), + } + + if let Kind::Dir { entries, .. } = &mut state.fs.inodes[target_parent_inode].kind { + let result = entries.insert(target_entry_name, source_entry); + assert!( + result.is_none(), + "Fatal error: race condition on filesystem detected or internal logic error" + ); + } + + __WASI_ESUCCESS } /// ### `path_symlink()` @@ -1964,14 +2070,8 @@ pub fn path_symlink( debug!("wasi::path_symlink"); let state = get_wasi_state(ctx); let memory = ctx.memory(0); - let old_path_str = wasi_try!( - old_path.get_utf8_string(memory, old_path_len), - __WASI_EINVAL - ); - let new_path_str = wasi_try!( - new_path.get_utf8_string(memory, new_path_len), - __WASI_EINVAL - ); + let old_path_str = get_input_str!(memory, old_path, old_path_len); + let new_path_str = get_input_str!(memory, new_path, new_path_len); let base_fd = wasi_try!(state.fs.get_fd(fd)); if !has_rights(base_fd.rights, __WASI_RIGHT_PATH_SYMLINK) { return __WASI_EACCES; @@ -2053,7 +2153,7 @@ pub fn path_unlink_file( if !has_rights(base_dir.rights, __WASI_RIGHT_PATH_UNLINK_FILE) { return __WASI_EACCES; } - let path_str = wasi_try!(path.get_utf8_string(memory, path_len).ok_or(__WASI_EINVAL)); + let path_str = get_input_str!(memory, path, path_len); debug!("Requested file: {}", path_str); let inode = wasi_try!(state.fs.get_inode_at_path(fd, path_str, false)); @@ -2097,16 +2197,43 @@ pub fn path_unlink_file( } _ => unimplemented!("wasi::path_unlink_file for Buffer"), } - let inode_was_removed = unsafe { state.fs.remove_inode(removed_inode) }; + // TODO: test this on Windows and actually make it portable + // make the file an orphan fd if the fd is still open + let fd_is_orphaned = if let Kind::File { handle, .. } = &state.fs.inodes[removed_inode].kind + { + handle.is_some() + } else { + false + }; + let removed_inode_val = unsafe { state.fs.remove_inode(removed_inode) }; assert!( - inode_was_removed, + removed_inode_val.is_some(), "Inode could not be removed because it doesn't exist" ); + + if fd_is_orphaned { + state + .fs + .orphan_fds + .insert(removed_inode, removed_inode_val.unwrap()); + } } __WASI_ESUCCESS } +/// ### `poll_oneoff()` +/// Concurrently poll for a set of events +/// Inputs: +/// - `const __wasi_subscription_t *in` +/// The events to subscribe to +/// - `__wasi_event_t *out` +/// The events that have occured +/// - `u32 nsubscriptions` +/// The number of subscriptions and the number of events +/// Output: +/// - `u32 nevents` +/// The number of events seen pub fn poll_oneoff( ctx: &mut Ctx, in_: WasmPtr<__wasi_subscription_t, Array>, @@ -2117,6 +2244,7 @@ pub fn poll_oneoff( debug!("wasi::poll_oneoff"); unimplemented!("wasi::poll_oneoff") } + pub fn proc_exit(ctx: &mut Ctx, code: __wasi_exitcode_t) -> Result { debug!("wasi::proc_exit, {}", code); Err(ExitCode { code })