1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
//! GH-228: the HTTP transport the OCI Distribution v1.1 push runs on.
//!
//! Every registry verb used to be a `curl` subprocess whose raw `-D -` header
//! dump was re-parsed back into a status code and whose exit code stood in for
//! "did the registry accept this?". That made an external binary an undeclared
//! runtime dependency of `forjar build --push` (GH-224), and it put the two
//! load-bearing correctness rules of this module one remove from the code,
//! expressed as argv strings rather than as checks on a response.
//!
//! This module is the only place `ureq` is named. The two rules now live in
//! [`agent_for_url`] as configuration, and every caller gates on
//! [`RegistryResponse::status`]:
//!
//! * `http_status_as_error(false)` — a 4xx must arrive as a response with its
//! body intact, not as an `Err` with the registry's diagnostic discarded.
//! That diagnostic is what `--fail-with-body` was added for in #154.
//! * `max_redirects(0)` — **the #210 gate**. `docker.io` answers the upload
//! POST with a 301 to its marketing site. ureq follows up to 10 redirects by
//! default, so leaving that on would hand `initiate_upload` a 200 from a web
//! page and reopen exactly the hole #210 closed.
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use std::time::Duration;
use ureq::http::request::Builder;
use ureq::http::Response;
use ureq::tls::{RootCerts, TlsConfig};
use ureq::{Agent, AsSendBody, Body, Proxy, SendBody};
/// Connect timeout for registry requests.
///
/// Only the CONNECT phase is bounded — never the transfer — so a slow 64 MB
/// upload is unaffected while an unroutable registry fails fast instead of
/// hanging. This is why no global timeout is configured.
pub(crate) const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
/// How much of a failing registry's response body is quoted back in an error.
const MAX_ERROR_BODY: u64 = 8 * 1024;
/// What a registry answered, reduced to the four facts the push protocol needs.
pub(crate) struct RegistryResponse {
/// The HTTP status. Every caller gates on this and nothing else.
pub status: u16,
/// `Location`, the upload-session URL on a 202.
pub location: Option<String>,
/// `Docker-Content-Digest`, used to prove a tag resolves to what we pushed.
pub docker_content_digest: Option<String>,
/// The response body, read only for a non-2xx and capped at 8 KiB — this
/// is the registry's own error text (`BLOB_UPLOAD_INVALID`, a token hint),
/// which is the thing `--fail-with-body` existed to surface.
pub body: String,
}
/// What to send as the request body.
pub(crate) enum RequestBody<'a> {
/// No body (HEAD, and the POST that opens an upload session).
Empty,
/// An in-memory body — the manifest JSON.
Bytes(&'a [u8]),
/// A whole file, streamed. `Content-Length` comes from its metadata, so a
/// 64 MB blob is never buffered.
File(&'a Path),
/// A byte range of a file, streamed — one chunk of a chunked PATCH upload.
/// The caller must set `Content-Length: len`; without it ureq falls back to
/// chunked transfer-encoding, which some registries reject on PATCH.
FileRange {
/// File to read the chunk from.
path: &'a Path,
/// Offset of the chunk within the file.
offset: u64,
/// Length of the chunk in bytes.
len: u64,
},
}
/// Build the agent for requests aimed at `url`.
///
/// Proxy handling is the one place this differs per URL. A loopback registry
/// (the usual way to test a push without handing credentials to anyone) must
/// never be proxied: `curl` honored an ambient `HTTP(S)_PROXY` even for
/// `127.0.0.1`, with no way to say otherwise short of mutating process-global
/// env, which is why all four in-process-registry tests had to be `#[ignore]`d.
/// Configuring the proxy per agent removes that, so those tests can run.
pub(crate) fn agent_for_url(url: &str) -> Agent {
let loopback = super::registry_push_http::registry_scheme(authority(url)) == "http";
let proxy = if loopback {
None
} else {
Proxy::try_from_env()
};
Agent::config_builder()
.http_status_as_error(false)
.max_redirects(0)
.timeout_global(None)
.timeout_connect(Some(CONNECT_TIMEOUT))
.proxy(proxy)
.tls_config(
TlsConfig::builder()
.root_certs(RootCerts::PlatformVerifier)
.build(),
)
.build()
.new_agent()
}
/// The `host[:port]` of an absolute URL, or the whole string if it is not one.
pub(crate) fn authority(url: &str) -> &str {
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
rest.split(['/', '?', '#']).next().unwrap_or(rest)
}
/// Perform one registry request. `Err` means the transport never produced a
/// response; any HTTP status — including 4xx and 5xx — comes back as `Ok`, so
/// the caller decides what the status means.
pub(crate) fn send(
agent: &Agent,
method: &str,
url: &str,
headers: &[(&str, String)],
body: RequestBody<'_>,
) -> Result<RegistryResponse, String> {
let mut builder = ureq::http::Request::builder().method(method).uri(url);
for (name, value) in headers {
builder = builder.header(*name, value);
}
Ok(collect(dispatch(agent, builder, body)?))
}
/// One arm per body shape; the type of the body differs in each, which is why
/// this is a match over four `run` calls rather than one.
fn dispatch(
agent: &Agent,
builder: Builder,
body: RequestBody<'_>,
) -> Result<Response<Body>, String> {
match body {
RequestBody::Empty => run(agent, builder, ()),
RequestBody::Bytes(bytes) => run(agent, builder, bytes),
RequestBody::File(path) => run(agent, builder, open(path)?),
RequestBody::FileRange { path, offset, len } => {
let mut file = open(path)?;
file.seek(SeekFrom::Start(offset))
.map_err(|e| format!("seek {} to {offset}: {e}", path.display()))?;
run(agent, builder, SendBody::from_owned_reader(file.take(len)))
}
}
}
/// Open a blob for upload, naming the file in the error.
fn open(path: &Path) -> Result<File, String> {
File::open(path).map_err(|e| format!("open {}: {e}", path.display()))
}
fn run<B: AsSendBody>(agent: &Agent, builder: Builder, body: B) -> Result<Response<Body>, String> {
let request = builder
.body(body)
.map_err(|e| format!("malformed request: {e}"))?;
agent.run(request).map_err(|e| e.to_string())
}
/// Reduce a live response to [`RegistryResponse`], draining the body only when
/// the status says something went wrong.
fn collect(mut response: Response<Body>) -> RegistryResponse {
let status = response.status().as_u16();
let location = header(&response, "location");
let docker_content_digest = header(&response, "docker-content-digest");
let body = if is_success(status) {
String::new()
} else {
read_error_body(&mut response)
};
RegistryResponse {
status,
location,
docker_content_digest,
body,
}
}
/// Did the registry accept the request?
pub(crate) fn is_success(status: u16) -> bool {
(200..300).contains(&status)
}
fn header(response: &Response<Body>, name: &str) -> Option<String> {
response
.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
/// Read at most [`MAX_ERROR_BODY`] bytes of the body. Truncates rather than
/// failing: a body that is too long, or not UTF-8, must not cost us the
/// diagnostic entirely.
fn read_error_body(response: &mut Response<Body>) -> String {
let mut buf = Vec::new();
let _ = response
.body_mut()
.as_reader()
.take(MAX_ERROR_BODY)
.read_to_end(&mut buf);
String::from_utf8_lossy(&buf).trim().to_string()
}
/// The registry's own words about a failure, for an error message.
pub(crate) fn detail(response: &RegistryResponse) -> String {
if response.body.is_empty() {
"no response body".to_string()
} else {
response.body.clone()
}
}