Skip to main content

shadowforge_lib/domain/deadrop/
mod.rs

1//! Dead drop mode: platform-aware cover generation for public posting.
2//!
3//! Produces stego covers optimised for specific platforms. The sender posts
4//! publicly and the recipient retrieves via URL — no direct file transfer.
5
6use sha2::{Digest, Sha256};
7
8use crate::domain::types::{PlatformProfile, RetrievalManifest, StegoTechnique};
9
10/// Build a [`RetrievalManifest`] for a dead-drop cover.
11///
12/// The `stego_bytes` are hashed to produce the integrity digest.
13pub fn build_retrieval_manifest(
14    platform: &PlatformProfile,
15    retrieval_url: String,
16    technique: StegoTechnique,
17    stego_bytes: &[u8],
18) -> RetrievalManifest {
19    let hash = Sha256::digest(stego_bytes);
20    let stego_hash = hex::encode(hash);
21
22    RetrievalManifest {
23        platform: platform.clone(),
24        retrieval_url,
25        technique,
26        stego_hash,
27    }
28}
29
30/// Returns `true` if the platform passes images losslessly (no recompression).
31#[must_use]
32pub const fn platform_is_lossless(platform: &PlatformProfile) -> bool {
33    matches!(platform, PlatformProfile::Telegram)
34}
35
36#[cfg(test)]
37mod tests {
38    use super::*;
39
40    type TestResult = Result<(), Box<dyn std::error::Error>>;
41
42    #[test]
43    fn retrieval_manifest_serialises_to_json() -> TestResult {
44        let manifest = build_retrieval_manifest(
45            &PlatformProfile::Instagram,
46            "https://instagram.com/p/abc123".to_owned(),
47            StegoTechnique::LsbImage,
48            b"fake stego bytes",
49        );
50
51        let json = serde_json::to_string_pretty(&manifest)?;
52        assert!(json.contains("Instagram"));
53        assert!(json.contains("abc123"));
54        assert!(!manifest.stego_hash.is_empty());
55        Ok(())
56    }
57
58    #[test]
59    fn retrieval_manifest_hash_changes_with_content() {
60        let m1 = build_retrieval_manifest(
61            &PlatformProfile::Twitter,
62            "https://x.com/post/1".to_owned(),
63            StegoTechnique::LsbImage,
64            b"content A",
65        );
66        let m2 = build_retrieval_manifest(
67            &PlatformProfile::Twitter,
68            "https://x.com/post/1".to_owned(),
69            StegoTechnique::LsbImage,
70            b"content B",
71        );
72
73        assert_ne!(m1.stego_hash, m2.stego_hash);
74    }
75
76    #[test]
77    fn telegram_is_lossless() {
78        assert!(platform_is_lossless(&PlatformProfile::Telegram));
79    }
80
81    #[test]
82    fn instagram_is_not_lossless() {
83        assert!(!platform_is_lossless(&PlatformProfile::Instagram));
84    }
85
86    #[test]
87    fn retrieval_manifest_deserialises_roundtrip() -> TestResult {
88        let manifest = build_retrieval_manifest(
89            &PlatformProfile::Custom {
90                quality: 85,
91                subsampling: crate::domain::types::ChromaSubsampling::Yuv420,
92            },
93            "https://example.com/image.png".to_owned(),
94            StegoTechnique::LsbImage,
95            b"test data",
96        );
97
98        let json = serde_json::to_string(&manifest)?;
99        let recovered: RetrievalManifest = serde_json::from_str(&json)?;
100
101        assert_eq!(recovered.stego_hash, manifest.stego_hash);
102        assert_eq!(recovered.retrieval_url, manifest.retrieval_url);
103        Ok(())
104    }
105}