#include #include #include #include #include #include #include #include #include #include #ifdef TEST #include "unit-test.h" #endif // FIXME: translate static const char *const DEFAULT_TITLE = "Repositories"; static const char *const LOGO_ALT = "Logo image of the repository list"; static const char *const FOOTER_TEMPLATE = "Generated with %s"; static const char *const LANG = "en"; static const char *const NAME = "Name"; static const char *const DESCRIPTION = "Description"; static const char *const LAST_COMMIT = "Last commit"; static const char *const USAGE = "Usage: -h -o -i -x...\n"; static const char *const LOGO_STR = "" "\n" "\n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" "\n"; static const char *const STYLE_STR = "" "body {\n" " font-family: monospace;\n" " max-width: 1100px;\n" " margin: 0 auto 0 auto;\n" "}\n" "\n" "img.logo {\n" " height: 3em;\n" " width: 3em;\n" "}\n" "\n" ".idx-header {\n" " display: flex;\n" " align-items: center;\n" " margin-top: 1em;\n" " margin-bottom: 1em;\n" "}\n" "\n" ".idx-header-description {\n" " margin-left: 1em;\n" "}\n" "\n" "hr {\n" " border: 0;\n" " border-top: 3px solid hsl(0, 0%, 67%);\n" "}\n" "\n" "table {\n" " margin-top: 2em;\n" " margin-bottom: 2em;\n" "}\n" "\n" "thead td {\n" " font-weight: bold;\n" " padding-bottom: 1em;\n" "}\n" "\n" "tbody tr:hover {\n" " background-color: hsl(0, 0%, 93%);" "}\n" "\n" "td {\n" " padding-left: 1em;\n" " padding-right: 1em;\n" "}\n" "\n" "footer { \n" " text-align: center;\n" "}\n" ""; #define PROGNAME "gistatic" static const char *const GIT_SUFFIX = ".git"; static const char *const PROJECT_HOMEPAGE_LINK = "" PROGNAME "."; static const int EXIT_ERROR = 1; static const int EXIT_USAGE = 2; static int usage(FILE *const fd) { if (fprintf(fd, "%s", USAGE) < 0) { fprintf( stderr, "%s:%s:%d: fprintf(): %s\n", PROGNAME, __FILE__, __LINE__, strerror(errno) ); return -1; } return 0; } static char *remove_suffix(char *const s, const char *const suffix) { if (!s || !suffix) { return NULL; } char *const name = strdup(s); if (!name) { fprintf( stderr, "%s:%s:%d: strdup(\"s\"): %s\n", PROGNAME, __FILE__, __LINE__, strerror(errno) ); return NULL; } const char *const match_suffix = strrchr(name, suffix[0]); if (match_suffix && (strcmp(match_suffix, suffix) == 0)) { const size_t suffix_idx = strlen(name) - strlen(suffix); name[suffix_idx] = '\0'; } return name; } #ifdef TEST static void test_remove_suffix() { test_start("test_remove_suffix"); { testing("empty string"); char *const s = remove_suffix("", "suffix"); assert(s); assert(strcmp(s, "") == 0); free(s); test_ok(); } { testing("empty suffix"); char *const s = remove_suffix("a string", ""); assert(s); assert(strcmp(s, "a string") == 0); free(s); test_ok(); } { testing("with no suffix match"); char *const s = remove_suffix("another string", "NO MATCH!"); assert(s); assert(strcmp(s, "another string") == 0); free(s); test_ok(); } { testing("with suffix bigger than string"); char *const s = remove_suffix("str", "a string"); assert(s); assert(strcmp(s, "str") == 0); free(s); test_ok(); } { testing("with a NULL string"); char *const s = remove_suffix(NULL, "a non-null suffix"); assert(s == NULL); test_ok(); } { testing("with a NULL suffix"); char *const s = remove_suffix("not null", NULL); assert(s == NULL);; test_ok(); } { testing("both are NULL"); char *const s = remove_suffix(NULL, NULL); assert(s == NULL); test_ok(); } { testing("matching prefix"); char *const s = remove_suffix("aaa bbb ccc", "aaa"); assert(s); assert(strcmp(s, "aaa bbb ccc") == 0); free(s); test_ok(); } { testing("matching infix"); char *const s = remove_suffix("aaa bbb ccc", "bbb"); assert(s); assert(strcmp(s, "aaa bbb ccc") == 0); free(s); test_ok(); } { testing("with suffix equal to string"); char *const s = remove_suffix("string", "string"); assert(s); assert(strcmp(s, "") == 0); free(s); test_ok(); } { testing("with matching suffix"); char *const s = remove_suffix("string", "ing"); assert(s); assert(strcmp(s, "str") == 0); free(s); test_ok(); } { testing("example usage: with .git suffix"); char *const s = remove_suffix("reponame.git", GIT_SUFFIX); assert(s); assert(strcmp(s, "reponame") == 0); free(s); test_ok(); } { testing("example usage: with full path"); char *const s = remove_suffix("an/example/path", "ath"); assert(s); assert(strcmp(s, "an/example/p") == 0); free(s); test_ok(); } } #endif static char *strjoin(const char *const s1, const char *const s2) { if (!s1 || !s2) { return NULL; } const size_t size = strlen(s1) + strlen(s2) + sizeof('\0'); char *const s = malloc(size); if (!s) { fprintf( stderr, "%s:%s:%d: malloc(%ld): %s\n", PROGNAME, __FILE__, __LINE__, size, strerror(errno) ); return NULL; } strcpy(s, s1); strcat(s, s2); return s; } #ifdef TEST static void test_strjoin() { test_start("test_strjoin"); { testing("joining empty strings"); char *const s = strjoin("", ""); assert(s); assert(strcmp(s, "") == 0); free(s); test_ok(); } { testing("joining NULL strings"); assert(strjoin(NULL, NULL) == NULL); assert(strjoin("", NULL) == NULL); assert(strjoin(NULL, "") == NULL); test_ok(); } { testing("first string is empty"); char *const s = strjoin("", "second not empty"); assert(s); assert(strcmp(s, "second not empty") == 0); free(s); test_ok(); } { testing("second string is empty"); char *const s = strjoin("first not empty", ""); assert(s); assert(strcmp(s, "first not empty") == 0); free(s); test_ok(); } { testing("two non-empty strings"); char *const s = strjoin("abc", "def"); assert(s); assert(strcmp(s, "abcdef") == 0); free(s); test_ok(); } { testing("example usage: with file names"); char *const s = strjoin("../repository.git", "/description"); assert(s); assert(strcmp(s, "../repository.git/description") == 0); free(s); test_ok(); } } #endif static char *formatted_date(const time_t time_sec) { struct tm *time_utc = gmtime(&time_sec); if (!time_utc) { fprintf( stderr, "%s:%s:%d: gmtime(%ld): %s\n", PROGNAME, __FILE__, __LINE__, time_sec, strerror(errno) ); return NULL; } // Expected size, plus breathing room const size_t size = strlen("XXX-XX-XX XX:XX") * 2; char *const formatted = malloc(size); if (!formatted) { fprintf( stderr, "%s:%s:%d: malloc(%ld): %s\n", PROGNAME, __FILE__, __LINE__, size, strerror(errno) ); return NULL; } strftime(formatted, size, "%Y-%m-%d %H:%M", time_utc); return formatted; } #ifdef TEST static void test_formatted_date() { test_start("test_formatted_date"); { testing("when given 0"); char *const s = formatted_date(0); assert(s); assert(strcmp(s, "1970-01-01 00:00") == 0); free(s); test_ok(); } { testing("when given negative values"); char *s; s = formatted_date(-1); assert(s); assert(strcmp(s, "1969-12-31 23:59") == 0); free(s); s = formatted_date(-123456789); assert(s); assert(strcmp(s, "1966-02-02 02:26") == 0); free(s); s = formatted_date(-999999999); assert(s); assert(strcmp(s, "1938-04-24 22:13") == 0); free(s); test_ok(); } { testing("when given recent date values"); char *s; s = formatted_date(1627645522); assert(s); assert(strcmp(s, "2021-07-30 11:45") == 0); free(s); s = formatted_date(1500000000); assert(s); assert(strcmp(s, "2017-07-14 02:40") == 0); free(s); s = formatted_date(1000000000); assert(s); assert(strcmp(s, "2001-09-09 01:46") == 0); free(s); test_ok(); } } #endif static char *escape_html(const char *s) { if (!s) { return NULL; } #define AMPCHAR '&' #define LTCHAR '<' #define GTCHAR '>' #define DQUOTCHAR '"' #define SQUOTCHAR '\'' static const char *const AMP = "&"; static const char *const LT = "<"; static const char *const GT = ">"; static const char *const DQUOT = """; static const char *const SQUOT = "'"; const size_t AMP_SIZE = strlen(AMP); const size_t LT_SIZE = strlen(LT); const size_t GT_SIZE = strlen(GT); const size_t DQUOT_SIZE = strlen(DQUOT); const size_t SQUOT_SIZE = strlen(SQUOT); const size_t input_size = strlen(s); size_t escaped_size = 0; for (size_t i = 0; i < input_size; i++) { switch (s[i]) { case AMPCHAR: escaped_size += AMP_SIZE; break; case LTCHAR: escaped_size += LT_SIZE; break; case GTCHAR: escaped_size += GT_SIZE; break; case DQUOTCHAR: escaped_size += DQUOT_SIZE; break; case SQUOTCHAR: escaped_size += SQUOT_SIZE; break; default: escaped_size += sizeof(s[i]); break; } } const size_t size = escaped_size + sizeof('\0'); char *const escaped = malloc(size); if (!escaped) { fprintf( stderr, "%s:%s:%d: malloc(%ld): %s\n", PROGNAME, __FILE__, __LINE__, size, strerror(errno) ); return NULL; } escaped[0] = '\0'; char c; size_t escaped_idx = 0; while ((c = *s++)) { switch (c) { case AMPCHAR: strcat(escaped, AMP); escaped_idx += AMP_SIZE; break; case LTCHAR: strcat(escaped, LT); escaped_idx += LT_SIZE; break; case GTCHAR: strcat(escaped, GT); escaped_idx += GT_SIZE; break; case DQUOTCHAR: strcat(escaped, DQUOT); escaped_idx += DQUOT_SIZE; break; case SQUOTCHAR: strcat(escaped, SQUOT); escaped_idx += SQUOT_SIZE; break; default: escaped[escaped_idx] = c; escaped[escaped_idx + 1] = '\0'; escaped_idx += sizeof(c); break; } } return escaped; } #ifdef TEST static void test_escape_html() { test_start("test_escape_html"); { testing("string with no escapable chars"); char *const s = escape_html("a plain string"); assert(s); assert(strcmp(s, "a plain string") == 0); free(s); test_ok(); } { testing("an empty string"); char *const s = escape_html(""); assert(s); assert(strcmp(s, "") == 0); free(s); test_ok(); } { testing("a NULL value"); char *const s = escape_html(NULL); assert(s == NULL); test_ok(); } { testing("string with a single & character"); char *const s = escape_html("&"); assert(s); assert(strcmp(s, "&") == 0); free(s); test_ok(); } { testing("a string with many escapable characters in sequence"); char *const s = escape_html("&& >> text <<"); assert(s); assert(strcmp(s, "&& >> text <<") == 0); free(s); test_ok(); } { testing("all escapable characters"); char *const s = escape_html("&<>\"'"); assert(s); assert(strcmp(s, "&<>"'") == 0); free(s); test_ok(); } { testing("example usage: /path/edn->json.git/"); char *const s = escape_html("/path/edn->json.git/"); assert(s); assert(strcmp(s, "/path/edn->json.git/") == 0); free(s); test_ok(); } { testing("example usage: Description" " with \"quotes\" && 'quotes'"); char *const s = escape_html( "Description with \"quotes\" && 'quotes'" ); assert(s); const char *const expected = "Description with" " "quotes" && 'quotes'"; assert(strcmp(s, expected) == 0); free(s); test_ok(); } { testing("example usage: plain HTML"); char *const s = escape_html("link"); assert(s); const char *const expected = "<a" " href="#">link</a>"; assert(strcmp(s, expected) == 0); free(s); test_ok(); } } #endif static int write_header(FILE *const fd, const char *const idx_title) { const int e = fprintf( fd, "" "\n" "\n" " \n" " \n" " \n" " \n" " \n" " %s\n" " \n" " \n" "
\n" "

\n" " \"%s\"\n" " \n" " %s\n" " \n" "

\n" "
\n" "
\n" "
\n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n", LANG, idx_title, LOGO_ALT, idx_title, NAME, DESCRIPTION, LAST_COMMIT ); if (e < 0) { fprintf( stderr, "%s:%s:%d: fprintf(): %s\n", PROGNAME, __FILE__, __LINE__, strerror(errno) ); return -1; } return 0; } static int write_footer(FILE *const fd) { const size_t footer_size = strlen(FOOTER_TEMPLATE) - strlen("%s") + strlen(PROJECT_HOMEPAGE_LINK) + sizeof('\0'); char *const footer_text = malloc(footer_size); if (!footer_text) { fprintf( stderr, "%s:%s:%d: malloc(%ld): %s\n", PROGNAME, __FILE__, __LINE__, footer_size, strerror(errno) ); return -1; } sprintf(footer_text, FOOTER_TEMPLATE, PROJECT_HOMEPAGE_LINK); const int e = fprintf( fd, "" " \n" "
\n" " %s\n" " \n" " %s\n" " \n" " %s\n" "
\n" "
\n" "
\n" "
\n" "

\n" " %s\n" "

\n" "
\n" " \n" "\n", footer_text ); free(footer_text); if (e < 0) { fprintf( stderr, "%s:%s:%d: fprintf(): %s\n", PROGNAME, __FILE__, __LINE__, strerror(errno) ); return -1; } return 0; } static char *last_commit_date(struct git_repository *repo) { struct git_commit *commit = NULL; struct git_revwalk *walker = NULL; struct git_oid oid; char *ret; if ( git_revwalk_new(&walker, repo) || git_revwalk_push_head(walker) || git_revwalk_next(&oid, walker) || git_commit_lookup(&commit, repo, &oid) ) { char *const default_return = strdup(""); if (!default_return) { fprintf( stderr, "%s:%s:%d: strdup(\"\"): %s\n", PROGNAME, __FILE__, __LINE__, strerror(errno) ); } ret = default_return; goto cleanup; } const struct git_signature *const author = git_commit_author(commit); assert(author); ret = formatted_date(author->when.time); cleanup: git_commit_free(commit); git_revwalk_free(walker); return ret; } #ifdef TEST static void test_last_commit_date() { test_start("test_last_commit_date"); { testing("embedded Git repository"); struct git_repository *repo; const int e = git_repository_open_ext( &repo, "tests/resources/embedded-repo", GIT_REPOSITORY_OPEN_NO_SEARCH, NULL ); assert(e == 0); char *const date = last_commit_date(repo); assert(date); assert(strcmp(date, "2021-07-30 14:18") == 0); free(date); git_repository_free(repo); test_ok(); } } #endif static int write_logo(FILE *const fd) { if (fprintf(fd, "%s", LOGO_STR) < 0) { fprintf( stderr, "%s:%s:%d: fprintf(): %s\n", PROGNAME, __FILE__, __LINE__, strerror(errno) ); return -1; } return 0; } static int write_style(FILE *const fd) { if (fprintf(fd, "%s", STYLE_STR) < 0) { fprintf( stderr, "%s:%s:%d: fprintf(): %s\n", PROGNAME, __FILE__, __LINE__, strerror(errno) ); return -1; } return 0; } static int write_index_row(FILE *const fd, char *const repopath) { int ret = 0; char description[81] = ""; int e; struct git_repository *repo = NULL; char *name = NULL; char *description_path = NULL; char *date = NULL; char *encoded_name = NULL; char *encoded_description = NULL; char *encoded_date = NULL; e = git_repository_open_ext( &repo, repopath, GIT_REPOSITORY_OPEN_NO_SEARCH, NULL ); if (e) { fprintf(stderr, "%s: cannot open repository\n", repopath); ret = 1; goto cleanup; } if ( !(name = remove_suffix(basename(repopath), GIT_SUFFIX)) || !(description_path = strjoin(repopath, "/description")) || !(date = last_commit_date(repo)) ) { ret = -1; goto cleanup; } FILE *const f = fopen(description_path, "r"); if (f) { if (!fgets(description, sizeof(description), f)) { fprintf( stderr, "%s:%s:%d: fgets(\"%s\"): %s\n", PROGNAME, __FILE__, __LINE__, description_path, strerror(errno) ); } if (fclose(f)) { fprintf( stderr, "%s:%s:%d: fclose(\"%s\"): %s\n", PROGNAME, __FILE__, __LINE__, description_path, strerror(errno) ); ret = -1; goto cleanup; } } if ( !(encoded_name = escape_html(name)) || !(encoded_description = escape_html(description)) || !(encoded_date = escape_html(date)) ) { ret = -1; goto cleanup; } e = fprintf( fd, "" " \n" " \n" " \n" " %s\n" " \n" " \n" " \n" " %s\n" " \n" " \n" " %s\n" " \n" " \n", encoded_name, encoded_name, encoded_description, encoded_date ); if (e < 0) { fprintf( stderr, "%s:%s:%d: fprintf(): %s\n", PROGNAME, __FILE__, __LINE__, strerror(errno) ); ret = -1; goto cleanup; } cleanup: free(encoded_date); free(encoded_description); free(encoded_name); free(date); free(description_path); free(name); git_repository_free(repo); return ret; } static int write_index( const char *const outdir, const char *const idx_description, const int repoc, char *const repov[] ) { int ret = 0; char *index_path = NULL; char *logo_path = NULL; char *style_path = NULL; FILE *index_fd = NULL; FILE *logo_fd = NULL; FILE *style_fd = NULL; if ( !(index_path = strjoin(outdir, "/index.html")) || !(logo_path = strjoin(outdir, "/logo.svg")) || !(style_path = strjoin(outdir, "/style.css")) ) { ret = -1; goto cleanup; } if (!(index_fd = fopen(index_path, "w"))) { fprintf( stderr, "%s:%s:%d: fopen(\"%s\"): %s\n", PROGNAME, __FILE__, __LINE__, index_path, strerror(errno) ); ret = -1; goto cleanup; } if (!(logo_fd = fopen(logo_path, "w"))) { fprintf( stderr, "%s:%s:%d: fopen(\"%s\"): %s\n", PROGNAME, __FILE__, __LINE__, logo_path, strerror(errno) ); ret = -1; goto cleanup; } if (!(style_fd = fopen(style_path, "w"))) { fprintf( stderr, "%s:%s:%d: fopen(\"%s\"): %s\n", PROGNAME, __FILE__, __LINE__, style_path, strerror(errno) ); ret = -1; goto cleanup; } if (write_header(index_fd, idx_description)) { ret = -1; goto cleanup; } for (int i = 0; i < repoc; i++) { const int e = write_index_row(index_fd, repov[i]); if (e == -1) { ret = -1; goto cleanup; } if (e) { ret = 1; } } if ( write_footer(index_fd) || write_logo(logo_fd) || write_style(style_fd) ) { ret = -1; goto cleanup; } cleanup: if (style_fd && fclose(style_fd)) { fprintf( stderr, "%s:%s:%d: fclose(\"%s\"): %s\n", PROGNAME, __FILE__, __LINE__, style_path, strerror(errno) ); ret = -1; } if (logo_fd && fclose(logo_fd)) { fprintf( stderr, "%s:%s:%d: fclose(\"%s\"): %s\n", PROGNAME, __FILE__, __LINE__, logo_path, strerror(errno) ); ret = -1; } if (index_fd && fclose(index_fd)) { fprintf( stderr, "%s:%s:%d: fclose(\"%s\"): %s\n", PROGNAME, __FILE__, __LINE__, index_path, strerror(errno) ); ret = -1; } free(style_path); free(logo_path); free(index_path); return ret; } #ifdef TEST static void unit_tests(){ git_libgit2_init(); test_remove_suffix(); test_strjoin(); test_formatted_date(); test_escape_html(); test_last_commit_date(); git_libgit2_shutdown(); } #endif int main(int argc, char *argv[]) { #ifdef TEST unit_tests(); return EXIT_SUCCESS; #endif if (argc < 2) { usage(stderr); return EXIT_USAGE; } int ret = 0; int flag; bool index = false; const char *idx_title = DEFAULT_TITLE; const char *outdir = "."; while ((flag = getopt(argc, argv, "o:t:ihV")) != -1) { switch (flag) { case 'i': index = true; break; case 't': idx_title = optarg; break; case 'o': outdir = optarg; break; default: if (usage(stderr)) { return EXIT_ERROR; } return EXIT_USAGE; } } git_libgit2_init(); errno = 0; if ( mkdir(outdir, S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH) && errno != EEXIST ) { fprintf( stderr, "%s:%s:%d: mkdir(\"%s\"): %s\n", PROGNAME, __FILE__, __LINE__, outdir, strerror(errno) ); ret = 1; goto cleanup; } if (index) { if (write_index(outdir, idx_title, argc - optind, argv + optind)) { ret = 1; goto cleanup; } } cleanup: git_libgit2_shutdown(); return ret; }