| (in-package #:nekod.docker)
|
|
|
|
|
|
|
| (defvar *max-header-size* (* 64 1024))
|
| (defvar *connection-policy* :close)
|
|
|
| (defstruct http-response
|
| (version "" :type string)
|
| (status 0 :type integer)
|
| (reason "" :type string)
|
| (headers nil :type list)
|
| (body #() :type (vector (unsigned-byte 8))))
|
|
|
| (defstruct http-parser-state
|
| (buffer (make-array 4096 :element-type '(unsigned-byte 8)
|
| :adjustable t :fill-pointer 0))
|
| (header-complete nil :type boolean)
|
| (content-length nil)
|
| (chunked nil :type boolean)
|
| (body-bytes-read 0 :type integer)
|
| (complete nil :type boolean))
|
|
|
|
|
|
|
| (defun build-http-request (method path &optional body)
|
| "Build an HTTP/1.1 request string for the Docker API."
|
| (let* ((body-octets (if body
|
| (nekod.docker::string-to-octets body)
|
| #()))
|
| (content-length (length body-octets))
|
| (connection (if (eq *connection-policy* :keep-alive) "keep-alive" "close")))
|
| (format nil "~a ~a HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: ~a\r\nConnection: ~a\r\n\r\n~@[~a~]"
|
| method path content-length connection body)))
|
|
|
|
|
|
|
| (defun parse-status-line (line)
|
| "Parse 'HTTP/1.1 200 OK' into (values version status reason)."
|
| (let* ((sp1 (position #\Space line))
|
| (sp2 (and sp1 (position #\Space line :start (1+ sp1)))))
|
| (unless (and sp1 sp2)
|
| (error 'nekod:http-protocol-error
|
| :message (format nil "Malformed status line: ~a" line)))
|
| (values (subseq line 0 sp1)
|
| (parse-integer (subseq line (1+ sp1) sp2))
|
| (subseq line (1+ sp2)))))
|
|
|
| (defun parse-http-headers (header-text)
|
| "Parse header block into alist of (name . value)."
|
| (let ((headers nil)
|
| (lines (remove "" (mapcar (lambda (l) (string-trim '(#\Return) l))
|
| (uiop:split-string header-text :separator '(#\Newline)))
|
| :test #'string=)))
|
| (dolist (line lines)
|
| (let ((colon (position #\: line)))
|
| (when colon
|
| (push (cons (string-downcase (subseq line 0 colon))
|
| (string-trim '(#\Space) (subseq line (1+ colon))))
|
| headers))))
|
| (nreverse headers)))
|
|
|
| (defun header-value (headers name)
|
| "Get header value by lowercase name from alist."
|
| (cdr (assoc name headers :test #'string=)))
|
|
|
| (defun decode-chunk-size (line)
|
| "Decode hex chunk size from a chunk header line."
|
| (parse-integer (string-trim '(#\Return #\Newline #\Space) line)
|
| :radix 16 :junk-allowed t))
|
|
|
|
|
|
|
| (defun read-content-length-body (sock content-length)
|
| "Read exactly content-length bytes from socket."
|
| (let ((body (make-array content-length :element-type '(unsigned-byte 8)))
|
| (total-read 0))
|
| (loop while (< total-read content-length)
|
| do (let ((n (socket-read-octets sock body
|
| :start total-read
|
| :end content-length)))
|
| (when (<= n total-read)
|
| (error 'nekod:http-body-truncated
|
| :message "Connection closed before body complete"
|
| :expected content-length
|
| :received total-read))
|
| (setf total-read n)))
|
| body))
|
|
|
| (defun read-chunked-body (sock)
|
| "Read chunked transfer-encoding body incrementally."
|
| (let ((result (make-array 0 :element-type '(unsigned-byte 8)
|
| :adjustable t :fill-pointer 0)))
|
| (loop
|
| (let* ((size-line-octets (socket-read-until sock (format nil "~c~c" #\Return #\Newline)))
|
| (size-line (nekod.docker::octets-to-string size-line-octets :external-format :utf-8))
|
| (chunk-size (decode-chunk-size size-line)))
|
| (when (or (null chunk-size) (zerop chunk-size))
|
| (socket-read-until sock (format nil "~c~c" #\Return #\Newline))
|
| (return result))
|
| (let ((chunk (make-array chunk-size :element-type '(unsigned-byte 8)))
|
| (read-so-far 0))
|
| (loop while (< read-so-far chunk-size)
|
| do (let ((n (socket-read-octets sock chunk
|
| :start read-so-far
|
| :end chunk-size)))
|
| (when (<= n read-so-far)
|
| (error 'nekod:http-body-truncated
|
| :message "Chunk truncated"
|
| :expected chunk-size
|
| :received read-so-far))
|
| (setf read-so-far n)))
|
| (loop for b across chunk do (vector-push-extend b result))
|
| (socket-read-until sock (format nil "~c~c" #\Return #\Newline)))))
|
| result))
|
|
|
|
|
|
|
| (defun read-http-response (sock)
|
| "Read a complete HTTP response from socket. Handles fragmented reads."
|
| (let* ((header-octets (socket-read-until sock (format nil "~c~c~c~c"
|
| #\Return #\Newline
|
| #\Return #\Newline)
|
| :max-bytes *max-header-size*))
|
| (header-text (nekod.docker::octets-to-string header-octets :external-format :utf-8))
|
| (header-end (search (format nil "~c~c~c~c" #\Return #\Newline #\Return #\Newline)
|
| header-text))
|
| (status-end (position #\Newline header-text))
|
| (status-line (subseq header-text 0 (and status-end
|
| (if (and status-end
|
| (> status-end 0)
|
| (char= (char header-text (1- status-end)) #\Return))
|
| (1- status-end)
|
| status-end))))
|
| (header-block (if header-end
|
| (subseq header-text (1+ status-end) header-end)
|
| "")))
|
| (multiple-value-bind (version status reason) (parse-status-line status-line)
|
| (let* ((headers (parse-http-headers header-block))
|
| (cl-val (header-value headers "content-length"))
|
| (te-val (header-value headers "transfer-encoding"))
|
| (content-length (and cl-val (parse-integer cl-val :junk-allowed t)))
|
| (chunked-p (and te-val (search "chunked" te-val)))
|
| (body (cond
|
| ((member status '(204 304 101))
|
| (make-array 0 :element-type '(unsigned-byte 8)))
|
| (chunked-p
|
| (read-chunked-body sock))
|
| (content-length
|
| (read-content-length-body sock content-length))
|
| (t
|
| (make-array 0 :element-type '(unsigned-byte 8))))))
|
| (make-http-response :version version
|
| :status status
|
| :reason reason
|
| :headers headers
|
| :body body)))))
|
|
|
|
|
|
|
| (defun parse-http-response (raw)
|
| "Legacy: Parse HTTP response string. Extract status code and body."
|
| (let* ((header-end (search (format nil "~c~c~c~c" #\Return #\Newline #\Return #\Newline) raw))
|
| (status-line (subseq raw 0 (position #\Return raw)))
|
| (sp1 (position #\Space status-line))
|
| (sp2 (and sp1 (position #\Space status-line :start (1+ sp1))))
|
| (status-code (and sp1 sp2 (parse-integer (subseq status-line (1+ sp1) sp2)
|
| :junk-allowed t)))
|
| (body (if header-end (subseq raw (+ header-end 4)) "")))
|
| (values (or status-code 0) body)))
|
|
|
| (defun docker-request (sock method path &optional body)
|
| "Send HTTP request and read full response via real socket transport.
|
| Returns http-response struct."
|
| (let ((req (build-http-request method path body)))
|
| (socket-write sock req)
|
| (read-http-response sock)))
|
|
|
| (defun docker-get (sock path)
|
| "GET request to Docker API. Returns (values status body-string)."
|
| (let ((resp (docker-request sock "GET" path)))
|
| (values (http-response-status resp)
|
| (nekod.docker::octets-to-string (http-response-body resp)
|
| :external-format :utf-8))))
|
|
|
| (defun docker-post (sock path &optional body)
|
| "POST request to Docker API. Returns (values status body-string)."
|
| (let ((resp (docker-request sock "POST" path body)))
|
| (values (http-response-status resp)
|
| (nekod.docker::octets-to-string (http-response-body resp)
|
| :external-format :utf-8))))
|
|
|
| (defun docker-delete (sock path)
|
| "DELETE request to Docker API. Returns (values status body-string)."
|
| (let ((resp (docker-request sock "DELETE" path)))
|
| (values (http-response-status resp)
|
| (nekod.docker::octets-to-string (http-response-body resp)
|
| :external-format :utf-8))))
|
|
|