nono 0.70.0

Capability-based sandboxing library using Landlock (Linux) and Seatbelt (macOS)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
//! Resource limits — parsed, validated ceilings for a sandboxed process tree.
//!
//! The internal, enforcement-facing type; the schema-generated [`crate::manifest`]
//! types are the on-disk contract (same split as
//! [`crate::capability::CapabilitySet`] vs the manifest). This module defines and
//! parses the limits from human-friendly CLI input. Enforcement lives in the CLI
//! supervisor (`nono-cli`'s `resource_cgroup`), which renders them to cgroup v2
//! knobs on Linux — keeping the library policy-free.

use crate::error::{NonoError, Result};
use serde::{Deserialize, Serialize};

/// Parsed, validated resource ceilings; `None` means "no limit". Values are raw
/// bytes/counts — sizes like `512M` are parsed at the CLI boundary via
/// [`parse_size`], never stored as strings, so the manifest stays a
/// fully-resolved machine contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ResourceLimits {
    /// Maximum resident memory for the process tree, in bytes
    /// (cgroup `memory.max` + `memory.swap.max=0` + `memory.oom.group=1`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub memory_bytes: Option<u64>,
    /// Maximum number of processes and threads (tasks) in the process tree
    /// (cgroup `pids.max`). Unlike the memory cap, a breach does not kill
    /// anything: the kernel refuses new `fork`/`clone` calls with `EAGAIN`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_processes: Option<u64>,
}

impl ResourceLimits {
    /// True when no ceiling is set. Decides whether to show limits or require a
    /// supervised run.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.memory_bytes.is_none() && self.max_processes.is_none()
    }

    /// One-line human-readable summary for `--dry-run` / capability output. Both
    /// ceilings are always shown (an unset one reads `unlimited`) so the summary
    /// is a complete statement of the enforced limits, not just the set ones.
    #[must_use]
    pub fn summary(&self) -> String {
        let mem = self
            .memory_bytes
            .map_or_else(|| "unlimited".to_string(), format_bytes);
        let procs = self
            .max_processes
            .map_or_else(|| "unlimited".to_string(), |n| n.to_string());
        format!("memory={mem}, processes={procs}")
    }
}

/// Parse a human-friendly size string into a byte count.
///
/// An integer plus an optional unit suffix (case-insensitive): bare/`B` = bytes;
/// `K`/`Ki`/`KiB`, `M`/`Mi`/`MiB`, `G`/`Gi`/`GiB`, `T`/`Ti`/`TiB` are binary
/// (1024-based); `KB`/`MB`/`GB`/`TB` are decimal (1000-based). E.g. `512M` →
/// 536870912, `1Gi` → 1073741824.
///
/// [`NonoError::ConfigParse`] on empty input, missing number, non-integer,
/// unknown unit, overflow, or zero (a zero limit is rejected, not read as
/// "unlimited").
pub fn parse_size(input: &str) -> Result<u64> {
    let s = input.trim();
    if s.is_empty() {
        return Err(NonoError::ConfigParse("size cannot be empty".to_string()));
    }

    let digits_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
    let (num_str, unit) = s.split_at(digits_end);
    if num_str.is_empty() {
        return Err(NonoError::ConfigParse(format!(
            "invalid size '{input}': missing numeric value (decimals are not supported, e.g. use 512M not 0.5G)"
        )));
    }
    let value: u64 = num_str.parse().map_err(|_| {
        NonoError::ConfigParse(format!(
            "invalid size '{input}': '{num_str}' is not a valid integer"
        ))
    })?;

    let multiplier: u64 = match unit.trim().to_ascii_lowercase().as_str() {
        "" | "b" => 1,
        "k" | "ki" | "kib" => 1024_u64.pow(1),
        "kb" => 1000_u64.pow(1),
        "m" | "mi" | "mib" => 1024_u64.pow(2),
        "mb" => 1000_u64.pow(2),
        "g" | "gi" | "gib" => 1024_u64.pow(3),
        "gb" => 1000_u64.pow(3),
        "t" | "ti" | "tib" => 1024_u64.pow(4),
        "tb" => 1000_u64.pow(4),
        other => {
            return Err(NonoError::ConfigParse(format!(
                "invalid size '{input}': unknown unit '{other}' (use B, K/KiB, M/MiB, G/GiB, T/TiB)"
            )));
        }
    };

    let bytes = value.checked_mul(multiplier).ok_or_else(|| {
        NonoError::ConfigParse(format!("size '{input}' overflows a 64-bit byte count"))
    })?;
    if bytes == 0 {
        return Err(NonoError::ConfigParse(format!(
            "size '{input}' must be greater than zero"
        )));
    }
    Ok(bytes)
}

/// Format a byte count with binary units for display (e.g. `512.0 MiB`).
///
/// Shared by [`ResourceLimits::summary`] and the CLI's failure diagnostics so a
/// limit and the memory that breached it render the same everywhere.
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn format_bytes(bytes: u64) -> String {
    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
    let mut val = bytes as f64;
    let mut idx = 0;
    while val >= 1024.0 && idx < UNITS.len() - 1 {
        val /= 1024.0;
        idx += 1;
    }
    if idx == 0 {
        // Under 1 KiB: whole bytes, no decimal.
        format!("{bytes} B")
    } else {
        format!("{val:.1} {}", UNITS[idx])
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn parse_size_plain_bytes() {
        assert_eq!(parse_size("1024").unwrap(), 1024);
        assert_eq!(parse_size("1B").unwrap(), 1);
    }

    #[test]
    fn parse_size_binary_units() {
        assert_eq!(parse_size("512M").unwrap(), 512 * 1024 * 1024);
        assert_eq!(parse_size("1Gi").unwrap(), 1024 * 1024 * 1024);
        assert_eq!(parse_size("2KiB").unwrap(), 2048);
        assert_eq!(parse_size("1t").unwrap(), 1024_u64.pow(4));
    }

    #[test]
    fn parse_size_decimal_units() {
        // All four 1000-based tiers, paired with their 1024-based twins (covered in
        // parse_size_binary_units) so a swapped multiplier on any tier is caught.
        assert_eq!(parse_size("1KB").unwrap(), 1000);
        assert_eq!(parse_size("1MB").unwrap(), 1_000_000);
        assert_eq!(parse_size("1GB").unwrap(), 1_000_000_000);
        assert_eq!(parse_size("1TB").unwrap(), 1_000_000_000_000);
    }

    #[test]
    fn parse_size_is_case_insensitive_and_trims() {
        assert_eq!(parse_size("  512m  ").unwrap(), 512 * 1024 * 1024);
        assert_eq!(parse_size("1GIB").unwrap(), 1024 * 1024 * 1024);
    }

    #[test]
    fn parse_size_rejects_bad_input() {
        assert!(parse_size("").is_err());
        assert!(parse_size("abc").is_err());
        assert!(parse_size("M").is_err());
        assert!(parse_size("12x").is_err());
        assert!(parse_size("0").is_err(), "zero must be rejected");
        assert!(parse_size("0M").is_err(), "zero must be rejected");
        assert!(parse_size("1.5G").is_err(), "decimals are not supported");
    }

    #[test]
    fn parse_size_rejects_overflow() {
        assert!(parse_size("99999999999999999999T").is_err());
    }

    #[test]
    fn parse_size_rejection_messages_name_the_cause() {
        // Each rejection names its specific cause, so the CLI can guide the user
        // rather than emit a generic "bad size". Pins the message routing so a
        // future edit can't accidentally collapse them into one error.
        let msg = |s: &str| parse_size(s).unwrap_err().to_string();
        assert!(msg("").contains("cannot be empty"));
        assert!(msg("abc").contains("missing numeric value"));
        assert!(msg("12x").contains("unknown unit"));
        assert!(msg("0").contains("greater than zero"));
        // A numeric part too large for u64 fails at integer parse...
        assert!(msg("99999999999999999999").contains("not a valid integer"));
        // ...whereas a value that fits but overflows once scaled by the unit
        // reports the byte-count overflow (checked_mul path).
        assert!(msg("18014398509481984K").contains("overflows"));
    }

    #[test]
    fn limits_is_empty_and_summary() {
        let none = ResourceLimits::default();
        assert!(none.is_empty());

        let some = ResourceLimits {
            memory_bytes: Some(512 * 1024 * 1024),
            max_processes: None,
        };
        assert!(!some.is_empty());
        let s = some.summary();
        assert_eq!(s, "memory=512.0 MiB, processes=unlimited");

        // A process-only ceiling is non-empty too, and reads with memory unlimited.
        let procs = ResourceLimits {
            memory_bytes: None,
            max_processes: Some(64),
        };
        assert!(!procs.is_empty());
        assert_eq!(procs.summary(), "memory=unlimited, processes=64");

        // Both ceilings set: both rendered.
        let both = ResourceLimits {
            memory_bytes: Some(512 * 1024 * 1024),
            max_processes: Some(64),
        };
        assert_eq!(both.summary(), "memory=512.0 MiB, processes=64");

        let unset = ResourceLimits::default();
        assert_eq!(unset.summary(), "memory=unlimited, processes=unlimited");
    }

    #[test]
    fn limits_serde_roundtrip() {
        let limits = ResourceLimits {
            memory_bytes: Some(1024),
            max_processes: Some(32),
        };
        let json = serde_json::to_string(&limits).unwrap();
        let back: ResourceLimits = serde_json::from_str(&json).unwrap();
        assert_eq!(limits, back);
    }

    // ---- Pure-function correctness & serde contract ----

    #[test]
    fn parse_size_zero_forms_and_leading_chars() {
        // Every spelling that evaluates to zero must be rejected: a zero limit is
        // refused rather than silently meaning "unlimited".
        assert!(parse_size("0").is_err());
        assert!(parse_size("00").is_err());
        assert!(parse_size("000").is_err());
        assert!(parse_size("0B").is_err());
        assert!(parse_size("0K").is_err());
        assert!(parse_size("000K").is_err());

        // A leading '+' is not an ASCII digit, so digits_end is 0 and the numeric
        // part is empty -> rejected as a missing numeric value (NOT parsed as +5).
        assert!(parse_size("+5").is_err());

        // Leading zeros on a non-zero value are accepted (u64 parse ignores them).
        assert_eq!(parse_size("007").unwrap(), 7);
        assert_eq!(parse_size("0007K").unwrap(), 7 * 1024);
    }

    #[test]
    fn parse_size_unit_overflow_boundaries_per_unit() {
        // For each multiplier, the largest u64-fitting value must parse and the
        // next integer up must overflow — pinning the checked_mul boundary exactly.

        // K / Ki / KiB multiplier = 1024. u64::MAX / 1024 = 18014398509481983.
        assert_eq!(
            parse_size("18014398509481983K").unwrap(),
            18_014_398_509_481_983_u64 * 1024
        );
        assert!(parse_size("18014398509481984K").is_err());

        // KB multiplier = 1000. u64::MAX / 1000 = 18446744073709551.
        assert_eq!(
            parse_size("18446744073709551KB").unwrap(),
            18_446_744_073_709_551_u64 * 1000
        );
        assert!(parse_size("18446744073709552KB").is_err());

        // M / Mi / MiB multiplier = 1024^2 = 1048576.
        // u64::MAX / 1048576 = 17592186044415.
        assert_eq!(
            parse_size("17592186044415M").unwrap(),
            17_592_186_044_415_u64 * 1024_u64.pow(2)
        );
        assert!(parse_size("17592186044416M").is_err());

        // T / Ti / TiB multiplier = 1024^4 = 1099511627776.
        // u64::MAX / 1099511627776 = 16777215.
        assert_eq!(
            parse_size("16777215T").unwrap(),
            16_777_215_u64 * 1024_u64.pow(4)
        );
        assert!(parse_size("16777216T").is_err());

        // Bare bytes have multiplier 1: u64::MAX itself fits, nothing overflows.
        assert_eq!(parse_size("18446744073709551615").unwrap(), u64::MAX);
    }

    #[test]
    fn parse_size_unit_distinctions_and_internal_whitespace() {
        // Decimal vs binary kilobyte must be distinct, asserted side by side.
        assert_eq!(parse_size("1KB").unwrap(), 1000);
        assert_eq!(parse_size("1KiB").unwrap(), 1024);
        assert_eq!(parse_size("1K").unwrap(), 1024);
        assert_eq!(parse_size("1Ki").unwrap(), 1024);
        // ...and the same one-step-up distinction for M.
        assert_eq!(parse_size("1MB").unwrap(), 1_000_000);
        assert_eq!(parse_size("1MiB").unwrap(), 1024 * 1024);

        // Whitespace BETWEEN the number and the unit is tolerated, because the
        // unit is trimmed before matching: "1 K" -> unit " K" -> "k" -> 1024.
        assert_eq!(parse_size("1 K").unwrap(), 1024);
        assert_eq!(parse_size("512 MiB").unwrap(), 512 * 1024 * 1024);

        // But a SECOND run of digits after a space is part of the unit, which then
        // fails to match any known unit -> rejected (not silently truncated).
        assert!(parse_size("5 12").is_err());
        assert!(parse_size("1 2K").is_err());
    }

    #[test]
    fn format_bytes_exact_boundaries() {
        // Pin format_bytes at unit boundaries, the sub-KiB whole-byte branch, a TiB
        // value, the unit cap, and a rounding case. (summary() embeds this same
        // rendering; the summary format itself is pinned in limits_is_empty_and_summary.)
        // Under 1 KiB: whole bytes, no decimal, ' B' suffix.
        assert_eq!(format_bytes(1), "1 B");
        assert_eq!(format_bytes(1023), "1023 B");
        // Exactly 1 KiB: switches to one-decimal binary unit.
        assert_eq!(format_bytes(1024), "1.0 KiB");
        // Half a KiB above 1 KiB.
        assert_eq!(format_bytes(1536), "1.5 KiB");
        // 1587 / 1024 = 1.5498 -> rounds to one decimal as 1.5.
        assert_eq!(format_bytes(1587), "1.5 KiB");
        // 1100 / 1024 = 1.0742 -> rounds to 1.1.
        assert_eq!(format_bytes(1100), "1.1 KiB");
        // Exact MiB / GiB.
        assert_eq!(format_bytes(1024 * 1024), "1.0 MiB");
        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GiB");
        // Exact TiB (1024^4).
        assert_eq!(format_bytes(1024_u64.pow(4)), "1.0 TiB");
        assert_eq!(
            format_bytes(1024_u64.pow(4) + 1024_u64.pow(4) / 2),
            "1.5 TiB"
        );
        // 1 PiB has no PiB unit: the loop caps at TiB, so it reads as 1024.0 TiB.
        assert_eq!(format_bytes(1024_u64.pow(5)), "1024.0 TiB");
    }

    #[test]
    fn parse_size_format_bytes_roundtrip_on_exact_binary_values() {
        // For exact single-unit binary multiples, format_bytes' rendered form
        // re-parses back to the same byte count: a closed loop proving the units
        // and the parser agree.
        for (bytes, unit) in [
            (1024_u64, "KiB"),
            (512 * 1024, "KiB"),
            (1024 * 1024, "MiB"),
            (512 * 1024 * 1024, "MiB"),
            (1024 * 1024 * 1024, "GiB"),
            (1024_u64.pow(4), "TiB"),
        ] {
            // format_bytes renders e.g. "1.0 KiB"; strip the ".0" to reconstruct the
            // integer+unit the parser accepts.
            let rendered = format_bytes(bytes);
            let (value, suffix) = rendered.split_once(' ').unwrap();
            assert_eq!(suffix, unit, "unexpected unit for {bytes}");
            // The integer magnitude in the rendered "<n>.0" form, re-attached to
            // the unit, must parse straight back to the original byte count.
            let int_part = value.strip_suffix(".0").unwrap();
            let reparsed = parse_size(&format!("{int_part}{unit}")).unwrap();
            assert_eq!(reparsed, bytes, "round-trip failed for {bytes}");
        }
    }

    #[test]
    fn is_empty_tracks_both_fields_and_summary_unlimited() {
        // is_empty is true only when BOTH ceilings are unset; summary reflects the
        // same, showing each field as unlimited when unset.
        let empty = ResourceLimits {
            memory_bytes: None,
            max_processes: None,
        };
        assert!(empty.is_empty());
        assert_eq!(empty.summary(), "memory=unlimited, processes=unlimited");

        // Either field alone makes it non-empty and is rendered, not elided.
        let mem = ResourceLimits {
            memory_bytes: Some(1),
            max_processes: None,
        };
        assert!(!mem.is_empty());
        assert_eq!(mem.summary(), "memory=1 B, processes=unlimited");

        let procs = ResourceLimits {
            memory_bytes: None,
            max_processes: Some(1),
        };
        assert!(!procs.is_empty());
        assert_eq!(procs.summary(), "memory=unlimited, processes=1");

        // Default is the unlimited/empty state.
        assert!(ResourceLimits::default().is_empty());
    }

    #[test]
    fn none_memory_serializes_to_empty_object_with_no_key() {
        // skip_serializing_if = "Option::is_none": a None ceiling must produce `{}`,
        // not `{"memory_bytes":null}` — the on-disk contract that keeps None states
        // forward-compatible.
        let none = ResourceLimits::default();
        assert_eq!(serde_json::to_string(&none).unwrap(), "{}");

        let v: serde_json::Value = serde_json::to_value(none).unwrap();
        let obj = v.as_object().expect("serializes to a JSON object");
        assert!(obj.is_empty(), "None must emit no keys, got {obj:?}");
        assert!(!obj.contains_key("memory_bytes"));
    }

    #[test]
    fn empty_and_null_object_deserialize_to_none() {
        // Inverse of the skip-serialize contract: both `{}` and explicit `null`
        // deserialize to None via #[serde(default)], so older-build state round-trips.
        let from_empty: ResourceLimits = serde_json::from_str("{}").unwrap();
        assert!(from_empty.memory_bytes.is_none());
        assert!(from_empty.is_empty());

        let from_null: ResourceLimits = serde_json::from_str(r#"{"memory_bytes":null}"#).unwrap();
        assert!(from_null.memory_bytes.is_none());
    }

    #[test]
    fn some_memory_serializes_with_exactly_one_key_and_roundtrips() {
        // memory alone must serialize to exactly { "memory_bytes": N } and round-trip
        // (max_processes: None is skipped). This pins the exact serialized shape,
        // which the roundtrip alone would miss (serde ignores unknown fields on read).
        let some = ResourceLimits {
            memory_bytes: Some(536_870_912),
            max_processes: None,
        };
        let v: serde_json::Value = serde_json::to_value(some).unwrap();
        let obj = v.as_object().expect("object");
        assert_eq!(obj.len(), 1, "exactly one serialized key, got {obj:?}");
        assert_eq!(
            obj.get("memory_bytes").and_then(serde_json::Value::as_u64),
            Some(536_870_912)
        );
        assert!(!obj.contains_key("max_processes"), "None field is skipped");

        let back: ResourceLimits = serde_json::from_value(v).unwrap();
        assert_eq!(back, some);
    }

    #[test]
    fn max_processes_serializes_independently_and_roundtrips() {
        // A process-only ceiling serializes to exactly { "max_processes": N } (memory
        // skipped), and deserializes back — mirror of the memory-only contract so the
        // two ceilings are provably independent on the wire.
        let procs = ResourceLimits {
            memory_bytes: None,
            max_processes: Some(64),
        };
        let v: serde_json::Value = serde_json::to_value(procs).unwrap();
        let obj = v.as_object().expect("object");
        assert_eq!(obj.len(), 1, "exactly one serialized key, got {obj:?}");
        assert_eq!(
            obj.get("max_processes").and_then(serde_json::Value::as_u64),
            Some(64)
        );
        assert!(!obj.contains_key("memory_bytes"), "None field is skipped");

        let back: ResourceLimits = serde_json::from_value(v).unwrap();
        assert_eq!(back, procs);

        // Deserializing an explicit null for max_processes yields None (serde default).
        let from_null: ResourceLimits = serde_json::from_str(r#"{"max_processes":null}"#).unwrap();
        assert!(from_null.max_processes.is_none());
        assert!(from_null.is_empty());
    }
}