Skip to main content

fstool/fs/grf/
mod.rs

1//! GRF (Gravity Ragnarok File) — Korean MMO archive format.
2//!
3//! GRF is the on-disk archive format used by *Ragnarok Online*'s
4//! game client to ship art, maps, scripts, and sounds. The original
5//! libgrf implementation (~2003, by the user) is the reference; the
6//! port here lives under the MIT-licensed fstool crate with the
7//! rights holder's permission.
8//!
9//! Three versions are in the wild:
10//!
11//! - **`0x102` / `0x103`**: file table is RAW (not zlib) and each
12//!   filename inside it is encrypted with a fixed-key permutation
13//!   cipher ([`crypt::decode_filename`]). The on-disk `len` /
14//!   `len_aligned` fields carry magic offsets that the v0x102 decoder
15//!   in the `table` module strips. File bodies can also be encrypted
16//!   per-entry via the `MIXCRYPT` / `DES` flags.
17//! - **`0x200`**: file table is zlib-compressed but filenames are
18//!   plain CP949. Magic offsets removed. This is what the writer
19//!   produces today.
20//!
21//! Filenames stored on disk are CP949 (Korean MS codepage); the
22//! [`crate::fs::Filesystem`] surface exposes UTF-8 strings, with
23//! conversion happening once at parse / write time
24//! (see [`encoding`]).
25//!
26//! Layout on disk:
27//!
28//! ```text
29//! offset 0     : 46-byte header
30//! offset 46    : file data blocks (each compressed, optionally encrypted)
31//! offset N     : file table
32//! ```
33//!
34//! `header.table_offset` is relative to the end of the 46-byte
35//! header, so the absolute file position is
36//! `header.table_offset + HEADER_SIZE`. Removing files marks their
37//! data wasted; flush rewrites the table; repacking compacts.
38//! See [`writer`].
39
40pub mod crypt;
41pub mod encoding;
42pub mod header;
43pub mod table;
44pub mod writer;
45
46pub use table::{Entry, GRF_FLAG_DES, GRF_FLAG_FILE, GRF_FLAG_MIXCRYPT};
47
48use std::collections::BTreeMap;
49use std::io::Read;
50
51use crate::Result;
52use crate::block::BlockDevice;
53use crate::fs::{FileMeta, FileSource, MutationCapability};
54
55pub(crate) const HEADER_SIZE: usize = 0x2e;
56
57/// Public format-side options for
58/// [`crate::fs::FilesystemFactory::format`]. The writer always emits
59/// version 0x200 today; older versions are readable but not writeable
60/// (they're rarely useful outside legacy game clients).
61#[derive(Debug, Clone)]
62pub struct FormatOpts {
63    /// GRF version word to write. Only 0x200 is supported by the
64    /// writer right now.
65    pub version: u32,
66    /// zlib compression level (0..=9). 0 = store, 6 = default.
67    pub compression_level: u32,
68}
69
70impl Default for FormatOpts {
71    fn default() -> Self {
72        Self {
73            version: 0x200,
74            compression_level: 6,
75        }
76    }
77}
78
79impl FormatOpts {
80    /// Apply a generic option-bag (CLI `-O key=val` / TOML
81    /// `[filesystem.options]`) on top of these opts. Unknown keys are
82    /// left in the map for the caller to flag.
83    pub fn apply_options(&mut self, map: &mut crate::format_opts::OptionMap) -> crate::Result<()> {
84        if let Some(v) = map.take_u32("version")? {
85            self.version = v;
86        }
87        if let Some(n) = map.take_u32("compression_level")? {
88            if n > 9 {
89                return Err(crate::Error::InvalidImage(format!(
90                    "compression_level {n} out of range (0..=9)"
91                )));
92            }
93            self.compression_level = n;
94        }
95        Ok(())
96    }
97}
98
99/// An opened GRF archive.
100pub struct Grf {
101    pub version: u32,
102    pub table_offset: u32,
103    pub seed: u32,
104    pub encrypted_header: bool,
105    /// Entries keyed by their normalised path (`/` prefix stripped).
106    /// On-disk filenames are CP949; this map's keys are UTF-8.
107    pub entries: BTreeMap<String, Entry>,
108    /// First byte past the last file's data — where new data appends
109    /// and where the table will land at flush time.
110    data_end: u64,
111    /// Bytes inside the data area that no longer back any entry
112    /// (accumulated when files are removed). Drives the repack
113    /// decision.
114    wasted_space: u64,
115    /// True if the in-memory state diverges from disk; flush rewrites
116    /// the table + header.
117    dirty: bool,
118    /// `false` until [`Self::format`] or [`Self::open`] finishes. Set
119    /// to mark the handle as a fresh writer (no existing file data
120    /// to preserve).
121    fresh: bool,
122    /// Byte length of the archive after the last flush (data + table).
123    /// `0` until flushed; lets a repack truncate the over-provisioned
124    /// backing file to fit.
125    image_end: u64,
126}
127
128impl Grf {
129    /// Build a `Grf` handle that represents a freshly-formatted empty
130    /// archive. The header isn't written until
131    /// [`<Self as crate::fs::Filesystem>::flush`](crate::fs::Filesystem::flush).
132    pub fn format_with(_dev: &mut dyn BlockDevice, opts: &FormatOpts) -> Result<Self> {
133        if opts.version != 0x200 {
134            return Err(crate::Error::Unsupported(format!(
135                "grf: writer only emits v0x200 (asked for {:#x})",
136                opts.version
137            )));
138        }
139        Ok(Self {
140            version: opts.version,
141            table_offset: 0,
142            seed: 0,
143            encrypted_header: false,
144            entries: BTreeMap::new(),
145            data_end: HEADER_SIZE as u64,
146            wasted_space: 0,
147            dirty: true,
148            fresh: true,
149            image_end: 0,
150        })
151    }
152
153    /// Open an existing GRF on `dev`. Parses the header + file table
154    /// fully into memory.
155    pub fn open_dev(dev: &mut dyn BlockDevice) -> Result<Self> {
156        let mut head_buf = [0u8; HEADER_SIZE];
157        dev.read_at(0, &mut head_buf)?;
158        let head = header::Header::decode(&head_buf)?;
159
160        let table_abs = head.table_offset as u64 + HEADER_SIZE as u64;
161        let entries = read_table(dev, table_abs, head.version, head.filecount)?;
162
163        // data_end = the maximum (pos + len_aligned) across all
164        // entries, anchored at HEADER_SIZE so an empty archive lays
165        // its first file directly after the header.
166        let mut data_end = HEADER_SIZE as u64;
167        for e in entries.values() {
168            let end = HEADER_SIZE as u64 + e.pos as u64 + e.len_aligned as u64;
169            if end > data_end {
170                data_end = end;
171            }
172        }
173
174        // Wasted space: the table starts at `table_abs` and runs to
175        // the end of the file. If there's a gap between data_end and
176        // table_abs, that gap is wasted (left over from removed
177        // files in a previous lifetime of this archive).
178        let wasted_space = table_abs.saturating_sub(data_end);
179
180        Ok(Self {
181            version: head.version,
182            table_offset: head.table_offset,
183            seed: head.seed,
184            encrypted_header: head.encrypted_header,
185            entries,
186            data_end,
187            wasted_space,
188            dirty: false,
189            fresh: false,
190            image_end: 0,
191        })
192    }
193
194    /// Read the body of `entry` into a freshly-allocated buffer.
195    /// Handles per-file MIXCRYPT/DES decryption and zlib inflation.
196    pub fn read_entry(&self, dev: &mut dyn BlockDevice, entry: &Entry) -> Result<Vec<u8>> {
197        let abs = HEADER_SIZE as u64 + entry.pos as u64;
198        // `len_aligned` is an attacker-controlled u32 (up to ~4 GiB).
199        // Allocating it without bounding against the device is an OOM
200        // vector; a real entry's body lies within the image, so reject a
201        // read that would run past the end of the device before allocating.
202        let dev_size = dev.total_size();
203        let end = abs.saturating_add(u64::from(entry.len_aligned));
204        if end > dev_size {
205            return Err(crate::Error::InvalidImage(format!(
206                "grf: entry body (pos {} len_aligned {}) past end of device size {}",
207                entry.pos, entry.len_aligned, dev_size
208            )));
209        }
210        let mut comp = vec![0u8; entry.len_aligned as usize];
211        if entry.len_aligned > 0 {
212            dev.read_at(abs, &mut comp)?;
213        }
214        if let Some(cycle) = entry.crypto_cycle() {
215            // flag_type is 0 for MIXCRYPT, 1 for DES — see grf.c
216            // decode_des_etc(..., (cycle==0), cycle).
217            let flag_type = if cycle == 0 { 1 } else { 0 };
218            crypt::decode_des_etc(&mut comp, flag_type, cycle);
219        }
220        let comp_slice = comp.get(..entry.len as usize).ok_or_else(|| {
221            crate::Error::InvalidImage(format!(
222                "grf: entry compressed len {} exceeds aligned buffer {}",
223                entry.len, entry.len_aligned
224            ))
225        })?;
226        let plain = crate::compression::decompress(
227            crate::compression::Algo::Zlib,
228            comp_slice,
229            entry.size as usize,
230        )?;
231        Ok(plain)
232    }
233
234    /// Total wasted bytes inside the data area. A nonzero value
235    /// means a repack would shrink the archive.
236    pub fn wasted_space(&self) -> u64 {
237        self.wasted_space
238    }
239}
240
241fn read_table(
242    dev: &mut dyn BlockDevice,
243    table_abs: u64,
244    version: u32,
245    filecount: u32,
246) -> Result<BTreeMap<String, Entry>> {
247    if filecount == 0 {
248        return Ok(BTreeMap::new());
249    }
250
251    let dev_size = dev.total_size();
252    if table_abs >= dev_size {
253        return Err(crate::Error::InvalidImage(
254            "grf: table offset past end of file".into(),
255        ));
256    }
257
258    let entries = match version {
259        0x102 | 0x103 => {
260            // v0x102/0x103: the table layout starts with 8 bytes of
261            // posinfo just like v0x200 (libgrf inflates the
262            // remainder), followed by a 4-byte legacy-framing word.
263            // See grf.c lines 826–910 — the only difference from
264            // v0x200 is the extra 4-byte `brokenpos` field after the
265            // compressed payload.
266            let table = read_compressed_table(dev, table_abs, /* legacy_framing = */ true)?;
267            table::decode_v102(&table)?
268        }
269        0x200 => {
270            let table = read_compressed_table(dev, table_abs, /* legacy_framing = */ false)?;
271            table::decode_v200(&table)?
272        }
273        other => {
274            return Err(crate::Error::Unsupported(format!(
275                "grf: cannot read table for version {other:#x}"
276            )));
277        }
278    };
279
280    let mut map = BTreeMap::new();
281    for e in entries {
282        map.insert(normalise_path(&e.name), e);
283    }
284    Ok(map)
285}
286
287fn read_compressed_table(
288    dev: &mut dyn BlockDevice,
289    table_abs: u64,
290    legacy_framing: bool,
291) -> Result<Vec<u8>> {
292    let dev_size = dev.total_size();
293    let mut posinfo = [0u8; 8];
294    if table_abs + 8 > dev_size {
295        return Err(crate::Error::InvalidImage(
296            "grf: table header truncated".into(),
297        ));
298    }
299    dev.read_at(table_abs, &mut posinfo)?;
300    let comp_size = u32::from_le_bytes(posinfo[0..4].try_into().unwrap()) as usize;
301    let uncomp_size = u32::from_le_bytes(posinfo[4..8].try_into().unwrap()) as usize;
302
303    let comp_start = table_abs + 8;
304    if comp_start + comp_size as u64 > dev_size {
305        return Err(crate::Error::InvalidImage(
306            "grf: compressed table payload past end of file".into(),
307        ));
308    }
309    let mut comp = vec![0u8; comp_size];
310    dev.read_at(comp_start, &mut comp)?;
311
312    // Legacy framing — there's an additional 4-byte word after the
313    // compressed payload in v0x102/0x103. We don't use its value
314    // (libgrf calls it `brokenpos` and treats it as opaque).
315    let _ = legacy_framing;
316
317    crate::compression::decompress(crate::compression::Algo::Zlib, &comp, uncomp_size)
318}
319
320/// Strip a leading `/` from a path string so it lines up with the
321/// CP949 names libgrf writes (which never start with `/`).
322fn normalise_path(s: &str) -> String {
323    s.trim_start_matches('/').to_string()
324}
325
326impl crate::fs::FilesystemFactory for Grf {
327    type FormatOpts = FormatOpts;
328
329    fn format(dev: &mut dyn BlockDevice, opts: &Self::FormatOpts) -> Result<Self> {
330        Self::format_with(dev, opts)
331    }
332
333    fn open(dev: &mut dyn BlockDevice) -> Result<Self> {
334        Self::open_dev(dev)
335    }
336}
337
338impl crate::fs::Filesystem for Grf {
339    fn create_file(
340        &mut self,
341        dev: &mut dyn BlockDevice,
342        path: &std::path::Path,
343        src: FileSource,
344        _meta: FileMeta,
345    ) -> Result<()> {
346        let key = normalise_path(
347            path.to_str()
348                .ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?,
349        );
350        writer::add_file(self, dev, key, src)
351    }
352
353    /// Stream the body straight into the (zlib-compressed) data area — no
354    /// temp file. GRF must hold one file in memory to deflate it as a
355    /// single stream, but never more than the largest file, and never the
356    /// whole tree.
357    fn create_file_streaming(
358        &mut self,
359        dev: &mut dyn BlockDevice,
360        path: &std::path::Path,
361        body: &mut dyn std::io::Read,
362        len: u64,
363        _meta: FileMeta,
364    ) -> Result<()> {
365        let key = normalise_path(
366            path.to_str()
367                .ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?,
368        );
369        let mut plain = Vec::with_capacity(len.min(64 * 1024 * 1024) as usize);
370        std::io::Read::take(body, len).read_to_end(&mut plain)?;
371        writer::add_file_bytes(self, dev, key, plain)
372    }
373
374    fn create_dir(
375        &mut self,
376        _dev: &mut dyn BlockDevice,
377        _path: &std::path::Path,
378        _meta: FileMeta,
379    ) -> Result<()> {
380        // GRF has no directory entries — paths' parents are implicit
381        // from the slashes. `create_dir` is a no-op so that callers
382        // who emit dirs (e.g. the repack walker) don't error out.
383        Ok(())
384    }
385
386    fn create_symlink(
387        &mut self,
388        _dev: &mut dyn BlockDevice,
389        _path: &std::path::Path,
390        _target: &std::path::Path,
391        _meta: FileMeta,
392    ) -> Result<()> {
393        Err(crate::Error::Unsupported(
394            "grf: symlinks are not part of the archive format".into(),
395        ))
396    }
397
398    fn create_device(
399        &mut self,
400        _dev: &mut dyn BlockDevice,
401        _path: &std::path::Path,
402        _kind: crate::fs::DeviceKind,
403        _major: u32,
404        _minor: u32,
405        _meta: FileMeta,
406    ) -> Result<()> {
407        Err(crate::Error::Unsupported(
408            "grf: device nodes are not part of the archive format".into(),
409        ))
410    }
411
412    fn remove(&mut self, _dev: &mut dyn BlockDevice, path: &std::path::Path) -> Result<()> {
413        let key = normalise_path(
414            path.to_str()
415                .ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?,
416        );
417        writer::remove(self, &key)
418    }
419
420    fn list(
421        &mut self,
422        _dev: &mut dyn BlockDevice,
423        path: &std::path::Path,
424    ) -> Result<Vec<crate::fs::DirEntry>> {
425        let prefix = {
426            let s = path
427                .to_str()
428                .ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?;
429            let trimmed = s.trim_start_matches('/').trim_end_matches('/');
430            if trimmed.is_empty() {
431                String::new()
432            } else {
433                format!("{trimmed}/")
434            }
435        };
436
437        // Collect the immediate children of `prefix`: each unique
438        // first path component after the prefix. Files appear as
439        // Regular, intermediate path components appear as Dir.
440        use std::collections::BTreeMap as B;
441        let mut children: B<String, crate::fs::EntryKind> = B::new();
442        let mut sizes: B<String, u64> = B::new();
443        for (name, entry) in &self.entries {
444            let Some(tail) = name.strip_prefix(&prefix) else {
445                continue;
446            };
447            if tail.is_empty() {
448                continue;
449            }
450            if let Some((leaf, _)) = tail.split_once('/') {
451                children.insert(leaf.to_string(), crate::fs::EntryKind::Dir);
452                sizes.insert(leaf.to_string(), 0);
453            } else {
454                children.insert(tail.to_string(), crate::fs::EntryKind::Regular);
455                sizes.insert(tail.to_string(), entry.size as u64);
456            }
457        }
458        Ok(children
459            .into_iter()
460            .map(|(name, kind)| {
461                let size = *sizes.get(&name).unwrap_or(&0);
462                crate::fs::DirEntry {
463                    name,
464                    inode: 0,
465                    kind,
466                    size,
467                }
468            })
469            .collect())
470    }
471
472    fn read_file<'a>(
473        &'a mut self,
474        dev: &'a mut dyn BlockDevice,
475        path: &std::path::Path,
476    ) -> Result<Box<dyn Read + 'a>> {
477        let key = normalise_path(
478            path.to_str()
479                .ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?,
480        );
481        let entry =
482            self.entries.get(&key).cloned().ok_or_else(|| {
483                crate::Error::InvalidArgument(format!("grf: no entry at {key:?}"))
484            })?;
485        // TODO(streaming): grf member is whole-file zlib+DES; forward decode
486        // is inherently from-start, so `read_entry` materialises the whole
487        // body here. Acceptable for the forward path (no fake Seek offered).
488        let bytes = self.read_entry(dev, &entry)?;
489        Ok(Box::new(std::io::Cursor::new(bytes)))
490    }
491
492    fn open_file_ro<'a>(
493        &'a mut self,
494        _dev: &'a mut dyn BlockDevice,
495        path: &std::path::Path,
496    ) -> Result<Box<dyn crate::fs::FileReadHandle + 'a>> {
497        // GRF stores each file as a single zlib stream (optionally
498        // per-block DES-encrypted). It is whole-member compressed and
499        // decodes only forward from the start, so a seekable handle
500        // could only be faked by inflating the entire body into RAM.
501        // That's dishonest — refuse and let callers stream forward via
502        // `read_file` instead.
503        let key = normalise_path(
504            path.to_str()
505                .ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?,
506        );
507        if !self.entries.contains_key(&key) {
508            return Err(crate::Error::InvalidArgument(format!(
509                "grf: no entry at {key:?}"
510            )));
511        }
512        Err(crate::Error::Unsupported(format!(
513            "grf: {key:?} is compressed; only forward reads are supported"
514        )))
515    }
516
517    fn flush(&mut self, dev: &mut dyn BlockDevice) -> Result<()> {
518        writer::flush(self, dev)
519    }
520
521    /// Exact archive length after flush (data region + file table), so a
522    /// repack can truncate the over-provisioned backing file to fit.
523    fn image_len(&self) -> Option<u64> {
524        (self.image_end > 0).then_some(self.image_end)
525    }
526
527    fn mutation_capability(&self) -> MutationCapability {
528        MutationCapability::Mutable
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::block::MemoryBackend;
536    use crate::fs::{Filesystem, FilesystemFactory};
537
538    #[test]
539    fn empty_round_trip() {
540        let mut dev = MemoryBackend::new(64 * 1024);
541        let mut grf = Grf::format(&mut dev, &FormatOpts::default()).unwrap();
542        grf.flush(&mut dev).unwrap();
543
544        let reopen = Grf::open(&mut dev).unwrap();
545        assert_eq!(reopen.version, 0x200);
546        assert_eq!(reopen.entries.len(), 0);
547    }
548
549    #[test]
550    fn add_read_round_trip() {
551        let mut dev = MemoryBackend::new(64 * 1024);
552        let mut grf = Grf::format(&mut dev, &FormatOpts::default()).unwrap();
553
554        let body = b"hello, world!";
555        grf.create_file(
556            &mut dev,
557            std::path::Path::new("/data/info.txt"),
558            FileSource::Reader {
559                reader: Box::new(std::io::Cursor::new(body.to_vec())),
560                len: body.len() as u64,
561            },
562            FileMeta::default(),
563        )
564        .unwrap();
565        grf.flush(&mut dev).unwrap();
566
567        let mut reopen = Grf::open(&mut dev).unwrap();
568        assert_eq!(reopen.entries.len(), 1);
569        let entries = reopen
570            .list(&mut dev, std::path::Path::new("/data"))
571            .unwrap();
572        assert!(entries.iter().any(|e| e.name == "info.txt"));
573        let entry = reopen.entries.get("data/info.txt").cloned().unwrap();
574        let bytes = reopen.read_entry(&mut dev, &entry).unwrap();
575        assert_eq!(bytes, body);
576    }
577
578    #[test]
579    fn open_file_ro_refuses_compressed_member() {
580        // GRF members are whole-file zlib streams: honestly forward-only,
581        // so `open_file_ro` must refuse rather than fake Seek by buffering
582        // the whole body. The forward `read_file` path still works.
583        use std::io::Read;
584        let mut dev = MemoryBackend::new(64 * 1024);
585        let mut grf = Grf::format(&mut dev, &FormatOpts::default()).unwrap();
586        let body: Vec<u8> = (0..1024u32).map(|i| (i & 0xff) as u8).collect();
587        grf.create_file(
588            &mut dev,
589            std::path::Path::new("/blob.bin"),
590            FileSource::Reader {
591                reader: Box::new(std::io::Cursor::new(body.clone())),
592                len: body.len() as u64,
593            },
594            FileMeta::default(),
595        )
596        .unwrap();
597        grf.flush(&mut dev).unwrap();
598
599        let mut grf = Grf::open(&mut dev).unwrap();
600        // Random-access open is refused for a compressed member.
601        match grf.open_file_ro(&mut dev, std::path::Path::new("/blob.bin")) {
602            Err(crate::Error::Unsupported(_)) => {}
603            Ok(_) => panic!("open_file_ro must refuse a compressed grf member"),
604            Err(e) => panic!("expected Unsupported, got {e:?}"),
605        }
606        // A missing entry still reports InvalidArgument (not Unsupported).
607        match grf.open_file_ro(&mut dev, std::path::Path::new("/nope.bin")) {
608            Err(crate::Error::InvalidArgument(_)) => {}
609            Ok(_) => panic!("open_file_ro must fail for a missing entry"),
610            Err(e) => panic!("expected InvalidArgument, got {e:?}"),
611        }
612        // Forward read still returns the exact bytes.
613        let mut r = grf
614            .read_file(&mut dev, std::path::Path::new("/blob.bin"))
615            .unwrap();
616        let mut got = Vec::new();
617        r.read_to_end(&mut got).unwrap();
618        assert_eq!(got, body);
619    }
620
621    #[test]
622    fn hangul_filename_round_trip() {
623        let mut dev = MemoryBackend::new(64 * 1024);
624        let mut grf = Grf::format(&mut dev, &FormatOpts::default()).unwrap();
625        grf.create_file(
626            &mut dev,
627            std::path::Path::new("/data/한글.txt"),
628            FileSource::Reader {
629                reader: Box::new(std::io::Cursor::new(b"hi".to_vec())),
630                len: 2,
631            },
632            FileMeta::default(),
633        )
634        .unwrap();
635        grf.flush(&mut dev).unwrap();
636
637        let reopen = Grf::open(&mut dev).unwrap();
638        assert!(reopen.entries.contains_key("data/한글.txt"));
639    }
640
641    #[test]
642    fn remove_marks_wasted_space() {
643        let mut dev = MemoryBackend::new(64 * 1024);
644        let mut grf = Grf::format(&mut dev, &FormatOpts::default()).unwrap();
645        grf.create_file(
646            &mut dev,
647            std::path::Path::new("/a.txt"),
648            FileSource::Reader {
649                reader: Box::new(std::io::Cursor::new(vec![0u8; 4096])),
650                len: 4096,
651            },
652            FileMeta::default(),
653        )
654        .unwrap();
655        grf.create_file(
656            &mut dev,
657            std::path::Path::new("/b.txt"),
658            FileSource::Reader {
659                reader: Box::new(std::io::Cursor::new(vec![0u8; 4096])),
660                len: 4096,
661            },
662            FileMeta::default(),
663        )
664        .unwrap();
665        grf.flush(&mut dev).unwrap();
666
667        let mut reopen = Grf::open(&mut dev).unwrap();
668        reopen
669            .remove(&mut dev, std::path::Path::new("/a.txt"))
670            .unwrap();
671        reopen.flush(&mut dev).unwrap();
672        assert!(reopen.wasted_space() > 0);
673    }
674}