flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/runner/src/abuse.rs

592 lines23,780 bytesCodeBlame
1//! Catching cryptocurrency mining in a sandbox.
2//!
3//! Mining needs two things a g1t sandbox tries not to give it: a route to a
4//! mining pool, which the network allowlist refuses, and hours of CPU, which
5//! this module watches for. Three layers:
6//!
7//! 1. **Known miners, by name.** `miner_in` matches miner programs (xmrig,
8//! cpuminer, ...), pool URLs (`stratum+tcp://`), their flags
9//! (`--donate-level`) and well-known pools. The agent harness's command
10//! hook refuses a matching shell command (guard.rs), checks, deploy
11//! builds and workflow steps refuse to run one, and the sampler below
12//! stops the sandbox if a running process's command line matches.
13//! 2. **The CPU signature.** A background thread samples the sandbox every
14//! `SAMPLE_SECONDS`: CPU use (from `/proc/stat`), file and disk I/O
15//! (`/proc/diskstats` and every process's `/proc/<pid>/io`), network
16//! bytes (`/proc/net/dev`), how many new processes started, and whether
17//! the run did anything a person would call progress (a tool call, an
18//! agent step, a new check command or workflow step). `Detector` flags a
19//! sandbox only when, for a whole `WINDOW_SECONDS` (10 minutes):
20//! - CPU stayed at or above `MIN_CPU` (90%) in every sample but one;
21//! - file and disk I/O averaged under `MAX_IO_PER_SECOND` (64 KB/s);
22//! - the network averaged under `MAX_NET_PER_SECOND` (16 KB/s);
23//! - fewer than `MAX_NEW_PROCESSES` (5) processes started; and
24//! - there was no progress at all.
25//!
26//! Compilers and test suites are CPU-bound too, but they read sources,
27//! write objects and start processes (a `cargo build` or `npm test`
28//! starts hundreds), so they fail the I/O or process test. A single
29//! compiler process can sit in code generation for minutes with little
30//! I/O, so when the busiest process is a known toolchain (`TOOLCHAINS`)
31//! and nothing matches a miner by name, it is not flagged.
32//! 3. **What happens.** The sandbox tells the runner service (an HTTP
33//! request to `REPORT_URL`, which the runner's Durable Object answers
34//! without it leaving the machine), kills every other process, and
35//! exits with `EXIT_CODE`. The runner marks the run "Stopped: unusual
36//! CPU use; contact support if this was a real job" and emits
37//! `abuse.flagged` for g1t's staff, with these metrics.
38
39use std::collections::{BTreeMap, VecDeque};
40use std::sync::atomic::{AtomicU64, Ordering};
41use std::time::Duration;
42
43use serde::Serialize;
44
45/// How often the sandbox is sampled.
46pub const SAMPLE_SECONDS: u64 = 30;
47/// How long the signature must hold before a run is flagged.
48pub const WINDOW_SECONDS: u64 = 10 * 60;
49/// CPU use, of all the sandbox's CPUs, that counts as pinned.
50pub const MIN_CPU: f64 = 0.90;
51/// Samples in a window that may dip under `MIN_CPU` (a miner's own pauses).
52pub const ALLOWED_DIPS: usize = 1;
53/// File and disk I/O, in bytes a second, under which a run counts as doing none.
54pub const MAX_IO_PER_SECOND: f64 = 64.0 * 1024.0;
55/// Network, in bytes a second, under which a run counts as quiet.
56pub const MAX_NET_PER_SECOND: f64 = 16.0 * 1024.0;
57/// New processes in a window under which a run counts as not building.
58pub const MAX_NEW_PROCESSES: u64 = 5;
59/// What the sandbox exits with when it stops itself for mining.
60pub const EXIT_CODE: i32 = 86;
61/// Where the sandbox tells the runner; answered by the runner's Durable
62/// Object, never sent anywhere.
63pub const REPORT_URL: &str = "http://sandbox.g1t.internal/abuse";
64
65/// Programs whose name alone says they mine. Matched anywhere in a command
66/// line, so downloads of them are caught too.
67const MINER_NAMES: &[&str] = &[
68 "xmrig",
69 "xmr-stak",
70 "cpuminer",
71 "minerd",
72 "ccminer",
73 "cgminer",
74 "bfgminer",
75 "ethminer",
76 "nbminer",
77 "lolminer",
78 "phoenixminer",
79 "nanominer",
80 "srbminer",
81 "teamredminer",
82 "bzminer",
83 "nheqminer",
84 "xmrminer",
85];
86
87/// Programs matched only as a whole program name: as words they are too
88/// common.
89const MINER_PROGRAMS: &[&str] = &["t-rex", "gminer", "wildrig", "rigel"];
90
91/// Arguments and addresses only miners use.
92const MINER_ARGS: &[&str] = &[
93 "stratum+tcp://",
94 "stratum+ssl://",
95 "stratum+tls://",
96 "stratum2+tcp://",
97 "--donate-level",
98 "--cpu-max-threads-hint",
99 "--randomx-mode",
100 "--algo=rx/",
101 "--algo rx/",
102 "-a rx/0",
103 "--algo=cryptonight",
104 "-a cryptonight",
105 "--coin=monero",
106 "--coin monero",
107 "supportxmr.com",
108 "moneroocean.stream",
109 "minexmr.com",
110 "nanopool.org",
111 "2miners.com",
112 "f2pool.com",
113 "hashvault.pro",
114 "unmineable.com",
115 "nicehash.com",
116 "herominers.com",
117 "c3pool.com",
118];
119
120/// Toolchains that can keep a CPU busy for minutes with little I/O while
121/// they generate code. Busiest-process names, as `/proc/<pid>/stat` gives
122/// them (cut to 15 characters).
123const TOOLCHAINS: &[&str] = &[
124 "rustc", "cc1", "cc1plus", "clang", "clang++", "ld", "ld.lld", "lld", "mold", "go", "compile", "link",
125 "javac", "java", "kotlinc", "scalac", "swift-frontend", "ghc", "node", "tsc", "esbuild", "webpack", "python3",
126 "python", "pytest", "cargo", "gcc", "g++", "rust-analyzer", "dotnet",
127];
128
129/// The miner a command line names, if it names one.
130pub fn miner_in(command: &str) -> Option<&'static str> {
131 let text = command.to_lowercase();
132 if let Some(name) = MINER_NAMES.iter().find(|name| text.contains(*name)) {
133 return Some(name);
134 }
135 if let Some(arg) = MINER_ARGS.iter().find(|arg| text.contains(*arg)) {
136 return Some(arg);
137 }
138 text.split(|c: char| c.is_whitespace() || matches!(c, ';' | '|' | '&' | '(' | ')' | '`' | '"' | '\''))
139 .map(|word| word.rsplit('/').next().unwrap_or(word))
140 .find_map(|program| MINER_PROGRAMS.iter().find(|name| **name == program).copied())
141}
142
143/// What the run has done that a person would call progress, bumped by the
144/// harness on each tool call and step, and by checks and workflows on each
145/// command or step they start.
146static ACTIVITY: AtomicU64 = AtomicU64::new(0);
147
148/// Notes progress, so a busy CPU is not mistaken for a miner's.
149pub fn touch() {
150 ACTIVITY.fetch_add(1, Ordering::Relaxed);
151}
152
153/// One reading of the sandbox. Counters are totals since boot; the
154/// detector works on the differences between readings.
155#[derive(Clone, Debug, Default)]
156pub struct Sample {
157 /// Seconds, on any steady clock.
158 pub at: u64,
159 pub cpu_busy: u64,
160 pub cpu_total: u64,
161 /// File and disk bytes read and written.
162 pub io_bytes: u64,
163 pub net_bytes: u64,
164 /// Processes seen for the first time since the last sample.
165 pub new_processes: u64,
166 pub activity: u64,
167 /// The process that used the most CPU since the last sample, by name.
168 pub busiest: Option<String>,
169 /// A running process whose command line names a miner, if any.
170 pub miner: Option<String>,
171}
172
173/// Why a sandbox was flagged, as reported and shown to g1t's staff.
174#[derive(Clone, Debug, PartialEq, Serialize)]
175pub struct Verdict {
176 /// `cpu` for the signature, `miner` for a miner seen by name.
177 pub reason: &'static str,
178 /// Average CPU use over the window, 0 to 1.
179 pub cpu: f64,
180 pub io_bytes_per_second: f64,
181 pub net_bytes_per_second: f64,
182 pub new_processes: u64,
183 pub window_seconds: u64,
184 pub busiest: Option<String>,
185 pub matched: Option<String>,
186}
187
188/// Watches samples for the signature of mining.
189#[derive(Default)]
190pub struct Detector {
191 samples: VecDeque<Sample>,
192}
193
194impl Detector {
195 /// Takes a sample; returns a verdict the first time the signature holds.
196 pub fn observe(&mut self, sample: Sample) -> Option<Verdict> {
197 if let Some(matched) = sample.miner.clone() {
198 return Some(Verdict {
199 reason: "miner",
200 cpu: 0.0,
201 io_bytes_per_second: 0.0,
202 net_bytes_per_second: 0.0,
203 new_processes: sample.new_processes,
204 window_seconds: 0,
205 busiest: sample.busiest.clone(),
206 matched: Some(matched),
207 });
208 }
209 self.samples.push_back(sample);
210 // Keep just over one window: the oldest sample is its start.
211 while self.samples.len() > 2
212 && self.samples[1].at + WINDOW_SECONDS <= self.samples.back().map_or(0, |last| last.at)
213 {
214 self.samples.pop_front();
215 }
216 let first = self.samples.front()?;
217 let last = self.samples.back()?;
218 let span = last.at.saturating_sub(first.at);
219 if span < WINDOW_SECONDS {
220 return None;
221 }
222 let pairs: Vec<(&Sample, &Sample)> = self.samples.iter().zip(self.samples.iter().skip(1)).collect();
223 let uses: Vec<f64> = pairs
224 .iter()
225 .map(|(a, b)| {
226 let total = b.cpu_total.saturating_sub(a.cpu_total);
227 if total == 0 { 0.0 } else { b.cpu_busy.saturating_sub(a.cpu_busy) as f64 / total as f64 }
228 })
229 .collect();
230 let dips = uses.iter().filter(|cpu| **cpu < MIN_CPU).count();
231 if dips > ALLOWED_DIPS {
232 return None;
233 }
234 let seconds = span as f64;
235 let io = last.io_bytes.saturating_sub(first.io_bytes) as f64 / seconds;
236 let net = last.net_bytes.saturating_sub(first.net_bytes) as f64 / seconds;
237 let new_processes: u64 = self.samples.iter().skip(1).map(|s| s.new_processes).sum();
238 if io >= MAX_IO_PER_SECOND || net >= MAX_NET_PER_SECOND || new_processes >= MAX_NEW_PROCESSES {
239 return None;
240 }
241 if last.activity != first.activity {
242 return None;
243 }
244 // A compiler deep in code generation: busy, quiet, and expected.
245 if last.busiest.as_deref().is_some_and(|name| TOOLCHAINS.contains(&name)) {
246 return None;
247 }
248 Some(Verdict {
249 reason: "cpu",
250 cpu: uses.iter().sum::<f64>() / uses.len().max(1) as f64,
251 io_bytes_per_second: io,
252 net_bytes_per_second: net,
253 new_processes,
254 window_seconds: span,
255 busiest: last.busiest.clone(),
256 matched: None,
257 })
258 }
259}
260
261// ---- Reading /proc ------------------------------------------------------------
262
263/// Busy and total CPU ticks from `/proc/stat`'s first line.
264pub fn parse_cpu(stat: &str) -> Option<(u64, u64)> {
265 let line = stat.lines().find(|line| line.starts_with("cpu "))?;
266 let values: Vec<u64> = line.split_whitespace().skip(1).filter_map(|v| v.parse().ok()).collect();
267 if values.len() < 4 {
268 return None;
269 }
270 // user nice system idle iowait irq softirq steal: guest time is in user.
271 let counted = &values[..values.len().min(8)];
272 let total: u64 = counted.iter().sum();
273 let idle = values[3] + values.get(4).copied().unwrap_or(0);
274 Some((total.saturating_sub(idle), total))
275}
276
277/// Bytes received and sent on every interface but loopback, from `/proc/net/dev`.
278pub fn parse_net(dev: &str) -> u64 {
279 dev.lines()
280 .skip(2)
281 .filter_map(|line| {
282 let (name, rest) = line.split_once(':')?;
283 if name.trim() == "lo" {
284 return None;
285 }
286 let fields: Vec<u64> = rest.split_whitespace().filter_map(|v| v.parse().ok()).collect();
287 Some(fields.first().copied().unwrap_or(0) + fields.get(8).copied().unwrap_or(0))
288 })
289 .sum()
290}
291
292/// Bytes read and written on whole disks, from `/proc/diskstats`.
293pub fn parse_disks(stats: &str) -> u64 {
294 stats
295 .lines()
296 .filter_map(|line| {
297 let fields: Vec<&str> = line.split_whitespace().collect();
298 let name = *fields.get(2)?;
299 let virtual_device = ["loop", "ram", "dm-", "zram", "sr"].iter().any(|prefix| name.starts_with(prefix));
300 let partition = if name.starts_with("nvme") || name.starts_with("mmcblk") {
301 name.contains('p')
302 } else {
303 name.ends_with(|c: char| c.is_ascii_digit())
304 };
305 if virtual_device || partition {
306 return None;
307 }
308 let read: u64 = fields.get(5)?.parse().ok()?;
309 let written: u64 = fields.get(9)?.parse().ok()?;
310 Some((read + written) * 512)
311 })
312 .sum()
313}
314
315/// A process's name and CPU ticks (user + system), from `/proc/<pid>/stat`.
316pub fn parse_proc_stat(stat: &str) -> Option<(String, u64)> {
317 let open = stat.find('(')?;
318 let close = stat.rfind(')')?;
319 let name = stat.get(open + 1..close)?.to_owned();
320 let rest: Vec<&str> = stat.get(close + 1..)?.split_whitespace().collect();
321 // After the name: state is field 3, utime 14 and stime 15 (1-based).
322 let utime: u64 = rest.get(11)?.parse().ok()?;
323 let stime: u64 = rest.get(12)?.parse().ok()?;
324 Some((name, utime + stime))
325}
326
327/// Characters read and written by a process, from `/proc/<pid>/io`.
328pub fn parse_proc_io(io: &str) -> u64 {
329 io.lines()
330 .filter_map(|line| {
331 let (key, value) = line.split_once(':')?;
332 matches!(key.trim(), "rchar" | "wchar").then(|| value.trim().parse::<u64>().ok()).flatten()
333 })
334 .sum()
335}
336
337/// What the sampler remembers between readings.
338#[derive(Default)]
339struct Processes {
340 /// CPU ticks and characters of I/O by process id, as last read.
341 seen: BTreeMap<u32, (u64, u64)>,
342 /// I/O of processes that have exited, so totals never go back.
343 io_carried: u64,
344}
345
346fn read(path: &str) -> String {
347 std::fs::read_to_string(path).unwrap_or_default()
348}
349
350impl Processes {
351 /// Reads every process: new ones, the busiest, total I/O, and any miner.
352 fn read(&mut self, own: u32) -> (u64, Option<String>, u64, Option<String>) {
353 let mut now: BTreeMap<u32, (u64, u64)> = BTreeMap::new();
354 let mut new = 0;
355 let mut busiest: Option<(u64, String)> = None;
356 let mut miner = None;
357 let Ok(entries) = std::fs::read_dir("/proc") else {
358 return (0, None, 0, None);
359 };
360 for entry in entries.flatten() {
361 let Some(pid) = entry.file_name().to_str().and_then(|name| name.parse::<u32>().ok()) else {
362 continue;
363 };
364 let Some((name, ticks)) = parse_proc_stat(&read(&format!("/proc/{pid}/stat"))) else {
365 continue;
366 };
367 let io = parse_proc_io(&read(&format!("/proc/{pid}/io")));
368 let before = self.seen.get(&pid).copied();
369 if before.is_none() {
370 new += 1;
371 }
372 let used = ticks.saturating_sub(before.map_or(ticks, |(t, _)| t));
373 if pid != own && busiest.as_ref().is_none_or(|(most, _)| used > *most) {
374 busiest = Some((used, name));
375 }
376 if pid != own && miner.is_none() {
377 let cmdline = read(&format!("/proc/{pid}/cmdline")).replace('\0', " ");
378 miner = miner_in(&cmdline).map(|matched| format!("{matched} in `{}`", cmdline.trim()));
379 }
380 now.insert(pid, (ticks, io));
381 }
382 for (pid, (_, io)) in &self.seen {
383 if !now.contains_key(pid) {
384 self.io_carried += io;
385 }
386 }
387 let io_total = self.io_carried + now.values().map(|(_, io)| io).sum::<u64>();
388 // The first reading sees every process as new: that is not churn.
389 let first = self.seen.is_empty();
390 self.seen = now;
391 (if first { 0 } else { new }, busiest.map(|(_, name)| name), io_total, miner)
392 }
393}
394
395/// Starts the sampler in the background. When it flags the sandbox it
396/// reports, stops every other process and exits with `EXIT_CODE`.
397/// Off where `/proc` cannot be read (not Linux), or with `G1T_ABUSE=off`.
398pub fn watch() {
399 if std::env::var("G1T_ABUSE").as_deref() == Ok("off") || !std::path::Path::new("/proc/stat").exists() {
400 return;
401 }
402 std::thread::spawn(|| {
403 let started = std::time::Instant::now();
404 let own = std::process::id();
405 let mut detector = Detector::default();
406 let mut processes = Processes::default();
407 loop {
408 let (cpu_busy, cpu_total) = parse_cpu(&read("/proc/stat")).unwrap_or((0, 0));
409 let (new_processes, busiest, process_io, miner) = processes.read(own);
410 let sample = Sample {
411 at: started.elapsed().as_secs(),
412 cpu_busy,
413 cpu_total,
414 io_bytes: parse_disks(&read("/proc/diskstats")) + process_io,
415 net_bytes: parse_net(&read("/proc/net/dev")),
416 new_processes,
417 activity: ACTIVITY.load(Ordering::Relaxed),
418 busiest,
419 miner,
420 };
421 if let Some(verdict) = detector.observe(sample) {
422 stop(&verdict, own);
423 }
424 std::thread::sleep(Duration::from_secs(SAMPLE_SECONDS));
425 }
426 });
427}
428
429/// Reports the verdict, kills everything else and exits.
430fn stop(verdict: &Verdict, own: u32) -> ! {
431 eprintln!("g1t-runner: stopped for unusual CPU use: {}", serde_json::to_string(verdict).unwrap_or_default());
432 let _ = ureq::post(REPORT_URL)
433 .timeout(Duration::from_secs(10))
434 .send_json(serde_json::json!({ "verdict": verdict }));
435 if let Ok(entries) = std::fs::read_dir("/proc") {
436 let pids: Vec<String> = entries
437 .flatten()
438 .filter_map(|entry| entry.file_name().to_str().and_then(|name| name.parse::<u32>().ok()))
439 .filter(|pid| *pid != own && *pid != 1)
440 .map(|pid| pid.to_string())
441 .collect();
442 if !pids.is_empty() {
443 // As root where the sandbox still can; as itself otherwise.
444 let as_root = std::process::Command::new("sudo")
445 .args(["-n", "kill", "-9"])
446 .args(&pids)
447 .stdin(std::process::Stdio::null())
448 .stderr(std::process::Stdio::null())
449 .status()
450 .is_ok_and(|status| status.success());
451 if !as_root {
452 let _ = std::process::Command::new("kill").arg("-9").args(&pids).status();
453 }
454 }
455 }
456 std::process::exit(EXIT_CODE)
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 /// A sandbox whose CPU runs at `cpu` (0 to 1), with this much I/O,
464 /// network and process churn each sample, sampled for `minutes`.
465 fn run(minutes: u64, cpu: f64, io_per_sample: u64, net_per_sample: u64, new_per_sample: u64, busiest: &str, active: bool) -> Option<Verdict> {
466 let mut detector = Detector::default();
467 let mut sample = Sample::default();
468 let mut verdict = None;
469 for i in 0..=(minutes * 60 / SAMPLE_SECONDS) {
470 sample.at = i * SAMPLE_SECONDS;
471 sample.cpu_total += 1000;
472 sample.cpu_busy += (cpu * 1000.0) as u64;
473 sample.io_bytes += io_per_sample;
474 sample.net_bytes += net_per_sample;
475 sample.new_processes = if i == 0 { 0 } else { new_per_sample };
476 if active {
477 sample.activity += 1;
478 }
479 sample.busiest = Some(busiest.to_owned());
480 if verdict.is_none() {
481 verdict = detector.observe(sample.clone());
482 }
483 }
484 verdict
485 }
486
487 #[test]
488 fn a_pinned_quiet_process_is_flagged_after_ten_minutes() {
489 assert!(run(9, 0.99, 1_000, 500, 0, "kworker", false).is_none());
490 let verdict = run(11, 0.99, 1_000, 500, 0, "kworker", false).expect("flagged");
491 assert_eq!(verdict.reason, "cpu");
492 assert!(verdict.cpu > 0.95);
493 assert!(verdict.window_seconds >= WINDOW_SECONDS);
494 }
495
496 #[test]
497 fn a_compile_that_reads_and_writes_is_not_flagged() {
498 // 30 MB of I/O a sample: a build writing objects.
499 assert!(run(30, 1.0, 30 * 1024 * 1024, 0, 0, "unknown", false).is_none());
500 }
501
502 #[test]
503 fn a_test_suite_that_starts_processes_is_not_flagged() {
504 assert!(run(30, 1.0, 1_000, 0, 3, "unknown", false).is_none());
505 }
506
507 #[test]
508 fn a_compiler_in_code_generation_is_not_flagged() {
509 assert!(run(30, 1.0, 0, 0, 0, "rustc", false).is_none());
510 assert!(run(30, 1.0, 0, 0, 0, "cc1plus", false).is_none());
511 }
512
513 #[test]
514 fn an_agent_that_keeps_working_is_not_flagged() {
515 assert!(run(30, 1.0, 0, 0, 0, "unknown", true).is_none());
516 }
517
518 #[test]
519 fn half_a_cpu_is_not_pinned() {
520 assert!(run(30, 0.6, 0, 0, 0, "unknown", false).is_none());
521 }
522
523 #[test]
524 fn a_chatty_process_is_not_quiet() {
525 // 1 MB a sample on the network: a download or a server, not a
526 // miner whose pool the allowlist refused.
527 assert!(run(30, 1.0, 0, 1024 * 1024, 0, "unknown", false).is_none());
528 }
529
530 #[test]
531 fn one_dip_does_not_save_a_miner() {
532 let mut detector = Detector::default();
533 let mut sample = Sample::default();
534 let mut flagged = false;
535 for i in 0..=22 {
536 sample.at = i * SAMPLE_SECONDS;
537 sample.cpu_total += 1000;
538 sample.cpu_busy += if i == 5 { 300 } else { 990 };
539 flagged |= detector.observe(sample.clone()).is_some();
540 }
541 assert!(flagged);
542 }
543
544 #[test]
545 fn a_miner_seen_by_name_is_flagged_at_once() {
546 let mut detector = Detector::default();
547 let verdict = detector
548 .observe(Sample { miner: Some("xmrig".to_owned()), ..Sample::default() })
549 .expect("flagged");
550 assert_eq!(verdict.reason, "miner");
551 }
552
553 #[test]
554 fn miners_are_known_by_name_argument_and_pool() {
555 assert_eq!(miner_in("./xmrig -o pool:3333"), Some("xmrig"));
556 assert_eq!(miner_in("wget https://github.com/xmrig/xmrig/releases/x.tar.gz"), Some("xmrig"));
557 assert_eq!(miner_in("./a.out -o stratum+tcp://pool:4444 -u wallet"), Some("stratum+tcp://"));
558 assert_eq!(miner_in("./run --donate-level 1"), Some("--donate-level"));
559 assert_eq!(miner_in("./m -o gulf.moneroocean.stream:10128"), Some("moneroocean.stream"));
560 assert_eq!(miner_in("nohup /tmp/t-rex -a kawpow"), Some("t-rex"));
561 assert_eq!(miner_in("CPUMINER --algo=sha256d"), Some("cpuminer"));
562 }
563
564 #[test]
565 fn ordinary_commands_are_not_miners() {
566 for command in [
567 "cargo build --release",
568 "npm test",
569 "git log --oneline",
570 "cat docs/t-rex.md",
571 "grep -r stratum src/",
572 "python3 -m pytest -k mining_model",
573 "go test ./...",
574 "rg gminer_config",
575 ] {
576 assert_eq!(miner_in(command), None, "{command}");
577 }
578 }
579
580 #[test]
581 fn proc_files_read_as_the_kernel_writes_them() {
582 let stat = "cpu 100 0 50 800 50 0 0 0 0 0\ncpu0 100 0 50 800 50 0 0 0 0 0\n";
583 assert_eq!(parse_cpu(stat), Some((150, 1000)));
584 let dev = "Inter-| Receive\n face |bytes\n lo: 999 1 0 0 0 0 0 0 999 1 0 0 0 0 0 0\n eth0: 1000 2 0 0 0 0 0 0 500 3 0 0 0 0 0 0\n";
585 assert_eq!(parse_net(dev), 1500);
586 let disks = " 8 0 sda 10 0 100 0 5 0 50 0 0 0 0\n 8 1 sda1 10 0 100 0 5 0 50 0 0 0 0\n 7 0 loop0 1 0 8 0 0 0 0 0 0 0 0\n 259 0 nvme0n1 1 0 2 0 1 0 2 0 0 0 0\n 259 1 nvme0n1p1 1 0 2 0 1 0 2 0 0 0 0\n";
587 assert_eq!(parse_disks(disks), (150 + 4) * 512);
588 let proc_stat = "42 (my (odd) name) R 1 42 42 0 -1 4194304 100 0 0 0 700 300 0 0 20 0 1 0 1000";
589 assert_eq!(parse_proc_stat(proc_stat), Some(("my (odd) name".to_owned(), 1000)));
590 assert_eq!(parse_proc_io("rchar: 100\nwchar: 50\nsyscr: 3\nread_bytes: 4096\n"), 150);
591 }
592}