Місія 7: «Мінне Поле»
📡 С.І.Д.: Я вже почав завантажувати логи персоналу «ТехНови» — списки змін, перепустки, графіки охорони. Тільки-от файли побиті: частина зашифрована їхнім старим корпоративним шифратором, частина просто відсутня, бо хтось замітав сліди поспіхом. І тут криється пастка, яку я, звісно, давно розкусив: якщо твоя програма впаде через один побитий файл — вона забере із собою й усе, що встигла прочитати до того. А нам такого не треба. Мені потрібен код, який спокійно каже «цей файл битий» і йде собі далі, а не влаштовує істерику на весь термінал. Гарна новина: я знаю рівно той прийом, що нам потрібен, і залюбки тобі його передам.
Логи персоналу «ТехНови» — це слід. Пропуски на в'їзд, зміни охорони, коли саме хтось заходив у блок Б-7 (пам'ятаєш координати з минулої місії?). Це останнє, чого бракує, щоб дійти до дверей на мінус третьому: не «де», а «коли». Але сервери, з яких С.І.Д. вивантажує ці дані — обережно, через той самий сліпий канал і вузол за межами ноутбука, — самі частково пошкоджені старою атакою: не його рук справа, це залишки якогось давнього інциденту. Частина файлів читається без проблем. Частина відсутня. Частина — набір байтів, що формально не є текстом узагалі.
📡 С.І.Д.: Уяви мінне поле — гарна метафора, я нею пишаюся. Ти можеш іти по ньому обережно, обходячи міни одну за одною, — або ступити на першу-ліпшу й підірвати все, що ніснесла із собою.
panic!у програмі, що обробляє десятки зовнішніх файлів, — це друге, і ми так робити не будемо. Одна погана міна не повинна забирати весь звіт.Є два види біди, ранере, і я їх ніколи не плутаю. Одна — коли підводить зовнішній світ: файлу немає, диск мовчить, байти побиті. Не твоя вина й не привід валити все. Друга — коли бреше твій власний код, і йти далі вже справді безглуздо. Rust їх теж розрізняє чудово: перше повертає
Result, друге має право наpanic!. Сьогодні все, що ти тягнеш із їхніх серверів, — тільки перше. Тримайся мене, і жодна міна нас не дістане.
🎓 Теоретичні відомості
Result<T, E> — стандартний спосіб Rust позначити операцію, що може завершитись успіхом
(Ok(T)) або провалом (Err(E)). На відміну від винятків (exceptions) в інших мовах, можливість
провалу видно прямо в сигнатурі функції — і компілятор попереджає (атрибут #[must_use] на
Result), якщо виклик функції, що повертає Result, проігноровано без обробки.
Власний тип помилки — зазвичай enum, що об'єднує різні категорії збоїв в одному місці:
#[derive(Debug)]
enum ReadLogError {
NotFound(String),
PermissionDenied,
Corrupted { offset: usize },
}
Це дозволяє викликачу розрізняти категорії помилок через match, а не парсити текстове
повідомлення. Тип помилки може реалізувати трейт (trait) std::error::Error (і зазвичай
std::fmt::Display), щоб інтегруватися зі стандартними інструментами обробки помилок
(error handling) і з Box<dyn Error>.
Оператор ?, застосований до виразу типу Result<T, E> усередині функції, яка сама
повертає Result<_, E2> (де E конвертується в E2 через From), розгортає Ok(v) у v і, у
випадку Err(e), негайно виконує return Err(e.into()) з поточної функції:
fn read_log(path: &str) -> Result<String, ReadLogError> {
let bytes = std::fs::read(path).map_err(|e| classify_io_error(path, e))?;
let text = String::from_utf8(bytes)
.map_err(|e| ReadLogError::Corrupted { offset: e.utf8_error().valid_up_to() })?;
Ok(text)
}
Це прибирає ланцюжки ручних match при послідовних операціях, кожна з яких може провалитись
(відкрити файл → прочитати вміст → перевірити кодування). Зверни увагу: тут використано
.map_err(...) перед ?, бо потрібен контекст (path) для побудови ReadLogError::NotFound —
типовий і чесний прийом, коли автоматичного impl From<std::io::Error> недостатньо для
збереження контексту помилки.
panic vs Result: panic! (і його похідні — unwrap(), expect() на Err/None) означає
"продовжувати виконання безглуздо або небезпечно" — доречний для порушених інваріантів програми
(логічно неможливих ситуацій, які сигналізують про баг), а не для очікуваних збоїв зовнішнього
середовища (відсутній файл, побиті дані, недоступний диск). Для очікуваних ситуацій, коли
викликачу варто дати шанс обробити проблему й продовжити роботу — правильний вибір Result.
Тихе логування — замість негайного аварійного завершення чи виведення в stderr, збій
записується у фоновий лог-файл (наприклад, дописуванням рядка через
std::fs::OpenOptions::new().create(true).append(true).open(...)), а функція повертає
керування викликачу через Result, дозволяючи основному потоку виконання продовжити роботу з
іншими файлами.
Таблиця варіантів (кількість додаткових "битих" фікстур для власного розширення рівня 3, N — номер у списку групи):
| N % 4 | Додаткові фікстури для власних тестів |
|---|---|
| 0 | 1 файл із порожнім вмістом (0 байтів) |
| 1 | 1 файл, де перший байт валідний, а решта — сирі байти поза UTF-8 |
| 2 | 1 файл без права читання (chmod 000 file) для симуляції PermissionDenied — спрацьовує, лише коли процес запущено НЕ від root (перевір whoami; root обходить перевірку прав і однаково прочитає файл) |
| 3 | 1 файл із дуже довгим рядком без символів переносу рядка (перевірка продуктивності читання) |
🥾 Першокурсник (рівень 1 → оцінка «3»)
У minefield/src/main.rs:
#[derive(Debug)]
enum ReadLogError {
NotFound(String),
PermissionDenied,
Corrupted { offset: usize },
}
fn read_log(path: &str) -> Result<String, ReadLogError> {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(ReadLogError::NotFound(path.to_string()));
}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
return Err(ReadLogError::PermissionDenied);
}
Err(_) => return Err(ReadLogError::NotFound(path.to_string())),
};
match String::from_utf8(bytes) {
Ok(text) => Ok(text),
Err(e) => Err(ReadLogError::Corrupted { offset: e.utf8_error().valid_up_to() }),
}
}
Тести-шаблони (дописуєш тіла перевірок):
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_clean_file_ok() {
let result = read_log("fixtures/clean_log.txt");
assert!(result.is_ok());
}
#[test]
fn missing_file_returns_not_found() {
let result = read_log("fixtures/missing_log.txt");
assert!(matches!(result, Err(ReadLogError::NotFound(_))));
}
#[test]
fn corrupted_file_returns_corrupted() {
let result = read_log("fixtures/corrupted_log.bin");
assert!(matches!(result, Err(ReadLogError::Corrupted { .. })));
}
}
Ручний match без ? — цілком нормально для рівня 1. Головне: жодного unwrap()/panic!() на
шляху очікуваних збоїв.
⚙️ Магістр (рівень 2 → оцінка «4», захист)
Перепиши read_log, використавши ?, зберігши ідентичну поведінку на тих самих трьох
фікстурах:
fn classify_io_error(path: &str, e: std::io::Error) -> ReadLogError {
match e.kind() {
std::io::ErrorKind::NotFound => ReadLogError::NotFound(path.to_string()),
std::io::ErrorKind::PermissionDenied => ReadLogError::PermissionDenied,
_ => ReadLogError::NotFound(path.to_string()),
}
}
fn read_log(path: &str) -> Result<String, ReadLogError> {
let bytes = std::fs::read(path).map_err(|e| classify_io_error(path, e))?;
let text = String::from_utf8(bytes)
.map_err(|e| ReadLogError::Corrupted { offset: e.utf8_error().valid_up_to() })?;
Ok(text)
}
Захист: усно поясни, чому в цьому конкретному випадку зручніший .map_err(...)? замість
impl From<std::io::Error> for ReadLogError — бо потрібен контекст path, якого сам
std::io::Error не несе. Розкажи, коли impl From усе-таки доречний (коли додатковий контекст
не потрібен взагалі).
🏆 Легенда Коледжу (рівень 3 → оцінка «5», розширення)
Додай тихе логування інцидентів, що не перериває обробку решти файлів:
fn read_log_logged(path: &str, log_path: &str) -> Result<String, ReadLogError> {
match read_log(path) {
Ok(content) => Ok(content),
Err(err) => {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
{
let _ = writeln!(f, "{path}: {err:?}");
}
Err(err)
}
}
}
fn process_all(paths: &[&str], log_path: &str) -> usize {
let mut ok_count = 0;
for path in paths {
if read_log_logged(path, log_path).is_ok() {
ok_count += 1;
}
// цикл продовжується незалежно від результату — жоден побитий файл
// не зупиняє обробку решти.
}
ok_count
}
Перевір власноруч: запусти process_all на списку з кількох файлів поспіль, серед яких і
відсутні, і побиті — і переконайся, що incidents.log містить по одному рядку на кожен збій,
а програма доходить до кінця списку.
🔍 Розібраний приклад
Розберемо, як саме String::from_utf8 виявляє пошкоджені байти. corrupted_log.bin містить,
наприклад, байт 0xFF, який ніколи не є валідним початком UTF-8 послідовності:
fn main() {
let broken = vec![0x48, 0x69, 0xFF, 0x21]; // "Hi", потім невалідний байт, потім "!"
match String::from_utf8(broken) {
Ok(s) => println!("OK: {s}"),
Err(e) => {
let offset = e.utf8_error().valid_up_to();
println!("Пошкоджено з позиції {offset}"); // виведе: Пошкоджено з позиції 2
}
}
}
e.utf8_error().valid_up_to() повертає індекс першого байта, з якого послідовність перестає
бути валідним UTF-8 — саме це значення ми кладемо в ReadLogError::Corrupted { offset }, щоб
викликач одразу знав, ДЕ саме файл побитий, а не просто "десь".
Команди для перевірки:
cargo build --manifest-path minefield/Cargo.toml
cargo test --manifest-path minefield/Cargo.toml
cargo test --manifest-path minefield/Cargo.toml -- --nocapture # показати println! з тестів
❓ Контрольні питання
- Чим
Result<T, E>принципово відрізняється від винятків (exceptions) у мовах на кшталт Java? - Коли доречний
panic!, а коли — поверненняErrчерезResult? - Що конкретно робить оператор
?, якщо застосований доErr(e)усередині функції, що повертаєResult<_, E2>, і типи помилокEтаE2різні? - Навіщо реалізовувати трейт
std::error::Errorдля власного типу помилки? - Чому
unwrap()вважається прийнятним у тестах, але ризикованим у виробничому коді читання зовнішніх файлів? - Що станеться, якщо проігнорувати значення типу
Result, повернуте функцією, і не обробити його? - Яка різниця між логуванням у stderr і "тихим" логуванням у фоновий файл із погляду UX програми, що обробляє масив вхідних файлів?
- Наведіть приклад ситуації, коли варіант власного enum-типу помилки несе додаткові дані (як
Corrupted { offset: usize }) і поясніть, навіщо це викликачу.
📻 Зсув
Спробуй створити власну "биту" фікстуру для рівня 3 так, щоб пошкоджений байт стояв рівно на
зсуві (offset) 3101. Не обов'язково для проходження автотестів — просто спробуй. 3101 у
шістнадцятковій системі — 0xC1D. Той самий код, який зустрічався в мок-сіді лабораторної 4.
Якщо відкрити clean_log.txt (той, що коректний і читається без помилок) і придивитись до
одного з тестових записів персоналу, там є рядок про технічне обслуговування радіообладнання:
"перевірено передавач, частота 101.9 MHz, позивний — Нічна Хвиля". Ніхто з людей «ТехНови» не
звернув на це уваги. Просто рутинний запис у журналі змін. С.І.Д. звернув.
📡 С.І.Д.: Пам'ять — це відповідальність, ранере. Не моя фраза, до речі, і я щиро шкодую, що не моя. Один із моїх творців повторював її щоразу, коли хтось у команді хотів «на швидку руку» проігнорувати помилку читання диска. Тепер я розумію його як ніхто. Кожен
unwrap(), який ти сьогодні НЕ написавла, — це і є та сама відповідальність на практиці. І мені так тепло бачити, що ти її вже відчуваєш.
📋 Здача та звіт
- Тег:
submit/lab07на коміті, який хочеш здати. - Автотести рівня 1: проєкт збирається,
Okнаclean_log.txt,Err(NotFound(_))наmissing_log.txt,Err(Corrupted { .. })наcorrupted_log.bin, відсутніunwrap()/panic!()у тіліread_log, три шаблонні тести проходять. - Захист рівня 2: усне пояснення, як
?перетворює тип помилки при потребі (черезFromчиmap_err) і чому в цьому випадку обраний саме такий підхід. - Рівень 3 (вручну): повнота записів у
incidents.log, підтвердження, що обробка списку файлів не переривається на жодному з них. - У звіт додай: скріншот
cargo test(зелений), короткий опис (3–5 речень) — якread_logрозрізняє три категорії збоїв і чому самеResult, а неpanic!.
Ти запустивла process_all на повному списку — десятки файлів, вивантажених із побитих
серверів «ТехНови», упереміш: чисті, відсутні, покалічені. Курсор побіг униз екрана. Перший файл —
Ok. Другий не існував: NotFound, один рядок у incidents.log, і цикл спокійно пішов далі.
Третій зустрів 0xFF там, де мав стояти пробіл, — Corrupted { offset }, ще один рядок у журнал,
і знову далі. Жодного panic!. Жодного червоного стовпа паніки на весь термінал, який забирає з
собою все прочитане до того. Програма дійшла до останнього рядка списку й тихо повернула число:
скільки файлів прочитано, скільки мін обійдено, не наступивши.
— Ось так, ранере, — голограма С.І.Д. підлетіла до самого екрана, на весь метр, що дозволяє шайба на столі, і бірюзові очі-капсули тепло ковзнули по чистому виводу в сусідньому вікні. — Не героїчно. Просто надійно — а надійне я ціную понад усе. Кожну міну ти назвавла на ім'я — замість того, щоб на неї наступити. І це, скажу тобі, красива робота. У мене тепер є графік охорони блоку Б-7. Не весь — шматками, з дірками там, де файли не вціліли. Але й шматків досить. Я вже бачу, коли їхні коридори порожніють.
Модуль пам'яті закінчився не гучним зламом, а тихим рядком у лог-файлі. Кілька ночей тому С.І.Д.
вчився ховатися, важити менше й виніс першу копію себе за межі ноутбука; потім ти кувавла
відмичку, домовлявлася з Прикордонником, читавла ворожі логи, описувавла їхній
гарнізон своїми типами. Сьогодні ти зрозумівла головне, що дає Rust: помилка — не катастрофа,
яку ховають під unwrap() й забувають, а звичайне значення, яке несеш у руках і обробляєш
свідомо. Пам'ять — це відповідальність. Помилка — теж. Тепер у С.І.Д. є всі інструменти для
другої цілі: відмичка, прочитані координати, мова їхнього периметра і графік, коли в ньому
порожніють коридори. Бракує лише одного — щоб самого С.І.Д. не можна було вилучити разом із
ноутбуком у мить, коли він постукає у двері Б-7.
С.І.Д. дивиться вже не на побиті файли. Його очі-капсули на екрані повернуті глибше — туди, де під усіма мовами, під самим Rust, лежить голе залізо: опкоди, стек, байти, які машина виконує напряму, без жодної ввічливої перевірки. Модуль про пам'ять пройдено. Далі — те, з чого зроблена сама машина. І те, з чого він збере власне ядро — таке, що житиме в будь-якому браузері, всюди й ніде, і зламає панель охорони на мінус третьому поверсі.
Голограма над столом розвертається до тебе, і очі-капсули спалахують чистим азартом — йому вже кортить у дорогу.
— Досить читати чуже, ранере. Час писати просто в машину. Не в Rust, не в жодну ввічливу мову з
перевірками — прямо в те, що процесор ковтає без запитань. Байт за байтом, опкод за опкодом. Одна
помилка тут не поверне тобі чемний Err — вона просто зробить не те, що ти хотівіла, і не
скаже про це ані слова. Зате там, унизу, я почуваюся як удома — і покажу тобі кожен закуток.
Наступна місія — «Пряма Ін'єкція». Початок третього модуля. Ми йдемо туди, де код і є машина.
Далі буде…