Skip to main content

sioul_sync/
loginflow.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Signing in to a Nextcloud through its own page, in the browser: its Login
5//! Flow v2 (docs/database.md, "Joining through your Nextcloud's sign-in"). A
6//! device with nothing set up yet gets an app password of its own, made by
7//! the server, without a password typed into Sioul; with it, the sharing
8//! folder is found there and joined, and the contacts and calendars come.
9//!
10//! - **Begun** (`begin`): `POST <server>/index.php/login/v2`, signed in as
11//!   nobody; the server answers the page to open (`login`) and where to ask
12//!   for the result (`poll`: an address, and a token).
13//! - **The page** opens in the system's browser: you sign in there as on the
14//!   server's own site, its second step included, and grant Sioul access.
15//! - **Asked** (`wait`): the poll address asked every three seconds, the
16//!   token in the request's body, until it answers the server, your login
17//!   name and an app password; stopped at Cancel, after twenty minutes (the
18//!   server's own limit for a sign-in), or when it answers.
19//! - **Kept to the server you chose**: HTTPS only; the page, the poll address
20//!   and the server the answer names, each on the origin you chose (scheme,
21//!   host and port); one elsewhere is refused, and nothing is kept. Never
22//!   redirected.
23//! - **Secret**: the poll token and the app password are never written to a
24//!   file or a log, never shown by `Debug`, and go to that origin only. The
25//!   window keeps the app password in the keyring (`secret`), as every
26//!   account's password.
27//! - **Given back** (`revoke`): an app password not used after all (Cancel
28//!   pressed after it came) is deleted on the server
29//!   (`DELETE /ocs/v2.php/core/apppassword`, Nextcloud's own way).
30//!
31//! Murena's server is `MURENA`: its sign-in answers there itself (16 October
32//! 2026: the page and the poll address on `https://murena.io`, without
33//! `index.php`). Any other Nextcloud by its address.
34
35use crate::dav;
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::time::{Duration, Instant};
38use zeroize::Zeroizing;
39
40/// Murena's Nextcloud (the cloud of /e/OS).
41pub const MURENA: &str = "https://murena.io";
42
43/// How long a sign-in may take: the server forgets it after twenty minutes.
44pub const LIMIT: Duration = Duration::from_secs(20 * 60);
45
46/// How often the poll address is asked while you sign in.
47pub const EVERY: Duration = Duration::from_secs(3);
48
49/// Polls in a row that may fail (the network down a moment, the server busy)
50/// before the sign-in says so: half a minute at `EVERY`.
51const FAILURES: u32 = 10;
52
53/// An answer is never read past this.
54const ANSWER: u64 = 64 << 10;
55
56/// A secret held in memory (a sign-in's page, an app password): zeroed when dropped.
57pub type Secret = Zeroizing<String>;
58
59/// A secret held in memory from `text`.
60pub fn secret(text: &str) -> Secret {
61    Zeroizing::new(text.to_string())
62}
63
64/// Why a sign-in did not go, for the window's words, each with the server's name.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum Refusal {
67    /// What was typed is not a server's address.
68    Address,
69    /// The address is not on HTTPS: nothing was asked.
70    NotHttps(String),
71    /// The server could not be reached (the network, a timeout).
72    Unreachable(String),
73    /// Its certificate could not be checked: nothing was sent.
74    Tls(String),
75    /// No Nextcloud answered there (its sign-in not as Nextcloud makes it).
76    NotNextcloud(String),
77    /// The server named another server (its page, its poll address, or the
78    /// server its answer names): refused, nothing kept. The chosen one's
79    /// name, then the other's.
80    Elsewhere(String, String),
81    /// The server answered with an error.
82    Server(String),
83    /// Twenty minutes went by without the sign-in.
84    TimedOut,
85    /// Cancel was pressed.
86    Cancelled,
87}
88
89impl Refusal {
90    /// Its code for the window: "unreachable:murena.io", "elsewhere:murena.io:evil.example".
91    pub fn code(&self) -> String {
92        match self {
93            Refusal::Address => "address".into(),
94            Refusal::NotHttps(host) => format!("not-https:{host}"),
95            Refusal::Unreachable(host) => format!("unreachable:{host}"),
96            Refusal::Tls(host) => format!("tls:{host}"),
97            Refusal::NotNextcloud(host) => format!("not-nextcloud:{host}"),
98            Refusal::Elsewhere(chosen, named) => format!("elsewhere:{chosen}:{named}"),
99            Refusal::Server(host) => format!("server:{host}"),
100            Refusal::TimedOut => "timed-out".into(),
101            Refusal::Cancelled => "cancelled".into(),
102        }
103    }
104}
105
106/// The server's address as typed, made whole: `cloud.example.org` →
107/// `https://cloud.example.org`; `https://cloud.example.org/nextcloud/` →
108/// `https://cloud.example.org/nextcloud`; the server's own pages after it
109/// (`/index.php/…`, `/login…`, `/apps/files/…`) left out. A plain
110/// `http://` is refused: the app password would travel unencrypted.
111pub fn address_of(typed: &str) -> Result<String, Refusal> {
112    let typed = typed.trim();
113    let (scheme, rest) = match typed.split_once("://") {
114        Some((scheme, rest)) => (scheme.to_ascii_lowercase(), rest),
115        None => ("https".to_string(), typed),
116    };
117    let rest = rest.split(['?', '#']).next().unwrap_or("");
118    let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
119    if authority.is_empty() || authority.contains('@') || authority.chars().any(|c| c.is_whitespace() || c.is_control()) || authority.starts_with(':') {
120        return Err(Refusal::Address);
121    }
122    let authority = authority.to_ascii_lowercase();
123    let mut kept: Vec<&str> = Vec::new();
124    for part in path.split('/').filter(|p| !p.is_empty()) {
125        if matches!(part, "index.php" | "login" | "apps" | "remote.php" | "ocs" | "status.php" | "settings") {
126            break;
127        }
128        kept.push(part);
129    }
130    let base = if kept.is_empty() { format!("{scheme}://{authority}") } else { format!("{scheme}://{authority}/{}", kept.join("/")) };
131    if dav::allowed(&base).is_err() {
132        return Err(Refusal::NotHttps(crate::remote::host_of(&base)));
133    }
134    if dav::origin(&base).is_none() {
135        return Err(Refusal::Address);
136    }
137    Ok(base)
138}
139
140/// A sign-in under way: the page to open, and where to ask for its result.
141/// Its token and the page's address are secrets: no `Debug` shows them.
142pub struct Flow {
143    /// The server chosen, as `address_of` made it.
144    base: String,
145    origin: String,
146    login: Zeroizing<String>,
147    endpoint: String,
148    token: Zeroizing<String>,
149    /// What Sioul says it is on the server's page and in its list of
150    /// devices ("Sioul (Android)").
151    client: String,
152}
153
154impl std::fmt::Debug for Flow {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        f.debug_struct("Flow").field("base", &self.base).field("login", &"…").field("token", &"…").finish()
157    }
158}
159
160impl Flow {
161    /// The page to open in the browser (its address holds the sign-in's own key: never logged).
162    pub fn page(&self) -> &str {
163        &self.login
164    }
165
166    /// The server's name: "murena.io".
167    pub fn host(&self) -> String {
168        crate::remote::host_of(&self.base)
169    }
170}
171
172/// What the sign-in gave: the server, as it named itself (on the origin
173/// chosen), the login name, and the app password. No `Debug` shows the password.
174pub struct Grant {
175    pub server: String,
176    pub login: String,
177    password: Zeroizing<String>,
178}
179
180impl std::fmt::Debug for Grant {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        f.debug_struct("Grant").field("server", &self.server).field("password", &"…").finish()
183    }
184}
185
186impl Grant {
187    /// For the requests to its server only, and the keyring.
188    pub fn password(&self) -> &str {
189        &self.password
190    }
191
192    /// The server's name: "murena.io".
193    pub fn host(&self) -> String {
194        crate::remote::host_of(&self.server)
195    }
196
197    /// The account's login to its server, with the app password, for the
198    /// sharing's own requests (`remote`): its files there, under `remote.php/dav/`.
199    pub fn login_for(&self, account: &str) -> crate::remote::Login {
200        crate::remote::Login { account: account.to_string(), url: format!("{}/remote.php/dav/", self.server), user: self.login.clone(), password: Some(self.password.to_string()) }
201    }
202
203    /// A grant from its parts: the window's, holding one between the
204    /// sign-in and the joining, and the tests'.
205    pub fn of(server: &str, login: &str, password: &str) -> Grant {
206        Grant { server: server.trim_end_matches('/').to_string(), login: login.to_string(), password: Zeroizing::new(password.to_string()) }
207    }
208}
209
210/// The agent for the sign-in's requests: never redirected, each bounded.
211fn agent() -> ureq::Agent {
212    dav::agent(&dav::Budget { connect: Duration::from_secs(10), stall: Duration::from_secs(15), whole: Duration::from_secs(30) })
213}
214
215fn refused(e: &ureq::Error, host: &str) -> Refusal {
216    match e {
217        ureq::Error::Tls(_) => Refusal::Tls(host.to_string()),
218        _ if format!("{e:?}").starts_with("Rustls") => Refusal::Tls(host.to_string()),
219        _ => Refusal::Unreachable(host.to_string()),
220    }
221}
222
223/// A request's body, read up to `ANSWER`, zeroed once dropped (it may hold a secret).
224fn read(response: &mut ureq::http::Response<ureq::Body>) -> Zeroizing<String> {
225    Zeroizing::new(response.body_mut().with_config().limit(ANSWER).read_to_string().unwrap_or_default())
226}
227
228/// Whether an address is on `origin`, over HTTPS (a test's own stand-in apart: `dav::allowed`).
229fn on(url: &str, origin: &str) -> bool {
230    dav::allowed(url).is_ok() && dav::origin(url).as_deref() == Some(origin)
231}
232
233#[derive(serde::Deserialize)]
234struct Begun {
235    login: String,
236    poll: Poll,
237}
238
239#[derive(serde::Deserialize)]
240struct Poll {
241    token: String,
242    endpoint: String,
243}
244
245#[derive(serde::Deserialize)]
246struct Answered {
247    server: String,
248    #[serde(rename = "loginName")]
249    login_name: String,
250    #[serde(rename = "appPassword")]
251    app_password: String,
252}
253
254/// A sign-in begun on `base` (`address_of`'s), Sioul saying it is `client`
255/// there ("Sioul (Android)"): the page to open, and where to ask. Its page
256/// and poll address both on `base`'s origin, over HTTPS, or it is refused.
257pub fn begin(base: &str, client: &str) -> Result<Flow, Refusal> {
258    let host = crate::remote::host_of(base);
259    let origin = dav::origin(base).ok_or(Refusal::Address)?;
260    let url = format!("{}/index.php/login/v2", base.trim_end_matches('/'));
261    dav::allowed(&url).map_err(|_| Refusal::NotHttps(host.clone()))?;
262    let request = ureq::http::Request::builder().method("POST").uri(&url).header("User-Agent", client).header("Accept", "application/json").body(Vec::new()).map_err(|_| Refusal::Address)?;
263    let mut response = agent().run(request).map_err(|e| refused(&e, &host))?;
264    let status = response.status().as_u16();
265    let body = read(&mut response);
266    match status {
267        200 => {}
268        404 | 405 => return Err(Refusal::NotNextcloud(host)),
269        _ => return Err(Refusal::Server(host)),
270    }
271    let begun: Begun = serde_json::from_str(&body).map_err(|_| Refusal::NotNextcloud(host.clone()))?;
272    let (login, token) = (Zeroizing::new(begun.login), Zeroizing::new(begun.poll.token));
273    for named in [login.as_str(), begun.poll.endpoint.as_str()] {
274        if !on(named, &origin) {
275            return Err(Refusal::Elsewhere(host, crate::remote::host_of(named)));
276        }
277    }
278    if token.is_empty() || token.len() > 1024 || !token.chars().all(|c| c.is_ascii_alphanumeric()) {
279        return Err(Refusal::NotNextcloud(host));
280    }
281    Ok(Flow { base: base.trim_end_matches('/').to_string(), origin, login, endpoint: begun.poll.endpoint, token, client: client.to_string() })
282}
283
284/// Asks once whether the sign-in is done: none while it is not (the server
285/// answers 404 until you granted access); the grant when it is, its server
286/// on the origin chosen (another is refused: nothing is kept). The server
287/// answers it once only.
288pub fn poll(flow: &Flow) -> Result<Option<Grant>, Refusal> {
289    let host = crate::remote::host_of(&flow.base);
290    let body = Zeroizing::new(format!("token={}", flow.token.as_str()).into_bytes());
291    let request = ureq::http::Request::builder()
292        .method("POST")
293        .uri(&flow.endpoint)
294        .header("User-Agent", flow.client.as_str())
295        .header("Accept", "application/json")
296        .header("Content-Type", "application/x-www-form-urlencoded")
297        .body(body.to_vec())
298        .map_err(|_| Refusal::Server(host.clone()))?;
299    let mut response = agent().run(request).map_err(|e| refused(&e, &host))?;
300    let status = response.status().as_u16();
301    let answer = read(&mut response);
302    match status {
303        200 => {}
304        404 => return Ok(None),
305        _ => return Err(Refusal::Server(host)),
306    }
307    let answered: Answered = serde_json::from_str(&answer).map_err(|_| Refusal::NotNextcloud(host.clone()))?;
308    let password = Zeroizing::new(answered.app_password);
309    let server = answered.server.trim().trim_end_matches('/').to_string();
310    if !on(&server, &flow.origin) {
311        // Made by the server chosen all the same: given back to it, never kept.
312        let named = crate::remote::host_of(&server);
313        let _ = revoke(&Grant { server: flow.base.clone(), login: answered.login_name, password });
314        // The same server named over plain HTTP (a server's own setting): said as not encrypted.
315        return Err(if named == host { Refusal::NotHttps(host) } else { Refusal::Elsewhere(host, named) });
316    }
317    if answered.login_name.trim().is_empty() || password.is_empty() {
318        return Err(Refusal::NotNextcloud(host));
319    }
320    Ok(Some(Grant { server, login: answered.login_name.trim().to_string(), password }))
321}
322
323/// Asks every `every` until the sign-in is done, `stop` is set (Cancel), or
324/// `limit` went by. The network down a moment is asked again; for half a
325/// minute in a row, it is said.
326pub fn wait(flow: &Flow, stop: &AtomicBool, limit: Duration, every: Duration) -> Result<Grant, Refusal> {
327    let end = Instant::now() + limit;
328    let mut failures = 0;
329    loop {
330        if stop.load(Ordering::SeqCst) {
331            return Err(Refusal::Cancelled);
332        }
333        match poll(flow) {
334            Ok(Some(grant)) => {
335                // Cancel pressed while the answer came: given back.
336                if stop.load(Ordering::SeqCst) {
337                    let _ = revoke(&grant);
338                    return Err(Refusal::Cancelled);
339                }
340                return Ok(grant);
341            }
342            Ok(None) => failures = 0,
343            Err(e @ (Refusal::Elsewhere(..) | Refusal::Tls(_) | Refusal::NotNextcloud(_))) => return Err(e),
344            Err(e) => {
345                failures += 1;
346                if failures >= FAILURES {
347                    return Err(e);
348                }
349            }
350        }
351        let next = Instant::now() + every;
352        while Instant::now() < next {
353            if stop.load(Ordering::SeqCst) {
354                return Err(Refusal::Cancelled);
355            }
356            if Instant::now() >= end {
357                return Err(Refusal::TimedOut);
358            }
359            std::thread::sleep(Duration::from_millis(100).min(every));
360        }
361        if Instant::now() >= end {
362            return Err(Refusal::TimedOut);
363        }
364    }
365}
366
367/// An app password given back: deleted on its server, which made it (Cancel
368/// pressed once it came, or a grant refused). Already gone there: done.
369pub fn revoke(grant: &Grant) -> Result<(), Refusal> {
370    let host = grant.host();
371    let url = format!("{}/ocs/v2.php/core/apppassword", grant.server);
372    dav::allowed(&url).map_err(|_| Refusal::NotHttps(host.clone()))?;
373    let authorization = Zeroizing::new(format!("Basic {}", sioul_core::lines::base64_encode(format!("{}:{}", grant.login, grant.password()).as_bytes())));
374    let request = ureq::http::Request::builder()
375        .method("DELETE")
376        .uri(&url)
377        .header("Authorization", authorization.as_str())
378        .header("OCS-APIREQUEST", "true")
379        .header("User-Agent", "Sioul")
380        .body(Vec::new())
381        .map_err(|_| Refusal::Server(host.clone()))?;
382    let response = agent().run(request).map_err(|e| refused(&e, &host))?;
383    match response.status().as_u16() {
384        200 | 204 | 401 | 403 | 404 => Ok(()),
385        _ => Err(Refusal::Server(host)),
386    }
387}
388
389/// The keyring's name for a sign-in's app password, kept for the sharing
390/// (`remote::State::user`): "Nextcloud app password: jane on murena.io",
391/// under the service `sioul` as every secret of Sioul's (`secret`).
392pub fn keyring_name(login: &str, host: &str) -> String {
393    format!("Nextcloud app password: {login} on {host}")
394}
395
396/// The grant's app password kept in the keyring (`keyring_name`), replacing
397/// one kept there before for that login and server. Never in a file.
398pub fn keep(grant: &Grant) -> Result<(), crate::SyncError> {
399    crate::secret::save_named(&keyring_name(&grant.login, &grant.host()), grant.password())
400}
401
402/// The app password kept for that login and server, from the keyring.
403pub fn kept(login: &str, host: &str) -> Option<String> {
404    crate::secret::named(&keyring_name(login, host))
405}
406
407/// The app password kept for that login and server, forgotten here.
408pub fn forget(login: &str, host: &str) -> Result<(), crate::SyncError> {
409    crate::secret::forget_named(&keyring_name(login, host))
410}
411
412/// Where the contacts and calendars are on the grant's server, found there
413/// alone, with its app password (`dav::discover_on`).
414pub fn homes(grant: &Grant) -> Result<dav::Homes, crate::SyncError> {
415    dav::discover_on(&dav::Client::new(&grant.login, grant.password()), &grant.server)
416}
417
418/// Whether the app password opens the account under `login` there (one
419/// account's login may differ from the sign-in's own: its address rather
420/// than its name): a principal asked of its server, on its origin alone.
421pub fn opens(grant: &Grant, login: &str) -> Result<bool, crate::SyncError> {
422    let client = dav::Client::new(login, grant.password());
423    match dav::discover_on(&client, &grant.server) {
424        Ok(_) => Ok(true),
425        Err(crate::SyncError::Login(_)) => Ok(false),
426        Err(e) => Err(e),
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn an_address_typed_is_made_whole_and_kept_to_https() {
436        assert_eq!(address_of("murena.io"), Ok("https://murena.io".to_string()));
437        assert_eq!(address_of(" https://Cloud.Example.org/nextcloud/ "), Ok("https://cloud.example.org/nextcloud".to_string()));
438        assert_eq!(address_of("https://cloud.example.org/index.php/apps/files/?dir=/"), Ok("https://cloud.example.org".to_string()));
439        assert_eq!(address_of("cloud.example.org:8443/nc/login"), Ok("https://cloud.example.org:8443/nc".to_string()));
440        assert_eq!(address_of("http://cloud.example.org"), Err(Refusal::NotHttps("cloud.example.org".into())));
441        for typed in ["", "   ", "https://", "jane@cloud.example.org", "https://jane:secret@cloud.example.org", "cloud example.org"] {
442            assert_eq!(address_of(typed), Err(Refusal::Address), "{typed:?}");
443        }
444    }
445
446    #[test]
447    fn no_debug_shows_a_secret() {
448        let grant = Grant::of("https://cloud.example.org", "jane", "app-password-secret");
449        let said = format!("{grant:?}");
450        assert!(!said.contains("app-password-secret") && said.contains("cloud.example.org"), "{said}");
451        let flow = Flow { base: "https://cloud.example.org".into(), origin: "https://cloud.example.org:443".into(), login: Zeroizing::new("https://cloud.example.org/login/v2/flow/page-secret".into()), endpoint: "https://cloud.example.org/login/v2/poll".into(), token: Zeroizing::new("token-secret".into()), client: "Sioul".into() };
452        let said = format!("{flow:?}");
453        assert!(!said.contains("page-secret") && !said.contains("token-secret"), "{said}");
454        assert_eq!(Refusal::Elsewhere("murena.io".into(), "evil.example".into()).code(), "elsewhere:murena.io:evil.example");
455    }
456
457    use crate::remote::fake::{APP_PASSWORD, Fake, USER};
458    use std::sync::Arc;
459
460    /// A stand-in Nextcloud with its sign-in, its files in a scratch folder.
461    fn stand_in(name: &str) -> (Arc<Fake>, std::path::PathBuf) {
462        let files = std::env::temp_dir().join(format!("sioul-loginflow-{name}-{}", std::process::id()));
463        let _ = std::fs::remove_dir_all(&files);
464        std::fs::create_dir_all(&files).unwrap();
465        (Fake::start(&files), files)
466    }
467
468    #[test]
469    fn a_sign_in_answered_gives_an_app_password_once_for_its_server_alone() {
470        let (fake, files) = stand_in("answered");
471        let base = address_of(&fake.base).unwrap();
472        let flow = begin(&base, "Sioul (test)").unwrap();
473        assert!(flow.page().starts_with(&format!("{base}/login/v2/flow/")), "the page on the server chosen");
474        // Not signed in yet: nothing.
475        assert!(poll(&flow).unwrap().is_none());
476        fake.grant_flows();
477        let grant = poll(&flow).unwrap().expect("signed in");
478        assert_eq!((grant.server.as_str(), grant.login.as_str()), (base.as_str(), USER));
479        assert!(grant.password().starts_with(APP_PASSWORD));
480        // Answered once only, as Nextcloud does.
481        assert!(poll(&flow).unwrap().is_none());
482        // The poll's token went in the request's body, never in an address.
483        assert!(fake.seen().iter().all(|line| !line.contains("polltoken")), "{:?}", fake.seen());
484        // The contacts and calendars found there, with the app password.
485        let homes = homes(&grant).unwrap();
486        assert_eq!(homes.calendars.as_deref(), Some(format!("{base}/remote.php/dav/calendars/{USER}/").as_str()));
487        assert_eq!(homes.contacts.as_deref(), Some(format!("{base}/remote.php/dav/addressbooks/users/{USER}/").as_str()));
488        assert!(opens(&grant, USER).unwrap());
489        assert!(!opens(&grant, "someone-else").unwrap(), "another login with it is refused there");
490        // Waited for: the person signs in a moment later.
491        let flow = begin(&base, "Sioul (test)").unwrap();
492        let granting = Arc::clone(&fake);
493        let signed = std::thread::spawn(move || {
494            std::thread::sleep(Duration::from_millis(300));
495            granting.grant_flows();
496        });
497        let stop = AtomicBool::new(false);
498        let second = wait(&flow, &stop, Duration::from_secs(20), Duration::from_millis(50)).unwrap();
499        signed.join().unwrap();
500        assert_ne!(second.password(), grant.password(), "an app password of its own");
501        // Given back: it opens nothing any more.
502        revoke(&second).unwrap();
503        assert!(!fake.app_passwords.lock().unwrap().iter().any(|p| p == second.password()));
504        assert!(matches!(homes_of(&second), Err(crate::SyncError::Login(_))));
505        let _ = std::fs::remove_dir_all(files);
506    }
507
508    fn homes_of(grant: &Grant) -> Result<crate::dav::Homes, crate::SyncError> {
509        homes(grant)
510    }
511
512    #[test]
513    fn a_sign_in_cancelled_or_left_twenty_minutes_keeps_nothing() {
514        let (fake, files) = stand_in("cancelled");
515        let base = address_of(&fake.base).unwrap();
516        // Cancel pressed while the browser is open.
517        let flow = begin(&base, "Sioul (test)").unwrap();
518        let stop = Arc::new(AtomicBool::new(false));
519        let pressing = Arc::clone(&stop);
520        let pressed = std::thread::spawn(move || {
521            std::thread::sleep(Duration::from_millis(200));
522            pressing.store(true, Ordering::SeqCst);
523        });
524        let started = Instant::now();
525        assert_eq!(wait(&flow, &stop, Duration::from_secs(20), Duration::from_millis(50)).unwrap_err(), Refusal::Cancelled);
526        pressed.join().unwrap();
527        assert!(started.elapsed() < Duration::from_secs(5), "stopped at once");
528        // Granted, but Cancel pressed first: never asked for, so never made.
529        fake.grant_flows();
530        assert_eq!(wait(&flow, &stop, Duration::from_secs(20), Duration::from_millis(50)).unwrap_err(), Refusal::Cancelled);
531        assert!(fake.app_passwords.lock().unwrap().is_empty());
532        // Never granted: over at the limit (twenty minutes in Sioul, a moment here).
533        let flow = begin(&base, "Sioul (test)").unwrap();
534        let stop = AtomicBool::new(false);
535        assert_eq!(wait(&flow, &stop, Duration::from_millis(300), Duration::from_millis(50)).unwrap_err(), Refusal::TimedOut);
536        assert!(fake.app_passwords.lock().unwrap().is_empty());
537        assert_eq!(LIMIT, Duration::from_secs(20 * 60));
538        let _ = std::fs::remove_dir_all(files);
539    }
540
541    #[test]
542    fn a_server_named_elsewhere_is_refused_and_nothing_kept() {
543        let (fake, files) = stand_in("elsewhere");
544        let (other, other_files) = stand_in("elsewhere-other");
545        let base = address_of(&fake.base).unwrap();
546        // Its answer names another server: refused, the app password given back to the one that made it.
547        fake.signing.lock().unwrap().server = other.base.clone();
548        let flow = begin(&base, "Sioul (test)").unwrap();
549        fake.grant_flows();
550        let refused = poll(&flow).unwrap_err();
551        assert_eq!(refused, Refusal::Elsewhere(crate::remote::host_of(&base), crate::remote::host_of(&other.base)));
552        assert!(fake.app_passwords.lock().unwrap().is_empty(), "given back");
553        assert!(other.seen().is_empty(), "nothing asked of the other server");
554        // The same server named under another scheme (plain HTTP, where it was chosen on HTTPS): refused, said as not encrypted.
555        fake.signing.lock().unwrap().server = format!("https://{}", crate::remote::host_of(&base));
556        let flow = begin(&base, "Sioul (test)").unwrap();
557        fake.grant_flows();
558        assert_eq!(poll(&flow).unwrap_err(), Refusal::NotHttps(crate::remote::host_of(&base)), "its own host on another scheme");
559        // On HTTPS there, refused all the same: another origin.
560        fake.signing.lock().unwrap().server = "https://cloud.example.org".into();
561        let flow = begin(&base, "Sioul (test)").unwrap();
562        fake.grant_flows();
563        assert!(matches!(poll(&flow), Err(Refusal::Elsewhere(_, named)) if named == "cloud.example.org"));
564        // Its poll address on another server: refused before anything is asked there.
565        fake.signing.lock().unwrap().server.clear();
566        fake.signing.lock().unwrap().endpoint = format!("{}/login/v2/poll", other.base);
567        assert!(matches!(begin(&base, "Sioul (test)"), Err(Refusal::Elsewhere(_, _))));
568        assert!(other.seen().is_empty());
569        // No Nextcloud there: said so.
570        let none = crate::dav::stand_in::serve(Arc::new(Nothing));
571        assert_eq!(begin(&none, "Sioul (test)").unwrap_err(), Refusal::NotNextcloud(crate::remote::host_of(&none)));
572        // Plain HTTP elsewhere than a test's own stand-in: never asked.
573        assert_eq!(begin("http://cloud.example.org", "Sioul (test)").unwrap_err(), Refusal::NotHttps("cloud.example.org".into()));
574        let _ = std::fs::remove_dir_all(files);
575        let _ = std::fs::remove_dir_all(other_files);
576    }
577
578    /// A web server that is no Nextcloud: 404 everywhere.
579    struct Nothing;
580    impl crate::dav::stand_in::Server for Nothing {
581        fn misbehaves(&self, _: &crate::dav::stand_in::Request) -> Option<crate::dav::stand_in::Fault> {
582            None
583        }
584        fn answer(&self, _: &crate::dav::stand_in::Request) -> crate::dav::stand_in::Reply {
585            crate::dav::stand_in::Reply::new(404, "")
586        }
587    }
588
589    /// A server that names its principal on another one: the app password
590    /// never goes there, nor to your login's domain.
591    #[test]
592    fn the_app_password_never_leaves_its_server() {
593        use crate::dav::stand_in::{Reply, Request, Server as StandIn, serve};
594        struct Noted(std::sync::Mutex<Vec<String>>);
595        impl StandIn for Noted {
596            fn misbehaves(&self, _: &Request) -> Option<crate::dav::stand_in::Fault> {
597                None
598            }
599            fn answer(&self, request: &Request) -> Reply {
600                self.0.lock().unwrap().push(format!("{} {}", request.method, request.path));
601                Reply::new(404, "")
602            }
603        }
604        struct Pointing(String);
605        impl StandIn for Pointing {
606            fn misbehaves(&self, _: &Request) -> Option<crate::dav::stand_in::Fault> {
607                None
608            }
609            fn answer(&self, _: &Request) -> Reply {
610                Reply::new(207, format!(r#"<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/remote.php/dav/</d:href><d:propstat><d:prop><d:current-user-principal><d:href>{}</d:href></d:current-user-principal></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#, self.0))
611            }
612        }
613        let noted = Arc::new(Noted(std::sync::Mutex::new(Vec::new())));
614        let there = serve(noted.clone());
615        let here = serve(Arc::new(Pointing(format!("{there}/remote.php/dav/principals/users/jane/"))));
616        // The login an address of another domain: its domain is never asked either.
617        let grant = Grant::of(&here, "jane@elsewhere.example", "app-password");
618        assert!(homes(&grant).is_err());
619        assert!(noted.0.lock().unwrap().is_empty(), "{:?}", noted.0.lock().unwrap());
620    }
621}