mirror of
https://github.com/denoland/deno.git
synced 2024-11-21 15:04:11 -05:00
feat(cli): add test permissions to Deno.test (#10188)
This commits adds adds "permissions" option to the test definitions which allows tests to run with different permission sets than the process's permission. The change will only be in effect within the test function, once the test has completed the original process permission set is restored. Test permissions cannot exceed the process's permission. You can only narrow or drop permissions, failure to acquire a permission results in an error being thrown and the test case will fail.
This commit is contained in:
parent
7063e449f1
commit
f3751e498f
14 changed files with 392 additions and 10 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -3897,6 +3897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
|
||||
dependencies = [
|
||||
"getrandom 0.2.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
@ -78,7 +78,7 @@ termcolor = "1.1.2"
|
|||
text-size = "1.1.0"
|
||||
tokio = { version = "1.4.0", features = ["full"] }
|
||||
tokio-rustls = "0.22.0"
|
||||
uuid = { version = "0.8.2", features = ["v4"] }
|
||||
uuid = { version = "0.8.2", features = ["v4", "serde"] }
|
||||
walkdir = "2.3.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
|
|
134
cli/dts/lib.deno.unstable.d.ts
vendored
134
cli/dts/lib.deno.unstable.d.ts
vendored
|
@ -1181,6 +1181,140 @@ declare namespace Deno {
|
|||
* then the underlying HttpConn resource is closed automatically.
|
||||
*/
|
||||
export function serveHttp(conn: Conn): HttpConn;
|
||||
|
||||
/** **UNSTABLE**: New option, yet to be vetted. */
|
||||
export interface TestDefinition {
|
||||
/** Specifies the permissions that should be used to run the test.
|
||||
* Set this to "inherit" to keep the calling thread's permissions.
|
||||
* Set this to "none" to revoke all permissions.
|
||||
*
|
||||
* Defaults to "inherit".
|
||||
*/
|
||||
permissions?: "inherit" | "none" | {
|
||||
/** Specifies if the `net` permission should be requested or revoked.
|
||||
* If set to `"inherit"`, the current `env` permission will be inherited.
|
||||
* If set to `true`, the global `net` permission will be requested.
|
||||
* If set to `false`, the global `net` permission will be revoked.
|
||||
*
|
||||
* Defaults to "inherit".
|
||||
*/
|
||||
env?: "inherit" | boolean;
|
||||
|
||||
/** Specifies if the `hrtime` permission should be requested or revoked.
|
||||
* If set to `"inherit"`, the current `hrtime` permission will be inherited.
|
||||
* If set to `true`, the global `hrtime` permission will be requested.
|
||||
* If set to `false`, the global `hrtime` permission will be revoked.
|
||||
*
|
||||
* Defaults to "inherit".
|
||||
*/
|
||||
hrtime?: "inherit" | boolean;
|
||||
|
||||
/** Specifies if the `net` permission should be requested or revoked.
|
||||
* if set to `"inherit"`, the current `net` permission will be inherited.
|
||||
* if set to `true`, the global `net` permission will be requested.
|
||||
* if set to `false`, the global `net` permission will be revoked.
|
||||
* if set to `string[]`, the `net` permission will be requested with the
|
||||
* specified host strings with the format `"<host>[:<port>]`.
|
||||
*
|
||||
* Defaults to "inherit".
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* ```
|
||||
* Deno.test({
|
||||
* name: "inherit",
|
||||
* permissions: {
|
||||
* net: "inherit",
|
||||
* },
|
||||
* async fn() {
|
||||
* const status = await Deno.permissions.query({ name: "net" })
|
||||
* assertEquals(status.state, "granted");
|
||||
* },
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* ```
|
||||
* Deno.test({
|
||||
* name: "true",
|
||||
* permissions: {
|
||||
* net: true,
|
||||
* },
|
||||
* async fn() {
|
||||
* const status = await Deno.permissions.query({ name: "net" });
|
||||
* assertEquals(status.state, "granted");
|
||||
* },
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* ```
|
||||
* Deno.test({
|
||||
* name: "false",
|
||||
* permissions: {
|
||||
* net: false,
|
||||
* },
|
||||
* async fn() {
|
||||
* const status = await Deno.permissions.query({ name: "net" });
|
||||
* assertEquals(status.state, "denied");
|
||||
* },
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* ```
|
||||
* Deno.test({
|
||||
* name: "localhost:8080",
|
||||
* permissions: {
|
||||
* net: ["localhost:8080"],
|
||||
* },
|
||||
* async fn() {
|
||||
* const status = await Deno.permissions.query({ name: "net", host: "localhost:8080" });
|
||||
* assertEquals(status.state, "granted");
|
||||
* },
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
net?: "inherit" | boolean | string[];
|
||||
|
||||
/** Specifies if the `plugin` permission should be requested or revoked.
|
||||
* If set to `"inherit"`, the current `plugin` permission will be inherited.
|
||||
* If set to `true`, the global `plugin` permission will be requested.
|
||||
* If set to `false`, the global `plugin` permission will be revoked.
|
||||
*
|
||||
* Defaults to "inherit".
|
||||
*/
|
||||
plugin?: "inherit" | boolean;
|
||||
|
||||
/** Specifies if the `read` permission should be requested or revoked.
|
||||
* If set to `"inherit"`, the current `read` permission will be inherited.
|
||||
* If set to `true`, the global `read` permission will be requested.
|
||||
* If set to `false`, the global `read` permission will be revoked.
|
||||
* If set to `Array<string | URL>`, the `read` permission will be requested with the
|
||||
* specified file paths.
|
||||
*
|
||||
* Defaults to "inherit".
|
||||
*/
|
||||
read?: "inherit" | boolean | Array<string | URL>;
|
||||
|
||||
/** Specifies if the `run` permission should be requested or revoked.
|
||||
* If set to `"inherit"`, the current `run` permission will be inherited.
|
||||
* If set to `true`, the global `run` permission will be requested.
|
||||
* If set to `false`, the global `run` permission will be revoked.
|
||||
*
|
||||
* Defaults to "inherit".
|
||||
*/
|
||||
run?: "inherit" | boolean;
|
||||
|
||||
/** Specifies if the `write` permission should be requested or revoked.
|
||||
* If set to `"inherit"`, the current `write` permission will be inherited.
|
||||
* If set to `true`, the global `write` permission will be requested.
|
||||
* If set to `false`, the global `write` permission will be revoked.
|
||||
* If set to `Array<string | URL>`, the `write` permission will be requested with the
|
||||
* specified file paths.
|
||||
*
|
||||
* Defaults to "inherit".
|
||||
*/
|
||||
write?: "inherit" | boolean | Array<string | URL>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare function fetch(
|
||||
|
|
25
cli/main.rs
25
cli/main.rs
|
@ -160,6 +160,7 @@ pub fn create_main_worker(
|
|||
program_state: &Arc<ProgramState>,
|
||||
main_module: ModuleSpecifier,
|
||||
permissions: Permissions,
|
||||
enable_testing: bool,
|
||||
) -> MainWorker {
|
||||
let module_loader = CliModuleLoader::new(program_state.clone());
|
||||
|
||||
|
@ -219,6 +220,11 @@ pub fn create_main_worker(
|
|||
// above
|
||||
ops::errors::init(js_runtime);
|
||||
ops::runtime_compiler::init(js_runtime);
|
||||
|
||||
if enable_testing {
|
||||
ops::test_runner::init(js_runtime);
|
||||
}
|
||||
|
||||
js_runtime.sync_ops_cache();
|
||||
}
|
||||
worker.bootstrap(&options);
|
||||
|
@ -427,7 +433,7 @@ async fn install_command(
|
|||
let program_state = ProgramState::build(preload_flags).await?;
|
||||
let main_module = resolve_url_or_path(&module_url)?;
|
||||
let mut worker =
|
||||
create_main_worker(&program_state, main_module.clone(), permissions);
|
||||
create_main_worker(&program_state, main_module.clone(), permissions, false);
|
||||
// First, fetch and compile the module; this step ensures that the module exists.
|
||||
worker.preload_module(&main_module).await?;
|
||||
tools::installer::install(flags, &module_url, args, name, root, force)
|
||||
|
@ -494,7 +500,7 @@ async fn eval_command(
|
|||
let permissions = Permissions::from_options(&flags.clone().into());
|
||||
let program_state = ProgramState::build(flags).await?;
|
||||
let mut worker =
|
||||
create_main_worker(&program_state, main_module.clone(), permissions);
|
||||
create_main_worker(&program_state, main_module.clone(), permissions, false);
|
||||
// Create a dummy source file.
|
||||
let source_code = if print {
|
||||
format!("console.log({})", code)
|
||||
|
@ -728,7 +734,7 @@ async fn run_repl(flags: Flags) -> Result<(), AnyError> {
|
|||
let permissions = Permissions::from_options(&flags.clone().into());
|
||||
let program_state = ProgramState::build(flags).await?;
|
||||
let mut worker =
|
||||
create_main_worker(&program_state, main_module.clone(), permissions);
|
||||
create_main_worker(&program_state, main_module.clone(), permissions, false);
|
||||
worker.run_event_loop().await?;
|
||||
|
||||
tools::repl::run(&program_state, worker).await
|
||||
|
@ -742,6 +748,7 @@ async fn run_from_stdin(flags: Flags) -> Result<(), AnyError> {
|
|||
&program_state.clone(),
|
||||
main_module.clone(),
|
||||
permissions,
|
||||
false,
|
||||
);
|
||||
|
||||
let mut source = Vec::new();
|
||||
|
@ -819,8 +826,12 @@ async fn run_with_watch(flags: Flags, script: String) -> Result<(), AnyError> {
|
|||
async move {
|
||||
let main_module = main_module.clone();
|
||||
let program_state = ProgramState::build(flags).await?;
|
||||
let mut worker =
|
||||
create_main_worker(&program_state, main_module.clone(), permissions);
|
||||
let mut worker = create_main_worker(
|
||||
&program_state,
|
||||
main_module.clone(),
|
||||
permissions,
|
||||
false,
|
||||
);
|
||||
debug!("main_module {}", main_module);
|
||||
worker.execute_module(&main_module).await?;
|
||||
worker.execute("window.dispatchEvent(new Event('load'))")?;
|
||||
|
@ -853,7 +864,7 @@ async fn run_command(flags: Flags, script: String) -> Result<(), AnyError> {
|
|||
let program_state = ProgramState::build(flags.clone()).await?;
|
||||
let permissions = Permissions::from_options(&flags.clone().into());
|
||||
let mut worker =
|
||||
create_main_worker(&program_state, main_module.clone(), permissions);
|
||||
create_main_worker(&program_state, main_module.clone(), permissions, false);
|
||||
|
||||
let mut maybe_coverage_collector =
|
||||
if let Some(ref coverage_dir) = program_state.coverage_dir {
|
||||
|
@ -970,7 +981,7 @@ async fn test_command(
|
|||
}
|
||||
|
||||
let mut worker =
|
||||
create_main_worker(&program_state, main_module.clone(), permissions);
|
||||
create_main_worker(&program_state, main_module.clone(), permissions, true);
|
||||
|
||||
if let Some(ref coverage_dir) = flags.coverage_dir {
|
||||
env::set_var("DENO_UNSTABLE_COVERAGE_DIR", coverage_dir);
|
||||
|
|
|
@ -2,5 +2,6 @@
|
|||
|
||||
pub mod errors;
|
||||
pub mod runtime_compiler;
|
||||
pub mod test_runner;
|
||||
|
||||
pub use deno_runtime::ops::{reg_async, reg_sync};
|
||||
|
|
66
cli/ops/test_runner.rs
Normal file
66
cli/ops/test_runner.rs
Normal file
|
@ -0,0 +1,66 @@
|
|||
// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license.
|
||||
|
||||
use deno_core::error::generic_error;
|
||||
use deno_core::error::AnyError;
|
||||
use deno_core::serde_json;
|
||||
use deno_core::serde_json::Value;
|
||||
use deno_core::OpState;
|
||||
use deno_core::ZeroCopyBuf;
|
||||
use deno_runtime::ops::worker_host::create_worker_permissions;
|
||||
use deno_runtime::ops::worker_host::PermissionsArg;
|
||||
use deno_runtime::permissions::Permissions;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn init(rt: &mut deno_core::JsRuntime) {
|
||||
super::reg_sync(rt, "op_pledge_test_permissions", op_pledge_test_permissions);
|
||||
super::reg_sync(
|
||||
rt,
|
||||
"op_restore_test_permissions",
|
||||
op_restore_test_permissions,
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PermissionsHolder(Uuid, Permissions);
|
||||
|
||||
pub fn op_pledge_test_permissions(
|
||||
state: &mut OpState,
|
||||
args: Value,
|
||||
_zero_copy: Option<ZeroCopyBuf>,
|
||||
) -> Result<Uuid, AnyError> {
|
||||
deno_runtime::ops::check_unstable(state, "Deno.test.permissions");
|
||||
|
||||
let token = Uuid::new_v4();
|
||||
let parent_permissions = state.borrow::<Permissions>().clone();
|
||||
let worker_permissions = {
|
||||
let permissions: PermissionsArg = serde_json::from_value(args)?;
|
||||
create_worker_permissions(parent_permissions.clone(), permissions)?
|
||||
};
|
||||
|
||||
state.put::<PermissionsHolder>(PermissionsHolder(token, parent_permissions));
|
||||
|
||||
// NOTE: This call overrides current permission set for the worker
|
||||
state.put::<Permissions>(worker_permissions);
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn op_restore_test_permissions(
|
||||
state: &mut OpState,
|
||||
token: Uuid,
|
||||
_zero_copy: Option<ZeroCopyBuf>,
|
||||
) -> Result<(), AnyError> {
|
||||
deno_runtime::ops::check_unstable(state, "Deno.test.permissions");
|
||||
|
||||
if let Some(permissions_holder) = state.try_take::<PermissionsHolder>() {
|
||||
if token != permissions_holder.0 {
|
||||
panic!("restore test permissions token does not match the stored token");
|
||||
}
|
||||
|
||||
let permissions = permissions_holder.1;
|
||||
state.put::<Permissions>(permissions);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(generic_error("no permissions to restore"))
|
||||
}
|
||||
}
|
|
@ -2395,6 +2395,18 @@ mod integration {
|
|||
output: "test/deno_test.out",
|
||||
});
|
||||
|
||||
itest!(allow_all {
|
||||
args: "test --unstable --allow-all test/allow_all.ts",
|
||||
exit_code: 0,
|
||||
output: "test/allow_all.out",
|
||||
});
|
||||
|
||||
itest!(allow_none {
|
||||
args: "test --unstable test/allow_none.ts",
|
||||
exit_code: 1,
|
||||
output: "test/allow_none.out",
|
||||
});
|
||||
|
||||
itest!(fail_fast {
|
||||
args: "test --fail-fast test/test_runner_test.ts",
|
||||
exit_code: 1,
|
||||
|
|
18
cli/tests/test/allow_all.out
Normal file
18
cli/tests/test/allow_all.out
Normal file
|
@ -0,0 +1,18 @@
|
|||
[WILDCARD]
|
||||
running 14 tests
|
||||
test read false ... ok [WILDCARD]
|
||||
test read true ... ok [WILDCARD]
|
||||
test write false ... ok [WILDCARD]
|
||||
test write true ... ok [WILDCARD]
|
||||
test net false ... ok [WILDCARD]
|
||||
test net true ... ok [WILDCARD]
|
||||
test env false ... ok [WILDCARD]
|
||||
test env true ... ok [WILDCARD]
|
||||
test run false ... ok [WILDCARD]
|
||||
test run true ... ok [WILDCARD]
|
||||
test plugin false ... ok [WILDCARD]
|
||||
test plugin true ... ok [WILDCARD]
|
||||
test hrtime false ... ok [WILDCARD]
|
||||
test hrtime true ... ok [WILDCARD]
|
||||
|
||||
test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out [WILDCARD]
|
35
cli/tests/test/allow_all.ts
Normal file
35
cli/tests/test/allow_all.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { assertEquals } from "../../../test_util/std/testing/asserts.ts";
|
||||
|
||||
const permissions: Deno.PermissionName[] = [
|
||||
"read",
|
||||
"write",
|
||||
"net",
|
||||
"env",
|
||||
"run",
|
||||
"plugin",
|
||||
"hrtime",
|
||||
];
|
||||
|
||||
for (const name of permissions) {
|
||||
Deno.test({
|
||||
name: `${name} false`,
|
||||
permissions: {
|
||||
[name]: false,
|
||||
},
|
||||
async fn() {
|
||||
const status = await Deno.permissions.query({ name });
|
||||
assertEquals(status.state, "denied");
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: `${name} true`,
|
||||
permissions: {
|
||||
[name]: true,
|
||||
},
|
||||
async fn() {
|
||||
const status = await Deno.permissions.query({ name });
|
||||
assertEquals(status.state, "granted");
|
||||
},
|
||||
});
|
||||
}
|
51
cli/tests/test/allow_none.out
Normal file
51
cli/tests/test/allow_none.out
Normal file
|
@ -0,0 +1,51 @@
|
|||
[WILDCARD]
|
||||
running 7 tests
|
||||
test read ... FAILED [WILDCARD]
|
||||
test write ... FAILED [WILDCARD]
|
||||
test net ... FAILED [WILDCARD]
|
||||
test env ... FAILED [WILDCARD]
|
||||
test run ... FAILED [WILDCARD]
|
||||
test plugin ... FAILED [WILDCARD]
|
||||
test hrtime ... FAILED [WILDCARD]
|
||||
|
||||
failures:
|
||||
|
||||
read
|
||||
PermissionDenied: Can't escalate parent thread permissions
|
||||
[WILDCARD]
|
||||
|
||||
write
|
||||
PermissionDenied: Can't escalate parent thread permissions
|
||||
[WILDCARD]
|
||||
|
||||
net
|
||||
PermissionDenied: Can't escalate parent thread permissions
|
||||
[WILDCARD]
|
||||
|
||||
env
|
||||
PermissionDenied: Can't escalate parent thread permissions
|
||||
[WILDCARD]
|
||||
|
||||
run
|
||||
PermissionDenied: Can't escalate parent thread permissions
|
||||
[WILDCARD]
|
||||
|
||||
plugin
|
||||
PermissionDenied: Can't escalate parent thread permissions
|
||||
[WILDCARD]
|
||||
|
||||
hrtime
|
||||
PermissionDenied: Can't escalate parent thread permissions
|
||||
[WILDCARD]
|
||||
|
||||
failures:
|
||||
|
||||
read
|
||||
write
|
||||
net
|
||||
env
|
||||
run
|
||||
plugin
|
||||
hrtime
|
||||
|
||||
test result: FAILED. 0 passed; 7 failed; 0 ignored; 0 measured; 0 filtered out [WILDCARD]
|
23
cli/tests/test/allow_none.ts
Normal file
23
cli/tests/test/allow_none.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { unreachable } from "../../../test_util/std/testing/asserts.ts";
|
||||
|
||||
const permissions: Deno.PermissionName[] = [
|
||||
"read",
|
||||
"write",
|
||||
"net",
|
||||
"env",
|
||||
"run",
|
||||
"plugin",
|
||||
"hrtime",
|
||||
];
|
||||
|
||||
for (const name of permissions) {
|
||||
Deno.test({
|
||||
name,
|
||||
permissions: {
|
||||
[name]: true,
|
||||
},
|
||||
fn() {
|
||||
unreachable();
|
||||
},
|
||||
});
|
||||
}
|
|
@ -333,6 +333,7 @@
|
|||
defineEventHandler(Worker.prototype, "messageerror");
|
||||
|
||||
window.__bootstrap.worker = {
|
||||
parsePermissions,
|
||||
Worker,
|
||||
};
|
||||
})(this);
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
((window) => {
|
||||
const core = window.Deno.core;
|
||||
const colors = window.__bootstrap.colors;
|
||||
const { parsePermissions } = window.__bootstrap.worker;
|
||||
const { setExitHandler, exit } = window.__bootstrap.os;
|
||||
const { Console, inspectArgs } = window.__bootstrap.console;
|
||||
const { stdout } = window.__bootstrap.files;
|
||||
|
@ -121,6 +122,7 @@ finishing test case.`;
|
|||
sanitizeOps: true,
|
||||
sanitizeResources: true,
|
||||
sanitizeExit: true,
|
||||
permissions: null,
|
||||
};
|
||||
|
||||
if (typeof t === "string") {
|
||||
|
@ -226,6 +228,17 @@ finishing test case.`;
|
|||
}
|
||||
}
|
||||
|
||||
function pledgeTestPermissions(permissions) {
|
||||
return core.opSync(
|
||||
"op_pledge_test_permissions",
|
||||
parsePermissions(permissions),
|
||||
);
|
||||
}
|
||||
|
||||
function restoreTestPermissions(token) {
|
||||
core.opSync("op_restore_test_permissions", token);
|
||||
}
|
||||
|
||||
// TODO(bartlomieju): already implements AsyncGenerator<RunTestsMessage>, but add as "implements to class"
|
||||
// TODO(bartlomieju): implements PromiseLike<RunTestsEndResult>
|
||||
class TestRunner {
|
||||
|
@ -257,6 +270,7 @@ finishing test case.`;
|
|||
|
||||
const results = [];
|
||||
const suiteStart = +new Date();
|
||||
|
||||
for (const test of this.testsToRun) {
|
||||
const endMessage = {
|
||||
name: test.name,
|
||||
|
@ -268,15 +282,30 @@ finishing test case.`;
|
|||
this.stats.ignored++;
|
||||
} else {
|
||||
const start = +new Date();
|
||||
|
||||
let token;
|
||||
try {
|
||||
if (test.permissions) {
|
||||
token = pledgeTestPermissions(test.permissions);
|
||||
}
|
||||
|
||||
await test.fn();
|
||||
|
||||
endMessage.status = "passed";
|
||||
this.stats.passed++;
|
||||
} catch (err) {
|
||||
endMessage.status = "failed";
|
||||
endMessage.error = err;
|
||||
this.stats.failed++;
|
||||
} finally {
|
||||
// Permissions must always be restored for a clean environment,
|
||||
// otherwise the process can end up dropping permissions
|
||||
// until there are none left.
|
||||
if (token) {
|
||||
restoreTestPermissions(token);
|
||||
}
|
||||
}
|
||||
|
||||
endMessage.duration = +new Date() - start;
|
||||
}
|
||||
results.push(endMessage);
|
||||
|
|
|
@ -228,7 +228,7 @@ fn merge_run_permission(
|
|||
Ok(main)
|
||||
}
|
||||
|
||||
fn create_worker_permissions(
|
||||
pub fn create_worker_permissions(
|
||||
main_perms: Permissions,
|
||||
worker_perms: PermissionsArg,
|
||||
) -> Result<Permissions, AnyError> {
|
||||
|
@ -244,7 +244,7 @@ fn create_worker_permissions(
|
|||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PermissionsArg {
|
||||
pub struct PermissionsArg {
|
||||
#[serde(default, deserialize_with = "as_unary_env_permission")]
|
||||
env: Option<UnaryPermission<EnvDescriptor>>,
|
||||
#[serde(default, deserialize_with = "as_permission_state")]
|
||||
|
|
Loading…
Reference in a new issue