Skip to main content

tar_install/
version.rs

1use anyhow::{Context, Result};
2use regex::Regex;
3use serde_json::Value;
4use std::fs;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8use std::thread;
9use std::time::{Duration, Instant};
10
11const MAX_METADATA_BYTES: u64 = 512 * 1024;
12const MAX_OUTPUT_BYTES: u64 = 128 * 1024;
13const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct VersionProbeResult {
17    pub version: String,
18    pub source: String,
19}
20
21/// Detect version after the archive has been copied into the final app dir.
22///
23/// Order:
24/// 1. Metadata files: product-info.json, package.json, VERSION, etc.
25/// 2. Runtime command probes: --version, -version, -v, version, -V, then no args.
26///
27/// Important: runtime probes execute code from the extracted tarball. Only call this
28/// after the user has decided to install the package, and expose a way to disable it.
29pub fn detect_installed_version(
30    app_dir: &Path,
31    exec_path_inside_app: &Path,
32    command_name: &str,
33    app_id: &str,
34    app_name: &str,
35) -> Result<Option<VersionProbeResult>> {
36    if let Some(found) = detect_from_metadata(app_dir)? {
37        return Ok(Some(found));
38    }
39
40    let exec_abs = app_dir.join(exec_path_inside_app);
41    detect_from_command(&exec_abs, app_dir, command_name, app_id, app_name)
42}
43
44fn detect_from_metadata(app_dir: &Path) -> Result<Option<VersionProbeResult>> {
45    let json_candidates = [
46        PathBuf::from("product-info.json"),          // JetBrains IDEs
47        PathBuf::from("package.json"),               // Node / Electron apps
48        PathBuf::from("resources/app/package.json"), // Electron apps
49        PathBuf::from("app/package.json"),
50    ];
51
52    for rel in json_candidates {
53        let path = app_dir.join(&rel);
54        if !path.is_file() {
55            continue;
56        }
57
58        let text = read_small_text(&path)?;
59        if text.trim().is_empty() {
60            continue;
61        }
62
63        if let Ok(json) = serde_json::from_str::<Value>(&text) {
64            if let Some(version) = json
65                .get("version")
66                .and_then(Value::as_str)
67                .and_then(clean_version)
68            {
69                return Ok(Some(VersionProbeResult {
70                    version,
71                    source: rel.display().to_string(),
72                }));
73            }
74
75            // Some products expose buildNumber but not version. Use it only as a fallback.
76            if let Some(version) = json
77                .get("buildNumber")
78                .and_then(Value::as_str)
79                .and_then(clean_version)
80            {
81                return Ok(Some(VersionProbeResult {
82                    version,
83                    source: format!("{}:buildNumber", rel.display()),
84                }));
85            }
86        }
87    }
88
89    let text_candidates = [
90        PathBuf::from("VERSION"),
91        PathBuf::from("VERSION.txt"),
92        PathBuf::from("version.txt"),
93        PathBuf::from("build.txt"),
94        PathBuf::from("RELEASE"),
95    ];
96
97    for rel in text_candidates {
98        let path = app_dir.join(&rel);
99        if !path.is_file() {
100            continue;
101        }
102
103        let text = read_small_text(&path)?;
104        if let Some(version) = parse_version_from_text(&text, "", "", "") {
105            return Ok(Some(VersionProbeResult {
106                version,
107                source: rel.display().to_string(),
108            }));
109        }
110    }
111
112    Ok(None)
113}
114
115fn detect_from_command(
116    exec_abs: &Path,
117    app_dir: &Path,
118    command_name: &str,
119    app_id: &str,
120    app_name: &str,
121) -> Result<Option<VersionProbeResult>> {
122    if !exec_abs.is_file() {
123        return Ok(None);
124    }
125
126    let probes: &[&[&str]] = &[
127        &["--version"],
128        &["-version"],
129        &["-v"],
130        &["version"],
131        &["-V"],
132        &[],
133    ];
134
135    for args in probes {
136        let output = match run_probe(exec_abs, app_dir, args, PROBE_TIMEOUT) {
137            Ok(output) => output,
138            Err(_) => continue,
139        };
140
141        if let Some(version) = parse_version_from_text(&output, command_name, app_id, app_name) {
142            let rendered_args = if args.is_empty() {
143                "<no args>".to_string()
144            } else {
145                args.join(" ")
146            };
147
148            return Ok(Some(VersionProbeResult {
149                version,
150                source: format!("{} {}", exec_abs.display(), rendered_args),
151            }));
152        }
153    }
154
155    Ok(None)
156}
157
158fn run_probe(exec_abs: &Path, app_dir: &Path, args: &[&str], timeout: Duration) -> Result<String> {
159    let mut child = Command::new(exec_abs)
160        .args(args)
161        .current_dir(app_dir)
162        .env("LC_ALL", "C")
163        .env("LANG", "C")
164        .env("NO_COLOR", "1")
165        .stdout(Stdio::piped())
166        .stderr(Stdio::piped())
167        .spawn()
168        .with_context(|| format!("failed to run version probe: {}", exec_abs.display()))?;
169
170    let stdout = child.stdout.take();
171    let stderr = child.stderr.take();
172
173    let out_handle = stdout.map(|stream| thread::spawn(move || read_limited(stream, MAX_OUTPUT_BYTES)));
174    let err_handle = stderr.map(|stream| thread::spawn(move || read_limited(stream, MAX_OUTPUT_BYTES)));
175
176    let started = Instant::now();
177    loop {
178        if child.try_wait()?.is_some() {
179            break;
180        }
181
182        if started.elapsed() >= timeout {
183            let _ = child.kill();
184            let _ = child.wait();
185            break;
186        }
187
188        thread::sleep(Duration::from_millis(40));
189    }
190
191    let mut output = String::new();
192
193    if let Some(handle) = out_handle {
194        output.push_str(&handle.join().unwrap_or_default());
195    }
196
197    if let Some(handle) = err_handle {
198        if !output.ends_with('\n') && !output.is_empty() {
199            output.push('\n');
200        }
201        output.push_str(&handle.join().unwrap_or_default());
202    }
203
204    Ok(output)
205}
206
207fn read_limited<R: Read>(stream: R, limit: u64) -> String {
208    let mut text = String::new();
209    let mut limited = stream.take(limit);
210    let _ = limited.read_to_string(&mut text);
211    text
212}
213
214fn read_small_text(path: &Path) -> Result<String> {
215    let meta = fs::metadata(path)?;
216    if meta.len() > MAX_METADATA_BYTES {
217        return Ok(String::new());
218    }
219
220    fs::read_to_string(path).with_context(|| format!("failed to read metadata file: {}", path.display()))
221}
222
223fn clean_version(value: &str) -> Option<String> {
224    let trimmed = value.trim().trim_matches(['\'', '"']);
225    if trimmed.is_empty() {
226        return None;
227    }
228
229    parse_version_from_text(trimmed, "", "", "")
230        .or_else(|| Some(trimmed.trim_start_matches(['v', 'V']).to_string()))
231}
232
233pub fn parse_version_from_text(
234    text: &str,
235    command_name: &str,
236    app_id: &str,
237    app_name: &str,
238) -> Option<String> {
239    let version_re = Regex::new(
240        r#"(?ix)
241        \b
242        (?:version\s*)?
243        ["']?
244        v?
245        (\d{1,4}(?:\.\d{1,8}){1,5}(?:[-+~._][0-9A-Za-z][0-9A-Za-z._+-]*)?)
246        ["']?
247        "#,
248    )
249    .unwrap();
250
251    let needles = [command_name, app_id, app_name]
252        .into_iter()
253        .filter(|s| !s.trim().is_empty())
254        .map(|s| s.to_ascii_lowercase())
255        .collect::<Vec<_>>();
256
257    let mut best: Option<(i32, String)> = None;
258
259    for (line_index, line) in text.lines().take(80).enumerate() {
260        let lower = line.to_ascii_lowercase();
261
262        for caps in version_re.captures_iter(line) {
263            let Some(raw) = caps.get(1).map(|m| m.as_str()) else {
264                continue;
265            };
266
267            if looks_like_false_positive(raw, &lower) {
268                continue;
269            }
270
271            let mut score = 1000 - line_index as i32;
272
273            if line_index == 0 {
274                score += 120;
275            }
276            if lower.contains("version") {
277                score += 80;
278            }
279            if needles.iter().any(|needle| lower.contains(needle)) {
280                score += 80;
281            }
282            if lower.contains("build date")
283                || lower.contains("build time")
284                || lower.contains("commit")
285                || lower.contains("runtime")
286                || lower.contains("luajit")
287            {
288                score -= 160;
289            }
290
291            let version = raw.trim_start_matches(['v', 'V']).to_string();
292
293            match &best {
294                Some((best_score, _)) if *best_score >= score => {}
295                _ => best = Some((score, version)),
296            }
297        }
298    }
299
300    best.map(|(_, version)| version)
301}
302
303fn looks_like_false_positive(version: &str, line_lower: &str) -> bool {
304    let lower = version.to_ascii_lowercase();
305
306    if line_lower.contains("copyright") || line_lower.contains("license") {
307        return true;
308    }
309
310    // Usually timestamps/build ids, unless they are dev/hash suffix versions.
311    if !lower.contains('-') && !lower.contains('+') {
312        if lower.split('.').any(|part| part.len() > 8) {
313            return true;
314        }
315    }
316
317    false
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn parses_nvim_dev_version() {
326        let text = "NVIM v0.13.0-dev-915+g3b8c19ea46\nLuaJIT 2.1.1782726002";
327        assert_eq!(
328            parse_version_from_text(text, "nvim", "nvim", "Neovim").as_deref(),
329            Some("0.13.0-dev-915+g3b8c19ea46")
330        );
331    }
332
333    #[test]
334    fn parses_blender_version() {
335        let text = "Blender 5.1.2\n\tbuild date: 2026-05-19";
336        assert_eq!(
337            parse_version_from_text(text, "blender", "blender", "Blender").as_deref(),
338            Some("5.1.2")
339        );
340    }
341
342    #[test]
343    fn parses_java_version() {
344        let text = "openjdk version \"21.0.11\" 2026-04-21 LTS";
345        assert_eq!(
346            parse_version_from_text(text, "java", "java", "Java").as_deref(),
347            Some("21.0.11")
348        );
349    }
350
351    #[test]
352    fn parses_jetbrains_style_version() {
353        let text = "2026.1.4";
354        assert_eq!(
355            parse_version_from_text(text, "idea", "idea", "IntelliJ IDEA").as_deref(),
356            Some("2026.1.4")
357        );
358    }
359}