[Перевод] Как не переписать проект на Rust

Advertisements

Как только вы переступаете через болевой порог Борроу-Чекера и осознаёте, что Rust позволяет вытворять невообразимые (и порой опасные) в других языках вещи, вас может постигнуть настолько же непреодолимое желание Переписать Всё на Rust. Хоть и в лучшем случае это банально непродуктивно (бессмысленное разбазаривание усилий на несколько проектов), а в худшем — приводит к уменьшению качества кода (ведь с чего вы считаете себя более опытным в области применения библиотеки, чем её изначальный автор?)

Гораздо полезнее будет предоставить безопасный интерфейс для оригинальной библиотеки, повторно используя её код.

Первые шаги
Собираем chmlib-sys
Пишем безопасную обёртку на Rust
  • Поиск элементов по имени
  • Обход элементов по фильтру
  • Чтение содержимого файлов
Добавляем примеры
  • Оглавление CHM-файла
  • Распаковка CHM-файла на диск
Что дальше?

В этой статье рассматривается реальный проект. Мне надо было вытащить информацию из существующих CHM-файлов, а времени разбираться в формате не было. Лень — двигатель прогресса.

Крейт chmlib опубликован на crates.io, его исходный код доступен на GitHub. Если вы нашли его полезным или нашли в нём проблемы, то дайте мне знать через багтрекер.

Первые шаги

Для начала стоит разобраться в том, как изначально задумывалась работа с библиотекой.

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

Не пропускайте этот шаг!

Мы будем работать с CHMLib, библиотекой на Си для чтения файлов Microsoft Compiled HTML Help (.chm).

Начнём с создания нового проекта и подключения CHMLib в виде git-подмодуля:

$ git init chmlib && cd chmlib
  Initialized empty Git repository in /home/michael/Documents/chmlib/.git/
$ touch README.md Cargo.toml
$ cargo new --lib chmlib
  Created library `chmlib` package
$ cargo new --lib chmlib-sys
  Created library `chmlib-sys` package
$ cat Cargo.toml
  [workspace]
  members = ["chmlib", "chmlib-sys"]
$ git submodule add git@github.com:jedwing/CHMLib.git vendor/CHMLib
  Cloning into '/home/michael/Documents/chmlib/vendor/CHMLib'...
  remote: Enumerating objects: 99, done.
  remote: Total 99 (delta 0), reused 0 (delta 0), pack-reused 99
  Receiving objects: 100% (99/99), 375.51 KiB | 430.00 KiB/s, done.
  Resolving deltas: 100% (45/45), done.

После этого глянем, что там внутри, с помощью tree:

$ tree vendor/CHMLib
vendor/CHMLib
├── acinclude.m4
├── AUTHORS
├── ChangeLog
├── ChmLib-ce.zip
├── ChmLib-ds6.zip
├── configure.in
├── contrib
│   └── mozilla_helper.sh
├── COPYING
├── Makefile.am
├── NEWS
├── NOTES
├── README
└── src
    ├── chm_http.c
    ├── chm_lib.c
    ├── chm_lib.h
    ├── enum_chmLib.c
    ├── enumdir_chmLib.c
    ├── extract_chmLib.c
    ├── lzx.c
    ├── lzx.h
    ├── Makefile.am
    ├── Makefile.simple
    └── test_chmLib.c

2 directories, 23 files

Похоже, библиотека использует GNU Autotools для сборки. Это нехорошо, потому что всем пользователям крейта chmlib (и их пользователям) потребуется устанавливать Autotools.

Мы постараемся избавиться от этой «заразной» зависимости, собирая код на Си вручную, но об этом позже.

Файлы lzx.h и lzx.c содержат реализацию алгоритма сжатия LZX. Вообще лучше было бы использовать какую-нибудь библиотеку liblzx, чтобы получать обновления бесплатно и всё такое, но, пожалуй, проще будет тупо скомпилировать эти файлы.

enum_chmLib.c, enumdir_chmLib.c, extract_chmLib.c, похоже, являются примерами использования функций chm_enumerate(), chm_enumerate_dir(), chm_retrieve_object(). Это пригодится…

В файле test_chmLib.c находится ещё один пример, на это раз извлекающий одну страницу из CHM-файла на диск.

chm_http.c реализует простой HTTP-сервер, показывающий CHM-файл в браузере. Вот это, наверное, уже не пригодится.

Вот мы и разобрали всё, что находится в vendor/CHMLib/src. Будем собирать библиотеку?

Честно говоря, она достаточно маленькая, чтобы применить метод научного тыка.

$ clang chm_lib.c enum_chmLib.c -o enum_chmLib
  /usr/bin/ld: /tmp/chm_lib-537dfe.o: in function `chm_close':
  chm_lib.c:(.text+0x8fa): undefined reference to `LZXteardown'
  /usr/bin/ld: /tmp/chm_lib-537dfe.o: in function `_chm_decompress_region':
  chm_lib.c:(.text+0x18ca): undefined reference to `LZXinit'
  /usr/bin/ld: /tmp/chm_lib-537dfe.o: in function `_chm_decompress_block':
  chm_lib.c:(.text+0x2900): undefined reference to `LZXreset'
  /usr/bin/ld: chm_lib.c:(.text+0x2a4b): undefined reference to `LZXdecompress'
  /usr/bin/ld: chm_lib.c:(.text+0x2abe): undefined reference to `LZXreset'
  /usr/bin/ld: chm_lib.c:(.text+0x2bf4): undefined reference to `LZXdecompress'
  clang: error: linker command failed with exit code 1 (use -v to see invocation)

Ладненько, может этот LZX всё же нужен…

$ clang chm_lib.c enum_chmLib.c lzx.c -o enum_chmLib

Э-э-э… и всё?

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

$ curl http://www.innovasys.com/static/hs/samples/topics.classic.chm.zip 
           -o topics.classic.chm.zip
$ unzip topics.classic.chm.zip
Archive:  topics.classic.chm.zip
  inflating: output/compiled/topics.classic.chm
$ file output/compiled/topics.classic.chm
output/compiled/topics.classic.chm: MS Windows HtmlHelp Data

Посмотрим, как с ним справится enum_chmLib:

$ ./enum_chmLib output/compiled/topics.classic.chm
output/compiled/topics.classic.chm:
 spc    start   length   type           name
 ===    =====   ======   ====           ====
   0        0        0   normal dir     /
   1  5125797     4096   special file       /#IDXHDR
   ...
   1  4944434    11234   normal file        /BrowserView.html
   ...
   0        0        0   normal dir     /flash/
   1   532689      727   normal file        /flash/expressinstall.swf
   0        0        0   normal dir     /Images/Commands/RealWorld/
   1    24363     1254   normal file        /Images/Commands/RealWorld/BrowserBack.bmp
   ...
   1    35672     1021   normal file        /Images/Employees24.gif
   ...
   1  3630715   200143   normal file        /template/packages/jquery-mobile/script/
                                             jquery.mobile-1.4.5.min.js
   ...
   0      134     1296   meta file      ::DataSpace/Storage/MSCompressed/Transform/
                                          {7FC28940-9D31-11D0-9B27-00A0C91E9C7C}/
                                          InstanceData/ResetTable

Господи, даже здесь jQuery ¯_(ツ)_/¯

Собираем chmlib-sys

Теперь мы знаем достаточно, чтобы использовать CHMLib в крейте chmlib-sys, который отвечает за сборку нативной библиотеки, линковку её компилятором Раста, и интерфейс к функциям на Си.

Для сборки библиотеки нужно написать файл build.rs. С помощью крейта cc он вызовёт компилятор Си и сделает прочую дружбомагию, чтобы всё работало вместе как надо.

Нам повезло, что мы можем переложить большую часть работы на cc, но порой бывает значительно труднее. Подробнее читайте в документации на сборочные скрипты.

Сперва добавим cc как зависимость для chmlib-sys:

$ cd chmlib-sys
$ cargo add --build cc
    Updating 'https://github.com/rust-lang/crates.io-index' index
      Adding cc v1.0.46 to build-dependencies

Затем напишем build.rs:

// chmlib-sys/build.rs

use cc::Build;
use std::{env, path::PathBuf};

fn main() {
    let project_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap())
        .canonicalize()
        .unwrap();
    let root_dir = project_dir.parent().unwrap();
    let src = root_dir.join("vendor").join("CHMLib").join("src");

    Build::new()
        .file(src.join("chm_lib.c"))
        .file(src.join("lzx.c"))
        .include(&src)
        .warnings(false)
        .compile("chmlib");
}

Ещё надо рассказать Cargo о том, что chmlib-sys линкуется с библиотекой chmlib. Тогда Cargo сможет гарантировать, что во всём графе зависимостей присутствует только один крейт, зависящий от конкретной нативной библиотеки. Это позволяет избежать непонятных сообщений об ошибках про повторяющиеся символы или случайного использования несовместимых библиотек.

--- a/chmlib-sys/Cargo.toml
+++ b/chmlib-sys/Cargo.toml
@@ -3,7 +3,13 @@ name = "chmlib-sys"
 version = "0.1.0"
 authors = ["Michael Bryan <michaelfbryan@gmail.com>"]
 edition = "2018"
 description = "Raw bindings to the CHMLib C library"
 license = "LGPL"
 repository = "https://github.com/Michael-F-Bryan/chmlib"
+links = "chmlib"
+build = "build.rs"

 [dependencies]

 [build-dependencies]
 cc = { version = "1.0" }

Дальше нам остаётся объявить все функции, экспортируемые библиотекой chmlib, чтобы их можно было использовать из Раста.

Именно для этого и существует замечательный проект bindgen. На вход ему отдаётся заголовочный файл на Си, а на выходе получается файл с FFI-привязками для Раста.

$ cargo install bindgen
$ bindgen ../vendor/CHMLib/src/chm_lib.h 
    -o src/lib.rs 
    --raw-line '#![allow(non_snake_case, non_camel_case_types)]'
$ head src/lib.rs
  /* automatically generated by rust-bindgen */

  #![allow(non_snake_case, non_camel_case_types)]

  pub const CHM_UNCOMPRESSED: u32 = 0;
  pub const CHM_COMPRESSED: u32 = 1;
  pub const CHM_MAX_PATHLEN: u32 = 512;
  pub const CHM_PARAM_MAX_BLOCKS_CACHED: u32 = 0;
  pub const CHM_RESOLVE_SUCCESS: u32 = 0;
  pub const CHM_RESOLVE_FAILURE: u32 = 1;
$ tail src/lib.rs
  extern "C" {
      pub fn chm_enumerate_dir(
          h: *mut chmFile,
          prefix: *const ::std::os::raw::c_char,
          what: ::std::os::raw::c_int,
          e: CHM_ENUMERATOR,
          context: *mut ::std::os::raw::c_void,
      ) -> ::std::os::raw::c_int;
  }

Очень рекомендую почитать руководство пользователя Bindgen, если вам надо что-то подправить в его выхлопе.

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

// chmlib-sys/tests/smoke_test.rs

// Нам потребуется преобразовать Path в char* с нулевым байтом в конце.
// К сожалению, OsStr (и Path) на Windows используют [u16] под капотом,
// поэтому их нельзя так просто преобразовать в char*.
#![cfg(unix)]

use std::{ffi::CString, os::unix::ffi::OsStrExt, path::Path};

#[test]
fn open_example_file() {
    let project_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
    let sample_chm = project_dir.parent().unwrap().join("topics.classic.chm");
    let c_str = CString::new(sample_chm.as_os_str().as_bytes()).unwrap();

    unsafe {
        let handle = chmlib_sys::chm_open(c_str.as_ptr());
        assert!(!handle.is_null());
        chmlib_sys::chm_close(handle);
    }
}

cargo test говорит, что вроде всё в порядке:

$ cargo test
    Finished test [unoptimized + debuginfo] target(s) in 0.03s
     Running ~/chmlib/target/debug/deps/chmlib_sys-2ffd7b11a9fd8437

running 1 test
test bindgen_test_layout_chmUnitInfo ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running ~/chmlib/target/debug/deps/smoke_test-f7be9810412559dc

running 1 test
test open_example_file ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests chmlib-sys

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Пишем безопасную обёртку на Rust

Техни-и-ически мы теперь можем вызывать CHMLib из Раста, но это требует кучи unsafe. Для наколенной поделки может и сойдёт, но для публикации на crates.io стоит написать безопасную обёртку для всего небезопасного кода.

Если посмотреть на API chmlib-sys с помощью cargo doc --open, то в нём видно много функций, которые принимают *mut ChmFile как первый аргумент. Это похоже на объекты и методы.

Заголовочный файл CHMLib

/* $Id: chm_lib.h,v 1.10 2002/10/09 01:16:33 jedwin Exp $ */
/***************************************************************************
 *             chm_lib.h - CHM archive manipulation routines               *
 *                           -------------------                           *
 *                                                                         *
 *  author:     Jed Wing <jedwin@ugcs.caltech.edu>                         *
 *  version:    0.3                                                        *
 *  notes:      These routines are meant for the manipulation of microsoft *
 *              .chm (compiled html help) files, but may likely be used    *
 *              for the manipulation of any ITSS archive, if ever ITSS     *
 *              archives are used for any other purpose.                   *
 *                                                                         *
 *              Note also that the section names are statically handled.   *
 *              To be entirely correct, the section names should be read   *
 *              from the section names meta-file, and then the various     *
 *              content sections and the "transforms" to apply to the data *
 *              they contain should be inferred from the section name and  *
 *              the meta-files referenced using that name; however, all of *
 *              the files I've been able to get my hands on appear to have *
 *              only two sections: Uncompressed and MSCompressed.          *
 *              Additionally, the ITSS.DLL file included with Windows does *
 *              not appear to handle any different transforms than the     *
 *              simple LZX-transform.  Furthermore, the list of transforms *
 *              to apply is broken, in that only half the required space   *
 *              is allocated for the list.  (It appears as though the      *
 *              space is allocated for ASCII strings, but the strings are  *
 *              written as unicode.  As a result, only the first half of   *
 *              the string appears.)  So this is probably not too big of   *
 *              a deal, at least until CHM v4 (MS .lit files), which also  *
 *              incorporate encryption, of some description.               *
 ***************************************************************************/

/***************************************************************************
 *                                                                         *
 *   This program is free software; you can redistribute it and/or modify  *
 *   it under the terms of the GNU Lesser General Public License as        *
 *   published by the Free Software Foundation; either version 2.1 of the  *
 *   License, or (at your option) any later version.                       *
 *                                                                         *
 ***************************************************************************/

#ifndef INCLUDED_CHMLIB_H
#define INCLUDED_CHMLIB_H

#ifdef __cplusplus
extern "C" {
#endif

/* RWE 6/12/1002 */
#ifdef PPC_BSTR
#include <wtypes.h>
#endif

#ifdef WIN32
#ifdef __MINGW32__
#define __int64 long long
#endif
typedef unsigned __int64 LONGUINT64;
typedef __int64          LONGINT64;
#else
typedef unsigned long long LONGUINT64;
typedef long long          LONGINT64;
#endif

/* the two available spaces in a CHM file                      */
/* N.B.: The format supports arbitrarily many spaces, but only */
/*       two appear to be used at present.                     */
#define CHM_UNCOMPRESSED (0)
#define CHM_COMPRESSED   (1)

/* structure representing an ITS (CHM) file stream             */
struct chmFile;

/* structure representing an element from an ITS file stream   */
#define CHM_MAX_PATHLEN  (512)
struct chmUnitInfo
{
    LONGUINT64         start;
    LONGUINT64         length;
    int                space;
    int                flags;
    char               path[CHM_MAX_PATHLEN+1];
};

/* open an ITS archive */
#ifdef PPC_BSTR
/* RWE 6/12/2003 */
struct chmFile* chm_open(BSTR filename);
#else
struct chmFile* chm_open(const char *filename);
#endif

/* close an ITS archive */
void chm_close(struct chmFile *h);

/* methods for ssetting tuning parameters for particular file */
#define CHM_PARAM_MAX_BLOCKS_CACHED 0
void chm_set_param(struct chmFile *h,
                   int paramType,
                   int paramVal);

/* resolve a particular object from the archive */
#define CHM_RESOLVE_SUCCESS (0)
#define CHM_RESOLVE_FAILURE (1)
int chm_resolve_object(struct chmFile *h,
                       const char *objPath,
                       struct chmUnitInfo *ui);

/* retrieve part of an object from the archive */
LONGINT64 chm_retrieve_object(struct chmFile *h,
                              struct chmUnitInfo *ui,
                              unsigned char *buf,
                              LONGUINT64 addr,
                              LONGINT64 len);

/* enumerate the objects in the .chm archive */
typedef int (*CHM_ENUMERATOR)(struct chmFile *h,
                              struct chmUnitInfo *ui,
                              void *context);
#define CHM_ENUMERATE_NORMAL    (1)
#define CHM_ENUMERATE_META      (2)
#define CHM_ENUMERATE_SPECIAL   (4)
#define CHM_ENUMERATE_FILES     (8)
#define CHM_ENUMERATE_DIRS      (16)
#define CHM_ENUMERATE_ALL       (31)
#define CHM_ENUMERATOR_FAILURE  (0)
#define CHM_ENUMERATOR_CONTINUE (1)
#define CHM_ENUMERATOR_SUCCESS  (2)
int chm_enumerate(struct chmFile *h,
                  int what,
                  CHM_ENUMERATOR e,
                  void *context);

int chm_enumerate_dir(struct chmFile *h,
                      const char *prefix,
                      int what,
                      CHM_ENUMERATOR e,
                      void *context);

#ifdef __cplusplus
}
#endif

#endif /* INCLUDED_CHMLIB_H */

Начнём с типа данных, который в конструкторе вызывает chm_open(), а в деструкторе — chm_close().

pub unsafe extern "C" fn chm_open(filename: *const c_char) -> *mut chmFile;
pub unsafe extern "C" fn chm_close(h: *mut chmFile);

Для упрощения обработки ошибок мы используем крейт thiserror, которые автоматически реализует std::error::Error.

$ cd chmlib
$ cargo add thiserror

Теперь надо придумать, как превратить std::path::Path в *const c_char. К сожалению, это не так-то просто сделать из-за разных приколов с совместимостью.

// chmlib/src/lib.rs

use thiserror::Error;
use std::{ffi::CString, path::Path};

#[cfg(unix)]
fn path_to_cstring(path: &Path) -> Result<CString, InvalidPath> {
    use std::os::unix::ffi::OsStrExt;
    let bytes = path.as_os_str().as_bytes();
    CString::new(bytes).map_err(|_| InvalidPath)
}

#[cfg(not(unix))]
fn path_to_cstring(path: &Path) -> Result<CString, InvalidPath> {
    // К сожалению, на Windows CHMLib использует CreateFileA(), поэтому она
    // умеет работать только с путями в ASCII. Я не знаю... давайте просто
    // верить, что не будет использовать Юникод и тут ничего не сломается?
    let rust_str = path.as_os_str().as_str().ok_or(InvalidPath)?;
    CString::new(rust_str).map_err(|_| InvalidPath)
}

#[derive(Error, Debug, Copy, Clone, PartialEq)]
#[error("Invalid Path")]
pub struct InvalidPath;

Теперь определим структуру ChmFile. Она хранит ненулевой указатель на chmlib_sys::chmFile. Если chm_open() возвращает нулевой указатель, то значит у неё не получилось открыть файл из-за какой-то ошибки.

// chmlib/src/lib.rs

use std::{ffi::CString, path::Path, ptr::NonNull};

#[derive(Debug)]
pub struct ChmFile {
    raw: NonNull<chmlib_sys::chmFile>,
}

impl ChmFile {
    pub fn open<P: AsRef<Path>>(path: P) -> Result<ChmFile, OpenError> {
        let c_path = path_to_cstring(path.as_ref())?;

        // безопасно, потому что c_path корректный
        unsafe {
            let raw = chmlib_sys::chm_open(c_path.as_ptr());

            match NonNull::new(raw) {
                Some(raw) => Ok(ChmFile { raw }),
                None => Err(OpenError::Other),
            }
        }
    }
}

impl Drop for ChmFile {
    fn drop(&mut self) {
        unsafe {
            chmlib_sys::chm_close(self.raw.as_ptr());
        }
    }
}

/// The error returned when we are unable to open a [`ChmFile`].
#[derive(Error, Debug, Copy, Clone, PartialEq)]
pub enum OpenError {
    #[error("Invalid path")]
    InvalidPath(#[from] InvalidPath),
    #[error("Unable to open the ChmFile")]
    Other,
}

Чтобы убедиться в отсутствии утечек памяти, запустим простой тест под Valgrind. Он создаст ChmFile и сразу же его освободит.

// chmlib/src/lib.rs

#[test]
fn open_valid_chm_file() {
    let sample = sample_path();

    // открыть файл
    let chm_file = ChmFile::open(&sample).unwrap();
    // и тут же его закрыть
    drop(chm_file);
}

fn sample_path() -> PathBuf {
    let project_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
    let sample = project_dir.parent().unwrap().join("topics.classic.chm");
    assert!(sample.exists());

    sample
}

Valgrind говорит, что неучтённой памяти не осталось:

$ valgrind ../target/debug/deps/chmlib-8d8c740d578324 open_valid_chm_file
==8953== Memcheck, a memory error detector
==8953== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==8953== Using Valgrind-3.14.0 and LibVEX; rerun with -h for copyright info
==8953== Command: ~/chmlib/target/debug/deps/chmlib-8d8c740d578324 open_valid_chm_file
==8953==

running 1 test
test tests::open_valid_chm_file ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

==8953==
==8953== HEAP SUMMARY:
==8953==     in use at exit: 0 bytes in 0 blocks
==8953==   total heap usage: 249 allocs, 249 frees, 43,273 bytes allocated
==8953==
==8953== All heap blocks were freed -- no leaks are possible
==8953==
==8953== For counts of detected and suppressed errors, rerun with: -v
==8953== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Поиск элементов по имени

На очереди функция chm_resolve_object():

pub const CHM_RESOLVE_SUCCESS: u32 = 0;
pub const CHM_RESOLVE_FAILURE: u32 = 1;
/* resolve a particular object from the archive */
pub unsafe extern "C" fn chm_resolve_object(
    h: *mut chmFile,
    objPath: *const c_char,
    ui: *mut chmUnitInfo
) -> c_int;

Поиск может завершиться ошибкой, поэтому chm_resolve_object() возвращает код ошибки, сообщающий об успехе или неудаче, а информация о найденном объекте будет записана по переданному указателю на chmUnitInfo.

Тип std::mem::MaybeUninit создан как раз для нашего случая с out-параметром ui.

Пока что оставим структуру UnitInfo пустой — это Rust-эквивалент C-структуры chmUnitInfo. Поля мы добавим, когда начнём что-то читать из ChmFile.

// chmlib/src/lib.rs

impl ChmFile {
    ...

    /// Find a particular object in the archive.
    pub fn find<P: AsRef<Path>>(&mut self, path: P) -> Option<UnitInfo> {
        let path = path_to_cstring(path.as_ref()).ok()?;

        unsafe {
            // создаём неинициализированную chmUnitInfo на стеке
            let mut resolved = MaybeUninit::<chmlib_sys::chmUnitInfo>::uninit();

            // попробуем что-нибудь найти
            let ret = chmlib_sys::chm_resolve_object(
                self.raw.as_ptr(),
                path.as_ptr(),
                resolved.as_mut_ptr(),
            );

            if ret == chmlib_sys::CHM_RESOLVE_SUCCESS {
                // в случае успеха "resolved" будет инициализированной
                Some(UnitInfo::from_raw(resolved.assume_init()))
            } else {
                None
            }
        }
    }
}

#[derive(Debug)]
pub struct UnitInfo;

impl UnitInfo {
    fn from_raw(ui: chmlib_sys::chmUnitInfo) -> UnitInfo { UnitInfo }
}

Заметьте, что ChmFile::find() принимает &mut self, хотя код на Расте не содержит явного изменения состояния. Дело в том, что реализация на Си использует всякие fseek() для перемещения по файлу, поэтому внутреннее состояние всё же изменяется при поиске.

Проверим ChmFile::find() на подопытном файле, который мы ранее скачали:

// chmlib/src/lib.rs

#[test]
fn find_an_item_in_the_sample() {
    let sample = sample_path();
    let chm = ChmFile::open(&sample).unwrap();

    assert!(chm.find("/BrowserView.html").is_some());
    assert!(chm.find("doesn't exist.txt").is_none());
}

Обход элементов по фильтру

CHMLib предоставляет API для просмотра содержимого CHM-файла через фильтр по битовой маске.

Возьмём удобный крейт bitflags для работы с масками и флажками:

$ cargo add bitflags
    Updating 'https://github.com/rust-lang/crates.io-index' index
      Adding bitflags v1.2.1 to dependencies

И определим флажки Filter на основе констант из chm_lib.h:

// chmlib/src/lib.rs

bitflags::bitflags! {
    pub struct Filter: c_int {
        /// A normal file.
        const NORMAL = chmlib_sys::CHM_ENUMERATE_NORMAL as c_int;
        /// A meta file (typically used by the CHM system).
        const META = chmlib_sys::CHM_ENUMERATE_META as c_int;
        /// A special file (starts with `#` or `$`).
        const SPECIAL = chmlib_sys::CHM_ENUMERATE_SPECIAL as c_int;
        /// It's a file.
        const FILES = chmlib_sys::CHM_ENUMERATE_FILES as c_int;
        /// It's a directory.
        const DIRS = chmlib_sys::CHM_ENUMERATE_DIRS as c_int;
    }
}

Ещё нам понадобится extern "C"-адаптер для Растовых замыканий, который можно передать в Си в виде указателя на функцию:

// chmlib/src/lib.rs

unsafe extern "C" fn function_wrapper<F>(
    file: *mut chmlib_sys::chmFile,
    unit: *mut chmlib_sys::chmUnitInfo,
    state: *mut c_void,
) -> c_int
where
    F: FnMut(&mut ChmFile, UnitInfo) -> Continuation,
{
    // предотвращаем утечки паник за границы FFI-вызова
    let result = panic::catch_unwind(|| {
        // Мы используем ManuallyDrop чтобы передать ссылку `&mut ChmFile`
        // но при этом не хотим вызывать деструктор (избегая double-free).
        let mut file = ManuallyDrop::new(ChmFile {
            raw: NonNull::new_unchecked(file),
        });
        let unit = UnitInfo::from_raw(unit.read());
        // указатель state гарантировано указывает на подходящее замыкание
        let closure = &mut *(state as *mut F);
        closure(&mut file, unit)
    });

    match result {
        Ok(Continuation::Continue) => {
            chmlib_sys::CHM_ENUMERATOR_CONTINUE as c_int
        },
        Ok(Continuation::Stop) => chmlib_sys::CHM_ENUMERATOR_SUCCESS as c_int,
        Err(_) => chmlib_sys::CHM_ENUMERATOR_FAILURE as c_int,
    }
}

function_wrapper содержит хитрый unsafe-код, которым надо уметь пользоваться:

  • Указатель state должен указывать на экземпляр замыкания F.
  • Код на Расте, исполняемый замыканием, может вызывать панику. Она не должна пересекать границу между Растом и Си, так как раскрутка стека на разных языках — это неопределённое поведение. Возможную панику следует перехватить с помощью std::panic::catch_unwind().
  • Указатель на chmlib_sys::chmFile, передаваемый в function_wrapper, также хранится в вызывающем ChmFile. На время вызова надо гарантировать, что только замыкание может манипулировать chmlib_sys::chmFile, иначе может возникнуть состояние гонки.
  • Замыканию надо передать &mut ChmFile, а для этого потребуется создать временный объект на стеке, используя имеющийся указатель. Однако, если при этом отработает деструктор ChmFile, то chmlib_sys::chmFile будет освобождён слишком рано. Для решения этой проблемы существует std::mem::ManuallyDrop.

Вот как function_wrapper используется для реализации ChmFile::for_each():

// chmlib/src/lib.rs

impl ChmFile {
    ...

    /// Inspect each item within the [`ChmFile`].
    pub fn for_each<F>(&mut self, filter: Filter, mut cb: F)
    where
        F: FnMut(&mut ChmFile, UnitInfo) -> Continuation,
    {
        unsafe {
            chmlib_sys::chm_enumerate(
                self.raw.as_ptr(),
                filter.bits(),
                Some(function_wrapper::<F>),
                &mut cb as *mut _ as *mut c_void,
            );
        }
    }

    /// Inspect each item within the [`ChmFile`] inside a specified directory.
    pub fn for_each_item_in_dir<F, P>(
        &mut self,
        filter: Filter,
        prefix: P,
        mut cb: F,
    ) where
        P: AsRef<Path>,
        F: FnMut(&mut ChmFile, UnitInfo) -> Continuation,
    {
        let path = match path_to_cstring(prefix.as_ref()) {
            Ok(p) => p,
            Err(_) => return,
        };

        unsafe {
            chmlib_sys::chm_enumerate_dir(
                self.raw.as_ptr(),
                path.as_ptr(),
                filter.bits(),
                Some(function_wrapper::<F>),
                &mut cb as *mut _ as *mut c_void,
            );
        }
    }
}

Обратите внимание на то, как параметр F взаимодействует с обобщённой функцией function_wrapper. Такой приём часто применяется, когда надо передать замыкание Rust через FFI в код на другом языке.

Чтение содержимого файлов

Последняя функция, которая нам нужна, отвечает за собственно чтение файла с помощью chm_retrieve_object().

Её реализация довольно тривиальная. Это похоже на типичный трейт std::io::Read, за исключением явного смещения в файле.

// chmlib/src/lib.rs

impl ChmFile {
    ...

    pub fn read(
        &mut self,
        unit: &UnitInfo,
        offset: u64,
        buffer: &mut [u8],
    ) -> Result<usize, ReadError> {
        let mut unit = unit.0.clone();

        let bytes_written = unsafe {
            chmlib_sys::chm_retrieve_object(
                self.raw.as_ptr(),
                &mut unit,
                buffer.as_mut_ptr(),
                offset,
                buffer.len() as _,
            )
        };

        if bytes_written >= 0 {
            Ok(bytes_written as usize)
        } else {
            Err(ReadError)
        }
    }
}

#[derive(Error, Debug, Copy, Clone, PartialEq)]
#[error("The read failed")]
pub struct ReadError;

Конечно, было бы неплохо иметь более детализированное сообщение об ошибке, чем «не вышло прочитать», но судя по исходному коду, chm_retrieve_object() не особо различает ошибки:

  • возвращает 0, когда файл дочитан до конца;
  • возвращает 0 при неправильных аргументах: нулевых указателях или выходе за границы;
  • возвращает −1 при ошибках чтения файлах системой (и заполняет errno);
  • возвращает −1 при ошибках распаковки, не различая порчу данных и, скажем, невозможность выделить память под временный буфер через malloc().

Протестировать ChmFile::read() можно с помощью файлов с известным содержимым:

// chmlib/src/lib.rs

#[test]
fn read_an_item() {
    let sample = sample_path();
    let mut chm = ChmFile::open(&sample).unwrap();
    let filename = "/template/packages/core-web/css/index.responsive.css";

    // этот файл должен быть в тестовом архиве
    let item = chm.find(filename).unwrap();

    // считаем его во временный буфер
    let mut buffer = vec![0; item.length() as usize];
    let bytes_written = chm.read(&item, 0, &mut buffer).unwrap();

    // он должен быть полностью заполнен
    assert_eq!(bytes_written, item.length() as usize);

    // ...и содержать то, что мы ожидаем
    let got = String::from_utf8(buffer).unwrap();
    assert!(got.starts_with(
        "html, body, div#i-index-container, div#i-index-body"
    ));
}

Добавляем примеры

Мы покрыли большую часть API библиотеки CHMLib и многие на этом бы закончили работу, считая портирование успешно завершённым. Однако, было бы неплохо сделать наш крейт ещё более удобным для пользователей. Этой цели служат примеры кода и документация — я заметил, что в сообществе Rust и Go этим моментам уделяется довольно много внимания (наверное потому, что rustdoc и godoc хорошо интегрированы в языковую среду).

К счастью, в CHMLib уже есть примеры кода, так что нам будет достаточно просто портировать их.

Также эти примеры послужат своеобразным интеграционным тестом, проверяющим, что исходная библиотека и наша обёртка ведут себя одинаково.

Оглавление CHM-файла

Этот пример открывает CHM-файл и печатает таблицу с информаций обо всех его элементах.

Оригинальный пример на Си

/* $Id: enum_chmLib.c,v 1.7 2002/10/09 12:38:12 jedwin Exp $ */
/***************************************************************************
 *          enum_chmLib.c - CHM archive test driver                        *
 *                           -------------------                           *
 *                                                                         *
 *  author:     Jed Wing <jedwin@ugcs.caltech.edu>                         *
 *  notes:      This is a quick-and-dirty test driver for the chm lib      *
 *              routines.  The program takes as its input the paths to one *
 *              or more .chm files.  It attempts to open each .chm file in *
 *              turn, and display a listing of all of the files in the     *
 *              archive.                                                   *
 *                                                                         *
 *              It is not included as a particularly useful program, but   *
 *              rather as a sort of "simplest possible" example of how to  *
 *              use the enumerate portion of the API.                      *
 ***************************************************************************/

/***************************************************************************
 *                                                                         *
 *   This program is free software; you can redistribute it and/or modify  *
 *   it under the terms of the GNU Lesser General Public License as        *
 *   published by the Free Software Foundation; either version 2.1 of the  *
 *   License, or (at your option) any later version.                       *
 *                                                                         *
 ***************************************************************************/

#include "chm_lib.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/*
 * callback function for enumerate API
 */
int _print_ui(struct chmFile *h,
              struct chmUnitInfo *ui,
              void *context)
{
    static char szBuf[128];
    memset(szBuf, 0, 128);
    if(ui->flags & CHM_ENUMERATE_NORMAL)
        strcpy(szBuf, "normal ");
    else if(ui->flags & CHM_ENUMERATE_SPECIAL)
        strcpy(szBuf, "special ");
    else if(ui->flags & CHM_ENUMERATE_META)
        strcpy(szBuf, "meta ");

    if(ui->flags & CHM_ENUMERATE_DIRS)
        strcat(szBuf, "dir");
    else if(ui->flags & CHM_ENUMERATE_FILES)
        strcat(szBuf, "file");

    printf("   %1d %8d %8d   %stt%sn",
           (int)ui->space,
           (int)ui->start,
           (int)ui->length,
           szBuf,
           ui->path);
    return CHM_ENUMERATOR_CONTINUE;
}

int main(int c, char **v)
{
    struct chmFile *h;
    int i;

    for (i=1; i<c; i++)
    {
        h = chm_open(v[i]);
        if (h == NULL)
        {
            fprintf(stderr, "failed to open %sn", v[i]);
            exit(1);
        }

        printf("%s:n", v[i]);
        printf(" spc    start   length   typetttnamen");
        printf(" ===    =====   ======   ====ttt====n");

        if (! chm_enumerate(h,
                            CHM_ENUMERATE_ALL,
                            _print_ui,
                            NULL))
            printf("   *** ERROR ***n");

        chm_close(h);
    }

    return 0;
}

Функцию _print_ui() легко переписать на Rust. Она всего лишь генерирует текстовое описание из флажков UnitInfo и строк, а потом немного шаманит с выравниванием, чтобы таблица выглядела как таблица.

// chmlib/examples/enumerate-items.rs

fn describe_item(item: UnitInfo) {
    let mut description = String::new();

    if item.is_normal() {
        description.push_str("normal ");
    } else if item.is_special() {
        description.push_str("special ");
    } else if item.is_meta() {
        description.push_str("meta ");
    }

    if item.is_dir() {
        description.push_str("dir");
    } else if item.is_file() {
        description.push_str("file");
    }

    println!(
        "   {} {:8} {:8}   {}tt{}",
        item.space(),
        item.start(),
        item.length(),
        description,
        item.path().unwrap_or(Path::new("")).display()
    );
}

Функция main() на скорую руку разбирает аргументы командной строки, открывает файл, и вызывает describe_item() через ChmFile::for_each().

// chmlib/examples/enumerate-items.rs

fn main() {
    let filename = env::args()
        .nth(1)
        .unwrap_or_else(|| panic!("Usage: enumerate-items <filename>"));

    let mut file = ChmFile::open(&filename).expect("Unable to open the file");

    println!("{}:", filename);
    println!(" spc    start   length   typetttname");
    println!(" ===    =====   ======   ====ttt====");

    file.for_each(Filter::all(), |_file, item| {
        describe_item(item);
        Continuation::Continue
    });
}

Для проверки давайте сравним вывод примера на Расте с оригиналом:

$ cargo run --example enumerate-items topics.classic.chm > rust-example.txt
$ cd vendor/CHMLib/src
$ clang chm_lib.c enum_chmLib.c lzx.c -o enum_chmLib
$ cd ../../..
$ ./vendor/CHMLib/src/enum_chmLib topics.classic.chm > c-example.txt
$ diff -u rust-example.txt c-example.txt
$ echo $?
0

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

diff --git a/chmlib/examples/enumerate-items.rs b/chmlib/examples/enumerate-items.rs
index e68fa58..ef855ac 100644
--- a/chmlib/examples/enumerate-items.rs
+++ b/chmlib/examples/enumerate-items.rs
@@ -36,6 +36,10 @@ fn describe_item(item: UnitInfo) {
         description.push_str("file");
     }

+    if item.length() % 7 == 0 {
+        description.push_str(" :)");
+    }
+
     println!(
         "   {} {:8} {:8}   {}tt{}",
         item.space(),

Запускаем тест с новым кодом:

$ cargo run --example enumerate-items topics.classic.chm > rust-example.txt
$ diff -u rust-example.txt c-example.txt
--- rust-example.txt    2019-10-20 16:51:53.933560892 +0800
+++ c-example.txt       2019-10-20 16:40:42.007053966 +0800
@@ -1,9 +1,9 @@
 topics.classic.chm:
  spc    start   length   type            name
  ===    =====   ======   ====            ====
-   0        0        0   normal dir :)       /
+   0        0        0   normal dir          /
    1  5125797     4096   special file        /#IDXHDR
-   0        0        0   special file :)     /#ITBITS
+   0        0        0   special file        /#ITBITS
    1  5104520      148   special file        /#IVB
    1  5132009     1227   special file        /#STRINGS
    0     1430     4283   special file        /#SYSTEM
@@ -13,9 +13,9 @@
...

Победа!

Распаковка CHM-файла на диск

Другой пример, идущий в комплекте с CHMLib, извлекает все «обычные» файлы на диск.

Оригинальный пример на Си

/* $Id: extract_chmLib.c,v 1.4 2002/10/10 03:24:51 jedwin Exp $ */
/***************************************************************************
 *          extract_chmLib.c - CHM archive extractor                       *
 *                           -------------------                           *
 *                                                                         *
 *  author:     Jed Wing <jedwin@ugcs.caltech.edu>                         *
 *  notes:      This is a quick-and-dirty chm archive extractor.           *
 ***************************************************************************/

/***************************************************************************
 *                                                                         *
 *   This program is free software; you can redistribute it and/or modify  *
 *   it under the terms of the GNU Lesser General Public License as        *
 *   published by the Free Software Foundation; either version 2.1 of the  *
 *   License, or (at your option) any later version.                       *
 *                                                                         *
 ***************************************************************************/

#include "chm_lib.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef WIN32
#include <windows.h>
#include <direct.h>
#define mkdir(X, Y) _mkdir(X)
#define snprintf _snprintf
#else
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#endif

struct extract_context
{
    const char *base_path;
};

static int dir_exists(const char *path)
{
#ifdef WIN32
        /* why doesn't this work?!? */
        HANDLE hFile;

        hFile = CreateFileA(path,
                        FILE_LIST_DIRECTORY,
                        0,
                        NULL,
                        OPEN_EXISTING,
                        FILE_ATTRIBUTE_NORMAL,
                        NULL);
        if (hFile != INVALID_HANDLE_VALUE)
        {
        CloseHandle(hFile);
        return 1;
        }
        else
        return 0;
#else
        struct stat statbuf;
        if (stat(path, &statbuf) != -1)
                return 1;
        else
                return 0;
#endif
}

static int rmkdir(char *path)
{
    /*
     * strip off trailing components unless we can stat the directory, or we
     * have run out of components
     */

    char *i = strrchr(path, '/');

    if(path[0] == ''  ||  dir_exists(path))
        return 0;

    if (i != NULL)
    {
        *i = '';
        rmkdir(path);
        *i = '/';
        mkdir(path, 0777);
    }

#ifdef WIN32
        return 0;
#else
    if (dir_exists(path))
        return 0;
    else
        return -1;
#endif
}

/*
 * callback function for enumerate API
 */
int _extract_callback(struct chmFile *h,
              struct chmUnitInfo *ui,
              void *context)
{
    LONGUINT64 ui_path_len;
    char buffer[32768];
    struct extract_context *ctx = (struct extract_context *)context;
    char *i;

    if (ui->path[0] != '/')
        return CHM_ENUMERATOR_CONTINUE;

    /* quick hack for security hole mentioned by Sven Tantau */
    if (strstr(ui->path, "/../") != NULL)
    {
        /* fprintf(stderr, "Not extracting %s (dangerous path)n", ui->path); */
        return CHM_ENUMERATOR_CONTINUE;
    }

    if (snprintf(buffer, sizeof(buffer), "%s%s", ctx->base_path, ui->path) > 1024)
        return CHM_ENUMERATOR_FAILURE;

    /* Get the length of the path */
    ui_path_len = strlen(ui->path)-1;

    /* Distinguish between files and dirs */
    if (ui->path[ui_path_len] != '/' )
    {
        FILE *fout;
        LONGINT64 len, remain=ui->length;
        LONGUINT64 offset = 0;

        printf("--> %sn", ui->path);
        if ((fout = fopen(buffer, "wb")) == NULL)
    {
        /* make sure that it isn't just a missing directory before we abort */
        char newbuf[32768];
        strcpy(newbuf, buffer);
        i = strrchr(newbuf, '/');
        *i = '';
        rmkdir(newbuf);
        if ((fout = fopen(buffer, "wb")) == NULL)
              return CHM_ENUMERATOR_FAILURE;
    }

        while (remain != 0)
        {
            len = chm_retrieve_object(h, ui, (unsigned char *)buffer, offset, 32768);
            if (len > 0)
            {
                fwrite(buffer, 1, (size_t)len, fout);
                offset += len;
                remain -= len;
            }
            else
            {
                fprintf(stderr, "incomplete file: %sn", ui->path);
                break;
            }
        }

        fclose(fout);
    }
    else
    {
        if (rmkdir(buffer) == -1)
            return CHM_ENUMERATOR_FAILURE;
    }

    return CHM_ENUMERATOR_CONTINUE;
}

int main(int c, char **v)
{
    struct chmFile *h;
    struct extract_context ec;

    if (c < 3)
    {
        fprintf(stderr, "usage: %s <chmfile> <outdir>n", v[0]);
        exit(1);
    }

    h = chm_open(v[1]);
    if (h == NULL)
    {
        fprintf(stderr, "failed to open %sn", v[1]);
        exit(1);
    }

    printf("%s:n", v[1]);
    ec.base_path = v[2];
    if (! chm_enumerate(h,
                        CHM_ENUMERATE_ALL,
                        _extract_callback,
                        (void *)&ec))
        printf("   *** ERROR ***n");

    chm_close(h);

    return 0;
}

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

Интерес здесь представляет функция extract(). Код в принципе самоочевиден, так что я не буду особо утруждать себя его пересказом своими словами на русском.

// chmlib/examples/extract.rs

fn extract(
    root_dir: &Path,
    file: &mut ChmFile,
    item: &UnitInfo,
) -> Result<(), Box<dyn Error>> {
    if !item.is_file() || !item.is_normal() {
        // меня интересуют только обычные файлы
        return Ok(());
    }
    let path = match item.path() {
        Some(p) => p,
        // нет пути так нет пути, едем дальше
        None => return Ok(()),
    };

    let mut dest = root_dir.to_path_buf();
    // Примечание: в CHM все пути к обычным файлам абсолютные (начинаются с "/"),
    // поэтому при конкатенации с root_dir надо удалить ведущий символ "/".
    dest.extend(path.components().skip(1));

    // гарантируем существование родительской директории
    if let Some(parent) = dest.parent() {
        fs::create_dir_all(parent)?;
    }

    let mut f = File::create(dest)?;
    let mut start_offset = 0;
    // CHMLib не даёт прямого доступа к &[u8] с содержимым файла (например,
    // потому что файл может быть сжатым), так что нам следует собирать его
    // по кусочкам во временном буфере
    let mut buffer = vec![0; 1 << 16];

    loop {
        let bytes_read = file.read(item, start_offset, &mut buffer)?;
        if bytes_read == 0 {
            // дошли до конца файла
            break;
        } else {
            // записываем этот фрагмент и продолжаем
            start_offset += bytes_read as u64;
            f.write_all(&buffer)?;
        }
    }

    Ok(())
}

Функция main() существенно проще, чем extract(), и от предыдущего примера отличается только обработкой ошибок распаковки.

// chmlib/examples/extract.rs

fn main() {
    let args: Vec<_> = env::args().skip(1).collect();
    if args.len() != 2 || args.iter().any(|arg| arg.contains("-h")) {
        println!("Usage: extract <chm-file> <out-dir>");
        return;
    }

    let mut file = ChmFile::open(&args[0]).expect("Unable to open the file");

    let out_dir = PathBuf::from(&args[1]);

    file.for_each(Filter::all(), |file, item| {
        match extract(&out_dir, file, &item) {
            Ok(_) => Continuation::Continue,
            Err(e) => {
                eprintln!("Error: {}", e);
                Continuation::Stop
            },
        }
    });
}

Натравив собранный пример на подопытный CHM-файл мы получаем пачку HTML-файлов, которые можно открыть в обычном веб-браузере.

$ cargo run --example extract -- ./topics.classic.chm ./extracted
$ tree ./extracted
./extracted
├── default.html
├── BrowserForward.html
...
├── Images
│   ├── Commands
│   │   └── RealWorld
│   │       ├── BrowserBack.bmp
...
├── script
│   ├── _community
│   │   └── disqus.js
│   ├── hs-common.js
...
└── userinterface.html
$ firefox topics.classic/default.html
(открывает default.html в Firefox)

Часть JavaScript не работает (подозреваю какие-то особенности родного браузера Microsoft Help), поиска тоже нет, но в целом жить можно.

Что дальше?

Крейт chmlib теперь полностью функционален и, за исключением пары мелочей, готов к публикации на crates.io.

Несколько вопросов остаются читателю в качестве домашнего задания:

  • Если замыкание в ChmFile::for_each() или ChmFile::for_each_item_in_dir() запаникует, то после возврата из Си в Раст следует продолжить панику, а не просто вернуть ошибку.
  • Было бы неплохо, чтобы для типичного случая прохода по всем файлам в ChmFile нам не надо было явно возвращать Continuation::Continue во всех замыканиях. Вероятно, тут стоило бы принимать F: FnMut(&mut ChmFile, UnitInfo) -> C где C: Into<Continuation>, после чего реализовать impl From<()> for Continuation.
  • Ошибки во время обхода файла (например, как у нас в extract()) хорошо бы возвращать назад из ChmFile::for_each() и прерывать обход. Это можно совместить с предыдущим пунктом с помощью impl<E> From<Result<(), E>> for Continuation where E: Error + 'static.
  • Как-то не очень красиво вручную копировать кусочки файла во временный буфер перед записью их в std::fs::File. Удобнее было бы добавить оптимизированную функцию, которая в цикле вызывает ChmFile::read() и перекладывает результат в какой-нибудь std::io::Writer.
Facebooktwitterlinkedinrssyoutube

Be the first to comment

Leave a Reply

Your email address will not be published.


*


Time limit is exhausted. Please reload the CAPTCHA.