/
Поиск
RU

Эффекты и капабилити

kola устроена так, чтобы на вопрос «что может сделать эта функция?» рецензент отвечал по сигнатуре, не читая тела. Для этого в системе типов есть две части. Эффекты говорят, что за действие может произойти («обращается к внешнему миру»). Капабилити говорят, с чем именно («вот с этим каталогом и ни с чем больше»).

Эффекты: что за действие

Эффектов пять, и набор закрыт намеренно; в конце раздела объяснено, какую проверку должен пройти новый эффект. Ряд эффектов пишется после возвращаемого типа и перед контрактами.

ряд

значит

через что

!{}

чистая: доказуемо без выделения памяти, без ввода-вывода, без блокировок

ни через что

alloc(r)

выделяет память в регионе r

регион (и его бюджет Mem, если он есть)

io

обращается к внешнему миру через операционную систему: файлы, сокеты, потоки ввода-вывода, нити выполнения, окружение

капабилити (Fs, Stdout, Dial, …)

hw

привилегированная или чувствительная к порядку машинная операция: состояние прерываний, обслуживание кэша или TLB, барьер памяти, MMIO

Cpu или Mmio

block

может заблокироваться или уснуть

обычно капабилити

log

дописывает диагностическую строку в журнал процесса

ни через что; это единственное исключение

ct в списке нет. Это пометка самой функции (ct fn ...), и она не даёт права, а отнимает свободу: внутри такой функции запрещены и io, и hw. См. Константное время.

use std::log;
use std::stdio;

// Ряд эффектов пишется после возвращаемого типа.
fn report(label: &str, value: u64) !{log} {
    log::info(f"{label}: {value}");
}

// Вывод программы — другой канал: байты, которые читает другая программа.
fn emit(out: &Stdout, line: &[u8]) -> Result[usize, (IoErr, OsErr)] !{io} {
    out.write(line)
}

fn main(sys: Sys) -> u8 !{io, log} {
    report("answer", 42);
    match emit(&sys.stdout, &b"42\n") {
        Ok(_) => 0,
        Err(_) => 1,
    }
}

Публичная функция обязана объявить свои эффекты. У приватной ряд можно опустить, и компилятор выведет его сам.

Правило проверки умещается в одно предложение и объясняет любой отказ, который вам встретится: эффекты вызываемой функции должны быть подмножеством эффектов вызывающей. Функция не может поручить другой то, чего не вправе сделать сама.

Вызывающая

может вызвать !{}

может вызвать !{io}

может вызвать !{io, block}

!{}

да

нет

нет

!{io}

да

да

нет: block не объявлен

!{io, block}

да

да

да

Строгость насчёт block оправдана. Сигнатура !{io} обещает: «я трогаю мир, но не жду». Код на горячем пути, например обработчик прерывания или диспетчер, может вызвать такую функцию, зная, что она не застрянет. Если бы ей разрешили блокирующий вызов, обещание нарушилось бы незаметно для всех. Вот чистая функция, которая вызывает функцию, читающую часы:

error[card §6]: `pure_wants_io` performs `io` (call to `tick`) but does not declare it
  --> subset.kl:3:5
   |
 3 |     tick(c)
   |     ^^^^^^^
   = help: add `io` to the effect clause, or hand the work to a caller that has it

Изменение того, что вам передали, эффектом не считается. Чистая функция может менять всё, что получила через параметр &mut. rng.weak_u64() продвигает состояние генератора и при этом остаётся !{}. Эффекты описывают влияние на мир за пределами аргументов — то, чего не видно в месте вызова. Если вы передали функции &mut, возможность изменения уже записана в сигнатуре у вас перед глазами. (assert проводит эту границу иначе; почему — в главе Контракты, трапы и тесты.)

Когда забота заслуживает отдельного эффекта?

Эффекты не делят всё, что может сделать функция, на чистые непересекающиеся части. Они перекрываются намеренно: log — ослабленный io, block обычно идёт вместе с io, но неблокирующая запись — это io без block, а hw — это io, ограниченный машиной. Новый ряд оправдан, только если, назвав его отдельно, мы сообщаем рецензенту то, чего он не прочтёт по капабилити или по типам. hw эту проверку проходит: Cpu и Mmio дают право трогать железо, но лишь ряд говорит «это нельзя переставлять через барьер». У прочих кандидатов уже есть своё место. Недетерминизм и так помечен параметром Clock или Rng. Возможность отказа — дело контрактов и Result, ведь трап возможен почти где угодно. Секретность и недоверенность входа выражаются типами (Secret[T], untrusted). А деление io на сеть и файловую систему ничего бы не добавило: ресурс и так назван капабилити.

Говорить и выводить — два разных канала

print в kola нет. Вместо этого язык разделяет две вещи, которые в большинстве языков слиты: программа говорит и программа выводит.

Говорить — это трассировка, предупреждение, «подключились к 10.0.0.4». Такое идёт в журнал: use std::log;, затем log::info(f"x={v}"). Эффект — !{log}, капабилити не нужна. Уровней четыре: error, warn, info и debug. Функции журнала принимают &str; обычный литерал "..." — это просто текст, а дыры {...} подставляет только литерал f"...". В каждой дыре одно выражение, например f"sum={a + b} point=({p.x},{p.y})"; отдельного синтаксиса спецификаторов формата нет.

Выводить — это байты, которые потребляет другая программа, и они идут в поток: sys.stdout.write(&bytes) возвращает Result[usize, (IoErr, OsErr)] с эффектом !{io} и требует капабилити Stdout. Парная операция ввода — sys.stdin.read_line(&mut buf), она возвращает Result[bool, (IoErr, OsErr)] с !{io, block} (Ok(false) означает конец ввода). Капабилити раздельные, потому что «может ли этот код читать консоль» и «может ли он в неё писать» — разные вопросы для рецензента. Диагностика для того, кто вызвал программу, например сообщения компилятора вида file:line:col, идёт в sys.stderr того же типа Stdout.

Разделение нужно потому, что stdout — это протокол. Когда вы запускаете prog | other, оболочка соединяет стандартный вывод prog со стандартным вводом other, и other разбирает каждый полученный байт. Случайная диагностическая строка там не просто засоряет вывод: она портит данные для того, кто разбирает другой конец канала. Запустите программу выше, и каналы разойдутся у вас на глазах:

$ kola run two-channels.kl
[info] answer: 42        <- stderr: программа говорит (приёмник по умолчанию)
42                       <- stdout: вывод программы

$ kola run two-channels.kl --log off > answer.txt
$ cat answer.txt
42                       <- через канал прошёл только вывод

Тем же флагом журнал можно направить и в тот поток, от которого его берегли. С --log stdout диагностика оказывается внутри байтов, которые разбирает потребитель:

$ kola run two-channels.kl --log stdout > answer.txt
$ cat answer.txt
[info] answer: 42
42

Какие строки вообще доходят до приёмника, задаёт отдельная настройка: --log-level error | warn | info | debug (или KOLA_LOG_LEVEL). Уровень оставляет себя и всё более серьёзное. --log-level warn оставляет error и warn; без настройки действует debug, и проходит всё.

$ kola run level-demo.kl --log-level warn
[error] disk full
[warn] retrying connection

Фильтр уровня не делает вызов журнала бесплатным. Строка собирается в месте вызова до того, как сработает фильтр, поэтому строка журнала внутри горячего цикла форматируется на каждой итерации при любом уровне. На горячих путях пользуйтесь статическими литералами.

hw: машина, а не системный вызов

io охватывает взаимодействие с миром через операционную систему: программа просит ядро что-то сделать системным вызовом (syscall), например read или write. Код ядра и драйверов делает и кое-что другого рода — напрямую с железом. Он маскирует прерывания, чтобы процессор не перескочил в обработчик прерывания посреди критического шага. Сбрасывает строку кэша, чтобы записанные процессором данные дошли до основной памяти, где их увидит устройство. Ставит барьер памяти — инструкцию, которая не даёт процессору переставлять обращения к памяти через неё. И пишет в регистры устройств через MMIO (memory-mapped I/O, ввод-вывод через отображение в память): управляющие регистры устройства видны по адресам памяти, и загрузка или запись по такому адресу обращается к устройству, а не к ОЗУ. Сброс кэша не выполняет ни ввода, ни вывода; его смысл — согласованность и порядок. Сказать рецензенту ядра «эта функция делает io» значило бы ничего не сказать о том, трогает ли она флаг прерываний, поэтому у таких операций свой эффект — hw.

// Драйвер устройства: `hw`, а не `io`. С ОС здесь ничего не общается.
// Это обращения к регистрам, от порядка которых зависит устройство.
const UART_DR: usize = 0x00;   // регистр данных
const UART_FR: usize = 0x18;   // флаги: бит 0 = приёмный FIFO не пуст

pub fn uart_put(w: &MmioWindow, b: u8) !{hw}
    req w.len >= 0x20
{
    w.write8(UART_DR, b);
}

pub fn uart_ready(w: &MmioWindow) -> bool !{hw}
    req w.len >= 0x20
{
    (w.read32(UART_FR) & 1u32) != 0u32
}

hw — отдельный эффект, а не синоним. Если объявить у uart_put ряд !{io}, компилятор откажет:

error[card §6]: `uart_put` performs `hw` (mmio write) but does not declare it
  --> uart.kl:7:5
   |
 7 |     w.write8(UART_DR, b);
   |     ^^^^^^^^^^^^^^^^^^^^
   = help: add `hw` to the effect clause, or hand the work to a caller that has it

Один тип пользуется обоими рядами, и это показывает, что граница проходит по последствиям, а не по капабилити. RamWindow — память, общая с железом, например кольцо дескрипторов, из которого сетевая карта узнаёт, какие пакеты отправить. Карта читает эту память через DMA (прямой доступ к памяти), минуя процессор. Обычные чтения и записи такого окна — !{io}, ведь это просто память. А его операции с кэшем для DMA, dma_writeback и dma_invalidate, и записи с упорядочиванием release — !{hw}, потому что только ряд может сказать, что от порядка зависит другое ядро или устройство.

Капабилити: с чем именно

Капабилити — значение без публичного конструктора; Clock из ничего не создать. Среда выполнения передаёт корневой набор в main и больше никуда. Вот он целиком — программа, которая вынимает из sys каждую часть:

use std::log;

fn main(sys: Sys) -> u8 !{io, block, hw, log, alloc(heap)} {
    let _t: u64       = sys.clock.now();   // Clock: время
    let _r: Rng       = sys.rng;           // случайность
    let _f: Fs        = sys.fs;            // файлы в рабочем каталоге
    let _o: Stdout    = sys.stdout;        // вывод программы
    let _e2: Stdout   = sys.stderr;        // диагностика для вызывающего
    let _i: Stdin     = sys.stdin;         // ввод программы
    let _a: Args      = sys.args;          // argv
    let _e: Env       = sys.env;           // окружение
    let _y: Tty       = sys.tty;           // управляющий терминал
    let _l: Dial      = sys.dial;          // исходящие соединения
    let _s: Listen    = sys.listen;        // входящие соединения
    let _p: Ping      = sys.ping;          // непривилегированный ICMP echo
    let _x: Proc      = sys.proc;          // запуск выданных бинарников
    let _w: Mmio      = sys.mmio;          // аппаратные окна
    let _c: Cpu       = sys.cpu;           // привилегированные операции процессора
    let _d: Dev       = sys.dev;           // узлы устройств
    let _g: Window    = sys.window;        // окна рабочего стола и ввод
    let _n: Netlink   = sys.netlink;       // канал маршрутизации ядра
    let _q: RawIp     = sys.raw_ip;        // исходящий сырой IP (Linux)
    let _h: Inherited = sys.inherited;     // дескрипторы, переданные родителем

    // sys.mem: Mem тоже здесь, но так привязать его не получится.
    // Почему — объясняет раздел о том, какие капабилити можно бросить.
    log::info("that is the whole root set");
    0
}

У sys есть ещё два метода: sys.cores() отвечает, сколько ядер может использовать этот процесс, а sys.perturb(b"name") — тестовый крючок, который возвращает false везде, кроме тестового прогона, где его включили. Операциям каждой капабилити посвящён свой раздел STD-CARD.

Из main капабилити передаются вниз по дереву вызовов явно, обычно как ссылки на время вызова (&sys.clock), и каждая функция получает ровно столько мира, сколько ей дали:

use std::log;

enum Verdict { Clean, Suspicious }

// На входе враждебные байты, на выходе вердикт. Капабилити нет, !{} проверен.
pub fn scan[r](payload: &r [u8]) -> Verdict !{} {
    let mut zeros = 0;
    for i in 0..payload.len {
        if payload[i] == 0 { zeros = zeros + 1; }
    }
    if zeros * 4 > payload.len { Verdict::Suspicious } else { Verdict::Clean }
}

// Единственная часть, которая может что-то сказать, и всё, что она может, — писать в журнал.
fn report(v: Verdict) !{log} {
    match v {
        Verdict::Clean => log::info("payload: clean"),
        Verdict::Suspicious => log::info("payload: suspicious"),
    }
}

fn main(sys: Sys) -> u8 !{io, block, alloc(heap), log} {
    region r {
        let good = alloc in r [1u8, 2u8, 3u8, 4u8];
        let bad  = alloc in r [0u8, 0u8, 7u8, 0u8];
        report(scan(good));    // report может писать в журнал; scan не получает ничего
        report(scan(bad));
    }
    0
}

Это ослабление: на каждом шаге вниз полномочий становится меньше. main держит весь корневой набор, report может дописать строку в журнал, доступный только на запись, а scan не получает ничего. Сужаются и сами капабилити. Поскольку капабилити перемещается, а не копируется, из той, что у вас есть, получают более узкую, а исходную оставляют себе: dial.to_host(host, port) достаёт до одного адреса, dir.ro() — только до операций чтения, а clock.split(), rng.split() и dial.split() выдают рабочему потоку собственный экземпляр.

От кого это защищает? Обычно не от злонамеренного коллеги. Типичные случаи — тело функции, сгенерированное моделью, которое никто не читает построчно, или сторонняя библиотека разбора, обновлённая вместе со всеми транзитивными зависимостями. В обоих сигнатура !{} без капабилити означает: что бы ни оказалось в теле, отправить разобранные данные ему не с чем — ни канала наружу, ни выделения памяти, ни возможности ждать. Злоумышленник, которому нужна функция с капабилити, упирается в то же свойство с другой стороны: таких функций мало, и их можно перечислить. kola query attack-surface перечисляет все капабилити, которые получает функция, включая дескрипторы внутри ваших собственных типов (&Session, в котором лежит Conn), а также значения-функции, пересекающие границу, и возвраты, выходящие за регион. Эти сигнатуры плюс манифест пакета — небольшая поверхность для проверки.

Если программе не нужна случайность, просто не передавайте sys.rng никому. Неиспользованные капабилити остаются в sys, пока main не вернётся, и их забирает среда выполнения. Пакету можно запретить целые классы капабилити: манифест с [caps] allowed = [] объявляет пакет «только данные», который нигде в своём коде не может держать Clock, Rng, Fs или другую капабилити внешнего мира (см. Пакеты). Список описывает, что сам пакет может взять из корневого набора. Уже открытый дескриптор, который передаёт вызывающий, например Conn, засчитывается тому, кто его открыл, а kola audit отдельно перечисляет такие значения в публичных сигнатурах пакета.

Выдачи: решает оператор

Кое-что из нужного программе она по замыслу не может назвать сама: каталог за пределами рабочего, окно физической памяти, диапазон портов ввода-вывода x86 (отдельное адресное пространство, которым пользуются старые устройства вроде последовательных портов), узел устройства (файл в /dev, за которым стоит драйвер), устройство, полученное из смонтированной файловой системы, внешний бинарник для запуска. Всё это устроено по одной схеме.

  1. Оператор называет выдачу при запуске программы, вне исходного текста: --fs-root db:ro=/srv/db, --ram-window ring0=0x1000:0x100, --mmio-window uart0=0x0900_0000:0x1000, --io-window uart=0x3f8:8, --device widget=/dev/widget:map=4096:ioctl=0x80384b01, --dev-mint vol0=/mnt/data:mint=0x80384b01:map=4096, --proc-bin ffmpeg=/usr/bin/ffmpeg. У каждого флага есть двойник в окружении (KOLA_FS_ROOT_db, KOLA_PROC_BIN_ffmpeg и так далее). Выдачи приходят только от оператора. Переменная окружения с одним из этих префиксов, переданная дочернему процессу через sys.proc.spawn, вызывает трап, так что программа не может создать полномочия для своего потомка.

  2. Программа ищет выдачу по имени и получает Option: sys.fs.root(b"db"), sys.mmio.ram(b"ring0"), sys.mmio.window(b"uart0"), sys.mmio.io_window(b"uart"), sys.dev.open(b"widget"). Неизвестное имя — это None, а не ошибка, так что «мне это не выдали» — обычная ветка. Операция, которая сама выполняет работу, отвечает ошибкой своего семейства: sys.dev.mint(..) возвращает Result, а запуск невыданного бинарника — Err(NotGranted).

  3. Пакет объявляет, о каких именах может просить, для каждого вида отдельно: [caps] fs_roots, ram_windows, mmio_windows, io_windows, devices, proc_bins. kola audit это проверяет, поэтому про поставляемый образ можно точно сказать, какие узлы открывает каждый пакет.

  4. Доступ только на чтение — отдельный тип, а не флаг: DirRo рядом с Dir, RamWindowRo рядом с RamWindow. Операций записи у узкого типа нет, и потребитель «только на чтение», попытавшийся писать, не скомпилируется.

use std::fs;
use std::log;

// Неизвестное имя выдачи — это None, так что программа без выдачи
// `db` идёт по обычной ветке, а не по пути отказа.
fn main(sys: Sys) -> u8 !{io, block, log, alloc(heap)} {
    match sys.fs.root_ro(b"db") {
        Some(d) => {
            match d.open_read("index") {
                Ok(fh) => {
                    log::info("db/index opened");
                    let _ = fh.close();
                }
                Err(_) => log::warn("db/index missing"),
            }
            0
        }
        None => { log::warn("no db grant, running without it"); 0 }
    }
}

Один и тот же бинарник ведёт себя двумя способами, и выбор делает тот, кто его запускает:

$ kola run grant.kl --fs-root db:ro=/tmp/demo/db
[info] db/index opened

$ kola run grant.kl
[warn] no db grant, running without it

Пункт 4 на практике: w.ro() сужает окно RAM до его половины для чтения. Взять себе меньше полномочий всегда безопасно, поэтому выдача для этого не нужна. Получается тип, у которого операций записи просто нет:

error[card §7]: `RamWindowRo` is the READ-ONLY half of a RAM window — `write32` writes, and the write half does not exist on this type
  --> ro.kl:3:5
   |
 3 |     ro.write32(0, 1u32);
   |     ^^^^^^^^^^^^^^^^^^^
   = help: `w.ro()` ATTENUATES a `RamWindow`: the result carries `read8/16/32/64`, `copy_out`, `.len` and `snapshot`, and nothing that mutates — so a seqlock reader that writes cannot compile. Write through the full window `w` instead, or if this code should not hold one, take a `&RamWindowRo` parameter and let the caller keep the writable half

Несколько подробностей об аппаратных выдачах. В обычном (hosted) запуске окно MMIO или портов ввода-вывода опирается на модель устройства (--sim-mmio, --sim-io): несколько чистых функций kola над вашей собственной структурой состояния, так что поведение устройства — это тело функции. Окно, выданное без модели, отвергается при старте, а не читает нули. Окну RAM модель не нужна: обычная память сама себе модель. Выдача --device без модели (--sim-dev) под kola run открывает настоящий узел, а под kola test отвергается, потому что настоящий ioctl прочитал бы состояние машины, на которой идёт тест. Device — линейный дескриптор, операции которого ограничены выдачей. Драйверами в основном управляют через ioctl — системный вызов, отправляющий устройству пронумерованную команду; код ioctl, которого нет в разрешённом списке выдачи, даёт Err(Denied) ещё до обращения к устройству. Устройство можно также получить из выданной точки монтирования через sys.dev.mint(b"vol0"): открытие, ioctl и закрытие выполняются внутри среды выполнения, а сама операция получения принадлежит выдаче, и у дескриптора её нет. Proc применяет ту же схему к программам: sys.proc.spawn(b"ffmpeg", argv, env, disp, slots) запускает бинарник, который оператор связал с этим именем, и в дочерний процесс не переходит ничего (ни окружение, ни открытые дескрипторы), кроме того, что передано вызовом. Подробно это разобрано в главе о командной строке.

Какие капабилити можно бросить

Капабилити — ресурсы, а правило ресурсов (см. Ресурсы и линейность) требует израсходовать ресурс ровно один раз на каждом пути. Некоторые капабилити от этого освобождены, и какие именно, решает один вопрос: владеет ли капабилити чем-нибудь?

  • Маркер полномочия не владеет ничем, кроме права: Clock, Rng, Fs, Dir, DirRo, Dial, Listen, Stdout, Stdin, Args, Env, Tty, Ping, Proc, Mmio и его окна, Cpu, Dev, Window и сам sys. Бросайте его на любом пути; среда выполнения его заберёт.

  • Количество (Mem) и линейный дескриптор (File, Conn, Device, Child, RawMode, …) бросать нельзя. Потеря означала бы пропавший бюджет или утёкший дескриптор, поэтому действует обычное правило.

Компилятор отвечает на этот вопрос по единому реестру капабилити, так что капабилити, добавленная позже, сама окажется на правильной стороне, и никакой список обновлять не придётся. Два теста показывают правило:

// Маркеры полномочий можно бросить: сверять нечего.
test tokens_may_simply_be_dropped(mut sim: Sim) {
    let clock = sim.clock;
    let rng   = sim.rng;
    let env   = sim.env;
    let term  = sim.tty;
    assert true;              // ни один из четырёх не израсходован, и это законно
}

// Бюджет — это количество: брошенный кусок Mem означал бы потерянную память.
test a_budget_must_come_back(mut sim: Sim) {
    let m: Mem = sim.mem.split(4096);
    sim.mem.absorb(m);        // удалите эту строку, и тест не скомпилируется
    assert true;
}
error[card §2]: resource `m` is not consumed on this path
  --> rule.kl:13:5
    |
 13 |     let m: Mem = sim.mem.split(4096);
    |     ^^^
    = help: no implicit destructors: move it on, return it, destructure it, or pass it to a consuming fn

Бюджеты памяти

use std::log;

fn main(mut sys: Sys) -> u8 !{io, block, alloc(heap), log} {
    let small: Mem = sys.mem.split(1048576);
    region r from small {
        let v: &r mut Vec[u64] = alloc in r mut Vec::new();
        v.push(42);
        log::info(f"v[0]={v[0]}");
    }   // r закрыт, его расход возвращён в small
    sys.mem.absorb(small);
    0
}

Бюджеты заставляют прожорливый компонент падать там, где проблема. Без них, когда на машине кончается память, OOM killer (механизм операционной системы на случай нехватки памяти) выбирает какой-нибудь процесс и завершает его, часто далеко от причины. С бюджетами превышение — детерминированный трап, который указывает на виновника. Превысить бюджет можно двумя способами, и оба проверяются:

$ kola run overdraw.kl                       # отрезаем больше, чем есть
trap: error[card §7]: memory budget exceeded in split
  --> overdraw.kl:3:21
   |
 3 |     let tiny: Mem = small.split(4096);
   |                     ^^^^^^^^^^^^^^^^^

$ kola run overshoot.kl                      # перерастаем бюджет внутри региона
trap: error[card §7]: memory budget exceeded in region
  --> overshoot.kl:7:13
   |
 7 |             v.push(1u64);
   |             ^^^^^^^^^^^^

Забытый absorb ловит при компиляции линейность. Обычный region r без from ничем не ограничен и берёт память у того, что подкрепляет объемлющий регион, поэтому обычный регион, вложенный в регион с бюджетом, расходует тот же бюджет. Интерпретатор списывает смоделированные размеры, а нативная сборка — реальные байты своей арены, так что при большом запасе они согласны в том, превышен ли бюджет, но не в точном байте. kola test --sim показывает пик каждого региона (см. Память: регионы и ссылки).

Детерминизм

Весь недетерминизм программы (время, случайность, порядок ввода-вывода, планирование потоков) проходит через капабилити, а значит, его можно подменить целиком. kola test --sim --seed N даёт тестам набор капабилити с зерном: каждый источник случайности начинается с одного числа — зерна (seed) — и при каждом запуске выдаёт одну и ту же последовательность. Одно зерно — один и тот же прогон. Упавший тест печатает своё зерно, FAILED (seed 1802464353), и, передав его в --seed, вы воспроизведёте падение на своей машине.

Так же подменяются капабилити, которые читают то, что снаружи процесса. Под test и --sim sys.env пуст, так что тест не может зависеть от оболочки, которая его запустила, а sys.rng получает зерно для каждого теста вместо энтропии ОС. Симулятора нет только у одной капабилити: выданный sys.proc.spawn действительно запускает бинарник, и под test тоже.

Генератор с зерном — именно то, что нужно тесту, и именно то, из чего никогда нельзя брать ключ. Rng::weak_u64 — быстрый поток, засеянный один раз, и тот, кто видел одно его значение, предскажет остальные. Значение, которое противник не должен угадать (ключ, токен, соль, эфемерный секрет), берут из Entropy::draw[[u8; 32]](&mut rng) или rng.fill_os(&mut buf): они при каждом вызове читают энтропию ОС (непредсказуемые биты, которые ядро собирает из аппаратных событий), а Entropy::draw ещё и записывает это происхождение в тип. Под test и --seed они намеренно переходят на поток с зерном, чтобы закреплённый прогон воспроизводился в точности; ключи в таком прогоне — тестовые. Полное правило — в главе Криптография.

// Экспоненциальная задержка с джиттером. Джиттер не обязан быть непредсказуемым.
pub fn retry_after(rng: &mut Rng, attempt: u32) -> u64 !{}
    req attempt < 16
    ens ret < 1024
{
    let exp: u64 = if attempt < 9 { 1u64 << attempt } else { 512 };
    let jitter = rng.weak_u64() % exp;
    exp + jitter
}

// (mut sim: Sim) даёт тесту набор капабилити с зерном:
// sim.rng засеян, sim.clock виртуальный, sim.env пуст.
test backoff_is_bounded(mut sim: Sim) {
    let mut attempt: u32 = 0;
    while attempt < 16 {
        let d = retry_after(&mut sim.rng, attempt);
        assert d < 1024;
        attempt = attempt + 1;
    }
}

test env_is_empty_under_test(mut sim: Sim) {
    assert sim.env.get(b"HOME").is_none();
}

Нормативное описание — SPEC-CARD §6 и §7 (kola help spec-card); операции каждой капабилити — в STD-CARD.