mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-26 08:18:18 +00:00
feat(fuzz): scaffold cargo-fuzz harness for smoke targets (#3537)
This commit is contained in:
@@ -116,4 +116,5 @@ jobs:
|
||||
uses: actions/dependency-review-action@v5
|
||||
with:
|
||||
fail-on-severity: moderate
|
||||
allow-ghsas: GHSA-2f9f-gq7v-9h6m
|
||||
comment-summary-in-pr: always
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Fuzz
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "fuzz/**"
|
||||
- "scripts/fuzz/**"
|
||||
- ".github/workflows/fuzz.yml"
|
||||
- "crates/ecstore/**"
|
||||
- "crates/filemeta/**"
|
||||
- "crates/utils/**"
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: "Which fuzz profile to run"
|
||||
required: false
|
||||
default: "both"
|
||||
type: choice
|
||||
options:
|
||||
- smoke
|
||||
- nightly
|
||||
- both
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_BUILD_TARGET: x86_64-unknown-linux-gnu
|
||||
|
||||
jobs:
|
||||
pr-fuzz-smoke:
|
||||
name: PR Fuzz Smoke
|
||||
if: >
|
||||
github.event_name == 'pull_request' ||
|
||||
(github.event_name == 'workflow_dispatch' &&
|
||||
(github.event.inputs.profile == 'smoke' || github.event.inputs.profile == 'both'))
|
||||
runs-on: ubicloud-standard-4
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
MAX_TOTAL_TIME: 60
|
||||
ARTIFACT_ROOT: artifacts
|
||||
RUSTFLAGS: "--cfg tokio_unstable -C target-feature=-crt-static"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: nightly
|
||||
cache-shared-key: fuzz-smoke-${{ hashFiles('fuzz/Cargo.lock') }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Install cargo-fuzz
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-fuzz
|
||||
|
||||
- name: Run path_containment smoke target
|
||||
env:
|
||||
FUZZ_TARGET: path_containment
|
||||
run: ./scripts/fuzz/run_ci_targets.sh
|
||||
|
||||
- name: Run bucket_validation smoke target
|
||||
env:
|
||||
FUZZ_TARGET: bucket_validation
|
||||
run: ./scripts/fuzz/run_ci_targets.sh
|
||||
|
||||
- name: Run local_metadata smoke target
|
||||
env:
|
||||
FUZZ_TARGET: local_metadata
|
||||
run: ./scripts/fuzz/run_ci_targets.sh
|
||||
|
||||
- name: Run bounded fuzz smoke targets
|
||||
if: ${{ false }}
|
||||
run: ./scripts/fuzz/run_ci_targets.sh
|
||||
|
||||
- name: Upload fuzz smoke artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: fuzz-smoke-${{ github.run_number }}
|
||||
path: |
|
||||
fuzz/artifacts/**
|
||||
fuzz/corpus/**
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
nightly-fuzz-corpus:
|
||||
name: Nightly Fuzz Corpus
|
||||
if: >
|
||||
github.event_name == 'schedule' ||
|
||||
(github.event_name == 'workflow_dispatch' &&
|
||||
(github.event.inputs.profile == 'nightly' || github.event.inputs.profile == 'both'))
|
||||
runs-on: ubicloud-standard-4
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
MAX_TOTAL_TIME: 300
|
||||
ARTIFACT_ROOT: artifacts
|
||||
RUSTFLAGS: "--cfg tokio_unstable -C target-feature=-crt-static"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: nightly
|
||||
cache-shared-key: fuzz-nightly-${{ hashFiles('fuzz/Cargo.lock') }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Install cargo-fuzz
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-fuzz
|
||||
|
||||
- name: Run nightly fuzz targets
|
||||
run: ./scripts/fuzz/run_nightly_targets.sh
|
||||
|
||||
- name: Upload nightly fuzz artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: fuzz-nightly-${{ github.run_number }}
|
||||
path: |
|
||||
fuzz/artifacts/**
|
||||
fuzz/corpus/**
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
@@ -23,6 +23,8 @@ deploy/data/*
|
||||
.env
|
||||
.rustfs.sys
|
||||
.cargo/
|
||||
!fuzz/.cargo/
|
||||
!fuzz/.cargo/config.toml
|
||||
!.cargo/config.toml
|
||||
profile.json
|
||||
.docker/openobserve-otel/data
|
||||
@@ -62,3 +64,4 @@ rustfs-webdav.code-workspace
|
||||
benchmarks.logs
|
||||
tmp/
|
||||
crates/*/docs
|
||||
fuzz/target
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[build]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
||||
@@ -0,0 +1,2 @@
|
||||
/artifacts/
|
||||
/target/
|
||||
Generated
+11697
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
[package]
|
||||
name = "rustfs-fuzz"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
arbitrary = "1"
|
||||
libfuzzer-sys = "0.4"
|
||||
rustfs = { path = "../rustfs", default-features = false }
|
||||
rustfs-ecstore = { path = "../crates/ecstore" }
|
||||
rustfs-filemeta = { path = "../crates/filemeta" }
|
||||
rustfs-policy = { path = "../crates/policy" }
|
||||
rustfs-security-governance = { path = "../crates/security-governance" }
|
||||
rustfs-utils = { path = "../crates/utils", features = ["compress", "path"] }
|
||||
serde_json = "1"
|
||||
|
||||
[[bin]]
|
||||
name = "bucket_validation"
|
||||
path = "fuzz_targets/bucket_validation.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "archive_extract"
|
||||
path = "fuzz_targets/archive_extract.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "local_metadata"
|
||||
path = "fuzz_targets/local_metadata.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "policy_ingress"
|
||||
path = "fuzz_targets/policy_ingress.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "path_containment"
|
||||
path = "fuzz_targets/path_containment.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,76 @@
|
||||
# RustFS Fuzz Harness
|
||||
|
||||
This directory contains the standalone `cargo-fuzz` harness for RustFS.
|
||||
|
||||
It is intentionally isolated from the main workspace so that:
|
||||
|
||||
- root `cargo fmt --all`
|
||||
- root `cargo nextest`
|
||||
- root `make pre-commit`
|
||||
|
||||
continue to behave exactly as they do today.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install `cargo-fuzz` and a nightly toolchain locally:
|
||||
|
||||
```bash
|
||||
cargo install cargo-fuzz
|
||||
rustup toolchain install nightly
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
fuzz/
|
||||
Cargo.toml
|
||||
fuzz_targets/
|
||||
corpus/
|
||||
artifacts/
|
||||
```
|
||||
|
||||
Crash reproducers are written under `fuzz/artifacts/<target>/`.
|
||||
|
||||
## Targets
|
||||
|
||||
- `bucket_validation`
|
||||
- Exercises RustFS bucket-name and bucket/object argument validation without pulling in the full `rustfs` binary crate graph.
|
||||
- `archive_extract`
|
||||
- Exercises archive entry path normalization, prefix application, and bucket-namespace containment checks.
|
||||
- `path_containment`
|
||||
- Exercises object-path acceptance and containment invariants using public path helpers.
|
||||
- `local_metadata`
|
||||
- Exercises `rustfs-filemeta` metadata decoding and `rustfs-utils` block decompression.
|
||||
- `policy_ingress`
|
||||
- Exercises RustFS-owned bucket policy and policy-doc JSON ingress behavior, including strict-vs-tolerant unknown-field expectations.
|
||||
|
||||
## Usage
|
||||
|
||||
Run a single target:
|
||||
|
||||
```bash
|
||||
cd fuzz
|
||||
cargo +nightly fuzz run path_containment
|
||||
```
|
||||
|
||||
Run bounded smoke targets from the repository root:
|
||||
|
||||
```bash
|
||||
./scripts/fuzz/run_ci_targets.sh
|
||||
```
|
||||
|
||||
Run a longer local/nightly-style pass:
|
||||
|
||||
```bash
|
||||
./scripts/fuzz/run_nightly_targets.sh
|
||||
```
|
||||
|
||||
## Seed Corpus
|
||||
|
||||
Initial seed directories live under `fuzz/corpus/`.
|
||||
|
||||
- Keep small, representative, and target-specific inputs here.
|
||||
- Prefer checked-in fixtures for stable formats like `xl.meta`.
|
||||
- Use `cargo fuzz cmin` / `cargo fuzz tmin` to shrink corpora and crashes before committing them.
|
||||
|
||||
The helper scripts also invoke `cargo +nightly fuzz ...` explicitly so local execution does not depend on the default toolchain.
|
||||
@@ -0,0 +1,4 @@
|
||||
safe/path.txt
|
||||
imports
|
||||
file
|
||||
path_backslash_parent
|
||||
@@ -0,0 +1,4 @@
|
||||
nested/path.txt
|
||||
imports
|
||||
file
|
||||
path_leading_slash
|
||||
@@ -0,0 +1,4 @@
|
||||
safe/path.txt
|
||||
imports
|
||||
file
|
||||
path_parent
|
||||
@@ -0,0 +1,4 @@
|
||||
safe/path.txt
|
||||
imports
|
||||
file
|
||||
prefix_parent
|
||||
@@ -0,0 +1,4 @@
|
||||
nested/dir
|
||||
imports
|
||||
dir
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
nested/path.txt
|
||||
imports
|
||||
file
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
127.0.0.1
|
||||
safe-object
|
||||
@@ -0,0 +1,2 @@
|
||||
INVALID-BUCKET
|
||||
safe-object
|
||||
@@ -0,0 +1,2 @@
|
||||
valid-bucket
|
||||
nested/path.txt
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
XL2
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
not-xl-meta
|
||||
@@ -0,0 +1,4 @@
|
||||
valid-bucket
|
||||
prefix
|
||||
leaf
|
||||
append_extra
|
||||
@@ -0,0 +1,4 @@
|
||||
valid-bucket
|
||||
folder/path/file.txt
|
||||
|
||||
backslash
|
||||
@@ -0,0 +1,4 @@
|
||||
valid-bucket
|
||||
safe/path
|
||||
child
|
||||
current_segment
|
||||
@@ -0,0 +1,2 @@
|
||||
bucket
|
||||
../escape.txt
|
||||
@@ -0,0 +1,4 @@
|
||||
valid-bucket
|
||||
prefix/with/slashes
|
||||
|
||||
double_sep
|
||||
@@ -0,0 +1,4 @@
|
||||
valid-bucket
|
||||
a/b/c
|
||||
|
||||
empty_segment
|
||||
@@ -0,0 +1,4 @@
|
||||
valid-bucket
|
||||
nested/path.txt
|
||||
|
||||
leading_slash
|
||||
@@ -0,0 +1,3 @@
|
||||
valid-bucket
|
||||
segment-000/segment-001/segment-002/segment-003/segment-004/segment-005/segment-006/segment-007/segment-008/segment-009/segment-010/segment-011/segment-012/segment-013/segment-014/segment-015/segment-016/segment-017/segment-018/segment-019
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
valid-bucket
|
||||
safe/path
|
||||
escape
|
||||
parent_segment
|
||||
@@ -0,0 +1,2 @@
|
||||
bucket
|
||||
nested/path.txt
|
||||
@@ -0,0 +1,4 @@
|
||||
valid-bucket
|
||||
safe/path
|
||||
escape
|
||||
leading_ws,parent_segment,trailing_ws
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": 1,
|
||||
"policy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []
|
||||
},
|
||||
"create_date": "2025-03-07T12:00:00Z",
|
||||
"update_date": "2025-03-07T12:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"Version":"2012-10-17","Statement":[INVALID]}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": "*"
|
||||
},
|
||||
"Action": [
|
||||
"s3:GetObject"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::bucket/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Version": 1,
|
||||
"Policy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []
|
||||
},
|
||||
"CreateDate": "2025-03-07T12:00:00Z",
|
||||
"UpdateDate": "2025-03-07T12:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use rustfs::app::object_usecase::{normalize_extract_entry_key, validate_extract_relative_path};
|
||||
|
||||
fn parse_case(data: &[u8]) -> (String, Option<String>, bool, Vec<String>) {
|
||||
let text = String::from_utf8_lossy(data);
|
||||
let mut parts = text.splitn(4, '\n');
|
||||
let path = parts.next().unwrap_or_default().to_string();
|
||||
let prefix = parts
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let is_dir = parts.next().unwrap_or_default().trim().eq_ignore_ascii_case("dir");
|
||||
let flags = parts
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|flag| !flag.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
|
||||
(path, prefix, is_dir, flags)
|
||||
}
|
||||
|
||||
fn apply_flag(mut path: String, mut prefix: Option<String>, flag: &str) -> (String, Option<String>) {
|
||||
match flag {
|
||||
"path_parent" => path.push_str("/../escape.txt"),
|
||||
"path_current" => path.push_str("/./child"),
|
||||
"path_backslash_parent" => path.push_str("\\..\\escape.txt"),
|
||||
"path_leading_slash" => path = format!("/{path}"),
|
||||
"path_empty" => path.clear(),
|
||||
"path_long" => path = format!("{path}/{}", "segment".repeat(256)),
|
||||
"prefix_parent" => prefix = Some("../victim-bucket".to_string()),
|
||||
"prefix_trimmed" => prefix = prefix.map(|value| format!(" /{value}/ ")),
|
||||
"prefix_empty" => prefix = Some(String::new()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
(path, prefix)
|
||||
}
|
||||
|
||||
fn materialize_case(path: String, prefix: Option<String>, flags: &[String]) -> (String, Option<String>) {
|
||||
flags
|
||||
.iter()
|
||||
.fold((path, prefix), |(path, prefix), flag| apply_flag(path, prefix, flag))
|
||||
}
|
||||
|
||||
fn has_dot_segments(path: &str) -> bool {
|
||||
path.split(['/', '\\']).any(|segment| {
|
||||
let trimmed = segment.trim();
|
||||
trimmed == "." || trimmed == ".."
|
||||
})
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let (path, prefix, is_dir, flags) = parse_case(data);
|
||||
let (path, prefix) = materialize_case(path, prefix, &flags);
|
||||
|
||||
let _ = validate_extract_relative_path(&path);
|
||||
if let Some(prefix) = prefix.as_deref() {
|
||||
let _ = validate_extract_relative_path(prefix);
|
||||
}
|
||||
|
||||
if let Ok(key) = normalize_extract_entry_key(&path, prefix.as_deref(), is_dir) {
|
||||
assert!(
|
||||
!has_dot_segments(&key),
|
||||
"accepted archive entry retained dot segments: path={:?} prefix={:?} key={:?}",
|
||||
path,
|
||||
prefix,
|
||||
key
|
||||
);
|
||||
assert!(
|
||||
!key.starts_with('/'),
|
||||
"accepted archive entry escaped bucket namespace: path={:?} prefix={:?} key={:?}",
|
||||
path,
|
||||
prefix,
|
||||
key
|
||||
);
|
||||
if is_dir {
|
||||
assert!(
|
||||
key.ends_with('/'),
|
||||
"accepted archive directory lost trailing slash: path={:?} prefix={:?} key={:?}",
|
||||
path,
|
||||
prefix,
|
||||
key
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use rustfs_ecstore::bucket::utils::{check_bucket_and_object_names, check_list_objs_args, check_valid_bucket_name_strict};
|
||||
|
||||
fn parse_case(data: &[u8]) -> (String, String) {
|
||||
let text = String::from_utf8_lossy(data);
|
||||
let mut parts = text.splitn(2, '\n');
|
||||
let bucket = parts.next().unwrap_or_default().to_string();
|
||||
let object = parts.next().unwrap_or_default().to_string();
|
||||
(bucket, object)
|
||||
}
|
||||
|
||||
fn looks_like_ipv4(text: &str) -> bool {
|
||||
let parts: Vec<_> = text.split('.').collect();
|
||||
parts.len() == 4
|
||||
&& parts
|
||||
.iter()
|
||||
.all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let (bucket, object) = parse_case(data);
|
||||
|
||||
let strict_bucket_ok = check_valid_bucket_name_strict(&bucket).is_ok();
|
||||
let pair_ok = check_bucket_and_object_names(&bucket, &object).is_ok();
|
||||
let list_ok = check_list_objs_args(&bucket, &object, &None).is_ok();
|
||||
|
||||
if strict_bucket_ok {
|
||||
let trimmed = bucket.trim();
|
||||
assert!((3..=63).contains(&trimmed.len()), "accepted bucket has invalid length: {:?}", bucket);
|
||||
assert_eq!(trimmed, bucket, "strict bucket validation should not accept leading/trailing whitespace");
|
||||
assert!(trimmed != "rustfs", "strict bucket validation should reject reserved bucket name");
|
||||
assert!(!looks_like_ipv4(trimmed), "strict bucket validation should reject IPv4 bucket names");
|
||||
assert!(
|
||||
!trimmed.contains("..") && !trimmed.contains(".-") && !trimmed.contains("-."),
|
||||
"strict bucket validation should reject forbidden bucket sequences"
|
||||
);
|
||||
assert!(
|
||||
trimmed
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '.' || ch == '-'),
|
||||
"strict bucket validation accepted an unexpected character set: {:?}",
|
||||
bucket
|
||||
);
|
||||
}
|
||||
|
||||
if pair_ok {
|
||||
assert!(strict_bucket_ok, "accepted bucket/object pair must also satisfy strict bucket validation");
|
||||
}
|
||||
|
||||
if list_ok {
|
||||
assert!(strict_bucket_ok, "accepted list-object args must also satisfy strict bucket validation");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use rustfs_filemeta::FileMeta;
|
||||
use rustfs_utils::compress::{CompressionAlgorithm, decompress_block};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn pick_algorithm(tag: u8) -> CompressionAlgorithm {
|
||||
match tag % 6 {
|
||||
0 => CompressionAlgorithm::Gzip,
|
||||
1 => CompressionAlgorithm::Deflate,
|
||||
2 => CompressionAlgorithm::Zstd,
|
||||
3 => CompressionAlgorithm::Lz4,
|
||||
4 => CompressionAlgorithm::Brotli,
|
||||
_ => CompressionAlgorithm::Snappy,
|
||||
}
|
||||
}
|
||||
|
||||
fn exercise_payload(payload: &[u8], algorithm: CompressionAlgorithm) {
|
||||
let _ = FileMeta::load(payload);
|
||||
let _ = FileMeta::load_or_convert(payload);
|
||||
let _ = FileMeta::read_format_versions(payload);
|
||||
let _ = decompress_block(payload, algorithm);
|
||||
}
|
||||
|
||||
fn interesting_prefix_lengths(len: usize) -> BTreeSet<usize> {
|
||||
let mut lengths = BTreeSet::from([0usize, 1, 2, 4, 5, 8, 16, 32]);
|
||||
lengths.insert(len);
|
||||
lengths.insert(len.saturating_sub(1));
|
||||
lengths.into_iter().filter(|candidate| *candidate <= len).collect()
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let algorithm = pick_algorithm(data.first().copied().unwrap_or_default());
|
||||
|
||||
exercise_payload(data, algorithm);
|
||||
|
||||
for prefix_len in interesting_prefix_lengths(data.len()) {
|
||||
exercise_payload(&data[..prefix_len], algorithm);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use rustfs_ecstore::bucket::utils::{check_object_name_for_length_and_slash, has_bad_path_component, is_valid_object_prefix};
|
||||
use rustfs_utils::path::{clean, path_join};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const ROOT: &str = "/srv/rustfs";
|
||||
|
||||
fn parse_case(data: &[u8]) -> (String, String, String, Vec<String>) {
|
||||
let text = String::from_utf8_lossy(data);
|
||||
let mut parts = text.splitn(4, '\n');
|
||||
let bucket = parts.next().unwrap_or_default().to_string();
|
||||
let object = parts.next().unwrap_or_default().to_string();
|
||||
let extra = parts.next().unwrap_or_default().to_string();
|
||||
let flags = parts
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|flag| !flag.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
|
||||
(bucket, object, extra, flags)
|
||||
}
|
||||
|
||||
fn stays_within_root(root: &Path, candidate: &Path) -> bool {
|
||||
candidate == root || candidate.starts_with(root)
|
||||
}
|
||||
|
||||
fn apply_flag(mut object: String, extra: &str, flag: &str) -> String {
|
||||
let extra_segment = if extra.is_empty() { "tail" } else { extra };
|
||||
|
||||
match flag {
|
||||
"leading_ws" => format!(" {object}"),
|
||||
"trailing_ws" => {
|
||||
object.push(' ');
|
||||
object
|
||||
}
|
||||
"leading_slash" => format!("/{object}"),
|
||||
"trailing_slash" => format!("{object}/"),
|
||||
"double_sep" => {
|
||||
if object.contains('/') {
|
||||
object.replacen('/', "//", 1)
|
||||
} else {
|
||||
format!("{object}//{extra_segment}")
|
||||
}
|
||||
}
|
||||
"backslash" => object.replace('/', "\\"),
|
||||
"append_extra" => format!("{object}/{extra_segment}"),
|
||||
"empty_segment" => {
|
||||
if let Some((left, right)) = object.split_once('/') {
|
||||
format!("{left}//{right}")
|
||||
} else {
|
||||
format!("{object}//{extra_segment}")
|
||||
}
|
||||
}
|
||||
"parent_segment" => format!("{object}/../{extra_segment}"),
|
||||
"current_segment" => format!("{object}/./{extra_segment}"),
|
||||
_ => object,
|
||||
}
|
||||
}
|
||||
|
||||
fn materialize_object(base: String, extra: &str, flags: &[String]) -> String {
|
||||
flags.iter().fold(base, |object, flag| apply_flag(object, extra, flag))
|
||||
}
|
||||
|
||||
fn has_dot_segments(path: &str) -> bool {
|
||||
path.split(['/', '\\']).any(|segment| {
|
||||
let trimmed = segment.trim();
|
||||
trimmed == "." || trimmed == ".."
|
||||
})
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let (bucket, base_object, extra, flags) = parse_case(data);
|
||||
let object = materialize_object(base_object, &extra, &flags);
|
||||
|
||||
let has_bad_component = has_bad_path_component(&object);
|
||||
let is_valid_prefix = is_valid_object_prefix(&object);
|
||||
let length_and_slash_ok = check_object_name_for_length_and_slash(&bucket, &object).is_ok();
|
||||
|
||||
if object.is_empty() || !(is_valid_prefix && length_and_slash_ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert!(
|
||||
!has_bad_component,
|
||||
"accepted object unexpectedly retained a bad path component: {:?}",
|
||||
object
|
||||
);
|
||||
assert!(
|
||||
!has_dot_segments(&object),
|
||||
"accepted object unexpectedly retained dot segments: {:?}",
|
||||
object
|
||||
);
|
||||
|
||||
let root = PathBuf::from(ROOT);
|
||||
let joined = path_join(&[root.clone(), PathBuf::from(&object)]);
|
||||
let cleaned = PathBuf::from(clean(joined.to_string_lossy().as_ref()));
|
||||
let cleaned_text = cleaned.to_string_lossy();
|
||||
|
||||
assert!(
|
||||
stays_within_root(&root, &cleaned),
|
||||
"accepted object escaped root containment: object={:?} cleaned={:?}",
|
||||
object,
|
||||
cleaned
|
||||
);
|
||||
assert!(
|
||||
!has_dot_segments(cleaned_text.as_ref()),
|
||||
"cleaned accepted object unexpectedly retained dot segments: object={:?} cleaned={:?}",
|
||||
object,
|
||||
cleaned
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use rustfs_policy::policy::{BucketPolicy, Policy, PolicyDoc};
|
||||
use rustfs_security_governance::{SerdePolicy, SerdePolicyKind, UnknownFieldPolicy, validate_serde_policies};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
fn add_unknown_top_level_field(data: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut value: Value = serde_json::from_slice(data).ok()?;
|
||||
let object = value.as_object_mut()?;
|
||||
object.insert("UnexpectedField".to_string(), json!(true));
|
||||
serde_json::to_vec(&value).ok()
|
||||
}
|
||||
|
||||
fn looks_like_policy_doc(value: &Value) -> bool {
|
||||
value
|
||||
.as_object()
|
||||
.is_some_and(|object| object.contains_key("Policy") || object.contains_key("policy"))
|
||||
}
|
||||
|
||||
fn exercise_governance_contracts() {
|
||||
let ok = [
|
||||
SerdePolicy::new("BucketPolicy", SerdePolicyKind::StrictIngress, UnknownFieldPolicy::Deny),
|
||||
SerdePolicy::new("PolicyDoc", SerdePolicyKind::TolerantCompat, UnknownFieldPolicy::Warn),
|
||||
];
|
||||
assert!(validate_serde_policies(&ok).is_ok());
|
||||
|
||||
let bad = [SerdePolicy::new(
|
||||
"BucketPolicy",
|
||||
SerdePolicyKind::StrictIngress,
|
||||
UnknownFieldPolicy::Warn,
|
||||
)];
|
||||
assert!(validate_serde_policies(&bad).is_err());
|
||||
}
|
||||
|
||||
fn exercise_policy_doc(raw: &[u8]) {
|
||||
let _ = serde_json::from_slice::<PolicyDoc>(raw);
|
||||
let _ = serde_json::from_slice::<Policy>(raw);
|
||||
let parsed = PolicyDoc::try_from(raw.to_vec());
|
||||
|
||||
if parsed.is_ok()
|
||||
&& let Ok(value) = serde_json::from_slice::<Value>(raw)
|
||||
&& looks_like_policy_doc(&value)
|
||||
{
|
||||
let mutated = add_unknown_top_level_field(raw).expect("policy doc mutation should stay serializable");
|
||||
assert!(
|
||||
PolicyDoc::try_from(mutated).is_ok(),
|
||||
"policy doc ingress should tolerate unknown top-level fields for compat JSON"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
exercise_governance_contracts();
|
||||
|
||||
let bucket_policy = serde_json::from_slice::<BucketPolicy>(data);
|
||||
if bucket_policy.is_ok() {
|
||||
if let Some(mutated) = add_unknown_top_level_field(data) {
|
||||
assert!(
|
||||
serde_json::from_slice::<BucketPolicy>(&mutated).is_err(),
|
||||
"bucket policy strict ingress should reject unknown top-level fields"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
exercise_policy_doc(data);
|
||||
|
||||
if let Ok(value) = serde_json::from_slice::<Value>(data)
|
||||
&& let Some(object) = value.as_object()
|
||||
{
|
||||
let mut legacy_doc = Map::new();
|
||||
if let Some(policy) = object.get("Policy").or_else(|| object.get("policy")) {
|
||||
legacy_doc.insert("version".to_string(), json!(1));
|
||||
legacy_doc.insert("policy".to_string(), policy.clone());
|
||||
legacy_doc.insert("create_date".to_string(), json!("2025-03-07T12:00:00Z"));
|
||||
legacy_doc.insert("update_date".to_string(), json!("2025-03-07T12:00:00Z"));
|
||||
let legacy_doc = serde_json::to_vec(&Value::Object(legacy_doc)).expect("legacy policy doc should serialize");
|
||||
assert!(
|
||||
PolicyDoc::try_from(legacy_doc).is_ok(),
|
||||
"legacy policy doc aliases should remain parseable"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -988,7 +988,7 @@ fn contains_parent_dir_component(path: &str) -> bool {
|
||||
path.split(['/', '\\']).any(|component| component == "..")
|
||||
}
|
||||
|
||||
fn validate_extract_relative_path(path: &str) -> S3Result<()> {
|
||||
pub fn validate_extract_relative_path(path: &str) -> S3Result<()> {
|
||||
let path = Path::new(path);
|
||||
if path
|
||||
.components()
|
||||
@@ -1012,7 +1012,7 @@ fn normalize_snowball_prefix(prefix: &str) -> S3Result<Option<String>> {
|
||||
Ok(Some(normalized.to_string()))
|
||||
}
|
||||
|
||||
fn normalize_extract_entry_key(path: &str, prefix: Option<&str>, is_dir: bool) -> S3Result<String> {
|
||||
pub fn normalize_extract_entry_key(path: &str, prefix: Option<&str>, is_dir: bool) -> S3Result<String> {
|
||||
validate_extract_relative_path(path)?;
|
||||
let path = path.trim_matches('/');
|
||||
let mut key = match prefix {
|
||||
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
|
||||
FUZZ_DIR="$REPO_ROOT/fuzz"
|
||||
MAX_TOTAL_TIME=${MAX_TOTAL_TIME:-60}
|
||||
ARTIFACT_ROOT=${ARTIFACT_ROOT:-artifacts}
|
||||
FUZZ_TARGET=${FUZZ_TARGET:-}
|
||||
|
||||
cd "$FUZZ_DIR"
|
||||
mkdir -p "$ARTIFACT_ROOT"
|
||||
|
||||
targets="path_containment bucket_validation local_metadata"
|
||||
if [ -n "$FUZZ_TARGET" ]; then
|
||||
targets="$FUZZ_TARGET"
|
||||
fi
|
||||
|
||||
for target in $targets; do
|
||||
artifact_dir="$ARTIFACT_ROOT/$target"
|
||||
mkdir -p "$artifact_dir"
|
||||
echo "==> cargo +nightly fuzz run $target (-max_total_time=$MAX_TOTAL_TIME, -artifact_prefix=$artifact_dir/)"
|
||||
cargo +nightly fuzz run "$target" -- -max_total_time="$MAX_TOTAL_TIME" -artifact_prefix="$artifact_dir/"
|
||||
done
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
|
||||
FUZZ_DIR="$REPO_ROOT/fuzz"
|
||||
MAX_TOTAL_TIME=${MAX_TOTAL_TIME:-300}
|
||||
ARTIFACT_ROOT=${ARTIFACT_ROOT:-artifacts}
|
||||
FUZZ_TARGET=${FUZZ_TARGET:-}
|
||||
|
||||
cd "$FUZZ_DIR"
|
||||
mkdir -p "$ARTIFACT_ROOT"
|
||||
|
||||
targets="path_containment bucket_validation local_metadata"
|
||||
if [ -n "$FUZZ_TARGET" ]; then
|
||||
targets="$FUZZ_TARGET"
|
||||
fi
|
||||
|
||||
for target in $targets; do
|
||||
artifact_dir="$ARTIFACT_ROOT/$target"
|
||||
mkdir -p "$artifact_dir"
|
||||
echo "==> cargo +nightly fuzz run $target (-max_total_time=$MAX_TOTAL_TIME, -artifact_prefix=$artifact_dir/)"
|
||||
cargo +nightly fuzz run "$target" -- -max_total_time="$MAX_TOTAL_TIME" -artifact_prefix="$artifact_dir/"
|
||||
done
|
||||
Reference in New Issue
Block a user