Compare commits
4 commits
a8e5d09ff0
...
c5383639f5
Author | SHA1 | Date | |
---|---|---|---|
c5383639f5 | |||
88561f7c2c | |||
ce03ce0baf | |||
e78315025d |
8 changed files with 164 additions and 78 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -3,4 +3,5 @@
|
||||||
/cache
|
/cache
|
||||||
flamegraph*.svg
|
flamegraph*.svg
|
||||||
perf.data*
|
perf.data*
|
||||||
dhat.out.*
|
dhat.out.*
|
||||||
|
settings.yaml
|
15
build.rs
15
build.rs
|
@ -1,10 +1,25 @@
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
use vergen::{vergen, Config, ShaKind};
|
use vergen::{vergen, Config, ShaKind};
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
// Initialize vergen stuff
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
*config.git_mut().sha_kind_mut() = ShaKind::Short;
|
*config.git_mut().sha_kind_mut() = ShaKind::Short;
|
||||||
vergen(config)?;
|
vergen(config)?;
|
||||||
|
|
||||||
|
// Initialize SQL stuff
|
||||||
|
let project_root = std::env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||||
|
Command::new("mkdir")
|
||||||
|
.args(["cache", "--parents"])
|
||||||
|
.current_dir(&project_root)
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
Command::new("sqlite3")
|
||||||
|
.args(["cache/metadata.sqlite", include_str!("db_queries/init.sql")])
|
||||||
|
.current_dir(&project_root)
|
||||||
|
.output()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# This script needs to be run once in order for compile time macros to not
|
|
||||||
# complain about a missing DB
|
|
||||||
|
|
||||||
# We can trust that our program will initialize the db at runtime the same way
|
|
||||||
# as it pulls from the same file for initialization
|
|
||||||
|
|
||||||
mkdir cache
|
|
||||||
sqlite3 cache/metadata.sqlite < db_queries/init.sql
|
|
|
@ -18,6 +18,8 @@
|
||||||
# MangaDex@Home configuration file
|
# MangaDex@Home configuration file
|
||||||
# We are pleased to have you here
|
# We are pleased to have you here
|
||||||
# May fate stay the night with you!
|
# May fate stay the night with you!
|
||||||
|
#
|
||||||
|
# Default values are commented out.
|
||||||
|
|
||||||
# The size in mebibytes of the cache
|
# The size in mebibytes of the cache
|
||||||
# You can use megabytes instead in a pinch,
|
# You can use megabytes instead in a pinch,
|
||||||
|
@ -25,37 +27,31 @@
|
||||||
max_cache_size_in_mebibytes: 0
|
max_cache_size_in_mebibytes: 0
|
||||||
|
|
||||||
server_settings:
|
server_settings:
|
||||||
# The client secret
|
# The client secret. Keep this secret at all costs :P
|
||||||
# Keep this secret at all costs :P
|
secret: suichan wa kyou mo kawaii!
|
||||||
secret: ina_is_the_cutest
|
|
||||||
|
|
||||||
# The port for the webserver to listen on
|
# The port for the webserver to listen on. 443 is recommended for max appeal.
|
||||||
# 443 is recommended for maximum appeal
|
# port: 443
|
||||||
port: 443
|
|
||||||
|
|
||||||
# This controls the value the server receives
|
# This controls the value the server receives for your upload speed.
|
||||||
# for your upload speed
|
|
||||||
external_max_kilobits_per_second: 0
|
external_max_kilobits_per_second: 0
|
||||||
|
|
||||||
#
|
#
|
||||||
# Stuff that you probably don't need to change
|
# Advanced settings
|
||||||
#
|
#
|
||||||
|
|
||||||
# The external port to broadcast to the backend
|
# The external hostname to listen on. Keep this at 0.0.0.0 unless you know
|
||||||
# Keep this at 0 unless you know what you're doing
|
# what you're doing.
|
||||||
# 0 means broadcast the same value as `port`
|
# hostname: 0.0.0.0
|
||||||
external_port: 0
|
|
||||||
|
|
||||||
# How long to wait for the graceful shutdown (Ctrl-C or SIGINT)
|
# The external port to broadcast to the backend. Keep this at 0 unless you
|
||||||
# This is rounded to a multiple of 5 seconds
|
# know what you're doing. 0 means broadcast the same value as `port`.
|
||||||
graceful_shutdown_wait_seconds: 60
|
# external_port: 0
|
||||||
|
|
||||||
# The external hostname to listen on
|
# How long to wait at most for the graceful shutdown (Ctrl-C or SIGINT).
|
||||||
# Keep this at 0.0.0.0 unless you know what you're doing
|
# graceful_shutdown_wait_seconds: 60
|
||||||
hostname: 0.0.0.0
|
|
||||||
|
|
||||||
# The external ip to broadcast to the webserver
|
# The external ip to broadcast to the webserver. The default of null (~) means
|
||||||
# The default of null means the backend will infer it
|
# the backend will infer it from where it was sent from, which may fail in the
|
||||||
# from where it was sent from, which may fail in the
|
# presence of multiple IPs.
|
||||||
# presence of multiple IPs
|
# external_ip: ~
|
||||||
external_ip: ~
|
|
||||||
|
|
6
src/cache/fs.rs
vendored
6
src/cache/fs.rs
vendored
|
@ -111,21 +111,21 @@ pub(super) async fn read_file(
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let header = if let Some(header) = Header::from_slice(&header_bytes) {
|
let file_header = if let Some(header) = Header::from_slice(&header_bytes) {
|
||||||
header
|
header
|
||||||
} else {
|
} else {
|
||||||
warn!("Found file, but encrypted header was invalid. Assuming corrupted!");
|
warn!("Found file, but encrypted header was invalid. Assuming corrupted!");
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
|
|
||||||
let secret_stream = if let Ok(stream) = SecretStream::init_pull(&header, key) {
|
let secret_stream = if let Ok(stream) = SecretStream::init_pull(&file_header, key) {
|
||||||
stream
|
stream
|
||||||
} else {
|
} else {
|
||||||
warn!("Failed to init secret stream with key and header. Assuming corrupted!");
|
warn!("Failed to init secret stream with key and header. Assuming corrupted!");
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
|
|
||||||
maybe_header = Some(header);
|
maybe_header = Some(file_header);
|
||||||
|
|
||||||
reader = Some(Box::pin(EncryptedDiskReader::new(file, secret_stream)));
|
reader = Some(Box::pin(EncryptedDiskReader::new(file, secret_stream)));
|
||||||
}
|
}
|
||||||
|
|
130
src/config.rs
130
src/config.rs
|
@ -2,8 +2,8 @@ use std::fmt::{Display, Formatter};
|
||||||
use std::fs::{File, OpenOptions};
|
use std::fs::{File, OpenOptions};
|
||||||
use std::hint::unreachable_unchecked;
|
use std::hint::unreachable_unchecked;
|
||||||
use std::io::{ErrorKind, Write};
|
use std::io::{ErrorKind, Write};
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||||
use std::num::{NonZeroU16, NonZeroU64};
|
use std::num::NonZeroU16;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
@ -64,7 +64,7 @@ pub fn load_config() -> Result<Config, serde_yaml::Error> {
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents a fully parsed config file.
|
/// Represents a fully parsed config, from a variety of sources.
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub cache_type: CacheType,
|
pub cache_type: CacheType,
|
||||||
pub cache_path: PathBuf,
|
pub cache_path: PathBuf,
|
||||||
|
@ -75,27 +75,93 @@ pub struct Config {
|
||||||
pub bind_address: SocketAddr,
|
pub bind_address: SocketAddr,
|
||||||
pub external_address: Option<SocketAddr>,
|
pub external_address: Option<SocketAddr>,
|
||||||
pub ephemeral_disk_encryption: bool,
|
pub ephemeral_disk_encryption: bool,
|
||||||
pub unstable_options: Vec<UnstableOptions>,
|
|
||||||
pub network_speed: KilobitsPerSecond,
|
pub network_speed: KilobitsPerSecond,
|
||||||
pub disk_quota: Mebibytes,
|
pub disk_quota: Mebibytes,
|
||||||
pub memory_quota: Mebibytes,
|
pub memory_quota: Mebibytes,
|
||||||
|
pub unstable_options: Vec<UnstableOptions>,
|
||||||
pub override_upstream: Option<Url>,
|
pub override_upstream: Option<Url>,
|
||||||
|
pub enable_metrics: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
fn from_cli_and_file(cli_args: CliArgs, file_args: YamlArgs) -> Self {
|
fn from_cli_and_file(cli_args: CliArgs, file_args: YamlArgs) -> Self {
|
||||||
|
let file_extended_options = file_args.extended_options.unwrap_or_default();
|
||||||
|
|
||||||
let log_level = match (cli_args.quiet, cli_args.verbose) {
|
let log_level = match (cli_args.quiet, cli_args.verbose) {
|
||||||
(n, _) if n > 2 => LevelFilter::Off,
|
(n, _) if n > 2 => LevelFilter::Off,
|
||||||
(2, _) => LevelFilter::Error,
|
(2, _) => LevelFilter::Error,
|
||||||
(1, _) => LevelFilter::Warn,
|
(1, _) => LevelFilter::Warn,
|
||||||
// Use log level from file if no flags were provided to CLI
|
// Use log level from file if no flags were provided to CLI
|
||||||
(0, 0) => file_args.extended_options.logging_level,
|
(0, 0) => file_extended_options
|
||||||
|
.logging_level
|
||||||
|
.unwrap_or(LevelFilter::Info),
|
||||||
(_, 1) => LevelFilter::Debug,
|
(_, 1) => LevelFilter::Debug,
|
||||||
(_, n) if n > 1 => LevelFilter::Trace,
|
(_, n) if n > 1 => LevelFilter::Trace,
|
||||||
// compiler can't figure it out
|
// compiler can't figure it out
|
||||||
_ => unsafe { unreachable_unchecked() },
|
_ => unsafe { unreachable_unchecked() },
|
||||||
};
|
};
|
||||||
todo!()
|
|
||||||
|
let bind_port = cli_args
|
||||||
|
.port
|
||||||
|
.unwrap_or(file_args.server_settings.port)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// This needs to be outside because rust isn't smart enough yet to
|
||||||
|
// realize a disjointed borrow of a moved value is ok. This will be
|
||||||
|
// fixed in Rust 2021.
|
||||||
|
let external_port = file_args
|
||||||
|
.server_settings
|
||||||
|
.external_port
|
||||||
|
.map_or(bind_port, Port::get);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
cache_type: cli_args
|
||||||
|
.cache_type
|
||||||
|
.or(file_extended_options.cache_type)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
cache_path: cli_args
|
||||||
|
.cache_path
|
||||||
|
.or(file_extended_options.cache_path)
|
||||||
|
.unwrap_or_else(|| PathBuf::from_str("./cache").unwrap()),
|
||||||
|
shutdown_timeout: file_args
|
||||||
|
.server_settings
|
||||||
|
.graceful_shutdown_wait_seconds
|
||||||
|
.unwrap_or(unsafe { NonZeroU16::new_unchecked(60) }),
|
||||||
|
log_level,
|
||||||
|
// secret should never be in CLI
|
||||||
|
client_secret: file_args.server_settings.secret,
|
||||||
|
port: cli_args.port.unwrap_or(file_args.server_settings.port),
|
||||||
|
bind_address: SocketAddr::new(
|
||||||
|
file_args
|
||||||
|
.server_settings
|
||||||
|
.hostname
|
||||||
|
.unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))),
|
||||||
|
bind_port,
|
||||||
|
),
|
||||||
|
external_address: file_args
|
||||||
|
.server_settings
|
||||||
|
.external_ip
|
||||||
|
.map(|ip_addr| SocketAddr::new(ip_addr, external_port)),
|
||||||
|
ephemeral_disk_encryption: cli_args
|
||||||
|
.ephemeral_disk_encryption
|
||||||
|
.or(file_extended_options.ephemeral_disk_encryption)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
network_speed: cli_args
|
||||||
|
.network_speed
|
||||||
|
.unwrap_or(file_args.server_settings.external_max_kilobits_per_second),
|
||||||
|
disk_quota: cli_args
|
||||||
|
.disk_quota
|
||||||
|
.unwrap_or(file_args.max_cache_size_in_mebibytes),
|
||||||
|
memory_quota: cli_args
|
||||||
|
.memory_quota
|
||||||
|
.or(file_extended_options.memory_quota)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
enable_metrics: file_extended_options.enable_metrics.unwrap_or_default(),
|
||||||
|
|
||||||
|
// Unstable options (and related) should never be in yaml config
|
||||||
|
unstable_options: cli_args.unstable_options,
|
||||||
|
override_upstream: cli_args.override_upstream,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,13 +171,14 @@ struct YamlArgs {
|
||||||
max_cache_size_in_mebibytes: Mebibytes,
|
max_cache_size_in_mebibytes: Mebibytes,
|
||||||
server_settings: YamlServerSettings,
|
server_settings: YamlServerSettings,
|
||||||
// This implementation custom options
|
// This implementation custom options
|
||||||
extended_options: YamlExtendedOptions,
|
extended_options: Option<YamlExtendedOptions>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Naming is legacy
|
// Naming is legacy
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct YamlServerSettings {
|
struct YamlServerSettings {
|
||||||
secret: ClientSecret,
|
secret: ClientSecret,
|
||||||
|
#[serde(default)]
|
||||||
port: Port,
|
port: Port,
|
||||||
external_max_kilobits_per_second: KilobitsPerSecond,
|
external_max_kilobits_per_second: KilobitsPerSecond,
|
||||||
external_port: Option<Port>,
|
external_port: Option<Port>,
|
||||||
|
@ -124,21 +191,14 @@ struct YamlServerSettings {
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
pub struct ClientSecret(String);
|
pub struct ClientSecret(String);
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, Default)]
|
||||||
struct YamlExtendedOptions {
|
struct YamlExtendedOptions {
|
||||||
memory_quota: Option<NonZeroU64>,
|
memory_quota: Option<Mebibytes>,
|
||||||
#[serde(default)]
|
cache_type: Option<CacheType>,
|
||||||
cache_type: CacheType,
|
ephemeral_disk_encryption: Option<bool>,
|
||||||
#[serde(default)]
|
enable_metrics: Option<bool>,
|
||||||
ephemeral_disk_encryption: bool,
|
logging_level: Option<LevelFilter>,
|
||||||
#[serde(default)]
|
cache_path: Option<PathBuf>,
|
||||||
enable_metrics: bool,
|
|
||||||
#[serde(default = "default_logging_level")]
|
|
||||||
logging_level: LevelFilter,
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn default_logging_level() -> LevelFilter {
|
|
||||||
LevelFilter::Info
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Copy, Clone)]
|
#[derive(Deserialize, Copy, Clone)]
|
||||||
|
@ -172,25 +232,25 @@ impl Default for CacheType {
|
||||||
#[clap(version = crate_version!(), author = crate_authors!(), about = crate_description!())]
|
#[clap(version = crate_version!(), author = crate_authors!(), about = crate_description!())]
|
||||||
struct CliArgs {
|
struct CliArgs {
|
||||||
/// The port to listen on.
|
/// The port to listen on.
|
||||||
#[clap(short, long, default_value = "42069")]
|
#[clap(short, long)]
|
||||||
pub port: Port,
|
pub port: Option<Port>,
|
||||||
/// How large, in bytes, the in-memory cache should be. Note that this does
|
/// How large, in mebibytes, the in-memory cache should be. Note that this
|
||||||
/// not include runtime memory usage.
|
/// does not include runtime memory usage.
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
pub memory_quota: Option<NonZeroU64>,
|
pub memory_quota: Option<Mebibytes>,
|
||||||
/// How large, in bytes, the on-disk cache should be. Note that actual
|
/// How large, in mebibytes, the on-disk cache should be. Note that actual
|
||||||
/// values may be larger for metadata information.
|
/// values may be larger for metadata information.
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
pub disk_quota: u64,
|
pub disk_quota: Option<Mebibytes>,
|
||||||
/// Sets the location of the disk cache.
|
/// Sets the location of the disk cache.
|
||||||
#[clap(long, default_value = "./cache")]
|
#[clap(long)]
|
||||||
pub cache_path: PathBuf,
|
pub cache_path: Option<PathBuf>,
|
||||||
/// The network speed to advertise to Mangadex@Home control server.
|
/// The network speed to advertise to Mangadex@Home control server.
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
pub network_speed: NonZeroU64,
|
pub network_speed: Option<KilobitsPerSecond>,
|
||||||
/// Changes verbosity. Default verbosity is INFO, while increasing counts of
|
/// Changes verbosity. Default verbosity is INFO, while increasing counts of
|
||||||
/// verbose flags increases the verbosity to DEBUG and TRACE, respectively.
|
/// verbose flags increases the verbosity to DEBUG and TRACE, respectively.
|
||||||
#[clap(short, long, parse(from_occurrences))]
|
#[clap(short, long, parse(from_occurrences), conflicts_with = "quiet")]
|
||||||
pub verbose: usize,
|
pub verbose: usize,
|
||||||
/// Changes verbosity. Default verbosity is INFO, while increasing counts of
|
/// Changes verbosity. Default verbosity is INFO, while increasing counts of
|
||||||
/// quiet flags decreases the verbosity to WARN, ERROR, and no logs,
|
/// quiet flags decreases the verbosity to WARN, ERROR, and no logs,
|
||||||
|
@ -205,11 +265,11 @@ struct CliArgs {
|
||||||
/// encrypted with a key generated at runtime. There are implications to
|
/// encrypted with a key generated at runtime. There are implications to
|
||||||
/// performance, privacy, and usability with this flag enabled.
|
/// performance, privacy, and usability with this flag enabled.
|
||||||
#[clap(short, long)]
|
#[clap(short, long)]
|
||||||
pub ephemeral_disk_encryption: bool,
|
pub ephemeral_disk_encryption: Option<bool>,
|
||||||
#[clap(short, long)]
|
#[clap(short, long)]
|
||||||
pub config_path: Option<PathBuf>,
|
pub config_path: Option<PathBuf>,
|
||||||
#[clap(default_value = "on_disk")]
|
#[clap(short = 't', long)]
|
||||||
pub cache_type: CacheType,
|
pub cache_type: Option<CacheType>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
|
|
@ -93,7 +93,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||||
ENCRYPTION_KEY.set(gen_key()).unwrap();
|
ENCRYPTION_KEY.set(gen_key()).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics::init();
|
if config.enable_metrics {
|
||||||
|
metrics::init();
|
||||||
|
}
|
||||||
|
|
||||||
// HTTP Server init
|
// HTTP Server init
|
||||||
|
|
||||||
|
|
30
src/units.rs
30
src/units.rs
|
@ -1,5 +1,5 @@
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use std::num::{NonZeroU16, NonZeroU64};
|
use std::num::{NonZeroU16, NonZeroU64, ParseIntError};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
@ -8,6 +8,18 @@ use serde::{Deserialize, Serialize};
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
|
||||||
pub struct Port(NonZeroU16);
|
pub struct Port(NonZeroU16);
|
||||||
|
|
||||||
|
impl Port {
|
||||||
|
pub const fn get(self) -> u16 {
|
||||||
|
self.0.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Port {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(unsafe { NonZeroU16::new_unchecked(443) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FromStr for Port {
|
impl FromStr for Port {
|
||||||
type Err = <NonZeroU16 as FromStr>::Err;
|
type Err = <NonZeroU16 as FromStr>::Err;
|
||||||
|
|
||||||
|
@ -25,9 +37,11 @@ impl Display for Port {
|
||||||
#[derive(Copy, Clone, Serialize, Deserialize, Default, Debug, Hash, Eq, PartialEq)]
|
#[derive(Copy, Clone, Serialize, Deserialize, Default, Debug, Hash, Eq, PartialEq)]
|
||||||
pub struct Mebibytes(usize);
|
pub struct Mebibytes(usize);
|
||||||
|
|
||||||
impl Mebibytes {
|
impl FromStr for Mebibytes {
|
||||||
pub const fn get(self) -> usize {
|
type Err = ParseIntError;
|
||||||
self.0
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
s.parse::<usize>().map(Self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,6 +62,14 @@ impl From<Mebibytes> for Bytes {
|
||||||
#[derive(Copy, Clone, Deserialize, Debug, Hash, Eq, PartialEq)]
|
#[derive(Copy, Clone, Deserialize, Debug, Hash, Eq, PartialEq)]
|
||||||
pub struct KilobitsPerSecond(NonZeroU64);
|
pub struct KilobitsPerSecond(NonZeroU64);
|
||||||
|
|
||||||
|
impl FromStr for KilobitsPerSecond {
|
||||||
|
type Err = ParseIntError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
s.parse::<NonZeroU64>().map(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Serialize, Debug, Hash, Eq, PartialEq)]
|
#[derive(Copy, Clone, Serialize, Debug, Hash, Eq, PartialEq)]
|
||||||
pub struct BytesPerSecond(NonZeroU64);
|
pub struct BytesPerSecond(NonZeroU64);
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue