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