blob: e53610e89a0dc928698bbc8de23d35607940766d [file]
//! Fetch in the owner's browser (PLAN M19, third cut): a tool whose grant says
//! `browser = true` is fetched by the companion extension beside the web launcher's
//! page, with the browser's own cookies, so a page the owner is signed in to answers
//! the session as it would answer them, and no credential is copied into a grant.
//!
//! The browser is the third host of fetch, beside the component and the tool server,
//! and the guarantees stay where they are: the policy decides the URL at the session's
//! label before the browser is asked, as the tap host decides a request before it
//! leaves; the ask carries the hosts the session may reach, which the extension holds
//! its requests to with the browser's own request rules, so a redirect elsewhere is
//! stopped before it is made and the refusal says where it was going; the URL that
//! answered is decided again before anything is kept; and the answer is shaped by
//! [`dm_fetch::answer`], the same as the other two hosts', then scanned and kept as a
//! source observed by `browser`. The hops between are the browser's, so the answer
//! names where it started and where it ended, not each step; and the call goes to the
//! web launcher on loopback, not through the egress, so it is not metered there.
//!
//! An argument `render` has the browser open the page in a tab and read the document as
//! its scripts built it, for a page that is nothing without them; the tab is held to the
//! same hosts and closed when read. Only the browser renders: the other hosts refuse the
//! argument in [`crate::taps`].
//!
//! The way in is the running web launcher over this root, found as `amem` finds it,
//! by `state/web.json` and the nonce it must answer under the token; the ask is a
//! `POST` to [`dm_web::companion::FETCH_PATH`] with the token.
use dm_server::sources::Answered;
use dm_server::Memory;
use serde_json::{json, Value};
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
/// How long the whole exchange with the web launcher may take: the companion's own
/// minute, and the round trips around it.
const WAIT: Duration = dm_web::companion::WAIT.saturating_add(Duration::from_secs(10));
/// The web launcher running over `root`, proven to hold the token: its address, and
/// the token to ask with.
async fn launcher(root: &Path) -> Result<(SocketAddr, String), String> {
let state = dm_web::state::load(root)
.map_err(|e| format!("the web launcher's state: {e}"))?
.state;
let Some(url) = state.url.as_deref() else {
return Err(
"no web launcher is running over this root: the browser fetches only while `amem` runs with its companion connected"
.into(),
);
};
let running = crate::open::running(url, &state.token, root)
.await
.ok_or_else(|| {
format!("the web launcher at {url} is not answering; run `amem` to start it")
})?;
Ok((running.addr(), state.token))
}
/// One `POST` of JSON to the web launcher on loopback, with the token: the status and
/// the body.
async fn post(
addr: SocketAddr,
path: &str,
token: &str,
body: &str,
) -> Result<(u16, String), String> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let go = async {
let mut s = tokio::net::TcpStream::connect(addr)
.await
.map_err(|e| format!("connecting to the web launcher at {addr}: {e}"))?;
let req = format!(
"POST {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: Bearer {token}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
s.write_all(req.as_bytes())
.await
.map_err(|e| format!("asking the web launcher: {e}"))?;
let mut out = Vec::new();
s.read_to_end(&mut out)
.await
.map_err(|e| format!("reading the web launcher's answer: {e}"))?;
let text = String::from_utf8_lossy(&out).into_owned();
let (head, body) = text
.split_once("\r\n\r\n")
.ok_or_else(|| "the web launcher answered no HTTP".to_string())?;
let status: u16 = head
.split(' ')
.nth(1)
.and_then(|c| c.parse().ok())
.ok_or_else(|| "the web launcher answered no status".to_string())?;
Ok::<_, String>((status, body.to_string()))
};
tokio::time::timeout(WAIT, go)
.await
.map_err(|_| "the web launcher did not answer in time".to_string())?
}
/// What the browser said, as [`call`] reads it.
#[derive(Debug, PartialEq, Eq)]
struct Got {
status: u16,
content_type: String,
final_url: String,
body: String,
truncated: bool,
}
/// The browser's answer out of the web launcher's JSON: what it fetched, or why not.
fn got(url: &str, v: &Value) -> Result<Got, String> {
if let Some(e) = v["error"].as_str() {
return Err(format!("the browser did not fetch {url}: {e}"));
}
let status = v["status"]
.as_u64()
.and_then(|s| u16::try_from(s).ok())
.ok_or_else(|| format!("the browser's answer for {url} carries no status"))?;
Ok(Got {
status,
content_type: v["content_type"].as_str().unwrap_or_default().to_string(),
final_url: v["final_url"]
.as_str()
.filter(|f| !f.is_empty())
.unwrap_or(url)
.to_string(),
body: v["body"].as_str().unwrap_or_default().to_string(),
truncated: v["truncated"].as_bool().unwrap_or(false),
})
}
/// The answer and the URLs reached, once the URL that answered has passed the policy:
/// where it started, and where it ended when that differs.
fn answered(memory: &Memory, tool: &str, url: &str, g: &Got) -> Result<Answered, String> {
let mut reached = vec![url.to_string()];
let mut redirects = Vec::new();
if g.final_url != url {
memory.check_reached(tool, &g.final_url).map_err(|why| {
format!(
"{url} answered from {}, which this session may not reach: {why}",
g.final_url
)
})?;
reached.push(g.final_url.clone());
redirects.push(g.final_url.clone());
}
let answer = dm_fetch::answer(&dm_fetch::Fetched {
url,
final_url: &g.final_url,
status: g.status,
content_type: &g.content_type,
body: g.body.as_bytes(),
truncated: g.truncated,
redirects: &redirects,
})?;
Ok(Answered { answer, reached })
}
/// Fetch `args` in the owner's browser for the session `memory` is.
///
/// # Errors
///
/// Arguments or configuration that are not the tool's; the policy refusing the URL, or
/// the one that answered; no web launcher running, or no companion connected to it; or
/// the browser's own failure to fetch.
pub async fn call(
memory: Arc<Memory>,
tool: &str,
config: &str,
args: &str,
) -> Result<Answered, String> {
let config = dm_fetch::Config::parse(config)?;
let args = dm_fetch::Args::parse(args)?;
// decided here before the browser is asked, as a request is before it leaves
memory
.check_reached(tool, &args.url)
.map_err(|why| format!("request to {}: refused: {why}", args.url))?;
let hosts = memory.fetch_hosts(tool);
let (addr, token) = launcher(memory.root()).await?;
let ask = json!({ "url": args.url, "hosts": hosts, "max_bytes": config.max_bytes, "render": args.render });
let (status, body) = post(
addr,
dm_web::companion::FETCH_PATH,
&token,
&ask.to_string(),
)
.await?;
if status != 200 {
return Err(format!(
"the web launcher answered {status}: {}",
body.trim()
));
}
let v: Value = serde_json::from_str(&body)
.map_err(|e| format!("the web launcher answered no JSON: {e}"))?;
answered(&memory, tool, &args.url, &got(&args.url, &v)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_browser_s_answer_is_read_or_its_refusal_is() {
let v = json!({"status": 200, "content_type": "text/html", "final_url": "https://a.example/b", "body": "<p>x</p>", "truncated": true});
assert_eq!(
got("https://a.example/a", &v).unwrap(),
Got {
status: 200,
content_type: "text/html".into(),
final_url: "https://a.example/b".into(),
body: "<p>x</p>".into(),
truncated: true,
}
);
let bare = got("https://a.example/a", &json!({"status": 204})).unwrap();
assert_eq!(bare.final_url, "https://a.example/a");
assert_eq!(bare.body, "");
assert!(got("u", &json!({"error": "no"}))
.unwrap_err()
.contains("did not fetch u: no"));
assert!(got("u", &json!({"body": "x"}))
.unwrap_err()
.contains("no status"));
}
}