///| Native HTTP client implementation using @http package
///| This file is only compiled for native target
///|
pub struct NativeHttpClient {
// No state needed, uses @http directly
dummy : Int
}
///|
pub fn NativeHttpClient::new() -> NativeHttpClient {
{ dummy: 0, }
}
///|
async fn apply_auth_header(
url : String,
headers : Map[String, String],
) -> Map[String, String] raise @bit.GitError {
if headers.contains("Authorization") {
return headers
}
let auth = resolve_auth_header(url)
match auth {
None => headers
Some(value) => {
let out : Map[String, String] = Map([])
for k, v in headers {
out[k] = v
}
out["Authorization"] = value
out
}
}
}
///|
async fn resolve_auth_header(url : String) -> String? raise @bit.GitError {
match parse_http_url(url) {
None => None
Some((protocol, host, path, username, password)) =>
match password {
Some(pass) => {
let user = username.unwrap_or("")
Some(build_basic_auth(user, pass))
}
None => {
let cred_path = normalize_credential_path(path)
match bit_credential_fill(protocol, host, cred_path, username) {
None => None
Some((user, pass)) => Some(build_basic_auth(user, pass))
}
}
}
}
}
///|
fn build_basic_auth(username : String, password : String) -> String {
let raw = username + ":" + password
let encoded = @base64.encode(@utf8.encode(raw), url_safe=false)
"Basic " + encoded
}
///|
fn parse_http_url(url : String) -> (String, String, String, String?, String?)? {
let (protocol, rest) = if url.has_prefix("https://") {
("https", String::unsafe_substring(url, start=8, end=url.length()))
} else if url.has_prefix("http://") {
("http", String::unsafe_substring(url, start=7, end=url.length()))
} else {
return None
}
let slash = rest.find("/")
let authority = match slash {
Some(idx) => String::unsafe_substring(rest, start=0, end=idx)
None => rest
}
let path = match slash {
Some(idx) =>
String::unsafe_substring(rest, start=idx + 1, end=rest.length())
None => ""
}
let mut host = authority
let mut username : String? = None
let mut password : String? = None
match authority.rev_find("@") {
Some(at) => {
let userinfo = String::unsafe_substring(authority, start=0, end=at)
host = String::unsafe_substring(
authority,
start=at + 1,
end=authority.length(),
)
match userinfo.find(":") {
Some(colon) => {
username = Some(
String::unsafe_substring(userinfo, start=0, end=colon),
)
password = Some(
String::unsafe_substring(
userinfo,
start=colon + 1,
end=userinfo.length(),
),
)
}
None => username = Some(userinfo)
}
}
None => ()
}
Some((protocol, host, path, username, password))
}
///|
fn normalize_credential_path(path : String) -> String {
let mut out = path
if out.find("?") is Some(q) {
out = String::unsafe_substring(out, start=0, end=q)
}
let suffix_info = "/info/refs"
if out.has_suffix(suffix_info) {
out = String::unsafe_substring(
out,
start=0,
end=out.length() - suffix_info.length(),
)
}
let suffix_recv = "/git-receive-pack"
if out.has_suffix(suffix_recv) {
out = String::unsafe_substring(
out,
start=0,
end=out.length() - suffix_recv.length(),
)
}
out
}
///|
async fn bit_credential_fill(
protocol : String,
host : String,
path : String,
username : String?,
) -> (String, String)? raise @bit.GitError {
let input = build_credential_input(protocol, host, path, username)
let (stdin, writer) = @process.write_to_process() catch {
err => raise @bit.GitError::IoError("git credential pipe failed: \{err}")
}
writer.write(input) catch {
err => raise @bit.GitError::IoError("git credential write failed: \{err}")
}
writer.close()
let (code, stdout, stderr) = @process.collect_output(
"git",
["credential", "fill"],
inherit_env=true,
stdin~,
extra_env={ "GIT_TERMINAL_PROMPT": "0" },
) catch {
err => raise @bit.GitError::IoError("git credential fill failed: \{err}")
}
if code != 0 {
ignore(stderr)
return None
}
let text = stdout.text() catch { _ => "" }
let mut user : String? = None
let mut pass : String? = None
for line_view in text.split("\n") {
let line = line_view.to_owned()
match line.find("=") {
Some(eq) => {
let key = String::unsafe_substring(line, start=0, end=eq)
let value = String::unsafe_substring(
line,
start=eq + 1,
end=line.length(),
)
if key == "username" {
user = Some(value)
} else if key == "password" || key == "token" || key == "oauth_token" {
pass = Some(value)
}
}
None => ()
}
}
match pass {
Some(p) => Some((user.unwrap_or(""), p))
None => None
}
}
///|
fn build_credential_input(
protocol : String,
host : String,
path : String,
username : String?,
) -> String {
let sb = StringBuilder()
sb.write_string("protocol=" + protocol + "\n")
sb.write_string("host=" + host + "\n")
if path.length() > 0 {
sb.write_string("path=" + path + "\n")
}
if username is Some(u) {
sb.write_string("username=" + u + "\n")
}
sb.write_string("\n")
sb.to_string()
}
///|
/// Read a response body to completion and close the client.
///
/// A read error is treated as end of body rather than raised, because some
/// servers close abruptly once the body is complete. `drain_response_strict`
/// is the variant for callers that cannot tolerate a silent truncation.
async fn drain_response(client : @http.Client) -> Bytes raise @bit.GitError {
let data = collect_response(client, false)
client.close()
data
}
///|
/// Read a response body to completion, raising if the transfer breaks.
///
/// Object storage reads must not truncate silently: a short pack that reports
/// success is worse than a failed request.
async fn drain_response_strict(
client : @http.Client,
) -> Bytes raise @bit.GitError {
errdefer client.close()
let data = collect_response(client, true)
client.close()
data
}
///|
async fn collect_response(
client : @http.Client,
strict : Bool,
) -> Bytes raise @bit.GitError {
let chunks : Array[Byte] = []
while true {
let chunk = client.read_some() catch {
err =>
if strict {
raise @bit.GitError::IoError("HTTP response body truncated: \{err}")
} else {
break
}
}
match chunk {
Some(b) =>
for byte in b {
chunks.push(byte)
}
None => break
}
}
Bytes::from_array(FixedArray::makei(chunks.length(), i => chunks[i]))
}
///|
/// Carry the response headers through.
///
/// They were previously dropped, which made ETags invisible and so made
/// compare-and-swap against an object store impossible.
fn http_response_of(response : @http.Response) -> @bit.HttpResponse {
let headers : Map[String, String] = Map([])
for name, value in response.headers {
headers[name.0] = value
}
@bit.HttpResponse::with_headers(response.code, headers)
}
///|
/// `@http` keys request headers by `CaseInsensitiveString` (async 0.22+);
/// bit's transport-neutral interfaces keep plain `String` keys.
fn to_http_headers(headers : Map[String, String]) -> @http.Headers {
let out : @http.Headers = Map([])
for name, value in headers {
out[@http.CaseInsensitiveString(name)] = value
}
out
}
///|
/// Split an absolute URL into the origin a `Client` connects to and the path
/// a request line carries.
fn split_url_origin(url : String) -> (String, String) raise @bit.GitError {
let scheme_end = match url.find("://") {
Some(i) => i + 3
None => raise @bit.GitError::IoError("URL has no scheme: \{url}")
}
let rest = String::unsafe_substring(url, start=scheme_end, end=url.length())
match rest.find("/") {
Some(i) =>
(
String::unsafe_substring(url, start=0, end=scheme_end + i),
String::unsafe_substring(url, start=scheme_end + i, end=url.length()),
)
None => (url, "/")
}
}
///|
pub async fn native_http_get(
url : String,
headers : Map[String, String],
) -> (@bit.HttpResponse, Bytes) raise @bit.GitError {
let auth_headers = apply_auth_header(url, headers)
let (response, client) = @http.get_stream(
url,
headers=to_http_headers(auth_headers),
) catch {
e => raise @bit.GitError::IoError("HTTP GET failed: \{e}")
}
let data = drain_response(client)
(http_response_of(response), data)
}
///|
pub async fn native_http_post(
url : String,
body : Bytes,
headers : Map[String, String],
) -> (@bit.HttpResponse, Bytes) raise @bit.GitError {
let auth_headers = apply_auth_header(url, headers)
let client = @http.post_stream(url, headers=to_http_headers(auth_headers)) catch {
e => raise @bit.GitError::IoError("HTTP POST failed: \{e}")
}
client.write(body) catch {
e => raise @bit.GitError::IoError("HTTP POST write failed: \{e}")
}
let response = client.end_request() catch {
e => raise @bit.GitError::IoError("HTTP POST end_request failed: \{e}")
}
let data = drain_response(client)
(http_response_of(response), data)
}
///|
pub impl @types.AsyncHttpClient for NativeHttpClient with fn get(
_self,
url,
headers,
) {
native_http_get(url, headers)
}
///|
pub impl @types.AsyncHttpClient for NativeHttpClient with fn post(
_self,
url,
body,
headers,
) {
native_http_post(url, body, headers)
}
///|
pub async fn native_http_put(
url : String,
body : Bytes,
headers : Map[String, String],
) -> (@bit.HttpResponse, Bytes) raise @bit.GitError {
let auth_headers = apply_auth_header(url, headers)
let client = @http.put_stream(url, headers=to_http_headers(auth_headers)) catch {
e => raise @bit.GitError::IoError("HTTP PUT failed: \{e}")
}
client.write(body) catch {
e => raise @bit.GitError::IoError("HTTP PUT write failed: \{e}")
}
let response = client.end_request() catch {
e => raise @bit.GitError::IoError("HTTP PUT end_request failed: \{e}")
}
let data = drain_response(client)
(http_response_of(response), data)
}
///|
/// DELETE, needed by object-store garbage collection.
///
/// `@http` exposes no `delete_stream`, so the request is issued through a
/// `Client` directly: the constructor takes an origin, the request line takes
/// the path.
pub async fn native_http_delete(
url : String,
headers : Map[String, String],
) -> (@bit.HttpResponse, Bytes) raise @bit.GitError {
let auth_headers = apply_auth_header(url, headers)
let (origin, path) = split_url_origin(url)
let client = @http.Client::Client(origin) catch {
e => raise @bit.GitError::IoError("HTTP DELETE connect failed: \{e}")
}
let response = try {
client.request(
@http.RequestMethod::Delete,
path,
extra_headers=to_http_headers(auth_headers),
)
client.end_request()
} catch {
e => {
client.close()
raise @bit.GitError::IoError("HTTP DELETE failed: \{e}")
}
}
let data = drain_response_strict(client)
(http_response_of(response), data)
}
///|
/// Native transport for `bit_objstore`.
///
/// This is the only place the object store touches the network: the store
/// itself takes this as an injected function, which is what lets its
/// status-code handling be tested without a bucket and lets the same store
/// run under a different transport elsewhere.
///
/// Bodies are read strictly — a truncated pack that reported success would
/// corrupt the repository it was read for.
pub async fn native_objstore_send(
verb : String,
url : String,
headers : Map[String, String],
body : Bytes,
) -> @objstore.HttpReply raise @bit.GitError {
let (origin, path) = split_url_origin(url)
let request_method = match verb {
"GET" => @http.RequestMethod::Get
"PUT" => @http.RequestMethod::Put
"POST" => @http.RequestMethod::Post
"DELETE" => @http.RequestMethod::Delete
"HEAD" => @http.RequestMethod::Head
other => raise @bit.GitError::IoError("unsupported HTTP verb: \{other}")
}
// The request is already SigV4-signed, so no credential helper may add to
// it: an extra header would invalidate the signature.
let client = @http.Client::Client(origin) catch {
e => raise @bit.GitError::IoError("connect to \{origin} failed: \{e}")
}
let response = try {
client.request(request_method, path, extra_headers=to_http_headers(headers))
if body.length() > 0 {
client.write(body)
}
client.end_request()
} catch {
e => {
client.close()
raise @bit.GitError::IoError("\{verb} \{url} failed: \{e}")
}
}
let data = drain_response_strict(client)
let out_headers : Map[String, String] = Map([])
for name, value in response.headers {
out_headers[name.0] = value
}
{ status: response.code, headers: out_headers, body: data, }
}
///|
/// Build an S3-backed store wired to the native transport and clock.
pub fn native_s3_store(
config : @objstore.S3Config,
page_size? : Int = 1000,
) -> @objstore.S3Store {
@objstore.S3Store::new(
config,
(verb, url, headers, body) => native_objstore_send(verb, url, headers, body),
amz_date_now,
page_size~,
)
}