2 * Copyright (c) 2024 Omar Polo <op@openbsd.org>
4 * Permission to use, copy, modify, and distribute this software for any
5 * purpose with or without fee is hereby granted, provided that the above
6 * copyright notice and this permission notice appear in all copies.
8 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
9 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
10 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
11 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
12 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
13 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
14 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18 #include <sys/types.h>
19 #include <sys/socket.h>
35 #include "got_opentemp.h"
36 #include "got_version.h"
42 #define USERAGENT "got-notify-http/" GOT_VERSION_STR
44 static int http_timeout = 300; /* 5 minutes in seconds */
49 fprintf(stderr, "usage: %s [-c] -r repo -h host -p port path\n",
55 dial(const char *host, const char *port)
57 struct addrinfo hints, *res, *res0;
58 const char *cause = NULL;
59 int s, error, save_errno;
61 memset(&hints, 0, sizeof(hints));
62 hints.ai_family = AF_UNSPEC;
63 hints.ai_socktype = SOCK_STREAM;
64 error = getaddrinfo(host, port, &hints, &res0);
66 errx(1, "failed to resolve %s:%s: %s", host, port,
70 for (res = res0; res; res = res->ai_next) {
71 s = socket(res->ai_family, res->ai_socktype,
78 if (connect(s, res->ai_addr, res->ai_addrlen) == -1) {
97 escape(FILE *fp, const uint8_t *s)
99 uint32_t codepoint, state;
100 const uint8_t *start = s;
104 switch (decode(&state, &codepoint, *s)) {
109 fprintf(fp, "\\%c", *s);
127 /* other control characters */
128 if (codepoint < ' ' || codepoint == 0x7F) {
129 fprintf(fp, "\\u%04x", codepoint);
132 fwrite(start, 1, s - start + 1, fp);
139 /* bad UTF-8 sequence; try to recover */
140 fputs("\\uFFFD", fp);
149 json_field(FILE *fp, const char *key, const char *val, int comma)
151 fprintf(fp, "\"%s\":\"", key);
153 fprintf(fp, "\"%s", comma ? "," : "");
157 json_date(FILE *fp, const char *key, const char *date, int comma)
159 fprintf(fp, "\"%s\":%s%s", key, date, comma ? "," : "");
163 json_author(FILE *fp, const char *type, char *address, int comma)
165 char *gt, *lt, *at, *email, *endname;
167 fprintf(fp, "\"%s\":{", type);
169 gt = strchr(address, '<');
171 /* long format, e.g. "Omar Polo <op@openbsd.org>" */
173 json_field(fp, "full", address, 1);
176 while (endname > address && endname[-1] == ' ')
180 json_field(fp, "name", address, 1);
183 lt = strchr(email, '>');
187 json_field(fp, "mail", email, 1);
189 at = strchr(email, '@');
193 json_field(fp, "user", email, 0);
195 /* short format only shows the username */
196 json_field(fp, "user", address, 0);
199 fprintf(fp, "}%s", comma ? "," : "");
203 jsonify_branch_rm(FILE *fp, char *line, const char *repo)
207 line = strchr(line, ' ');
209 errx(1, "invalid branch rm line");
210 line += strspn(line, " ");
214 line = strchr(line, ':');
216 errx(1, "invalid branch rm line");
218 id = line + strspn(line, " ");
221 json_field(fp, "type", "branch-deleted", 1);
222 json_field(fp, "repo", repo, 1);
223 json_field(fp, "ref", ref, 1);
224 json_field(fp, "id", id, 0);
231 jsonify_commit_short(FILE *fp, char *line, const char *repo)
233 char *t, *date, *id, *author, *message;
237 if ((t = strchr(t, ' ')) == NULL)
238 errx(1, "malformed line");
242 if ((t = strchr(t, ' ')) == NULL)
243 errx(1, "malformed line");
247 if ((t = strchr(t, ' ')) == NULL)
248 errx(1, "malformed line");
253 fprintf(fp, "{\"type\":\"commit\",\"short\":true,");
254 json_field(fp, "repo", repo, 1);
255 json_field(fp, "id", id, 1);
256 json_author(fp, "committer", author, 1);
257 json_date(fp, "date", date, 1);
258 json_field(fp, "short_message", message, 0);
265 jsonify_commit(FILE *fp, const char *repo, char **line, ssize_t *linesize)
273 int msglen = 0, msgwrote = 0;
288 if (strncmp(l, "commit ", 7) != 0)
289 errx(1, "%s: unexpected line: %s", __func__, l);
292 fprintf(fp, "{\"type\":\"commit\",\"short\":false,");
293 json_field(fp, "repo", repo, 1);
294 json_field(fp, "id", l, 1);
297 if ((linelen = getline(line, linesize, stdin)) == -1)
300 if ((*line)[linelen - 1] == '\n')
301 (*line)[--linelen] = '\0';
306 if (strncmp(l, "from: ", 6) != 0)
307 errx(1, "unexpected from line");
314 json_author(fp, "author", l, 1);
321 if (!strncmp(l, "via: ", 5)) {
323 json_author(fp, "committer", l, 1);
328 if (author == NULL) /* impossible */
329 err(1, "from not specified");
330 json_author(fp, "committer", author, 1);
339 if (!strncmp(l, "date: ", 6)) {
341 json_date(fp, "date", l, 1);
349 /* optional - more than one */
350 if (!strncmp(l, "parent ", 7)) {
352 l += strcspn(l, ":");
357 fprintf(fp, "\"parents\":[");
374 if (strncmp(l, "messagelen: ", 12) != 0)
375 errx(1, "unexpected messagelen line");
377 msglen = strtonum(l, 1, INT_MAX, &errstr);
379 errx(1, "message len is %s: %s", errstr, l);
388 * The commit message is indented with one extra
389 * space which is not accounted for in messagelen,
390 * but we also strip the trailing \n so that
393 * Since we read line-by-line and there is always
394 * a \n added at the end of the message,
395 * tolerate one byte less than advertised.
398 errx(1, "unexpected line in commit message");
400 l++; /* skip leading space */
403 if (msgwrote == 0 && linelen != 0) {
404 json_field(fp, "short_message", l, 1);
405 fprintf(fp, "\"message\":\"");
409 } else if (msgwrote != 0) {
414 msglen -= linelen + 1;
423 if (files == 0 && !strcmp(l, " "))
427 fputs("\"diffstat\":{\"files\":[", fp);
436 errx(1, "bad diffstat line");
445 json_field(fp, "action", "added", 1);
448 json_field(fp, "action", "deleted", 1);
451 json_field(fp, "action", "modified", 1);
454 json_field(fp, "action", "mode changed", 1);
457 json_field(fp, "action", "unknown", 1);
465 errx(1, "invalid diffstat: no filename");
470 errx(1, "invalid diffstat: no separator");
472 while (t > filename && *t == ' ')
474 json_field(fp, "file", filename, 1);
482 errx(1, "invalid diffstat: no added counter");
485 n = strtonum(l, 0, INT_MAX, &errstr);
487 errx(1, "added counter is %s: %s", errstr, l);
488 fprintf(fp, "\"added\":%d,", n);
496 errx(1, "invalid diffstat: no del counter");
499 n = strtonum(l, 0, INT_MAX, &errstr);
501 errx(1, "del counter is %s: %s", errstr, l);
502 fprintf(fp, "\"removed\":%d", n);
511 fputs("\"total\":{", fp);
516 errx(1, "missing number of additions");
519 n = strtonum(t, 0, INT_MAX, &errstr);
521 errx(1, "add counter is %s: %s", errstr, t);
522 fprintf(fp, "\"added\":%d,", n);
526 errx(1, "missing number of deletions");
533 errx(1, "malformed diffstat sum line");
536 n = strtonum(l, 0, INT_MAX, &errstr);
538 errx(1, "del counter is %s: %s", errstr, l);
539 fprintf(fp, "\"removed\":%d", n);
547 errx(1, "unexpected line: %s", *line);
553 errx(1, "unexpected EOF");
560 jsonify_tag(FILE *fp, const char *repo, char **line, ssize_t *linesize)
565 int msglen = 0, msgwrote = 0;
576 if (strncmp(l, "tag ", 4) != 0)
577 errx(1, "%s: unexpected line: %s", __func__, l);
581 json_field(fp, "type", "tag", 1);
582 json_field(fp, "repo", repo, 1);
583 json_field(fp, "tag", l, 1);
586 if ((linelen = getline(line, linesize, stdin)) == -1)
589 if ((*line)[linelen - 1] == '\n')
590 (*line)[--linelen] = '\0';
595 if (strncmp(l, "from: ", 6) != 0)
596 errx(1, "unexpected from line");
599 json_author(fp, "tagger", l, 1);
606 if (!strncmp(l, "date: ", 6)) {
608 json_date(fp, "date", l, 1);
617 if (!strncmp(l, "object: ", 8)) {
624 errx(1, "malformed tag object line");
627 fputs("\"object\":{", fp);
628 json_field(fp, "type", type, 1);
629 json_field(fp, "id", id, 0);
639 if (strncmp(l, "messagelen: ", 12) != 0)
640 errx(1, "unexpected messagelen line");
642 msglen = strtonum(l, 1, INT_MAX, &errstr);
644 errx(1, "message len is %s: %s", errstr, l);
653 errx(1, "unexpected line in tag message");
655 l++; /* skip leading space */
658 if (msgwrote == 0 && linelen != 0) {
659 fprintf(fp, "\"message\":\"");
663 } else if (msgwrote != 0) {
668 msglen -= linelen + 1;
678 errx(1, "unexpected line: %s", *line);
684 errx(1, "unexpected EOF");
691 jsonify(FILE *fp, const char *repo)
698 fprintf(fp, "{\"notifications\":[");
699 while ((linelen = getline(&line, &linesize, stdin)) != -1) {
700 if (line[linelen - 1] == '\n')
701 line[--linelen] = '\0';
710 if (strncmp(line, "Removed refs/heads/", 19) == 0) {
711 if (jsonify_branch_rm(fp, line, repo) == -1)
712 err(1, "jsonify_branch_rm");
716 if (strncmp(line, "commit ", 7) == 0) {
717 if (jsonify_commit(fp, repo, &line, &linesize) == -1)
718 err(1, "jsonify_commit");
722 if (*line >= '0' && *line <= '9') {
723 if (jsonify_commit_short(fp, line, repo) == -1)
724 err(1, "jsonify_commit_short");
728 if (strncmp(line, "tag ", 4) == 0) {
729 if (jsonify_tag(fp, repo, &line, &linesize) == -1)
730 err(1, "jsonify_tag");
734 errx(1, "unexpected line: %s", line);
762 errx(1, "invalid sixet 0x%x", c);
766 basic_auth(const char *username, const char *password)
768 char *str, *tmp, *end, *s, *p;
772 r = asprintf(&str, "%s:%s", username, password);
777 * Will need 4 * r/3 bytes to encode the string, plus a
778 * rounding to the next multiple of 4 for padding, plus NUL.
781 len = (len + 3) & ~3;
784 tmp = calloc(1, len);
791 memset(buf, 0, sizeof(buf));
792 for (i = 0; i < 3 && *s != '\0'; ++i, ++s)
795 *p++ = sixet2ch(buf[0] >> 2);
796 *p++ = sixet2ch((buf[1] >> 4) | (buf[0] << 4));
798 *p++ = sixet2ch((buf[1] << 2) | (buf[2] >> 6));
800 *p++ = sixet2ch(buf[2]);
803 for (end = tmp + len - 1; p < end; ++p)
811 bufio2poll(struct bufio *bio)
816 if (f & BUFIO_WANT_READ)
818 if (f & BUFIO_WANT_WRITE)
824 main(int argc, char **argv)
829 struct timespec timeout;
830 const char *username;
831 const char *password;
832 const char *timeoutstr;
834 const char *repo = NULL;
835 const char *host = NULL, *port = NULL, *path = NULL;
836 char *auth, *line, *spc;
841 int response_code = 0, done = 0;
842 int ch, flags, ret, nonstd = 0;
845 if (pledge("stdio rpath tmppath dns inet", NULL) == -1)
849 log_init(0, LOG_DAEMON);
851 while ((ch = getopt(argc, argv, "ch:p:r:")) != -1) {
872 if (host == NULL || repo == NULL || argc != 1)
874 if (tls && port == NULL)
878 username = getenv("GOT_NOTIFY_HTTP_USER");
879 password = getenv("GOT_NOTIFY_HTTP_PASS");
880 if ((username != NULL && password == NULL) ||
881 (username == NULL && password != NULL))
882 fatalx("username or password are not specified");
883 if (username && *password == '\0')
884 fatalx("password can't be empty");
886 /* used by the regression test suite */
887 timeoutstr = getenv("GOT_NOTIFY_TIMEOUT");
889 http_timeout = strtonum(timeoutstr, 0, 600, &errstr);
891 fatalx("timeout in seconds is %s: %s",
895 memset(&timeout, 0, sizeof(timeout));
896 timeout.tv_sec = http_timeout;
898 tmpfp = got_opentemp();
902 jsonify(tmpfp, repo);
904 paylen = ftello(tmpfp);
907 if (fseeko(tmpfp, 0, SEEK_SET) == -1)
912 if (pledge("stdio rpath dns inet", NULL) == -1)
916 memset(&pfd, 0, sizeof(pfd));
917 pfd.fd = dial(host, port);
919 if ((flags = fcntl(pfd.fd, F_GETFL)) == -1)
920 fatal("fcntl(F_GETFL)");
921 if (fcntl(pfd.fd, F_SETFL, flags | O_NONBLOCK) == -1)
922 fatal("fcntl(F_SETFL)");
924 if (bufio_init(&bio) == -1)
926 bufio_set_fd(&bio, pfd.fd);
927 if (tls && bufio_starttls(&bio, host, 0, NULL, 0, NULL, 0) == -1)
928 fatal("bufio_starttls");
931 /* drop rpath dns inet */
932 if (pledge("stdio", NULL) == -1)
936 if ((!tls && strcmp(port, "80") != 0) ||
937 (tls && strcmp(port, "443")) != 0)
940 ret = bufio_compose_fmt(&bio,
941 "POST %s HTTP/1.1\r\n"
943 "Content-Type: application/json\r\n"
944 "Content-Length: %lld\r\n"
946 "Connection: close\r\n",
948 nonstd ? ":" : "", nonstd ? port : "",
949 (long long)paylen, USERAGENT);
951 fatal("bufio_compose_fmt");
954 auth = basic_auth(username, password);
955 ret = bufio_compose_fmt(&bio, "Authorization: basic %s\r\n",
958 fatal("bufio_compose_fmt");
962 if (bufio_compose(&bio, "\r\n", 2) == -1)
963 fatal("bufio_compose");
966 struct timespec elapsed, start, stop;
969 pfd.events = bufio2poll(&bio);
970 clock_gettime(CLOCK_MONOTONIC, &start);
971 ret = ppoll(&pfd, 1, &timeout, NULL);
974 clock_gettime(CLOCK_MONOTONIC, &stop);
975 timespecsub(&stop, &start, &elapsed);
976 timespecsub(&timeout, &elapsed, &timeout);
977 if (ret == 0 || timeout.tv_sec <= 0)
980 if (bio.wbuf.len > 0) {
981 if (bufio_write(&bio) == -1 && errno != EAGAIN)
982 fatalx("bufio_write: %s", bufio_io_err(&bio));
985 r = bufio_read(&bio);
986 if (r == -1 && errno != EAGAIN)
987 fatalx("bufio_read: %s", bufio_io_err(&bio));
989 fatalx("unexpected EOF");
992 line = buf_getdelim(&bio.rbuf, "\r\n", &len);
995 if (response_code && *line == '\0') {
997 * end of headers, don't bother
998 * reading the body, if there is.
1003 if (response_code) {
1004 buf_drain(&bio.rbuf, len);
1007 spc = strchr(line, ' ');
1009 fatalx("bad HTTP response from server");
1011 if (strcasecmp(line, "HTTP/1.1") != 0)
1012 log_warnx("unexpected protocol: %s", line);
1015 spc = strchr(line, ' ');
1017 fatalx("bad HTTP response from server");
1020 response_code = strtonum(line, 100, 599,
1023 log_warnx("response code is %s: %s",
1026 buf_drain(&bio.rbuf, len);
1031 if (!feof(tmpfp) && bio.wbuf.len < sizeof(buf)) {
1032 len = fread(buf, 1, sizeof(buf), tmpfp);
1039 if (bufio_compose(&bio, buf, len) == -1)
1040 fatal("buf_compose");
1044 if (response_code >= 200 && response_code < 300)
1046 fatal("request failed with code %d", response_code);