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