Skip to main content

opencode_ctl/launcher/
windows.rs

1//! Windows rules that are pure text and path logic, compiled on every platform so the Linux and macOS
2//! legs test them: the verbatim-prefix strip, the `wt` directory argument, `PATHEXT` resolution, the
3//! `cmd.exe /c` wrap and the `.ps1` wrapper.
4
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use super::WrapperInput;
9
10/// `\\?\C:\x` -> `C:\x` and `\\?\UNC\server\share` -> `\\server\share` (what
11/// `std::fs::canonicalize` returns on Windows); any other path is returned unchanged.
12pub fn strip_verbatim(p: &str) -> String {
13    if let Some(rest) = p.strip_prefix(r"\\?\UNC\") {
14        return format!(r"\\{rest}");
15    }
16    if let Some(rest) = p.strip_prefix(r"\\?\") {
17        return rest.to_string();
18    }
19    p.to_string()
20}
21
22/// The `-d` argument of `wt`, passed through unchanged. MEASURED on Windows 11 with wt: a bare `C:\`
23/// opens the drive root, while the `C:` this once produced opens the drive's current directory
24/// (`C:\WINDOWS\system32` there). The trailing-backslash worry applies only to a hand-quoted `"C:\"`,
25/// and `Command` quotes only arguments that contain whitespace, which a drive root never does.
26pub fn wt_directory_arg(dir: &str) -> String {
27    dir.to_string()
28}
29
30const DEFAULT_PATHEXT: &str = ".COM;.EXE;.BAT;.CMD";
31
32fn is_cmd_ext(ext: &str) -> bool {
33    ext.eq_ignore_ascii_case(".cmd") || ext.eq_ignore_ascii_case(".bat")
34}
35
36/// The Windows lookup of a bare command name: every directory of `path` (`;`-separated) in order,
37/// and in each, the name as is when it already ends in a `pathext` extension, then the name plus
38/// each `pathext` extension in order. The flag is true for a `.cmd`/`.bat` script, which
39/// `CreateProcess` cannot start by bare name and `cmd_wrap` runs through `cmd.exe /c`.
40pub fn resolve_executable(name: &str, path: &str, pathext: &str) -> Option<(PathBuf, bool)> {
41    let exts: Vec<&str> = pathext.split(';').filter(|e| !e.is_empty()).collect();
42    let own_ext = name
43        .rfind('.')
44        .map(|i| &name[i..])
45        .filter(|e| exts.iter().any(|x| x.eq_ignore_ascii_case(e)));
46    for dir in path.split(';').filter(|d| !d.is_empty()) {
47        let dir = Path::new(dir);
48        if let Some(ext) = own_ext {
49            let cand = dir.join(name);
50            if cand.is_file() {
51                return Some((cand, is_cmd_ext(ext)));
52            }
53        }
54        for ext in &exts {
55            let cand = dir.join(format!("{name}{ext}"));
56            if cand.is_file() {
57                return Some((cand, is_cmd_ext(ext)));
58            }
59        }
60    }
61    None
62}
63
64/// `cmd.exe /c <exe> <args>` (spec Launcher step 5: a `.cmd` controller).
65pub fn cmd_wrap(exe: &Path, args: &[String]) -> Vec<String> {
66    let mut v = vec![
67        "cmd.exe".to_string(),
68        "/c".to_string(),
69        exe.to_string_lossy().into_owned(),
70    ];
71    v.extend(args.iter().cloned());
72    v
73}
74
75/// The single command line `cmd.exe /c` receives for a `.cmd` controller. `cmd /c` strips the first and the
76/// last quote of a line that holds more than two, so a quoted executable followed by any quoted argument
77/// breaks (MEASURED, Task 25: `'C:\Users\x' is not recognized`, and a quoted `a&echo INJECTED` ran), hence the
78/// ONE outer pair. The executable is always quoted, an argument only when it is empty or has whitespace, and a
79/// backslash run at the end of a quoted argument is doubled so it cannot escape the closing quote. The
80/// launcher already refuses `" % & | < > ^` and newlines in controller arguments.
81pub fn cmd_line(exe: &Path, args: &[String]) -> String {
82    let quote = |a: &str| -> String {
83        if a.is_empty() || a.contains([' ', '\t']) {
84            let trailing = a.len() - a.trim_end_matches('\\').len();
85            format!("\"{a}{}\"", "\\".repeat(trailing))
86        } else {
87            a.to_string()
88        }
89    };
90    let mut parts = vec![format!("\"{}\"", exe.to_string_lossy())];
91    parts.extend(args.iter().map(|a| quote(a)));
92    format!("\"{}\"", parts.join(" "))
93}
94
95/// The process for an argv from `controller_command`. A `cmd.exe /c <script> <args>` argv on Windows gets the
96/// raw command line of `cmd_line` (the standard argument quoting breaks `cmd /c`); anything else is the plain
97/// program and arguments.
98pub fn controller_process(argv: &[String]) -> Command {
99    #[cfg(windows)]
100    if argv.len() >= 3 && argv[0] == "cmd.exe" && argv[1] == "/c" {
101        use std::os::windows::process::CommandExt;
102        let mut c = Command::new("cmd.exe");
103        c.raw_arg("/c")
104            .raw_arg(cmd_line(Path::new(&argv[2]), &argv[3..]));
105        return c;
106    }
107    let mut c = Command::new(&argv[0]);
108    c.args(&argv[1..]);
109    c
110}
111
112/// The argv that starts the controller. Off Windows the bare name and the arguments (the OS finds
113/// the executable). On Windows the executable is resolved through `PATHEXT` here, and a `.cmd`/`.bat`
114/// goes through `cmd.exe /c`; `None` is `CONTROLLER_NOT_FOUND`.
115pub fn controller_command(
116    name: &str,
117    args: &[String],
118    windows: bool,
119    path: Option<&str>,
120    pathext: Option<&str>,
121) -> Option<Vec<String>> {
122    if !windows {
123        let mut v = vec![name.to_string()];
124        v.extend(args.iter().cloned());
125        return Some(v);
126    }
127    let (exe, is_cmd) = resolve_executable(name, path?, pathext.unwrap_or(DEFAULT_PATHEXT))?;
128    if is_cmd {
129        return Some(cmd_wrap(&exe, args));
130    }
131    let mut v = vec![exe.to_string_lossy().into_owned()];
132    v.extend(args.iter().cloned());
133    Some(v)
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum WindowsShell {
138    Pwsh,
139    PowershellExe,
140}
141
142impl WindowsShell {
143    pub fn program(self) -> &'static str {
144        match self {
145            WindowsShell::Pwsh => "pwsh",
146            WindowsShell::PowershellExe => "powershell.exe",
147        }
148    }
149}
150
151/// `pwsh` when it is on PATH, else the stock `powershell.exe`.
152pub fn select_windows_shell(path_has: &dyn Fn(&str) -> bool) -> WindowsShell {
153    if path_has("pwsh") {
154        WindowsShell::Pwsh
155    } else {
156        WindowsShell::PowershellExe
157    }
158}
159
160/// Single-quote a value for PowerShell, doubling EVERY quote character it recognises: U+0027 and the
161/// typographic U+2018, U+2019, U+201A, U+201B (a lone one ends the string, or lets text after it run).
162pub fn quote_ps(v: &str) -> String {
163    let mut out = String::with_capacity(v.len() + 2);
164    out.push('\'');
165    for c in v.chars() {
166        out.push(c);
167        if matches!(c, '\'' | '\u{2018}' | '\u{2019}' | '\u{201a}' | '\u{201b}') {
168            out.push(c);
169        }
170    }
171    out.push('\'');
172    out
173}
174
175/// The spec's boot value: `LastBootUpTime` as epoch seconds truncated to the minute (the same
176/// expression `SystemProbe::boot_id` runs).
177pub const BOOT_EXPR_PS1: &str = "[math]::Floor([DateTimeOffset]::new((Get-CimInstance Win32_OperatingSystem).LastBootUpTime).ToUnixTimeSeconds() / 60) * 60";
178
179/// The start time as FILETIME, the number `SystemProbe::starttime` reads from `GetProcessTimes`.
180pub const START_EXPR_PS1: &str = "(Get-Process -Id $PID).StartTime.ToFileTime()";
181
182/// The `.ps1` tab wrapper (UTF-8 text, no BOM: `write_wrapper` prepends it). The sentinels are written with
183/// `[System.IO.File]::WriteAllText`, which writes UTF-8 without a BOM and without a newline on both
184/// PowerShell editions; `Out-File`, `>` and `Set-Content` do not.
185pub fn wrapper_script_ps1(input: &WrapperInput) -> String {
186    let q = |p: &Path| quote_ps(&p.to_string_lossy());
187    let pair_file = |suffix: &str| {
188        input.home.join(format!(
189            "pair.{}.g{}.{suffix}",
190            input.pair_id, input.generation
191        ))
192    };
193    let (boot, start) = if input.boot_id_expr {
194        (BOOT_EXPR_PS1, START_EXPR_PS1)
195    } else {
196        ("'BOOT'", "'START'")
197    };
198    let mut s = String::new();
199    s.push_str(&format!(
200        "# opencode-ctl tab wrapper: pair {} generation {}\n",
201        input.pair_id, input.generation
202    ));
203    s.push_str(&format!("$tuiPid = {}\n", q(&pair_file("tui-pid"))));
204    s.push_str(&format!("$tuiExited = {}\n", q(&pair_file("tui-exited"))));
205    s.push_str(&format!("$boot = {boot}\n"));
206    s.push_str(&format!("$start = {start}\n"));
207    s.push_str("[System.IO.File]::WriteAllText($tuiPid, ('{0}:{1}:{2}' -f $PID, $boot, $start))\n");
208    if let (Some(_), Some(pw)) = (input.server_url, input.server_password) {
209        s.push_str(&format!(
210            "$env:OPENCODE_SERVER_PASSWORD = {}\n",
211            quote_ps(pw)
212        ));
213        s.push_str("Remove-Item Env:OPENCODE_PASSWORD -ErrorAction SilentlyContinue\n");
214    }
215    s.push_str("try {\n");
216    let mut cmd = format!(
217        "& {} {} -s {}",
218        q(input.opencode_bin),
219        q(input.directory),
220        quote_ps(input.session_id)
221    );
222    if let Some(url) = input.server_url {
223        cmd.push_str(&format!(" --server {}", quote_ps(url)));
224    }
225    cmd.push('\n');
226    // The command sits at the start of its line (the tests read it that way).
227    s.push_str(&cmd);
228    s.push_str("} finally {\n");
229    s.push_str(
230        "[System.IO.File]::WriteAllText($tuiExited, [string][DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())\n",
231    );
232    s.push_str("}\n");
233    s
234}