Skip to main content

argentor_builtins/
encode_decode.rs

1use argentor_core::{ArgentorResult, ToolCall, ToolResult};
2use argentor_skills::skill::{Skill, SkillDescriptor};
3use async_trait::async_trait;
4use base64::{engine::general_purpose, Engine as _};
5
6/// Encoding and decoding skill.
7///
8/// Supports Base64 (standard and URL-safe), hex, URL percent-encoding,
9/// HTML entity encoding, and JWT payload decoding. Inspired by AutoGPT
10/// encoder/decoder blocks.
11pub struct EncodeDecodeSkill {
12    descriptor: SkillDescriptor,
13}
14
15impl EncodeDecodeSkill {
16    /// Create a new encode/decode skill.
17    pub fn new() -> Self {
18        Self {
19            descriptor: SkillDescriptor {
20                name: "encode_decode".to_string(),
21                description:
22                    "Encoding/decoding: Base64, hex, URL, HTML entities, JWT payload parsing."
23                        .to_string(),
24                parameters_schema: serde_json::json!({
25                    "type": "object",
26                    "properties": {
27                        "operation": {
28                            "type": "string",
29                            "enum": [
30                                "base64_encode", "base64_decode",
31                                "base64url_encode", "base64url_decode",
32                                "hex_encode", "hex_decode",
33                                "url_encode", "url_decode",
34                                "html_encode", "html_decode",
35                                "jwt_decode"
36                            ],
37                            "description": "The encoding/decoding operation to perform"
38                        },
39                        "input": {
40                            "type": "string",
41                            "description": "The input string to encode or decode"
42                        }
43                    },
44                    "required": ["operation", "input"]
45                }),
46                required_capabilities: vec![],
47                requires_approval: false,
48            },
49        }
50    }
51}
52
53impl Default for EncodeDecodeSkill {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59/// URL percent-encode a string, preserving unreserved characters per RFC 3986.
60fn url_encode(input: &str) -> String {
61    let mut encoded = String::with_capacity(input.len() * 3);
62    for byte in input.bytes() {
63        match byte {
64            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
65                encoded.push(byte as char);
66            }
67            _ => {
68                encoded.push('%');
69                encoded.push_str(&format!("{byte:02X}"));
70            }
71        }
72    }
73    encoded
74}
75
76/// Decode a URL percent-encoded string.
77fn url_decode(input: &str) -> Result<String, String> {
78    let mut bytes = Vec::with_capacity(input.len());
79    let mut chars = input.bytes().peekable();
80    while let Some(b) = chars.next() {
81        if b == b'%' {
82            let hi = chars
83                .next()
84                .ok_or("Incomplete percent-encoding: unexpected end of input")?;
85            let lo = chars
86                .next()
87                .ok_or("Incomplete percent-encoding: unexpected end of input")?;
88            let hex_str = format!("{}{}", hi as char, lo as char);
89            let decoded = u8::from_str_radix(&hex_str, 16)
90                .map_err(|_| format!("Invalid percent-encoding: %{hex_str}"))?;
91            bytes.push(decoded);
92        } else if b == b'+' {
93            bytes.push(b' ');
94        } else {
95            bytes.push(b);
96        }
97    }
98    String::from_utf8(bytes).map_err(|e| format!("Decoded bytes are not valid UTF-8: {e}"))
99}
100
101/// Encode a string with HTML entities for the five special characters.
102fn html_encode(input: &str) -> String {
103    let mut encoded = String::with_capacity(input.len());
104    for ch in input.chars() {
105        match ch {
106            '&' => encoded.push_str("&amp;"),
107            '<' => encoded.push_str("&lt;"),
108            '>' => encoded.push_str("&gt;"),
109            '"' => encoded.push_str("&quot;"),
110            '\'' => encoded.push_str("&#39;"),
111            _ => encoded.push(ch),
112        }
113    }
114    encoded
115}
116
117/// Decode HTML entities back to their original characters.
118fn html_decode(input: &str) -> String {
119    input
120        .replace("&amp;", "&")
121        .replace("&lt;", "<")
122        .replace("&gt;", ">")
123        .replace("&quot;", "\"")
124        .replace("&#39;", "'")
125        .replace("&#x27;", "'")
126        .replace("&#x2F;", "/")
127}
128
129/// Decode a JWT token's payload (second segment) without signature verification.
130fn jwt_decode(token: &str) -> Result<String, String> {
131    let parts: Vec<&str> = token.split('.').collect();
132    if parts.len() != 3 {
133        return Err(format!(
134            "Invalid JWT format: expected 3 dot-separated parts, got {}",
135            parts.len()
136        ));
137    }
138
139    let payload_b64 = parts[1];
140
141    // JWT uses base64url encoding without padding; add padding if needed.
142    let padded = match payload_b64.len() % 4 {
143        2 => format!("{payload_b64}=="),
144        3 => format!("{payload_b64}="),
145        _ => payload_b64.to_string(),
146    };
147
148    let decoded_bytes = general_purpose::URL_SAFE_NO_PAD
149        .decode(padded.trim_end_matches('='))
150        .or_else(|_| general_purpose::URL_SAFE.decode(&padded))
151        .map_err(|e| format!("Failed to base64url-decode JWT payload: {e}"))?;
152
153    let payload_str = String::from_utf8(decoded_bytes)
154        .map_err(|e| format!("JWT payload is not valid UTF-8: {e}"))?;
155
156    // Validate that the payload is valid JSON
157    serde_json::from_str::<serde_json::Value>(&payload_str)
158        .map_err(|e| format!("JWT payload is not valid JSON: {e}"))?;
159
160    Ok(payload_str)
161}
162
163/// Build a success response in the standard `{"result": ..., "encoding": ...}` format.
164fn success_response(call_id: &str, result: &str, encoding: &str) -> ToolResult {
165    let response = serde_json::json!({
166        "result": result,
167        "encoding": encoding
168    });
169    ToolResult::success(call_id, response.to_string())
170}
171
172#[async_trait]
173impl Skill for EncodeDecodeSkill {
174    fn descriptor(&self) -> &SkillDescriptor {
175        &self.descriptor
176    }
177
178    async fn execute(&self, call: ToolCall) -> ArgentorResult<ToolResult> {
179        let operation = match call.arguments["operation"].as_str() {
180            Some(op) => op,
181            None => {
182                return Ok(ToolResult::error(
183                    &call.id,
184                    "Missing required parameter: 'operation'",
185                ))
186            }
187        };
188        let input = match call.arguments["input"].as_str() {
189            Some(v) => v,
190            None => {
191                return Ok(ToolResult::error(
192                    &call.id,
193                    "Missing required parameter: 'input'",
194                ))
195            }
196        };
197
198        match operation {
199            "base64_encode" => {
200                let encoded = general_purpose::STANDARD.encode(input.as_bytes());
201                Ok(success_response(&call.id, &encoded, "base64"))
202            }
203            "base64_decode" => match general_purpose::STANDARD.decode(input.as_bytes()) {
204                Ok(bytes) => match String::from_utf8(bytes) {
205                    Ok(decoded) => Ok(success_response(&call.id, &decoded, "base64")),
206                    Err(e) => Ok(ToolResult::error(
207                        &call.id,
208                        format!("Decoded bytes are not valid UTF-8: {e}"),
209                    )),
210                },
211                Err(e) => Ok(ToolResult::error(
212                    &call.id,
213                    format!("Invalid base64 input: {e}"),
214                )),
215            },
216            "base64url_encode" => {
217                let encoded = general_purpose::URL_SAFE_NO_PAD.encode(input.as_bytes());
218                Ok(success_response(&call.id, &encoded, "base64url"))
219            }
220            "base64url_decode" => {
221                // Try without padding first, then with padding
222                let decode_result = general_purpose::URL_SAFE_NO_PAD
223                    .decode(input.as_bytes())
224                    .or_else(|_| general_purpose::URL_SAFE.decode(input.as_bytes()));
225                match decode_result {
226                    Ok(bytes) => match String::from_utf8(bytes) {
227                        Ok(decoded) => Ok(success_response(&call.id, &decoded, "base64url")),
228                        Err(e) => Ok(ToolResult::error(
229                            &call.id,
230                            format!("Decoded bytes are not valid UTF-8: {e}"),
231                        )),
232                    },
233                    Err(e) => Ok(ToolResult::error(
234                        &call.id,
235                        format!("Invalid base64url input: {e}"),
236                    )),
237                }
238            }
239            "hex_encode" => {
240                let encoded = hex::encode(input.as_bytes());
241                Ok(success_response(&call.id, &encoded, "hex"))
242            }
243            "hex_decode" => match hex::decode(input) {
244                Ok(bytes) => match String::from_utf8(bytes) {
245                    Ok(decoded) => Ok(success_response(&call.id, &decoded, "hex")),
246                    Err(e) => Ok(ToolResult::error(
247                        &call.id,
248                        format!("Decoded bytes are not valid UTF-8: {e}"),
249                    )),
250                },
251                Err(e) => Ok(ToolResult::error(
252                    &call.id,
253                    format!("Invalid hex input: {e}"),
254                )),
255            },
256            "url_encode" => {
257                let encoded = url_encode(input);
258                Ok(success_response(&call.id, &encoded, "url"))
259            }
260            "url_decode" => match url_decode(input) {
261                Ok(decoded) => Ok(success_response(&call.id, &decoded, "url")),
262                Err(e) => Ok(ToolResult::error(&call.id, e)),
263            },
264            "html_encode" => {
265                let encoded = html_encode(input);
266                Ok(success_response(&call.id, &encoded, "html"))
267            }
268            "html_decode" => {
269                let decoded = html_decode(input);
270                Ok(success_response(&call.id, &decoded, "html"))
271            }
272            "jwt_decode" => match jwt_decode(input) {
273                Ok(payload) => {
274                    let response = serde_json::json!({
275                        "result": serde_json::from_str::<serde_json::Value>(&payload).unwrap_or(serde_json::Value::String(payload)),
276                        "encoding": "jwt"
277                    });
278                    Ok(ToolResult::success(&call.id, response.to_string()))
279                }
280                Err(e) => Ok(ToolResult::error(&call.id, e)),
281            },
282            _ => Ok(ToolResult::error(
283                &call.id,
284                format!(
285                    "Unknown operation: '{operation}'. Supported: base64_encode, base64_decode, \
286                     base64url_encode, base64url_decode, hex_encode, hex_decode, \
287                     url_encode, url_decode, html_encode, html_decode, jwt_decode"
288                ),
289            )),
290        }
291    }
292}
293
294#[cfg(test)]
295#[allow(clippy::unwrap_used, clippy::expect_used)]
296mod tests {
297    use super::*;
298
299    fn make_call(args: serde_json::Value) -> ToolCall {
300        ToolCall {
301            id: "test".to_string(),
302            name: "encode_decode".to_string(),
303            arguments: args,
304        }
305    }
306
307    #[tokio::test]
308    async fn test_base64_encode() {
309        let skill = EncodeDecodeSkill::new();
310        let call = make_call(serde_json::json!({
311            "operation": "base64_encode",
312            "input": "hello world"
313        }));
314        let result = skill.execute(call).await.unwrap();
315        assert!(!result.is_error, "Result: {}", result.content);
316        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
317        assert_eq!(parsed["result"], "aGVsbG8gd29ybGQ=");
318        assert_eq!(parsed["encoding"], "base64");
319    }
320
321    #[tokio::test]
322    async fn test_base64_decode() {
323        let skill = EncodeDecodeSkill::new();
324        let call = make_call(serde_json::json!({
325            "operation": "base64_decode",
326            "input": "aGVsbG8gd29ybGQ="
327        }));
328        let result = skill.execute(call).await.unwrap();
329        assert!(!result.is_error);
330        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
331        assert_eq!(parsed["result"], "hello world");
332    }
333
334    #[tokio::test]
335    async fn test_base64_decode_invalid() {
336        let skill = EncodeDecodeSkill::new();
337        let call = make_call(serde_json::json!({
338            "operation": "base64_decode",
339            "input": "!!!not-base64!!!"
340        }));
341        let result = skill.execute(call).await.unwrap();
342        assert!(result.is_error);
343    }
344
345    #[tokio::test]
346    async fn test_base64url_encode() {
347        let skill = EncodeDecodeSkill::new();
348        let call = make_call(serde_json::json!({
349            "operation": "base64url_encode",
350            "input": "hello+world/foo"
351        }));
352        let result = skill.execute(call).await.unwrap();
353        assert!(!result.is_error);
354        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
355        assert_eq!(parsed["encoding"], "base64url");
356        // URL-safe base64 should not contain + or /
357        let encoded = parsed["result"].as_str().unwrap();
358        assert!(!encoded.contains('+'));
359        assert!(!encoded.contains('/'));
360    }
361
362    #[tokio::test]
363    async fn test_base64url_roundtrip() {
364        let skill = EncodeDecodeSkill::new();
365        let original = "data with special chars: +/=";
366
367        let enc_call = make_call(serde_json::json!({
368            "operation": "base64url_encode",
369            "input": original
370        }));
371        let enc_result = skill.execute(enc_call).await.unwrap();
372        let enc_parsed: serde_json::Value = serde_json::from_str(&enc_result.content).unwrap();
373        let encoded = enc_parsed["result"].as_str().unwrap();
374
375        let dec_call = make_call(serde_json::json!({
376            "operation": "base64url_decode",
377            "input": encoded
378        }));
379        let dec_result = skill.execute(dec_call).await.unwrap();
380        assert!(!dec_result.is_error);
381        let dec_parsed: serde_json::Value = serde_json::from_str(&dec_result.content).unwrap();
382        assert_eq!(dec_parsed["result"], original);
383    }
384
385    #[tokio::test]
386    async fn test_hex_encode() {
387        let skill = EncodeDecodeSkill::new();
388        let call = make_call(serde_json::json!({
389            "operation": "hex_encode",
390            "input": "hello"
391        }));
392        let result = skill.execute(call).await.unwrap();
393        assert!(!result.is_error);
394        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
395        assert_eq!(parsed["result"], "68656c6c6f");
396        assert_eq!(parsed["encoding"], "hex");
397    }
398
399    #[tokio::test]
400    async fn test_hex_decode() {
401        let skill = EncodeDecodeSkill::new();
402        let call = make_call(serde_json::json!({
403            "operation": "hex_decode",
404            "input": "68656c6c6f"
405        }));
406        let result = skill.execute(call).await.unwrap();
407        assert!(!result.is_error);
408        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
409        assert_eq!(parsed["result"], "hello");
410    }
411
412    #[tokio::test]
413    async fn test_hex_decode_invalid() {
414        let skill = EncodeDecodeSkill::new();
415        let call = make_call(serde_json::json!({
416            "operation": "hex_decode",
417            "input": "zzzz"
418        }));
419        let result = skill.execute(call).await.unwrap();
420        assert!(result.is_error);
421    }
422
423    #[tokio::test]
424    async fn test_url_encode() {
425        let skill = EncodeDecodeSkill::new();
426        let call = make_call(serde_json::json!({
427            "operation": "url_encode",
428            "input": "hello world&foo=bar"
429        }));
430        let result = skill.execute(call).await.unwrap();
431        assert!(!result.is_error);
432        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
433        assert_eq!(parsed["result"], "hello%20world%26foo%3Dbar");
434        assert_eq!(parsed["encoding"], "url");
435    }
436
437    #[tokio::test]
438    async fn test_url_decode() {
439        let skill = EncodeDecodeSkill::new();
440        let call = make_call(serde_json::json!({
441            "operation": "url_decode",
442            "input": "hello%20world%26foo%3Dbar"
443        }));
444        let result = skill.execute(call).await.unwrap();
445        assert!(!result.is_error);
446        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
447        assert_eq!(parsed["result"], "hello world&foo=bar");
448    }
449
450    #[tokio::test]
451    async fn test_url_decode_plus_as_space() {
452        let skill = EncodeDecodeSkill::new();
453        let call = make_call(serde_json::json!({
454            "operation": "url_decode",
455            "input": "hello+world"
456        }));
457        let result = skill.execute(call).await.unwrap();
458        assert!(!result.is_error);
459        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
460        assert_eq!(parsed["result"], "hello world");
461    }
462
463    #[tokio::test]
464    async fn test_url_decode_incomplete_percent() {
465        let skill = EncodeDecodeSkill::new();
466        let call = make_call(serde_json::json!({
467            "operation": "url_decode",
468            "input": "hello%2"
469        }));
470        let result = skill.execute(call).await.unwrap();
471        assert!(result.is_error);
472    }
473
474    #[tokio::test]
475    async fn test_html_encode() {
476        let skill = EncodeDecodeSkill::new();
477        let call = make_call(serde_json::json!({
478            "operation": "html_encode",
479            "input": "<p class=\"test\">Hello & 'world'</p>"
480        }));
481        let result = skill.execute(call).await.unwrap();
482        assert!(!result.is_error);
483        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
484        assert_eq!(
485            parsed["result"],
486            "&lt;p class=&quot;test&quot;&gt;Hello &amp; &#39;world&#39;&lt;/p&gt;"
487        );
488    }
489
490    #[tokio::test]
491    async fn test_html_decode() {
492        let skill = EncodeDecodeSkill::new();
493        let call = make_call(serde_json::json!({
494            "operation": "html_decode",
495            "input": "&lt;p&gt;Hello &amp; &#39;world&#39;&lt;/p&gt;"
496        }));
497        let result = skill.execute(call).await.unwrap();
498        assert!(!result.is_error);
499        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
500        assert_eq!(parsed["result"], "<p>Hello & 'world'</p>");
501    }
502
503    #[tokio::test]
504    async fn test_jwt_decode() {
505        let skill = EncodeDecodeSkill::new();
506        // Construct a valid JWT with known payload: {"sub":"1234567890","name":"John Doe","iat":1516239022}
507        let header =
508            general_purpose::URL_SAFE_NO_PAD.encode(b"{\"alg\":\"HS256\",\"typ\":\"JWT\"}");
509        let payload = general_purpose::URL_SAFE_NO_PAD
510            .encode(b"{\"sub\":\"1234567890\",\"name\":\"John Doe\",\"iat\":1516239022}");
511        let token = format!("{header}.{payload}.fake_signature");
512
513        let call = make_call(serde_json::json!({
514            "operation": "jwt_decode",
515            "input": token
516        }));
517        let result = skill.execute(call).await.unwrap();
518        assert!(!result.is_error, "Result: {}", result.content);
519        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
520        assert_eq!(parsed["encoding"], "jwt");
521        assert_eq!(parsed["result"]["sub"], "1234567890");
522        assert_eq!(parsed["result"]["name"], "John Doe");
523        assert_eq!(parsed["result"]["iat"], 1516239022);
524    }
525
526    #[tokio::test]
527    async fn test_jwt_decode_invalid_format() {
528        let skill = EncodeDecodeSkill::new();
529        let call = make_call(serde_json::json!({
530            "operation": "jwt_decode",
531            "input": "not.a-jwt"
532        }));
533        let result = skill.execute(call).await.unwrap();
534        assert!(result.is_error);
535        assert!(result.content.contains("3 dot-separated parts"));
536    }
537
538    #[tokio::test]
539    async fn test_missing_operation() {
540        let skill = EncodeDecodeSkill::new();
541        let call = make_call(serde_json::json!({
542            "input": "hello"
543        }));
544        let result = skill.execute(call).await.unwrap();
545        assert!(result.is_error);
546        assert!(result.content.contains("operation"));
547    }
548
549    #[tokio::test]
550    async fn test_missing_input() {
551        let skill = EncodeDecodeSkill::new();
552        let call = make_call(serde_json::json!({
553            "operation": "base64_encode"
554        }));
555        let result = skill.execute(call).await.unwrap();
556        assert!(result.is_error);
557        assert!(result.content.contains("input"));
558    }
559
560    #[tokio::test]
561    async fn test_unknown_operation() {
562        let skill = EncodeDecodeSkill::new();
563        let call = make_call(serde_json::json!({
564            "operation": "rot13",
565            "input": "hello"
566        }));
567        let result = skill.execute(call).await.unwrap();
568        assert!(result.is_error);
569        assert!(result.content.contains("Unknown operation"));
570    }
571
572    #[tokio::test]
573    async fn test_empty_string_base64_roundtrip() {
574        let skill = EncodeDecodeSkill::new();
575        let enc_call = make_call(serde_json::json!({
576            "operation": "base64_encode",
577            "input": ""
578        }));
579        let enc_result = skill.execute(enc_call).await.unwrap();
580        assert!(!enc_result.is_error);
581        let enc_parsed: serde_json::Value = serde_json::from_str(&enc_result.content).unwrap();
582        let encoded = enc_parsed["result"].as_str().unwrap();
583
584        let dec_call = make_call(serde_json::json!({
585            "operation": "base64_decode",
586            "input": encoded
587        }));
588        let dec_result = skill.execute(dec_call).await.unwrap();
589        assert!(!dec_result.is_error);
590        let dec_parsed: serde_json::Value = serde_json::from_str(&dec_result.content).unwrap();
591        assert_eq!(dec_parsed["result"], "");
592    }
593
594    #[test]
595    fn test_url_encode_unreserved_chars_preserved() {
596        // RFC 3986 unreserved: A-Z a-z 0-9 - _ . ~
597        let result = url_encode("abc-123_test.file~v2");
598        assert_eq!(result, "abc-123_test.file~v2");
599    }
600
601    #[test]
602    fn test_html_encode_no_special_chars() {
603        assert_eq!(html_encode("hello world"), "hello world");
604    }
605
606    #[test]
607    fn test_html_decode_no_entities() {
608        assert_eq!(html_decode("hello world"), "hello world");
609    }
610
611    #[test]
612    fn test_descriptor_name() {
613        let skill = EncodeDecodeSkill::new();
614        assert_eq!(skill.descriptor().name, "encode_decode");
615    }
616}