commit 5565365ce71d431c522f98b121706b13c4bf2d22 from: Omar Polo date: Wed Mar 27 19:22:39 2024 UTC add got-notify-http ok stsp@ commit - f9e6537008c7344a6107b88bc19c5f293cacd793 commit + 5565365ce71d431c522f98b121706b13c4bf2d22 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,19 +1538,28 @@ 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); + ret = -1; + goto done; + } + + if ((user != NULL && password == NULL) || + (user == NULL && password != NULL)) { + yyerror("missing username or password"); ret = -1; goto done; } @@ -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