From: Omar Polo Subject: add got-notify-http To: gameoftrees@openbsd.org Date: Wed, 27 Mar 2024 16:53:17 +0100 This is the first draft for got-notify-http. It's starting to work enough that I think it can be improved in-tree. got-notify-http will allow to post a JSON representation of the changes to a remote HTTP server, similarly to how got-notify-smtp does it via email. The missing bits are the parsing of the tag and branch notifications, which will result in a parse error at the moment, and the parsing of the diffstat, which is currently ignored. The generated JSON is also wrong when it comes to non-ASCII characters, see the XXX below. The generated JSON is an array of notification objects, whose exact layout is only indicative at the moment. I'd like to improve it, but all the information are there already. Regarding the implementation: I've reused a bufferized I/O thingy I wrote for some other personal projects. It's async-friendly and has optional TLS support (via libtls), and could also come in handy in an eventual got-fetch-http and to add smtps/starttls support to got-notify-smtp, so I'm inclided to include it as well. I can replace with something custom eventually if preferred. A limitation is that we don't follow redirects. More than a technical reason is just to drop the "inet dns" promise and run the TLS and HTTP machinery under "stdio" alone. It would also allow to sandbox in -portable. A server replying with a redirect that force the client to re-try the post at a new URL is not that common, so maybe it's an acceptable limitation. ok to import this and continue hacking in tree? commit d35c51da3960ad29482ffe21c31d7f4f70a3e555 (http-notif) from: Omar Polo date: Wed Mar 27 15:39:13 2024 UTC add got-notify-http diff f9e6537008c7344a6107b88bc19c5f293cacd793 d35c51da3960ad29482ffe21c31d7f4f70a3e555 commit - f9e6537008c7344a6107b88bc19c5f293cacd793 commit + d35c51da3960ad29482ffe21c31d7f4f70a3e555 blob - 45a21b3bd385d22ec6ae514d311b7bd4a6753183 blob + 602eb98ac68d38179e233dd928e6198ad72b3e34 --- gotd/gotd.conf.5 +++ gotd/gotd.conf.5 @@ -254,15 +254,15 @@ The default content of email notifications looks simil .Cm got log -d command. .Pp -.\" Notifications via HTTP require a HTTP or HTTPS server which is accepting -.\" POST requests with or without HTTP Basic authentication. -.\" Depending on the use case a custom server-side CGI script may be required -.\" for the processing of notifications. -.\" HTTP notifications can achieve functionality -.\" similar to Git's server-side post-receive hook script with -.\" .Xr gotd 8 -.\" by triggering arbitrary post-commit actions via the HTTP server. -.\" .Pp +Notifications via HTTP require a HTTP or HTTPS server which is accepting +POST requests with or without HTTP Basic authentication. +Depending on the use case a custom server-side CGI script may be required +for the processing of notifications. +HTTP notifications can achieve functionality +similar to Git's server-side post-receive hook script with +.Xr gotd 8 +by triggering arbitrary post-commit actions via the HTTP server. +.Pp The .Ic notify directive expects parameters which must be enclosed in curly braces. @@ -329,41 +329,40 @@ and .Ic port directives can be used to specify a different SMTP server address and port. .Pp -.\" .It Ic url Ar URL Ic user Ar user Ic password Ar password Oc -.\" Send notifications via HTTP. -.\" This directive may be specified multiple times to build a list of -.\" HTTP servers to send notifications to. -.\" .Pp -.\" The notification will be sent as a POST request to the given -.\" .Ar URL , -.\" which must be a valid HTTP URL and begin with either -.\" .Dq http:// -.\" or -.\" .Dq https:// . -.\" If HTTPS is used, sending of notifications will only succeed if -.\" no TLS errors occur. -.\" .Pp -.\" The optional -.\" .Ic user -.\" and -.\" .Ic password -.\" directives enable HTTP Basic authentication. -.\" If used, both a -.\" .Ar user -.\" and a -.\" .Ar password -.\" must be specified. -.\" The -.\" .Ar password -.\" must not be an empty string. -.\" .Pp -.\" The request body contains a JSON document with the following objects: -.\" .Bl -tag -width { "notifications" : array } -.\" .It { "notifications" : array } -.\" The top-level object contains an array of all notifications in this request. -.\" .It TODO ... -.\" .El +.It Ic url Ar URL Oo Ic user Ar user Ic password Ar password Oc +Send notifications via HTTP. +This directive may be specified multiple times to build a list of +HTTP servers to send notifications to. +.Pp +The notification will be sent as a POST request to the given +.Ar URL , +which must be a valid HTTP URL and begin with either +.Dq http:// +or +.Dq https:// . +If HTTPS is used, sending of notifications will only succeed if +no TLS errors occur. +.Pp +The optional +.Ic user +and +.Ic password +directives enable HTTP Basic authentication. +If used, both a +.Ar user +and a +.Ar password +must be specified. +The +.Ar password +must not be an empty string. +.Pp +The request body contains a JSON document with the following objects: +.Bl -tag -width { "notifications" : array } +.It { "notifications" : array } +The top-level object contains an array of all notifications in this request. .El +.El .Sh FILES .Bl -tag -width Ds -compact .It Pa /etc/gotd.conf blob - 0db514ba51566c92c7c7cf3d1df4c1b69e81c7fe blob + c4375525ccc474f8fe090e2653580512c0faf220 --- gotd/gotd.h +++ gotd/gotd.h @@ -106,7 +106,10 @@ struct gotd_notification_target { char *port; } email; struct { - char *url; + int tls; + char *hostname; + char *port; + char *path; char *user; char *password; } http; blob - 59c09c35e0b26fb30a138fc1dd52d8f994826c32 blob + c813ab49420ca5ad0a04538cd3722ea5e71af243 --- gotd/libexec/Makefile +++ gotd/libexec/Makefile @@ -1,3 +1,3 @@ -SUBDIR = got-notify-email +SUBDIR = got-notify-email got-notify-http .include blob - /dev/null blob + f7c019a6d9614f8bbaac0c5ec29a63e69b94b5ea (mode 644) --- /dev/null +++ gotd/libexec/got-notify-http/Makefile @@ -0,0 +1,14 @@ +.PATH:${.CURDIR}/../.. +.PATH:${.CURDIR}/../../../lib + +.include "../../../got-version.mk" + +PROG= got-notify-http +SRCS= got-notify-http.c bufio.c opentemp.c pollfd.c error.c hash.c + +CPPFLAGS= -I${.CURDIR}/../../../include -I${.CURDIR}/../../../lib + +DPADD= ${LIBTLS} +LDADD= -ltls + +.include blob - /dev/null blob + 779b64b8f926d8b54243bbffe99d9cb186659d70 (mode 644) --- /dev/null +++ gotd/libexec/got-notify-http/got-notify-http.c @@ -0,0 +1,624 @@ +/* + * Copyright (c) 2024 Omar Polo + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "got_opentemp.h" +#include "got_version.h" + +#include "bufio.h" + +#define USERAGENT "got-notify-http/" GOT_VERSION_STR + +static int http_timeout = 300; /* 5 minutes in seconds */ + +__dead static void +usage(void) +{ + fprintf(stderr, "usage: %s [-c] -h host -p port path\n", + getprogname()); + exit(1); +} + +static int +dial(const char *host, const char *port) +{ + struct addrinfo hints, *res, *res0; + const char *cause = NULL; + int s, error, save_errno; + + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + error = getaddrinfo(host, port, &hints, &res0); + if (error) + errx(1, "failed to resolve %s:%s: %s", host, port, + gai_strerror(error)); + + s = -1; + for (res = res0; res; res = res->ai_next) { + s = socket(res->ai_family, res->ai_socktype, + res->ai_protocol); + if (s == -1) { + cause = "socket"; + continue; + } + + if (connect(s, res->ai_addr, res->ai_addrlen) == -1) { + cause = "connect"; + save_errno = errno; + close(s); + errno = save_errno; + s = -1; + continue; + } + + break; + } + + freeaddrinfo(res0); + if (s == -1) + err(1, "%s", cause); + return s; +} + +static void +escape(FILE *fp, const uint8_t *s) +{ + for (; *s; ++s) { + /* + * XXX: this is broken for UNICODE: we should leave + * the multibyte characters as-is. + */ + + if (*s >= ' ' && *s <= '~') { + fputc(*s, fp); + continue; + } + + switch (*s) { + case '"': + case '\\': + fprintf(fp, "\\%c", *s); + break; + case '\b': + fprintf(fp, "\\b"); + break; + case '\f': + fprintf(fp, "\\f"); + break; + case '\n': + fprintf(fp, "\\n"); + break; + case '\r': + fprintf(fp, "\\r"); + break; + case '\t': + fprintf(fp, "\\t"); + break; + default: + fprintf(fp, "\\u%04X", *s); + break; + } + } +} + +static void +json_field(FILE *fp, const char *key, const char *val, int comma) +{ + fprintf(fp, "\"%s\":\"", key); + escape(fp, val); + fprintf(fp, "\"%s", comma ? "," : ""); +} + +static int +jsonify_short(FILE *fp) +{ + char *t, *date, *id, *author, *message; + char *line = NULL; + size_t linesize = 0; + ssize_t linelen; + int needcomma = 0; + + fprintf(fp, "{\"notifications\":["); + while ((linelen = getline(&line, &linesize, stdin)) != -1) { + if (line[linelen - 1] == '\n') + line[--linelen] = '\0'; + + if (needcomma) + fputc(',', fp); + needcomma = 1; + + t = line; + date = t; + if ((t = strchr(t, ' ')) == NULL) + errx(1, "malformed line"); + *t++ = '\0'; + + id = t; + if ((t = strchr(t, ' ')) == NULL) + errx(1, "malformed line"); + *t++ = '\0'; + + author = t; + if ((t = strchr(t, ' ')) == NULL) + errx(1, "malformed line"); + *t++ = '\0'; + + message = t; + + fprintf(fp, "{\"short\":true,"); + json_field(fp, "id", id, 1); + json_field(fp, "author", author, 1); + json_field(fp, "date", date, 1); + json_field(fp, "message", message, 0); + fprintf(fp, "}"); + } + + if (ferror(stdin)) + err(1, "getline"); + free(line); + + fprintf(fp, "]}"); + + return 0; +} + +static int +jsonify(FILE *fp) +{ + const char *errstr; + char *l; + char *line = NULL; + size_t linesize = 0; + ssize_t linelen; + int parent = 0; + int msglen = 0, msgwrote = 0; + int needcomma = 0; + enum { + P_COMMIT, + P_FROM, + P_VIA, + P_DATE, + P_PARENT, + P_MSGLEN, + P_MSG, + P_DST, + P_SUM, + } phase = P_COMMIT; + + fprintf(fp, "{\"notifications\":["); + while ((linelen = getline(&line, &linesize, stdin)) != -1) { + if (line[linelen - 1] == '\n') + line[--linelen] = '\0'; + + l = line; + switch (phase) { + case P_COMMIT: + if (*line == '\0') + continue; + + if (strncmp(l, "commit ", 7) != 0) + errx(1, "unexpected commit line: %s", line); + l += 7; + + if (needcomma) + fputc(',', fp); + needcomma = 1; + + fprintf(fp, "{\"short\":false,"); + json_field(fp, "id", l, 1); + + phase = P_FROM; + break; + + case P_FROM: + if (strncmp(l, "from: ", 6) != 0) + errx(1, "unexpected from line"); + l += 6; + json_field(fp, "author", l, 1); + phase = P_VIA; + break; + + case P_VIA: + /* optional */ + if (!strncmp(l, "via: ", 5)) { + l += 5; + json_field(fp, "via", l, 1); + phase = P_DATE; + break; + } + phase = P_DATE; + /* fallthrough */ + + case P_DATE: + /* optional */ + if (!strncmp(l, "date: ", 6)) { + l += 6; + json_field(fp, "date", l, 1); + phase = P_PARENT; + break; + } + phase = P_PARENT; + /* fallthough */ + + case P_PARENT: + /* optional - more than one */ + if (!strncmp(l, "parent ", 7)) { + l += 7; + l += strcspn(l, ":"); + l += strspn(l, " "); + + if (parent == 0) { + parent = 1; + fprintf(fp, "\"parents\":["); + } + + fputc('"', fp); + escape(fp, l); + fputc('"', fp); + + break; + } + if (parent != 0) { + fprintf(fp, "],"); + parent = 0; + } + phase = P_MSGLEN; + /* fallthrough */ + + case P_MSGLEN: + if (strncmp(l, "messagelen: ", 12) != 0) + errx(1, "unexpected messagelen line"); + l += 12; + msglen = strtonum(l, 1, INT_MAX, &errstr); + if (errstr) + errx(1, "message len is %s: %s", errstr, l); + + fprintf(fp, "\"message\":\""); + phase = P_MSG; + break; + + case P_MSG: + /* + * The commit message is indented with one extra + * space which is not accounted for in messagelen, + * but we also strip the trailing \n so that + * accounts for it. + * + * Since we read line-by-line and there is always + * a \n added at the end of the message, + * tolerate one byte less than advertised. + */ + if (*l == ' ') { + escape(fp, l + 1); /* skip leading space */ + + /* avoid pre-pending \n to the commit msg */ + msgwrote += linelen - 1; + if (msgwrote != 0) + escape(fp, "\n"); + } + msglen -= linelen; + if (msglen <= 1) { + fprintf(fp, "\","); + msgwrote = 0; + phase = P_DST; + } + break; + + case P_DST: + /* XXX: ignore the diffstat for now */ + if (*line == '\0') { + fprintf(fp, "\"diffstat\":{},"); + phase = P_SUM; + break; + } + break; + + case P_SUM: + /* XXX: ignore the sum of changes for now */ + fprintf(fp, "\"changes\":{}}"); + /* restart the state machine */ + phase = P_COMMIT; + break; + + default: + errx(1, "unimplemented"); + } + } + if (ferror(stdin)) + err(1, "getline"); + if (phase != P_COMMIT) + errx(1, "unexpected EOF"); + free(line); + + fprintf(fp, "]}"); + + return 0; +} + +static char * +basic_auth(const char *username, const char *password) +{ + char *tmp; + int len; + + len = asprintf(&tmp, "%s:%s", username, password); + if (len == -1) + err(1, "asprintf"); + + /* XXX base64-ify */ + return tmp; +} + +static inline int +bufio2poll(struct bufio *bio) +{ + int f, ret = 0; + + f = bufio_ev(bio); + if (f & BUFIO_WANT_READ) + ret |= POLLIN; + if (f & BUFIO_WANT_WRITE) + ret |= POLLOUT; + return ret; +} + +int +main(int argc, char **argv) +{ + FILE *tmpfp; + struct bufio bio; + struct pollfd pfd; + struct timespec timeout; + const char *username; + const char *password; + const char *timeoutstr; + const char *errstr; + const char *host = NULL, *port = NULL, *path = NULL; + char *auth, *line, *spc; + size_t len; + ssize_t r; + off_t paylen; + int tls = 0; + int response_code = 0, done = 0; + int ch, flags, ret, nonstd = 0; + +#ifndef PROFILE + if (pledge("stdio rpath tmppath dns inet", NULL) == -1) + err(1, "pledge"); +#endif + + while ((ch = getopt(argc, argv, "ch:p:")) != -1) { + switch (ch) { + case 'c': + tls = 1; + break; + case 'h': + host = optarg; + break; + case 'p': + port = optarg; + break; + default: + usage(); + } + } + argc -= optind; + argv += optind; + + if (host == NULL || argc != 1) + usage(); + if (tls && port == NULL) + port = "443"; + path = argv[0]; + + username = getenv("GOT_NOTIFY_HTTP_USER"); + password = getenv("GOT_NOTIFY_HTTP_PASS"); + if ((username != NULL && password == NULL) || + (username == NULL && password != NULL)) + errx(1, "username or password are not specified"); + if (username && *password == '\0') + errx(1, "password can't be empty"); + + /* used by the regression test suite */ + timeoutstr = getenv("GOT_NOTIFY_TIMEOUT"); + if (timeoutstr) { + http_timeout = strtonum(timeoutstr, 0, 600, &errstr); + if (errstr != NULL) + errx(1, "timeout in seconds is %s: %s", + errstr, timeoutstr); + } + + memset(&timeout, 0, sizeof(timeout)); + timeout.tv_sec = http_timeout; + + tmpfp = got_opentemp(); + if (tmpfp == NULL) + err(1, "opentemp"); + + /* detect the format of the input */ + ch = fgetc(stdin); + if (ch == EOF) + errx(1, "unexpected EOF"); + ungetc(ch, stdin); + if (ch == 'c') { + /* starts with "commit" so it's the long format */ + jsonify(tmpfp); + } else { + /* starts with the date so it's the short format */ + jsonify_short(tmpfp); + } + + paylen = ftello(tmpfp); + if (paylen == -1) + err(1, "ftello"); + if (fseeko(tmpfp, 0, SEEK_SET) == -1) + err(1, "fseeko"); + +#ifndef PROFILE + /* drop tmppath */ + if (pledge("stdio rpath dns inet", NULL) == -1) + err(1, "pledge"); +#endif + + memset(&pfd, 0, sizeof(pfd)); + pfd.fd = dial(host, port); + + if ((flags = fcntl(pfd.fd, F_GETFL)) == -1) + err(1, "fcntl(F_GETFL)"); + if (fcntl(pfd.fd, F_SETFL, flags | O_NONBLOCK) == -1) + err(1, "fcntl(F_SETFL)"); + + if (bufio_init(&bio) == -1) + err(1, "bufio_init"); + bufio_set_fd(&bio, pfd.fd); + if (tls && bufio_starttls(&bio, host, 0, NULL, 0, NULL, 0) == -1) + err(1, "bufio_starttls"); + +#ifndef PROFILE + /* drop rpath dns inet */ + if (pledge("stdio", NULL) == -1) + err(1, "pledge"); +#endif + + if ((!tls && strcmp(port, "80") != 0) || + (tls && strcmp(port, "443")) != 0) + nonstd = 1; + + ret = bufio_compose_fmt(&bio, + "POST %s HTTP/1.1\r\n" + "Host: %s%s%s\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %lld\r\n" + "User-Agent: %s\r\n" + "Connection: close\r\n", + path, host, + nonstd ? ":" : "", nonstd ? port : "", + (long long)paylen, USERAGENT); + if (ret == -1) + err(1, "bufio_compose_fmt"); + + if (username) { + auth = basic_auth(username, password); + ret = bufio_compose_fmt(&bio, "Authorization: basic %s\r\n", + auth); + if (ret == -1) + err(1, "bufio_compose_fmt"); + free(auth); + } + + if (bufio_compose(&bio, "\r\n", 2) == -1) + err(1, "bufio_compose"); + + while (!done) { + struct timespec elapsed, start, stop; + char buf[BUFSIZ]; + + pfd.events = bufio2poll(&bio); + clock_gettime(CLOCK_MONOTONIC, &start); + ret = ppoll(&pfd, 1, &timeout, NULL); + if (ret == -1) + err(1, "poll"); + clock_gettime(CLOCK_MONOTONIC, &stop); + timespecsub(&stop, &start, &elapsed); + timespecsub(&timeout, &elapsed, &timeout); + if (ret == 0 || timeout.tv_sec <= 0) + errx(1, "timeout"); + + if (bio.wbuf.len > 0 && (pfd.revents & POLLOUT)) { + if (bufio_write(&bio) == -1 && errno != EAGAIN) + errx(1, "bufio_write: %s", bufio_io_err(&bio)); + } + if (pfd.revents & POLLIN) { + r = bufio_read(&bio); + if (r == -1 && errno != EAGAIN) + errx(1, "bufio_read: %s", bufio_io_err(&bio)); + if (r == 0) + errx(1, "unexpected EOF"); + + for (;;) { + line = buf_getdelim(&bio.rbuf, "\r\n", &len); + if (line == NULL) + break; + if (response_code && *line == '\0') { + /* + * end of headers, don't bother + * reading the body, if there is. + */ + done = 1; + break; + } + if (response_code) { + buf_drain(&bio.rbuf, len); + continue; + } + spc = strchr(line, ' '); + if (spc == NULL) + errx(1, "bad reply"); + *spc++ = '\0'; + if (strcasecmp(line, "HTTP/1.1") != 0) + errx(1, "unexpected protocol: %s", + line); + line = spc; + + spc = strchr(line, ' '); + if (spc == NULL) + errx(1, "bad reply"); + *spc++ = '\0'; + + response_code = strtonum(line, 100, 599, + &errstr); + if (errstr != NULL) + errx(1, "response code is %s: %s", + errstr, line); + + buf_drain(&bio.rbuf, len); + } + if (done) + break; + } + + if (!feof(tmpfp) && bio.wbuf.len < sizeof(buf)) { + len = fread(buf, 1, sizeof(buf), tmpfp); + if (len == 0) { + if (ferror(tmpfp)) + err(1, "fread"); + continue; + } + + if (bufio_compose(&bio, buf, len) == -1) + err(1, "buf_compose"); + } + } + + if (response_code >= 200 && response_code < 400) + return 0; + errx(1, "request failed with code %d", response_code); +} blob - 91e6586a11678bd1994490278888e2c6b0dc6314 blob + 130421d61e42a690b8ee37a09bbf38b3bc2da092 --- gotd/notify.c +++ gotd/notify.c @@ -253,11 +253,24 @@ notify_email(struct gotd_notification_target *target, } static void -notify_http(struct gotd_notification_target *target, const char *subject_line, - int fd) +notify_http(struct gotd_notification_target *target, int fd) { - const char *argv[10] = { 0 }; /* TODO */ + const char *argv[8]; + int argc = 0; + argv[argc++] = GOTD_PATH_PROG_NOTIFY_HTTP; + if (target->conf.http.tls) + argv[argc++] = "-c"; + + argv[argc++] = "-h"; + argv[argc++] = target->conf.http.hostname; + argv[argc++] = "-p"; + argv[argc++] = target->conf.http.port; + + argv[argc++] = target->conf.http.path; + + argv[argc] = NULL; + run_notification_helper(GOTD_PATH_PROG_NOTIFY_HTTP, argv, fd); } @@ -296,7 +309,7 @@ send_notification(struct imsg *imsg, struct gotd_imsge notify_email(target, inotify.subject_line, fd); break; case GOTD_NOTIFICATION_VIA_HTTP: - notify_http(target, inotify.subject_line, fd); + notify_http(target, fd); break; } } blob - ad685fad9127a30b70881cfc8f2b608b5da20495 blob + ecec20991036938d1cd3fe4264832632cf47e167 --- gotd/parse.y +++ gotd/parse.y @@ -1538,16 +1538,18 @@ conf_notify_http(struct gotd_repo *repo, char *url, ch { const struct got_error *error; struct gotd_notification_target *target; - char *proto, *host, *port, *request_path; - int ret = 0; + char *proto, *hostname, *port, *path; + int tls = 0, ret = 0; - error = gotd_parse_url(&proto, &host, &port, &request_path, url); + error = gotd_parse_url(&proto, &hostname, &port, &path, url); if (error) { yyerror("invalid HTTP notification URL '%s' in " "repository '%s': %s", url, repo->name, error->msg); return -1; } + tls = !strcmp(proto, "https"); + if (strcmp(proto, "http") != 0 && strcmp(proto, "https") != 0) { yyerror("invalid protocol '%s' in notification URL '%s' in " "repository '%s", proto, url, repo->name); @@ -1555,6 +1557,13 @@ conf_notify_http(struct gotd_repo *repo, char *url, ch goto done; } + if ((user != NULL && password == NULL) || + (user == NULL && password != NULL)) { + yyerror("missing username or password"); + ret = -1; + goto done; + } + if (strcmp(proto, "http") == 0 && (user != NULL || password != NULL)) { log_warnx("%s: WARNING: Using basic authentication over " "plaintext http:// will leak credentials; https:// is " @@ -1564,7 +1573,10 @@ conf_notify_http(struct gotd_repo *repo, char *url, ch STAILQ_FOREACH(target, &repo->notification_targets, entry) { if (target->type != GOTD_NOTIFICATION_VIA_HTTP) continue; - if (strcmp(target->conf.http.url, url) == 0) { + if (target->conf.http.tls == tls && + !strcmp(target->conf.http.hostname, hostname) && + !strcmp(target->conf.http.port, port) && + !strcmp(target->conf.http.path, path)) { yyerror("duplicate notification for URL '%s' in " "repository '%s'", url, repo->name); ret = -1; @@ -1576,26 +1588,27 @@ conf_notify_http(struct gotd_repo *repo, char *url, ch if (target == NULL) fatal("calloc"); target->type = GOTD_NOTIFICATION_VIA_HTTP; - target->conf.http.url = strdup(url); - if (target->conf.http.url == NULL) - fatal("calloc"); + target->conf.http.tls = tls; + target->conf.http.hostname = hostname; + target->conf.http.port = port; + target->conf.http.path = path; + hostname = port = path = NULL; + if (user) { target->conf.http.user = strdup(user); if (target->conf.http.user == NULL) - fatal("calloc"); - } - if (password) { + fatal("strdup"); target->conf.http.password = strdup(password); if (target->conf.http.password == NULL) - fatal("calloc"); + fatal("strdup"); } STAILQ_INSERT_TAIL(&repo->notification_targets, target, entry); done: free(proto); - free(host); + free(hostname); free(port); - free(request_path); + free(path); return ret; } blob - /dev/null blob + 827a113ec3eaa7465036408230a7a7c14d55577b (mode 644) --- /dev/null +++ lib/bufio.c @@ -0,0 +1,421 @@ +/* + * This is free and unencumbered software released into the public domain. + * + * Anyone is free to copy, modify, publish, use, compile, sell, or + * distribute this software, either in source code form or as a compiled + * binary, for any purpose, commercial or non-commercial, and by any + * means. + * + * In jurisdictions that recognize copyright laws, the author or authors + * of this software dedicate any and all copyright interest in the + * software to the public domain. We make this dedication for the benefit + * of the public at large and to the detriment of our heirs and + * successors. We intend this dedication to be an overt act of + * relinquishment in perpetuity of all present and future rights to this + * software under copyright law. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR + * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bufio.h" + +int +buf_init(struct buf *buf) +{ + const size_t cap = BIO_CHUNK; + + memset(buf, 0, sizeof(*buf)); + if ((buf->buf = malloc(cap)) == NULL) + return (-1); + buf->cap = cap; + return (0); +} + +static int +buf_grow(struct buf *buf) +{ + size_t newcap; + void *t; + + newcap = buf->cap + BIO_CHUNK; + t = realloc(buf->buf, newcap); + if (t == NULL) + return (-1); + buf->buf = t; + buf->cap = newcap; + return (0); +} + +int +buf_has_line(struct buf *buf, const char *nl) +{ + return (memmem(buf->buf, buf->len, nl, strlen(nl)) != NULL); +} + +char * +buf_getdelim(struct buf *buf, const char *nl, size_t *len) +{ + uint8_t *endl; + size_t nlen; + + *len = 0; + + nlen = strlen(nl); + if ((endl = memmem(buf->buf, buf->len, nl, nlen)) == NULL) + return (NULL); + *len = endl + nlen - buf->buf; + *endl = '\0'; + return (buf->buf); +} + +void +buf_drain(struct buf *buf, size_t l) +{ + buf->cur = 0; + + if (l >= buf->len) { + buf->len = 0; + return; + } + + memmove(buf->buf, buf->buf + l, buf->len - l); + buf->len -= l; +} + +void +buf_drain_line(struct buf *buf, const char *nl) +{ + uint8_t *endln; + size_t nlen; + + nlen = strlen(nl); + if ((endln = memmem(buf->buf, buf->len, nl, nlen)) == NULL) + return; + buf_drain(buf, endln + nlen - buf->buf); +} + +void +buf_free(struct buf *buf) +{ + free(buf->buf); + memset(buf, 0, sizeof(*buf)); +} + +int +bufio_init(struct bufio *bio) +{ + memset(bio, 0, sizeof(*bio)); + bio->fd = -1; + + if (buf_init(&bio->wbuf) == -1) + return (-1); + if (buf_init(&bio->rbuf) == -1) { + buf_free(&bio->wbuf); + return (-1); + } + return (0); +} + +void +bufio_free(struct bufio *bio) +{ + if (bio->ctx) + tls_free(bio->ctx); + bio->ctx = NULL; + + if (bio->fd != -1) + close(bio->fd); + bio->fd = -1; + + buf_free(&bio->rbuf); + buf_free(&bio->wbuf); +} + +int +bufio_close(struct bufio *bio) +{ + if (bio->ctx == NULL) + return (0); + + switch (tls_close(bio->ctx)) { + case 0: + return 0; + case TLS_WANT_POLLIN: + errno = EAGAIN; + bio->wantev = BUFIO_WANT_READ; + return (-1); + case TLS_WANT_POLLOUT: + errno = EAGAIN; + bio->wantev = BUFIO_WANT_WRITE; + return (-1); + default: + return (-1); + } +} + +int +bufio_reset(struct bufio *bio) +{ + bufio_free(bio); + return (bufio_init(bio)); +} + +void +bufio_set_fd(struct bufio *bio, int fd) +{ + bio->fd = fd; +} + +int +bufio_starttls(struct bufio *bio, const char *host, int insecure, + const uint8_t *cert, size_t certlen, const uint8_t *key, size_t keylen) +{ + struct tls_config *conf; + + if ((conf = tls_config_new()) == NULL) + return (-1); + + if (insecure) { + tls_config_insecure_noverifycert(conf); + tls_config_insecure_noverifyname(conf); + tls_config_insecure_noverifytime(conf); + } + + if (cert && tls_config_set_keypair_mem(conf, cert, certlen, + key, keylen) == -1) { + tls_config_free(conf); + return (-1); + } + + if ((bio->ctx = tls_client()) == NULL) { + tls_config_free(conf); + return (-1); + } + + if (tls_configure(bio->ctx, conf) == -1) { + tls_config_free(conf); + return (-1); + } + + tls_config_free(conf); + + if (tls_connect_socket(bio->ctx, bio->fd, host) == -1) + return (-1); + + return (0); +} + +int +bufio_ev(struct bufio *bio) +{ + short ev; + + if (bio->wantev) + return (bio->wantev); + + ev = BUFIO_WANT_READ; + if (bio->wbuf.len != 0) + ev |= BUFIO_WANT_WRITE; + + return (ev); +} + +int +bufio_handshake(struct bufio *bio) +{ + if (bio->ctx == NULL) { + errno = EINVAL; + return (-1); + } + + switch (tls_handshake(bio->ctx)) { + case 0: + return (0); + case TLS_WANT_POLLIN: + errno = EAGAIN; + bio->wantev = BUFIO_WANT_READ; + return (-1); + case TLS_WANT_POLLOUT: + errno = EAGAIN; + bio->wantev = BUFIO_WANT_WRITE; + return (-1); + default: + return (-1); + } +} + +ssize_t +bufio_read(struct bufio *bio) +{ + struct buf *rbuf = &bio->rbuf; + ssize_t r; + + assert(rbuf->cap >= rbuf->len); + if (rbuf->cap - rbuf->len < BIO_CHUNK) { + if (buf_grow(rbuf) == -1) + return (-1); + } + + if (bio->ctx) { + r = tls_read(bio->ctx, rbuf->buf + rbuf->len, + rbuf->cap - rbuf->len); + switch (r) { + case TLS_WANT_POLLIN: + errno = EAGAIN; + bio->wantev = BUFIO_WANT_READ; + return (-1); + case TLS_WANT_POLLOUT: + errno = EAGAIN; + bio->wantev = BUFIO_WANT_WRITE; + return (-1); + case -1: + return (-1); + default: + bio->wantev = 0; + rbuf->len += r; + return (r); + } + } + + r = read(bio->fd, rbuf->buf + rbuf->len, rbuf->cap - rbuf->len); + if (r == -1) + return (-1); + rbuf->len += r; + return (r); +} + +size_t +bufio_drain(struct bufio *bio, void *d, size_t len) +{ + struct buf *rbuf = &bio->rbuf; + + if (len > rbuf->len) + len = rbuf->len; + memcpy(d, rbuf->buf, len); + buf_drain(rbuf, len); + return (len); +} + +ssize_t +bufio_write(struct bufio *bio) +{ + struct buf *wbuf = &bio->wbuf; + ssize_t w; + + if (bio->ctx) { + switch (w = tls_write(bio->ctx, wbuf->buf, wbuf->len)) { + case TLS_WANT_POLLIN: + errno = EAGAIN; + bio->wantev = BUFIO_WANT_READ; + return (-1); + case TLS_WANT_POLLOUT: + errno = EAGAIN; + bio->wantev = BUFIO_WANT_WRITE; + return (-1); + case -1: + return (-1); + default: + bio->wantev = 0; + buf_drain(wbuf, w); + return (w); + } + } + + w = write(bio->fd, wbuf->buf, wbuf->len); + if (w == -1) + return (-1); + buf_drain(wbuf, w); + return (w); +} + +const char * +bufio_io_err(struct bufio *bio) +{ + if (bio->ctx) + return tls_error(bio->ctx); + + return strerror(errno); +} + +int +bufio_compose(struct bufio *bio, const void *d, size_t len) +{ + struct buf *wbuf = &bio->wbuf; + + while (wbuf->cap - wbuf->len < len) { + if (buf_grow(wbuf) == -1) + return (-1); + } + + memcpy(wbuf->buf + wbuf->len, d, len); + wbuf->len += len; + return (0); +} + +int +bufio_compose_str(struct bufio *bio, const char *str) +{ + return (bufio_compose(bio, str, strlen(str))); +} + +int +bufio_compose_fmt(struct bufio *bio, const char *fmt, ...) +{ + va_list ap; + char *str; + int r; + + va_start(ap, fmt); + r = vasprintf(&str, fmt, ap); + va_end(ap); + + if (r == -1) + return (-1); + r = bufio_compose(bio, str, r); + free(str); + return (r); +} + +void +bufio_rewind_cursor(struct bufio *bio) +{ + bio->rbuf.cur = 0; +} + +int +bufio_get_cb(void *d) +{ + struct bufio *bio = d; + struct buf *rbuf = &bio->rbuf; + + if (rbuf->cur >= rbuf->len) + return (EOF); + return (rbuf->buf[rbuf->cur++]); +} + +int +bufio_peek_cb(void *d) +{ + struct bufio *bio = d; + struct buf *rbuf = &bio->rbuf; + + if (rbuf->cur >= rbuf->len) + return (EOF); + return (rbuf->buf[rbuf->cur]); +} blob - /dev/null blob + c51a1017d3a424e3dc4aecc1184a9d9b4cc9636b (mode 644) --- /dev/null +++ lib/bufio.h @@ -0,0 +1,75 @@ +/* + * This is free and unencumbered software released into the public domain. + * + * Anyone is free to copy, modify, publish, use, compile, sell, or + * distribute this software, either in source code form or as a compiled + * binary, for any purpose, commercial or non-commercial, and by any + * means. + * + * In jurisdictions that recognize copyright laws, the author or authors + * of this software dedicate any and all copyright interest in the + * software to the public domain. We make this dedication for the benefit + * of the public at large and to the detriment of our heirs and + * successors. We intend this dedication to be an overt act of + * relinquishment in perpetuity of all present and future rights to this + * software under copyright law. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR + * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +struct tls; + +#define BIO_CHUNK 128 +struct buf { + uint8_t *buf; + size_t len; + size_t cap; + size_t cur; +}; + +struct bufio { + int fd; + struct tls *ctx; + int wantev; + struct buf wbuf; + struct buf rbuf; +}; + +#define BUFIO_WANT_READ 0x1 +#define BUFIO_WANT_WRITE 0x2 + +int buf_init(struct buf *); +int buf_has_line(struct buf *, const char *); +char *buf_getdelim(struct buf *, const char *, size_t *); +void buf_drain(struct buf *, size_t); +void buf_drain_line(struct buf *, const char *); +void buf_free(struct buf *); + +int bufio_init(struct bufio *); +void bufio_free(struct bufio *); +int bufio_close(struct bufio *); +int bufio_reset(struct bufio *); +void bufio_set_fd(struct bufio *, int); +int bufio_starttls(struct bufio *, const char *, int, + const uint8_t *, size_t, const uint8_t *, size_t); +int bufio_ev(struct bufio *); +int bufio_handshake(struct bufio *); +ssize_t bufio_read(struct bufio *); +size_t bufio_drain(struct bufio *, void *, size_t); +ssize_t bufio_write(struct bufio *); +const char *bufio_io_err(struct bufio *); +int bufio_compose(struct bufio *, const void *, size_t); +int bufio_compose_str(struct bufio *, const char *); +int bufio_compose_fmt(struct bufio *, const char *, ...) + __attribute__((__format__ (printf, 2, 3))); +void bufio_rewind_cursor(struct bufio *); + +/* callbacks for pdjson */ +int bufio_get_cb(void *); +int bufio_peek_cb(void *); blob - eaca641d2887c0f5e66a410257eaf3fae004a13d blob + 00f69c40c6fd31670fde808184134e4ac98ce8e1 --- regress/gotd/Makefile +++ regress/gotd/Makefile @@ -4,7 +4,8 @@ REGRESS_TARGETS=test_repo_read test_repo_read_group \ test_repo_read_denied_user test_repo_read_denied_group \ test_repo_read_bad_user test_repo_read_bad_group \ test_repo_write test_repo_write_empty test_request_bad \ - test_repo_write_protected test_email_notification + test_repo_write_protected test_email_notification \ + test_http_notification NOOBJ=Yes CLEANFILES=gotd.conf @@ -17,6 +18,7 @@ GOTD_TEST_REPO!?=mktemp -d "$(GOTD_TEST_ROOT)/gotd-tes GOTD_TEST_REPO_NAME=test-repo GOTD_TEST_REPO_URL=ssh://${GOTD_DEVUSER}@127.0.0.1/$(GOTD_TEST_REPO_NAME) GOTD_TEST_SMTP_PORT=2525 +GOTD_TEST_HTTP_PORT=8000 GOTD_TEST_USER?=${DOAS_USER} .if empty(GOTD_TEST_USER) @@ -53,6 +55,7 @@ GOTD_TEST_ENV=GOTD_TEST_ROOT=$(GOTD_TEST_ROOT) \ GOTD_DEVUSER=$(GOTD_DEVUSER) \ GOTD_USER=$(GOTD_USER) \ GOTD_TEST_SMTP_PORT=$(GOTD_TEST_SMTP_PORT) \ + GOTD_TEST_HTTP_PORT=$(GOTD_TEST_HTTP_PORT) \ HOME=$(GOTD_TEST_USER_HOME) \ PATH=$(GOTD_TEST_USER_HOME)/bin:$(PATH) @@ -169,6 +172,19 @@ start_gotd_email_notification: ensure_root @$(GOTD_TRAP); $(GOTD_START_CMD) @$(GOTD_TRAP); sleep .5 +start_gotd_http_notification: ensure_root + @echo 'listen on "$(GOTD_SOCK)"' > $(PWD)/gotd.conf + @echo "user $(GOTD_USER)" >> $(PWD)/gotd.conf + @echo 'repository "test-repo" {' >> $(PWD)/gotd.conf + @echo ' path "$(GOTD_TEST_REPO)"' >> $(PWD)/gotd.conf + @echo ' permit rw $(GOTD_DEVUSER)' >> $(PWD)/gotd.conf + @echo ' notify {' >> $(PWD)/gotd.conf + @echo ' url "http://localhost:${GOTD_TEST_HTTP_PORT}/"' >> $(PWD)/gotd.conf + @echo " }" >> $(PWD)/gotd.conf + @echo "}" >> $(PWD)/gotd.conf + @$(GOTD_TRAP); $(GOTD_START_CMD) + @$(GOTD_TRAP); sleep .5 + prepare_test_repo: ensure_root @chown ${GOTD_USER} "${GOTD_TEST_REPO}" @su -m ${GOTD_USER} -c 'env $(GOTD_TEST_ENV) sh ./prepare_test_repo.sh' @@ -241,4 +257,9 @@ test_email_notification: prepare_test_repo start_gotd_ 'env $(GOTD_TEST_ENV) sh ./email_notification.sh' @$(GOTD_STOP_CMD) 2>/dev/null +test_http_notification: prepare_test_repo start_gotd_http_notification + @-$(GOTD_TRAP); su -m ${GOTD_TEST_USER} -c \ + 'env $(GOTD_TEST_ENV) sh ./http_notification.sh' + @$(GOTD_STOP_CMD) 2>/dev/null + .include blob - /dev/null blob + 7dbafb4ae773efae234479b2c918d895dce5ac1e (mode 755) --- /dev/null +++ regress/gotd/http-server @@ -0,0 +1,90 @@ +#!/usr/bin/env perl +# +# Copyright (c) 2024 Omar Polo +# +# Permission to use, copy, modify, and distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +use v5.36; +use IPC::Open2; +use Getopt::Long qw(:config bundling); + +my $port = 8000; + +GetOptions("p:i" => \$port) + or die("usage: $0 [-p port]\n"); + +my $pid = open2(my $out, my $in, 'nc', '-l', 'localhost', $port); + +my $clen; +while (<$out>) { + local $/ = "\r\n"; + + chomp; + say; + + last if /^$/; + + if (m/^POST/) { + die "bad http request" unless m,^POST / HTTP/1.1$,; + next; + } + + if (m/^Host:/) { + die "bad Host header" unless /^Host: localhost:$port$/; + next; + } + + if (m/^Content-Type/) { + die "bad content-type header" + unless m,Content-Type: application/json$,; + next; + } + + if (m/^Content-Length/) { + die "double content-length" if defined $clen; + die "bad content-length header" + unless m/Content-Length: (\d+)$/; + $clen = $1; + next; + } + + if (m/Connection/) { + die "bad connection header" + unless m/Connection: close$/; + next; + } +} + +die "no Content-Length header" unless defined $clen; + +while ($clen != 0) { + my $len = $clen; + $len = 512 if $clen > 512; + + my $r = read($out, my $buf, $len); + $clen -= $r; + + print $buf; +} +say ""; + +print $in "HTTP/1.1 200 OK\r\n"; +print $in "Content-Length: 0\r\n"; +print $in "Connection: close\r\n"; +print $in "\r\n"; + +close $in; +close $out; + +waitpid($pid, 0); +exit $? >> 8; blob - /dev/null blob + 5bd5779ff7fd7e13571b71c414ee5f1bb1f6dc2e (mode 644) --- /dev/null +++ regress/gotd/http_notification.sh @@ -0,0 +1,234 @@ +#!/bin/sh +# +# Copyright (c) 2024 Omar Polo +# +# Permission to use, copy, modify, and distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +. ../cmdline/common.sh +. ./common.sh + +test_file_changed() { + local testroot=`test_init file_changed 1` + + got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone + ret=$? + if [ $ret -ne 0 ]; then + echo "got clone failed unexpectedly" >&2 + test_done "$testroot" 1 + return 1 + fi + + got checkout -q $testroot/repo-clone $testroot/wt >/dev/null + ret=$? + if [ $ret -ne 0 ]; then + echo "got checkout failed unexpectedly" >&2 + test_done "$testroot" 1 + return 1 + fi + + echo "change alpha" > $testroot/wt/alpha + (cd $testroot/wt && got commit -m 'make changes' > /dev/null) + local commit_id=`git_show_head $testroot/repo-clone` + local author_time=`git_show_author_time $testroot/repo-clone` + + timeout 5 ./http-server -p $GOTD_TEST_HTTP_PORT \ + > $testroot/stdout & + + got send -b main -q -r $testroot/repo-clone + ret=$? + if [ $ret -ne 0 ]; then + echo "got send failed unexpectedly" >&2 + test_done "$testroot" "1" + return 1 + fi + + wait %1 # wait for the http "server" + + d=`date -u -r $author_time +"%a %b %e %X %Y UTC"` + + cat <<-EOF > $testroot/stdout.expected + POST / HTTP/1.1 + Host: localhost:${GOTD_TEST_HTTP_PORT} + Content-Type: application/json + Content-Length: 224 + User-Agent: got-notify-http/${GOT_VERSION_STR} + Connection: close + + {"notifications":[{"short":false,"id":"$commit_id","author":"$GOT_AUTHOR","date":"$d","message":"make changes\n","diffstat":{},"changes":{}}]} + EOF + + cmp -s $testroot/stdout.expected $testroot/stdout + ret=$? + if [ $ret -ne 0 ]; then + diff -u $testroot/stdout.expected $testroot/stdout + test_done "$testroot" "$ret" + return 1 + fi + + test_done "$testroot" "$ret" +} + +test_many_commits_not_summarized() { + local testroot=`test_init many_commits_not_summarized 1` + + got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone + ret=$? + if [ $ret -ne 0 ]; then + echo "got clone failed unexpectedly" >&2 + test_done "$testroot" 1 + return 1 + fi + + got checkout -q $testroot/repo-clone $testroot/wt >/dev/null + ret=$? + if [ $ret -ne 0 ]; then + echo "got checkout failed unexpectedly" >&2 + test_done "$testroot" 1 + return 1 + fi + + for i in `seq 1 24`; do + echo "alpha $i" > $testroot/wt/alpha + (cd $testroot/wt && got commit -m 'make changes' > /dev/null) + local commit_id=`git_show_head $testroot/repo-clone` + local author_time=`git_show_author_time $testroot/repo-clone` + d=`date -u -r $author_time +"%a %b %e %X %Y UTC"` + set -- "$@" "$commit_id $d" + done + + timeout 5 ./http-server -p "$GOTD_TEST_HTTP_PORT" \ + > $testroot/stdout & + + got send -b main -q -r $testroot/repo-clone + ret=$? + if [ $ret -ne 0 ]; then + echo "got send failed unexpectedly" >&2 + test_done "$testroot" "1" + return 1 + fi + + wait %1 # wait for the http "server" + + cat <<-EOF > $testroot/stdout.expected + POST / HTTP/1.1 + Host: localhost:${GOTD_TEST_HTTP_PORT} + Content-Type: application/json + Content-Length: 4939 + User-Agent: got-notify-http/${GOT_VERSION_STR} + Connection: close + + EOF + + printf '{"notifications":[' >> $testroot/stdout.expected + comma="" + for i in `seq 1 24`; do + s=`pop_idx $i "$@"` + commit_id=$(echo $s | cut -d' ' -f1) + commit_time=$(echo $s | sed -e "s/^$commit_id //g") + printf '%s{"short":false,"id":"%s","author":"%s","date":"%s","message":"%s","diffstat":{},"changes":{}}' \ + "$comma" "$commit_id" "$GOT_AUTHOR" "$commit_time" "make changes\n" + comma="," + done >> $testroot/stdout.expected + echo "]}" >> $testroot/stdout.expected + + cmp -s $testroot/stdout.expected $testroot/stdout + ret=$? + if [ $ret -ne 0 ]; then + diff -u $testroot/stdout.expected $testroot/stdout + test_done "$testroot" "$ret" + return 1 + fi + + test_done "$testroot" "$ret" +} + +test_many_commits_summarized() { + local testroot=`test_init many_commits_summarized 1` + + got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone + ret=$? + if [ $ret -ne 0 ]; then + echo "got clone failed unexpectedly" >&2 + test_done "$testroot" 1 + return 1 + fi + + got checkout -q $testroot/repo-clone $testroot/wt >/dev/null + ret=$? + if [ $ret -ne 0 ]; then + echo "got checkout failed unexpectedly" >&2 + test_done "$testroot" 1 + return 1 + fi + + for i in `seq 1 51`; do + echo "alpha $i" > $testroot/wt/alpha + (cd $testroot/wt && got commit -m 'make changes' > /dev/null) + local commit_id=`git_show_head $testroot/repo-clone` + local short_commit_id=`trim_obj_id 33 $commit_id` + local author_time=`git_show_author_time $testroot/repo-clone` + d=`date -u -r $author_time +"%G-%m-%d"` + set -- "$@" "$short_commit_id $d" + done + + timeout 5 ./http-server -p "$GOTD_TEST_HTTP_PORT" \ + > $testroot/stdout & + + got send -b main -q -r $testroot/repo-clone + ret=$? + if [ $ret -ne 0 ]; then + echo "got send failed unexpectedly" >&2 + test_done "$testroot" "1" + return 1 + fi + + wait %1 # wait for the http "server" + + cat <<-EOF > $testroot/stdout.expected + POST / HTTP/1.1 + Host: localhost:${GOTD_TEST_HTTP_PORT} + Content-Type: application/json + Content-Length: 4864 + User-Agent: got-notify-http/${GOT_VERSION_STR} + Connection: close + + EOF + + printf '{"notifications":[' >> $testroot/stdout.expected + comma="" + for i in `seq 1 51`; do + s=`pop_idx $i "$@"` + commit_id=$(echo $s | cut -d' ' -f1) + commit_time=$(echo $s | sed -e "s/^$commit_id //g") + printf '%s{"short":true,"id":"%s","author":"%s","date":"%s","message":"%s"}' \ + "$comma" "$commit_id" "$GOT_AUTHOR_8" \ + "$commit_time" "make changes" + comma="," + done >> $testroot/stdout.expected + echo "]}" >> $testroot/stdout.expected + + cmp -s $testroot/stdout.expected $testroot/stdout + ret=$? + if [ $ret -ne 0 ]; then + diff -u $testroot/stdout.expected $testroot/stdout + test_done "$testroot" "$ret" + return 1 + fi + + test_done "$testroot" "$ret" +} + +test_parseargs "$@" +run_test test_file_changed +run_test test_many_commits_not_summarized +run_test test_many_commits_summarized