API-Docker
view release on metacpan or search on metacpan
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
unless defined $line;
$self->_croak_truncated($ctx, phase => 'chunk-header',
detail => 'the stream ended inside a chunk header, after '
. length($line) . ' byte' . (length($line) == 1 ? '' : 's') . ' of one')
unless $line =~ /\n\z/;
# The line arrived whole and terminated, and its size is not a hexadecimal
# number. Left to hex() that warns once and reads as 0 -- the terminating
# zero chunk -- so a 200 whose framing is corrupt used to come back as an
# empty body, the same body-shaped lie a truncation is. A chunk size is hex
# digits, optionally followed by a ';' extension (RFC 9112 section 7.1.1),
# which is read past and discarded; anything else is refused here rather than
# silently misread by the caller. The returned size is parsed from the same
# match, so hex() is called on nothing but hex digits and never has cause to
# warn on a legal extension either.
my ($size) = $line =~ /^([0-9A-Fa-f]+)(?:;.*)?\r?\n\z/;
$self->_croak_truncated($ctx, phase => 'chunk-header',
detail => "the chunk size line '" . ($line =~ s/\r?\n\z//r)
. "' is not a hexadecimal number")
unless defined $size;
return hex($size);
}
# Completeness only, never content: a terminator that arrived but is not CRLF
# is a daemon speaking chunked wrongly, which is a different complaint and one
# this reader has never made.
sub _assert_chunk_terminator {
my ($self, $ctx, $line) = @_;
$self->_croak_truncated($ctx, phase => 'chunk-terminator',
detail => 'the stream ended before the CRLF that terminates a chunk')
unless defined $line && $line =~ /\n\z/;
return;
}
# The declared body length, validated to be the digits RFC 9110 section 8.6
# requires before it is compared against or counted down (karr k113). The
# sibling of _assert_chunk_header's hex check: a Content-Length that is not a
# number -- 'abc', an empty value, a duplicated '11, 11', a leading space --
# left as it stood is run through `$len > 0`, which warns once ("isn't
# numeric") and reads as 0, so the body is taken to be empty and a response
# that had one comes back blank. Refused here rather than silently misread, so
# hex()'s sibling warning is never reached either.
sub _assert_content_length {
my ($self, $ctx, $value) = @_;
return $value if $value =~ /\A[0-9]+\z/;
$self->_croak_truncated($ctx, phase => 'content-length',
detail => "the Content-Length header '" . $value . "' is not a number");
}
sub _uri_encode {
my ($str) = @_;
# Escape a character string by its UTF-8 bytes ('ü' -> %C3%BC, not %FC), and
# a byte string as it stands. ord() on a character is not its wire byte: a
# name or tag typed under `use utf8`, or read through a :utf8 layer, arrives
# as characters and used to escape to a lone high byte or a bare codepoint
# (%FC, %4E2D) that is not UTF-8 at all. But the encoding cannot be
# unconditional: encode_json has already handed a HASH param (filters among
# them) its UTF-8 octets, and re-encoding those would double them
# (%C3%BC -> %C3%83%C2%BC). The utf8 flag is exactly that distinction -- on
# for a decoded string, off for encode_json's output -- so a copy is encoded
# only when it carries one, leaving the caller's own value untouched either
# way.
my $bytes = $str;
utf8::encode($bytes) if utf8::is_utf8($bytes);
$bytes =~ s/([^A-Za-z0-9\-_.~:\/])/sprintf("%%%02X", ord($1))/ge;
return $bytes;
}
sub get {
my ($self, $path, %opts) = @_;
return $self->_request('GET', $path, %opts);
}
sub post {
my ($self, $path, $body, %opts) = @_;
$opts{body} = $body if defined $body;
return $self->_request('POST', $path, %opts);
}
sub put {
my ($self, $path, $body, %opts) = @_;
$opts{body} = $body if defined $body;
return $self->_request('PUT', $path, %opts);
}
sub delete_request {
my ($self, $path, %opts) = @_;
return $self->_request('DELETE', $path, %opts);
}
sub head {
my ($self, $path, %opts) = @_;
return $self->_request('HEAD', $path, %opts);
}
sub stream_frames {
my ($self, $method, $path, %opts) = @_;
my $tty = delete $opts{tty};
if (my $cb = delete $opts{on_frame}) {
# tty is a declaration here, not the hint it is on the buffered path. The
# sniff below needs the whole body to decide, and the whole body is what a
# callback stream does not have; so an unframed stream has to say so, and
# anything not declared is required to be framed.
return $self->_request($method, $path, %opts,
$tty
? ( on_chunk => sub { $cb->({ stream => 'raw', data => $_[0] }, $_[1]) } )
: ( on_frame => $cb ),
);
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
=head3 Where an end of stream is still the end
A body delimited by nothing but the close. C<attach>,
C<< logs(follow => 1) >>, C</exec/{id}/start> -- the whole
C<application/vnd.docker.raw-stream> family -- carry neither a
C<Content-Length> nor chunked encoding, so the response announces no end and
there is nothing for a short one to be short of. That is how every one of them
finishes, and treating it as truncation would break all of them.
Their B<heads> are another matter and are checked like every other head. An
engine writes those two by hand rather than through its HTTP server, so it is
worth saying that they are well-formed: both answer with C<HTTP/1.1 200 OK>, a
single C<Content-Type> line and the blank line, measured on Docker 29.7.2 and
on rootless Podman 5.8.4. So does every other shape either of them produces --
200, 204, 304, C<HEAD>, chunked. Nothing legitimate ends a head without its
blank line.
The same goes for a stream a callback ended with C<< $stop->() >>: the rest of
the response is unread because the caller said so, and every check on the
streaming path is skipped once it has.
=head3 Against a timeout, and against a status
L<API::Docker::Error::Timeout> is the daemon going B<quiet> for longer than a
bound the caller asked for; this is the daemon B<closing> mid-response, and
needs no bound to be noticed. The two share a contract -- neither ever returns
a short body, and both hand over what arrived -- and are separate classes
because only one of them is about an option, and only one of them can fire on
a response that would have completed.
A response whose status is 400 or above raises this rather than an
L<API::Docker::Error::HTTP> when it is B<its> body that was cut short, which
is the rule the timeout already follows in the same place: the transport
cannot tell a caller what the engine said when it did not finish saying it.
C<< ->partial >> holds the part of the error body that did arrive.
=head2 Header names are rejected, header values are stripped
A CR or LF in a header B<value> is stripped and the value is flattened onto
its own line. A header B<name> that is not an RFC 9110 token is refused with
a croak instead.
The asymmetry is deliberate. A value can pick up a stray newline honestly --
C<MIME::Base64::encode_base64> wraps its output by default, and a token pasted
out of a file brings its line ending along -- and flattening it preserves what
the caller meant. A name is a literal the programmer wrote; there is no benign
way for one to contain CR, LF, a space or a colon, and quietly rewriting
C<< "X-Foo\r\nX-Bar" >> into C<X-FooX-Bar> would put a header on the wire
under a name nobody asked for. Validating against the token grammar also
catches the separators that would corrupt the request without injecting
anything.
=head2 A request path is rejected, not sanitised
The C<$path> given to L</get>, L</post>, L</put>, L</delete_request>, L</head>
and C<_request> is spliced straight into the request line as
C<< $method /v$version$path HTTP/1.1 >>, and it carries caller data: the
resource methods build it by interpolation -- C<< "/containers/$id/json" >>,
C<< "/images/$name/push" >> -- so a container name or an image reference the
user typed ends up in the request line unescaped. A byte the line's own
grammar reads therefore rewrites the request rather than naming a resource: a
CR or LF ends the line and opens a header of its own, a space starts the
HTTP-version field, and a C<?> or C<#> opens the query string or fragment.
So the path is checked against the RFC 3986 origin-form character set --
unreserved, the sub-delims, and C<:> C<@> C<%> C<< / >>, which is the set an
image reference lives in -- and a path outside it is refused with a croak
before anything reaches the wire, the same treatment and for the same reason a
header name gets. Sanitising is not on the table here: percent-encoding the
path at this layer cannot tell a separator from data, so it would either
mangle every C<< / >> and C<:> or leave the injection open. Query parameters
belong in C<params>, which is assembled separately and runs each element
through C<_uri_encode>.
=head2 post
my $data = $client->post($path, $body, %opts);
Perform HTTP POST request. C<$body> is automatically JSON-encoded if provided.
Options: C<params>, C<headers>, C<ndjson>, C<croak_on_error>, C<raw>,
C<response> and the C<on_event>/C<on_frame>/C<on_chunk> callbacks as for
L</get>, plus C<raw_body> and C<content_type> for sending a non-JSON payload
such as a build context tarball.
=head2 put
my $data = $client->put($path, $body, %opts);
Perform HTTP PUT request. C<$body> is automatically JSON-encoded if provided.
Options: C<params>, C<headers>, C<ndjson>, C<croak_on_error>, C<raw>,
C<response> and the C<on_event>/C<on_frame>/C<on_chunk> callbacks as for
L</get>, plus C<raw_body> and C<content_type> for sending a non-JSON payload
-- C<< containers->put_archive >> uses both to send a tar stream.
=head2 delete_request
my $data = $client->delete_request($path, %opts);
Perform HTTP DELETE request.
Options: C<params> (hashref of query parameters).
=head2 head
my %res;
$client->head("/containers/$id/archive",
params => { path => '/etc/hostname' },
response => \%res,
);
my $stat = decode_json(decode_base64($res{headers}{'x-docker-container-path-stat'}));
Perform HTTP HEAD request. Always returns C<undef>: a HEAD response has no
body by definition, so everything it says is in the status line and the
headers, and C<response> is the only way to reach them.
The body is not read even when the response announces one. A HEAD response
repeats the header fields the equivalent GET would send, C<Content-Length>
among them, and then sends nothing -- reading it would block on bytes that
( run in 1.978 second using v1.01-cache-2.11-cpan-54e63673c56 )