mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-17 10:17:55 +00:00
f17ea7f146
* fix(heal): gate auto replacement formatting Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): require replacement target outcomes Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): bind resumes to replacement targets Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): fence healing marker ownership Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): cover replacement target completion Co-Authored-By: heihutu <heihutu@gmail.com> * docs(heal): clarify replacement recovery status Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): canonicalize replacement target checks Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): satisfy marker test module lint Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): scope automatic replacement format Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): require a mounted replacement target Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): avoid cloned ref slice in test Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): revalidate replacement before scanning Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): reset stale resume checkpoints Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): release scanner disk map before probing Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): persist replacement intent before format Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): fail closed on mountinfo read errors Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): fence replacement target identity Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): order replacement completion cleanup Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): atomically seal replacement completion Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): census replacement target shards Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): fence replacement recovery ownership Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): preserve replacement recovery anchors Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): satisfy replacement recovery lint gates Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): bind replacement identity to mount lease Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): cover durable replacement recovery states Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): validate persisted resume task identifiers Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): avoid blocking replacement marker CAS Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): report failed marker rollback Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): pin replacement resume schema compatibility Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): preserve durable recovery anchors Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): preserve public disk path semantics Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): use canonical replacement task ids Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): cover automatic replacement in 3x4 cluster Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): verify replacement target commits Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): persist replacement completion proof Co-Authored-By: heihutu <heihutu@gmail.com> * feat(heal): expose durable replacement status Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): bound durable replacement discovery Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): remove replacement readiness bypass Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): retry terminal replacement cleanup Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): isolate replacement intents from legacy resume Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): migrate legacy replacement intents at startup Co-Authored-By: heihutu <heihutu@gmail.com> * style(heal): apply strict clippy fix Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): prioritize active replacement recovery state Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): bind readiness to the admitted mount lease Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): atomically publish replacement intents Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): isolate replacement recovery directory Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): tolerate an empty recovery directory Co-Authored-By: heihutu <heihutu@gmail.com> * style(heal): remove redundant disk bytes conversion Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): reconcile proof-first replacement recovery Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): fence torn intent recovery Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): cover replacement migration conflicts Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): fence replacement lease mount identity Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): cover missing replacement path admission Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): reject conflicting legacy completion proof Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): fall back to proc mount identity Co-Authored-By: heihutu <heihutu@gmail.com> * feat(admin): expose replacement recovery status Surface the local durable replacement recovery snapshot in the background heal status response so operators can tell whether replacement cleanup is definitive or still pending. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): keep replacement status compatible Keep the existing background heal status response wire-compatible while retaining the Linux mount lease cleanup needed for the replacement recovery branch. Co-Authored-By: heihutu <heihutu@gmail.com> * style(ecstore): match linux mount lease formatting Keep Linux rustfmt output stable for the replacement mount lease comparison. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): qualify mount lease test constant Use the disk module path for the format config constant in the Linux mount lease regression test. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): keep procfd mount roots directory-safe Use a procfd path with an explicit directory component so Unix directory guards can open the replacement mount lease root with O_NOFOLLOW while preserving handle-relative I/O semantics. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): delete empty leased buckets via dirfd Use the held mount lease fd as the parent for non-force empty bucket deletion on Linux so procfd-rooted paths do not get rejected as BucketNotEmpty. Also make the download-part OpenOptions truncate behavior explicit and keep fsync test recording stable across procfd canonicalization. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): scan leased bucket paths for emptiness Use the local disk I/O root for bucket emptiness probes before non-force bucket deletion and table-bucket metadata checks. This keeps validation on the same mount instance as the subsequent local disk delete path. Co-Authored-By: heihutu <heihutu@gmail.com> * test(ecstore): align lease path test probes Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): block unsafe replacement recovery restarts Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): defer blocked replacement candidates Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): retry transient replacement discovery Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): keep transient recovery errors retryable Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): block corrupt legacy replacement state Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): classify flat replacement intent corruption Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): keep transient resume loads retryable Classify malformed legacy replacement state as blocking corruption while preserving disk and transient load failures for retry. This avoids permanently blocking replacement recovery on temporary storage errors. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): avoid latching transient legacy publishes Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): retry blocked legacy migrations Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): defer blocked startup recoveries Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): preserve disk sync limiter across lease roots Co-Authored-By: heihutu <heihutu@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> Co-authored-by: zhi22915 <qiuzgang@gmail.com>
553 lines
18 KiB
Rust
553 lines
18 KiB
Rust
// 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.
|
|
|
|
use super::{DiskInfo, IOStats, fs_type::get_fs_type};
|
|
use rustix::fs::statfs;
|
|
use std::collections::BTreeSet;
|
|
use std::fs;
|
|
use std::fs::File;
|
|
use std::io::{self, BufRead, Error, ErrorKind, Read};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Mutex, OnceLock};
|
|
use tracing::warn;
|
|
|
|
static BAVAIL_GT_BFREE_WARNING_PATHS: OnceLock<Mutex<BTreeSet<PathBuf>>> = OnceLock::new();
|
|
|
|
/// Returns total and free bytes available in a directory, e.g. `/`.
|
|
pub fn get_info(p: impl AsRef<Path>) -> std::io::Result<DiskInfo> {
|
|
// Use statfs on Linux to get access to f_type (filesystem magic number)
|
|
let stat = statfs(p.as_ref())?;
|
|
|
|
// Linux statfs:
|
|
// f_bsize: Optimal transfer block size
|
|
// f_blocks: Total data blocks in file system
|
|
// f_frsize: Fragment size (since Linux 2.6) - unit for blocks
|
|
//
|
|
// If f_frsize is > 0, it is the unit for f_blocks, f_bfree, f_bavail.
|
|
// Otherwise f_bsize is used.
|
|
let bsize = if stat.f_frsize > 0 {
|
|
stat.f_frsize as u64
|
|
} else {
|
|
stat.f_bsize as u64
|
|
};
|
|
|
|
let bfree = stat.f_bfree as u64;
|
|
let bavail = stat.f_bavail as u64;
|
|
let blocks = stat.f_blocks as u64;
|
|
let (total, free, used) = calculate_space_usage(blocks, bfree, bavail, bsize, p.as_ref())?;
|
|
|
|
let st = rustix::fs::stat(p.as_ref())?;
|
|
|
|
Ok(DiskInfo {
|
|
total,
|
|
free,
|
|
used,
|
|
files: stat.f_files as u64,
|
|
ffree: stat.f_ffree as u64,
|
|
fstype: get_fs_type(stat.f_type as u64).to_string(),
|
|
major: rustix::fs::major(st.st_dev) as u64,
|
|
minor: rustix::fs::minor(st.st_dev) as u64,
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
fn calculate_space_usage(blocks: u64, bfree: u64, bavail: u64, bsize: u64, path: &Path) -> std::io::Result<(u64, u64, u64)> {
|
|
let available = if bfree < bavail {
|
|
if should_warn_bavail_greater_than_bfree(path) {
|
|
warn!(
|
|
path = %path.display(),
|
|
f_bfree = bfree,
|
|
f_bavail = bavail,
|
|
"detected f_bavail greater than f_bfree, capping available blocks to f_bfree for compatibility"
|
|
);
|
|
}
|
|
bfree
|
|
} else {
|
|
bavail
|
|
};
|
|
let reserved = bfree - available;
|
|
|
|
let total = match blocks.checked_sub(reserved) {
|
|
Some(total) => total * bsize,
|
|
None => {
|
|
return Err(Error::other(format!(
|
|
"detected reserved space ({reserved}) > blocks space ({blocks}), fs corruption at ({}). please run 'fsck'",
|
|
path.display(),
|
|
)));
|
|
}
|
|
};
|
|
|
|
let free = available * bsize;
|
|
let used = match total.checked_sub(free) {
|
|
Some(used) => used,
|
|
None => {
|
|
return Err(Error::other(format!(
|
|
"detected free space ({free}) > total drive space ({total}), fs corruption at ({}). please run 'fsck'",
|
|
path.display(),
|
|
)));
|
|
}
|
|
};
|
|
|
|
Ok((total, free, used))
|
|
}
|
|
|
|
fn should_warn_bavail_greater_than_bfree(path: &Path) -> bool {
|
|
let warned_paths = BAVAIL_GT_BFREE_WARNING_PATHS.get_or_init(|| Mutex::new(BTreeSet::new()));
|
|
let mut warned_paths = match warned_paths.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
};
|
|
if warned_paths.contains(path) {
|
|
false
|
|
} else {
|
|
warned_paths.insert(path.to_path_buf())
|
|
}
|
|
}
|
|
|
|
pub fn same_disk(disk1: &str, disk2: &str) -> std::io::Result<bool> {
|
|
let stat1 = rustix::fs::stat(disk1)?;
|
|
let stat2 = rustix::fs::stat(disk2)?;
|
|
|
|
Ok(stat1.st_dev == stat2.st_dev)
|
|
}
|
|
|
|
/// Return whether `path` is an exact Linux mount point.
|
|
///
|
|
/// Device numbers alone are insufficient here: bind mounts may deliberately
|
|
/// share a device number with their source. Auto-format callers use this to
|
|
/// refuse an unmounted mountpoint directory rather than writing into its
|
|
/// parent filesystem.
|
|
pub fn is_mount_point(path: &Path) -> std::io::Result<bool> {
|
|
let metadata = fs::symlink_metadata(path)?;
|
|
if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() {
|
|
return Ok(false);
|
|
}
|
|
|
|
let canonical_path = fs::canonicalize(path)?;
|
|
let mountinfo = fs::read_to_string("/proc/self/mountinfo")?;
|
|
let canonical_path = canonical_path.to_string_lossy().replace(' ', "\\040");
|
|
Ok(mountinfo
|
|
.lines()
|
|
.any(|line| mountinfo_path(line) == Some(canonical_path.as_ref())))
|
|
}
|
|
|
|
fn mountinfo_path(line: &str) -> Option<&str> {
|
|
let mut fields = line.split_whitespace();
|
|
for _ in 0..4 {
|
|
fields.next()?;
|
|
}
|
|
fields.next()
|
|
}
|
|
|
|
/// Resolve the leaf physical device identities backing a local filesystem path.
|
|
///
|
|
/// Linux block stacks such as partitions, `dm-*`, or software RAID can all
|
|
/// expose a filesystem through an intermediate device node. This helper walks
|
|
/// sysfs until it reaches the leaf backing devices so the caller can compare
|
|
/// physical failure domains instead of only filesystem device numbers.
|
|
pub fn get_physical_device_ids(disk: &str) -> std::io::Result<Vec<String>> {
|
|
let stat = rustix::fs::stat(disk)?;
|
|
let major = rustix::fs::major(stat.st_dev) as u64;
|
|
let minor = rustix::fs::minor(stat.st_dev) as u64;
|
|
let devices = resolve_block_device_ids(major, minor)?;
|
|
|
|
Ok(devices.into_iter().collect())
|
|
}
|
|
|
|
fn resolve_block_device_ids(major: u64, minor: u64) -> std::io::Result<BTreeSet<String>> {
|
|
let sysfs_path = PathBuf::from(format!("/sys/dev/block/{major}:{minor}"));
|
|
let resolved = match fs::canonicalize(&sysfs_path) {
|
|
Ok(path) => path,
|
|
Err(err) if err.kind() == ErrorKind::NotFound => {
|
|
return Ok(BTreeSet::from([format!("{major}:{minor}")]));
|
|
}
|
|
Err(err) => return Err(err),
|
|
};
|
|
let devices = collect_block_device_ids(&resolved)?;
|
|
|
|
if devices.is_empty() {
|
|
Ok(BTreeSet::from([format!("{major}:{minor}")]))
|
|
} else {
|
|
Ok(devices)
|
|
}
|
|
}
|
|
|
|
fn collect_block_device_ids(device_path: &Path) -> std::io::Result<BTreeSet<String>> {
|
|
let mut ids = BTreeSet::new();
|
|
let slaves_dir = device_path.join("slaves");
|
|
|
|
match fs::read_dir(&slaves_dir) {
|
|
Ok(entries) => {
|
|
let mut found_slave = false;
|
|
for entry in entries {
|
|
let entry = entry?;
|
|
found_slave = true;
|
|
let resolved = fs::canonicalize(entry.path())?;
|
|
ids.extend(collect_block_device_ids(&resolved)?);
|
|
}
|
|
|
|
if found_slave {
|
|
return Ok(ids);
|
|
}
|
|
}
|
|
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
|
Err(err) => return Err(err),
|
|
}
|
|
|
|
ids.insert(normalize_block_device_name(device_path));
|
|
|
|
Ok(ids)
|
|
}
|
|
|
|
fn normalize_block_device_name(device_path: &Path) -> String {
|
|
if device_path.join("partition").exists()
|
|
&& let Some(parent_name) = device_path.parent().and_then(|parent| parent.file_name())
|
|
{
|
|
return parent_name.to_string_lossy().into_owned();
|
|
}
|
|
|
|
device_path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| device_path.display().to_string())
|
|
}
|
|
|
|
/// Check whether any configured export path contains nested mount points.
|
|
///
|
|
/// This mirrors the intent of MinIO's cross-device mount guardrail: once an
|
|
/// export path is selected, RustFS should not silently traverse into child
|
|
/// mount points hosted by other devices.
|
|
pub fn check_cross_device_mounts(paths: &[String]) -> std::io::Result<()> {
|
|
check_cross_device_mounts_with_reader(paths, File::open("/proc/mounts")?)
|
|
}
|
|
|
|
/// Parse `/proc/mounts`-style content and validate each export path against it.
|
|
fn check_cross_device_mounts_with_reader(paths: &[String], mut reader: impl Read) -> std::io::Result<()> {
|
|
let mut content = String::new();
|
|
reader.read_to_string(&mut content)?;
|
|
let mount_paths = parse_mount_paths(&content);
|
|
|
|
for path in paths {
|
|
ensure_no_sub_mounts(path, &mount_paths)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Extract mount paths from `/proc/mounts` content, decoding escaped spaces.
|
|
fn parse_mount_paths(content: &str) -> Vec<String> {
|
|
content
|
|
.lines()
|
|
.filter_map(|line| {
|
|
let fields = line.split_whitespace().collect::<Vec<_>>();
|
|
if fields.len() != 6 {
|
|
return None;
|
|
}
|
|
|
|
Some(fields[1].replace("\\040", " "))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Validate that `path` does not contain nested child mount points.
|
|
fn ensure_no_sub_mounts(path: &str, mount_paths: &[String]) -> std::io::Result<()> {
|
|
if !Path::new(path).is_absolute() {
|
|
return Err(Error::new(
|
|
ErrorKind::InvalidInput,
|
|
format!("Invalid argument, path ({path}) is expected to be absolute"),
|
|
));
|
|
}
|
|
if path == "/" {
|
|
return Err(Error::new(
|
|
ErrorKind::InvalidInput,
|
|
"Invalid argument, path (/) cannot be the filesystem root for export validation",
|
|
));
|
|
}
|
|
|
|
let base = normalize_mount_path(path);
|
|
let mut cross_mounts = Vec::new();
|
|
|
|
for mount_path in mount_paths {
|
|
let mount_base = normalize_mount_path(mount_path);
|
|
if mount_base.starts_with(&base) && mount_base != base {
|
|
cross_mounts.push(mount_path.clone());
|
|
}
|
|
}
|
|
|
|
if cross_mounts.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
cross_mounts.sort();
|
|
cross_mounts.dedup();
|
|
|
|
Err(Error::other(format!(
|
|
"Nested mount points detected under path ({path}) at the following locations: {}. Export path should not have any sub-mounts, refusing to start.",
|
|
cross_mounts.join(", ")
|
|
)))
|
|
}
|
|
|
|
/// Normalize mount paths so prefix checks treat `/a/b` and `/a/b/` identically.
|
|
fn normalize_mount_path(path: &str) -> String {
|
|
let trimmed = path.trim_end_matches('/');
|
|
if trimmed.is_empty() {
|
|
"/".to_string()
|
|
} else {
|
|
format!("{trimmed}/")
|
|
}
|
|
}
|
|
|
|
pub fn get_drive_stats(major: u32, minor: u32) -> std::io::Result<IOStats> {
|
|
read_drive_stats(&format!("/sys/dev/block/{major}:{minor}/stat"))
|
|
}
|
|
|
|
fn read_drive_stats(stats_file: &str) -> std::io::Result<IOStats> {
|
|
let stats = read_stat(stats_file)?;
|
|
if stats.len() < 11 {
|
|
return Err(Error::new(
|
|
ErrorKind::InvalidData,
|
|
format!("found invalid format while reading {stats_file}"),
|
|
));
|
|
}
|
|
let mut io_stats = IOStats {
|
|
read_ios: stats[0],
|
|
read_merges: stats[1],
|
|
read_sectors: stats[2],
|
|
read_ticks: stats[3],
|
|
write_ios: stats[4],
|
|
write_merges: stats[5],
|
|
write_sectors: stats[6],
|
|
write_ticks: stats[7],
|
|
current_ios: stats[8],
|
|
total_ticks: stats[9],
|
|
req_ticks: stats[10],
|
|
..Default::default()
|
|
};
|
|
|
|
if stats.len() > 14 {
|
|
io_stats.discard_ios = stats[11];
|
|
io_stats.discard_merges = stats[12];
|
|
io_stats.discard_sectors = stats[13];
|
|
io_stats.discard_ticks = stats[14];
|
|
}
|
|
Ok(io_stats)
|
|
}
|
|
|
|
fn read_stat(file_name: &str) -> std::io::Result<Vec<u64>> {
|
|
// Open file
|
|
let path = Path::new(file_name);
|
|
let file = File::open(path)?;
|
|
|
|
// Create a BufReader
|
|
let reader = io::BufReader::new(file);
|
|
|
|
// Read first line
|
|
let mut stats = Vec::new();
|
|
if let Some(line) = reader.lines().next() {
|
|
let line = line?;
|
|
// Split line and parse as u64
|
|
// https://rust-lang.github.io/rust-clippy/master/index.html#trim_split_whitespace
|
|
for token in line.split_whitespace() {
|
|
let ui64: u64 = token
|
|
.parse()
|
|
.map_err(|e| Error::new(ErrorKind::InvalidData, format!("failed to parse '{token}' as u64: {e}")))?;
|
|
stats.push(ui64);
|
|
}
|
|
}
|
|
|
|
Ok(stats)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn normalize_partition_device_to_parent_disk() {
|
|
let dir = tempdir().unwrap();
|
|
let block = dir.path().join("block");
|
|
let disk = block.join("nvme0n1");
|
|
let partition = disk.join("nvme0n1p1");
|
|
let slaves = partition.join("slaves");
|
|
|
|
fs::create_dir_all(&slaves).unwrap();
|
|
fs::write(partition.join("partition"), "1").unwrap();
|
|
|
|
let ids = collect_block_device_ids(&partition).unwrap();
|
|
assert_eq!(ids.into_iter().collect::<Vec<_>>(), vec!["nvme0n1".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn flatten_device_mapper_slaves_to_leaf_devices() {
|
|
let dir = tempdir().unwrap();
|
|
let block = dir.path().join("block");
|
|
let dm = block.join("dm-0");
|
|
let dm_slaves = dm.join("slaves");
|
|
let nvme0 = block.join("nvme0n1");
|
|
let nvme1 = block.join("nvme1n1");
|
|
|
|
fs::create_dir_all(&dm_slaves).unwrap();
|
|
fs::create_dir_all(&nvme0).unwrap();
|
|
fs::create_dir_all(&nvme1).unwrap();
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
std::os::unix::fs::symlink(&nvme0, dm_slaves.join("nvme0n1")).unwrap();
|
|
std::os::unix::fs::symlink(&nvme1, dm_slaves.join("nvme1n1")).unwrap();
|
|
}
|
|
|
|
let ids = collect_block_device_ids(&dm).unwrap();
|
|
assert_eq!(ids.into_iter().collect::<Vec<_>>(), vec!["nvme0n1".to_string(), "nvme1n1".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_cross_device_sub_mounts() {
|
|
let mounts = "\
|
|
/dev/root / ext4 rw 0 0
|
|
/dev/sdb1 /data ext4 rw 0 0
|
|
/dev/sdc1 /data/disk1/sub ext4 rw 0 0
|
|
";
|
|
|
|
let err = check_cross_device_mounts_with_reader(&["/data/disk1".to_string()], mounts.as_bytes()).unwrap_err();
|
|
assert!(err.to_string().contains("Nested mount points detected under path"));
|
|
assert!(err.to_string().contains("/data/disk1/sub"));
|
|
}
|
|
|
|
#[test]
|
|
fn allow_mount_path_without_sub_mounts() {
|
|
let mounts = "\
|
|
/dev/root / ext4 rw 0 0
|
|
/dev/sdb1 /data/disk1 ext4 rw 0 0
|
|
";
|
|
|
|
check_cross_device_mounts_with_reader(&["/data/disk1".to_string()], mounts.as_bytes()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn parse_mount_paths_decodes_escaped_spaces() {
|
|
let mounts = "/dev/sdb1 /data/my\\040disk ext4 rw 0 0\n";
|
|
|
|
let paths = parse_mount_paths(mounts);
|
|
assert_eq!(paths, vec!["/data/my disk".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn mountinfo_path_extracts_the_mountpoint() {
|
|
let line = "42 31 8:1 / /data/replacement rw,relatime - ext4 /dev/sda1 rw";
|
|
|
|
assert_eq!(mountinfo_path(line), Some("/data/replacement"));
|
|
}
|
|
|
|
#[test]
|
|
fn reject_relative_path_for_cross_device_validation() {
|
|
let err = ensure_no_sub_mounts("relative/path", &[]).unwrap_err();
|
|
assert_eq!(err.kind(), ErrorKind::InvalidInput);
|
|
assert!(err.to_string().contains("expected to be absolute"));
|
|
}
|
|
|
|
#[test]
|
|
fn reject_root_path_for_cross_device_validation() {
|
|
let err = ensure_no_sub_mounts("/", &[]).unwrap_err();
|
|
assert_eq!(err.kind(), ErrorKind::InvalidInput);
|
|
assert!(err.to_string().contains("cannot be the filesystem root"));
|
|
}
|
|
|
|
#[test]
|
|
fn fallback_to_major_minor_when_sysfs_link_missing() {
|
|
let major = u64::MAX;
|
|
let minor = u64::MAX;
|
|
let ids = resolve_block_device_ids(major, minor).unwrap();
|
|
assert_eq!(ids.into_iter().collect::<Vec<_>>(), vec![format!("{major}:{minor}")]);
|
|
}
|
|
|
|
#[test]
|
|
fn calculate_space_usage_normal_bfree_greater_than_bavail() {
|
|
// Typical ext4/xfs scenario: bavail < bfree due to reserved blocks
|
|
let blocks = 1_000_u64;
|
|
let bfree = 900_u64;
|
|
let bavail = 850_u64;
|
|
let bsize = 4_096_u64;
|
|
|
|
let (total, free, used) = calculate_space_usage(blocks, bfree, bavail, bsize, Path::new("/data")).unwrap();
|
|
let reserved = bfree - bavail;
|
|
assert_eq!(total, (blocks - reserved) * bsize);
|
|
assert_eq!(free, bavail * bsize);
|
|
assert_eq!(used, total - free);
|
|
}
|
|
|
|
#[test]
|
|
fn calculate_space_usage_bfree_equals_bavail() {
|
|
// No reserved blocks: bfree == bavail (e.g. FAT/exFAT or root user)
|
|
let blocks = 500_u64;
|
|
let bfree = 400_u64;
|
|
let bavail = 400_u64;
|
|
let bsize = 4_096_u64;
|
|
|
|
let (total, free, used) = calculate_space_usage(blocks, bfree, bavail, bsize, Path::new("/data")).unwrap();
|
|
assert_eq!(total, blocks * bsize);
|
|
assert_eq!(free, bfree * bsize);
|
|
assert_eq!(used, (blocks - bfree) * bsize);
|
|
}
|
|
|
|
#[test]
|
|
fn calculate_space_usage_all_zero_blocks() {
|
|
let (total, free, used) = calculate_space_usage(0, 0, 0, 4_096, Path::new("/data")).unwrap();
|
|
assert_eq!(total, 0);
|
|
assert_eq!(free, 0);
|
|
assert_eq!(used, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn calculate_space_usage_all_blocks_free() {
|
|
let blocks = 1_000_u64;
|
|
let bfree = 1_000_u64;
|
|
let bavail = 1_000_u64;
|
|
let bsize = 4_096_u64;
|
|
|
|
let (total, free, used) = calculate_space_usage(blocks, bfree, bavail, bsize, Path::new("/data")).unwrap();
|
|
assert_eq!(total, blocks * bsize);
|
|
assert_eq!(free, blocks * bsize);
|
|
assert_eq!(used, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn calculate_space_usage_allows_bavail_greater_than_bfree() {
|
|
let blocks = 1_000_u64;
|
|
let bfree = 900_u64;
|
|
let bavail = 920_u64;
|
|
let bsize = 4_096_u64;
|
|
|
|
let (total, free, used) = calculate_space_usage(blocks, bfree, bavail, bsize, Path::new("/data")).unwrap();
|
|
assert_eq!(total, blocks * bsize);
|
|
assert_eq!(free, bfree * bsize);
|
|
assert_eq!(used, total - free);
|
|
}
|
|
|
|
#[test]
|
|
fn calculate_space_usage_rejects_free_greater_than_total() {
|
|
let err = calculate_space_usage(100, 120, 120, 4_096, Path::new("/data")).unwrap_err();
|
|
assert!(err.to_string().contains("detected free space"));
|
|
}
|
|
|
|
#[test]
|
|
fn bavail_greater_than_bfree_warning_is_once_per_path() {
|
|
let path = Path::new("/data/rustfs-bavail-warning-once");
|
|
|
|
assert!(should_warn_bavail_greater_than_bfree(path));
|
|
assert!(!should_warn_bavail_greater_than_bfree(path));
|
|
assert!(should_warn_bavail_greater_than_bfree(Path::new("/data/rustfs-bavail-warning-other")));
|
|
}
|
|
}
|