assetpack_core/file_transform/
selector.rs1use std::{
2 io::{self, BufReader, Cursor, Write},
3 sync::Arc,
4};
5
6use sha3::{Digest, Sha3_256};
7
8use super::{FileHint, FileTransform, FileTransformConfig, TransformSelection, TransformSpec, spool::StoredStreamSpool};
9use crate::{
10 FastCdcSplitter,
11 error::Result,
12 hash::Hash32,
13 pipeline::{ChunkCompressionPolicy, ChunkCompressor, DefaultChunkCompressor, Pipeline, PipelineConfig},
14};
15
16struct HashingSink {
17 hasher: Sha3_256,
18 bytes: u64,
19}
20
21impl HashingSink {
22 fn new() -> Self {
23 Self {
24 hasher: Sha3_256::new(),
25 bytes: 0,
26 }
27 }
28
29 fn finish(self) -> (Hash32, u64) {
30 let digest = self.hasher.finalize();
31 (Hash32::new(digest.into()), self.bytes)
32 }
33}
34
35impl Write for HashingSink {
36 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
37 self.hasher.update(buf);
38 self.bytes += buf.len() as u64;
39 Ok(buf.len())
40 }
41
42 fn flush(&mut self) -> io::Result<()> {
43 Ok(())
44 }
45}
46
47#[derive(Clone)]
48pub struct TransformSelector {
49 config: FileTransformConfig,
50 temp_dir: Option<std::path::PathBuf>,
51 candidates: Vec<TransformCandidate>,
52}
53
54impl TransformSelector {
55 pub fn new(config: FileTransformConfig, temp_dir: Option<std::path::PathBuf>, candidates: Vec<TransformSpec>) -> Self {
56 let candidates = candidates
57 .into_iter()
58 .filter(|spec| spec.selectable && (spec.enabled)(&config))
59 .map(|spec| TransformCandidate {
60 transform: (spec.build)(&config),
61 use_quick_check: spec.use_quick_check,
62 })
63 .collect();
64
65 Self {
66 config,
67 temp_dir,
68 candidates,
69 }
70 }
71
72 pub fn select_bytes(&self, data: Vec<u8>, hint: &FileHint, original_hash: Hash32) -> Result<TransformSelection> {
73 let original_size = data.len() as u64;
74 if !self.config.enabled {
75 return Ok(TransformSelection::none(data, original_hash));
76 }
77 if original_size > self.config.max_transform_bytes {
78 return Ok(TransformSelection::none(data, original_hash));
79 }
80 if self.candidates.is_empty() {
81 return Ok(TransformSelection::none(data, original_hash));
82 }
83 let pipeline = Pipeline::new(PipelineConfig {
84 splitter: FastCdcSplitter::v1_defaults(),
85 chunk_compression: ChunkCompressionPolicy::Auto,
86 discard_payload: true,
87 });
88 let compressor = DefaultChunkCompressor;
89 let extension = hint.extension.as_deref();
90 let mut candidate_plans = Vec::new();
91
92 for candidate in &self.candidates {
93 let attempt = TransformAttempt {
94 transform: candidate.transform.as_ref(),
95 data: &data,
96 hint,
97 original_hash,
98 original_size,
99 extension,
100 use_quick_check: candidate.use_quick_check,
101 pipeline: &pipeline,
102 compressor: &compressor,
103 };
104 if let Some(result) = self.try_transform(attempt)? {
105 candidate_plans.push(result);
106 }
107 }
108
109 let baseline_selection = TransformSelection::none(data, original_hash);
110 let baseline_plan = match pipeline.run_with_selection_ref(&baseline_selection, extension, &compressor) {
111 Ok(plan) => plan,
112 Err(_) => {
113 return Ok(baseline_selection);
114 }
115 };
116 let threshold = (baseline_plan.estimated_bytes as f64 * (1.0 - self.config.min_gain)).floor();
117
118 let _plans = candidate_plans
119 .iter()
120 .map(|(sel, size)| (sel.transform_id, *size))
121 .collect::<Vec<_>>();
122 let mut best: Option<(TransformSelection, u64)> = None;
123 for candidate in candidate_plans {
124 if (candidate.1 as f64) <= threshold {
125 best = pick_best(best, candidate);
126 }
127 }
128
129 if let Some((selection, _)) = best {
136 return Ok(selection);
137 }
138
139 Ok(baseline_selection)
140 }
141
142 fn try_transform(&self, attempt: TransformAttempt<'_>) -> Result<Option<(TransformSelection, u64)>> {
143 let TransformAttempt {
144 transform,
145 data,
146 hint,
147 original_hash,
148 original_size,
149 extension,
150 use_quick_check,
151 pipeline,
152 compressor,
153 } = attempt;
154
155 if use_quick_check && !transform.quick_check(hint) {
156 return Ok(None);
157 }
158
159 let mut spool = if original_size <= self.config.max_in_memory_bytes {
160 StoredStreamSpool::new_memory(self.config.eval.max_buffer_bytes)
161 } else if self.config.spill_to_disk {
162 match StoredStreamSpool::new_file(self.temp_dir.as_deref()) {
163 Ok(spool) => spool,
164 Err(_) => {
165 return Ok(None);
166 }
167 }
168 } else {
169 return Ok(None);
170 };
171
172 if transform.encode(&mut BufReader::new(Cursor::new(data)), &mut spool).is_err() {
173 return Ok(None);
174 }
175
176 let mut verify_reader = match spool.reader() {
177 Ok(reader) => reader,
178 Err(_) => {
179 return Ok(None);
180 }
181 };
182 let mut verify_reader = BufReader::new(&mut verify_reader);
183 let mut sink = HashingSink::new();
184 if transform.decode(&mut verify_reader, &mut sink).is_err() {
185 return Ok(None);
186 }
187
188 let (decoded_hash, decoded_size) = sink.finish();
189 if decoded_hash != original_hash || decoded_size != original_size {
190 return Ok(None);
191 }
192
193 let stored_stream = match spool.into_bytes() {
194 Ok(bytes) => bytes,
195 Err(_) => {
196 return Ok(None);
197 }
198 };
199
200 let selection = TransformSelection {
201 transform_id: transform.id(),
202 transform_version: transform.version(),
203 original_size,
204 original_hash,
205 stored_stream,
206 };
207 let plan = match pipeline.run_with_selection_ref(&selection, extension, compressor) {
208 Ok(plan) => plan,
209 Err(_) => {
210 return Ok(None);
211 }
212 };
213
214 Ok(Some((selection, plan.estimated_bytes)))
215 }
216}
217
218impl std::fmt::Debug for TransformSelector {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 f.debug_struct("TransformSelector")
221 .field("config", &self.config)
222 .field("temp_dir", &self.temp_dir)
223 .field("candidate_count", &self.candidates.len())
224 .finish()
225 }
226}
227
228#[derive(Clone)]
229struct TransformCandidate {
230 transform: Arc<dyn FileTransform>,
231 use_quick_check: bool,
232}
233
234struct TransformAttempt<'a> {
235 transform: &'a dyn FileTransform,
236 data: &'a [u8],
237 hint: &'a FileHint,
238 original_hash: Hash32,
239 original_size: u64,
240 extension: Option<&'a str>,
241 use_quick_check: bool,
242 pipeline: &'a Pipeline,
243 compressor: &'a dyn ChunkCompressor,
244}
245
246fn pick_best(best: Option<(TransformSelection, u64)>, candidate: (TransformSelection, u64)) -> Option<(TransformSelection, u64)> {
247 match best {
248 None => Some(candidate),
249 Some((best_sel, best_est)) => {
250 if candidate.1 < best_est {
251 Some(candidate)
252 } else {
253 Some((best_sel, best_est))
254 }
255 }
256 }
257}