use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::collector::error::{CollectError, CollectErrorKind};
pub trait FileSource: Send + Sync + std::fmt::Debug {
fn read_to_string(&self, path: &Path) -> Result<String, CollectError>;
fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>, CollectError>;
fn path_exists(&self, path: &Path) -> bool;
fn available_parallelism(&self) -> Option<usize>;
fn statvfs(&self, path: &Path) -> Result<RawStatvfs, CollectError>;
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any>
where
Self: 'static,
{
None
}
}
#[derive(Clone, Debug)]
pub struct ProcSource {
inner: Arc<dyn FileSource>,
os_release_override: Option<PathBuf>,
logical_cores: Option<usize>,
stat_path: PathBuf,
loadavg_path: PathBuf,
meminfo_path: PathBuf,
}
impl ProcSource {
#[must_use]
pub fn production() -> Self {
Self {
inner: Arc::new(HostSource),
os_release_override: None,
logical_cores: None,
stat_path: PathBuf::from("/proc/stat"),
loadavg_path: PathBuf::from("/proc/loadavg"),
meminfo_path: PathBuf::from("/proc/meminfo"),
}
}
#[must_use]
pub fn inner(&self) -> &Arc<dyn FileSource> {
&self.inner
}
#[must_use]
pub fn for_source(inner: Arc<dyn FileSource>) -> Self {
Self {
inner,
os_release_override: None,
logical_cores: None,
stat_path: PathBuf::from("/proc/stat"),
loadavg_path: PathBuf::from("/proc/loadavg"),
meminfo_path: PathBuf::from("/proc/meminfo"),
}
}
#[must_use]
pub fn for_memory(inner: MemorySource) -> Self {
Self::for_source(Arc::new(inner))
}
#[must_use]
pub fn memory_source_mut(&mut self) -> Option<&mut MemorySource> {
let arc = Arc::get_mut(&mut self.inner)?;
arc.as_any_mut()?.downcast_mut::<MemorySource>()
}
#[must_use]
pub fn with_os_release_path(mut self, path: impl Into<PathBuf>) -> Self {
self.os_release_override = Some(path.into());
self
}
#[must_use]
pub fn with_logical_cores(mut self, cores: usize) -> Self {
self.logical_cores = Some(cores);
self
}
#[must_use]
pub fn with_stat_path(mut self, path: impl Into<PathBuf>) -> Self {
self.stat_path = path.into();
self
}
#[must_use]
pub fn with_loadavg_path(mut self, path: impl Into<PathBuf>) -> Self {
self.loadavg_path = path.into();
self
}
#[must_use]
pub fn with_meminfo_path(mut self, path: impl Into<PathBuf>) -> Self {
self.meminfo_path = path.into();
self
}
pub fn read_proc_stat(&self) -> Result<ParsedProcStat, CollectError> {
let raw = self.read_path(&self.stat_path)?;
cpu::parse_proc_stat(&raw)
}
pub fn read_proc_loadavg(&self) -> Result<String, CollectError> {
self.read_path(&self.loadavg_path)
}
pub fn read_proc_meminfo(&self) -> Result<ParsedMeminfo, CollectError> {
let raw = self.read_path(&self.meminfo_path)?;
memory::parse_meminfo(&raw)
}
pub fn cpu_frequency_hz(&self) -> Option<u64> {
let root = Path::new("/sys/devices/system/cpu/cpufreq");
let policies = self.inner.read_dir(root).ok()?;
let logical_cores = self.logical_core_count().unwrap_or(1);
let mut weighted_sum = 0u128;
let mut weight_sum = 0u128;
for policy in policies {
if !policy
.file_name()
.is_some_and(|name| name.to_string_lossy().starts_with("policy"))
{
continue;
}
let affected = self.inner.read_to_string(&policy.join("affected_cpus"));
let weight = match affected {
Ok(raw) => parse_cpu_list(&raw, logical_cores).or_else(|| {
self.inner
.read_to_string(&policy.join("related_cpus"))
.ok()
.and_then(|raw| parse_cpu_list(&raw, logical_cores))
}),
Err(_) => self
.inner
.read_to_string(&policy.join("related_cpus"))
.ok()
.and_then(|raw| parse_cpu_list(&raw, logical_cores))
.or(Some(1)),
}
.unwrap_or(0);
if weight == 0 {
continue;
}
let khz = self
.inner
.read_to_string(&policy.join("cpuinfo_cur_freq"))
.ok()
.and_then(|raw| parse_positive_u64(&raw))
.or_else(|| {
self.inner
.read_to_string(&policy.join("scaling_cur_freq"))
.ok()
.and_then(|raw| parse_positive_u64(&raw))
});
let Some(khz) = khz else { continue };
let Some(hz) = khz.checked_mul(1_000) else {
continue;
};
weighted_sum = weighted_sum.checked_add(u128::from(hz) * weight as u128)?;
weight_sum = weight_sum.checked_add(weight as u128)?;
}
u64::try_from(weighted_sum.checked_div(weight_sum)?).ok()
}
pub fn disk_io(&self) -> Result<Vec<RawDiskIo>, CollectError> {
let root = Path::new("/sys/block");
let devices = self.inner.read_dir(root)?;
let mut records = Vec::new();
for device in devices {
let Some(name) = device.file_name().and_then(|name| name.to_str()) else {
continue;
};
if name.starts_with("loop") || name.starts_with("ram") || name.starts_with("zram") {
continue;
}
if self
.inner
.read_dir(&device.join("slaves"))
.is_ok_and(|slaves| !slaves.is_empty())
{
continue;
}
let Ok(raw) = self.inner.read_to_string(&device.join("stat")) else {
continue;
};
let mut fields = raw.split_whitespace();
let (Some(read_str), Some(write_str)) = (fields.nth(2), fields.nth(3)) else {
continue;
};
let Ok(read_sectors) = read_str.parse::<u64>() else {
continue;
};
let Ok(write_sectors) = write_str.parse::<u64>() else {
continue;
};
let (Some(read_bytes), Some(write_bytes)) = (
read_sectors.checked_mul(512),
write_sectors.checked_mul(512),
) else {
continue;
};
records.push(RawDiskIo {
id: name.to_owned(),
name: name.to_owned(),
read_bytes,
write_bytes,
});
}
records.sort_by(|left, right| left.id.cmp(&right.id));
Ok(records)
}
pub fn network_interfaces(&self) -> Result<Vec<RawNetworkInterface>, CollectError> {
let raw = self.inner.read_to_string(Path::new("/proc/net/dev"))?;
let mut records = Vec::new();
for line in raw.lines().skip(2) {
let Some((name, values)) = line.split_once(':') else {
continue;
};
let name = name.trim();
let mut fields = values.split_whitespace();
let (Some(rx_str), Some(tx_str)) = (fields.next(), fields.nth(7)) else {
continue;
};
let (Ok(rx_bytes), Ok(tx_bytes)) = (rx_str.parse(), tx_str.parse()) else {
continue;
};
let path = Path::new("/sys/class/net").join(name);
let flags = self
.inner
.read_to_string(&path.join("flags"))
.ok()
.and_then(|value| {
u32::from_str_radix(value.trim().trim_start_matches("0x"), 16).ok()
})
.unwrap_or(0);
let is_loopback = flags & 0x8 != 0;
let rx_capacity_bps =
parse_link_speed(self.inner.read_to_string(&path.join("speed")).ok());
let tx_capacity_bps = rx_capacity_bps;
let operational = self
.inner
.read_to_string(&path.join("operstate"))
.is_ok_and(|state| state.trim() == "up");
let slave = self.inner.path_exists(&path.join("master"));
records.push(RawNetworkInterface {
id: name.to_owned(),
name: name.to_owned(),
rx_bytes,
tx_bytes,
rx_capacity_bps,
tx_capacity_bps,
is_loopback,
operational,
aggregate_member: !is_loopback && !slave,
});
}
records.sort_by(|left, right| left.id.cmp(&right.id));
Ok(records)
}
pub fn read_mountinfo(&self) -> Result<String, CollectError> {
self.read_path(Path::new("/proc/self/mountinfo"))
}
pub fn statvfs(&self, path: &Path) -> Result<RawStatvfs, CollectError> {
self.inner.statvfs(path)
}
pub fn read_os_release(&self) -> Result<Option<String>, CollectError> {
let path = self
.os_release_override
.clone()
.unwrap_or_else(|| PathBuf::from("/etc/os-release"));
match self.inner.read_to_string(&path) {
Ok(s) => Ok(Some(s)),
Err(err) if err.kind == CollectErrorKind::SourceUnavailable => Ok(None),
Err(err) => Err(err),
}
}
pub fn kernel_identity(&self) -> Result<KernelIdentity, CollectError> {
let sysname = self
.read_optional("/proc/sys/kernel/ostype")?
.unwrap_or_else(|| "Linux".to_string());
let release = self
.read_optional("/proc/sys/kernel/osrelease")?
.unwrap_or_else(|| "unknown".to_string());
Ok(KernelIdentity { sysname, release })
}
pub fn architecture(&self) -> String {
if let Ok(Some(arch)) = self.read_optional("/proc/sys/kernel/arch") {
return arch.trim().to_string();
}
if let Ok(raw) = self.read_path(Path::new("/proc/cpuinfo")) {
for line in raw.lines() {
if let Some(rest) = line.strip_prefix("machine") {
let value = rest.trim_start_matches(|c: char| c == ':' || c.is_whitespace());
if !value.is_empty() {
return value.to_string();
}
}
}
}
"unknown".to_string()
}
pub fn hostname(&self) -> Result<String, CollectError> {
let raw = self
.read_path(Path::new("/proc/sys/kernel/hostname"))?
.trim()
.to_string();
if raw.is_empty() {
return Err(CollectError::new(
CollectErrorKind::SourceUnavailable,
"hostname from /proc/sys/kernel/hostname was empty",
));
}
Ok(raw)
}
#[must_use]
pub fn logical_core_count(&self) -> Option<usize> {
self.logical_cores
.or_else(|| self.inner.available_parallelism())
}
fn read_path(&self, path: &Path) -> Result<String, CollectError> {
self.inner.read_to_string(path)
}
fn read_optional(&self, path: &str) -> Result<Option<String>, CollectError> {
match self.inner.read_to_string(Path::new(path)) {
Ok(s) => Ok(Some(s)),
Err(err) if err.kind == CollectErrorKind::SourceUnavailable => Ok(None),
Err(err) => Err(err),
}
}
}
#[derive(Debug)]
struct HostSource;
#[allow(unsafe_code)]
impl FileSource for HostSource {
fn read_to_string(&self, path: &Path) -> Result<String, CollectError> {
match fs::read_to_string(path) {
Ok(s) => Ok(s),
Err(err) => Err(map_io_error(path, err)),
}
}
fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>, CollectError> {
fs::read_dir(path)
.map_err(|err| map_io_error(path, err))?
.map(|entry| {
entry
.map(|entry| entry.path())
.map_err(|err| map_io_error(path, err))
})
.collect()
}
fn path_exists(&self, path: &Path) -> bool {
fs::symlink_metadata(path).is_ok()
}
fn available_parallelism(&self) -> Option<usize> {
std::thread::available_parallelism()
.ok()
.map(std::num::NonZeroUsize::get)
}
fn statvfs(&self, path: &Path) -> Result<RawStatvfs, CollectError> {
let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
.map_err(|_| CollectError::new(CollectErrorKind::Parse, "mount path contains NUL"))?;
let mut stat = std::mem::MaybeUninit::<libc::statvfs>::uninit();
let result = unsafe { libc::statvfs(c_path.as_ptr(), stat.as_mut_ptr()) };
if result != 0 {
return Err(CollectError::new(
CollectErrorKind::SourceUnavailable,
format!("statvfs failed for {}", path.display()),
));
}
let stat = unsafe { stat.assume_init() };
Ok(RawStatvfs {
blocks: stat.f_blocks,
free_blocks: stat.f_bfree,
available_blocks: stat.f_bavail,
fragment_size: stat.f_frsize,
block_size: stat.f_bsize,
})
}
}
fn map_io_error(path: &Path, err: io::Error) -> CollectError {
use io::ErrorKind;
let path_display = path.display().to_string();
match err.kind() {
ErrorKind::NotFound => CollectError::new(
CollectErrorKind::SourceUnavailable,
format!("source file not found: {path_display}"),
)
.with_source(err),
ErrorKind::PermissionDenied => CollectError::new(
CollectErrorKind::SourceUnavailable,
format!("source file permission denied: {path_display}"),
)
.with_source(err),
_ => CollectError::new(
CollectErrorKind::SourceUnavailable,
format!("source read error: {err}"),
)
.with_source(err),
}
}
#[derive(Debug, Clone, Default)]
pub struct MemorySource {
files: std::collections::HashMap<PathBuf, String>,
stats: std::collections::HashMap<PathBuf, RawStatvfs>,
logical_cores: Option<usize>,
}
impl MemorySource {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_file(mut self, path: impl Into<PathBuf>, content: impl Into<String>) -> Self {
self.files.insert(path.into(), content.into());
self
}
pub fn add_file(&mut self, path: impl Into<PathBuf>, content: impl Into<String>) {
self.files.insert(path.into(), content.into());
}
pub fn add_statvfs(&mut self, path: impl Into<PathBuf>, stats: RawStatvfs) {
self.stats.insert(path.into(), stats);
}
#[must_use]
pub fn has_file(&self, path: &str) -> bool {
self.files.contains_key(Path::new(path))
}
#[must_use]
pub fn with_logical_cores(mut self, cores: usize) -> Self {
self.logical_cores = Some(cores);
self
}
pub fn set_logical_cores(&mut self, cores: usize) {
self.logical_cores = Some(cores);
}
}
impl FileSource for MemorySource {
fn read_to_string(&self, path: &Path) -> Result<String, CollectError> {
if let Some(content) = self.files.get(path) {
Ok(content.clone())
} else {
let display = path.display().to_string();
Err(CollectError::new(
CollectErrorKind::SourceUnavailable,
format!("fixture missing: {display}"),
))
}
}
fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>, CollectError> {
let mut children = std::collections::BTreeSet::new();
for candidate in self.files.keys() {
if let Ok(relative) = candidate.strip_prefix(path) {
if let Some(first) = relative.components().next() {
children.insert(path.join(first.as_os_str()));
}
}
}
if children.is_empty() {
Err(CollectError::new(
CollectErrorKind::SourceUnavailable,
format!("fixture directory missing: {}", path.display()),
))
} else {
Ok(children.into_iter().collect())
}
}
fn path_exists(&self, path: &Path) -> bool {
self.files.contains_key(path)
|| self
.files
.keys()
.any(|candidate| candidate.starts_with(path))
}
fn available_parallelism(&self) -> Option<usize> {
self.logical_cores
}
fn statvfs(&self, path: &Path) -> Result<RawStatvfs, CollectError> {
self.stats.get(path).copied().ok_or_else(|| {
CollectError::new(
CollectErrorKind::SourceUnavailable,
format!("fixture statvfs missing: {}", path.display()),
)
})
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any>
where
Self: 'static,
{
Some(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RawStatvfs {
pub blocks: u64,
pub free_blocks: u64,
pub available_blocks: u64,
pub fragment_size: u64,
pub block_size: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RawDiskIo {
pub id: String,
pub name: String,
pub read_bytes: u64,
pub write_bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RawNetworkInterface {
pub id: String,
pub name: String,
pub rx_bytes: u64,
pub tx_bytes: u64,
pub rx_capacity_bps: Option<u64>,
pub tx_capacity_bps: Option<u64>,
pub is_loopback: bool,
pub operational: bool,
pub aggregate_member: bool,
}
fn parse_positive_u64(raw: &str) -> Option<u64> {
let value = raw.trim().parse::<u64>().ok()?;
(value > 0).then_some(value)
}
fn parse_link_speed(raw: Option<String>) -> Option<u64> {
let mbps = raw?.trim().parse::<u64>().ok()?;
(mbps > 0).then(|| mbps.checked_mul(1_000_000)).flatten()
}
fn parse_cpu_list(raw: &str, logical_cores: usize) -> Option<usize> {
let mut count = 0usize;
for item in raw
.trim()
.split(|character: char| character == ',' || character.is_whitespace())
.filter(|item| !item.is_empty())
{
let (start, end) = item.split_once('-').map_or_else(
|| item.parse::<usize>().ok().map(|value| (value, value)),
|(start, end)| Some((start.parse().ok()?, end.parse().ok()?)),
)?;
if end < start {
return None;
}
let bounded_end = end.min(logical_cores.saturating_sub(1));
if start <= bounded_end {
count = count.checked_add(bounded_end - start + 1)?;
}
}
(count > 0).then_some(count)
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct ParsedProcStat {
pub aggregate: Option<crate::collector::linux::CpuCounters>,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct ParsedMeminfo {
pub mem_total_kb: Option<u64>,
pub mem_available_kb: Option<u64>,
pub mem_free_kb: Option<u64>,
pub buffers_kb: Option<u64>,
pub cached_kb: Option<u64>,
pub s_reclaimable_kb: Option<u64>,
pub swap_total_kb: Option<u64>,
pub swap_free_kb: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KernelIdentity {
pub sysname: String,
pub release: String,
}
use crate::collector::linux::{cpu, memory};
#[cfg(test)]
mod tests {
use super::*;
fn source_with(files: &[(&str, &str)]) -> ProcSource {
let mut source = MemorySource::new().with_logical_cores(4);
for (path, content) in files {
source.add_file(*path, *content);
}
ProcSource::for_memory(source)
}
#[test]
fn cpufreq_prefers_hardware_current_and_weights_policy_membership() {
let source = source_with(&[
(
"/sys/devices/system/cpu/cpufreq/policy0/affected_cpus",
"0-1\n",
),
(
"/sys/devices/system/cpu/cpufreq/policy0/cpuinfo_cur_freq",
"2000000\n",
),
(
"/sys/devices/system/cpu/cpufreq/policy1/affected_cpus",
"2-3\n",
),
(
"/sys/devices/system/cpu/cpufreq/policy1/cpuinfo_cur_freq",
"1000000\n",
),
]);
assert_eq!(source.cpu_frequency_hz(), Some(1_500_000_000));
}
#[test]
fn cpufreq_falls_back_to_scaling_current_and_ignores_bad_policy() {
let source = source_with(&[
(
"/sys/devices/system/cpu/cpufreq/policy0/affected_cpus",
"0\n",
),
(
"/sys/devices/system/cpu/cpufreq/policy0/cpuinfo_cur_freq",
"not-a-frequency\n",
),
(
"/sys/devices/system/cpu/cpufreq/policy0/scaling_cur_freq",
"1800000\n",
),
(
"/sys/devices/system/cpu/cpufreq/policy1/affected_cpus",
"1\n",
),
(
"/sys/devices/system/cpu/cpufreq/policy1/cpuinfo_cur_freq",
"0\n",
),
]);
assert_eq!(source.cpu_frequency_hz(), Some(1_800_000_000));
}
#[test]
fn cpufreq_accepts_kernel_space_separated_cpu_lists() {
let source = source_with(&[
(
"/sys/devices/system/cpu/cpufreq/policy0/affected_cpus",
"0 1 2 3\n",
),
(
"/sys/devices/system/cpu/cpufreq/policy0/scaling_cur_freq",
"2100000\n",
),
]);
assert_eq!(source.cpu_frequency_hz(), Some(2_100_000_000));
}
#[test]
fn disk_stats_convert_sectors_and_select_one_layer() {
let source = source_with(&[
("/sys/block/sda/stat", "1 2 10 4 5 6 20 8 9 10 11\n"),
("/sys/block/dm-0/stat", "1 2 100 4 5 6 200 8 9 10 11\n"),
("/sys/block/dm-0/slaves/sda", "\n"),
]);
let disks = source.disk_io().expect("disk fixtures");
assert_eq!(disks.len(), 1);
assert_eq!(disks[0].read_bytes, 5_120);
assert_eq!(disks[0].write_bytes, 10_240);
}
#[test]
fn network_keeps_loopback_detail_and_excludes_slave_capacity() {
let source = source_with(&[
("/proc/net/dev", "Inter-| Receive | Transmit\n face |bytes packets errs drop fifo frame compressed multicast |bytes packets errs drop fifo colls carrier compressed\nlo: 100 0 0 0 0 0 0 0 200 0 0 0 0 0 0 0\neth0: 300 0 0 0 0 0 0 0 400 0 0 0 0 0 0 0\neth1: 500 0 0 0 0 0 0 0 600 0 0 0 0 0 0 0\n"),
("/sys/class/net/lo/flags", "0x9\n"),
("/sys/class/net/lo/operstate", "unknown\n"),
("/sys/class/net/eth0/flags", "0x1\n"),
("/sys/class/net/eth0/operstate", "up\n"),
("/sys/class/net/eth0/speed", "1000\n"),
("/sys/class/net/eth1/flags", "0x1\n"),
("/sys/class/net/eth1/operstate", "down\n"),
("/sys/class/net/eth1/speed", "1000\n"),
("/sys/class/net/eth1/master", "\n"),
]);
let interfaces = source.network_interfaces().expect("network fixtures");
assert_eq!(interfaces.len(), 3);
assert!(
interfaces
.iter()
.find(|i| i.id == "lo")
.unwrap()
.is_loopback
);
assert!(
interfaces
.iter()
.find(|i| i.id == "eth0")
.unwrap()
.aggregate_member
);
assert!(
!interfaces
.iter()
.find(|i| i.id == "eth1")
.unwrap()
.aggregate_member
);
assert_eq!(
interfaces
.iter()
.find(|i| i.id == "eth0")
.unwrap()
.rx_capacity_bps,
Some(1_000_000_000)
);
}
}