Skip to main content

phasm_core/stego/ghost/
pipeline.rs

1// Copyright (c) 2026 Christoph Gaffga
2// SPDX-License-Identifier: GPL-3.0-only
3// https://github.com/cgaffga/phasmcore
4
5//! Ghost mode encode/decode pipeline.
6//!
7//! Ghost mode embeds encrypted messages into JPEG DCT coefficients using:
8//! 1. J-UNIWARD cost function to identify low-detectability embedding positions
9//! 2. Fisher-Yates permutation (keyed by passphrase) to scatter the payload
10//! 3. Syndrome-Trellis Coding (STC) to minimize total embedding distortion
11//! 4. nsF5-style LSB modification (toward zero for |coeff| > 1, away from
12//!    zero for |coeff| == 1 to prevent shrinkage)
13
14use crate::codec::jpeg::JpegImage;
15use crate::stego::cost::uniward::compute_positions_streaming;
16use crate::stego::crypto;
17use crate::stego::error::StegoError;
18use crate::stego::frame::{self, MAX_FRAME_BITS};
19use crate::stego::payload::{self, FileEntry, PayloadData};
20use crate::stego::permute;
21use crate::stego::progress;
22use crate::stego::side_info::{self, SideInfo};
23use crate::stego::shadow;
24use crate::stego::quality::{self, EncodeQuality, GhostMetrics};
25use crate::stego::stc::{embed, extract, hhat};
26
27/// STC constraint length for Ghost Phase 1.
28const STC_H: usize = 7;
29
30/// Maximum number of STC cover positions before OOM protection kicks in.
31/// 500M positions × 16 bytes/u128 = 8 GB back pointer memory (segmented
32/// path uses O(sqrt(n)) but the coefficient vectors are still O(n)).
33const STC_POSITION_LIMIT: usize = 500_000_000;
34
35/// Progress steps allocated to JPEG parsing.  Reported immediately after
36/// `JpegImage::from_bytes` returns so the bar moves off 0% right away.
37const PARSE_STEPS: u32 = 5;
38
39/// Total number of progress steps reported by [`ghost_decode`].
40///
41/// 5 (parse) + 100 (UNIWARD) + 2 (STC extraction + decryption).
42pub const GHOST_DECODE_STEPS: u32 = PARSE_STEPS + crate::stego::cost::uniward::UNIWARD_PROGRESS_STEPS + 2;
43
44/// Total number of progress steps reported by Ghost encode.
45///
46/// 5 (parse) + 100 (UNIWARD sub-steps) + 50 (STC Viterbi sub-steps) + 2 (permute + LSB mod) + 20 (JPEG write).
47pub const GHOST_ENCODE_STEPS: u32 =
48    PARSE_STEPS
49    + crate::stego::cost::uniward::UNIWARD_PROGRESS_STEPS
50    + crate::stego::stc::embed::STC_PROGRESS_STEPS
51    + 2
52    + crate::codec::jpeg::scan::JPEG_WRITE_STEPS;
53
54/// Compute the STC width `w`, usable cover length, and effective `m_max` from
55/// the total number of AC positions. Both encoder and decoder must agree on
56/// these values — they can because both see the same image and derive the
57/// same `n`.
58///
59/// `m_max` is capped at `MAX_FRAME_BITS` (the u16 protocol limit) but also
60/// capped at `n` so that small images still work (with `w = 1`).
61///
62/// Uses `w = floor(n / m_max)` so that `n_used = m_max * w <= n` and the
63/// extraction produces exactly `m_max` bits.
64///
65/// # Returns
66/// `(w, n_used, m_max)` where `w` is the STC submatrix width, `n_used` is the
67/// number of cover positions to use (always <= `n`), and `m_max` is the
68/// effective extraction size in bits.
69///
70/// # Errors
71/// Returns [`StegoError::ImageTooSmall`] if `n` is 0.
72/// Returns [`StegoError::ImageTooLarge`] if the STC Viterbi back_ptr would
73/// exceed the memory budget (prevents OOM on very large images).
74fn compute_stc_params(n: usize) -> Result<(usize, usize, usize), StegoError> {
75    let m_max = MAX_FRAME_BITS.min(n);
76    if m_max == 0 {
77        return Err(StegoError::ImageTooSmall);
78    }
79    let w = n / m_max; // floor division
80    let n_used = m_max * w;
81
82    if n_used > STC_POSITION_LIMIT {
83        return Err(StegoError::ImageTooLarge);
84    }
85
86    Ok((w, n_used, m_max))
87}
88
89/// Encode a text message into a cover JPEG using Ghost mode.
90///
91/// # Arguments
92/// - `image_bytes`: Raw bytes of the cover JPEG image.
93/// - `message`: The plaintext message to embed (must fit within capacity).
94/// - `passphrase`: Used for both structural key derivation and encryption.
95///
96/// # Returns
97/// The stego JPEG image as bytes, or an error if the image is too small
98/// or the message exceeds the embedding capacity.
99///
100/// # Errors
101/// - [`StegoError::InvalidJpeg`] if `image_bytes` is not a valid baseline JPEG.
102/// - [`StegoError::NoLuminanceChannel`] if the image has no Y component.
103/// - [`StegoError::ImageTooSmall`] if the cover has too few usable coefficients.
104/// - [`StegoError::MessageTooLarge`] if the message exceeds STC capacity.
105pub fn ghost_encode(
106    image_bytes: &[u8],
107    message: &str,
108    passphrase: &str,
109) -> Result<Vec<u8>, StegoError> {
110    ghost_encode_impl(image_bytes, message, &[], passphrase, None, None)
111        .map(|(bytes, _)| bytes)
112}
113
114/// Encode with Ghost mode and return the encode quality score.
115pub fn ghost_encode_with_quality(
116    image_bytes: &[u8],
117    message: &str,
118    passphrase: &str,
119) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
120    ghost_encode_impl(image_bytes, message, &[], passphrase, None, None)
121}
122
123/// Encode a text message with file attachments into a cover JPEG using Ghost mode.
124///
125/// Files are embedded alongside the text message in the payload. The entire
126/// payload (text + files) is compressed with Brotli before encryption.
127pub fn ghost_encode_with_files(
128    image_bytes: &[u8],
129    message: &str,
130    files: &[FileEntry],
131    passphrase: &str,
132) -> Result<Vec<u8>, StegoError> {
133    ghost_encode_impl(image_bytes, message, files, passphrase, None, None)
134        .map(|(bytes, _)| bytes)
135}
136
137/// Encode with Ghost mode + files and return the encode quality score.
138pub fn ghost_encode_with_files_quality(
139    image_bytes: &[u8],
140    message: &str,
141    files: &[FileEntry],
142    passphrase: &str,
143) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
144    ghost_encode_impl(image_bytes, message, files, passphrase, None, None)
145}
146
147/// Encode using Ghost mode with side information (SI-UNIWARD / "Deep Cover").
148///
149/// When the original uncompressed pixels are available (non-JPEG input like
150/// PNG, HEIC, or RAW), the quantization rounding errors enable more efficient
151/// embedding — roughly 1.5-2× capacity at the same detection risk.
152///
153/// The `raw_pixels_rgb` must be the ORIGINAL pixels that were JPEG-compressed
154/// to produce `image_bytes`. They must have the same dimensions.
155///
156/// # Arguments
157/// - `image_bytes`: Cover JPEG (as compressed by the platform from the raw pixels).
158/// - `raw_pixels_rgb`: Original RGB pixels, row-major, 3 bytes per pixel.
159/// - `pixel_width`, `pixel_height`: Dimensions of the raw pixel buffer.
160/// - `message`: Plaintext message to embed.
161/// - `passphrase`: Used for structural key derivation and encryption.
162pub fn ghost_encode_si(
163    image_bytes: &[u8],
164    raw_pixels_rgb: &[u8],
165    pixel_width: u32,
166    pixel_height: u32,
167    message: &str,
168    passphrase: &str,
169) -> Result<Vec<u8>, StegoError> {
170    ghost_encode_si_with_files(
171        image_bytes, raw_pixels_rgb, pixel_width, pixel_height,
172        message, &[], passphrase,
173    )
174}
175
176/// Encode with SI-UNIWARD and return the encode quality score.
177pub fn ghost_encode_si_with_quality(
178    image_bytes: &[u8],
179    raw_pixels_rgb: &[u8],
180    pixel_width: u32,
181    pixel_height: u32,
182    message: &str,
183    passphrase: &str,
184) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
185    ghost_encode_si_with_files_quality(
186        image_bytes, raw_pixels_rgb, pixel_width, pixel_height,
187        message, &[], passphrase,
188    )
189}
190
191/// Encode with side information and file attachments.
192pub fn ghost_encode_si_with_files(
193    image_bytes: &[u8],
194    raw_pixels_rgb: &[u8],
195    pixel_width: u32,
196    pixel_height: u32,
197    message: &str,
198    files: &[FileEntry],
199    passphrase: &str,
200) -> Result<Vec<u8>, StegoError> {
201    ghost_encode_si_with_files_quality(
202        image_bytes, raw_pixels_rgb, pixel_width, pixel_height,
203        message, files, passphrase,
204    ).map(|(bytes, _)| bytes)
205}
206
207/// Encode with SI-UNIWARD + files and return the encode quality score.
208pub fn ghost_encode_si_with_files_quality(
209    image_bytes: &[u8],
210    raw_pixels_rgb: &[u8],
211    pixel_width: u32,
212    pixel_height: u32,
213    message: &str,
214    files: &[FileEntry],
215    passphrase: &str,
216) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
217    // Parse image first to get the grid and QT for side info computation
218    let img = JpegImage::from_bytes(image_bytes)?;
219    let fi = img.frame_info();
220    crate::stego::validate_encode_dimensions(fi.width as u32, fi.height as u32)?;
221
222    if img.num_components() == 0 {
223        return Err(StegoError::NoLuminanceChannel);
224    }
225
226    let qt_id = fi.components[0].quant_table_id as usize;
227    let qt = img.quant_table(qt_id).ok_or(StegoError::NoLuminanceChannel)?;
228
229    let si = SideInfo::compute(
230        raw_pixels_rgb,
231        pixel_width,
232        pixel_height,
233        img.dct_grid(0),
234        &qt.values,
235    );
236
237    // Pass pre-parsed image through to avoid re-parsing in ghost_encode_impl
238    ghost_encode_impl(image_bytes, message, files, passphrase, Some(si), Some(img))
239}
240
241/// Shadow layer descriptor for encoding.
242pub struct ShadowLayer {
243    /// Text message for this shadow layer.
244    pub message: String,
245    /// Passphrase for this shadow layer (must be unique across all layers).
246    pub passphrase: String,
247    /// Optional file attachments for this shadow layer.
248    pub files: Vec<FileEntry>,
249}
250
251/// Progress steps for Ghost encode with shadows.
252///
253/// 5 (parse) + 100 (UNIWARD) + 1 (shadow prepare) + 1 (permute)
254/// + 50 (STC) + 100 (verification UNIWARD) + 20 (JPEG write).
255/// If the escalation cascade triggers, the total is bumped dynamically
256/// via `progress::set_total()` to avoid the bar stalling at 100%.
257pub const GHOST_ENCODE_WITH_SHADOWS_STEPS: u32 =
258    PARSE_STEPS
259    + crate::stego::cost::uniward::UNIWARD_PROGRESS_STEPS
260    + 2  // shadow prep + permute
261    + crate::stego::stc::embed::STC_PROGRESS_STEPS
262    + crate::stego::cost::uniward::UNIWARD_PROGRESS_STEPS  // verification pass
263    + crate::codec::jpeg::scan::JPEG_WRITE_STEPS;
264
265/// Escalation cascade for shadow verification.
266///
267/// Tries fraction=1 (100% pool) first — eliminates boundary disagreement
268/// entirely. If fraction=1 fails, tighter fractions won't help at the same
269/// parity. Then tries tighter fractions with higher proven parity for stealth.
270const CASCADE: &[(usize, usize)] = &[
271    // Try 100% pool first — eliminates boundary disagreement entirely.
272    // If this fails, tighter fractions won't help.
273    (1, 4),
274    (1, 8),
275    (1, 16),
276    (1, 32),
277    (1, 64),
278    (1, 128),
279    // Then try tighter fractions with proven-working parity for better stealth.
280    (2, 16), (5, 16), (10, 16),
281    (2, 32), (5, 32),
282    (2, 64),
283];
284
285/// Encode with Ghost mode plus shadow messages for plausible deniability.
286///
287/// Shadow layers are embedded first using repetition coding. The primary
288/// message is then embedded via STC, treating shadow modifications as
289/// part of the cover. Each passphrase must be unique.
290pub fn ghost_encode_with_shadows(
291    image_bytes: &[u8],
292    message: &str,
293    files: &[FileEntry],
294    passphrase: &str,
295    shadows: &[ShadowLayer],
296    si: Option<SideInfo>,
297) -> Result<Vec<u8>, StegoError> {
298    ghost_encode_with_shadows_impl(image_bytes, message, files, passphrase, shadows, si, None)
299        .map(|(bytes, _)| bytes)
300}
301
302/// Encode with Ghost mode + shadows and return the encode quality score.
303pub fn ghost_encode_with_shadows_quality(
304    image_bytes: &[u8],
305    message: &str,
306    files: &[FileEntry],
307    passphrase: &str,
308    shadows: &[ShadowLayer],
309    si: Option<SideInfo>,
310) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
311    ghost_encode_with_shadows_impl(image_bytes, message, files, passphrase, shadows, si, None)
312}
313
314/// Encode with Ghost SI-UNIWARD plus shadow messages.
315pub fn ghost_encode_si_with_shadows(
316    image_bytes: &[u8],
317    raw_pixels_rgb: &[u8],
318    pixel_width: u32,
319    pixel_height: u32,
320    message: &str,
321    files: &[FileEntry],
322    passphrase: &str,
323    shadows: &[ShadowLayer],
324) -> Result<Vec<u8>, StegoError> {
325    ghost_encode_si_with_shadows_quality(
326        image_bytes, raw_pixels_rgb, pixel_width, pixel_height,
327        message, files, passphrase, shadows,
328    ).map(|(bytes, _)| bytes)
329}
330
331/// Encode with SI-UNIWARD + shadows and return the encode quality score.
332pub fn ghost_encode_si_with_shadows_quality(
333    image_bytes: &[u8],
334    raw_pixels_rgb: &[u8],
335    pixel_width: u32,
336    pixel_height: u32,
337    message: &str,
338    files: &[FileEntry],
339    passphrase: &str,
340    shadows: &[ShadowLayer],
341) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
342    let img = JpegImage::from_bytes(image_bytes)?;
343    let fi = img.frame_info();
344    crate::stego::validate_encode_dimensions(fi.width as u32, fi.height as u32)?;
345
346    if img.num_components() == 0 {
347        return Err(StegoError::NoLuminanceChannel);
348    }
349
350    let qt_id = fi.components[0].quant_table_id as usize;
351    let qt = img.quant_table(qt_id).ok_or(StegoError::NoLuminanceChannel)?;
352
353    let si = SideInfo::compute(
354        raw_pixels_rgb,
355        pixel_width,
356        pixel_height,
357        img.dct_grid(0),
358        &qt.values,
359    );
360
361    ghost_encode_with_shadows_impl(image_bytes, message, files, passphrase, shadows, Some(si), Some(img))
362}
363
364fn ghost_encode_with_shadows_impl(
365    image_bytes: &[u8],
366    message: &str,
367    files: &[FileEntry],
368    passphrase: &str,
369    shadows: &[ShadowLayer],
370    si: Option<SideInfo>,
371    pre_parsed: Option<JpegImage>,
372) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
373    // Initialize progress with worst-case cascade budget so the bar moves
374    // smoothly without dynamic set_total() jumps.
375    let cascade_budget = CASCADE.len() as u32 * (
376        crate::stego::stc::embed::STC_PROGRESS_STEPS
377        + crate::stego::cost::uniward::UNIWARD_PROGRESS_STEPS
378    );
379    progress::init(GHOST_ENCODE_WITH_SHADOWS_STEPS + cascade_budget);
380
381    // Validate passphrases are unique (primary + all shadows).
382    {
383        let mut all_passes: Vec<&str> = vec![passphrase];
384        for s in shadows {
385            all_passes.push(&s.passphrase);
386        }
387        for i in 0..all_passes.len() {
388            for j in (i + 1)..all_passes.len() {
389                if all_passes[i] == all_passes[j] {
390                    return Err(StegoError::DuplicatePassphrase);
391                }
392            }
393        }
394    }
395
396    // Auto-sort: the largest payload becomes primary (gets full STC stealth).
397    // Smaller payloads become shadows (direct LSB + RS, negligible detectability).
398    let primary_payload_size = payload::compressed_payload_size(message, files);
399    let mut swap_idx: Option<usize> = None;
400    for (i, s) in shadows.iter().enumerate() {
401        let shadow_size = payload::compressed_payload_size(&s.message, &s.files);
402        if shadow_size > primary_payload_size {
403            if let Some(prev) = swap_idx {
404                let prev_size = payload::compressed_payload_size(&shadows[prev].message, &shadows[prev].files);
405                if shadow_size > prev_size {
406                    swap_idx = Some(i);
407                }
408            } else {
409                swap_idx = Some(i);
410            }
411        }
412    }
413
414    // If a shadow is larger, swap it with primary for optimal stealth.
415    // We need `primary_as_shadow` to live long enough, so declare it before the branch.
416    let primary_as_shadow;
417    let (eff_message, eff_files, eff_passphrase, eff_shadows);
418    if let Some(idx) = swap_idx {
419        eff_message = shadows[idx].message.as_str();
420        eff_files = &shadows[idx].files[..];
421        eff_passphrase = shadows[idx].passphrase.as_str();
422        // Build new shadows list: original primary + all shadows except the swapped one.
423        primary_as_shadow = ShadowLayer {
424            message: message.to_string(),
425            passphrase: passphrase.to_string(),
426            files: files.to_vec(),
427        };
428        let mut new_shadows: Vec<&ShadowLayer> = Vec::with_capacity(shadows.len());
429        new_shadows.push(&primary_as_shadow);
430        for (i, s) in shadows.iter().enumerate() {
431            if i != idx {
432                new_shadows.push(s);
433            }
434        }
435        eff_shadows = new_shadows;
436    } else {
437        eff_message = message;
438        eff_files = files;
439        eff_passphrase = passphrase;
440        eff_shadows = shadows.iter().collect();
441    };
442
443    // Build the primary payload.
444    let payload_bytes = payload::encode_payload(eff_message, eff_files)?;
445
446    let mut img = match pre_parsed {
447        Some(img) => img,
448        None => JpegImage::from_bytes(image_bytes)?,
449    };
450    progress::advance_by(PARSE_STEPS);
451    let fi = img.frame_info();
452    crate::stego::validate_encode_dimensions(fi.width as u32, fi.height as u32)?;
453
454    if img.num_components() == 0 {
455        return Err(StegoError::NoLuminanceChannel);
456    }
457
458    // 1. Compute J-UNIWARD positions.
459    // Overlap Argon2id structural key derivation (~200ms) with UNIWARD (1-10s).
460    #[cfg(feature = "parallel")]
461    let key_thread = {
462        let pass = eff_passphrase.to_string();
463        std::thread::spawn(move || crypto::derive_structural_key(&pass))
464    };
465
466    let qt_id = img.frame_info().components[0].quant_table_id as usize;
467    let qt = img.quant_table(qt_id).ok_or(StegoError::NoLuminanceChannel)?;
468    let si_ref = si.as_ref().map(|s| (s, img.dct_grid(0)));
469    let mut positions = compute_positions_streaming(img.dct_grid(0), qt, si_ref)?;
470
471    // 2. Prepare shadow layers (Y-channel direct LSB + RS).
472    // Sort positions by cost in-place for cost-pool selection (avoids cloning
473    // the entire positions vector, saving ~800 MB on 200MP images).
474    positions.sort_by(|a, b| a.cost.total_cmp(&b.cost));
475
476    let mut shadow_states: Vec<shadow::ShadowState> = Vec::new();
477    if !eff_shadows.is_empty() {
478        let initial_parity = 4;
479        for s in eff_shadows.iter() {
480            let state = shadow::prepare_shadow(
481                &positions,
482                &s.passphrase,
483                &s.message,
484                &s.files,
485                initial_parity,
486            )?;
487            shadow_states.push(state);
488        }
489    }
490    progress::advance(); // shadow preparation step
491
492    // Restore raster order (sort by flat_idx) for STC permutation.
493    // The raster order matches compute_positions_streaming output.
494    positions.sort_by_key(|p| p.flat_idx);
495
496    // Save original Y grid for restoration before embedding.
497    let original_y = img.dct_grid(0).clone();
498
499    // 3. Primary STC setup.
500    #[cfg(feature = "parallel")]
501    let structural_key = key_thread.join().expect("key derivation thread")?;
502    #[cfg(not(feature = "parallel"))]
503    let structural_key = crypto::derive_structural_key(eff_passphrase)?;
504    let perm_seed: [u8; 32] = structural_key[..32].try_into().unwrap();
505    let hhat_seed: [u8; 32] = structural_key[32..].try_into().unwrap();
506
507    permute::permute_positions(&mut positions, &perm_seed);
508    let n = positions.len();
509
510    // Step: permutation complete.
511    progress::advance();
512
513    let (ciphertext, nonce, salt) = crypto::encrypt(&payload_bytes, eff_passphrase)?;
514    let frame_bytes = frame::build_frame(payload_bytes.len(), &salt, &nonce, &ciphertext);
515    let frame_bits = frame::bytes_to_bits(&frame_bytes);
516    let m = frame_bits.len();
517
518    // Dynamic w: pick highest that fits the actual primary message.
519    let w = (n / m).clamp(1, 10);
520    let m_max = n / w;
521    let n_used = m_max * w;
522
523    if m > m_max {
524        return Err(StegoError::MessageTooLarge);
525    }
526
527    // Upfront w check: w=1 means no STC slack — ∞-cost protection disabled,
528    // shadow BER ≈ 50%. Fail fast instead of a 10-minute cascade.
529    if w < 2 && !shadow_states.is_empty() {
530        return Err(StegoError::MessageTooLarge);
531    }
532
533    if n_used > STC_POSITION_LIMIT {
534        return Err(StegoError::ImageTooLarge);
535    }
536
537    positions.truncate(n_used);
538    let hhat_matrix = hhat::generate_hhat(STC_H, w, &hhat_seed);
539
540    // 4. Build ∞-cost mask for shadow positions when w >= 2.
541    // When w >= 2, Viterbi has enough slack to route around shadow positions,
542    // achieving BER ~ 0% on shadows without any RS correction needed.
543    let shadow_inf_costs = build_inf_cost_set(w, &shadow_states);
544
545    // Compute median cost before STC for quality scoring.
546    let median_cost = {
547        let mut finite_costs: Vec<f32> = positions.iter()
548            .map(|p| p.cost)
549            .filter(|c| c.is_finite())
550            .collect();
551        if finite_costs.is_empty() {
552            0.0f32
553        } else {
554            let mid = finite_costs.len() / 2;
555            finite_costs.select_nth_unstable_by(mid, f32::total_cmp);
556            finite_costs[mid]
557        }
558    };
559    let is_si = si.is_some();
560    let grid_ref = img.dct_grid(0);
561    let total_coefficients = grid_ref.blocks_wide() * grid_ref.blocks_tall() * 64;
562
563    // Count total shadow embedding positions for quality scoring.
564    // Use n_total (actual RS-encoded bit count) rather than positions.len()
565    // (the pool size), since only n_total positions are written by embed_shadow_lsb.
566    let shadow_modifications: usize = shadow_states.iter()
567        .map(|s| s.n_total)
568        .sum();
569
570    // 5. Embed shadows + primary STC (always short STC: frame_bits, not padded).
571    let mut stc_total_cost: f64 = 0.0;
572    let mut stc_num_modifications: usize = 0;
573
574    if shadow_states.is_empty() {
575        // No shadows → no verification UNIWARD pass needed.
576        let (tc, nm) = run_stc_pass(&mut img, &original_y, &positions, &[],
577                     &frame_bits, &hhat_matrix, w, &si, &None)?;
578        stc_total_cost = tc;
579        stc_num_modifications = nm;
580    } else {
581        let (tc, nm) = run_stc_pass(&mut img, &original_y, &positions, &shadow_states,
582                     &frame_bits, &hhat_matrix, w, &si, &shadow_inf_costs)?;
583        stc_total_cost = tc;
584        stc_num_modifications = nm;
585
586        // Skip verification when ALL shadows use fraction=1 (100% pool).
587        // With 100% pool there's no cost-pool boundary, so decoder always
588        // selects the same positions regardless of cover vs stego costs.
589        // This saves a full UNIWARD recomputation (2-10s on large images).
590        let all_fraction_1 = shadow_states.iter().all(|s| s.cost_fraction == 1);
591
592        // Verify shadows from the DECODER's perspective: compute UNIWARD costs
593        // on the stego image (as the decoder will see it), select positions
594        // using stego costs + same fraction/hash, and check RS decode + decrypt.
595        // This catches cost-pool boundary disagreements between cover and stego.
596        if !all_fraction_1 {
597            let qt_verify = img.quant_table(qt_id).ok_or(StegoError::NoLuminanceChannel)?;
598            let mut stego_y_positions = compute_positions_streaming(img.dct_grid(0), qt_verify, None)?;
599            stego_y_positions.sort_by(|a, b| a.cost.total_cmp(&b.cost));
600
601            if !verify_all_shadows_decoder_side(&img, &shadow_states, &eff_shadows, &stego_y_positions) {
602                // Escalate: try progressively larger fractions (more positions,
603                // lower BER) and higher parities (more error correction).
604                // CASCADE is ordered: fraction=1 first (eliminates boundary
605                // disagreement), then tighter fractions with higher parity.
606
607                // Build cost-sorted positions for cascade (only allocated when
608                // verification fails — saves ~800 MB in the happy path on large images).
609                let mut cascade_positions = positions.clone();
610                cascade_positions.sort_by(|a, b| a.cost.total_cmp(&b.cost));
611
612                let mut verified = false;
613
614                #[cfg(feature = "parallel")]
615                {
616                    use std::sync::atomic::{AtomicUsize, Ordering};
617                    use rayon::prelude::*;
618
619                    // Group CASCADE by parity, try all fractions for each parity in parallel.
620                    // Within each parity tier, find the BEST (largest) fraction that verifies.
621                    // Larger fraction = fewer positions = tighter pool = more stealth.
622                    let best_fraction = AtomicUsize::new(0);
623
624                    for &parity in &[4, 8, 16, 32, 64, 128] {
625                        let fractions_for_parity: Vec<usize> = CASCADE.iter()
626                            .filter(|&&(_, p)| p == parity)
627                            .map(|&(f, _)| f)
628                            .collect();
629                        if fractions_for_parity.is_empty() { continue; }
630
631                        let has_fraction_1 = fractions_for_parity.contains(&1);
632
633                        let successes: Vec<(usize, crate::codec::jpeg::JpegImage, Vec<shadow::ShadowState>, f64, usize)> =
634                            fractions_for_parity.par_iter().filter_map(|&fraction| {
635                            // Checkpoint: skip if a better (larger) fraction already verified.
636                            if best_fraction.load(Ordering::Relaxed) > fraction { return None; }
637
638                            // Rebuild shadows with this (fraction, parity).
639                            let mut local_states = shadow_states.clone();
640                            let local_cascade_positions = cascade_positions.clone();
641                            for state in local_states.iter_mut() {
642                                if shadow::rebuild_shadow(state, &local_cascade_positions, parity, fraction).is_err() {
643                                    return None;
644                                }
645                            }
646
647                            // Checkpoint before STC (expensive).
648                            if best_fraction.load(Ordering::Relaxed) > fraction { return None; }
649
650                            let mut local_img = img.clone();
651                            let new_inf_costs = build_inf_cost_set(w, &local_states);
652                            let (local_tc, local_nm) = match run_stc_pass(&mut local_img, &original_y, &positions, &local_states,
653                                         &frame_bits, &hhat_matrix, w, &si, &new_inf_costs) {
654                                Ok(v) => v,
655                                Err(_) => return None,
656                            };
657
658                            // Checkpoint before UNIWARD recomputation (expensive).
659                            if best_fraction.load(Ordering::Relaxed) > fraction { return None; }
660
661                            let qt_re = match local_img.quant_table(qt_id) {
662                                Some(qt) => qt,
663                                None => return None,
664                            };
665                            let mut local_stego_positions = match compute_positions_streaming(local_img.dct_grid(0), qt_re, None) {
666                                Ok(p) => p,
667                                Err(_) => return None,
668                            };
669                            local_stego_positions.sort_by(|a, b| a.cost.total_cmp(&b.cost));
670
671                            if verify_all_shadows_decoder_side(&local_img, &local_states, &eff_shadows, &local_stego_positions) {
672                                // Publish: this fraction verified successfully.
673                                best_fraction.fetch_max(fraction, Ordering::Relaxed);
674                                Some((fraction, local_img, local_states, local_tc, local_nm))
675                            } else {
676                                None
677                            }
678                        }).collect();
679
680                        if !successes.is_empty() {
681                            // Pick the best (largest fraction = tightest pool = most stealth).
682                            let best = successes.into_iter().max_by_key(|(f, _, _, _, _)| *f).unwrap();
683                            img = best.1;
684                            shadow_states = best.2;
685                            stc_total_cost = best.3;
686                            stc_num_modifications = best.4;
687                            verified = true;
688                            break;
689                        }
690
691                        // fraction=1 early bailout: if 100% pool fails at this parity,
692                        // tighter fractions at higher parity won't fix boundary issues.
693                        if has_fraction_1 && parity == 128 {
694                            break;
695                        }
696                    }
697                }
698
699                #[cfg(not(feature = "parallel"))]
700                {
701                    let mut last_fraction_1_failed_parity: Option<usize> = None;
702                    for &(frac, par) in CASCADE {
703                        // Early termination: if fraction=1 failed at a parity,
704                        // tighter fractions at that same parity will also fail.
705                        if frac > 1 {
706                            if let Some(failed_par) = last_fraction_1_failed_parity {
707                                if par <= failed_par {
708                                    continue;
709                                }
710                            }
711                        }
712
713                        for state in shadow_states.iter_mut() {
714                            shadow::rebuild_shadow(state, &cascade_positions, par, frac)?;
715                        }
716                        let new_inf_costs = build_inf_cost_set(w, &shadow_states);
717                        let (tc, nm) = run_stc_pass(&mut img, &original_y, &positions, &shadow_states,
718                                     &frame_bits, &hhat_matrix, w, &si, &new_inf_costs)?;
719                        stc_total_cost = tc;
720                        stc_num_modifications = nm;
721
722                        // Recompute stego costs after re-encoding.
723                        let qt_re = img.quant_table(qt_id).ok_or(StegoError::NoLuminanceChannel)?;
724                        stego_y_positions = compute_positions_streaming(img.dct_grid(0), qt_re, None)?;
725                        stego_y_positions.sort_by(|a, b| a.cost.total_cmp(&b.cost));
726
727                        if verify_all_shadows_decoder_side(&img, &shadow_states, &eff_shadows, &stego_y_positions) {
728                            verified = true;
729                            break;
730                        }
731
732                        // Track fraction=1 failures for early termination.
733                        if frac == 1 {
734                            last_fraction_1_failed_parity = Some(par);
735                        }
736
737                        // fraction=1 at max parity failed — nothing will work
738                        if frac == 1 && par == 128 {
739                            break;
740                        }
741                    }
742                }
743
744                if !verified {
745                    return Err(StegoError::MessageTooLarge);
746                }
747            }
748        } // if !all_fraction_1
749    }
750
751    // Compute quality score with shadow penalty.
752    let encode_quality = quality::ghost_stealth_score(&GhostMetrics {
753        num_modifications: stc_num_modifications,
754        n_used,
755        w,
756        total_cost: stc_total_cost,
757        median_cost,
758        is_si,
759        shadow_modifications,
760        total_coefficients,
761    });
762
763    // Free large allocations before JPEG output to reduce peak memory.
764    drop(positions);
765    drop(original_y);
766    drop(shadow_states);
767    drop(shadow_inf_costs);
768    drop(si);
769
770    // Write JPEG (with progress reporting during scan encoding).
771    let progress_cb = || progress::advance();
772    let stego_bytes = if let Ok(bytes) = img.to_bytes_with_progress(Some(&progress_cb)) { bytes } else {
773        img.rebuild_huffman_tables();
774        img.to_bytes_with_progress(Some(&progress_cb)).map_err(StegoError::InvalidJpeg)?
775    };
776
777    Ok((stego_bytes, encode_quality))
778}
779
780/// Run a single STC pass: restore Y grid, embed shadows, then run STC with optional ∞-cost.
781///
782/// Returns `(total_cost, num_modifications)` from STC embedding.
783fn run_stc_pass(
784    img: &mut JpegImage,
785    original_y: &crate::codec::jpeg::dct::DctGrid,
786    positions: &[permute::CoeffPos],
787    shadow_states: &[shadow::ShadowState],
788    message_bits: &[u8],
789    hhat_matrix: &[Vec<u32>],
790    w: usize,
791    si: &Option<SideInfo>,
792    shadow_inf_costs: &Option<std::collections::HashSet<u32>>,
793) -> Result<(f64, usize), StegoError> {
794    *img.dct_grid_mut(0) = original_y.clone();
795
796    for state in shadow_states {
797        shadow::embed_shadow_lsb(img, state);
798    }
799
800    let grid = img.dct_grid(0);
801    let cover_bits: Vec<u8> = positions.iter().map(|p| {
802        let coeff = flat_get(grid, p.flat_idx as usize);
803        (coeff.unsigned_abs() & 1) as u8
804    }).collect();
805
806    // Build cost vector with ∞ for shadow positions when w >= 2.
807    let costs: Vec<f32> = if let Some(inf_set) = shadow_inf_costs {
808        positions.iter().map(|p| {
809            if inf_set.contains(&p.flat_idx) {
810                f32::INFINITY
811            } else {
812                p.cost
813            }
814        }).collect()
815    } else {
816        positions.iter().map(|p| p.cost).collect()
817    };
818
819    let result = embed::stc_embed(&cover_bits, &costs, message_bits, hhat_matrix, STC_H, w);
820    progress::check_cancelled()?;
821    let result = result.ok_or(StegoError::MessageTooLarge)?;
822
823    let total_cost = result.total_cost;
824    let num_modifications = result.num_modifications;
825
826    apply_stc_changes(img, positions, &cover_bits, &result.stego_bits, si);
827
828    Ok((total_cost, num_modifications))
829}
830
831/// Apply STC LSB changes to the DctGrid.
832fn apply_stc_changes(
833    img: &mut JpegImage,
834    positions: &[permute::CoeffPos],
835    cover_bits: &[u8],
836    stego_bits: &[u8],
837    si: &Option<SideInfo>,
838) {
839    let grid_mut = img.dct_grid_mut(0);
840    for (idx, pos) in positions.iter().enumerate() {
841        if cover_bits[idx] != stego_bits[idx] {
842            let fi = pos.flat_idx as usize;
843            let coeff = flat_get(grid_mut, fi);
844            let modified = if let Some(side_info) = si {
845                side_info::si_modify_coefficient(coeff, side_info.error_at(fi))
846            } else {
847                side_info::nsf5_modify_coefficient(coeff)
848            };
849            flat_set(grid_mut, fi, modified);
850        }
851    }
852}
853
854/// Check if all shadow layers can be decoded from the decoder's perspective.
855///
856/// Uses stego-image UNIWARD costs to select positions (simulating what the
857/// decoder will do), then checks RS decode + AES-GCM decrypt.
858fn verify_all_shadows_decoder_side(
859    img: &JpegImage,
860    shadow_states: &[shadow::ShadowState],
861    shadows: &[&ShadowLayer],
862    stego_y_positions_sorted: &[permute::CoeffPos],
863) -> bool {
864    for (i, state) in shadow_states.iter().enumerate() {
865        if shadow::verify_shadow_decoder_side(
866            img, state, &shadows[i].passphrase, stego_y_positions_sorted,
867        ).is_err() {
868            return false;
869        }
870    }
871    true
872}
873
874/// Build the ∞-cost HashSet for shadow positions (used when w >= 2).
875fn build_inf_cost_set(w: usize, shadow_states: &[shadow::ShadowState]) -> Option<std::collections::HashSet<u32>> {
876    if w >= 2 && !shadow_states.is_empty() {
877        let mut set = std::collections::HashSet::new();
878        for state in shadow_states {
879            for pos in &state.positions {
880                set.insert(pos.flat_idx);
881            }
882        }
883        Some(set)
884    } else {
885        None
886    }
887}
888
889fn ghost_encode_impl(
890    image_bytes: &[u8],
891    message: &str,
892    files: &[FileEntry],
893    passphrase: &str,
894    si: Option<SideInfo>,
895    pre_parsed: Option<JpegImage>,
896) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
897    // Initialize encode progress (100 UNIWARD + 50 STC + 2 misc).
898    progress::init(GHOST_ENCODE_STEPS);
899
900    // Build the payload (text + files + compression).
901    let payload_bytes = payload::encode_payload(message, files)?;
902
903    let mut img = match pre_parsed {
904        Some(img) => img,
905        None => JpegImage::from_bytes(image_bytes)?,
906    };
907    progress::advance_by(PARSE_STEPS);
908
909    // Validate dimensions before any heavy processing.
910    let fi = img.frame_info();
911    crate::stego::validate_encode_dimensions(fi.width as u32, fi.height as u32)?;
912
913    if img.num_components() == 0 {
914        return Err(StegoError::NoLuminanceChannel);
915    }
916
917    // 1. Compute J-UNIWARD costs strip-by-strip and collect positions directly.
918    // Overlap Argon2id structural key derivation (~200ms) with UNIWARD (1-10s).
919    #[cfg(feature = "parallel")]
920    let key_thread = {
921        let pass = passphrase.to_string();
922        std::thread::spawn(move || crypto::derive_structural_key(&pass))
923    };
924
925    let qt_id = img.frame_info().components[0].quant_table_id as usize;
926    let qt = img.quant_table(qt_id).ok_or(StegoError::NoLuminanceChannel)?;
927    let si_ref = si.as_ref().map(|s| (s, img.dct_grid(0)));
928    let mut positions = compute_positions_streaming(img.dct_grid(0), qt, si_ref)?;
929
930    // 2. Derive structural key (Tier 1).
931    #[cfg(feature = "parallel")]
932    let structural_key = key_thread.join().expect("key derivation thread")?;
933    #[cfg(not(feature = "parallel"))]
934    let structural_key = crypto::derive_structural_key(passphrase)?;
935    let perm_seed: [u8; 32] = structural_key[..32].try_into().unwrap();
936    let hhat_seed: [u8; 32] = structural_key[32..].try_into().unwrap();
937
938    // 3. Permute positions.
939    permute::permute_positions(&mut positions, &perm_seed);
940    let n = positions.len();
941
942    // Step: permutation + key derivation complete.
943    progress::advance();
944
945    // 4. Encrypt payload (Tier 2 key with random salt).
946    let (ciphertext, nonce, salt) = crypto::encrypt(&payload_bytes, passphrase)?;
947
948    // 5. Build payload frame.
949    let frame_bytes = frame::build_frame(payload_bytes.len(), &salt, &nonce, &ciphertext);
950    let frame_bits = frame::bytes_to_bits(&frame_bytes);
951    let m = frame_bits.len();
952
953    // 6. Dynamic w: pick highest that fits the actual message (short STC).
954    let w = (n / m).clamp(1, 10);
955    let m_max = n / w;
956    let n_used = m_max * w;
957
958    if m > m_max {
959        return Err(StegoError::MessageTooLarge);
960    }
961
962    // Memory budget check (same as compute_stc_params).
963    if n_used > STC_POSITION_LIMIT {
964        return Err(StegoError::ImageTooLarge);
965    }
966
967    positions.truncate(n_used);
968
969    // 7. Extract cover LSBs and costs in permuted order.
970    let grid = img.dct_grid(0);
971    let cover_bits: Vec<u8> = positions.iter().map(|p| {
972        let coeff = flat_get(grid, p.flat_idx as usize);
973        (coeff.unsigned_abs() & 1) as u8
974    }).collect();
975    let costs: Vec<f32> = positions.iter().map(|p| p.cost).collect();
976
977    // 8. Compute median cost (for quality score) before STC.
978    let median_cost = {
979        let mut finite_costs: Vec<f32> = costs.iter().copied().filter(|c| c.is_finite()).collect();
980        if finite_costs.is_empty() {
981            0.0f32
982        } else {
983            let mid = finite_costs.len() / 2;
984            finite_costs.select_nth_unstable_by(mid, f32::total_cmp);
985            finite_costs[mid]
986        }
987    };
988    let is_si = si.is_some();
989
990    // Total Y-channel coefficients for quality scoring.
991    let grid_ref = img.dct_grid(0);
992    let total_coefficients = grid_ref.blocks_wide() * grid_ref.blocks_tall() * 64;
993
994    // 9. Generate H-hat and embed with short STC (actual m bits, not padded).
995    let hhat_matrix = hhat::generate_hhat(STC_H, w, &hhat_seed);
996    let result = embed::stc_embed(&cover_bits, &costs, &frame_bits, &hhat_matrix, STC_H, w);
997    progress::check_cancelled()?;
998    let result = result.ok_or(StegoError::MessageTooLarge)?;
999
1000    // 10. Compute encode quality score.
1001    let encode_quality = quality::ghost_stealth_score(&GhostMetrics {
1002        num_modifications: result.num_modifications,
1003        n_used,
1004        w,
1005        total_cost: result.total_cost,
1006        median_cost,
1007        is_si,
1008        shadow_modifications: 0,
1009        total_coefficients,
1010    });
1011
1012    // 11. Apply LSB changes to DctGrid.
1013    let grid_mut = img.dct_grid_mut(0);
1014    for (idx, pos) in positions.iter().enumerate() {
1015        let old_bit = cover_bits[idx];
1016        let new_bit = result.stego_bits[idx];
1017        if old_bit != new_bit {
1018            let fi = pos.flat_idx as usize;
1019            let coeff = flat_get(grid_mut, fi);
1020            let modified = if let Some(ref side_info) = si {
1021                side_info::si_modify_coefficient(coeff, side_info.error_at(fi))
1022            } else {
1023                side_info::nsf5_modify_coefficient(coeff)
1024            };
1025            flat_set(grid_mut, fi, modified);
1026        }
1027    }
1028
1029    // Free large allocations before JPEG output to reduce peak memory.
1030    drop(positions);
1031    drop(cover_bits);
1032    drop(costs);
1033    drop(result);
1034    drop(si);
1035
1036    // 12. Write modified JPEG (with progress reporting during scan encoding).
1037    let progress_cb = || progress::advance();
1038    let stego_bytes = if let Ok(bytes) = img.to_bytes_with_progress(Some(&progress_cb)) { bytes } else {
1039        img.rebuild_huffman_tables();
1040        img.to_bytes_with_progress(Some(&progress_cb)).map_err(StegoError::InvalidJpeg)?
1041    };
1042
1043    Ok((stego_bytes, encode_quality))
1044}
1045
1046/// Decode a payload from a stego JPEG using Ghost mode.
1047///
1048/// Returns the decoded text and any embedded files.
1049///
1050/// # Errors
1051/// - [`StegoError::DecryptionFailed`] if the passphrase is wrong.
1052/// - [`StegoError::FrameCorrupted`] if the CRC check fails.
1053/// - [`StegoError::InvalidUtf8`] if the decrypted payload is not valid UTF-8.
1054pub fn ghost_decode(
1055    stego_bytes: &[u8],
1056    passphrase: &str,
1057) -> Result<PayloadData, StegoError> {
1058    let img = JpegImage::from_bytes(stego_bytes)?;
1059    progress::advance_by(PARSE_STEPS);
1060
1061    if img.num_components() == 0 {
1062        return Err(StegoError::NoLuminanceChannel);
1063    }
1064
1065    // 1. Compute J-UNIWARD costs strip-by-strip and collect positions directly.
1066    // Overlap Argon2id structural key derivation (~200ms) with UNIWARD (1-10s).
1067    #[cfg(feature = "parallel")]
1068    let key_thread = {
1069        let pass = passphrase.to_string();
1070        std::thread::spawn(move || crypto::derive_structural_key(&pass))
1071    };
1072
1073    let qt_id = img.frame_info().components[0].quant_table_id as usize;
1074    let qt = img.quant_table(qt_id).ok_or(StegoError::NoLuminanceChannel)?;
1075    let mut positions = compute_positions_streaming(img.dct_grid(0), qt, None)?;
1076
1077    progress::check_cancelled()?;
1078
1079    // 2. Derive structural key.
1080    #[cfg(feature = "parallel")]
1081    let structural_key = key_thread.join().expect("key derivation thread")?;
1082    #[cfg(not(feature = "parallel"))]
1083    let structural_key = crypto::derive_structural_key(passphrase)?;
1084    let perm_seed: [u8; 32] = structural_key[..32].try_into().unwrap();
1085    let hhat_seed: [u8; 32] = structural_key[32..].try_into().unwrap();
1086
1087    // 3. Permute positions.
1088    permute::permute_positions(&mut positions, &perm_seed);
1089    let n = positions.len();
1090
1091    // Step: permutation complete.
1092    progress::advance();
1093
1094    // 4. Extract all stego LSBs (full n, reused across w candidates).
1095    let all_stego_bits: Vec<u8> = {
1096        let grid = img.dct_grid(0);
1097        positions.iter().map(|p| {
1098            let coeff = flat_get(grid, p.flat_idx as usize);
1099            (coeff.unsigned_abs() & 1) as u8
1100        }).collect()
1101    };
1102    // Free positions and parsed image — no longer needed after bit extraction.
1103    drop(positions);
1104    drop(img);
1105
1106    // 5. Brute-force w: try the natural w first (backward compat), then dynamic candidates.
1107    let w_natural = compute_stc_params(n).map(|(w, _, _)| w).unwrap_or(1);
1108    let w_candidates_raw: &[usize] = &[w_natural, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
1109
1110    // Deduplicate and filter w candidates up front.
1111    let mut deduped_w: Vec<usize> = Vec::with_capacity(w_candidates_raw.len());
1112    {
1113        let mut tried_w = 0u16;
1114        for &w in w_candidates_raw {
1115            if w == 0 || n / w == 0 {
1116                continue;
1117            }
1118            if w <= 15 && (tried_w & (1 << w)) != 0 {
1119                continue;
1120            }
1121            if w <= 15 {
1122                tried_w |= 1 << w;
1123            }
1124            let n_used = (n / w) * w;
1125            if n_used > all_stego_bits.len() {
1126                continue;
1127            }
1128            deduped_w.push(w);
1129        }
1130    }
1131
1132    #[cfg(feature = "parallel")]
1133    {
1134        use rayon::prelude::*;
1135        let result = deduped_w.par_iter().find_map_first(|&w| {
1136            let m_max = n / w;
1137            let n_used = m_max * w;
1138
1139            let stego_bits = &all_stego_bits[..n_used];
1140            let hhat_matrix = hhat::generate_hhat(STC_H, w, &hhat_seed);
1141            let extracted_bits = extract::stc_extract(stego_bits, &hhat_matrix, w);
1142
1143            let frame_bytes = frame::bits_to_bytes(&extracted_bits[..m_max]);
1144            match try_parse_and_decrypt(&frame_bytes, passphrase) {
1145                Ok(payload) => Some(Ok(payload)),
1146                Err(StegoError::DecryptionFailed) => Some(Err(StegoError::DecryptionFailed)),
1147                Err(_) => None,
1148            }
1149        });
1150        match result {
1151            Some(Ok(payload)) => {
1152                progress::advance();
1153                return Ok(payload);
1154            }
1155            Some(Err(e)) => {
1156                progress::advance();
1157                return Err(e);
1158            }
1159            None => {
1160                // Fall through to error below.
1161            }
1162        }
1163    }
1164    #[cfg(not(feature = "parallel"))]
1165    {
1166        let mut saw_decrypt_fail = false;
1167        for &w in &deduped_w {
1168            let m_max = n / w;
1169            let n_used = m_max * w;
1170
1171            let stego_bits = &all_stego_bits[..n_used];
1172            let hhat_matrix = hhat::generate_hhat(STC_H, w, &hhat_seed);
1173            let extracted_bits = extract::stc_extract(stego_bits, &hhat_matrix, w);
1174
1175            let frame_bytes = frame::bits_to_bytes(&extracted_bits[..m_max]);
1176            match try_parse_and_decrypt(&frame_bytes, passphrase) {
1177                Ok(payload) => {
1178                    progress::advance();
1179                    return Ok(payload);
1180                }
1181                Err(StegoError::DecryptionFailed) => {
1182                    saw_decrypt_fail = true;
1183                }
1184                Err(_) => {}
1185            }
1186        }
1187
1188        // Step: STC extraction + decryption complete (all attempts failed).
1189        progress::advance();
1190
1191        if saw_decrypt_fail {
1192            return Err(StegoError::DecryptionFailed);
1193        }
1194    }
1195
1196    // Step: STC extraction + decryption complete (all attempts failed).
1197    #[cfg(feature = "parallel")]
1198    progress::advance();
1199
1200    Err(StegoError::FrameCorrupted)
1201}
1202
1203/// Helper: parse frame, decrypt, and decode payload. Used by brute-force w loop.
1204fn try_parse_and_decrypt(
1205    frame_bytes: &[u8],
1206    passphrase: &str,
1207) -> Result<PayloadData, StegoError> {
1208    let parsed = frame::parse_frame(frame_bytes)?;
1209    let plaintext = crypto::decrypt(
1210        &parsed.ciphertext,
1211        passphrase,
1212        &parsed.salt,
1213        &parsed.nonce,
1214    )?;
1215    let len = parsed.plaintext_len as usize;
1216    if len > plaintext.len() {
1217        return Err(StegoError::FrameCorrupted);
1218    }
1219    payload::decode_payload(&plaintext[..len])
1220}
1221
1222/// Decode a shadow message from a stego JPEG.
1223///
1224/// Shadow uses cost-pool position selection (UNIWARD costs for stealth)
1225/// plus hash permutation and RS error correction.
1226pub fn ghost_shadow_decode(
1227    stego_bytes: &[u8],
1228    passphrase: &str,
1229) -> Result<PayloadData, StegoError> {
1230    let img = JpegImage::from_bytes(stego_bytes)?;
1231    ghost_shadow_decode_from_image(&img, passphrase)
1232}
1233
1234/// Decode a shadow message from an already-parsed JPEG image.
1235///
1236/// Computes Y-channel UNIWARD costs, sorts by cost (cheapest first), and
1237/// passes to the shadow extractor which brute-forces (fraction, parity, fdl).
1238pub fn ghost_shadow_decode_from_image(
1239    img: &JpegImage,
1240    passphrase: &str,
1241) -> Result<PayloadData, StegoError> {
1242    if img.num_components() == 0 {
1243        return Err(StegoError::NoLuminanceChannel);
1244    }
1245
1246    // Compute Y-channel UNIWARD costs for cost-pool selection.
1247    let qt_id = img.frame_info().components[0].quant_table_id as usize;
1248    let qt = img.quant_table(qt_id).ok_or(StegoError::NoLuminanceChannel)?;
1249    let mut positions = compute_positions_streaming(img.dct_grid(0), qt, None)?;
1250
1251    // Sort by cost (cheapest first) for cost-pool tier selection.
1252    positions.sort_by(|a, b| a.cost.total_cmp(&b.cost));
1253
1254    shadow::shadow_extract(img, &positions, passphrase)
1255}
1256
1257// --- DctGrid flat access helpers ---
1258
1259use crate::codec::jpeg::dct::DctGrid;
1260
1261/// Read a coefficient from a `DctGrid` using a flat index.
1262///
1263/// The flat index encodes `block_index * 64 + row * 8 + col`, where
1264/// `block_index = block_row * blocks_wide + block_col`.
1265pub(super) fn flat_get(grid: &DctGrid, flat_idx: usize) -> i16 {
1266    let bw = grid.blocks_wide();
1267    let block_idx = flat_idx / 64;
1268    let pos = flat_idx % 64;
1269    let br = block_idx / bw;
1270    let bc = block_idx % bw;
1271    let i = pos / 8;
1272    let j = pos % 8;
1273    grid.get(br, bc, i, j)
1274}
1275
1276/// Write a coefficient into a `DctGrid` using a flat index.
1277pub(super) fn flat_set(grid: &mut DctGrid, flat_idx: usize, val: i16) {
1278    let bw = grid.blocks_wide();
1279    let block_idx = flat_idx / 64;
1280    let pos = flat_idx % 64;
1281    let br = block_idx / bw;
1282    let bc = block_idx % bw;
1283    let i = pos / 8;
1284    let j = pos % 8;
1285    grid.set(br, bc, i, j, val);
1286}