[Rust 実践] #11 簡易アーカイバ arc — バイナリ形式を設計して、安全に展開する
はじめに
今回は、複数のファイルを1つにまとめて、また取り出す簡易アーカイバ arc を作ります。これまでの回で出てきたものを、まとめて使います。
| 出てきた回 | 書き方 | 今回の使いどころ |
|---|---|---|
| 第5回 | #[derive(Debug)] | エラー型に付け忘れると、テストの unwrap() が止まる |
| 第6・7回 | Result、?、From、Display | ArcError(第9回の error.rs と同じ形) |
| 第8回 | <'a> | バイト列を読み進める Reader<'a> |
| 第9回 | mod、pub、cargo add、cargo test | 5つのファイルへの分割、clap の追加、8件のテスト |
| 第10回 | into_string()、Path | 非 UTF-8 のファイル名の扱い、Path::components() |
作るものと、ファイルの形式
| コマンド | 動き |
|---|---|
arc pack 出力 入力... | ファイルやディレクトリを、1つのアーカイブにまとめる |
arc list アーカイブ | 中身の一覧を表示する |
arc unpack アーカイブ [展開先] [--force] | 展開する。既存のファイルがあると、--force がなければ止まる |
アーカイブの形式(ARC1)は、次のとおりです。数値は、すべてリトルエンディアン(下の位のバイトが先)です。
| 位置 | 大きさ | 中身 |
|---|---|---|
| 先頭 | 4 バイト | ARC1(目印) |
| 次 | 4 バイト(u32) | ファイルの数 |
| 以降、ファイルの数だけ | 4 バイト(u32) | 名前の長さ(バイト数) |
| 名前の長さ分 | 名前(UTF-8) | |
| 8 バイト(u64) | データの長さ | |
| データの長さ分 | データ |
data ディレクトリ(5つのファイル)をまとめた t.arc の、先頭 24 バイトです。
41 52 43 31 05 00 00 00 0c 00 00 00 64 61 74 61
2f 62 69 67 2e 62 69 6e
41 52 43 31 が ARC1、05 00 00 00 が 5 件、0c 00 00 00 が名前の長さ 12、続く 12 バイトが data/big.bin です。ファイル全体の大きさ 1449 バイトも、次の計算と一致しました。
先頭の 8 バイト
+ 名前の長さの合計 73(12 + 15 + 20 + 13 + 13)
+ データの長さの合計 1308(1280 + 3 + 19 + 6 + 0)
+ 1 件あたり 12 バイト(名前の長さ 4 + データの長さ 8)× 5 件 = 60
= 1449
clap でサブコマンドを作る
引数の解釈には、第9回の要領で cargo add した clap を使います。
cargo new arc
cd arc
cargo add clap --features derive
[dependencies]
clap = { version = "4.6.7", features = ["derive"] }
main.rs の、構造体と enum に、#[derive(Parser)] や #[derive(Subcommand)] を付けると、そこから引数の解釈が作られます。
| 書き方 | 意味 |
|---|---|
#[derive(Parser)] | この構造体を、コマンドライン引数から作れるようにする |
#[command(subcommand)] | このフィールドは、サブコマンド(pack、list、unpack) |
#[derive(Subcommand)] | この enum の各バリアントを、サブコマンドにする |
/// ...(ドキュメントコメント) | --help に表示される説明になる |
#[arg(required = true)] | 引数を最低1つ求める |
#[arg(default_value = ".")] | 省略したときの値 |
#[arg(long)] | --force の形で指定するオプションにする |
Cli::parse() | 引数を解釈する。不正なときは、使い方を表示して終了する |
arc --help の結果です。
簡易アーカイバ
Usage: arc <COMMAND>
Commands:
pack ファイルやディレクトリを、1つのアーカイブにまとめる
list アーカイブの中身を一覧する
unpack アーカイブを展開する
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
引数の間違いは、clap が表示します(どれも終了コード 2)。
$ arc pack x.arc
error: the following required arguments were not provided:
<INPUTS>...
Usage: arc pack <OUTPUT> <INPUTS>...
For more information, try '--help'.
$ arc unpack --bogus t.arc
error: unexpected argument '--bogus' found
tip: to pass '--bogus' as a value, use '-- --bogus'
Usage: arc unpack [OPTIONS] <ARCHIVE> [DEST]
arc foo(存在しないサブコマンド)は error: unrecognized subcommand 'foo'、引数なしの arc は使い方の表示でした。clap に任せない側(ファイルがない、形式が違う、など)のエラーは、第8回と同じ エラー: ... の形で、終了コード 1 です。
バイナリ形式を読み書きする
書く(encode)
数値を、決まった順序のバイト列にするのが、to_le_bytes() です。
let count = u32::try_from(items.len()).map_err(|_| ArcError::TooLarge)?;
out.extend_from_slice(&count.to_le_bytes());
| 書き方 | 意味 |
|---|---|
1u32.to_le_bytes() | [1, 0, 0, 0](リトルエンディアンのバイト列) |
u32::try_from(n) | usize などから u32 へ。入りきらなければ Err(第2回の as は、黙って切り捨てる) |
out.extend_from_slice(&bytes) | Vec<u8> の後ろに、バイト列をまとめて足す |
.map_err(|_| ArcError::TooLarge) | Err の中身を、自分のエラー型に変える |
読む(decode)
読み取りで気をつけるのは、足りないデータや、でたらめな長さで panic しないことです。外から来るデータ(ファイル)は、信用できません。
struct Reader<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn take(&mut self, n: usize) -> Result<&'a [u8], ArcError> {
let end = self.pos.checked_add(n).ok_or(ArcError::Truncated)?;
if end > self.bytes.len() {
return Err(ArcError::Truncated);
}
let slice = &self.bytes[self.pos..end];
self.pos = end;
Ok(slice)
}
}
Reader<'a> は、第8回の Excerpt<'a> と同じ形です。take は、&mut self から借りたものではなく、元のバイト列('a)から借りたスライスを返す、という署名になっています。
| 書き方 | 意味 |
|---|---|
self.pos.checked_add(n) | 足し算が桁あふれしたら None(第6回) |
.ok_or(ArcError::Truncated)? | None を Err に変えて、? で返す |
u32::from_le_bytes([b0, b1, b2, b3]) | 4バイトから u32 に戻す |
take は、pos + n が桁あふれしても、長さを超えても、Err(Truncated) を返します。データの長さに u64::MAX を書いたアーカイブも、panic せず、アーカイブが途中で切れています になりました。
テスト
format.rs の末尾に、次のテストを書きました。
#[cfg(test)]
mod tests {
use super::*;
fn item(name: &str, data: &[u8]) -> Item {
Item {
name: name.to_string(),
data: data.to_vec(),
}
}
#[test]
fn round_trip() {
let items = vec![
item("a.txt", b"hello"),
item("dir/b.bin", &[0, 255, 7]),
item("empty", b""),
];
let bytes = encode(&items).unwrap();
let back = decode(&bytes).unwrap();
assert_eq!(back.len(), 3);
assert_eq!(back[0].name, "a.txt");
assert_eq!(back[1].data, vec![0, 255, 7]);
assert!(back[2].data.is_empty());
}
#[test]
fn header_layout() {
let bytes = encode(&[item("x", b"ab")]).unwrap();
assert_eq!(&bytes[0..4], b"ARC1");
assert_eq!(&bytes[4..8], &[1, 0, 0, 0]);
assert_eq!(&bytes[8..12], &[1, 0, 0, 0]);
assert_eq!(bytes[12], b'x');
assert_eq!(&bytes[13..21], &[2, 0, 0, 0, 0, 0, 0, 0]);
assert_eq!(&bytes[21..], b"ab");
}
#[test]
fn bad_magic() {
assert!(matches!(decode(b"ZIP!...."), Err(ArcError::BadMagic)));
assert!(matches!(decode(b""), Err(ArcError::BadMagic)));
}
#[test]
fn every_truncation_is_an_error() {
let bytes = encode(&[item("a.txt", b"hello"), item("b", b"xyz")]).unwrap();
for n in 0..bytes.len() {
assert!(decode(&bytes[..n]).is_err(), "n={n}");
}
assert!(decode(&bytes).is_ok());
}
#[test]
fn huge_length_does_not_panic() {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"ARC1");
bytes.extend_from_slice(&1u32.to_le_bytes());
bytes.extend_from_slice(&1u32.to_le_bytes());
bytes.push(b'a');
bytes.extend_from_slice(&u64::MAX.to_le_bytes());
assert!(matches!(
decode(&bytes),
Err(ArcError::Truncated | ArcError::TooLarge)
));
}
}
header_layout で、先頭から名前・データまでの並びが、上の表のとおりであることを確かめています。every_truncation_is_an_error は、正しいアーカイブを、1バイトずつ短くしながら(0 バイトから、全長より1バイト短いところまで)読み直し、すべてエラーになることを確かめています。
テストを書いている途中で、1回、止まりました。
error[E0277]: `error::ArcError` doesn't implement `std::fmt::Debug`
--> src/format.rs:90:36
|
90 | let bytes = encode(&items).unwrap();
| ^^^^^^ the trait `std::fmt::Debug` is not implemented for `error::ArcError`
|
= note: add `#[derive(Debug)]` to `error::ArcError` or manually `impl std::fmt::Debug for error::ArcError`
Result の unwrap() は、Err のときに中身を {:?} で表示するので、エラー型に Debug が必要です(第5回の #[derive(Debug)])。ArcError に付けると、通りました(行番号は、そのときのものです)。
展開と、パスの安全性
アーカイブの中の名前は、作った人が自由に書けます。名前が ../evil.txt や /etc/passwd のアーカイブを、そのまま展開すると、展開先の外にファイルが書かれます。
実験用に、名前を dest.join(name) で作るだけの版(対策なし)を作って、ok.txt と ../evil.txt を入れたアーカイブを、box/work/dest に展開しました。
2 件を box/work/dest に展開しました
box/work/dest/ok.txt
box/work/evil.txt ← 展開先(dest)の外
第10回の Path::join は、/etc/passwd のような絶対パスで置き換わりました。../ は、join では、そのまま残るので、あとから上の階層に出ます。対策あり版では、Path::components() で、名前を部品に分けて、普通の部品(Component::Normal)だけを許します。
fn safe_path(dest: &Path, name: &str) -> Result<PathBuf, ArcError> {
if name.is_empty() {
return Err(ArcError::UnsafeName(name.to_string()));
}
let mut path = dest.to_path_buf();
for comp in Path::new(name).components() {
match comp {
Component::Normal(part) => path.push(part),
_ => return Err(ArcError::UnsafeName(name.to_string())),
}
}
Ok(path)
}
| 名前 | 結果 |
|---|---|
a/b.txt | out/a/b.txt |
a/./b | out/a/b(途中の . は、components() が取り除く) |
../evil、a/../../evil、/etc/passwd、./a、空 | UnsafeName で拒否 |
同じ evil1.arc(ok.txt と ../evil.txt)を、対策あり版で展開した結果です。
エラー: 危険な名前を拒否しました: ../evil.txt
展開先のディレクトリは、作られませんでした。ok.txt も書かれていません。unpack は、書き始める前に、すべての名前を検査するためです。
let mut targets = Vec::new();
for item in &items {
targets.push(safe_path(dest, &item.name)?);
}
for (item, path) in items.iter().zip(&targets) {
write_item(path, item, force)?;
}
.iter().zip(&targets) は、2つの並びを、1組ずつ同時に取り出します(イテレータは、この連載ではまだ扱っていません)。
もう1つの安全策が、create_new です。--force がないときは、既存のファイルを上書きしないように、ファイルを新しく作れたときだけ開きます。
let mut f = match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(f) => f,
Err(e) if e.kind() == ErrorKind::AlreadyExists => {
return Err(ArcError::Exists(path.display().to_string()));
}
Err(e) => return Err(e.into()),
};
エラー: すでに存在します(上書きするには --force): out1/data/big.bin
(Err(e) if ... は、match の腕に条件を付けるガードで、第5回のものです)
unpack のテストは、次のとおりです。
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_names_are_joined() {
let p = safe_path(Path::new("out"), "a/b.txt").unwrap();
assert_eq!(p, Path::new("out").join("a").join("b.txt"));
}
#[test]
fn inner_dot_is_dropped() {
let p = safe_path(Path::new("out"), "a/./b").unwrap();
assert_eq!(p, Path::new("out").join("a").join("b"));
}
#[test]
fn unsafe_names_are_rejected() {
for name in ["../evil", "a/../../evil", "/etc/passwd", "./a", ""] {
assert!(
matches!(
safe_path(Path::new("out"), name),
Err(ArcError::UnsafeName(_))
),
"name={name:?}"
);
}
}
}
テストは、8件とも通りました。inner_dot_is_dropped は、最初、a/./b を「拒否される名前」の一覧に入れていました。実行すると、name="a/./b" で失敗して、components() が途中の . を取り除くことに気づきました。そのため、期待値を直して、別のテストにしました。
作りながら直したこと
| 起きたこと | 原因 | 直し方 |
|---|---|---|
arc pack x.arc . が、UTF-8 でないファイル名は扱えません: . と出る | Path::new(".").file_name() は None(第10回の / と同じ)。それを「UTF-8 でない」と扱っていた | file_name() が None のときは、fs::canonicalize で本当の名前を調べる。/ のように、それでも名前がなければ 名前を決められません: / |
出力のアーカイブが入力のディレクトリの中にあると、2回目の実行で、前回の出力も取り込む(self/self.arc が増えた) | 入力を集める時点で、出力ファイルを知らなかった | 出力ファイルを fs::canonicalize して、同じファイルは入力から外す(2回目も 1 件のまま) |
上書きを拒否したときのエラーが File exists (os error 17) で、どのファイルか分からない | io::Error のまま返していた | ErrorKind::AlreadyExists のときだけ、パス付きの ArcError::Exists に変える |
テストが E0277(Debug がない) | ArcError に #[derive(Debug)] がなかった | 付けた |
a/./b のテストが失敗 | components() が途中の . を取り除くことを知らなかった | 期待値を直した |
動かして確かめる
次のファイルを用意して、実行しました。
data/
├── big.bin (1280 バイト。0〜255 の繰り返し)
├── note.txt (6 バイト)
├── zero.dat (0 バイト)
├── link.txt (note.txt へのシンボリックリンク)
├── empty/ (空のディレクトリ)
└── docs/
├── b.txt (3 バイト)
└── メモ.txt (19 バイト)
$ arc pack t.arc data
スキップ(通常のファイルではない): data/link.txt
5 件を t.arc に書き出しました
$ arc list t.arc
1280 data/big.bin
3 data/docs/b.txt
19 data/docs/メモ.txt
6 data/note.txt
0 data/zero.dat
(5 件)
$ arc unpack t.arc out1
5 件を out1 に展開しました
diff -r で、元の data/ と、展開した out1/data/ を比べると、違いは次の2つだけでした。
Only in data/: empty
Only in data/: link.txt
空のディレクトリと、シンボリックリンクは、アーカイブに入りません(この形式は、ファイルの名前と中身だけを持ちます)。ほかのファイルは、中身が同じでした(メモ.txt の日本語、0 バイトの zero.dat も)。
ファイルを直接指定したときは、渡した名前のままになります。
$ arc pack f.arc data/note.txt data/docs
3 件を f.arc に書き出しました
3 docs/b.txt
19 docs/メモ.txt
6 note.txt
壊れたアーカイブや、別の種類のファイルを渡した結果です(どれも終了コード 1)。
| 入力 | 表示 |
|---|---|
t.arc の先頭 100 バイトだけ | アーカイブが途中で切れています |
| 先頭 3 バイトだけ | アーカイブではありません(先頭の4バイトが ARC1 ではない) |
ZIP のような先頭(PK...) | 同上 |
データの長さが u64::MAX | アーカイブが途中で切れています |
名前が ff fe(UTF-8 でない) | アーカイブ内の名前が UTF-8 ではありません |
名前が ../evil.txt、/tmp/abs_evil.txt、a/../../evil3.txt、./x.txt | 危険な名前を拒否しました: ...(展開先の外にも、何も書かれなかった) |
| 空のアーカイブ(0 件) | (0 件)(終了コード 0) |
| 存在しないファイル | No such file or directory (os error 2) |
UTF-8 でないファイル名を含むディレクトリを pack | UTF-8 でないファイル名は扱えません: "bad\xFF.txt"(第10回の {:?} 表示) |
50,000,000 バイトのファイルを pack すると、アーカイブは 50,000,028 バイト(8 + 4 + 名前 8 + 8 = 28 バイトの増加)で、ピークのメモリ使用量は、約 99 MB でした(デバッグビルド、0.09 秒)。ファイル全体を一度にメモリへ読み込む作りなので、ファイルの大きさに比例して、メモリが要ります。
Java と並べる
Java で、同じ種類のことを試しました。
DataOutputStream out = new DataOutputStream(bo);
out.writeInt(1);
out.writeLong(2);
// [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2]
Java(DataOutputStream) | Rust | |
|---|---|---|
| 数値の並び | writeInt(1) は [0, 0, 0, 1](上の位が先) | 1u32.to_le_bytes() は [1, 0, 0, 0](形式に合わせて、自分で選ぶ) |
| バイトの型 | (byte) 0xff は -1(符号付き)。b & 0xff で 255 | u8(0〜255) |
| データが足りない | readInt()・readFully() が EOFException | 自分で書いた Truncated(take が Err) |
| 名前を展開先に付ける | dest.resolve("/etc/passwd") は /etc/passwd に置き換わる | Path::join も同じ |
| 外に出るか調べる | normalize() したあと startsWith(dest) で調べる(../evil.txt は inside=false) | components() で、Normal 以外を拒否する |
途中の . | Path.of("a/./b").normalize() は a/b | components() は a/b として扱う |
Java の Path.resolve も、../evil.txt を、そのまま box/work/dest/../evil.txt にします。normalize() すると box/work/evil.txt になって、startsWith で外だと分かります。
サンプル: arc の全コード
Cargo.toml は、上のとおりです。テストは、前の節に載せました。
src/main.rs
mod error;
mod format;
mod pack;
mod unpack;
use std::path::PathBuf;
use std::process;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "arc", about = "簡易アーカイバ")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// ファイルやディレクトリを、1つのアーカイブにまとめる
Pack {
/// 出力するアーカイブ
output: PathBuf,
/// まとめるファイルやディレクトリ
#[arg(required = true)]
inputs: Vec<PathBuf>,
},
/// アーカイブの中身を一覧する
List { archive: PathBuf },
/// アーカイブを展開する
Unpack {
archive: PathBuf,
/// 展開先(省略すると、カレントディレクトリ)
#[arg(default_value = ".")]
dest: PathBuf,
/// 既存のファイルを上書きする
#[arg(long)]
force: bool,
},
}
fn main() {
let cli = Cli::parse();
let result = match cli.cmd {
Cmd::Pack { output, inputs } => pack::pack(&output, &inputs),
Cmd::List { archive } => unpack::list(&archive),
Cmd::Unpack {
archive,
dest,
force,
} => unpack::unpack(&archive, &dest, force),
};
if let Err(e) = result {
eprintln!("エラー: {e}");
process::exit(1);
}
}
src/error.rs
use std::fmt;
use std::io;
#[derive(Debug)]
pub enum ArcError {
Io(io::Error),
BadMagic,
Truncated,
TooLarge,
BadName,
Exists(String),
UnsafeName(String),
NonUtf8Name(String),
NoName(String),
}
impl From<io::Error> for ArcError {
fn from(e: io::Error) -> ArcError {
ArcError::Io(e)
}
}
impl fmt::Display for ArcError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ArcError::Io(e) => write!(f, "{e}"),
ArcError::BadMagic => write!(
f,
"アーカイブではありません(先頭の4バイトが ARC1 ではない)"
),
ArcError::Truncated => write!(f, "アーカイブが途中で切れています"),
ArcError::TooLarge => write!(f, "大きすぎて、この形式では扱えません"),
ArcError::BadName => write!(f, "アーカイブ内の名前が UTF-8 ではありません"),
ArcError::Exists(n) => write!(f, "すでに存在します(上書きするには --force): {n}"),
ArcError::UnsafeName(n) => write!(f, "危険な名前を拒否しました: {n}"),
ArcError::NoName(n) => write!(f, "名前を決められません: {n}"),
ArcError::NonUtf8Name(n) => write!(f, "UTF-8 でないファイル名は扱えません: {n}"),
}
}
}
src/format.rs(テストは前の節)
use crate::error::ArcError;
const MAGIC: &[u8; 4] = b"ARC1";
pub struct Item {
pub name: String,
pub data: Vec<u8>,
}
pub fn encode(items: &[Item]) -> Result<Vec<u8>, ArcError> {
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
let count = u32::try_from(items.len()).map_err(|_| ArcError::TooLarge)?;
out.extend_from_slice(&count.to_le_bytes());
for item in items {
let name = item.name.as_bytes();
let name_len = u32::try_from(name.len()).map_err(|_| ArcError::TooLarge)?;
out.extend_from_slice(&name_len.to_le_bytes());
out.extend_from_slice(name);
out.extend_from_slice(&(item.data.len() as u64).to_le_bytes());
out.extend_from_slice(&item.data);
}
Ok(out)
}
struct Reader<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn take(&mut self, n: usize) -> Result<&'a [u8], ArcError> {
let end = self.pos.checked_add(n).ok_or(ArcError::Truncated)?;
if end > self.bytes.len() {
return Err(ArcError::Truncated);
}
let slice = &self.bytes[self.pos..end];
self.pos = end;
Ok(slice)
}
fn u32(&mut self) -> Result<u32, ArcError> {
let b = self.take(4)?;
Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
fn u64(&mut self) -> Result<u64, ArcError> {
let b = self.take(8)?;
Ok(u64::from_le_bytes([
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
]))
}
}
pub fn decode(bytes: &[u8]) -> Result<Vec<Item>, ArcError> {
let mut r = Reader { bytes, pos: 0 };
if r.take(4).map_err(|_| ArcError::BadMagic)? != MAGIC {
return Err(ArcError::BadMagic);
}
let count = r.u32()?;
let mut items = Vec::new();
for _ in 0..count {
let name_len = r.u32()? as usize;
let name = String::from_utf8(r.take(name_len)?.to_vec()).map_err(|_| ArcError::BadName)?;
let data_len = usize::try_from(r.u64()?).map_err(|_| ArcError::TooLarge)?;
let data = r.take(data_len)?.to_vec();
items.push(Item { name, data });
}
Ok(items)
}
src/pack.rs
use std::fs;
use std::path::{Path, PathBuf};
use crate::error::ArcError;
use crate::format::{self, Item};
fn collect(path: &Path, name: String, found: &mut Vec<(PathBuf, String)>) -> Result<(), ArcError> {
let meta = fs::symlink_metadata(path)?;
if meta.is_dir() {
for entry in fs::read_dir(path)? {
let entry = entry?;
let child = match entry.file_name().into_string() {
Ok(s) => s,
Err(os) => return Err(ArcError::NonUtf8Name(format!("{os:?}"))),
};
collect(&entry.path(), format!("{name}/{child}"), found)?;
}
} else if meta.is_file() {
found.push((path.to_path_buf(), name));
} else {
eprintln!("スキップ(通常のファイルではない): {}", path.display());
}
Ok(())
}
pub fn pack(output: &Path, inputs: &[PathBuf]) -> Result<(), ArcError> {
let mut found = Vec::new();
for input in inputs {
let named = if input.file_name().is_some() {
input.clone()
} else {
fs::canonicalize(input)?
};
let name = match named.file_name().map(|n| n.to_str()) {
Some(Some(s)) => s.to_string(),
Some(None) => return Err(ArcError::NonUtf8Name(input.display().to_string())),
None => return Err(ArcError::NoName(input.display().to_string())),
};
collect(input, name, &mut found)?;
}
if let Ok(out) = fs::canonicalize(output) {
found.retain(|(path, _)| fs::canonicalize(path).map(|p| p != out).unwrap_or(true));
}
found.sort_by(|a, b| a.1.cmp(&b.1));
let mut items = Vec::new();
for (path, name) in found {
items.push(Item {
data: fs::read(&path)?,
name,
});
}
fs::write(output, format::encode(&items)?)?;
println!("{} 件を {} に書き出しました", items.len(), output.display());
Ok(())
}
src/unpack.rs(テストは前の節)
use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::{Component, Path, PathBuf};
use crate::error::ArcError;
use crate::format::{self, Item};
pub fn list(archive: &Path) -> Result<(), ArcError> {
let items = format::decode(&fs::read(archive)?)?;
for item in &items {
println!("{:>10} {}", item.data.len(), item.name);
}
println!("({} 件)", items.len());
Ok(())
}
fn safe_path(dest: &Path, name: &str) -> Result<PathBuf, ArcError> {
if name.is_empty() {
return Err(ArcError::UnsafeName(name.to_string()));
}
let mut path = dest.to_path_buf();
for comp in Path::new(name).components() {
match comp {
Component::Normal(part) => path.push(part),
_ => return Err(ArcError::UnsafeName(name.to_string())),
}
}
Ok(path)
}
fn write_item(path: &Path, item: &Item, force: bool) -> Result<(), ArcError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
if force {
fs::write(path, &item.data)?;
} else {
let mut f = match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(f) => f,
Err(e) if e.kind() == ErrorKind::AlreadyExists => {
return Err(ArcError::Exists(path.display().to_string()));
}
Err(e) => return Err(e.into()),
};
f.write_all(&item.data)?;
}
Ok(())
}
pub fn unpack(archive: &Path, dest: &Path, force: bool) -> Result<(), ArcError> {
let items = format::decode(&fs::read(archive)?)?;
let mut targets = Vec::new();
for item in &items {
targets.push(safe_path(dest, &item.name)?);
}
for (item, path) in items.iter().zip(&targets) {
write_item(path, item, force)?;
}
println!("{} 件を {} に展開しました", items.len(), dest.display());
Ok(())
}
初めて出てきた書き方
| 書き方 | 意味 |
|---|---|
fs::symlink_metadata(path) | シンボリックリンクをたどらずに、種類を調べる(fs::metadata はたどる) |
found.retain(|(path, _)| ...) | 条件に合うものだけ残す。|...| は、第8回のクロージャ |
fs::canonicalize(path) | 実際の絶対パスに直す(. や .. や、シンボリックリンクを解決する) |
input.file_name().map(|n| n.to_str()) | Option<&OsStr> の中身を、Option<&str> を持つ Option に変える。結果は Some(Some(s))、Some(None)(UTF-8 でない)、None の3通り |
matches!(x, Pattern) | パターンに合うかどうかを bool にする(テストで使用) |
Err(ArcError::Truncated | ArcError::TooLarge) | パターンの中の | は、「どちらか」 |
OpenOptions::new().write(true).create_new(true) | 新しく作れたときだけ開く。すでにあると AlreadyExists |
fs::create_dir_all(parent) | 途中のディレクトリも、まとめて作る |
{:>10} | 10 文字の幅に、右寄せ(第8回の {:>8} と同じ) |
歴史
| 年月 | できごと |
|---|---|
| 1979年1月 | tar が、Version 7 Unix で初めて導入された(Wikipedia の記述) |
| 1989年 | PKWARE の Phil Katz が、PKZIP で ZIP 形式を作った(Wikipedia の記述) |
tar は、名前が “tape archive” に由来し、独自のファイルシステムを持たない、順に読み書きする装置へ書くために作られたとされます。形式は、ファイルごとに 512 バイトのヘッダ、データ(512 バイトの倍数に切り上げ)、の繰り返しで、終わりは 2 つ以上のゼロで埋めたレコードです。tar 自体は圧縮をせず、圧縮は gzip や bzip2 などが、アーカイブ全体に対して行います。
ZIP は、ARC 形式(Thom Henderson 作)の代わりとして作られたと書かれています。知られている最も古い仕様は、1989 年の PKZIP 0.9 に入っていた APPNOTE.TXT です。ZIP は、各ファイルの前に「ローカルヘッダ」(名前、サイズ、CRC-32 など)があり、末尾に「セントラルディレクトリ」(ファイルの一覧と、各ヘッダの位置)を置きます。記事は、末尾に置く理由を、ファイルを追加しやすく(古いディレクトリを読み、新しいファイルを追記して、更新したディレクトリを追記すれば済む)、全体を読まずに一覧を取れるため、と説明しています。
arc の形式は、ファイルが順に並ぶだけで、末尾の一覧も、圧縮も、CRC もありません。arc list も、ファイル全体を読んでから、一覧を作っています。
動作確認
確認できたこと
- 上のコード、コマンド、出力は、すべて Linux(Rust 1.97.0、Cargo 1.97.0、edition 2024、
clap4.6.7)で、実際に実行した結果です。Java も、同じ環境(OpenJDK 21)で実行しました pack・list・unpackの往復。元のファイルと、展開したファイルの中身が同じこと(diff -r)。空のディレクトリとシンボリックリンクが入らないこと- ファイルの大きさ 1449 バイトが、形式の計算と一致すること。先頭 24 バイトの内容
clapの--help、必須引数の不足、未知のオプション、未知のサブコマンドの表示と、終了コード 2- 壊れたアーカイブ、別の種類のファイル、でたらめな長さ、UTF-8 でない名前、危険な名前(4種類)に対する表示。危険な名前のとき、展開先の外にも、何も書かれなかったこと
- 対策なしの版で、
../evil.txtが展開先の外に書かれること(実験用の版は、スクラッチ用のディレクトリの中だけで実行) cargo test8 件の成功。cargo clippy --all-targetsとcargo fmt --checkが指摘なしであること- 50,000,000 バイトのファイルの
pack(0.09 秒、ピークのメモリ約 99 MB。デバッグビルド) - Java の
DataOutputStreamのバイト列、(byte) 0xff、EOFException、resolveとnormalizeの結果
歴史の出典
- tar の経緯は、Wikipedia「tar (computing)」のミラー(
ipfs.ssi.eecc.de)の記述です。取得ツールが返した要約を通して確認しました - ZIP の経緯は、Wikipedia「ZIP (file format)」の保存ページ(
webarchive.library.unt.edu)の記述です。取得ツールが返した要約を通して確認しました。同じ要約には、PKZIP の1989年と APPNOTE の 0.9 版の前後関係は、記事から確定できない、という注記がありました - どちらも、1つの出典だけです。全文は読んでいません
確認できていないこと
- Windows と macOS での動作。名前の区切りが
\の場合、ドライブ名つきの名前(Component::Prefix)の扱いは、確かめていません - 読めないファイル(権限がない場合)の扱い。この環境は root で動いていたため、読めないファイルを作れませんでした
- ファイルの権限、更新日時、シンボリックリンクの保存。この形式は、持っていません
- データが壊れたとき(データの途中のバイトが変わったとき)の検出。CRC などは持たないので、気づけない設計ですが、実験はしていません
- メモリに入りきらない大きさのアーカイブ
clapの他の機能(複数の短いオプション、シェル補完など)- デバッグビルドとリリースビルドの速度とメモリの違い
次回
次回は、Tauri v2 の構造(フロントと Rust の役割分担)を扱います。ここまでの回で作ってきた Rust のコードを、画面から呼び出す仕組みの、土台を見ます。
連載の目次(予定)
入門編(8回・完結)
- Rust とは / 環境構築 / Hello World
- 変数・型・関数(Java との対比)
- 所有権(ムーブ)
- 借用と参照
- 構造体・enum・match
- Result / Option とエラー処理
- トレイトとジェネリクス(Java の interface との違い)
- ライフタイム入門と、総まとめの CLI
実践編(8回・予定。内容は、書きながら変わる可能性があります)
- Cargo・クレート・モジュール構成
- ファイル操作・パス・文字コード
- 小さな CLI を作り込む(簡易アーカイバ)(今回・連載の第11回)
- Tauri v2 の構造(フロントと Rust の役割分担)
- コマンドとイベントの橋渡し
- グローバルホットキー常駐(shotkey を分解)
- 設定の保存・トレイ・自動起動
- ビルド・署名・GitHub リリース