use std::collections::HashMap;
use std::io::Read;
use crate::apply::path::expansion_folder_id;
use crate::chunk::sqpk::add_data::SqpkAddData;
use crate::chunk::{
Chunk, SqpackFile, SqpkCommand, SqpkFile, SqpkFileOperation, SqpkHeader, SqpkHeaderTarget,
TargetHeaderKind, ZiPatchReader,
};
use crate::{Platform, Result, ZiPatchError};
use tracing::{info, info_span, trace};
use super::plan::{
FilesystemOp, PartExpected, PartSource, PatchRef, PatchSourceKind, PatchType, Plan, Region,
Target, TargetPath,
};
use super::region_map;
const SQPK_SUB_CMD_BODY_OFFSET: u64 = 5;
const SQPK_HEADER_DATA_OFFSET: u64 = 11;
fn reject_unsafe_relative_path(path: &str) -> Result<()> {
if path.starts_with('/') || path.starts_with('\\') {
return Err(ZiPatchError::UnsafeTargetPath(path.to_owned()));
}
let bytes = path.as_bytes();
if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
return Err(ZiPatchError::UnsafeTargetPath(path.to_owned()));
}
for component in path.split(['/', '\\']) {
if component == ".." {
return Err(ZiPatchError::UnsafeTargetPath(path.to_owned()));
}
}
Ok(())
}
const MAX_UNITS_PER_REGION: u32 = u32::MAX / 128;
#[derive(Debug)]
pub struct PlanBuilder {
state: BuilderState,
}
impl Default for PlanBuilder {
fn default() -> Self {
Self::new()
}
}
impl PlanBuilder {
#[must_use]
pub fn new() -> Self {
Self {
state: BuilderState::new(),
}
}
pub fn add_patch<R: Read>(
&mut self,
name: impl Into<String>,
mut reader: ZiPatchReader<R>,
) -> Result<()> {
let name = name.into();
let span = info_span!("build_plan_patch", patch = %name);
let _enter = span.enter();
self.state.begin_patch(name)?;
let mut chunks: usize = 0;
while let Some(chunk) = reader.next() {
let chunk = chunk?;
let body_offset = reader
.current_chunk_body_offset()
.expect("body offset is set whenever next() yielded Some(Ok(_))");
self.state.consume_chunk(chunk, body_offset)?;
chunks += 1;
}
info!(
chunks,
targets = self.state.target_order.len(),
fs_ops = self.state.fs_ops.len(),
"plan: patch consumed"
);
Ok(())
}
#[must_use]
pub fn finish(self) -> Plan {
self.state.finalize()
}
}
pub fn build_plan<R: Read>(
reader: ZiPatchReader<R>,
patch_name: impl Into<String>,
) -> Result<Plan> {
let mut b = PlanBuilder::new();
b.add_patch(patch_name, reader)?;
Ok(b.finish())
}
pub fn build_plan_chain<R, I, N>(patches: I) -> Result<Plan>
where
I: IntoIterator<Item = (N, ZiPatchReader<R>)>,
N: Into<String>,
R: Read,
{
let mut b = PlanBuilder::new();
for (name, reader) in patches {
b.add_patch(name, reader)?;
}
let plan = b.finish();
let region_count: usize = plan.targets.iter().map(|t| t.regions.len()).sum();
info!(
patches = plan.patches.len(),
targets = plan.targets.len(),
regions = region_count,
fs_ops = plan.fs_ops.len(),
"plan: chain built"
);
Ok(plan)
}
#[derive(Debug)]
struct BuilderState {
platform: Platform,
patches: Vec<PatchRef>,
current_patch: u32,
fs_ops: Vec<FilesystemOp>,
targets: HashMap<TargetPath, Vec<Region>>,
target_order: Vec<TargetPath>,
}
impl BuilderState {
fn new() -> Self {
Self {
platform: Platform::Win32,
patches: Vec::new(),
current_patch: 0,
fs_ops: Vec::new(),
targets: HashMap::new(),
target_order: Vec::new(),
}
}
fn begin_patch(&mut self, name: String) -> Result<()> {
if self.patches.iter().any(|p| p.name == name) {
return Err(ZiPatchError::DuplicatePatch { name });
}
let idx = u32::try_from(self.patches.len()).expect("more than u32::MAX patches");
self.current_patch = idx;
self.patches.push(PatchRef {
name,
patch_type: None,
});
Ok(())
}
fn current_patch_ref_mut(&mut self) -> &mut PatchRef {
let idx = self.current_patch as usize;
&mut self.patches[idx]
}
fn consume_chunk(&mut self, chunk: Chunk, body_offset: u64) -> Result<()> {
match chunk {
Chunk::FileHeader(fh) => {
let pt = PatchType::from_tag(*fh.patch_type());
self.current_patch_ref_mut().patch_type = Some(pt);
trace!(version = fh.version(), "plan: file header");
}
Chunk::ApplyOption(opt) => {
trace!(kind = ?opt.kind, value = opt.value, "plan: apply option (ignored in v1)");
}
Chunk::ApplyFreeSpace(_) => {
trace!("plan: apply free space (ignored in v1)");
}
Chunk::AddDirectory(ad) => {
reject_unsafe_relative_path(&ad.name)?;
self.fs_ops.push(FilesystemOp::EnsureDir(ad.name));
}
Chunk::DeleteDirectory(dd) => {
reject_unsafe_relative_path(&dd.name)?;
self.fs_ops.push(FilesystemOp::DeleteDir(dd.name));
}
Chunk::Sqpk(cmd) => self.consume_sqpk(cmd, body_offset)?,
Chunk::EndOfFile => {}
}
Ok(())
}
fn consume_sqpk(&mut self, cmd: SqpkCommand, body_offset: u64) -> Result<()> {
match cmd {
SqpkCommand::TargetInfo(t) => {
self.platform = match t.platform_id {
0 => Platform::Win32,
1 => Platform::Ps3,
2 => Platform::Ps4,
id => Platform::Unknown(id),
};
trace!(platform = ?self.platform, "plan: target info");
}
SqpkCommand::PatchInfo(_) | SqpkCommand::Index(_) => {
trace!("plan: SQPK metadata-only chunk (ignored in v1)");
}
SqpkCommand::AddData(c) => self.consume_add_data(&c, body_offset),
SqpkCommand::DeleteData(c) => {
self.push_empty_block_region(&c.target_file, c.block_offset, c.block_count);
}
SqpkCommand::ExpandData(c) => {
self.push_empty_block_region(&c.target_file, c.block_offset, c.block_count);
}
SqpkCommand::Header(c) => self.consume_header(&c, body_offset),
SqpkCommand::File(c) => self.consume_file(*c, body_offset)?,
}
Ok(())
}
fn consume_add_data(&mut self, c: &SqpkAddData, body_offset: u64) {
let data_abs_offset =
body_offset + SQPK_SUB_CMD_BODY_OFFSET + SqpkAddData::DATA_SOURCE_OFFSET;
let data_bytes = u32::try_from(c.data_bytes)
.expect("SqpkAddData::data_bytes is bounded by the parser's 512 MiB chunk size limit");
let path = dat_target(&c.target_file);
self.push_region(
&path,
Region {
target_offset: c.block_offset,
length: data_bytes,
source: PartSource::Patch {
patch_idx: self.current_patch,
offset: data_abs_offset,
kind: PatchSourceKind::Raw { len: data_bytes },
decoded_skip: 0,
},
expected: PartExpected::SizeOnly,
},
);
if c.block_delete_number > 0 {
let mut remaining = c.block_delete_number;
let mut cursor = c.block_offset + c.data_bytes;
while remaining > 0 {
let chunk = u32::try_from(remaining.min(u64::from(u32::MAX)))
.expect("clamped to u32::MAX above");
self.push_region(
&path,
Region {
target_offset: cursor,
length: chunk,
source: PartSource::Zeros,
expected: PartExpected::Zeros,
},
);
cursor += u64::from(chunk);
remaining -= u64::from(chunk);
}
}
}
fn push_empty_block_region(&mut self, target_file: &SqpackFile, offset: u64, units: u32) {
let path = dat_target(target_file);
if units <= MAX_UNITS_PER_REGION {
self.push_region(
&path,
Region {
target_offset: offset,
length: units * 128,
source: PartSource::EmptyBlock { units },
expected: PartExpected::EmptyBlock { units },
},
);
return;
}
let cap = MAX_UNITS_PER_REGION;
let cap_bytes = u64::from(cap) * 128;
self.push_region(
&path,
Region {
target_offset: offset,
length: cap * 128,
source: PartSource::EmptyBlock { units: cap },
expected: PartExpected::EmptyBlock { units: cap },
},
);
let total_bytes = u64::from(units) * 128;
let mut cursor = offset + cap_bytes;
let mut remaining = total_bytes - cap_bytes;
while remaining > 0 {
let chunk = u32::try_from(remaining.min(u64::from(u32::MAX)))
.expect("clamped to u32::MAX above");
self.push_region(
&path,
Region {
target_offset: cursor,
length: chunk,
source: PartSource::Zeros,
expected: PartExpected::Zeros,
},
);
cursor += u64::from(chunk);
remaining -= u64::from(chunk);
}
}
fn consume_header(&mut self, c: &SqpkHeader, body_offset: u64) {
let header_abs_offset = body_offset + SQPK_SUB_CMD_BODY_OFFSET + SQPK_HEADER_DATA_OFFSET;
let target_offset: u64 = match c.header_kind {
TargetHeaderKind::Version => 0,
TargetHeaderKind::Index | TargetHeaderKind::Data => 1024,
};
let path = match &c.target {
SqpkHeaderTarget::Dat(f) => dat_target(f),
SqpkHeaderTarget::Index(f) => index_target(f),
};
self.push_region(
&path,
Region {
target_offset,
length: 1024,
source: PartSource::Patch {
patch_idx: self.current_patch,
offset: header_abs_offset,
kind: PatchSourceKind::Raw { len: 1024 },
decoded_skip: 0,
},
expected: PartExpected::SizeOnly,
},
);
}
fn consume_file(&mut self, c: SqpkFile, body_offset: u64) -> Result<()> {
reject_unsafe_relative_path(&c.path)?;
match c.operation {
SqpkFileOperation::AddFile => {
let inner_path: String = c.path;
let path = TargetPath::Generic(inner_path.clone());
if c.file_offset == 0 {
self.drop_target(&path);
self.fs_ops.push(FilesystemOp::DeleteFile(inner_path));
}
let mut cursor = u64::try_from(c.file_offset)
.map_err(|_| ZiPatchError::NegativeFileOffset(c.file_offset))?;
for (i, block) in c.blocks.iter().enumerate() {
let block_source_offset = c.block_source_offsets[i];
let abs_offset = body_offset + SQPK_SUB_CMD_BODY_OFFSET + block_source_offset;
let decompressed_len = u32::try_from(block.decompressed_size())
.expect("block decompressed_size bounded by chunk size limit");
let kind = if block.is_compressed() {
PatchSourceKind::Deflated {
compressed_len: u32::try_from(block.data_len())
.expect("block data_len bounded by chunk size limit"),
decompressed_len,
}
} else {
PatchSourceKind::Raw {
len: decompressed_len,
}
};
self.push_region(
&path,
Region {
target_offset: cursor,
length: decompressed_len,
source: PartSource::Patch {
patch_idx: self.current_patch,
offset: abs_offset,
kind,
decoded_skip: 0,
},
expected: PartExpected::SizeOnly,
},
);
cursor += u64::from(decompressed_len);
}
}
SqpkFileOperation::RemoveAll => {
self.fs_ops
.push(FilesystemOp::RemoveAllInExpansion(c.expansion_id));
self.drop_targets_under_expansion(c.expansion_id);
}
SqpkFileOperation::DeleteFile => {
let path = TargetPath::Generic(c.path.clone());
self.drop_target(&path);
self.fs_ops.push(FilesystemOp::DeleteFile(c.path));
}
SqpkFileOperation::MakeDirTree => {
self.fs_ops.push(FilesystemOp::MakeDirTree(c.path));
}
}
Ok(())
}
fn push_region(&mut self, path: &TargetPath, region: Region) {
if region.length == 0 {
return;
}
if let Some(regions) = self.targets.get_mut(path) {
region_map::insert(regions, region);
return;
}
let owned = path.clone();
self.target_order.push(owned.clone());
let regions = self.targets.entry(owned).or_default();
region_map::insert(regions, region);
}
fn drop_target(&mut self, path: &TargetPath) {
self.targets.remove(path);
self.target_order.retain(|tp| tp != path);
}
fn drop_targets_under_expansion(&mut self, expansion_id: u16) {
let folder = expansion_folder_id(expansion_id);
let sqpack_prefix = format!("sqpack/{folder}/");
let movie_prefix = format!("movie/{folder}/");
let mut order = std::mem::take(&mut self.target_order);
order.retain(|tp| {
if target_falls_under(tp, expansion_id, &sqpack_prefix, &movie_prefix) {
self.targets.remove(tp);
false
} else {
true
}
});
self.target_order = order;
}
fn finalize(self) -> Plan {
let BuilderState {
platform,
patches,
current_patch: _,
fs_ops,
mut targets,
target_order,
} = self;
let mut out_targets = Vec::with_capacity(target_order.len());
for path in target_order {
let regions = targets.remove(&path).unwrap_or_default();
let final_size = regions
.last()
.map_or(0, |r| r.target_offset + u64::from(r.length));
debug_assert!(
regions
.windows(2)
.all(|w| w[0].target_offset + u64::from(w[0].length) <= w[1].target_offset),
"regions must be sorted and non-overlapping after build"
);
out_targets.push(Target {
path,
final_size,
regions,
});
}
Plan {
schema_version: Plan::CURRENT_SCHEMA_VERSION,
platform,
patches,
targets: out_targets,
fs_ops,
}
}
}
fn target_falls_under(
tp: &TargetPath,
expansion_id: u16,
sqpack_prefix: &str,
movie_prefix: &str,
) -> bool {
match tp {
TargetPath::SqpackDat { sub_id, .. } | TargetPath::SqpackIndex { sub_id, .. } => {
(sub_id >> 8) == expansion_id
}
TargetPath::Generic(path) => {
path.starts_with(sqpack_prefix) || path.starts_with(movie_prefix)
}
}
}
fn dat_target(f: &SqpackFile) -> TargetPath {
TargetPath::SqpackDat {
main_id: f.main_id,
sub_id: f.sub_id,
file_id: f.file_id,
}
}
fn index_target(f: &SqpackFile) -> TargetPath {
TargetPath::SqpackIndex {
main_id: f.main_id,
sub_id: f.sub_id,
file_id: f.file_id,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn synthetic_sqpack_file() -> SqpackFile {
SqpackFile {
main_id: 1,
sub_id: 2,
file_id: 0,
}
}
#[test]
fn consume_add_data_splits_huge_block_delete_into_u32_chunks() {
let mut state = BuilderState::new();
state.begin_patch("synthetic".into()).unwrap();
let huge: u64 = u64::from(u32::MAX) + 1024; let cmd = SqpkAddData {
target_file: synthetic_sqpack_file(),
block_offset: 0,
data_bytes: 128,
block_delete_number: huge,
data: vec![0xAA; 128],
};
state.consume_add_data(&cmd, 0);
let plan = state.finalize();
assert_eq!(plan.targets.len(), 1);
let regions = &plan.targets[0].regions;
assert_eq!(regions.len(), 3);
assert_eq!(regions[0].target_offset, 0);
assert_eq!(regions[0].length, 128);
assert!(matches!(regions[0].source, PartSource::Patch { .. }));
assert_eq!(regions[1].target_offset, 128);
assert_eq!(regions[1].length, u32::MAX);
assert!(matches!(regions[1].source, PartSource::Zeros));
assert_eq!(regions[2].target_offset, 128 + u64::from(u32::MAX));
assert_eq!(regions[2].length, 1024);
assert!(matches!(regions[2].source, PartSource::Zeros));
assert_eq!(plan.targets[0].final_size, 128 + huge);
}
#[test]
fn consume_file_rejects_negative_file_offset() {
use crate::chunk::{SqpkFile, SqpkFileOperation};
let mut state = BuilderState::new();
state.begin_patch("synthetic".into()).unwrap();
let cmd = SqpkFile {
operation: SqpkFileOperation::AddFile,
file_offset: -1,
file_size: 0,
expansion_id: 0,
path: "synthetic/path".into(),
block_source_offsets: Vec::new(),
blocks: Vec::new(),
};
let err = state
.consume_file(cmd, 0)
.expect_err("negative file_offset must error");
match err {
ZiPatchError::NegativeFileOffset(v) => assert_eq!(v, -1),
other => panic!("unexpected error variant: {other:?}"),
}
}
#[test]
fn push_empty_block_region_emits_single_region_when_in_range() {
let mut state = BuilderState::new();
state.begin_patch("synthetic".into()).unwrap();
state.push_empty_block_region(&synthetic_sqpack_file(), 0, 8);
let plan = state.finalize();
assert_eq!(plan.targets.len(), 1);
let regions = &plan.targets[0].regions;
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].length, 8 * 128);
assert!(matches!(
regions[0].source,
PartSource::EmptyBlock { units: 8 }
));
assert!(matches!(
regions[0].expected,
PartExpected::EmptyBlock { units: 8 }
));
}
#[test]
fn push_empty_block_region_splits_when_bytes_exceed_u32_max() {
let mut state = BuilderState::new();
state.begin_patch("synthetic".into()).unwrap();
let units: u32 = 1 << 25; state.push_empty_block_region(&synthetic_sqpack_file(), 0, units);
let plan = state.finalize();
assert_eq!(plan.targets.len(), 1);
let regions = &plan.targets[0].regions;
let cap_units: u32 = u32::MAX / 128;
let cap_bytes: u64 = u64::from(cap_units) * 128;
assert_eq!(regions[0].target_offset, 0);
assert_eq!(regions[0].length, cap_units * 128);
match regions[0].source {
PartSource::EmptyBlock { units: u } => assert_eq!(u, cap_units),
ref other => panic!("expected EmptyBlock, got {other:?}"),
}
let total_bytes: u64 = u64::from(units) * 128;
let mut covered: u64 = cap_bytes;
for region in ®ions[1..] {
assert_eq!(region.target_offset, covered);
assert!(matches!(region.source, PartSource::Zeros));
assert!(matches!(region.expected, PartExpected::Zeros));
covered += u64::from(region.length);
}
assert_eq!(covered, total_bytes);
assert_eq!(plan.targets[0].final_size, total_bytes);
for region in ®ions[1..] {
assert!(region.length <= u32::MAX);
}
}
#[test]
fn reject_unsafe_relative_path_accepts_safe_paths() {
for safe in [
"sqpack/ffxiv/000000.win32.dat0",
"movie/ffxiv/opening.bk2",
"boot/launcher.exe",
"a/b/c.txt",
"single",
] {
assert!(
reject_unsafe_relative_path(safe).is_ok(),
"safe path rejected: {safe}"
);
}
}
#[test]
fn reject_unsafe_relative_path_rejects_traversal_and_absolute() {
for bad in [
"../etc/passwd",
"..\\etc\\passwd",
"sqpack/../../etc/passwd",
"a/b/../../../etc/passwd",
"/etc/passwd",
"\\\\server\\share\\file",
"C:/Windows/system32",
"c:\\Windows\\system32",
"C:",
] {
let err = reject_unsafe_relative_path(bad)
.expect_err(&format!("unsafe path accepted: {bad}"));
match err {
ZiPatchError::UnsafeTargetPath(s) => assert_eq!(s, bad),
other => panic!("expected UnsafeTargetPath, got {other:?}"),
}
}
}
#[test]
fn consume_file_rejects_path_traversal() {
let mut state = BuilderState::new();
state.begin_patch("synthetic".into()).unwrap();
let cmd = SqpkFile {
operation: SqpkFileOperation::AddFile,
file_offset: 0,
file_size: 0,
expansion_id: 0,
path: "../../etc/passwd".into(),
block_source_offsets: Vec::new(),
blocks: Vec::new(),
};
let err = state
.consume_file(cmd, 0)
.expect_err("must reject traversal");
assert!(matches!(err, ZiPatchError::UnsafeTargetPath(_)));
}
#[test]
fn begin_patch_rejects_duplicate_name() {
let mut state = BuilderState::new();
state.begin_patch("p1".into()).unwrap();
let err = state
.begin_patch("p1".into())
.expect_err("duplicate name must error");
match err {
ZiPatchError::DuplicatePatch { name } => assert_eq!(name, "p1"),
other => panic!("expected DuplicatePatch, got {other:?}"),
}
}
}