physis/
patch.rs

1// SPDX-FileCopyrightText: 2023 Joshua Goins <josh@redstrate.com>
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use core::cmp::min;
5use std::fs;
6use std::fs::{File, OpenOptions, read, read_dir};
7use std::io::{BufWriter, Cursor, Seek, SeekFrom, Write};
8use std::path::{Path, PathBuf};
9
10use crate::ByteBuffer;
11use binrw::BinRead;
12use binrw::{BinWrite, binrw};
13
14use crate::common::{Platform, Region, get_platform_string};
15use crate::common_file_operations::{
16    get_string_len, read_bool_from, read_string, write_bool_as, write_string,
17};
18use crate::sqpack::{read_data_block_patch, write_data_block_patch};
19
20#[binrw]
21#[derive(Debug)]
22#[brw(little)]
23struct PatchHeader {
24    #[br(temp)]
25    #[bw(calc = *b"ZIPATCH")]
26    #[brw(pad_before = 1)]
27    #[brw(pad_after = 4)]
28    #[br(assert(magic == *b"ZIPATCH"))]
29    magic: [u8; 7],
30}
31
32#[binrw]
33#[allow(dead_code)]
34#[brw(little)]
35struct PatchChunk {
36    #[brw(big)]
37    size: u32,
38    chunk_type: ChunkType,
39    #[br(if(chunk_type != ChunkType::EndOfFile))]
40    #[bw(if(*chunk_type != ChunkType::EndOfFile))]
41    crc32: u32,
42}
43
44#[binrw]
45#[derive(PartialEq, Debug)]
46enum ChunkType {
47    #[brw(magic = b"FHDR")]
48    FileHeader(
49        #[brw(pad_before = 2)]
50        #[brw(pad_after = 1)]
51        FileHeaderChunk,
52    ),
53    #[brw(magic = b"APLY")]
54    ApplyOption(ApplyOptionChunk),
55    #[brw(magic = b"ADIR")]
56    AddDirectory(DirectoryChunk),
57    #[brw(magic = b"DELD")]
58    DeleteDirectory(DirectoryChunk),
59    #[brw(magic = b"SQPK")]
60    Sqpk(SqpkChunk),
61    #[brw(magic = b"EOF_")]
62    EndOfFile,
63}
64
65#[binrw]
66#[derive(PartialEq, Debug)]
67enum FileHeaderChunk {
68    #[brw(magic = 2u8)]
69    Version2(FileHeaderChunk2),
70    #[brw(magic = 3u8)]
71    Version3(FileHeaderChunk3),
72}
73
74#[binrw]
75#[derive(PartialEq, Debug)]
76#[brw(big)]
77struct FileHeaderChunk2 {
78    #[br(count = 4)]
79    #[br(map = read_string)]
80    #[bw(map = write_string)]
81    name: String,
82
83    #[brw(pad_before = 8)]
84    depot_hash: u32,
85}
86
87#[binrw]
88#[derive(PartialEq, Debug)]
89#[brw(big)]
90struct FileHeaderChunk3 {
91    #[br(count = 4)]
92    #[br(map = read_string)]
93    #[bw(map = write_string)]
94    name: String,
95
96    entry_files: u32,
97
98    add_directories: u32,
99    delete_directories: u32,
100    delete_data_size: u32,
101    delete_data_size_2: u32,
102    minor_version: u32,
103    repository_name: u32,
104    commands: u32,
105    sqpk_add_commands: u32,
106    sqpk_delete_commands: u32,
107    sqpk_expand_commands: u32,
108    sqpk_header_commands: u32,
109    #[brw(pad_after = 0xB8)]
110    sqpk_file_commands: u32,
111}
112
113#[binrw]
114#[brw(repr = u32)]
115#[brw(big)]
116#[derive(PartialEq, Debug)]
117enum ApplyOption {
118    IgnoreMissing = 1,
119    IgnoreOldMismatch = 2,
120}
121
122#[binrw]
123#[derive(PartialEq, Debug)]
124struct ApplyOptionChunk {
125    #[brw(pad_after = 4)]
126    option: ApplyOption,
127    #[brw(big)]
128    value: u32,
129}
130
131#[binrw]
132#[derive(PartialEq, Debug)]
133struct DirectoryChunk {
134    #[br(temp)]
135    #[bw(calc = get_string_len(name) as u32)]
136    name_length: u32,
137
138    #[br(count = name_length)]
139    #[br(map = read_string)]
140    #[bw(map = write_string)]
141    name: String,
142}
143
144#[binrw]
145#[derive(PartialEq, Debug)]
146enum SqpkOperation {
147    #[brw(magic = b'A')]
148    AddData(SqpkAddData),
149    #[brw(magic = b'D')]
150    DeleteData(SqpkDeleteData),
151    #[brw(magic = b'E')]
152    ExpandData(SqpkDeleteData),
153    #[brw(magic = b'F')]
154    FileOperation(SqpkFileOperationData),
155    #[brw(magic = b'H')]
156    HeaderUpdate(SqpkHeaderUpdateData),
157    #[brw(magic = b'X')]
158    PatchInfo(SqpkPatchInfo),
159    #[brw(magic = b'T')]
160    TargetInfo(SqpkTargetInfo),
161    #[brw(magic = b'I')]
162    Index(SqpkIndex),
163}
164
165#[binrw]
166#[derive(PartialEq, Debug)]
167struct SqpkPatchInfo {
168    status: u8,
169    #[brw(pad_after = 1)]
170    version: u8,
171
172    #[brw(big)]
173    install_size: u64,
174}
175
176#[binrw]
177#[derive(PartialEq, Debug)]
178enum SqpkFileOperation {
179    #[brw(magic = b'A')]
180    AddFile,
181    #[brw(magic = b'R')]
182    RemoveAll,
183    #[brw(magic = b'D')]
184    DeleteFile,
185    #[brw(magic = b'M')]
186    MakeDirTree,
187}
188
189#[binrw]
190#[derive(PartialEq, Debug)]
191#[brw(big)]
192struct SqpkAddData {
193    #[brw(pad_before = 3)]
194    main_id: u16,
195    sub_id: u16,
196    file_id: u32,
197
198    #[br(map = | x : u32 | (x as u64) << 7 )]
199    block_offset: u64,
200    #[br(map = | x : u32 | (x as u64) << 7 )]
201    block_number: u64,
202    #[br(map = | x : u32 | (x as u64) << 7 )]
203    block_delete_number: u64,
204
205    #[br(count = block_number)]
206    block_data: Vec<u8>,
207}
208
209#[binrw]
210#[derive(PartialEq, Debug)]
211#[brw(big)]
212struct SqpkDeleteData {
213    #[brw(pad_before = 3)]
214    main_id: u16,
215    sub_id: u16,
216    file_id: u32,
217
218    #[br(map = | x : u32 | (x as u64) << 7 )]
219    block_offset: u64,
220    #[brw(pad_after = 4)]
221    block_number: u32,
222}
223
224#[binrw]
225#[derive(PartialEq, Debug)]
226enum TargetFileKind {
227    #[brw(magic = b'D')]
228    Dat,
229    #[brw(magic = b'I')]
230    Index,
231}
232
233#[binrw]
234#[derive(PartialEq, Debug)]
235enum TargetHeaderKind {
236    #[brw(magic = b'V')]
237    Version,
238    #[brw(magic = b'I')]
239    Index,
240    #[brw(magic = b'D')]
241    Data,
242}
243
244#[binrw]
245#[derive(PartialEq, Debug)]
246#[brw(big)]
247struct SqpkHeaderUpdateData {
248    file_kind: TargetFileKind,
249    header_kind: TargetHeaderKind,
250
251    #[brw(pad_before = 1)]
252    main_id: u16,
253    sub_id: u16,
254    file_id: u32,
255
256    #[br(count = 1024)]
257    header_data: Vec<u8>,
258}
259
260#[binrw]
261#[derive(PartialEq, Debug)]
262#[brw(big)]
263struct SqpkFileOperationData {
264    #[brw(pad_after = 2)]
265    operation: SqpkFileOperation,
266
267    offset: u64,
268    file_size: u64,
269
270    // Note: counts the \0 at the end... for some reason
271    #[br(temp)]
272    #[bw(calc = get_string_len(path) as u32)]
273    path_length: u32,
274
275    #[brw(pad_after = 2)]
276    expansion_id: u16,
277
278    #[br(count = path_length)]
279    #[br(map = read_string)]
280    #[bw(map = write_string)]
281    path: String,
282}
283
284#[binrw]
285#[derive(PartialEq, Debug)]
286#[brw(big)]
287struct SqpkTargetInfo {
288    #[brw(pad_before = 3)]
289    #[brw(pad_size_to = 2)]
290    platform: Platform, // Platform is read as a u16, but the enum is u8
291    region: Region,
292    #[br(map = read_bool_from::<u16>)]
293    #[bw(map = write_bool_as::<u16>)]
294    is_debug: bool,
295    version: u16,
296    #[brw(little)]
297    deleted_data_size: u64,
298    #[brw(little)]
299    #[brw(pad_after = 96)]
300    seek_count: u64,
301}
302
303#[binrw]
304#[derive(PartialEq, Debug)]
305enum SqpkIndexCommand {
306    #[brw(magic = b'A')]
307    Add,
308    #[brw(magic = b'D')]
309    Delete,
310}
311
312#[binrw]
313#[derive(PartialEq, Debug)]
314#[brw(big)]
315struct SqpkIndex {
316    command: SqpkIndexCommand,
317    #[br(map = read_bool_from::<u8>)]
318    #[bw(map = write_bool_as::<u8>)]
319    is_synonym: bool,
320
321    #[brw(pad_before = 1)]
322    file_hash: u64,
323
324    block_offset: u32,
325    #[brw(pad_after = 8)] // data?
326    block_number: u32,
327}
328
329#[binrw]
330#[derive(PartialEq, Debug)]
331#[brw(big)]
332struct SqpkChunk {
333    size: u32,
334    operation: SqpkOperation,
335}
336
337static WIPE_BUFFER: [u8; 1 << 16] = [0; 1 << 16];
338
339fn wipe(mut file: &File, length: usize) -> Result<(), PatchError> {
340    let mut length: usize = length;
341    while length > 0 {
342        let num_bytes = min(WIPE_BUFFER.len(), length);
343        file.write_all(&WIPE_BUFFER[0..num_bytes])?;
344        length -= num_bytes;
345    }
346
347    Ok(())
348}
349
350fn wipe_from_offset(mut file: &File, length: usize, offset: u64) -> Result<(), PatchError> {
351    file.seek(SeekFrom::Start(offset))?;
352    wipe(file, length)
353}
354
355fn write_empty_file_block_at(
356    mut file: &File,
357    offset: u64,
358    block_number: u64,
359) -> Result<(), PatchError> {
360    wipe_from_offset(file, (block_number << 7) as usize, offset)?;
361
362    file.seek(SeekFrom::Start(offset))?;
363
364    let block_size: i32 = 1 << 7;
365    file.write_all(block_size.to_le_bytes().as_slice())?;
366
367    let unknown: i32 = 0;
368    file.write_all(unknown.to_le_bytes().as_slice())?;
369
370    let file_size: i32 = 0;
371    file.write_all(file_size.to_le_bytes().as_slice())?;
372
373    let num_blocks: i32 = (block_number - 1).try_into().unwrap();
374    file.write_all(num_blocks.to_le_bytes().as_slice())?;
375
376    let used_blocks: i32 = 0;
377    file.write_all(used_blocks.to_le_bytes().as_slice())?;
378
379    Ok(())
380}
381
382fn get_expansion_folder_sub(sub_id: u16) -> String {
383    let expansion_id = sub_id >> 8;
384
385    get_expansion_folder(expansion_id)
386}
387
388fn get_expansion_folder(id: u16) -> String {
389    match id {
390        0 => "ffxiv".to_string(),
391        n => format!("ex{}", n),
392    }
393}
394
395#[derive(Debug)]
396/// Errors emitted in the patching process
397pub enum PatchError {
398    /// Failed to read parts of the file
399    InvalidPatchFile,
400    /// Failed to parse the patch format
401    ParseError,
402}
403
404impl From<std::io::Error> for PatchError {
405    // TODO: implement specific PatchErrors for stuff like out of storage space. invalidpatchfile is a bad name for this
406    fn from(_: std::io::Error) -> Self {
407        PatchError::InvalidPatchFile
408    }
409}
410
411impl From<binrw::Error> for PatchError {
412    fn from(_: binrw::Error) -> Self {
413        PatchError::ParseError
414    }
415}
416
417fn recurse(path: impl AsRef<Path>) -> Vec<PathBuf> {
418    let Ok(entries) = read_dir(path) else {
419        return vec![];
420    };
421    entries
422        .flatten()
423        .flat_map(|entry| {
424            let Ok(meta) = entry.metadata() else {
425                return vec![];
426            };
427            if meta.is_dir() {
428                return recurse(entry.path());
429            }
430            if meta.is_file() {
431                return vec![entry.path()];
432            }
433            vec![]
434        })
435        .collect()
436}
437
438pub struct ZiPatch;
439
440impl ZiPatch {
441    /// Applies a boot or a game patch to the specified _data_dir_.
442    pub fn apply(data_dir: &str, patch_path: &str) -> Result<(), PatchError> {
443        let mut file = File::open(patch_path)?;
444
445        PatchHeader::read(&mut file)?;
446
447        let mut target_info: Option<SqpkTargetInfo> = None;
448
449        let get_dat_path =
450            |target_info: &SqpkTargetInfo, main_id: u16, sub_id: u16, file_id: u32| -> String {
451                let filename = format!(
452                    "{:02x}{:04x}.{}.dat{}",
453                    main_id,
454                    sub_id,
455                    get_platform_string(&target_info.platform),
456                    file_id
457                );
458                let path: PathBuf = [
459                    data_dir,
460                    "sqpack",
461                    &get_expansion_folder_sub(sub_id),
462                    &filename,
463                ]
464                .iter()
465                .collect();
466
467                path.to_str().unwrap().to_string()
468            };
469
470        let get_index_path =
471            |target_info: &SqpkTargetInfo, main_id: u16, sub_id: u16, file_id: u32| -> String {
472                let mut filename = format!(
473                    "{:02x}{:04x}.{}.index",
474                    main_id,
475                    sub_id,
476                    get_platform_string(&target_info.platform)
477                );
478
479                // index files have no special ending if it's file_id == 0
480                if file_id != 0 {
481                    filename += &*format!("{}", file_id);
482                }
483
484                let path: PathBuf = [
485                    data_dir,
486                    "sqpack",
487                    &get_expansion_folder_sub(sub_id),
488                    &filename,
489                ]
490                .iter()
491                .collect();
492
493                path.to_str().unwrap().to_string()
494            };
495
496        loop {
497            let chunk = PatchChunk::read(&mut file)?;
498
499            match chunk.chunk_type {
500                ChunkType::Sqpk(pchunk) => {
501                    match pchunk.operation {
502                        SqpkOperation::AddData(add) => {
503                            let filename = get_dat_path(
504                                target_info.as_ref().unwrap(),
505                                add.main_id,
506                                add.sub_id,
507                                add.file_id,
508                            );
509
510                            let (left, _) = filename.rsplit_once('/').unwrap();
511                            fs::create_dir_all(left)?;
512
513                            let mut new_file = OpenOptions::new()
514                                .write(true)
515                                .create(true)
516                                .truncate(false)
517                                .open(filename)?;
518
519                            new_file.seek(SeekFrom::Start(add.block_offset))?;
520
521                            new_file.write_all(&add.block_data)?;
522
523                            wipe(&new_file, add.block_delete_number as usize)?;
524                        }
525                        SqpkOperation::DeleteData(delete) => {
526                            let filename = get_dat_path(
527                                target_info.as_ref().unwrap(),
528                                delete.main_id,
529                                delete.sub_id,
530                                delete.file_id,
531                            );
532
533                            let new_file = OpenOptions::new()
534                                .write(true)
535                                .create(true)
536                                .truncate(false)
537                                .open(filename)?;
538
539                            write_empty_file_block_at(
540                                &new_file,
541                                delete.block_offset,
542                                delete.block_number as u64,
543                            )?;
544                        }
545                        SqpkOperation::ExpandData(expand) => {
546                            let filename = get_dat_path(
547                                target_info.as_ref().unwrap(),
548                                expand.main_id,
549                                expand.sub_id,
550                                expand.file_id,
551                            );
552
553                            let (left, _) = filename.rsplit_once('/').unwrap();
554                            fs::create_dir_all(left)?;
555
556                            let new_file = OpenOptions::new()
557                                .write(true)
558                                .create(true)
559                                .truncate(false)
560                                .open(filename)?;
561
562                            write_empty_file_block_at(
563                                &new_file,
564                                expand.block_offset,
565                                expand.block_number as u64,
566                            )?;
567                        }
568                        SqpkOperation::HeaderUpdate(header) => {
569                            let file_path = match header.file_kind {
570                                TargetFileKind::Dat => get_dat_path(
571                                    target_info.as_ref().unwrap(),
572                                    header.main_id,
573                                    header.sub_id,
574                                    header.file_id,
575                                ),
576                                TargetFileKind::Index => get_index_path(
577                                    target_info.as_ref().unwrap(),
578                                    header.main_id,
579                                    header.sub_id,
580                                    header.file_id,
581                                ),
582                            };
583
584                            let (left, _) =
585                                file_path.rsplit_once('/').ok_or(PatchError::ParseError)?;
586                            fs::create_dir_all(left)?;
587
588                            let mut new_file = OpenOptions::new()
589                                .write(true)
590                                .create(true)
591                                .truncate(false)
592                                .open(file_path)?;
593
594                            if header.header_kind != TargetHeaderKind::Version {
595                                new_file.seek(SeekFrom::Start(1024))?;
596                            }
597
598                            new_file.write_all(&header.header_data)?;
599                        }
600                        SqpkOperation::FileOperation(fop) => {
601                            let file_path = format!("{}/{}", data_dir, fop.path);
602                            let (parent_directory, _) = file_path.rsplit_once('/').unwrap();
603
604                            match fop.operation {
605                                SqpkFileOperation::AddFile => {
606                                    fs::create_dir_all(parent_directory)?;
607
608                                    // reverse reading crc32
609                                    file.seek(SeekFrom::Current(-4))?;
610
611                                    let mut data: Vec<u8> =
612                                        Vec::with_capacity(fop.file_size as usize);
613
614                                    while data.len() < fop.file_size as usize {
615                                        data.append(&mut read_data_block_patch(&mut file).unwrap());
616                                    }
617
618                                    // re-apply crc32
619                                    file.seek(SeekFrom::Current(4))?;
620
621                                    // now apply the file!
622                                    let new_file = OpenOptions::new()
623                                        .write(true)
624                                        .create(true)
625                                        .truncate(false)
626                                        .open(&file_path);
627
628                                    if let Ok(mut file) = new_file {
629                                        if fop.offset == 0 {
630                                            file.set_len(0)?;
631                                        }
632
633                                        file.seek(SeekFrom::Start(fop.offset))?;
634                                        file.write_all(&data)?;
635                                    } else {
636                                        // silently skip if it does not exist
637                                    }
638                                }
639                                SqpkFileOperation::DeleteFile => {
640                                    if fs::remove_file(file_path.as_str()).is_err() {
641                                        // TODO: return an error if we failed to remove the file
642                                    }
643                                }
644                                SqpkFileOperation::RemoveAll => {
645                                    let path: PathBuf = [
646                                        data_dir,
647                                        "sqpack",
648                                        &get_expansion_folder(fop.expansion_id),
649                                    ]
650                                    .iter()
651                                    .collect();
652
653                                    if fs::read_dir(&path).is_ok() {
654                                        fs::remove_dir_all(&path)?;
655                                    }
656                                }
657                                SqpkFileOperation::MakeDirTree => {
658                                    fs::create_dir_all(parent_directory)?;
659                                }
660                            }
661                        }
662                        SqpkOperation::PatchInfo(_) => {
663                            // Currently, there's nothing we need from PatchInfo. Intentional NOP.
664                        }
665                        SqpkOperation::TargetInfo(new_target_info) => {
666                            target_info = Some(new_target_info);
667                        }
668                        SqpkOperation::Index(_) => {
669                            // Currently, there's nothing we need from Index command. Intentional NOP.
670                        }
671                    }
672                }
673                ChunkType::FileHeader(_) => {
674                    // Currently there's nothing very useful in the FileHeader, so it's an intentional NOP.
675                }
676                ChunkType::ApplyOption(_) => {
677                    // Currently, IgnoreMissing and IgnoreOldMismatch is not used in XIVQuickLauncher either. This stays as an intentional NOP.
678                }
679                ChunkType::AddDirectory(_) => {
680                    // another NOP
681                }
682                ChunkType::DeleteDirectory(_) => {
683                    // another NOP
684                }
685                ChunkType::EndOfFile => {
686                    return Ok(());
687                }
688            }
689        }
690    }
691
692    /// Creates a new ZiPatch describing the diff between `base_directory` and `new_directory`.
693    pub fn create(base_directory: &str, new_directory: &str) -> Option<ByteBuffer> {
694        let mut buffer = ByteBuffer::new();
695
696        {
697            let cursor = Cursor::new(&mut buffer);
698            let mut writer = BufWriter::new(cursor);
699
700            let header = PatchHeader {};
701            header.write(&mut writer).ok()?;
702
703            let base_files = crate::patch::recurse(base_directory);
704            let new_files = crate::patch::recurse(new_directory);
705
706            // A set of files not present in base, but in new (aka added files)
707            let added_files: Vec<&PathBuf> = new_files
708                .iter()
709                .filter(|item| {
710                    let metadata = fs::metadata(item).unwrap();
711                    !base_files.contains(item) && metadata.len() > 0 // TODO: we filter out zero byte files here, but does SqEx do that?
712                })
713                .collect();
714
715            // A set of files not present in the new directory, that used to be in base (aka removedf iles)
716            let removed_files: Vec<&PathBuf> = base_files
717                .iter()
718                .filter(|item| !new_files.contains(item))
719                .collect();
720
721            // Process added files
722            for file in added_files {
723                let file_data = read(file.to_str().unwrap()).unwrap();
724                let relative_path = file
725                    .strip_prefix(new_directory)
726                    .unwrap()
727                    .to_str()
728                    .unwrap()
729                    .to_string();
730
731                let add_file_chunk = PatchChunk {
732                    size: 0,
733                    chunk_type: ChunkType::Sqpk(SqpkChunk {
734                        size: 0,
735                        operation: SqpkOperation::FileOperation(SqpkFileOperationData {
736                            operation: SqpkFileOperation::AddFile,
737                            offset: 0,
738                            file_size: file_data.len() as u64,
739                            expansion_id: 0,
740                            path: relative_path,
741                        }),
742                    }),
743                    crc32: 0,
744                };
745
746                add_file_chunk.write(&mut writer).ok()?;
747
748                // reverse reading crc32
749                writer.seek(SeekFrom::Current(-4)).ok()?;
750
751                // add file data, dummy ver for now
752                write_data_block_patch(&mut writer, file_data);
753
754                // re-apply crc32
755                writer.seek(SeekFrom::Current(4)).ok()?;
756            }
757
758            // Process deleted files
759            for file in removed_files {
760                let relative_path = file
761                    .strip_prefix(base_directory)
762                    .unwrap()
763                    .to_str()
764                    .unwrap()
765                    .to_string();
766
767                let remove_file_chunk = PatchChunk {
768                    size: 0,
769                    chunk_type: ChunkType::Sqpk(SqpkChunk {
770                        size: 0,
771                        operation: SqpkOperation::FileOperation(SqpkFileOperationData {
772                            operation: SqpkFileOperation::DeleteFile,
773                            offset: 0,
774                            file_size: 0,
775                            expansion_id: 0,
776                            path: relative_path,
777                        }),
778                    }),
779                    crc32: 0,
780                };
781
782                remove_file_chunk.write(&mut writer).ok()?;
783            }
784
785            let eof_chunk = PatchChunk {
786                size: 0,
787                chunk_type: ChunkType::EndOfFile,
788                crc32: 0,
789            };
790            eof_chunk.write(&mut writer).ok()?;
791        }
792
793        Some(buffer)
794    }
795}
796
797// Note that these only deal with fake patch data. To test retail patches, see tests/patching_test.rs
798#[cfg(test)]
799mod tests {
800    use std::fs::{read, write};
801    use std::path::PathBuf;
802
803    use super::*;
804
805    // Prepares a temporary data directory to use
806    fn prepare_data_dir() -> String {
807        let mut dir = std::env::temp_dir();
808        dir.push("physis-patch-tests");
809        if dir.exists() {
810            fs::remove_dir_all(&dir);
811        }
812
813        fs::create_dir_all(&dir);
814
815        dir.to_str().unwrap().to_string()
816    }
817
818    #[test]
819    fn test_invalid() {
820        let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
821        d.push("resources/tests");
822        d.push("random");
823
824        let data_dir = prepare_data_dir();
825
826        write(data_dir.clone() + "/test.patch", read(d).unwrap()).unwrap();
827
828        // Feeding it invalid data should not panic
829        let Err(PatchError::ParseError) =
830            ZiPatch::apply(&data_dir.clone(), &(data_dir + "/test.patch"))
831        else {
832            panic!("Expecting a parse error!");
833        };
834    }
835
836    #[test]
837    fn test_add_file_op() {
838        let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
839        d.push("resources/tests");
840        d.push("random");
841
842        let data_dir = prepare_data_dir();
843
844        let mut resources_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
845        resources_dir.push("resources/tests");
846
847        // Let's create a patch that re-creates the resources dir into our data directory
848        let patch = ZiPatch::create(&data_dir, resources_dir.to_str().unwrap()).unwrap();
849
850        write(data_dir.clone() + "/test.patch", &patch).unwrap();
851
852        ZiPatch::apply(&data_dir.clone(), &(data_dir.clone() + "/test.patch")).unwrap();
853
854        fs::remove_file(data_dir.clone() + "/test.patch").unwrap();
855
856        let old_files = recurse(&resources_dir);
857        let new_files = recurse(&data_dir);
858
859        let mut old_relative_files: Vec<&Path> = old_files
860            .iter()
861            .filter(|item| {
862                let metadata = fs::metadata(item).unwrap();
863                metadata.len() > 0 // filter out zero byte files because ZiPatch::create does
864            })
865            .map(|x| x.strip_prefix(&resources_dir).unwrap())
866            .collect();
867        let mut new_relative_files: Vec<&Path> = new_files
868            .iter()
869            .map(|x| x.strip_prefix(&data_dir).unwrap())
870            .collect();
871
872        assert_eq!(old_relative_files.sort(), new_relative_files.sort());
873    }
874}