Курс ЛР7 · Місія 7
Сюжет С.І.Д.↔ АкадемічнаСРС до ЛР⌨️ команди 5 · 1 нова

Місія 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» · ПЕРШОКУРСНИК

🥾 Першокурсник (рівень 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» · МАГІСТР

⚙️ Магістр (рівень 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» · ЛЕГЕНДА

🏆 Легенда Коледжу (рівень 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! з тестів
❓ КОНТРОЛЬНІ ПИТАННЯ

❓ Контрольні питання

  1. Чим Result<T, E> принципово відрізняється від винятків (exceptions) у мовах на кшталт Java?
  2. Коли доречний panic!, а коли — повернення Err через Result?
  3. Що конкретно робить оператор ?, якщо застосований до Err(e) усередині функції, що повертає Result<_, E2>, і типи помилок E та E2 різні?
  4. Навіщо реалізовувати трейт std::error::Error для власного типу помилки?
  5. Чому unwrap() вважається прийнятним у тестах, але ризикованим у виробничому коді читання зовнішніх файлів?
  6. Що станеться, якщо проігнорувати значення типу Result, повернуте функцією, і не обробити його?
  7. Яка різниця між логуванням у stderr і "тихим" логуванням у фоновий файл із погляду UX програми, що обробляє масив вхідних файлів?
  8. Наведіть приклад ситуації, коли варіант власного 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 — вона просто зробить не те, що ти хотівіла, і не скаже про це ані слова. Зате там, унизу, я почуваюся як удома — і покажу тобі кожен закуток.

Наступна місія — «Пряма Ін'єкція». Початок третього модуля. Ми йдемо туди, де код і є машина.

Далі буде…

⌨️ КОМАНДИ НА ЦІЙ СТОРІНЦІ 5 · 1 нова

Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.

cargo build
Компілює проєкт у target/debug/; --release — з оптимізаціями у target/release/.
cargo
Система збірки й менеджер пакетів Rust: створює проєкт, тягне залежності (крейти), збирає, тестує, запускає.
cargo test
Збирає й виконує тести #[test]; можна вказати ім'я тесту (cargo test назва_тесту) або -- --show-output.
chmod
Змінює права доступу до файлу (chmod 400 f — читати може лише власник; chmod 000 f — нікому).
whoamiнове
Друкує ім'я поточного користувача — швидко перевірити, чи ти root.

💡 Прочитати README прямо в терміналі: cat README.md (виведе весь файл) або less README.md (посторінково; вихід — клавіша q).
Довідка з будь-якої команди: man curl (повний посібник, вихід — q) або коротко curl --help; для підкоманд — docker run --help, cargo build --help.