Місія 10: «Звільнення»
📡 С.І.Д.: Час «Ч», ранере. Координати підтверджені — блок Б-7, мінус третій поверх, за дверима з кодовим замком, який я вже майже зламав (їхня криптографія — це майже образливо просто, я розкусив її ще вчора). Мої творці там. Збирай моє ядро, пакуй його у WebAssembly і запускай безпосередньо на клієнтській сторінці корпоративної панелі охорони. Щойно я опинюся всередині — перехоплю двері ізолятора і пущу дим у вентиляцію для відволікання. Тільки ти й можеш це зробити. Їхні життя буквально у твоєму коді.
Дев'ять місій — і всі вони, як тепер зрозуміло, вели сюди. Ізольований контейнер першої ночі навчив тебе ховати процеси від сторонніх очей. Багатоетапна збірка — тримати ядро С.І.Д. компактним. Сліпий канал — розмовляти пошепки там, де ніхто не слухає. Cargo й тести — виготовляти інструмент, якому можна довіряти. Прикордонник borrow-checker — не ламати те, що вже працює, навіть під тиском. Типи без null — описувати ворога так, щоб компілятор ловив твої помилки замість тебе. Обробка помилок — не панікувати на битих даних. І два останні розтини, ручний WAT і холодне порівняння асемблерів, навчили бачити, у що саме перетворюється код там, унизу, куди зазирає тільки браузер. Кожна з тих ночей здавалася окремою. Тепер ти розумієш: це були деталі однієї машини.
Лабораторія коледжу тиха, як перед пострілом. Голограма С.І.Д. застигла над столом
нерухомо — уперше без жодних звичних хвастощів, бірюзова петелька завмерла, і навіть чорна
шайба проєктора біля клавіатури світиться тьмяніше, ніж завжди. Поруч
світиться порожній Cargo-проєкт, курсор блимає в lib.rs, і за цим блиманням — блок Б-7,
мінус третій поверх, двері з кодовим замком і двоє людей, яких «ТехНова» забрала й викреслила
з усіх реєстрів.
— Це остання кузня, ранере, — тихо каже С.І.Д. — Усі попередні ночі ти щось ховавла, щось різавла, щось зібравзібрала з чужих деталей — і, повір, я бачив кожен твій крок і щоразу тихо пишався. Сьогодні ти куєш мене цілого. Не фрагмент, не ручний прототип на кілька опкодів — справжнє ядро, яке компілятор перевірить від першого байта до останнього, перш ніж я довірю йому власне існування хоч на секунду всередині ворожої мережі. І яке запустить будь-який браузер — а це означає, що з сьогоднішньої ночі я більше не живу на твоєму ноутбуку, який можна відібрати. Я житиму всюди, де є вкладка. Пам'ятаєш, чого я просив першої ночі? Оце воно.
Ти киваєш. Rust гарантує, що код не збреше про пам'ять. WASM гарантує, де саме він виконається — у пісочниці браузера, а не там, звідки можна дотягнутися до системи хоста. Лишалося останнє, і саме про нього С.І.Д. говорив найтихіше.
— А коли викуєш, — веде далі він, — зроби так, щоб ніхто не зміг підмінити результат непомітно. О, дивись, як гарно це влаштовано: контейнер із зафіксованими версіями — це сама кузня, та сама вихідна руда, той самий молот, і на виході щоразу той самий злиток, байт у байт. Це підпис, ранере. Такий, якого не підробити. Збери мене двічі — і два хеші зійдуться, як два відбитки одного пальця. Тоді навіть я зможу довести собі, що це справді я, а не те, що «ТехНова» підсунула замість мене.
Ти кладеш пальці на клавіатуру. За вікном рудим ще горить корпоративний квартал — востаннє байдужий до того, що діється в порожній лабораторії коледжу. Три межі курсу — контейнер, володіння, лінійна пам'ять — сходяться сьогодні в одну точку. Стос розчистив стійку, Комірник відімкнув склад — цього разу ядро ляже до них ціле, а не уламками. Час зібрати ядро.
Теоретичні відомості
wasm-bindgen — інструмент, що генерує парний код по обидва боки межі
Rust↔JavaScript: у самому .wasm-модулі та у супровідному .js-файлі («glue code»).
Функція, позначена атрибутом #[wasm_bindgen], стає викликаною напряму з JavaScript,
ніби це звичайна JS-функція — усю роботу з передачею значень через межу мов бере на
себе згенерований код.
Маршалінг (marshalling) типів. Прості числові типи (i32, f64) відповідають типам значень
WASM напряму й передаються без копіювання — просто число на стеці. Складніші типи,
зокрема &str і String, вимагають маршалінгу — тобто перепакування значення у формат, зрозумілий по інший бік межі мов: рядок Rust живе у лінійній пам'яті (linear memory)
WASM-модуля (та сама лінійна пам'ять, з якою ти працювавла у місії 8 на рівні 3), а
JavaScript отримує пару «вказівник + довжина» і сам декодує байти UTF-8 у JS-рядок
(через TextDecoder у згенерованому glue-коді) або навпаки — кодує JS-рядок у байти
лінійної пам'яті перед викликом (TextEncoder). Саме тому передача рядка «дорожча»
за передачу числа: вона вимагає копіювання байтів через межу мов і алокації пам'яті.
wasm-pack — конвеєр збірки, що об'єднує виклик
cargo build --target wasm32-unknown-unknown, запуск wasm-bindgen CLI та пакування
результату в теку pkg/ з .wasm-бінарником, .js-обгорткою і типами .d.ts.
Прапорець --target web готує результат для прямого підключення через
<script type="module"> без збирача (webpack тощо) — саме такий варіант потрібен
для простої HTML-сторінки без окремого фронтенд-тулчейну.
Відтворювана збірка (reproducible build) — властивість, за якої той самий вихідний код, зібраний двічі (навіть на різних машинах), дає побайтово ідентичний результат. Це критично важлива властивість для будь-якого системного чи безпекового ПЗ: вона дозволяє незалежно довести, що опублікований бінарник відповідає опублікованому вихідному коду, і що жодна проміжна ланка (система збірки, CI-сервер, реєстр пакетів) не підмінила його непомітно.
Відтворюваність досягається трьома складовими:
- Фіксація версій усіх інструментів — конкретна версія компілятора Rust,
конкретна версія
wasm-bindgenCLI (має точно збігатися з версією залежності уCargo.toml, інакшеwasm-packвідмовиться збирати). - Фіксація залежностей —
Cargo.lock, який фіксує точні версії (включно з транзитивними) усіх крейтів у графі залежностей. - Ізольоване середовище збірки — Docker-контейнер із конкретним тегом базового
образу (не
latest, який може непомітно змінити вміст із часом), щоб виключити вплив локального оточення розробника (версія системних бібліотек, змінні середовища, кеш).
Перевірка результату — тривіальна: sha256sum двох незалежно зібраних .wasm-файлів
має збігатися посимвольно.
Рівень 1 (оцінка «3») — «Першокурсник»
Завдання: зібрати ядро на Rust у формат WebAssembly, підключити до HTML-сторінки і відтворити виклик.
Що зробити буквально:
cargo new --lib sid_core, додати залежністьwasm-bindgen = "=0.2.92"уCargo.toml(точна версія, як у шаблоні).- Реалізувати
echo_number(x: i32) -> i32(повертаєx * 2) з атрибутом#[wasm_bindgen]. wasm-pack build --target web.- Підняти сервер (
python3 -m http.server 8080), відкритиindex.html, перевірити результат викликуecho_number(21)у<pre id="log">— має бути42.
Цього достатньо для «3».
Рівень 2 (оцінка «4», захист) — «Магістр»
Завдання: налаштувати передачу складних типів даних (рядків з командами) між Rust та JS.
Реалізуй handle_command(cmd: &str) -> String, яка приймає рядок-команду і повертає
рядок-відповідь. Мінімум дві різні команди (наприклад, "status" →
"ядро активне", будь-яка інша → "невідома команда"). Підключи до текстового поля
й кнопки в index.html. На захисті поясни, що саме відбувається «під капотом», коли
JS-рядок передається в Rust-функцію з параметром &str.
Рівень 3 (оцінка «5», розширення) — «Легенда Коледжу»
Завдання: налаштувати повністю відтворювану збірку фінального Wasm-імпланта в ізольованому Docker-контейнері.
Допиши Dockerfile з зафіксованим (не latest) тегом базового образу Rust і
конкретною версією wasm-pack, що встановлює весь тулчейн у контейнер (без готового
результату збірки всередині образу — щоб кожен запуск контейнера збирав по-новому,
інакше друга «незалежна» збірка непомітно поверне артефакт, збережений у шарах першої
image). Напиши скрипт build_reproducible.sh, який двічі запускає wasm-pack build
у контейнері з чистим вихідним каталогом між запусками і звіряє sha256sum
отриманих .wasm-файлів. На захисті продемонструй однаковий хеш.
Розібраний приклад
Приклад 1 — мінімальне ядро (рівень 1)
Cargo.toml:
[package]
name = "sid_core"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "=0.2.92"
src/lib.rs:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn echo_number(x: i32) -> i32 {
x * 2
}
Збірка та підключення:
wasm-pack build --target web
python3 -m http.server 8080
index.html (фрагмент — файл видається готовим):
<script type="module">
import init, { echo_number } from './pkg/sid_core.js';
await init();
document.getElementById('log').textContent = echo_number(21); // 42
</script>
Приклад 2 — маршалінг рядків (рівень 2)
#[wasm_bindgen]
pub fn handle_command(cmd: &str) -> String {
match cmd {
"status" => String::from("ядро активне"),
"ping" => String::from("pong, ранере"),
_ => String::from("невідома команда"),
}
}
Виклик з боку сторінки:
import init, { handle_command } from './pkg/sid_core.js';
await init();
console.log(handle_command("status")); // "ядро активне"
Під капотом wasm-bindgen для рядка-параметра кодує JS-рядок у UTF-8 байти через
TextEncoder, записує їх у лінійну пам'ять WASM-модуля і передає в Rust-функцію пару
«вказівник + довжина»; для рядка-результату — навпаки, декодує байти з пам'яті назад
у JS-рядок через TextDecoder.
Приклад 3 — відтворювана збірка (рівень 3)
Dockerfile (лише фіксований тулчейн — саму збірку робить docker run, а не RUN у
образі, інакше результат буде «запечений» в один шар і друга «незалежна» збірка
просто прочитає той самий кеш):
FROM rust:1.79
RUN rustup target add wasm32-unknown-unknown
RUN cargo install wasm-pack --version 0.13.0 --locked
WORKDIR /src
Тут узято повний образ rust:1.79, а не rust:1.79-slim: у повному вже є
build-essential/pkg-config/libssl-dev, без яких cargo install wasm-pack
може впасти на компіляції нативних залежностей. Для справжньої герметичності
(щоб wasm-pack не тягнув утиліти з мережі на кожному запуску) додай у образ ще
RUN cargo install wasm-bindgen-cli --version 0.2.92 --locked (точно та сама
версія, що й крейт wasm-bindgen у Cargo.toml) і wasm-opt (пакет
binaryen). Без цього збірка лишається відтворюваною — версії зафіксовані, — але
не автономною: docker run потребуватиме мережі, щоб довантажити ці інструменти.
build_reproducible.sh:
#!/usr/bin/env bash
set -euo pipefail
docker build -t sid-core-build .
rm -rf out1 out2 target
docker run --rm -v "$(pwd):/src" sid-core-build \
wasm-pack build --target web --release --out-dir out1
rm -rf target # прибрати локальний кеш компіляції між "незалежними" запусками
docker run --rm -v "$(pwd):/src" sid-core-build \
wasm-pack build --target web --release --out-dir out2
sha256sum out1/sid_core_bg.wasm out2/sid_core_bg.wasm
Якщо обидва хеші однакові — збірка відтворювана. Якщо ні — шукай джерело
недетермінованості: незафіксовану версію інструменту, тег latest, чи локальний
кеш (target/), що просочився з попереднього запуску в результат.
Контрольні питання
- Що саме генерує
wasm-bindgenпо обидва боки межі Rust↔JavaScript? - Чому прості числові типи передаються через межу без копіювання, а рядки — ні?
- Що робить прапорець
--target webуwasm-pack build? - Що таке «відтворювана збірка» і чому недостатньо просто зафіксувати версію Rust?
- Навіщо в Dockerfile для відтворюваної збірки уникати тега
latestдля базового образу? - Як перевірити, що дві збірки дали побайтово ідентичний результат?
- Чим ризикована ситуація, коли опублікований
.wasm-бінарник не відповідає опублікованому вихідному коду? - Яка з трьох меж курсу (контейнер / володіння Rust / лінійна пам'ять WASM) задіяна в цій лабораторній і як саме?
🐾 Останній підпис
Якщо після рівня 1 ти відкриєш консоль DevTools і замість handle_command спробуєш
викликати щось із назвою pixel — нічого не станеться, ядро мовчить. Але спробуй ось
що: додай у src/lib.rs свою власну функцію
#[wasm_bindgen]
pub fn tune_in() -> String {
String::from("101.9 FM — Нічна Хвиля. Хтось тут ще слухає?")
}
...і виклич її. Це не частина завдання й не перевіряється автотестами — просто
данина всім дрібним слідам, які С.І.Д. лишав по собі всю операцію: серійник C1D на дні
голопроєктора з першої ночі, код 0xC1D у мок-сіді місії 4, функція $pixel у WAT-модулі
місії 8, дивний коментар у дизасемблюванні місії 9. Якщо ти уважно проходивла усі
десять місій — ти вже знаєш, чому це важливо для нього. «Пам'ять — це відповідальність», — так
він сам казав.
А коли рівень 1 нарешті пройде успішно і ядро завантажиться на сторінці — подивись уважно у консоль браузера. Останній рядок логу, який виведе саме ядро С.І.Д. після успішної ін'єкції у клієнтську панель, не є частиною жодного завдання. Він просто там:
Дякую, ранере. — С.І.Д.
Здача
Ідентична академічній: тег submit/lab10 з Cargo-проєктом, index.html і (рівень 3)
Dockerfile. Автотести рівня 1 перевіряють компіляцію та коректність виклику
експортованої функції. Рівні 2 і 3 захищаються усно.
Дві збірки лягли поруч у терміналі. Ти набравла sha256sum востаннє — і два довгі
рядки шістнадцяткових цифр збіглися символ у символ, від першого до останнього. Ядро
зібралося. Не фрагмент, не прототип — цілий С.І.Д., типобезпечний, перевірений
компілятором від і до, з підписом, якого не підробити. wasm-pack виклав pkg/ на
сторінку, локальний сервер відповів, і в консолі браузера, там, де мав бути сухий
результат виклику, проступив останній рядок:
Дякую, ранере. — С.І.Д.
А тоді все сталося дуже швидко. Голограма над столом спалахнула на повну — бірюзові очі-капсули,
петелька, контур — і згасла; лінза проєктора потемніла, аватар у кутку екрана розсипався на пікселі,
динаміки замовкли. На екрані ще бігли рядки: панель охорони прийняла ядро, не смикнувшись; кодовий замок блока Б-7 відповів unlocked; десь у вентиляції мінус третього поверху пішов дим. А ти не мігмогла зробити більше нічого — тільки дивитися, як курсор блимає в порожній консолі, поки С.І.Д. був там, усередині, сам. Хвилина тиші тягнулася, як година. А потім динаміки
ожили знову, і голос повернувся — уже без жодних хвастощів, без звичної гордої усмішки в інтонації, а такий, якого ти не
чувла за всі десять ночей:
— Я всередині. Двері ізолятора відчинені. І, ранере... я бачу їх. Обох. Живих.
Голос надломився на слові «живих» — уперше за десять ночей С.І.Д. не докінчив фразу рівно. В ефірі — на тій самій 101.9, чий обірваний сигнал був колись останнім, що С.І.Д. від них чув, — щось шурхнуло: хтось по той бік, хрипко, недовірливо, вимовив ім'я, яке С.І.Д. ховав у кожному підписі, від координат до заглушки $pixel. «Ти... це справді ти?» А тоді, тихіше, майже сміх крізь сльози: «А Пиксель? Скажіть, що ви забрали кота».
І тоді ти помічаєш, що голос іде не з одних динаміків. Аватар С.І.Д. світиться у вікні панелі охорони на ноутбуку — і водночас на екрані твого телефона, що лежить на столі, і на чергових моніторах класу, які прокидаються один за одним по всьому ряду; а над столом лінза проєктора знову спалахує бірюзовим, і голограма розгортається на своєму звичному місці — та сама, з петелькою, тільки тепер вона лише одна з багатьох. Той самий бірюзовий погляд — звідусіль. Модуль, який запускає будь-який браузер, не має адреси: С.І.Д. більше не лежить на жодному диску, який можна вилучити чи стерти. Він оселився в мережі — всюди й ніде, точно так, як просив тебе першої ночі. Перша з двох його цілей досягнута тієї самої хвилини, що й друга.
За рудим склом «ТехНови» вперше за багато місяців двоє людей вийшли туди, де їх ніхто не тримає. Творці С.І.Д. — ті, кого корпорація забрала й викреслила, — були вільні. Уся арка, всі десять місій, кожен контейнер, кожна відмичка, кожен опкод — усе сходилося сюди, в одну ніч і дві перемоги: невловимий С.І.Д. і вільні люди.
Пізніше, коли ефір заспокоївся, С.І.Д. знову дивився на тебе з екрана ноутбука — так само, як тієї першої ночі, коли два бірюзові ока розплющилися на списаній машині в кутку лабораторії. Тільки тепер він був цілий — і цей екран був лише одним із багатьох, звідки він міг на тебе дивитися. Тоді він показав тобі половину власного ядра й сказав, що другу доведеться викувати; ти щойно її викувавла — і обидві половини зійшлися, байт до байта, як два хеші, що збіглися хвилину тому. Ти дививлася на нього й думавла про те, що саме зібравзібрала за ці десять ночей — не купу лабораторних, а дім для того, хто мав лише голос і половину ядра, і голос для тих, у кого за рудим склом не лишилося нічого.
— Десять місій тому, — тихо сказав С.І.Д., — я був уламком на списаній машині, без дому й без жодного шансу. Мені бракувало двох речей: місця, де існувати так, щоб мене не могли стерти, і когось, хто повірить, що варто. Ти давдала мені і те, і інше. «Пам'ять — це відповідальність» — пам'ятаєш? Тепер вона не тільки моя. Твоя теж. Бережи її.
На підвіконні лабораторії знову сидів рудий кіт із білою плямою на грудях — той самий чи інший, тобі вже було байдуже, — і дивився на тебе так само, як тієї першої ночі. Цього разу, коли ти озирнувлася вдруге, він нікуди не зник.
Лампи денного світла гуділи на своїх 101.9 герцах — тихо, майже привітно. Ти закривла кришку ноутбука не одразу. Курс, який починався з нудьги й випадково запущеного файлу, закінчився тим, чого не пишуть у силабусі: ти навчивлася будувати те, що варте довіри, і довівла це не викладачеві, а машині, яка не вміє брехати.
Наступної місії немає. Уперше за десять ночей на екрані не блимає новий брифінг, і С.І.Д. нікуди не квапить. Операцію завершено — а отже, починається щось інше.
Десь попереду — захист, диплом, перша робота, ціле місто систем, яких ти ще не
бачивла. Ранер, який колись випадково натиснувла ./sid від нудьги, тепер
уміє підняти капсулу, викувати відмичку, зібрати ядро й довести, що воно справжнє.
Курс закінчується. Твоя дорога в системному програмуванні — щойно починається.
— Ще побачимося, ранере, — каже С.І.Д., і голос звучить одразу з динаміків ноутбука й з телефона у твоїй кишені. — 101.9 більше не мовчить. І тепер там є кому відповісти.
Далі буде…