mirror of
https://github.com/openai/codex.git
synced 2026-05-04 21:32:21 +03:00
Run exec-server fs operations through sandbox helper (#17294)
## Summary - run exec-server filesystem RPCs requiring sandboxing through a `codex-fs` arg0 helper over stdin/stdout - keep direct local filesystem execution for `DangerFullAccess` and external sandbox policies - remove the standalone exec-server binary path in favor of top-level arg0 dispatch/runtime paths - add sandbox escape regression coverage for local and remote filesystem paths ## Validation - `just fmt` - `git diff --check` - remote devbox: `cd codex-rs && bazel test --bes_backend= --bes_results_url= //codex-rs/exec-server:all` (6/6 passed) --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
546
codex-rs/exec-server/src/fs_sandbox.rs
Normal file
546
codex-rs/exec-server/src/fs_sandbox.rs
Normal file
@@ -0,0 +1,546 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::ReadOnlyAccess;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_sandboxing::SandboxCommand;
|
||||
use codex_sandboxing::SandboxExecRequest;
|
||||
use codex_sandboxing::SandboxManager;
|
||||
use codex_sandboxing::SandboxTransformRequest;
|
||||
use codex_sandboxing::SandboxablePreference;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::canonicalize_preserving_symlinks;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::ExecServerRuntimePaths;
|
||||
use crate::FileSystemSandboxContext;
|
||||
use crate::fs_helper::CODEX_FS_HELPER_ARG1;
|
||||
use crate::fs_helper::FsHelperPayload;
|
||||
use crate::fs_helper::FsHelperRequest;
|
||||
use crate::fs_helper::FsHelperResponse;
|
||||
use crate::local_file_system::current_sandbox_cwd;
|
||||
use crate::local_file_system::resolve_existing_path;
|
||||
use crate::protocol::FsCopyParams;
|
||||
use crate::protocol::FsCreateDirectoryParams;
|
||||
use crate::protocol::FsGetMetadataParams;
|
||||
use crate::protocol::FsReadDirectoryParams;
|
||||
use crate::protocol::FsReadFileParams;
|
||||
use crate::protocol::FsRemoveParams;
|
||||
use crate::protocol::FsWriteFileParams;
|
||||
use crate::rpc::internal_error;
|
||||
use crate::rpc::invalid_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct FileSystemSandboxRunner {
|
||||
runtime_paths: ExecServerRuntimePaths,
|
||||
}
|
||||
|
||||
impl FileSystemSandboxRunner {
|
||||
pub(crate) fn new(runtime_paths: ExecServerRuntimePaths) -> Self {
|
||||
Self { runtime_paths }
|
||||
}
|
||||
|
||||
pub(crate) async fn run(
|
||||
&self,
|
||||
sandbox: &FileSystemSandboxContext,
|
||||
request: FsHelperRequest,
|
||||
) -> Result<FsHelperPayload, JSONRPCErrorError> {
|
||||
let request_sandbox_policy =
|
||||
normalize_sandbox_policy_root_aliases(sandbox.sandbox_policy.clone());
|
||||
let helper_sandbox_policy = normalize_sandbox_policy_root_aliases(
|
||||
sandbox_policy_with_helper_runtime_defaults(&sandbox.sandbox_policy),
|
||||
);
|
||||
let cwd = current_sandbox_cwd().map_err(io_error)?;
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(cwd.as_path())
|
||||
.map_err(|err| invalid_request(format!("current directory is not absolute: {err}")))?;
|
||||
let request_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
|
||||
&request_sandbox_policy,
|
||||
cwd.as_path(),
|
||||
);
|
||||
let file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
|
||||
&helper_sandbox_policy,
|
||||
cwd.as_path(),
|
||||
);
|
||||
let request = resolve_request_paths(request, &request_file_system_policy, &cwd)?;
|
||||
let network_policy = NetworkSandboxPolicy::Restricted;
|
||||
let command = self.sandbox_exec_request(
|
||||
&helper_sandbox_policy,
|
||||
&file_system_policy,
|
||||
network_policy,
|
||||
&cwd,
|
||||
sandbox,
|
||||
)?;
|
||||
let request_json = serde_json::to_vec(&request).map_err(json_error)?;
|
||||
run_command(command, request_json).await
|
||||
}
|
||||
|
||||
fn sandbox_exec_request(
|
||||
&self,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
file_system_policy: &FileSystemSandboxPolicy,
|
||||
network_policy: NetworkSandboxPolicy,
|
||||
cwd: &AbsolutePathBuf,
|
||||
sandbox_context: &FileSystemSandboxContext,
|
||||
) -> Result<SandboxExecRequest, JSONRPCErrorError> {
|
||||
let helper = &self.runtime_paths.codex_self_exe;
|
||||
let sandbox_manager = SandboxManager::new();
|
||||
let sandbox = sandbox_manager.select_initial(
|
||||
file_system_policy,
|
||||
network_policy,
|
||||
SandboxablePreference::Auto,
|
||||
sandbox_context.windows_sandbox_level,
|
||||
/*has_managed_network_requirements*/ false,
|
||||
);
|
||||
let command = SandboxCommand {
|
||||
program: helper.as_path().as_os_str().to_owned(),
|
||||
args: vec![CODEX_FS_HELPER_ARG1.to_string()],
|
||||
cwd: cwd.clone(),
|
||||
env: HashMap::new(),
|
||||
additional_permissions: Some(
|
||||
self.helper_permissions(sandbox_context.additional_permissions.as_ref()),
|
||||
),
|
||||
};
|
||||
sandbox_manager
|
||||
.transform(SandboxTransformRequest {
|
||||
command,
|
||||
policy: sandbox_policy,
|
||||
file_system_policy,
|
||||
network_policy,
|
||||
sandbox,
|
||||
enforce_managed_network: false,
|
||||
network: None,
|
||||
sandbox_policy_cwd: cwd.as_path(),
|
||||
codex_linux_sandbox_exe: self.runtime_paths.codex_linux_sandbox_exe.as_deref(),
|
||||
use_legacy_landlock: sandbox_context.use_legacy_landlock,
|
||||
windows_sandbox_level: sandbox_context.windows_sandbox_level,
|
||||
windows_sandbox_private_desktop: sandbox_context.windows_sandbox_private_desktop,
|
||||
})
|
||||
.map_err(|err| invalid_request(format!("failed to prepare fs sandbox: {err}")))
|
||||
}
|
||||
|
||||
fn helper_permissions(
|
||||
&self,
|
||||
additional_permissions: Option<&PermissionProfile>,
|
||||
) -> PermissionProfile {
|
||||
PermissionProfile {
|
||||
network: None,
|
||||
file_system: additional_permissions
|
||||
.and_then(|permissions| permissions.file_system.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_request_paths(
|
||||
request: FsHelperRequest,
|
||||
file_system_policy: &FileSystemSandboxPolicy,
|
||||
cwd: &AbsolutePathBuf,
|
||||
) -> Result<FsHelperRequest, JSONRPCErrorError> {
|
||||
match request {
|
||||
FsHelperRequest::ReadFile(FsReadFileParams { path, sandbox }) => {
|
||||
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::No)?;
|
||||
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Read)?;
|
||||
Ok(FsHelperRequest::ReadFile(FsReadFileParams {
|
||||
path,
|
||||
sandbox,
|
||||
}))
|
||||
}
|
||||
FsHelperRequest::WriteFile(FsWriteFileParams {
|
||||
path,
|
||||
data_base64,
|
||||
sandbox,
|
||||
}) => Ok(FsHelperRequest::WriteFile(FsWriteFileParams {
|
||||
path: {
|
||||
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::No)?;
|
||||
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Write)?;
|
||||
path
|
||||
},
|
||||
data_base64,
|
||||
sandbox,
|
||||
})),
|
||||
FsHelperRequest::CreateDirectory(FsCreateDirectoryParams {
|
||||
path,
|
||||
recursive,
|
||||
sandbox,
|
||||
}) => Ok(FsHelperRequest::CreateDirectory(FsCreateDirectoryParams {
|
||||
path: {
|
||||
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::No)?;
|
||||
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Write)?;
|
||||
path
|
||||
},
|
||||
recursive,
|
||||
sandbox,
|
||||
})),
|
||||
FsHelperRequest::GetMetadata(FsGetMetadataParams { path, sandbox }) => {
|
||||
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::No)?;
|
||||
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Read)?;
|
||||
Ok(FsHelperRequest::GetMetadata(FsGetMetadataParams {
|
||||
path,
|
||||
sandbox,
|
||||
}))
|
||||
}
|
||||
FsHelperRequest::ReadDirectory(FsReadDirectoryParams { path, sandbox }) => {
|
||||
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::No)?;
|
||||
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Read)?;
|
||||
Ok(FsHelperRequest::ReadDirectory(FsReadDirectoryParams {
|
||||
path,
|
||||
sandbox,
|
||||
}))
|
||||
}
|
||||
FsHelperRequest::Remove(FsRemoveParams {
|
||||
path,
|
||||
recursive,
|
||||
force,
|
||||
sandbox,
|
||||
}) => Ok(FsHelperRequest::Remove(FsRemoveParams {
|
||||
path: {
|
||||
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::Yes)?;
|
||||
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Write)?;
|
||||
path
|
||||
},
|
||||
recursive,
|
||||
force,
|
||||
sandbox,
|
||||
})),
|
||||
FsHelperRequest::Copy(FsCopyParams {
|
||||
source_path,
|
||||
destination_path,
|
||||
recursive,
|
||||
sandbox,
|
||||
}) => Ok(FsHelperRequest::Copy(FsCopyParams {
|
||||
source_path: {
|
||||
let source_path = resolve_sandbox_path(&source_path, PreserveTerminalSymlink::Yes)?;
|
||||
ensure_path_access(
|
||||
file_system_policy,
|
||||
cwd,
|
||||
&source_path,
|
||||
FileSystemAccessMode::Read,
|
||||
)?;
|
||||
source_path
|
||||
},
|
||||
destination_path: {
|
||||
let destination_path =
|
||||
resolve_sandbox_path(&destination_path, PreserveTerminalSymlink::No)?;
|
||||
ensure_path_access(
|
||||
file_system_policy,
|
||||
cwd,
|
||||
&destination_path,
|
||||
FileSystemAccessMode::Write,
|
||||
)?;
|
||||
destination_path
|
||||
},
|
||||
recursive,
|
||||
sandbox,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum PreserveTerminalSymlink {
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
fn resolve_sandbox_path(
|
||||
path: &AbsolutePathBuf,
|
||||
preserve_terminal_symlink: PreserveTerminalSymlink,
|
||||
) -> Result<AbsolutePathBuf, JSONRPCErrorError> {
|
||||
if matches!(preserve_terminal_symlink, PreserveTerminalSymlink::Yes)
|
||||
&& std::fs::symlink_metadata(path.as_path())
|
||||
.map(|metadata| metadata.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(normalize_top_level_alias(path.clone()));
|
||||
}
|
||||
|
||||
let resolved = resolve_existing_path(path.as_path()).map_err(io_error)?;
|
||||
absolute_path(resolved)
|
||||
}
|
||||
|
||||
fn normalize_sandbox_policy_root_aliases(sandbox_policy: SandboxPolicy) -> SandboxPolicy {
|
||||
let mut sandbox_policy = sandbox_policy;
|
||||
match &mut sandbox_policy {
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted { readable_roots, .. },
|
||||
..
|
||||
} => {
|
||||
normalize_root_aliases(readable_roots);
|
||||
}
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots,
|
||||
read_only_access,
|
||||
..
|
||||
} => {
|
||||
normalize_root_aliases(writable_roots);
|
||||
if let ReadOnlyAccess::Restricted { readable_roots, .. } = read_only_access {
|
||||
normalize_root_aliases(readable_roots);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
sandbox_policy
|
||||
}
|
||||
|
||||
fn normalize_root_aliases(paths: &mut Vec<AbsolutePathBuf>) {
|
||||
for path in paths {
|
||||
*path = normalize_top_level_alias(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_top_level_alias(path: AbsolutePathBuf) -> AbsolutePathBuf {
|
||||
let raw_path = path.to_path_buf();
|
||||
for ancestor in raw_path.ancestors() {
|
||||
if std::fs::symlink_metadata(ancestor).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Ok(normalized_ancestor) = canonicalize_preserving_symlinks(ancestor) else {
|
||||
continue;
|
||||
};
|
||||
if normalized_ancestor == ancestor {
|
||||
continue;
|
||||
}
|
||||
let Ok(suffix) = raw_path.strip_prefix(ancestor) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(normalized_path) =
|
||||
AbsolutePathBuf::from_absolute_path(normalized_ancestor.join(suffix))
|
||||
{
|
||||
return normalized_path;
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn absolute_path(path: PathBuf) -> Result<AbsolutePathBuf, JSONRPCErrorError> {
|
||||
AbsolutePathBuf::from_absolute_path(path.as_path())
|
||||
.map_err(|err| invalid_request(format!("resolved sandbox path is not absolute: {err}")))
|
||||
}
|
||||
|
||||
fn ensure_path_access(
|
||||
file_system_policy: &FileSystemSandboxPolicy,
|
||||
cwd: &AbsolutePathBuf,
|
||||
path: &AbsolutePathBuf,
|
||||
required_access: FileSystemAccessMode,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
let actual_access = file_system_policy.resolve_access_with_cwd(path.as_path(), cwd.as_path());
|
||||
let permitted = match required_access {
|
||||
FileSystemAccessMode::Read => actual_access.can_read(),
|
||||
FileSystemAccessMode::Write => actual_access.can_write(),
|
||||
FileSystemAccessMode::None => true,
|
||||
};
|
||||
if permitted {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(invalid_request(format!(
|
||||
"{} is not permitted by filesystem sandbox",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
async fn run_command(
|
||||
command: SandboxExecRequest,
|
||||
request_json: Vec<u8>,
|
||||
) -> Result<FsHelperPayload, JSONRPCErrorError> {
|
||||
let mut child = spawn_command(command)?;
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| internal_error("failed to open fs sandbox helper stdin".to_string()))?;
|
||||
stdin.write_all(&request_json).await.map_err(io_error)?;
|
||||
stdin.shutdown().await.map_err(io_error)?;
|
||||
drop(stdin);
|
||||
|
||||
let output = child.wait_with_output().await.map_err(io_error)?;
|
||||
if !output.status.success() {
|
||||
return Err(internal_error(format!(
|
||||
"fs sandbox helper failed with status {status}: {stderr}",
|
||||
status = output.status,
|
||||
stderr = String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let response: FsHelperResponse = serde_json::from_slice(&output.stdout).map_err(json_error)?;
|
||||
match response {
|
||||
FsHelperResponse::Ok(payload) => Ok(payload),
|
||||
FsHelperResponse::Error(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_command(
|
||||
SandboxExecRequest {
|
||||
command: argv,
|
||||
cwd,
|
||||
env,
|
||||
arg0,
|
||||
..
|
||||
}: SandboxExecRequest,
|
||||
) -> Result<tokio::process::Child, JSONRPCErrorError> {
|
||||
let Some((program, args)) = argv.split_first() else {
|
||||
return Err(invalid_request("fs sandbox command was empty".to_string()));
|
||||
};
|
||||
let mut command = Command::new(program);
|
||||
#[cfg(unix)]
|
||||
if let Some(arg0) = arg0 {
|
||||
command.arg0(arg0);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = arg0;
|
||||
command.args(args);
|
||||
command.current_dir(cwd.as_path());
|
||||
command.env_clear();
|
||||
command.envs(env);
|
||||
command.stdin(std::process::Stdio::piped());
|
||||
command.stdout(std::process::Stdio::piped());
|
||||
command.stderr(std::process::Stdio::piped());
|
||||
command.spawn().map_err(io_error)
|
||||
}
|
||||
|
||||
fn sandbox_policy_with_helper_runtime_defaults(sandbox_policy: &SandboxPolicy) -> SandboxPolicy {
|
||||
let mut sandbox_policy = sandbox_policy.clone();
|
||||
match &mut sandbox_policy {
|
||||
SandboxPolicy::ReadOnly { access, .. } => enable_platform_defaults(access),
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
read_only_access, ..
|
||||
} => enable_platform_defaults(read_only_access),
|
||||
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {}
|
||||
}
|
||||
sandbox_policy
|
||||
}
|
||||
|
||||
fn enable_platform_defaults(access: &mut ReadOnlyAccess) {
|
||||
if let ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults,
|
||||
..
|
||||
} = access
|
||||
{
|
||||
*include_platform_defaults = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn io_error(err: std::io::Error) -> JSONRPCErrorError {
|
||||
internal_error(err.to_string())
|
||||
}
|
||||
|
||||
fn json_error(err: serde_json::Error) -> JSONRPCErrorError {
|
||||
internal_error(format!(
|
||||
"failed to encode or decode fs sandbox helper message: {err}"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::ReadOnlyAccess;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use crate::ExecServerRuntimePaths;
|
||||
|
||||
use super::FileSystemSandboxRunner;
|
||||
use super::sandbox_policy_with_helper_runtime_defaults;
|
||||
|
||||
#[test]
|
||||
fn helper_sandbox_policy_enables_platform_defaults_for_read_only_access() {
|
||||
let sandbox_policy = SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: false,
|
||||
readable_roots: Vec::new(),
|
||||
},
|
||||
network_access: false,
|
||||
};
|
||||
|
||||
let updated = sandbox_policy_with_helper_runtime_defaults(&sandbox_policy);
|
||||
|
||||
assert_eq!(
|
||||
updated,
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: Vec::new(),
|
||||
},
|
||||
network_access: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_sandbox_policy_enables_platform_defaults_for_workspace_read_access() {
|
||||
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
read_only_access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: false,
|
||||
readable_roots: Vec::new(),
|
||||
},
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
|
||||
let updated = sandbox_policy_with_helper_runtime_defaults(&sandbox_policy);
|
||||
|
||||
assert_eq!(
|
||||
updated,
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
read_only_access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: Vec::new(),
|
||||
},
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_permissions_strip_network_grants() {
|
||||
let codex_self_exe = std::env::current_exe().expect("current exe");
|
||||
let runtime_paths = ExecServerRuntimePaths::new(
|
||||
codex_self_exe.clone(),
|
||||
/*codex_linux_sandbox_exe*/ None,
|
||||
)
|
||||
.expect("runtime paths");
|
||||
let runner = FileSystemSandboxRunner::new(runtime_paths);
|
||||
let readable = AbsolutePathBuf::from_absolute_path(
|
||||
codex_self_exe.parent().expect("current exe parent"),
|
||||
)
|
||||
.expect("absolute readable path");
|
||||
let writable = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path())
|
||||
.expect("absolute writable path");
|
||||
|
||||
let permissions = runner.helper_permissions(Some(&PermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![readable.clone()]),
|
||||
write: Some(vec![writable.clone()]),
|
||||
}),
|
||||
}));
|
||||
|
||||
assert_eq!(permissions.network, None);
|
||||
assert_eq!(
|
||||
permissions
|
||||
.file_system
|
||||
.as_ref()
|
||||
.and_then(|fs| fs.write.clone()),
|
||||
Some(vec![writable])
|
||||
);
|
||||
assert_eq!(
|
||||
permissions
|
||||
.file_system
|
||||
.as_ref()
|
||||
.and_then(|fs| fs.read.clone()),
|
||||
Some(vec![readable])
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user