Раздел 23 · Rust

Тип одного варианта: компоненты пути

senior~40 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Тип одного варианта: компоненты пути

Ты пишешь сборщик отчётов. Каталог уже выбран: /srv/reports. Остаётся присоединить имя файла, которое передал другой модуль. Кажется, join просто поставит разделитель между двумя строками:

use std::path::Path;

fn main() {
    #[cfg(unix)]
    {
        let base = Path::new("/srv/reports");
        assert_eq!(base.join("today.csv"), Path::new("/srv/reports/today.csv"));
        assert_eq!(base.join("/tmp/today.csv"), Path::new("/tmp/today.csv"));
    }
}

Второй вызов отбросил выбранный каталог. Ошибки нет: таков контракт Path::join, абсолютный правый аргумент заменяет левый путь. А ../today.csv сохранит .. в результате на Unix: это уже не обещание «добавить одно имя», а инструкция перейти к родителю.

Проблема не в join. Ты передал путь туда, где ожидал только имя. &OsStr умеет хранить оба значения и не различает их смысл.

Идею урока предложил Richard Schneeman (Schneems) в статье A Type Stronger than the Sum of its Components. Он выделил отдельные типы для вариантов std::path::Component. Здесь ты построишь заимствующий вариант и разберёшь границы его гарантий. Это следующий шаг после проверяемых newtype: сохрани не только правильность значения, но и известный вариант enum.

Enum знает несколько случаев, функция хочет один

Path::components() разбирает путь на компоненты. На Unix путь /srv/reports/today.csv даёт корень и три обычных имени. У Component пять вариантов:

ВариантЧто означает
PrefixПрефикс Windows, например диск или сетевой ресурс
RootDirКорень
CurDirТекущий каталог, .
ParentDirРодитель, ..
Normal(&OsStr)Обычный компонент имени

Сигнатура fn append(component: Component<'_>) принимает все пять случаев. Даже если вызывающий код только что нашёл Normal, тело append обязано снова разобрать enum. Тип параметра потерял факт выбора ветки.

Сузь параметр до NormalComponent<'_>. Такой тип станет

свидетелем

того, что перед тобой ровно один обычный компонент. Для ParentDir можно сделать другой тип и другую операцию. Не нужно выделять все пять типов, если твой API использует только два.

Закрой конструктор, а не только назови тип

У Component есть тонкость: варианты публичные. Любой код может написать Component::Normal(OsStr::new("../escape")), не вызывая components(). Поэтому один match над произвольным Component ещё не доказательство. Публичная конверсия должна проверить и содержимое варианта.

Следующий модуль содержит весь API: проверку имени, абсолютный путь, присоединение и сохранение имени с владением. Помести его в main.rs. Следующий за ним main будет использовать этот же модуль.

mod checked_path {
    use std::ffi::{OsStr, OsString};
    use std::path::{Component, Path, PathBuf};

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub struct InvalidNormal;

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub struct NormalComponent<'a>(&'a OsStr);

    impl<'a> NormalComponent<'a> {
        pub fn parse(raw: &'a Path) -> Result<Self, InvalidNormal> {
            let mut parts = raw.components();
            match (parts.next(), parts.next()) {
                (Some(Component::Normal(name)), None)
                    if name == raw.as_os_str() => Ok(Self(name)),
                _ => Err(InvalidNormal),
            }
        }

        pub fn as_os_str(self) -> &'a OsStr {
            self.0
        }

        pub fn to_owned(self) -> OwnedNormalComponent {
            OwnedNormalComponent(self.0.to_os_string())
        }
    }

    impl<'a> TryFrom<Component<'a>> for NormalComponent<'a> {
        type Error = InvalidNormal;

        fn try_from(component: Component<'a>) -> Result<Self, Self::Error> {
            match component {
                Component::Normal(name) => Self::parse(Path::new(name)),
                _ => Err(InvalidNormal),
            }
        }
    }

    #[derive(Debug)]
    pub struct OwnedNormalComponent(OsString);

    impl OwnedNormalComponent {
        pub fn as_borrowed(&self) -> NormalComponent<'_> {
            NormalComponent(&self.0)
        }
    }

    #[derive(Debug, PartialEq, Eq)]
    pub struct NotAbsolute;

    #[derive(Debug)]
    pub struct AbsPath(PathBuf);

    impl TryFrom<PathBuf> for AbsPath {
        type Error = NotAbsolute;

        fn try_from(raw: PathBuf) -> Result<Self, Self::Error> {
            if raw.is_absolute() {
                Ok(Self(raw))
            } else {
                Err(NotAbsolute)
            }
        }
    }

    impl AbsPath {
        pub fn as_path(&self) -> &Path {
            &self.0
        }

        pub fn join_normal(&self, name: NormalComponent<'_>) -> Self {
            Self(self.0.join(name.as_os_str()))
        }
    }
}

Поля приватные именно за границей checked_path. Внутри модуля ты всё ещё можешь нарушить инвариант, поэтому каждый способ создания входит в доверенную часть реализации. Снаружи нет ни сырого конструктора, ни &mut OsStr, ни &mut PathBuf, которые позволили бы заменить содержимое.

parse проверяет вариант, а не только число компонентов: у . и .. тоже по одному компоненту. Пустой путь не даёт ни одного. Корень не имя. a/b даёт два. Все эти случаи отклоняются без промежуточного Vec.

Сравнение с raw.as_os_str() тоже важно. Итератор частично нормализует запись: убирает повторные и завершающие разделители, а также внутренние .. Поэтому у name/ и name/. может остаться один Normal("name"). Начальное ./name, напротив, сохраняет CurDir. Наш сырой разбор требует ровно исходную запись одного имени и отклоняет все эти формы. Это намеренно строгая политика нашего API, не универсальное определение обычного компонента. Сравниваются OsStr, а не Path: сравнение путей само учитывает компоненты.

При извлечении компонента из большого пути конверсия получает только имя. Она не может обещать, что исходный большой путь не имел завершающего /. Это другой контракт: свидетель описывает компонент, не всю исходную запись.

Проверь границы на коротких входах

Добавь этот main после модуля. Он не создаёт файлов и не требует существующего каталога reports: типы здесь доказывают форму пути.

use checked_path::{AbsPath, NormalComponent};
use std::ffi::OsStr;
use std::path::{Component, Path, PathBuf};

fn main() {
    for raw in ["", ".", "..", "a/b", "./name", "name/", "name/."] {
        assert!(NormalComponent::parse(Path::new(raw)).is_err(), "{raw:?}");
    }
    assert!(NormalComponent::parse(Path::new(std::path::MAIN_SEPARATOR_STR)).is_err());
    assert!(AbsPath::try_from(PathBuf::from("reports")).is_err());

    let name = NormalComponent::parse(Path::new("today.csv")).unwrap();
    let base = AbsPath::try_from(std::env::current_dir().unwrap()).unwrap();
    let joined = base.join_normal(name);
    assert!(joined.as_path().is_absolute());
    assert_eq!(joined.as_path(), base.as_path().join("today.csv"));

    let forged = Component::Normal(OsStr::new("../escape"));
    assert!(NormalComponent::try_from(forged).is_err());
    let parsed = Path::new("dir/today.csv").components().last().unwrap();
    assert_eq!(NormalComponent::try_from(parsed).unwrap(), name);

    let saved = {
        let source = PathBuf::from("memo.txt");
        NormalComponent::parse(&source).unwrap().to_owned()
    };
    assert_eq!(saved.as_borrowed().as_os_str(), OsStr::new("memo.txt"));

    #[cfg(unix)]
    {
        use std::os::unix::ffi::OsStrExt;
        let raw = OsStr::from_bytes(b"report-\xff");
        assert!(raw.to_str().is_none());
        assert_eq!(NormalComponent::parse(Path::new(raw)).unwrap().as_os_str(), raw);
    }
}

Все утверждения должны пройти. На Unix последнее имя не UTF-8, но это обычный компонент. Поэтому внутри нет String, to_str().unwrap() или to_string_lossy(): последняя операция могла бы изменить имя. Тип не обещает допустимость каждого имени для файловой системы: например, запрет нулевого байта и правила имён Windows требуют отдельного контракта.

NormalComponent заимствует существующее имя без выделения памяти. TryFrom<PathBuf> забирает готовый буфер, не копируя его. join_normal создаёт новый принадлежащий результат и не меняет исходный путь. Ему не нужна повторная проверка is_absolute: обычное имя не заменяет корень или префикс. Аллокация результата относится к операции join, а не к доказательству имени.

В статье Schneeman хранит OsString. Это полезно, когда имя должно пережить исходный путь, например остаться в очереди обработки. Здесь такая копия появляется только по явному to_owned. as_borrowed не разбирает имя повторно: закрытый владеющий тип уже сохраняет гарантию. Выбирай владение по сроку жизни данных, не по привычке.

Вариант без данных тоже заслуживает типа

ParentDir не хранит имя. Ему достаточно закрытого единичного свидетеля. Этот самостоятельный пример показывает лексический переход к родителю:

mod parent_step {
    use std::path::{Component, Path};

    #[derive(Debug)]
    pub struct ParentDirComponent(());

    impl TryFrom<Component<'_>> for ParentDirComponent {
        type Error = &'static str;

        fn try_from(component: Component<'_>) -> Result<Self, Self::Error> {
            match component {
                Component::ParentDir => Ok(Self(())),
                _ => Err("ожидался ParentDir"),
            }
        }
    }

    pub fn up(position: &Path, _step: ParentDirComponent) -> Option<&Path> {
        position.parent()
    }
}

fn main() {
    use parent_step::{ParentDirComponent, up};
    use std::path::{Component, Path};

    let step = ParentDirComponent::try_from(Component::ParentDir).unwrap();
    assert_eq!(up(Path::new("a/b"), step), Some(Path::new("a")));
    assert!(ParentDirComponent::try_from(Component::CurDir).is_err());
}

Здесь конверсии достаточно сравнить вариант: у ParentDir нет произвольного содержимого. Но свидетель не привязан к исходному пути и не доказывает, что каталог существует. up использует Path::parent, не ходит на диск и не разрешает символические ссылки. Это операция над записью пути.

Абсолютный не значит безопасный

Раздели обещания, прежде чем дать типу имя SafePath:

Факт или операцияЧто ты действительно знаешь
is_absolute()Путь не зависит от текущего каталога по правилам платформы
components()Получено частично нормализованное разбиение, без обращения к диску
Лексическое сворачивание a/../bПереписана запись; физический адрес мог измениться
canonicalize()При успешном вызове разрешены ссылки и получен абсолютный путь
Проверка существованияНаблюдение состояния файловой системы в конкретный момент
Удержание внутри песочницыНужна отдельная политика доступа, а не только форма пути

AbsPath допускает .. в исходной базе. join_normal не очищает её и не превращает в канонический путь. Даже одно обычное имя может оказаться символической ссылкой наружу: /srv/reports/export лексически находится под reports, но export может вести в /private.

На Unix представь ссылку a/link, ведущую в /other/dir. Путь a/link/../file разрешается через цель ссылки; механическая замена link/.. на пустую строку дала бы другой адрес. Поэтому components() не сворачивает ParentDir.

std::fs::canonicalize обращается к файловой системе. Если промежуточный skipped отсутствует, base/skipped/.. может завершиться ошибкой, а не превратиться успешно в base. Если промежуточная часть не каталог, это тоже ошибка. Канонизация не заменяет доступ к отсутствующему дереву лексической арифметикой.

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

Windows отдельно. Абсолютность требует префикс и корень: C:\\temp абсолютен, C:temp и \\temp нет. У join есть отдельные правила для аргументов с префиксом или только корнем. canonicalize возвращает запись расширенной длины с префиксом вида \\?\; её семантику нельзя выводить из поведения Unix. Документация Rust отдельно описывает нормализацию . и .. при join к базе с таким префиксом. Поэтому не делай вывод «канонизация, join и снова канонизация всегда работают» или «всегда падают». Проверяй конкретную операцию на целевой платформе. Примеры с cfg(unix) в этом уроке не проверяют Windows.

Приём работает не только с путями

Представь Message::Text(&str) и Message::Close. Функция подсчёта слов не должна принимать закрытие соединения. Выдели её допустимый аргумент:

mod messages {
    pub enum Message<'a> { Text(&'a str), Close }
    pub struct TextMessage<'a>(&'a str);

    impl<'a> TryFrom<Message<'a>> for TextMessage<'a> {
        type Error = &'static str;

        fn try_from(message: Message<'a>) -> Result<Self, Self::Error> {
            match message {
                Message::Text(text) => Ok(Self(text)),
                Message::Close => Err("это не текстовое сообщение"),
            }
        }
    }

    pub fn word_count(message: TextMessage<'_>) -> usize {
        message.0.split_whitespace().count()
    }
}

fn main() {
    use messages::{Message, TextMessage, word_count};
    let text = TextMessage::try_from(Message::Text("два слова")).unwrap();
    assert_eq!(word_count(text), 2);
    assert!(TextMessage::try_from(Message::Close).is_err());
}

Этот тип обещает только вариант Text, не непустоту и не достоверность сообщения. В отличие от пути, произвольная строка здесь допустима: дополнительный разбор содержимого не нужен. Когда операция принимает конкретный случай, узкий тип убирает невозможные для неё ветки.

Что унести

Enum описывает выбор между случаями. Отдельный тип может сохранить уже выбранный случай для следующей функции. Его гарантию определяют закрытая граница создания и все доступные операции, а не удачное название.

Для путей различай одно имя, абсолютную запись и физический доступ. Заимствуй, пока исходные данные живы; копируй только при необходимости владения. Не приписывай лексическому доказательству защиту песочницы.

домашка

Домашка