Compare commits

...

6 Commits

Author SHA1 Message Date
60f81f6d4e dev: reduce build sizes (especially debug) 2026-03-24 23:48:33 +01:00
7a4876b0e8 feat: add image etag support 2026-03-24 23:47:38 +01:00
70f77f3501 perf: use const-str's include_asset! 2026-03-24 22:31:22 +01:00
8a65bb2c92 dev: do something about HEAD requests 2026-03-24 21:47:20 +01:00
af3d1a567b perf(neglible): completely veto Arc from AppState 2026-03-24 15:37:31 +01:00
4449607449 fix: bit mixing
so the fn was basically $ka + kb + kc + ... = k(a + b + c + d)$, so completely useless, this mixes better, and also position independent

and now my lovely dancing lain falls under the root domain path hash
2026-03-24 14:37:40 +01:00
10 changed files with 190 additions and 50 deletions

60
Cargo.lock generated
View File

@@ -457,6 +457,23 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "const-macros"
version = "0.1.2"
source = "git+https://git.javalsai.tuxcord.net/rust/const-macros.git#6f91e1e1a6ce2e5cdb5866a1e1d7841ed5e3cd6f"
dependencies = [
"const-macros-proc",
]
[[package]]
name = "const-macros-proc"
version = "0.1.0"
source = "git+https://git.javalsai.tuxcord.net/rust/const-macros.git#6f91e1e1a6ce2e5cdb5866a1e1d7841ed5e3cd6f"
dependencies = [
"magic",
"sha2",
]
[[package]] [[package]]
name = "const-str" name = "const-str"
version = "1.1.0" version = "1.1.0"
@@ -1075,7 +1092,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]] [[package]]
name = "magic" name = "magic"
version = "0.16.7" version = "0.16.7"
source = "git+https://github.com/javalsai/rust-magic.git?branch=dbpaths-clone#c5a357bd482b7625e407c39f5f5af5f2d425a2fb" source = "git+https://github.com/javalsai/rust-magic.git?branch=dbpaths-clone#211e458a706c728aebffb1a48175b5c6edef3493"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"magic-sys", "magic-sys",
@@ -1083,9 +1100,9 @@ dependencies = [
[[package]] [[package]]
name = "magic-sys" name = "magic-sys"
version = "0.4.2" version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b275c342ee5dd5b19e51905dcc092b8fd1bb1187abbc600ac8910bbb15840e" checksum = "9a7813c89073ddea0d1979d29786cfb5bd5c8bfe19fdede15a9e6e27fe919b23"
dependencies = [ dependencies = [
"pkg-config", "pkg-config",
"vcpkg", "vcpkg",
@@ -1175,6 +1192,7 @@ dependencies = [
"anstyle", "anstyle",
"anyhow", "anyhow",
"clap", "clap",
"const-macros",
"const-str", "const-str",
"futures-util", "futures-util",
"libc", "libc",
@@ -1182,6 +1200,7 @@ dependencies = [
"moka", "moka",
"pamsock", "pamsock",
"serde", "serde",
"sha2",
"thiserror", "thiserror",
"tokio", "tokio",
"toml", "toml",
@@ -1519,9 +1538,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.0.4" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
dependencies = [ dependencies = [
"serde_core", "serde_core",
] ]
@@ -1549,6 +1568,17 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -1758,9 +1788,9 @@ dependencies = [
[[package]] [[package]]
name = "toml" name = "toml"
version = "1.0.7+spec-1.1.0" version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"serde_core", "serde_core",
@@ -1773,27 +1803,27 @@ dependencies = [
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.0.1+spec-1.1.0" version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
dependencies = [ dependencies = [
"serde_core", "serde_core",
] ]
[[package]] [[package]]
name = "toml_parser" name = "toml_parser"
version = "1.0.10+spec-1.1.0" version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
dependencies = [ dependencies = [
"winnow", "winnow",
] ]
[[package]] [[package]]
name = "toml_writer" name = "toml_writer"
version = "1.0.7+spec-1.1.0" version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
[[package]] [[package]]
name = "tracing" name = "tracing"
@@ -1841,9 +1871,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
version = "1.12.0" version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"

View File

@@ -10,8 +10,19 @@ keywords = ["oauth", "oauth2", "oidc", "OpenID"]
categories = ["web-programming::http-server", "authentication"] categories = ["web-programming::http-server", "authentication"]
[profile.release] [profile.release]
lto = true
strip = "symbols" # almost half of binary size smh strip = "symbols" # almost half of binary size smh
# so I don't need "full" just yet and the debug builds are HUGE
[profile.dev]
debug = 1
[profile.dev.package.actix-web]
opt-level = 3
debug = false
[profile.dev.package.tokio]
opt-level = 3
debug = false
[features] [features]
default = ["pamsock"] default = ["pamsock"]
pamsock = ["dep:pamsock"] pamsock = ["dep:pamsock"]
@@ -25,24 +36,31 @@ actix-web = "4.13"
anstyle = "1.0" anstyle = "1.0"
anyhow = "1.0" anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
const-macros = { git = "https://git.javalsai.tuxcord.net/rust/const-macros.git", version = "0.1.2" }
const-str = { version = "1.1", features = ["proc"] } const-str = { version = "1.1", features = ["proc"] }
futures-util = "0.3" futures-util = "0.3"
libc = "0.2" libc = "0.2"
# https://github.com/robo9k/rust-magic/issues/434 magic = "0.16"
magic = { version = "0.16", git = "https://github.com/javalsai/rust-magic.git", branch = "dbpaths-clone" }
moka = { version = "0.12", features = ["async-lock", "future"] } moka = { version = "0.12", features = ["async-lock", "future"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10"
thiserror = "2.0" thiserror = "2.0"
tokio = { version = "1.49", features = ["full"] } tokio = { version = "1.49", features = ["full"] }
toml = "1.0" toml = "1.0"
users = "0.11" users = "0.11"
[patch.crates-io]
# https://github.com/robo9k/rust-magic/issues/434
magic = { git = "https://github.com/javalsai/rust-magic.git", branch = "dbpaths-clone" }
[lints.clippy] [lints.clippy]
cargo = { level = "warn", priority = -1 } cargo = { level = "warn", priority = -1 }
multiple_crate_versions = { level = "allow" } # otherwise there's too much multiple_crate_versions = { level = "allow" } # otherwise there's too much
correctness = { level = "deny", priority = -1 } correctness = { level = "deny", priority = -1 }
nursery = { level = "deny", priority = -1 } nursery = { level = "deny", priority = -1 }
option_if_let_else = { level = "allow" } # I personally sometimes prefer what this prevents
pedantic = { level = "deny", priority = -1 } pedantic = { level = "deny", priority = -1 }
perf = { level = "deny", priority = -1 } perf = { level = "deny", priority = -1 }
style = { level = "deny", priority = -1 } style = { level = "deny", priority = -1 }

View File

@@ -11,3 +11,7 @@ This is a reimplementation of the oauth2 server I was making and forgot where I
# License # License
All code licensed under the GPL-2.0-only. All code licensed under the GPL-2.0-only.
# Known Issues
HTTP `HEAD` requests are not responded to properly, I'm exploring solutions but each one has its drawbacks to consider.

View File

@@ -2,6 +2,8 @@
use std::time::Duration; use std::time::Duration;
use const_macros::{file::FileAsset, include_asset};
/// Max [`moka`] pfp cache capacity /// Max [`moka`] pfp cache capacity
pub const MAX_PFP_CACHE_CAPACITY: u64 = 1024; pub const MAX_PFP_CACHE_CAPACITY: u64 = 1024;
@@ -9,8 +11,7 @@ pub const MAX_PFP_CACHE_CAPACITY: u64 = 1024;
pub const MAX_PFP_SIZE: u64 = 8 * 1024 * 1024; // 8 MiB; TODO: might lower, high for prototyping pub const MAX_PFP_SIZE: u64 = 8 * 1024 * 1024; // 8 MiB; TODO: might lower, high for prototyping
/// Default user image to use if users have none /// Default user image to use if users have none
pub const DEFAULT_USER_PFP: &[u8] = include_bytes!("../assets/default-pfp.png"); pub const DEFAULT_USER_PFP: FileAsset = include_asset!("assets/default-pfp.png");
pub const DEFAULT_USER_PFP_MIME: &str = "image/png; charset=binary";
/// Basically [`USER_CACHES_HEADER`] but much longer for the [`DEFAULT_USER_PFP`] /// Basically [`USER_CACHES_HEADER`] but much longer for the [`DEFAULT_USER_PFP`]
pub const DEFAULT_USER_PFP_CACHES_HEADER: &str = pub const DEFAULT_USER_PFP_CACHES_HEADER: &str =
crate::utils::web::make_static_cache_header!(Duration::from_hours(2), Duration::from_days(1)); crate::utils::web::make_static_cache_header!(Duration::from_hours(2), Duration::from_days(1));

View File

@@ -1,7 +1,8 @@
use std::{ffi::OsStr, io, os::unix::fs::MetadataExt, path::Path, sync::Arc}; use std::{ffi::OsStr, io, os::unix::fs::MetadataExt, path::Path, sync::Arc};
use ::users::os::unix::UserExt; use ::users::os::unix::UserExt as _;
use moka::future::{Cache, CacheBuilder}; use moka::future::{Cache, CacheBuilder};
use sha2::Digest as _;
use tokio::{fs::File, io::AsyncReadExt}; use tokio::{fs::File, io::AsyncReadExt};
use crate::{ use crate::{
@@ -14,12 +15,13 @@ pub mod users;
const MIME_IMAGE_PREFIX: &str = "image/"; const MIME_IMAGE_PREFIX: &str = "image/";
pub struct MimeWithBytes { pub struct ImageInfo {
pub mime: Box<str>, pub mime: Box<str>,
pub bytes: Box<[u8]>, pub bytes: Box<[u8]>,
pub shasum: Box<str>,
} }
pub type Image = Arc<MimeWithBytes>; pub type Image = Arc<ImageInfo>;
// TODO: most of the cache methods here block the executor, if we wanna commit to async we'd have // TODO: most of the cache methods here block the executor, if we wanna commit to async we'd have
// to consider that // to consider that
@@ -115,15 +117,23 @@ impl<'a> AppCache<'a> {
for subpath in consts::USER_PFP_PATHS { for subpath in consts::USER_PFP_PATHS {
let path = home.join(subpath); let path = home.join(subpath);
// I'm relying too much on this condition
if let Ok(img_buf) = read_limited_path::<{ consts::MAX_PFP_SIZE }>(&path).await if let Ok(img_buf) = read_limited_path::<{ consts::MAX_PFP_SIZE }>(&path).await
&& let Ok(Ok(mime)) = self.magic_mime_cookie.buffer(&img_buf) // TODO: first layer && let Ok(Ok(mime)) = self.magic_mime_cookie.buffer(&img_buf) // TODO: first layer
// error is actually // error is actually
// relevant // relevant
&& mime.starts_with(MIME_IMAGE_PREFIX) && mime.starts_with(MIME_IMAGE_PREFIX)
{ {
return Some(Arc::new(MimeWithBytes { let shasum = sha2::Sha256::digest(&img_buf);
let shasum = format!(
"\"{}\"",
crate::utils::shasum::sha256sum_to_hex_string(&shasum)
);
return Some(Arc::new(ImageInfo {
mime: mime.into_boxed_str(), mime: mime.into_boxed_str(),
bytes: img_buf.into_boxed_slice(), bytes: img_buf.into_boxed_slice(),
shasum: shasum.into_boxed_str(),
})); }));
} }
} }

View File

@@ -4,6 +4,7 @@ use actix_web::{App, HttpServer, web};
pub mod caches; pub mod caches;
pub mod services; pub mod services;
pub mod static_app_data;
pub struct AppState { pub struct AppState {
pub args: crate::args::Args, pub args: crate::args::Args,
@@ -11,6 +12,9 @@ pub struct AppState {
pub cache: caches::AppCache<'static>, pub cache: caches::AppCache<'static>,
} }
/// Type alias to be used just as `data: AppData,` extractor in [`actix_web`] request handlers.
pub type AppData = static_app_data::StaticAppDataExtractor<&'static AppState>;
/// Leaks memory for the sake of not atomic'ing all over. /// Leaks memory for the sake of not atomic'ing all over.
#[expect(clippy::missing_errors_doc)] #[expect(clippy::missing_errors_doc)]
pub async fn start_app(args: crate::args::Args, config: crate::conf::Config) -> io::Result<()> { pub async fn start_app(args: crate::args::Args, config: crate::conf::Config) -> io::Result<()> {
@@ -18,10 +22,7 @@ pub async fn start_app(args: crate::args::Args, config: crate::conf::Config) ->
let config = Box::leak(Box::new(config)); let config = Box::leak(Box::new(config));
let cache = caches::AppCache::new( let cache = caches::AppCache::new(&config.unix.magic_paths, &config.unix.groups);
&config.unix.magic_paths,
&config.unix.groups,
);
let app: &AppState = Box::leak(Box::new(AppState { let app: &AppState = Box::leak(Box::new(AppState {
args, args,
@@ -36,7 +37,7 @@ pub async fn start_app(args: crate::args::Args, config: crate::conf::Config) ->
HttpServer::new(move || { HttpServer::new(move || {
App::new() App::new()
.app_data(web::Data::new(app)) .app_data(app)
.service(services::images::make_scope(ws::IMAGES)) .service(services::images::make_scope(ws::IMAGES))
.default_service(web::to(services::not_found::not_found)) .default_service(web::to(services::not_found::not_found))
}) })

View File

@@ -1,12 +1,10 @@
//! Scope for the image get backend. //! Scope for the image get backend.
//! - `/`: Gives the default image. //! - `/default`: Gives the default image.
//! - `/{username}`: Gives username's pfp or redirects to the default image's path (for better //! - `/user/{username}`: Gives username's pfp or redirects to the default image's path (for better
//! cache control) if there's no image. //! cache control) if there's no image.
//! //!
//! Must be scoped at [`ws::IMAGES`] //! Must be scoped at [`ws::IMAGES`]
// TODO: etags
use actix_web::{ use actix_web::{
HttpResponse, get, HttpResponse, get,
http::header, http::header,
@@ -15,7 +13,7 @@ use actix_web::{
use crate::{ use crate::{
consts::{self, web_scopes as ws}, consts::{self, web_scopes as ws},
server, server::AppData,
}; };
#[must_use] #[must_use]
@@ -25,33 +23,39 @@ pub fn make_scope(path: &str) -> actix_web::Scope {
.service(get_image) .service(get_image)
} }
#[get("/")] #[get("/default")]
async fn get_default_image( async fn get_default_image() -> HttpResponse {
_data: web::Data<&server::AppState>,
_username: web::Path<String>,
) -> HttpResponse {
HttpResponse::Ok() HttpResponse::Ok()
.insert_header(( .insert_header((
header::CACHE_CONTROL, header::CACHE_CONTROL,
consts::DEFAULT_USER_PFP_CACHES_HEADER, consts::DEFAULT_USER_PFP_CACHES_HEADER,
)) ))
.content_type(consts::DEFAULT_USER_PFP_MIME) .insert_header((
.body(web::Bytes::from_static(consts::DEFAULT_USER_PFP)) header::ETAG,
const_str::concat!('"', consts::DEFAULT_USER_PFP.shasum_str, '"'),
))
.content_type(consts::DEFAULT_USER_PFP.mime)
.body(web::Bytes::from_static(consts::DEFAULT_USER_PFP.bytes))
} }
#[get("/{username}")] #[get("/user/{username}")]
async fn get_image( async fn get_image(
data: web::Data<&server::AppState>, data: AppData,
username: web::Path<String>, username: web::Path<String>,
) -> web::Either<Redirect, HttpResponse> { ) -> web::Either<Redirect, HttpResponse> {
let cached_pfp = data.cache.get_pfp(username.to_string()).await; let cached_pfp = data.cache.get_pfp(username.to_string()).await;
cached_pfp.as_ref().map_or_else( cached_pfp.as_ref().map_or_else(
|| web::Either::Left(web::Redirect::to(ws::IMAGES).temporary()), || {
web::Either::Left(
web::Redirect::to(const_str::concat!(ws::IMAGES, "/default")).temporary(),
)
},
|img| { |img| {
web::Either::Right( web::Either::Right(
HttpResponse::Ok() HttpResponse::Ok()
.insert_header((header::CACHE_CONTROL, consts::USER_CACHES_HEADER)) .insert_header((header::CACHE_CONTROL, consts::USER_CACHES_HEADER))
.insert_header((header::ETAG, img.shasum.as_ref()))
.content_type(img.mime.as_ref()) .content_type(img.mime.as_ref())
.body(web::Bytes::copy_from_slice(img.bytes.as_ref())), .body(web::Bytes::copy_from_slice(img.bytes.as_ref())),
) )

View File

@@ -1,7 +1,4 @@
use actix_web::{ use actix_web::{HttpRequest, HttpResponse, web};
HttpRequest, HttpResponse,
web::{self, Bytes},
};
use futures_util::stream; use futures_util::stream;
use crate::consts; use crate::consts;
@@ -11,9 +8,10 @@ fn mix_u32s(seed: impl Iterator<Item = u32>) -> u32 {
const U32_BIT_MIXING_CONSTANT: u32 = 0x7feb_352d; const U32_BIT_MIXING_CONSTANT: u32 = 0x7feb_352d;
const U32_SELF_BIT_MIXING: u32 = 0x846c_a68b; const U32_SELF_BIT_MIXING: u32 = 0x846c_a68b;
let mut random = seed.fold(0_u32, |acc, e| { let mut random = seed.fold(0_u32, |mut acc, e| {
// mix bits // mix bits
acc.wrapping_add(e.wrapping_mul(U32_BIT_MIXING_CONSTANT)) acc ^= e.wrapping_mul(e);
acc.wrapping_mul(U32_BIT_MIXING_CONSTANT).wrapping_add(e)
}); });
// GPT's suggestion for a nicer distribution, like 8 cpu instructions so whatever // GPT's suggestion for a nicer distribution, like 8 cpu instructions so whatever
@@ -25,6 +23,16 @@ fn mix_u32s(seed: impl Iterator<Item = u32>) -> u32 {
} }
pub async fn not_found(req: HttpRequest) -> HttpResponse { pub async fn not_found(req: HttpRequest) -> HttpResponse {
if req.method().as_str().eq_ignore_ascii_case("HEAD") {
return HttpResponse::NotImplemented()
.insert_header((
"Unimplemented",
"The HEAD method is not yet implemented \
and this response is not valid for this endpoint",
))
.body(());
}
let seed = req.path().as_bytes(); let seed = req.path().as_bytes();
let random = mix_u32s(seed.iter().copied().map(u32::from)); let random = mix_u32s(seed.iter().copied().map(u32::from));
@@ -39,7 +47,7 @@ pub async fn not_found(req: HttpRequest) -> HttpResponse {
Ok::<_, !>(web::Bytes::from(format!( Ok::<_, !>(web::Bytes::from(format!(
"> {method} '{url}'\n404 NOT FOUND\nLet's all love lain\n\n" "> {method} '{url}'\n404 NOT FOUND\nLet's all love lain\n\n"
))), ))),
Ok(Bytes::from_static(res.as_bytes())), Ok(web::Bytes::from_static(res.as_bytes())),
])) ]))
// .body(format!( // .body(format!(
// "> {method} '{url}'\n404 NOT FOUND\nLet's all love lain\n\n{res}" // "> {method} '{url}'\n404 NOT FOUND\nLet's all love lain\n\n{res}"

View File

@@ -0,0 +1,38 @@
use std::{future::Ready, ops::Deref};
use actix_web::{error::InternalError, http::StatusCode};
/// App data extractor without any [`Arc`] inbetween, perefct if the app data is a simple
/// `&'static` reference that can be copied, no atomic anywhere.
pub struct StaticAppDataExtractor<T> {
pub data: T,
}
impl<T> Deref for StaticAppDataExtractor<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.data
}
}
impl<T: Copy + 'static> actix_web::FromRequest for StaticAppDataExtractor<T> {
type Error = InternalError<&'static str>;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(
req: &actix_web::HttpRequest,
_payload: &mut actix_web::dev::Payload,
) -> Self::Future {
let val = match req.app_data::<T>() {
Some(&data) => Ok(Self { data }),
None => Err(InternalError::new(
"Requested application data is not configured correctly. \
View/enable debug logs for more details.",
StatusCode::INTERNAL_SERVER_ERROR,
)),
};
std::future::ready(val)
}
}

View File

@@ -37,3 +37,29 @@ pub mod web {
} }
} }
} }
pub mod shasum {
#[must_use]
pub fn sha256sum_to_hex_string(sha256sum: &[u8]) -> String {
let mut out = String::with_capacity(sha256sum.len() * 2);
for &b in sha256sum {
/// Only correct as long as `n` is ranged within a nibble
#[inline]
const fn nibble_to_hex(n: u8) -> char {
(match n {
0..=9 => b'0' + n,
_ => b'a' + (n - 10),
}) as char
}
let hi = (b >> 4) & 0x0f;
let lo = b & 0x0f;
out.push(nibble_to_hex(hi));
out.push(nibble_to_hex(lo));
}
out
}
}