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