Skip to main content

asc_engine/config/
decompilers.rs

1//! `[decompilers.<name>]`: how to run each decompiler backend
2//! (docs/decompile-backends.md §4). Unlike the settings, these are whole
3//! tables (an argument list, an environment, options passed through), so
4//! they are read before the rest of the file is flattened into keys.
5
6use super::{ConfigError, expand_home, parse_duration};
7use std::collections::BTreeMap;
8use std::path::PathBuf;
9use std::time::Duration;
10
11/// One `[decompilers.<name>]` table. Defaults follow §4.
12#[derive(Debug, Clone, PartialEq, Eq)]
13#[non_exhaustive]
14pub struct Decompiler {
15    /// `command`: the executable; looked up on `PATH` when it has no `/`.
16    /// Only the built-in backend ([`BUILT_IN_DECOMPILER`]) may leave it out,
17    /// to change its other keys and keep the `asc-dexdec-worker` asc finds.
18    pub command: Option<PathBuf>,
19    /// `args`.
20    pub args: Vec<String>,
21    /// `env`: added to the inherited environment.
22    pub env: BTreeMap<String, String>,
23    /// `cwd`; `None`: `decompilers/<name>/` in the cache directory.
24    pub cwd: Option<PathBuf>,
25    /// `instances`: processes in the pool, at least 1.
26    pub instances: usize,
27    /// `max_concurrency`: caps the requests one process takes at once
28    /// (the backend declares its own limit); `None`: as declared.
29    pub max_concurrency: Option<u32>,
30    /// `idle`: a process idle this long exits.
31    pub idle: Duration,
32    /// `timeout`: deadline of one request.
33    pub timeout: Duration,
34    /// `startup_timeout`: deadline of the `initialize` handshake.
35    pub startup_timeout: Duration,
36    /// `inputs`: the input kinds asc may hand the backend (`archive`,
37    /// `slice`); empty: whatever it declares.
38    pub inputs: Vec<String>,
39    /// `options`: passed through to `initialize` uninterpreted.
40    pub options: serde_json::Map<String, serde_json::Value>,
41    /// `enabled`: `false` keeps the table but never starts the backend.
42    pub enabled: bool,
43}
44
45/// Keys of a table, for the error naming an unknown one.
46const KEYS: &[&str] = &[
47    "command",
48    "args",
49    "env",
50    "cwd",
51    "instances",
52    "max_concurrency",
53    "idle",
54    "timeout",
55    "startup_timeout",
56    "inputs",
57    "options",
58    "enabled",
59];
60
61/// The name of asc's own backend, `asc-dexdec-worker`.
62pub const BUILT_IN_DECOMPILER: &str = "dexdec";
63
64/// What `inputs` may list.
65const INPUT_KINDS: &[&str] = &["archive", "slice"];
66
67impl Decompiler {
68    /// A backend run as `command` with every other key at its default.
69    pub fn new(command: impl Into<PathBuf>) -> Decompiler {
70        Decompiler {
71            command: Some(command.into()),
72            args: Vec::new(),
73            env: BTreeMap::new(),
74            cwd: None,
75            instances: 1,
76            max_concurrency: None,
77            idle: Duration::from_secs(10 * 60),
78            timeout: Duration::from_secs(60),
79            startup_timeout: Duration::from_secs(30),
80            inputs: Vec::new(),
81            options: serde_json::Map::new(),
82            enabled: true,
83        }
84    }
85
86    /// Every table under `[decompilers]` of the file `origin`.
87    pub(super) fn parse_all(value: &toml::Value, origin: &str) -> Result<BTreeMap<String, Decompiler>, ConfigError> {
88        let invalid =
89            |key: String, text: String, expected: &'static str| ConfigError::Invalid { origin: origin.into(), key, text, expected };
90        let toml::Value::Table(tables) = value else {
91            return Err(invalid("decompilers".into(), format!("<{}>", value.type_str()), "a table of [decompilers.NAME] tables"));
92        };
93        tables
94            .iter()
95            .map(|(name, table)| {
96                let toml::Value::Table(table) = table else {
97                    return Err(invalid(format!("decompilers.{name}"), format!("<{}>", table.type_str()), "a table"));
98                };
99                Ok((name.clone(), Decompiler::parse(name, table, origin)?))
100            })
101            .collect()
102    }
103
104    fn parse(name: &str, table: &toml::Table, origin: &str) -> Result<Decompiler, ConfigError> {
105        let invalid = |key: &str, value: &toml::Value, expected: &'static str| ConfigError::Invalid {
106            origin: origin.into(),
107            key: format!("decompilers.{name}.{key}"),
108            text: match value {
109                toml::Value::String(s) => s.clone(),
110                toml::Value::Integer(n) => n.to_string(),
111                toml::Value::Float(x) => x.to_string(),
112                toml::Value::Boolean(b) => b.to_string(),
113                other => format!("<{}>", other.type_str()),
114            },
115            expected,
116        };
117        let command = match table.get("command") {
118            Some(command) => Some(command.as_str().filter(|c| !c.is_empty()).ok_or_else(|| invalid("command", command, "a command"))?),
119            None if name == BUILT_IN_DECOMPILER => None,
120            None => {
121                return Err(ConfigError::Invalid {
122                    origin: origin.into(),
123                    key: format!("decompilers.{name}.command"),
124                    text: String::new(),
125                    expected: "a command (required but for the built-in dexdec)",
126                });
127            }
128        };
129        if command.is_none()
130            && let Some(args) = table.get("args")
131        {
132            return Err(invalid("args", args, "no args without a command (the built-in dexdec takes none)"));
133        }
134        let mut decompiler = Decompiler::new("");
135        decompiler.command = command.map(expand_home);
136        for (key, value) in table {
137            let strings = || -> Option<Vec<String>> { value.as_array()?.iter().map(|v| v.as_str().map(String::from)).collect() };
138            let duration = |expected| {
139                let parsed = match value {
140                    toml::Value::Integer(n) => u64::try_from(*n).ok().map(Duration::from_secs),
141                    toml::Value::String(s) => parse_duration(s, Duration::from_secs(1)),
142                    _ => None,
143                };
144                parsed.ok_or_else(|| invalid(key, value, expected))
145            };
146            const DURATION: &str = "a duration (60, 30s, 10m; a bare number is seconds)";
147            match key.as_str() {
148                "command" => {}
149                "args" => decompiler.args = strings().ok_or_else(|| invalid(key, value, "an array of strings"))?,
150                "env" => {
151                    let env = value.as_table().and_then(|t| t.iter().map(|(k, v)| Some((k.clone(), v.as_str()?.to_string()))).collect());
152                    decompiler.env = env.ok_or_else(|| invalid(key, value, "a table of strings"))?;
153                }
154                "cwd" => {
155                    let cwd = value.as_str().filter(|c| !c.is_empty()).ok_or_else(|| invalid(key, value, "a directory"))?;
156                    decompiler.cwd = Some(expand_home(cwd));
157                }
158                "instances" => {
159                    let n = value.as_integer().and_then(|n| usize::try_from(n).ok()).filter(|&n| n > 0);
160                    decompiler.instances = n.ok_or_else(|| invalid(key, value, "a number of processes, at least 1"))?;
161                }
162                "max_concurrency" => {
163                    let n = value.as_integer().and_then(|n| u32::try_from(n).ok()).filter(|&n| n > 0);
164                    decompiler.max_concurrency = Some(n.ok_or_else(|| invalid(key, value, "a number of requests, at least 1"))?);
165                }
166                "idle" => decompiler.idle = duration(DURATION)?,
167                "timeout" => decompiler.timeout = duration(DURATION)?,
168                "startup_timeout" => decompiler.startup_timeout = duration(DURATION)?,
169                "inputs" => {
170                    let kinds = strings().filter(|kinds| kinds.iter().all(|kind| INPUT_KINDS.contains(&kind.as_str())));
171                    decompiler.inputs = kinds.ok_or_else(|| invalid(key, value, "an array of archive, slice"))?;
172                }
173                "options" => {
174                    let options = value.as_table().and_then(|t| serde_json::to_value(t).ok()).and_then(|v| match v {
175                        serde_json::Value::Object(map) => Some(map),
176                        _ => None,
177                    });
178                    decompiler.options = options.ok_or_else(|| invalid(key, value, "a table"))?;
179                }
180                "enabled" => decompiler.enabled = value.as_bool().ok_or_else(|| invalid(key, value, "true or false"))?,
181                unknown => {
182                    return Err(ConfigError::UnknownKey {
183                        origin: origin.into(),
184                        key: format!("decompilers.{name}.{unknown}"),
185                        known: KEYS.join(", "),
186                    });
187                }
188            }
189        }
190        Ok(decompiler)
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::config::Config;
198    use std::path::Path;
199
200    fn load(text: &str) -> Result<Config, ConfigError> {
201        Config::resolve(Some((Path::new("c.toml"), text)), |_| None, &[])
202    }
203
204    #[test]
205    fn tables_read_with_defaults_and_pass_options_through() {
206        let c = load(
207            r#"
208            threads = 2
209            [decompile]
210            default = "jeb"
211            fallback = ["dexdec", "jadx"]
212            [decompilers.jeb]
213            command = "java"
214            args = ["-Xmx6g", "-jar", "/opt/jeb-server.jar", "--stdio"]
215            env = { JEB_HOME = "/opt/jeb" }
216            instances = 2
217            max_concurrency = 1
218            idle = "5m"
219            timeout = 90
220            startup_timeout = "1m"
221            inputs = ["slice"]
222            options = { finally_rebuilder = true, depth = 3 }
223            [decompilers.jadx]
224            command = "jadx-worker"
225            enabled = false
226            "#,
227        )
228        .unwrap();
229        assert_eq!(c.decompile_default.value.0, "jeb");
230        assert_eq!(c.decompile_fallback.value.0, ["dexdec", "jadx"]);
231        let jeb = &c.decompilers["jeb"];
232        assert_eq!(jeb.command.as_deref(), Some(Path::new("java")));
233        assert_eq!(jeb.args.len(), 4);
234        assert_eq!(jeb.env["JEB_HOME"], "/opt/jeb");
235        assert_eq!((jeb.instances, jeb.max_concurrency), (2, Some(1)));
236        assert_eq!(
237            (jeb.idle, jeb.timeout, jeb.startup_timeout),
238            (Duration::from_secs(300), Duration::from_secs(90), Duration::from_secs(60))
239        );
240        assert_eq!(jeb.inputs, ["slice"]);
241        assert_eq!(serde_json::Value::Object(jeb.options.clone()), serde_json::json!({"finally_rebuilder": true, "depth": 3}));
242        let jadx = &c.decompilers["jadx"];
243        assert!(!jadx.enabled);
244        assert_eq!((jadx.instances, jadx.timeout, jadx.cwd.as_ref()), (1, Duration::from_secs(60), None));
245    }
246
247    #[test]
248    fn bad_tables_name_the_key() {
249        let cases = [
250            ("[decompilers.x]\nargs = []\n", "decompilers.x.command"),
251            ("[decompilers.x]\ncommand = \"x\"\ninstances = 0\n", "decompilers.x.instances = \"0\": expected a number of processes"),
252            ("[decompilers.x]\ncommand = \"x\"\ntimeout = \"soon\"\n", "decompilers.x.timeout = \"soon\": expected a duration"),
253            ("[decompilers.x]\ncommand = \"x\"\ninputs = [\"apk\"]\n", "expected an array of archive, slice"),
254            ("[decompilers.x]\ncommand = \"x\"\nmaxConcurrency = 1\n", "unknown setting \"decompilers.x.maxConcurrency\""),
255            ("[decompile]\nfallback = [1]\n", "decompile.fallback = \"<array>\": expected an array of strings"),
256            ("decompilers = 1\n", "expected a table of [decompilers.NAME] tables"),
257            ("[decompilers.dexdec]\nargs = [\"x\"]\n", "decompilers.dexdec.args"),
258        ];
259        for (text, want) in cases {
260            let e = load(text).unwrap_err().to_string();
261            assert!(e.starts_with("c.toml: ") && e.contains(want), "{text:?}: {e}");
262        }
263    }
264
265    #[test]
266    fn the_built_in_backend_needs_no_command() {
267        let c = load("[decompilers.dexdec]\noptions = { indent = \"  \" }\ninstances = 2\n").unwrap();
268        let dexdec = &c.decompilers[BUILT_IN_DECOMPILER];
269        assert_eq!((dexdec.command.as_ref(), dexdec.instances), (None, 2));
270        assert_eq!(dexdec.options["indent"], "  ");
271    }
272
273    #[test]
274    fn decompile_settings_come_from_any_layer() {
275        let c = Config::resolve(None, |_| None, &[]).unwrap();
276        assert_eq!((c.decompile_default.value.0.as_str(), c.decompile_fallback.value.0.len()), ("dexdec", 0));
277        assert_eq!(c.decompile_mapping.value.0, None);
278        let env = |name: &str| (name == "ASC_DECOMPILE_FALLBACK").then(|| "jadx, jeb".to_string());
279        let flags = [("decompile.mapping".to_string(), "/m/mapping.txt".to_string())];
280        let c = Config::resolve(None, env, &flags).unwrap();
281        assert_eq!(c.decompile_fallback.value.0, ["jadx", "jeb"]);
282        assert_eq!(c.decompile_mapping.value.0.as_deref(), Some(Path::new("/m/mapping.txt")));
283        let c = load("[decompile]\nfallback = []\n").unwrap();
284        assert!(c.decompile_fallback.value.0.is_empty());
285    }
286}