Add tests, implement read creds files
This commit is contained in:
parent
c2da970817
commit
16ba3d8f33
95
Cargo.lock
generated
95
Cargo.lock
generated
@ -108,6 +108,12 @@ dependencies = [
|
|||||||
"syn 2.0.13",
|
"syn 2.0.13",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fuchsia-cprng"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iana-time-zone"
|
name = "iana-time-zone"
|
||||||
version = "0.1.56"
|
version = "0.1.56"
|
||||||
@ -251,6 +257,43 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293"
|
||||||
|
dependencies = [
|
||||||
|
"fuchsia-cprng",
|
||||||
|
"libc",
|
||||||
|
"rand_core 0.3.1",
|
||||||
|
"rdrand",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.4.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rdrand"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.3.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.7.3"
|
version = "1.7.3"
|
||||||
@ -275,6 +318,15 @@ version = "0.6.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
|
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "remove_dir_all"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.13"
|
version = "1.0.13"
|
||||||
@ -358,6 +410,21 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "systemd-credentials"
|
name = "systemd-credentials"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"test-ext",
|
||||||
|
"thiserror",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempdir"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8"
|
||||||
|
dependencies = [
|
||||||
|
"rand",
|
||||||
|
"remove_dir_all",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "termcolor"
|
name = "termcolor"
|
||||||
@ -368,6 +435,34 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "test-ext"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"tempdir",
|
||||||
|
"test-ext",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.40"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.40"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.13",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thread_local"
|
name = "thread_local"
|
||||||
version = "1.1.7"
|
version = "1.1.7"
|
||||||
|
@ -2,4 +2,10 @@
|
|||||||
members = [
|
members = [
|
||||||
'crates/systemd-credentials',
|
'crates/systemd-credentials',
|
||||||
'crates/tracing-ecs',
|
'crates/tracing-ecs',
|
||||||
|
'crates/test-ext',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
systemd-credentials = { path = "./crates/systemd-credentials" }
|
||||||
|
tracing-ecs = { path = "./crates/tracing-ecs" }
|
||||||
|
test-ext = { path = "./crates/test-ext" }
|
||||||
|
@ -3,6 +3,9 @@ name = "systemd-credentials"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
thiserror = "1"
|
||||||
|
tracing = "0.1.37"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
test-ext = { workspace = true }
|
||||||
|
@ -1,14 +1,168 @@
|
|||||||
pub fn add(left: usize, right: usize) -> usize {
|
use std::env::VarError;
|
||||||
left + right
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("Failed to read credential {name} in directory {path:?}: {io}")]
|
||||||
|
Read {
|
||||||
|
name: String,
|
||||||
|
path: PathBuf,
|
||||||
|
io: std::io::Error,
|
||||||
|
},
|
||||||
|
#[error("Credential {name} in directory {path:?} is empty")]
|
||||||
|
Empty { name: String, path: PathBuf },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PartialEq for Error {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
match (self, other) {
|
||||||
|
(Self::Read { .. }, Self::Empty { .. }) | (Self::Empty { .. }, Self::Read { .. }) => {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
(Self::Empty { name: na, path: pa }, Self::Empty { name: nb, path: pb })
|
||||||
|
| (
|
||||||
|
Self::Read {
|
||||||
|
name: na, path: pa, ..
|
||||||
|
},
|
||||||
|
Self::Read {
|
||||||
|
name: nb, path: pb, ..
|
||||||
|
},
|
||||||
|
) => na == nb && pa == pb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
pub trait CredentialDirectoryPath {
|
||||||
|
fn path(&self) -> std::result::Result<String, VarError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct EnvPath;
|
||||||
|
|
||||||
|
impl CredentialDirectoryPath for EnvPath {
|
||||||
|
fn path(&self) -> std::result::Result<String, VarError> {
|
||||||
|
std::env::var("CREDENTIALS_DIRECTORY")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ReadCredential {
|
||||||
|
/// Tries to read credential from systemd
|
||||||
|
/// it will return None if the `CREDENTIALS_DIRECTORY` environment variable is not set
|
||||||
|
/// which means that the process is not running under systemd.
|
||||||
|
///
|
||||||
|
/// This function will return an error if reading the secret from the file fails
|
||||||
|
fn read_credential(&self, name: &str) -> Result<Option<String>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ReadCredDir<R: CredentialDirectoryPath = EnvPath>(R);
|
||||||
|
|
||||||
|
impl<R: CredentialDirectoryPath> ReadCredential for ReadCredDir<R> {
|
||||||
|
fn read_credential(&self, name: &str) -> Result<Option<String>> {
|
||||||
|
let credentials_dir = self.0.path();
|
||||||
|
let Ok(creds_dir) = credentials_dir.map(PathBuf::from) else {
|
||||||
|
tracing::warn!(
|
||||||
|
"CREDENTIALS_DIRECTORY is not set while looking for {} - not running under systemd?",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let pass = std::fs::read_to_string(creds_dir.join(name))
|
||||||
|
.map_err(|e| Error::Read {
|
||||||
|
name: name.into(),
|
||||||
|
path: creds_dir.as_path().to_path_buf(),
|
||||||
|
io: e,
|
||||||
|
})?
|
||||||
|
.trim_end_matches('\n')
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if pass.is_empty() {
|
||||||
|
return Err(Error::Empty {
|
||||||
|
name: name.into(),
|
||||||
|
path: creds_dir.as_path().to_path_buf(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(pass))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ReadCredentialDir = ReadCredDir<EnvPath>;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use crate::{CredentialDirectoryPath, Error, ReadCredDir, ReadCredential};
|
||||||
|
use std::env::VarError;
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
struct MemPath(Option<PathBuf>);
|
||||||
|
|
||||||
|
impl CredentialDirectoryPath for MemPath {
|
||||||
|
fn path(&self) -> Result<String, VarError> {
|
||||||
|
Ok(self
|
||||||
|
.0
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| VarError::NotPresent)?
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn it_works() {
|
fn empty_var() {
|
||||||
let result = add(2, 2);
|
let m = MemPath(None);
|
||||||
assert_eq!(result, 4);
|
let res = ReadCredDir(m).read_credential("creds.txt");
|
||||||
|
assert_eq!(res, Ok(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_dir() {
|
||||||
|
test_ext::with_dir(|dir| {
|
||||||
|
let p = dir.into_path();
|
||||||
|
let m = MemPath(Some(p.clone()));
|
||||||
|
let res = ReadCredDir(m).read_credential("creds.txt");
|
||||||
|
assert_eq!(
|
||||||
|
res,
|
||||||
|
Err(Error::Read {
|
||||||
|
name: "creds.txt".into(),
|
||||||
|
path: p,
|
||||||
|
io: ErrorKind::NotFound.into()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_file() {
|
||||||
|
test_ext::with_dir(|dir| {
|
||||||
|
let p = dir.into_path();
|
||||||
|
let m = MemPath(Some(p.clone()));
|
||||||
|
std::fs::write(p.join("creds.txt"), "").unwrap();
|
||||||
|
|
||||||
|
let res = ReadCredDir(m).read_credential("creds.txt");
|
||||||
|
assert_eq!(
|
||||||
|
res,
|
||||||
|
Err(Error::Empty {
|
||||||
|
name: "creds.txt".into(),
|
||||||
|
path: p
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_with_content() {
|
||||||
|
test_ext::with_dir(|dir| {
|
||||||
|
let p = dir.into_path();
|
||||||
|
let m = MemPath(Some(p.clone()));
|
||||||
|
std::fs::write(p.join("creds.txt"), "ah87shd8ashd87ashd87").unwrap();
|
||||||
|
|
||||||
|
let res = ReadCredDir(m).read_credential("creds.txt");
|
||||||
|
assert_eq!(res, Ok(Some("ah87shd8ashd87ashd87".into())));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
10
crates/test-ext/Cargo.toml
Normal file
10
crates/test-ext/Cargo.toml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "test-ext"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tempdir = "*"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
test-ext = { workspace = true }
|
16
crates/test-ext/src/lib.rs
Normal file
16
crates/test-ext/src/lib.rs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
use std::future::Future;
|
||||||
|
|
||||||
|
pub fn with_dir<F>(f: F)
|
||||||
|
where
|
||||||
|
F: Fn(tempdir::TempDir),
|
||||||
|
{
|
||||||
|
f(tempdir::TempDir::new_in("/tmp", "").unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn with_dir_async<F, FUT>(f: F)
|
||||||
|
where
|
||||||
|
FUT: Future<Output = ()>,
|
||||||
|
F: Fn(tempdir::TempDir) -> FUT,
|
||||||
|
{
|
||||||
|
f(tempdir::TempDir::new_in("/tmp", "").unwrap()).await
|
||||||
|
}
|
@ -5,11 +5,11 @@ use std::path::Path;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::Subscriber;
|
use tracing::Subscriber;
|
||||||
use tracing_subscriber::fmt::{format, FmtContext, FormatEvent, FormatFields, MakeWriter};
|
use tracing_subscriber::fmt::{format, FmtContext, FormatEvent, FormatFields};
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
use tracing_subscriber::registry::LookupSpan;
|
use tracing_subscriber::registry::LookupSpan;
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
use tracing_subscriber::{EnvFilter, Layer, Registry};
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
/// Represents Elastic Common Schema version.
|
/// Represents Elastic Common Schema version.
|
||||||
const ECS_VERSION: &str = "1.2.0";
|
const ECS_VERSION: &str = "1.2.0";
|
||||||
@ -181,6 +181,7 @@ impl<'a, 'env> Event<'env> {
|
|||||||
timestamp: DateTime<Utc>,
|
timestamp: DateTime<Utc>,
|
||||||
event: &'a tracing_core::Event<'a>,
|
event: &'a tracing_core::Event<'a>,
|
||||||
env: &'env str,
|
env: &'env str,
|
||||||
|
app_name: &'static str,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let meta = event.metadata();
|
let meta = event.metadata();
|
||||||
let file_path = meta.file().map(Path::new);
|
let file_path = meta.file().map(Path::new);
|
||||||
@ -193,7 +194,7 @@ impl<'a, 'env> Event<'env> {
|
|||||||
timestamp,
|
timestamp,
|
||||||
log: Log::new(event),
|
log: Log::new(event),
|
||||||
service: Service {
|
service: Service {
|
||||||
name: "seomatic",
|
name: app_name,
|
||||||
version: env!("CARGO_PKG_VERSION"),
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
environment: env,
|
environment: env,
|
||||||
},
|
},
|
||||||
@ -218,6 +219,7 @@ impl<'a, 'env> Event<'env> {
|
|||||||
|
|
||||||
struct EcsFormatter {
|
struct EcsFormatter {
|
||||||
env: String,
|
env: String,
|
||||||
|
app_name: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S, N> FormatEvent<S, N> for EcsFormatter
|
impl<S, N> FormatEvent<S, N> for EcsFormatter
|
||||||
@ -231,52 +233,59 @@ where
|
|||||||
mut writer: format::Writer<'_>,
|
mut writer: format::Writer<'_>,
|
||||||
event: &tracing_core::Event<'_>,
|
event: &tracing_core::Event<'_>,
|
||||||
) -> std::fmt::Result {
|
) -> std::fmt::Result {
|
||||||
let event = Event::new(Utc::now(), event, &self.env);
|
let event = Event::new(Utc::now(), event, &self.env, self.app_name);
|
||||||
|
|
||||||
writeln!(writer, "{}", serde_json::to_string(&event).unwrap())
|
writeln!(writer, "{}", serde_json::to_string(&event).unwrap())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init() {
|
pub fn init(app_name: &'static str) {
|
||||||
init_with_writer(|| std::io::stdout());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_with_writer<'writer, W>(writer: W)
|
|
||||||
where
|
|
||||||
W: std::io::Write,
|
|
||||||
{
|
|
||||||
let layer = tracing_subscriber::fmt::layer()
|
let layer = tracing_subscriber::fmt::layer()
|
||||||
.event_format(EcsFormatter {
|
.event_format(formatter(app_name))
|
||||||
env: std::env::var("ENV").unwrap_or_else(|_| "prod".into()),
|
.with_writer(std::io::stdout);
|
||||||
})
|
|
||||||
.with_writer(|| writer);
|
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(EnvFilter::from_default_env())
|
.with(EnvFilter::from_default_env())
|
||||||
.with(layer)
|
.with(layer)
|
||||||
.init();
|
.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn formatter(app_name: &'static str) -> EcsFormatter {
|
||||||
|
EcsFormatter {
|
||||||
|
env: std::env::var("ENV").unwrap_or_else(|_| "prod".into()),
|
||||||
|
app_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::{init, init_with_writer};
|
use crate::formatter;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_test::internal::{logs_assert, MockWriter};
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
use tracing_test::internal::MockWriter;
|
||||||
|
|
||||||
|
static mut BUFFER: Option<Mutex<Vec<u8>>> = None;
|
||||||
|
|
||||||
|
fn buffer() -> &'static Mutex<Vec<u8>> {
|
||||||
|
unsafe { BUFFER.get_or_insert_with(|| Mutex::new(Vec::with_capacity(1_024))) }
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_msg() {
|
fn format_msg() {
|
||||||
init();
|
let buffer = buffer();
|
||||||
|
let writer: MockWriter = MockWriter::new(buffer);
|
||||||
|
let layer = tracing_subscriber::fmt::layer()
|
||||||
|
.event_format(formatter("test msg format"))
|
||||||
|
.with_writer(writer);
|
||||||
|
tracing_subscriber::registry().with(layer).init();
|
||||||
|
|
||||||
info!("Message");
|
info!("Message");
|
||||||
|
|
||||||
let buffer = Mutex::new(Vec::with_capacity(1_024));
|
let b = buffer.lock().unwrap();
|
||||||
let writer = MockWriter::new(&buffer);
|
let res = std::str::from_utf8(&*b).unwrap();
|
||||||
init_with_writer(|| writer);
|
assert!(res.contains("Message"));
|
||||||
|
assert!(res.contains("\"logger\":\"ECS Logger\""));
|
||||||
logs_assert(|lines: &[&str]| {
|
assert!(res.contains("\"name\":\"test msg format\""));
|
||||||
for line in lines {
|
|
||||||
return Err(format!("{line:?}"));
|
|
||||||
}
|
|
||||||
todo!()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user