roxify 1.13.3 → 1.13.5
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/Cargo.toml +3 -1
- package/README.md +26 -13
- package/dist/rox-macos-universal +0 -0
- package/dist/roxify_native +0 -0
- package/dist/roxify_native-macos-arm64 +0 -0
- package/dist/roxify_native-macos-x64 +0 -0
- package/dist/roxify_native.exe +0 -0
- package/dist/utils/decoder.js +62 -58
- package/dist/utils/encoder.js +13 -6
- package/native/encoder.rs +10 -22
- package/native/io_advice.rs +43 -0
- package/native/lib.rs +2 -0
- package/native/main.rs +81 -43
- package/native/packer.rs +232 -75
- package/native/png_chunk_writer.rs +146 -0
- package/native/png_utils.rs +70 -54
- package/native/streaming.rs +16 -39
- package/native/streaming_decode.rs +313 -114
- package/native/streaming_encode.rs +272 -128
- package/package.json +1 -1
- package/roxify_native-aarch64-apple-darwin.node +0 -0
- package/roxify_native-aarch64-pc-windows-msvc.node +0 -0
- package/roxify_native-aarch64-unknown-linux-gnu.node +0 -0
- package/roxify_native-i686-pc-windows-msvc.node +0 -0
- package/roxify_native-i686-unknown-linux-gnu.node +0 -0
- package/roxify_native-x86_64-apple-darwin.node +0 -0
- package/roxify_native-x86_64-pc-windows-msvc.node +0 -0
- package/roxify_native-x86_64-unknown-linux-gnu.node +0 -0
package/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "roxify_native"
|
|
3
|
-
version = "1.13.
|
|
3
|
+
version = "1.13.5"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
publish = false
|
|
6
6
|
|
|
@@ -35,6 +35,7 @@ zstd = { version = "0.11", features = ["zstdmt"] }
|
|
|
35
35
|
crc32fast = "1.3"
|
|
36
36
|
num_cpus = "1.16"
|
|
37
37
|
clap = { version = "4", features = ["derive"] }
|
|
38
|
+
serde = { version = "1.0", features = ["derive"] }
|
|
38
39
|
serde_json = "1.0"
|
|
39
40
|
anyhow = "1.0"
|
|
40
41
|
png = "0.18.0"
|
|
@@ -58,6 +59,7 @@ bytemuck = { version = "1.14", features = ["derive"] }
|
|
|
58
59
|
tokio = { version = "1", features = ["sync", "rt"], optional = true }
|
|
59
60
|
parking_lot = "0.12"
|
|
60
61
|
libsais = { version = "0.2.0", default-features = false }
|
|
62
|
+
libc = "0.2"
|
|
61
63
|
|
|
62
64
|
[features]
|
|
63
65
|
default = []
|
package/README.md
CHANGED
|
@@ -57,27 +57,40 @@ The core compression and image-processing logic is written in Rust and exposed t
|
|
|
57
57
|
|
|
58
58
|
## Benchmarks
|
|
59
59
|
|
|
60
|
-
All measurements
|
|
60
|
+
All measurements below use Roxify native Rust CLI (`roxify_native`) with streaming directory packing, Zstd L3, multi-threading, long-distance matching, and `window_log(30)`.
|
|
61
61
|
|
|
62
|
-
###
|
|
62
|
+
### Cold-cache throughput on ext4
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
| --- | --- | --- | --- | --- | --- | --- |
|
|
66
|
-
| Test A (19 638 files, 177 MB) | 177 MB | 87.7 MB (49.6%) | 54.9 MB (31.0%) | 17.6 s | 1.2 s | 14.7x |
|
|
67
|
-
| Test B (3 936 files, 1.4 GB) | 1.4 GB | 513 MB (36.7%) | 409 MB (29.2%) | 1 min 46 s | 6.7 s | 15.9x |
|
|
64
|
+
Measured with targeted page-cache eviction (`POSIX_FADV_DONTNEED`) before both encode and decode. Raw manifest lives in `docs/COLD_BENCHMARK_2026-04-15.json`.
|
|
68
65
|
|
|
69
|
-
|
|
66
|
+
| Dataset | Files | Source | Output PNG | Encode | Encode throughput | Decode | Decode throughput |
|
|
67
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
68
|
+
| Glados-Disc | 19,645 | 208.18 MiB | 54.83 MiB | 2.883 s | 72.22 MiB/s | 0.954 s | 218.16 MiB/s |
|
|
69
|
+
| Gmod | 3,936 | 1.36 GiB | 411.09 MiB | 6.127 s | 227.69 MiB/s | 5.850 s | 238.48 MiB/s |
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
| --- | --- | --- | --- |
|
|
73
|
-
| Test A (177 MB) | 2.4 s | 1.3 s | 1.8x |
|
|
74
|
-
| Test B (1.4 GB) | 8.4 s | 2.9 s | 2.9x |
|
|
71
|
+
### High-latency source filesystem encode
|
|
75
72
|
|
|
76
|
-
Roxify
|
|
73
|
+
Roxify 1.13.4 adds adaptive parallel preload for small files before feeding Zstd. This specifically targets metadata-heavy trees on slower filesystems such as NTFS, APFS, exFAT, and network-backed mounts.
|
|
74
|
+
|
|
75
|
+
| Dataset | Source FS | Before 1.13.4 | Roxify 1.13.4 | Speedup |
|
|
76
|
+
| --- | --- | --- | --- | --- |
|
|
77
|
+
| Glados-Disc (19,645 files) | NTFS under Linux | 81.608 s | 2.189 s | 37.3x |
|
|
78
|
+
| Gmod (3,936 files) | NTFS under Linux | 22.578 s | 4.517 s | 5.0x |
|
|
79
|
+
|
|
80
|
+
### Portal 2 comparative reference: ZIP vs PNG
|
|
81
|
+
|
|
82
|
+
Measured on the full `Portal 2` game directory (`3,731 files`, `193 folders`, `12.83 GiB` logical source) to compare classic ZIP packaging against Roxify PNG packing on the same dataset.
|
|
83
|
+
|
|
84
|
+
| Format | Time (s) | Time (min:sec) | Throughput | Compression ratio |
|
|
85
|
+
| --- | ---: | --- | ---: | ---: |
|
|
86
|
+
| ZIP Encode | 633,87 | 10 min 33 s | 21,73 Mo/s | 36,08% |
|
|
87
|
+
| ZIP Decode | 232,88 | 3 min 52 s | 59,15 Mo/s | - |
|
|
88
|
+
| PNG Encode | 157,80 | 2 min 37 s | 87,30 Mo/s | 41,09% |
|
|
89
|
+
| PNG Decode | 156,00 | 2 min 36 s | 88,30 Mo/s | - |
|
|
77
90
|
|
|
78
91
|
### Data integrity
|
|
79
92
|
|
|
80
|
-
|
|
93
|
+
All benchmark runs completed with byte-exact roundtrip validation. Decode output matched original logical source bytes on every dataset.
|
|
81
94
|
|
|
82
95
|
---
|
|
83
96
|
|
package/dist/rox-macos-universal
CHANGED
|
Binary file
|
package/dist/roxify_native
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/roxify_native.exe
CHANGED
|
Binary file
|
package/dist/utils/decoder.js
CHANGED
|
@@ -12,9 +12,51 @@ import { cropAndReconstitute } from './reconstitution.js';
|
|
|
12
12
|
import { decodeRobustAudio, isRobustAudioWav } from './robust-audio.js';
|
|
13
13
|
import { decodeRobustImage, isRobustImage } from './robust-image.js';
|
|
14
14
|
import { parallelZstdDecompress } from './zstd.js';
|
|
15
|
+
const HEADER_VERSION_V1 = 1;
|
|
16
|
+
const HEADER_VERSION_V2 = 2;
|
|
15
17
|
function isColorMatch(r1, g1, b1, r2, g2, b2) {
|
|
16
18
|
return Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) < 50;
|
|
17
19
|
}
|
|
20
|
+
function readPayloadLength(buf, idx, version) {
|
|
21
|
+
if (version === HEADER_VERSION_V1) {
|
|
22
|
+
if (idx + 4 > buf.length) {
|
|
23
|
+
throw new DataFormatError('Truncated payload length');
|
|
24
|
+
}
|
|
25
|
+
return { payloadLen: buf.readUInt32BE(idx), nextIdx: idx + 4 };
|
|
26
|
+
}
|
|
27
|
+
if (version === HEADER_VERSION_V2) {
|
|
28
|
+
if (idx + 8 > buf.length) {
|
|
29
|
+
throw new DataFormatError('Truncated payload length');
|
|
30
|
+
}
|
|
31
|
+
const payloadLen = buf.readBigUInt64BE(idx);
|
|
32
|
+
if (payloadLen > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
33
|
+
throw new DataFormatError('Payload too large for JS decoder');
|
|
34
|
+
}
|
|
35
|
+
return { payloadLen: Number(payloadLen), nextIdx: idx + 8 };
|
|
36
|
+
}
|
|
37
|
+
throw new DataFormatError(`Unsupported pixel header version ${version}`);
|
|
38
|
+
}
|
|
39
|
+
function readPixelPayloadHeader(buf, idx) {
|
|
40
|
+
if (idx + 2 > buf.length) {
|
|
41
|
+
throw new DataFormatError('Pixel payload header truncated');
|
|
42
|
+
}
|
|
43
|
+
const version = buf[idx++];
|
|
44
|
+
const nameLen = buf[idx++];
|
|
45
|
+
let name;
|
|
46
|
+
if (nameLen > 0) {
|
|
47
|
+
if (idx + nameLen > buf.length) {
|
|
48
|
+
throw new DataFormatError('Pixel payload name truncated');
|
|
49
|
+
}
|
|
50
|
+
name = buf.subarray(idx, idx + nameLen).toString('utf8');
|
|
51
|
+
idx += nameLen;
|
|
52
|
+
}
|
|
53
|
+
const { payloadLen, nextIdx } = readPayloadLength(buf, idx, version);
|
|
54
|
+
const available = buf.length - nextIdx;
|
|
55
|
+
if (available < payloadLen) {
|
|
56
|
+
throw new DataFormatError(`Pixel payload truncated: expected ${payloadLen} bytes but only ${available} available`);
|
|
57
|
+
}
|
|
58
|
+
return { version, name, payloadOffset: nextIdx, payloadLen };
|
|
59
|
+
}
|
|
18
60
|
/**
|
|
19
61
|
* Un-stretch an image that was nearest-neighbor scaled.
|
|
20
62
|
* 1. Crops to non-background bounding box
|
|
@@ -318,18 +360,12 @@ export async function decodePngToBinary(input, opts = {}) {
|
|
|
318
360
|
const pcmData = wavToBytes(processedBuf);
|
|
319
361
|
// The WAV payload starts with PIXEL_MAGIC ("PXL1")
|
|
320
362
|
if (pcmData.length >= 4 && pcmData.subarray(0, 4).equals(PIXEL_MAGIC)) {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
idx += nameLen;
|
|
328
|
-
}
|
|
329
|
-
const payloadLen = pcmData.readUInt32BE(idx);
|
|
330
|
-
idx += 4;
|
|
331
|
-
const rawPayload = pcmData.subarray(idx, idx + payloadLen);
|
|
332
|
-
idx += payloadLen;
|
|
363
|
+
const header = readPixelPayloadHeader(pcmData, 4);
|
|
364
|
+
let idx = header.payloadOffset;
|
|
365
|
+
const version = header.version;
|
|
366
|
+
const name = header.name;
|
|
367
|
+
const rawPayload = pcmData.subarray(idx, idx + header.payloadLen);
|
|
368
|
+
idx += header.payloadLen;
|
|
333
369
|
// Check for rXFL file list after payload
|
|
334
370
|
let fileListJson;
|
|
335
371
|
if (idx + 8 < pcmData.length &&
|
|
@@ -652,21 +688,10 @@ export async function decodePngToBinary(input, opts = {}) {
|
|
|
652
688
|
if (logicalData.length < 8 + PIXEL_MAGIC.length) {
|
|
653
689
|
throw new DataFormatError('Pixel mode data too short');
|
|
654
690
|
}
|
|
655
|
-
|
|
656
|
-
const version =
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
if (nameLen > 0 && nameLen < 256) {
|
|
660
|
-
name = logicalData.slice(idx, idx + nameLen).toString('utf8');
|
|
661
|
-
idx += nameLen;
|
|
662
|
-
}
|
|
663
|
-
const payloadLen = logicalData.readUInt32BE(idx);
|
|
664
|
-
idx += 4;
|
|
665
|
-
const available = logicalData.length - idx;
|
|
666
|
-
if (available < payloadLen) {
|
|
667
|
-
throw new DataFormatError(`Pixel payload truncated: expected ${payloadLen} bytes but only ${available} available`);
|
|
668
|
-
}
|
|
669
|
-
const rawPayload = logicalData.slice(idx, idx + payloadLen);
|
|
691
|
+
const header = readPixelPayloadHeader(logicalData, 8 + PIXEL_MAGIC.length);
|
|
692
|
+
const version = header.version;
|
|
693
|
+
const name = header.name;
|
|
694
|
+
const rawPayload = logicalData.slice(header.payloadOffset, header.payloadOffset + header.payloadLen);
|
|
670
695
|
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
671
696
|
try {
|
|
672
697
|
payload = await tryDecompress(payload, (info) => {
|
|
@@ -945,31 +970,10 @@ export async function decodePngToBinary(input, opts = {}) {
|
|
|
945
970
|
}
|
|
946
971
|
}
|
|
947
972
|
if (idx > 0) {
|
|
948
|
-
const
|
|
949
|
-
const
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
name = pixelBytes.slice(idx, idx + nameLen).toString('utf8');
|
|
953
|
-
idx += nameLen;
|
|
954
|
-
}
|
|
955
|
-
const payloadLen = pixelBytes.readUInt32BE(idx);
|
|
956
|
-
idx += 4;
|
|
957
|
-
if (idx + 4 <= pixelBytes.length) {
|
|
958
|
-
const marker = pixelBytes.slice(idx, idx + 4).toString('utf8');
|
|
959
|
-
if (marker === 'rXFL') {
|
|
960
|
-
idx += 4;
|
|
961
|
-
if (idx + 4 <= pixelBytes.length) {
|
|
962
|
-
const jsonLen = pixelBytes.readUInt32BE(idx);
|
|
963
|
-
idx += 4;
|
|
964
|
-
idx += jsonLen;
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
const available = pixelBytes.length - idx;
|
|
969
|
-
if (available < payloadLen) {
|
|
970
|
-
throw new DataFormatError(`Pixel payload truncated: expected ${payloadLen} bytes but only ${available} available`);
|
|
971
|
-
}
|
|
972
|
-
const rawPayload = pixelBytes.slice(idx, idx + payloadLen);
|
|
973
|
+
const header = readPixelPayloadHeader(pixelBytes, idx);
|
|
974
|
+
const version = header.version;
|
|
975
|
+
const name = header.name;
|
|
976
|
+
const rawPayload = pixelBytes.slice(header.payloadOffset, header.payloadOffset + header.payloadLen);
|
|
973
977
|
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
974
978
|
try {
|
|
975
979
|
payload = await tryDecompress(payload, (info) => {
|
|
@@ -1162,10 +1166,10 @@ export async function decodePngToBinary(input, opts = {}) {
|
|
|
1162
1166
|
ii = found + PIXEL_MAGIC.length;
|
|
1163
1167
|
}
|
|
1164
1168
|
if (ii > 0) {
|
|
1165
|
-
const
|
|
1166
|
-
const
|
|
1167
|
-
const
|
|
1168
|
-
const rawPayload2 = pixelBytes2.slice(
|
|
1169
|
+
const header2 = readPixelPayloadHeader(pixelBytes2, ii);
|
|
1170
|
+
const version2 = header2.version;
|
|
1171
|
+
const name2 = header2.name;
|
|
1172
|
+
const rawPayload2 = pixelBytes2.slice(header2.payloadOffset, header2.payloadOffset + header2.payloadLen);
|
|
1169
1173
|
let payload2 = tryDecryptIfNeeded(rawPayload2, opts.passphrase);
|
|
1170
1174
|
payload2 = await tryDecompress(payload2, (info) => {
|
|
1171
1175
|
if (opts.onProgress)
|
|
@@ -1181,13 +1185,13 @@ export async function decodePngToBinary(input, opts = {}) {
|
|
|
1181
1185
|
if (opts.onProgress)
|
|
1182
1186
|
opts.onProgress({ phase: 'done' });
|
|
1183
1187
|
progressBar?.stop();
|
|
1184
|
-
return { files: unpacked2.files, meta: { name } };
|
|
1188
|
+
return { files: unpacked2.files, meta: { name: name2 } };
|
|
1185
1189
|
}
|
|
1186
1190
|
}
|
|
1187
1191
|
if (opts.onProgress)
|
|
1188
1192
|
opts.onProgress({ phase: 'done' });
|
|
1189
1193
|
progressBar?.stop();
|
|
1190
|
-
return { buf: payload2, meta: { name } };
|
|
1194
|
+
return { buf: payload2, meta: { name: name2 } };
|
|
1191
1195
|
}
|
|
1192
1196
|
}
|
|
1193
1197
|
throw new DataFormatError('Screenshot mode zstd decompression failed: ' + errMsg);
|
package/dist/utils/encoder.js
CHANGED
|
@@ -9,6 +9,15 @@ import { native } from './native.js';
|
|
|
9
9
|
import { encodeRobustAudio } from './robust-audio.js';
|
|
10
10
|
import { encodeRobustImage } from './robust-image.js';
|
|
11
11
|
import { parallelZstdCompress } from './zstd.js';
|
|
12
|
+
const HEADER_VERSION_V2 = 2;
|
|
13
|
+
function writePayloadLength(payloadLen) {
|
|
14
|
+
if (!Number.isSafeInteger(payloadLen) || payloadLen < 0) {
|
|
15
|
+
throw new RangeError(`Invalid payload length: ${payloadLen}`);
|
|
16
|
+
}
|
|
17
|
+
const buf = Buffer.alloc(8);
|
|
18
|
+
buf.writeBigUInt64BE(BigInt(payloadLen), 0);
|
|
19
|
+
return buf;
|
|
20
|
+
}
|
|
12
21
|
/**
|
|
13
22
|
* Encode a buffer or array of buffers into a PNG image (ROX format).
|
|
14
23
|
*
|
|
@@ -309,9 +318,8 @@ export async function encodeBinaryToPng(input, opts = {}) {
|
|
|
309
318
|
if (opts.container === 'sound') {
|
|
310
319
|
const nameBuf = opts.name ? Buffer.from(opts.name, 'utf8') : Buffer.alloc(0);
|
|
311
320
|
const nameLen = nameBuf.length;
|
|
312
|
-
const payloadLenBuf =
|
|
313
|
-
|
|
314
|
-
const version = 1;
|
|
321
|
+
const payloadLenBuf = writePayloadLength(payloadTotalLen);
|
|
322
|
+
const version = HEADER_VERSION_V2;
|
|
315
323
|
let wavPayload = [
|
|
316
324
|
PIXEL_MAGIC,
|
|
317
325
|
Buffer.from([version]),
|
|
@@ -357,9 +365,8 @@ export async function encodeBinaryToPng(input, opts = {}) {
|
|
|
357
365
|
{
|
|
358
366
|
const nameBuf = opts.name ? Buffer.from(opts.name, 'utf8') : Buffer.alloc(0);
|
|
359
367
|
const nameLen = nameBuf.length;
|
|
360
|
-
const payloadLenBuf =
|
|
361
|
-
|
|
362
|
-
const version = 1;
|
|
368
|
+
const payloadLenBuf = writePayloadLength(payloadTotalLen);
|
|
369
|
+
const version = HEADER_VERSION_V2;
|
|
363
370
|
let metaPixel = [
|
|
364
371
|
Buffer.from([version]),
|
|
365
372
|
Buffer.from([nameLen]),
|
package/native/encoder.rs
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
use anyhow::Result;
|
|
2
2
|
|
|
3
|
+
use crate::png_chunk_writer::{write_chunked_idat_bytes, write_png_chunk};
|
|
4
|
+
|
|
3
5
|
const MAGIC: &[u8] = b"ROX1";
|
|
4
6
|
const PIXEL_MAGIC: &[u8] = b"PXL1";
|
|
5
7
|
const PNG_HEADER: &[u8] = &[137, 80, 78, 71, 13, 10, 26, 10];
|
|
8
|
+
const HEADER_VERSION_V2: u8 = 2;
|
|
6
9
|
|
|
7
10
|
const MARKER_START: [(u8, u8, u8); 3] = [(255, 0, 0), (0, 255, 0), (0, 0, 255)];
|
|
8
11
|
const MARKER_END: [(u8, u8, u8); 3] = [(0, 0, 255), (0, 255, 0), (255, 0, 0)];
|
|
@@ -204,12 +207,12 @@ fn build_flat_pixel_buffer(
|
|
|
204
207
|
}
|
|
205
208
|
|
|
206
209
|
fn build_meta_pixel_with_name_and_filelist(payload: &[u8], name: Option<&str>, file_list: Option<&str>) -> Result<Vec<u8>> {
|
|
207
|
-
let version =
|
|
210
|
+
let version = HEADER_VERSION_V2;
|
|
208
211
|
let name_bytes = name.map(|n| n.as_bytes()).unwrap_or(&[]);
|
|
209
212
|
let name_len = name_bytes.len().min(255) as u8;
|
|
210
|
-
let payload_len_bytes = (payload.len() as
|
|
213
|
+
let payload_len_bytes = (payload.len() as u64).to_be_bytes();
|
|
211
214
|
|
|
212
|
-
let mut result = Vec::with_capacity(1 + 1 + name_len as usize +
|
|
215
|
+
let mut result = Vec::with_capacity(1 + 1 + name_len as usize + 8 + payload.len() + 256);
|
|
213
216
|
result.push(version);
|
|
214
217
|
result.push(name_len);
|
|
215
218
|
|
|
@@ -245,33 +248,18 @@ fn build_png(width: usize, height: usize, idat_data: &[u8], file_list: Option<&s
|
|
|
245
248
|
ihdr_data[11] = 0;
|
|
246
249
|
ihdr_data[12] = 0;
|
|
247
250
|
|
|
248
|
-
|
|
249
|
-
|
|
251
|
+
write_png_chunk(&mut png, b"IHDR", &ihdr_data)?;
|
|
252
|
+
write_chunked_idat_bytes(&mut png, idat_data)?;
|
|
250
253
|
|
|
251
254
|
if let Some(file_list_json) = file_list {
|
|
252
|
-
|
|
255
|
+
write_png_chunk(&mut png, b"rXFL", file_list_json.as_bytes())?;
|
|
253
256
|
}
|
|
254
257
|
|
|
255
|
-
|
|
258
|
+
write_png_chunk(&mut png, b"IEND", &[])?;
|
|
256
259
|
|
|
257
260
|
Ok(png)
|
|
258
261
|
}
|
|
259
262
|
|
|
260
|
-
fn write_chunk(out: &mut Vec<u8>, chunk_type: &[u8; 4], data: &[u8]) -> Result<()> {
|
|
261
|
-
let len = data.len() as u32;
|
|
262
|
-
out.extend_from_slice(&len.to_be_bytes());
|
|
263
|
-
out.extend_from_slice(chunk_type);
|
|
264
|
-
out.extend_from_slice(data);
|
|
265
|
-
|
|
266
|
-
let mut hasher = crc32fast::Hasher::new();
|
|
267
|
-
hasher.update(chunk_type);
|
|
268
|
-
hasher.update(data);
|
|
269
|
-
let crc = hasher.finalize();
|
|
270
|
-
|
|
271
|
-
out.extend_from_slice(&crc.to_be_bytes());
|
|
272
|
-
Ok(())
|
|
273
|
-
}
|
|
274
|
-
|
|
275
263
|
fn create_raw_deflate_from_rows(flat: &[u8], row_bytes: usize, height: usize) -> Vec<u8> {
|
|
276
264
|
let stride = row_bytes + 1;
|
|
277
265
|
let scanlines_total = height * stride;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
use std::fs::File;
|
|
2
|
+
#[cfg(target_os = "linux")]
|
|
3
|
+
use std::os::fd::AsRawFd;
|
|
4
|
+
|
|
5
|
+
pub const INPUT_DROP_GRANULARITY: u64 = 8 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
pub fn advise_file_sequential(file: &File) {
|
|
8
|
+
#[cfg(target_os = "linux")]
|
|
9
|
+
unsafe {
|
|
10
|
+
let _ = libc::posix_fadvise(file.as_raw_fd(), 0, 0, libc::POSIX_FADV_SEQUENTIAL);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
#[cfg(not(target_os = "linux"))]
|
|
14
|
+
let _ = file;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
pub fn advise_drop(file: &File, offset: u64, len: u64) {
|
|
18
|
+
if len == 0 {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#[cfg(target_os = "linux")]
|
|
23
|
+
unsafe {
|
|
24
|
+
let _ = libc::posix_fadvise(
|
|
25
|
+
file.as_raw_fd(),
|
|
26
|
+
offset as libc::off_t,
|
|
27
|
+
len as libc::off_t,
|
|
28
|
+
libc::POSIX_FADV_DONTNEED,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#[cfg(not(target_os = "linux"))]
|
|
33
|
+
let _ = (file, offset, len);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pub fn sync_and_drop(file: &File, len: u64) {
|
|
37
|
+
if len == 0 {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let _ = file.sync_data();
|
|
42
|
+
advise_drop(file, 0, len);
|
|
43
|
+
}
|
package/native/lib.rs
CHANGED
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;
|
|
@@ -144,6 +146,19 @@ fn parse_markers(v: &[String]) -> Option<Vec<u8>> {
|
|
|
144
146
|
|
|
145
147
|
fn main() -> anyhow::Result<()> {
|
|
146
148
|
let cli = Cli::parse();
|
|
149
|
+
|
|
150
|
+
fn parse_requested_files(files: &str) -> anyhow::Result<Vec<String>> {
|
|
151
|
+
if files.trim_start().starts_with('[') {
|
|
152
|
+
serde_json::from_str::<Vec<String>>(files)
|
|
153
|
+
.map_err(|e| anyhow::anyhow!("Invalid JSON for --files: {}", e))
|
|
154
|
+
} else {
|
|
155
|
+
Ok(files
|
|
156
|
+
.split(',')
|
|
157
|
+
.map(|file| file.trim().to_string())
|
|
158
|
+
.filter(|file| !file.is_empty())
|
|
159
|
+
.collect())
|
|
160
|
+
}
|
|
161
|
+
}
|
|
147
162
|
match cli.command {
|
|
148
163
|
Commands::TrainDict { samples, size, output } => {
|
|
149
164
|
let dict = core::train_zstd_dictionary(&samples, size)?;
|
|
@@ -169,7 +184,7 @@ fn main() -> anyhow::Result<()> {
|
|
|
169
184
|
eprintln!("PROGRESS:{}:{}:{}", current, total, step);
|
|
170
185
|
})),
|
|
171
186
|
)?;
|
|
172
|
-
println!("(
|
|
187
|
+
println!("(directory payload, rXFL chunk embedded)");
|
|
173
188
|
return Ok(());
|
|
174
189
|
}
|
|
175
190
|
|
|
@@ -234,7 +249,7 @@ fn main() -> anyhow::Result<()> {
|
|
|
234
249
|
if file_list_json.is_some() {
|
|
235
250
|
eprintln!("PROGRESS:100:100:done");
|
|
236
251
|
if is_dir {
|
|
237
|
-
println!("(
|
|
252
|
+
println!("(directory payload, rXFL chunk embedded)");
|
|
238
253
|
} else {
|
|
239
254
|
println!("(rXFL chunk embedded)");
|
|
240
255
|
}
|
|
@@ -242,32 +257,39 @@ fn main() -> anyhow::Result<()> {
|
|
|
242
257
|
}
|
|
243
258
|
Commands::List { input } => {
|
|
244
259
|
let mut file = File::open(&input)?;
|
|
245
|
-
let
|
|
260
|
+
let mut chunk_scan_error: Option<anyhow::Error> = None;
|
|
246
261
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
262
|
+
match png_utils::extract_png_chunks_streaming(&mut file) {
|
|
263
|
+
Ok(chunks) => {
|
|
264
|
+
if let Some(rxfl_chunk) = chunks.iter().find(|c| c.name == "rXFL") {
|
|
265
|
+
println!("{}", String::from_utf8_lossy(&rxfl_chunk.data));
|
|
266
|
+
return Ok(());
|
|
267
|
+
}
|
|
251
268
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
269
|
+
if let Some(meta_chunk) = chunks.iter().find(|c| c.name == "rOXm") {
|
|
270
|
+
if let Some(pos) = meta_chunk.data.windows(4).position(|w| w == b"rXFL") {
|
|
271
|
+
if pos + 8 <= meta_chunk.data.len() {
|
|
272
|
+
let json_len = u32::from_be_bytes([
|
|
273
|
+
meta_chunk.data[pos + 4],
|
|
274
|
+
meta_chunk.data[pos + 5],
|
|
275
|
+
meta_chunk.data[pos + 6],
|
|
276
|
+
meta_chunk.data[pos + 7],
|
|
277
|
+
]) as usize;
|
|
278
|
+
|
|
279
|
+
let json_start = pos + 8;
|
|
280
|
+
let json_end = json_start + json_len;
|
|
281
|
+
|
|
282
|
+
if json_end <= meta_chunk.data.len() {
|
|
283
|
+
println!("{}", String::from_utf8_lossy(&meta_chunk.data[json_start..json_end]));
|
|
284
|
+
return Ok(());
|
|
285
|
+
}
|
|
286
|
+
}
|
|
268
287
|
}
|
|
269
288
|
}
|
|
270
289
|
}
|
|
290
|
+
Err(err) => {
|
|
291
|
+
chunk_scan_error = Some(anyhow::anyhow!(err));
|
|
292
|
+
}
|
|
271
293
|
}
|
|
272
294
|
|
|
273
295
|
let png_data = std::fs::read(&input)?;
|
|
@@ -276,11 +298,14 @@ fn main() -> anyhow::Result<()> {
|
|
|
276
298
|
println!("{}", json);
|
|
277
299
|
return Ok(());
|
|
278
300
|
}
|
|
279
|
-
Err(
|
|
301
|
+
Err(pixel_err) => {
|
|
302
|
+
if let Some(chunk_err) = chunk_scan_error {
|
|
303
|
+
return Err(anyhow::anyhow!("chunk scan: {}; pixel scan: {}", chunk_err, pixel_err));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
280
306
|
}
|
|
281
307
|
|
|
282
|
-
|
|
283
|
-
std::process::exit(1);
|
|
308
|
+
return Err(anyhow::anyhow!("No file list found in PNG"));
|
|
284
309
|
}
|
|
285
310
|
Commands::Havepassphrase { input } => {
|
|
286
311
|
let buf = read_all(&input)?;
|
|
@@ -343,6 +368,11 @@ fn main() -> anyhow::Result<()> {
|
|
|
343
368
|
&& sig == [137, 80, 78, 71, 13, 10, 26, 10]
|
|
344
369
|
});
|
|
345
370
|
|
|
371
|
+
let requested_files = match files.as_deref() {
|
|
372
|
+
Some(files_str) => Some(parse_requested_files(files_str)?),
|
|
373
|
+
None => None,
|
|
374
|
+
};
|
|
375
|
+
|
|
346
376
|
if is_png_file && files.is_none() && dict.is_none() {
|
|
347
377
|
let out_dir = output.clone().unwrap_or_else(|| PathBuf::from("out.raw"));
|
|
348
378
|
match streaming_decode::streaming_decode_to_dir_encrypted_with_progress(
|
|
@@ -355,34 +385,40 @@ fn main() -> anyhow::Result<()> {
|
|
|
355
385
|
) {
|
|
356
386
|
Ok(written) => {
|
|
357
387
|
eprintln!("PROGRESS:100:100:done");
|
|
358
|
-
println!("Unpacked {} files
|
|
388
|
+
println!("Unpacked {} files", written.len());
|
|
359
389
|
return Ok(());
|
|
360
390
|
}
|
|
361
391
|
Err(e) => {
|
|
362
|
-
|
|
392
|
+
return Err(anyhow::anyhow!("Streaming decode failed: {}", e));
|
|
363
393
|
}
|
|
364
394
|
}
|
|
365
395
|
}
|
|
366
396
|
|
|
397
|
+
if is_png_file && requested_files.is_some() && dict.is_none() {
|
|
398
|
+
let out_dir = output.clone().unwrap_or_else(|| PathBuf::from("."));
|
|
399
|
+
std::fs::create_dir_all(&out_dir).map_err(|e| anyhow::anyhow!("Cannot create output directory {:?}: {}", out_dir, e))?;
|
|
400
|
+
let written = streaming_decode::streaming_decode_selected_to_dir_encrypted_with_progress(
|
|
401
|
+
&input,
|
|
402
|
+
&out_dir,
|
|
403
|
+
requested_files.as_deref(),
|
|
404
|
+
passphrase.as_deref(),
|
|
405
|
+
Some(Box::new(|current, total, step| {
|
|
406
|
+
eprintln!("PROGRESS:{}:{}:{}", current, total, step);
|
|
407
|
+
})),
|
|
408
|
+
).map_err(|e| anyhow::anyhow!(e))?;
|
|
409
|
+
eprintln!("PROGRESS:100:100:done");
|
|
410
|
+
println!("Unpacked {} files", written.len());
|
|
411
|
+
return Ok(());
|
|
412
|
+
}
|
|
413
|
+
|
|
367
414
|
let buf = read_all(&input)?;
|
|
368
415
|
eprintln!("PROGRESS:20:100:decompressing");
|
|
369
416
|
let dict_bytes: Option<Vec<u8>> = match dict {
|
|
370
417
|
Some(path) => Some(read_all(&path)?),
|
|
371
418
|
None => None,
|
|
372
419
|
};
|
|
373
|
-
if
|
|
374
|
-
let file_list
|
|
375
|
-
match serde_json::from_str::<Vec<String>>(&files_str) {
|
|
376
|
-
Ok(v) => Some(v),
|
|
377
|
-
Err(e) => {
|
|
378
|
-
eprintln!("Invalid JSON for --files: {}", e);
|
|
379
|
-
std::process::exit(1);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
} else {
|
|
383
|
-
let list = files_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect::<Vec<_>>();
|
|
384
|
-
Some(list)
|
|
385
|
-
};
|
|
420
|
+
if requested_files.is_some() {
|
|
421
|
+
let file_list = requested_files;
|
|
386
422
|
|
|
387
423
|
let is_png = buf.len() >= 8 && &buf[0..8] == &[137, 80, 78, 71, 13, 10, 26, 10];
|
|
388
424
|
|
|
@@ -427,7 +463,9 @@ fn main() -> anyhow::Result<()> {
|
|
|
427
463
|
std::fs::create_dir_all(&out_dir).map_err(|e| anyhow::anyhow!("Cannot create output directory {:?}: {}", out_dir, e))?;
|
|
428
464
|
let files_slice = file_list.as_ref().map(|v| v.as_slice());
|
|
429
465
|
|
|
430
|
-
let written = packer::unpack_stream_to_dir(&mut reader, &out_dir, files_slice
|
|
466
|
+
let written = packer::unpack_stream_to_dir(&mut reader, &out_dir, files_slice, Some(&|current, total, step| {
|
|
467
|
+
eprintln!("PROGRESS:{}:{}:{}", current, total, step);
|
|
468
|
+
}), 0).map_err(|e| anyhow::anyhow!(e))?;
|
|
431
469
|
eprintln!("PROGRESS:100:100:done");
|
|
432
470
|
println!("Unpacked {} files", written.len());
|
|
433
471
|
} else {
|
|
@@ -498,7 +536,7 @@ fn main() -> anyhow::Result<()> {
|
|
|
498
536
|
.map_err(|e| anyhow::anyhow!("mkdir {:?}: {}", out_dir, e))?;
|
|
499
537
|
let written = archive::tar_unpack(&out_bytes, &out_dir)
|
|
500
538
|
.map_err(|e| anyhow::anyhow!(e))?;
|
|
501
|
-
println!("Unpacked {} files
|
|
539
|
+
println!("Unpacked {} files to {:?}", written.len(), out_dir);
|
|
502
540
|
} else if out_bytes.len() >= 4
|
|
503
541
|
&& (u32::from_be_bytes(out_bytes[0..4].try_into().unwrap()) == 0x524f5850u32
|
|
504
542
|
|| u32::from_be_bytes(out_bytes[0..4].try_into().unwrap()) == 0x524f5849u32)
|