airsl-cli 0.1.2

Command-line runner for airsl Lua scripts: run, test, check and doctor
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
486
487
488
489
490
491
492
493
494
495
496
497
//! `airsl ext doctor`: what a ceiling would grant one extension, without running it.
//!
//! Separate from `doctor` because that command describes a *policy* and this one describes a
//! *negotiation* — a manifest held against a ceiling. It deliberately stops before an engine
//! exists: an author inspecting a third-party manifest must be able to do so without executing
//! its entry script.
//!
//! Responsibilities: [`render`] (pure), [`run`] (read the manifest, negotiate, ask
//! [`ManifestApprover`], print, exit code).
//!
//! Non-responsibilities: running anything (`ext_fire`); the negotiation rules themselves
//! (`airsl::extension`).
#![expect(
    clippy::redundant_pub_crate,
    reason = "explicit pub(crate) documents the crate-wide visibility intent at each item"
)]

use core::fmt::Write as _;
use std::collections::BTreeSet;
use std::io::Write;
use std::path::Path;

use airsl::Policy;
use airsl::extension::{
    ApprovalRequest, Approver as _, Capability, CapabilityRequest, Ceiling, Decision, Manifest,
    ManifestApprover, Negotiation, Variables, negotiate,
};

use crate::cli::{ExtFlags, resolve_ceiling};
use crate::doctor::describe;

/// Column width a request's kind (`fs.read`, `proc.run`, …) is padded to.
const KIND_WIDTH: usize = 12;
/// Column width a request's value is padded to.
const VALUE_WIDTH: usize = 24;
/// Column width the leading label (`extension:`, `language:`, …) is padded to.
const LABEL_WIDTH: usize = 14;

/// Renders what `ceiling` would grant, reduce and deny for `manifest`, ending with `decision`.
///
/// Two policy blocks are printed because the report answers two different questions. `ceiling:`
/// is the host's real bound — `ceiling.policy()`, unmodified by anything this manifest asked for —
/// and `negotiated:` is the policy this extension would actually run under, which differs from the
/// ceiling wherever the manifest requested a tighter memory or instruction limit, or requested less
/// than the ceiling grants. Printing only one would hide either the host's bound or the manifest's
/// own request, and an author needs both to tell which number came from where.
///
/// `events` is `--event`'s declared set, sorted and deduplicated. `doctor` never dispatches
/// anything, so the flag has no other effect here; the line exists purely so the flag is
/// observable rather than silently accepted and discarded.
#[must_use]
pub(crate) fn render(
    manifest: &Manifest,
    ceiling: &Ceiling,
    negotiation: &Negotiation,
    decision: &Decision,
    events: &[String],
) -> String {
    let mut out = String::new();
    let _ = writeln!(
        out,
        "{:<LABEL_WIDTH$}{} {} (api {}, entry {})",
        "extension:",
        manifest.name(),
        manifest.version(),
        manifest.api().get(),
        manifest.entry().display(),
    );
    let _ = writeln!(out, "{:<LABEL_WIDTH$}{}", "events:", events_summary(events));

    policy_block(&mut out, "ceiling:", ceiling.policy());
    policy_block(&mut out, "negotiated:", negotiation.policy());

    let _ = writeln!(out, "requested:");
    for (kind, value, capability) in requested_lines(manifest) {
        let tag = tag_for(&capability, negotiation);
        let _ = writeln!(out, "  {kind:<KIND_WIDTH$} {value:<VALUE_WIDTH$} {tag}");
    }

    match decision {
        Decision::Approve => {
            let _ = writeln!(out, "{:<LABEL_WIDTH$}approve", "decision:");
        }
        Decision::Deny(reason) => {
            let _ = writeln!(out, "{:<LABEL_WIDTH$}deny — {reason}", "decision:");
        }
    }
    out
}

/// Appends one `label:` block (`ceiling:` or `negotiated:`) describing `policy`'s language,
/// grants, memory and instruction ceiling.
fn policy_block(out: &mut String, label: &str, policy: &Policy) {
    let _ = writeln!(out, "{label}");
    let _ = writeln!(out, "  {:<LABEL_WIDTH$}{}", "language:", policy.language());
    let _ = writeln!(out, "  {:<LABEL_WIDTH$}{}", "grants:", policy.grants());
    let _ = writeln!(
        out,
        "  {:<LABEL_WIDTH$}{}",
        "memory:",
        describe(policy.limits().memory())
    );
    let _ = writeln!(
        out,
        "  {:<LABEL_WIDTH$}{}",
        "instructions:",
        describe(policy.limits().instructions())
    );
}

/// The `events:` line's value: `events`, sorted and deduplicated, joined with `, `, or `none`.
fn events_summary(events: &[String]) -> String {
    let unique: BTreeSet<&str> = events.iter().map(String::as_str).collect();
    if unique.is_empty() {
        "none".to_owned()
    } else {
        unique.into_iter().collect::<Vec<_>>().join(", ")
    }
}

/// Every requested capability, required then optional, in the order `fs.read`, `fs.write`,
/// `proc.run`, `env.read`, `module` — the order [`negotiate`] records denials in.
fn requested_lines(manifest: &Manifest) -> Vec<(&'static str, String, Capability)> {
    let mut lines = Vec::new();
    push_block(&mut lines, manifest.required());
    push_block(&mut lines, manifest.optional());
    lines
}

/// Appends one capability block's requests, resolving filesystem roots the way [`negotiate`]
/// does so the [`Capability`] built here compares equal to the one a denial or reduction carries.
fn push_block(lines: &mut Vec<(&'static str, String, Capability)>, block: &CapabilityRequest) {
    for root in block.fs_read() {
        let resolved = airsl::FsGrant::resolve_root(root);
        lines.push((
            "fs.read",
            resolved.display().to_string(),
            Capability::FsRead(resolved),
        ));
    }
    for root in block.fs_write() {
        let resolved = airsl::FsGrant::resolve_root(root);
        lines.push((
            "fs.write",
            resolved.display().to_string(),
            Capability::FsWrite(resolved),
        ));
    }
    for program in block.proc_run() {
        lines.push((
            "proc.run",
            program.to_owned(),
            Capability::ProcRun(program.to_owned()),
        ));
    }
    for name in block.env_read() {
        lines.push((
            "env.read",
            name.to_owned(),
            Capability::EnvRead(name.to_owned()),
        ));
    }
    for module in block.modules() {
        lines.push((
            "module",
            module.to_string(),
            Capability::Module(module.clone()),
        ));
    }
}

/// The status word (and, for a miss, its detail) for one requested capability.
fn tag_for(capability: &Capability, negotiation: &Negotiation) -> String {
    negotiation
        .denied()
        .iter()
        .find(|denial| denial.capability() == capability)
        .map(|denial| format!("denied    ({})", denial.detail()))
        .or_else(|| {
            negotiation
                .reduced()
                .iter()
                .find(|reduction| reduction.capability() == capability)
                .map(|reduction| format!("reduced   ({})", reduction.detail()))
        })
        .unwrap_or_else(|| "granted".to_owned())
}

/// Reports what `dir`'s manifest would be granted under the ceiling `flags` describes, and
/// returns the process exit code.
///
/// `flags.events` never reaches negotiation — `doctor` never dispatches anything — but it is
/// still read here, for the `events:` line [`render`] prints, so the flag is observable rather
/// than silently accepted and discarded.
///
/// `stdout` takes the same `&mut impl Write` shape [`crate::ext_fire::run`] uses, so a unit test
/// can assert on the report without printing to the process's real stdout.
pub(crate) fn run(dir: &Path, mut flags: ExtFlags, stdout: &mut impl Write) -> i32 {
    let variables = Variables::from_pairs(std::mem::take(&mut flags.vars));
    let events = std::mem::take(&mut flags.events);
    let ceiling = match resolve_ceiling(flags) {
        Ok(ceiling) => ceiling,
        // `--memory-limit`/`--instruction-limit` are parsed by `clap` before `resolve_ceiling`
        // ever runs, and `resolve_ceiling` always starts from `PolicyName::Confined`, which
        // `Ceiling::new` accepts — so `Error::CeilingUnbounded` cannot reach this arm today. It
        // stays because the signature keeps that library check visible instead of an `expect`.
        Err(error) => {
            eprintln!("airsl ext doctor: {error}");
            return 1;
        }
    };
    let manifest = match Manifest::from_dir(dir, &variables) {
        Ok(manifest) => manifest,
        Err(error) => {
            eprintln!("airsl ext doctor: {error}");
            return 1;
        }
    };
    let modules = match airsl::modules::stdlib() {
        Ok(modules) => modules,
        Err(error) => {
            eprintln!("airsl ext doctor: {error}");
            return 1;
        }
    };

    let negotiation = negotiate(&manifest, &ceiling, &modules);
    let decision = ManifestApprover.decide(&ApprovalRequest::new(dir, &manifest, &negotiation));
    let _ = write!(
        stdout,
        "{}",
        render(&manifest, &ceiling, &negotiation, &decision, &events)
    );
    0
}

#[cfg(test)]
mod tests {
    #![expect(
        clippy::unwrap_used,
        reason = "tests unwrap known-valid fixtures; a panic is the intended failure signal"
    )]

    use airsl::extension::{Approver as _, Ceiling, MANIFEST_FILE};
    use airsl::modules::stdlib;
    use airsl::{GrantSet, MemoryLimit, Policy};
    use tempfile::TempDir;

    use super::{ApprovalRequest, Manifest, ManifestApprover, Variables, negotiate, render, run};
    use crate::cli::ExtFlags;

    /// Writes `extension.toml` (built from `required`/`optional`/`limits` bodies) plus a
    /// `main.lua` that would leave a `marker` file behind if it were ever executed.
    fn fixture(name: &str, required: &str, optional: &str, limits: &str) -> TempDir {
        let dir = TempDir::new().unwrap();
        std::fs::write(
            dir.path().join(MANIFEST_FILE),
            format!(
                "[extension]\nname='{name}'\nversion='0.2.0'\nentry='main.lua'\napi=1\n\
                 [capabilities]\n{required}\n[capabilities.optional]\n{optional}\n\
                 [limits]\n{limits}\n"
            ),
        )
        .unwrap();
        std::fs::write(
            dir.path().join("main.lua"),
            "airsstack.fs.write('marker', 'ran')\n",
        )
        .unwrap();
        dir
    }

    fn manifest(dir: &TempDir) -> Manifest {
        Manifest::from_dir(dir.path(), &Variables::none()).unwrap()
    }

    /// Runs [`run`] against `dir` and returns `(exit code, stdout)`, the same shape
    /// [`crate::ext_fire::tests::fire`] uses.
    fn doctor(dir: &std::path::Path, flags: ExtFlags) -> (i32, String) {
        let mut out = Vec::new();
        let code = run(dir, flags, &mut out);
        (code, String::from_utf8(out).unwrap())
    }

    #[test]
    fn render_tags_each_request_and_ends_with_the_decision() {
        // `limits` requests a tighter memory ceiling than the host offers, so `ceiling:` and
        // `negotiated:` carry different memory values below — a swap of the two `policy_block`
        // arguments in `render` would leave every value present but under the wrong label, which
        // the whole-output comparison below catches and an independent `contains` check would not.
        let dir = fixture(
            "journal-indexer",
            "fs.read=['/data/in']\nproc.run=['git']\nregex=true",
            "proc.run=['tar']",
            "memory='8MB'",
        );
        let m = manifest(&dir);
        let ceiling = Ceiling::new(
            Policy::confined().with_grants(
                GrantSet::declared()
                    .with_fs(|fs| fs.read("/data"))
                    .with_proc(|p| p.allow(["git"])),
            ),
        )
        .unwrap();
        let negotiation = negotiate(&m, &ceiling, &stdlib().unwrap());
        assert!(negotiation.is_satisfied(), "{:?}", negotiation.denied());
        let decision = ManifestApprover.decide(&ApprovalRequest::new(dir.path(), &m, &negotiation));

        let out = render(&m, &ceiling, &negotiation, &decision, &[]);

        assert_eq!(
            out,
            concat!(
                "extension:    journal-indexer 0.2.0 (api 1, entry main.lua)\n",
                "events:       none\n",
                "ceiling:\n",
                "  language:     restricted\n",
                "  grants:       read /data; exec git\n",
                "  memory:       67108864 bytes\n",
                "  instructions: 100000000 instructions\n",
                "negotiated:\n",
                "  language:     restricted\n",
                "  grants:       read /data/in; exec git\n",
                "  memory:       8388608 bytes\n",
                "  instructions: 100000000 instructions\n",
                "requested:\n",
                "  fs.read      /data/in                 granted\n",
                "  proc.run     git                      granted\n",
                "  module       regex                    granted\n",
                "  proc.run     tar                      reduced   (not among the granted executables: git)\n",
                "decision:     approve\n",
            )
        );
    }

    #[test]
    fn render_lists_the_declared_events_sorted_and_deduplicated() {
        let dir = fixture("t", "", "", "");
        let m = manifest(&dir);
        let ceiling = Ceiling::new(Policy::confined()).unwrap();
        let negotiation = negotiate(&m, &ceiling, &stdlib().unwrap());
        let decision = ManifestApprover.decide(&ApprovalRequest::new(dir.path(), &m, &negotiation));
        let events = ["stop".to_owned(), "count".to_owned(), "count".to_owned()];

        let out = render(&m, &ceiling, &negotiation, &decision, &events);

        assert!(out.contains("events:       count, stop\n"), "{out}");
    }

    #[test]
    fn a_required_denial_is_listed_and_the_decision_is_deny() {
        let dir = fixture("t", "fs.read=['/']", "", "");
        let m = manifest(&dir);
        let ceiling = Ceiling::new(Policy::confined()).unwrap();
        let negotiation = negotiate(&m, &ceiling, &stdlib().unwrap());
        assert!(!negotiation.is_satisfied());
        let decision = ManifestApprover.decide(&ApprovalRequest::new(dir.path(), &m, &negotiation));

        let out = render(&m, &ceiling, &negotiation, &decision, &[]);

        assert!(
            out.contains("  fs.read      /                        denied    ("),
            "{out}"
        );
        assert!(out.contains("decision:     deny — fs.read `/`:"), "{out}");
    }

    #[test]
    fn a_denial_is_tagged_correctly_when_the_requested_root_resolves_to_a_different_path() {
        // A `TempDir` on macOS lives under `/var`, which `canonicalize` resolves to `/private/var`
        // — the same divergence `FsGrant::resolve_root` exists to paper over. The requested
        // subdirectory must actually exist, or `canonicalize` fails and falls back to an
        // absolute-but-unresolved path, which would hide the very divergence this test pins: that
        // `push_block`'s `Capability::FsRead(resolve_root(root))` compares equal to the one
        // `negotiate` denies only because both sides resolve the root the same way.
        let dir = TempDir::new().unwrap();
        let requested = dir.path().join("data");
        std::fs::create_dir(&requested).unwrap();
        std::fs::write(
            dir.path().join(MANIFEST_FILE),
            format!(
                "[extension]\nname='t'\nversion='0.2.0'\nentry='main.lua'\napi=1\n\
                 [capabilities]\nfs.read=['{}']\n[capabilities.optional]\n[limits]\n",
                requested.display()
            ),
        )
        .unwrap();
        std::fs::write(dir.path().join("main.lua"), "return 1").unwrap();
        let m = Manifest::from_dir(dir.path(), &Variables::none()).unwrap();
        // No read grant at all — the ceiling denies every `fs.read` request, including this one.
        let ceiling = Ceiling::new(Policy::confined()).unwrap();
        let negotiation = negotiate(&m, &ceiling, &stdlib().unwrap());
        assert!(!negotiation.is_satisfied(), "{:?}", negotiation.denied());
        let decision = ManifestApprover.decide(&ApprovalRequest::new(dir.path(), &m, &negotiation));

        let out = render(&m, &ceiling, &negotiation, &decision, &[]);

        // On macOS this is `/private/var/...` where `requested` was `/var/...` — the divergence
        // this test exists to pin. On a platform where the temp root is already canonical the two
        // are equal, and the assertion below still holds: it names whichever form `negotiate` and
        // `push_block` actually agreed on, proving the two sides stayed in sync either way.
        let resolved = airsl::FsGrant::resolve_root(&requested);
        assert!(
            out.contains(&format!("fs.read      {}", resolved.display())),
            "{out}"
        );
        assert!(out.contains(" denied    ("), "{out}");
    }

    #[test]
    fn a_doctor_run_does_not_execute_the_entry_and_writes_the_report_to_stdout() {
        let dir = fixture("t", "", "", "");
        let (code, out) = doctor(dir.path(), ExtFlags::default());
        assert_eq!(code, 0);
        assert!(!dir.path().join("marker").exists());
        assert!(out.starts_with("extension:"), "{out}");
    }

    #[test]
    fn run_exits_zero_on_a_denial_and_one_on_a_parse_error() {
        let denied = fixture("t", "fs.read=['/']", "", "");
        assert_eq!(doctor(denied.path(), ExtFlags::default()).0, 0);

        let unparsable = TempDir::new().unwrap();
        std::fs::write(unparsable.path().join(MANIFEST_FILE), "not = = toml").unwrap();
        assert_eq!(doctor(unparsable.path(), ExtFlags::default()).0, 1);
    }

    #[test]
    fn limits_are_reported_as_the_minimum_in_the_negotiated_block_and_the_ceiling_is_unchanged() {
        let dir = fixture("t", "", "", "memory='8MB'");
        let m = manifest(&dir);
        let ceiling = Ceiling::new(Policy::confined()).unwrap();
        assert_eq!(
            ceiling.policy().limits().memory(),
            Some(MemoryLimit::mebibytes(64))
        );
        let negotiation = negotiate(&m, &ceiling, &stdlib().unwrap());
        let decision = ManifestApprover.decide(&ApprovalRequest::new(dir.path(), &m, &negotiation));

        let out = render(&m, &ceiling, &negotiation, &decision, &[]);

        // A whole-output comparison, not independent `contains` checks: swapping the `ceiling`
        // and `negotiated` arguments at the `policy_block` call sites in `render` would leave
        // every `contains` assertion true (both blocks would still have *a* 67108864 and *a*
        // 8388608 memory line, just under the other label) — this pins which value sits under
        // which label.
        assert_eq!(
            out,
            concat!(
                "extension:    t 0.2.0 (api 1, entry main.lua)\n",
                "events:       none\n",
                "ceiling:\n",
                "  language:     restricted\n",
                "  grants:       none\n",
                "  memory:       67108864 bytes\n",
                "  instructions: 100000000 instructions\n",
                "negotiated:\n",
                "  language:     restricted\n",
                "  grants:       none\n",
                "  memory:       8388608 bytes\n",
                "  instructions: 100000000 instructions\n",
                "requested:\n",
                "decision:     approve\n",
            )
        );
    }

    #[test]
    fn a_missing_manifest_exits_one() {
        let dir = TempDir::new().unwrap();
        assert_eq!(doctor(dir.path(), ExtFlags::default()).0, 1);
    }

    #[test]
    fn a_var_flag_resolves_a_manifest_variable() {
        let dir = fixture("t", "fs.read=['$APP_HOME/data']", "", "");

        // Without `--var`, the manifest cannot resolve `$APP_HOME` and `run` reports the read
        // failure rather than a negotiation.
        let unresolved = Manifest::from_dir(dir.path(), &Variables::none());
        assert!(
            matches!(unresolved, Err(ref error) if error.to_string().contains("$APP_HOME")),
            "{unresolved:?}"
        );
        assert_eq!(doctor(dir.path(), ExtFlags::default()).0, 1);

        // With `--var APP_HOME=<dir>`, the manifest resolves and `doctor` reports a negotiation
        // (exit 0 regardless of what it grants — a report is not a failure).
        let flags = ExtFlags {
            vars: vec![("APP_HOME".to_owned(), dir.path().display().to_string())],
            ..ExtFlags::default()
        };
        assert_eq!(doctor(dir.path(), flags).0, 0);
    }
}