Skip to main content

rightkit_qa/
binary.rs

1//! Find the dev build of the app under test, wherever the build put it.
2//!
3//! `right-release dev` and `rightkit cargo build` run through the RightKit build broker, which builds
4//! into its own target root (`<managed root>/rightkit-build-control/ws/<key>/g/<gen>/<leaf>/<triple>/<profile>/`)
5//! and does not forward app env such as `MAILRIGHT_APP_BINARY` into a later `rightkit cargo test`. A
6//! journey run there could not find the executable, and every app hand-rolled discovery.
7//!
8//! [`resolve`] tries, in order, and reports which source won ([`BinarySource`]):
9//!
10//! 1. **Env override**: `RIGHTKIT_QA_APP_BINARY`, then `RIGHTKIT_QA_UI_BINARY`, then the app's own
11//!    variable named by `ui.binary_env`. A set variable that fails validation is an error, never a fall-through.
12//! 2. **Pointer file**: a gitignored file under the app root (`.rightkit-qa-app-binary`, or `ui.pointer_file`)
13//!    whose first non-empty line is the executable path (relative paths resolve against the app root).
14//!    Same strictness as the env override.
15//! 3. **Broker target root**: the newest dev-profile executable named `<bin>` under the broker workspace
16//!    whose `identity.json` `canonicalRoot` is the app's cargo workspace root. See "Broker format" below.
17//! 4. **App-root relative paths**: the existing `ui.binaries` entry.
18//!
19//! Every candidate must also be a QA build: it carries [`QA_CONTROL_MARKER`] (Latin-1 or UTF-16LE), the same
20//! bytes `right-release` scans for. A broker or app-root candidate without it is skipped with a reason; an env or
21//! pointer value without it is an error. With a single dev profile a plain dev build and the control build both
22//! write `target/<triple>/debug/<bin>`, so the marker is what tells them apart. `--build-ui` keeps a stable copy
23//! ([`stash_qa_build`]) and points `.rightkit-qa-app-binary` at it. Both that pointer file and the
24//! `<target>/rightkit-qa/` copy are build output: git-ignore them.
25//!
26//! # One fixed launch path
27//!
28//! macOS ties privacy grants to the path an app runs from, and the broker's target folders change per
29//! generation (`ws/<hash>/g/<gen>/...`). [`resolve_stashed`] (what `QaConfig::resolve_ui_binary` and the suite
30//! use) therefore ALWAYS returns the fixed stash copy under the app root, copied in place with a kept
31//! `<bin>.previous`, whichever source found the build. A QA launch never runs from a broker generation path
32//! or from `.previous`.
33//!
34//! Every candidate must exist, be a regular file, be named `<bin>` (or `<bin>.exe`) and must not sit in a
35//! `release` profile directory. A shipping or release-tier artifact is never picked.
36//!
37//! # Broker format
38//!
39//! The broker's per-job receipts (`<broker root>/receipts/<id>.json`) carry only freshness counts
40//! (`compilerArtifacts`), no executable path, so they cannot name the last built artifact. What is stable
41//! is the workspace state: `ws/<key>/identity.json` (`{"schemaVersion":1,"key","canonicalRoot"}`) and the
42//! target tree `ws/<key>/g/<generation>/<leaf>/...` where `<leaf>` is `t` or `target`. Under the leaf
43//! sits `[<triple>/]<profile>/<bin>[.exe]`. Only the dev profiles `release-iterate`, `debug` and `dev` are
44//! read; the newest by modification time wins.
45use crate::util::{err, Error, Result};
46use std::path::{Path, PathBuf};
47
48/// Env var: the dev executable, for one app.
49pub const APP_BINARY_ENV: &str = "RIGHTKIT_QA_APP_BINARY";
50/// Older env var read by the suite runner; honored second.
51pub const UI_BINARY_ENV: &str = "RIGHTKIT_QA_UI_BINARY";
52/// The embedded control server's marker; keep identical to `embedded::QA_CONTROL_MARKER` and
53/// `packages/release/qa-control-scan.mjs`. Not feature-gated here: this module only scans files.
54pub const QA_CONTROL_MARKER: &str = "RIGHTKIT-QA-CONTROL-SERVER-PRESENT";
55/// Directory (under the cargo target dir) that holds the stable QA copy.
56pub const STASH_DIR: &str = ".rightkit-qa-bin";
57/// Default pointer file name, relative to the app root.
58pub const POINTER_FILE: &str = ".rightkit-qa-app-binary";
59/// Profile directories the broker lookup reads. `release` is never in this list.
60pub const DEV_PROFILES: [&str; 3] = ["release-iterate", "debug", "dev"];
61
62/// Which source produced the path.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum BinarySource {
65    /// The named env var.
66    Env(String),
67    /// The pointer file at this path.
68    PointerFile(PathBuf),
69    /// The broker target root workspace directory (`ws/<key>`).
70    BrokerTarget(PathBuf),
71    /// The configured app-root relative path.
72    AppRoot,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct ResolvedBinary {
77    pub path: PathBuf,
78    pub source: BinarySource,
79}
80
81/// Inputs to [`resolve`]. `env` is injectable so tests never read the process environment.
82pub struct BinaryQuery<'a> {
83    /// Cargo target name (executable stem), e.g. `mailright`.
84    pub bin: &'a str,
85    pub app_root: &'a Path,
86    /// Cargo workspace roots to match against the broker's `canonicalRoot`, first match wins per entry.
87    pub workspace_roots: Vec<PathBuf>,
88    /// `<managed root>/rightkit-build-control`; `None` skips the broker step.
89    pub broker_root: Option<PathBuf>,
90    /// The app-specific env var name (`ui.binary_env`).
91    pub extra_env: Option<&'a str>,
92    /// Pointer file path, relative to the app root unless absolute.
93    pub pointer_file: Option<&'a str>,
94    /// The configured `ui.binaries` path for this platform, relative to the app root.
95    pub app_relative: Option<&'a str>,
96    pub env: &'a dyn Fn(&str) -> Option<String>,
97}
98
99/// The broker root under a managed root.
100pub fn broker_root_of(managed_root: &Path) -> PathBuf {
101    managed_root.join("rightkit-build-control")
102}
103
104pub fn resolve(q: &BinaryQuery) -> Result<ResolvedBinary> {
105    if q.bin.trim().is_empty() {
106        return err("binary name is empty");
107    }
108    // 1. Env override.
109    let mut names = vec![APP_BINARY_ENV.to_string(), UI_BINARY_ENV.to_string()];
110    if let Some(n) = q.extra_env.filter(|n| !n.trim().is_empty()) {
111        names.push(n.to_string());
112    }
113    for name in names {
114        if let Some(v) = (q.env)(&name)
115            .map(|v| v.trim().to_string())
116            .filter(|v| !v.is_empty())
117        {
118            let path = absolute(q.app_root, &v);
119            validate(&path, q.bin).map_err(|e| Error(format!("{name}: {}", e.0)))?;
120            require_qa(&path).map_err(|e| Error(format!("{name}: {}", e.0)))?;
121            return Ok(ResolvedBinary {
122                path,
123                source: BinarySource::Env(name),
124            });
125        }
126    }
127    // 2. Pointer file.
128    let pointer = absolute(q.app_root, q.pointer_file.unwrap_or(POINTER_FILE));
129    if let Ok(text) = std::fs::read_to_string(&pointer) {
130        if let Some(line) = text.lines().map(str::trim).find(|l| !l.is_empty()) {
131            let path = absolute(q.app_root, line);
132            validate(&path, q.bin)
133                .map_err(|e| Error(format!("pointer file {}: {}", pointer.display(), e.0)))?;
134            require_qa(&path)
135                .map_err(|e| Error(format!("pointer file {}: {}", pointer.display(), e.0)))?;
136            return Ok(ResolvedBinary {
137                path,
138                source: BinarySource::PointerFile(pointer),
139            });
140        }
141    }
142    // 3. Broker target root.
143    let mut notes = Vec::new();
144    let mut not_qa: Option<PathBuf> = None;
145    if let Some(broker) = &q.broker_root {
146        let (found, skipped) = from_broker(broker, &q.workspace_roots, q.bin);
147        if let Some((path, ws)) = found {
148            return Ok(ResolvedBinary {
149                path,
150                source: BinarySource::BrokerTarget(ws),
151            });
152        }
153        if let Some(first) = skipped.first() {
154            not_qa = Some(first.clone());
155        }
156        notes.push(format!(
157            "no QA dev build of {} under {} (skipped without the control marker: {})",
158            q.bin,
159            broker.display(),
160            skipped.len()
161        ));
162    }
163    // 4. App-root relative.
164    if let Some(rel) = q.app_relative {
165        let path = absolute(q.app_root, rel);
166        match validate(&path, q.bin) {
167            Ok(()) => match has_qa_marker(&path) {
168                Ok(true) => {
169                    return Ok(ResolvedBinary {
170                        path,
171                        source: BinarySource::AppRoot,
172                    })
173                }
174                _ => {
175                    notes.push(format!("{} lacks the control marker", path.display()));
176                    not_qa.get_or_insert(path);
177                }
178            },
179            Err(e) => notes.push(e.0),
180        }
181    }
182    if let Some(found) = not_qa {
183        return err(format!("{}; {}", not_qa_message(&found), notes.join("; ")));
184    }
185    err(format!(
186        "no dev binary for {}: set {APP_BINARY_ENV}, write a path into {}, or build it ({})",
187        q.bin,
188        pointer.display(),
189        notes.join("; ")
190    ))
191}
192
193fn not_qa_message(path: &Path) -> String {
194    format!(
195        "found {} but it isn't a test-mode/control build; run the suite with --build-ui or build with the app's QA feature",
196        path.display()
197    )
198}
199
200fn require_qa(path: &Path) -> Result<()> {
201    match has_qa_marker(path) {
202        Ok(true) => Ok(()),
203        Ok(false) => err(not_qa_message(path)),
204        Err(e) => err(format!("cannot read {}: {e}", path.display())),
205    }
206}
207
208/// True when the file contains [`QA_CONTROL_MARKER`] as Latin-1/UTF-8 bytes or as UTF-16LE (Windows
209/// wide string). Streamed in chunks, so a large executable is never held in memory.
210pub fn has_qa_marker(path: &Path) -> std::io::Result<bool> {
211    use std::io::Read;
212    let narrow = QA_CONTROL_MARKER.as_bytes().to_vec();
213    let wide: Vec<u8> = QA_CONTROL_MARKER.bytes().flat_map(|b| [b, 0]).collect();
214    let keep = wide.len() - 1;
215    let find = |hay: &[u8], pat: &[u8]| hay.windows(pat.len()).any(|w| w == pat);
216    let mut f = std::fs::File::open(path)?;
217    let mut buf = vec![0u8; 1 << 20];
218    let mut carry: Vec<u8> = Vec::new();
219    loop {
220        let n = f.read(&mut buf)?;
221        if n == 0 {
222            return Ok(false);
223        }
224        carry.extend_from_slice(&buf[..n]);
225        if find(&carry, &narrow) || find(&carry, &wide) {
226            return Ok(true);
227        }
228        if carry.len() > keep {
229            carry.drain(..carry.len() - keep);
230        }
231    }
232}
233
234/// Exists, a regular file, named `<bin>` or `<bin>.exe`, and not in a `release` profile directory.
235pub fn validate(path: &Path, bin: &str) -> Result<()> {
236    crate::installed::refuse_installed(path, "app binary")?;
237    let meta =
238        std::fs::metadata(path).map_err(|_| Error(format!("{} does not exist", path.display())))?;
239    if !meta.is_file() {
240        return err(format!("{} is not a file", path.display()));
241    }
242    if !name_matches(path, bin) {
243        return err(format!("{} is not named {bin}", path.display()));
244    }
245    if path
246        .parent()
247        .and_then(|p| p.file_name())
248        .is_some_and(|n| n == "release")
249    {
250        return err(format!("{} is a release-tier artifact", path.display()));
251    }
252    Ok(())
253}
254
255fn name_matches(path: &Path, bin: &str) -> bool {
256    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
257        return false;
258    };
259    let exe = format!("{bin}.exe");
260    name == bin || name == exe || (cfg!(windows) && name.eq_ignore_ascii_case(&exe))
261}
262
263fn absolute(root: &Path, text: &str) -> PathBuf {
264    let p = Path::new(text);
265    if p.is_absolute() {
266        p.to_path_buf()
267    } else {
268        root.join(p)
269    }
270}
271
272fn norm(text: &str) -> String {
273    let t = text.strip_prefix(r"\\?\").unwrap_or(text);
274    let t = t.replace('\\', "/");
275    let t = t.trim_end_matches('/').to_string();
276    if cfg!(windows) {
277        t.to_lowercase()
278    } else {
279        t
280    }
281}
282
283fn norm_path(p: &Path) -> String {
284    let real = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
285    norm(&real.to_string_lossy())
286}
287
288/// Newest QA dev-profile `<bin>` in any broker workspace whose canonical root is one of `roots`.
289/// Returns the executable and its `ws/<key>` directory, plus the valid candidates skipped for
290/// lacking the control marker (newest first).
291fn from_broker(
292    broker: &Path,
293    roots: &[PathBuf],
294    bin: &str,
295) -> (Option<(PathBuf, PathBuf)>, Vec<PathBuf>) {
296    let wanted: Vec<String> = roots.iter().map(|r| norm_path(r)).collect();
297    let mut all: Vec<(std::time::SystemTime, PathBuf, PathBuf)> = Vec::new();
298    let Ok(rd) = std::fs::read_dir(broker.join("ws")) else {
299        return (None, vec![]);
300    };
301    for entry in rd.flatten() {
302        let ws = entry.path();
303        let Ok(text) = std::fs::read_to_string(ws.join("identity.json")) else {
304            continue;
305        };
306        let Ok(id) = serde_json::from_str::<serde_json::Value>(&text) else {
307            continue;
308        };
309        let Some(root) = id.get("canonicalRoot").and_then(|v| v.as_str()) else {
310            continue;
311        };
312        if !wanted.contains(&norm_path(Path::new(root))) {
313            continue;
314        }
315        for file in broker_candidates(&ws, bin) {
316            if validate(&file, bin).is_err() {
317                continue;
318            }
319            let Ok(t) = std::fs::metadata(&file).and_then(|m| m.modified()) else {
320                continue;
321            };
322            all.push((t, file, ws.clone()));
323        }
324    }
325    all.sort_by_key(|a| std::cmp::Reverse(a.0));
326    let mut skipped = Vec::new();
327    for (_, file, ws) in all {
328        if has_qa_marker(&file).unwrap_or(false) {
329            return (Some((file, ws)), skipped);
330        }
331        skipped.push(file);
332    }
333    (None, skipped)
334}
335
336fn subdirs(dir: &Path) -> Vec<PathBuf> {
337    std::fs::read_dir(dir)
338        .map(|rd| {
339            rd.flatten()
340                .map(|e| e.path())
341                .filter(|p| p.is_dir())
342                .collect()
343        })
344        .unwrap_or_default()
345}
346
347fn is_dev_profile(p: &Path) -> bool {
348    p.file_name()
349        .and_then(|n| n.to_str())
350        .is_some_and(|n| DEV_PROFILES.contains(&n))
351}
352
353/// `g/<gen>/<leaf>/[<triple>/]<dev profile>/<bin>[.exe]` under one workspace.
354fn broker_candidates(ws: &Path, bin: &str) -> Vec<PathBuf> {
355    let mut out = Vec::new();
356    for generation in subdirs(&ws.join("g")) {
357        for leaf in subdirs(&generation) {
358            if !matches!(
359                leaf.file_name().and_then(|n| n.to_str()),
360                Some("t" | "target")
361            ) {
362                continue;
363            }
364            for child in subdirs(&leaf) {
365                let profiles = if is_dev_profile(&child) {
366                    vec![child]
367                } else {
368                    subdirs(&child)
369                };
370                for profile in profiles.into_iter().filter(|p| is_dev_profile(p)) {
371                    for name in [bin.to_string(), format!("{bin}.exe")] {
372                        let f = profile.join(name);
373                        if f.is_file() {
374                            out.push(f);
375                        }
376                    }
377                }
378            }
379        }
380    }
381    out
382}
383
384/// True when `path` already sits under the fixed stash `<app_root>/<STASH_DIR>`.
385pub fn is_stashed(path: &Path, app_root: &Path) -> bool {
386    let stash = app_root.join(STASH_DIR);
387    path.starts_with(&stash)
388        || match (path.canonicalize(), stash.canonicalize()) {
389            (Ok(p), Ok(s)) => p.starts_with(s),
390            _ => false,
391        }
392}
393
394/// [`resolve`], then ALWAYS the fixed stash: whichever source wins (env, pointer, broker target or an
395/// app-root path), the returned path is the stash copy `<app_root>/.rightkit-qa-bin/<triple>/<bin>`, copied
396/// in place with a kept `<bin>.previous` ([`stash_qa_build`]). A QA launch therefore never runs from a
397/// broker generation path (`ws/<hash>/g/<gen>/...`, a new folder on every generation) or from
398/// `target/debug`, so macOS privacy grants keep one path. `source` still names where the build was
399/// found. The pointer file is not rewritten (a fresh build is picked up on the next run); the stash
400/// copy of the same bytes does not rotate `.previous`.
401pub fn resolve_stashed(q: &BinaryQuery) -> Result<ResolvedBinary> {
402    let found = resolve(q)?;
403    if is_stashed(&found.path, q.app_root) {
404        return Ok(found);
405    }
406    let path = stash_binary(&found.path, q.app_root, None, true)?;
407    Ok(ResolvedBinary {
408        path,
409        source: found.source,
410    })
411}
412
413/// After a successful `--build-ui`: copy the built QA executable (and a sibling `.pdb`) to
414/// `<app_root>/<STASH_DIR>/<triple-or-host>/<name>` in place (staging copy, then rename, keeping
415/// `<name>.previous`), and point `<app_root>/.rightkit-qa-app-binary` (or `pointer_file`) at the copy.
416/// A later plain dev build that overwrites `debug/<bin>` then cannot displace it. The built file must
417/// carry the control marker.
418pub fn stash_qa_build(exe: &Path, app_root: &Path, pointer_file: Option<&str>) -> Result<PathBuf> {
419    let dest = stash_binary(exe, app_root, Some(pointer_file), true)?;
420    Ok(dest)
421}
422
423/// The stash copy itself. `pointer` is `Some(<pointer file override>)` to write the pointer file, `None` to leave
424/// it alone; `require_marker` keeps the QA control-marker check (an explicitly named UI binary skips it, as
425/// before, and passes `false`). The caller has already refused installed apps.
426pub fn stash_binary(
427    exe: &Path,
428    app_root: &Path,
429    pointer: Option<Option<&str>>,
430    require_marker: bool,
431) -> Result<PathBuf> {
432    if require_marker {
433        require_qa(exe)?;
434    }
435    let name = exe
436        .file_name()
437        .ok_or_else(|| Error(format!("{} has no file name", exe.display())))?;
438    let profile = exe
439        .parent()
440        .ok_or_else(|| Error("artifact has no profile dir".into()))?;
441    // `<target>/[<triple>/]<profile>/<name>`: a parent dir name with `-` is the target triple.
442    let up_name = profile
443        .parent()
444        .and_then(|p| p.file_name())
445        .and_then(|n| n.to_str())
446        .unwrap_or("");
447    let triple = if up_name.contains('-') {
448        up_name
449    } else {
450        "host"
451    };
452    // Under the app root, not the cargo target dir: the broker's target tree is garbage-collected and
453    // its generation folder changes, so nothing may run from it.
454    let dir = app_root.join(STASH_DIR).join(triple);
455    std::fs::create_dir_all(&dir)
456        .map_err(|e| Error(format!("cannot create {}: {e}", dir.display())))?;
457    let dest = dir.join(name);
458    crate::inplace::replace_file_in_place(exe, &dest)?;
459    let stem = exe.file_stem().and_then(|n| n.to_str()).unwrap_or("");
460    for pdb in [stem.to_string(), stem.replace('-', "_")] {
461        let from = profile.join(format!("{pdb}.pdb"));
462        if from.is_file() {
463            crate::inplace::replace_file_in_place(&from, &dir.join(format!("{pdb}.pdb")))?;
464        }
465    }
466    if let Some(pointer_file) = pointer {
467        let pointer = absolute(app_root, pointer_file.unwrap_or(POINTER_FILE));
468        let tmp = pointer.with_extension(format!("tmp-{}", crate::util::new_id()));
469        std::fs::write(&tmp, format!("{}\n", dest.display()))
470            .and_then(|_| std::fs::rename(&tmp, &pointer))
471            .map_err(|e| Error(format!("cannot write {}: {e}", pointer.display())))?;
472    }
473    Ok(dest)
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use std::time::{Duration, SystemTime};
480
481    fn tmp(tag: &str) -> PathBuf {
482        let d = std::env::temp_dir().join(format!("rkqa-binary-{tag}-{}", crate::util::new_id()));
483        std::fs::create_dir_all(&d).unwrap();
484        d
485    }
486
487    /// A fixture executable that carries the control marker.
488    fn touch(p: &Path, age_s: u64) {
489        write_exe(p, age_s, true);
490    }
491
492    fn write_exe(p: &Path, age_s: u64, marker: bool) {
493        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
494        let mut bytes = b"MZ....padding".to_vec();
495        if marker {
496            bytes.extend_from_slice(QA_CONTROL_MARKER.as_bytes());
497        }
498        std::fs::write(p, bytes).unwrap();
499        let t = SystemTime::now() - Duration::from_secs(age_s);
500        std::fs::File::options()
501            .write(true)
502            .open(p)
503            .unwrap()
504            .set_modified(t)
505            .unwrap();
506    }
507
508    fn exe(name: &str) -> String {
509        if cfg!(windows) {
510            format!("{name}.exe")
511        } else {
512            name.to_string()
513        }
514    }
515
516    /// A fixture broker with one workspace for `root`.
517    fn broker(base: &Path, root: &Path) -> PathBuf {
518        let ws = base.join("rightkit-build-control/ws/abc123");
519        std::fs::create_dir_all(&ws).unwrap();
520        let id = serde_json::json!({"schemaVersion":1,"key":"abc123","canonicalRoot":root.to_string_lossy()});
521        std::fs::write(ws.join("identity.json"), id.to_string()).unwrap();
522        ws
523    }
524
525    fn query<'a>(
526        bin: &'a str,
527        app: &'a Path,
528        broker: Option<PathBuf>,
529        env: &'a dyn Fn(&str) -> Option<String>,
530    ) -> BinaryQuery<'a> {
531        BinaryQuery {
532            bin,
533            app_root: app,
534            workspace_roots: vec![app.to_path_buf()],
535            broker_root: broker,
536            extra_env: Some("MYAPP_APP_BINARY"),
537            pointer_file: None,
538            app_relative: Some("target/debug/myapp"),
539            env,
540        }
541    }
542
543    #[test]
544    fn broker_picks_newest_dev_profile_and_never_release() {
545        let base = tmp("broker");
546        let app = base.join("app");
547        std::fs::create_dir_all(&app).unwrap();
548        let ws = broker(&base, &app);
549        let old = ws
550            .join("g/001/t/x86_64-pc-windows-msvc/release-iterate")
551            .join(exe("myapp"));
552        let new = ws
553            .join("g/002/t/x86_64-pc-windows-msvc/debug")
554            .join(exe("myapp"));
555        let rel = ws
556            .join("g/002/t/x86_64-pc-windows-msvc/release")
557            .join(exe("myapp"));
558        touch(&old, 500);
559        touch(&new, 100);
560        touch(&rel, 1); // newest, but release tier
561        let env = |_: &str| None;
562        let got = resolve(&query(
563            "myapp",
564            &app,
565            Some(base.join("rightkit-build-control")),
566            &env,
567        ))
568        .unwrap();
569        assert_eq!(got.path, new);
570        assert_eq!(got.source, BinarySource::BrokerTarget(ws));
571    }
572
573    #[test]
574    fn broker_ignores_other_workspaces_and_other_bins() {
575        let base = tmp("other");
576        let app = base.join("app");
577        let other = base.join("other");
578        std::fs::create_dir_all(&app).unwrap();
579        std::fs::create_dir_all(&other).unwrap();
580        let ws = broker(&base, &other);
581        touch(&ws.join("g/001/target/debug").join(exe("myapp")), 10);
582        let ws2 = base.join("rightkit-build-control/ws/def456");
583        std::fs::create_dir_all(&ws2).unwrap();
584        let id = serde_json::json!({"canonicalRoot": app.to_string_lossy()});
585        std::fs::write(ws2.join("identity.json"), id.to_string()).unwrap();
586        touch(&ws2.join("g/001/target/debug").join(exe("helper")), 10);
587        let env = |_: &str| None;
588        let e = resolve(&query(
589            "myapp",
590            &app,
591            Some(base.join("rightkit-build-control")),
592            &env,
593        ))
594        .unwrap_err();
595        assert!(e.0.contains("no dev binary for myapp"), "{}", e.0);
596    }
597
598    #[test]
599    fn env_beats_pointer_beats_broker_beats_app_root() {
600        let base = tmp("order");
601        let app = base.join("app");
602        let ws = broker(&base, &app);
603        let from_broker = ws.join("g/001/t/debug").join(exe("myapp"));
604        touch(&from_broker, 10);
605        let rel = app.join("target/debug/myapp");
606        touch(&rel, 10);
607        touch(&app.join("target/debug/myapp.exe"), 10);
608        let b = Some(base.join("rightkit-build-control"));
609
610        let none = |_: &str| None;
611        let got = resolve(&query("myapp", &app, b.clone(), &none)).unwrap();
612        assert!(matches!(got.source, BinarySource::BrokerTarget(_)));
613
614        let pointed = base.join("pointed").join(exe("myapp"));
615        touch(&pointed, 10);
616        std::fs::write(app.join(POINTER_FILE), format!("\n{}\n", pointed.display())).unwrap();
617        let got = resolve(&query("myapp", &app, b.clone(), &none)).unwrap();
618        assert_eq!(got.path, pointed);
619        assert!(matches!(got.source, BinarySource::PointerFile(_)));
620
621        let envd = base.join("envd").join(exe("myapp"));
622        touch(&envd, 10);
623        let p = envd.to_string_lossy().into_owned();
624        let env = move |k: &str| (k == "MYAPP_APP_BINARY").then(|| p.clone());
625        let got = resolve(&query("myapp", &app, b.clone(), &env)).unwrap();
626        assert_eq!(got.path, envd);
627        assert_eq!(got.source, BinarySource::Env("MYAPP_APP_BINARY".into()));
628
629        // With no broker and no pointer, the app-root path wins.
630        std::fs::remove_file(app.join(POINTER_FILE)).unwrap();
631        let got = resolve(&query("myapp", &app, None, &none)).unwrap();
632        assert_eq!(got.source, BinarySource::AppRoot);
633    }
634
635    #[test]
636    fn invalid_override_is_an_error_not_a_fall_through() {
637        let base = tmp("bad");
638        let app = base.join("app");
639        std::fs::create_dir_all(&app).unwrap();
640        let wrong = base.join("wrong").join(exe("helper"));
641        touch(&wrong, 1);
642        let p = wrong.to_string_lossy().into_owned();
643        let env = move |k: &str| (k == APP_BINARY_ENV).then(|| p.clone());
644        let e = resolve(&query("myapp", &app, None, &env)).unwrap_err();
645        assert!(e.0.contains("is not named myapp"), "{}", e.0);
646        let missing = |k: &str| (k == APP_BINARY_ENV).then(|| "nope/myapp".to_string());
647        assert!(resolve(&query("myapp", &app, None, &missing))
648            .unwrap_err()
649            .0
650            .contains("does not exist"));
651    }
652
653    #[test]
654    fn validate_rejects_directories_and_release_dirs() {
655        let base = tmp("val");
656        let dir = base.join("myapp");
657        std::fs::create_dir_all(&dir).unwrap();
658        assert!(validate(&dir, "myapp")
659            .unwrap_err()
660            .0
661            .contains("not a file"));
662        let rel = base.join("target/release").join(exe("myapp"));
663        touch(&rel, 1);
664        assert!(validate(&rel, "myapp")
665            .unwrap_err()
666            .0
667            .contains("release-tier"));
668        let ok = base.join("target/release-iterate").join(exe("myapp"));
669        touch(&ok, 1);
670        assert!(validate(&ok, "myapp").is_ok());
671    }
672
673    #[test]
674    fn marker_scan_finds_narrow_wide_and_chunk_straddling_bytes() {
675        let base = tmp("marker");
676        let narrow = base.join("n.exe");
677        write_exe(&narrow, 1, true);
678        assert!(has_qa_marker(&narrow).unwrap());
679        let none = base.join("none.exe");
680        write_exe(&none, 1, false);
681        assert!(!has_qa_marker(&none).unwrap());
682        let wide = base.join("w.exe");
683        let mut b = vec![7u8; 100];
684        b.extend(QA_CONTROL_MARKER.bytes().flat_map(|c| [c, 0]));
685        std::fs::write(&wide, b).unwrap();
686        assert!(has_qa_marker(&wide).unwrap());
687        // The marker straddles the 1 MiB read boundary.
688        let big = base.join("big.exe");
689        let mut b = vec![1u8; (1 << 20) - 10];
690        b.extend_from_slice(QA_CONTROL_MARKER.as_bytes());
691        std::fs::write(&big, b).unwrap();
692        assert!(has_qa_marker(&big).unwrap());
693        // A truncated marker is not a match.
694        let part = base.join("part.exe");
695        std::fs::write(&part, &QA_CONTROL_MARKER.as_bytes()[..20]).unwrap();
696        assert!(!has_qa_marker(&part).unwrap());
697    }
698
699    #[test]
700    fn broker_skips_non_qa_newest_and_takes_the_qa_one() {
701        let base = tmp("skip");
702        let app = base.join("app");
703        std::fs::create_dir_all(&app).unwrap();
704        let ws = broker(&base, &app);
705        let qa = ws.join("g/001/t/debug").join(exe("myapp"));
706        let plain = ws.join("g/002/t/debug").join(exe("myapp"));
707        write_exe(&qa, 500, true);
708        write_exe(&plain, 5, false); // newest, but a plain dev build
709        let env = |_: &str| None;
710        let b = Some(base.join("rightkit-build-control"));
711        let got = resolve(&query("myapp", &app, b.clone(), &env)).unwrap();
712        assert_eq!(got.path, qa);
713        // Only the plain build exists: fail closed with the instructive message.
714        std::fs::remove_file(&qa).unwrap();
715        let e = resolve(&query("myapp", &app, b, &env)).unwrap_err();
716        assert!(
717            e.0.contains("but it isn't a test-mode/control build"),
718            "{}",
719            e.0
720        );
721        assert!(e.0.contains("--build-ui"), "{}", e.0);
722    }
723
724    #[test]
725    fn env_and_pointer_without_marker_are_errors() {
726        let base = tmp("nomarker");
727        let app = base.join("app");
728        std::fs::create_dir_all(&app).unwrap();
729        let plain = base.join("p").join(exe("myapp"));
730        write_exe(&plain, 1, false);
731        let p = plain.to_string_lossy().into_owned();
732        let env = move |k: &str| (k == APP_BINARY_ENV).then(|| p.clone());
733        assert!(resolve(&query("myapp", &app, None, &env))
734            .unwrap_err()
735            .0
736            .contains("isn't a test-mode/control build"));
737        std::fs::write(app.join(POINTER_FILE), plain.to_string_lossy().as_bytes()).unwrap();
738        let none = |_: &str| None;
739        let e = resolve(&query("myapp", &app, None, &none)).unwrap_err();
740        assert!(
741            e.0.contains("pointer file") && e.0.contains("isn't a test-mode/control build"),
742            "{}",
743            e.0
744        );
745    }
746
747    #[test]
748    fn app_root_candidate_without_marker_is_refused() {
749        let base = tmp("approot");
750        let app = base.join("app");
751        write_exe(&app.join("target/debug").join(exe("myapp")), 1, false);
752        write_exe(&app.join("target/debug/myapp"), 1, false);
753        let none = |_: &str| None;
754        let e = resolve(&query("myapp", &app, None, &none)).unwrap_err();
755        assert!(e.0.contains("isn't a test-mode/control build"), "{}", e.0);
756    }
757
758    #[test]
759    fn resolve_stashed_never_returns_a_broker_generation_path() {
760        let base = tmp("stashed");
761        let app = base.join("app");
762        std::fs::create_dir_all(&app).unwrap();
763        let ws = broker(&base, &app);
764        let gen1 = ws.join("g/001/t/debug").join(exe("myapp"));
765        touch(&gen1, 10);
766        let env = |_: &str| None;
767        let b = Some(base.join("rightkit-build-control"));
768        let q = query("myapp", &app, b.clone(), &env);
769        assert!(matches!(
770            resolve(&q).unwrap().source,
771            BinarySource::BrokerTarget(_)
772        ));
773        let got = resolve_stashed(&q).unwrap();
774        assert!(matches!(got.source, BinarySource::BrokerTarget(_)));
775        assert!(is_stashed(&got.path, &app));
776        assert!(got.path.starts_with(app.join(STASH_DIR)));
777        assert!(!got.path.starts_with(&ws));
778        // A new generation lands in a new folder; the launch path stays the same, `.previous` keeps the old copy.
779        let gen2 = ws.join("g/002/t/debug").join(exe("myapp"));
780        std::fs::create_dir_all(gen2.parent().unwrap()).unwrap();
781        std::fs::write(
782            &gen2,
783            [b"MZ-gen2".as_slice(), QA_CONTROL_MARKER.as_bytes()].concat(),
784        )
785        .unwrap();
786        let later = resolve_stashed(&query("myapp", &app, b, &env)).unwrap();
787        assert_eq!(later.path, got.path);
788        assert!(crate::inplace::previous_path(&got.path).is_file());
789        // The app-root source is stashed too, and an env override is no exception.
790        let rel = app.join("target/debug/myapp");
791        touch(&rel, 5);
792        let envd = base.join("envd").join(exe("myapp"));
793        touch(&envd, 5);
794        let p = envd.to_string_lossy().into_owned();
795        let env2 = move |k: &str| (k == "MYAPP_APP_BINARY").then(|| p.clone());
796        let from_env = resolve_stashed(&query("myapp", &app, None, &env2)).unwrap();
797        assert!(from_env.path.starts_with(app.join(STASH_DIR)));
798        assert_eq!(
799            from_env.source,
800            BinarySource::Env("MYAPP_APP_BINARY".into())
801        );
802        // The installed-app refusal from 0.6.3 still applies to the resolved source.
803        assert!(is_stashed(&from_env.path, &app) && !is_stashed(&envd, &app));
804    }
805
806    #[test]
807    fn stash_copies_atomically_and_writes_the_pointer() {
808        let base = tmp("stash");
809        let app = base.join("app");
810        std::fs::create_dir_all(&app).unwrap();
811        let built = base
812            .join("target/x86_64-pc-windows-msvc/debug")
813            .join(exe("myapp"));
814        write_exe(&built, 5, true);
815        std::fs::write(built.with_extension("pdb"), b"pdb").unwrap();
816        let got = stash_qa_build(&built, &app, None).unwrap();
817        assert_eq!(
818            got,
819            app.join(".rightkit-qa-bin/x86_64-pc-windows-msvc")
820                .join(exe("myapp"))
821        );
822        assert!(has_qa_marker(&got).unwrap());
823        assert!(got.with_extension("pdb").is_file());
824        // Stashing a different build replaces it in place and keeps exactly one `.previous`.
825        write_exe(&built, 3, true);
826        std::fs::write(
827            &built,
828            [b"MZ-second".as_slice(), QA_CONTROL_MARKER.as_bytes()].concat(),
829        )
830        .unwrap();
831        let again = stash_qa_build(&built, &app, None).unwrap();
832        assert_eq!(again, got);
833        let prev = crate::inplace::previous_path(&got);
834        assert!(prev.is_file(), "the earlier stash is kept as .previous");
835        assert!(!has_qa_marker(&prev).is_err());
836        // A later plain dev build overwrites the original; the stashed copy and pointer still win.
837        write_exe(&built, 1, false);
838        let none = |_: &str| None;
839        let r = resolve(&query("myapp", &app, None, &none)).unwrap();
840        assert_eq!(r.path, got);
841        assert!(matches!(r.source, BinarySource::PointerFile(_)));
842        // No triple directory: stored under `host`.
843        let plain_layout = base.join("t2/debug").join(exe("myapp"));
844        write_exe(&plain_layout, 1, true);
845        assert!(stash_qa_build(&plain_layout, &app, None)
846            .unwrap()
847            .starts_with(app.join(".rightkit-qa-bin/host")));
848        // A build without the marker is refused.
849        let bad = base.join("t3/debug").join(exe("myapp"));
850        write_exe(&bad, 1, false);
851        assert!(stash_qa_build(&bad, &app, None).is_err());
852    }
853}