Hardening Rust Code

本周在this week in rust上看到了好几篇有意思的文章,话题也比较接近,干脆整理在一起吧

原计划还有一篇,由于篇幅很长且重合度较高故只留网址不写了

防御性编程

indexing into a vector

if !matching_users.is_empty() {
    let existing_user = &matching_users[0];
    // ...
}

虽然看起来没问题,但是访问matching_user的合法性全系于前面的条件检查,一旦逻辑复杂就容易遗忘

match matching_users.as_slice() {
    [] => todo!("What to do if no users found!?"),
    [existing_user] => {  // Safe! Compiler guarantees exactly one element
        // No need to index into the vector,
        // we can directly use `existing_user` here 
    }
    _ => Err(RepositoryError::DuplicateUsers)
}

as_slice()match来强制错误处理,这是一种让编译器负责约束的模式

lazy use of Default

let foo = Foo {
    field1: value1,
    field2: value2,
    ..Default::default()  // Implicitly sets all other fields
};

这样定义是坏的,假如为foo新增了一个字段,它会被悄悄默认,但是其默认值未必是想要的。

let foo = Foo {
    field1: value1,
    field2: value2,
    field3: value3, // Explicitly set all fields
    field4: value4,
    // ...
};

显式声明就不会有这个问题,但是太笨了,完全浪费了Default的设计

let Foo { field1, field2, field3, field4 } = Foo::default();

let foo = Foo {
    field1: value1,    // Override what you need
    field2: value2,    // Override what you need
    field3,            // Use default value
    field4,            // Use default value
};

这样也是可以接受的

脆弱的特征实现

对于这样一个结构体:

struct PizzaOrder {
    size: PizzaSize,
    toppings: Vec<Topping>,
    crust_type: CrustType,
    ordered_at: SystemTime,
}

为了让此类型可以比较,需要手动实现PartialEq特征:

impl PartialEq for PizzaOrder {
    fn eq(&self, other: &Self) -> bool {
        self.size == other.size 
            && self.toppings == other.toppings 
            && self.crust_type == other.crust_type
            // Oops! What happens when we add extra_cheese or delivery_address later?
    }
}

然后就遇到了和刚才一样的问题,假如PizzaOrder又多了一个字段extra_cheese: bool, // New field addedPartialEq特征不会自动更新,代码会顺利地通过编译,留下一个bug

impl PartialEq for PizzaOrder {
    fn eq(&self, other: &Self) -> bool {
        let Self {
            size,
            toppings,
            crust_type,
            ordered_at: _,
        } = self;
        let Self {
            size: other_size,
            toppings: other_toppings,
            crust_type: other_crust,
            ordered_at: _,
        } = other;

        size == other_size && toppings == other_toppings && crust_type == other_crust
    }
}

用结构体解构的方法提取里面的变量,然后比较。这样一来一旦结构体定义变化,编译就会报错

实际上是TryFromFrom

From把一个给定类型转化为另一个类型,TryFrom同理但是认为可能出错,所以返回一个Result

From不会出错固然好,但有时它是伪装的TryFrom:

impl From<&DetectorStartupErrorReport> for DetectorStartupErrorSubject {
    fn from(report: &DetectorStartupErrorReport) -> Self {
        let postfix = report
            .get_identifier()
            .or_else(get_binary_name)
            .unwrap_or_else(|| UNKNOWN_DETECTOR_SUBJECT.to_string());

        Self(StreamSubject::from(
            format!("apps.errors.detectors.startup.{postfix}").as_str(),
        ))
    }
}

在错误的时候返回一个特殊的默认值,这是不合适的,莫不如实现TryFrom让他尽早报错

missing match arms

match self {
    Self::Variant1 => { /* ... */ }
    Self::Variant2 => { /* ... */ }
    _ => { /* catch-all */ }
}

同理,当self多了一个枚举可能时,会默认因而可能忘记处理

match self {
    Self::Variant1 => { /* ... */ }
    Self::Variant2 => { /* ... */ }
    Self::Variant3 => { /* ... */ }
    Self::Variant4 => { /* ... */ }
}

或者:

match self {
    Self::Variant1 => { /* ... */ }
    Self::Variant2 => { /* ... */ }
    Self::Variant3 | Self::Variant4 => { /* shared logic */ }
}

迷惑占位符

像以下这样用对未使用的变量用占位符会很让人疑惑,他们到底占了什么的位置

match self {
    Self::Rocket { _, _, .. } => { /* ... */ }
}

这种写法就清晰多了,即使用不到:

match self {
    Self::Rocket { has_fuel: _, has_crew: _, .. } => { /* ... */ }
}

临时可变性

如果只想让一个变量临时具有可变性,比如初始化阶段,显式地写出来:

let mut data = get_vec();
data.sort();
let data = data;  // Shadow to make immutable

// Here `data` is immutable.

也可以这样:

let data = {
    let mut data = get_vec();
    data.sort();
    data  // Return the final value
};
// Here `data` is immutable

而且不会让temp这样的常见变量名到处飞,严格限制其作用域:

let data = {
    let mut data = get_vec();
    let temp = compute_something();
    data.extend(temp);
    data.sort();
    data  // Return the final value
};

处理constructor

仅对库和需要应对未来变化的API适用

假设一个结构体提供一个构造器,每次初始化借助构造器来完成。

假设有这样一个结构体:

pub struct S {
    pub field1: String,
    pub field2: u32,
}

现在想要确保构造出来的S是合法的,可以在构造时返回一个Result:

impl S {
    pub fn new(field1: String, field2: u32) -> Result<Self, String> {
        if field1.is_empty() {
            return Err("field1 cannot be empty".to_string());
        }
        if field2 == 0 {
            return Err("field2 cannot be zero".to_string());
        }
        Ok(Self { field1, field2 })
    }
}

但是总有人可以这样构建:

let s = S {
    field1: "".to_string(),
    field2: 0,
};

这种行为不应当能通过编译,解决方法是增加一个私有字段:

pub struct S {
    pub field1: String,
    pub field2: u32,
    _private: (), // This prevents external construction 
}

impl S {
    pub fn new(field1: String, field2: u32) -> Result<Self, String> {
        if field1.is_empty() {
            return Err("field1 cannot be empty".to_string());
        }
        if field2 == 0 {
            return Err("field2 cannot be zero".to_string());
        }
        Ok(Self { field1, field2, _private: () })
    }
}

看起来有点抽象,用属性宏#[non_exhaustive]也是一样的:

#[non_exhaustive]
pub struct S {
    pub field1: String,
    pub field2: u32,
}

该用_private还是#[non_exhaustive]?

#[non_exhaustive]的作用范围是crate之外,意味着当前crate内还可以直接构建;_private 则是moduel范围,在这之外都访问不到也就不能直接构建

但是在同一个moduel内呢?

// Still compiles in the same module!
let s = S {
    field1: "".to_string(),
    field2: 0,
    _private: (),
};

如果想把这也纳入到限制范围呢?可以用nested private modules

mod inner {
    pub struct S {
        pub field1: String,
        pub field2: u32,
        _seal: Seal,
    }
    
    // This type is private to the inner module
    struct Seal;
    
    impl S {
        pub fn new(field1: String, field2: u32) -> Result<Self, String> {
            if field1.is_empty() {
                return Err("field1 cannot be empty".to_string());
            }
            if field2 == 0 {
                return Err("field2 cannot be zero".to_string());
            }
            Ok(Self { field1, field2, _seal: Seal })
        }
    }
}

// Re-export for public use
pub use inner::S;

结构体seal是private的,inner这个mod只对外暴露了S,不包括seal,这样里面的东西就密封好了

现在构造过程绝对安全了,但是构造之后仍然是可以修改的:

let s = S::new("valid".to_string(), 42).unwrap();
s.field1 = "".to_string(); // Still possible to mutate fields directly

这样以来还是有可能出现非法的S,解决方法是私有化这些字段,然后只提供getter

mod inner {
    pub struct S {
        field1: String,
        field2: u32,
        _seal: Seal,
    }
    
    struct Seal;
    
    impl S {
        pub fn new(field1: String, field2: u32) -> Result<Self, String> {
            if field1.is_empty() {
                return Err("field1 cannot be empty".to_string());
            }
            if field2 == 0 {
                return Err("field2 cannot be zero".to_string());
            }
            Ok(Self { field1, field2, _seal: Seal })
        }

        pub fn field1(&self) -> &str {
            &self.field1
        }

        pub fn field2(&self) -> u32 {
            self.field2
        }
    }
}

对重要的类型使用#[must_use]

这是一个简单好用的防止调用者忽略重要的返回值的方法,但是可惜经常被忽视

比如这样一个类型:

#[must_use = "Configuration must be applied to take effect"]
pub struct Config {
    // ...
}

impl Config {
    pub fn new() -> Self {
        // ...
    }

    pub fn with_timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }
}

with_timeout方法中,self不是引用,它返回一个改变后的Config,而不是原地修改

所以这种情况使用者就会得到编译器warn:

let config = Config::new();
// Warning: Configuration must be applied to take effect
config.with_timeout(Duration::from_secs(30)); 

// Correct usage:
let config = Config::new()
    .with_timeout(Duration::from_secs(30));
apply_config(config);

标准库中的ResultIterator就被贴上了#[must_use],因而不处理Result或者不消费迭代器就会被warn

Boolean 盲区

布尔变量参数对调用者可读性是一个灾难

// Too many boolean parameters
fn process_data(data: &[u8], compress: bool, encrypt: bool, validate: bool) {
    // ...
}

// At the call site, what do these booleans mean?
process_data(&data, true, false, true);  // What does this do?

不看签名每人知道这堆参数是干什么的。所以用枚举类型:

enum Compression {
    Strong,
    Medium,
    None,
}

enum Encryption {
    AES,
    ChaCha20,
    None,
}

enum Validation {
    Enabled,
    Disabled,
}

fn process_data(
    data: &[u8],
    compression: Compression,
    encryption: Encryption,
    validation: Validation,
) {
    // ...
}

// Now the call site is self-documenting
process_data(
    &data,
    Compression::Strong,
    Encryption::None,
    Validation::Enabled
);

枚举类型可以承载更多信息,同时引入了类型系统来避免低级错误,但是代价是函数签名很不美丽。可以用一个参数结构体:

struct ProcessDataParams {
    compression: Compression,
    encryption: Encryption,
    validation: Validation,
}

impl ProcessDataParams {
    // Common configurations as constructor methods
    pub fn production() -> Self {
        Self {
            compression: Compression::Strong,
            encryption: Encryption::AES,
            validation: Validation::Enabled,
        }
    }

    pub fn development() -> Self {
        Self {
            compression: Compression::None,
            encryption: Encryption::None,
            validation: Validation::Enabled,
        }
    }
}

fn process_data(data: &[u8], params: ProcessDataParams) {
    // ...
}

// Usage with preset configurations
process_data(&data, ProcessDataParams::production());

// Or customize for specific needs
process_data(&data, ProcessDataParams {
    compression: Compression::Medium,
    encryption: Encryption::ChaCha20,
    validation: Validation::Enabled, 
});

既简化了写法,还能预设一些模板,完美

Clippy

以上都可以在Clippy中设置

Lint 规则 Description (描述)
clippy::indexing_slicing Prevents direct indexing into slices and vectors
clippy::fallible_impl_from Warns about From implementations that can panic and should be TryFrom instead.
clippy::wildcard_enum_match_arm Disallows wildcard _ patterns.
clippy::unneeded_field_pattern Identifies when you’re ignoring too many struct fields with .. unnecessarily.
clippy::fn_params_excessive_bools Warns when a function has too many boolean parameters (4 or more by default).
clippy::must_use_candidate Suggests adding #[must_use] to types that are good candidates for it.

Cargo.toml中补充:

[lints.clippy]
indexing_slicing = "deny"
fallible_impl_from = "deny"
wildcard_enum_match_arm = "deny"
unneeded_field_pattern = "deny"
fn_params_excessive_bools = "deny"
must_use_candidate = "deny"

加固生产环境代码

防御性编程可以尽可能确保代码逻辑上的安全,但是代码在运行时还会遇到各种各样的问题

Panic 语义是API的一部分

一个rust程序在panic时会发生什么?这有很多种可能

Unwind (栈展开) 与 Abort (终止)

unwind模式触发一个闭包,它会捕获错误原因:

let result = panic::catch_unwind(|| {
    panic!("oh no!");
});

但是著名教材Rustonomicon表示unwind建议仅作为备用,因为rust的当前实现假定不会unwind并基于此做了大量优化……所以别把它当成Result的替代使用

替代方法是Abort掉整个程序,在Cargo.toml中打开:

[profile.release]
panic = "abort"

即使不显式声明,遇到像栈溢出、out of memory这样的重大问题也会默认abort,因为unwinding在这种情况下会产生未定义行为。想在rust程序里栈溢出还是有难度的,一般发生在

  • C FFI
  • malloc 失败

这样的问题和常规panic完全不同,catch_unwind对他们无效,也就无法事后抢救,只能根据实际情况事先预防,比如对于malloc失效,

Thread-Level vs. Process-Level Failures

panic会结束整个进程是一个误解,在多线程程序中不总是这样的,举个例子的话

use std::{thread, time::Duration};

fn handle_request(id: u32) {
    println!("request {id}: started");

    if id == 2 {
        panic!("request {id}: handler panicked");
    }

    thread::sleep(Duration::from_millis(100));
    println!("request {id}: finished");
}

fn main() {
    thread::scope(|s| {
        let requests: Vec<_> = (1..=3)
            .map(|id| (id, s.spawn(move || handle_request(id))))
            .collect();

        for (id, request) in requests {
            match request.join() {
                Ok(()) => println!("main: request {id} completed"),
                Err(_) => println!("main: request {id} failed, but the process is still alive"),
            }
        }
    });

    println!("main: service keeps running");
}

输出是:

request 1: finished
main: request 1 completed
main: request 2 failed, but the process is still alive
request 3: finished
main: request 3 completed
main: service keeps running

一个线程崩溃了,但是其余线程还能正常工作,所以panic是有线程级隔离的

但是这也带来了风险,如果是局部违背比如请求参数错误,那这么做合情合理,但如果是全局状态问题,继续执行可能就是很危险的。

panic处理也是系统错误模型的一部分,对待panic和对待其他错误没什么不同。永远不要让panic脱离控制!

如果维护一个库,你无从得知使用者的使用方法,所以考虑开启更严格的clippy规则比如indexing_slicingarithmetic_side_effects来捕获更多panic源

Observing Failures With Panic Hooks

理解panic的工作原理之后,就可以考虑加强panic处理了。默认情况下,只会打印一些信息到stderr,但在生产环境里还不太够。

于是需要panic hook来

use std::panic;

fn main() {
    panic::set_hook(Box::new(|panic_info| {
        eprintln!("panic occurred: {panic_info}");
        // log to your monitoring system
        // send crash reports
        // clean up resources
    }));

    panic!("Something went wrong!");
}

主动上报、清理资源,不一而足。

比如一个实际一点的例子:

panic::set_hook(Box::new(|panic_info| {
    let panic_data = serde_json::json!({
        "message": panic_info.to_string(),
        "location": panic_info.location().map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())),
        "timestamp": chrono::Utc::now().to_rfc3339(),
        "version": env!("CARGO_PKG_VERSION"),
    });

    // Send to your crash reporting service
    crash_reporter::report(panic_data);
}));

一个很优雅的实现

fn setup(&self, _cfg: &mut ClientOptions) {
    INIT.call_once(|| {
        let next = panic::take_hook();
        panic::set_hook(Box::new(move |info| {
            panic_handler(info);
            next(info);
        }));
    });
}

发送错误信息,用take_hook保存原本的panic hook(一个闭包),然后next执行它

用一个全局的静态变量防止重复初始化

敏感信息消毒

Panic hooks 也是避免信息泄漏的最后机会。敏感信息来源于两个地方,panic_payload 和panic_location。

payload 是无论什么代码中传给panic! unwarp expect或一个断言,这意味着它可以包含用户输入,内部状态,请求头,token,身份信息等等,而location可以暴露源文件路径,CI/build 设备目录布局

解决的下策是用正则表达式兜底,看起来大概像这样:

panic::set_hook(Box::new(|panic_info| {
    let sanitized_message = sanitize_panic_message(panic_info.to_string());
    log::error!("Application panic: {sanitized_message}");
}));

而上策是根本不让敏感信息被输出,比如用一个uuid替代,用其他途径记录。

脱敏可以在代码中"配置"

use veil::Redact;

#[derive(Redact)]
pub struct Customer {
    id: u64,

    #[redact(partial)]
    first_name: String,

    #[redact(partial)]
    last_name: String,

    #[redact]
    email: Option<String>,

    #[redact(fixed = 2)]
    age: u32,

    #[redact(with = "[REDACTED]")]
    address: String,
}

Cleanup

在进程终止前,可能想要刷新日志,关闭网络连接或通知其他系统当前实例即将关闭,设置一个hook是个很好的方法

但是要注意这一步中打交道的子系统可能造成panic!双重panic会立刻Abort,所以一定要保持清理操作的容错性,避免任何可能panic的情况

Stack Overflows And Runtime Behavior

除了panic,还有其它运行时错误需要考虑

fn factorial(n: u64) -> u64 {
    if n == 0 {
        1
    } else {
        n * factorial(n - 1)
    }
}

像这样一个函数,如果参数很大,栈会很容易爆掉,而对于stable rust,编译器不保证尾递归优化。所以根本就不要写成递归的形式

fn factorial(n: u64) -> u64 {
    let mut result = 1;
    for i in 1..=n {
        result *= i;
    }
    result
}

Release and Debug Builds Are Two Different Programs

本地运行的debug模式和release模式的很多行为是不一样的。不要假设他们逻辑等价。

最显而易见的是整数溢出处理,debug模式下会panic,但是release会静默。

同时,release会移除所有的debug_assert!检查,开启优化同时对于cfg(debug_assertions)产生不同的行为。Unsafe代码尤其要考虑这些。

fn apply_discount(price: u32, percent: u32) -> u32 {
    debug_assert!(percent <= 100);
    price - (price * percent / 100)
}

在release下,断言消失,下溢出就可能发生

所以需要

# Add this to your CI pipeline alongside regular `cargo test`
cargo test --release

供应链安全

其重要性自不必说,我们不是cpp

cargo-audit 它会读取项目中的Cargo.lock 文件然后将这些版本与 RustSec 漏洞数据库 进行比对。一旦发现你当前使用的某个 Crate 版本存在已被公开的漏洞(CVE),它就会发出警告甚至报错。

cargo-denycargo-audit的检查已知漏洞的基础上,还检查开源协议,支持黑名单,限制依赖的下载源与去重

Secure Allocations With mimalloc

mimalloc是微软开发的可以无痛替换的堆分配器,用于在特定场景下获得更高的性能。他有一个secure模式,支持保护页,分配随机化等等手段,主要用于为unsafe代码兜底,想要使用只需要:

[dependencies]
mimalloc = { version = "0.1", features = ["secure"] }

再用它作为全局分配器

use mimalloc::MiMalloc;

#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;

由于安全是有性能代价的,所以测量性能后使用

限制运行环境攻击面

假设真的被getshell了,也可以做一些约束

最小化docker image

又是我为什么没早点看到系列

作者的推荐是 Google’s distroless images,但是仅供参考,他也不是这方面的专家。

这是一个剥离了几乎所有不必要的东西的镜像,同时保留了TLS证书和一个非root用户。对于一个典型的Rust web服务,可以从gcr.io/distroless/cc-debian13:nonroot开始,它包含了动态链接需要的C库但是没有shell也没有包管理器

一个用cargo-chef的例子:

# syntax=docker/dockerfile:1

ARG RUST_VERSION=1.92

FROM rust:${RUST_VERSION}-bookworm AS chef
RUN cargo install cargo-chef --locked
WORKDIR /app

FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json

COPY . .
RUN cargo build --locked --release --bin myapp

FROM gcr.io/distroless/cc-debian13:nonroot AS runtime
COPY --from=builder /app/target/release/myapp /bin/myapp
ENTRYPOINT ["/bin/myapp"]

cargo-chef,要理解它的作用,需要知道Docker image是层缓存的,每次改动代码都会使后续的命令失效,这意味着极慢的重新构建过程。而cargo-chef把项目代码和项目依赖分开,这样修改代码只会重建自己的很小一部分。这里又会涉及一大堆细节与实践知识。

Landlock

Landlock 是一个Linux下让程序主动限制文件访问能力的安全模块,程序只能访问显式声明的文件。特别注意的是很多底层些的文件比如/usr/bin /usr/share/zoneinfo SQLite 等等,也是需要显式声明的。

use landlock::{
    Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr,
    RulesetCreatedAttr, ABI,
};

fn sandbox() -> Result<(), Box<dyn std::error::Error>> {
    let abi = ABI::V3;

    Ruleset::default()
        .handle_access(AccessFs::from_read(abi))?
        .create()?
        // Allow read-only access to /etc for config files
        .add_rule(PathBeneath::new(PathFd::new("/etc")?, AccessFs::from_read(abi)))?
        // Allow read+write access to /var/data for your app's data
        .add_rule(PathBeneath::new(
            PathFd::new("/var/data")?,
            AccessFs::from_all(abi),
        ))?
        .restrict_self()?;

    Ok(())
}

fn main() {
    sandbox().expect("failed to apply landlock sandbox");

    // Your service starts here.
    // The service is now restricted to /etc (read) and /var/data (read/write)
    // Any attempt to open /tmp, /home, /proc etc. will be denied!
}

限制会作用到整个进程

Drop Privileges and Capabilities

容器里的root也是root,需要小心容器被打穿。不要为了绑到80端口开root,用更高的端口。

Linux capabilities 把权限切细,为了绑定一个端口,可能只需要一个CAP_NET_BIND_SERVICE

Miri

此物在前段时间某篇文章中亦有提及,一个rust UB检测解释器

使用起来很简单:

rustup +nightly component add miri
cargo +nightly miri test

然后测试就会用miri运行

Graceful shutdown handling

粗暴的关进程有这么几个问题:

  • 正在处理的请求中断
  • 数据库写了一半,数据损坏了
  • 部分资源没正确关闭
  • 日志丢失

标准的处理模式是:监听信号,停止接收新请求,完成已有工作,退出。

axum有内置支持,可以直接使用。

一个参考实现,它引入了一个subsystem的概念可以并发运行并持续监听。

use tokio_graceful_shutdown::{SubsystemHandle, Toplevel};

async fn subsys1(subsys: &mut SubsystemHandle) -> Result<()>
{
    log::info!("Subsystem1 started.");
    subsys.on_shutdown_requested().await;
    log::info!("Subsystem1 stopped.");
    Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
    Toplevel::new(async |s: &mut SubsystemHandle| {
        s.start(SubsystemBuilder::new("Subsys1", subsys1))
    })
    .catch_signals()
    .handle_shutdown_requests(Duration::from_millis(1000))
    .await
    .map_err(Into::into)
}

Circuit Breakers (熔断器) for External Dependencies

假设一个外部依赖突然宕机,发过去的请求全都得不到回应,整个系统就很有可能被他拖死。

合适的做法是失败次数(或超时)超过设定阈值后,就认为不可用,直接报错即可,然后在后续的时间里时不时发请求确认是死是活

failsaferecloser 是相关实现的crate

Resource limits

没有边界的资源是failures之源

对一切都显式设置限制,通常来说这包括:

  • 用户输入上界,文件大小、参数长度
  • 请求体
  • 外部调用超时
  • 外部资源的并发连接数
  • 后台任务数量
  • 线程数和连接池大小

一些常用写法:
用户输入 axum DefaultBodyLimit

let app = Router::new()
    .route("/", post(|request: Request| async {}))
    .layer(DefaultBodyLimit::max(1024));

队列深度

use tokio::sync::mpsc;
let (tx, rx) = mpsc::channel::<Job>(1000); // bounded channel, max 1000 pending

超时设置

let client = reqwest::Client::builder()
    .connect_timeout(Duration::from_secs(5))
    .timeout(Duration::from_secs(30))
    .build()?;

Health Checks and Self-healing

理想情况下,一个系统应该能在不需要人为干预的情况下从错误中恢复,health check让load balancers 和 orchestrators 察觉问题并得以做出反应

一个典型的设计需要两个探针,一个验活,一个验功能。这又牵扯很多很多细节,一个简单的实现如下:

use axum::{routing::get, Router, Json};
use serde::Serialize;

/// Status can be "healthy", "degraded", or "unhealthy"
#[derive(Serialize)]
enum Status {
    // Everything is good, all dependencies are healthy
    Healthy,
    // Some dependencies are degraded,
    // but the service can still handle requests
    Degraded,
    // Critical dependencies are down
    // Don't send any traffic
    Unhealthy,
}

/// This is our health status struct,
/// which we will return as JSON from
/// the readiness probe
#[derive(Serialize)]
struct HealthResponse {
    // Health status of the service
    status: Status,
    // Is the database connection healthy?
    database: bool,
    // Is the cache connection healthy?
    cache: bool,
    // What version of the service is running?
    // (Useful for debugging and monitoring.)
    version: &'static str,
}

// Liveness: "Is the process alive?"
// Should always return 200 if the server can respond at all
async fn liveness() -> &'static str {
    "OK"
}

// Readiness: "Can you handle traffic?"
// Check dependencies before saying yes
async fn readiness(
    db: Extension<DbPool>,
    cache: Extension<CachePool>,
) -> Json<HealthResponse> {
    let db_ok = db.ping().await.is_ok();
    let cache_ok = cache.ping().await.is_ok();

    let status = match (db_ok, cache_ok) {
        (true, true) => Status::Healthy,
        (false, false) => Status::Unhealthy,
        _ => Status::Degraded,
    };

    Json(HealthResponse {
        status,
        database: db_ok,
        cache: cache_ok,
        version: env!("CARGO_PKG_VERSION"),
    })
}

let app = Router::new()
    .route("/health/live", get(liveness))
    .route("/health/ready", get(readiness));

相应的kubernetes配置:

livenessProbe:
  httpGet:
    path: /health/live
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10

readinessProbe:
  httpGet:
    path: /health/ready
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 5

Runtime Hardening Tooling

最后,一些工具分享:

  • cargo-fuzz:针对 Rust 代码的模糊测试(Fuzz testing)工具。
  • honggfuzz:另一款支持 Rust 生态的强大模糊测试器。
  • cargo-geiger:用于统计和检测你的项目中(包括依赖树)unsafe 代码的使用情况。
  • cargo-valgrind:在 Rust 代码上运行 Valgrind,用于深度排查内存错误。
  • cargo-llvm-cov:通过 rustc/LLVM 基于源码的插桩机制(-C instrument-coverage)来生成代码覆盖率报告。它支持精确到行和代码块区域的覆盖率统计,并且与 cargo testcargo nextest 完美兼容,是新项目的首推默认选择
  • cargo-tarpaulin:一款较早期的 Rust 代码覆盖率工具,在 Cargo 和持续集成(CI)的易用性上做得非常好。 注意:在 Linux 上,它默认使用 ptrace 后端(仅支持 x86_64 架构);你也可以通过 --engine llvm 标志开启 LLVM 覆盖率(这在 macOS 和 Windows 上是默认选项)。如果它的报告格式契合你的工作流,它会非常顺手,但与 cargo-llvm-cov 相比,它在不同平台或不同的测试运行器下可能会遇到一些边缘情况(Edge cases)。