Skip to main content

qcode/desktop/
signin.rs

1//! Signing in from a window: how the page the application wants opened reaches QCode, and the
2//! window of last resort it can be shown in inside the container.
3//!
4//! An application in a container asks the desktop to open a web address the way every Linux
5//! program does, by running `xdg-open`. Inside a container that is nobody: there is no browser,
6//! and until this was measured there was not even an `xdg-open`, so the call returned success and
7//! did nothing at all — the person pressed "sign in" and the machine stayed silent.
8//!
9//! So the image carries `xdg-utils` and a program of QCode's own, and the container is told
10//! through `BROWSER` to use it. That program writes the address into a folder the window's
11//! container shares with QCode, and nothing else: the folder is the workspace's own, and the only
12//! thing that travels through it is a line of text.
13//!
14//! QCode opens the page in the person's own browser, and carries the sign-in's way back to the
15//! application's `localhost` inside the container: that is [`super::callback`].
16//!
17//! It showed the page inside the container first, in a small browser window of its own, so that
18//! the `localhost` the sign-in returns to was the application's without anything carried. Google
19//! refuses that window: "Couldn't sign you in — This browser or app may not be secure." It stays
20//! in the image as the window of last resort, which the person can ask for from the tab, for a
21//! sign-in that is not Google's or for the day Google lets it through.
22//!
23//! That window costs the image next to nothing: it is the application's own Electron — a whole
24//! Chromium — started with a twenty-line program of QCode's instead of the application's. Electron
25//! finds the program to run beside the executable it was started as, and resolves a symbolic link
26//! to the real file first, which opened the editor itself when that was tried; a hard link is a
27//! file of its own name and keeps [`BROWSER_DIR`] as the place Electron looks. The links are made
28//! in the same build step that unpacks the archive, which is what keeps them free: made in a step
29//! of their own, the layer copies the 200 MB executable.
30//!
31//! The address is written to a temporary name and moved into place, so QCode never reads half a
32//! line; and every address QCode takes is removed as it is read, so a folder left behind cannot
33//! open yesterday's page tomorrow. Only an `http` or `https` address is shown at all; everything
34//! else is said on the tab instead.
35
36use std::path::{Path, PathBuf};
37
38/// The folder the window's container writes addresses into, seen from inside that container.
39pub const OPEN_DIR: &str = "/run/qcode-open";
40
41/// The program the container runs to open an address, seen from inside the container.
42pub const OPEN_PROGRAM: &str = "/usr/local/bin/qcode-open";
43
44/// What marks a file in that folder as an address waiting to be opened.
45const SUFFIX: &str = ".url";
46
47/// The program the image installs at [`OPEN_PROGRAM`].
48///
49/// It is a shell script rather than anything larger because the base image is the only thing it
50/// may rely on. It writes beside the final name and moves it into place so that a reader never
51/// sees half an address, and it says nothing on its output: whatever it printed would land in the
52/// application's own log, not in front of the person.
53pub const SCRIPT: &str = "#!/bin/sh\n\
54                          # Hands the address to QCode, which shows it in a window of this container.\n\
55                          set -eu\n\
56                          [ -n \"${1:-}\" ] || exit 1\n\
57                          [ -d \"$0_DIR\" ] || exit 1\n\
58                          name=\"$0_DIR/$(date +%s%N)\"\n\
59                          printf '%s\\n' \"$1\" > \"$name.part\"\n\
60                          mv \"$name.part\" \"$name.url\"\n";
61
62/// The script with its folder filled in, ready to be written into an image.
63#[must_use]
64pub fn script() -> String {
65    SCRIPT.replace("$0_DIR", OPEN_DIR)
66}
67
68/// The build step that puts [`OPEN_PROGRAM`] into an image, with the folder it writes into made
69/// in the image so that a container given nothing there still has it. It runs as root.
70///
71/// The script is written with `printf '%b'` from a single line: a `RUN` step is one line, so the
72/// script's own line breaks travel as `\n` and are turned back into breaks by printf.
73#[must_use]
74pub fn opener_step() -> String {
75    format!(
76        "RUN {write} \\\n && chmod 0755 '{OPEN_PROGRAM}' \\\n && mkdir -p '{OPEN_DIR}'",
77        write = written(&script(), OPEN_PROGRAM),
78    )
79}
80
81/// Where the sign-in window's browser lives in the image: hard links to the application's own
82/// Electron and its files, beside a program of QCode's own.
83pub const BROWSER_DIR: &str = "/opt/qcode-browser";
84
85/// The browser's executable, a hard link to the application's.
86pub const BROWSER_PROGRAM: &str = "/opt/qcode-browser/qcode-browser";
87
88/// What Electron reads first: the program's name, which is also the folder its cookies are kept
89/// in under `~/.config`, and the file it starts.
90pub const BROWSER_PACKAGE: &str = r#"{ "name": "qcode-browser", "version": "1.0.0", "main": "main.js" }
91"#;
92
93/// The browser itself: one window, showing the address it was started with.
94///
95/// A second start with another address hands it to the window already open rather than opening a
96/// second one, because two Chromiums cannot share one profile folder. The name of this program
97/// and of Electron are taken out of the user agent, leaving the Chromium it is; Google's sign-in
98/// is known to turn away browsers that call themselves embedded, and what is left is true.
99///
100/// Written without a backslash anywhere, so the one layer of escaping that takes it into the
101/// image cannot change a character of it.
102pub const BROWSER_MAIN: &str = r#"'use strict';
103const { app, BrowserWindow } = require('electron');
104const page = (words) => words.slice(1).reverse().find((word) => word.startsWith('https://') || word.startsWith('http://'));
105app.userAgentFallback = app.userAgentFallback.split(' ').filter((word) => !word.startsWith('Electron/') && !word.startsWith('qcode-browser/')).join(' ');
106if (!app.requestSingleInstanceLock()) {
107  app.quit();
108} else {
109  let shown = null;
110  app.on('second-instance', (event, words) => {
111    const next = page(words);
112    if (shown && next) {
113      shown.loadURL(next);
114      shown.show();
115      shown.focus();
116    }
117  });
118  app.whenReady().then(() => {
119    shown = new BrowserWindow({ width: 560, height: 760, autoHideMenuBar: true });
120    shown.setMenu(null);
121    const first = page(process.argv);
122    if (first) {
123      shown.loadURL(first);
124    }
125  });
126  app.on('window-all-closed', () => app.quit());
127}
128"#;
129
130/// The shell line that writes `contents` to `path`, for a build step.
131///
132/// A build step is one line, so the text travels with its line breaks as `\n`, inside single
133/// quotes, and `printf '%b'` turns them back into breaks.
134#[must_use]
135pub fn written(contents: &str, path: &str) -> String {
136    let quoted = contents.replace('\\', "\\\\").replace('\'', "'\\''").replace('\n', "\\n");
137    format!("printf '%b' '{quoted}' > '{path}'")
138}
139
140/// The commands that put the sign-in window's browser into an image, given the folder the
141/// application was unpacked into and its executable's name there. They belong in the very build
142/// step that unpacks it, after the unpacking, so that every link costs nothing.
143///
144/// Everything of the application is linked but its `resources`, which is where Electron would find
145/// the application itself; that folder is QCode's own here. The set-user-id helper Chromium's
146/// sandbox falls back to is linked too, and a hard link is the same file, bit and owner alike.
147#[must_use]
148pub fn browser_install(install_dir: &str, program: &str) -> Vec<String> {
149    vec![
150        format!("mkdir -p '{BROWSER_DIR}/resources/app'"),
151        format!(
152            "for part in '{install_dir}'/*; do [ \"${{part##*/}}\" = resources ] || cp -al \"$part\" '{BROWSER_DIR}/'; done"
153        ),
154        format!("mv '{BROWSER_DIR}/{program}' '{BROWSER_PROGRAM}'"),
155        written(BROWSER_PACKAGE, &format!("{BROWSER_DIR}/resources/app/package.json")),
156        written(BROWSER_MAIN, &format!("{BROWSER_DIR}/resources/app/main.js")),
157        format!("test -x '{BROWSER_PROGRAM}'"),
158    ]
159}
160
161/// What an exec into the window's container answers when its image has no sign-in window.
162pub const NO_BROWSER: &str = "qcode-no-browser";
163
164/// The command, run inside the window's container, that shows `address` in the sign-in window,
165/// started with the application's own `flags` so that it reaches the same compositor the same
166/// way.
167///
168/// The browser is left running on its own and not waited for: it lives as long as the person
169/// keeps it open, or as long as the container does. An image with no browser says [`NO_BROWSER`]
170/// rather than failing, so the one reason that has a remedy is told apart from an engine that
171/// refused.
172#[must_use]
173pub fn page_command(flags: &[&str], address: &str) -> Vec<String> {
174    let mut command = vec![
175        "sh".to_owned(),
176        "-c".to_owned(),
177        format!("[ -x \"$1\" ] || {{ echo {NO_BROWSER}; exit 0; }}; nohup \"$@\" </dev/null >/dev/null 2>&1 &"),
178        "sh".to_owned(),
179        BROWSER_PROGRAM.to_owned(),
180    ];
181    command.extend(flags.iter().map(|flag| (*flag).to_owned()));
182    command.push(address.to_owned());
183    command
184}
185
186/// Takes every address waiting in `folder`, oldest first, and removes each one as it is taken.
187///
188/// A file that cannot be read is removed too rather than left to be tried again forever; an
189/// address the person never sees is better than a tab that keeps stumbling over the same file.
190#[must_use]
191pub fn taken(folder: &Path) -> Vec<String> {
192    let Ok(entries) = std::fs::read_dir(folder) else { return Vec::new() };
193    let mut files: Vec<PathBuf> = entries
194        .flatten()
195        .map(|entry| entry.path())
196        .filter(|path| path.to_str().is_some_and(|name| name.ends_with(SUFFIX)))
197        .collect();
198    // The names count nanoseconds, so their order is the order they were written in.
199    files.sort();
200    let mut addresses = Vec::new();
201    for file in files {
202        let read = std::fs::read_to_string(&file);
203        let _ = std::fs::remove_file(&file);
204        if let Ok(text) = read {
205            let address = text.trim().to_owned();
206            if !address.is_empty() {
207                addresses.push(address);
208            }
209        }
210    }
211    addresses
212}
213
214/// Whether an address is one QCode will hand a browser.
215///
216/// Only `http` and `https`. The container is on the other side of this folder, and a line of text
217/// from it must never become a program to run or a file to open: `file:`, `javascript:` and
218/// anything else are refused and said aloud rather than opened quietly.
219#[must_use]
220pub fn is_web(address: &str) -> bool {
221    let lower = address.to_ascii_lowercase();
222    (lower.starts_with("http://") || lower.starts_with("https://")) && !address.contains(['\n', '\r', '\0'])
223}
224
225#[cfg(test)]
226mod tests {
227    use super::{BROWSER_DIR, BROWSER_MAIN, BROWSER_PROGRAM, browser_install, is_web, page_command, script, taken};
228    use std::path::PathBuf;
229
230    fn folder(name: &str) -> PathBuf {
231        let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
232        let path = std::env::temp_dir().join(format!("qcode-signin-{name}-{stamp}"));
233        std::fs::create_dir_all(&path).expect("a folder of this test's own");
234        path
235    }
236
237    #[test]
238    fn the_script_names_the_folder_and_moves_the_address_into_place() {
239        let text = script();
240        assert!(text.starts_with("#!/bin/sh\n"), "{text}");
241        assert!(text.contains(super::OPEN_DIR), "the folder is filled in: {text}");
242        assert!(!text.contains("$0_DIR"), "nothing is left to fill in: {text}");
243        assert!(text.contains(".part\"\nmv "), "it moves the finished file into place: {text}");
244    }
245
246    #[test]
247    fn addresses_are_taken_oldest_first_and_never_twice() {
248        let dir = folder("taken");
249        std::fs::write(dir.join("200.url"), "https://example.com/second\n").expect("an address");
250        std::fs::write(dir.join("100.url"), "https://example.com/first\n").expect("an address");
251        // Not an address yet: the writer has not moved it into place.
252        std::fs::write(dir.join("300.url.part"), "https://example.com/half").expect("a half-written file");
253        assert_eq!(taken(&dir), ["https://example.com/first", "https://example.com/second"]);
254        assert_eq!(taken(&dir), Vec::<String>::new(), "each one is taken once");
255        assert!(dir.join("300.url.part").exists(), "a half-written file is left alone");
256        let _ = std::fs::remove_dir_all(&dir);
257    }
258
259    #[test]
260    fn an_empty_or_unreadable_file_leaves_nothing_behind() {
261        let dir = folder("empty");
262        std::fs::write(dir.join("100.url"), "   \n").expect("an empty address");
263        assert_eq!(taken(&dir), Vec::<String>::new());
264        assert!(!dir.join("100.url").exists(), "it is not tried again forever");
265        assert_eq!(taken(&folder("none").join("gone")), Vec::<String>::new(), "a folder that is not there is quiet");
266        let _ = std::fs::remove_dir_all(&dir);
267    }
268
269    #[test]
270    fn only_web_addresses_are_opened() {
271        assert!(is_web("https://accounts.google.com/o/oauth2/auth?client_id=x"));
272        assert!(is_web("http://localhost:45049/oauth-callback"));
273        for refused in [
274            "file:///etc/passwd",
275            "javascript:alert(1)",
276            "antigravity-ide://open",
277            "ssh://host",
278            "https://example.com/\nfile:///etc/passwd",
279            "",
280        ] {
281            assert!(!is_web(refused), "{refused}");
282        }
283    }
284
285    #[test]
286    fn the_browser_is_the_applications_own_executable_linked_not_copied_beside_a_program_of_ours() {
287        // A stand-in of the unpacked application: its executable, the sandbox helper, a data file
288        // Electron needs beside the executable, and the application's own `resources`.
289        let root = folder("browser");
290        let app = root.join("app");
291        std::fs::create_dir_all(app.join("resources/app")).expect("the application's resources");
292        std::fs::create_dir_all(app.join("locales")).expect("a folder of data files");
293        std::fs::write(app.join("antigravity-ide"), "#!/bin/sh\n").expect("an executable");
294        std::fs::set_permissions(app.join("antigravity-ide"), std::os::unix::fs::PermissionsExt::from_mode(0o755))
295            .expect("it can be run");
296        std::fs::write(app.join("chrome-sandbox"), "helper").expect("the sandbox helper");
297        std::fs::write(app.join("locales/en-US.pak"), "words").expect("a data file");
298        std::fs::write(app.join("resources/app/package.json"), "{\"name\": \"the editor\"}").expect("the editor");
299
300        // The commands as the image runs them, with only the two folders moved into this test's own.
301        let browser = root.join("browser");
302        let here = |command: &String| {
303            command
304                .replace(BROWSER_DIR, &browser.display().to_string())
305                .replace("/opt/antigravity-ide", &app.display().to_string())
306        };
307        let commands: Vec<String> =
308            browser_install("/opt/antigravity-ide", "antigravity-ide").iter().map(here).collect();
309        let ran = std::process::Command::new("sh").arg("-c").arg(commands.join(" && ")).status().expect("a shell");
310        assert!(ran.success(), "{commands:?}");
311
312        use std::os::unix::fs::MetadataExt;
313        let inode = |path: &std::path::Path| std::fs::metadata(path).expect("the file is there").ino();
314        let program = browser.join(BROWSER_PROGRAM.rsplit('/').next().expect("a file name"));
315        // A hard link, not a symbolic one: Electron resolves a symbolic link to the real file and
316        // then runs the editor that lives beside it.
317        assert_eq!(inode(&program), inode(&app.join("antigravity-ide")), "the executable is linked");
318        assert!(!std::fs::symlink_metadata(&program).expect("it is there").file_type().is_symlink());
319        assert_eq!(inode(&browser.join("chrome-sandbox")), inode(&app.join("chrome-sandbox")));
320        assert_eq!(inode(&browser.join("locales/en-US.pak")), inode(&app.join("locales/en-US.pak")));
321        // The program Electron finds beside it is ours, and the editor's is nowhere near it.
322        let main = std::fs::read_to_string(browser.join("resources/app/main.js")).expect("our program");
323        assert_eq!(main, BROWSER_MAIN, "written into the image exactly as it is here");
324        let package = std::fs::read_to_string(browser.join("resources/app/package.json")).expect("its package");
325        assert!(package.contains("\"qcode-browser\"") && package.contains("\"main.js\""), "{package}");
326        assert!(!package.contains("editor"), "{package}");
327        let _ = std::fs::remove_dir_all(&root);
328    }
329
330    #[test]
331    fn the_browsers_program_survives_the_one_layer_of_escaping_unchanged() {
332        assert!(!BROWSER_MAIN.contains('\\'), "a backslash would be read by printf");
333        // The name that decides where its cookies are kept, in the profile's home volume.
334        assert!(super::BROWSER_PACKAGE.contains("\"name\": \"qcode-browser\""));
335        // One window per profile folder; a second address goes to the window already open.
336        assert!(BROWSER_MAIN.contains("requestSingleInstanceLock"));
337        assert!(BROWSER_MAIN.contains("'second-instance'"));
338    }
339
340    #[test]
341    fn a_page_is_shown_by_the_sign_in_window_left_running_on_its_own() {
342        let address = "https://accounts.google.com/o/oauth2/auth?a=1&redirect_uri=http%3A%2F%2Flocalhost%3A1";
343        let command = page_command(&["--ozone-platform=wayland"], address);
344        assert_eq!(command[..2], ["sh", "-c"]);
345        assert_eq!(command[3..], ["sh", BROWSER_PROGRAM, "--ozone-platform=wayland", address]);
346
347        // Run as the container would, with a stand-in browser: it starts with the address as one
348        // word, and the shell does not wait for it. The stand-in says it has started, then waits
349        // for this test to let it go before it writes its words, so the shell answering while it
350        // still waits is the proof that nobody waited for it; no clock is read. It writes beside
351        // the final name and moves the file into place, so what is read is never half written.
352        let dir = folder("page");
353        let stand_in = dir.join("browser");
354        let (started, go, seen, gave_up) = (dir.join("started"), dir.join("go"), dir.join("seen"), dir.join("gave-up"));
355        let script = format!(
356            "#!/bin/sh\n: > '{started}'\n\
357             i=0; while [ ! -e '{go}' ] && [ $i -lt 600 ]; do sleep 0.1; i=$((i + 1)); done\n\
358             [ -e '{go}' ] || {{ : > '{gave_up}'; exit 0; }}\n\
359             printf '%s\\n' \"$@\" > '{seen}.part' && mv '{seen}.part' '{seen}'\n",
360            started = started.display(),
361            go = go.display(),
362            gave_up = gave_up.display(),
363            seen = seen.display(),
364        );
365        std::fs::write(&stand_in, script).expect("a stand-in browser");
366        std::fs::set_permissions(&stand_in, std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("runnable");
367        let mut here = command.clone();
368        here[4] = stand_in.display().to_string();
369        let out = std::process::Command::new(&here[0]).args(&here[1..]).output().expect("a shell");
370        assert!(out.status.success());
371        assert!(!gave_up.exists(), "the shell waited for the browser until it gave up");
372        assert!(!seen.exists(), "the browser cannot have finished before it was let go");
373        assert_eq!(String::from_utf8_lossy(&out.stdout), "", "a browser that is there says nothing");
374        let within = |path: &std::path::Path| {
375            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
376            while !path.exists() && std::time::Instant::now() < deadline {
377                std::thread::sleep(std::time::Duration::from_millis(50));
378            }
379            path.exists()
380        };
381        assert!(within(&started), "the stand-in browser was started");
382        std::fs::write(&go, "").expect("the stand-in is let go");
383        assert!(within(&seen), "the stand-in browser wrote what it was started with");
384        let words = std::fs::read_to_string(&seen).expect("what it was started with");
385        assert_eq!(words, format!("--ozone-platform=wayland\n{address}\n"));
386
387        // An image with no browser says so rather than failing.
388        here[4] = dir.join("nothing-here").display().to_string();
389        let out = std::process::Command::new(&here[0]).args(&here[1..]).output().expect("a shell");
390        assert!(out.status.success());
391        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), super::NO_BROWSER);
392        let _ = std::fs::remove_dir_all(&dir);
393    }
394}