Skip to main content

steganography_online_codec/
lib.rs

1//! Steganography Online Codec Web API client (Rust).
2//!
3//! See the product page: <https://www.pelock.com/products/steganography-online-codec>
4
5use std::path::Path;
6
7use base64::Engine;
8use reqwest::multipart::{Form, Part};
9use serde::Deserialize;
10
11/// Error codes returned by the Steganography Online Codec Web API.
12pub mod errors {
13    /// Cannot connect to the Web API interface (network error).
14    pub const WEBAPI_CONNECTION: i32 = -1;
15    /// Success.
16    pub const SUCCESS: i32 = 0;
17    /// Unknown error.
18    pub const UNKNOWN: i32 = 1;
19    /// Message is too long for the selected image file.
20    pub const MESSAGE_TOO_LONG: i32 = 2;
21    /// Image file is too big.
22    pub const IMAGE_TOO_BIG: i32 = 3;
23    /// Invalid input file or file does not exist.
24    pub const INVALID_INPUT: i32 = 4;
25    /// Image file format is not supported.
26    pub const INVALID_IMAGE_FORMAT: i32 = 5;
27    /// Image file is malformed.
28    pub const IMAGE_MALFORMED: i32 = 6;
29    /// Invalid password.
30    pub const INVALID_PASSWORD: i32 = 7;
31    /// Provided message is too long for the current license tier.
32    pub const LIMIT_MESSAGE: i32 = 9;
33    /// Provided password is too long for the current license tier.
34    pub const LIMIT_PASSWORD: i32 = 10;
35    /// Error while writing output file.
36    pub const OUTPUT_FILE: i32 = 99;
37    /// License key is invalid or expired.
38    pub const INVALID_LICENSE: i32 = 100;
39}
40
41/// License information returned by the API (`license` object).
42#[derive(Debug, Clone, Deserialize)]
43pub struct LicenseInfo {
44    #[serde(rename = "activationStatus")]
45    pub activation_status: Option<bool>,
46    #[serde(rename = "userName")]
47    pub user_name: Option<String>,
48    #[serde(rename = "type")]
49    pub license_type: Option<i64>,
50    #[serde(rename = "usagesTotal")]
51    pub usages_total: Option<i64>,
52    #[serde(rename = "usagesCount")]
53    pub usages_count: Option<i64>,
54}
55
56/// Current limits returned by the API (`limits` object).
57#[derive(Debug, Clone, Deserialize)]
58pub struct LimitsInfo {
59    #[serde(rename = "maxPasswordLen")]
60    pub max_password_len: Option<i64>,
61    #[serde(rename = "maxMessageLen")]
62    pub max_message_len: Option<i64>,
63    #[serde(rename = "maxFileSize")]
64    pub max_file_size: Option<i64>,
65}
66
67/// Successful API response fields used by the SDK (after `error == SUCCESS`).
68#[derive(Debug, Clone)]
69pub struct CodecResult {
70    pub license: Option<LicenseInfo>,
71    pub limits: Option<LimitsInfo>,
72    /// Decoded secret message (`decode` only).
73    pub message: Option<String>,
74}
75
76#[derive(Debug, thiserror::Error)]
77pub enum SteganographyError {
78    #[error("{error_message}")]
79    Api {
80        code: i32,
81        error_message: String,
82        raw: Option<serde_json::Value>,
83    },
84}
85
86impl SteganographyError {
87    pub fn code(&self) -> i32 {
88        match self {
89            SteganographyError::Api { code, .. } => *code,
90        }
91    }
92
93    pub fn error_message(&self) -> &str {
94        match self {
95            SteganographyError::Api { error_message, .. } => error_message,
96        }
97    }
98
99    pub fn raw(&self) -> Option<&serde_json::Value> {
100        match self {
101            SteganographyError::Api { raw, .. } => raw.as_ref(),
102        }
103    }
104}
105
106/// Default Steganography Online Codec Web API endpoint.
107pub const DEFAULT_API_URL: &str = "https://www.pelock.com/api/steganography-online-codec/v1";
108
109/// Client for the Steganography Online Codec Web API.
110pub struct SteganographyOnlineCodec {
111    api_key: Option<String>,
112    api_url: String,
113    client: reqwest::Client,
114}
115
116impl SteganographyOnlineCodec {
117    /// Create a new client. Pass `None` or empty string for demo mode (no activation key).
118    pub fn new(api_key: Option<String>) -> Self {
119        Self::with_url(api_key, DEFAULT_API_URL.to_string())
120    }
121
122    /// Create a client with a custom API base URL (mainly for testing).
123    pub fn with_url(api_key: Option<String>, api_url: String) -> Self {
124        let key = api_key.filter(|s| !s.is_empty());
125        Self {
126            api_key: key,
127            api_url,
128            client: reqwest::Client::new(),
129        }
130    }
131
132    /// Login and retrieve license and limit information.
133    pub async fn login(&self) -> Result<CodecResult, SteganographyError> {
134        let form = self.build_form("login", |f| f);
135        self.post_request_codec_result(form).await
136    }
137
138    /// Encrypt and hide a message in an image; writes a PNG to `output_image_path`.
139    pub async fn encode(
140        &self,
141        input_image_path: impl AsRef<Path>,
142        message_to_hide: &str,
143        password: &str,
144        output_image_path: impl AsRef<Path>,
145    ) -> Result<CodecResult, SteganographyError> {
146        let path = input_image_path.as_ref();
147        let bytes = tokio::fs::read(path)
148            .await
149            .map_err(|e| SteganographyError::Api {
150                code: errors::INVALID_INPUT,
151                error_message: e.to_string(),
152                raw: None,
153            })?;
154        let filename = path
155            .file_name()
156            .and_then(|n| n.to_str())
157            .unwrap_or("image.bin");
158        self.encode_bytes(&bytes, filename, message_to_hide, password, output_image_path)
159            .await
160    }
161
162    /// Same as [`encode`](Self::encode) but reads image bytes you already have in memory.
163    pub async fn encode_bytes(
164        &self,
165        image: &[u8],
166        filename_for_upload: &str,
167        message_to_hide: &str,
168        password: &str,
169        output_image_path: impl AsRef<Path>,
170    ) -> Result<CodecResult, SteganographyError> {
171        if image.is_empty() {
172            return Err(SteganographyError::Api {
173                code: errors::INVALID_INPUT,
174                error_message: "input_image is required".to_string(),
175                raw: None,
176            });
177        }
178        if message_to_hide.is_empty() {
179            return Err(SteganographyError::Api {
180                code: errors::INVALID_INPUT,
181                error_message: "message_to_hide is required".to_string(),
182                raw: None,
183            });
184        }
185        if password.is_empty() {
186            return Err(SteganographyError::Api {
187                code: errors::INVALID_INPUT,
188                error_message: "password is required".to_string(),
189                raw: None,
190            });
191        }
192        let out = output_image_path.as_ref();
193        if out.as_os_str().is_empty() {
194            return Err(SteganographyError::Api {
195                code: errors::INVALID_INPUT,
196                error_message: "output_image_path is required".to_string(),
197                raw: None,
198            });
199        }
200
201        let image_part = Part::bytes(image.to_vec())
202            .file_name(filename_for_upload.to_string())
203            .mime_str("application/octet-stream")
204            .map_err(|e| SteganographyError::Api {
205                code: errors::UNKNOWN,
206                error_message: e.to_string(),
207                raw: None,
208            })?;
209
210        let form = self.build_form("encode", |f| {
211            f.text("message", message_to_hide.to_string())
212                .text("password", password.to_string())
213                .part("image", image_part)
214        });
215
216        let json = self.post_request_json(form).await?;
217        let encoded_b64 = json
218            .get("encodedImage")
219            .and_then(|v| v.as_str())
220            .ok_or_else(|| SteganographyError::Api {
221                code: errors::UNKNOWN,
222                error_message: "Malformed API response: missing encodedImage".to_string(),
223                raw: Some(json.clone()),
224            })?;
225
226        let decoded = base64::engine::general_purpose::STANDARD
227            .decode(encoded_b64)
228            .map_err(|e| SteganographyError::Api {
229                code: errors::UNKNOWN,
230                error_message: e.to_string(),
231                raw: Some(json.clone()),
232            })?;
233
234        tokio::fs::write(output_image_path.as_ref(), &decoded)
235            .await
236            .map_err(|e| SteganographyError::Api {
237                code: errors::OUTPUT_FILE,
238                error_message: e.to_string(),
239                raw: Some(json.clone()),
240            })?;
241
242        Self::json_to_codec_result(json)
243    }
244
245    /// Extract a hidden message from a PNG image.
246    pub async fn decode(
247        &self,
248        input_image_path: impl AsRef<Path>,
249        password: &str,
250    ) -> Result<CodecResult, SteganographyError> {
251        let path = input_image_path.as_ref();
252        let bytes = tokio::fs::read(path)
253            .await
254            .map_err(|e| SteganographyError::Api {
255                code: errors::INVALID_INPUT,
256                error_message: e.to_string(),
257                raw: None,
258            })?;
259        let filename = path
260            .file_name()
261            .and_then(|n| n.to_str())
262            .unwrap_or("image.png");
263        self.decode_bytes(&bytes, filename, password).await
264    }
265
266    /// Same as [`decode`](Self::decode) but uses in-memory image bytes.
267    pub async fn decode_bytes(
268        &self,
269        image: &[u8],
270        filename_for_upload: &str,
271        password: &str,
272    ) -> Result<CodecResult, SteganographyError> {
273        if image.is_empty() {
274            return Err(SteganographyError::Api {
275                code: errors::INVALID_INPUT,
276                error_message: "input_image is required".to_string(),
277                raw: None,
278            });
279        }
280        if password.is_empty() {
281            return Err(SteganographyError::Api {
282                code: errors::INVALID_INPUT,
283                error_message: "password is required".to_string(),
284                raw: None,
285            });
286        }
287
288        let image_part = Part::bytes(image.to_vec())
289            .file_name(filename_for_upload.to_string())
290            .mime_str("application/octet-stream")
291            .map_err(|e| SteganographyError::Api {
292                code: errors::UNKNOWN,
293                error_message: e.to_string(),
294                raw: None,
295            })?;
296
297        let form = self.build_form("decode", |f| {
298            f.text("password", password.to_string())
299                .part("image", image_part)
300        });
301
302        self.post_request_codec_result(form).await
303    }
304
305    /// Convert a byte count to a human-readable string (e.g. `"1.23 MB"`).
306    pub fn convert_size(size_bytes: i64) -> String {
307        Self::convert_size_f64(size_bytes as f64)
308    }
309
310    fn convert_size_f64(size_bytes: f64) -> String {
311        if !size_bytes.is_finite() || size_bytes < 0.0 {
312            return "0 bytes".to_string();
313        }
314        if size_bytes == 0.0 {
315            return "0 bytes".to_string();
316        }
317
318        const NAMES: &[&str] = &[
319            "bytes", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB",
320        ];
321        let i = ((size_bytes.ln() / 1024_f64.ln()).floor() as usize).min(NAMES.len() - 1);
322        let p = 1024_f64.powi(i as i32);
323        let s = ((size_bytes / p) * 100.0).round() / 100.0;
324        format!("{} {}", s, NAMES[i])
325    }
326
327    fn key_field(&self) -> String {
328        self.api_key.clone().unwrap_or_default()
329    }
330
331    fn build_form(&self, command: &'static str, extend: impl FnOnce(Form) -> Form) -> Form {
332        let base = Form::new()
333            .text("key", self.key_field())
334            .text("command", command.to_string());
335        extend(base)
336    }
337
338    async fn post_request_json(&self, form: Form) -> Result<serde_json::Value, SteganographyError> {
339        let response = self
340            .client
341            .post(&self.api_url)
342            .multipart(form)
343            .send()
344            .await
345            .map_err(|e| SteganographyError::Api {
346                code: errors::WEBAPI_CONNECTION,
347                error_message: e.to_string(),
348                raw: None,
349            })?;
350
351        if !response.status().is_success() {
352            return Err(SteganographyError::Api {
353                code: errors::WEBAPI_CONNECTION,
354                error_message: format!("HTTP {}", response.status()),
355                raw: None,
356            });
357        }
358
359        let json: serde_json::Value = response.json().await.map_err(|e| {
360            SteganographyError::Api {
361                code: errors::WEBAPI_CONNECTION,
362                error_message: e.to_string(),
363                raw: None,
364            }
365        })?;
366
367        let err_code = json
368            .get("error")
369            .and_then(|v| v.as_i64())
370            .map(|v| v as i32);
371
372        let Some(code) = err_code else {
373            return Err(SteganographyError::Api {
374                code: errors::UNKNOWN,
375                error_message: "Malformed API response: missing error field".to_string(),
376                raw: Some(json),
377            });
378        };
379
380        if code == errors::SUCCESS {
381            return Ok(json);
382        }
383
384        let msg = json
385            .get("error_message")
386            .and_then(|v| v.as_str())
387            .unwrap_or("API error")
388            .to_string();
389
390        Err(SteganographyError::Api {
391            code,
392            error_message: msg,
393            raw: Some(json),
394        })
395    }
396
397    async fn post_request_codec_result(
398        &self,
399        form: Form,
400    ) -> Result<CodecResult, SteganographyError> {
401        let json = self.post_request_json(form).await?;
402        Self::json_to_codec_result(json)
403    }
404
405    fn json_to_codec_result(json: serde_json::Value) -> Result<CodecResult, SteganographyError> {
406        let license = json
407            .get("license")
408            .cloned()
409            .and_then(|v| serde_json::from_value::<LicenseInfo>(v).ok());
410        let limits = json
411            .get("limits")
412            .cloned()
413            .and_then(|v| serde_json::from_value::<LimitsInfo>(v).ok());
414        let message = json
415            .get("message")
416            .and_then(|v| v.as_str())
417            .map(|s| s.to_string());
418
419        Ok(CodecResult {
420            license,
421            limits,
422            message,
423        })
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn convert_size_matches_js_style() {
433        assert_eq!(SteganographyOnlineCodec::convert_size(0), "0 bytes");
434        assert_eq!(SteganographyOnlineCodec::convert_size(500), "500 bytes");
435    }
436}