roxify 1.13.4 → 1.13.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/native/main.rs CHANGED
@@ -11,6 +11,8 @@ mod encoder;
11
11
  mod packer;
12
12
  mod crypto;
13
13
  mod png_utils;
14
+ mod png_chunk_writer;
15
+ mod io_advice;
14
16
  mod audio;
15
17
  mod reconstitution;
16
18
  mod archive;
@@ -22,6 +24,34 @@ mod progress;
22
24
  use crate::encoder::ImageFormat;
23
25
  use std::path::PathBuf;
24
26
 
27
+ const MB_U64: u64 = 1024 * 1024;
28
+ const GB_U64: u64 = 1024 * MB_U64;
29
+
30
+ // Adaptive RAM Budget Configuration
31
+ const MIN_RAM_BUDGET_MB: u64 = 512;
32
+ const DEFAULT_RAM_BUDGET_MB: u64 = 2048;
33
+ const RESERVED_RAM_MB: u64 = 1024;
34
+ const MIN_WRITE_BUFFER_BYTES: usize = 16 * 1024 * 1024;
35
+ const MAX_WRITE_BUFFER_BYTES: usize = 256 * 1024 * 1024;
36
+
37
+ // Performance Targets (under 10 seconds)
38
+ const TARGET_ENCODE_TIME_SECS: u64 = 10;
39
+ const TARGET_DECODE_TIME_SECS: u64 = 10;
40
+ const FAST_COMPRESSION_THRESHOLD_MB: u64 = 100;
41
+ const STREAMING_THRESHOLD_MB: u64 = 500;
42
+
43
+ // Adaptive Compression Levels
44
+ const COMPRESSION_ULTRA_FAST: i32 = 1; // < 100MB files, lots of RAM
45
+ const COMPRESSION_FAST: i32 = 2; // < 500MB files
46
+ const COMPRESSION_BALANCED: i32 = 3; // Default, 500MB-2GB
47
+ const COMPRESSION_SMALL_FILES: i32 = 5; // Many small files
48
+
49
+ // RAM Usage Tiers
50
+ const RAM_TIER_ULTRA: u64 = 16384; // 16GB+ - Aggressive optimization
51
+ const RAM_TIER_HIGH: u64 = 8192; // 8GB+ - Fast mode
52
+ const RAM_TIER_MEDIUM: u64 = 4096; // 4GB+ - Balanced
53
+ const RAM_TIER_LOW: u64 = 2048; // 2GB+ - Conservative
54
+
25
55
  #[derive(Parser)]
26
56
  #[command(author, version)]
27
57
  struct Cli {
@@ -45,6 +75,8 @@ enum Commands {
45
75
  /// optional zstd dictionary file for payload compression
46
76
  #[arg(long, value_name = "FILE")]
47
77
  dict: Option<PathBuf>,
78
+ #[arg(long, value_name = "MB")]
79
+ ram_budget_mb: Option<u64>,
48
80
  },
49
81
 
50
82
  List {
@@ -98,6 +130,8 @@ enum Commands {
98
130
  /// optional dictionary file used during decompression
99
131
  #[arg(long, value_name = "FILE")]
100
132
  dict: Option<PathBuf>,
133
+ #[arg(long, value_name = "MB")]
134
+ ram_budget_mb: Option<u64>,
101
135
  },
102
136
  Crc32 {
103
137
  input: PathBuf,
@@ -116,10 +150,192 @@ fn read_all(path: &PathBuf) -> anyhow::Result<Vec<u8>> {
116
150
  Ok(buf)
117
151
  }
118
152
 
153
+ pub fn parse_linux_mem_available_mb() -> Option<u64> {
154
+ let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
155
+ let line = meminfo.lines().find(|l| l.starts_with("MemAvailable:"))?;
156
+ let kb = line
157
+ .split_whitespace()
158
+ .nth(1)
159
+ .and_then(|v| v.parse::<u64>().ok())?;
160
+ Some(kb / 1024)
161
+ }
162
+
163
+ fn parse_total_ram_mb() -> Option<u64> {
164
+ let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
165
+ let line = meminfo.lines().find(|l| l.starts_with("MemTotal:"))?;
166
+ let kb = line
167
+ .split_whitespace()
168
+ .nth(1)
169
+ .and_then(|v| v.parse::<u64>().ok())?;
170
+ Some(kb / 1024)
171
+ }
172
+
173
+ fn get_cpu_cores() -> usize {
174
+ std::thread::available_parallelism()
175
+ .map(|p| p.get())
176
+ .unwrap_or(4)
177
+ }
178
+
179
+ /// Determine RAM tier based on available memory
180
+ fn get_ram_tier(available_mb: u64) -> &'static str {
181
+ match available_mb {
182
+ x if x >= RAM_TIER_ULTRA => "ultra",
183
+ x if x >= RAM_TIER_HIGH => "high",
184
+ x if x >= RAM_TIER_MEDIUM => "medium",
185
+ _ => "low",
186
+ }
187
+ }
188
+
189
+ /// Calculate adaptive RAM budget with aggressive optimization for Pyxelze
190
+ fn auto_ram_budget_mb() -> u64 {
191
+ let total_mb = parse_total_ram_mb().unwrap_or(4096);
192
+ let available_mb = parse_linux_mem_available_mb().unwrap_or(total_mb / 2);
193
+ let cpu_cores = get_cpu_cores();
194
+
195
+ // Base calculation: use up to 85% of available RAM for ultra-fast mode
196
+ let base_budget = available_mb.saturating_mul(85) / 100;
197
+
198
+ // Adjust based on RAM tier and CPU cores
199
+ let tier_multiplier = match get_ram_tier(total_mb) {
200
+ "ultra" if cpu_cores >= 8 => 90, // 16GB+ with 8+ cores: 90%
201
+ "ultra" => 85, // 16GB+ with fewer cores
202
+ "high" if cpu_cores >= 6 => 80, // 8GB+ with 6+ cores
203
+ "high" => 75, // 8GB+
204
+ "medium" => 70, // 4GB+
205
+ _ => 65, // 2GB+
206
+ };
207
+
208
+ let budget = available_mb.saturating_mul(tier_multiplier) / 100;
209
+ let budget = budget.max(MIN_RAM_BUDGET_MB);
210
+
211
+ // Cap at reasonable maximum to prevent OOM
212
+ let max_budget = (total_mb / 2).max(8192);
213
+ budget.min(max_budget)
214
+ }
215
+
216
+ /// Auto-select optimal compression level based on file size and available RAM
217
+ fn auto_compression_level(file_size_mb: u64, ram_budget_mb: u64) -> i32 {
218
+ // Ultra-fast for small files with lots of RAM
219
+ if file_size_mb < FAST_COMPRESSION_THRESHOLD_MB && ram_budget_mb >= RAM_TIER_HIGH {
220
+ return COMPRESSION_ULTRA_FAST;
221
+ }
222
+
223
+ // Fast mode for medium files
224
+ if file_size_mb < STREAMING_THRESHOLD_MB {
225
+ return COMPRESSION_FAST;
226
+ }
227
+
228
+ // Balanced for large files
229
+ if ram_budget_mb >= RAM_TIER_MEDIUM {
230
+ return COMPRESSION_BALANCED;
231
+ }
232
+
233
+ // Conservative for low RAM
234
+ COMPRESSION_SMALL_FILES
235
+ }
236
+
237
+ /// Determine if streaming mode should be used
238
+ fn should_use_streaming(file_size_mb: u64, ram_budget_mb: u64) -> bool {
239
+ // Force streaming for very large files
240
+ if file_size_mb >= 2048 { // 2GB+
241
+ return true;
242
+ }
243
+
244
+ // Use streaming when file is larger than 60% of RAM budget
245
+ let threshold = ram_budget_mb.saturating_mul(60) / 100;
246
+ file_size_mb >= threshold
247
+ }
248
+
249
+ /// Get optimal thread count for zstd based on RAM and CPU
250
+ fn optimal_zstd_threads(file_size_mb: u64, ram_budget_mb: u64) -> i32 {
251
+ let cpu_cores = get_cpu_cores();
252
+ let ram_tier = get_ram_tier(parse_total_ram_mb().unwrap_or(4096));
253
+
254
+ match ram_tier {
255
+ "ultra" => cpu_cores.min(16) as i32,
256
+ "high" => cpu_cores.min(8) as i32,
257
+ _ if file_size_mb > 1000 => cpu_cores.min(4) as i32,
258
+ _ => cpu_cores.min(2) as i32,
259
+ }
260
+ }
261
+
262
+ fn resolve_ram_budget_mb(cli_value: Option<u64>) -> u64 {
263
+ if let Some(v) = cli_value {
264
+ return v.max(MIN_RAM_BUDGET_MB);
265
+ }
266
+
267
+ if let Ok(v) = std::env::var("ROX_RAM_BUDGET_MB") {
268
+ if let Ok(parsed) = v.trim().parse::<u64>() {
269
+ return parsed.max(MIN_RAM_BUDGET_MB);
270
+ }
271
+ }
272
+
273
+ auto_ram_budget_mb()
274
+ }
275
+
276
+ fn effective_ram_budget_mb() -> u64 {
277
+ if let Ok(v) = std::env::var("ROX_RAM_BUDGET_MB_EFFECTIVE") {
278
+ if let Ok(parsed) = v.trim().parse::<u64>() {
279
+ return parsed.max(MIN_RAM_BUDGET_MB);
280
+ }
281
+ }
282
+ resolve_ram_budget_mb(None)
283
+ }
284
+
285
+ fn ram_budget_bytes(ram_budget_mb: u64) -> u64 {
286
+ ram_budget_mb.saturating_mul(MB_U64)
287
+ }
288
+
289
+ fn streaming_preference_threshold_bytes(ram_budget_mb: u64) -> u64 {
290
+ ram_budget_bytes(ram_budget_mb)
291
+ .saturating_mul(60)
292
+ .saturating_div(100)
293
+ .max(64 * MB_U64)
294
+ }
295
+
296
+ fn normalize_png_archive_bytes(png_data: &[u8], passphrase: Option<&str>) -> anyhow::Result<Vec<u8>> {
297
+ let payload = png_utils::extract_payload_from_png(png_data).map_err(|e| anyhow::anyhow!(e))?;
298
+ if payload.is_empty() {
299
+ return Err(anyhow::anyhow!("Empty payload"));
300
+ }
301
+
302
+ if payload[0] == 0x00u8 {
303
+ Ok(payload[1..].to_vec())
304
+ } else {
305
+ let decrypted = crate::crypto::try_decrypt(&payload, passphrase)
306
+ .map_err(|e| anyhow::anyhow!("Encrypted payload: {}", e))?;
307
+ Ok(decrypted)
308
+ }
309
+ }
310
+
311
+ fn unpack_archive_bytes(
312
+ normalized: Vec<u8>,
313
+ out_dir: &std::path::Path,
314
+ files_slice: Option<&[String]>,
315
+ progress: Option<&(dyn Fn(u64, u64, &str) + Send)>,
316
+ ) -> anyhow::Result<Vec<String>> {
317
+ let mut reader: Box<dyn std::io::Read> = if normalized.starts_with(b"ROX1") {
318
+ Box::new(std::io::Cursor::new(normalized[4..].to_vec()))
319
+ } else {
320
+ let mut dec = zstd::stream::Decoder::new(std::io::Cursor::new(normalized))
321
+ .map_err(|e| anyhow::anyhow!("zstd decoder init: {}", e))?;
322
+ dec.window_log_max(31)
323
+ .map_err(|e| anyhow::anyhow!("zstd window_log_max: {}", e))?;
324
+ Box::new(dec)
325
+ };
326
+
327
+ std::fs::create_dir_all(out_dir)
328
+ .map_err(|e| anyhow::anyhow!("Cannot create output directory {:?}: {}", out_dir, e))?;
329
+ packer::unpack_stream_to_dir(&mut reader, out_dir, files_slice, progress, 0)
330
+ .map_err(|e| anyhow::anyhow!(e))
331
+ }
332
+
119
333
  fn write_all(path: &PathBuf, data: &[u8]) -> anyhow::Result<()> {
120
334
  let f = File::create(path)?;
121
- let buf_size = if data.len() > 64 * 1024 * 1024 { 16 * 1024 * 1024 }
122
- else { (8 * 1024 * 1024).min(data.len().max(8192)) };
335
+ let budget_bytes = ram_budget_bytes(effective_ram_budget_mb());
336
+ let dynamic_cap = (budget_bytes / 24)
337
+ .clamp(MIN_WRITE_BUFFER_BYTES as u64, MAX_WRITE_BUFFER_BYTES as u64) as usize;
338
+ let buf_size = dynamic_cap.min(data.len().max(8192));
123
339
  let mut writer = std::io::BufWriter::with_capacity(buf_size, f);
124
340
  writer.write_all(data)?;
125
341
  writer.flush()?;
@@ -164,7 +380,9 @@ fn parse_requested_files(files: &str) -> anyhow::Result<Vec<String>> {
164
380
  println!("wrote {} bytes dictionary to {:?}", dict.len(), output);
165
381
  return Ok(());
166
382
  }
167
- Commands::Encode { input, output, level, passphrase, encrypt, name, dict } => {
383
+ Commands::Encode { input, output, level, passphrase, encrypt, name, dict, ram_budget_mb } => {
384
+ let ram_budget_mb = resolve_ram_budget_mb(ram_budget_mb);
385
+ std::env::set_var("ROX_RAM_BUDGET_MB_EFFECTIVE", ram_budget_mb.to_string());
168
386
  let is_dir = input.is_dir();
169
387
 
170
388
  let file_name = name.as_deref()
@@ -203,7 +421,7 @@ fn parse_requested_files(files: &str) -> anyhow::Result<Vec<String>> {
203
421
  None => None,
204
422
  };
205
423
 
206
- let use_streaming = payload.len() > 64 * 1024 * 1024;
424
+ let use_streaming = (payload.len() as u64) > streaming_preference_threshold_bytes(ram_budget_mb);
207
425
  eprintln!("PROGRESS:50:100:encoding");
208
426
 
209
427
  if use_streaming {
@@ -255,32 +473,39 @@ fn parse_requested_files(files: &str) -> anyhow::Result<Vec<String>> {
255
473
  }
256
474
  Commands::List { input } => {
257
475
  let mut file = File::open(&input)?;
258
- let chunks = png_utils::extract_png_chunks_streaming(&mut file).map_err(|e| anyhow::anyhow!(e))?;
476
+ let mut chunk_scan_error: Option<anyhow::Error> = None;
259
477
 
260
- if let Some(rxfl_chunk) = chunks.iter().find(|c| c.name == "rXFL") {
261
- println!("{}", String::from_utf8_lossy(&rxfl_chunk.data));
262
- return Ok(());
263
- }
478
+ match png_utils::extract_png_chunks_streaming(&mut file) {
479
+ Ok(chunks) => {
480
+ if let Some(rxfl_chunk) = chunks.iter().find(|c| c.name == "rXFL") {
481
+ println!("{}", String::from_utf8_lossy(&rxfl_chunk.data));
482
+ return Ok(());
483
+ }
264
484
 
265
- if let Some(meta_chunk) = chunks.iter().find(|c| c.name == "rOXm") {
266
- if let Some(pos) = meta_chunk.data.windows(4).position(|w| w == b"rXFL") {
267
- if pos + 8 <= meta_chunk.data.len() {
268
- let json_len = u32::from_be_bytes([
269
- meta_chunk.data[pos + 4],
270
- meta_chunk.data[pos + 5],
271
- meta_chunk.data[pos + 6],
272
- meta_chunk.data[pos + 7],
273
- ]) as usize;
274
-
275
- let json_start = pos + 8;
276
- let json_end = json_start + json_len;
277
-
278
- if json_end <= meta_chunk.data.len() {
279
- println!("{}", String::from_utf8_lossy(&meta_chunk.data[json_start..json_end]));
280
- return Ok(());
485
+ if let Some(meta_chunk) = chunks.iter().find(|c| c.name == "rOXm") {
486
+ if let Some(pos) = meta_chunk.data.windows(4).position(|w| w == b"rXFL") {
487
+ if pos + 8 <= meta_chunk.data.len() {
488
+ let json_len = u32::from_be_bytes([
489
+ meta_chunk.data[pos + 4],
490
+ meta_chunk.data[pos + 5],
491
+ meta_chunk.data[pos + 6],
492
+ meta_chunk.data[pos + 7],
493
+ ]) as usize;
494
+
495
+ let json_start = pos + 8;
496
+ let json_end = json_start + json_len;
497
+
498
+ if json_end <= meta_chunk.data.len() {
499
+ println!("{}", String::from_utf8_lossy(&meta_chunk.data[json_start..json_end]));
500
+ return Ok(());
501
+ }
502
+ }
281
503
  }
282
504
  }
283
505
  }
506
+ Err(err) => {
507
+ chunk_scan_error = Some(anyhow::anyhow!(err));
508
+ }
284
509
  }
285
510
 
286
511
  let png_data = std::fs::read(&input)?;
@@ -289,11 +514,14 @@ fn parse_requested_files(files: &str) -> anyhow::Result<Vec<String>> {
289
514
  println!("{}", json);
290
515
  return Ok(());
291
516
  }
292
- Err(_) => {}
517
+ Err(pixel_err) => {
518
+ if let Some(chunk_err) = chunk_scan_error {
519
+ return Err(anyhow::anyhow!("chunk scan: {}; pixel scan: {}", chunk_err, pixel_err));
520
+ }
521
+ }
293
522
  }
294
523
 
295
- eprintln!("No file list found in PNG");
296
- std::process::exit(1);
524
+ return Err(anyhow::anyhow!("No file list found in PNG"));
297
525
  }
298
526
  Commands::Havepassphrase { input } => {
299
527
  let buf = read_all(&input)?;
@@ -347,7 +575,9 @@ fn parse_requested_files(files: &str) -> anyhow::Result<Vec<String>> {
347
575
  let dest = output.unwrap_or_else(|| PathBuf::from("out.zst"));
348
576
  write_all(&dest, &out)?;
349
577
  }
350
- Commands::Decompress { input, output, files, passphrase, dict } => {
578
+ Commands::Decompress { input, output, files, passphrase, dict, ram_budget_mb } => {
579
+ let ram_budget_mb = resolve_ram_budget_mb(ram_budget_mb);
580
+ std::env::set_var("ROX_RAM_BUDGET_MB_EFFECTIVE", ram_budget_mb.to_string());
351
581
  let file_size = std::fs::metadata(&input).map(|m| m.len()).unwrap_or(0);
352
582
  let is_png_file = input.extension().map(|e| e == "png").unwrap_or(false)
353
583
  || (file_size >= 8 && {
@@ -361,45 +591,87 @@ fn parse_requested_files(files: &str) -> anyhow::Result<Vec<String>> {
361
591
  None => None,
362
592
  };
363
593
 
364
- if is_png_file && files.is_none() && dict.is_none() {
365
- let out_dir = output.clone().unwrap_or_else(|| PathBuf::from("out.raw"));
366
- match streaming_decode::streaming_decode_to_dir_encrypted_with_progress(
367
- &input,
368
- &out_dir,
369
- passphrase.as_deref(),
370
- Some(Box::new(|current, total, step| {
371
- eprintln!("PROGRESS:{}:{}:{}", current, total, step);
372
- })),
373
- ) {
374
- Ok(written) => {
375
- eprintln!("PROGRESS:100:100:done");
376
- println!("Unpacked {} files", written.len());
377
- return Ok(());
378
- }
379
- Err(e) => {
380
- eprintln!("Streaming decode failed ({}), falling back to in-memory", e);
594
+ if is_png_file && dict.is_none() {
595
+ let out_dir = if requested_files.is_some() {
596
+ output.clone().unwrap_or_else(|| PathBuf::from("."))
597
+ } else {
598
+ output.clone().unwrap_or_else(|| PathBuf::from("out.raw"))
599
+ };
600
+
601
+ let should_try_streaming = file_size >= streaming_preference_threshold_bytes(ram_budget_mb);
602
+
603
+ if should_try_streaming {
604
+ let streaming_result = if let Some(ref selected) = requested_files {
605
+ streaming_decode::streaming_decode_selected_to_dir_encrypted_with_progress(
606
+ &input,
607
+ &out_dir,
608
+ Some(selected.as_slice()),
609
+ passphrase.as_deref(),
610
+ Some(Box::new(|current, total, step| {
611
+ eprintln!("PROGRESS:{}:{}:{}", current, total, step);
612
+ })),
613
+ )
614
+ } else {
615
+ streaming_decode::streaming_decode_to_dir_encrypted_with_progress(
616
+ &input,
617
+ &out_dir,
618
+ passphrase.as_deref(),
619
+ Some(Box::new(|current, total, step| {
620
+ eprintln!("PROGRESS:{}:{}:{}", current, total, step);
621
+ })),
622
+ )
623
+ };
624
+
625
+ match streaming_result {
626
+ Ok(written) => {
627
+ eprintln!("PROGRESS:100:100:done");
628
+ println!("Unpacked {} files", written.len());
629
+ return Ok(());
630
+ }
631
+ Err(e) => {
632
+ eprintln!("PROGRESS:12:100:streaming_fallback");
633
+ eprintln!("Streaming decode failed, falling back to reconstruction: {}", e);
634
+ }
381
635
  }
382
636
  }
383
- }
384
637
 
385
- if is_png_file && requested_files.is_some() && dict.is_none() {
386
- let out_dir = output.clone().unwrap_or_else(|| PathBuf::from("."));
387
- std::fs::create_dir_all(&out_dir).map_err(|e| anyhow::anyhow!("Cannot create output directory {:?}: {}", out_dir, e))?;
388
- let written = streaming_decode::streaming_decode_selected_to_dir_encrypted_with_progress(
389
- &input,
638
+ if file_size > ram_budget_bytes(ram_budget_mb) {
639
+ return Err(anyhow::anyhow!(
640
+ "PNG fallback requires in-memory reconstruction ({} MB file > {} MB RAM budget). Increase --ram-budget-mb.",
641
+ file_size / MB_U64,
642
+ ram_budget_mb
643
+ ));
644
+ }
645
+
646
+ let buf = read_all(&input)?;
647
+
648
+ eprintln!("PROGRESS:20:100:decompressing");
649
+ let progress_cb = |current: u64, total: u64, step: &str| {
650
+ eprintln!("PROGRESS:{}:{}:{}", current, total, step);
651
+ };
652
+
653
+ let normalized = normalize_png_archive_bytes(&buf, passphrase.as_deref())?;
654
+ let written = unpack_archive_bytes(
655
+ normalized,
390
656
  &out_dir,
391
657
  requested_files.as_deref(),
392
- passphrase.as_deref(),
393
- Some(Box::new(|current, total, step| {
394
- eprintln!("PROGRESS:{}:{}:{}", current, total, step);
395
- })),
396
- ).map_err(|e| anyhow::anyhow!(e))?;
658
+ Some(&progress_cb),
659
+ )?;
397
660
  eprintln!("PROGRESS:100:100:done");
398
661
  println!("Unpacked {} files", written.len());
399
662
  return Ok(());
400
663
  }
401
664
 
665
+ if file_size > ram_budget_bytes(ram_budget_mb) {
666
+ return Err(anyhow::anyhow!(
667
+ "Input is {} MB but RAM budget is {} MB. Increase --ram-budget-mb for non-streaming decode paths.",
668
+ file_size / MB_U64,
669
+ ram_budget_mb
670
+ ));
671
+ }
672
+
402
673
  let buf = read_all(&input)?;
674
+
403
675
  eprintln!("PROGRESS:20:100:decompressing");
404
676
  let dict_bytes: Option<Vec<u8>> = match dict {
405
677
  Some(path) => Some(read_all(&path)?),