Diff options
Diffstat (limited to '')
113 files changed, 3485 insertions, 4799 deletions
@@ -5,6 +5,203 @@ Notable changes to this fork, newest release first. History before v2.0.0 belongs to upstream cgit and is not covered here. +v2.11.1 (2026-10-31) +-------------------- + +A bugfix release for v2.11.0, which crashed on the log and summary pages of +any repository with enable-mailmap on. + +Fixed +..... + +* The log and summary pages of a repository with enable-mailmap on crashed, + as releasing the walk also freed the mailmap cgit keeps for the whole + request. The patch and stats pages could free memory twice when git failed + to start their walk. +* The syntax highlighter left a file whose extension is in capitals, such as + MAIN.C, without colour. +* A page served uncached because its cache slot could not be written showed + its title twice. +* A scan logged "Unable to read the owner" with "Success" as the reason for + each repository whose owner has no passwd entry. +* tools/build-development.sh probed the compiler named by CC for sanitizers + but built with cc, and tools/build-release.sh left its probe files behind + in the temporary directory. +* Some tests with an exec filter failed now and then, depending on whether + cgit or the filter ran first. + + +v2.11.0 (2026-10-31) +-------------------- + +A release that cuts cgit back to a lean core. Nearly 300 commits take out dead +code, repeated logic and stale comments across the sources, the stylesheet, +the extensions, the tests and the docs, and fix the bugs found on the way. The +caches now lock with flock throughout, so read the two upgrade notes under +Changed before deploying, above all on a cache-root shared between hosts. + +Added +..... + +* tools/build-release.sh builds on macOS as well as on Linux and the BSDs. It + probes its linker flags as it already did its compiler flags, and keeps full + RELRO wherever the linker offers it. +* tools/build-development.sh uses whichever sanitizers the compiler offers, + falling back to UndefinedBehaviorSanitizer alone where AddressSanitizer is + missing, as on OpenBSD. +* make get-git fetches, checks and unpacks git's tarball with the tools each + system ships, curl, wget, fetch or ftp, and sha256sum, shasum, sha256 or + openssl, piping xz to tar where tar cannot read it. + +Changed +....... + +* The page cache and the cached repository list take the same lock, an flock + on a lock file opened close-on-exec. A request that finds a slot or the list + being rebuilt by another no longer logs "Unable to lock slot", while a real + failure to open, lock or write a lock file is logged with its path. +* Upgrade note for Linux: a scan by the old binary still running when the new + one starts holds an fcntl lock that flock does not see, so one rebuild of + the repository list can be written by two processes at once. Delete the + rc-* files under cache-root once the new binary is in place, or upgrade + while no scan runs. +* Upgrade note for NFS: on a mount with local_lock=flock or local_lock=all, + flock stays on each client, so hosts sharing one cache-root can rebuild the + same repository list at once, as the page cache always could. Give each + host a cache-root of its own on local disk. +* The command line switches --repo, --page, --head, --oid and --ofs are gone. + They were undocumented, --query covers them, and --repo never selected a + repository. +* syntax-highlight.lua colours a file only when Scintillua's detect() names a + lexer for it, and no longer guesses from the file extension. A Scintillua + without detect() leaves every file uncoloured but escaped. +* about-render.lua no longer puts a data-lang attribute on fenced code or a + manpage class on man pages, since nothing styled or read either. +* A line range such as #n10-n20 scrolls to where a single line lands, rather + than three quarters of the way down the window. +* The truncation link of a side-by-side diff is underlined like the unified + one, and the login error takes the colour of the other error messages. +* An empty repository's page no longer draws a Clone heading over no rows when + clone-url expands to nothing, which matches the summary page. +* A corrupt repository whose root tree is missing lists as empty instead of + answering 404. +* The diff page names a revision that is not a commit as the request wrote + it, and --version prints cgit in lower case. +* The test suite writes its configs through shared helpers, and its duplicate + checks are gone. + +Fixed +..... + +* The sample cgitrc placed scan-path ahead of about 30 settings that must + come before it, so uncommenting it where it stood silently dropped those + settings for every scanned repository. +* With enable-commit-graph and follow both on, the log labelled the age column + Graph and lost its Age header. +* The stats page dropped commits made on the window's first day earlier than + the time of the request. +* The stats page handed a path starting with a colon to git as pathspec + magic, which the log, patch and feed pages refuse. +* A snapshot of a repository whose url is ".git" or empty read past a buffer + while working out the snapshot's name. +* The age labels skipped every other age when they moved to a coarser unit, + leaving some stale until the next pass. +* post-receive.cgit-age printed an error and exited non-zero when it could not + write the age file. Like the other hooks it now says nothing and exits 0. +* The auth filters failed every login on Lua 5.3 and later when + session_seconds was not a whole number, and accepted a few malformed + spellings of a valid cookie. +* Filters and snapshot compressors inherited a stray copy of a cache slot's + lock, a refresh that could not take its lock went unlogged, and a slot whose + key could not be written stayed locked through the uncached render. +* On FreeBSD, NetBSD and OpenBSD the build left Lua out, because those systems + name its package lua-5.4 or lua54. +* Small leaks on the tree, refs, page header, log, snapshot and history walk + paths are closed. +* The docs stated a few defaults and behaviours wrongly. virtual-root now + reads as defaulting to SCRIPT_NAME followed by a slash, and + enable-version-info, project-list and the server configs' asset links are + described as they work. +* Two filter tests passed without their filter running, and several checks + matched text other than what they were after. + + +v2.10.0 (2026-10-29) +-------------------- + +A release that squares off the interface and brings its colours up to WCAG +AA, with a readme renderer that copes with more of what projects ship and an +example hook that keeps the commit-graph current. + +Added +..... + +* enable-version-info, off by default, puts cgit's version in the footer, the + generator tag and the signature of patches, with git's beside it in the + footer. +* custom/hooks/post-receive.commit-graph writes the commit-graph and its + changed path filters after each push, which speeds up the log, blame and + graph pages and any log limited to one file. +* The readme renderer makes a heading of a paragraph underlined with equals + signs or dashes, starts a code block at four spaces or a tab, and keeps an + escaped pipe inside its table cell. +* tools/build-development.sh builds cgit for debugging, unoptimised and held + to git's developer warnings, with AddressSanitizer and + UndefinedBehaviorSanitizer linked in. With clang and ctags it also leaves a + compilation database and a tags file in build/. + +Changed +....... + +* Controls, tabs and labels have square corners. The radius tokens stay, set + to zero, so a theme can still round them. +* Every text colour reaches 4.5:1 on the backgrounds it sits on, row stripes + included, and links inside running text are underlined. +* Form controls have a border of 3:1 contrast, and every link and control + shows the same focus ring. +* Every framed box and table grid has the same mid grey border. +* The diff and stat options panel has its caption as a header strip, with + the selects and the button sharing one column. +* Page titles name the page first, then the repository and the site, so + tabs, history and search results can tell the pages apart. +* A signed tag's signature prints as part of its message again, as it did up + to v2.9.1, rather than folded away. +* The syntax highlight colours are tokens, so a theme can change them like + the rest. +* The scripts load with defer. A site script placed after cgit.js can still + call window.cgit. +* The post-receive.cgit-cache hook drops only the pushed repository's pages + and the index. It finds the repository's url from scan-path or + hooks.cgiturl, and clears everything as before when it has neither. +* The example server configs name the release they were written for, nginx + 1.30, Apache httpd 2.4 and lighttpd 1.4. +* tools/release-build.sh is now tools/build-release.sh, and CHANGELOG.txt is + now CHANGE.txt. + +Fixed +..... + +* A long hash or download name made the whole page scroll sideways on a + phone. Both may now break. +* A readme holding a NUL byte lost everything after it. Each NUL now shows as + U+FFFD. +* A markdown link opening with a mix of slashes and backslashes, which a + browser follows to another site, now renders as text, as one opening with + two slashes already did. +* Man pages written by DocBook, pod2man or by hand leaked roff onto the page. + Their escapes, comments and preambles now draw nothing, the common named + characters render, and .EX, .Vb, .TP and the alternating font macros lay + out as intended. +* In forced colours, such as a Windows high contrast theme, the active tab + and the lines a URL names had no mark. They now take the reader's Highlight + colour. +* A screen reader announced the graph and links columns with no name. +* The commit page offered diff options for an octopus merge, which draws no + diff. +* Error text such as "Path not found" sat further in than the rest of the + page. + + v2.9.2 (2026-10-25) ------------------- @@ -18,15 +18,11 @@ Global settings about-filter:: Specifies a command which will be invoked to format the content of about - pages (both top-level and for each repository). The command will get the - content of the about-file on its stdin and the name of the file, with - the tree url its links resolve against, as arguments, and the stdout - from the command will be included verbatim on the about page. Default - value: none. When no about-filter is set, a repository's readme is - escaped and served as plain text, while root-readme is included - verbatim. A bundled Lua filter, about-render.lua, renders markdown, man - pages and plain text when about-filter points at it. See also: "Filter - API". + pages (both top-level and for each repository). When no about-filter is + set, a repository's readme is escaped and served as plain text. A + bundled Lua filter, about-render.lua, renders markdown, man pages and + plain text when about-filter points at it. Default value: none. See + also: "Filter API". age-file:: Specifies a path, relative to each repository path, which can be used to @@ -37,9 +33,8 @@ age-file:: auth-filter:: Specifies a command that will be invoked for authenticating repository - access. Receives 12 arguments, with request data on stdin and its answer - on stdout, as described under "Filter API". If no auth-filter is - specified, no authentication is performed. Default value: none. + access. If no auth-filter is specified, no authentication is performed. + Default value: none. See also: "Filter API". branch-sort:: Flag which, when set to "age", enables date ordering in the branch ref @@ -48,24 +43,24 @@ branch-sort:: cache-about-ttl:: Number which specifies the time-to-live, in minutes, for the cached - version of the repository about page. See also: "The cache". Default - value: "15". + version of the repository about page. Default value: "15". See also: + "The cache". cache-dynamic-ttl:: Number which specifies the time-to-live, in minutes, for the cached - version of repository pages accessed without a fixed object id. See - also: "The cache". Default value: "5". + version of repository pages accessed without a fixed object id. Default + value: "5". See also: "The cache". cache-index-ttl:: Number which specifies the time-to-live, in minutes, for the cached - version of the repository index page. See also: "The cache". Default - value: "5". + version of the repository index page. Default value: "5". See also: "The + cache". cache-max-slot-size:: Number which specifies the largest response, in kilobytes, that a cache slot may keep. A larger response is still served but not kept, so one archive cannot take a slot's worth of disk. Set to "0" to remove the - limit. See also: "The cache". Default value: "65536" (64 MB). + limit. Default value: "65536" (64 MB). See also: "The cache". cache-root:: Path used to store the cgit cache entries. Default value: @@ -74,32 +69,32 @@ cache-root:: cache-scan-ttl:: Number which specifies the time-to-live, in minutes, for the result of - scanning a path for git repositories. See also: "The cache". Default - value: "15". + scanning a path for git repositories. Default value: "15". See also: + "The cache". cache-size:: The maximum number of entries in the cgit cache. When set to "0", - caching is disabled. See also: "The cache". Default value: "0". + caching is disabled. Default value: "0". See also: "The cache". cache-snapshot-ttl:: Number which specifies the time-to-live, in minutes, for the cached version of snapshots. A snapshot requested with a fixed object id uses - "cache-static-ttl" instead, since its content can never change. See - also: "The cache". Default value: "5". + "cache-static-ttl" instead, since its content can never change. Default + value: "5". See also: "The cache". cache-static-ttl:: Number which specifies the time-to-live, in minutes, for the cached - version of repository pages accessed with a fixed object id. See also: - "The cache". Default value: "-1". + version of repository pages accessed with a fixed object id. Default + value: "-1". See also: "The cache". cache-summary-ttl:: Number which specifies the time-to-live, in minutes, for the cached - version of the repository summary page. See also: "The cache". Default - value: "5". + version of the repository summary page. Default value: "5". See also: + "The cache". case-sensitive-sort:: - Sort items in the repository list case sensitively. Default value: "1". - See also: repository-sort, section-sort. + Sort items in the repository list case-sensitively. Default value: "1". + See also: "repository-sort", "section-sort". clone-prefix:: Space-separated list of common prefixes which, when combined with a @@ -113,10 +108,11 @@ clone-url:: "Macro expansion", "Filter API". commit-filter:: - Specifies a command which will be invoked to format commit messages. The - command will get the message on its stdin, and the stdout from the - command will be included verbatim as the commit message, which is one - way to link a bug tracker. Default value: none. See also: "Filter API". + Specifies a command which will be invoked to format commit messages, for + example to link a bug tracker. A ready filter linking object names and + configurable text references ships as + custom/extensions/link-commits.lua. Default value: none. See also: + "Filter API". commit-sort:: Flag which, when set to "date", enables strict date ordering in the @@ -125,11 +121,11 @@ commit-sort:: none. css:: - Url which specifies the css document to include in all cgit pages. - Default value: "/cgit.css". May be given multiple times, each css URL - path is added in the head section of the document in turn. Setting this - to an empty string will disable generation of the link to this file in - the head section. + Url which specifies the CSS document to include in all cgit pages. May + be given multiple times, and each CSS url path is added in the head + section of the document in turn. Setting this to an empty string will + disable generation of the link to this file in the head section. Default + value: "/cgit.css". date-format:: Format used for the calendar dates in the age columns of the log, refs @@ -147,14 +143,14 @@ date-format:: email-filter:: Specifies a command which will be invoked to format names and email address of committers, authors, and taggers, as represented in various - places throughout the cgit interface. This command will receive an email - address and an origin page string as its command line arguments, and the - text to format on stdin. It is to write the formatted text back out onto - stdout. Default value: none. See also: "Filter API". + places throughout the cgit interface. Ready filters adding a Gravatar or + Libravatar icon ship as custom/extensions/email-gravatar.lua and + custom/extensions/email-libravatar.lua. Default value: none. See also: + "Filter API". embedded:: - Flag which, when set to "1", makes cgit generate a html fragment - suitable for embedding in other html pages. Default value: "0". See + Flag which, when set to "1", makes cgit generate an HTML fragment + suitable for embedding in other HTML pages. Default value: "0". See also: "enable-header". enable-blame:: @@ -164,9 +160,8 @@ enable-blame:: enable-cache-list:: Flag which, when set to "1", exposes the "ls_cache" page. That page - lists the cache directory path and the URLs of requests other visitors - made, so it is disabled by default and should stay off on any instance - where that disclosure matters. Default value: "0". + lists the cache directory path and the urls other visitors requested. + Default value: "0". enable-commit-graph:: Flag which, when set to "1", makes cgit print an ASCII-art commit @@ -177,10 +172,8 @@ enable-commit-trailers:: Flag which, when set to "1", makes the commit page split the trailer block, the Signed-off-by lines and their kin, off the end of the commit message and show it as a table under the message. The block is found the - way git finds it for "git interpret-trailers", so the last paragraph is - taken when every line in it is a trailer, or when it holds a - Signed-off-by and at least a quarter of its lines are trailers. Default - value: "0". See also: "trailer-filter", "repo.enable-commit-trailers". + way "git interpret-trailers" finds it. Default value: "0". See also: + "trailer-filter", "repo.enable-commit-trailers". enable-follow-links:: Flag which, when set to "1", allows users to follow a file in the log @@ -194,7 +187,8 @@ enable-git-config:: repo.section, and repo.desc respectively. All git config keys that begin with "cgit." are mapped to the corresponding "repo." key in cgit, subject to "trust-scan-config" the way a repository's cgitrc is. Default - value: "0". See also: scan-path, section-from-path, trust-scan-config. + value: "0". See also: "scan-path", "section-from-path", + "trust-scan-config". enable-gitmodules-links:: Flag which, when set to "1", makes submodule listings derive a link from @@ -223,15 +217,11 @@ enable-html-serving:: "repo.enable-html-serving". enable-http-clone:: - If set to "1", cgit acts as a dumb HTTP endpoint for git clones. Adding - "http://$HTTP_HOST$SCRIPT_NAME/$CGIT_REPO_URL" to clone-url exposes it. - The files it serves come straight off the disk and are never cached. - Most sites can leave this as it is, though the dumb transport is git's - older one and is not recommended. cgit does not speak the smart - protocol git prefers, so a site that wants it routes clone requests to - git-http-backend in its web server, and smart clients use that - instead. A site that serves git repositories another way can turn this - off. Default value: "1". + If set to "1", cgit acts as a dumb HTTP endpoint for git clones, which + adding "http://$HTTP_HOST$SCRIPT_NAME/$CGIT_REPO_URL" to clone-url + exposes. Its files are never cached. cgit does not speak the smart + protocol git prefers, which a site can offer by routing clone requests + to git-http-backend in its web server. Default value: "1". enable-index-links:: Flag which, when set to "1", makes cgit generate extra links for each @@ -293,8 +283,8 @@ enable-tree-linenumbers:: enable-version-info:: Flag which, when set to "1", names the cgit and git versions in the - standard page footer and the generator meta tag, and the cgit version - in the signature that ends each patch. Exact versions help someone + standard page footer, and the cgit version in the generator meta tag + and in the signature that ends each patch. Exact versions help someone match a site against published advisories, so they are left out unless a site asks for them, and a footer set with footer replaces the standard one either way. Default value: "0". @@ -312,25 +302,24 @@ footer:: head-include:: The content of the file specified with this option will be included - verbatim in the html HEAD section on all pages. Default value: none. + verbatim in the HTML head section on all pages. Default value: none. header:: The content of the file specified with this option will be included verbatim at the top of all pages. Default value: none. include:: - Name of a configfile to include before the rest of the current - configfile is parsed. Includes may nest eight levels deep, and anything + Name of a config file to include before the rest of the current + config file is parsed. Includes may nest eight levels deep, and anything deeper is ignored. Default value: none. See also: "Macro expansion". js:: - Url which specifies the javascript script document to include in all - cgit pages. Default value: "/cgit.js". May be given multiple times, each - script URL path is added in the head section of the document in turn. - Each is loaded with defer, so it runs once the page has been parsed, - still in the order given. - Setting this to an empty string will disable generation of the link to - this file in the head section. + Url which specifies the JavaScript document to include in all cgit + pages. May be given multiple times, and each script url path is + added in the head section of the document in turn. Each is loaded with + defer, so it runs once the page has been parsed, still in the order + given. Setting this to an empty string will disable generation of the + link to this file in the head section. Default value: "/cgit.js". local-time:: Flag which, if set to "1", makes cgit print commit and tag times in the @@ -350,9 +339,11 @@ max-atom-items:: max-blob-size:: Specifies the maximum size in KBytes of a blob that cgit will read into - memory to serve. It caps both the HTML blob page and the raw "plain" and - "blob" output, and a larger object is refused rather than loaded whole. - Default value: "10240" (10 MB). Set to "0" to disable the limit. + memory to serve. The tree page's file view, the blame page, a tracked + readme, a snapshot signature and the raw "plain" and "blob" output all + refuse a larger object rather than load it whole, and the diff and patch + pages show it as binary. Set to "0" to disable the limit. Default value: + "10240" (10 MB). max-commit-count:: Specifies the number of entries to list per page in "log" page. Default @@ -361,14 +352,14 @@ max-commit-count:: max-diff-files:: If a commit changes more files than this, the "commit" and "diff" pages show only the diffstat, where each file links to its own diff page. The - "rawdiff" and "patch" pages are never limited. Default value: "200". Set - to "0" to always render every file inline. + "rawdiff" and "patch" pages are never limited. Set to "0" to always + render every file inline. Default value: "200". max-diff-lines:: If a single file's diff spans more lines than this, the whole-commit pages print a note in its place linking to the file's own diff page, - which always renders in full. Default value: "1000". Set to "0" to - always render oversized files inline. + which always renders in full. Set to "0" to always render oversized + files inline. Default value: "1000". max-message-length:: Specifies the maximum number of commit message characters to display in @@ -377,13 +368,13 @@ max-message-length:: max-patch-count:: Specifies the maximum number of commits emitted as a series by the "patch" page when an explicit revision range is requested. A single - commit is unaffected. Default value: "50". Set to "0" for no limit. + commit is unaffected. Set to "0" for no limit. Default value: "50". max-ref-count:: Specifies the number of branches and tags to list per section on the "refs" page, and per page on the dedicated branch and tag pages that its - overflow links lead to. Default value: "200". A value of "0" or less lists - every ref on one page. + overflow links lead to. A value of "0" or less lists every ref on one + page. Default value: "200". max-repo-count:: Specifies the number of entries to list per page on the repository index @@ -405,10 +396,7 @@ mimetype-file:: Specifies the file to use for automatic mimetype lookup. If specified then this field is used as a fallback when no "mimetype.<ext>" match is found. If unspecified then no such lookup is performed. The typical file - to use on a Linux system is /etc/mime.types. Each line in the file holds - a mimetype, like image/png, followed by one or more file extensions, - like jpg, separated by whitespace. Empty lines and lines whose first - non-blank character is a hash are ignored. Default value: none. See + to use on a Linux system is /etc/mime.types. Default value: none. See also: "mimetype.<ext>". mimetype.<ext>:: @@ -417,27 +405,28 @@ mimetype.<ext>:: "mimetype-file". module-link:: - Text which will be used as the formatstring for a hyperlink when a + Text which will be used as the format string for a hyperlink when a submodule is printed in a directory listing. The arguments for the - formatstring are the name of the submodule directory and the object id + format string are the name of the submodule directory and the object id of the submodule commit. Default value: none. project-list:: - A list of subdirectories inside of scan-path, relative to it, that + A file listing the subdirectories of scan-path, relative to it, that should be loaded as git repositories. This must be defined prior to - scan-path. Default value: none. See also: scan-path, "Macro expansion". + scan-path. Default value: none. See also: "scan-path", "Macro + expansion". readme:: Text which will be used as default value for "repo.readme". Multiple config keys may be specified, and cgit will use the first found file in this list. This is useful in conjunction with scan-path. Default value: - none. See also: scan-path, repo.readme. + none. See also: "scan-path", "repo.readme". remove-suffix:: If set to "1" and scan-path is enabled, if any repositories are found with a suffix of ".git", this suffix will be removed for the url and name. This must be defined prior to scan-path. Default value: "0". See - also: scan-path. + also: "scan-path". rename-limit:: Maximum number of files to consider when detecting renames. The value @@ -448,7 +437,7 @@ repository-sort:: The way in which repositories in each section are sorted. Valid values are "name" for sorting by the repository name or "age" for sorting by the most recently updated repository. Default value: "name". See also: - section, case-sensitive-sort, section-sort. + "section", "case-sensitive-sort", "section-sort". robots:: Text used as content for the "robots" meta-tag. Default value: "index, @@ -473,7 +462,7 @@ scan-hidden-path:: directories whose name starts with a period ('.'). Otherwise, scan-path skips such directories as hidden. The ".git" directory of a non-bare repository is still found. This must be defined prior to scan-path. - Default value: "0". See also: scan-path. + Default value: "0". See also: "scan-path". scan-path:: A path which is scanned for repositories. If caching is enabled, the @@ -487,8 +476,8 @@ scan-path:: filters and the enable flags, not only the entries that say so explicitly. The readme list is the exception, since a repository with none of its own follows the global list as the whole cgitrc leaves it. - Default value: none. See also: cache-scan-ttl, project-list, - strict-export, "Macro expansion". + Default value: none. See also: "cache-scan-ttl", "project-list", + "strict-export", "Macro expansion". section:: The name of the current repository section. All repositories defined @@ -498,14 +487,16 @@ section:: section-from-path:: A number which, if defined prior to scan-path, specifies how many path elements from each repository path to use as a default section name. If - negative, cgit discards the specified number of path elements above the - repository directory. Default value: "0". + negative, cgit instead drops that many path elements from the end, the + repository directory included, so "-1" uses every directory above the + repository. Default value: "0". section-sort:: Flag which, when set to "1", sorts the sections on the repository listing by name, along with the repositories within each section. Set this flag to "0" to preserve the order in the cgitrc file. Default - value: "0". See also: section, case-sensitive-sort, repository-sort. + value: "0". See also: "section", "case-sensitive-sort", + "repository-sort". side-by-side-diffs:: If set to "1" shows side-by-side diffs instead of unidiffs by default. @@ -515,19 +506,17 @@ snapshots:: Text which specifies the default set of snapshot formats that cgit generates links for. The value is a space-separated list of zero or more of the values "tar", "tar.gz", "tar.bz2", "tar.lz", "tar.xz", "tar.zst" - and "zip". The special value "all" enables all snapshot formats. Default - value: none. All compressors use default settings. Some settings can be - influenced with environment variables, for example set ZSTD_CLEVEL=10 in - web server environment for higher (but slower) zstd compression. + and "zip". The special value "all" enables all snapshot formats. All + compressors use default settings. Some settings can be influenced with + environment variables, for example set ZSTD_CLEVEL=10 in the web server + environment for higher (but slower) zstd compression. Default value: + none. source-filter:: Specifies a command which will be invoked to format plaintext blobs in - the tree and blame pages. The command will get the blob content on its - stdin and the name of the blob as its only command line argument. The - stdout from the command will be included verbatim as the blob contents, - which is how syntax highlighting is done. When no source-filter is - configured, cgit serves the text plain. A ready syntax highlighter using - the Scintillua lexer collection ships as + the tree and blame pages, which is how syntax highlighting is done. When + no source-filter is configured, cgit serves the text plain. A ready + syntax highlighter using the Scintillua lexer collection ships as custom/extensions/syntax-highlight.lua, see the comments in that file for its dependencies. Default value: none. See also: "Filter API". @@ -536,11 +525,11 @@ strict-export:: for cgit to allow access to that repository. This can be used to emulate gitweb's EXPORT_OK and STRICT_EXPORT functionality and limit cgit's repositories to match those exported by git-daemon. This option must be - defined prior to scan-path. Default value: none. See also: scan-path. + defined prior to scan-path. Default value: none. See also: "scan-path". summary-branches:: Specifies the number of branches to display in the repository "summary" - page. Default value: "10". A value of "0" or less lists every branch. + page. A value of "0" or less lists every branch. Default value: "10". summary-log:: Specifies the number of log entries to display in the repository @@ -548,19 +537,17 @@ summary-log:: summary-tags:: Specifies the number of tags to display in the repository "summary" - page. Default value: "10". A value of "0" or less lists every tag. + page. A value of "0" or less lists every tag. Default value: "10". trailer-filter:: Specifies a command which will be invoked to format the value of a - commit trailer when "enable-commit-trailers" is set. The command will - get the trailer key and an origin page string as its command line - arguments and the value on its stdin, and the stdout from the command - will be included verbatim as the value. Trailers whose value is a name - and email address, such as Signed-off-by, go through the email-filter - instead, and when no trailer-filter is configured the remaining values - go through the commit-filter. A ready filter linking Fixes, Reverts, - Closes, Bug and URL values ships as custom/extensions/link-trailers.lua. - Default value: none. See also: "Filter API". + commit trailer when "enable-commit-trailers" is set. Trailers whose + value is a name and email address, such as Signed-off-by, go through + the email-filter instead, and when no trailer-filter is configured the + remaining values go through the commit-filter. A ready filter linking + Fixes, Reverts, Closes, Bug and URL values ships as + custom/extensions/link-trailers.lua. Default value: none. See also: + "Filter API". trust-scan-config:: Flag which, when set to "1", honours every setting in a repository's own @@ -568,16 +555,16 @@ trust-scan-config:: whoever can push to the repository, so without it the settings that run a command, put raw markup on the page, serve a file as markup, place a link or read a file off the disk are ignored with a warning. Those are - the filters, head-content, module-link, logo, logo-link, clone-url, - enable-html-serving and a readme that names a file rather than a git - object. Settings in the main cgitrc, the "repo.<option>" form included, - never need this. Default value: "0". See also: "scan-path", - "enable-git-config". + the filters, head-content, module-link, module-link.<path>, logo, + logo-link, clone-url, enable-html-serving and a readme that names a file + rather than a git object. Settings in the main cgitrc, the + "repo.<option>" form included, never need this. Default value: "0". See + also: "scan-path", "enable-git-config". virtual-root:: - Url which, if specified, is used as root for all cgit links. cgit then - generates virtual urls such as "/cgit/tree/README" in place of - "?url=cgit/tree/README". Default value: none. + Url used as root for all cgit links, such as "/cgit/" for links like + "/cgit/repo/tree/README". Default value: SCRIPT_NAME followed by a + slash. Repository settings @@ -588,9 +575,7 @@ repo.about-filter:: also: "Filter API", "trust-scan-config". repo.branch-sort:: - Flag which, when set to "age", enables date ordering in the branch ref - list, and when set to "name" enables ordering by branch name. Default - value: <branch-sort>. + Override the default branch-sort. Default value: <branch-sort>. repo.clone-url:: A list of space-separated urls which can be used to clone this @@ -601,10 +586,7 @@ repo.commit-filter:: also: "Filter API", "trust-scan-config". repo.commit-sort:: - Flag which, when set to "date", enables strict date ordering in the - commit log, and when set to "topo" enables strict topological ordering. - If unset, the default ordering of "git log" is used. Default value: - <commit-sort>. + Override the default commit-sort. Default value: <commit-sort>. repo.defbranch:: The name of the default branch for this repository. If no such branch @@ -667,7 +649,7 @@ repo.enable-subject-links:: "enable-subject-links". Default value: <enable-subject-links>. repo.head-content:: - This value will be added verbatim to the html HEAD section of each page + This value will be added verbatim to the HTML head section of each page displayed for this repository. Default value: none. See also: "head-include". @@ -682,12 +664,10 @@ repo.ignore:: path. Default value: "0". See also: "repo.hide". repo.logo:: - Url which specifies the source of an image which will be used as a logo - on this repository's pages. Default value: <logo>. + Override the default logo. Default value: <logo>. repo.logo-link:: - Url loaded when clicking on the cgit logo image. Default value: - <logo-link>. + Override the default logo-link. Default value: <logo-link>. repo.max-stats:: Override the default maximum statistics period. Valid values are equal @@ -696,15 +676,12 @@ repo.max-stats:: <max-stats>. repo.module-link:: - Text which will be used as the formatstring for a hyperlink when a - submodule is printed in a directory listing. The arguments for the - formatstring are the name of the submodule directory and the object id - of the submodule commit. Default value: <module-link>. + Override the default module-link. Default value: <module-link>. repo.module-link.<path>:: - Text which will be used as the formatstring for a hyperlink when a + Text which will be used as the format string for a hyperlink when a submodule with the specified subdirectory path is printed in a directory - listing. The only argument for the formatstring is the object id of the + listing. The only argument for the format string is the object id of the submodule commit. Default value: none. repo.name:: @@ -764,10 +741,7 @@ When the option "scan-path" is used to auto-discover git repositories, cgit tries to parse the file "cgitrc" within any found repository. Such a file may contain any of the repository settings described above, except "repo.url" and "repo.path", with the "repo." prefix dropped, so "repo.desc" becomes "desc". The -options that run a command, put raw markup on the page, serve a file as markup, -place a link or read a file off the disk are only honoured when -"trust-scan-config" is set to "1", since the file belongs to whoever can push to -the repository. +settings "trust-scan-config" guards are only honoured when it is set to "1". Filter API @@ -806,9 +780,9 @@ specification with one of these strings. 'html_attr(str)':: HTML escapes for an attribute and writes 'str' to the webpage. 'html_url_path(str)':: - URL escapes for a path and writes 'str' to the webpage. + Url escapes for a path and writes 'str' to the webpage. 'html_url_arg(str)':: - URL escapes for an argument and writes 'str' to the webpage. + Url escapes for an argument and writes 'str' to the webpage. 'html_include(file)':: Includes 'file' in webpage. @@ -922,15 +896,11 @@ environment variable. Macro expansion will also happen on the content of $CGIT_CONFIG, if defined. -The set of available variables is not fixed by cgit. The options above are -expanded while the configuration is parsed, against whatever environment the web -server hands the cgit process, so any variable the server sets can be used. A -CGI server provides the standard request variables, among them $HTTP_HOST, -$HTTPS, $PATH_INFO, $QUERY_STRING, $REQUEST_METHOD, $SCRIPT_NAME, $SERVER_NAME -and $SERVER_PORT, and server directives such as SetEnv (Apache), fastcgi_param -(nginx) or setenv.add-environment (lighttpd) can add anything else. cgit unsets -$HOME and $XDG_CONFIG_HOME before reading its configuration, so those two never -expand. +The options above are expanded while the configuration is parsed. Any variable +in the environment the web server hands cgit can be used, from the CGI request +variables such as $HTTP_HOST to anything set with SetEnv (Apache), fastcgi_param +(nginx) or setenv.add-environment (lighttpd). cgit unsets $HOME and +$XDG_CONFIG_HOME before reading its configuration, so those two never expand. One usage of this feature is virtual hosting, which in its simplest form can be accomplished by adding a line like this one to /etc/cgitrc. @@ -948,20 +918,24 @@ The cache --------- All cache ttl values are in minutes. Negative ttl values indicate that a page -type will never expire, and thus the first time a URL is accessed, the result +type will never expire, and thus the first time a url is accessed, the result will be cached indefinitely, even if the underlying git repository changes. Conversely, when a ttl value is zero, the cache is disabled for that particular -page type. The files of the dumb transport are never cached, since they already -sit on the disk, and a response larger than "cache-max-slot-size" is served but -not kept. - -The cache directory holds one file per slot plus transient lock files, all -created by cgit itself. Create the directory ahead of time, owned by the account -the web server runs cgit as, with mode 0700. cgit creates its files by name -without guarding against links planted beside them, so a directory other -accounts can write to would let those accounts redirect the writes. A directory -other accounts can read exposes every cached page along with the URLs visitors -asked for. +page type. A response larger than "cache-max-slot-size" is served but not kept. + +The cache directory holds one file per slot and the cached results of scan-path +as rc-* files, plus transient lock files, all created by cgit itself. Create the +directory ahead of time, owned by the account the web server runs cgit as, with +mode 0700. cgit creates its files by name without guarding against links planted +beside them, so a directory other accounts can write to would let those accounts +redirect the writes. A directory other accounts can read exposes every cached +page along with the urls visitors asked for. + +A slot or a scan result is rebuilt only by the process holding an flock on its +lock file. A request that finds the lock held serves the expired copy, or goes +without the cache when there is none, and logs nothing. Any other failure to +open, lock or write a lock file is logged with its path, apart from one in the +background rebuild of an expired scan result, whose output is discarded. Signatures @@ -12,11 +12,8 @@ # run their own jobs in parallel. .NOTPARALLEL: -# Sources live in source/, web assets in assets/, Lua filters in -# custom/extensions/, the bundled Git tree in vendor/git, and everything -# generated lands in build/. CGIT_ROOT leads from inside the Git tree back to -# the project root, because the cgit build runs with vendor/git as its working -# directory. +# CGIT_ROOT leads from inside the Git tree back to the project root, because +# the cgit build runs with vendor/git as its working directory. SRCDIR = source ASSETDIR = assets EXTDIR = custom/extensions @@ -32,18 +29,17 @@ GIT_SHA256 = 26c56c296b38c0695b26fa95f475f1d01704d2d38e73465ca30b0b2f5dc789d3 GIT_URL = https://www.kernel.org/pub/software/scm/git/git-$(GIT_VERSION).tar.xz GIT_TARBALL = git-$(GIT_VERSION).tar.xz -# Compiled-in cgit defaults, all overridable through cgit.conf. Every one -# carries the CGIT_ prefix except CACHE_ROOT, which is inherited from upstream -# under that name. -CGIT_VERSION = v2.9.2 +# The defaults compiled into cgit and the directories make install writes to, +# all overridable through cgit.conf. +CGIT_VERSION = v2.11.1 CGIT_SCRIPT_NAME = cgit.cgi CGIT_SCRIPT_PATH = /var/www/htdocs/cgit CGIT_DATA_PATH = $(CGIT_SCRIPT_PATH) CGIT_CONFIG = /etc/cgitrc CACHE_ROOT = /var/cache/cgit -# The standard GNU install directories, kept lowercase so that packaging can -# override them. filterdir is the one cgit-specific addition. +# The standard GNU install directories, under the lowercase names packaging +# expects. filterdir is the one cgit-specific addition. prefix = /usr/local libdir = $(prefix)/lib filterdir = $(libdir)/cgit/filters @@ -64,8 +60,7 @@ INSTALL = install -include $(GITDIR)/config.mak.uname -include cgit.conf -export CGIT_VERSION CGIT_SCRIPT_NAME CGIT_SCRIPT_PATH -export CGIT_DATA_PATH CGIT_CONFIG CACHE_ROOT +export CGIT_VERSION CGIT_SCRIPT_NAME CGIT_CONFIG CACHE_ROOT .PHONY: all all: cgit @@ -101,8 +96,6 @@ cgit-nolua: sparse: +@$(MAKE) -C $(GITDIR) -f $(CGIT_ROOT)/$(SRCDIR)/cgit.mk $(GIT_BUILD_FLAGS) cgit-sparse -# The two builds share the Git tree, so they are made one after the other -# even under -j. .PHONY: test test: cgit-with-git +@$(MAKE) cgit-nolua @@ -129,10 +122,8 @@ uninstall: $(RM) $(addprefix $(DESTDIR)$(CGIT_DATA_PATH)/,$(ASSETS)) $(RM) $(addprefix $(DESTDIR)$(filterdir)/,$(FILTER_FILES)) -# Compiled output lives under build/ and the test suite writes into tests/, so -# cleaning removes both. The descent into tests is guarded because -# tests/Makefile includes git's config.mak.uname, which needs the submodule -# present. +# The descent into tests is guarded because tests/Makefile includes git's +# config.mak.uname, which needs the submodule present. .PHONY: clean clean: $(RM) -r $(BUILDDIR) @@ -143,14 +134,25 @@ cleanall: clean @if test -e $(GITDIR)/Makefile; then $(MAKE) -C $(GITDIR) clean; fi # The tarball is checked against its sum before anything is unpacked from it. -# An older macOS has no sha256sum, but its shasum reads the same format. +# Each system ships its own fetcher and sum tool (curl or wget on Linux and +# macOS, fetch on FreeBSD, ftp on OpenBSD and NetBSD, sha256 on the BSDs), so +# the first one present is used, and xz is piped to tar for a tar that cannot +# read it, as OpenBSD's cannot. .PHONY: get-git get-git: mkdir -p $(dir $(GITDIR)) - curl -fL -o $(GIT_TARBALL) $(GIT_URL) - @if command -v sha256sum >/dev/null; then sum=sha256sum; else sum='shasum -a 256'; fi; \ - echo '$(GIT_SHA256) $(GIT_TARBALL)' | $$sum -c - || { $(RM) $(GIT_TARBALL); exit 1; } - tar -xJf $(GIT_TARBALL) && rm -rf $(GITDIR) && mv git-$(GIT_VERSION) $(GITDIR) + @if command -v curl >/dev/null; then curl -fL -o $(GIT_TARBALL) $(GIT_URL); \ + elif command -v wget >/dev/null; then wget -O $(GIT_TARBALL) $(GIT_URL); \ + elif command -v fetch >/dev/null; then fetch -o $(GIT_TARBALL) $(GIT_URL); \ + else ftp -o $(GIT_TARBALL) $(GIT_URL); fi + @if command -v sha256sum >/dev/null; then sum=$$(sha256sum $(GIT_TARBALL)); \ + elif command -v shasum >/dev/null; then sum=$$(shasum -a 256 $(GIT_TARBALL)); \ + elif command -v sha256 >/dev/null; then sum=$$(sha256 -q $(GIT_TARBALL)); \ + else sum=$$(openssl dgst -sha256 -r $(GIT_TARBALL)); fi; \ + test "$${sum%% *}" = '$(GIT_SHA256)' || \ + { echo 'get-git: $(GIT_TARBALL) does not match its sum' >&2; $(RM) $(GIT_TARBALL); exit 1; } + @if command -v xz >/dev/null; then xz -dc $(GIT_TARBALL) | tar -xf -; else tar -xJf $(GIT_TARBALL); fi + rm -rf $(GITDIR) && mv git-$(GIT_VERSION) $(GITDIR) $(RM) $(GIT_TARBALL) .PHONY: tags @@ -11,11 +11,9 @@ Installation Building cgit involves building a proper version of Git. How to do this depends on how you obtained the cgit sources. -In a cloned cgit repository, first initialize and update the Git submodule. +In a cloned cgit repository, first fetch the Git submodule. - $ git submodule init # register the Git submodule in .git/config - $ $EDITOR .git/config # if you want to specify a different url for git - $ git submodule update # clone/fetch and checkout correct git version + $ git submodule update --init From a cgit tarball, download a proper git version instead. @@ -71,7 +69,7 @@ repositories to display. A fully commented starting point with every option at its default is in custom/cgitrc, and MANUAL.txt documents each option in full. -Customization +Customisation ------------- Every page is one document with the same skeleton, so a site stylesheet or @@ -93,7 +91,7 @@ script added through the css and js options has stable hooks to work with. ext- followed by the file extension. * The commit and tag pages use table.commit-info, the trailer table is table.commit-trailers, the diffstat is table.diffstat and each file of a diff - is div.file, or tbody.file in the side by side view, with the path in + is div.file, or tbody.file in the side-by-side view, with the path in data-path. The blob and blame pages open with div.blob-header. * Relative ages are time elements with an age-* class and the timestamp in data-ut, while a date shown in full is a plain time element. Line numbers are @@ -135,7 +133,7 @@ and each script's header lists the exact install commands for its own. luaposix. * The email filters, email-gravatar.lua and email-libravatar.lua, need luaossl. * The syntax highlighter, syntax-highlight.lua, needs lpeg and a Scintillua - lexer set. + recent enough to have detect(). * The about-page renderer, about-render.lua, needs lpeg for markdown and man pages. Plain text needs only Lua. * link-commits.lua and link-trailers.lua need nothing beyond Lua itself. @@ -144,13 +142,13 @@ On Lua 5.5 the auth and email filters need luaossl 20260910 or later, since earlier builds fail to load there. -URLs and query parameters +Urls and query parameters ------------------------- cgit serves every page from one CGI program, so a url names the repository, the page and a path inside that page. There are two forms and they carry the same -information. With virtual-root set, a request is a path, written as -/<repo>/<page>/<path> with anything else as a query string. Without it, the +information. The one cgit's own links take is a path, written as +/<repo>/<page>/<path> with anything else as a query string. In the other the whole request lives in the query string as ?url=<repo>/<page>/<path>. The page name is the second element and is one of about, atom, blame, blob, @@ -187,8 +185,8 @@ one, where a value of all marks the few not tied to particular pages. | s | index | sort key for the listing | | showmsg | log | show full commit messages | | period | stats | the statistics window, one of w, m, q or y | -| dt | diff commit | 0 unified, 1 side by side or 2 stat only | -| ss | diff commit | shorthand for the side by side diff | +| dt | diff commit | 0 unified, 1 side-by-side or 2 stat only | +| ss | diff commit | shorthand for the side-by-side diff | | all | atom | include every ref rather than one branch | | context | diff commit | lines of context around a diff hunk | | ignorews | diff commit | ignore whitespace when diffing | diff --git a/assets/cgit.css b/assets/cgit.css index 2d1be91..ffbc902 100644 --- a/assets/cgit.css +++ b/assets/cgit.css @@ -88,8 +88,8 @@ div#cgit { font-family: var(--font-sans); font-size: 13px; line-height: 1.4; - text-size-adjust: 100%; -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; color: var(--fg); background: var(--bg); } @@ -118,10 +118,10 @@ div#cgit a:hover { } div#cgit .markdown a, +div#cgit .truncated a, div#cgit div.blob-header a, div#cgit div.commit-msg a, div#cgit div.notes a, -div#cgit div.truncated a, div#cgit table.log th a { text-decoration: underline; } @@ -164,8 +164,8 @@ div#cgit input[type="password"] { } div#cgit select { - appearance: none; -webkit-appearance: none; + appearance: none; padding-right: 1.7em; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23888' stroke-width='1.5'/%3E%3C/svg%3E"); background-repeat: no-repeat; @@ -528,26 +528,19 @@ div#cgit table.list tr.nohover { background: var(--bg); } -div#cgit table.list tbody + tbody > tr:first-child > th { - padding-top: calc(1lh + 0.95em); -} - @media (hover: hover) { div#cgit table.list tr:hover { background: var(--row-hover); } - div#cgit table.list tr.nohover:hover { + div#cgit table.list tr.nohover:hover, + div#cgit table.list tr.nohover-highlight:hover:nth-child(odd) { background: var(--bg); } div#cgit table.list tr.nohover-highlight:hover:nth-child(even) { background: var(--surface-2); } - - div#cgit table.list tr.nohover-highlight:hover:nth-child(odd) { - background: var(--bg); - } } div#cgit table.list th { @@ -556,6 +549,10 @@ div#cgit table.list th { vertical-align: baseline; } +div#cgit table.list tbody + tbody > tr:first-child > th { + padding-top: calc(1lh + 0.95em); +} + div#cgit table.list td { border: none; padding: 0.3em 0.6em; @@ -860,13 +857,11 @@ div#cgit table.ssdiff td.lineno a:hover { color: var(--fg); } -div#cgit table.blob td.linenumbers a, -div#cgit table.blame td.linenumbers a { +div#cgit table.blob td.linenumbers a { scroll-margin-top: calc(50vh - 1em); } -div#cgit table.blob td.linenumbers a:target, -div#cgit table.blame td.linenumbers a:target { +div#cgit table.blob td.linenumbers a:target { background: var(--line-hl); color: var(--fg); } @@ -903,14 +898,6 @@ div#cgit .hl-func { color: var(--hl-func); } -div#cgit table.blame { - position: relative; - display: block; - min-width: 100%; - max-width: 100%; - overflow-x: auto; -} - div#cgit table.blame td.hashes, div#cgit table.blame td.lines, div#cgit table.blame td.linenumbers { @@ -948,8 +935,8 @@ div#cgit table.blame .oid { font-size: 1em; } -div#cgit table.bin-blob { - margin-top: 0.5em; +div#cgit table.bin-blob, +div#cgit table.stats { border: solid 1px var(--border-mid); display: block; width: max-content; @@ -957,6 +944,10 @@ div#cgit table.bin-blob { overflow-x: auto; } +div#cgit table.bin-blob { + margin-top: 0.5em; +} + div#cgit table.bin-blob th { font-family: var(--font-mono); white-space: pre; @@ -975,14 +966,16 @@ div#cgit table.commit-info { margin-top: 1.5em; } -div#cgit table.commit-info th { +div#cgit table.commit-info th, +div#cgit table.commit-trailers th { text-align: left; font-weight: normal; padding: 0.1em 1em 0.1em 0.1em; vertical-align: top; } -div#cgit table.commit-info td { +div#cgit table.commit-info td, +div#cgit table.commit-trailers td { font-weight: normal; padding: 0.1em 1em 0.1em 0.1em; } @@ -1000,7 +993,8 @@ div#cgit div.commit-subject { overflow-wrap: anywhere; } -div#cgit div.commit-msg { +div#cgit div.commit-msg, +div#cgit div.notes { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--font-mono); @@ -1011,27 +1005,20 @@ div#cgit table.commit-trailers { } div#cgit table.commit-trailers th { - text-align: left; - font-weight: normal; - padding: 0.1em 1em 0.1em 0.1em; - vertical-align: top; white-space: nowrap; } div#cgit table.commit-trailers td { - padding: 0.1em 1em 0.1em 0.1em; overflow-wrap: anywhere; } -div#cgit div.notes-header { +div#cgit div.notes-header, +div#cgit div.diffstat-header { font-weight: bold; padding-top: 1.5em; } div#cgit div.notes { - white-space: pre-wrap; - overflow-wrap: anywhere; - font-family: var(--font-mono); border: solid 1px var(--notes-border); background-color: var(--notes-bg); padding: 0.3em 2em 0.3em 1em; @@ -1072,11 +1059,6 @@ div#cgit div.cgit-panel input[type="submit"] { grid-column: 2; } -div#cgit div.diffstat-header { - font-weight: bold; - padding-top: 1.5em; -} - div#cgit table.diffstat { border: solid 1px var(--border-mid); border-collapse: separate; @@ -1086,13 +1068,6 @@ div#cgit table.diffstat { max-width: 100%; } -div#cgit table.diffstat th { - font-weight: normal; - text-align: left; - text-decoration: underline; - padding: 0.1em 1em 0.1em 0.1em; -} - div#cgit table.diffstat td { padding: 0.2em 0.2em 0.1em 0.1em; border: none; @@ -1253,16 +1228,15 @@ div#cgit table.ssdiff td.lineno { min-width: 3em; } -div#cgit table.ssdiff td.hunk { - color: var(--fg); - background: var(--ss-hunk-bg); +div#cgit table.ssdiff td.hunk, +div#cgit table.ssdiff td.head { border-top: solid 1px var(--border-mid); border-bottom: solid 1px var(--border-mid); } -div#cgit table.ssdiff td.head { - border-top: solid 1px var(--border-mid); - border-bottom: solid 1px var(--border-mid); +div#cgit table.ssdiff td.hunk { + color: var(--fg); + background: var(--ss-hunk-bg); } div#cgit table.ssdiff td.head div.head { @@ -1271,10 +1245,8 @@ div#cgit table.ssdiff td.head div.head { } div#cgit table.ssdiff td.foot { + border: none; border-top: solid 1px var(--border-mid); - border-left: none; - border-right: none; - border-bottom: none; } div#cgit table.ssdiff td.space { @@ -1282,14 +1254,6 @@ div#cgit table.ssdiff td.space { height: 3em; } -div#cgit table.stats { - border: solid 1px var(--border-mid); - display: block; - width: max-content; - max-width: 100%; - overflow-x: auto; -} - div#cgit table.stats th { text-align: left; padding: 1px 0.5em; @@ -1318,7 +1282,7 @@ div#cgit table.stats td.left { } div#cgit p.login-error { - color: var(--del); + color: var(--accent); font-weight: bold; } @@ -1338,14 +1302,8 @@ div#cgit form.login input[type="password"] { } @media (max-width: 900px) { - div#cgit .tabs .search { - display: none; - } - - div#cgit #header .branch-switch { - display: none; - } - + div#cgit #header .branch-switch, + div#cgit .tabs .search, div#cgit table.list th.col-author, div#cgit table.list td.col-author { display: none; @@ -1376,46 +1334,52 @@ div#cgit form.login input[type="password"] { } @media (max-width: 640px) { - div#cgit .tabs a { - padding: 0.3em 0.4em; - } - + div#cgit #header .owner, div#cgit .col-desc, div#cgit .col-owner, - div#cgit .col-links { - display: none; - } - + div#cgit .col-links, div#cgit table.log th.col-count, - div#cgit table.log td.col-count { + div#cgit table.log td.col-count, + div#cgit th.ls-mode, + div#cgit td.ls-mode, + div#cgit th.ls-links, + div#cgit td.ls-links, + div#cgit div.cgit-panel, + div#cgit table.diffstat td.mode { display: none; } - div#cgit table.list td { - overflow-wrap: anywhere; + div#cgit #header { + grid-template-columns: auto 1fr; + grid-template-areas: + "logo heading" + "desc desc"; + row-gap: 6px; } - div#cgit th.ls-mode, - div#cgit td.ls-mode, - div#cgit th.ls-links, - div#cgit td.ls-links { - display: none; + div#cgit #header::before { + grid-column: 1 / -1; } - div#cgit td.ls-size { - width: auto; + div#cgit #header h1.main { + white-space: normal; + overflow-wrap: anywhere; } - div#cgit table.diffstat td.mode { - display: none; + div#cgit .tabs a { + padding: 0.3em 0.4em; } div#cgit span.snapshot { white-space: normal; } - div#cgit table.repolist { - width: 100%; + div#cgit table.list td { + overflow-wrap: anywhere; + } + + div#cgit td.ls-size { + width: auto; } div#cgit table.repolist th.col-name, @@ -1431,36 +1395,10 @@ div#cgit form.login input[type="password"] { text-align: right; } - div#cgit #header { - grid-template-columns: auto 1fr; - grid-template-areas: - "logo heading" - "desc desc"; - row-gap: 6px; - } - - div#cgit #header .owner { - display: none; - } - - div#cgit #header::before { - grid-column: 1 / -1; - } - - div#cgit #header h1.main { - white-space: normal; - overflow-wrap: anywhere; - } - - div#cgit div.cgit-panel { - display: none; - } - div#cgit div.notes { float: none; margin-left: 0; } - } @media (forced-colors: active) { diff --git a/assets/cgit.js b/assets/cgit.js index 90f06c0..f62eb06 100644 --- a/assets/cgit.js +++ b/assets/cgit.js @@ -22,19 +22,18 @@ var YEAR = 365 * DAY; var MONTH = YEAR / 12; // The five arrays are indexed together by the bucket an age falls into. -// The search in render_age can stop one place past the last class, so -// age_suffix and age_unit repeat their final entry. Each limit is twice the -// next unit up, matching the thresholds cgit_print_age compares against. +// Each limit is twice the next unit up, matching the thresholds +// cgit_print_age compares against, and an age past the last is in years. var age_classes = [ "age-mins", "age-hours", "age-days", "age-weeks", "age-months", "age-years" ]; -var age_suffix = [ "min.", "hours", "days", "weeks", "months", "years", "years" ]; -var age_unit = [ MINUTE, HOUR, DAY, WEEK, MONTH, YEAR, YEAR ]; -var age_limit = [ 2 * HOUR, 2 * DAY, 2 * WEEK, 2 * MONTH, 2 * YEAR, 2 * YEAR ]; +var age_suffix = [ "min.", "hours", "days", "weeks", "months", "years" ]; +var age_unit = [ MINUTE, HOUR, DAY, WEEK, MONTH, YEAR ]; +var age_limit = [ 2 * HOUR, 2 * DAY, 2 * WEEK, 2 * MONTH, 2 * YEAR ]; var update_delay = [ 10, 5 * MINUTE, 30 * MINUTE, DAY, DAY, DAY ]; function render_age(element, age) { var text, bucket; - for (bucket = 0; bucket < age_classes.length; bucket++) + for (bucket = 0; bucket < age_limit.length; bucket++) if (age < age_limit[bucket]) break; @@ -45,10 +44,6 @@ function render_age(element, age) { // for nothing. if (element.textContent != text) { element.textContent = text; - // An age past the last limit is still counted in years, but there - // is no class beyond age-years to put on it. - if (bucket == age_classes.length) - bucket--; if (element.className != age_classes[bucket]) element.className = age_classes[bucket]; } @@ -63,11 +58,11 @@ function render_age(element, age) { */ function update_ages() { var bucket, elements, i, age; - var delay = 24 * 3600; + var delay = DAY; var now = Math.round(new Date().getTime() / 1000); for (bucket = 0; bucket < age_classes.length; bucket++) { - elements = document.getElementsByClassName(age_classes[bucket]); + elements = document.querySelectorAll("." + age_classes[bucket]); if (elements.length && update_delay[bucket] < delay) delay = update_delay[bucket]; @@ -91,7 +86,7 @@ function refresh_ages() { window.setTimeout(refresh_ages, update_ages() * 1000); } -document.addEventListener("DOMContentLoaded", refresh_ages, false); +document.addEventListener("DOMContentLoaded", refresh_ages); // A site script that adds rows of its own can ask for one pass over them. window.cgit = window.cgit || {}; @@ -153,13 +148,14 @@ function place_bar() { table.appendChild(bar); // A browser only scrolls to a fragment that names a real id, which a range - // never does, so bring the start of one into view here. + // never does, so bring the start of one into view here. The scroll margin + // on the anchors centres it the way it centres a single line. if (match[2]) - first.scrollIntoView({ block: "center" }); + first.scrollIntoView(); } -document.addEventListener("DOMContentLoaded", place_bar, false); -window.addEventListener("hashchange", place_bar, false); +document.addEventListener("DOMContentLoaded", place_bar); +window.addEventListener("hashchange", place_bar); // A site script that moves the lines about can ask for the bar to be laid // again. @@ -176,18 +172,18 @@ window.cgit.highlightLines = place_bar; (function () { -function fade_tabs(list) { - var hidden = list.scrollWidth - list.clientWidth; - - // A fractional scroll position can stop a pixel short of either end. - list.classList.toggle("fade-start", list.scrollLeft > 1); - list.classList.toggle("fade-end", list.scrollLeft < hidden - 1); -} - function fit_tabs() { var list = document.querySelector("nav.tabs ul"); var active, list_box, tab_box; + function fade_tabs() { + var hidden = list.scrollWidth - list.clientWidth; + + // A fractional scroll position can stop a pixel short of either end. + list.classList.toggle("fade-start", list.scrollLeft > 1); + list.classList.toggle("fade-end", list.scrollLeft < hidden - 1); + } + if (!list) return; active = list.querySelector("a.active"); @@ -196,11 +192,11 @@ function fit_tabs() { tab_box = active.getBoundingClientRect(); list.scrollLeft += tab_box.left - list_box.left - (list_box.width - tab_box.width) / 2; } - fade_tabs(list); - list.addEventListener("scroll", function () { fade_tabs(list); }, { passive: true }); - window.addEventListener("resize", function () { fade_tabs(list); }, false); + fade_tabs(); + list.addEventListener("scroll", fade_tabs, { passive: true }); + window.addEventListener("resize", fade_tabs); } -document.addEventListener("DOMContentLoaded", fit_tabs, false); +document.addEventListener("DOMContentLoaded", fit_tabs); })(); diff --git a/custom/cgitrc b/custom/cgitrc index 86f00aa..c1a5b68 100644 --- a/custom/cgitrc +++ b/custom/cgitrc @@ -10,10 +10,6 @@ # list repositories by hand in the per-repository section at the end of this # file. # -# About pages (markdown, man and plain-text readmes) can render through the -# bundled about-render.lua about-filter in the filter section below. Source -# syntax highlighting is optional and ships as a source-filter there too. -# # One key=value pair per line. Lines starting with # or ; are comments. # Maximum number of entries in the cgit output cache. A value of 0 disables @@ -23,36 +19,33 @@ cache-size=0 # Directory used to store cgit cache entries. Value is a filesystem path that # may use macros. Default is /var/cache/cgit. The directory must be owned by the # account the web server runs cgit as, with mode 0700, since the cache holds -# every rendered page and the URLs visitors asked for. +# every rendered page and the urls visitors asked for. cache-root=/var/cache/cgit -# Minutes to cache the repository index page. Value is an integer number of -# minutes. Default is 5. +# Minutes to cache the repository index page. Value is an integer. Default is 5. cache-index-ttl=5 -# Minutes to cache the repository summary page. Value is an integer number of -# minutes. Default is 5. +# Minutes to cache the repository summary page. Value is an integer. Default is +# 5. cache-summary-ttl=5 # Minutes to cache the result of scanning a path for repositories. Value is an -# integer number of minutes. Default is 15. +# integer. Default is 15. cache-scan-ttl=15 # Minutes to cache repository pages requested with a fixed object id. A negative -# value keeps the page forever. Value is an integer number of minutes. Default -# is -1. +# value keeps the page forever. Value is an integer. Default is -1. cache-static-ttl=-1 # Minutes to cache repository pages requested without a fixed object id. Value -# is an integer number of minutes. Default is 5. +# is an integer. Default is 5. cache-dynamic-ttl=5 -# Minutes to cache the repository about page. Value is an integer number of -# minutes. Default is 15. +# Minutes to cache the repository about page. Value is an integer. Default is +# 15. cache-about-ttl=15 -# Minutes to cache snapshots. Value is an integer number of minutes. Default is -# 5. +# Minutes to cache snapshots. Value is an integer. Default is 5. cache-snapshot-ttl=5 # Largest response, in kilobytes, that a cache slot keeps. A larger one is @@ -82,12 +75,12 @@ root-desc=a fast webinterface for the git dscm #readme=:README.md # CSS document urls added to the head of every page. An empty value disables it. -# Value is one or more urls. Default is /cgit.css. +# Value is a url, and each css line adds one. Default is /cgit.css. css=/cgit.css # JavaScript document urls included on every page, loaded with defer in the -# order given. An empty value disables it. Value is one or more urls. Default -# is /cgit.js. +# order given. An empty value disables it. Value is a url, and each js line +# adds one. Default is /cgit.js. js=/cgit.js # Shortcut icon url for all pages. Value is a url. Default is /favicon.ico. @@ -124,8 +117,8 @@ enable-header=1 # or 1. Default is 0. embedded=0 -# Root url for all cgit links, enabling virtual urls. Value is a url path. -# Default is none. +# Root url for all cgit links. Value is a url path. Default is SCRIPT_NAME +# followed by a slash. #virtual-root=/ # Link format string for submodules in directory listings. Value is a format @@ -133,24 +126,21 @@ embedded=0 # Default is none. #module-link=/%s/commit/?id=%s -# Directory scanned for git repositories at parse time. Value is a filesystem -# path that may use macros. Default is none. -#scan-path=/var/lib/git - # When scanning, recurse into dot-directories. Must precede scan-path. Values # are 0 or 1. Default is 0. scan-hidden-path=0 -# Derive default section names from N leading path elements. Must precede -# scan-path. Value is an integer that may be negative. Default is 0. +# Derive default section names from the first N path elements, or from all +# but the last -N when N is negative. Must precede scan-path. Value is an +# integer. Default is 0. section-from-path=0 # Strip the .git suffix from a scanned repository's url and name. Must precede # scan-path. Values are 0 or 1. Default is 0. remove-suffix=0 -# File listing subdirs of scan-path to load. Must precede scan-path. Value is a -# filesystem path that may use macros. Default is none. +# File listing subdirectories of scan-path to load. Must precede scan-path. +# Value is a filesystem path that may use macros. Default is none. #project-list=/var/lib/git/projects.list # Serve a repository only if this file exists inside it. Emulates gitweb @@ -233,18 +223,17 @@ enable-remote-branches=0 # 1. Default is 0. enable-subject-links=0 -# Allow /plain to serve HTML mimetypes instead of downgrading to text/plain. -# Values are 0 or 1. Default is 0. +# Allow /plain to serve HTML mimetypes instead of downgrading to text/plain or +# application/octet-stream. Values are 0 or 1. Default is 0. enable-html-serving=0 # Generate line-number links for plaintext blobs in the tree and blame pages. # Values are 0 or 1. Default is 1. enable-tree-linenumbers=1 -# Name the cgit and git versions in the page footer, the generator tag and -# the signature of patches. Exact versions help someone match a site against -# advisories, so they stay out unless asked for. Values are 0 or 1. Default -# is 0. +# Name the cgit version in the page footer, the generator tag and patch +# signatures, and the git version in the footer. Values are 0 or 1. +# Default is 0. enable-version-info=0 # List directories first, then files, in the tree page. Values are 0 or 1. @@ -281,44 +270,38 @@ enable-http-clone=1 # repository ships under custom/extensions/. The ones written as # /path/to/your-command mark where your own command goes. -# Honour every setting in a repository's own cgitrc and git config found by -# scan-path. Those files belong to whoever can push, so without this the -# settings that run a command, put raw markup on the page, serve a file as -# markup, place a link or read a file off the disk are ignored. The repo.* lines -# below never need it. Values are 0 or 1. Default is 0. +# Honour every setting in a scanned repository's own cgitrc and git config, +# including those that run a command or put markup on the page. Those files +# belong to whoever can push. The repo.* lines below never need it. Values are +# 0 or 1. Default is 0. trust-scan-config=0 # Filter command used to format about-page content. The bundled about-render.lua -# renders markdown, man pages and plain text. Value is a command optionally -# prefixed with exec or lua. Default is none. +# renders markdown, man pages and plain text. Value is a command. Default is +# none. #about-filter=lua:/usr/local/lib/cgit/filters/about-render.lua # Filter command used to format commit messages, for example to turn object -# names and issue numbers into links. Value is a command optionally prefixed -# with exec or lua. Default is none. +# names and issue numbers into links. Value is a command. Default is none. #commit-filter=lua:/usr/local/lib/cgit/filters/link-commits.lua # Filter command used to format author and committer emails, for example to add -# avatar images. Value is a command optionally prefixed with exec or lua. -# Default is none. +# avatar images. Value is a command. Default is none. #email-filter=lua:/usr/local/lib/cgit/filters/email-gravatar.lua # Filter command used to format the value of a commit trailer when # enable-commit-trailers is 1. The shipped filter links Fixes, Reverts, Closes, -# Bug and URL values. Value is a command optionally prefixed with exec or lua. -# Default is none. +# Bug and URL values. Value is a command. Default is none. #trailer-filter=lua:/usr/local/lib/cgit/filters/link-trailers.lua # Filter command used to format plaintext blobs in the tree and blame pages. The -# shipped filter uses the Scintillua lexers and needs lpeg, and missing either -# means plain uncolored text, not an error. Value is a command optionally -# prefixed with exec or lua. Default is none. +# comments in custom/extensions/syntax-highlight.lua list what the shipped +# filter needs. Value is a command. Default is none. #source-filter=lua:/usr/local/lib/cgit/filters/syntax-highlight.lua # Filter command invoked to authenticate access, gating repositories behind a # login. See custom/extensions/auth-inline.lua and -# custom/extensions/auth-file.lua. Value is a command optionally prefixed with -# exec or lua. Default is none. +# custom/extensions/auth-file.lua. Value is a command. Default is none. #auth-filter=lua:/usr/local/lib/cgit/filters/auth-inline.lua # Number of items to display in atom feeds. Value is an integer. Default is 10. @@ -332,7 +315,7 @@ max-message-length=80 # integer. Default is 80. max-repodesc-length=80 -# Max blob size in KBytes read into memory for html, plain and blob output. A +# Max blob size in KBytes read into memory for HTML, plain and blob output. A # value of 0 disables the limit. Value is an integer. Default is 10240. max-blob-size=10240 @@ -361,8 +344,7 @@ max-diff-lines=1000 # Branches and tags listed per section on the refs page, and per page on the # dedicated branch and tag pages. A value of 0 or less lists everything on one -# page. -# Value is an integer. Default is 200. +# page. Value is an integer. Default is 200. max-ref-count=200 # Max files considered when detecting renames. A value of -1 uses the git @@ -426,6 +408,11 @@ age-file=info/web/last-modified #mimetype.png=image/png #mimetype.svg=image/svg+xml +# Directory scanned for git repositories at parse time. Each repository found +# inherits the global settings above this line, so it comes after them all. +# Value is a filesystem path that may use macros. Default is none. +#scan-path=/var/lib/git + # Each repository is introduced by a repo.url line. All following repo.* # settings apply to that repository until the next repo.url line. When # repositories are discovered via scan-path the repo. prefix is dropped inside @@ -462,7 +449,7 @@ age-file=info/web/last-modified #repo.section=main # Prefix for snapshot filenames instead of the repository basename. Value is a -# prefix string. Default is none. +# prefix string. Default is the repository basename. #repo.snapshot-prefix=example # Extra content added verbatim to the head of each page for this repository. diff --git a/custom/extensions/about-render.lua b/custom/extensions/about-render.lua index 808dd08..5738b00 100644 --- a/custom/extensions/about-render.lua +++ b/custom/extensions/about-render.lua @@ -3,8 +3,9 @@ -- interpreter so a readme costs no extra process per request. Markdown, man -- pages and plain text are the three formats, chosen from the readme's file -- extension, and anything that fails to parse falls back to escaped plain --- text. The wrappers it emits carry the classes that assets/cgit.css styles. --- It runs on Lua 5.1 through 5.5 and LuaJIT. +-- text. Markdown and man pages come out in div.markdown and plain text in +-- pre.plaintext, the two wrappers assets/cgit.css styles. It runs on Lua 5.1 +-- through 5.5 and LuaJIT. -- -- about-filter=lua:/usr/local/lib/cgit/filters/about-render.lua @@ -190,9 +191,7 @@ end -- first. html() only ever gets literal tag scaffolding and the character -- references whose shape the markdown grammar checked. -local emit_inline - -emit_inline = function(list) +local function emit_inline(list) for _, node in ipairs(list) do local kind = node.kind if kind == "text" then @@ -271,9 +270,7 @@ local function emit_lines(lines) end end -local emit_blocks - -emit_blocks = function(blocks) +local function emit_blocks(blocks) for _, block in ipairs(blocks) do local kind = block.kind if kind == "heading" then @@ -285,13 +282,7 @@ emit_blocks = function(blocks) elseif kind == "hr" then html("<hr>") elseif kind == "code" then - html("<pre><code") - if block.lang and block.lang ~= "" then - html(" data-lang='") - html_attr(block.lang) - html("'") - end - html(">") + html("<pre><code>") html_txt(block.text) html("</code></pre>") elseif kind == "blockquote" then @@ -348,10 +339,23 @@ end -- Both parse into a node tree before anything is emitted, so a failed parse -- falls back to plain text without leaving half a page behind. +local function render_blocks(text, parse) + local ok, blocks = pcall(parse, text) + if not ok then + return render_plaintext(text) + end + html("<div class='markdown'>") + -- Emitting is wrapped so a bug there can at worst truncate the page, + -- never end the request in an error. + pcall(emit_blocks, blocks) + html("</div>") +end + local render_markdown, render_man if has_lpeg then - local P, R, S, B, C, Ct = lpeg.P, lpeg.R, lpeg.S, lpeg.B, lpeg.C, lpeg.Ct + local P, R, S, B = lpeg.P, lpeg.R, lpeg.S, lpeg.B + local C, Cc, Ct = lpeg.C, lpeg.Cc, lpeg.Ct local space = S(" \t") local whitespace = S(" \t\r\n\f\v") @@ -447,28 +451,27 @@ if has_lpeg then return { node_text(s) } end inline_depth = inline_depth + 1 - local nodes = inline_grammar:match(s) or { node_text(s) } + local nodes = inline_grammar:match(s) inline_depth = inline_depth - 1 return nodes end - local lang_char = R("az", "AZ", "09") + S("_.+#-") local heading_line = C(P("#") * P("#") ^ -5) * space ^ 1 * C(P(1) ^ 0) -- A run of equals signs or dashes under a paragraph turns it into a -- heading, as on GitHub, rather than reading as more of its text. - local setext_line = space ^ 0 * (C(P("=") ^ 1) + C(P("-") ^ 1)) + local setext_line = space ^ 0 * (P("=") ^ 1 * Cc(1) + P("-") ^ 1 * Cc(2)) * space ^ 0 * eol local code_indent = P(" ") + P("\t") local function thematic(mark) return space ^ 0 * P(mark) * (space ^ 0 * P(mark)) ^ 2 * space ^ 0 * eol end local break_line = thematic("-") + thematic("*") + thematic("_") - local fence_line = space ^ 0 * (C(P("`") ^ 3) + C(P("~") ^ 3)) * space ^ 0 * C(lang_char ^ 0) + local fence_line = space ^ 0 * (C(P("`") ^ 3) + C(P("~") ^ 3)) local close_backtick = space ^ 0 * P("`") ^ 3 * space ^ 0 * eol local close_tilde = space ^ 0 * P("~") ^ 3 * space ^ 0 * eol local quote_line = space ^ 0 * P(">") local bullet = S("-*+") - local number = R("09") ^ 1 * S(".)") + local number = digit ^ 1 * S(".)") local item_line = space ^ 0 * C(bullet + number) * space ^ 1 * C(P(1) ^ 0) local dash_cell = space ^ 0 * P(":") ^ -1 * P("-") ^ 1 * P(":") ^ -1 * space ^ 0 -- A delimiter row is dash-cells joined by pipes. Require at least one @@ -480,16 +483,10 @@ if has_lpeg then pipe * dash_cell * (pipe * dash_cell) ^ 0 * pipe ^ -1 + dash_cell * (pipe * dash_cell) ^ 1 * pipe ^ -1 ) * space ^ 0 * eol - local block_start = space ^ 0 * ((P("#") * P("#") ^ -5 * space) + P(">") - + P("`") ^ 3 + P("~") ^ 3 + ((bullet + number) * space)) + local block_start = space ^ 0 * heading_line + quote_line + fence_line + item_line local function ordered(mark) return mark:match("%d") ~= nil end - local function follows_list(blocks) - local last = blocks[#blocks] - return last ~= nil and last.kind == "list" - end - -- An indented block runs on through blank lines, and up to four spaces -- or one tab come off each of its lines. local function continues_code(line) @@ -500,13 +497,6 @@ if has_lpeg then return line:match("^\t(.*)$") or line:match("^ ? ? ? ?(.*)$") end - local function setext_level(line) - local run = setext_line:match(line) - if run then - return run:sub(1, 1) == "=" and 1 or 2 - end - end - local function table_starts(lines, i) return i < #lines and lines[i]:find("|", 1, true) ~= nil and table_delimiter:match(lines[i + 1]) ~= nil @@ -517,7 +507,15 @@ if has_lpeg then -- paragraph short. local function ends_paragraph(lines, i) return lines[i]:match("^%s*$") or block_start:match(lines[i]) - or table_starts(lines, i) + or break_line:match(lines[i]) or table_starts(lines, i) + end + + local function code_block(lines) + local text = table.concat(lines, "\n") + if #lines > 0 then + text = text .. "\n" + end + return { kind = "code", text = text } end -- A paragraph's lines run together the way GitHub shows them, unless one @@ -542,13 +540,11 @@ if has_lpeg then return lines end - local parse_blocks - - parse_blocks = function(lines, depth) + local function parse_blocks(lines, depth) local blocks, i, n = {}, 1, #lines while i <= n do local line = lines[i] - local fence, lang = fence_line:match(line) + local fence = fence_line:match(line) local hashes, title = heading_line:match(line) local mark = item_line:match(line) if line:match("^%s*$") then @@ -562,15 +558,7 @@ if has_lpeg then i = i + 1 end i = i + 1 - local body = table.concat(code, "\n") - if #code > 0 then - body = body .. "\n" - end - blocks[#blocks + 1] = { - kind = "code", - lang = lang, - text = body, - } + blocks[#blocks + 1] = code_block(code) elseif hashes then blocks[#blocks + 1] = { kind = "heading", @@ -628,10 +616,8 @@ if has_lpeg then end local item = { content } i = i + 1 - -- A wrapped line carries the item on, whatever its - -- indent, while a rule still ends the list. - while i <= n and not ends_paragraph(lines, i) - and not break_line:match(lines[i]) do + -- A wrapped line carries the item on, whatever its indent. + while i <= n and not ends_paragraph(lines, i) do item[#item + 1] = lines[i] i = i + 1 end @@ -645,7 +631,8 @@ if has_lpeg then -- Four spaces or a tab start a code block, except straight -- after a list, where an indented paragraph more likely -- carries an item on. - elseif code_indent:match(line) and not follows_list(blocks) then + elseif code_indent:match(line) + and (blocks[#blocks] or {}).kind ~= "list" then local code = {} while i <= n and continues_code(lines[i]) do code[#code + 1] = unindent(lines[i]) @@ -654,17 +641,13 @@ if has_lpeg then while code[#code]:match("^%s*$") do code[#code] = nil end - blocks[#blocks + 1] = { - kind = "code", - text = table.concat(code, "\n") .. "\n", - } + blocks[#blocks + 1] = code_block(code) else local paragraph, level = { line }, nil i = i + 1 while i <= n do - level = setext_level(lines[i]) - if level or ends_paragraph(lines, i) - or break_line:match(lines[i]) then + level = setext_line:match(lines[i]) + if level or ends_paragraph(lines, i) then break end paragraph[#paragraph + 1] = lines[i] @@ -693,19 +676,9 @@ if has_lpeg then end render_markdown = function(text) - if #text > max_bytes then - return render_plaintext(text) - end - local ok, blocks = pcall(parse_blocks, split_lines(text), 0) - if not ok then - return render_plaintext(text) - end - html("<div class='markdown'>") - -- Emitting is wrapped so a bug there can at worst - -- truncate the page, never raise and let the outer - -- fallback duplicate what has already been sent. - pcall(emit_blocks, blocks) - html("</div>") + render_blocks(text, function(t) + return parse_blocks(split_lines(t), 0) + end) end -- Roff fonts are a current state carried across a run rather than @@ -733,15 +706,8 @@ if has_lpeg then } local backslash = P("\\") - local function text_token(text) return { kind = "text", text = text } end - local skip_token = { kind = "skip" } - local function skip() return skip_token end local function font_token(name) - return { kind = "font", font = fonts[name] or "R" } - end - local function char_token(name) return text_token(man_chars[name] or "") end - local function string_token(name) - return text_token(man_strings[name] or "") + return { font = fonts[name] or "R" } end -- \( takes a two character name and \[ a longer one, bounded so that a @@ -754,50 +720,43 @@ if has_lpeg then -- the escapes that only steer hyphenation or breaks. local man_inline_grammar = Ct(( (backslash * P("f") * (named + C(P(1)))) / font_token - + (backslash * S('"#') * P(1) ^ 0) / skip - + (backslash * P("-")) / function() return text_token("-") end - + (backslash * P("e")) / function() return text_token("\\") end - + (backslash * S(" 0~")) / function() return text_token(" ") end - + (backslash * S("&|^%:cud{}")) / skip - + (backslash * named) / char_token - + (backslash * P("*") * (named + C(P(1)))) / string_token - + (backslash * P("s") * S("+-") ^ -1 * (named + R("09") * R("09") ^ -1)) / skip - + (backslash * S("mM") * (named + P(1))) / skip - + (backslash * C(P(1))) / text_token - + backslash / skip - + C((1 - backslash) ^ 1) / text_token + + backslash * S('"#') * P(1) ^ 0 + + (backslash * P("-")) / "-" + + (backslash * P("e")) / "\\" + + (backslash * S(" 0~")) / " " + + backslash * S("&|^%:cud{}") + + (backslash * named) / man_chars + + (backslash * P("*") * (named + C(P(1)))) / man_strings + + (backslash * P("s") * S("+-") ^ -1 * (named + digit * digit ^ -1)) / 0 + + (backslash * S("mM") * (named + P(1))) / 0 + + backslash * C(P(1)) + + backslash + + C((1 - backslash) ^ 1) ) ^ 0) + local function in_font(font, nodes) + if font == "B" then + return { { kind = "strong", kids = nodes } } + elseif font == "I" then + return { { kind = "em", kids = nodes } } + end + return nodes + end + local function man_inline(text) - local tokens = man_inline_grammar:match(text) or {} + local tokens = man_inline_grammar:match(text) local nodes, parts, font = {}, {}, "R" local function flush() - if #parts == 0 then - return - end local run = table.concat(parts) parts = {} - if run == "" then - return - end - if font == "B" then - nodes[#nodes + 1] = { - kind = "strong", - kids = { { kind = "text", text = run } }, - } - elseif font == "I" then - nodes[#nodes + 1] = { - kind = "em", - kids = { { kind = "text", text = run } }, - } - else - nodes[#nodes + 1] = { kind = "text", text = run } + if run ~= "" then + nodes[#nodes + 1] = in_font(font, { node_text(run) })[1] end end for _, token in ipairs(tokens) do - if token.kind == "text" then - parts[#parts + 1] = token.text - elseif token.kind == "font" then + if type(token) == "string" then + parts[#parts + 1] = token + else flush() font = token.font end @@ -809,13 +768,7 @@ if has_lpeg then -- A no fill block keeps its lines as they are, so only the text of -- each one is wanted there, without its fonts. local function man_plain(text) - local parts = {} - for _, token in ipairs(man_inline_grammar:match(text) or {}) do - if token.kind == "text" then - parts[#parts + 1] = token.text - end - end - return table.concat(parts) + return table.concat(plain_text(man_inline(text), {})) end -- A macro's arguments divide at blanks, except inside double quotes. @@ -838,15 +791,6 @@ if has_lpeg then end end - local function in_font(font, nodes) - if font == "B" then - return { { kind = "strong", kids = nodes } } - elseif font == "I" then - return { { kind = "em", kids = nodes } } - end - return nodes - end - -- Each alternating macro sets its arguments in its two fonts by turns -- with nothing between them, as in .BR ls (1). local single_font = { B = "B", I = "I" } @@ -883,11 +827,7 @@ if has_lpeg then end local function flush_nofill() if nofill_lines then - local body = table.concat(nofill_lines, "\n") - if #nofill_lines > 0 then - body = body .. "\n" - end - blocks[#blocks + 1] = { kind = "code", text = body } + blocks[#blocks + 1] = code_block(nofill_lines) nofill_lines = nil end end @@ -1000,22 +940,8 @@ if has_lpeg then end render_man = function(text) - if #text > max_bytes then - return render_plaintext(text) - end - local ok, blocks = pcall(parse_man, text) - if not ok then - return render_plaintext(text) - end - html("<div class='markdown manpage'>") - pcall(emit_blocks, blocks) - html("</div>") + render_blocks(text, parse_man) end -else - -- No lpeg, so markdown and man readmes read as escaped source, not - -- nothing. - render_markdown = render_plaintext - render_man = render_plaintext end @@ -1043,32 +969,13 @@ end -- cgit takes filter output through a C string sink that stops at the first -- NUL byte, so each one becomes U+FFFD, as a browser would show it, and the --- text past it survives. A plain find works on Lua 5.1, where a NUL cannot --- sit in a pattern. -local function replace_nul(text) - if not text:find("\0", 1, true) then - return text - end - local parts, start = {}, 1 - while true do - local at = text:find("\0", start, true) - if not at then - parts[#parts + 1] = text:sub(start) - return table.concat(parts, "\239\191\189") - end - parts[#parts + 1] = text:sub(start, at - 1) - start = at + 1 - end -end - +-- text past it survives. The class names NUL as the one byte outside \1 to +-- \255, since on Lua 5.1 a zero byte in a pattern ends it. function filter_close() - local text = replace_nul(table.concat(chunks)) + local text = table.concat(chunks):gsub("[^\1-\255]", "\239\191\189") chunks = {} local ext = (filename:match("%.([^.]+)$") or ""):lower() - local render = handlers[ext] or render_plaintext - local ok = pcall(render, text) - if not ok then - render_plaintext(text) - end + local render = #text <= max_bytes and handlers[ext] or render_plaintext + render(text) return 0 end diff --git a/custom/extensions/auth-file.lua b/custom/extensions/auth-file.lua index a03e1ea..ab56ee1 100644 --- a/custom/extensions/auth-file.lua +++ b/custom/extensions/auth-file.lua @@ -53,7 +53,6 @@ -- still lists a protected repository with its description unless cgitrc sets -- repo.hide=1 for it. --- The values that follow are the configuration and are meant to be edited. -- Accounts live one per line as username:hash, where the hash is any kind this -- system's crypt(3) checks. yescrypt ($y$) is what libxcrypt recommends, as on @@ -79,8 +78,8 @@ local groups_filename = "/etc/cgit-auth/groups" -- Per-repository access lives one per line as reponame:group1,group2 and so -- on. A repository named here is protected and one that is not named is -- public. The repository name has to match exactly, while group and user names --- match regardless of case. While this file cannot be read every repository --- is refused, since nothing then tells the protected ones apart. +-- match regardless of case. While this file cannot be read every page, the +-- index too, is refused, since nothing then tells the protected ones apart. local repos_filename = "/etc/cgit-auth/repos" -- Where the cookie-signing secret is stored, created on first use. It must be @@ -105,6 +104,7 @@ local cookie_insecure = false -- the password's length, and one longer than this is turned down unchecked. local max_password_bytes = 256 + -- A browser accepts a cookie named __Host- only when it is Secure, covers the -- whole host and was set by this host, so no sibling subdomain can plant a -- session in its place. Either cookie setting above can rule that out. @@ -113,12 +113,18 @@ if not cookie_insecure and cookie_path == "/" then session_cookie = "__Host-" .. cookie_name end +-- A cookie carries its expiry and Max-Age as whole seconds, while on Lua 5.3 +-- and later a length worked out with a fraction, such as 1.5 days, is a float +-- that tostring would write with a trailing ".0". +session_seconds = math.floor(session_seconds) + -- One request reaches this script as an open, a write and a close, so what the -- open decodes is kept here for the calls that follow. local action, http, cgit, post --- The two lookups below, account_hash and repo_userset, are the only part --- of this script that differs from auth-inline.lua. +-- Apart from the configuration, the lookups below, account_hash and +-- repo_userset with their helpers, are the only code that differs from +-- auth-inline.lua, which defines trim later, beside get_cookie. -- Lua patterns backtrack, so "^%s*(.-)%s*$" costs the square of a run of -- spaces inside the text. Finding each end directly stays linear, which @@ -152,9 +158,6 @@ end -- a failed login can spend the same work on a hash of the same kind and -- cost, and its timing does not tell whether the name exists. function account_hash(user) - if user == nil then - return nil - end local wanted = user:lower() local users_file = io.open(users_filename, "r") if users_file == nil then @@ -180,9 +183,6 @@ end -- repository whose groups resolve to nobody comes back as an empty table -- instead, so it denies everyone rather than falling through to public. function repo_userset(repo) - if repo == nil then - return nil - end local groups = nil local repos_file, err = io.open(repos_filename, "r") if repos_file == nil then @@ -215,25 +215,17 @@ function repo_userset(repo) return users end --- The URL helpers below are adapted from keplerproject/wsapi. +-- The url helpers below are adapted from keplerproject/wsapi. function url_decode(str) - if not str then - return "" - end str = string.gsub(str, "+", " ") str = string.gsub(str, "%%(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end) - str = string.gsub(str, "\r\n", "\n") return str end function url_encode(str) - if not str then - return "" - end - str = string.gsub(str, "\n", "\r\n") str = string.gsub(str, "([^%w ])", function(char) return string.format("%%%02X", string.byte(char)) end) @@ -245,13 +237,13 @@ end -- '=', for example a base64 password, so the value runs to the next '&'. function parse_query(query) local params = {} - for key, value in string.gmatch(query or "", "([^&=]+)=([^&]*)") do + for key, value in string.gmatch(query, "([^&=]+)=([^&]*)") do params[url_decode(key)] = url_decode(value) end return params end --- The token comes back still URL encoded by secure_value. Decoding it here +-- The token comes back still url encoded by secure_value. Decoding it here -- would break the signature check for any value carrying a percent escape. function get_cookie(cookies, name) for raw in string.gmatch(cookies or "", "[^;]+") do @@ -350,46 +342,9 @@ end -- or to nil to turn it down, which is how a session meets its account's -- current hash. function validate_value(expected_field, cookie, bind) - local i = 0 - local value = "" - local field = "" - local expiration = 0 - local salt = "" - local signature = "" - - if cookie == nil or cookie:len() < 3 or cookie:sub(1, 1) == "|" then - return nil - end - - for component in string.gmatch(cookie, "[^|]+") do - if i == 0 then - field = component - elseif i == 1 then - value = component - elseif i == 2 then - -- The expiration must be a plain integer, since - -- tonumber and tostring of "1e9" or "100.0" differ - -- across Lua versions and the signed bytes have to - -- come back byte for byte. - if not string.match(component, "^%d+$") then - return nil - end - expiration = tonumber(component) - elseif i == 3 then - salt = component - elseif i == 4 then - signature = component - else - break - end - i = i + 1 - end - - if signature == nil or signature:len() == 0 then - return nil - end - - if url_decode(field) ~= expected_field then + local field, value, expiration, salt, signature = + string.match(cookie or "", "^([^|]+)|([^|]+)|(%d+)|([^|]+)|([^|]+)$") + if signature == nil or url_decode(field) ~= expected_field then return nil end @@ -408,12 +363,13 @@ function validate_value(expected_field, cookie, bind) end end - local payload = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt + local payload = field .. "|" .. value .. "|" .. expiration .. "|" .. salt if not constant_equals(signature, sign(payload, binding)) then return nil end -- An expiration of 0 never expires and is used for the redirect token. + expiration = tonumber(expiration) if expiration ~= 0 and expiration <= os.time() then return nil end @@ -435,12 +391,6 @@ function secure_value(field, value, expiration, binding) return payload .. "|" .. sign(payload, binding) end --- A control character in a header value would let that value split the --- response, so it is dropped rather than escaped. -function strip_controls(s) - return (string.gsub(s or "", "%c", "")) -end - -- Compare two strings in time that does not depend on how many leading bytes -- match, so a mismatch position is not revealed by timing. function constant_equals(a, b) @@ -456,7 +406,8 @@ function constant_equals(a, b) end -- An empty value is how a login is cleared, since Max-Age=0 tells the browser --- to drop the cookie it already holds. +-- to drop the cookie it already holds. Any other value is a token secure_value +-- built, which holds no control character to split the response with. function set_cookie(cookie, value) local attributes = "; HttpOnly; SameSite=Lax; Path=" .. cookie_path if not cookie_insecure then @@ -464,16 +415,18 @@ function set_cookie(cookie, value) end if value == "" then attributes = attributes .. "; Max-Age=0" - elseif session_seconds > 0 then + else attributes = attributes .. "; Max-Age=" .. tostring(session_seconds) end - html("Set-Cookie: " .. cookie .. "=" .. strip_controls(value) .. attributes .. "\n") + html("Set-Cookie: " .. cookie .. "=" .. value .. attributes .. "\n") end +-- A control character in the location would let it split the response, so +-- it is dropped rather than escaped. function redirect_to(url) html("Status: 302 Found\n") html("Cache-Control: no-cache, no-store\n") - html("Location: " .. strip_controls(url) .. "\n") + html("Location: " .. url:gsub("%c", "") .. "\n") end -- A failed login lands back on the page it came from with this flag, which @@ -501,7 +454,7 @@ end function authenticate_post() local redirect = validate_value("redirect", post["redirect"]) - if redirect == nil or not is_safe_redirect(redirect) then + if not is_safe_redirect(redirect) then not_found() return 0 end @@ -581,33 +534,17 @@ function body() return 0 end --- filter_open unpacks the action and request fields cgit passes in fixed --- order, filter_write collects a post body, and filter_close runs the action. - local actions = {} actions["authenticate-post"] = authenticate_post actions["authenticate-cookie"] = authenticate_cookie actions["body"] = body -function filter_open(...) - action = actions[select(1, ...)] - +function filter_open(name, cookie, method, query, referer, path, host, https, + repo, page, url, login) + action = actions[name] post = {} - - http = {} - http["cookie"] = select(2, ...) - http["method"] = select(3, ...) - http["query"] = select(4, ...) - http["referer"] = select(5, ...) - http["path"] = select(6, ...) - http["host"] = select(7, ...) - http["https"] = select(8, ...) - - cgit = {} - cgit["repo"] = select(9, ...) - cgit["page"] = select(10, ...) - cgit["url"] = select(11, ...) - cgit["login"] = select(12, ...) + http = { cookie = cookie } + cgit = { repo = repo, url = url, login = login } end function filter_close() diff --git a/custom/extensions/auth-inline.lua b/custom/extensions/auth-inline.lua index c4b87f6..e795717 100644 --- a/custom/extensions/auth-inline.lua +++ b/custom/extensions/auth-inline.lua @@ -53,7 +53,6 @@ -- still lists a protected repository with its description unless cgitrc sets -- repo.hide=1 for it. --- The values that follow are the configuration and are meant to be edited. -- Protected repositories and the users allowed into each. A repository named -- here is protected and one that is not named is public. The repository key @@ -104,6 +103,7 @@ local cookie_insecure = false -- the password's length, and one longer than this is turned down unchecked. local max_password_bytes = 256 + -- A browser accepts a cookie named __Host- only when it is Secure, covers the -- whole host and was set by this host, so no sibling subdomain can plant a -- session in its place. Either cookie setting above can rule that out. @@ -112,12 +112,18 @@ if not cookie_insecure and cookie_path == "/" then session_cookie = "__Host-" .. cookie_name end +-- A cookie carries its expiry and Max-Age as whole seconds, while on Lua 5.3 +-- and later a length worked out with a fraction, such as 1.5 days, is a float +-- that tostring would write with a trailing ".0". +session_seconds = math.floor(session_seconds) + -- One request reaches this script as an open, a write and a close, so what the -- open decodes is kept here for the calls that follow. local action, http, cgit, post --- The two lookups below, account_hash and repo_userset, are the only part --- of this script that differs from auth-file.lua. +-- Apart from the configuration, the lookups below, account_hash and +-- repo_userset with the fold that feeds them, are the only code that +-- differs from auth-file.lua, which defines trim among its lookups. -- The configured tables are folded to lowercased user names once, so that a -- lookup matches whatever case the login form was filled in with, the way @@ -141,9 +147,6 @@ end -- a failed login can spend the same work on a hash of the same kind and -- cost, and its timing does not tell whether the name exists. function account_hash(user) - if user == nil then - return nil - end local hash = users[user:lower()] if hash ~= nil then return hash @@ -155,31 +158,20 @@ end -- The users allowed into a repository come back keyed by lowercased name, and -- a repository the table does not name comes back as nil. function repo_userset(repo) - if repo == nil then - return nil - end return protected_repos[repo] end --- The URL helpers below are adapted from keplerproject/wsapi. +-- The url helpers below are adapted from keplerproject/wsapi. function url_decode(str) - if not str then - return "" - end str = string.gsub(str, "+", " ") str = string.gsub(str, "%%(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end) - str = string.gsub(str, "\r\n", "\n") return str end function url_encode(str) - if not str then - return "" - end - str = string.gsub(str, "\n", "\r\n") str = string.gsub(str, "([^%w ])", function(char) return string.format("%%%02X", string.byte(char)) end) @@ -191,7 +183,7 @@ end -- '=', for example a base64 password, so the value runs to the next '&'. function parse_query(query) local params = {} - for key, value in string.gmatch(query or "", "([^&=]+)=([^&]*)") do + for key, value in string.gmatch(query, "([^&=]+)=([^&]*)") do params[url_decode(key)] = url_decode(value) end return params @@ -212,7 +204,7 @@ local function trim(text) return text:sub(first, last) end --- The token comes back still URL encoded by secure_value. Decoding it here +-- The token comes back still url encoded by secure_value. Decoding it here -- would break the signature check for any value carrying a percent escape. function get_cookie(cookies, name) for raw in string.gmatch(cookies or "", "[^;]+") do @@ -311,46 +303,9 @@ end -- or to nil to turn it down, which is how a session meets its account's -- current hash. function validate_value(expected_field, cookie, bind) - local i = 0 - local value = "" - local field = "" - local expiration = 0 - local salt = "" - local signature = "" - - if cookie == nil or cookie:len() < 3 or cookie:sub(1, 1) == "|" then - return nil - end - - for component in string.gmatch(cookie, "[^|]+") do - if i == 0 then - field = component - elseif i == 1 then - value = component - elseif i == 2 then - -- The expiration must be a plain integer, since - -- tonumber and tostring of "1e9" or "100.0" differ - -- across Lua versions and the signed bytes have to - -- come back byte for byte. - if not string.match(component, "^%d+$") then - return nil - end - expiration = tonumber(component) - elseif i == 3 then - salt = component - elseif i == 4 then - signature = component - else - break - end - i = i + 1 - end - - if signature == nil or signature:len() == 0 then - return nil - end - - if url_decode(field) ~= expected_field then + local field, value, expiration, salt, signature = + string.match(cookie or "", "^([^|]+)|([^|]+)|(%d+)|([^|]+)|([^|]+)$") + if signature == nil or url_decode(field) ~= expected_field then return nil end @@ -369,12 +324,13 @@ function validate_value(expected_field, cookie, bind) end end - local payload = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt + local payload = field .. "|" .. value .. "|" .. expiration .. "|" .. salt if not constant_equals(signature, sign(payload, binding)) then return nil end -- An expiration of 0 never expires and is used for the redirect token. + expiration = tonumber(expiration) if expiration ~= 0 and expiration <= os.time() then return nil end @@ -396,12 +352,6 @@ function secure_value(field, value, expiration, binding) return payload .. "|" .. sign(payload, binding) end --- A control character in a header value would let that value split the --- response, so it is dropped rather than escaped. -function strip_controls(s) - return (string.gsub(s or "", "%c", "")) -end - -- Compare two strings in time that does not depend on how many leading bytes -- match, so a mismatch position is not revealed by timing. function constant_equals(a, b) @@ -417,7 +367,8 @@ function constant_equals(a, b) end -- An empty value is how a login is cleared, since Max-Age=0 tells the browser --- to drop the cookie it already holds. +-- to drop the cookie it already holds. Any other value is a token secure_value +-- built, which holds no control character to split the response with. function set_cookie(cookie, value) local attributes = "; HttpOnly; SameSite=Lax; Path=" .. cookie_path if not cookie_insecure then @@ -425,16 +376,18 @@ function set_cookie(cookie, value) end if value == "" then attributes = attributes .. "; Max-Age=0" - elseif session_seconds > 0 then + else attributes = attributes .. "; Max-Age=" .. tostring(session_seconds) end - html("Set-Cookie: " .. cookie .. "=" .. strip_controls(value) .. attributes .. "\n") + html("Set-Cookie: " .. cookie .. "=" .. value .. attributes .. "\n") end +-- A control character in the location would let it split the response, so +-- it is dropped rather than escaped. function redirect_to(url) html("Status: 302 Found\n") html("Cache-Control: no-cache, no-store\n") - html("Location: " .. strip_controls(url) .. "\n") + html("Location: " .. url:gsub("%c", "") .. "\n") end -- A failed login lands back on the page it came from with this flag, which @@ -462,7 +415,7 @@ end function authenticate_post() local redirect = validate_value("redirect", post["redirect"]) - if redirect == nil or not is_safe_redirect(redirect) then + if not is_safe_redirect(redirect) then not_found() return 0 end @@ -542,33 +495,17 @@ function body() return 0 end --- filter_open unpacks the action and request fields cgit passes in fixed --- order, filter_write collects a post body, and filter_close runs the action. - local actions = {} actions["authenticate-post"] = authenticate_post actions["authenticate-cookie"] = authenticate_cookie actions["body"] = body -function filter_open(...) - action = actions[select(1, ...)] - +function filter_open(name, cookie, method, query, referer, path, host, https, + repo, page, url, login) + action = actions[name] post = {} - - http = {} - http["cookie"] = select(2, ...) - http["method"] = select(3, ...) - http["query"] = select(4, ...) - http["referer"] = select(5, ...) - http["path"] = select(6, ...) - http["host"] = select(7, ...) - http["https"] = select(8, ...) - - cgit = {} - cgit["repo"] = select(9, ...) - cgit["page"] = select(10, ...) - cgit["url"] = select(11, ...) - cgit["login"] = select(12, ...) + http = { cookie = cookie } + cgit = { repo = repo, url = url, login = login } end function filter_close() diff --git a/custom/extensions/email-gravatar.lua b/custom/extensions/email-gravatar.lua index aebb27c..467deff 100644 --- a/custom/extensions/email-gravatar.lua +++ b/custom/extensions/email-gravatar.lua @@ -47,7 +47,7 @@ local base_url = "https://gravatar.com/avatar/" -- One name reaches this filter as an open, writes and a close, so what the -- open works out is kept here for the close. -local buffer = "" +local chunks = {} local avatar_hash = nil local function hash_hex(input) @@ -96,7 +96,7 @@ local function normalize_email(email) end function filter_open(email, page) - buffer = "" + chunks = {} local address = normalize_email(email) if address == nil or not has_digest then avatar_hash = nil @@ -106,7 +106,7 @@ function filter_open(email, page) end function filter_write(text) - buffer = buffer .. text + chunks[#chunks + 1] = text end local function avatar_url(size) @@ -118,14 +118,15 @@ end -- off the critical path. The icon only decorates the name beside it, so its -- alt text is empty and a screen reader goes straight to the name. function filter_close() + local text = table.concat(chunks) if avatar_hash == nil then - html(buffer) + html(text) else html("<img src='" .. avatar_url(avatar_size) .. "' srcset='" .. avatar_url(avatar_size * 2) .. " 2x" .. "' alt='' width='" .. avatar_size .. "' height='" .. avatar_size .. - "' loading='lazy' decoding='async'> " .. buffer) + "' loading='lazy' decoding='async'> " .. text) end return 0 end diff --git a/custom/extensions/email-libravatar.lua b/custom/extensions/email-libravatar.lua index fca67a7..0a6e10c 100644 --- a/custom/extensions/email-libravatar.lua +++ b/custom/extensions/email-libravatar.lua @@ -46,7 +46,7 @@ local base_url = "https://seccdn.libravatar.org/avatar/" -- One name reaches this filter as an open, writes and a close, so what the -- open works out is kept here for the close. -local buffer = "" +local chunks = {} local avatar_hash = nil local function hash_hex(input) @@ -95,7 +95,7 @@ local function normalize_email(email) end function filter_open(email, page) - buffer = "" + chunks = {} local address = normalize_email(email) if address == nil or not has_digest then avatar_hash = nil @@ -105,7 +105,7 @@ function filter_open(email, page) end function filter_write(text) - buffer = buffer .. text + chunks[#chunks + 1] = text end local function avatar_url(size) @@ -117,14 +117,15 @@ end -- off the critical path. The icon only decorates the name beside it, so its -- alt text is empty and a screen reader goes straight to the name. function filter_close() + local text = table.concat(chunks) if avatar_hash == nil then - html(buffer) + html(text) else html("<img src='" .. avatar_url(avatar_size) .. "' srcset='" .. avatar_url(avatar_size * 2) .. " 2x" .. "' alt='' width='" .. avatar_size .. "' height='" .. avatar_size .. - "' loading='lazy' decoding='async'> " .. buffer) + "' loading='lazy' decoding='async'> " .. text) end return 0 end diff --git a/custom/extensions/link-commits.lua b/custom/extensions/link-commits.lua index 3666140..fc095a4 100644 --- a/custom/extensions/link-commits.lua +++ b/custom/extensions/link-commits.lua @@ -28,8 +28,8 @@ local objects = { } -- Text-reference rules, each one a Lua pattern with a single capture and a --- URL where %s is replaced by that capture, percent-encoded. The whole match --- is what gets shown and the capture is only what goes into the URL. Rules are +-- Url where %s is replaced by that capture, percent-encoded. The whole match +-- is what gets shown and the capture is only what goes into the url. Rules are -- tried in order and the leftmost match wins, so put the more specific -- patterns first, and an empty list leaves only object names linked. The -- examples are commented out, so an unedited copy links object names alone, @@ -54,15 +54,15 @@ local rules = { local chunks = {} --- Percent-encode everything but the URL unreserved characters, so a captured --- value cannot break out of the href attribute or out of the URL itself. +-- Percent-encode everything but the url unreserved characters, so a captured +-- value cannot break out of the href attribute or out of the url itself. local function url_encode(s) return (string.gsub(s, "[^%w._~-]", function(c) return string.format("%%%02X", string.byte(c)) end)) end --- Build one anchor from a URL template holding %s and the text to show. The +-- Build one anchor from a url template holding %s and the text to show. The -- replacement is a function so that a '%' in the encoded value is not taken -- for a gsub reference. local function make_link(url_template, capture, display) diff --git a/custom/extensions/link-trailers.lua b/custom/extensions/link-trailers.lua index b281150..df7c661 100644 --- a/custom/extensions/link-trailers.lua +++ b/custom/extensions/link-trailers.lua @@ -13,9 +13,9 @@ -- Rules are tried in order and the first whose key matches the trailer, case -- insensitively, and whose pattern matches the value wins. Each pattern is a --- Lua pattern with a single capture and a URL where %s is replaced by that +-- Lua pattern with a single capture and a url where %s is replaced by that -- capture, percent-encoded. The matched run is what gets wrapped and the --- capture is only what goes into the URL. A rule without a key applies to +-- capture is only what goes into the url. A rule without a key applies to -- every trailer. -- -- Lua patterns are not regular expressions. The reference is @@ -38,7 +38,7 @@ local rules = { -- url = "https://www.cve.org/CVERecord?id=CVE-%s" }, } --- A value that is nothing but one http or https URL, under any key, links to +-- A value that is nothing but one http or https url, under any key, links to -- itself. Set false to leave such values as text. local link_urls = true @@ -46,15 +46,15 @@ local link_urls = true local key = "" local chunks = {} --- Percent-encode everything but the URL unreserved characters, so a captured --- value cannot break out of the href attribute or out of the URL itself. +-- Percent-encode everything but the url unreserved characters, so a captured +-- value cannot break out of the href attribute or out of the url itself. local function url_encode(s) return (string.gsub(s, "[^%w._~-]", function(c) return string.format("%%%02X", string.byte(c)) end)) end --- Build one anchor from a URL template holding %s and the text to show. The +-- Build one anchor from a url template holding %s and the text to show. The -- replacement is a function so that a '%' in the encoded value is not taken -- for a gsub reference. local function make_link(url_template, capture, display) @@ -66,7 +66,7 @@ local function make_link(url_template, capture, display) end -- The value is already escaped, which is also what an attribute wants, so a --- URL only has to be kept away from the quote that closes the attribute. +-- Url only has to be kept away from the quote that closes the attribute. local function link_url(text) if not link_urls or not string.find(text, "^https?://[^%s'\"]+$") then return nil diff --git a/custom/extensions/syntax-highlight.lua b/custom/extensions/syntax-highlight.lua index 8a1e7ab..7414b63 100644 --- a/custom/extensions/syntax-highlight.lua +++ b/custom/extensions/syntax-highlight.lua @@ -1,4 +1,4 @@ --- Server-side syntax highlighting for cgit's tree and blob views, named by +-- Server-side syntax highlighting for cgit's tree and blame pages, named by -- the source-filter setting and run inside cgit's embedded Lua interpreter -- so a coloured blob costs no extra process per request. Tokens come from the -- Scintillua lexers and reach the page wrapped in span elements carrying the @@ -28,7 +28,8 @@ local scintillua_env = "CGIT_SCINTILLUA_PATH" -- directory next to cgitrc is enough for it to be found. The lexers come -- from https://orbitalquark.github.io/scintillua/ and need lpeg, built for -- the Lua cgit is linked against. Forgetting that is the usual reason --- nothing is coloured. +-- nothing is coloured. A file is coloured only when Scintillua's detect() +-- names its lexer, so an older Scintillua without detect() colours nothing. -- -- # Debian and Ubuntu -- sudo apt install lua-lpeg @@ -74,25 +75,6 @@ local css = { pseudoelement = "hl-func", } --- Extension to lexer name fixes for the fallback path, reached only when --- this Scintillua has no detect(). Most extensions already equal their lexer --- name and these are the frequent exceptions. -local ext_lexer = { - py = "python", - js = "javascript", - ts = "typescript", - rb = "ruby", - pl = "perl", - pm = "perl", - sh = "bash", - md = "markdown", - htm = "html", - yml = "yaml", - rs = "rust", - c = "ansi_c", - h = "ansi_c", -} - local scintillua = nil local filename = "" @@ -170,37 +152,13 @@ local function load_scintillua() return nil end --- Loading a lexer runs its Lua, and one written for another Scintillua can --- raise, so a failure here just leaves this file uncoloured. -local function load_lexer_name(name) - if name == nil then - return nil - end - local ok, lexer = pcall(scintillua.load, name) - if ok and lexer then - return lexer - end - return nil -end - +-- The first line goes along to detect(), so a script with no extension is +-- still known by its shebang. detect() matches an extension by its case, so +-- a name in capitals such as MAIN.C is tried again in lower case. local function lexer_for(name, text) - if type(scintillua.detect) == "function" then - -- The first line goes along, so a script with no extension is still - -- known by its shebang. - local ok, lang = pcall(scintillua.detect, name, text:match("^[^\n]*")) - if ok and lang then - local lexer = load_lexer_name(lang) - if lexer then - return lexer - end - end - end - local ext = string.match(name, "%.([^.]+)$") - if not ext then - return nil - end - ext = string.lower(ext) - return load_lexer_name(ext_lexer[ext]) or load_lexer_name(ext) + local first = string.match(text, "^[^\n]*") + local lang = scintillua.detect(name, first) or scintillua.detect(string.lower(name), first) + return lang and scintillua.load(lang) end local function highlight(text) @@ -208,8 +166,8 @@ local function highlight(text) if not lexer then return nil end - local ok, tokens = pcall(lexer.lex, lexer, text) - if not ok or type(tokens) ~= "table" then + local tokens = lexer:lex(text) + if type(tokens) ~= "table" then return nil end local out = {} @@ -262,6 +220,9 @@ function filter_close() scintillua = load_scintillua() or false end if scintillua then + -- Every step of highlight runs Scintillua's own Lua, and code + -- written for another version of it can raise, so a failure + -- anywhere there just leaves this file uncoloured. local ok, marked = pcall(highlight, text) if ok and marked then html(marked) diff --git a/custom/hooks/post-receive.cgit-age b/custom/hooks/post-receive.cgit-age index a87e325..82a13f0 100755 --- a/custom/hooks/post-receive.cgit-age +++ b/custom/hooks/post-receive.cgit-age @@ -2,8 +2,13 @@ # Updates the age-file cgit reads for the idle column. The path below is the # default age-file, so a cgitrc that changes the setting needs the same change -# here. To install the hook, copy or link it to hooks/post-receive in each -# repository. +# here. A file the pusher cannot write stays as it was, and nothing here +# complains or exits non-zero, so the pusher is not shown an error over a stale +# idle column. +# +# To install the hook, copy or link it to hooks/post-receive in each +# repository. Git runs one post-receive per repository, so fold this together +# with the other hooks here to have more than one. set -eu @@ -32,10 +37,10 @@ date="$(git for-each-ref --sort=-committerdate --count=1 \ # being handed an empty file. test -n "$date" || exit 0 -mkdir -p "$(dirname "$agefile")" +mkdir -p "$(dirname "$agefile")" 2>/dev/null || exit 0 # Write through a temporary file so a reader never sees a half written date. tmpfile="$agefile.$$" trap 'rm -f "$tmpfile"' EXIT -printf '%s\n' "$date" >"$tmpfile" -mv "$tmpfile" "$agefile" +{ printf '%s\n' "$date" >"$tmpfile"; } 2>/dev/null || exit 0 +mv "$tmpfile" "$agefile" 2>/dev/null || : diff --git a/custom/hooks/post-receive.cgit-cache b/custom/hooks/post-receive.cgit-cache index f1a4b40..61ac1ea 100755 --- a/custom/hooks/post-receive.cgit-cache +++ b/custom/hooks/post-receive.cgit-cache @@ -19,12 +19,12 @@ # The path below is the default cache-root, so a cgitrc that changes the # setting needs the same change here. The web server owns the cache root, so # the pushing user needs write access to it. Nothing here complains or exits -# nonzero when that access is missing, so the pusher is not shown an error +# non-zero when that access is missing, so the pusher is not shown an error # over a stale page. # # To install the hook, copy or link it to hooks/post-receive in each # repository. Git runs one post-receive per repository, so fold this together -# with the age-file hook to have both. +# with the other hooks here to have more than one. set -eu diff --git a/custom/hooks/post-receive.commit-graph b/custom/hooks/post-receive.commit-graph index 51d8c3c..d2d711c 100755 --- a/custom/hooks/post-receive.commit-graph +++ b/custom/hooks/post-receive.commit-graph @@ -10,7 +10,7 @@ # --split writes each push's new commits as a small layer over the last, and # git merges the layers as they pile up, so a push costs little. A git too old # for these options leaves the graph as it was, and nothing here complains or -# exits nonzero, so the pusher is not shown an error over a missing speedup. +# exits non-zero, so the pusher is not shown an error over a missing speedup. # # To install the hook, copy or link it to hooks/post-receive in each # repository. Git runs one post-receive per repository, so fold this together diff --git a/custom/servers/apache.conf b/custom/servers/apache.conf index 7778c7f..3d65d5e 100644 --- a/custom/servers/apache.conf +++ b/custom/servers/apache.conf @@ -17,16 +17,14 @@ # cgit CGI binary /usr/lib/cgit/cgit.cgi # static assets /usr/share/cgit (cgit.css cgit.js cgit.png favicon.ico robots.txt) # cgit config /etc/cgitrc -# public URL https://git.example.org/ (cgit at the domain root) +# public url https://git.example.org/ (cgit at the domain root) # # cgit is one CGI executable. It learns the repository and the page from -# PATH_INFO and reads page options such as h= and id= from QUERY_STRING. -# ScriptAlias runs the binary and forwards the trailing path as PATH_INFO, so -# no extra path tuning is needed. cgit builds its own link base from -# SCRIPT_NAME. The five static assets are served straight off disk. mod_alias -# resolves Alias and ScriptAlias in order and the first match wins, so the -# static Alias lines come before the catch-all ScriptAlias to stop the two -# routes from shadowing each other. +# PATH_INFO and reads page options such as h= and id= from QUERY_STRING. The +# five static assets are served straight off disk. mod_alias resolves Alias and +# ScriptAlias in order and the first match wins, so the static Alias lines come +# before the catch-all ScriptAlias to stop the two routes from shadowing each +# other. # ServerRoot is what every relative path below resolves against, including the @@ -138,8 +136,8 @@ AddType text/plain .txt Require all granted # These assets rarely change, so let browsers cache them. Keep the - # caching scoped to this directory. Ordinary cgit pages carry no caching - # headers, so a server-wide ExpiresDefault would stamp freshness onto + # caching scoped to this directory. Ordinary cgit pages carry no expiry + # time, so a server-wide ExpiresDefault would stamp freshness onto # them, fight the no-store cgit puts on the login page, and could let # one visitor's page be served to another from a shared cache. <IfModule mod_expires.c> @@ -150,7 +148,6 @@ AddType text/plain .txt # The cgit binary. <Directory "/usr/lib/cgit"> - # Allow CGI execution here. Options +ExecCGI # Run cgit.cgi as a CGI even if it is ever reached through a plain Alias # rather than ScriptAlias. @@ -204,13 +201,8 @@ AddType text/plain .txt LimitRequestBody 65536 # Site-wide security headers are set here so they also cover the static - # assets Apache serves. cgit itself sends only the headers the proxy - # cannot supply. Those are Status, Content-Type, Content-Length and - # Content-Disposition on downloads, Location on redirects, a Cache-Control - # marking the login page no-store and a page behind an auth filter private, - # the auth filter's Set-Cookie, and on raw repository bytes a nosniff of - # its own next to the stricter policy "default-src 'none'". Everything - # else, this policy included, is the proxy's job. + # assets Apache serves. On raw repository bytes cgit sends a nosniff and + # the stricter policy "default-src 'none'" of its own. # # The word setifempty is load bearing on the two headers cgit can also # emit. "Header always set" replaces a same-named header even when the @@ -248,23 +240,22 @@ AddType text/plain .txt # once browsers have seen it. Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains" - # These five files are the only things served off disk. cgit.css and - # cgit.js are the paths cgit's HTML points at by default, so if you - # relocate the assets update both these Alias targets and the css, js, - # logo and favicon settings in cgitrc to agree. + # These five files are the only things served off disk. cgit's default + # css, js, logo and favicon settings point at these paths, so if you + # relocate the assets update both these Alias targets and those settings + # in cgitrc to agree. Alias /cgit.css /usr/share/cgit/cgit.css Alias /cgit.js /usr/share/cgit/cgit.js Alias /cgit.png /usr/share/cgit/cgit.png Alias /favicon.ico /usr/share/cgit/favicon.ico Alias /robots.txt /usr/share/cgit/robots.txt - # ScriptAlias maps a URL prefix to a path, marks it executable, and - # forwards the rest of the URL as PATH_INFO. Mapping / makes cgit the - # handler for every URL the static Aliases above did not already claim. - # The trailing slash on cgit.cgi/ is load bearing, telling Apache that - # cgit.cgi is the program and the rest of the URL is PATH_INFO. cgit - # derives its link base from SCRIPT_NAME, which at the domain root is / - # and needs no tuning. For a sub-path install see the note below. + # Mapping / makes cgit the handler for every url the static Aliases above + # did not already claim. The trailing slash on cgit.cgi/ is load bearing, + # telling Apache that cgit.cgi is the program and the rest of the url is + # PATH_INFO. cgit derives its link base from SCRIPT_NAME, which at the + # domain root is / and needs no tuning. For a sub-path install see the + # note below. ScriptAlias / /usr/lib/cgit/cgit.cgi/ </VirtualHost> diff --git a/custom/servers/lighttpd.conf b/custom/servers/lighttpd.conf index 8fef3c4..edbf162 100644 --- a/custom/servers/lighttpd.conf +++ b/custom/servers/lighttpd.conf @@ -9,19 +9,18 @@ # lighttpd -D -f /path/to/lighttpd.conf # run it in the foreground # # lighttpd runs the cgit.cgi binary directly through mod_cgi, so no FastCGI -# bridge is needed. This is cgit's classic reference deployment, mod_cgi with -# mod_alias and mod_setenv. +# bridge is needed. # # Paths assumed below, edit them to match your install. # cgit CGI binary /usr/lib/cgit/cgit.cgi # static assets /usr/share/cgit (cgit.css cgit.js cgit.png favicon.ico robots.txt) # cgit config /etc/cgitrc -# public URL https://git.example.org/ (cgit at the domain root) +# public url https://git.example.org/ (cgit at the domain root) # # cgit is one CGI executable. It learns the repository and the page from -# PATH_INFO and reads page options such as h= and id= from QUERY_STRING. cgit -# builds its own link base from SCRIPT_NAME. The five static assets are served -# straight off disk and must never be routed through cgit. +# PATH_INFO and reads page options such as h= and id= from QUERY_STRING. The +# five static assets are served straight off disk and must never be routed +# through cgit. # A plain assignment rather than "+=", since this config stands on its own @@ -75,13 +74,8 @@ $HTTP["host"] == "git.example.org" { setenv.add-environment = ( "CGIT_CONFIG" => "/etc/cgitrc" ) # Site-wide security headers are set here so they also cover the static - # assets lighttpd serves. cgit itself sends only the headers the server - # cannot supply. Those are Status, Content-Type, Content-Length and - # Content-Disposition on downloads, Location on redirects, a Cache-Control - # marking the login page no-store and a page behind an auth filter private, - # the auth filter's Set-Cookie, and on raw repository bytes a nosniff of - # its own next to the stricter policy "default-src 'none'". Everything - # else, this policy included, is the server's job. + # assets lighttpd serves. On raw repository bytes cgit sends a nosniff and + # the stricter policy "default-src 'none'" of its own. # # The add in add-response-header is load bearing. It appends a second # copy next to what cgit emitted, so a raw page carries both policies and @@ -128,7 +122,7 @@ $HTTP["host"] == "git.example.org" { # hands everything else to cgit. # # The trailing slash on cgit.cgi/ is load bearing. lighttpd builds the - # physical path by stripping the matched key off the front of the URL and + # physical path by stripping the matched key off the front of the url and # appending the rest to the value. For the key / the remainder carries no # leading slash, so without the trailing slash a request for # /linux/tree/kernel/sched.c glues onto the binary name as diff --git a/custom/servers/nginx.conf b/custom/servers/nginx.conf index d18a109..7f09682 100644 --- a/custom/servers/nginx.conf +++ b/custom/servers/nginx.conf @@ -12,13 +12,12 @@ # cgit CGI binary /usr/lib/cgit/cgit.cgi # static assets /usr/share/cgit (cgit.css cgit.js cgit.png favicon.ico robots.txt) # cgit config /etc/cgitrc -# public URL https://git.example.org/ (cgit at the domain root) +# public url https://git.example.org/ (cgit at the domain root) # # cgit is one CGI executable. It learns the repository and the page from # PATH_INFO and reads page options such as h= and id= from QUERY_STRING, so -# nginx must pass PATH_INFO through to the binary. cgit builds its own link -# base from SCRIPT_NAME. The five static assets are served straight off disk -# and must never be routed through cgit. +# nginx must pass PATH_INFO through to the binary. The five static assets are +# served straight off disk and must never be routed through cgit. # # nginx cannot run CGI programs itself, so a small bridge called fcgiwrap runs # the cgit.cgi binary and speaks FastCGI to nginx. Nothing below works until @@ -120,7 +119,6 @@ http { root /var/www/html; } - # Everything else moves to HTTPS. location / { return 301 https://$host$request_uri; } @@ -152,15 +150,9 @@ http { ssl_session_timeout 1d; ssl_session_tickets off; - # Site-wide security headers sit here because they must also cover the - # static assets nginx serves directly. cgit itself sends only the - # headers the proxy cannot supply. Those are Status, Content-Type, - # Content-Length and Content-Disposition on downloads, Location on - # redirects, a Cache-Control marking the login page no-store and a page - # behind an auth filter private, the auth filter's Set-Cookie, and on - # raw repository bytes a nosniff of its own next to the stricter policy - # "default-src 'none'". Everything else, this policy included, is the - # proxy's job. + # Site-wide security headers are set here so they also cover the + # static assets nginx serves. On raw repository bytes cgit sends a + # nosniff and the stricter policy "default-src 'none'" of its own. # # add_header appends and never replaces what cgit sent, so a raw page # carries both policies and the browser enforces the stricter one, @@ -199,9 +191,9 @@ http { # hard to undo once browsers have seen it. add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; - # The document root is the directory that holds the static assets. cgit - # emits absolute links to /cgit.css and /cgit.png by default, so those - # files must resolve at the root of the URL space. + # The document root is the directory that holds the static assets. + # cgit's default css, js, logo and favicon settings are absolute links + # to these files, so they must resolve at the root of the url space. root /usr/share/cgit; # The only request body cgit ever reads is the auth-filter login form, @@ -214,18 +206,17 @@ http { # Match the assets by their exact root-level names, never by bare # extension. A repository can hold files ending in .css or .png, and - # /myrepo/tree/style.css is a real cgit URL a broad match would + # /myrepo/tree/style.css is a real cgit url a broad match would # capture and 404. The regex anchored at ^/ matches /cgit.css but # never /myrepo/tree/cgit.css, and a regex location beats the prefix - # location below, so assets win their exact URLs and cgit the rest. + # location below, so assets win their exact urls and cgit the rest. location ~ ^/(cgit\.css|cgit\.js|cgit\.png|favicon\.ico|robots\.txt)$ { expires 30d; access_log off; try_files $uri =404; } - # Everything that is not a static asset above is a cgit URL, the repo - # index, a repository, a page within a repository, a snapshot, a feed. + # Everything else is a cgit url. location / { # The CGI environment, normally "include fastcgi_params", written # out here so this config stands alone. The cookie and referer @@ -249,7 +240,7 @@ http { # whole request path is the PATH_INFO. fastcgi_param PATH_INFO $uri; - # Page options such as h=branch, id=sha and the snapshot format. + # Page options such as h=branch and id=sha. fastcgi_param QUERY_STRING $query_string; # Which config cgit reads. It checks CGIT_CONFIG and falls back to @@ -257,11 +248,11 @@ http { # explicit and lets you move cgitrc without recompiling. fastcgi_param CGIT_CONFIG /etc/cgitrc; - # The browser's Host header, so cgit builds clone URLs against the + # The browser's Host header, so cgit builds clone urls against the # name the visitor used rather than server_name. fastcgi_param HTTP_HOST $http_host; - # The real scheme, so cgit builds correct https clone URLs. + # The real scheme, so cgit builds correct https clone urls. fastcgi_param HTTPS $https if_not_empty; # The routine remainder, needed by fcgiwrap and by the login form. @@ -281,7 +272,6 @@ http { fastcgi_param SERVER_PORT $server_port; fastcgi_param SERVER_NAME $server_name; - # Hand off to the fcgiwrap socket. fastcgi_pass unix:/run/fcgiwrap.socket; # Large outputs such as snapshot tarballs and blame on big files @@ -289,7 +279,7 @@ http { fastcgi_read_timeout 300s; fastcgi_buffering off; - # Ordinary cgit pages carry no caching headers, so browsers + # Ordinary cgit pages carry no expiry time, so browsers # refetch them and cgit's internal cache keeps that cheap. Do not # add a blanket expires or Cache-Control in this location. It # would fight the no-store cgit puts on the login page and could @@ -300,7 +290,7 @@ http { # To serve cgit at https://git.example.org/cgit/ instead of the root, split -# the URL so SCRIPT_NAME is the prefix and PATH_INFO is the rest. Keep every +# the url so SCRIPT_NAME is the prefix and PATH_INFO is the rest. Keep every # other fastcgi_param from the location above. # # location /cgit/ { @@ -320,7 +310,7 @@ http { # access_log off; # } # -# cgit's default css=/cgit.css and logo=/cgit.png point at the domain root, so -# under a sub-path also set css=/cgit/cgit.css and logo=/cgit/cgit.png in -# cgitrc. cgit derives the /cgit prefix from SCRIPT_NAME. If links come out -# wrong, pin it in cgitrc with virtual-root=/cgit/. +# cgit's default css, js, logo and favicon settings point at the domain root, +# so under a sub-path also set them to the /cgit/ paths in cgitrc. cgit +# derives the /cgit prefix from SCRIPT_NAME. If links come out wrong, pin it +# in cgitrc with virtual-root=/cgit/. diff --git a/source/cache.c b/source/cache.c index 9594fd6..0a3166d 100644 --- a/source/cache.c +++ b/source/cache.c @@ -12,7 +12,6 @@ #include "cache.h" #include "cgit.h" #include "html.h" -#include "shared.h" #include <sys/file.h> #ifdef HAVE_LINUX_SENDFILE #include <sys/sendfile.h> @@ -31,21 +30,6 @@ #define FNV_OFFSET 0x811c9dc5 #define FNV_PRIME 0x01000193 -/* - * Cache trouble goes to stderr, which under CGI is the web server's error - * log, so that it cannot land in the middle of the page being written to - * stdout. - */ -__attribute__((format (printf,1,2))) -static void log_error(const char *format, ...) -{ - va_list args; - - va_start(args, format); - vfprintf(stderr, format, args); - va_end(args); -} - struct cache_slot { const char *key; size_t keylen; @@ -112,17 +96,12 @@ static int key_fits_slot(const char *key) return strlen(key) + 1 <= CACHE_BUFSIZE; } -static int close_slot(struct cache_slot *slot) +static void close_fd(int *fd) { - int err = 0; - - if (slot->cache_fd > 0) { - if (close(slot->cache_fd)) - err = errno; - else - slot->cache_fd = -1; + if (*fd > 0) { + close(*fd); + *fd = -1; } - return err; } static int print_slot(struct cache_slot *slot) @@ -182,7 +161,7 @@ static int serve_slot(struct cache_slot *slot) err = print_slot(slot); // A client that went away mid-page is not trouble worth a line. if (err && err != EPIPE) - log_error("[cgit] Unable to send slot %s: %s (%d)\n", slot->path, strerror(err), err); + fprintf(stderr, "[cgit] Unable to send slot %s: %s (%d)\n", slot->path, strerror(err), err); return err; } @@ -212,63 +191,95 @@ static int is_modified(struct cache_slot *slot) ); } -static int close_lock(struct cache_slot *slot) -{ - int err = 0; - - if (slot->lock_fd > 0) { - if (close(slot->lock_fd)) - err = errno; - else - slot->lock_fd = -1; - } - return err; -} - /* * The lock file becomes the slot once it is renamed, so it has to open with * the key the same way a slot does. The lock is taken without blocking, * because failing to get it is how a second process learns that this slot is - * already being rebuilt, so it returns an errno instead of waiting. + * already being rebuilt, so it returns an errno instead of waiting. Every + * other failure is logged here, for both callers. */ -static int lock_slot(struct cache_slot *slot) +int cache_lock_file(const char *path, int *fd) { struct stat held, named; + int err; - slot->lock_fd = open(slot->lock_path, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR); - if (slot->lock_fd == -1) - return errno; - // An flock rather than a POSIX record lock. The descriptor is put on - // stdout for the fill, and a record lock is dropped as soon as any + // Close-on-exec, so that a program a cache fill runs gets the file + // only as its stdout, and holds the lock no longer than it can write + // there. + *fd = open(path, O_RDWR | O_CREAT | O_CLOEXEC, S_IRUSR | S_IWUSR); + if (*fd == -1) { + err = errno; + fprintf(stderr, "[cgit] Unable to open %s: %s (%d)\n", path, strerror(err), err); + return err; + } + // An flock rather than a POSIX record lock. A slot's descriptor is put + // on stdout for the fill, and a record lock is dropped as soon as any // descriptor on the file closes, which an exec filter does to stdout, - // while an flock holds until the last descriptor closes. - if (flock(slot->lock_fd, LOCK_EX | LOCK_NB) < 0) { - int saved_errno = errno; - close(slot->lock_fd); - slot->lock_fd = -1; - return saved_errno; + // while an flock holds until the last descriptor closes. Either way a + // holder that dies takes its lock with it. + if (flock(*fd, LOCK_EX | LOCK_NB) < 0) { + // A lock held elsewhere only means concurrent requests, which + // is not worth a line in the server log. Any other failure, a + // filesystem without lock support say, keeps the cache from + // ever being written and does deserve one. + err = errno; + if (err != EWOULDBLOCK) + fprintf(stderr, "[cgit] Unable to lock %s: %s (%d)\n", path, strerror(err), err); + goto fail; } // The lock landed on whatever inode the path named at open. A holder - // finishing in between renames that inode into place as the live - // slot, and once the path is confirmed to still name this file that - // rename can no longer happen, because it takes the lock held here. + // finishing in between renames that inode into place, and once the + // path is confirmed to still name this file that rename can no longer + // happen, because it takes the lock held here. if ( - fstat(slot->lock_fd, &held) || - stat(slot->lock_path, &named) || - held.st_ino != named.st_ino || + fstat(*fd, &held) || + stat(path, &named) || + held.st_ino != named.st_ino || held.st_dev != named.st_dev ) { - close(slot->lock_fd); - slot->lock_fd = -1; - return EAGAIN; + err = EAGAIN; + goto fail; } // A run that died before its rename leaves the lock file behind, so // start from empty now that nobody else can be writing it. - if (ftruncate(slot->lock_fd, 0) < 0) - return errno; - if (write_in_full(slot->lock_fd, slot->key, slot->keylen + 1) < 0) - return errno; + if (ftruncate(*fd, 0) < 0) { + err = errno; + fprintf(stderr, "[cgit] Unable to write %s: %s (%d)\n", path, strerror(err), err); + unlink(path); + goto fail; + } return 0; + +fail: + close(*fd); + *fd = -1; + return err; +} + +static int lock_slot(struct cache_slot *slot) +{ + int err = cache_lock_file(slot->lock_path, &slot->lock_fd); + + if (err) + return err; + if (write_in_full(slot->lock_fd, slot->key, slot->keylen + 1) < 0) { + err = errno; + fprintf(stderr, "[cgit] Unable to write %s: %s (%d)\n", slot->lock_path, strerror(err), err); + unlink(slot->lock_path); + close(slot->lock_fd); + slot->lock_fd = -1; + return err; + } + return 0; +} + +static void restore_stdout(struct cache_slot *slot) +{ + if (slot->saved_stdout >= 0) { + dup2(slot->saved_stdout, STDOUT_FILENO); + close(slot->saved_stdout); + slot->saved_stdout = -1; + } } static int unlock_slot(struct cache_slot *slot, int replace_old_slot) @@ -280,11 +291,7 @@ static int unlock_slot(struct cache_slot *slot, int replace_old_slot) else err = unlink(slot->lock_path); - if (slot->saved_stdout >= 0) { - dup2(slot->saved_stdout, STDOUT_FILENO); - close(slot->saved_stdout); - slot->saved_stdout = -1; - } + restore_stdout(slot); if (err) return errno; @@ -328,11 +335,7 @@ int cache_abandon_fill(void) // caller that could not write it must not try again here. fflush(stdout); - if (slot->saved_stdout >= 0) { - dup2(slot->saved_stdout, STDOUT_FILENO); - close(slot->saved_stdout); - slot->saved_stdout = -1; - } + restore_stdout(slot); replay_lock_file(slot); unlink(slot->lock_path); return 1; @@ -347,7 +350,7 @@ static void publish_slot(struct cache_slot *slot) int err = unlock_slot(slot, 1); if (err) - log_error("[cgit] Unable to publish cache slot %s: %s (%d)\n", slot->path, + fprintf(stderr, "[cgit] Unable to publish cache slot %s: %s (%d)\n", slot->path, strerror(err), err); } @@ -405,13 +408,13 @@ static void refresh_slot(struct cache_slot *slot) // lock_slot, the copy already open is served rather than the newer. if (is_modified(slot) || fill_slot(slot)) { unlock_slot(slot, 0); - close_lock(slot); + close_fd(&slot->lock_fd); } else if (slot->abandoned) { // The abandoned fill answered the visitor itself and removed // the lock file, so only the descriptor is left to clean up. - close_lock(slot); + close_fd(&slot->lock_fd); } else { - close_slot(slot); + close_fd(&slot->cache_fd); if (slot->oversized) { // The expired copy goes too, or every later request // would refill it and drop the result again. @@ -436,11 +439,11 @@ static int process_slot(struct cache_slot *slot) // visitor, and serving the stale copy still open would append // a second page to that answer. if (slot->abandoned) { - close_slot(slot); + close_fd(&slot->cache_fd); return 0; } err = serve_slot(slot); - close_slot(slot); + close_fd(&slot->cache_fd); return err; } @@ -448,23 +451,22 @@ static int process_slot(struct cache_slot *slot) // and two popular pages sharing one slot evict each other on every // alternating visit. if (!err) - log_error("[cgit] Cache slot %s holds a different key, consider a larger cache-size\n", + fprintf(stderr, "[cgit] Cache slot %s holds a different key, consider a larger cache-size\n", slot->path); // If any part of creating a slot fails the page is still rendered // straight to the client and the caller is told the request succeeded, // because it did. - close_slot(slot); - if ((err = lock_slot(slot)) != 0) { - log_error("[cgit] Unable to lock slot %s: %s (%d)\n", slot->lock_path, strerror(err), err); + close_fd(&slot->cache_fd); + if (lock_slot(slot)) { slot->fn(); return 0; } if ((err = fill_slot(slot)) != 0) { - log_error("[cgit] Unable to fill slot %s: %s (%d)\n", slot->lock_path, strerror(err), err); + fprintf(stderr, "[cgit] Unable to fill slot %s: %s (%d)\n", slot->lock_path, strerror(err), err); unlock_slot(slot, 0); - close_lock(slot); + close_fd(&slot->lock_fd); // Rendering again is only right when nothing was delivered, // and an abandoned fill has already sent the error page. if (!slot->abandoned) @@ -473,7 +475,7 @@ static int process_slot(struct cache_slot *slot) } if (slot->abandoned) { - close_lock(slot); + close_fd(&slot->lock_fd); return 0; } @@ -486,26 +488,11 @@ static int process_slot(struct cache_slot *slot) else publish_slot(slot); err = serve_slot(slot); - close_slot(slot); + close_fd(&slot->cache_fd); return err; } /* - * The result lives in a static buffer and is only good until the next call. - */ -static const char *format_time(const char *format, time_t when) -{ - static char buf[64]; - struct tm tm; - - if (!when) - return "-"; - gmtime_r(&when, &tm); - strftime(buf, sizeof(buf) - 1, format, &tm); - return buf; -} - -/* * The accumulator is unsigned long, so on a 64 bit host this is not the * published FNV-1 value. Only slot selection depends on it. */ @@ -514,9 +501,6 @@ unsigned long cache_hash_str(const char *str) unsigned long h = FNV_OFFSET; unsigned char *s = (unsigned char *)str; - if (!s) - return h; - while (*s) { h *= FNV_PRIME; h ^= *s++; @@ -524,6 +508,16 @@ unsigned long cache_hash_str(const char *str) return h; } +/* + * Unlike git's strbuf_complete, an empty path gains the slash too, so an empty + * cache-root keeps its slots under / rather than in the working directory. + */ +static void end_with_slash(struct strbuf *path) +{ + if (!path->len || path->buf[path->len - 1] != '/') + strbuf_addch(path, '/'); +} + int cache_process(int size, const char *path, const char *key, int ttl, size_t max_bytes, cache_fill_fn fn) { @@ -539,21 +533,14 @@ int cache_process(int size, const char *path, const char *key, int ttl, size_t m return 0; } - if (!path) { - log_error("[cgit] Cache path not specified, caching is disabled\n"); - fn(); - return 0; - } - if (!key) - key = ""; if (!key_fits_slot(key)) { - log_error("[cgit] Cache key too long for a slot, caching is disabled for this request\n"); + fprintf(stderr, "[cgit] Cache key too long for a slot, caching is disabled for this request\n"); fn(); return 0; } hash = cache_hash_str(key) % size; strbuf_addstr(&slot_path, path); - strbuf_ensure_end(&slot_path, '/'); + end_with_slash(&slot_path); for (i = 0; i < SLOT_NAME_LEN; i++) { strbuf_addf(&slot_path, "%x", (unsigned char)(hash & 0xf)); hash >>= 4; @@ -577,7 +564,7 @@ int cache_process(int size, const char *path, const char *key, int ttl, size_t m return result; } -int cache_ls(const char *path) +void cache_ls(const char *path) { DIR *dir; struct dirent *ent; @@ -590,42 +577,41 @@ int cache_ls(const char *path) char *nul; int keylen; - if (!path) { - log_error("[cgit] Cache path not specified\n"); - return -1; - } dir = opendir(path); if (!dir) { err = errno; - log_error("[cgit] Unable to open %s: %s (%d)\n", path, strerror(err), err); - return err; + fprintf(stderr, "[cgit] Unable to open %s: %s (%d)\n", path, strerror(err), err); + return; } strbuf_addstr(&slot_path, path); - strbuf_ensure_end(&slot_path, '/'); + end_with_slash(&slot_path); prefixlen = slot_path.len; while ((ent = readdir(dir))) { + char mtime[64] = "-"; + struct tm tm; + if (strlen(ent->d_name) != SLOT_NAME_LEN) continue; strbuf_setlen(&slot_path, prefixlen); strbuf_addstr(&slot_path, ent->d_name); slot.path = slot_path.buf; if ((err = open_slot(&slot)) != 0) { - log_error("[cgit] Unable to open %s: %s (%d)\n", slot_path.buf, strerror(err), err); - close_slot(&slot); + fprintf(stderr, "[cgit] Unable to open %s: %s (%d)\n", slot_path.buf, strerror(err), err); + close_fd(&slot.cache_fd); continue; } + if (slot.st.st_mtime && gmtime_r(&slot.st.st_mtime, &tm)) + strftime(mtime, sizeof(mtime), "%Y-%m-%d %H:%M:%S", &tm); // A truncated or corrupt slot may hold no NUL, so the print is // bounded by what was read and cannot run off the end. nul = memchr(slot.buf, 0, slot.buflen); keylen = nul ? (int)(nul - slot.buf) : slot.buflen; htmlf( - "%s %s %10"PRIuMAX" %.*s\n", slot_path.buf, - format_time("%Y-%m-%d %H:%M:%S", slot.st.st_mtime), + "%s %s %10"PRIuMAX" %.*s\n", slot_path.buf, mtime, (uintmax_t)slot.st.st_size, keylen, slot.buf ); - close_slot(&slot); + close_fd(&slot.cache_fd); } closedir(dir); strbuf_release(&slot_path); - return 0; } diff --git a/source/cache.h b/source/cache.h index d3a7ec4..cfe1fbf 100644 --- a/source/cache.h +++ b/source/cache.h @@ -27,7 +27,7 @@ extern int cache_process(int size, const char *path, const char *key, int ttl, * Write one line per cache slot to stdout, giving its path, modification time, * size and key. */ -extern int cache_ls(const char *path); +extern void cache_ls(const char *path); /* * Give up on the slot being filled and put stdout back on the client, after @@ -40,4 +40,14 @@ extern int cache_abandon_fill(void); extern unsigned long cache_hash_str(const char *str); +/* + * Open the lock file at path, creating it if need be, then lock it without + * waiting and empty it, leaving the descriptor in *fd. The lock holds until + * the last descriptor on the file closes, and the caller renames or removes + * the file before that. Returns 0, EAGAIN or EWOULDBLOCK when another process + * holds the lock or has renamed the file away, or the errno of the step that + * failed, which has been logged. *fd is -1 on failure. + */ +extern int cache_lock_file(const char *path, int *fd); + #endif // CGIT_CACHE_H diff --git a/source/cgit.c b/source/cgit.c index eaf1d19..f8795d7 100644 --- a/source/cgit.c +++ b/source/cgit.c @@ -42,10 +42,6 @@ // every row it skips, so this bounds the work an offset alone can buy. #define MAX_QUERY_OFFSET 100000 -// cgit knows fewer than eight archive formats, so this stands in for every bit -// a snapshots mask can carry. -#define ALL_SNAPSHOT_FORMATS 0xFF - // The first branch found is the fallback, so a repository whose default branch // does not exist still has something to show. struct refmatch { @@ -89,11 +85,19 @@ static NORETURN void die_routine(const char *msg, va_list params) exit(0); } +static char *ensure_end(const char *str, char c) +{ + size_t len = strlen(str); + + return len && str[len - 1] == c ? xstrdup(str) : xstrfmt("%s%c", str, c); +} + static void prepare_context(void) { + const char *script_name = getenv("SCRIPT_NAME"); + memset(&ctx, 0, sizeof(ctx)); ctx.cfg.agefile = "info/web/last-modified"; - ctx.cfg.cache_size = 0; ctx.cfg.cache_root = CGIT_CACHE_ROOT; ctx.cfg.cache_about_ttl = 15; ctx.cfg.cache_snapshot_ttl = 5; @@ -106,11 +110,8 @@ static void prepare_context(void) // is served but not kept. ctx.cfg.cache_max_slot_size = 64 * 1024; ctx.cfg.case_sensitive_sort = 1; - ctx.cfg.branch_sort = 0; - ctx.cfg.commit_sort = 0; ctx.cfg.logo = "/cgit.png"; ctx.cfg.favicon = "/favicon.ico"; - ctx.cfg.local_time = 0; ctx.cfg.date_mode = date_mode_from_type(DATE_SHORT); ctx.cfg.enable_header = 1; ctx.cfg.enable_mailmap = 1; @@ -119,7 +120,6 @@ static void prepare_context(void) ctx.cfg.enable_http_clone = 1; ctx.cfg.enable_index_owner = 1; ctx.cfg.enable_tree_linenumbers = 1; - ctx.cfg.enable_git_config = 0; ctx.cfg.max_repo_count = 50; ctx.cfg.max_commit_count = 50; ctx.cfg.max_patch_count = 50; @@ -131,23 +131,16 @@ static void prepare_context(void) // Counted in kilobytes, so ten megabytes, which bounds the memory one // request can be made to allocate for a blob. ctx.cfg.max_blob_size = 10 * 1024; - ctx.cfg.max_stats = 0; - ctx.cfg.project_list = NULL; ctx.cfg.renamelimit = -1; - ctx.cfg.remove_suffix = 0; ctx.cfg.robots = "index, nofollow"; ctx.cfg.root_title = "Git repository browser"; ctx.cfg.root_desc = "a fast webinterface for the git dscm"; - ctx.cfg.scan_hidden_path = 0; - ctx.cfg.script_name = CGIT_SCRIPT_NAME; ctx.cfg.section = ""; ctx.cfg.repository_sort = "name"; - ctx.cfg.section_sort = 0; ctx.cfg.summary_branches = 10; ctx.cfg.summary_log = 10; ctx.cfg.summary_tags = 10; ctx.cfg.max_atom_items = 10; - ctx.cfg.difftype = DIFF_UNIFIED; ctx.env.cgit_config = getenv("CGIT_CONFIG"); ctx.env.http_host = getenv("HTTP_HOST"); ctx.env.https = getenv("HTTPS"); @@ -155,21 +148,16 @@ static void prepare_context(void) ctx.env.path_info = getenv("PATH_INFO"); ctx.env.query_string = getenv("QUERY_STRING"); ctx.env.request_method = getenv("REQUEST_METHOD"); - ctx.env.script_name = getenv("SCRIPT_NAME"); ctx.env.server_name = getenv("SERVER_NAME"); ctx.env.server_port = getenv("SERVER_PORT"); ctx.env.http_cookie = getenv("HTTP_COOKIE"); ctx.env.http_referer = getenv("HTTP_REFERER"); ctx.env.content_length = getenv("CONTENT_LENGTH") ? strtoul(getenv("CONTENT_LENGTH"), NULL, 10) : 0; - ctx.env.authenticated = 0; ctx.page.mimetype = "text/html"; ctx.page.charset = PAGE_ENCODING; - ctx.page.filename = NULL; - ctx.page.size = 0; string_list_init_dup(&ctx.cfg.mimetypes); - if (ctx.env.script_name) - ctx.cfg.script_name = xstrdup(ctx.env.script_name); + ctx.cfg.virtual_root = ensure_end(script_name ? script_name : CGIT_SCRIPT_NAME, '/'); if (ctx.env.query_string) ctx.qry.raw = xstrdup(ctx.env.query_string); if (!ctx.env.cgit_config) @@ -178,7 +166,7 @@ static void prepare_context(void) static void print_version(void) { - printf("CGit %s | https://github.com/brycekwon/cgit\n\nCompiled in features:\n", CGIT_VERSION); + printf("cgit %s | https://github.com/brycekwon/cgit\n\nCompiled in features:\n", CGIT_VERSION); #ifdef NO_LUA printf("[-] "); #else @@ -262,7 +250,7 @@ static void print_repo(FILE *f, struct cgit_repo *repo) cgit_fprintf_filter(repo->trailer_filter, f, "repo.trailer-filter="); if (repo->snapshots != ctx.cfg.snapshots) { char *formats = build_snapshot_setting(repo->snapshots); - fprintf(f, "repo.snapshots=%s\n", formats ? formats : ""); + fprintf(f, "repo.snapshots=%s\n", formats); free(formats); } if (repo->snapshot_prefix) @@ -279,12 +267,10 @@ static void print_repo(FILE *f, struct cgit_repo *repo) fprintf(f, "repo.enable-html-serving=%d\n", repo->enable_html_serving); if (repo->branch_sort == 1) fprintf(f, "repo.branch-sort=age\n"); - if (repo->commit_sort) { - if (repo->commit_sort == 1) - fprintf(f, "repo.commit-sort=date\n"); - else if (repo->commit_sort == 2) - fprintf(f, "repo.commit-sort=topo\n"); - } + if (repo->commit_sort == 1) + fprintf(f, "repo.commit-sort=date\n"); + else if (repo->commit_sort == 2) + fprintf(f, "repo.commit-sort=topo\n"); fprintf(f, "repo.hide=%d\n", repo->hide); fprintf(f, "repo.ignore=%d\n", repo->ignore); fprintf(f, "\n"); @@ -315,17 +301,6 @@ static void parse_args(int argc, const char **argv) ctx.env.no_http = "1"; } else if (skip_prefix(argv[i], "--query=", &arg)) { ctx.qry.raw = xstrdup(arg); - } else if (skip_prefix(argv[i], "--repo=", &arg)) { - ctx.qry.repo = xstrdup(arg); - } else if (skip_prefix(argv[i], "--page=", &arg)) { - ctx.qry.page = xstrdup(arg); - } else if (skip_prefix(argv[i], "--head=", &arg)) { - ctx.qry.head = xstrdup(arg); - } else if (skip_prefix(argv[i], "--oid=", &arg)) { - ctx.qry.oid = xstrdup(arg); - ctx.qry.has_oid = 1; - } else if (skip_prefix(argv[i], "--ofs=", &arg)) { - ctx.qry.ofs = atoi(arg); } else if ( skip_prefix(argv[i], "--scan-tree=", &arg) || skip_prefix(argv[i], "--scan-path=", &arg) @@ -333,73 +308,36 @@ static void parse_args(int argc, const char **argv) // A repository's snapshots setting is masked with the // global one, and cgitrc has not been read here, so an // empty mask would discard what the repository set. - ctx.cfg.snapshots = ALL_SNAPSHOT_FORMATS; + ctx.cfg.snapshots = INT_MAX; scanned++; scan_tree(arg); } } if (scanned) { - cgit_qsort(cgit_repolist.repos, cgit_repolist.count, sizeof(struct cgit_repo), cmp_repos); + QSORT(cgit_repolist.repos, cgit_repolist.count, cmp_repos); print_repolist(stdout, &cgit_repolist, 0); exit(0); } } /* - * The lock is a fcntl lock rather than the mere existence of the lock file, + * The lock is an flock rather than the mere existence of the lock file, * because a lock the kernel drops with its process cannot outlive a scan * killed mid-run. A leftover file would count as a scan forever in progress. */ static int generate_cached_repolist(const char *path, const char *cached_rc) { struct strbuf locked_rc = STRBUF_INIT; - struct flock lock = { - .l_type = F_WRLCK, - .l_whence = SEEK_SET, - .l_start = 0, - .l_len = 0, - }; - struct stat held, named; int err = 0; int fd; int first; FILE *f; strbuf_addf(&locked_rc, "%s.lock", cached_rc); - fd = open(locked_rc.buf, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR); - if (fd == -1) { - err = errno; - fprintf(stderr, "[cgit] Unable to open %s: %s (%d)\n", locked_rc.buf, strerror(err), err); - goto out; - } - if (fcntl(fd, F_SETLK, &lock) < 0) { - // A lock held elsewhere only means concurrent requests, which - // is not worth a line in the server log. Any other failure, a - // filesystem without lock support say, silently costs a scan - // on every request and does deserve one. - err = errno; - if (err != EACCES && err != EAGAIN) - fprintf(stderr, "[cgit] Unable to lock %s: %s (%d)\n", locked_rc.buf, strerror(err), err); - close(fd); - goto out; - } - // The lock landed on whatever inode the path named at open. A holder - // finishing in between renames that inode into place as the live - // list, and once the path is confirmed to still name this file that - // rename can no longer happen, because it takes the lock held here. - if ( - fstat(fd, &held) || - stat(locked_rc.buf, &named) || - held.st_ino != named.st_ino || - held.st_dev != named.st_dev - ) { - err = EAGAIN; - close(fd); + err = cache_lock_file(locked_rc.buf, &fd); + if (err) goto out; - } - // A run that died before its rename leaves the lock file behind, so - // start from empty now that nobody else can be writing it. - if (ftruncate(fd, 0) < 0 || !(f = fdopen(fd, "w"))) { + if (!(f = fdopen(fd, "w"))) { err = errno; fprintf(stderr, "[cgit] Unable to write %s: %s (%d)\n", locked_rc.buf, strerror(err), err); unlink(locked_rc.buf); @@ -407,10 +345,7 @@ static int generate_cached_repolist(const char *path, const char *cached_rc) goto out; } first = cgit_repolist.count; - if (ctx.cfg.project_list) - scan_projects(path, ctx.cfg.project_list); - else - scan_tree(path); + scan_tree(path); print_repolist(f, &cgit_repolist, first); // Flushed before the rename, because print_repolist writes through // stdio and a rename of the file would otherwise publish a repolist @@ -457,12 +392,8 @@ static void process_cached_repolist(const char *path) if (stat(cached_rc.buf, &st)) { // Nothing is cached yet, so this request scans in its own // process, leaving no copy behind when it cannot take the lock. - if (generate_cached_repolist(path, cached_rc.buf)) { - if (ctx.cfg.project_list) - scan_projects(path, ctx.cfg.project_list); - else - scan_tree(path); - } + if (generate_cached_repolist(path, cached_rc.buf)) + scan_tree(path); goto out; } @@ -504,6 +435,46 @@ static void add_mimetype(const char *name, const char *value) item->util = xstrdup(value); } +/* + * Accept only the date formats cgit documents, leaving mode unchanged for + * anything else. Git's parse_date_format dies on anything unknown, which would + * turn a cgitrc typo into a failed request. + */ +static void parse_date_setting(const char *format, struct date_mode *mode) +{ + static const char * const names[] = { + "default", "human", "iso", "iso-strict", "iso8601", + "iso8601-strict", "raw", "relative", "rfc", + "rfc2822", "short", "unix", + }; + const char *rest; + size_t i; + + if (skip_prefix(format, "format:", &rest)) { + // An empty strftime format would render every date blank. + if (*rest) + parse_date_format(format, mode); + return; + } + + // git reads local only as a whole alias of default-local, and dies on + // it with a suffix of its own. + if (!strcmp(format, "local")) { + parse_date_format(format, mode); + return; + } + + for (i = 0; i < ARRAY_SIZE(names); i++) { + if (!skip_prefix(format, names[i], &rest)) + continue; + // "iso" also prefixes "iso8601", so a partial hit is skipped. + if (*rest && strcmp(rest, "-local")) + continue; + parse_date_format(format, mode); + return; + } +} + static void apply_config(const char *name, const char *value) { const char *arg; @@ -545,7 +516,7 @@ static void apply_config(const char *name, const char *value) else if (!strcmp(name, "strict-export")) ctx.cfg.strict_export = cgit_strdup_first_line(value); else if (!strcmp(name, "virtual-root")) - ctx.cfg.virtual_root = cgit_ensure_end(value, '/'); + ctx.cfg.virtual_root = ensure_end(value, '/'); else if (!strcmp(name, "enable-plain-email")) ctx.cfg.enable_plain_email = atoi(value); else if (!strcmp(name, "enable-header")) @@ -657,8 +628,6 @@ static void apply_config(const char *name, const char *value) else if (!strcmp(name, "scan-path")) { if (ctx.cfg.cache_size) process_cached_repolist(cgit_expand_macros(value)); - else if (ctx.cfg.project_list) - scan_projects(cgit_expand_macros(value), ctx.cfg.project_list); else scan_tree(cgit_expand_macros(value)); // The scan grows the repository array, so a record taken before @@ -699,7 +668,7 @@ static void apply_config(const char *name, const char *value) else if (!strcmp(name, "local-time")) ctx.cfg.local_time = atoi(value); else if (!strcmp(name, "date-format")) - cgit_parse_date_format(value, &ctx.cfg.date_mode); + parse_date_setting(value, &ctx.cfg.date_mode); else if (!strcmp(name, "commit-sort")) { if (!strcmp(value, "date")) ctx.cfg.commit_sort = 1; @@ -738,9 +707,6 @@ static int query_int(const char *value, int min, int max) static void apply_query_param(const char *name, const char *value) { - if (!value) - value = ""; - if (!strcmp(name, "r")) { ctx.qry.repo = xstrdup(value); ctx.repo = cgit_get_repoinfo(value); @@ -757,10 +723,7 @@ static void apply_query_param(const char *name, const char *value) } else if (!strcmp(name, "qt")) { ctx.qry.grep = xstrdup(value); } else if (!strcmp(name, "q")) { - if (strlen(value) > MAX_SEARCH_LEN) - ctx.qry.search = xstrndup(value, MAX_SEARCH_LEN); - else - ctx.qry.search = xstrdup(value); + ctx.qry.search = xstrndup(value, MAX_SEARCH_LEN); } else if (!strcmp(name, "h")) { ctx.qry.head = xstrdup(value); } else if (!strcmp(name, "id")) { @@ -803,6 +766,23 @@ static void apply_query_param(const char *name, const char *value) } } +static void http_parse_querystring(const char *txt, void (*fn)(const char *name, const char *value)) +{ + const char *p = txt; + + while (p && *p) { + char *name = url_decode_parameter_name(&p); + // A name ends at an equals sign or an ampersand, and only the + // first is followed by a value. + if (*name) { + char *value = p[-1] == '=' ? url_decode_parameter_value(&p) : xstrdup(""); + fn(name, value); + free(value); + } + free(name); + } +} + static void open_auth_filter(const char *action) { cgit_open_filter(ctx.cfg.auth_filter, action, @@ -823,7 +803,7 @@ static void open_auth_filter(const char *action) * The filter answers the login POST itself, writing the status line and every * header, so nothing here prints any and the process ends before this returns. */ -static void authenticate_post(void) +static NORETURN void authenticate_post(void) { char buffer[MAX_AUTHENTICATION_POST_BYTES]; size_t len; @@ -856,10 +836,8 @@ static void authenticate_cookie(void) !ctx.repo && !strcmp(ctx.env.request_method, "POST") && !strcmp(ctx.qry.page, "login") - ) { + ) authenticate_post(); - return; - } open_auth_filter("authenticate-cookie"); ctx.env.authenticated = cgit_close_filter(ctx.cfg.auth_filter); @@ -884,33 +862,6 @@ static int is_full_oid(const char *rev) } /* - * Only these pages render the same bytes for the same id for ever. A log or - * refs page named with an id still lists branches and tags, which move. - */ -static int page_is_static(void) -{ - static const char *const pages[] = { - "blame", - "blob", - "commit", - "diff", - "patch", - "plain", - "rawdiff", - "snapshot", - "tag", - "tree", - }; - size_t i; - - for (i = 0; i < ARRAY_SIZE(pages); i++) { - if (!strcmp(ctx.qry.page, pages[i])) - return 1; - } - return 0; -} - -/* * Every cache-*-ttl setting is written in minutes, and so is this. */ static int calc_ttl(void) @@ -937,7 +888,8 @@ static int calc_ttl(void) // page to rebuild. if ( ctx.qry.has_oid && - page_is_static() && + cmd && + cmd->is_static && (!ctx.qry.oid || is_full_oid(ctx.qry.oid)) && (!ctx.qry.oid2 || is_full_oid(ctx.qry.oid2)) ) @@ -991,22 +943,13 @@ static int find_current_ref(const struct reference *ref, void *data) static char *find_default_branch(struct cgit_repo *repo) { - struct refmatch match; - char *ref; + struct refmatch match = { .wanted = repo->defbranch }; - match.wanted = repo->defbranch; - match.first = NULL; - match.found = 0; refs_for_each_branch_ref(get_main_ref_store(the_repository), find_current_ref, &match); - if (match.found) - ref = match.wanted; - else - ref = match.first; - if (ref) - ref = xstrdup(ref); + if (!match.found) + return match.first; free(match.first); - - return ref; + return xstrdup(match.wanted); } static char *guess_defbranch(void) @@ -1046,7 +989,7 @@ static void parse_readme(const char *readme, char **filename, char **ref, struct } if (!(*ref) && readme[0] != '/') - *filename = cgit_fmtalloc("%s/%s", repo->path, readme); + *filename = xstrfmt("%s/%s", repo->path, readme); else *filename = xstrdup(readme); } @@ -1072,7 +1015,7 @@ static void choose_readme(struct cgit_repo *repo) continue; } if (ref) { - if (cgit_ref_path_exists(filename, ref, 1)) { + if (cgit_ref_path_exists(filename, ref)) { found = 1; break; } @@ -1090,7 +1033,32 @@ static void choose_readme(struct cgit_repo *repo) string_list_append(&repo->readme, filename)->util = ref; } -static void prepare_repo_env(int *nongit, int *err) +/* + * Export the CGIT_REPO_* variables that filters and hooks read. + */ +static void prepare_repo_env(struct cgit_repo *repo) +{ + const struct { + const char *name; + const char *value; + } vars[] = { + { .name = "CGIT_REPO_URL", .value = repo->url }, + { .name = "CGIT_REPO_NAME", .value = repo->name }, + { .name = "CGIT_REPO_PATH", .value = repo->path }, + { .name = "CGIT_REPO_OWNER", .value = repo->owner }, + { .name = "CGIT_REPO_DEFBRANCH", .value = repo->defbranch }, + { .name = "CGIT_REPO_SECTION", .value = repo->section }, + { .name = "CGIT_REPO_CLONE_URL", .value = repo->clone_url } + }; + size_t i; + + for (i = 0; i < ARRAY_SIZE(vars); i++) + if (vars[i].value && setenv(vars[i].name, vars[i].value, 1)) + fprintf(stderr, "[cgit] Unable to set %s=%s: %s (%d)\n", + vars[i].name, vars[i].value, strerror(errno), errno); +} + +static void open_repo(int *nongit, int *err) { // A repository configured without a path has nothing to open. if (!ctx.repo->path) { @@ -1104,14 +1072,13 @@ static void prepare_repo_env(int *nongit, int *err) setup_git_directory_gently(the_repository, nongit); *err = errno; // Notes come out of the object store, which a repository that failed - // to open has none of, and git dies loading a notes ref that names - // no tree, so such a ref is passed over. + // to open has none of. The commit and log pages always ask for them, + // so they are set up either way, but a default ref that names no tree + // is left out, since git dies loading one. if (!*nongit) { struct display_notes_opt opt; char *ref = default_notes_ref(the_repository); - // The notes are always set up, since the commit and log pages - // ask for them, only with no ref when the default names no tree. init_display_notes(&opt); opt.use_default_notes = cgit_notes_ref_usable(ref); load_display_notes(&opt); @@ -1126,11 +1093,16 @@ static void prepare_repo_env(int *nongit, int *err) */ static int prepare_repo_cmd(int nongit, int err, int clone) { + // Static, since the pages replace the title without freeing it. Reset + // on entry, since a failed cache fill renders the page a second time. + static struct strbuf title = STRBUF_INIT; struct object_id oid; + strbuf_reset(&title); if (nongit) { const char *name = ctx.repo->name; - ctx.page.title = cgit_fmtalloc("%s - %s", ctx.cfg.root_title, "config error"); + strbuf_addf(&title, "%s - %s", ctx.cfg.root_title, "config error"); + ctx.page.title = title.buf; ctx.repo = NULL; cgit_print_error_page( 404, "Not Found", "Failed to open %s: %s", name, @@ -1138,7 +1110,8 @@ static int prepare_repo_cmd(int nongit, int err, int clone) ); return 1; } - ctx.page.title = cgit_fmtalloc("%s - %s", ctx.repo->name, ctx.cfg.root_title); + strbuf_addf(&title, "%s - %s", ctx.repo->name, ctx.cfg.root_title); + ctx.page.title = title.buf; if (!ctx.repo->defbranch) ctx.repo->defbranch = guess_defbranch(); @@ -1152,19 +1125,17 @@ static int prepare_repo_cmd(int nongit, int err, int clone) // The dumb transport serves refs and objects off the disk and // asks nothing of the head, and an empty repository clones. if (clone) { - cgit_prepare_repo_env(ctx.repo); + prepare_repo_env(ctx.repo); return 0; } ctx.empty_repo = 1; // Before the document starts, since the <head> carries // <link rel='vcs-git'> and those clone urls expand macros such // as $CGIT_REPO_URL out of this environment. - cgit_prepare_repo_env(ctx.repo); - cgit_print_http_headers(); - cgit_print_docstart(); - cgit_print_pageheader(); + prepare_repo_env(ctx.repo); + cgit_print_layout_start(); cgit_print_empty_repo(); - cgit_print_docend(); + cgit_print_layout_end(); return 1; } @@ -1185,7 +1156,7 @@ static int prepare_repo_cmd(int nongit, int err, int clone) return 1; } string_list_sort(&ctx.repo->submodules); - cgit_prepare_repo_env(ctx.repo); + prepare_repo_env(ctx.repo); choose_readme(ctx.repo); return 0; } @@ -1199,17 +1170,15 @@ static void process_request(void) // whatever page it asked for. if (!ctx.env.authenticated) { ctx.page.title = "Authentication Required"; - cgit_print_http_headers(); - cgit_print_docstart(); - cgit_print_pageheader(); + cgit_print_layout_start(); open_auth_filter("body"); cgit_close_filter(ctx.cfg.auth_filter); - cgit_print_docend(); + cgit_print_layout_end(); return; } if (ctx.repo) - prepare_repo_env(&nongit, &err); + open_repo(&nongit, &err); cmd = cgit_get_cmd(); if (!cmd || (!ctx.cfg.enable_http_clone && cmd->is_clone)) { @@ -1340,18 +1309,12 @@ int cmd_main(int argc, const char **argv) set_die_routine(die_routine); prepare_context(); - cgit_repolist.length = 0; - cgit_repolist.count = 0; - cgit_repolist.repos = NULL; parse_args(argc, argv); config_file_parse(cgit_expand_macros(ctx.env.cgit_config), apply_config); ctx.repo = NULL; http_parse_querystring(ctx.qry.raw, apply_query_param); - if (!ctx.cfg.virtual_root && ctx.cfg.script_name) - ctx.cfg.virtual_root = cgit_ensure_end(ctx.cfg.script_name, '/'); - // Falling back to PATH_INFO lets cgit serve virtual urls without a // rewrite rule in the web server, and folding it into the raw query // string keeps it part of the cache key. @@ -1362,7 +1325,7 @@ int cmd_main(int argc, const char **argv) path++; ctx.qry.url = xstrdup(path); if (ctx.qry.raw) { - char *path_and_query = cgit_fmtalloc("%s?%s", path, ctx.qry.raw); + char *path_and_query = xstrfmt("%s?%s", path, ctx.qry.raw); free(ctx.qry.raw); ctx.qry.raw = path_and_query; } else { @@ -1387,7 +1350,6 @@ int cmd_main(int argc, const char **argv) max_bytes, process_request); strbuf_release(&cache_key); - cgit_cleanup_filters(); // A slot that could not be sent because the client went away is not // worth an error page nobody will read. if (err && err != EPIPE) diff --git a/source/cgit.h b/source/cgit.h index 5e17a3e..da202b7 100644 --- a/source/cgit.h +++ b/source/cgit.h @@ -135,7 +135,6 @@ struct commitinfo { int committer_tz; char *subject; char *msg; - char *msg_encoding; }; struct taginfo { @@ -146,21 +145,6 @@ struct taginfo { char *msg; }; -struct refinfo { - const char *refname; - struct object *object; - union { - struct taginfo *tag; - struct commitinfo *commit; - }; -}; - -struct reflist { - struct refinfo **refs; - int alloc; - int count; -}; - struct cgit_query { int has_oid; int has_difftype; @@ -207,7 +191,6 @@ struct cgit_config { const char *root_title; const char *root_desc; char *root_readme; - const char *script_name; const char *section; const char *repository_sort; // Always ends with a slash. @@ -303,7 +286,6 @@ struct cgit_environment { const char *path_info; const char *query_string; const char *request_method; - const char *script_name; const char *server_name; const char *server_port; const char *http_cookie; diff --git a/source/cgit.mk b/source/cgit.mk index 0786086..66b94d3 100644 --- a/source/cgit.mk +++ b/source/cgit.mk @@ -4,9 +4,7 @@ # through CGIT_ROOT. The top level Makefile invokes it roughly as # # make -C vendor/git -f ../../source/cgit.mk ../../build/cgit -# -# so sources are read from ../../source and all output is written to -# ../../build. + include Makefile # BUILDDIR is named relative to the project root because the version recipe @@ -64,8 +62,11 @@ ifdef NO_LUA LUA_MESSAGE := linking without specified Lua support CGIT_CFLAGS += -DNO_LUA else +# Each system names Lua's package its own way: lua5.4 on Debian and Alpine, +# lua-5.4 on FreeBSD and NetBSD, lua54 on OpenBSD. ifeq ($(LUA_PKGCONFIG),) - LUA_PKGCONFIG := $(shell for pc in luajit lua lua5.5 lua5.4 lua5.3 lua5.2 lua5.1; do \ + LUA_PKGCONFIG := $(shell for pc in luajit lua lua5.5 lua5.4 lua5.3 lua5.2 lua5.1 \ + lua-5.5 lua-5.4 lua-5.3 lua-5.2 lua-5.1 lua55 lua54 lua53 lua52 lua51; do \ $(PKG_CONFIG) --exists $$pc 2>/dev/null && echo $$pc && break; \ done) LUA_MODE := autodetected @@ -158,9 +159,9 @@ $(CGIT_BUILD)/.depend: $(CGIT_BUILD)/CGIT-CFLAGS: FORCE @mkdir -p $(CGIT_BUILD)/ @FLAGS='$(subst ','\'',$(CGIT_CFLAGS))'; \ - OLD=`cat $(CGIT_BUILD)/CGIT-CFLAGS 2>/dev/null`; \ - if test x"$$FLAGS" != x"$$OLD"; then \ - echo 1>&2 " * new CGit build flags"; \ + OLD=$$(cat $(CGIT_BUILD)/CGIT-CFLAGS 2>/dev/null); \ + if test "$$FLAGS" != "$$OLD"; then \ + echo 1>&2 " * new cgit build flags"; \ echo "$$FLAGS" >$(CGIT_BUILD)/CGIT-CFLAGS; \ fi diff --git a/source/cmd.c b/source/cmd.c index 22a6c19..3fd393b 100644 --- a/source/cmd.c +++ b/source/cmd.c @@ -1,14 +1,13 @@ /* - * One thunk per page, each pulling the parts of the request its renderer - * needs out of the global ctx, plus the table that maps a page name onto its - * thunk. Keeping the thunks here lets every renderer take ordinary arguments - * instead of reaching into the request state itself. + * A renderer that reads the request itself goes into the page table as it is. + * The others sit behind a thunk here that pulls their arguments out of the + * global ctx or does some work first, such as checking that the page is + * enabled. */ #include "cache.h" #include "cgit.h" #include "cmd.h" -#include "html.h" #include "ui-atom.h" #include "ui-blame.h" #include "ui-blob.h" @@ -27,11 +26,6 @@ #include "ui-tag.h" #include "ui-tree.h" -static void head_fn(void) -{ - cgit_clone_head(); -} - static void about_fn(void) { char *query, *summary; @@ -75,7 +69,7 @@ static void blame_fn(void) static void blob_fn(void) { - cgit_print_blob(ctx.qry.oid, ctx.qry.path, ctx.qry.head, 0); + cgit_print_blob(ctx.qry.oid, ctx.qry.path, ctx.qry.head); } static void commit_fn(void) @@ -89,11 +83,6 @@ static void diff_fn(void) cgit_print_diff(ctx.qry.oid, ctx.qry.oid2, ctx.qry.path, 1, 0); } -static void info_fn(void) -{ - cgit_clone_info(); -} - static void log_fn(void) { cgit_prefix_title("log"); @@ -122,21 +111,11 @@ static void ls_cache_fn(void) cache_ls(ctx.cfg.cache_root); } -static void objects_fn(void) -{ - cgit_clone_objects(); -} - static void patch_fn(void) { cgit_print_patch(ctx.qry.oid, ctx.qry.oid2, ctx.qry.path); } -static void plain_fn(void) -{ - cgit_print_plain(); -} - static void rawdiff_fn(void) { cgit_print_diff(ctx.qry.oid, ctx.qry.oid2, ctx.qry.path, 1, 1); @@ -148,11 +127,6 @@ static void refs_fn(void) cgit_print_refs(); } -static void repolist_fn(void) -{ - cgit_print_repolist(); -} - static void snapshot_fn(void) { cgit_print_snapshot(ctx.qry.head, ctx.qry.oid, ctx.qry.path, ctx.qry.nohead); @@ -168,11 +142,6 @@ static void stats_fn(void) } } -static void summary_fn(void) -{ - cgit_print_summary(); -} - static void tag_fn(void) { cgit_print_tag(ctx.qry.oid); @@ -186,27 +155,27 @@ static void tree_fn(void) // The name is what a request spells as p=, so these strings are part of the // URL space and cannot be renamed. static const struct cgit_cmd commands[] = { - { .name = "HEAD", .fn = head_fn, .want_repo = 1, .is_clone = 1 }, + { .name = "HEAD", .fn = cgit_clone_head, .want_repo = 1, .is_clone = 1 }, { .name = "about", .fn = about_fn }, { .name = "atom", .fn = atom_fn, .want_repo = 1 }, - { .name = "blame", .fn = blame_fn, .want_repo = 1, .want_vpath = 1 }, - { .name = "blob", .fn = blob_fn, .want_repo = 1 }, - { .name = "commit", .fn = commit_fn, .want_repo = 1, .want_vpath = 1 }, - { .name = "diff", .fn = diff_fn, .want_repo = 1, .want_vpath = 1 }, - { .name = "info", .fn = info_fn, .want_repo = 1, .is_clone = 1 }, + { .name = "blame", .fn = blame_fn, .want_repo = 1, .want_vpath = 1, .is_static = 1 }, + { .name = "blob", .fn = blob_fn, .want_repo = 1, .is_static = 1 }, + { .name = "commit", .fn = commit_fn, .want_repo = 1, .want_vpath = 1, .is_static = 1 }, + { .name = "diff", .fn = diff_fn, .want_repo = 1, .want_vpath = 1, .is_static = 1 }, + { .name = "info", .fn = cgit_clone_info, .want_repo = 1, .is_clone = 1 }, { .name = "log", .fn = log_fn, .want_repo = 1, .want_vpath = 1 }, { .name = "ls_cache", .fn = ls_cache_fn }, - { .name = "objects", .fn = objects_fn, .want_repo = 1, .is_clone = 1 }, - { .name = "patch", .fn = patch_fn, .want_repo = 1, .want_vpath = 1 }, - { .name = "plain", .fn = plain_fn, .want_repo = 1 }, - { .name = "rawdiff", .fn = rawdiff_fn, .want_repo = 1, .want_vpath = 1 }, + { .name = "objects", .fn = cgit_clone_objects, .want_repo = 1, .is_clone = 1 }, + { .name = "patch", .fn = patch_fn, .want_repo = 1, .want_vpath = 1, .is_static = 1 }, + { .name = "plain", .fn = cgit_print_plain, .want_repo = 1, .is_static = 1 }, + { .name = "rawdiff", .fn = rawdiff_fn, .want_repo = 1, .want_vpath = 1, .is_static = 1 }, { .name = "refs", .fn = refs_fn, .want_repo = 1 }, - { .name = "repolist", .fn = repolist_fn }, - { .name = "snapshot", .fn = snapshot_fn, .want_repo = 1 }, + { .name = "repolist", .fn = cgit_print_repolist }, + { .name = "snapshot", .fn = snapshot_fn, .want_repo = 1, .is_static = 1 }, { .name = "stats", .fn = stats_fn, .want_repo = 1, .want_vpath = 1 }, - { .name = "summary", .fn = summary_fn, .want_repo = 1 }, - { .name = "tag", .fn = tag_fn, .want_repo = 1 }, - { .name = "tree", .fn = tree_fn, .want_repo = 1, .want_vpath = 1 }, + { .name = "summary", .fn = cgit_print_summary, .want_repo = 1 }, + { .name = "tag", .fn = tag_fn, .want_repo = 1, .is_static = 1 }, + { .name = "tree", .fn = tree_fn, .want_repo = 1, .want_vpath = 1, .is_static = 1 }, }; const struct cgit_cmd *cgit_find_cmd(const char *name) diff --git a/source/cmd.h b/source/cmd.h index f47878b..a855574 100644 --- a/source/cmd.h +++ b/source/cmd.h @@ -14,6 +14,10 @@ struct cgit_cmd { const char *name; cgit_cmd_fn fn; unsigned int want_repo:1, want_vpath:1, is_clone:1; + // Set on the pages that render the same bytes for the same id for + // ever. A log or refs page named with an id still lists branches and + // tags, which move. + unsigned int is_static:1; }; /* diff --git a/source/config.c b/source/config.c index b029613..49cd398 100644 --- a/source/config.c +++ b/source/config.c @@ -85,7 +85,7 @@ static int read_entry(FILE *f, struct strbuf *name, struct strbuf *value) } } -int config_file_parse(const char *filename, config_file_value_fn fn) +void config_file_parse(const char *filename, config_file_value_fn fn) { static int nesting; struct strbuf name = STRBUF_INIT; @@ -95,9 +95,9 @@ int config_file_parse(const char *filename, config_file_value_fn fn) // An include line calls back into here, so an include cycle would // recurse until the stack gave out. if (nesting > MAX_INCLUDE_NESTING) - return -1; + return; if (!(f = fopen(filename, "r"))) - return -1; + return; nesting++; while (read_entry(f, &name, &value)) fn(name.buf, value.buf); @@ -105,5 +105,4 @@ int config_file_parse(const char *filename, config_file_value_fn fn) fclose(f); strbuf_release(&name); strbuf_release(&value); - return 0; } diff --git a/source/config.h b/source/config.h index ab42e2f..3ddf974 100644 --- a/source/config.h +++ b/source/config.h @@ -7,10 +7,8 @@ #ifndef CGIT_CONFIG_H #define CGIT_CONFIG_H -#include "cgit.h" - typedef void (*config_file_value_fn)(const char *name, const char *value); -extern int config_file_parse(const char *filename, config_file_value_fn fn); +extern void config_file_parse(const char *filename, config_file_value_fn fn); #endif // CGIT_CONFIG_H diff --git a/source/filter.c b/source/filter.c index f2dfe12..6cc5e80 100644 --- a/source/filter.c +++ b/source/filter.c @@ -25,6 +25,31 @@ // The exec filter holding stdout, for cgit_abort_filters to take it back. static struct cgit_exec_filter *running_exec; +/* + * Each of these returns its argument untouched when the syscall it wraps + * succeeded, and dies with the message and errno otherwise. + */ +static int die_unless_zero(int result, const char *msg) +{ + if (result != 0) + die_errno("%s", msg); + return result; +} + +static int die_unless_positive(int result, const char *msg) +{ + if (result <= 0) + die_errno("%s", msg); + return result; +} + +static int die_unless_non_negative(int result, const char *msg) +{ + if (result < 0) + die_errno("%s", msg); + return result; +} + static int open_exec_filter(struct cgit_filter *base, va_list ap) { struct cgit_exec_filter *filter = (struct cgit_exec_filter *)base; @@ -34,12 +59,12 @@ static int open_exec_filter(struct cgit_filter *base, va_list ap) for (i = 0; i < filter->base.argument_count; i++) filter->argv[i + 1] = va_arg(ap, char *); - filter->old_stdout = cgit_die_unless_positive(dup(STDOUT_FILENO), "Unable to duplicate stdout"); + filter->old_stdout = die_unless_positive(dup(STDOUT_FILENO), "Unable to duplicate stdout"); // The child keeps the page's stdout, but not the descriptor the parent // needs it back from. fcntl(filter->old_stdout, F_SETFD, FD_CLOEXEC); - cgit_die_unless_zero(pipe(pipefd), "Unable to create pipe to subprocess"); - filter->pid = cgit_die_unless_non_negative(fork(), "Unable to create subprocess"); + die_unless_zero(pipe(pipefd), "Unable to create pipe to subprocess"); + filter->pid = die_unless_non_negative(fork(), "Unable to create subprocess"); if (filter->pid == 0) { close(pipefd[1]); if (dup2(pipefd[0], STDIN_FILENO) < 0) @@ -55,7 +80,7 @@ static int open_exec_filter(struct cgit_filter *base, va_list ap) _exit(EXEC_FAILED); } close(pipefd[0]); - cgit_die_unless_non_negative(dup2(pipefd[1], STDOUT_FILENO), "Unable to use pipe as stdout"); + die_unless_non_negative(dup2(pipefd[1], STDOUT_FILENO), "Unable to use pipe as stdout"); close(pipefd[1]); running_exec = filter; return 0; @@ -64,22 +89,16 @@ static int open_exec_filter(struct cgit_filter *base, va_list ap) static int close_exec_filter(struct cgit_filter *base) { struct cgit_exec_filter *filter = (struct cgit_exec_filter *)base; - int i, exit_status = 0; + int exit_status = 0; running_exec = NULL; - cgit_die_unless_non_negative(dup2(filter->old_stdout, STDOUT_FILENO), "Unable to restore stdout"); + die_unless_non_negative(dup2(filter->old_stdout, STDOUT_FILENO), "Unable to restore stdout"); close(filter->old_stdout); - if (filter->pid < 0) - goto done; waitpid(filter->pid, &exit_status, 0); if (!WIFEXITED(exit_status)) die("Subprocess %s exited abnormally", filter->cmd); if (WEXITSTATUS(exit_status) == EXEC_FAILED) die("Unable to run filter %s", filter->cmd); - -done: - for (i = 0; i < filter->base.argument_count; i++) - filter->argv[i + 1] = NULL; return WEXITSTATUS(exit_status); } @@ -103,16 +122,13 @@ static void cleanup_exec_filter(struct cgit_filter *base) static struct cgit_filter *new_exec_filter(const char *cmd, int argument_count) { struct cgit_exec_filter *filter; - int argv_size; filter = xmalloc(sizeof(*filter)); cgit_exec_filter_init(filter, cgit_strdup_first_line(cmd), NULL); filter->base.argument_count = argument_count; // argv is the command, then a slot per argument, then the NULL that // execvp needs to find the end. - argv_size = (2 + argument_count) * sizeof(char *); - filter->argv = xmalloc(argv_size); - memset(filter->argv, 0, argv_size); + filter->argv = xcalloc(2 + argument_count, sizeof(char *)); filter->argv[0] = filter->cmd; return &filter->base; } @@ -126,15 +142,8 @@ void cgit_exec_filter_init(struct cgit_exec_filter *filter, const char *cmd, con filter->base.cleanup = cleanup_exec_filter; filter->cmd = cmd; filter->argv = argv; - filter->base.argument_count = 0; } -#ifdef NO_LUA -void cgit_init_filters(void) -{ -} -#endif - #ifndef NO_LUA struct lua_filter { struct cgit_filter base; @@ -279,19 +288,9 @@ static int script_html_url_arg(lua_State *lua_state) return emit_unfiltered(lua_state, html_url_arg); } -/* - * html_include returns whether it found the file, and calling it through a - * pointer that claims it returns nothing would be undefined, so the result is - * dropped in a wrapper of the right shape. - */ -static void include_and_discard_result(const char *filename) -{ - html_include(filename); -} - static int script_html_include(lua_State *lua_state) { - return emit_unfiltered(lua_state, include_and_discard_result); + return emit_unfiltered(lua_state, html_include); } static const struct { @@ -383,8 +382,7 @@ static struct cgit_filter *new_lua_filter(const char *cmd, int argument_count) { struct lua_filter *filter; - filter = xmalloc(sizeof(*filter)); - memset(filter, 0, sizeof(*filter)); + filter = xcalloc(1, sizeof(*filter)); filter->base.open = open_lua_filter; filter->base.close = close_lua_filter; filter->base.fprintfp = fprintf_lua_filter; @@ -394,6 +392,10 @@ static struct cgit_filter *new_lua_filter(const char *cmd, int argument_count) return &filter->base; } +#else +void cgit_init_filters(void) +{ +} #endif int cgit_open_filter(struct cgit_filter *filter, ...) @@ -477,35 +479,14 @@ int cgit_filter_holds_stdout(void) return running_exec != NULL; } -static const struct { - const char *prefix; - struct cgit_filter *(*create)(const char *cmd, int argument_count); -} filter_specs[] = { - { "exec", new_exec_filter }, -#ifndef NO_LUA - { "lua", new_lua_filter }, -#endif -}; - struct cgit_filter *cgit_new_filter(const char *cmd, filter_type filtertype) { - const char *colon; - size_t i; - size_t len; + const char *colon, *arg; int argument_count; if (!cmd || !cmd[0]) return NULL; - colon = strchr(cmd, ':'); - // Measured only once there is a colon to measure against, because - // subtracting from a null pointer is not something C defines. - len = colon ? (size_t)(colon - cmd) : 0; - // A single letter before the colon is a Windows drive letter, not a - // filter prefix. - if (len == 1) - colon = NULL; - switch (filtertype) { case AUTH: argument_count = 12; @@ -526,17 +507,20 @@ struct cgit_filter *cgit_new_filter(const char *cmd, filter_type filtertype) break; } - if (!colon) - return new_exec_filter(cmd, argument_count); - - for (i = 0; i < ARRAY_SIZE(filter_specs); i++) { - if (len == strlen(filter_specs[i].prefix) && !strncmp(filter_specs[i].prefix, cmd, len)) - return filter_specs[i].create(colon + 1, argument_count); - } - + if (skip_prefix(cmd, "exec:", &arg)) + return new_exec_filter(arg, argument_count); + if (skip_prefix(cmd, "lua:", &arg)) { #ifdef NO_LUA - if (len == 3 && !strncmp("lua", cmd, len)) die("Built without Lua, cannot run filter %s", cmd); +#else + return new_lua_filter(arg, argument_count); #endif - die("Invalid filter type: %.*s", (int)len, cmd); + } + + // A single letter before the colon is a Windows drive letter, not a + // filter prefix. + colon = strchr(cmd, ':'); + if (!colon || colon - cmd == 1) + return new_exec_filter(cmd, argument_count); + die("Invalid filter type: %.*s", (int)(colon - cmd), cmd); } diff --git a/source/html.c b/source/html.c index d13ac5b..bc7a722 100644 --- a/source/html.c +++ b/source/html.c @@ -16,7 +16,11 @@ #define HTML_WRITE_BUFSIZE (64 * 1024) // The percent encoding for each byte, with NULL marking the bytes a URL may -// carry as themselves. Those are the letters, the digits, and !$()*,-./:;@[]_~ +// carry as themselves. An ampersand or plus is legal in a path but encoded all +// the same, since a bare plus in a query argument decodes back as a space, and +// an ampersand in an attribute value can start a character reference and +// quietly turn "a©.txt" into a different filename. The bytes left as +// themselves are the letters, the digits, and !$()*,-./:;@[]_~ static const char *url_escape_table[256] = { "%00", "%01", "%02", "%03", "%04", "%05", "%06", "%07", "%08", "%09", "%0A", "%0B", "%0C", "%0D", "%0E", "%0F", @@ -122,18 +126,6 @@ char *cgit_fmt(const char *format, ...) return buf[slot]; } -char *cgit_fmtalloc(const char *format, ...) -{ - struct strbuf sb = STRBUF_INIT; - va_list args; - - va_start(args, format); - strbuf_vaddf(&sb, format, args); - va_end(args); - - return strbuf_detach(&sb, NULL); -} - void html_flush(void) { size_t len = out_len; @@ -218,6 +210,24 @@ void html_vtxtf(const char *format, va_list ap) strbuf_release(&sb); } +/* + * The entity for a character that page text would otherwise read as markup, or + * NULL for any other character. + */ +static const char *txt_entity(int c) +{ + switch (c) { + case '<': + return "<"; + case '>': + return ">"; + case '&': + return "&"; + default: + return NULL; + } +} + void html_txt(const char *txt) { if (txt) @@ -234,15 +244,11 @@ ssize_t html_ntxt(const char *txt, size_t len) left = (ssize_t)len; while (p && *p && left--) { - int c = *p; - if (c == '<' || c == '>' || c == '&') { + const char *entity = txt_entity(*p); + + if (entity) { html_raw(txt, p - txt); - if (c == '>') - html(">"); - else if (c == '<') - html("<"); - else if (c == '&') - html("&"); + html(entity); txt = p + 1; } p++; @@ -308,16 +314,10 @@ void html_blob_txt(const char *txt, size_t len) const char *p; for (p = txt; p < end; p++) { - const char *entity = NULL, *esc = NULL; + const char *entity = txt_entity(*p), *esc = NULL; size_t width = 1; - if (*p == '<') - entity = "<"; - else if (*p == '>') - entity = ">"; - else if (*p == '&') - entity = "&"; - else + if (!entity) esc = html_line_break_escape(p, end, &width); if (!entity && !esc) continue; @@ -355,19 +355,17 @@ void html_attr(const char *txt) const char *p = txt; while (p && *p) { - int c = *p; - if (c == '<' || c == '>' || c == '\'' || c == '\"' || c == '&') { + const char *entity; + + if (*p == '\'') + entity = "'"; + else if (*p == '"') + entity = """; + else + entity = txt_entity(*p); + if (entity) { html_raw(txt, p - txt); - if (c == '>') - html(">"); - else if (c == '<') - html("<"); - else if (c == '\'') - html("'"); - else if (c == '"') - html("""); - else if (c == '&') - html("&"); + html(entity); txt = p + 1; } p++; @@ -382,11 +380,6 @@ void html_url_path(const char *txt) while (p && *p) { unsigned char c = *p; - // A raw ampersand or plus is legal in a URL path, but this - // table is shared with html_url_arg, where a bare plus decodes - // back as a space, and the paths written here land in attribute - // values, where an ampersand can start a character reference - // and quietly turn "a©.txt" into a different filename. const char *esc = url_escape_table[c]; if (esc) { html_raw(txt, p - txt); @@ -511,36 +504,7 @@ void html_intoption(int value, const char *text, int selected_value) html("</option>\n"); } -void html_link_open(const char *url, const char *title, const char *class) -{ - html("<a href='"); - html_attr(url); - if (title) { - html("' title='"); - html_attr(title); - } - if (class) { - html("' class='"); - html_attr(class); - } - html("'>"); -} - -void html_link_close(void) -{ - html("</a>"); -} - -/* - * Render one permission triplet, so a caller prints a whole mode by passing it - * shifted right by six, then by three, then unshifted. - */ -void html_fileperm(unsigned short mode) -{ - htmlf("%c%c%c", (mode & 4 ? 'r' : '-'), (mode & 2 ? 'w' : '-'), (mode & 1 ? 'x' : '-')); -} - -int html_include(const char *filename) +void html_include(const char *filename) { FILE *f; char buf[4096]; @@ -548,27 +512,9 @@ int html_include(const char *filename) if (!(f = fopen(filename, "r"))) { fprintf(stderr, "[cgit] Unable to include %s: %s (%d)\n", filename, strerror(errno), errno); - return -1; + return; } while ((len = fread(buf, 1, sizeof(buf), f)) > 0) html_raw(buf, len); fclose(f); - return 0; -} - -void http_parse_querystring(const char *txt, void (*fn)(const char *name, const char *value)) -{ - const char *p = txt; - - while (p && *p) { - char *name = url_decode_parameter_name(&p); - // A name ends at an equals sign or an ampersand, and only the - // first is followed by a value. - if (*name) { - char *value = p[-1] == '=' ? url_decode_parameter_value(&p) : xstrdup(""); - fn(name, value); - free(value); - } - free(name); - } } diff --git a/source/html.h b/source/html.h index 9cad3a6..d3cd8fe 100644 --- a/source/html.h +++ b/source/html.h @@ -92,16 +92,8 @@ extern void html_hidden(const char *name, const char *value); __attribute__((format (printf,1,2))) extern char *cgit_fmt(const char *format, ...); -__attribute__((format (printf,1,2))) -extern char *cgit_fmtalloc(const char *format, ...); - extern void html_option(const char *value, const char *text, const char *selected_value); extern void html_intoption(int value, const char *text, int selected_value); -extern void html_link_open(const char *url, const char *title, const char *class); -extern void html_link_close(void); -extern void html_fileperm(unsigned short mode); -extern int html_include(const char *filename); - -extern void http_parse_querystring(const char *txt, void (*fn)(const char *name, const char *value)); +extern void html_include(const char *filename); #endif // CGIT_HTML_H diff --git a/source/parsing.c b/source/parsing.c index 71ecdb7..f684241 100644 --- a/source/parsing.c +++ b/source/parsing.c @@ -4,7 +4,8 @@ * the request path settles which repository and which page the request works * on. Commit and tag objects arrive as header lines, a blank line, and then * the message, so the readers here walk the headers, copy out the fields the - * pages need, and re-encode the message into the page encoding. + * pages need, and re-encode the message into the page encoding. The records + * they return are freed here too. */ #define USE_THE_REPOSITORY_VARIABLE @@ -15,22 +16,6 @@ #include <mailmap.h> -static char *substr(const char *start, const char *end) -{ - size_t len; - char *buf; - - if (end < start) - return xstrdup(""); - // start points into the object buffer, so strlcpy would measure the - // whole rest of the object to copy a name off the front of it. - len = end - start; - buf = xmalloc(len + 1); - memcpy(buf, start, len); - buf[len] = '\0'; - return buf; -} - // The repository's mailmap, read once per request and applied to every ident a // page shows. Only the blob at HEAD is read, the way git reads a bare // repository, so a repository cannot point the reader at a file on the server. @@ -112,22 +97,21 @@ static int end_of_header(const char *p) * not UTF-8 has its stray bytes replaced, and a page declared UTF-8 never * carries any. */ -static const char *reencode(char **text, const char *from, const char *to) +static void reencode(char **text, const char *encoding) { char *converted; - if (!*text || !from || !to) - return *text; + if (!*text) + return; - if (strcasecmp(from, to)) { - converted = reencode_string(*text, to, from); + if (strcasecmp(encoding, PAGE_ENCODING)) { + converted = reencode_string(*text, PAGE_ENCODING, encoding); if (converted) { free(*text); *text = converted; } } cgit_utf8_sanitize(*text); - return *text; } /* @@ -182,6 +166,7 @@ struct commitinfo *cgit_parse_commit(struct commit *commit) struct commitinfo *info; const char *p = repo_get_commit_buffer(the_repository, commit, NULL); const char *eol; + char *encoding = NULL; info = xcalloc(1, sizeof(struct commitinfo)); info->commit = commit; @@ -210,15 +195,15 @@ struct commitinfo *cgit_parse_commit(struct commit *commit) if (p && skip_prefix(p, "encoding ", &p)) { eol = strchr(p, '\n'); if (eol) { - info->msg_encoding = substr(p, eol); + encoding = xmemdupz(p, eol - p); p = eol + 1; } } // Git only writes the header when the message is in something other // than UTF-8, so its absence means UTF-8. - if (!info->msg_encoding) - info->msg_encoding = xstrdup("UTF-8"); + if (!encoding) + encoding = xstrdup("UTF-8"); while (!end_of_header(p)) p = next_header_line(p); @@ -227,7 +212,7 @@ struct commitinfo *cgit_parse_commit(struct commit *commit) if (p) { eol = strchrnul(p, '\n'); - info->subject = substr(p, eol); + info->subject = xmemdupz(p, eol - p); while (*eol == '\n') eol++; info->msg = xstrdup(eol); @@ -238,16 +223,28 @@ struct commitinfo *cgit_parse_commit(struct commit *commit) info->msg = xstrdup(""); } - reencode(&info->author, info->msg_encoding, PAGE_ENCODING); - reencode(&info->author_email, info->msg_encoding, PAGE_ENCODING); - reencode(&info->committer, info->msg_encoding, PAGE_ENCODING); - reencode(&info->committer_email, info->msg_encoding, PAGE_ENCODING); - reencode(&info->subject, info->msg_encoding, PAGE_ENCODING); - reencode(&info->msg, info->msg_encoding, PAGE_ENCODING); + reencode(&info->author, encoding); + reencode(&info->author_email, encoding); + reencode(&info->committer, encoding); + reencode(&info->committer_email, encoding); + reencode(&info->subject, encoding); + reencode(&info->msg, encoding); + free(encoding); return info; } +void cgit_free_commitinfo(struct commitinfo *info) +{ + free(info->author); + free(info->author_email); + free(info->committer); + free(info->committer_email); + free(info->subject); + free(info->msg); + free(info); +} + struct taginfo *cgit_parse_tag(struct tag *tag) { void *data; @@ -283,3 +280,16 @@ cleanup: free(data); return info; } + +void cgit_free_taginfo(struct taginfo *info) +{ + // NULL is accepted because cgit_parse_tag returns it for a tag object + // that cannot be read, and a ref left holding that still gets freed + // along with the rest of its list. + if (!info) + return; + free(info->tagger); + free(info->tagger_email); + free(info->msg); + free(info); +} diff --git a/source/parsing.h b/source/parsing.h index 755d646..c0bdebc 100644 --- a/source/parsing.h +++ b/source/parsing.h @@ -2,7 +2,8 @@ * Readers that turn raw git object bodies and the incoming request URL into * the records cgit renders from. Commit and tag objects arrive as flat text, * so the work here is splitting off the headers and re-encoding the remaining - * free text into the page encoding. + * free text into the page encoding. Each record is freed beside the reader that + * builds it. */ #ifndef CGIT_PARSING_H @@ -18,6 +19,8 @@ extern void cgit_parse_url(const char *url); extern struct string_list *cgit_mailmap(void); extern struct commitinfo *cgit_parse_commit(struct commit *commit); +extern void cgit_free_commitinfo(struct commitinfo *info); extern struct taginfo *cgit_parse_tag(struct tag *tag); +extern void cgit_free_taginfo(struct taginfo *info); #endif // CGIT_PARSING_H diff --git a/source/scan-tree.c b/source/scan-tree.c index 10f661d..ec991e0 100644 --- a/source/scan-tree.c +++ b/source/scan-tree.c @@ -1,11 +1,7 @@ /* - * Discovery of repositories on disk, so that an administrator can point cgit - * at a directory instead of naming every repository in cgitrc. The tree below - * that directory is walked for bare repositories and working trees, or when a - * project list is configured only the paths it names are visited. Every - * repository found is registered under its path relative to the root of the - * scan, with owner, description and section taken from the files git keeps - * beside it, and a cgitrc in the repository has the last word over all of them. + * Every repository found takes its owner, description and section from the + * files git keeps beside it, and a cgitrc in the repository has the last word + * over all of them. */ #include "cgit.h" @@ -154,7 +150,6 @@ static void add_repo(const char *base, struct strbuf *path) size_t pathlen; struct strbuf relpath = STRBUF_INIT; char *comma; - size_t desc_size; if (stat(path->buf, &st)) { fprintf(stderr, "[cgit] Unable to access %s: %s (%d)\n", path->buf, strerror(errno), errno); @@ -185,23 +180,20 @@ static void add_repo(const char *base, struct strbuf *path) // Drop the trailing slash added above, or the "/.git" suffix test // below never matches and a working tree is named "repo/.git". - if (relpath.len && relpath.buf[relpath.len - 1] == '/') - strbuf_setlen(&relpath, relpath.len - 1); - if (relpath.len >= 5 && !strcmp(relpath.buf + relpath.len - 5, "/.git")) - strbuf_setlen(&relpath, relpath.len - 5); - else if (!strcmp(relpath.buf, ".git")) + strbuf_strip_suffix(&relpath, "/"); + if (!strbuf_strip_suffix(&relpath, "/.git") && !strcmp(relpath.buf, ".git")) strbuf_setlen(&relpath, 0); // The scan root may itself be the repository, leaving nothing after // the base, so that one is named after its directory. if (!relpath.len) { - const char *end = path->buf + pathlen - 1, *start; + size_t len = pathlen - 1; + const char *start; - if (end - path->buf >= 5 && !strncmp(end - 5, "/.git", 5)) - end -= 5; - start = end; + strip_suffix_mem(path->buf, &len, "/.git"); + start = path->buf + len; while (start > path->buf && start[-1] != '/') start--; - strbuf_add(&relpath, start, end - start); + strbuf_add(&relpath, start, path->buf + len - start); } current_repo = cgit_add_repo(relpath.buf); @@ -226,23 +218,27 @@ static void add_repo(const char *base, struct strbuf *path) strip_suffix_mem(current_repo->url, &urllen, "/"); current_repo->url[urllen] = '\0'; } - while (!current_repo->owner) { + if (!current_repo->owner) { + // A uid with no passwd entry is no error, and getpwuid tells + // the two apart only by setting errno on a failure. + errno = 0; if (!(pwd = getpwuid(st.st_uid))) { - fprintf(stderr, "[cgit] Unable to read the owner of %s: %s (%d)\n", - path->buf, strerror(errno), errno); - break; + if (errno) + fprintf(stderr, "[cgit] Unable to read the owner of %s: %s (%d)\n", + path->buf, strerror(errno), errno); + } else { + // A gecos field puts the owner's name in front of a + // comma separated list of office and phone details. + if (pwd->pw_gecos && (comma = strchr(pwd->pw_gecos, ','))) + *comma = '\0'; + current_repo->owner = + cgit_strdup_first_line(pwd->pw_gecos ? pwd->pw_gecos : pwd->pw_name); } - // A gecos field puts the owner's name in front of a comma - // separated list of office and phone details. - if (pwd->pw_gecos && (comma = strchr(pwd->pw_gecos, ','))) - *comma = '\0'; - current_repo->owner = cgit_strdup_first_line(pwd->pw_gecos ? pwd->pw_gecos : pwd->pw_name); } - if (current_repo->desc == cgit_default_repo_desc || !current_repo->desc) { + if (current_repo->desc == cgit_default_repo_desc) { strbuf_addstr(path, "description"); - if (!stat(path->buf, &st)) - cgit_read_first_line(path->buf, ¤t_repo->desc, &desc_size); + cgit_read_first_line(path->buf, ¤t_repo->desc); strbuf_setlen(path, pathlen); if (current_repo->desc && !strcmp(current_repo->desc, default_git_desc)) { free(current_repo->desc); @@ -254,8 +250,7 @@ static void add_repo(const char *base, struct strbuf *path) set_section_from_path(&relpath, ctx.cfg.section_from_path); strbuf_addstr(path, "cgitrc"); - if (!stat(path->buf, &st)) - config_file_parse(path->buf, apply_cgitrc); + config_file_parse(path->buf, apply_cgitrc); strbuf_release(&relpath); } @@ -344,7 +339,7 @@ end: closedir(dir); } -void scan_projects(const char *path, const char *projectsfile) +static void scan_projects(const char *path, const char *projectsfile) { struct strbuf line = STRBUF_INIT; FILE *projects; @@ -374,5 +369,8 @@ void scan_projects(const char *path, const char *projectsfile) void scan_tree(const char *path) { - scan_path(path, path); + if (ctx.cfg.project_list) + scan_projects(path, ctx.cfg.project_list); + else + scan_path(path, path); } diff --git a/source/scan-tree.h b/source/scan-tree.h index 515037a..e29f8bf 100644 --- a/source/scan-tree.h +++ b/source/scan-tree.h @@ -10,7 +10,6 @@ #ifndef CGIT_SCAN_TREE_H #define CGIT_SCAN_TREE_H -extern void scan_projects(const char *path, const char *projectsfile); extern void scan_tree(const char *path); #endif // CGIT_SCAN_TREE_H diff --git a/source/shared.c b/source/shared.c index a20773b..1e1bb26 100644 --- a/source/shared.c +++ b/source/shared.c This diff is too large to be rendered inline. View it on its own page. diff --git a/source/shared.h b/source/shared.h index 2bf305d..302b752 100644 --- a/source/shared.h +++ b/source/shared.h @@ -1,9 +1,8 @@ /* - * The helpers that belong to no single page. Repository registration, the - * lifetimes of the reference and commit records the renderers build, thin - * wrappers over git's diff machinery, and a handful of string, date and - * mimetype utilities all live here because several renderers need them. The - * two process-wide globals, ctx and cgit_repolist, are defined here as well. + * The helpers that more than one file needs. Repository registration and thin + * wrappers over git's diff machinery make up most of it, beside a few string + * and request checks. The two process-wide globals, ctx and cgit_repolist, + * are defined here as well. */ #ifndef CGIT_SHARED_H @@ -17,14 +16,6 @@ typedef void (*filepair_fn)(struct diff_filepair *pair); typedef void (*linediff_fn)(char *line, int len); /* - * Each of these returns its argument untouched when the syscall it wraps - * succeeded, and dies with the message and errno otherwise. - */ -extern int cgit_die_unless_zero(int result, const char *msg); -extern int cgit_die_unless_positive(int result, const char *msg); -extern int cgit_die_unless_non_negative(int result, const char *msg); - -/* * Append a repository to cgit_repolist and return it, seeded from the current * global configuration. The returned pointer is invalidated by the next call, * because the list grows by reallocation. @@ -33,22 +24,6 @@ extern struct cgit_repo *cgit_add_repo(const char *url); extern struct cgit_repo *cgit_get_repoinfo(const char *url); /* - * Export the CGIT_REPO_* variables that filters and hooks read. - */ -extern void cgit_prepare_repo_env(struct cgit_repo *repo); - -extern void cgit_free_reflist_inner(struct reflist *list); - -/* - * Matches git's reference iteration callback, and appends each ref it is given - * to the struct reflist passed as cb_data. - */ -extern int cgit_refs_cb(const struct reference *ref, void *cb_data); - -extern void cgit_free_commitinfo(struct commitinfo *info); -extern void cgit_free_taginfo(struct taginfo *info); - -/* * A diff format callback that hands each filepair to the filepair_fn data * points at. ISO C has no conversion between a function pointer and void *, * so the function travels by address. @@ -66,13 +41,6 @@ extern int cgit_diff_files(const struct object_id *old_oid, const struct object_ linediff_fn fn); /* - * Whether a blob of size bytes is past max-blob-size. Callers ask before - * reading the object, so a huge blob stays out of memory rather than being - * noticed once it is already there. - */ -extern int cgit_blob_too_large(size_t size); - -/* * Limit a diff to the paths below prefix, which is taken literally. */ extern void cgit_diff_limit_path(struct diff_options *opt, const char *prefix); @@ -82,23 +50,28 @@ extern void cgit_diff_tree(const struct object_id *old_oid, const struct object_ extern void cgit_diff_commit(struct commit *commit, filepair_fn fn, const char *prefix); /* - * Accept only the date formats cgit documents, leaving mode unchanged for - * anything else. + * Whether a blob of size bytes is past max-blob-size. Callers ask before + * reading the object, so a huge blob stays out of memory rather than being + * noticed once it is already there. + */ +extern int cgit_blob_too_large(size_t size); + +/* + * Point ps at item alone, set to match path literally rather than as a glob, + * or at no item when path is NULL, which leaves every path matching. */ -extern void cgit_parse_date_format(const char *format, struct date_mode *mode); +extern void cgit_literal_pathspec(struct pathspec *ps, struct pathspec_item *item, const char *path); /* * Copy str without its trailing runs of c, returning NULL if nothing is left. */ extern char *cgit_trim_end(const char *str, char c); -extern char *cgit_ensure_end(const char *str, char c); -extern void strbuf_ensure_end(struct strbuf *sb, char c); /* * Read the first line of a file into a newly allocated, zero terminated * buffer. Returns 0 on success and an errno value otherwise. */ -extern int cgit_read_first_line(const char *path, char **buf, size_t *size); +extern int cgit_read_first_line(const char *path, char **buf); extern char *cgit_strdup_first_line(const char *text); /* @@ -119,8 +92,6 @@ extern void cgit_utf8_sanitize(char *text); */ extern char *cgit_expand_macros(const char *text); -extern char *cgit_get_mimetype_for_filename(const char *filename); - /* * Whether a readme setting names a file tracked in the repository, written * as ref:path or :path, rather than a file on the server's disk. @@ -140,16 +111,4 @@ extern int cgit_valid_rev(const char *rev); */ extern int cgit_notes_ref_usable(const char *notes_ref); -/* - * qsort for a list that may be empty. C leaves a null base undefined even - * with nothing to sort, and glibc declares it nonnull, while the lists here - * stay null until their first entry. - */ -static inline void cgit_qsort(void *base, size_t n, size_t size, - int (*cmp)(const void *, const void *)) -{ - if (n) - qsort(base, n, size, cmp); -} - #endif // CGIT_SHARED_H diff --git a/source/ui-atom.c b/source/ui-atom.c index 55b59a1..b9762b1 100644 --- a/source/ui-atom.c +++ b/source/ui-atom.c @@ -105,7 +105,6 @@ static void print_entry(struct commit *commit, const char *host) { struct commitinfo *info; char *hex, *pageurl; - char delim = '&'; info = cgit_parse_commit(commit); hex = oid_to_hex(&commit->object.oid); @@ -135,15 +134,8 @@ static void print_entry(struct commit *commit, const char *host) xml_txt(feed_date(info->author_date)); html("</published>\n"); html("<link rel='alternate' type='text/html' href='"); - html(cgit_httpscheme()); - html_attr(host); - pageurl = cgit_pageurl(ctx.repo->url, "commit", NULL); - html_attr(pageurl); - // Without a virtual root the page url is already a query string, so - // the commit id continues it instead of opening one. - if (ctx.cfg.virtual_root) - delim = '?'; - html_attrf("%cid=%s", delim, hex); + pageurl = cgit_pageurl(ctx.repo->url, "commit", cgit_fmt("id=%s", hex)); + html_attrf("%s%s%s", cgit_httpscheme(), host, pageurl); html("'/>\n"); free(pageurl); html("<id>"); @@ -168,13 +160,8 @@ void cgit_print_atom(char *tip, const char *path, int max_count) int argc = 2; bool need_updated = true; - // A leading colon makes git read the path as a pathspec with magic, - // which can end the request inside git, so the feed refuses it as the - // log does. - if (path && path[0] == ':') { - cgit_print_error_page(400, "Bad Request", "Invalid path"); + if (cgit_refuse_pathspec_magic(path)) return; - } if (ctx.qry.show_all) argv[1] = "--all"; @@ -187,18 +174,10 @@ void cgit_print_atom(char *tip, const char *path, int max_count) } repo_init_revisions(the_repository, &rev, NULL); - rev.abbrev = DEFAULT_ABBREV; - rev.commit_format = CMIT_FMT_DEFAULT; - rev.verbose_header = 1; - rev.show_root_diff = 0; rev.max_count = max_count; setup_revisions(argc, argv, &rev, NULL); - // A failed setup leaves the walk holding freed commits, so it must - // not be read from. - if (prepare_revision_walk(&rev)) { - cgit_print_error_page(500, "Internal Server Error", "Unable to read the history"); + if (cgit_prepare_walk(&rev)) return; - } // CGI guarantees a server name, so only a bare test run reaches the // fallback, which keeps the mandatory feed id and links present. @@ -230,10 +209,10 @@ void cgit_print_atom(char *tip, const char *path, int max_count) html("<id>"); xml_txt(idbuf.buf); html("</id>\n"); - strbuf_release(&idbuf); html("<link rel='self' href='"); - html_attrf("%s%s%s", cgit_httpscheme(), host, fullurl); + html_attr(idbuf.buf); html("'/>\n"); + strbuf_release(&idbuf); html("<link rel='alternate' type='text/html' href='"); html_attrf("%s%s%s", cgit_httpscheme(), host, repourl); html("'/>\n"); diff --git a/source/ui-blame.c b/source/ui-blame.c index 48cbf8e..eef8602 100644 --- a/source/ui-blame.c +++ b/source/ui-blame.c @@ -24,7 +24,7 @@ enum blame_target { }; struct walk_tree_context { - char *rev; + const char *rev; int match_baselen; enum blame_target found; }; @@ -34,15 +34,6 @@ struct walk_tree_context { // detail is cached here and looked up by object id. static struct string_list suspect_details = STRING_LIST_INIT_DUP; -static void free_suspect_details(void) -{ - struct string_list_item *item; - - for_each_string_list_item(item, &suspect_details) - free(item->util); - string_list_clear(&suspect_details, 0); -} - /* * The scoreboard keeps a pointer to revs, so the caller owns both and has to * keep them alive together. @@ -57,7 +48,8 @@ static void run_blame(struct blame_scoreboard *sb, struct rev_info *revs, strvec_push(&argv, rev); repo_init_revisions(the_repository, revs, NULL); revs->diffopt.flags.allow_textconv = 1; - setup_revisions(argv.nr, argv.v, revs, NULL); + setup_revisions_from_strvec(&argv, revs, NULL); + strvec_clear(&argv); init_scoreboard(sb); sb->revs = revs; sb->repo = the_repository; @@ -67,7 +59,6 @@ static void run_blame(struct blame_scoreboard *sb, struct rev_info *revs, prio_queue_put(&sb->commits, origin->commit); blame_origin_decref(origin); sb->ent = NULL; - sb->path = path; assign_blame(sb, 0); blame_sort_final(sb); blame_coalesce(sb); @@ -161,44 +152,20 @@ static void emit_entry_hash(struct blame_entry *ent) emit_chars('\n', ent->num_lines); } -static void emit_hashes(struct blame_scoreboard *sb) -{ - struct blame_entry *ent; - - html("<td class='hashes'>"); - for (ent = sb->ent; ent; ent = ent->next) { - html("<div class='alt'><pre>"); - emit_entry_hash(ent); - html("</pre></div>"); - } - html("</td>\n"); -} - static void emit_entry_linenumbers(struct blame_entry *ent) { - struct strbuf numbers = STRBUF_INIT; - int lineno = ent->lno; - - while (lineno < ent->lno + ent->num_lines) { - lineno++; - strbuf_addf(&numbers, "<a id='n%d' href='#n%d'>%d</a>\n", lineno, lineno, lineno); - if (numbers.len >= HTML_BATCH) { - html_raw(numbers.buf, numbers.len); - strbuf_reset(&numbers); - } - } - html_raw(numbers.buf, numbers.len); - strbuf_release(&numbers); + cgit_print_line_anchors((size_t)ent->lno + 1, (size_t)ent->lno + ent->num_lines); } -static void emit_linenumbers(struct blame_scoreboard *sb) +static void emit_column(struct blame_scoreboard *sb, const char *class, + void (*emit_entry)(struct blame_entry *ent)) { struct blame_entry *ent; - html("<td class='linenumbers'>"); + htmlf("<td class='%s'>", class); for (ent = sb->ent; ent; ent = ent->next) { html("<div class='alt'><pre>"); - emit_entry_linenumbers(ent); + emit_entry(ent); html("</pre></div>"); } html("</td>\n"); @@ -220,9 +187,10 @@ static size_t line_width(struct blame_scoreboard *sb, int line) pos += len; continue; } - width++; if (*pos++ == '\t') - width = (width + TAB_WIDTH - 1) & ~(TAB_WIDTH - 1); + width += TAB_WIDTH - width % TAB_WIDTH; + else + width++; } return width; } @@ -275,8 +243,9 @@ static void print_blame_page(const struct object_id *oid, const char *path, enum object_type type; char *buf; size_t size; - struct rev_info revs; + struct rev_info revs = REV_INFO_INIT; struct blame_scoreboard sb; + int binary; type = odb_read_object_info(the_repository->objects, oid, &size); if (type == OBJ_BAD) { @@ -295,7 +264,13 @@ static void print_blame_page(const struct object_id *oid, const char *path, return; } - run_blame(&sb, &revs, path, rev); + // A NUL past the window buffer_is_binary sniffs would end a Lua + // filter's output early while the hash and line number columns still + // cover the whole file, so a blob holding one anywhere is treated as + // binary too. + binary = buffer_is_binary(buf, size) || memchr(buf, 0, size); + if (!binary) + run_blame(&sb, &revs, path, rev); cgit_set_title_from_path(path); cgit_prefix_title("blame"); @@ -307,29 +282,16 @@ static void print_blame_page(const struct object_id *oid, const char *path, cgit_tree_link("tree", NULL, NULL, ctx.qry.head, rev, path); html(")</div>\n"); - // A NUL past the window buffer_is_binary sniffs would end a Lua - // filter's output early while the hash and line number columns still - // cover the whole file, so a blob holding one anywhere is treated as - // binary too. - if (buffer_is_binary(buf, size) || memchr(buf, 0, size)) { - struct blame_entry *ent = sb.ent, *next; - - // The scoreboard is normally consumed by the column passes, - // so this early exit has to release it itself. - for (; ent; ent = next) { - next = ent->next; - free(ent); - } - free((void *)sb.final_buf); + if (binary) { html("<div class='error'>Blob is binary</div>"); goto cleanup; } html("<table class='blame blob'>\n<tr>\n"); - emit_hashes(&sb); + emit_column(&sb, "hashes", emit_entry_hash); if (ctx.cfg.enable_tree_linenumbers) - emit_linenumbers(&sb); + emit_column(&sb, "linenumbers", emit_entry_linenumbers); html("<td class='lines'><div>"); emit_line_backgrounds(&sb); @@ -338,12 +300,9 @@ static void print_blame_page(const struct object_id *oid, const char *path, html("<pre><code>"); if (ctx.repo->source_filter) { - char *filter_arg = xstrdup(filename); - - cgit_open_filter(ctx.repo->source_filter, filter_arg); + cgit_open_filter(ctx.repo->source_filter, filename); html_raw(buf, size); cgit_close_filter(ctx.repo->source_filter); - free(filter_arg); } else { html_blob_txt(buf, size); } @@ -355,8 +314,9 @@ static void print_blame_page(const struct object_id *oid, const char *path, cleanup: cgit_print_layout_end(); - free_suspect_details(); + string_list_clear(&suspect_details, 1); free(buf); + release_revisions(&revs); } static int walk_tree(const struct object_id *oid, struct strbuf *base, @@ -399,18 +359,8 @@ void cgit_print_blame(void) const char *rev = ctx.qry.oid; struct object_id oid; struct commit *commit; - int path_len = ctx.qry.path ? strlen(ctx.qry.path) : 0; - // nowildcard_len matches len so git treats the path as literal rather - // than as a glob, which is what a request naming one file means. - struct pathspec_item path_items = { - .match = ctx.qry.path, - .len = path_len, - .nowildcard_len = path_len - }; - struct pathspec paths = { - .nr = 1, - .items = &path_items - }; + struct pathspec_item path_item; + struct pathspec paths = { 0 }; struct walk_tree_context walk = { .found = TARGET_MISSING }; @@ -418,24 +368,17 @@ void cgit_print_blame(void) if (!rev) rev = ctx.qry.head; - if (repo_get_oid(the_repository, rev, &oid)) { - cgit_print_error_page(404, "Not Found", "Bad object id: %s", rev); + commit = cgit_get_commit(rev, &oid); + if (!commit) return; - } - commit = lookup_commit_reference(the_repository, &oid); - if (!commit || repo_parse_commit(the_repository, commit)) { - cgit_print_error_page(404, "Not Found", "Not a commit: %s", rev); - return; - } - walk.rev = xstrdup(rev); - walk.match_baselen = path_items.match ? basedir_len(path_items.match) : -1; + walk.rev = rev; + walk.match_baselen = ctx.qry.path ? basedir_len(ctx.qry.path) : -1; + cgit_literal_pathspec(&paths, &path_item, ctx.qry.path); read_tree(the_repository, repo_get_commit_tree(the_repository, commit), &paths, walk_tree, &walk); if (walk.found == TARGET_MISSING) cgit_print_error_page(404, "Not Found", "Not found"); else if (walk.found == TARGET_FOLDER) cgit_print_error_page(404, "Not Found", "Blame is not available for a directory"); - - free(walk.rev); } diff --git a/source/ui-blob.c b/source/ui-blob.c index 4690bb5..4ed9b3b 100644 --- a/source/ui-blob.c +++ b/source/ui-blob.c @@ -34,10 +34,6 @@ static void peel_to_commit(struct object_id *oid) oidcpy(oid, &commit->object.oid); } -/* - * read_tree reads the return value as a direction rather than a status, so - * READ_TREE_RECURSIVE means step into this entry and zero means step over it. - */ static int walk_tree(const struct object_id *oid, struct strbuf *base, const char *pathname, unsigned mode, void *context) { @@ -59,24 +55,14 @@ static int walk_tree(const struct object_id *oid, struct strbuf *base, /* * oid comes in naming a commit and goes out naming the blob found at path, - * which every caller relies on. The path has to be writable because a pathspec - * item does not hold a const string. + * which every caller relies on. */ -static int find_path_oid(struct object_id *oid, char *path, int file_only) +static int find_path_oid(struct object_id *oid, const char *path, int file_only) { struct commit *commit = lookup_commit_reference(the_repository, oid); struct tree *tree; - // nowildcard_len matching len makes git treat the path as literal - // rather than as a glob. - struct pathspec_item item = { - .match = path, - .len = strlen(path), - .nowildcard_len = strlen(path) - }; - struct pathspec paths = { - .nr = 1, - .items = &item - }; + struct pathspec_item item; + struct pathspec paths = { 0 }; struct walk_tree_context walk = { .match_path = path, .matched_oid = oid, @@ -91,30 +77,25 @@ static int find_path_oid(struct object_id *oid, char *path, int file_only) tree = repo_get_commit_tree(the_repository, commit); if (!tree) return 0; + cgit_literal_pathspec(&paths, &item, path); read_tree(the_repository, tree, &paths, walk_tree, &walk); return walk.found_path; } -int cgit_ref_path_exists(const char *path, const char *ref, int file_only) +int cgit_ref_path_exists(const char *path, const char *ref) { struct object_id oid; size_t size; - char *path_copy = xstrdup(path); - int found = 0; if (repo_get_oid(the_repository, ref, &oid)) - goto done; + return 0; peel_to_commit(&oid); if (odb_read_object_info(the_repository->objects, &oid, &size) != OBJ_COMMIT) - goto done; - found = find_path_oid(&oid, path_copy, file_only); - -done: - free(path_copy); - return found; + return 0; + return find_path_oid(&oid, path, 1); } -int cgit_print_file(char *path, const char *head, int file_only, int html_escape) +int cgit_print_file(char *path, const char *head, int html_escape) { struct object_id oid; enum object_type type; @@ -126,7 +107,7 @@ int cgit_print_file(char *path, const char *head, int file_only, int html_escape peel_to_commit(&oid); type = odb_read_object_info(the_repository->objects, &oid, &size); if (type == OBJ_COMMIT) { - if (!find_path_oid(&oid, path, file_only)) + if (!find_path_oid(&oid, path, 1)) return -1; type = odb_read_object_info(the_repository->objects, &oid, &size); } @@ -138,9 +119,8 @@ int cgit_print_file(char *path, const char *head, int file_only, int html_escape if (!buf) return -1; - // html_txt wants a terminated string, and git leaves a spare byte - // past every object it reads, so this write stays in the allocation. - buf[size] = '\0'; + // html_txt wants a terminated string, which git leaves past every + // object it reads. if (html_escape) { // html_txt stops at a NUL, so a blob holding one is written // segment by segment with a question mark standing in for @@ -162,7 +142,7 @@ int cgit_print_file(char *path, const char *head, int file_only, int html_escape return 0; } -void cgit_print_blob(const char *hex, char *path, const char *head, int file_only) +void cgit_print_blob(const char *hex, char *path, const char *head) { struct object_id oid; enum object_type type; @@ -191,7 +171,7 @@ void cgit_print_blob(const char *hex, char *path, const char *head, int file_onl } if (type == OBJ_COMMIT && path) { - if (!find_path_oid(&oid, path, file_only)) { + if (!find_path_oid(&oid, path, 0)) { cgit_print_error_page(404, "Not Found", "Path not found: %s", path); return; } @@ -215,11 +195,7 @@ void cgit_print_blob(const char *hex, char *path, const char *head, int file_onl return; } - buf[size] = '\0'; - if (buffer_is_binary(buf, size)) - ctx.page.mimetype = "application/octet-stream"; - else - ctx.page.mimetype = "text/plain"; + ctx.page.mimetype = buffer_is_binary(buf, size) ? "application/octet-stream" : "text/plain"; ctx.page.filename = path; ctx.page.untrusted = 1; cgit_print_http_headers(); diff --git a/source/ui-blob.h b/source/ui-blob.h index 2c10c55..4f8597b 100644 --- a/source/ui-blob.h +++ b/source/ui-blob.h @@ -8,15 +8,15 @@ #ifndef CGIT_UI_BLOB_H #define CGIT_UI_BLOB_H -extern int cgit_ref_path_exists(const char *path, const char *ref, int file_only); +extern int cgit_ref_path_exists(const char *path, const char *ref); /* * Write the file at path in head, escaped when html_escape is set. The return * is 0 once it is written, -2 when it is past max-blob-size, and -1 when it * cannot be found or read. */ -extern int cgit_print_file(char *path, const char *head, int file_only, int html_escape); +extern int cgit_print_file(char *path, const char *head, int html_escape); -extern void cgit_print_blob(const char *hex, char *path, const char *head, int file_only); +extern void cgit_print_blob(const char *hex, char *path, const char *head); #endif // CGIT_UI_BLOB_H diff --git a/source/ui-clone.c b/source/ui-clone.c index dad21d5..bdfbdf7 100644 --- a/source/ui-clone.c +++ b/source/ui-clone.c @@ -38,19 +38,6 @@ static int print_ref(const struct reference *ref, void *cb_data UNUSED) } /* - * A path ending in a slash is handed back whole, where git's own - * pack_basename would return the empty string. - */ -static const char *last_path_component(const char *path) -{ - const char *slash = strrchr(path, '/'); - - if (slash && slash[1] != '\0') - return slash + 1; - return path; -} - -/* * Only the packs this repository holds itself are listed, because one * borrowed from an alternate is not reachable below this URL and a client * told about it would come back for a file that is not there. @@ -74,7 +61,7 @@ static void print_pack_info(void) struct packed_git *pack = entry->pack; if (pack->pack_local) - htmlf("P %s\n", last_path_component(pack->pack_name)); + htmlf("P %s\n", pack_basename(pack)); } } } @@ -169,8 +156,10 @@ void cgit_clone_objects(void) { char *path; - if (!ctx.qry.path) - goto err; + if (!ctx.qry.path) { + cgit_print_error_page(400, "Bad Request", "Bad request"); + return; + } if (!strcmp(ctx.qry.path, "info/packs")) { print_pack_info(); @@ -185,10 +174,6 @@ void cgit_clone_objects(void) path = repo_git_path(the_repository, "objects/%s", ctx.qry.path); send_file(path); free(path); - return; - -err: - cgit_print_error_page(400, "Bad Request", "Bad request"); } /* diff --git a/source/ui-commit.c b/source/ui-commit.c index 63f2b7e..0916c33 100644 --- a/source/ui-commit.c +++ b/source/ui-commit.c @@ -1,10 +1,9 @@ /* - * The commit page, which shows one commit on its own and is where the log and - * the ref listings link. It renders the idents, the object ids, the message, - * the trailer table when the repository asks for one and any note, then hands - * off to ui-diff.c for the diff against the first parent. Free text and - * addresses are written through the repository's commit, trailer and email - * filters, so a site can rewrite any of them on the way out. + * The commit page renders the idents, the object ids, the message, the trailer + * table when the repository asks for one and any note, then hands off to + * ui-diff.c for the diff against the first parent. Free text and addresses are + * written through the repository's commit, trailer and email filters, so a + * site can rewrite any of them on the way out. */ #define USE_THE_REPOSITORY_VARIABLE @@ -13,7 +12,6 @@ #include "filter.h" #include "html.h" #include "parsing.h" -#include "shared.h" #include "ui-commit.h" #include "ui-diff.h" #include "ui-log.h" @@ -32,18 +30,10 @@ static void print_ident_row(const char *role, const char *name, const char *emai timestamp_t date, int tz) { htmlf("<tr><th scope='row'>%s</th><td>", role); - cgit_open_filter(ctx.repo->email_filter, email ? email : "", "commit"); - html_txt(name); - if (ctx.cfg.enable_plain_email) { - html(" "); - html_txt(email); - } - cgit_close_filter(ctx.repo->email_filter); - html("</td><td class='right'><time datetime='"); - html_attr(show_date(date, tz, cgit_date_mode(DATE_ISO8601_STRICT))); - html("'>"); - html_txt(show_date(date, tz, cgit_date_mode(DATE_ISO8601))); - html("</time></td></tr>\n"); + cgit_print_person(name, email, "commit", 1); + html("</td><td class='right'>"); + cgit_print_date(date, tz); + html("</td></tr>\n"); } /* @@ -106,6 +96,7 @@ static void print_trailer_value(const char *key, const char *value) struct string_list *map; struct strbuf email = STRBUF_INIT; const char *name, *mail, *rest; + char *who; size_t name_len, mail_len; if (!split_ident_line(&ident, value, strlen(value)) && @@ -120,13 +111,9 @@ static void print_trailer_value(const char *key, const char *value) strbuf_addch(&email, '<'); strbuf_add(&email, mail, mail_len); strbuf_addch(&email, '>'); - cgit_open_filter(ctx.repo->email_filter, email.buf, "commit"); - html_ntxt(name, name_len); - if (ctx.cfg.enable_plain_email) { - html(" "); - html_txt(email.buf); - } - cgit_close_filter(ctx.repo->email_filter); + who = xmemdupz(name, name_len); + cgit_print_person(who, email.buf, "commit", 1); + free(who); strbuf_release(&email); rest = ident.mail_end + 1; while (isspace((unsigned char)*rest)) @@ -247,28 +234,21 @@ void cgit_print_commit(char *hex, const char *prefix) struct object_id oid, first_oid; const char *commit_hex; char *first_parent; - char *tree_rev; int parents; if (!hex) hex = ctx.qry.head; - if (repo_get_oid(the_repository, hex, &oid)) { - cgit_print_error_page(404, "Not Found", "Bad object id: %s", hex); + commit = cgit_get_commit(hex, &oid); + if (!commit) return; - } - commit = lookup_commit_reference(the_repository, &oid); - if (!commit) { - cgit_print_error_page(404, "Not Found", "Not a commit: %s", hex); - return; - } info = cgit_parse_commit(commit); format_display_notes(&commit->object.oid, ¬es, PAGE_ENCODING, 1); load_ref_decorations(NULL, DECORATE_FULL_REFS); - ctx.page.title = cgit_fmtalloc("%s - %s", info->subject, ctx.page.title); + ctx.page.title = xstrfmt("%s - %s", info->subject, ctx.page.title); cgit_print_layout_start(); // An octopus merge draws no diff, so it has no options to offer. if (commit_list_count(commit->parents) < OCTOPUS_PARENTS) @@ -293,13 +273,11 @@ void cgit_print_commit(char *hex, const char *prefix) parents = print_parent_rows(commit, hex, prefix, &first_oid); html("<tr><th scope='row'>tree</th><td colspan='2' class='oid'><div class='spanning'>"); - tree_rev = xstrdup(hex); - cgit_tree_link(oid_to_hex(get_commit_tree_oid(commit)), NULL, NULL, ctx.qry.head, tree_rev, NULL); + cgit_tree_link(oid_to_hex(get_commit_tree_oid(commit)), NULL, NULL, ctx.qry.head, hex, NULL); if (prefix) { html(" /"); - cgit_tree_link(prefix, NULL, NULL, ctx.qry.head, tree_rev, prefix); + cgit_tree_link(prefix, NULL, NULL, ctx.qry.head, hex, prefix); } - free(tree_rev); html("</div></td></tr>\n"); if (ctx.repo->snapshots) { diff --git a/source/ui-diff.c b/source/ui-diff.c index 91ad860..315a8f0 100644 --- a/source/ui-diff.c +++ b/source/ui-diff.c @@ -54,7 +54,6 @@ static int bodies_usable; static linediff_fn render_line_fn; static int render_suppressed; -static struct diff_filepair *current_filepair; static const char *current_prefix; static int use_ssdiff; @@ -187,15 +186,9 @@ static int show_filepair(struct diff_filepair *pair) starts_with(pair->two->path, current_prefix); } -/* - * The line xdiff produces ends in its newline, which is swapped for a - * terminator while the text is escaped and then put back, because the line - * points into a buffer xdiff still owns. - */ static void print_line(char *line, int len) { const char *class = "ctx"; - char c = line[len - 1]; if (line[0] == '+') class = "add"; @@ -205,31 +198,26 @@ static void print_line(char *line, int len) class = "hunk"; htmlf("<div class='%s'>", class); - line[len - 1] = '\0'; - html_txt(line); + html_ntxt(line, len - 1); html("</div>"); - line[len - 1] = c; } /* - * The buffer repo_find_unique_abbrev returns is reused, so a caller printing - * two abbreviated names together keeps a copy of each. + * Opens the group end_file closes, in whichever view the page uses, and writes + * the header of the pair's diff into it. */ -static char *abbrev_oid(const struct object_id *oid) -{ - return xstrdup(repo_find_unique_abbrev(the_repository, oid, DEFAULT_ABBREV)); -} - -static void print_file_header(const struct object_id *old_oid, const char *old_path, unsigned old_mode, - const struct object_id *new_oid, const char *new_path, unsigned new_mode) +static void print_file_header(struct diff_filepair *pair) { - char *old_abbrev, *new_abbrev; + const struct object_id *old_oid = &pair->one->oid, *new_oid = &pair->two->oid; + const char *old_path = pair->one->path, *new_path = pair->two->path; + unsigned old_mode = pair->one->mode, new_mode = pair->two->mode; + char old_abbrev[GIT_MAX_HEXSZ + 1], new_abbrev[GIT_MAX_HEXSZ + 1]; int subproject; subproject = (S_ISGITLINK(old_mode) || S_ISGITLINK(new_mode)); - // The side by side view opens its file group before the header row, - // so only the unified view opens one here. - if (!use_ssdiff) { + if (use_ssdiff) { + cgit_ssdiff_header_begin(pair); + } else { html("<div class='file' data-path='"); html_attr(new_path); html("'>"); @@ -247,11 +235,9 @@ static void print_file_header(const struct object_id *old_oid, const char *old_p htmlf("<br>deleted file mode %.6o", old_mode); if (!subproject) { - old_abbrev = abbrev_oid(old_oid); - new_abbrev = abbrev_oid(new_oid); + repo_find_unique_abbrev_r(the_repository, old_abbrev, old_oid, DEFAULT_ABBREV); + repo_find_unique_abbrev_r(the_repository, new_abbrev, new_oid, DEFAULT_ABBREV); htmlf("<br>index %s..%s", old_abbrev, new_abbrev); - free(old_abbrev); - free(new_abbrev); if (old_mode != 0 && new_mode != 0) { htmlf(" %.6o", old_mode); if (new_mode != old_mode) @@ -279,12 +265,13 @@ static void print_file_header(const struct object_id *old_oid, const char *old_p html_txt(new_path); } html("</div>"); + if (use_ssdiff) + cgit_ssdiff_header_end(); } /* - * Closes the group print_file_header or cgit_ssdiff_header_begin opened. The - * side by side footer closes its own, so the unified view is the only one with - * work to do here. + * Closes the group print_file_header opened. The side by side footer closes its + * own, so the unified view is the only one with work to do here. */ static void end_file(void) { @@ -338,17 +325,13 @@ static void print_truncated(const char *path) static struct fileinfo *reserve_item(void) { - files++; - if (files >= slots) { - if (slots == 0) - slots = 4; - else - slots = slots * 2; - items = xrealloc(items, slots * sizeof(struct fileinfo)); - } - memset(&items[files - 1], 0, sizeof(items[files - 1])); - strbuf_init(&items[files - 1].body, 0); - return &items[files - 1]; + struct fileinfo *item; + + ALLOC_GROW(items, files + 1, slots); + item = &items[files++]; + memset(item, 0, sizeof(*item)); + strbuf_init(&item->body, 0); + return item; } /* @@ -362,15 +345,8 @@ static void collect_body(struct diff_filepair *pair, struct fileinfo *item, int linediff_fn line_fn = use_ssdiff ? cgit_ssdiff_line_cb : print_line; size_t header_len; - current_filepair = pair; - html_capture_begin(body); - if (use_ssdiff) - cgit_ssdiff_header_begin(pair->two->path); - print_file_header(&pair->one->oid, pair->one->path, pair->one->mode, - &pair->two->oid, pair->two->path, pair->two->mode); - if (use_ssdiff) - cgit_ssdiff_header_end(); + print_file_header(pair); header_len = body->len; // Cleared for the whole file rather than in the branch below, because @@ -468,7 +444,6 @@ static void print_diffstat(const struct object_id *old_oid, const struct object_ html("')"); } html("</div>\n"); - max_changes = 0; cgit_diff_tree(old_oid, new_oid, inspect_filepair, prefix, ctx.qry.ignorews); // A change to no files would leave the table an empty frame. if (files) { @@ -498,21 +473,13 @@ static void filepair_cb(struct diff_filepair *pair) size_t new_size = 0; int binary = 0; int idx; - linediff_fn line_fn = print_line; + linediff_fn line_fn = use_ssdiff ? cgit_ssdiff_line_cb : print_line; if (!show_filepair(pair)) return; idx = item_idx++; - current_filepair = pair; - if (use_ssdiff) { - cgit_ssdiff_header_begin(pair->two->path); - line_fn = cgit_ssdiff_line_cb; - } - print_file_header(&pair->one->oid, pair->one->path, pair->one->mode, - &pair->two->oid, pair->two->path, pair->two->mode); - if (use_ssdiff) - cgit_ssdiff_header_end(); + print_file_header(pair); if (over_line_cap(idx)) { print_truncated(items[idx].new_path); @@ -557,16 +524,6 @@ static void print_raw_patch(const struct object_id *old_tree_oid, const struct o diff_flush(&diffopt); } -struct diff_filespec *cgit_get_current_old_file(void) -{ - return current_filepair->one; -} - -struct diff_filespec *cgit_get_current_new_file(void) -{ - return current_filepair->two; -} - void cgit_print_diff_ctrls(void) { int i, selected; @@ -574,7 +531,7 @@ void cgit_print_diff_ctrls(void) html("<div class='cgit-panel'>\n"); html("<strong>Diff options</strong>"); html("<form method='get' aria-label='Diff options'>"); - cgit_add_hidden_formfields(1, 0, ctx.qry.page); + cgit_add_hidden_formfields(1, 0); html("<div class='fields'>"); html("<label for='diff-context'>context</label>"); html("<select id='diff-context' name='context'>"); @@ -647,31 +604,17 @@ void cgit_print_diff(const char *new_rev, const char *old_rev, const char *prefi if (!new_rev) new_rev = ctx.qry.head; - if (repo_get_oid(the_repository, new_rev, new_rev_oid)) { - cgit_print_error_page(404, "Not Found", "Bad object id: %s", new_rev); - return; - } - new_commit = lookup_commit_reference(the_repository, new_rev_oid); - if (!new_commit || repo_parse_commit(the_repository, new_commit)) { - cgit_print_error_page(404, "Not Found", "Not a commit: %s", oid_to_hex(new_rev_oid)); + new_commit = cgit_get_commit(new_rev, new_rev_oid); + if (!new_commit || cgit_get_old_oid(old_rev, new_commit, old_rev_oid)) return; - } new_tree_oid = get_commit_tree_oid(new_commit); - if (old_rev) { - if (repo_get_oid(the_repository, old_rev, old_rev_oid)) { - cgit_print_error_page(404, "Not Found", "Bad object id: %s", old_rev); - return; - } - } else if (new_commit->parents && new_commit->parents->item) { - oidcpy(old_rev_oid, &new_commit->parents->item->object.oid); - } else { - oidclr(old_rev_oid, the_repository->hash_algo); - } - if (!is_null_oid(old_rev_oid)) { + // A named old side is a commit already, so only a first parent + // missing from the repository fails here, and it has no name + // but its id. old_commit = lookup_commit_reference(the_repository, old_rev_oid); - if (!old_commit || repo_parse_commit(the_repository, old_commit)) { + if (!old_commit) { cgit_print_error_page(404, "Not Found", "Not a commit: %s", oid_to_hex(old_rev_oid)); return; } @@ -742,7 +685,6 @@ void cgit_print_diff(const char *new_rev, const char *old_rev, const char *prefi html_raw(items[i].body.buf, items[i].body.len); release_bodies(); } else { - item_idx = 0; cgit_diff_tree(old_rev_oid, new_rev_oid, filepair_cb, prefix, ctx.qry.ignorews); } if (!use_ssdiff) diff --git a/source/ui-diff.h b/source/ui-diff.h index 467c79f..359fad2 100644 --- a/source/ui-diff.h +++ b/source/ui-diff.h @@ -1,9 +1,8 @@ /* * The page that shows what one revision changed against another, and the * options panel that sits above it. The side by side renderer in ui-ssdiff - * builds a link on every line number, so the two revisions being compared and - * the file pair being rendered are published here for it rather than kept - * private to the page. + * builds a link on every line number, so the two revisions being compared are + * published here for it rather than kept private to the page. */ #ifndef CGIT_UI_DIFF_H @@ -17,12 +16,9 @@ extern void cgit_print_diff_ctrls(void); -extern void cgit_print_diff(const char *new_hex, const char *old_hex, const char *prefix, +extern void cgit_print_diff(const char *new_rev, const char *old_rev, const char *prefix, int show_ctrls, int raw); -extern struct diff_filespec *cgit_get_current_old_file(void); -extern struct diff_filespec *cgit_get_current_new_file(void); - extern struct object_id old_rev_oid[1]; extern struct object_id new_rev_oid[1]; diff --git a/source/ui-empty.c b/source/ui-empty.c index deefc21..76b2697 100644 --- a/source/ui-empty.c +++ b/source/ui-empty.c @@ -11,8 +11,17 @@ #include "ui-empty.h" #include "ui-shared.h" +static int clone_urls_printed; + static void print_clone_url(const char *url) { + // cgit_add_clone_urls may call back no times at all, so the table + // waits for a first url rather than being opened ahead of the walk. + if (clone_urls_printed++ == 0) { + html("<table class='list clone' aria-label='Clone urls'>\n<tbody class='clone'>\n"); + html("<tr class='nohover'><th class='left' scope='col'>Clone</th></tr>\n"); + } + cgit_print_clone_row(url, 0); } @@ -23,14 +32,8 @@ static void print_clone_url(const char *url) void cgit_print_empty_repo(void) { cgit_print_error("Repository seems to be empty"); - - // cgit_add_clone_urls draws on these two alone, so with neither set - // the table below would be a Clone heading with no rows. - if (!ctx.repo->clone_url && !ctx.cfg.clone_prefix) - return; - - html("<table class='list clone' aria-label='Clone urls'>\n<tbody class='clone'>\n"); - html("<tr class='nohover'><th class='left' scope='col'>Clone</th></tr>\n"); + clone_urls_printed = 0; cgit_add_clone_urls(print_clone_url); - html("</tbody>\n</table>\n"); + if (clone_urls_printed) + html("</tbody>\n</table>\n"); } diff --git a/source/ui-log.c b/source/ui-log.c index 870ff06..5b3a88a 100644 --- a/source/ui-log.c +++ b/source/ui-log.c @@ -11,7 +11,6 @@ #define USE_THE_REPOSITORY_VARIABLE #include "cgit.h" -#include "filter.h" #include "html.h" #include "parsing.h" #include "shared.h" @@ -212,28 +211,26 @@ static void print_graph_padding(struct rev_info *revs, struct strbuf *graph, int html("</td>\n"); } -// Ends a row in a table wider than the log with an empty cell for each column -// the log has no use for. +/* + * Ends a row in a table wider than the log with an empty cell for each column + * the log has no use for. + */ static void print_filler(int columns) { while (columns-- > 0) html("<td></td>"); } -static void print_commit(struct commit *commit, struct rev_info *revs, int filler) +/* + * columns counts the filler too, because the rows that span the table reach + * across it as well. + */ +static void print_commit(struct commit *commit, struct rev_info *revs, int columns, int filler) { struct commitinfo *info; - int columns = revs->graph ? 4 : 3; struct strbuf graphbuf = STRBUF_INIT; struct strbuf msgbuf = STRBUF_INIT; - if (ctx.repo->enable_log_filecount) - columns++; - if (ctx.repo->enable_log_linecount) - columns++; - // The rows that span the table reach across the filler as well. - columns += filler; - if (revs->graph) print_graph_above(revs, &graphbuf, columns); @@ -258,9 +255,7 @@ static void print_commit(struct commit *commit, struct rev_info *revs, int fille oid_to_hex(&commit->object.oid), ctx.qry.vpath); cgit_print_commit_decorations(commit); html("</td><td class='col-author'>"); - cgit_open_filter(ctx.repo->email_filter, info->author_email ? info->author_email : "", "log"); - html_txt(info->author); - cgit_close_filter(ctx.repo->email_filter); + cgit_print_person(info->author, info->author_email, "log", 0); if (revs->graph) { html("</td><td>"); @@ -355,28 +350,6 @@ static int valid_range_token(const char *arg) return ok; } -static char *next_token(char **src) -{ - char *token; - - if (!*src) - return NULL; - while (isspace((unsigned char)**src)) - (*src)++; - if (!**src) - return NULL; - token = *src; - while (**src) { - if (isspace((unsigned char)**src)) { - **src = '\0'; - (*src)++; - break; - } - (*src)++; - } - return token; -} - /* * Whether there is a page after this one is answered by pulling one more * commit off the walk. The links carry the path as it was asked for, since a @@ -466,13 +439,25 @@ void cgit_print_commit_decorations(struct commit *commit) html("</span>"); } -void cgit_print_log(const char *tip, int ofs, int cnt, char *grep, char *pattern, +int cgit_log_columns(int graph) +{ + int columns = graph ? 4 : 3; + + if (ctx.repo->enable_log_filecount) + columns++; + if (ctx.repo->enable_log_linecount) + columns++; + return columns; +} + +void cgit_print_log(const char *tip, int ofs, int cnt, const char *grep, const char *pattern, const char *path, int pager, int commit_graph, int commit_sort, int table_columns) { - struct rev_info rev; + struct rev_info rev = REV_INFO_INIT; struct commit *commit; struct strvec rev_argv = STRVEC_INIT; - int i, columns = commit_graph ? 4 : 3, filler = 0; + struct string_list tokens = STRING_LIST_INIT_DUP; + int i, columns, filler = 0; int must_free_tip = 0; // setup_revisions skips the first entry the way a command skips its @@ -487,25 +472,14 @@ void cgit_print_log(const char *tip, int ofs, int cnt, char *grep, char *pattern // option. if (tip && !cgit_valid_rev(tip)) { cgit_print_error_page(400, "Bad Request", "Invalid revision"); - if (must_free_tip) - free((char *)tip); - strvec_clear(&rev_argv); - return; + goto out; } strvec_push(&rev_argv, tip); - // A leading colon makes git read the path as a pathspec with magic, - // which can end the request inside git. - if (path && path[0] == ':') { - cgit_print_error_page(400, "Bad Request", "Invalid path"); - if (must_free_tip) - free((char *)tip); - strvec_clear(&rev_argv); - return; - } + if (cgit_refuse_pathspec_magic(path)) + goto out; if (grep && pattern && *pattern) { - pattern = xstrdup(pattern); if (!strcmp(grep, "grep") || !strcmp(grep, "author") || !strcmp(grep, "committer")) { // The search is literal and ignores case. setup_revisions // compiles the pattern, so the flags go in with it. @@ -513,21 +487,19 @@ void cgit_print_log(const char *tip, int ofs, int cnt, char *grep, char *pattern strvec_push(&rev_argv, "--regexp-ignore-case"); strvec_pushf(&rev_argv, "--%s=%s", grep, pattern); } else if (!strcmp(grep, "range")) { - char *arg; + struct string_list_item *token; // Each whitespace separated token has to pass the same // check as any other revision. The tip pushed above goes // away, since the range supersedes it. strvec_pop(&rev_argv); - while ((arg = next_token(&pattern))) { - if (!valid_range_token(arg)) { + string_list_split_f(&tokens, pattern, " \t\n\r", -1, STRING_LIST_SPLIT_NONEMPTY); + for_each_string_list_item(token, &tokens) { + if (!valid_range_token(token->string)) { cgit_print_error_page(400, "Bad Request", "Invalid revision"); - if (must_free_tip) - free((char *)tip); - strvec_clear(&rev_argv); - return; + goto out; } - strvec_push(&rev_argv, arg); + strvec_push(&rev_argv, token->string); } } } @@ -555,28 +527,24 @@ void cgit_print_log(const char *tip, int ofs, int cnt, char *grep, char *pattern repo_init_revisions(the_repository, &rev, NULL); // The author and committer searches then match the mapped idents. rev.mailmap = cgit_mailmap(); - rev.abbrev = DEFAULT_ABBREV; - rev.commit_format = CMIT_FMT_DEFAULT; - rev.verbose_header = 1; - rev.show_root_diff = 0; rev.ignore_missing = 1; - rev.simplify_history = 1; - setup_revisions(rev_argv.nr, rev_argv.v, &rev, NULL); + setup_revisions_from_strvec(&rev_argv, &rev, NULL); load_ref_decorations(NULL, DECORATE_FULL_REFS); rev.show_decorations = 1; + // A followed log is walked without the graph even when commit_graph + // asks for one, so the walk decides the columns. + columns = cgit_log_columns(rev.graph != NULL); + if (table_columns > columns) + filler = table_columns - columns; + rev.diffopt.detect_rename = 1; rev.diffopt.rename_limit = ctx.cfg.renamelimit; if (ctx.qry.ignorews) DIFF_XDL_SET(&rev.diffopt, IGNORE_WHITESPACE); - // A failed setup leaves the walk holding freed commits, so it must - // not be read from. - if (prepare_revision_walk(&rev)) { - cgit_print_error_page(500, "Internal Server Error", "Unable to read the history"); - strvec_clear(&rev_argv); - return; - } + if (cgit_prepare_walk(&rev)) + goto out; if (pager) { cgit_print_layout_start(); @@ -584,7 +552,7 @@ void cgit_print_log(const char *tip, int ofs, int cnt, char *grep, char *pattern } html("<tbody class='log'>\n<tr class='nohover'>"); - if (commit_graph) + if (rev.graph) html("<th scope='col' aria-label='Graph'></th>"); else html("<th class='left' scope='col'>Age</th>"); @@ -601,16 +569,10 @@ void cgit_print_log(const char *tip, int ofs, int cnt, char *grep, char *pattern html("</th><th class='left col-author' scope='col'>Author</th>"); if (rev.graph) html("<th class='left' scope='col'>Age</th>"); - if (ctx.repo->enable_log_filecount) { + if (ctx.repo->enable_log_filecount) html("<th class='left col-count' scope='col'>Files</th>"); - columns++; - } - if (ctx.repo->enable_log_linecount) { + if (ctx.repo->enable_log_linecount) html("<th class='left col-count' scope='col'>Lines</th>"); - columns++; - } - if (table_columns > columns) - filler = table_columns - columns; print_filler(filler); html("</tr>\n"); @@ -630,7 +592,7 @@ void cgit_print_log(const char *tip, int ofs, int cnt, char *grep, char *pattern counts_ready = 0; if (should_show(commit, &rev)) { i++; - print_commit(commit, &rev, filler); + print_commit(commit, &rev, columns + filler, filler); } release_commit_memory(the_repository->parsed_objects, commit); commit->parents = NULL; @@ -650,6 +612,12 @@ void cgit_print_log(const char *tip, int ofs, int cnt, char *grep, char *pattern html("</tbody>\n"); } + // The mailmap is cgit's own and outlives the walk. + rev.mailmap = NULL; + release_revisions(&rev); +out: if (must_free_tip) free((char *)tip); + strvec_clear(&rev_argv); + string_list_clear(&tokens, 0); } diff --git a/source/ui-log.h b/source/ui-log.h index d01a63d..349489a 100644 --- a/source/ui-log.h +++ b/source/ui-log.h @@ -14,8 +14,15 @@ * is that table's width, which every row is filled out to. 0 leaves the rows * at their own width. */ -extern void cgit_print_log(const char *tip, int ofs, int cnt, char *grep, char *pattern, +extern void cgit_print_log(const char *tip, int ofs, int cnt, const char *grep, const char *pattern, const char *path, int pager, int commit_graph, int commit_sort, int table_columns); + +/* + * The columns a log row spans: age, message and author, and one more each for + * the graph and for the file and line counts that are turned on. + */ +extern int cgit_log_columns(int graph); + extern void cgit_print_commit_decorations(struct commit *commit); #endif // CGIT_UI_LOG_H diff --git a/source/ui-patch.c b/source/ui-patch.c index 04083fe..0fa9a2e 100644 --- a/source/ui-patch.c +++ b/source/ui-patch.c @@ -10,100 +10,52 @@ #include "cgit.h" #include "html.h" -#include "ui-diff.h" #include "ui-patch.h" #include "ui-shared.h" // Room for two hex object ids, the two dots between them, and the null byte. #define REV_RANGE_LEN (2 * GIT_MAX_HEXSZ + 3) -// Where the format argument sits in the walk arguments below. -#define FORMAT_ARG 2 - -/* - * A root commit has no parent, so the old end is left null and the caller asks - * for that one commit instead. - */ -static int resolve_range(const char *new_rev, const char *old_rev, - struct object_id *new_oid, struct object_id *old_oid) -{ - struct commit *commit; - - if (repo_get_oid(the_repository, new_rev, new_oid)) { - cgit_print_error_page(404, "Not Found", "Bad object id: %s", new_rev); - return -1; - } - commit = lookup_commit_reference(the_repository, new_oid); - if (!commit) { - cgit_print_error_page(404, "Not Found", "Not a commit: %s", new_rev); - return -1; - } - - if (old_rev) { - if (repo_get_oid(the_repository, old_rev, old_oid)) { - cgit_print_error_page(404, "Not Found", "Bad object id: %s", old_rev); - return -1; - } - if (!lookup_commit_reference(the_repository, old_oid)) { - cgit_print_error_page(404, "Not Found", "Not a commit: %s", old_rev); - return -1; - } - } else if (commit->parents && commit->parents->item) { - oidcpy(old_oid, &commit->parents->item->object.oid); - } else { - oidclr(old_oid, the_repository->hash_algo); - } - return 0; -} - void cgit_print_patch(const char *new_rev, const char *old_rev, const char *prefix) { struct rev_info rev; struct commit *commit; struct object_id new_oid, old_oid; + struct strvec rev_argv = STRVEC_INIT; char rev_range[REV_RANGE_LEN]; const char *version; - // setup_revisions reads these the way git reads a command line, so the - // first entry stands in for the program name and is skipped, and the - // array has to stay null terminated because the path after the double - // dash is picked up past the count. - const char *rev_argv[] = { NULL, "--reverse", "--format=email", rev_range, "--", prefix, NULL }; - int rev_argc = ARRAY_SIZE(rev_argv) - 1; - - if (!prefix) - rev_argc--; if (!new_rev) new_rev = ctx.qry.head; - // A leading colon makes git read the path as a pathspec with magic, - // which can end the request inside git after the headers are out. - if (prefix && prefix[0] == ':') { - cgit_print_error_page(400, "Bad Request", "Invalid path"); + if (cgit_refuse_pathspec_magic(prefix)) return; - } - if (resolve_range(new_rev, old_rev, &new_oid, &old_oid)) + commit = cgit_get_commit(new_rev, &new_oid); + if (!commit || cgit_get_old_oid(old_rev, commit, &old_oid)) return; if (is_null_oid(&old_oid)) { - memcpy(rev_range, oid_to_hex(&new_oid), the_hash_algo->hexsz + 1); + oid_to_hex_r(rev_range, &new_oid); } else { - xsnprintf(rev_range, REV_RANGE_LEN, "%s..%s", oid_to_hex(&old_oid), oid_to_hex(&new_oid)); + xsnprintf(rev_range, sizeof(rev_range), "%s..%s", oid_to_hex(&old_oid), oid_to_hex(&new_oid)); } - if (!ctx.cfg.enable_plain_email) { - rev_argv[FORMAT_ARG] = - "--format=format:From %H Mon Sep 17 00:00:00 2001%n" - "From: %an%nDate: %aD%n%w(78,0,1)Subject: %s%n%n%w(0)%b"; - } + strvec_pushl(&rev_argv, "patch", "--reverse", NULL); + if (ctx.cfg.enable_plain_email) + strvec_push(&rev_argv, "--format=email"); + else + strvec_push(&rev_argv, "--format=format:From %H Mon Sep 17 00:00:00 2001%n" + "From: %an%nDate: %aD%n%w(78,0,1)Subject: %s%n%n%w(0)%b"); + strvec_pushl(&rev_argv, rev_range, "--", NULL); + if (prefix) + strvec_push(&rev_argv, prefix); // Git inlines a blob up to its own big-file threshold, far past what // any other view inflates, so max-blob-size is applied through it. if (ctx.cfg.max_blob_size) repo_settings_set_big_file_threshold(the_repository, (unsigned long)ctx.cfg.max_blob_size * 1024); repo_init_revisions(the_repository, &rev, NULL); - rev.abbrev = DEFAULT_ABBREV; rev.verbose_header = 1; rev.diff = 1; rev.show_root_diff = 1; @@ -113,17 +65,16 @@ void cgit_print_patch(const char *new_rev, const char *old_rev, const char *pref // the path comes from the request and a long one would abort the // process. if (prefix) - rev.diffopt.stat_sep = cgit_fmtalloc("(limited to '%s')\n\n", prefix); - setup_revisions(rev_argc, rev_argv, &rev, NULL); + rev.diffopt.stat_sep = xstrfmt("(limited to '%s')\n\n", prefix); + setup_revisions_from_strvec(&rev_argv, &rev, NULL); // A single commit resolves to a range starting at its parent, so this // only ever cuts an explicit range short and keeps one request from // emitting a patch for the whole history. if (ctx.cfg.max_patch_count > 0) rev.max_count = ctx.cfg.max_patch_count; - // A failed setup leaves the walk holding freed commits, so it must - // not be read from, and the headers wait until it has succeeded. - if (prepare_revision_walk(&rev)) { - cgit_print_error_page(500, "Internal Server Error", "Unable to read the history"); + // The headers wait until the walk has started. + if (cgit_prepare_walk(&rev)) { + strvec_clear(&rev_argv); return; } @@ -132,8 +83,7 @@ void cgit_print_patch(const char *new_rev, const char *old_rev, const char *pref cgit_print_http_headers(); // git format-patch signs with its bare version number, so the v that - // cgit's tags carry is left off to read the same way. Without - // enable-version-info the signature names cgit alone. + // cgit's tags carry is left off to read the same way. if (!skip_prefix(cgit_version, "v", &version)) version = cgit_version; @@ -147,4 +97,6 @@ void cgit_print_patch(const char *new_rev, const char *old_rev, const char *pref else printf("-- \ncgit\n\n"); } + release_revisions(&rev); + strvec_clear(&rev_argv); } diff --git a/source/ui-plain.c b/source/ui-plain.c index 57e4699..43bdb22 100644 --- a/source/ui-plain.c +++ b/source/ui-plain.c @@ -42,6 +42,55 @@ static int is_unsafe_type(const char *mimetype) strcmp(mimetype, "application/pdf"); } +static char *get_mimetype_for_filename(const char *filename) +{ + const char *ext; + char *type, line[1024]; + struct string_list fields = STRING_LIST_INIT_NODUP; + size_t i; + FILE *file; + struct string_list_item *entry; + + if (!filename) + return NULL; + + ext = strrchr(filename, '.'); + if (!ext) + return NULL; + ++ext; + if (!ext[0]) + return NULL; + entry = string_list_lookup(&ctx.cfg.mimetypes, ext); + if (entry) + return xstrdup(entry->util); + + if (!ctx.cfg.mimetype_file) + return NULL; + file = fopen(ctx.cfg.mimetype_file, "r"); + if (!file) + return NULL; + while (fgets(line, sizeof(line), file)) { + // A line of a mime.types file is one type followed by every + // extension that maps to it. + string_list_split_in_place(&fields, line, " \t\r\n", -1); + string_list_remove_empty_items(&fields, 0); + if (!fields.nr || fields.items[0].string[0] == '#') { + string_list_clear(&fields, 0); + continue; + } + type = fields.items[0].string; + for (i = 1; i < fields.nr; i++) { + if (!strcasecmp(ext, fields.items[i].string)) { + fclose(file); + return xstrdup(type); + } + } + string_list_clear(&fields, 0); + } + fclose(file); + return NULL; +} + /* * Writes the response for the object, error pages included, so the walk * does not go on to report the path as missing. @@ -69,7 +118,7 @@ static void print_object(const struct object_id *oid, const char *path) return; } - mimetype = cgit_get_mimetype_for_filename(path); + mimetype = get_mimetype_for_filename(path); ctx.page.mimetype = mimetype; if (!ctx.repo->enable_html_serving) { @@ -93,9 +142,9 @@ static void print_object(const struct object_id *oid, const char *path) static char *build_path(const char *base, int baselen, const char *path) { if (path[0]) - return cgit_fmtalloc("%.*s%s/", baselen, base, path); + return xstrfmt("%.*s%s/", baselen, base, path); else - return cgit_fmtalloc("%.*s/", baselen, base); + return xstrfmt("%.*s/", baselen, base); } static void print_dir(const char *base, int baselen, const char *path) @@ -159,20 +208,17 @@ static void print_dir_tail(void) html("</ul>\n</body>\n</html>\n"); } -/* - * read_tree reads the return value as a direction rather than a status, so - * READ_TREE_RECURSIVE means step into this entry and zero means step over it. - */ static int walk_tree(const struct object_id *oid, struct strbuf *base, const char *pathname, unsigned mode, void *context) { struct walk_tree_context *walk = context; - // A tree can hold two entries of one name, and only the first answers: - // a second response would follow the first in the same body. - if (walk->response != RESPONSE_NONE && walk->dir_len >= 0 && base->len == (size_t)walk->dir_len) - return 0; if (walk->dir_len >= 0 && base->len == (size_t)walk->dir_len) { + // A tree can hold two entries of one name, and only the first + // answers: a second response would follow the first in the same + // body. + if (walk->response != RESPONSE_NONE) + return 0; if (S_ISREG(mode) || S_ISLNK(mode)) { print_object(oid, pathname); walk->response = RESPONSE_BLOB; @@ -205,19 +251,8 @@ void cgit_print_plain(void) const char *rev = ctx.qry.oid; struct object_id oid; struct commit *commit; - int path_len = ctx.qry.path ? strlen(ctx.qry.path) : 0; - // nowildcard_len matches len so git treats the path as literal. As a - // glob, every entry a pattern like * matches would be answered with - // its own HTTP headers inside the body of the first. - struct pathspec_item path_items = { - .match = ctx.qry.path, - .len = path_len, - .nowildcard_len = path_len - }; - struct pathspec paths = { - .nr = 1, - .items = &path_items - }; + struct pathspec_item path_item; + struct pathspec paths = { 0 }; struct walk_tree_context walk = { .response = RESPONSE_NONE }; @@ -234,15 +269,18 @@ void cgit_print_plain(void) cgit_print_error_page(404, "Not Found", "Not found"); return; } - if (!path_items.match) { + // The path is taken literally, since as a glob every entry a pattern + // like * matches would be answered with its own HTTP headers inside + // the body of the first. + cgit_literal_pathspec(&paths, &path_item, ctx.qry.path); + if (!ctx.qry.path) { // The walk is never handed an entry for the top of the tree // itself, so the listing it would have opened is opened here. - paths.nr = 0; walk.dir_len = -1; print_dir("", 0, ""); walk.response = RESPONSE_LISTING; } else { - walk.dir_len = dir_prefix_len(path_items.match); + walk.dir_len = dir_prefix_len(ctx.qry.path); } read_tree(the_repository, repo_get_commit_tree(the_repository, commit), &paths, walk_tree, &walk); if (walk.response == RESPONSE_NONE) diff --git a/source/ui-refs.c b/source/ui-refs.c index c8e8f3f..69a2cc1 100644 --- a/source/ui-refs.c +++ b/source/ui-refs.c @@ -3,19 +3,33 @@ * the whole of a repository's refs page. Every row is one ref beside * the commit or tag object it points at, ordered newest first, with branches * then reordered by name unless branch-sort asks for age. A section longer - * than max-ref-count is cut short and ends in a link to a dedicated heads or + * than its cap is cut short and ends in a link to a dedicated heads or * tags page, which walks the same list one offset at a time. */ #define USE_THE_REPOSITORY_VARIABLE #include "cgit.h" -#include "filter.h" #include "html.h" -#include "shared.h" +#include "parsing.h" #include "ui-refs.h" #include "ui-shared.h" +struct refinfo { + const char *refname; + struct object *object; + union { + struct taginfo *tag; + struct commitinfo *commit; + }; +}; + +struct reflist { + struct refinfo **refs; + int alloc; + int count; +}; + // The slice of a sorted ref list that one page shows, with end one past the // last row. size is what a full page holds, so it also decides whether the // list needs a pager. @@ -25,6 +39,68 @@ struct ref_page { int end; }; +static void free_refinfo(struct refinfo *ref) +{ + free((char *)ref->refname); + switch (ref->object->type) { + case OBJ_TAG: + cgit_free_taginfo(ref->tag); + break; + case OBJ_COMMIT: + cgit_free_commitinfo(ref->commit); + break; + } + free(ref); +} + +static struct refinfo *make_refinfo(const char *refname, const struct object_id *oid) +{ + struct refinfo *ref; + + ref = xcalloc(1, sizeof(struct refinfo)); + ref->refname = xstrdup(refname); + ref->object = parse_object(the_repository, oid); + if (!ref->object) { + free((char *)ref->refname); + free(ref); + return NULL; + } + switch (ref->object->type) { + case OBJ_TAG: + ref->tag = cgit_parse_tag((struct tag *)ref->object); + break; + case OBJ_COMMIT: + ref->commit = cgit_parse_commit((struct commit *)ref->object); + break; + } + return ref; +} + +static void free_reflist_inner(struct reflist *list) +{ + int i; + + for (i = 0; i < list->count; i++) + free_refinfo(list->refs[i]); + free(list->refs); +} + +/* + * Matches git's reference iteration callback, and appends each ref it is given + * to the struct reflist passed as cb_data. + */ +static int refs_cb(const struct reference *ref, void *cb_data) +{ + struct reflist *list = (struct reflist *)cb_data; + struct refinfo *info = make_refinfo(ref->name, ref->oid); + + if (info) { + ALLOC_GROW(list->refs, list->count + 1, list->alloc); + list->refs[list->count++] = info; + } + return 0; +} + /* * The tagger date and the committer date live in a union in struct refinfo and * only the member matching the object type is ever filled, so assuming a ref @@ -32,13 +108,11 @@ struct ref_page { */ static timestamp_t ref_date(struct refinfo *ref) { - if (!ref->object) - return 0; switch (ref->object->type) { case OBJ_TAG: return ref->tag ? ref->tag->tagger_date : 0; case OBJ_COMMIT: - return ref->commit ? ref->commit->committer_date : 0; + return ref->commit->committer_date; } return 0; } @@ -71,9 +145,9 @@ static void collect_branches(struct reflist *list) { list->refs = NULL; list->alloc = list->count = 0; - refs_for_each_branch_ref(get_main_ref_store(the_repository), cgit_refs_cb, list); + refs_for_each_branch_ref(get_main_ref_store(the_repository), refs_cb, list); if (ctx.repo->enable_remote_branches) - refs_for_each_remote_ref(get_main_ref_store(the_repository), cgit_refs_cb, list); + refs_for_each_remote_ref(get_main_ref_store(the_repository), refs_cb, list); } /* @@ -99,13 +173,13 @@ static void print_branch_header(int columns) htmlf("<th%s class='left' scope='col'>Age</th></tr>\n", age_colspan(columns)); } -static int print_branch(struct refinfo *ref, int columns) +static void print_branch(struct refinfo *ref, int columns) { struct commitinfo *info = ref->commit; const char *name = ref->refname; if (!info) - return 1; + return; html("<tr class='branch'><td>"); cgit_log_link(name, NULL, NULL, name, NULL, NULL, 0, NULL, NULL, ctx.qry.showmsg, 0); html("</td><td>"); @@ -113,12 +187,7 @@ static int print_branch(struct refinfo *ref, int columns) if (ref->object->type == OBJ_COMMIT) { cgit_commit_link(info->subject, NULL, NULL, name, NULL, NULL); html("</td><td class='col-author'>"); - // A filter must not be handed a NULL argument. - cgit_open_filter( - ctx.repo->email_filter, info->author_email ? info->author_email : "", "refs" - ); - html_txt(info->author); - cgit_close_filter(ctx.repo->email_filter); + cgit_print_person(info->author, info->author_email, "refs", 0); htmlf("</td><td%s>", age_colspan(columns)); cgit_print_age(info->committer_date, info->committer_tz, -1); } else { @@ -128,14 +197,13 @@ static int print_branch(struct refinfo *ref, int columns) htmlf("</td><td class='col-author'></td><td%s>", age_colspan(columns)); } html("</td></tr>\n"); - return 0; } static void collect_tags(struct reflist *list) { list->refs = NULL; list->alloc = list->count = 0; - refs_for_each_tag_ref(get_main_ref_store(the_repository), cgit_refs_cb, list); + refs_for_each_tag_ref(get_main_ref_store(the_repository), refs_cb, list); } static void print_tag_header(int columns) @@ -149,7 +217,7 @@ static void print_tag_header(int columns) htmlf("<th%s class='left' scope='col'>Age</th></tr>\n", age_colspan(columns)); } -static int print_tag(struct refinfo *ref, int columns) +static void print_tag(struct refinfo *ref, int columns) { struct taginfo *info = NULL; const char *name = ref->refname; @@ -163,7 +231,7 @@ static int print_tag(struct refinfo *ref, int columns) obj = tag->tagged; info = ref->tag; if (!info) - return 1; + return; } html("<tr class='tag'><td>"); @@ -175,18 +243,10 @@ static int print_tag(struct refinfo *ref, int columns) cgit_object_link(obj, 0); html("</td><td class='col-author'>"); if (info) { - if (info->tagger) { - cgit_open_filter(ctx.repo->email_filter, info->tagger_email, "refs"); - html_txt(info->tagger); - cgit_close_filter(ctx.repo->email_filter); - } + if (info->tagger) + cgit_print_person(info->tagger, info->tagger_email, "refs", 0); } else if (ref->object->type == OBJ_COMMIT) { - cgit_open_filter( - ctx.repo->email_filter, ref->commit->author_email ? ref->commit->author_email : "", - "refs" - ); - html_txt(ref->commit->author); - cgit_close_filter(ctx.repo->email_filter); + cgit_print_person(ref->commit->author, ref->commit->author_email, "refs", 0); } htmlf("</td><td%s>", age_colspan(columns)); if (info) { @@ -196,8 +256,6 @@ static int print_tag(struct refinfo *ref, int columns) cgit_print_age(ref->commit->commit->date, 0, -1); } html("</td></tr>\n"); - - return 0; } static void print_refs_link(const char *path, int columns) @@ -258,9 +316,9 @@ static void print_branches_page(int pagesize) print_branch_header(REF_COLUMNS); collect_branches(&list); - cgit_qsort(list.refs, list.count, sizeof(*list.refs), cmp_date); + QSORT(list.refs, list.count, cmp_date); if (ctx.repo->branch_sort == 0) - cgit_qsort(list.refs, list.count, sizeof(*list.refs), cmp_name); + QSORT(list.refs, list.count, cmp_name); page = page_bounds(pagesize, list.count); @@ -271,7 +329,7 @@ static void print_branches_page(int pagesize) print_ref_pager(page.start, page.size, list.count, "heads"); html("</tbody>\n"); - cgit_free_reflist_inner(&list); + free_reflist_inner(&list); } static void print_tags_page(int pagesize) @@ -283,7 +341,7 @@ static void print_tags_page(int pagesize) collect_tags(&list); if (list.count == 0) return; - cgit_qsort(list.refs, list.count, sizeof(*list.refs), cmp_date); + QSORT(list.refs, list.count, cmp_date); page = page_bounds(pagesize, list.count); @@ -295,7 +353,7 @@ static void print_tags_page(int pagesize) print_ref_pager(page.start, page.size, list.count, "tags"); html("</tbody>\n"); - cgit_free_reflist_inner(&list); + free_reflist_inner(&list); } void cgit_print_branches(int maxcount, int columns) @@ -312,9 +370,9 @@ void cgit_print_branches(int maxcount, int columns) // The date sort covers the list, the name sort only the rows about to // be shown, so the section holds the newest branches rather than the // first ones by name. - cgit_qsort(list.refs, list.count, sizeof(*list.refs), cmp_date); + QSORT(list.refs, list.count, cmp_date); if (ctx.repo->branch_sort == 0) - cgit_qsort(list.refs, maxcount, sizeof(*list.refs), cmp_name); + QSORT(list.refs, maxcount, cmp_name); for (i = 0; i < maxcount; i++) print_branch(list.refs[i], columns); @@ -323,7 +381,7 @@ void cgit_print_branches(int maxcount, int columns) print_refs_link("heads", columns); html("</tbody>\n"); - cgit_free_reflist_inner(&list); + free_reflist_inner(&list); } void cgit_print_tags(int maxcount, int columns) @@ -334,7 +392,7 @@ void cgit_print_tags(int maxcount, int columns) collect_tags(&list); if (list.count == 0) return; - cgit_qsort(list.refs, list.count, sizeof(*list.refs), cmp_date); + QSORT(list.refs, list.count, cmp_date); if (maxcount <= 0 || maxcount > list.count) maxcount = list.count; print_tag_header(columns); @@ -345,7 +403,7 @@ void cgit_print_tags(int maxcount, int columns) print_refs_link("tags", columns); html("</tbody>\n"); - cgit_free_reflist_inner(&list); + free_reflist_inner(&list); } void cgit_print_refs(void) diff --git a/source/ui-repolist.c b/source/ui-repolist.c index 6df70e4..4e4f61b 100644 --- a/source/ui-repolist.c +++ b/source/ui-repolist.c @@ -27,11 +27,10 @@ struct sort_column { static time_t read_agefile(const char *path) { time_t result; - size_t size; char *buf = NULL; struct strbuf date = STRBUF_INIT; - if (cgit_read_first_line(path, &buf, &size)) { + if (cgit_read_first_line(path, &buf)) { free(buf); return 0; } @@ -77,7 +76,7 @@ static const char *tip_branch(const struct cgit_repo *repo, struct strbuf *head) return branch ? branch : "master"; } -static int get_repo_modtime(const struct cgit_repo *repo, time_t *mtime) +static time_t get_repo_modtime(const struct cgit_repo *repo) { struct strbuf path = STRBUF_INIT; struct strbuf head = STRBUF_INIT; @@ -87,39 +86,30 @@ static int get_repo_modtime(const struct cgit_repo *repo, time_t *mtime) // is kept in the repository itself so one page stats it only once. struct cgit_repo *writable = (struct cgit_repo *)repo; - if (repo->mtime != -1) { - *mtime = repo->mtime; - return 1; - } + if (repo->mtime != -1) + return repo->mtime; // A repository configured without a path has nothing on disk to date. if (!repo->path) { - *mtime = 0; writable->mtime = 0; return 0; } strbuf_addf(&path, "%s/%s", repo->path, ctx.cfg.agefile); - if (stat(path.buf, &st) == 0) { - *mtime = read_agefile(path.buf); - if (*mtime) { - writable->mtime = *mtime; - goto end; - } - } + writable->mtime = read_agefile(path.buf); + if (repo->mtime) + goto end; branch = tip_branch(repo, &head); strbuf_reset(&path); strbuf_addf(&path, "%s/refs/heads/%s", repo->path, branch); if (stat(path.buf, &st) == 0) { - *mtime = st.st_mtime; - writable->mtime = *mtime; + writable->mtime = st.st_mtime; goto end; } strbuf_reset(&path); strbuf_addf(&path, "%s/packed-refs", repo->path); if (stat(path.buf, &st) == 0) { - *mtime = st.st_mtime; - writable->mtime = *mtime; + writable->mtime = st.st_mtime; goto end; } @@ -127,26 +117,12 @@ static int get_repo_modtime(const struct cgit_repo *repo, time_t *mtime) // every ref update. strbuf_reset(&path); strbuf_addf(&path, "%s/reftable/tables.list", repo->path); - if (stat(path.buf, &st) == 0) { - *mtime = st.st_mtime; - writable->mtime = *mtime; - goto end; - } - - *mtime = 0; - writable->mtime = *mtime; + if (stat(path.buf, &st) == 0) + writable->mtime = st.st_mtime; end: strbuf_release(&path); strbuf_release(&head); - return (writable->mtime != 0); -} - -static void print_modtime(struct cgit_repo *repo) -{ - time_t mtime; - - if (get_repo_modtime(repo, &mtime)) - cgit_print_age(mtime, 0, -1); + return repo->mtime; } static int matches_search(struct cgit_repo *repo) @@ -247,11 +223,13 @@ static void print_repo_row(const char *currenturl, int sublevel) cgit_summary_link(ctx.repo->name, NULL, NULL, NULL); html("</td><td class='col-desc'>"); repourl = cgit_repourl(ctx.repo->url); - html_link_open(repourl, NULL, NULL); + html("<a href='"); + html_attr(repourl); + html("'>"); free(repourl); if (html_ntxt(ctx.repo->desc, ctx.cfg.max_repodesc_len) < 0) html("..."); - html_link_close(); + html("</a>"); html("</td>"); if (ctx.cfg.enable_index_owner) { html("<td class='col-owner'>"); @@ -269,7 +247,7 @@ static void print_repo_row(const char *currenturl, int sublevel) html("</td>"); } html("<td class='col-idle'>"); - print_modtime(ctx.repo); + cgit_print_age(get_repo_modtime(ctx.repo), 0, -1); html("</td>"); if (ctx.cfg.enable_index_links) { html("<td class='col-links'>"); @@ -340,11 +318,8 @@ static int cmp_idle(const void *a, const void *b) { const struct cgit_repo *r1 = a; const struct cgit_repo *r2 = b; - time_t t1, t2; + time_t t1 = get_repo_modtime(r1), t2 = get_repo_modtime(r2); - t1 = t2 = 0; - get_repo_modtime(r1, &t1); - get_repo_modtime(r2, &t2); // Only the sign is returned, because a 64-bit difference truncated // into an int could come back with the wrong sign and leave the // ordering inconsistent. @@ -377,11 +352,10 @@ static int cmp_section(const void *a, const void *b) */ static void resolve_modtimes(void) { - time_t t; int i; for (i = 0; i < cgit_repolist.count; i++) - get_repo_modtime(&cgit_repolist.repos[i], &t); + get_repo_modtime(&cgit_repolist.repos[i]); } static const struct sort_column sort_columns[] = { @@ -402,7 +376,7 @@ static int sort_repolist(const char *field) continue; if (column->cmp == cmp_idle || column->cmp == cmp_section) resolve_modtimes(); - cgit_qsort(cgit_repolist.repos, cgit_repolist.count, sizeof(struct cgit_repo), column->cmp); + QSORT(cgit_repolist.repos, cgit_repolist.count, column->cmp); return 1; } return 0; @@ -429,9 +403,7 @@ void cgit_print_repolist(void) ++columns; ctx.page.title = ctx.cfg.root_title; - cgit_print_http_headers(); - cgit_print_docstart(); - cgit_print_pageheader(); + cgit_print_layout_start(); if (ctx.qry.sort) column_sorted = sort_repolist(ctx.qry.sort); @@ -473,7 +445,7 @@ void cgit_print_repolist(void) if (hits > ctx.cfg.max_repo_count) print_pager(hits, ctx.cfg.max_repo_count, ctx.qry.search, ctx.qry.sort); free(currenturl); - cgit_print_docend(); + cgit_print_layout_end(); } void cgit_print_site_readme(void) diff --git a/source/ui-shared.c b/source/ui-shared.c index 936a6a4..81db404 100644 --- a/source/ui-shared.c +++ b/source/ui-shared.c This diff is too large to be rendered inline. View it on its own page. diff --git a/source/ui-shared.h b/source/ui-shared.h index da1b889..cd937a3 100644 --- a/source/ui-shared.h +++ b/source/ui-shared.h @@ -3,15 +3,14 @@ * handler renders its own body and calls in here for all that surrounds it, * which is the HTTP headers, the document head and footer, the header block * with its tabs and search form, the error page, and an anchor to any other - * cgit page. Whether a site addresses a request as a path below a virtual root - * or as a query string is settled behind these calls, so that nothing else has - * to know which of the two it is looking at. + * cgit page. */ #ifndef CGIT_UI_SHARED_H #define CGIT_UI_SHARED_H #include "cgit.h" + extern const char *cgit_httpscheme(void); extern char *cgit_hosturl(void); @@ -33,7 +32,6 @@ extern const char *cgit_loginurl(void); * result still needs escaping for wherever it lands. */ extern char *cgit_repourl(const char *reponame); -extern void cgit_print_clone_row(const char *url, int colspan); extern char *cgit_fileurl(const char *reponame, const char *pagename, const char *filename, const char *query); extern char *cgit_pageurl(const char *reponame, const char *pagename, const char *query); @@ -52,6 +50,14 @@ extern void cgit_tree_url(const char *head, struct strbuf *base, struct strbuf * extern void cgit_add_clone_urls(void (*fn)(const char *)); /* + * One row of the clone table. Every row is a link, but a browser cannot + * follow the ssh, scp or git forms, so those rows point at the first http + * url in the clone list, or at the repository's own page when the list has + * none. A colspan of zero leaves the attribute out. + */ +extern void cgit_print_clone_row(const char *url, int colspan); + +/* * Each of these writes one anchor to the page and nothing around it, with name * as the text a reader sees. A NULL title or class leaves that attribute out, * and what follows them is the state the target page should open in. @@ -101,6 +107,26 @@ extern struct date_mode cgit_date_mode(enum date_mode_type type); * relative dates off gets the date either way. */ extern void cgit_print_age(time_t t, int tz, time_t max_relative); + +/* + * Print date as a <time> element, readable on the page and exact in its + * datetime attribute. + */ +extern void cgit_print_date(timestamp_t date, int tz); + +/* + * Print the anchor of every line from first to last, each linking to itself. + * The anchors are handed over in batches rather than a write per line, and + * never built whole, which would come to several times the blob's size. + */ +extern void cgit_print_line_anchors(size_t first, size_t last); + +/* + * Print name through the email filter, followed by the address when + * show_email is set and enable-plain-email is on. A missing address reaches + * the filter as an empty string, since a filter must not be handed NULL. + */ +extern void cgit_print_person(const char *name, const char *email, const char *origin, int show_email); extern void cgit_print_http_headers(void); /* * Answer with a redirect to url, followed by query when one is given. Both @@ -108,24 +134,49 @@ extern void cgit_print_http_headers(void); * builds it, and only a byte a header line cannot carry is encoded here. */ extern void cgit_redirect(const char *url, const char *query, bool permanent); -extern void cgit_print_docstart(void); -extern void cgit_print_docend(void); __attribute__((format (printf,3,4))) extern void cgit_print_error_page(int code, const char *msg, const char *fmt, ...); -extern void cgit_vprint_error_page(int code, const char *msg, const char *fmt, va_list ap); /* * Answer with the error page for a blob of size bytes past max-blob-size. */ extern void cgit_print_blob_too_large(size_t size); -extern void cgit_print_pageheader(void); + +/* + * Resolve rev to the parsed commit it names, peeling a tag, with oid set to + * what rev itself names. Returns NULL once the 404 page naming rev is out. + */ +extern struct commit *cgit_get_commit(const char *rev, struct object_id *oid); + +/* + * Fill oid with the older end of a comparison with commit, which is what + * old_rev names when it is given, else commit's first parent, else the null + * id for a root commit. Returns -1 once the 404 page for old_rev is out. + */ +extern int cgit_get_old_oid(const char *old_rev, const struct commit *commit, struct object_id *oid); + +/* + * Start rev's walk, or print the error page and return non-zero when git + * cannot. A failed setup leaves the walk holding freed commits, so the caller + * must then neither read from it nor hand it to release_revisions. + */ +extern int cgit_prepare_walk(struct rev_info *rev); + +/* + * Refuse a path with a leading colon, printing the 400 page. git reads such a + * path as a pathspec with magic, which can end the request inside git. + * Returns non-zero when the path was refused. + */ +extern int cgit_refuse_pathspec_magic(const char *path); + extern void cgit_print_filemode(unsigned short mode); extern void cgit_print_snapshot_links(const struct cgit_repo *repo, const char *ref, const char *separator); /* * The name a snapshot of this repository is downloaded under, which is the - * configured prefix or the last component of the repository URL. + * configured prefix or the last component of the repository URL. A name taken + * from the URL is only good until the next call. */ extern const char *cgit_snapshot_prefix(const struct cgit_repo *repo); @@ -135,11 +186,17 @@ extern const char *cgit_snapshot_prefix(const struct cgit_repo *repo); * terms are carried only when the caller asks for them, since a form with a * field of its own for either would otherwise submit that name twice. */ -extern void cgit_add_hidden_formfields(int incl_head, int incl_search, const char *page); +extern void cgit_add_hidden_formfields(int incl_head, int incl_search); /* * Put path in front of the page title, its last component first. */ extern void cgit_set_title_from_path(const char *path); + +/* + * Names the page ahead of the repository and the site, so that the pages of one + * repository can be told apart in tabs, history and search results. + */ extern void cgit_prefix_title(const char *name); + #endif // CGIT_UI_SHARED_H diff --git a/source/ui-snapshot.c b/source/ui-snapshot.c index bd5ebf2..333bce6 100644 --- a/source/ui-snapshot.c +++ b/source/ui-snapshot.c @@ -1,13 +1,13 @@ /* * Serves a commit as a downloadable archive. Writing the archive is git's - * work, so each format here is a thin wrapper that either calls write_archive - * directly or pipes its tar output through an external compressor. The table - * of formats also lives here, and since a repository's snapshots mask is one - * bit per table position, that order is part of what cgitrc means. When the - * request carries no revision the file name is worked backwards to find one, - * so that a link to cgit-1.2.tar.gz can serve the tag it was named after. A - * name ending in .asc asks for the detached signature kept under refs/notes - * instead of the archive itself. + * work, so each format here is only a row in a table, naming the archive + * format git writes and, for a compressed tar, the compressor its output is + * piped through. A repository's snapshots mask is one bit per table position, + * so that order is part of what cgitrc means. When the request carries no + * revision the file name is worked backwards to find one, so that a link to + * cgit-1.2.tar.gz can serve the tag it was named after. A name ending in .asc + * asks for the detached signature kept under refs/notes instead of the archive + * itself. */ #define USE_THE_REPOSITORY_VARIABLE @@ -19,13 +19,10 @@ #include "ui-shared.h" #include "ui-snapshot.h" -#define SIG_SUFFIX ".asc" - -static int write_archive_format(const char *format_arg, const char *hex, const char *prefix) +static void write_archive_format(const char *format_arg, const char *hex, const char *prefix) { struct strvec argv = STRVEC_INIT; const char **args; - int result; strvec_push(&argv, "snapshot"); strvec_push(&argv, format_arg); @@ -48,20 +45,25 @@ static int write_archive_format(const char *format_arg, const char *hex, const c args = xmalloc(sizeof(char *) * (argv.nr + 1)); memcpy(args, argv.v, sizeof(char *) * (argv.nr + 1)); - result = write_archive(argv.nr, args, NULL, the_repository, NULL, 0); + write_archive(argv.nr, args, NULL, the_repository, NULL, 0); strvec_clear(&argv); free(args); - return result; } -static int write_tar_archive(const char *hex, const char *prefix) +static void write_snapshot(const struct cgit_snapshot_format *format, + const char *hex, const char *prefix) { - return write_archive_format("--format=tar", hex, prefix); -} + struct cgit_exec_filter compressor; + struct cgit_filter *filter = NULL; -static int write_zip_archive(const char *hex, const char *prefix) -{ - return write_archive_format("--format=zip", hex, prefix); + if (format->compressor) { + cgit_exec_filter_init(&compressor, format->compressor[0], + format->compressor); + filter = &compressor.base; + } + cgit_open_filter(filter); + write_archive_format(format->format_arg, hex, prefix); + cgit_close_filter(filter); } static int program_on_path(const char *program) @@ -101,66 +103,25 @@ static int program_on_path(const char *program) return found; } -static int write_compressed_tar_archive(const char *hex, const char *prefix, const char *argv[]) -{ - struct cgit_exec_filter filter; - int result; - - cgit_exec_filter_init(&filter, argv[0], argv); - cgit_open_filter(&filter.base); - result = write_tar_archive(hex, prefix); - cgit_close_filter(&filter.base); - return result; -} - -static int write_tar_gzip_archive(const char *hex, const char *prefix) -{ - const char *argv[] = { "gzip", "-n", NULL }; - - return write_compressed_tar_archive(hex, prefix, argv); -} - -static int write_tar_bzip2_archive(const char *hex, const char *prefix) -{ - const char *argv[] = { "bzip2", NULL }; - - return write_compressed_tar_archive(hex, prefix, argv); -} - -static int write_tar_lzip_archive(const char *hex, const char *prefix) -{ - const char *argv[] = { "lzip", NULL }; - - return write_compressed_tar_archive(hex, prefix, argv); -} - -static int write_tar_xz_archive(const char *hex, const char *prefix) -{ - // Current xz runs a thread per core unless told otherwise. - const char *argv[] = { "xz", "-T1", NULL }; - - return write_compressed_tar_archive(hex, prefix, argv); -} - -static int write_tar_zstd_archive(const char *hex, const char *prefix) -{ - // Single-threaded like the other compressors, since -T0 would let one - // request pin every core. - const char *argv[] = { "zstd", NULL }; - - return write_compressed_tar_archive(hex, prefix, argv); -} +static const char *gzip_argv[] = { "gzip", "-n", NULL }; +static const char *bzip2_argv[] = { "bzip2", NULL }; +static const char *lzip_argv[] = { "lzip", NULL }; +// Current xz runs a thread per core unless told otherwise. +static const char *xz_argv[] = { "xz", "-T1", NULL }; +// Single-threaded like the other compressors, since -T0 would let one +// request pin every core. +static const char *zstd_argv[] = { "zstd", NULL }; // ui-shared.c reads entry zero as the tar whose signature stands in for every // tar variant, so this order cannot change. const struct cgit_snapshot_format cgit_snapshot_formats[] = { - { ".tar", "application/x-tar", write_tar_archive, NULL }, - { ".tar.gz", "application/gzip", write_tar_gzip_archive, "gzip" }, - { ".tar.bz2", "application/x-bzip2", write_tar_bzip2_archive, "bzip2" }, - { ".tar.lz", "application/x-lzip", write_tar_lzip_archive, "lzip" }, - { ".tar.xz", "application/x-xz", write_tar_xz_archive, "xz" }, - { ".tar.zst", "application/zstd", write_tar_zstd_archive, "zstd" }, - { ".zip", "application/zip", write_zip_archive, NULL }, + { ".tar", "application/x-tar", "--format=tar", NULL }, + { ".tar.gz", "application/gzip", "--format=tar", gzip_argv }, + { ".tar.bz2", "application/x-bzip2", "--format=tar", bzip2_argv }, + { ".tar.lz", "application/x-lzip", "--format=tar", lzip_argv }, + { ".tar.xz", "application/x-xz", "--format=tar", xz_argv }, + { ".tar.zst", "application/zstd", "--format=tar", zstd_argv }, + { ".zip", "application/zip", "--format=zip", NULL }, { 0 } }; @@ -248,50 +209,41 @@ static int candidate_resolves(const struct cgit_repo *repo, const char *stem, * any is tried as a branch, which a branch spelled like a tag's stem would * otherwise win. */ -static const char *ref_from_filename(const struct cgit_repo *repo, const char *filename, - const struct cgit_snapshot_format *format) +static char *ref_from_filename(const struct cgit_repo *repo, const char *stem) { struct strbuf rev = STRBUF_INIT; - char *stem = xstrndup(filename, strlen(filename) - strlen(format->suffix)); int found = candidate_resolves(repo, stem, &rev, 1) || candidate_resolves(repo, stem, &rev, 0); - free(stem); if (!found) strbuf_release(&rev); return found ? strbuf_detach(&rev, NULL) : NULL; } -static int send_snapshot(const struct cgit_snapshot_format *format, const char *hex, +static void send_snapshot(const struct cgit_snapshot_format *format, const char *hex, const char *prefix, const char *filename) { struct object_id oid; - if (repo_get_oid(the_repository, hex, &oid)) { - cgit_print_error_page(404, "Not Found", "Bad object id: %s", hex); - return 1; - } - if (!lookup_commit_reference(the_repository, &oid)) { - cgit_print_error_page(404, "Not Found", "Not a commit: %s", hex); - return 1; - } + if (!cgit_get_commit(hex, &oid)) + return; // A compressor that cannot run only shows up as a broken pipe once // the tar is under way, so it is looked for while a status can still // say so. - if (format->program && !program_on_path(format->program)) { - cgit_print_error_page(500, "Internal Server Error", "Unable to run %s", format->program); - return 1; + if (format->compressor && !program_on_path(format->compressor[0])) { + cgit_print_error_page(500, "Internal Server Error", "Unable to run %s", + format->compressor[0]); + return; } - ctx.page.mimetype = xstrdup(format->mimetype); - ctx.page.filename = xstrdup(filename); + ctx.page.mimetype = format->mimetype; + ctx.page.filename = filename; cgit_print_http_headers(); init_archivers(); // The archiver reads its arguments as a command line, so it gets the // resolved id and never the name. - format->write_func(oid_to_hex(&oid), prefix); - return 0; + write_snapshot(format, oid_to_hex(&oid), prefix); } -static int send_sig(const struct cgit_snapshot_format *format, const char *hex, +static void send_sig(const struct cgit_snapshot_format *format, const char *hex, const char *archive_name, const char *sig_filename) { const struct object_id *note = cgit_snapshot_get_sig(hex, format); @@ -301,34 +253,33 @@ static int send_sig(const struct cgit_snapshot_format *format, const char *hex, if (!note) { cgit_print_error_page(404, "Not Found", "No signature for %s", archive_name); - return 0; + return; } // A note is any blob a pusher stored, so it is held to the same bound // as every other blob a page inflates. if (odb_read_object_info(the_repository->objects, note, &size) != OBJ_BLOB) { cgit_print_error_page(404, "Not Found", "Not found"); - return 0; + return; } if (cgit_blob_too_large(size)) { cgit_print_blob_too_large(size); - return 0; + return; } buf = odb_read_object(the_repository->objects, note, &type, &size); if (!buf) { cgit_print_error_page(404, "Not Found", "Not found"); - return 0; + return; } ctx.page.untrusted = 1; - ctx.page.mimetype = xstrdup("application/pgp-signature"); - ctx.page.filename = xstrdup(sig_filename); + ctx.page.mimetype = "application/pgp-signature"; + ctx.page.filename = sig_filename; cgit_print_http_headers(); html_raw(buf, size); free(buf); - return 0; } const struct object_id *cgit_snapshot_get_sig(const char *ref, const struct cgit_snapshot_format *f) @@ -401,58 +352,51 @@ void cgit_print_snapshot(const char *head, const char *hex, const char *filename const char *sig_filename = NULL; char *archive_name = NULL; char *prefix = NULL; + char *ref = NULL; + size_t len; if (!filename) { cgit_print_error_page(400, "Bad Request", "No snapshot name specified"); return; } - if (ends_with(filename, SIG_SUFFIX)) { + if (strip_suffix(filename, SIG_SUFFIX, &len)) { sig_filename = filename; - archive_name = xstrdup(filename); - archive_name[strlen(archive_name) - strlen(SIG_SUFFIX)] = '\0'; - filename = archive_name; + filename = archive_name = xstrndup(filename, len); } f = find_format(filename); if (!f || (!sig_filename && !(ctx.repo->snapshots & cgit_snapshot_format_bit(f)))) { cgit_print_error_page(400, "Bad Request", "Unsupported snapshot format: %s", filename); - return; + goto done; } - // The name becomes the prefix of every member of the archive, and a - // revision expression such as :/pattern could carry a path in it. - if (!hex && dwim && strchr(filename, '/')) { - cgit_print_error_page(404, "Not Found", "Not found"); - return; - } - // A name that is all suffix names no revision, though the lookup below - // would settle on a ref called v. - if (!hex && dwim && strlen(filename) == strlen(f->suffix)) { - cgit_print_error_page(404, "Not Found", "Not found"); - return; - } if (!hex && dwim) { - hex = ref_from_filename(ctx.repo, filename, f); + strip_suffix(filename, f->suffix, &len); + prefix = xstrndup(filename, len); + // The stem becomes the prefix of every member of the archive, + // and a revision expression such as :/pattern could carry a path + // in it. An empty stem names no revision, though the lookup + // would settle on a ref called v. + if (len && !strchr(prefix, '/')) + hex = ref = ref_from_filename(ctx.repo, prefix); if (!hex) { cgit_print_error_page(404, "Not Found", "Not found"); - return; + goto done; } - prefix = xstrdup(filename); - prefix[strlen(filename) - strlen(f->suffix)] = '\0'; - } - - if (!hex) - hex = head; - - if (!prefix) + } else { prefix = xstrdup(cgit_snapshot_prefix(ctx.repo)); + if (!hex) + hex = head; + } if (sig_filename) send_sig(f, hex, filename, sig_filename); else send_snapshot(f, hex, prefix, filename); +done: free(prefix); + free(ref); free(archive_name); } diff --git a/source/ui-snapshot.h b/source/ui-snapshot.h index 412807e..12241a9 100644 --- a/source/ui-snapshot.h +++ b/source/ui-snapshot.h @@ -11,14 +11,13 @@ #include "cgit.h" -typedef int (*write_archive_fn_t)(const char *hex, const char *prefix); - struct cgit_snapshot_format { const char *suffix; const char *mimetype; - write_archive_fn_t write_func; - // The compressor the writer runs, or NULL when it needs none. - const char *program; + const char *format_arg; + // The command line git's output is piped through, or NULL when the + // archive goes out as git writes it. + const char **compressor; }; // Terminated by an entry with a NULL suffix. @@ -28,12 +27,15 @@ extern unsigned cgit_snapshot_format_bit(const struct cgit_snapshot_format *f); /* * Turn a cgitrc snapshots value into a mask over cgit_snapshot_formats. A - * plain nonzero number is the legacy boolean form and enables plain tar + * plain non-zero number is the legacy boolean form and enables plain tar * alone, the word all enables every format, and otherwise the value is a * space separated list of suffixes. */ extern int cgit_parse_snapshots_mask(const char *str); +// The suffix that names a signature after the archive it signs. +#define SIG_SUFFIX ".asc" + /* * The detached signature stored for this format under refs/notes, if any. */ diff --git a/source/ui-ssdiff.c b/source/ui-ssdiff.c index afa25ea..90498b7 100644 --- a/source/ui-ssdiff.c +++ b/source/ui-ssdiff.c @@ -14,6 +14,14 @@ #include "ui-shared.h" #include "ui-ssdiff.h" +// The longest old line and the longest new line the view will compare +// character by character. The comparison fills a cell for every pair of +// characters, so its cost is the product of the two, and a line past the limit +// is shown whole instead. +#define MAX_SSDIFF_M 128 +#define MAX_SSDIFF_N 128 +#define MAX_SSDIFF_SIZE (MAX_SSDIFF_M * MAX_SSDIFF_N) + // One line held back until its run ends, owning the copy taken of it. struct deferred_line { int line_no; @@ -21,6 +29,8 @@ struct deferred_line { struct deferred_line *next; }; +// The pair being rendered, whose two sides the line number links lead into. +static struct diff_filepair *current_filepair; static int current_old_line, current_new_line; static int **lcs_table; static struct deferred_line *deferred_old, *deferred_old_last; @@ -182,6 +192,27 @@ static void print_lineno_cell(struct diff_filespec *file, const struct object_id strbuf_release(&path); } +/* + * mark is the class for the characters this side of the row does not share + * with the other. + */ +static void print_side(const char *class, struct diff_filespec *file, const struct object_id *rev, + int line_no, const char *line, const char *lcs, const char *mark) +{ + if (line_no > 0) + print_lineno_cell(file, rev, line_no); + else + html("<td class='lineno'></td>"); + htmlf("<td class='%s%s'>", class, line ? "" : "-dark"); + if (line) { + if (lcs) + print_line_with_lcs(mark, line, lcs); + else + html_txt(line); + } + html("</td>"); +} + static void print_row(const char *class, int old_line_no, char *old_line, int new_line_no, char *new_line, int highlight_chars) { @@ -195,38 +226,10 @@ static void print_row(const char *class, int old_line_no, char *old_line, if (highlight_chars && old_line && new_line) lcs = longest_common_subsequence(old_line, new_line); html("<tr>\n"); - if (old_line_no > 0) { - print_lineno_cell(cgit_get_current_old_file(), old_rev_oid, old_line_no); - htmlf("<td class='%s'>", class); - } else if (old_line) { - htmlf("<td class='lineno'></td><td class='%s'>", class); - } else { - htmlf("<td class='lineno'></td><td class='%s-dark'>", class); - } - if (old_line) { - if (lcs) - print_line_with_lcs("del", old_line, lcs); - else - html_txt(old_line); - } - - html("</td>\n"); - if (new_line_no > 0) { - print_lineno_cell(cgit_get_current_new_file(), new_rev_oid, new_line_no); - htmlf("<td class='%s'>", class); - } else if (new_line) { - htmlf("<td class='lineno'></td><td class='%s'>", class); - } else { - htmlf("<td class='lineno'></td><td class='%s-dark'>", class); - } - if (new_line) { - if (lcs) - print_line_with_lcs("add", new_line, lcs); - else - html_txt(new_line); - } - - html("</td></tr>\n"); + print_side(class, current_filepair->one, old_rev_oid, old_line_no, old_line, lcs, "del"); + html("\n"); + print_side(class, current_filepair->two, new_rev_oid, new_line_no, new_line, lcs, "add"); + html("</tr>\n"); free(lcs); free(new_line); free(old_line); @@ -268,38 +271,12 @@ static int count_deferred(struct deferred_line *item) return count; } -static void print_deferred_old_lines(void) -{ - struct deferred_line *item = deferred_old; - struct deferred_line *next; - - while (item) { - print_row("del", item->line_no, item->line, -1, NULL, 0); - next = item->next; - free_deferred(item); - item = next; - } -} - -static void print_deferred_new_lines(void) -{ - struct deferred_line *item = deferred_new; - struct deferred_line *next; - - while (item) { - print_row("add", -1, NULL, item->line_no, item->line, 0); - next = item->next; - free_deferred(item); - item = next; - } -} - /* * Pairing a removal with an addition only stands for anything when the two * runs are the same length, which is why the character marking is offered only * then. */ -static void print_deferred_changed_lines(void) +static void print_deferred_run(const char *class) { struct deferred_line *old_item = deferred_old; struct deferred_line *new_item = deferred_new; @@ -309,12 +286,12 @@ static void print_deferred_changed_lines(void) highlight_chars = count_deferred(old_item) == count_deferred(new_item); while (old_item || new_item) { if (old_item && new_item) - print_row("changed", old_item->line_no, old_item->line, + print_row(class, old_item->line_no, old_item->line, new_item->line_no, new_item->line, highlight_chars); else if (old_item) - print_row("changed", old_item->line_no, old_item->line, -1, NULL, 0); + print_row(class, old_item->line_no, old_item->line, -1, NULL, 0); else if (new_item) - print_row("changed", -1, NULL, new_item->line_no, new_item->line, 0); + print_row(class, -1, NULL, new_item->line_no, new_item->line, 0); if (old_item) { next = old_item->next; free_deferred(old_item); @@ -353,12 +330,7 @@ static void print_deferred_lines(void) { if (!deferred_old && !deferred_new) return; - if (deferred_old && !deferred_new) - print_deferred_old_lines(); - else if (!deferred_old && deferred_new) - print_deferred_new_lines(); - else - print_deferred_changed_lines(); + print_deferred_run(!deferred_new ? "del" : !deferred_old ? "add" : "changed"); deferred_old = deferred_old_last = NULL; deferred_new = deferred_new_last = NULL; } @@ -401,12 +373,13 @@ void cgit_ssdiff_line_cb(char *line, int len) line[len - 1] = terminator; } -void cgit_ssdiff_header_begin(const char *path) +void cgit_ssdiff_header_begin(struct diff_filepair *pair) { + current_filepair = pair; current_old_line = -1; current_new_line = -1; html("<tbody class='file' data-path='"); - html_attr(path); + html_attr(pair->two->path); html("'>\n"); html("<tr><td class='space' colspan='4'></td></tr>\n"); html("<tr><td class='head' colspan='4'>"); diff --git a/source/ui-ssdiff.h b/source/ui-ssdiff.h index 9417277..e40e9b8 100644 --- a/source/ui-ssdiff.h +++ b/source/ui-ssdiff.h @@ -7,26 +7,15 @@ #ifndef CGIT_UI_SSDIFF_H #define CGIT_UI_SSDIFF_H -// The longest old line and the longest new line the view will compare -// character by character. The comparison fills a cell for every pair of -// characters, so its cost is the product of the two, and a line past the limit -// is shown whole instead. -#ifndef MAX_SSDIFF_M -#define MAX_SSDIFF_M 128 -#endif - -#ifndef MAX_SSDIFF_N -#define MAX_SSDIFF_N 128 -#endif -#define MAX_SSDIFF_SIZE ((MAX_SSDIFF_M) * (MAX_SSDIFF_N)) - extern void cgit_ssdiff_line_cb(char *line, int len); /* - * Open the group of rows one file's diff occupies, header row included, and - * close it again after the last line. path lands on the group as data-path. + * Open the group of rows a file pair's diff occupies, header row included, and + * close it again after the last line. The new path lands on the group as + * data-path, and the rows read the pair until the footer, so it has to live + * that long. */ -extern void cgit_ssdiff_header_begin(const char *path); +extern void cgit_ssdiff_header_begin(struct diff_filepair *pair); extern void cgit_ssdiff_header_end(void); extern void cgit_ssdiff_footer(void); diff --git a/source/ui-stats.c b/source/ui-stats.c index e4dcf8e..ee5b76b 100644 --- a/source/ui-stats.c +++ b/source/ui-stats.c @@ -1,9 +1,7 @@ /* - * The statistics page, which counts commits per author across a window of - * recent weeks, months, quarters or years. The window sizes live in one table - * here, which cgit.c also resolves the max-stats setting against so a - * repository can refuse the coarser windows. The page reads no diff and no - * blob contents, so the whole thing costs about one rev-list. + * The statistics page counts each author's commits in the most recent periods + * of the size asked for. It reads no diff and no blob contents, so the whole + * thing costs about one rev-list. */ #define USE_THE_REPOSITORY_VARIABLE @@ -11,12 +9,29 @@ #include "cgit.h" #include "html.h" #include "parsing.h" -#include "shared.h" #include "ui-shared.h" #include "ui-stats.h" #define DEFAULT_AUTHOR_ROWS 10 +// How many periods the page shows side by side. +#define PERIOD_COLUMNS 4 + +struct cgit_period { + const char code; + const char *name; + + // Move a tm back to the first day of the period it falls in. + void (*trunc)(struct tm *tm); + + // Step a truncated tm n whole periods, back when n is negative. + void (*step)(struct tm *tm, int n); + + // Label the period a tm falls in. The buffer is reused, so a caller + // that keeps a label copies it. + char *(*pretty)(struct tm *tm); +}; + // One author's share of the window. periods is keyed by the label of a period, // with the commit count stored in the util field itself rather than behind // another allocation. @@ -35,19 +50,11 @@ static void trunc_week(struct tm *tm) gmtime_r(&t, tm); } -static void dec_week(struct tm *tm) -{ - time_t t = timegm(tm); - - t -= SECONDS_PER_WEEK; - gmtime_r(&t, tm); -} - -static void inc_week(struct tm *tm) +static void step_week(struct tm *tm, int n) { time_t t = timegm(tm); - t += SECONDS_PER_WEEK; + t += n * SECONDS_PER_WEEK; gmtime_r(&t, tm); } @@ -68,21 +75,15 @@ static void trunc_month(struct tm *tm) tm->tm_mday = 1; } -static void dec_month(struct tm *tm) +static void step_month(struct tm *tm, int n) { - tm->tm_mon--; + tm->tm_mon += n; if (tm->tm_mon < 0) { tm->tm_year--; - tm->tm_mon = 11; - } -} - -static void inc_month(struct tm *tm) -{ - tm->tm_mon++; - if (tm->tm_mon > 11) { + tm->tm_mon += 12; + } else if (tm->tm_mon > 11) { tm->tm_year++; - tm->tm_mon = 0; + tm->tm_mon -= 12; } } @@ -98,22 +99,12 @@ static char *pretty_month(struct tm *tm) static void trunc_quarter(struct tm *tm) { trunc_month(tm); - while (tm->tm_mon % 3 != 0) - dec_month(tm); + tm->tm_mon -= tm->tm_mon % 3; } -static void dec_quarter(struct tm *tm) +static void step_quarter(struct tm *tm, int n) { - dec_month(tm); - dec_month(tm); - dec_month(tm); -} - -static void inc_quarter(struct tm *tm) -{ - inc_month(tm); - inc_month(tm); - inc_month(tm); + step_month(tm, 3 * n); } static char *pretty_quarter(struct tm *tm) @@ -127,14 +118,9 @@ static void trunc_year(struct tm *tm) tm->tm_mon = 0; } -static void dec_year(struct tm *tm) -{ - tm->tm_year--; -} - -static void inc_year(struct tm *tm) +static void step_year(struct tm *tm, int n) { - tm->tm_year++; + tm->tm_year += n; } static char *pretty_year(struct tm *tm) @@ -145,10 +131,10 @@ static char *pretty_year(struct tm *tm) // The order runs from the finest window to the coarsest, because a repository // caps the page by storing an index into this table as its max-stats. static const struct cgit_period periods[] = { - {'w', "week", 4, trunc_week, dec_week, inc_week, pretty_week}, - {'m', "month", 4, trunc_month, dec_month, inc_month, pretty_month}, - {'q', "quarter", 4, trunc_quarter, dec_quarter, inc_quarter, pretty_quarter}, - {'y', "year", 4, trunc_year, dec_year, inc_year, pretty_year}, + {'w', "week", trunc_week, step_week, pretty_week}, + {'m', "month", trunc_month, step_month, pretty_month}, + {'q', "quarter", trunc_quarter, step_quarter, pretty_quarter}, + {'y', "year", trunc_year, step_year, pretty_year}, }; static void window_start(const struct cgit_period *period, struct tm *tm) @@ -162,8 +148,12 @@ static void window_start(const struct cgit_period *period, struct tm *tm) time(&now); gmtime_r(&now, tm); period->trunc(tm); - for (i = 1; i < period->count; i++) - period->dec(tm); + // The window opens at midnight, not at the time of day it is now. + tm->tm_hour = 0; + tm->tm_min = 0; + tm->tm_sec = 0; + for (i = 1; i < PERIOD_COLUMNS; i++) + period->step(tm, -1); } static void add_commit(struct string_list *authors, struct commitinfo *info, @@ -186,13 +176,12 @@ static void add_commit(struct string_list *authors, struct commitinfo *info, stats = author->util; when = info->committer_date; // A crafted commit can carry a date outside the range gmtime_r can - // represent, which would leave date uninitialized and later index the + // represent, which would leave date uninitialised and later index the // month table out of bounds. if (!gmtime_r(&when, &date)) return; period->trunc(&date); label = xstrdup(period->pretty(&date)); - // The pointer field holds the count itself. bucket = string_list_insert(&stats->periods, label); if (bucket->util) free(label); @@ -211,38 +200,25 @@ static int collect_stats(const struct cgit_period *period, struct string_list *a { struct rev_info rev; struct commit *commit; - // setup_revisions reads these the way main does, skipping the first - // entry, and reads past the count up to a NULL after the double dash, - // so the sentinel has to stay even when the path fills the slot before - // it. - const char *argv[] = { NULL, ctx.qry.head, NULL, NULL, NULL }; - int argc = 2; + struct strvec argv = STRVEC_INIT; time_t since; struct tm tm; window_start(period, &tm); since = timegm(&tm); - if (ctx.qry.path) { - argv[2] = "--"; - argv[3] = ctx.qry.path; - argc += 2; - } + strvec_pushl(&argv, "stats", ctx.qry.head, NULL); + if (ctx.qry.path) + strvec_pushl(&argv, "--", ctx.qry.path, NULL); repo_init_revisions(the_repository, &rev, NULL); - rev.abbrev = DEFAULT_ABBREV; - rev.commit_format = CMIT_FMT_DEFAULT; rev.max_parents = 1; - rev.verbose_header = 1; - rev.show_root_diff = 0; - setup_revisions(argc, argv, &rev, NULL); + setup_revisions_from_strvec(&argv, &rev, NULL); // Prune the walk to the displayed window instead of traversing the // whole history and discarding older commits. The check below still // bounds the period edge exactly. rev.max_age = since; - // A failed setup leaves the walk holding freed commits, so it must - // not be read from. - if (prepare_revision_walk(&rev)) { - cgit_print_error_page(500, "Internal Server Error", "Unable to read the history"); + if (cgit_prepare_walk(&rev)) { + strvec_clear(&argv); return -1; } while ((commit = get_revision(&rev))) { @@ -255,6 +231,8 @@ static int collect_stats(const struct cgit_period *period, struct string_list *a release_commit_memory(the_repository->parsed_objects, commit); commit->parents = NULL; } + release_revisions(&rev); + strvec_clear(&argv); return 0; } @@ -286,9 +264,9 @@ static struct string_list build_period_labels(const struct cgit_period *period) int i; window_start(period, &tm); - for (i = 0; i < period->count; i++) { + for (i = 0; i < PERIOD_COLUMNS; i++) { string_list_append(&labels, period->pretty(&tm)); - period->inc(&tm); + period->step(&tm, 1); } return labels; } @@ -297,9 +275,9 @@ static struct string_list build_period_labels(const struct cgit_period *period) * The run of authors to sum is given as a start and a count rather than as * two indices, so that an empty author list cannot describe a run that wraps. */ -static void print_summary_row(struct string_list *authors, size_t from, size_t count, - const char *label_format, const char *leftclass, const char *centerclass, - const char *rightclass, const struct string_list *labels) +static void print_row(struct string_list *authors, size_t from, size_t count, + const char *name, const char *nameclass, const char *cellclass, + const struct string_list *labels) { struct authorstat *stats; struct string_list_item *bucket; @@ -307,7 +285,9 @@ static void print_summary_row(struct string_list *authors, size_t from, size_t c long total, subtotal; total = 0; - htmlf("<tr><td class='%s'>%s</td>", leftclass, cgit_fmt(label_format, (long)count)); + htmlf("<tr><td class='%s'>", nameclass); + html_txt(name); + html("</td>"); for (column = 0; column < labels->nr; column++) { const char *label = labels->items[column].string; @@ -318,18 +298,18 @@ static void print_summary_row(struct string_list *authors, size_t from, size_t c if (bucket) subtotal += (uintptr_t)bucket->util; } - htmlf("<td class='%s'>%ld</td>", centerclass, subtotal); + if (cellclass) + htmlf("<td class='%s'>%ld</td>", cellclass, subtotal); + else + htmlf("<td>%ld</td>", subtotal); total += subtotal; } - htmlf("<td class='%s'>%ld</td></tr>\n", rightclass, total); + htmlf("<td class='sum'>%ld</td></tr>\n", total); } static void print_authors(struct string_list *authors, int max_rows, const struct string_list *labels) { - struct string_list_item *author, *bucket; - struct authorstat *stats; size_t i, column, rows; - long total; html("<table class='stats'>\n<tr><th scope='col'>Author</th>"); for (column = 0; column < labels->nr; column++) @@ -339,32 +319,14 @@ static void print_authors(struct string_list *authors, int max_rows, const struc // The row count arrives through ofs, which carries -1 for "all". rows = (max_rows <= 0 || (size_t)max_rows > authors->nr) ? authors->nr : (size_t)max_rows; - for (i = 0; i < rows; i++) { - author = &authors->items[i]; - html("<tr><td class='left'>"); - html_txt(author->string); - html("</td>"); - stats = author->util; - total = 0; - for (column = 0; column < labels->nr; column++) { - const char *label = labels->items[column].string; - - bucket = string_list_lookup(&stats->periods, label); - if (!bucket) - html("<td>0</td>"); - else { - htmlf("<td>%ju</td>", (uintmax_t)(uintptr_t)bucket->util); - total += (uintptr_t)bucket->util; - } - } - htmlf("<td class='sum'>%ld</td></tr>\n", total); - } + for (i = 0; i < rows; i++) + print_row(authors, i, 1, authors->items[i].string, "left", NULL, labels); if (rows < authors->nr) - print_summary_row(authors, rows, authors->nr - rows, "Others (%ld)", "left", "", "sum", - labels); + print_row(authors, rows, authors->nr - rows, + cgit_fmt("Others (%ld)", (long)(authors->nr - rows)), "left", NULL, labels); - print_summary_row(authors, 0, authors->nr, "Total", "total", "sum", "sum", labels); + print_row(authors, 0, authors->nr, "Total", "total", "sum", labels); html("</table>\n"); } @@ -375,7 +337,7 @@ static void print_options_form(const struct cgit_period *period, int top) html("<div class='cgit-panel'>\n"); html("<strong>Stat options</strong>"); html("<form method='get' aria-label='Stat options'>"); - cgit_add_hidden_formfields(1, 0, "stats"); + cgit_add_hidden_formfields(1, 0); html("<div class='fields'>"); if (ctx.repo->max_stats > 1) { choices = ctx.repo->max_stats; @@ -448,9 +410,11 @@ void cgit_show_stats(void) cgit_print_error_page(400, "Bad Request", "Statistics type disabled: %s", period->name); return; } + if (cgit_refuse_pathspec_magic(ctx.qry.path)) + return; if (collect_stats(period, &authors)) return; - cgit_qsort(authors.items, authors.nr, sizeof(struct string_list_item), cmp_total_commits); + QSORT(authors.items, authors.nr, cmp_total_commits); top = ctx.qry.ofs; if (!top) diff --git a/source/ui-stats.h b/source/ui-stats.h index 325e082..3479732 100644 --- a/source/ui-stats.h +++ b/source/ui-stats.h @@ -1,33 +1,13 @@ /* * The statistics page, which counts commits per author over a window of - * recent weeks, months, quarters or years. The windows are described here - * rather than inside the page, because cgit.c resolves a repository's - * max-stats setting against the same set. + * recent weeks, months, quarters or years, and the period lookups that cgit.c + * resolves a max-stats setting with. */ #ifndef CGIT_UI_STATS_H #define CGIT_UI_STATS_H -#include "cgit.h" - -struct cgit_period { - const char code; - const char *name; - - // How many periods a page shows side by side. - int count; - - // Move a tm back to the first day of the period it falls in. - void (*trunc)(struct tm *tm); - - // Step a truncated tm one whole period back or forward. - void (*dec)(struct tm *tm); - void (*inc)(struct tm *tm); - - // Label the period a tm falls in. The buffer is reused, so a caller - // that keeps a label copies it. - char *(*pretty)(struct tm *tm); -}; +struct cgit_period; /* * Look a period up by its one letter code or by its full name, storing the diff --git a/source/ui-summary.c b/source/ui-summary.c index f37650d..8cc48bf 100644 --- a/source/ui-summary.c +++ b/source/ui-summary.c @@ -10,30 +10,14 @@ #include "cgit.h" #include "filter.h" #include "html.h" -#include "shared.h" #include "ui-blob.h" #include "ui-log.h" #include "ui-refs.h" #include "ui-shared.h" #include "ui-summary.h" -// Age, commit message and author, the three columns a log row always has. -// ui-log.c adds one more for each of the two optional counts. -#define LOG_BASE_COLUMNS 3 - static int clone_urls_printed; -static int log_columns(void) -{ - int columns = LOG_BASE_COLUMNS; - - if (ctx.repo->enable_log_filecount) - columns++; - if (ctx.repo->enable_log_linecount) - columns++; - return columns; -} - /* * The sections share one table, which is as wide as the widest of them, and * every row fills it, since a short row would end its stripe short of the @@ -41,8 +25,8 @@ static int log_columns(void) */ static int table_columns(void) { - if (ctx.cfg.summary_log > 0 && log_columns() > REF_COLUMNS) - return log_columns(); + if (ctx.cfg.summary_log > 0 && cgit_log_columns(0) > REF_COLUMNS) + return cgit_log_columns(0); return REF_COLUMNS; } @@ -96,7 +80,7 @@ void cgit_print_repo_readme(void) // into safe HTML, so it is escaped rather than served raw. html("<pre class='plaintext'>"); if (ref) { - err = cgit_print_file(filename, ref, 1, 1); + err = cgit_print_file(filename, ref, 1); } else { struct strbuf sb = STRBUF_INIT; if (strbuf_read_file(&sb, filename, 0) >= 0) @@ -115,7 +99,7 @@ void cgit_print_repo_readme(void) // through unescaped. cgit_open_filter(ctx.repo->about_filter, filename, tree_base.buf, tree_query.buf); if (ref) - err = cgit_print_file(filename, ref, 1, 0); + err = cgit_print_file(filename, ref, 0); else html_include(filename); cgit_close_filter(ctx.repo->about_filter); diff --git a/source/ui-tag.c b/source/ui-tag.c index f9ce6b3..d60a58e 100644 --- a/source/ui-tag.c +++ b/source/ui-tag.c @@ -10,10 +10,8 @@ #define USE_THE_REPOSITORY_VARIABLE #include "cgit.h" -#include "filter.h" #include "html.h" #include "parsing.h" -#include "shared.h" #include "ui-shared.h" #include "ui-tag.h" @@ -28,25 +26,15 @@ static void print_name_row(const char *revname, const struct object_id *oid) static void print_date_row(struct taginfo *info) { - html("<tr><th scope='row'>tag date</th><td><time datetime='"); - html_attr(show_date(info->tagger_date, info->tagger_tz, cgit_date_mode(DATE_ISO8601_STRICT))); - html("'>"); - html_txt(show_date(info->tagger_date, info->tagger_tz, cgit_date_mode(DATE_ISO8601))); - html("</time></td></tr>\n"); + html("<tr><th scope='row'>tag date</th><td>"); + cgit_print_date(info->tagger_date, info->tagger_tz); + html("</td></tr>\n"); } static void print_tagger_row(struct taginfo *info) { html("<tr><th scope='row'>tagged by</th><td>"); - // A tag can carry a tagger with no address, and a filter must not be - // handed a NULL argument. - cgit_open_filter(ctx.repo->email_filter, info->tagger_email ? info->tagger_email : "", "tag"); - html_txt(info->tagger); - if (info->tagger_email && ctx.cfg.enable_plain_email) { - html(" "); - html_txt(info->tagger_email); - } - cgit_close_filter(ctx.repo->email_filter); + cgit_print_person(info->tagger, info->tagger_email, "tag", 1); html("</td></tr>\n"); } @@ -98,20 +86,18 @@ static void print_message(char *msg) } } -static void print_annotated_tag(const char *revname, const struct object_id *oid) +static void print_annotated_tag(const char *revname, struct tag *tag) { - struct tag *tag; - struct taginfo *info; + struct taginfo *info = cgit_parse_tag(tag); - tag = lookup_tag(the_repository, oid); - if (!tag || parse_tag(the_repository, tag) || !(info = cgit_parse_tag(tag))) { + if (!info) { cgit_print_error_page(500, "Internal Server Error", "Bad tag object: %s", revname); return; } cgit_print_layout_start(); html("<table class='commit-info'>\n"); - print_name_row(revname, oid); + print_name_row(revname, &tag->object.oid); if (info->tagger_date > 0) print_date_row(info); if (info->tagger) @@ -167,7 +153,7 @@ void cgit_print_tag(char *revname) } cgit_prefix_title(revname); if (obj->type == OBJ_TAG) - print_annotated_tag(revname, &oid); + print_annotated_tag(revname, (struct tag *)obj); else print_lightweight_tag(revname, obj); diff --git a/source/ui-tree.c b/source/ui-tree.c index 95ff337..aff4f0f 100644 --- a/source/ui-tree.c +++ b/source/ui-tree.c @@ -38,7 +38,7 @@ struct ls_entry { }; struct walk_tree_context { - char *rev; + const char *rev; char *match_path; enum walk_state state; struct ls_entry *entries; @@ -54,38 +54,19 @@ struct only_child { size_t count; }; -static void add_linenumber(struct strbuf *numbers, size_t lineno) -{ - strbuf_addf(numbers, "<a id='n%zu' href='#n%zu'>%zu</a>\n", lineno, lineno, lineno); -} - -/* - * The anchors are handed over in batches rather than a write per line, and - * never built whole, which would come to several times the blob's size. - */ static void print_linenumbers(const char *buf, size_t size) { - struct strbuf numbers = STRBUF_INIT; - size_t lineno = 0, idx = 0; + size_t lines = 0, idx; if (size) { - add_linenumber(&numbers, ++lineno); - + lines = 1; // The newline that ends the last line must not open a line of - // its own, so the final byte is left out of the scan. - while (idx < size - 1) { - if (buf[idx] == '\n') { - add_linenumber(&numbers, ++lineno); - if (numbers.len >= HTML_BATCH) { - html_raw(numbers.buf, numbers.len); - strbuf_reset(&numbers); - } - } - idx++; - } - html_raw(numbers.buf, numbers.len); + // its own, so the final byte is left out of the count. + for (idx = 0; idx < size - 1; idx++) + if (buf[idx] == '\n') + lines++; } - strbuf_release(&numbers); + cgit_print_line_anchors(1, lines); } static void print_text_buffer(const char *filename, char *buf, size_t size) @@ -100,20 +81,14 @@ static void print_text_buffer(const char *filename, char *buf, size_t size) html("<tr>\n"); } + html("<td class='lines'><pre><code>"); if (ctx.repo->source_filter) { - char *filter_arg = xstrdup(filename); - - html("<td class='lines'><pre><code>"); - cgit_open_filter(ctx.repo->source_filter, filter_arg); + cgit_open_filter(ctx.repo->source_filter, filename); html_raw(buf, size); cgit_close_filter(ctx.repo->source_filter); - free(filter_arg); - html("</code></pre></td></tr></table>\n"); - return; + } else { + html_blob_txt(buf, size); } - - html("<td class='lines'><pre><code>"); - html_blob_txt(buf, size); html("</code></pre></td></tr></table>\n"); } @@ -231,8 +206,8 @@ static int only_child_cb(const struct object_id *oid, struct strbuf *base UNUSED return 0; } -static void print_dir_chain(const struct object_id *oid, char *name, char *rev, - struct strbuf *fullpath) +static void print_dir_chain(const struct object_id *oid, const char *name, + const char *rev, struct strbuf *fullpath) { size_t initial_length = fullpath->len; struct tree *tree; @@ -254,7 +229,7 @@ static void print_dir_chain(const struct object_id *oid, char *name, char *rev, tree = lookup_tree(the_repository, &child.oid); if (!tree) - return; + break; free(child.name); child.name = NULL; @@ -270,13 +245,13 @@ static void print_dir_chain(const struct object_id *oid, char *name, char *rev, name = child.name; } + free(child.name); strbuf_setlen(fullpath, initial_length); } -static void print_ls_row(const struct object_id *oid, const char *pathname, +static void print_ls_row(const struct object_id *oid, const char *name, unsigned mode, struct walk_tree_context *walk) { - char *name; struct strbuf fullpath = STRBUF_INIT; struct strbuf linkpath = STRBUF_INIT; struct strbuf class = STRBUF_INIT; @@ -284,7 +259,6 @@ static void print_ls_row(const struct object_id *oid, const char *pathname, size_t size = 0; char *buf; - name = xstrdup(pathname); strbuf_addf(&fullpath, "%s%s%s", ctx.qry.path ? ctx.qry.path : "", ctx.qry.path ? "/" : "", name); if (!S_ISGITLINK(mode)) { @@ -314,7 +288,7 @@ static void print_ls_row(const struct object_id *oid, const char *pathname, } else if (S_ISDIR(mode)) { print_dir_chain(oid, name, walk->rev, &fullpath); } else { - char *ext = strrchr(name, '.'); + const char *ext = strrchr(name, '.'); strbuf_addstr(&class, "ls-blob"); // The extension becomes a class a stylesheet can pick files out @@ -366,7 +340,6 @@ static void print_ls_row(const struct object_id *oid, const char *pathname, html("</td></tr>\n"); cleanup: - free(name); strbuf_release(&fullpath); strbuf_release(&class); } @@ -435,25 +408,6 @@ static void ls_tail(void) cgit_print_layout_end(); } -static void ls_tree(const struct object_id *oid, struct walk_tree_context *walk) -{ - struct tree *tree; - struct pathspec paths = { - .nr = 0 - }; - - tree = parse_tree_indirect(oid); - if (!tree) { - cgit_print_error_page(404, "Not Found", "Not a tree object: %s", oid_to_hex(oid)); - return; - } - - ls_head(); - read_tree(the_repository, tree, &paths, ls_item, walk); - ls_flush(walk); - ls_tail(); -} - static int walk_tree(const struct object_id *oid, struct strbuf *base, const char *pathname, unsigned mode, void *data) { @@ -493,18 +447,8 @@ void cgit_print_tree(const char *rev, char *path) { struct object_id oid; struct commit *commit; - int path_len = path ? strlen(path) : 0; - // nowildcard_len matches len so git treats the path as literal rather - // than as a glob. - struct pathspec_item path_items = { - .match = path, - .len = path_len, - .nowildcard_len = path_len - }; - struct pathspec paths = { - .nr = path ? 1 : 0, - .items = &path_items - }; + struct pathspec_item path_item; + struct pathspec paths = { 0 }; struct walk_tree_context walk = { .match_path = path, .state = WALK_LOOKING @@ -513,22 +457,19 @@ void cgit_print_tree(const char *rev, char *path) if (!rev) rev = ctx.qry.head; - if (repo_get_oid(the_repository, rev, &oid)) { - cgit_print_error_page(404, "Not Found", "Bad object id: %s", rev); - return; - } - commit = lookup_commit_reference(the_repository, &oid); - if (!commit || repo_parse_commit(the_repository, commit)) { - cgit_print_error_page(404, "Not Found", "Not a commit: %s", rev); + commit = cgit_get_commit(rev, &oid); + if (!commit) return; - } - walk.rev = xstrdup(rev); + walk.rev = rev; + cgit_literal_pathspec(&paths, &path_item, path); if (!path) { + // The walk is never handed an entry for the top of the tree + // itself, so the listing it would have opened is opened here. cgit_prefix_title("tree"); - ls_tree(get_commit_tree_oid(commit), &walk); - goto cleanup; + walk.state = WALK_LISTING; + ls_head(); } read_tree(the_repository, repo_get_commit_tree(the_repository, commit), &paths, walk_tree, &walk); @@ -542,7 +483,4 @@ void cgit_print_tree(const char *rev, char *path) } // WALK_ERROR_SHOWN is left alone, since the error page print_object // put out is already complete. - -cleanup: - free(walk.rev); } diff --git a/tests/extensions/fake-lexers/lexer.lua b/tests/extensions/fake-lexers/lexer.lua index ea7768e..bb869de 100644 --- a/tests/extensions/fake-lexers/lexer.lua +++ b/tests/extensions/fake-lexers/lexer.lua @@ -1,10 +1,10 @@ -- A miniature stand-in for the Scintillua lexer module, just enough for the -- syntax-highlight checks to drive every path through the filter without the --- real collection installed. load() hands out one of the toy lexers below by --- name and detect() recognises a single filename suffix or a shebang naming --- fake, so a check can tell the detection path apart from the extension path --- by which lexer ends up running. Anything the filter treats as optional, a lexer that will not --- load or one that raises while lexing, has a name here too. +-- real collection installed. detect() answers for the suffixes it knows, +-- matched by case as Scintillua's own does, or fake for a shebang naming it, +-- and load() hands out the toy lexer of that name. Besides fake there is one +-- that stops short and one that raises, and python, which detect() knows but +-- load() cannot find, as a collection missing a lexer would answer. local M = {} @@ -68,14 +68,13 @@ function M.load(name) return lexer end +local suffixes = { fake = "fake", short = "short", badlex = "badlex", py = "python" } + function M.detect(filename, line) - if filename:match("%.viadetect$") then - return "fake" - end if line ~= nil and line:match("^#!.*fake") then return "fake" end - return nil + return suffixes[filename:match("%.([^.]+)$") or ""] end return M diff --git a/tests/extensions/harness.lua b/tests/extensions/harness.lua index 3df4719..2fe722f 100644 --- a/tests/extensions/harness.lua +++ b/tests/extensions/harness.lua @@ -54,18 +54,6 @@ html_url_path = sink(function(s) end)) end) --- No shipped extension calls the remaining sinks, so rather than risk an --- unfaithful copy quietly passing a test, using one fails loudly until its --- escaping is mirrored from source/html.c the way the ones above are. -local function unmirrored(name) - return function() - error(name .. " is not mirrored by the test harness yet") - end -end - -html_url_arg = unmirrored("html_url_arg") -html_include = unmirrored("html_include") - function harness.reset() pieces = {} end @@ -113,7 +101,7 @@ end -- Loaders registered here win over any real module on the package path, so a -- test can hand a script a deterministic stand-in, or with a failing loader -- prove the script's fallback for a module that is not installed. -function harness.preload(name, module) +local function preload(name, module) package.preload[name] = function() return module end @@ -164,7 +152,7 @@ end -- The slice of the luaossl digest interface the avatar filters use. function harness.stub_digest() - harness.preload("openssl.digest", { + preload("openssl.digest", { new = function(algorithm) return { final = function(self, text) @@ -188,27 +176,13 @@ local function fake_crypt(password, setting) return prefix .. password end --- The slices of luaossl and luaposix the auth filters use. The link and --- unlink stubs serve the secret creation path, which the auth checks bypass --- by replacing get_secret, so they only have to exist. +-- The slices of luaossl and luaposix the auth checks reach. function harness.stub_auth_modules() local rand_counter = 0 - harness.preload("posix.sys.stat", { - umask = function(mask) - return 18 - end, - }) - harness.preload("posix.unistd", { + preload("posix.unistd", { crypt = fake_crypt, - link = function(from, to) - return nil - end, - unlink = function(path) - os.remove(path) - return 0 - end, }) - harness.preload("openssl.rand", { + preload("openssl.rand", { bytes = function(count) local out = {} for i = 1, count do @@ -218,7 +192,7 @@ function harness.stub_auth_modules() return table.concat(out) end, }) - harness.preload("openssl.hmac", { + preload("openssl.hmac", { new = function(key, algorithm) return { final = function(self, payload) diff --git a/tests/extensions/lib.sh b/tests/extensions/lib.sh index 30a9cc8..92db245 100644 --- a/tests/extensions/lib.sh +++ b/tests/extensions/lib.sh @@ -1,6 +1,6 @@ # Shared groundwork for the t05xx scripts, sourced after setup.sh from inside # the trash directory. It locates the shipped extensions and the unit checks -# beside this file, finds the standalone Lua interpreters the checks can run +# beside this file, lists the standalone Lua interpreters the checks can run # under, and offers a probe that asks the Lua inside the cgit binary itself # whether a module is available to it, since that interpreter can differ from # every standalone one on the path. @@ -8,30 +8,21 @@ EXTENSIONS_DIRECTORY=$(cd "$TEST_OUTPUT_DIRECTORY/../custom/extensions" && pwd) EXT_TEST_DIRECTORY=$(cd "$TEST_OUTPUT_DIRECTORY/extensions" && pwd) -# Prints the Lua version a binary reports, like 5.1, which is also what -# LuaJIT reports. -ext_lua_version() { - "$1" -e 'io.write(string.match(_VERSION, "%d+%.%d+"))' 2>/dev/null -} - -# Prints the interpreters found on the path whose version falls between 5.1 -# and the given 5.x ceiling, one per line, so a script is never failed on a -# version it never claimed to run on. -ext_lua_interpreters() { - ext_lua_ceiling=$1 - for ext_lua_bin in luajit lua5.1 lua5.2 lua5.3 lua5.4 lua5.5 lua - do - command -v "$ext_lua_bin" >/dev/null 2>&1 || continue - case "$(ext_lua_version "$ext_lua_bin")" in - 5.[1-9]) - ext_lua_minor=$(ext_lua_version "$ext_lua_bin") - ext_lua_minor=${ext_lua_minor#5.} - test "$ext_lua_minor" -le "$ext_lua_ceiling" && - echo "$ext_lua_bin" - ;; - esac - done -} +# The interpreters on the path whose version the extensions claim, 5.1 to +# 5.5 with LuaJIT counting as 5.1, so no script is failed on a version it +# never claimed to run on. +interpreters= +for ext_lua_bin in luajit lua5.1 lua5.2 lua5.3 lua5.4 lua5.5 lua +do + command -v "$ext_lua_bin" >/dev/null 2>&1 || continue + case "$("$ext_lua_bin" -e 'io.write(_VERSION)' 2>/dev/null)" in + "Lua 5."[1-5]) + interpreters="$interpreters $ext_lua_bin" + ;; + esac +done +test -n "$interpreters" || + say 'no standalone lua on the path, unit checks skipped' # Asks whether cgit's own Lua can require every module named, by pointing a # throwaway commit-filter at the given repository and reading its answer off diff --git a/tests/extensions/test-about-render.lua b/tests/extensions/test-about-render.lua index 88c136c..e901d28 100644 --- a/tests/extensions/test-about-render.lua +++ b/tests/extensions/test-about-render.lua @@ -219,8 +219,7 @@ out = render("README.md", "[](y)\n\n\ntext") h.contains("a paragraph of badges leaves nothing behind", out, "<div class='markdown'><p>text</p></div>") out = render("README.md", "```lua\nlocal x = 1 < 2\n```\nafter") -h.contains("a fenced block carries its language", out, - "<pre><code data-lang='lua'>local x = 1 < 2\n</code></pre>") +h.contains("a fenced block renders as code", out, "<pre><code>local x = 1 < 2\n</code></pre>") h.contains("text after the fence renders on", out, "<p>after</p>") out = render("README.md", "~~~\n**not bold**\n~~~") @@ -329,7 +328,7 @@ h.contains("an oversized readme renders as plain text", out, "<pre class='plaint -- Man pages. out = render("page.1", ".SH NAME") -h.contains("a section heading renders", out, "<div class='markdown manpage'><h2 id='name'>NAME</h2></div>") +h.contains("a section heading renders", out, "<div class='markdown'><h2 id='name'>NAME</h2></div>") out = render("page.man", '.SH "TWO WORDS"') h.contains("a quoted heading argument is unquoted", out, "<h2 id='two-words'>TWO WORDS</h2>") @@ -393,7 +392,7 @@ h.contains("text after the definitions renders", out, "<p>after</p>") out = render("page.1", ".if n \\\n\\{\\\n.ds x y\n.\\}\nafter") h.contains("a conditional block's requests and braces draw nothing", out, - "<div class='markdown manpage'><p>after</p></div>") + "<div class='markdown'><p>after</p></div>") out = render("page.1", "The \\m[blue]\\fBManual\\fR\\m[]\\s-2\\u[1]\\d\\s+2 here") h.contains("DocBook's link colours and footnote marks draw nothing", out, @@ -401,13 +400,12 @@ h.contains("DocBook's link colours and footnote marks draw nothing", out, out = render("page.1", ".de x\n.B hidden\n.. \nshown") h.contains("a definition ends at two dots with blanks after them", out, - "<div class='markdown manpage'><p>shown</p></div>") + "<div class='markdown'><p>shown</p></div>") out = render("page.1", ".TP\n.PD 0\n.B \\-a\nall\n.PD\nDon\\*(Aqt") h.excludes("a spacing macro draws nothing", out, ">0") h.contains("DocBook's apostrophe string expands", out, "Don't") - out = render("page.1", '.\\" a comment\nvisible') h.excludes("a roff comment is dropped", out, "comment") h.contains("the line after it renders", out, "visible") diff --git a/tests/extensions/test-auth.lua b/tests/extensions/test-auth.lua index 742eabe..974544d 100644 --- a/tests/extensions/test-auth.lua +++ b/tests/extensions/test-auth.lua @@ -94,7 +94,6 @@ h.equals("an account is found whatever its case", hash, stored_hash) h.equals("an unknown account is not", account_hash("nobody"), nil) local _, stand_in = account_hash("nobody") h.equals("it brings another account's hash to spend instead", stand_in, stored_hash) -h.equals("a nil user is not", account_hash(nil), nil) local userset = repo_userset("secret-repo") h.check("a protected repository lists its users", @@ -113,12 +112,14 @@ if variant == "file" then h.equals("a missing users file turns every login down", account_hash("alice"), nil) h.redirect_file("/etc/cgit-auth/users", "auth-users") -- Without the repos file nothing tells a protected repository from a - -- public one, so every repository is refused while the index stays up. + -- public one, so every page is refused, the index too, which cgit asks + -- about with an empty repository name. h.redirect_file("/etc/cgit-auth/repos", "auth-repos-missing") local refused = repo_userset("unlisted") h.check("a missing repos file refuses every repository", refused ~= nil and next(refused) == nil) - h.equals("the index stays public all the same", repo_userset(nil), nil) + local index = repo_userset("") + h.check("and the index as well", index ~= nil and next(index) == nil) h.redirect_file("/etc/cgit-auth/repos", "auth-repos") end @@ -141,7 +142,7 @@ h.equals("no header yields no cookie", get_cookie(nil, "cgitauth"), nil) h.equals("a longer name does not match", get_cookie("xcgitauth=z", "cgitauth"), nil) h.equals("a magic character in the name is taken literally", get_cookie("a-b=z", "a-b"), "z") h.equals("spaces around a pair are ignored", get_cookie(" foo=1 ; cgitauth=abc ", "cgitauth"), "abc") -h.equals("a value keeps its own equals signs", get_cookie("cgitauth=a=b", "cgitauth"), "a=b") +h.equals("a cookie value keeps its own equals signs", get_cookie("cgitauth=a=b", "cgitauth"), "a=b") -- The Cookie header is the visitor's to write, so a run of spaces in it -- must not cost the square of its length. @@ -161,8 +162,6 @@ h.check("a scheme relative target is not", not is_safe_redirect("//evil")) h.check("a backslash variant is not", not is_safe_redirect("/\\evil")) h.check("a missing target is not", not is_safe_redirect(nil)) -h.equals("control characters are stripped from header values", strip_controls("a\r\nb"), "ab") - -- Signing and verification. local now = os.time() @@ -192,6 +191,7 @@ h.equals("a value signed for one field does not serve another", validate_value(" h.equals("no cookie does not verify", validate_value("username", nil), nil) h.equals("a tiny cookie does not verify", validate_value("username", "ab"), nil) h.equals("a leading separator does not verify", validate_value("username", "|x|1|s|sig"), nil) +h.equals("a trailing field does not verify", validate_value("username", cookie .. "|x"), nil) h.equals("an unsigned cookie does not verify", validate_value("username", "username|alice|123|salt"), nil) h.equals("an exponent spelling of the expiry does not verify", @@ -237,106 +237,96 @@ local function run_action(action, opts) return h.output(), ret end -local ret +-- A login posts the form, and a visit asks about a repository, with a +-- session cookie when it is given one, and comes back with the answer. +local function login(user, pass, redirect) + local body = "username=" .. url_encode(user) .. "&password=" .. url_encode(pass) + if redirect then + body = body .. "&redirect=" .. url_encode(redirect) + end + return run_action("authenticate-post", { method = "POST", body = body }) +end -out, ret = run_action("authenticate-cookie", { repo = "unlisted" }) -h.equals("a public repository needs no cookie", ret, 1) +local function visit(repo, token) + local _, ret = run_action("authenticate-cookie", + { repo = repo, cookie = token and session_name .. "=" .. token }) + return ret +end -out, ret = run_action("authenticate-cookie", { repo = "secret-repo" }) -h.equals("a protected repository turns a bare request away", ret, 0) +-- Where the signed way back in a login form leads. +local function way_back_in(form) + local token = form:match("name='redirect' value='([^']*)'") + return token and validate_value("redirect", token) +end + +h.equals("a public repository needs no cookie", visit("unlisted"), 1) +h.equals("a protected repository turns a bare request away", visit("secret-repo"), 0) local session = secure_value("username", "Alice", now + 3600, stored_hash) -out, ret = run_action("authenticate-cookie", { repo = "secret-repo", cookie = session_name .. "=" .. session }) -h.equals("a signed session for a member is let through", ret, 1) +h.equals("a signed session for a member is let through", visit("secret-repo", session), 1) -- A session is signed with its account's hash, so a new password or a -- removed account ends it. local stale = secure_value("username", "Alice", now + 3600, "$6$rounds=300000$old$hash") -out, ret = run_action("authenticate-cookie", { repo = "secret-repo", cookie = session_name .. "=" .. stale }) -h.equals("a session from before a password change is turned away", ret, 0) +h.equals("a session from before a password change is turned away", visit("secret-repo", stale), 0) local orphan = secure_value("username", "bob", now + 3600, stored_hash) -out, ret = run_action("authenticate-cookie", { repo = "secret-repo", cookie = session_name .. "=" .. orphan }) -h.equals("a session for a member with no account is turned away", ret, 0) +h.equals("a session for a member with no account is turned away", visit("secret-repo", orphan), 0) local unbound = secure_value("username", "Alice", now + 3600) h.equals("a session signed without its binding does not verify", validate_value("username", unbound, account_hash), nil) -out, ret = run_action("authenticate-cookie", { repo = "empty-repo", cookie = session_name .. "=" .. session }) -h.equals("a memberless repository denies even a valid session", ret, 0) +h.equals("a memberless repository denies even a valid session", visit("empty-repo", session), 0) local outsider = secure_value("username", "carol", now + 3600, stored_hash) -out, ret = run_action("authenticate-cookie", { repo = "secret-repo", cookie = session_name .. "=" .. outsider }) -h.equals("a signed session for an outsider is turned away", ret, 0) +h.equals("a signed session for an outsider is turned away", visit("secret-repo", outsider), 0) local forged = session:sub(1, -2) .. (session:sub(-1) == "0" and "1" or "0") -out, ret = run_action("authenticate-cookie", { repo = "secret-repo", cookie = session_name .. "=" .. forged }) -h.equals("a forged session is turned away", ret, 0) +h.equals("a forged session is turned away", visit("secret-repo", forged), 0) -out, ret = run_action("no-such-action", {}) +local _, ret = run_action("no-such-action", {}) h.equals("an unknown action denies rather than raising", ret, 0) -out, ret = run_action("body", { url = "/repo/log/?q=x" }) +out = run_action("body", { url = "/repo/log/?q=x" }) h.contains("the login form posts to the login url", out, "<form class='login' method='post' action='/?p=login'>") h.equals("a first visit says nothing failed", out:find("login-error", 1, true), nil) -local token = out:match("name='redirect' value='([^']*)'") -h.equals("the form carries a signed way back", token and validate_value("redirect", token), "/repo/log/?q=x") +h.equals("the form carries a signed way back", way_back_in(out), "/repo/log/?q=x") -out, ret = run_action("body", { url = "//evil.example/x" }) -token = out:match("name='redirect' value='([^']*)'") -h.equals("an unsafe destination is swapped for the login page", - token and validate_value("redirect", token), "/?p=login") +out = run_action("body", { url = "//evil.example/x" }) +h.equals("an unsafe destination is swapped for the login page", way_back_in(out), "/?p=login") h.equals("and is not taken for a failed login", out:find("login-error", 1, true), nil) -out, ret = run_action("body", { url = "/repo/log/?q=x&login=failed" }) +out = run_action("body", { url = "/repo/log/?q=x&login=failed" }) h.contains("a failed login says so", out, "<p class='login-error' role='alert'>Wrong username or password.</p>") -token = out:match("name='redirect' value='([^']*)'") -h.equals("and leaves the flag out of the way back", token and validate_value("redirect", token), "/repo/log/?q=x") +h.equals("and leaves the flag out of the way back", way_back_in(out), "/repo/log/?q=x") local way_back = secure_value("redirect", "/repo/", 0) -out, ret = run_action("authenticate-post", { - method = "POST", - body = "username=Alice&password=" .. url_encode(password) .. "&redirect=" .. url_encode(way_back), -}) +out = login("Alice", password, way_back) h.contains("a good login redirects back", out, "Status: 302") h.contains("to where the form said", out, "Location: /repo/\n") local granted = out:match("Set%-Cookie: __Host%-cgitauth=([^;]*);") h.equals("and grants a session that verifies", granted and validate_value("username", granted, account_hash), "Alice") -out, ret = run_action("authenticate-post", { - method = "POST", - body = "username=Alice&password=wrong&redirect=" .. url_encode(way_back), -}) +out = login("Alice", "wrong", way_back) h.contains("a bad password redirects the same way", out, "Status: 302") h.contains("back to the page with the failure flagged", out, "Location: /repo/?login=failed\n") h.contains("but clears the session cookie", out, "Set-Cookie: " .. session_name .. "=; ") -out, ret = run_action("authenticate-post", { - method = "POST", - body = "username=nobody&password=x&redirect=" .. url_encode(way_back), -}) -h.contains("an unknown user is told nothing different", out, "Set-Cookie: " .. session_name .. "=; ") -h.contains("and lands on the same flagged page", out, "Location: /repo/?login=failed\n") - -- A failed login for a name with no account spends crypt on a configured -- hash, the same work a real check costs, so timing tells nothing. crypt_settings = {} -run_action("authenticate-post", { - method = "POST", - body = "username=nobody&password=x&redirect=" .. url_encode(way_back), -}) +out = login("nobody", "x", way_back) +h.contains("an unknown user is told nothing different", out, "Set-Cookie: " .. session_name .. "=; ") +h.contains("and lands on the same flagged page", out, "Location: /repo/?login=failed\n") h.equals("a missing account spends crypt on a configured hash", crypt_settings[1], stored_hash) if variant == "file" then h.redirect_file("/etc/cgit-auth/users", "auth-users-missing") crypt_settings = {} - run_action("authenticate-post", { - method = "POST", - body = "username=nobody&password=x&redirect=" .. url_encode(way_back), - }) + login("nobody", "x", way_back) h.equals("with no accounts at all nothing is spent", #crypt_settings, 0) h.redirect_file("/etc/cgit-auth/users", "auth-users") end @@ -344,41 +334,36 @@ end -- crypt's work grows with the password, so one past the cap is turned down -- before crypt sees it. crypt_settings = {} -out, ret = run_action("authenticate-post", { - method = "POST", - body = "username=Alice&password=" .. string.rep("a", 257) .. "&redirect=" .. url_encode(way_back), -}) +out = login("Alice", string.rep("a", 257), way_back) h.contains("an overlong password is turned down", out, "Set-Cookie: " .. session_name .. "=; ") h.equals("without spending crypt on it", #crypt_settings, 0) -run_action("authenticate-post", { - method = "POST", - body = "username=Alice&password=" .. string.rep("a", 256) .. "&redirect=" .. url_encode(way_back), -}) +login("Alice", string.rep("a", 256), way_back) h.equals("a password at the cap is still checked", #crypt_settings, 1) -out, ret = run_action("authenticate-post", { - method = "POST", - body = "username=Alice&password=" .. url_encode(password), -}) +out = login("Alice", password) h.contains("a post without the signed token is not served", out, "Status: 404") -local hijack = secure_value("redirect", "//evil.example/", 0) -out, ret = run_action("authenticate-post", { - method = "POST", - body = "username=Alice&password=" .. url_encode(password) .. "&redirect=" .. url_encode(hijack), -}) +out = login("Alice", password, secure_value("redirect", "//evil.example/", 0)) h.contains("even a signed unsafe destination is not followed", out, "Status: 404") -- Over plain http the cookie cannot be Secure, and so loses the __Host- name. load_script({ { "local cookie_insecure = false", "local cookie_insecure = true" } }) -out, ret = run_action("authenticate-post", { - method = "POST", - body = "username=Alice&password=" .. url_encode(password) .. "&redirect=" .. url_encode(way_back), -}) +out = login("Alice", password, way_back) h.contains("an insecure session cookie keeps its plain name", out, "Set-Cookie: cgitauth=") h.excludes("and is not marked Secure", out, "; Secure") load_script() +-- A cookie carries whole seconds, while on Lua 5.3 and later a session length +-- worked out with a fraction is a float, which tostring writes with a ".0". +load_script({ { "local session_seconds = 7 * 24 * 60 * 60", + "local session_seconds = 1.5 * 24 * 60 * 60" } }) +out = login("Alice", password, way_back) +granted = out:match("Set%-Cookie: __Host%-cgitauth=([^;]*);") +h.equals("a session a day and a half long still verifies", + granted and validate_value("username", granted, account_hash), "Alice") +h.contains("and its cookie lasts as many whole seconds", out, "; Max-Age=129600\n") +load_script() + -- The libraries load only once a request needs one, so with every one of -- them gone a public repository is still served, while a protected one -- fails closed instead of reaching its content. @@ -386,10 +371,8 @@ for _, name in ipairs({ "openssl.rand", "openssl.hmac", "posix.sys.stat", "posix package.loaded[name] = nil h.preload_failure(name) end -out, ret = run_action("authenticate-cookie", { repo = "unlisted" }) -h.equals("without the libraries a public repository is served", ret, 1) -h.check("while a protected one fails closed", not pcall(run_action, - "authenticate-cookie", { repo = "secret-repo", cookie = session_name .. "=" .. session })) +h.equals("without the libraries a public repository is served", visit("unlisted"), 1) +h.check("while a protected one fails closed", not pcall(visit, "secret-repo", session)) h.check("and so does its login form", not pcall(run_action, "body", {})) h.finish() diff --git a/tests/extensions/test-syntax-highlight.lua b/tests/extensions/test-syntax-highlight.lua index 50c7bc8..a0debc6 100644 --- a/tests/extensions/test-syntax-highlight.lua +++ b/tests/extensions/test-syntax-highlight.lua @@ -70,10 +70,9 @@ h.equals("a lexer stopping early leaves an escaped tail", out, "<span class='hl- out = render("boom.badlex", "some <code>") h.equals("a lexer that raises falls back to escaped text", out, "some <code>") --- The viadetect lexer name only resolves through detect(), so highlighted --- output proves the detection path ran ahead of the extension path. -out = render("script.viadetect", "if") -h.equals("filename detection picks the lexer", out, "<span class='hl-keyword'>if</span>") +out = render("HELLO.FAKE", "if 42") +h.contains("an extension in capitals finds its lexer", out, + "<span class='hl-keyword'>if</span>") out = render("post-receive", "#!/usr/bin/fake\nif") h.contains("a shebang picks the lexer of a file with no extension", out, @@ -111,10 +110,4 @@ out, ret = render("empty.fake", "") h.equals("an empty blob renders empty", out, "") h.equals("an empty blob still answers zero", ret, 0) --- cgit's C sink stops at the first NUL byte, which the script documents for --- binaries that slip past the text detection. -out = render("blob.bin", "ab\0cd") -h.contains("bytes before a nul survive", out, "ab") -h.excludes("bytes after a nul are dropped", out, "cd") - h.finish() diff --git a/tests/filters/dump.lua b/tests/filters/dump.lua index d654621..585538a 100644 --- a/tests/filters/dump.lua +++ b/tests/filters/dump.lua @@ -1,4 +1,4 @@ --- Test fixture for the cgit Lua filter API, exercised by t0201-filters.sh. +-- Test fixture for the cgit Lua filter API. -- It echoes the filter_open arguments and upper-cases the body, which lets -- the test confirm arguments and content flow through the lua: filter path. diff --git a/tests/filters/dump.sh b/tests/filters/dump.sh index a4c1571..1340fe5 100755 --- a/tests/filters/dump.sh +++ b/tests/filters/dump.sh @@ -1,6 +1,6 @@ #!/bin/sh -# Test fixture for the cgit exec filter API, exercised by t0201-filters.sh. +# Test fixture for the cgit exec filter API. # It echoes the arguments it was invoked with and upper-cases the body, which # lets the test confirm that both flow through the exec: filter path. diff --git a/tests/setup.sh b/tests/setup.sh index a1f2d15..92451b7 100755 --- a/tests/setup.sh +++ b/tests/setup.sh @@ -1,13 +1,11 @@ # Shared groundwork sourced by every test script. It builds a handful of small # repositories under repos/ and writes a cgitrc pointing at them, then offers -# the helpers that ask cgit for a page so a test only has to look at what comes -# back. Sourcing it also brings in the test library from the bundled Git tree, -# which is where test_expect_success and the trash directory come from. Set -# CGIT_TEST_NO_CREATE_REPOS to get the helpers without paying for the fixtures. +# the helpers that write a config of a test's own and ask cgit for a page, so +# a test only has to look at what comes back. Sourcing it also brings in the +# test library from the bundled Git tree, which is where test_expect_success +# and the trash directory come from. Set CGIT_TEST_NO_CREATE_REPOS to get the +# helpers without paying for the fixtures. -# The Git test library would run every Git command under Valgrind if it saw -# --valgrind, and only cgit is worth watching, so the option is taken out -# here and acted on further down. LF=' ' # Trash directories are collected under one trash/, which keeps the ignore @@ -15,6 +13,9 @@ LF=' # one given on the command line still wins. test_argv="${LF}--root=trash" +# The Git test library would run every Git command under Valgrind if it saw +# --valgrind, and only cgit is worth watching, so the option is taken out +# here and acted on further down. while test $# != 0 do case "$1" in @@ -43,11 +44,10 @@ TEST_NO_CREATE_REPO=YesPlease # The library spells its results directory test-results and derives these # variables before it can be told otherwise, so they are re-pointed at the -# plainer results/. The --tee and --stress raw logs write earlier and keep -# the library's name. +# plainer results/. The --tee and --stress raw logs and the leak logs write +# earlier and keep the library's name. TEST_RESULTS_DIR="$TEST_OUTPUT_DIRECTORY/results" TEST_RESULTS_BASE="$TEST_RESULTS_DIR/$TEST_NAME$TEST_STRESS_JOB_SFX" -TEST_RESULTS_SAN_DIR="$TEST_RESULTS_BASE.$TEST_RESULTS_SAN_DIR_SFX" # The library has moved into the trash directory by now, so paths outside it # are anchored to TEST_OUTPUT_DIRECTORY. The tests run cgit by name, so the @@ -69,9 +69,10 @@ FILTER_DIRECTORY=$(cd "$TEST_OUTPUT_DIRECTORY/filters" && pwd) # them have to ask the binary rather than assume. if cgit --version | grep -F -q "[+] Lua scripting" then - export CGIT_HAS_LUA=1 + CGIT_HAS_LUA=1 + test_set_prereq CGIT_LUA else - export CGIT_HAS_LUA=0 + CGIT_HAS_LUA=0 fi # Creates a repository holding the given number of one file commits. A third @@ -107,8 +108,7 @@ mkrepo() { } setup_repos() { - rm -rf cache - mkdir -p cache + mkdir cache mkrepo repos/foo 5 >/dev/null mkrepo repos/bar 50 commit-graph >/dev/null mkrepo repos/foo+bar 10 testplus >/dev/null @@ -169,12 +169,60 @@ EOF fi } +# Writes a config to the file named first that serves repos/<url> under the +# url named second, with the cache off and links rooted at /, and takes the +# rest as settings. A repository copies its defaults from the global settings +# read before its repo.url, so whatever order they come in, the repo.* ones +# go after the repository and all others ahead of it. A repo.path setting +# names a repository kept somewhere else. +cgitrc_repo() { + cgitrc_file=$1 + cgitrc_url=$2 + shift 2 + cgitrc_path="repo.path=$PWD/repos/$cgitrc_url/.git" + cgitrc_before= + cgitrc_after= + for cgitrc_setting + do + case "$cgitrc_setting" in + repo.path=*) + cgitrc_path=$cgitrc_setting + ;; + repo.*) + cgitrc_after="$cgitrc_after$cgitrc_setting$LF" + ;; + *) + cgitrc_before="$cgitrc_before$cgitrc_setting$LF" + ;; + esac + done + printf '%s' "virtual-root=/$LF" "cache-size=0$LF" "$cgitrc_before" \ + "repo.url=$cgitrc_url$LF" "$cgitrc_path$LF" "$cgitrc_after" >"$cgitrc_file" +} + +# Writes the global settings named after the file ahead of a copy of the +# shared cgitrc. The copy turns the cache off, because a page is cached under +# its request alone and would otherwise come back as the shared config made +# it. +cgitrc_copy() { + cgitrc_file=$1 + shift + { + test $# = 0 || printf '%s\n' "$@" + sed "s/^cache-size=.*/cache-size=0/" cgitrc + } >"$cgitrc_file" +} + +cgit_rc() { + CGIT_CONFIG="$PWD/$1" QUERY_STRING="$2" cgit +} + cgit_query() { - CGIT_CONFIG="$PWD/cgitrc" QUERY_STRING="$1" cgit + cgit_rc cgitrc "$1" } cgit_url() { - CGIT_CONFIG="$PWD/cgitrc" QUERY_STRING="url=$1" cgit + cgit_rc cgitrc "url=$1" } # The response headers run down to the first empty line, and a test comparing diff --git a/tests/t0002-html-validity.sh b/tests/t0002-html-validity.sh index f5e918c..bdccaf2 100755 --- a/tests/t0002-html-validity.sh +++ b/tests/t0002-html-validity.sh @@ -1,6 +1,6 @@ #!/bin/sh -# Runs the tidy checker over one page of each kind cgit renders, caring only +# Runs the tidy checker over one page of most kinds cgit renders, caring only # about the shape of the markup. What the pages say is the business of the # t01xx scripts. Tidy is optional and old versions predate the elements cgit # uses, so the file steps aside when a usable one cannot be found. @@ -9,36 +9,21 @@ test_description='Validate html with tidy' . ./setup.sh test_url() { - tidy_options="-eq" - test -z "$NO_TIDY_WARNINGS" || tidy_options="$tidy_options --show-warnings no" # A second argument names a config of its own, for pages the shared # config keeps turned off. - if test -n "$2" - then - CGIT_CONFIG="$PWD/$2" QUERY_STRING="url=$1" cgit >tidy-$test_count.tmp || return - else - cgit_url "$1" >tidy-$test_count.tmp || return - fi + cgit_rc "${2:-cgitrc}" "url=$1" >tidy-$test_count.tmp || return # Tidy reads what it is given as a whole document, so the response # headers are dropped before it sees the page. strip_headers <tidy-$test_count.tmp >tidy-$test_count || return - "$tidy" $tidy_options tidy-$test_count - status=$? - # Tidy leaves with 1 for warnings and 2 for errors, and only an error is # worth failing the test over. - if test $status = 2 - then - false - else - : - fi + "$tidy" -eq tidy-$test_count + test $? != 2 } tidy=$(command -v tidy) || { skip_all='Skipping html validation tests: tidy not found' test_done - exit } # Releases of tidy that predate HTML5 reject the semantic elements cgit emits, @@ -49,7 +34,6 @@ printf '<!DOCTYPE html>\n<html lang="en"><head><title>t</title></head><body><hea test $? -ge 2 && { skip_all='Skipping html validation tests: tidy is too old for HTML5' test_done - exit } test_expect_success 'index page' 'test_url ""' @@ -65,14 +49,7 @@ test_expect_success 'error page' 'test_url "no-such-repo"' # Stats and blame stay off in the shared config, so their pages validate # through a config of their own. test_expect_success 'set up a features config' ' - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "max-stats=year" && - echo "enable-blame=1" && - echo "repo.url=feat" && - echo "repo.path=$PWD/repos/foo/.git" - } >featrc + cgitrc_repo featrc feat max-stats=year enable-blame=1 "repo.path=$PWD/repos/foo/.git" ' test_expect_success 'stats page' 'test_url "feat/stats/" featrc' diff --git a/tests/t0003-cache.sh b/tests/t0003-cache.sh index 06cdd99..e07fcd0 100755 --- a/tests/t0003-cache.sh +++ b/tests/t0003-cache.sh @@ -2,8 +2,6 @@ # Exercises the cache. The first three tests set cache-size to nothing, one # slot and the full table in turn, then count what the requests left behind. -# The rest cover a page longer than the output buffer and a key too long to -# be read back. test_description='Validate cache' . ./setup.sh @@ -31,8 +29,7 @@ test_expect_success 'verify cache-size=0' ' rm -f cache/* && set_cache_size 0 && request_all_pages && - ls cache >output && - test_line_count = 0 output + test_dir_is_empty cache ' test_expect_success 'verify cache-size=1' ' @@ -79,29 +76,10 @@ test_expect_success 'set up a repo with a page larger than the output buffer' ' rm -rf cache2 && mkdir cache2 ' -# The second request lands while the first is inside its filter, which says -# so through a marker file, since a fresh binary can take a moment to start. -overlap_requests() { - n=0 - rm -f filter-running - CGIT_CONFIG="$PWD/lockrc" QUERY_STRING="url=bigpage/about/" cgit >first.out 2>first.err & - while ! test -f filter-running && test $n -lt 10 - do - sleep 1 - n=$((n + 1)) - done - CGIT_CONFIG="$PWD/lockrc" QUERY_STRING="url=bigpage/log/" cgit >second.out 2>second.err - wait -} - -bigpage_query() { - CGIT_CONFIG="$PWD/bigrc" QUERY_STRING="$1" cgit -} - test_expect_success 'the first request fills the slot and the second replays it' ' - bigpage_query "url=bigpage/tree/big.txt" >big.first && + cgit_rc bigrc "url=bigpage/tree/big.txt" >big.first && test $(wc -c <big.first) -gt 65536 && - bigpage_query "url=bigpage/tree/big.txt" >big.second && + cgit_rc bigrc "url=bigpage/tree/big.txt" >big.second && strip_headers <big.first >big.first.body && strip_headers <big.second >big.second.body && test_cmp big.first.body big.second.body @@ -109,7 +87,7 @@ test_expect_success 'the first request fills the slot and the second replays it' test_expect_success 'the cached body matches one generated with the cache off' ' sed -e "s/^cache-size=64$/cache-size=0/" bigrc >bignocache && - CGIT_CONFIG="$PWD/bignocache" QUERY_STRING="url=bigpage/tree/big.txt" cgit >big.nocache && + cgit_rc bignocache "url=bigpage/tree/big.txt" >big.nocache && strip_headers <big.nocache >big.nocache.body && test_cmp big.nocache.body big.second.body ' @@ -126,15 +104,14 @@ test_expect_success 'an over-long cache key leaves no slot behind' ' rm -rf cache3 && mkdir cache3 && sed -e "s|^cache-root=.*|cache-root=$PWD/cache3|" bigrc >bigkeyrc && long=$(awk "BEGIN{s=\"\";for(i=0;i<6000;i++)s=s \"k\"; print s}") && - CGIT_CONFIG="$PWD/bigkeyrc" QUERY_STRING="url=bigpage/&q=$long" cgit >key.out 2>key.err && + cgit_rc bigkeyrc "url=bigpage/&q=$long" >key.out 2>/dev/null && grep "</html>" key.out && - ls cache3 >key.slots && - test_line_count = 0 key.slots + test_dir_is_empty cache3 ' test_expect_success 'an ordinary key still fills a slot' ' rm -rf cache3 && mkdir cache3 && - CGIT_CONFIG="$PWD/bigkeyrc" QUERY_STRING="url=bigpage/" cgit >/dev/null && + cgit_rc bigkeyrc "url=bigpage/" >/dev/null && ls cache3 >key.slots && test_line_count = 1 key.slots ' @@ -160,11 +137,10 @@ test_expect_success 'set up a repo missing a parent object' ' ' test_expect_success 'an error after output began ends the page with it and caches nothing' ' - CGIT_CONFIG="$PWD/brokenrc" QUERY_STRING="url=broken/commit/&id=$tip" cgit >broken.out && + cgit_rc brokenrc "url=broken/commit/&id=$tip" >broken.out && grep "Not a commit" broken.out && test $(grep -c "^Status:" broken.out) = 1 && - ls cache4 >broken.slots && - test_line_count = 0 broken.slots + test_dir_is_empty cache4 ' # An error page reports a condition the repository may grow out of, and a @@ -176,20 +152,18 @@ test_expect_success 'an error page leaves no slot behind' ' CGIT_CONFIG="$PWD/bigkeyrc" \ QUERY_STRING="url=bigpage/commit/&id=0123456789abcdef0123456789abcdef01234567" cgit >error.out && grep "Not a commit" error.out && - ls cache3 >error.slots && - test_line_count = 0 error.slots + test_dir_is_empty cache3 ' # The dumb transport serves files that already sit on the disk, so a copy in # a slot would only double the disk they take. test_expect_success 'a clone file leaves no slot behind' ' rm -rf cache3 && mkdir cache3 && - CGIT_CONFIG="$PWD/bigkeyrc" QUERY_STRING="url=bigpage/info/refs" cgit >clone.out && + cgit_rc bigkeyrc "url=bigpage/info/refs" >clone.out && grep "refs/heads/master" clone.out && - CGIT_CONFIG="$PWD/bigkeyrc" QUERY_STRING="url=bigpage/objects/info/packs" cgit >packs.out && + cgit_rc bigkeyrc "url=bigpage/objects/info/packs" >packs.out && grep "^Status: 200" packs.out && - ls cache3 >clone.slots && - test_line_count = 0 clone.slots + test_dir_is_empty cache3 ' # A response past cache-max-slot-size is served whole but kept nowhere, both @@ -202,25 +176,23 @@ test_expect_success 'a response over cache-max-slot-size is served but not kept' echo "cache-max-slot-size=32" && cat bigkeyrc } >slotsizerc && - CGIT_CONFIG="$PWD/slotsizerc" QUERY_STRING="url=bigpage/tree/big.txt" cgit >big.unkept && + cgit_rc slotsizerc "url=bigpage/tree/big.txt" >big.unkept && tail -c 200 big.unkept | grep "</html>" && - ls cache3 >unkept.slots && - test_line_count = 0 unkept.slots && - CGIT_CONFIG="$PWD/slotsizerc" QUERY_STRING="url=bigpage/" cgit >/dev/null && + test_dir_is_empty cache3 && + cgit_rc slotsizerc "url=bigpage/" >/dev/null && ls cache3 >kept.slots && test_line_count = 1 kept.slots ' test_expect_success 'an expired slot is dropped when its refill is too large' ' rm -rf cache3 && mkdir cache3 && - CGIT_CONFIG="$PWD/bigkeyrc" QUERY_STRING="url=bigpage/tree/big.txt" cgit >/dev/null && + cgit_rc bigkeyrc "url=bigpage/tree/big.txt" >/dev/null && ls cache3 >filled.slots && test_line_count = 1 filled.slots && touch -t 200001010000 cache3/* && - CGIT_CONFIG="$PWD/slotsizerc" QUERY_STRING="url=bigpage/tree/big.txt" cgit >big.refill && + cgit_rc slotsizerc "url=bigpage/tree/big.txt" >big.refill && tail -c 200 big.refill | grep "</html>" && - ls cache3 >refill.slots && - test_line_count = 0 refill.slots + test_dir_is_empty cache3 ' # The summary page keeps a ttl of its own, while every other page that follows @@ -232,12 +204,11 @@ test_expect_success 'pages following a branch take the dynamic ttl' ' echo "cache-dynamic-ttl=0" && cat bigkeyrc } >dynamicrc && - CGIT_CONFIG="$PWD/dynamicrc" QUERY_STRING="url=bigpage/log/" cgit >/dev/null && - CGIT_CONFIG="$PWD/dynamicrc" QUERY_STRING="url=bigpage/log/&h=master" cgit >/dev/null && - ls cache3 >dynamic.slots && - test_line_count = 0 dynamic.slots && - CGIT_CONFIG="$PWD/dynamicrc" QUERY_STRING="url=bigpage/" cgit >/dev/null && - CGIT_CONFIG="$PWD/dynamicrc" QUERY_STRING="url=bigpage/summary/" cgit >/dev/null && + cgit_rc dynamicrc "url=bigpage/log/" >/dev/null && + cgit_rc dynamicrc "url=bigpage/log/&h=master" >/dev/null && + test_dir_is_empty cache3 && + cgit_rc dynamicrc "url=bigpage/" >/dev/null && + cgit_rc dynamicrc "url=bigpage/summary/" >/dev/null && ls cache3 >summary.slots && test_line_count = 2 summary.slots ' @@ -251,17 +222,37 @@ test_expect_success 'a failed final write still delivers the page' ' ( trap "" XFSZ && ulimit -f 2 && - CGIT_CONFIG="$PWD/bigkeyrc" QUERY_STRING="url=bigpage/" cgit 2>flush.err + cgit_rc bigkeyrc "url=bigpage/" 2>flush.err ) | cat >flush.out && grep "^Status: 200" flush.out && grep "</html>" flush.out && ! grep "called many times" flush.err ' +# The second request lands while the first is inside its filter, which says +# so through a marker file, since a fresh binary can take a moment to start. +start_first_request() { + n=0 + rm -f filter-running + cgit_rc lockrc "url=bigpage/about/" >first.out 2>first.err & + while ! test -f filter-running && test $n -lt 10 + do + sleep 1 + n=$((n + 1)) + done +} + +overlap_requests() { + start_first_request + cgit_rc lockrc "url=bigpage/log/" >/dev/null 2>second.err + wait +} + # The fill writes the page into the lock file through stdout, and an exec # filter puts its own pipe on stdout while it runs. The slot lock has to # outlast that, or a second request on the slot rebuilds it under the first -# and the first visitor gets the two pages spliced together. +# and the first visitor gets the two pages spliced together. The second one +# finding the lock held is ordinary contention, which logs nothing. test_expect_success 'a slot stays locked while an exec filter runs' ' rm -rf cache3 && mkdir cache3 && write_script slow-filter.sh <<-EOF && @@ -278,7 +269,153 @@ test_expect_success 'a slot stays locked while an exec filter runs' ' overlap_requests && test $(grep -c "^Status:" first.out) = 1 && grep "id=.about." first.out && - ! grep "Unable to publish" first.err + ! grep "Unable to publish" first.err && + ! grep "Unable to lock" second.err +' + +# A request that opens the lock file just before its holder renames it into +# place as the live slot goes on to lock the slot itself. strace holds the +# second request's flock back until the first has finished, and the check +# that the path still names the locked file turns that lock down, so the +# slot keeps the first request's key. strace needs ptrace and a version that +# can delay a call, which containers and older systems may not give. +test_lazy_prereq STRACE_DELAY ' + strace -o /dev/null -e inject=flock:delay_enter=1 true +' + +test_expect_success STRACE_DELAY 'a lock taken after the rename leaves the slot alone' ' + rm -rf cache3 && mkdir cache3 && + start_first_request && + strace -o /dev/null -e inject=flock:delay_enter=4000000 \ + -E CGIT_CONFIG="$PWD/lockrc" -E QUERY_STRING="url=bigpage/log/" \ + cgit >second.out 2>second.err && + wait && + grep "id=.about." first.out && + grep "</html>" second.out && + tr "\000" "\n" <"cache3/$(ls cache3)" | head -n 1 >slot.key && + grep "about" slot.key && + ! grep "log" slot.key +' + +# An flock lasts until the last copy of its descriptor closes, so a filter +# may hold the slot's lock file only as the stdout it writes the page to. A +# stray copy would keep the slot locked for as long as the filter, or +# anything it leaves running, lives on. +test_expect_success PERL 'a filter holds the lock file only as its stdout' ' + rm -rf cache3 && mkdir cache3 && + write_script fd-filter "$PERL_PATH" <<-\EOF && + use POSIX qw(fstat); + 1 while <STDIN>; + my @lock = stat $ENV{LOCK_FILE} or die "$!\n"; + my @fds = grep { + my @fd = fstat($_); + @fd && $fd[0] == $lock[0] && $fd[1] == $lock[1] + } 0 .. 255; + print "lock file on fd @fds.\n"; + EOF + { + echo "cache-size=1" && + sed "/^cache-size=/d" bigkeyrc && + echo "repo.readme=:file-1" && + echo "repo.about-filter=exec:$PWD/fd-filter" + } >fdrc && + LOCK_FILE="$PWD/cache3/00000000.lock" CGIT_CONFIG="$PWD/fdrc" \ + QUERY_STRING="url=bigpage/about/" cgit >fd.out && + grep -F "lock file on fd 1." fd.out +' + +# A refresh that cannot take the lock serves the expired copy, and says why +# unless the reason is another request doing the refresh. A line appended +# to the copy shows that it is what was served. +test_expect_success SANITY 'a refresh that cannot lock serves the expired slot and says why' ' + rm -rf cache3 && mkdir cache3 && + cgit_rc bigkeyrc "url=bigpage/" >/dev/null && + slot=$(ls cache3) && + echo "the expired copy" >>"cache3/$slot" && + touch -t 200001010000 "cache3/$slot" && + test_when_finished "chmod u+w cache3" && + chmod a-w cache3 && + cgit_rc bigkeyrc "url=bigpage/" >stale.out 2>stale.err && + grep "the expired copy" stale.out && + grep "Unable to open .*\.lock: Permission denied" stale.err +' + +# A lock file that cannot take the key goes with its lock, rather than +# staying behind locked while the page renders uncached. The limit is on +# files, so the log goes through the pipe as well. +test_expect_success 'a slot lock that cannot take its key is let go' ' + rm -rf cache3 && mkdir cache3 && + ( + trap "" XFSZ && + ulimit -f 0 && + cgit_rc bigkeyrc "url=bigpage/" 2>&1 + ) | cat >nokey.out && + grep "</html>" nokey.out && + grep "Unable to write .*\.lock" nokey.out && + test_dir_is_empty cache3 +' + +# A scan is cached in a file named after the hash of its path, so a first +# scan finds out what that name is. The index page is left uncached, which +# makes each request read the list afresh. +test_expect_success 'set up a cached scan of two repositories' ' + mkrepo scanned/one 1 && + mkrepo scanned/two 1 && + { + echo "virtual-root=/" && + echo "cache-root=$PWD/cache5" && + echo "cache-size=64" && + echo "cache-index-ttl=0" && + echo "scan-path=$PWD/scanned" + } >scanrc && + rm -rf cache5 && mkdir cache5 && + cgit_rc scanrc "url=" >/dev/null && + scanned_rc=$(ls cache5) && + test_path_is_file "cache5/$scanned_rc" +' + +# A request that finds the scan's lock held and has no list to go on scans +# in its own process, leaving the file to the holder. That is only another +# request at work and no cause for a line in the log. +test_expect_success PERL 'a scan finding its lock held runs uncached' ' + write_script hold-lock "$PERL_PATH" <<-\EOF && + use Fcntl qw(:flock); + open(my $lock, ">>", shift) or die "$!\n"; + flock($lock, LOCK_EX | LOCK_NB) or die "$!\n"; + exit(system(@ARGV) ? 1 : 0); + EOF + rm "cache5/$scanned_rc" && + CGIT_CONFIG="$PWD/scanrc" QUERY_STRING="url=" \ + ./hold-lock "cache5/$scanned_rc.lock" cgit >held.out 2>held.err && + grep ">one</a>" held.out && + grep ">two</a>" held.out && + test_path_is_missing "cache5/$scanned_rc" && + test_must_be_empty held.err +' + +# A run that died before its rename leaves a lock file behind, here longer +# than anything the next run writes and ending in a repository and a marker. +# Emptying the file before reuse keeps that tail out of both caches. +test_expect_success 'a stale lock file reaches neither the scan nor a slot' ' + { + printf "%01024d\n" 0 0 0 0 && + echo "repo.url=ghost" && + echo "repo.path=$PWD/scanned/one/.git" + } >stale.lock && + rm -f "cache5/$scanned_rc" && + cp stale.lock "cache5/$scanned_rc.lock" && + CGIT_CONFIG="$PWD/scanrc" QUERY_STRING="url=" cgit >scan.out && + grep ">one</a>" scan.out && + ! grep ">ghost</a>" scan.out && + rm -rf cache3 && mkdir cache3 && + CGIT_CONFIG="$PWD/bigkeyrc" QUERY_STRING="url=bigpage/" cgit >page.out && + slot=$(ls cache3) && + rm "cache3/$slot" && + { cat page.out page.out && echo "STALE-TAIL"; } >"cache3/$slot.lock" && + CGIT_CONFIG="$PWD/bigkeyrc" QUERY_STRING="url=bigpage/" cgit >/dev/null && + CGIT_CONFIG="$PWD/bigkeyrc" QUERY_STRING="url=bigpage/" cgit >replay.out && + grep "</html>" replay.out && + ! grep "STALE-TAIL" replay.out ' test_done diff --git a/tests/t0004-docs.sh b/tests/t0004-docs.sh index 1eca51e..79c36b1 100755 --- a/tests/t0004-docs.sh +++ b/tests/t0004-docs.sh @@ -2,11 +2,13 @@ # Checks that the configuration documents stay consistent with each other. # The manual documents every key the reference config sets, the reference -# config sets every key the manual documents, both settings sections of the -# manual keep their entries sorted, and the three server configs carry one -# identical content security policy. +# config sets every key the manual documents and puts scan-path after every +# global setting, both settings sections of the manual keep their entries +# sorted, and the three server configs carry one identical content security +# policy. test_description='Check the configuration documents' +CGIT_TEST_NO_CREATE_REPOS=YesPlease . ./setup.sh ROOT="$TEST_OUTPUT_DIRECTORY/.." @@ -33,6 +35,16 @@ test_expect_success 'every key the reference config sets is documented' ' test_must_be_empty undocumented ' +# A scanned repository inherits the global settings above scan-path, so the +# sample keeps none below it. +test_expect_success 'the reference config scans after every global setting' ' + sed -n "/^#\{0,1\}scan-path=/,\$p" "$ROOT/custom/cgitrc" | + sed -n "s/^#\{0,1\}\([a-z][a-zA-Z0-9.<>-]*\)=.*/\1/p" | + grep -v "^repo\." >after-scan-path && + echo scan-path >expect && + test_cmp expect after-scan-path +' + test_expect_success 'the manual lists its global settings in order' ' sed -n "/^Global settings$/,/^Repository settings$/p" "$ROOT/MANUAL.txt" | sed -n "s/^\([a-z][a-zA-Z0-9.<>-]*\)::$/\1/p" >global-order && diff --git a/tests/t0005-markup.sh b/tests/t0005-markup.sh index 5fa866d..a6dd028 100755 --- a/tests/t0005-markup.sh +++ b/tests/t0005-markup.sh @@ -5,6 +5,7 @@ # names the page it renders and the attribute or element it expects. test_description='Check the markup hooks a theme relies on' +CGIT_TEST_NO_CREATE_REPOS=YesPlease . ./setup.sh test_expect_success 'set up a repository with a file that has an extension' ' @@ -16,23 +17,13 @@ test_expect_success 'set up a repository with a file that has an extension' ' git add dir && git commit -m sources ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "enable-blame=1" && - echo "max-stats=year" && - echo "readme=:file-1" && - echo "repo.url=hooks" && - echo "repo.path=$PWD/repos/hooks/.git" - } >hooksrc + cgitrc_repo hooksrc hooks enable-blame=1 max-stats=year readme=:file-1 ' -hooks() { CGIT_CONFIG="$PWD/hooksrc" QUERY_STRING="url=$1" cgit; } - test_expect_success 'the root names the page and the repository' ' - hooks "" >tmp && + cgit_rc hooksrc "url=" >tmp && grep "<div id=.cgit. data-page=.repolist.>" tmp && - hooks "hooks/log/" >tmp && + cgit_rc hooksrc "url=hooks/log/" >tmp && grep "<div id=.cgit. data-page=.log. data-repo=.hooks.>" tmp ' @@ -45,14 +36,14 @@ test_expect_success 'the landmarks and the search form are in place' ' ' test_expect_success 'the pages name the versions only when asked to' ' - hooks "hooks/" >footer && + cgit_rc hooksrc "url=hooks/" >footer && grep "<meta name=.generator. content=.cgit.>" footer && grep "<footer class=.footer.>generated by <a href=[^>]*>cgit</a></footer>" footer && { echo "enable-version-info=1" && cat hooksrc } >versionrc && - CGIT_CONFIG="$PWD/versionrc" QUERY_STRING="url=hooks/" cgit >footer && + cgit_rc versionrc "url=hooks/" >footer && grep "<meta name=.generator. content=.cgit v[0-9][^>]*>" footer && grep "<footer class=.footer.>generated by <a href=[^>]*>cgit v[0-9][^<]*</a> (<a href=[^>]*>git [0-9][^<]*</a>)</footer>" footer && echo "<footer>our own</footer>" >footer.html && @@ -61,7 +52,7 @@ test_expect_success 'the pages name the versions only when asked to' ' echo "footer=$PWD/footer.html" && cat hooksrc } >versionrc && - CGIT_CONFIG="$PWD/versionrc" QUERY_STRING="url=hooks/" cgit >footer && + cgit_rc versionrc "url=hooks/" >footer && grep "<footer>our own</footer>" footer && ! grep "git-scm" footer ' @@ -72,58 +63,58 @@ test_expect_success 'the listings name themselves, their sections and their rows grep "<tr class=.commit.>" tmp && grep "class=.active. aria-current=.page." tmp && grep "<th class=.left. scope=.col.>" tmp && - hooks "" >tmp && + cgit_rc hooksrc "url=" >tmp && grep "<table class=.list repolist. aria-label=.Repositories.>" tmp && grep "<tbody class=.repos.>" tmp && grep "<tr class=.repo.>" tmp && - hooks "hooks/" >tmp && + cgit_rc hooksrc "url=hooks/" >tmp && grep "<table class=.list summary. aria-label=.Summary.>" tmp && grep "<tbody class=.branches.>" tmp && grep "<tr class=.branch.>" tmp && - hooks "hooks/tree/" >tmp && + cgit_rc hooksrc "url=hooks/tree/" >tmp && grep "<table class=.list tree. aria-label=.Files.>" tmp && grep "<tr class=.dir.>" tmp && grep "<tr class=.blob.>" tmp && - hooks "hooks/tree/dir/" >tmp && + cgit_rc hooksrc "url=hooks/tree/dir/" >tmp && grep "class=.ls-blob ext-c." tmp ' test_expect_success 'the diff pages wrap each file and label their controls' ' - hooks "hooks/commit/" >tmp && + cgit_rc hooksrc "url=hooks/commit/" >tmp && grep "<table class=.commit-info.>" tmp && grep "<th scope=.row.>author</th>" tmp && grep "<div class=.file. data-path=.dir/hello.c.>" tmp && grep "<label for=.diff-context.>context</label>" tmp && grep "<select id=.diff-context. name=.context.>" tmp && grep "<form method=.get. aria-label=.Diff options.>" tmp && - hooks "hooks/diff/&ss=1" >tmp && + cgit_rc hooksrc "url=hooks/diff/&ss=1" >tmp && grep "<tbody class=.file. data-path=.dir/hello.c.>" tmp && - hooks "hooks/stats/" >tmp && + cgit_rc hooksrc "url=hooks/stats/" >tmp && grep "<label for=.stats-authors.>authors</label>" tmp ' test_expect_success 'the blob, blame and about pages open with their own containers' ' - hooks "hooks/tree/dir/hello.c" >tmp && + cgit_rc hooksrc "url=hooks/tree/dir/hello.c" >tmp && grep "<div class=.blob-header.>" tmp && grep "<a id=.n1." tmp && - hooks "hooks/blame/dir/hello.c" >tmp && + cgit_rc hooksrc "url=hooks/blame/dir/hello.c" >tmp && grep "<div class=.blob-header.>" tmp && - hooks "hooks/about/" >tmp && + cgit_rc hooksrc "url=hooks/about/" >tmp && grep "<div id=.about.>" tmp ' test_expect_success 'each page names itself, then the repository and the site' ' site="Git repository browser" && - hooks "hooks/" >tmp && + cgit_rc hooksrc "url=hooks/" >tmp && grep "<title>hooks - $site</title>" tmp && for page in about log refs tree stats do - hooks "hooks/$page/" >tmp && + cgit_rc hooksrc "url=hooks/$page/" >tmp && grep "<title>$page - hooks - $site</title>" tmp || return 1 done && - hooks "hooks/tree/dir/hello.c" >tmp && + cgit_rc hooksrc "url=hooks/tree/dir/hello.c" >tmp && grep "<title>hello.c < dir - hooks - $site</title>" tmp && - hooks "hooks/blame/dir/hello.c" >tmp && + cgit_rc hooksrc "url=hooks/blame/dir/hello.c" >tmp && grep "<title>blame - hello.c < dir - hooks - $site</title>" tmp ' @@ -150,7 +141,7 @@ test_expect_success 'the page background is the same everywhere it is set' ' test_line_count = 1 expect && sed -n "/^html.cgit-standalone {\$/,/^}/s/^ background: light-dark(\(#[0-9a-f]*\), \(#[0-9a-f]*\));\$/\1 \2/p" "$css" >actual && test_cmp expect actual && - hooks "hooks/" >tmp && + cgit_rc hooksrc "url=hooks/" >tmp && light=$(sed -n "s/.*theme-color. media=.(prefers-color-scheme: light). content=.\(#[0-9a-f]*\).*/\1/p" tmp) && dark=$(sed -n "s/.*theme-color. media=.(prefers-color-scheme: dark). content=.\(#[0-9a-f]*\).*/\1/p" tmp) && echo "$light $dark" >actual && @@ -158,21 +149,33 @@ test_expect_success 'the page background is the same everywhere it is set' ' ' test_expect_success 'the script waits for the page instead of holding it up' ' - hooks "hooks/" >tmp && + cgit_rc hooksrc "url=hooks/" >tmp && grep "<script src=./cgit.js. defer></script>" tmp ' -# The rest of the hooks need a tag, a symlink, a submodule, a trailer, a lone -# carriage return, clone urls, an empty repository and a second page of -# repositories, so they get a commit and a config of their own after the checks -# above have read the first ones. +# A collection from getElementsByClassName is live, so an age the loop moves to +# another unit drops out of it at once and the age after it is skipped. +test_expect_success 'the script walks the ages from a list that holds still' ' + ! grep "getElementsByClassName" "$TEST_OUTPUT_DIRECTORY/../assets/cgit.js" +' + +# The line anchors carry a scroll margin of half the window, which centres a +# line scrolled to the top of the view. Asking scrollIntoView to centre it as +# well centres the margin too and drops a range three quarters of the way down. +test_expect_success 'a line range scrolls where a single line does' ' + grep "^ scroll-margin-top: calc(50vh - 1em);$" "$TEST_OUTPUT_DIRECTORY/../assets/cgit.css" && + grep "first.scrollIntoView();" "$TEST_OUTPUT_DIRECTORY/../assets/cgit.js" +' + +# The rest of the hooks need a tag, a symlink, a submodule, a trailer, clone +# urls, an empty repository and a second page of repositories, so they get a +# commit and a config of their own after the checks above have read the first +# ones. test_expect_success 'set up the remaining kinds of entry' ' ( cd repos/hooks && git tag -a -m "first release" v1 && test_ln_s_add file-1 link && - printf "a\\rb\\n" >cr.txt && - git add cr.txt && git update-index --add --cacheinfo "160000,$(git rev-parse HEAD),sub" && git commit -m "more kinds of entry" -m "Fixes: $(git rev-parse --short HEAD)" ) && @@ -192,36 +195,29 @@ test_expect_success 'set up the remaining kinds of entry' ' } >morerc ' -more() { CGIT_CONFIG="$PWD/morerc" QUERY_STRING="url=$1" cgit; } - test_expect_success 'the refs, summary and tree name every section and row' ' - more "hooks/refs/" >tmp && + cgit_rc morerc "url=hooks/refs/" >tmp && grep "<table class=.list refs. aria-label=.Branches and tags.>" tmp && grep "<tbody class=.tags.>" tmp && grep "<tr class=.tag.>" tmp && - more "hooks/" >tmp && + cgit_rc morerc "url=hooks/" >tmp && grep "<tbody class=.clone.>" tmp && - more "hooks/tree/" >tmp && + cgit_rc morerc "url=hooks/tree/" >tmp && grep "<tbody class=.tree.>" tmp && grep "<tr class=.link.>" tmp && grep "<tr class=.mod.>" tmp ' -test_expect_success 'a character the browser would break a line at is an escape' ' - more "hooks/tree/cr.txt" >tmp && - grep "a<span class=.escape.>" tmp -' - test_expect_success 'the commit page has its trailer table and diffstat' ' - more "hooks/commit/" >tmp && + cgit_rc morerc "url=hooks/commit/" >tmp && grep "<table class=.commit-trailers.>" tmp && grep "<table class=.diffstat.>" tmp ' test_expect_success 'the breadcrumb, the pager and the ages carry their hooks' ' - more "hooks/tree/dir/" >tmp && + cgit_rc morerc "url=hooks/tree/dir/" >tmp && grep "<nav class=.path. aria-label=.breadcrumb.>" tmp && - more "" >tmp && + cgit_rc morerc "url=" >tmp && grep "<nav class=.pager. aria-label=.Pager.>" tmp && grep "class=.current. aria-current=.page." tmp && grep "<time class=.age-[a-z]*. datetime=.[^ ]* data-ut=.[0-9]*." tmp @@ -232,13 +228,13 @@ test_expect_success 'a date shown in full is a plain time element' ' echo "enable-relative-dates=0" && cat morerc } >fullrc && - CGIT_CONFIG="$PWD/fullrc" QUERY_STRING="url=" cgit >tmp && + cgit_rc fullrc "url=" >tmp && grep "<time datetime=" tmp && ! grep "data-ut=" tmp ' test_expect_success 'an empty repository lists its clone urls as a clone table' ' - more "empty/" >tmp && + cgit_rc morerc "url=empty/" >tmp && grep "<table class=.list clone. aria-label=.Clone urls.>" tmp && grep "<tbody class=.clone.>" tmp ' @@ -248,7 +244,7 @@ test_expect_success 'embedded mode marks the root' ' echo "embedded=1" && cat hooksrc } >embeddedrc && - CGIT_CONFIG="$PWD/embeddedrc" QUERY_STRING="url=hooks/" cgit >tmp && + cgit_rc embeddedrc "url=hooks/" >tmp && grep "<div id=.cgit. class=.cgit-embedded. data-page=.summary." tmp ' diff --git a/tests/t0006-nolua.sh b/tests/t0006-nolua.sh index c315e41..cd35fca 100755 --- a/tests/t0006-nolua.sh +++ b/tests/t0006-nolua.sh @@ -13,7 +13,6 @@ nolua="$TEST_OUTPUT_DIRECTORY/../build/nolua/cgit" test -x "$nolua" || { skip_all='Skipping the Lua-less checks: build/nolua/cgit is missing, run make test' test_done - exit } nolua_query() { CGIT_CONFIG="$PWD/$1" QUERY_STRING="$2" "$nolua"; } @@ -24,12 +23,7 @@ test_expect_success 'the binary reports no Lua' ' ' test_expect_success 'it serves a page' ' - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=foo" && - echo "repo.path=$PWD/repos/foo/.git" - } >plainrc && + cgitrc_repo plainrc foo && nolua_query plainrc "url=foo/log/" >tmp && grep "^Status: 200" tmp && grep ">commit 5</a>" tmp diff --git a/tests/t0101-index.sh b/tests/t0101-index.sh index 8d389b6..2e49ed8 100755 --- a/tests/t0101-index.sh +++ b/tests/t0101-index.sh @@ -11,9 +11,9 @@ test_description='Check content on index page' . ./setup.sh test_expect_success 'generate index page' 'cgit_url "" >tmp' -test_expect_success 'find foo repo' 'grep "foo" tmp' +test_expect_success 'find foo repo' 'grep ">foo</a>" tmp' test_expect_success 'find foo description' 'grep "\[no description\]" tmp' -test_expect_success 'find bar repo' 'grep "bar" tmp' +test_expect_success 'find bar repo' 'grep ">bar</a>" tmp' test_expect_success 'find bar description' 'grep "the bar repo" tmp' test_expect_success 'find foo+bar repo' 'grep ">foo+bar<" tmp' test_expect_success 'verify foo+bar links' 'test $(grep -o "href=./foo%2Bbar/." tmp | wc -l) = 2' @@ -22,23 +22,15 @@ test_expect_success 'no tree-link' '! grep "foo/tree" tmp' test_expect_success 'no log-link' '! grep "foo/log" tmp' test_expect_success 'index links lead to the summary, log and tree' ' - echo "enable-index-links=1" >links-cgitrc && - sed "s/^cache-size=.*/cache-size=0/" cgitrc >>links-cgitrc && - CGIT_CONFIG="$PWD/links-cgitrc" QUERY_STRING="url=" cgit >tmp && + cgitrc_copy links-cgitrc enable-index-links=1 && + cgit_rc links-cgitrc "url=" >tmp && grep ">summary</a><a class=.button. href=./foo/log/.>log</a>" tmp && grep "href=./foo/log/.>log</a><a class=.button. href=./foo/tree/.>tree<" tmp ' test_expect_success 'each css and js entry gets a link of its own' ' - { - echo "css=/a.css" && - echo "css=/b.css" && - echo "js=/a.js" && - echo "js=" && - echo "js=/b.js" - } >headrc && - sed "s/^cache-size=.*/cache-size=0/" cgitrc >>headrc && - CGIT_CONFIG="$PWD/headrc" QUERY_STRING="url=" cgit >tmp && + cgitrc_copy headrc css=/a.css css=/b.css js=/a.js js= js=/b.js && + cgit_rc headrc "url=" >tmp && grep "<link rel=.stylesheet. href=./a.css.>" tmp && grep "<link rel=.stylesheet. href=./b.css.>" tmp && grep "<script src=" tmp >scripts && @@ -47,12 +39,6 @@ test_expect_success 'each css and js entry gets a link of its own' ' grep "/b.js" scripts ' -test_expect_success 'the feed link encodes the repository and the branch' ' - sed "s/^cache-size=.*/cache-size=0/" cgitrc >feedrc && - CGIT_CONFIG="$PWD/feedrc" HTTP_HOST=example.org QUERY_STRING="url=foo%2Bbar/log/&h=1%2B2" cgit >tmp && - grep "title=.Atom feed. href=.http://example.org/foo%2Bbar/atom/?h=1%2B2." tmp -' - # The description limit counts bytes, so a cut has to land between # characters rather than leave half of one on the page. test_expect_success 'a cut description keeps whole characters' ' @@ -63,7 +49,7 @@ test_expect_success 'a cut description keeps whole characters' ' echo "repo.path=$PWD/repos/foo/.git" && echo "repo.desc=日本語の説明文です" } >descrc && - CGIT_CONFIG="$PWD/descrc" QUERY_STRING="url=" cgit >tmp && + cgit_rc descrc "url=" >tmp && grep ">日本語\.\.\.</a>" tmp ' diff --git a/tests/t0102-summary.sh b/tests/t0102-summary.sh index e95f270..9e0e14c 100755 --- a/tests/t0102-summary.sh +++ b/tests/t0102-summary.sh @@ -11,7 +11,7 @@ test_description='Check content on summary page' test_expect_success 'generate foo summary' 'cgit_url "foo" >tmp' test_expect_success 'find commit 1' 'grep "commit 1" tmp' test_expect_success 'find commit 5' 'grep "commit 5" tmp' -test_expect_success 'find branch master' 'grep "master" tmp' +test_expect_success 'find branch master' 'grep "<tr class=.branch.><td><a[^>]*>master</a>" tmp' test_expect_success 'no tags' '! grep "Tag</th>" tmp' test_expect_success 'clone-url expanded correctly' ' grep "git://example.org/foo.git" tmp @@ -24,11 +24,6 @@ test_expect_success 'repo description reaches the meta tag' ' test_expect_success 'no commit 45' '! grep "commit 45" tmp' test_expect_success 'find commit 46' 'grep "commit 46" tmp' test_expect_success 'find commit 50' 'grep "commit 50" tmp' -test_expect_success 'find branch master' 'grep "master" tmp' -test_expect_success 'no tags' '! grep "Tag</th>" tmp' -test_expect_success 'clone-url expanded correctly' ' - grep "git://example.org/bar.git" tmp -' # A clone url is shown as written, but a browser cannot follow the ssh, scp # or git forms, so those rows link to the first http url in the list, or to @@ -38,17 +33,40 @@ test_expect_success 'a git clone url row falls back to the page url' ' ' test_expect_success 'an scp clone url row links to the http url' ' + cgitrc_repo clonerc foo "repo.clone-url=git@example.org:foo https://example.org/foo" && + cgit_rc clonerc "url=foo" >tmp && + grep "title=.foo Git repository. href=.https://example.org/foo.>git@example.org:foo</a>" tmp && + grep "vcs-git. href=.https://example.org/foo." tmp && + ! grep "vcs-git. href=.git@" tmp +' + +# A repository can turn the global clone-url off with an empty one of its own. +# Neither its summary nor the page an empty repository gets then draws a Clone +# heading over no rows. +test_expect_success 'an empty clone url draws no clone heading' ' + git init -q --bare repos/empty.git && { echo "virtual-root=/" && echo "cache-size=0" && + echo "clone-url=https://example.org/\$CGIT_REPO_URL" && echo "repo.url=foo" && echo "repo.path=$PWD/repos/foo/.git" && - echo "repo.clone-url=git@example.org:foo https://example.org/foo" - } >clonerc && - CGIT_CONFIG="$PWD/clonerc" QUERY_STRING="url=foo" cgit >tmp && - grep "title=.foo Git repository. href=.https://example.org/foo.>git@example.org:foo</a>" tmp && - grep "vcs-git. href=.https://example.org/foo." tmp && - ! grep "vcs-git. href=.git@" tmp + echo "repo.clone-url=" && + echo "repo.url=empty" && + echo "repo.path=$PWD/repos/empty.git" && + echo "repo.clone-url=" && + echo "repo.url=cloned" && + echo "repo.path=$PWD/repos/empty.git" + } >emptyrc && + cgit_rc emptyrc "url=foo" >tmp && + grep "commit 1" tmp && + ! grep "Clone</th>" tmp && + cgit_rc emptyrc "url=empty" >tmp && + grep "Repository seems to be empty" tmp && + ! grep "Clone</th>" tmp && + cgit_rc emptyrc "url=cloned" >tmp && + grep "Clone</th>" tmp && + grep "https://example.org/cloned</a>" tmp ' # Prints how many columns each row of the page's list table spans, one row to @@ -74,7 +92,7 @@ row_widths() { # Fails unless every row of the list table at the url is as many columns wide # as the second argument says. A third names a config other than colsrc. test_widths() { - CGIT_CONFIG="$PWD/${3:-colsrc}" QUERY_STRING="url=$1" cgit >tmp && + cgit_rc "${3:-colsrc}" "url=$1" >tmp && row_widths tmp | sort -u >actual && echo "$2" >expect && test_cmp expect actual @@ -86,7 +104,7 @@ test_widths() { # Each repo below shows the same history with different counts, and every # section is cut short so that its [...] row is drawn too. test_expect_success 'set up a repo cut short in every section' ' - mkrepo repos/cols 3 >/dev/null && + mkrepo repos/cols 3 && git -C repos/cols branch other HEAD~1 && git -C repos/cols tag one HEAD~1 && git -C repos/cols tag -a -m two two && @@ -108,7 +126,7 @@ test_expect_success 'set up a repo cut short in every section' ' echo "repo.enable-log-filecount=1" && echo "repo.enable-log-linecount=1" } >colsrc && - CGIT_CONFIG="$PWD/colsrc" QUERY_STRING="url=none" cgit >tmp && + cgit_rc colsrc "url=none" >tmp && grep "<tbody class=.tags.>" tmp && grep "<tbody class=.clone.>" tmp && test $(grep -c ">\[...\]</a>" tmp) = 3 diff --git a/tests/t0103-log.sh b/tests/t0103-log.sh index d7e745d..9796394 100755 --- a/tests/t0103-log.sh +++ b/tests/t0103-log.sh @@ -12,7 +12,7 @@ test_expect_success 'find commit 1' 'grep "commit 1" tmp' test_expect_success 'find commit 5' 'grep "commit 5" tmp' test_expect_success 'generate bar/log' 'cgit_url "bar/log" >tmp' -test_expect_success 'find commit 1' 'grep "commit 1" tmp' +test_expect_success 'find commit 1' 'grep ">commit 1<" tmp' test_expect_success 'find commit 50' 'grep "commit 50" tmp' # The switch button submits the select as h, so whatever head the request @@ -38,7 +38,7 @@ test_expect_success 'a branch head keeps its plain option' ' test_expect_success 'generate "with%20space/log?qt=grep&q=commit+1"' ' cgit_url "with+space/log&qt=grep&q=commit+1" >tmp ' -test_expect_success 'find commit 1' 'grep "commit 1" tmp' +test_expect_success 'find commit 1' 'grep ">commit 1<" tmp' test_expect_success 'find link with %20 in path' 'grep "/with%20space/log/?qt=grep" tmp' test_expect_success 'find link with + in arg' 'grep "/log/?qt=grep&q=commit+1" tmp' test_expect_success 'no links with space in path' '! grep "href=./with space/" tmp' @@ -68,24 +68,17 @@ test_expect_success 'set up a renamed file to follow' ' git commit -am "edit b $i" || return 1 done ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "enable-follow-links=1" && - echo "max-commit-count=3" && - echo "repo.url=fol" && - echo "repo.path=$PWD/repos/fol/.git" - } >folrc + cgitrc_repo folrc fol enable-follow-links=1 max-commit-count=3 ' test_expect_success 'a followed log reaches the root commit' ' - CGIT_CONFIG="$PWD/folrc" QUERY_STRING="url=fol/log/b.txt&follow=1&ofs=6" cgit >tmp && + cgit_rc folrc "url=fol/log/b.txt&follow=1&ofs=6" >tmp && grep ">edit a 2<" tmp && grep ">create a<" tmp ' test_expect_success 'the pager of a followed log keeps the name followed' ' - CGIT_CONFIG="$PWD/folrc" QUERY_STRING="url=fol/log/b.txt&follow=1&ofs=3" cgit >tmp && + cgit_rc folrc "url=fol/log/b.txt&follow=1&ofs=3" >tmp && grep ">edit a 3<" tmp && grep "href=./fol/log/b.txt?follow=1.>\[prev\]" tmp && grep "href=./fol/log/b.txt?ofs=6&follow=1.>\[next\]" tmp @@ -93,24 +86,30 @@ test_expect_success 'the pager of a followed log keeps the name followed' ' test_expect_success 'a cut subject keeps whole characters' ' git -C repos/foo commit --allow-empty -m "日本語の説明文です日本語の説明文です" && - { - echo "max-message-length=20" && - sed "s/^cache-size=.*/cache-size=0/" cgitrc - } >subjectrc && - CGIT_CONFIG="$PWD/subjectrc" QUERY_STRING="url=foo/log/" cgit >tmp && + cgitrc_copy subjectrc max-message-length=20 && + cgit_rc subjectrc "url=foo/log/" >tmp && grep ">日本語の説\.\.\.</a>" tmp ' test_expect_success 'the log draws the commit graph when enabled' ' - { - echo "enable-commit-graph=1" && - sed "s/^cache-size=.*/cache-size=0/" cgitrc - } >graphrc && - CGIT_CONFIG="$PWD/graphrc" QUERY_STRING="url=foo/log/" cgit >tmp && + cgitrc_copy graphrc enable-commit-graph=1 && + cgit_rc graphrc "url=foo/log/" >tmp && grep "class=.commitgraph." tmp && grep "<th scope=.col. aria-label=.Graph.></th>" tmp ' +# A followed log is walked without the graph, so its first column holds the +# age whatever enable-commit-graph says. +test_expect_success 'a followed log heads its age column with the graph on' ' + { + echo "enable-commit-graph=1" && + cat folrc + } >folgraphrc && + cgit_rc folgraphrc "url=fol/log/b.txt&follow=1" >tmp && + grep "<th class=.left. scope=.col.>Age</th><th class=.left. scope=.col.>Commit message" tmp && + ! grep "aria-label=.Graph." tmp +' + # The search is literal, so text that is no valid regular expression still # finds the commit holding it. test_expect_success 'a search for brackets matches them literally' ' @@ -120,4 +119,18 @@ test_expect_success 'a search for brackets matches them literally' ' grep "fix \\[brackets\\] in docs" tmp ' +# A range is walked in full before the first row, so a missing parent fails +# the walk while the page can still answer with an error of its own. +test_expect_success 'a walk that cannot start answers one error page' ' + git clone -q repos/foo gap && + old=$(git -C gap rev-parse HEAD~4) && + parent=$(git -C gap rev-parse HEAD~2) && + rm gap/.git/objects/$(echo $parent | cut -c1-2)/$(echo $parent | cut -c3-) && + cgitrc_repo gaprc gap "repo.path=$PWD/gap/.git" && + cgit_rc gaprc "url=gap/log/&qt=range&q=$old..master" >tmp 2>err && + test $(grep -c "^Status:" tmp) = 1 && + grep "^Status: 500" tmp && + grep "Unable to read the history" tmp +' + test_done diff --git a/tests/t0104-tree.sh b/tests/t0104-tree.sh index 41134a4..26a0cb4 100755 --- a/tests/t0104-tree.sh +++ b/tests/t0104-tree.sh @@ -9,7 +9,7 @@ test_description='Check content on tree page' . ./setup.sh test_expect_success 'generate bar/tree' 'cgit_url "bar/tree" >tmp' -test_expect_success 'find file-1' 'grep "file-1" tmp' +test_expect_success 'find file-1' 'grep ">file-1<" tmp' test_expect_success 'find file-50' 'grep "file-50" tmp' test_expect_success 'the links column has a name' ' grep "<th class=.ls-links. scope=.col. aria-label=.Links.></th>" tmp @@ -44,17 +44,11 @@ test_expect_success 'set up a file holding the characters a browser breaks at' ' git add breaks.txt && git commit -m breaks ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "enable-blame=1" && - echo "repo.url=breaks" && - echo "repo.path=$PWD/repos/breaks/.git" - } >breaksrc + cgitrc_repo breaksrc breaks enable-blame=1 ' test_expect_success 'each of them is shown as an escape in the tree' ' - CGIT_CONFIG="$PWD/breaksrc" QUERY_STRING="url=breaks/tree/breaks.txt" cgit >tmp && + cgit_rc breaksrc "url=breaks/tree/breaks.txt" >tmp && grep "<code>a<span class=.escape.>\\\\r</span>b<span class=.escape.>\\\\v</span>c" tmp && grep "c<span class=.escape.>\\\\f</span>d<span class=.escape.>\\\\u0085</span>e" tmp && grep "e<span class=.escape.>\\\\u2028</span>f<span class=.escape.>\\\\u2029</span>g$" tmp @@ -73,7 +67,7 @@ test_expect_success 'the numbers count only the lines git counts' ' ' test_expect_success 'the blame page escapes them the same way' ' - CGIT_CONFIG="$PWD/breaksrc" QUERY_STRING="url=breaks/blame/breaks.txt" cgit >tmp && + cgit_rc breaksrc "url=breaks/blame/breaks.txt" >tmp && grep "<code>a<span class=.escape.>\\\\r</span>b<span class=.escape.>\\\\v</span>c" tmp && grep "e<span class=.escape.>\\\\u2028</span>f<span class=.escape.>\\\\u2029</span>g$" tmp && grep "^i<span class=.escape.>\\\\r</span></code>" tmp @@ -105,17 +99,11 @@ test_expect_success 'set up a repo with a nested submodule' ' git update-index --add --cacheinfo 160000,$sub,vendor/sub && git commit -m gitlink ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "enable-blame=1" && - echo "repo.url=submodules" && - echo "repo.path=$PWD/repos/submodules/.git" - } >submodrc + cgitrc_repo submodrc submodules enable-blame=1 ' test_expect_success 'generate submodules/tree/vendor' ' - CGIT_CONFIG="$PWD/submodrc" QUERY_STRING="url=submodules/tree/vendor" cgit >tmp + cgit_rc submodrc "url=submodules/tree/vendor" >tmp ' test_expect_success 'a submodule row shows the entry name alone' ' @@ -134,14 +122,13 @@ test_expect_success 'a submodule row shows no size' ' ' test_expect_success 'a submodule row offers log but not blame or plain' ' - grep "ls-mod" tmp >row && grep ">log</a>" row && ! grep ">blame</a>" row && ! grep ">plain</a>" row ' test_expect_success 'a file row still offers blame' ' - CGIT_CONFIG="$PWD/submodrc" QUERY_STRING="url=submodules/tree/" cgit >tmp && + cgit_rc submodrc "url=submodules/tree/" >tmp && grep ">blame</a>" tmp ' @@ -159,13 +146,8 @@ test_expect_success 'only a link target inside the tree is linked' ' git update-index --add --cacheinfo 120000,$abs,abs && git commit -m links ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=links" && - echo "repo.path=$PWD/repos/links/.git" - } >linksrc && - CGIT_CONFIG="$PWD/linksrc" QUERY_STRING="url=links/tree/" cgit >tmp && + cgitrc_repo linksrc links && + cgit_rc linksrc "url=links/tree/" >tmp && grep " -> <a class=.ls-blob. href=./links/tree/target.txt.>target.txt</a>" tmp && grep " -> /etc/passwd</td>" tmp && ! grep "href=./links/tree/etc/passwd" tmp @@ -187,17 +169,34 @@ test_expect_success 'a long directory chain ends on a link' ' git add . && git commit -m deep ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=deep" && - echo "repo.path=$PWD/repos/deep/.git" - } >deeprc && - CGIT_CONFIG="$PWD/deeprc" QUERY_STRING="url=deep/tree/" cgit >tmp && + cgitrc_repo deeprc deep && + cgit_rc deeprc "url=deep/tree/" >tmp && grep ">d15</a></td>" tmp && ! grep " / </td>" tmp ' +# Git holds one object per id, as the type it was first looked up as, so a +# folder the commit also names as its parent cannot be read as a tree and the +# chain stops on it. The rest of the row still belongs to the first folder. +test_expect_success 'a broken chain keeps the row links on the first folder' ' + test_create_repo repos/cut && + ( + cd repos/cut && + mkdir -p a/b && + echo x >a/b/file && + git add a && + git commit -m base && + odd=$(printf "tree %s\\nparent %s\\nauthor A <a@x> 1700000000 +0000\\ncommitter A <a@x> 1700000000 +0000\\n\\nodd\\n" \ + $(git rev-parse HEAD^{tree}) $(git rev-parse HEAD:a/b) | + git hash-object -t commit -w --stdin --literally) && + git update-ref refs/heads/odd $odd + ) && + cgitrc_repo cutrc cut && + cgit_rc cutrc "url=cut/tree/&h=odd" >tmp && + grep ">b</a></td>" tmp && + grep "href=./cut/log/a?h=odd.>log</a>" tmp +' + test_expect_success 'a binary file in the tree shows as a hex dump' ' test_create_repo repos/bin && ( @@ -206,13 +205,8 @@ test_expect_success 'a binary file in the tree shows as a hex dump' ' git add blob.bin && git commit -m binary ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=bin" && - echo "repo.path=$PWD/repos/bin/.git" - } >binrc && - CGIT_CONFIG="$PWD/binrc" QUERY_STRING="url=bin/tree/blob.bin" cgit >tmp && + cgitrc_repo binrc bin && + cgit_rc binrc "url=bin/tree/blob.bin" >tmp && grep "<table class=.bin-blob.>" tmp && grep "<td class=.right.>0000</td><td class=.hex.> 00 01 02 62 69 6e" tmp ' diff --git a/tests/t0105-plain.sh b/tests/t0105-plain.sh index 4615dc5..6a96b61 100755 --- a/tests/t0105-plain.sh +++ b/tests/t0105-plain.sh @@ -65,9 +65,9 @@ test_expect_success 'only text and xml types carry a charset' ' echo "mimetype.svg=image/svg+xml" && cat cgitrc } >typesrc && - CGIT_CONFIG="$PWD/typesrc" QUERY_STRING="url=foo/plain/doc.docx" cgit >tmp && + cgit_rc typesrc "url=foo/plain/doc.docx" >tmp && sed "/^$/q" tmp | grep "^Content-Type: $docx$" && - CGIT_CONFIG="$PWD/typesrc" QUERY_STRING="url=foo/plain/pic.svg" cgit >tmp && + cgit_rc typesrc "url=foo/plain/pic.svg" >tmp && sed "/^$/q" tmp | grep "^Content-Type: image/svg+xml; charset=UTF-8$" ' @@ -83,14 +83,8 @@ test_expect_success 'plain answers once for duplicate tree entries' ' c=$(echo dup | git commit-tree $top) && git update-ref refs/heads/master $c ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "mimetype.png=image/png" && - echo "repo.url=dup" && - echo "repo.path=$PWD/repos/dup/.git" - } >duprc && - CGIT_CONFIG="$PWD/duprc" QUERY_STRING="url=dup/plain/x.png" cgit >tmp && + cgitrc_repo duprc dup mimetype.png=image/png && + cgit_rc duprc "url=dup/plain/x.png" >tmp && test $(grep -c "^Status:" tmp) = 1 && test $(grep -c "^Content-Type:" tmp) = 1 && grep "^Content-Type: image/png" tmp @@ -106,7 +100,7 @@ test_expect_success 'a control character in a file name stays out of the headers c=$(echo ctl | git commit-tree $top) && git update-ref refs/heads/ctl $c ) && - CGIT_CONFIG="$PWD/duprc" QUERY_STRING="url=dup/plain/ctl%01name.txt&h=ctl" cgit >tmp && + cgit_rc duprc "url=dup/plain/ctl%01name.txt&h=ctl" >tmp && grep "^Content-Disposition: inline; filename=.ctl?name.txt.$" tmp ' diff --git a/tests/t0106-commit.sh b/tests/t0106-commit.sh index 5564038..9a0a7e1 100755 --- a/tests/t0106-commit.sh +++ b/tests/t0106-commit.sh @@ -1,8 +1,7 @@ #!/bin/sh # The commit page. Most checks read the markup for the tree link, the parent -# link, the subject and the diffstat. The last few use a root commit, which -# has no parent and diffs against the empty tree. +# link, the subject and the diffstat. test_description='Check content on commit page' . ./setup.sh @@ -38,8 +37,9 @@ test_expect_success 'a commit that changes no files draws no diffstat table' ' ! grep "<table class=.diffstat.>" tmp ' +# A root commit has no parent, so it diffs against the empty tree. test_expect_success 'get root commit' ' - root=$(cd repos/foo && git rev-list --reverse HEAD | head -1) && + root=$(git -C repos/foo rev-list --max-parents=0 HEAD) && cgit_url "foo/commit&id=$root" >tmp && grep "</html>" tmp ' @@ -66,7 +66,7 @@ test_expect_success 'a message that is not UTF-8 has its stray bytes replaced' ' cid=$(git hash-object --literally -t commit -w raw) && git update-ref refs/heads/latin "$cid" ) && - cgit_query "url=foo/commit&h=latin" >tmp && + cgit_url "foo/commit&h=latin" >tmp && grep "<div class=.commit-subject.>caf? subject<" tmp && grep "body ? end" tmp && ! grep "caf$(printf "\351")" tmp @@ -78,9 +78,9 @@ test_expect_success 'an octopus merge, which draws no diff, offers no diff optio cid=$(git commit-tree -p HEAD -p HEAD~1 -p HEAD~2 -m octopus HEAD^{tree}) && git update-ref refs/heads/octopus "$cid" ) && - cgit_query "url=foo/commit&h=octopus" >tmp && + cgit_url "foo/commit&h=octopus" >tmp && ! grep "cgit-panel" tmp && - cgit_query "url=foo/commit" >tmp && + cgit_url "foo/commit" >tmp && grep "cgit-panel" tmp ' diff --git a/tests/t0107-diff.sh b/tests/t0107-diff.sh index f058246..e173d00 100755 --- a/tests/t0107-diff.sh +++ b/tests/t0107-diff.sh @@ -1,8 +1,8 @@ #!/bin/sh -# The diff page, unified or side by side under dt=1. The first checks read +# The diff page, unified or side-by-side under dt=1. The first checks read # the markup for one added file in the fixture repository. The rest build -# repositories of their own, shaped for the side by side renderer's two +# repositories of their own, shaped for the side-by-side renderer's two # trickiest cases, a hunk covering a single line and a file where every # line changed. @@ -22,6 +22,16 @@ test_expect_success 'find added line' ' grep "<div class=.add.>+5</div>" tmp ' +# A revision that names no commit is quoted the way the request wrote it, as +# the commit and patch pages quote it, on either side of the diff. +test_expect_success 'a tree on either side is named as the request gave it' ' + tree=$(git -C repos/foo rev-parse --short HEAD^{tree}) && + cgit_url "foo/diff/&id=$tree" >tmp && + grep "<div class=.error.>Not a commit: $tree</div>" tmp && + cgit_url "foo/diff/&id2=$tree" >tmp && + grep "<div class=.error.>Not a commit: $tree</div>" tmp +' + # git writes a hunk length only when the hunk spans more than one line, so the # header for a single line reads "@@ -1 +1 @@" with no comma in it. Requiring # that comma left those hunks numbered from zero, which dropped the line @@ -36,24 +46,19 @@ test_expect_success 'set up a repo with single-line hunks' ' printf "ONLY\n" >one.txt && git commit -am "change one.txt" ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=hunk1" && - echo "repo.path=$PWD/repos/hunk1/.git" - } >hunk1rc && + cgitrc_repo hunk1rc hunk1 && git -C repos/hunk1 diff HEAD~1 HEAD >raw.diff && grep "^@@ -1 +1 @@" raw.diff ' test_expect_success 'ssdiff numbers a comma-less hunk from its real line' ' - CGIT_CONFIG="$PWD/hunk1rc" QUERY_STRING="url=hunk1/diff/&dt=1" cgit >tmp && + cgit_rc hunk1rc "url=hunk1/diff/&dt=1" >tmp && grep "#n1.>1</a>" tmp && ! grep "#n0.>0</a>" tmp ' test_expect_success 'ssdiff still numbers a hunk that carries a length' ' - cgit_query "url=foo/diff&dt=1" >tmp && + cgit_url "foo/diff&dt=1" >tmp && grep "#n1.>1</a>" tmp ' @@ -75,16 +80,11 @@ test_expect_success 'set up a repo with shrinking changed lines' ' s=s sprintf(\"%c\",c)} print s}}" >lines.txt && git commit -am "change every line" ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=lcs" && - echo "repo.path=$PWD/repos/lcs/.git" - } >lcsrc + cgitrc_repo lcsrc lcs ' test_expect_success 'every changed pair gets both a del and an add span' ' - CGIT_CONFIG="$PWD/lcsrc" QUERY_STRING="url=lcs/diff/&dt=1" cgit >tmp && + cgit_rc lcsrc "url=lcs/diff/&dt=1" >tmp && deletions=$(grep -o "<span class=.del.>" tmp | wc -l) && additions=$(grep -o "<span class=.add.>" tmp | wc -l) && test "$deletions" -gt 0 && @@ -93,22 +93,15 @@ test_expect_success 'every changed pair gets both a del and an add span' ' test_expect_success 'stripping the highlight leaves every line intact' ' sed -e "s/<[^>]*>//g" tmp >plain.out && - git -C repos/lcs show HEAD~1:lines.txt >old.txt && - git -C repos/lcs show HEAD:lines.txt >new.txt && - while read -r line - do - grep -qF "$line" plain.out || { - echo "missing old line: $line" - return 1 - } - done <old.txt && + git -C repos/lcs show HEAD~1:lines.txt >lines && + git -C repos/lcs show HEAD:lines.txt >>lines && while read -r line do grep -qF "$line" plain.out || { - echo "missing new line: $line" + echo "missing line: $line" return 1 } - done <new.txt + done <lines ' test_done diff --git a/tests/t0108-rawdiff.sh b/tests/t0108-rawdiff.sh index f68628a..9b4eb37 100755 --- a/tests/t0108-rawdiff.sh +++ b/tests/t0108-rawdiff.sh @@ -8,51 +8,48 @@ test_description='Check content on rawdiff page' . ./setup.sh test_expect_success 'generate foo/rawdiff' ' - cgit_query "url=foo/rawdiff" >tmp + cgit_url "foo/rawdiff" >tmp ' -# cgit writes its CGI headers and the blank line that ends them ahead of the -# body while git writes only the diff, so the head of the page comes off -# before the two are compared. test_expect_success 'compare with output of git-diff(1)' ' - git --git-dir="$PWD/repos/foo/.git" diff HEAD^.. >tmp2 && - strip_headers <tmp >tmp_ && - cmp tmp_ tmp2 + git -C repos/foo diff HEAD^.. >expect && + strip_headers <tmp >actual && + test_cmp expect actual ' test_expect_success 'find initial commit' ' - root=$(git --git-dir="$PWD/repos/foo/.git" rev-list --max-parents=0 HEAD) + root=$(git -C repos/foo rev-list --max-parents=0 HEAD) ' test_expect_success 'generate diff for initial commit' ' - cgit_query "url=foo/rawdiff&id=$root" >tmp + cgit_url "foo/rawdiff&id=$root" >tmp ' test_expect_success 'compare with output of git-diff-tree(1)' ' - git --git-dir="$PWD/repos/foo/.git" diff-tree -p --no-commit-id --root "$root" >tmp2 && - strip_headers <tmp >tmp_ && - cmp tmp_ tmp2 + git -C repos/foo diff-tree -p --no-commit-id --root "$root" >expect && + strip_headers <tmp >actual && + test_cmp expect actual ' test_expect_success 'generate diff for multiple commits' ' - id=$(git --git-dir="$PWD/repos/foo/.git" rev-parse HEAD) && - id2=$(git --git-dir="$PWD/repos/foo/.git" rev-parse HEAD~3) && - cgit_query "url=foo/rawdiff&id=$id&id2=$id2" >tmp + id=$(git -C repos/foo rev-parse HEAD) && + id2=$(git -C repos/foo rev-parse HEAD~3) && + cgit_url "foo/rawdiff&id=$id&id2=$id2" >tmp ' test_expect_success 'compare with output of git-diff(1)' ' - git --git-dir="$PWD/repos/foo/.git" diff HEAD~3..HEAD >tmp2 && - strip_headers <tmp >tmp_ && - cmp tmp_ tmp2 + git -C repos/foo diff HEAD~3..HEAD >expect && + strip_headers <tmp >actual && + test_cmp expect actual ' test_expect_success 'a path limits the diff to that file' ' - cgit_query "url=foo/rawdiff/file-4&id=$id&id2=$id2" >tmp && - git --git-dir="$PWD/repos/foo/.git" diff HEAD~3..HEAD -- file-4 >tmp2 && - strip_headers <tmp >tmp_ && - cmp tmp_ tmp2 && - grep "file-4" tmp_ && - ! grep "file-5" tmp_ + cgit_url "foo/rawdiff/file-4&id=$id&id2=$id2" >tmp && + git -C repos/foo diff HEAD~3..HEAD -- file-4 >expect && + strip_headers <tmp >actual && + test_cmp expect actual && + grep "file-4" actual && + ! grep "file-5" actual ' test_done diff --git a/tests/t0109-patch.sh b/tests/t0109-patch.sh index 6840f0c..bc942fe 100755 --- a/tests/t0109-patch.sh +++ b/tests/t0109-patch.sh @@ -2,8 +2,7 @@ # The patch page serves mail ready patches, compared line for line against # git format-patch for a single commit and for a range, once the CGI headers -# are stripped. The last check sets max-patch-count so a range wider than -# the limit comes back cut short. +# are stripped. test_description='Check content on patch page' . ./setup.sh @@ -19,38 +18,22 @@ check_cgit_signature() { } test_expect_success 'generate foo/patch' ' - cgit_query "url=foo/patch" >tmp -' - -test_expect_success 'find `From:` line' ' - grep "^From: " tmp -' - -test_expect_success 'find `Date:` line' ' - grep "^Date: " tmp -' - -test_expect_success 'find `Subject:` line' ' - grep "^Subject: commit 5" tmp -' - -test_expect_success 'find `cgit` signature' ' - check_cgit_signature + cgit_url "foo/patch" >tmp ' test_expect_success 'compare with output of git-format-patch(1)' ' - git --git-dir="$PWD/repos/foo/.git" format-patch --subject-prefix="" \ - --signature="cgit" --stdout HEAD^ >tmp2 && - strip_headers <tmp >tmp_ && - test_cmp tmp_ tmp2 + git -C repos/foo format-patch --subject-prefix="" \ + --signature="cgit" --stdout HEAD^ >expect && + strip_headers <tmp >actual && + test_cmp expect actual ' test_expect_success 'find initial commit' ' - root=$(git --git-dir="$PWD/repos/foo/.git" rev-list --max-parents=0 HEAD) + root=$(git -C repos/foo rev-list --max-parents=0 HEAD) ' test_expect_success 'generate patch for initial commit' ' - cgit_query "url=foo/patch&id=$root" >tmp + cgit_url "foo/patch&id=$root" >tmp ' test_expect_success 'find `cgit` signature on the initial commit' ' @@ -58,20 +41,16 @@ test_expect_success 'find `cgit` signature on the initial commit' ' ' test_expect_success 'generate patches for multiple commits' ' - id=$(git --git-dir="$PWD/repos/foo/.git" rev-parse HEAD) && - id2=$(git --git-dir="$PWD/repos/foo/.git" rev-parse HEAD~3) && - cgit_query "url=foo/patch&id=$id&id2=$id2" >tmp -' - -test_expect_success 'find `cgit` signature on the range' ' - check_cgit_signature + id=$(git -C repos/foo rev-parse HEAD) && + id2=$(git -C repos/foo rev-parse HEAD~3) && + cgit_url "foo/patch&id=$id&id2=$id2" >tmp ' test_expect_success 'compare the range with git-format-patch(1)' ' - git --git-dir="$PWD/repos/foo/.git" format-patch --subject-prefix="" \ - -N --signature="cgit" --stdout HEAD~3..HEAD >tmp2 && - strip_headers <tmp >tmp_ && - test_cmp tmp_ tmp2 + git -C repos/foo format-patch --subject-prefix="" \ + -N --signature="cgit" --stdout HEAD~3..HEAD >expect && + strip_headers <tmp >actual && + test_cmp expect actual ' test_expect_success 'max-patch-count bounds a patch range' ' @@ -81,18 +60,13 @@ test_expect_success 'max-patch-count bounds a patch range' ' echo "repo.url=foo" && echo "repo.path=$PWD/repos/foo/.git" } >patchcountrc && - id=$(git --git-dir="$PWD/repos/foo/.git" rev-parse HEAD) && - root=$(git --git-dir="$PWD/repos/foo/.git" rev-list --max-parents=0 HEAD) && - CGIT_CONFIG="$PWD/patchcountrc" QUERY_STRING="url=foo/patch&id=$id&id2=$root" cgit >tmp && + cgit_rc patchcountrc "url=foo/patch&id=$id&id2=$root" >tmp && test $(grep -c "^From " tmp) -eq 2 ' test_expect_success 'the signature names the version when asked to' ' - { - echo "enable-version-info=1" && - sed "s/^cache-size=.*/cache-size=0/" cgitrc - } >versionrc && - CGIT_CONFIG="$PWD/versionrc" QUERY_STRING="url=foo/patch" cgit >tmp && + cgitrc_copy versionrc enable-version-info=1 && + cgit_rc versionrc "url=foo/patch" >tmp && check_cgit_signature "$CGIT_VERSION" ' diff --git a/tests/t0110-snapshot.sh b/tests/t0110-snapshot.sh index bec0e3d..faebf5e 100755 --- a/tests/t0110-snapshot.sh +++ b/tests/t0110-snapshot.sh @@ -1,38 +1,43 @@ #!/bin/sh # The snapshot page hands a branch back as an archive. One function below -# registers the same checks for every tar based format, and each format +# registers the same checks for every compressed tar format, and each format # needs the same compressor cgit pipes through on the server, so one missing -# tool skips that format on both grounds at once. zip follows at the end, -# since unzip shares no flags with the rest. +# tool skips that format on both grounds at once. Plain tar and zip get checks +# of their own, since cgit runs no compressor for either and unzip shares no +# flags with the rest. test_description='Verify snapshot' . ./setup.sh -# Registers the checks for one tar based format. Takes the archive suffix, -# the content type the response must carry, the compressor to unpack with and -# the name of the prerequisite guarding the checks. +check_snapshot_headers() { + head -n 1 tmp | grep "Status: 200 OK" && + head -n 2 tmp | grep "^Content-Type: $2$" && + head -n 3 tmp | grep "Content-Disposition: inline; filename=.master.$1." +} + +check_snapshot_content() { + ls master/ >output && + test_line_count = 5 output && + grep "^5$" master/file-5 && + test_line_count = 1 master/file-5 +} + +# Registers the checks for one compressed tar format. test_tar_snapshot() { ext=$1 type=$2 tool=$3 prereq=$4 - if command -v "$tool" >/dev/null 2>&1 - then - test_set_prereq $prereq - else - say "Skipping $ext validation tests: $tool not found" - fi + test_lazy_prereq $prereq "command -v $tool" test_expect_success $prereq "get foo/snapshot/master.$ext" " cgit_url 'foo/snapshot/master.$ext' >tmp " test_expect_success $prereq "check $ext response headers" " - head -n 1 tmp | grep 'Status: 200 OK' && - head -n 2 tmp | grep '^Content-Type: $type\$' && - head -n 3 tmp | grep 'Content-Disposition: inline; filename=.master.$ext.' + check_snapshot_headers $ext $type " test_expect_success $prereq "verify $ext format" " @@ -43,10 +48,7 @@ test_tar_snapshot() { test_expect_success $prereq "untar $ext and verify the content" " rm -rf master && '$tool' -dc 'master.$ext' | tar -xf - && - ls master/ >output && - test_line_count = 5 output && - grep '^5\$' master/file-5 && - test_line_count = 1 master/file-5 + check_snapshot_content " } @@ -57,19 +59,14 @@ test_expect_success 'get foo/snapshot/master.tar' ' ' test_expect_success 'check tar response headers' ' - head -n 1 tmp | grep "Status: 200 OK" && - head -n 2 tmp | grep "^Content-Type: application/x-tar$" && - head -n 3 tmp | grep "Content-Disposition: inline; filename=.master.tar." + check_snapshot_headers tar application/x-tar ' test_expect_success 'untar and verify the content' ' strip_headers <tmp >master.tar && rm -rf master && tar -xf master.tar && - ls master/ >output && - test_line_count = 5 output && - grep "^5$" master/file-5 && - test_line_count = 1 master/file-5 + check_snapshot_content ' test_tar_snapshot tar.gz application/gzip gzip GZIP @@ -78,21 +75,12 @@ test_tar_snapshot tar.lz application/x-lzip lzip LZIP test_tar_snapshot tar.xz application/x-xz xz XZ test_tar_snapshot tar.zst application/zstd zstd ZSTD -if command -v unzip >/dev/null 2>&1 -then - test_set_prereq UNZIP -else - say 'Skipping zip validation tests: unzip not found' -fi - test_expect_success 'get foo/snapshot/master.zip' ' cgit_url "foo/snapshot/master.zip" >tmp ' test_expect_success 'check zip response headers' ' - head -n 1 tmp | grep "Status: 200 OK" && - head -n 2 tmp | grep "^Content-Type: application/zip$" && - head -n 3 tmp | grep "Content-Disposition: inline; filename=.master.zip." + check_snapshot_headers zip application/zip ' test_expect_success 'strip off the header lines' ' @@ -100,16 +88,13 @@ test_expect_success 'strip off the header lines' ' ' test_expect_success UNZIP 'verify zip format' ' - unzip -t master.zip + "$GIT_UNZIP" -t master.zip ' test_expect_success UNZIP 'unzip and verify the content' ' rm -rf master && - unzip master.zip && - ls master/ >output && - test_line_count = 5 output && - grep "^5$" master/file-5 && - test_line_count = 1 master/file-5 + "$GIT_UNZIP" master.zip && + check_snapshot_content ' # A tag named v1.0 sheds its v in the snapshot name unless dropping it would @@ -120,17 +105,11 @@ test_expect_success UNZIP 'unzip and verify the content' ' test_expect_success 'set up a repo with a versioned tag' ' mkrepo repos/vtags 2 && git -C repos/vtags update-ref refs/tags/v1.0 HEAD && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "snapshots=tar.gz" && - echo "repo.url=vtags" && - echo "repo.path=$PWD/repos/vtags/.git" - } >vtagsrc + cgitrc_repo vtagsrc vtags snapshots=tar.gz ' test_expect_success 'a loose v tag still sheds its v in the snapshot name' ' - CGIT_CONFIG="$PWD/vtagsrc" QUERY_STRING="url=vtags/refs/" cgit >tmp && + cgit_rc vtagsrc "url=vtags/refs/" >tmp && grep "vtags-1.0.tar.gz" tmp && ! grep "vtags-v1.0.tar.gz" tmp ' @@ -140,7 +119,7 @@ test_expect_success 'a loose v tag still sheds its v in the snapshot name' ' test_expect_success 'a real case collision keeps the v on both tags' ' git -C repos/vtags pack-refs --all && git -C repos/vtags update-ref refs/tags/V1.0 HEAD~1 && - CGIT_CONFIG="$PWD/vtagsrc" QUERY_STRING="url=vtags/refs/" cgit >tmp && + cgit_rc vtagsrc "url=vtags/refs/" >tmp && grep "vtags-v1.0.tar.gz" tmp && grep "vtags-V1.0.tar.gz" tmp && ! grep "vtags-1.0.tar.gz" tmp @@ -151,7 +130,7 @@ test_expect_success 'a real case collision keeps the v on both tags' ' test_expect_success 'a snapshot name with nothing before its suffix is refused' ' git -C repos/vtags branch v HEAD && sed "s/^snapshots=tar.gz$/snapshots=tar/" vtagsrc >vtagstarrc && - CGIT_CONFIG="$PWD/vtagstarrc" QUERY_STRING="url=vtags/snapshot/.tar" cgit >tmp && + cgit_rc vtagstarrc "url=vtags/snapshot/.tar" >tmp && grep "^Status: 404" tmp ' @@ -160,17 +139,47 @@ test_expect_success 'an empty snapshot-prefix puts no slash ahead of the members cat vtagstarrc && echo "repo.snapshot-prefix=" } >noprefixrc && - CGIT_CONFIG="$PWD/noprefixrc" QUERY_STRING="url=vtags/snapshot/vtags.tar&id=master" cgit >tmp && + cgit_rc noprefixrc "url=vtags/snapshot/vtags.tar&id=master" >tmp && strip_headers <tmp >noprefix.tar && tar -tf noprefix.tar >members && grep "^file-1$" members && ! grep "^/" members ' +# A snapshot is named after the last component of the url once its .git +# suffix is gone, so a url of .git alone leaves the ref as the whole name. +test_expect_success 'set up repositories whose urls end in .git' ' + mkrepo repos/dotgit 1 && + git -C repos/dotgit tag v1.0 && + { + echo "virtual-root=/" && + echo "cache-size=0" && + echo "snapshots=tar" && + echo "repo.url=.git" && + echo "repo.path=$PWD/repos/dotgit/.git" && + echo "repo.url=sub/.git" && + echo "repo.path=$PWD/repos/dotgit/.git" + } >dotgitrc +' + +test_expect_success 'a url of .git names a snapshot after the ref alone' ' + cgit_rc dotgitrc "url=.git/refs/" >tmp && + grep ">v1.0.tar<" tmp && + cgit_rc dotgitrc "url=.git/snapshot/v1.0.tar" >tmp && + strip_headers <tmp >dotgit.tar && + tar -tf dotgit.tar >members && + grep "^v1.0/file-1$" members +' + +test_expect_success 'a url ending in /.git names a snapshot after its directory' ' + cgit_rc dotgitrc "url=sub/.git/refs/" >tmp && + grep ">sub-1.0.tar<" tmp +' + # A server may run cgit with no PATH at all, and the compressor is then looked # for on the default path execvp searches. test_expect_success GZIP 'a compressed snapshot is made without a PATH' ' - sed "s/^cache-size=.*/cache-size=0/" cgitrc >nopathrc && + cgitrc_copy nopathrc && cgit_bin=$(command -v cgit) && ( unset PATH && @@ -183,9 +192,9 @@ test_expect_success GZIP 'a compressed snapshot is made without a PATH' ' # A link offers the snapshot of a tag, and the name on it has to lead back to # that tag even when a branch is spelled like the tag's stem. -test_expect_success 'a snapshot name leads to the tag ahead of a branch' ' +test_expect_success GZIP 'a snapshot name leads to the tag ahead of a branch' ' git -C repos/vtags update-ref refs/heads/1.0 $(git -C repos/vtags commit-tree -m shadow HEAD^{tree}) && - CGIT_CONFIG="$PWD/vtagsrc" QUERY_STRING="url=vtags/snapshot/vtags-1.0.tar.gz" cgit >tmp && + cgit_rc vtagsrc "url=vtags/snapshot/vtags-1.0.tar.gz" >tmp && strip_headers <tmp >tag.tar.gz && test "$(gzip -dc tag.tar.gz | git -C repos/vtags get-tar-commit-id)" = "$(git -C repos/vtags rev-parse refs/tags/v1.0^{commit})" ' @@ -198,7 +207,7 @@ test_expect_success 'a signature past max-blob-size is refused' ' echo "max-blob-size=1" && cat vtagsrc } >sigrc && - CGIT_CONFIG="$PWD/sigrc" QUERY_STRING="url=vtags/snapshot/vtags-1.0.tar.gz.asc" cgit >tmp && + cgit_rc sigrc "url=vtags/snapshot/vtags-1.0.tar.gz.asc" >tmp && grep "^Status: 413" tmp ' @@ -213,14 +222,8 @@ test_expect_success 'a snapshot and its signature are linked as one unit' ' sig=$(echo signature | git hash-object -w --stdin) && git notes --ref signatures/tar.gz add -C $sig one ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "snapshots=tar.gz" && - echo "repo.url=signed" && - echo "repo.path=$PWD/repos/signed/.git" - } >signedrc && - CGIT_CONFIG="$PWD/signedrc" QUERY_STRING="url=signed/refs/" cgit >tmp && + cgitrc_repo signedrc signed snapshots=tar.gz && + cgit_rc signedrc "url=signed/refs/" >tmp && grep "<span class=.snapshot.><a href=./signed/snapshot/signed-one.tar.gz.>signed-one.tar.gz</a> (<a href=./signed/snapshot/signed-one.tar.gz.asc.>sig</a>)</span>" tmp ' diff --git a/tests/t0111-atom.sh b/tests/t0111-atom.sh index 48448ef..608ab78 100755 --- a/tests/t0111-atom.sh +++ b/tests/t0111-atom.sh @@ -34,24 +34,13 @@ test_expect_success 'ten entries is the default ceiling' ' ' test_expect_success 'max-atom-items overrides the ceiling' ' - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "max-atom-items=3" && - echo "repo.url=bar" && - echo "repo.path=$PWD/repos/bar/.git" - } >atomrc && - CGIT_CONFIG="$PWD/atomrc" QUERY_STRING="url=bar/atom" cgit >tmp && + cgitrc_repo atomrc bar max-atom-items=3 && + cgit_rc atomrc "url=bar/atom" >tmp && strip_headers <tmp >feed && test $(grep -c "<entry>" feed) -eq 3 ' -if command -v xmllint >/dev/null 2>&1 -then - test_set_prereq XMLLINT -else - say 'Skipping well-formedness check: xmllint not found' -fi +test_lazy_prereq XMLLINT 'command -v xmllint' test_expect_success XMLLINT 'the feed is well-formed xml' ' cgit_url "foo/atom" >tmp && @@ -68,4 +57,10 @@ test_expect_success 'a path with pathspec magic is refused' ' grep "^Status: 200" tmp ' +test_expect_success 'the feed link encodes the repository and the branch' ' + cgitrc_copy feedrc && + CGIT_CONFIG="$PWD/feedrc" HTTP_HOST=example.org QUERY_STRING="url=foo%2Bbar/log/&h=1%2B2" cgit >tmp && + grep "title=.Atom feed. href=.http://example.org/foo%2Bbar/atom/?h=1%2B2." tmp +' + test_done diff --git a/tests/t0112-stats.sh b/tests/t0112-stats.sh index 7b08a6b..722a61c 100755 --- a/tests/t0112-stats.sh +++ b/tests/t0112-stats.sh @@ -28,50 +28,32 @@ test_expect_success 'set up a stats config' ' git commit --allow-empty -m fresh && git commit-graph write --reachable ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "max-stats=year" && - echo "repo.url=stat" && - echo "repo.path=$PWD/repos/pulse/.git" - } >statsrc && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "max-stats=month" && - echo "repo.url=stat" && - echo "repo.path=$PWD/repos/pulse/.git" - } >statmaxrc + cgitrc_repo statsrc stat max-stats=year "repo.path=$PWD/repos/pulse/.git" && + cgitrc_repo statmaxrc stat max-stats=month "repo.path=$PWD/repos/pulse/.git" ' -statq() { CGIT_CONFIG="$PWD/statsrc" QUERY_STRING="$1" cgit; } - test_expect_success 'stats tab appears when enabled' ' - statq "url=stat/" >tmp && + cgit_rc statsrc "url=stat/" >tmp && grep ">stats</a>" tmp ' test_expect_success 'stats page renders the authors table' ' - statq "url=stat/stats/" >tmp && + cgit_rc statsrc "url=stat/stats/" >tmp && grep "<h2>Commits per author" tmp ' -test_expect_success 'no language breakdown is rendered' ' - ! grep -i "language" tmp -' - test_expect_success 'stat options sit above the content' ' tr -d "\n" <tmp | sed "s/<h2>Commits per author.*//" | grep "cgit-panel" ' test_expect_success 'stats below a path stay a normal page' ' - statq "url=stat/stats/file-1" >tmp && + cgit_rc statsrc "url=stat/stats/file-1" >tmp && grep "Status: 200" tmp && grep "<h2>Commits per author" tmp ' test_expect_success 'max-stats=year offers all four periods' ' - statq "url=stat/stats/" >tmp && + cgit_rc statsrc "url=stat/stats/" >tmp && grep "<option value=.w." tmp && grep "<option value=.m." tmp && grep "<option value=.q." tmp && @@ -79,21 +61,52 @@ test_expect_success 'max-stats=year offers all four periods' ' ' test_expect_success 'max-stats bounds the selectable periods' ' - CGIT_CONFIG="$PWD/statmaxrc" QUERY_STRING="url=stat/stats/&period=y" cgit >tmp && + cgit_rc statmaxrc "url=stat/stats/&period=y" >tmp && grep "Statistics type disabled" tmp && - CGIT_CONFIG="$PWD/statmaxrc" QUERY_STRING="url=stat/stats/" cgit >tmp && + cgit_rc statmaxrc "url=stat/stats/" >tmp && ! grep "<option value=.q." tmp ' # Each period buckets commits and labels its columns its own way, so every one # gets a page of its own here. test_expect_success 'every period renders its own columns' ' - statq "url=stat/stats/&period=m" >tmp && + cgit_rc statsrc "url=stat/stats/&period=m" >tmp && grep -E "<th[^>]*>[A-Z][a-z][a-z] [0-9]{4}</th>" tmp && - statq "url=stat/stats/&period=q" >tmp && + cgit_rc statsrc "url=stat/stats/&period=q" >tmp && grep -E "<th[^>]*>Q[1-4] [0-9]{4}</th>" tmp && - statq "url=stat/stats/&period=y" >tmp && + cgit_rc statsrc "url=stat/stats/&period=y" >tmp && grep -E "<th[^>]*>[0-9]{4}</th>" tmp ' +# The yearly window opens on the first of January three years back. cgit reads +# the real clock, so the two commits sit either side of midnight on that day. +test_expect_success 'the window opens at midnight on its first day' ' + first=$(($(date -u +%Y) - 3)) && + test_create_repo repos/edge && + ( + cd repos/edge && + GIT_AUTHOR_NAME="Too Early" \ + GIT_AUTHOR_DATE="$((first - 1))-12-31 23:59:59 +0000" \ + GIT_COMMITTER_DATE="$((first - 1))-12-31 23:59:59 +0000" \ + git commit --allow-empty -m before && + GIT_AUTHOR_NAME="Early Bird" \ + GIT_AUTHOR_DATE="$first-01-01 00:00:00 +0000" \ + GIT_COMMITTER_DATE="$first-01-01 00:00:00 +0000" \ + git commit --allow-empty -m first + ) && + cgitrc_repo edgerc edge max-stats=year && + cgit_rc edgerc "url=edge/stats/&period=y" >tmp && + grep ">Early Bird<" tmp && + ! grep ">Too Early<" tmp +' + +# A path starting with a colon would reach git as pathspec magic, which the +# log, the feed and the patch page refuse as well. +test_expect_success 'a path with pathspec magic is refused' ' + cgit_rc statsrc "url=stat/stats/%3A%28icase%29FILE-1" >tmp && + grep "^Status: 400" tmp && + cgit_rc statsrc "url=stat/stats/file-1" >tmp && + grep "^Status: 200" tmp +' + test_done diff --git a/tests/t0113-tag.sh b/tests/t0113-tag.sh index 2043385..8aea27e 100755 --- a/tests/t0113-tag.sh +++ b/tests/t0113-tag.sh @@ -54,9 +54,7 @@ test_expect_success 'a tag of anything but a commit offers no download' ' ! grep ">download<" tmp && cgit_url "foo/tag/&h=blob-tag" >tmp && grep "tagged object" tmp && - ! grep ">download<" tmp && - cgit_url "foo/tag/&h=v1.0" >tmp && - grep ">download<" tmp + ! grep ">download<" tmp ' # git reads the signature off the end of a tag's message, so a block shaped @@ -82,8 +80,7 @@ test_expect_success 'a signed tag shows its signature in the message' ' ) && cgit_url "foo/tag/&h=signed" >tmp && tr -d "\n" <tmp >flat && - grep "<div class=.commit-msg.>Notes.-----BEGIN PGP SIGNATURE-----iQEzBAABCAAdFiEE-----END PGP SIGNATURE-----</div>" flat && - ! grep "<details" tmp + grep "<div class=.commit-msg.>Notes.-----BEGIN PGP SIGNATURE-----iQEzBAABCAAdFiEE-----END PGP SIGNATURE-----</div>" flat ' test_expect_success 'a tag page names the tag in its title' ' diff --git a/tests/t0201-filters.sh b/tests/t0201-filters.sh index 6d33803..8d8743a 100755 --- a/tests/t0201-filters.sh +++ b/tests/t0201-filters.sh @@ -1,8 +1,8 @@ #!/bin/sh # Checks the source, about, commit and email filters, which hand a piece of -# page content to an outside program before it is written out. The whole -# body runs once per kind of filter, the exec kind always and the lua kind +# page content to an outside program before it is written out. The checks in +# the loop run once per kind of filter, the exec kind always and the lua kind # only where cgit was built with lua, because both have to produce the same # page from the same repository. @@ -21,8 +21,14 @@ do cgit_url 'filter-$prefix/tree/a%2bb' >tmp " + # Page output is buffered and has to be flushed as stdout is handed + # to a filter and back. A missed flush reorders the page rather than + # losing any of it, so the test below watches which side of the + # filtered text the surrounding markup lands on. test_expect_success "check whether the $prefix source filter works" ' - grep "<code>a+b HELLO$" tmp + grep "<code>a+b HELLO$" tmp && + tr -d "\n" <tmp >flat.out && + grep "<code>a+b HELLO</code>" flat.out ' test_expect_success "generate filter-$prefix/about/" " @@ -48,27 +54,11 @@ do test_expect_success "check whether the $prefix email filter works for committers" ' grep "<committer@example.com> commit C O MITTER <COMMITTER@EXAMPLE.COM>" tmp ' - - # Page output is buffered and has to be flushed as stdout is handed - # to a filter and back. A missed flush reorders the page rather than - # losing any of it, so the two tests below watch which side of the - # filtered text the surrounding markup lands on. - test_expect_success "the $prefix source filter output stays inside its cell" " - cgit_url 'filter-$prefix/tree/a%2bb' >tmp && - tr -d '\n' <tmp >flat.out && - grep '<code>a+b HELLO</code>' flat.out - " - - test_expect_success "the $prefix about filter output stays inside its div" " - cgit_url 'filter-$prefix/about/' >tmp && - grep \"<div id='about'>a+b /filter-$prefix/tree/ HELLO\" tmp - " done test_expect_success 'the blame page goes through the source filter too' ' - echo "enable-blame=1" >blamerc && - sed "s/^cache-size=.*/cache-size=0/" cgitrc >>blamerc && - CGIT_CONFIG="$PWD/blamerc" QUERY_STRING="url=filter-exec/blame/a%2bb" cgit >tmp && + cgitrc_copy blamerc enable-blame=1 && + cgit_rc blamerc "url=filter-exec/blame/a%2bb" >tmp && grep "<td class=.linenumbers.>" tmp && grep "<code>a+b HELLO$" tmp ' @@ -76,22 +66,14 @@ test_expect_success 'the blame page goes through the source filter too' ' # A readme read at another branch links into the tree at that branch, which # the filter is told as the query to follow a path with. test_expect_success 'the about filter gets the tree url at the readme branch' ' - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "readme=:a+b" && - echo "about-filter=exec:$FILTER_DIRECTORY/dump.sh" && - echo "root-readme=$PWD/root.txt" && - echo "repo.url=filtered" && - echo "repo.path=$PWD/repos/filter/.git" - } >aboutargsrc && - CGIT_CONFIG="$PWD/aboutargsrc" QUERY_STRING="url=filtered/about/&h=1%2b2" cgit >tmp && + cgitrc_repo aboutargsrc filtered readme=:a+b "about-filter=exec:$FILTER_DIRECTORY/dump.sh" "root-readme=$PWD/root.txt" "repo.path=$PWD/repos/filter/.git" && + cgit_rc aboutargsrc "url=filtered/about/&h=1%2b2" >tmp && grep "<div id=.about.>a+b /filtered/tree/ ?h=1%2B2 HELLO" tmp ' test_expect_success 'the site readme leaves the tree url empty' ' echo site >root.txt && - CGIT_CONFIG="$PWD/aboutargsrc" QUERY_STRING="p=about" cgit >tmp && + cgit_rc aboutargsrc "p=about" >tmp && grep "root.txt SITE" tmp ' @@ -101,15 +83,12 @@ test_expect_success 'only a repository page hands filters its variables' ' echo "repo url: $CGIT_REPO_URL." EOF sed "s|^about-filter=.*|about-filter=exec:$PWD/env.sh|" aboutargsrc >envrc && - CGIT_CONFIG="$PWD/envrc" QUERY_STRING="url=filtered/about/" cgit >tmp && + cgit_rc envrc "url=filtered/about/" >tmp && grep "repo url: filtered\." tmp && - CGIT_CONFIG="$PWD/envrc" QUERY_STRING="p=about" cgit >tmp && + cgit_rc envrc "p=about" >tmp && grep "repo url: \." tmp ' -test "$CGIT_HAS_LUA" -eq 1 && test_set_prereq CGIT_LUA -test "$CGIT_HAS_LUA" -eq 1 || say 'cgit built without lua, error page check skipped' - # A die inside a lua filter has to reach the visitor as an error page rather # than being fed back into the filter that just failed, and the reason goes to # the log. @@ -125,7 +104,7 @@ test_expect_success CGIT_LUA 'a failing lua filter still renders an error page' echo "repo.path=$PWD/repos/foo/.git" && echo "auth-filter=lua:$PWD/broken.lua" } >brokenrc && - CGIT_CONFIG="$PWD/brokenrc" QUERY_STRING="url=foo/commit" cgit >tmp 2>err && + cgit_rc brokenrc "url=foo/commit" >tmp 2>err && grep "Status: 500" tmp && grep "Lua error in" err ' @@ -138,7 +117,7 @@ test_expect_success CGIT_LUA 'a lua filter raising a table is named by type' ' function filter_close() return 0 end EOF sed "s|broken.lua|tabled.lua|" brokenrc >tabledrc && - CGIT_CONFIG="$PWD/tabledrc" QUERY_STRING="url=foo/commit" cgit >tmp 2>err && + cgit_rc tabledrc "url=foo/commit" >tmp 2>err && grep "Status: 500" tmp && grep "Lua error in .*tabled.lua: (error object is a table value)" err ' @@ -158,7 +137,7 @@ test_expect_success CGIT_LUA 'a lua filter that writes while loading still rende echo "repo.path=$PWD/repos/foo/.git" && echo "repo.commit-filter=lua:$PWD/loud.lua" } >loudrc && - CGIT_CONFIG="$PWD/loudrc" QUERY_STRING="url=foo/commit" cgit >tmp && + cgit_rc loudrc "url=foo/commit" >tmp && grep "written while loading" tmp && grep "</html>" tmp ' @@ -184,13 +163,8 @@ test_expect_success CGIT_LUA 'a lua filter can encode an argument and include a end function filter_close() return 0 end EOF - { - echo "cache-size=0" && - echo "repo.url=foo" && - echo "repo.path=$PWD/repos/foo/.git" && - echo "repo.commit-filter=lua:$PWD/apis.lua" - } >apisrc && - CGIT_CONFIG="$PWD/apisrc" QUERY_STRING="url=foo/commit" cgit >tmp && + sed "s|loud.lua|apis.lua|" loudrc >apisrc && + cgit_rc apisrc "url=foo/commit" >tmp && grep "a+b%26c|included <b>bold</b>" tmp ' diff --git a/tests/t0202-submodule-links.sh b/tests/t0202-submodule-links.sh index 6db164f..a5810a0 100755 --- a/tests/t0202-submodule-links.sh +++ b/tests/t0202-submodule-links.sh @@ -11,9 +11,9 @@ test_description='Check submodule links derived from .gitmodules' test_expect_success 'set up a parent repo with submodules' ' mkrepo repos/subtarget 1 && mkrepo repos/subparent 1 && + sub=$(git -C repos/subparent rev-parse HEAD) && ( cd repos/subparent && - sub=$(git rev-parse HEAD) && git update-index --add --cacheinfo 160000,$sub,local-ssh && git update-index --add --cacheinfo 160000,$sub,sibling && git update-index --add --cacheinfo 160000,$sub,forge-https && @@ -55,21 +55,18 @@ test_expect_success 'set up a parent repo with submodules' ' ' test_expect_success 'generate subparent/tree' ' - CGIT_CONFIG="$PWD/subrc" QUERY_STRING="url=subparent/tree/" cgit >tmp + cgit_rc subrc "url=subparent/tree/" >tmp ' test_expect_success 'an ssh url served here links to the local repo' ' - sub=$(git -C repos/subparent rev-parse HEAD~1) && grep "href=./subtarget/tree/?id=$sub.>local-ssh</a>" tmp ' test_expect_success 'its hash links to the local commit page' ' - sub=$(git -C repos/subparent rev-parse HEAD~1) && grep "href=./subtarget/commit/?id=$sub." tmp ' test_expect_success 'a relative url resolves against this repository' ' - sub=$(git -C repos/subparent rev-parse HEAD~1) && grep "href=./subtarget/tree/?id=$sub.>sibling</a>" tmp ' @@ -78,18 +75,15 @@ test_expect_success 'a web url is linked as it is' ' ' test_expect_success 'a known forge hash links to its commit page' ' - sub=$(git -C repos/subparent rev-parse HEAD~1) && grep "href=.https://github.com/example/project/commit/$sub." tmp ' test_expect_success 'an ssh url to a known forge is rewritten' ' - sub=$(git -C repos/subparent rev-parse HEAD~1) && grep "href=.https://github.com/example/other.>forge-ssh</a>" tmp && grep "href=.https://github.com/example/other/commit/$sub." tmp ' test_expect_success 'a cgit host keeps its .git suffix in the commit link' ' - sub=$(git -C repos/subparent rev-parse HEAD~1) && grep "href=.https://git.kernel.org/pub/scm/git/git.git/commit/?id=$sub." tmp ' @@ -99,7 +93,7 @@ test_expect_success 'an unresolvable url stays unlinked with a tooltip' ' test_expect_success 'the links stay off without the flag' ' sed "/enable-gitmodules-links/d" subrc >subrcoff && - CGIT_CONFIG="$PWD/subrcoff" QUERY_STRING="url=subparent/tree/" cgit >tmp && + cgit_rc subrcoff "url=subparent/tree/" >tmp && ! grep "subtarget/tree" tmp && grep "class=.ls-mod.>local-ssh</span>" tmp ' diff --git a/tests/t0203-dates.sh b/tests/t0203-dates.sh index 3c8ecab..5c3567f 100755 --- a/tests/t0203-dates.sh +++ b/tests/t0203-dates.sh @@ -24,8 +24,6 @@ test_expect_success 'set up a dated repo' ' ) ' -# Writes the config afresh, one extra line per argument, which is what lets a -# test below name a setting or two and say nothing about the rest. daterc() { { echo "virtual-root=/" @@ -36,66 +34,64 @@ daterc() { } >daterc } -dateq() { CGIT_CONFIG="$PWD/daterc" QUERY_STRING="$1" cgit; } - test_expect_success 'relative dates are on by default' ' daterc && - dateq "url=dated/refs/" >tmp && + cgit_rc daterc "url=dated/refs/" >tmp && grep "class=.age-" tmp ' test_expect_success 'enable-relative-dates=0 shows a calendar date' ' daterc "enable-relative-dates=0" && - dateq "url=dated/refs/" >tmp && + cgit_rc daterc "url=dated/refs/" >tmp && ! grep "class=.age-" tmp && grep "[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]<" tmp ' test_expect_success 'the index and log columns follow the same flag' ' daterc "enable-relative-dates=0" && - dateq "" >tmp && + cgit_rc daterc "" >tmp && ! grep "class=.age-" tmp && - dateq "url=dated/log/" >tmp && + cgit_rc daterc "url=dated/log/" >tmp && ! grep "class=.age-" tmp ' test_expect_success 'date-format selects the calendar format' ' daterc "enable-relative-dates=0" "date-format=iso8601" && - dateq "url=dated/refs/" >tmp && + cgit_rc daterc "url=dated/refs/" >tmp && grep ">[0-9-]* [0-9][0-9]:[0-9][0-9]:[0-9][0-9] +0000<" tmp ' test_expect_success 'date-format accepts a strftime format' ' daterc "enable-relative-dates=0" "date-format=format:%Y/%m/%d" && - dateq "url=dated/refs/" >tmp && + cgit_rc daterc "url=dated/refs/" >tmp && grep ">[0-9][0-9][0-9][0-9]/[0-9][0-9]/[0-9][0-9]<" tmp ' test_expect_success 'an unknown date-format falls back to short' ' daterc "enable-relative-dates=0" "date-format=nonsense" && - dateq "url=dated/refs/" >tmp && + cgit_rc daterc "url=dated/refs/" >tmp && grep "[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]<" tmp && daterc "enable-relative-dates=0" "date-format=format:" && - dateq "url=dated/refs/" >tmp && + cgit_rc daterc "url=dated/refs/" >tmp && grep "[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]<" tmp ' test_expect_success 'a date-format git would refuse falls back to short' ' daterc "enable-relative-dates=0" "date-format=local-local" && - dateq "url=dated/refs/" >tmp && + cgit_rc daterc "url=dated/refs/" >tmp && grep "^Status: 200" tmp && grep "[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]<" tmp ' test_expect_success 'date-format=local is the default format in local time' ' daterc "enable-relative-dates=0" "date-format=local" && - dateq "url=dated/refs/" >tmp && + cgit_rc daterc "url=dated/refs/" >tmp && grep ">[A-Z][a-z][a-z] [A-Z][a-z][a-z] [0-9]* [0-9:]* [0-9]*<" tmp ' test_expect_success 'date-format still formats the log fallback' ' daterc "date-format=rfc2822" && - dateq "url=dated/log/" >tmp && + cgit_rc daterc "url=dated/log/" >tmp && grep "class=.age-" tmp && grep ">[A-Z][a-z][a-z], [0-9]* Apr 2005 " tmp ' @@ -103,7 +99,7 @@ test_expect_success 'date-format still formats the log fallback' ' test_expect_success 'date-format leaves the commit page on ISO 8601' ' daterc "date-format=rfc2822" && sha=$(git -C repos/dated rev-parse HEAD~1) && - dateq "url=dated/commit/&id=$sha" >tmp && + cgit_rc daterc "url=dated/commit/&id=$sha" >tmp && grep "2005-04-07 [0-9:]* +0000" tmp && ! grep "Apr 2005" tmp ' diff --git a/tests/t0204-limits.sh b/tests/t0204-limits.sh index 2d33368..ecc33bf 100755 --- a/tests/t0204-limits.sh +++ b/tests/t0204-limits.sh @@ -4,17 +4,10 @@ # on the lines and files a diff renders inline, on the size of a blob any # view will inflate, and the clamp on index query length. Crossing one has # to trim the page or point somewhere better suited, never drop the request. -# The last case is the same idea applied to a filter, which degrades to -# escaped text when its highlighting library is absent. test_description='Check the ref listing and diff size limits' . ./setup.sh -if test "$CGIT_HAS_LUA" -eq 1 -then - test_set_prereq LUA -fi - # The limits only show themselves against content that crosses them, so the # fixture carries more branches and tags than max-ref-count allows and a # commit whose oversized file diff sits beside a small one, which is what @@ -36,33 +29,16 @@ test_expect_success 'set up limit fixtures' ' git tag tag-$i || exit 1 done ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "max-ref-count=2" && - echo "max-diff-lines=20" && - echo "max-diff-files=0" && - echo "repo.url=limits" && - echo "repo.path=$PWD/repos/limits/.git" - } >limitrc && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "max-diff-files=1" && - echo "repo.url=limits" && - echo "repo.path=$PWD/repos/limits/.git" - } >limitfilesrc + cgitrc_repo limitrc limits max-ref-count=2 max-diff-lines=20 max-diff-files=0 && + cgitrc_repo limitfilesrc limits max-diff-files=1 ' -limitq() { CGIT_CONFIG="$PWD/limitrc" QUERY_STRING="$1" cgit; } - # An index query is compared against every repository the listing holds, so # an enormous one multiplies out across the whole index. Clamping it rather # than refusing it keeps the cost bounded without making an ordinary search # behave any differently. test_expect_success 'an enormous query is clamped, not rejected' ' long=$(awk "BEGIN{s=\"\";for(i=0;i<4000;i++)s=s \"a\"; print s}") && - test ${#long} -eq 4000 && cgit_query "q=$long" >tmp && grep "</html>" tmp && longest=$(grep -o "aaaa*" tmp | awk "{print length}" | sort -n | tail -1) && @@ -71,7 +47,7 @@ test_expect_success 'an enormous query is clamped, not rejected' ' test_expect_success 'an ordinary query still filters the index' ' cgit_query "q=foo" >tmp && - grep "foo" tmp && + grep ">foo</a>" tmp && ! grep ">bar<" tmp ' @@ -79,7 +55,7 @@ test_expect_success 'an ordinary query still filters the index' ' # hands the overflow to page on their own, so a limit that leaked between the # two sections would show up as the wrong link or the wrong count here. test_expect_success 'refs page lists max-ref-count branches and tags' ' - limitq "url=limits/refs/" >tmp && + cgit_rc limitrc "url=limits/refs/" >tmp && test $(grep -c "log/?h=" tmp) -eq 2 && test $(grep -c "/tag/?h=tag-" tmp) -eq 2 ' @@ -90,16 +66,16 @@ test_expect_success 'refs page links each overflow to its own category' ' ' test_expect_success 'branch page paginates independently' ' - limitq "url=limits/refs/heads/" >tmp && + cgit_rc limitrc "url=limits/refs/heads/" >tmp && test $(grep -c "log/?h=" tmp) -eq 2 && grep "\[next\]" tmp && - limitq "url=limits/refs/heads/&ofs=2" >tmp && + cgit_rc limitrc "url=limits/refs/heads/&ofs=2" >tmp && grep "\[prev\]" tmp && ! grep "/tag/?h=tag-" tmp ' test_expect_success 'tag page paginates independently' ' - limitq "url=limits/refs/tags/&ofs=2" >tmp && + cgit_rc limitrc "url=limits/refs/tags/&ofs=2" >tmp && grep "/tag/?h=tag-" tmp && grep "\[prev\]" tmp && ! grep "log/?h=" tmp @@ -115,8 +91,8 @@ test_expect_success 'a negative ref count lists every ref' ' echo "summary-tags=$n" && sed "s/^max-ref-count=2$/max-ref-count=$n/" limitrc } >count$n.rc && - CGIT_CONFIG="$PWD/count$n.rc" QUERY_STRING="url=limits/refs/" cgit >refs$n && - CGIT_CONFIG="$PWD/count$n.rc" QUERY_STRING="url=limits/" cgit >summary$n || + cgit_rc count$n.rc "url=limits/refs/" >refs$n && + cgit_rc count$n.rc "url=limits/" >summary$n || return 1 done && grep "log/?h=branch-3" refs0 && @@ -130,7 +106,7 @@ test_expect_success 'a negative ref count lists every ref' ' # The rest of the commit still renders, so one huge file cannot hide the # small change beside it. test_expect_success 'oversized file diff is replaced by a link' ' - limitq "url=limits/commit/" >tmp && + cgit_rc limitrc "url=limits/commit/" >tmp && grep "too large to be rendered inline" tmp && grep "View it on its own page" tmp ' @@ -140,7 +116,7 @@ test_expect_success 'small file in the same commit still renders inline' ' ' test_expect_success 'the single-file diff page always renders in full' ' - limitq "url=limits/diff/big.c" >tmp && + cgit_rc limitrc "url=limits/diff/big.c" >tmp && grep "class=.hunk." tmp && ! grep "too large to be rendered inline" tmp ' @@ -149,13 +125,13 @@ test_expect_success 'the single-file diff page always renders in full' ' # so it drops the whole body back to the diffstat while a request naming a # single file stays unaffected. test_expect_success 'a commit changing too many files shows stat only' ' - CGIT_CONFIG="$PWD/limitfilesrc" QUERY_STRING="url=limits/commit/" cgit >tmp && + cgit_rc limitfilesrc "url=limits/commit/" >tmp && grep "too large to be rendered inline" tmp && ! grep "class=.hunk." tmp ' test_expect_success 'the single-file page is not limited by max-diff-files' ' - CGIT_CONFIG="$PWD/limitfilesrc" QUERY_STRING="url=limits/diff/small.c" cgit >tmp && + cgit_rc limitfilesrc "url=limits/diff/small.c" >tmp && grep "class=.hunk." tmp ' @@ -172,15 +148,9 @@ test_expect_success 'a diff of an oversized blob is not inlined' ' awk "BEGIN{for(i=0;i<401;i++)print \"aaaaaaaa\"}" >big.txt && git commit -am change ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "max-blob-size=1" && - echo "repo.url=blobdiff" && - echo "repo.path=$PWD/repos/blobdiff/.git" - } >blobdiffrc && + cgitrc_repo blobdiffrc blobdiff max-blob-size=1 && sha=$(git -C repos/blobdiff rev-parse HEAD) && - CGIT_CONFIG="$PWD/blobdiffrc" QUERY_STRING="url=blobdiff/commit/&id=$sha" cgit >tmp && + cgit_rc blobdiffrc "url=blobdiff/commit/&id=$sha" >tmp && grep "Binary files differ" tmp && ! grep "aaaaaaaa" tmp ' @@ -192,28 +162,12 @@ test_expect_success 'every page showing a blob refuses an oversized one alike' ' } >blobpagesrc && for page in tree plain blob blame do - CGIT_CONFIG="$PWD/blobpagesrc" QUERY_STRING="url=blobdiff/$page/big.txt" cgit >tmp && + cgit_rc blobpagesrc "url=blobdiff/$page/big.txt" >tmp && grep "^Status: 413 Content Too Large" tmp && grep "Blob size (3 KB) exceeds the limit (1 KB)" tmp || return 1 done ' -# A missing scintillua leaves the shipped filter with nothing to highlight -# with, and a filter that failed there would take the whole page down with -# it, so it has to hand the source back escaped instead. -test_expect_success LUA 'highlight filter passes text through without scintillua' ' - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "source-filter=lua:$(cd "$TEST_OUTPUT_DIRECTORY/../custom/extensions" && pwd)/syntax-highlight.lua" && - echo "repo.url=limits" && - echo "repo.path=$PWD/repos/limits/.git" - } >hlrc && - CGIT_SCINTILLUA_PATH=/nonexistent CGIT_CONFIG="$PWD/hlrc" \ - QUERY_STRING="url=limits/tree/small.c" cgit >tmp && - grep "int y;" tmp -' - # The patch and rawdiff pages hand the work to git, whose own limit on an # inlined blob is far past max-blob-size, so the cap is passed through to it. test_expect_success 'patch and rawdiff show an oversized blob as binary' ' @@ -226,7 +180,7 @@ test_expect_success 'patch and rawdiff show an oversized blob as binary' ' } >blobrc && for p in patch rawdiff do - CGIT_CONFIG="$PWD/blobrc" QUERY_STRING="url=limits/$p/" cgit >tmp && + cgit_rc blobrc "url=limits/$p/" >tmp && grep "^Binary files" tmp && ! grep "^+1999$" tmp || return 1 done diff --git a/tests/t0205-config.sh b/tests/t0205-config.sh index 4536e49..32c260e 100755 --- a/tests/t0205-config.sh +++ b/tests/t0205-config.sh @@ -1,43 +1,35 @@ #!/bin/sh -# Checks what the configuration parser reports on stderr. A key it does not -# know, a repo key before any repository and a filter set by a scanned -# repository without trust-scan-config are each ignored with a warning naming -# the key, a repo filter in the main cgitrc passes without one, a repository -# whose path is missing answers 404 on every page, and a config cgit fully -# understands stays silent. +# Checks what the configuration parser reports on stderr. test_description='Check configuration diagnostics' . ./setup.sh test_expect_success 'set up configs' ' - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "no-such-key=1" && - echo "repo.url=diag" && - echo "repo.path=$PWD/repos/foo/.git" && - echo "repo.no-such-repo-key=1" - } >diagrc && + write_script drain-filter <<-\EOF && + cat >/dev/null + EOF + cgitrc_repo diagrc diag no-such-key=1 "repo.path=$PWD/repos/foo/.git" repo.no-such-repo-key=1 && { echo "virtual-root=/" && echo "cache-size=0" && echo "repo.desc=early" && echo "repo.url=diag" && echo "repo.path=$PWD/repos/foo/.git" && - echo "repo.about-filter=exec:/bin/true" + echo "repo.readme=:file-1" && + echo "repo.about-filter=exec:$PWD/drain-filter" } >earlyrc ' test_expect_success 'unknown keys are reported and the page still renders' ' - CGIT_CONFIG="$PWD/diagrc" QUERY_STRING="url=diag/" cgit >tmp 2>err && + cgit_rc diagrc "url=diag/" >tmp 2>err && grep "Status: 200" tmp && grep "Unknown config key: no-such-key" err && grep "Unknown repo config key: no-such-repo-key" err ' test_expect_success 'a repo key before any repo.url is reported' ' - CGIT_CONFIG="$PWD/earlyrc" QUERY_STRING="url=diag/" cgit >tmp 2>err && + cgit_rc earlyrc "url=diag/" >tmp 2>err && grep "Status: 200" tmp && grep "Ignoring repo.desc before any repo.url" err ' @@ -50,7 +42,7 @@ test_expect_success 'a filter in a scanned repository is reported without its fl echo "cache-size=0" && echo "scan-path=$PWD/scan" } >scanrc && - CGIT_CONFIG="$PWD/scanrc" QUERY_STRING="url=foo.git/commit/" cgit >tmp 2>err && + cgit_rc scanrc "url=foo.git/commit/" >tmp 2>err && grep "<div class=.commit-subject.>commit 5" tmp && grep "Ignoring commit-filter in $PWD/scan/foo.git/: trust-scan-config is not set" err ' @@ -60,14 +52,17 @@ test_expect_success 'the flag lets a scanned repository set its filters' ' echo "trust-scan-config=1" && cat scanrc } >scanrc-on && - CGIT_CONFIG="$PWD/scanrc-on" QUERY_STRING="url=foo.git/commit/" cgit >tmp 2>err && + cgit_rc scanrc-on "url=foo.git/commit/" >tmp 2>err && grep "<div class=.commit-subject.>COMMIT 5" tmp && ! grep "Ignoring" err ' +# The filter prints nothing, so an empty about div shows that it ran in place +# of the readme. It reads its input first, so its exit cannot race cgit's +# write. test_expect_success 'a repo filter in the main cgitrc needs no flag' ' - ! grep "about-filter" err && - CGIT_CONFIG="$PWD/earlyrc" QUERY_STRING="url=diag/" cgit >tmp 2>err && + cgit_rc earlyrc "url=diag/about/" >tmp 2>err && + grep "<div id=.about.></div>" tmp && ! grep "about-filter" err ' @@ -82,7 +77,7 @@ test_expect_success 'a repository whose path is missing answers 404' ' mkdir -p gone-cache && for page in "" log/ commit/ "tag/&h=x" atom/ plain/f do - CGIT_CONFIG="$PWD/gonerc" QUERY_STRING="url=gone/$page" cgit >tmp 2>err && + cgit_rc gonerc "url=gone/$page" >tmp 2>err && grep "^Status: 404 Not Found" tmp && grep "Failed to open gone: No such file or directory" tmp || return 1 done && @@ -106,7 +101,7 @@ test_expect_success 'a NUL inside a comment does not end it' ' echo "repo.path=$PWD/repos/foo/.git" } >nulrc && grep -c Smuggled nulrc && - CGIT_CONFIG="$PWD/nulrc" QUERY_STRING="url=" cgit >tmp && + cgit_rc nulrc "url=" >tmp && grep "</html>" tmp && ! grep Smuggled tmp ' diff --git a/tests/t0206-trailers.sh b/tests/t0206-trailers.sh index 61fba94..15c3600 100755 --- a/tests/t0206-trailers.sh +++ b/tests/t0206-trailers.sh @@ -87,7 +87,7 @@ test_expect_success 'a message with no block gets no table' ' test_expect_success 'with the flag off the message stays whole' ' sed "s/^enable-commit-trailers=1/enable-commit-trailers=0/" cgitrc >cgitrc-off && - CGIT_CONFIG="$PWD/cgitrc-off" QUERY_STRING="url=trailers/commit/" cgit >tmp && + cgit_rc cgitrc-off "url=trailers/commit/" >tmp && ! grep "commit-trailers" tmp && grep "Signed-off-by: A U Thor" tmp ' diff --git a/tests/t0208-mailmap.sh b/tests/t0208-mailmap.sh index 86f6d50..9e9ae0f 100755 --- a/tests/t0208-mailmap.sh +++ b/tests/t0208-mailmap.sh @@ -62,9 +62,9 @@ test_expect_success 'the atom feed uses the mapped name and address' ' ' test_expect_success 'the author search matches the mapped name' ' - cgit_query "url=mapped/log/&qt=author&q=New+Name" >tmp && + cgit_url "mapped/log/&qt=author&q=New+Name" >tmp && grep ">first commit</a>" tmp && - cgit_query "url=mapped/log/&qt=author&q=Nobody" >tmp && + cgit_url "mapped/log/&qt=author&q=Nobody" >tmp && ! grep ">first commit</a>" tmp ' @@ -73,7 +73,7 @@ test_expect_success 'with the flag off the old ident shows' ' echo "enable-mailmap=0" && cat cgitrc } >cgitrc-off && - CGIT_CONFIG="$PWD/cgitrc-off" QUERY_STRING="url=mapped/log/" cgit >tmp && + cgit_rc cgitrc-off "url=mapped/log/" >tmp && grep "<td class=.col-author.>Old Name</td>" tmp ' diff --git a/tests/t0301-security.sh b/tests/t0301-security.sh index d9ec265..b62fc62 100755 --- a/tests/t0301-security.sh +++ b/tests/t0301-security.sh @@ -8,15 +8,13 @@ test_description='Check security fixes and fork-specific behaviour' . ./setup.sh -# Most of what follows shares one repository and one config, so the fixture -# carries everything they need at once, a blob over the size limit, readmes -# holding markup that must not reach the page as markup, and a subdirectory -# to sort ahead of the files. +# Some cases below share one repository and one config, so the fixture +# carries everything they need at once, readmes holding markup that must not +# reach the page as markup and a subdirectory to sort ahead of the files. test_expect_success 'set up security fixtures' ' mkrepo repos/sec 1 && ( cd repos/sec && - dd if=/dev/zero bs=1024 count=4 2>/dev/null | tr "\0" "X" >big.txt && printf "# Title\n<script>alert(1)</script>\n" >README.md && printf "<script>alert(2)</script>\n" >readme.txt && printf "top\n" >afile && @@ -25,25 +23,14 @@ test_expect_success 'set up security fixtures' ' git add -A && git commit -m fixtures ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "max-blob-size=1" && - echo "enable-blame=1" && - echo "enable-tree-group-dirs=1" && - echo "repo.url=sec" && - echo "repo.path=$PWD/repos/sec/.git" - } >seccgitrc + cgitrc_repo seccgitrc sec max-blob-size=1 enable-tree-group-dirs=1 ' -secq() { CGIT_CONFIG="$PWD/seccgitrc" QUERY_STRING="$1" cgit; } - # A revision beginning with a dash reaches git as an option rather than as a # tip, so a request for id=--output=<path> could create or truncate any file # the server is able to write. test_expect_success 'log id=--output does not write a file' ' - rm -f pwned && - cgit_query "url=foo/log&id=--output=$PWD/pwned" >tmp 2>&1 && + cgit_url "foo/log&id=--output=$PWD/pwned" >tmp 2>&1 && ! test -e pwned ' @@ -51,34 +38,15 @@ test_expect_success 'log id=--output is rejected as an invalid revision' ' grep -i "invalid revision" tmp ' -test_expect_success 'a normal log still renders' ' - cgit_query "url=foo/log" >tmp && - grep -i "commit 5" tmp -' - test_expect_success 'a valid id= still renders the log' ' sha=$(git -C repos/foo rev-parse HEAD) && - cgit_query "url=foo/log&id=$sha" >tmp && + cgit_url "foo/log&id=$sha" >tmp && grep -i "commit 5" tmp ' -# max-blob-size is enforced before the object is read, so every view that -# would otherwise inline a file has to turn the same one away rather than -# inflate it first and think better of it afterwards. -test_expect_success 'tree view refuses an oversized blob' ' - secq "url=sec/tree/big.txt" | grep -iE "exceeds|too large" -' - -test_expect_success 'plain view refuses an oversized blob' ' - secq "url=sec/plain/big.txt" | grep -iE "exceeds|too large|413" -' - -test_expect_success 'blame view refuses an oversized blob' ' - secq "url=sec/blame/big.txt" | grep -iE "exceeds|too large" -' - test_expect_success 'a small blob is still served' ' - secq "url=sec/plain/afile" | grep -F "top" + cgit_rc seccgitrc "url=sec/plain/afile" >tmp && + grep -F top tmp ' # A readme is repository content, so with no about filter configured it has @@ -90,31 +58,24 @@ test_expect_success 'markdown readme without a filter is escaped as plain text' echo "cache-size=0" && echo "repo.url=md" && echo "repo.path=$PWD/repos/sec/.git" && - echo "repo.readme=master:README.md" - } >secmdrc && - CGIT_CONFIG="$PWD/secmdrc" QUERY_STRING="url=md/about/" cgit >tmp && + echo "repo.readme=master:README.md" && + echo "repo.url=txt" && + echo "repo.path=$PWD/repos/sec/.git" && + echo "repo.readme=master:readme.txt" + } >secreadmerc && + cgit_rc secreadmerc "url=md/about/" >tmp && grep "pre class=.plaintext." tmp && grep "<script>" tmp && ! grep "<script>alert(1)</script>" tmp ' -test_expect_success 'non-markdown readme without a filter is escaped' ' - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=txt" && - echo "repo.path=$PWD/repos/sec/.git" && - echo "repo.readme=master:readme.txt" - } >sectxtrc && - CGIT_CONFIG="$PWD/sectxtrc" QUERY_STRING="url=txt/about/" cgit >tmp && +test_expect_success 'non-markdown readme without a filter is escaped as plain text' ' + cgit_rc secreadmerc "url=txt/about/" >tmp && + grep "pre class=.plaintext." tmp && grep "<script>" tmp && ! grep "<script>alert(2)</script>" tmp ' -test_expect_success 'non-markdown readme keeps its line structure' ' - grep "pre class=.plaintext." tmp -' - # root-readme is the site's own file rather than repository content, so it # goes out as written, on an about page of its own. test_expect_success 'root-readme without a filter is its own page, as written' ' @@ -124,10 +85,10 @@ test_expect_success 'root-readme without a filter is its own page, as written' ' echo "cache-size=0" && echo "root-readme=$PWD/root.html" } >secrootrc && - CGIT_CONFIG="$PWD/secrootrc" QUERY_STRING="url=" cgit >tmp && + cgit_rc secrootrc "url=" >tmp && grep "<li><a title=.About this site. href=./?p=about.>about</a>" tmp && ! grep "<b>site</b>" tmp && - CGIT_CONFIG="$PWD/secrootrc" QUERY_STRING="p=about" cgit >tmp && + cgit_rc secrootrc "p=about" >tmp && grep "^<b>site</b>$" tmp ' @@ -136,7 +97,7 @@ test_expect_success 'root-readme without a filter is its own page, as written' ' # button and its selects carry no handlers at all. test_expect_success 'diff options submit through a button' ' sha=$(git -C repos/foo rev-parse HEAD) && - cgit_query "url=foo/commit&id=$sha" >tmp && + cgit_url "foo/commit&id=$sha" >tmp && grep "type=.submit. value=.reload." tmp && ! grep "onchange" tmp ' @@ -144,7 +105,7 @@ test_expect_success 'diff options submit through a button' ' # Grouping directories ahead of files is behaviour this fork adds, so nothing # upstream covers it. test_expect_success 'tree groups directories before files' ' - secq "url=sec/tree/" >tmp && + cgit_rc seccgitrc "url=sec/tree/" >tmp && dirline=$(grep -n "tree/zsub" tmp | head -1 | cut -d: -f1) && fileline=$(grep -n "tree/afile" tmp | head -1 | cut -d: -f1) && test -n "$dirline" && @@ -153,7 +114,7 @@ test_expect_success 'tree groups directories before files' ' ' # A file name is repository content and may hold a quote, which would break -# out of the href attribute on the line number links of a side by side diff, +# out of the href attribute on the line number links of a side-by-side diff, # so the path is percent-encoded on its way into them. test_expect_success 'ssdiff percent-encodes a quoted file path' ' mkrepo repos/xss 1 && @@ -166,14 +127,9 @@ test_expect_success 'ssdiff percent-encodes a quoted file path' ' printf "a\nc\n" >"$name" && git commit -am change ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=xss" && - echo "repo.path=$PWD/repos/xss/.git" - } >xssrc && + cgitrc_repo xssrc xss && sha=$(git -C repos/xss rev-parse HEAD) && - CGIT_CONFIG="$PWD/xssrc" QUERY_STRING="url=xss/diff/&id=$sha&ss=1" cgit >tmp && + cgit_rc xssrc "url=xss/diff/&id=$sha&ss=1" >tmp && grep "tree/x%27y.txt" tmp && ! grep "href=.[^>]*x.y.txt.[^>]*>" tmp ' @@ -189,15 +145,10 @@ test_expect_success 'a message-less commit renders without crashing' ' cid=$(git hash-object -t commit -w raw) && git update-ref refs/heads/master "$cid" ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=nomsg" && - echo "repo.path=$PWD/repos/nomsg/.git" - } >nomsgrc && - CGIT_CONFIG="$PWD/nomsgrc" QUERY_STRING="url=nomsg/log/&showmsg=1" cgit >tmp && + cgitrc_repo nomsgrc nomsg && + cgit_rc nomsgrc "url=nomsg/log/&showmsg=1" >tmp && grep "no commit message" tmp && - CGIT_CONFIG="$PWD/nomsgrc" QUERY_STRING="url=nomsg/" cgit >tmp && + cgit_rc nomsgrc "url=nomsg/" >tmp && grep "no commit message" tmp ' @@ -218,42 +169,21 @@ test_expect_success 'set up a repo whose branches point at odd objects' ' grep -q "tree.refs/heads/points-at-tree" refs.out && grep -q "tag.refs/heads/points-at-tag" refs.out ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "branch-sort=age" && - echo "repo.url=oddref" && - echo "repo.path=$PWD/repos/oddref/.git" - } >oddrefrc -' - -test_expect_success 'refs page sorts such branches without crashing' ' - CGIT_CONFIG="$PWD/oddrefrc" QUERY_STRING="url=oddref/refs/" cgit >tmp && - grep "points-at-tree" tmp && - grep "</html>" tmp -' - -test_expect_success 'the branch page sorts them without crashing' ' - CGIT_CONFIG="$PWD/oddrefrc" QUERY_STRING="url=oddref/refs/heads/" cgit >tmp && - grep "points-at-tree" tmp && - grep "</html>" tmp + cgitrc_repo oddrefrc oddref branch-sort=age ' -test_expect_success 'the summary page sorts them without crashing' ' - CGIT_CONFIG="$PWD/oddrefrc" QUERY_STRING="url=oddref/" cgit >tmp && - grep "points-at-tree" tmp && - grep "</html>" tmp +test_expect_success 'the refs, branch and summary pages sort such branches' ' + for page in refs/ refs/heads/ "" + do + cgit_rc oddrefrc "url=oddref/$page" >tmp && + grep "points-at-tree" tmp && + grep "</html>" tmp || return 1 + done ' test_expect_success 'name-sorted branches are unaffected' ' - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "branch-sort=name" && - echo "repo.url=oddref" && - echo "repo.path=$PWD/repos/oddref/.git" - } >oddrefnamerc && - CGIT_CONFIG="$PWD/oddrefnamerc" QUERY_STRING="url=oddref/refs/heads/" cgit >tmp && + sed "s/^branch-sort=age$/branch-sort=name/" oddrefrc >oddrefnamerc && + cgit_rc oddrefnamerc "url=oddref/refs/heads/" >tmp && grep "points-at-tree" tmp && grep "</html>" tmp ' @@ -266,9 +196,9 @@ test_expect_success 'name-sorted branches are unaffected' ' # working. test_expect_success 'set up a submodule fixture with a hostile module-link' ' mkrepo repos/modlink 1 && + sub=$(git -C repos/modlink rev-parse HEAD) && ( cd repos/modlink && - sub=$(git rev-parse HEAD) && git update-index --add --cacheinfo 160000,$sub,submod && git commit -m gitlink ) && @@ -284,7 +214,7 @@ test_expect_success 'set up a submodule fixture with a hostile module-link' ' ' test_expect_success 'a surplus conversion does not crash the tree view' ' - CGIT_CONFIG="$PWD/modlinkrc" QUERY_STRING="url=modlink/tree/" cgit >tmp && + cgit_rc modlinkrc "url=modlink/tree/" >tmp && grep "ls-mod" tmp ' @@ -293,41 +223,20 @@ test_expect_success 'a surplus conversion is shown literally, not filled' ' ' test_expect_success 'a well-formed module-link still takes name and sha1' ' - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "module-link=/mod/%s/commit/?id=%s" && - echo "repo.url=modlink" && - echo "repo.path=$PWD/repos/modlink/.git" - } >modlinkokrc && - sub=$(git -C repos/modlink rev-parse HEAD~1) && - CGIT_CONFIG="$PWD/modlinkokrc" QUERY_STRING="url=modlink/tree/" cgit >tmp && + cgitrc_repo modlinkokrc modlink "module-link=/mod/%s/commit/?id=%s" && + cgit_rc modlinkokrc "url=modlink/tree/" >tmp && grep "href=./mod/submod/commit/?id=$sub." tmp ' test_expect_success 'a per-path module-link takes only the sha1' ' - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=modlink" && - echo "repo.path=$PWD/repos/modlink/.git" && - echo "repo.module-link.submod=https://example.com/s/?id=%s" - } >modlinkpathrc && - sub=$(git -C repos/modlink rev-parse HEAD~1) && - CGIT_CONFIG="$PWD/modlinkpathrc" QUERY_STRING="url=modlink/tree/" cgit >tmp && + cgitrc_repo modlinkpathrc modlink "repo.module-link.submod=https://example.com/s/?id=%s" && + cgit_rc modlinkpathrc "url=modlink/tree/" >tmp && grep "href=.https://example.com/s/?id=$sub." tmp ' test_expect_success 'a doubled percent in a module-link renders as one' ' - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "module-link=/m/%s/%%/%s" && - echo "repo.url=modlink" && - echo "repo.path=$PWD/repos/modlink/.git" - } >modlinkpctrc && - sub=$(git -C repos/modlink rev-parse HEAD~1) && - CGIT_CONFIG="$PWD/modlinkpctrc" QUERY_STRING="url=modlink/tree/" cgit >tmp && + cgitrc_repo modlinkpctrc modlink "module-link=/m/%s/%%/%s" && + cgit_rc modlinkpctrc "url=modlink/tree/" >tmp && grep "href=./m/submod/%/$sub." tmp ' @@ -345,7 +254,7 @@ test_expect_success 'a clone endpoint with http clone off answers the same way' echo "enable-http-clone=0" && cat cgitrc } >noclonerc && - CGIT_CONFIG="$PWD/noclonerc" QUERY_STRING="url=foo/info/refs" cgit >tmp && + cgit_rc noclonerc "url=foo/info/refs" >tmp && grep "^Status: 404 Not Found" tmp && grep "<select name=.h. " tmp ' @@ -369,17 +278,11 @@ test_expect_success 'set up a hostile .gitmodules' ' git add .gitmodules && git commit -m hostile ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "enable-gitmodules-links=1" && - echo "repo.url=modlink" && - echo "repo.path=$PWD/repos/modlink/.git" - } >modlinkgmrc + cgitrc_repo modlinkgmrc modlink enable-gitmodules-links=1 ' test_expect_success 'a javascript url never reaches an href' ' - CGIT_CONFIG="$PWD/modlinkgmrc" QUERY_STRING="url=modlink/tree/" cgit >tmp && + cgit_rc modlinkgmrc "url=modlink/tree/" >tmp && ! grep "href=.javascript:" tmp && grep "class=.ls-mod. title=.javascript:alert(1).>submod</span>" tmp ' @@ -412,8 +315,7 @@ test_expect_success 'set up the advisory fixtures' ' ) && mkdir -p readme && printf "readme text\n" >readme/README && - cat >auth.sh <<-\EOF && - #!/bin/sh + write_script auth.sh <<-\EOF && case "$1" in authenticate-post) cat >/dev/null @@ -422,22 +324,11 @@ test_expect_success 'set up the advisory fixtures' ' esac exit 1 EOF - chmod +x auth.sh && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "auth-filter=exec:$PWD/auth.sh" && - echo "repo.url=cve" && - echo "repo.path=$PWD/repos/cve/.git" && - echo "repo.readme=$PWD/readme/README" && - echo "repo.source-filter=exec:$FILTER_DIRECTORY/dump.sh" - } >cverc + cgitrc_repo cverc cve "auth-filter=exec:$PWD/auth.sh" "repo.readme=$PWD/readme/README" "repo.source-filter=exec:$FILTER_DIRECTORY/dump.sh" ' -cveq() { CGIT_CONFIG="$PWD/cverc" QUERY_STRING="$1" cgit; } - test_expect_success 'a newline in a file name cannot split the headers' ' - cveq "url=cve/plain/crlf%0d%0aX-Injected:%201.txt" >tmp && + cgit_rc cverc "url=cve/plain/crlf%0d%0aX-Injected:%201.txt" >tmp && grep "^Status: 200" tmp && ! grep "^X-Injected" tmp && grep "^Content-Disposition: inline; filename=.crlf\\\\r\\\\nX-Injected: 1.txt.$" tmp @@ -451,48 +342,48 @@ test_expect_success 'a posted length past any buffer is clamped' ' ' test_expect_success 'the dumb transport refuses a path that climbs out' ' - cveq "url=cve/objects/../config" >tmp && + cgit_rc cverc "url=cve/objects/../config" >tmp && grep "^Status: 404" tmp && ! grep "repositoryformatversion" tmp && - cveq "url=cve/objects/info/../../config" >tmp && + cgit_rc cverc "url=cve/objects/info/../../config" >tmp && grep "^Status: 404" tmp && ! grep "repositoryformatversion" tmp ' test_expect_success 'the about page serves nothing above the readme directory' ' - cveq "url=cve/about/../cverc" >tmp && + cgit_rc cverc "url=cve/about/../cverc" >tmp && grep "^Status: 404" tmp && ! grep "virtual-root" tmp && - cveq "url=cve/about/" >tmp && + cgit_rc cverc "url=cve/about/" >tmp && grep "readme text" tmp ' test_expect_success 'a commit with an empty author renders everywhere' ' - cveq "url=cve/log/&h=noname" >tmp && + cgit_rc cverc "url=cve/log/&h=noname" >tmp && grep ">empty author</a>" tmp && - cveq "url=cve/commit/&h=noname" >tmp && + cgit_rc cverc "url=cve/commit/&h=noname" >tmp && grep "<div class=.commit-subject.>empty author<" tmp && - cveq "url=cve/atom/&h=noname" >tmp && + cgit_rc cverc "url=cve/atom/&h=noname" >tmp && grep "<name>unknown</name>" tmp && ! grep "<email></email>" tmp ' test_expect_success 'a percent sign without digits behind it is read as itself' ' - cveq "url=cve/log/&q=%" >tmp && + cgit_rc cverc "url=cve/log/&q=%" >tmp && grep "^Status: 200" tmp && - cveq "url=cve/log/&q=%zz%2" >tmp && + cgit_rc cverc "url=cve/log/&q=%zz%2" >tmp && grep "^Status: 200" tmp && grep "value=.%zz%2." tmp ' test_expect_success 'script in a file name is escaped on the diff page' ' - cveq "url=cve/diff/" >tmp && + cgit_rc cverc "url=cve/diff/" >tmp && ! grep "<script>alert" tmp && grep "<script>alert(1)x.txt" tmp ' test_expect_success 'shell syntax in a file name reaches a filter as a plain argument' ' - cveq "url=cve/tree/%24(touch%20pwned).c" >tmp && + cgit_rc cverc "url=cve/tree/%24(touch%20pwned).c" >tmp && grep "^Status: 200" tmp && grep "pwned).c" tmp && ! test -e pwned && diff --git a/tests/t0302-home-access.sh b/tests/t0302-home-access.sh index df55e0d..e6faa45 100755 --- a/tests/t0302-home-access.sh +++ b/tests/t0302-home-access.sh @@ -16,26 +16,15 @@ test_description='Ensure that git does not access $HOME' command -v strace >/dev/null 2>&1 || { skip_all='Skipping access validation tests: strace not found' test_done - exit } strace true 2>/dev/null || { skip_all='Skipping access validation tests: strace not functional' test_done - exit } test_no_home_access() { - # A home that happened to exist would be one git may legitimately - # read, leaving the check below with nothing to catch, so extend the - # path until nothing is there. - missing_home="/path/to/some/place/that/does/not/possibly/exist" - depth=0 - while test -d "$missing_home" - do - depth=$((depth + 1)) - missing_home="$missing_home/$depth" - done && + missing_home=/path/to/some/place/that/does/not/possibly/exist && strace \ -E HOME="$missing_home" \ -E CGIT_CONFIG="$PWD/cgitrc" \ diff --git a/tests/t0303-robustness.sh b/tests/t0303-robustness.sh index 07f7c09..c881045 100755 --- a/tests/t0303-robustness.sh +++ b/tests/t0303-robustness.sh @@ -10,17 +10,9 @@ test_description='Check the audit regressions' test_expect_success 'set up a repository and a config to vary' ' mkrepo repos/rob 3 && sha=$(git -C repos/rob rev-parse HEAD~1) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "snapshots=tar.gz" && - echo "repo.url=rob" && - echo "repo.path=$PWD/repos/rob/.git" - } >robrc + cgitrc_repo robrc rob snapshots=tar.gz ' -robq() { CGIT_CONFIG="$PWD/robrc" QUERY_STRING="$1" cgit; } - test_expect_success 'a repository without a path answers 404' ' { echo "virtual-root=/" && @@ -31,7 +23,7 @@ test_expect_success 'a repository without a path answers 404' ' } >nopathrc && for r in nopath emptypath do - CGIT_CONFIG="$PWD/nopathrc" QUERY_STRING="url=$r/log/" cgit >tmp && + cgit_rc nopathrc "url=$r/log/" >tmp && grep "^Status: 404 Not Found" tmp && grep "Failed to open $r: Not a valid git repository" tmp || return 1 done @@ -42,7 +34,7 @@ test_expect_success 'a repository without a path answers 404' ' test_expect_success 'a repository without a path has no age on the index' ' mkdir -p "(null)/refs/heads" && >"(null)/refs/heads/master" && - CGIT_CONFIG="$PWD/nopathrc" QUERY_STRING="url=" cgit >tmp && + cgit_rc nopathrc "url=" >tmp && grep "</html>" tmp && ! grep "<time " tmp ' @@ -57,12 +49,12 @@ test_expect_success 'a repository with an empty url is skipped with a warning' ' echo "repo.url=rob" && echo "repo.path=$PWD/repos/rob/.git" } >nourlrc && - CGIT_CONFIG="$PWD/nourlrc" QUERY_STRING="url=rob/" cgit >tmp 2>err && + cgit_rc nourlrc "url=rob/" >tmp 2>err && grep "^Status: 200" tmp && grep "Ignoring repository with an empty url" err && - CGIT_CONFIG="$PWD/nourlrc" QUERY_STRING="url=" cgit >tmp && + cgit_rc nourlrc "url=" >tmp && ! grep nourl tmp && - CGIT_CONFIG="$PWD/nourlrc" QUERY_STRING="r=" cgit >tmp && + cgit_rc nourlrc "r=" >tmp && ! grep nourl tmp ' @@ -73,7 +65,7 @@ test_expect_success 'a scan path that is itself a repository is named after it' echo "remove-suffix=1" && echo "scan-path=$PWD/repos/rob" } >rootrc && - CGIT_CONFIG="$PWD/rootrc" QUERY_STRING="url=" cgit >tmp && + cgit_rc rootrc "url=" >tmp && grep "toplevel-repo.><a href=./rob/.>rob</a>" tmp ' @@ -86,7 +78,7 @@ test_expect_success 'a repo key after scan-path is reported instead of applied' echo "scan-path=$PWD/repos" && echo "repo.desc=misplaced" } >stalerc && - CGIT_CONFIG="$PWD/stalerc" QUERY_STRING="url=" cgit >tmp 2>err && + cgit_rc stalerc "url=" >tmp 2>err && ! grep "misplaced" tmp && grep "Ignoring repo.desc before any repo.url" err ' @@ -100,7 +92,7 @@ test_expect_success 'a valueless or broken git config in a scanned repository is echo "enable-git-config=1" && echo "scan-path=$PWD/scan" } >gitcfgrc && - CGIT_CONFIG="$PWD/gitcfgrc" QUERY_STRING="url=" cgit >tmp 2>err && + cgit_rc gitcfgrc "url=" >tmp 2>err && grep "toplevel-repo.><a href=./a.git/.>" tmp && grep "Ignoring unreadable config in $PWD/scan/a.git/config" err ' @@ -113,7 +105,7 @@ test_expect_success 'only descriptive keys are taken from a scanned repository' echo "head-content=<script>alert(1)</script>" && echo "logo-link=javascript:alert(2)" } >scan/b.git/cgitrc && - CGIT_CONFIG="$PWD/gitcfgrc" QUERY_STRING="url=b.git/about/" cgit >tmp 2>err && + cgit_rc gitcfgrc "url=b.git/about/" >tmp 2>err && grep "from the repo" tmp && grep "pre class=.plaintext." tmp && ! grep "alert(" tmp && @@ -123,7 +115,7 @@ test_expect_success 'only descriptive keys are taken from a scanned repository' test_expect_success 'a filesystem readme from a scanned repository is refused' ' echo "readme=/etc/hosts" >scan/b.git/cgitrc && - CGIT_CONFIG="$PWD/gitcfgrc" QUERY_STRING="url=b.git/about/" cgit >tmp 2>err && + cgit_rc gitcfgrc "url=b.git/about/" >tmp 2>err && grep "Ignoring readme in " err ' @@ -132,7 +124,7 @@ test_expect_success 'a filesystem readme from a scanned repository is refused' ' test_expect_success 'a readme ending in a colon is refused like a path on disk' ' echo "outside the repository" >"scan/outside:" && echo "readme=../outside:" >scan/b.git/cgitrc && - CGIT_CONFIG="$PWD/gitcfgrc" QUERY_STRING="url=b.git/about/" cgit >tmp 2>err && + cgit_rc gitcfgrc "url=b.git/about/" >tmp 2>err && grep "Ignoring readme in " err && ! grep "outside the repository" tmp ' @@ -143,7 +135,7 @@ test_expect_success 'a readme set after scan-path reaches a scanned repository' rm scan/b.git/cgitrc && cat gitcfgrc >laterrc && echo "readme=master:file-1" >>laterrc && - CGIT_CONFIG="$PWD/laterrc" QUERY_STRING="url=b.git/about/" cgit >tmp && + cgit_rc laterrc "url=b.git/about/" >tmp && grep "<pre class=.plaintext.>1$" tmp ' @@ -167,7 +159,7 @@ test_expect_success 'html serving from a scanned repository waits on trust' ' echo "mimetype.html=text/html" && echo "scan-path=$PWD/scan2" } >htmlrc && - CGIT_CONFIG="$PWD/htmlrc" QUERY_STRING="url=c.git/plain/page.html" cgit >tmp 2>err && + cgit_rc htmlrc "url=c.git/plain/page.html" >tmp 2>err && grep "^Content-Type: text/plain" tmp && grep "^X-Content-Type-Options: nosniff" tmp && grep "Ignoring enable-html-serving in $PWD/scan2/c.git/: trust-scan-config is not set" err @@ -178,41 +170,46 @@ test_expect_success 'with trust-scan-config the same repository serves html' ' echo "trust-scan-config=1" && cat htmlrc } >htmltrustrc && - CGIT_CONFIG="$PWD/htmltrustrc" QUERY_STRING="url=c.git/plain/page.html" cgit >tmp && + cgit_rc htmltrustrc "url=c.git/plain/page.html" >tmp && grep "^Content-Type: text/html" tmp && ! grep "^X-Content-Type-Options" tmp ' test_expect_success SYMLINKS 'a symlink cycle under the scan path is entered once' ' ln -s . scan/loop && - CGIT_CONFIG="$PWD/gitcfgrc" QUERY_STRING="url=" cgit >tmp && + cgit_rc gitcfgrc "url=" >tmp && test $(grep -c "toplevel-repo" tmp) = 2 ' test_expect_success 'a bare query parameter does not swallow the next one' ' - robq "url=rob/commit/&x&id=$sha" >tmp && + cgit_rc robrc "url=rob/commit/&x&id=$sha" >tmp && grep "<div class=.commit-subject.>commit 2" tmp ' +# Whether cgit first sees the filter's exit status or its closed pipe +# depends on which side runs first, so either message will do. test_expect_success 'a filter that cannot run answers one error page' ' { echo "commit-filter=exec:$PWD/no/such/filter" && cat robrc } >badfilterrc && - CGIT_CONFIG="$PWD/badfilterrc" QUERY_STRING="url=rob/commit/" cgit >tmp 2>err && + cgit_rc badfilterrc "url=rob/commit/" >tmp 2>err && test $(grep -c "^Status:" tmp) = 1 && grep "Unable to complete the request" tmp && - grep "Unable to run filter $PWD/no/such/filter" err && + grep -e "Unable to run filter $PWD/no/such/filter" -e "Unable to write the page" err && grep "Unable to exec filter" err ' +# The filter may exit before or after cgit writes to it, so the page either +# completes or ends where the write failed. Either way cgit answers once and +# exits cleanly rather than dying of SIGPIPE. test_expect_success 'a filter that exits without reading does not end cgit' ' { - echo "email-filter=exec:/usr/bin/true" && + echo "email-filter=exec:true" && cat robrc } >truerc && - CGIT_CONFIG="$PWD/truerc" QUERY_STRING="url=rob/log/" cgit >tmp && - grep "^Status:" tmp + cgit_rc truerc "url=rob/log/" >tmp && + test $(grep -c "^Status:" tmp) = 1 ' test_expect_success 'a commit encoding header is read without its newline' ' @@ -222,11 +219,13 @@ test_expect_success 'a commit encoding header is read without its newline' ' git add file-1 && git -c i18n.commitEncoding=ISO-8859-1 commit -m "$(printf "caf\351")" ) && - robq "url=rob/commit/" >tmp && + cgit_rc robrc "url=rob/commit/" >tmp && grep "<div class=.commit-subject.>caf$(printf "\303\251")<" tmp ' -test_expect_success 'a submodule below the about path does not end the request' ' +# The about page shows the readme alone and its links lead into the tree, +# so a path below it names nothing, the site about page included. +test_expect_success 'a path below the about page answers 404' ' ( cd repos/rob && git update-index --add --cacheinfo 160000,$sha,sub && @@ -237,45 +236,45 @@ test_expect_success 'a submodule below the about path does not end the request' cat robrc && echo "repo.readme=master:file-1" } >aboutrc && - CGIT_CONFIG="$PWD/aboutrc" QUERY_STRING="url=rob/about/sub" cgit >tmp 2>err && - grep "^Status: 404" tmp && - ! grep "fatal" err -' - -test_expect_success 'a missing image on the about page errors as html' ' - CGIT_CONFIG="$PWD/aboutrc" QUERY_STRING="url=rob/about/missing.png" cgit >tmp && - grep "^Status: 404" tmp && - grep "^Content-Type: text/html" tmp + for path in sub sub/ missing.png file-1 ../robrc + do + cgit_rc aboutrc "url=rob/about/$path" >tmp 2>err && + grep "^Status: 404" tmp && + grep "^Content-Type: text/html" tmp && + ! grep fatal err || return 1 + done && + cgit_rc aboutrc "p=about&path=x" >tmp && + grep "^Status: 404" tmp ' test_expect_success 'a snapshot name holding a slash is refused' ' - robq "url=rob/snapshot/:/../../...tar.gz" >tmp && + cgit_rc robrc "url=rob/snapshot/:/../../...tar.gz" >tmp && grep "^Status: 404" tmp && - robq "url=rob/snapshot/rob-master.tar.gz" >tmp && + cgit_rc robrc "url=rob/snapshot/rob-master.tar.gz" >tmp && grep "^Status: 200" tmp ' test_expect_success 'a path opening with a colon is refused on the log and patch pages' ' - robq "url=rob/log/:%25x" >tmp && + cgit_rc robrc "url=rob/log/:%25x" >tmp && grep "^Status: 400" tmp && - robq "url=rob/patch/:%25x" >tmp && + cgit_rc robrc "url=rob/patch/:%25x" >tmp && grep "^Status: 400" tmp && test $(grep -c "^Status:" tmp) = 1 ' test_expect_success 'a revision spelled like an option is refused' ' git -C repos/rob update-ref refs/heads/--stdin HEAD && - robq "url=rob/log/&h=--stdin" >tmp && + cgit_rc robrc "url=rob/log/&h=--stdin" >tmp && grep "^Status: 404" tmp && - robq "url=rob/blame/file-1&id=--stdin" >tmp && + cgit_rc robrc "url=rob/blame/file-1&id=--stdin" >tmp && grep "^Status: 400" tmp && - robq "url=rob/atom/&h=--stdin" >tmp && + cgit_rc robrc "url=rob/atom/&h=--stdin" >tmp && grep "^Status: 404" tmp ' test_expect_success 'the blob page reads a file through an annotated tag' ' git -C repos/rob -c tag.gpgsign=false tag -a -m note ann HEAD && - robq "url=rob/blob/&h=ann&path=file-1" >tmp && + cgit_rc robrc "url=rob/blob/&h=ann&path=file-1" >tmp && strip_headers <tmp >body && printf "more\n" >want && test_cmp want body @@ -285,14 +284,14 @@ test_expect_success 'the blob page reads a file through an annotated tag' ' # blob page used to answer such a request with the raw commit object. test_expect_success 'the blob page walks a path out of the commit or tag the id names' ' tip=$(git -C repos/rob rev-parse HEAD) && - robq "url=rob/blob/file-1&id=$tip" >tmp && + cgit_rc robrc "url=rob/blob/file-1&id=$tip" >tmp && strip_headers <tmp >body && test_cmp want body && tag=$(git -C repos/rob rev-parse ann) && - robq "url=rob/blob/file-1&id=$tag" >tmp && + cgit_rc robrc "url=rob/blob/file-1&id=$tag" >tmp && strip_headers <tmp >body && test_cmp want body && - robq "url=rob/blob/missing&id=$tip" >tmp && + cgit_rc robrc "url=rob/blob/missing&id=$tip" >tmp && grep "^Status: 404" tmp ' @@ -316,31 +315,24 @@ test_expect_success 'the commit page shows a message whole when text follows its echo "enable-commit-trailers=1" && cat robrc } >trailrc && - CGIT_CONFIG="$PWD/trailrc" QUERY_STRING="url=rob/commit/" cgit >tmp && + cgit_rc trailrc "url=rob/commit/" >tmp && ! grep "commit-trailers" tmp && grep "Conflicts:" tmp ' test_expect_success 'a revision expression that walks the history is refused' ' - robq "url=rob/log/&h=:/zzzz" >tmp && + cgit_rc robrc "url=rob/log/&h=:/zzzz" >tmp && grep "^Status: 404" tmp && - robq "url=rob/commit/&id=HEAD^{/zzzz}" >tmp && + cgit_rc robrc "url=rob/commit/&id=HEAD^{/zzzz}" >tmp && grep "^Status: 400" tmp && - robq "url=rob/log/&qt=range&q=:/zzzz" >tmp && + cgit_rc robrc "url=rob/log/&qt=range&q=:/zzzz" >tmp && grep "^Status: 400" tmp && - robq "url=rob/log/&qt=range&q=master~1..master" >tmp && + cgit_rc robrc "url=rob/log/&qt=range&q=master~1..master" >tmp && grep "^Status: 400" tmp && - robq "url=rob/log/&qt=range&q=$sha..master" >tmp && + cgit_rc robrc "url=rob/log/&qt=range&q=$sha..master" >tmp && grep "^Status: 200" tmp ' -test_expect_success 'the log search is literal' ' - robq "url=rob/log/&qt=grep&q=commit.1" >tmp && - ! grep ">commit 1</a>" tmp && - robq "url=rob/log/&qt=grep&q=commit%201" >tmp && - grep ">commit 1</a>" tmp -' - test_expect_success 'a commit with a broken tree line does not crash the blob page' ' git clone -q repos/rob broken && ( @@ -349,20 +341,14 @@ test_expect_success 'a commit with a broken tree line does not crash the blob pa git hash-object -t commit -w --stdin --literally) && echo $bad >.git/refs/heads/bad ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=broken" && - echo "repo.path=$PWD/broken/.git" && - echo "repo.readme=:file-1" - } >brokenrc && - CGIT_CONFIG="$PWD/brokenrc" QUERY_STRING="url=broken/blob/&h=bad&path=file-1" cgit >tmp && + cgitrc_repo brokenrc broken "repo.path=$PWD/broken/.git" repo.readme=:file-1 && + cgit_rc brokenrc "url=broken/blob/&h=bad&path=file-1" >tmp && grep "^Status: 404" tmp ' test_expect_success 'a die inside git answers one 500 page and logs the reason' ' printf "[core\n" >>broken/.git/config && - CGIT_CONFIG="$PWD/brokenrc" QUERY_STRING="url=broken/log/" cgit >tmp 2>err && + cgit_rc brokenrc "url=broken/log/" >tmp 2>err && grep "^Status: 500" tmp && test $(grep -c "^Status:" tmp) = 1 && grep "bad config line" err && @@ -374,17 +360,12 @@ test_expect_success 'a history with a missing parent renders up to the gap' ' older=$(git -C gap rev-parse HEAD~3) && parent=$(git -C gap rev-parse HEAD~2) && rm gap/.git/objects/$(echo $parent | cut -c1-2)/$(echo $parent | cut -c3-) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=gap" && - echo "repo.path=$PWD/gap/.git" - } >gaprc && - CGIT_CONFIG="$PWD/gaprc" QUERY_STRING="url=gap/log/" cgit >tmp 2>err && + cgitrc_repo gaprc gap "repo.path=$PWD/gap/.git" && + cgit_rc gaprc "url=gap/log/" >tmp 2>err && test $(grep -c "^Status:" tmp) = 1 && grep "Unable to complete the request" tmp && grep "Failed to traverse parents" err && - CGIT_CONFIG="$PWD/gaprc" QUERY_STRING="url=gap/patch/&id2=$older" cgit >tmp && + cgit_rc gaprc "url=gap/patch/&id2=$older" >tmp && test $(grep -c "^Status:" tmp) = 1 && grep "^Status: 500" tmp ' @@ -407,20 +388,14 @@ test_expect_success 'a corrupt blob in a collected diff answers the error' ' test_copy_bytes $((size / 2)) <$obj >half && mv half $obj ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=corrupt" && - echo "repo.path=$PWD/corrupt/.git" - } >corruptrc && - CGIT_CONFIG="$PWD/corruptrc" QUERY_STRING="url=corrupt/commit/" cgit >tmp 2>err && + cgitrc_repo corruptrc corrupt "repo.path=$PWD/corrupt/.git" && + cgit_rc corruptrc "url=corrupt/commit/" >tmp 2>err && grep "is corrupt" err && grep "Unable to complete the request" tmp ' test_expect_success 'a filter pipeline sees the default SIGPIPE disposition' ' - cat >pipe.sh <<-\EOF && - #!/bin/sh + write_script pipe.sh <<-\EOF && n=0 while test $n -lt 20000 do @@ -428,12 +403,11 @@ test_expect_success 'a filter pipeline sees the default SIGPIPE disposition' ' n=$((n + 1)) done | head -c 5 EOF - chmod +x pipe.sh && { echo "source-filter=exec:$PWD/pipe.sh" && cat robrc } >piperc && - CGIT_CONFIG="$PWD/piperc" QUERY_STRING="url=rob/tree/file-1" cgit >tmp 2>err && + cgit_rc piperc "url=rob/tree/file-1" >tmp 2>err && grep "^Status: 200" tmp && ! grep -i "broken pipe" err ' @@ -441,7 +415,7 @@ test_expect_success 'a filter pipeline sees the default SIGPIPE disposition' ' test_expect_success 'the fallback branch skips a name spelled like an option' ' git -C repos/rob update-ref refs/heads/-first HEAD && git -C repos/rob symbolic-ref HEAD refs/heads/gone && - robq "url=rob/" >tmp && + cgit_rc robrc "url=rob/" >tmp && git -C repos/rob symbolic-ref HEAD refs/heads/master && grep "^Status: 200" tmp ' @@ -465,18 +439,17 @@ test_expect_success 'a blank line in the mimetype file is skipped' ' echo "mimetype-file=$PWD/mime.types" && cat robrc } >mimerc && - CGIT_CONFIG="$PWD/mimerc" QUERY_STRING="url=rob/plain/note.cgt" cgit >tmp && + cgit_rc mimerc "url=rob/plain/note.cgt" >tmp && grep "^Content-Type: image/x-cgit" tmp ' test_expect_success 'an indented comment in the mimetype file is skipped' ' printf " # cgt used to be image/x-old\nimage/x-cgit cgt\n" >mime.types && - CGIT_CONFIG="$PWD/mimerc" QUERY_STRING="url=rob/plain/note.cgt" cgit >tmp && + cgit_rc mimerc "url=rob/plain/note.cgt" >tmp && grep "^Content-Type: image/x-cgit" tmp ' test_expect_success 'a refs page named with an id is not cached for ever' ' - mkdir -p cache && { echo "cache-size=10" && echo "cache-root=$PWD/cache" && @@ -486,9 +459,9 @@ test_expect_success 'a refs page named with an id is not cached for ever' ' grep -v "^cache-size" robrc } >ttlrc && full=$(git -C repos/rob rev-parse HEAD) && - CGIT_CONFIG="$PWD/ttlrc" QUERY_STRING="url=rob/refs/&id=$full" cgit >tmp && + cgit_rc ttlrc "url=rob/refs/&id=$full" >tmp && git -C repos/rob tag later HEAD && - CGIT_CONFIG="$PWD/ttlrc" QUERY_STRING="url=rob/refs/&id=$full" cgit >tmp && + cgit_rc ttlrc "url=rob/refs/&id=$full" >tmp && grep ">later<" tmp ' @@ -507,15 +480,15 @@ test_expect_success 'the diff caps hold when the path names a directory' ' echo "max-diff-files=2" && cat robrc } >caprc && - CGIT_CONFIG="$PWD/caprc" QUERY_STRING="url=rob/diff/dir" cgit >tmp && + cgit_rc caprc "url=rob/diff/dir" >tmp && grep "too large to be rendered inline" tmp && - CGIT_CONFIG="$PWD/caprc" QUERY_STRING="url=rob/diff/dir/f1" cgit >tmp && + cgit_rc caprc "url=rob/diff/dir/f1" >tmp && ! grep "too large to be rendered inline" tmp ' test_expect_success 'a snapshot of a tag spelled like an option is archived' ' git -C repos/rob update-ref refs/tags/-l HEAD && - robq "url=rob/snapshot/-l.tar.gz" >tmp && + cgit_rc robrc "url=rob/snapshot/-l.tar.gz" >tmp && grep "^Status: 200" tmp && strip_headers <tmp | gzip -dc | tar -tf - >list && grep "file-1" list @@ -542,9 +515,9 @@ test_expect_success 'a disk readme in the repository directory serves no sibling cat robrc && echo "repo.readme=README" } >diskrc && - CGIT_CONFIG="$PWD/diskrc" QUERY_STRING="url=rob/about/" cgit >tmp && + cgit_rc diskrc "url=rob/about/" >tmp && grep "hello" tmp && - CGIT_CONFIG="$PWD/diskrc" QUERY_STRING="url=rob/about/config" cgit >tmp && + cgit_rc diskrc "url=rob/about/config" >tmp && grep "^Status: 404" tmp && ! grep "repositoryformatversion" tmp ' @@ -552,23 +525,18 @@ test_expect_success 'a disk readme in the repository directory serves no sibling test_expect_success 'a tag pointing at a tag links the inner tag by id' ' git -C repos/rob -c tag.gpgsign=false tag -a -m outer outer ann && inner=$(git -C repos/rob rev-parse ann) && - robq "url=rob/tag/&id=$inner" >tmp && + cgit_rc robrc "url=rob/tag/&id=$inner" >tmp && grep "^Status: 200" tmp && grep "tagged object" tmp ' test_expect_success 'the dumb transport serves an empty repository' ' git init -q --bare empty.git && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=empty" && - echo "repo.path=$PWD/empty.git" - } >emptyrc && - CGIT_CONFIG="$PWD/emptyrc" QUERY_STRING="url=empty/info/refs" cgit >tmp && + cgitrc_repo emptyrc empty "repo.path=$PWD/empty.git" && + cgit_rc emptyrc "url=empty/info/refs" >tmp && grep "^Status: 200" tmp && grep "^Content-Type: text/plain" tmp && - CGIT_CONFIG="$PWD/emptyrc" QUERY_STRING="url=empty/objects/info" cgit >tmp && + cgit_rc emptyrc "url=empty/objects/info" >tmp && grep "^Status: 404" tmp ' @@ -576,10 +544,10 @@ test_expect_success 'the dumb transport serves an empty repository' ' # its size up front and compare what arrived against it. test_expect_success 'the dumb transport sends a file with its size' ' git -C repos/rob repack -q -d && - robq "url=rob/objects/info/packs" >tmp && + cgit_rc robrc "url=rob/objects/info/packs" >tmp && pack=$(strip_headers <tmp | sed -n "s/^P //p" | head -1) && test -n "$pack" && - robq "url=rob/objects/pack/$pack" >tmp && + cgit_rc robrc "url=rob/objects/pack/$pack" >tmp && grep "^Status: 200" tmp && size=$(wc -c <repos/rob/.git/objects/pack/$pack | tr -d " ") && grep "^Content-Length: $size$" tmp && @@ -595,43 +563,38 @@ test_expect_success 'the dumb transport withholds the alternates file' ' for path in info/alternates info//alternates info/./alternates ./info/alternates \ info/Alternates /info/alternates do - robq "url=rob/objects/$path" >tmp && + cgit_rc robrc "url=rob/objects/$path" >tmp && grep "^Status: 404" tmp && ! grep "repos/foo" tmp || return 1 done && rm repos/rob/.git/objects/info/alternates && - robq "url=rob/objects/info/packs" >tmp && + cgit_rc robrc "url=rob/objects/info/packs" >tmp && grep "^Status: 200" tmp ' # A reftable repository keeps only a placeholder in its HEAD file, so the # branch a client is to check out has to come from the refs instead. test_expect_success 'the dumb transport answers HEAD from the refs' ' - robq "url=rob/HEAD" >tmp && + cgit_rc robrc "url=rob/HEAD" >tmp && grep "^Status: 200" tmp && strip_headers <tmp >head && echo "ref: refs/heads/master" >expect && test_cmp expect head && git init -q --bare --ref-format=reftable -b trunk rt.git && git -C repos/rob push -q "$PWD/rt.git" master:trunk && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=rt" && - echo "repo.path=$PWD/rt.git" - } >rtrc && - CGIT_CONFIG="$PWD/rtrc" QUERY_STRING="url=rt/HEAD" cgit >tmp && + cgitrc_repo rtrc rt "repo.path=$PWD/rt.git" && + cgit_rc rtrc "url=rt/HEAD" >tmp && strip_headers <tmp >head && echo "ref: refs/heads/trunk" >expect && test_cmp expect head && - CGIT_CONFIG="$PWD/emptyrc" QUERY_STRING="url=empty/HEAD" cgit >tmp && + cgit_rc emptyrc "url=empty/HEAD" >tmp && strip_headers <tmp >head && echo "ref: refs/heads/master" >expect && test_cmp expect head ' test_expect_success 'a reftable repository shows its age on the index' ' - CGIT_CONFIG="$PWD/rtrc" QUERY_STRING="url=" cgit >tmp && + cgit_rc rtrc "url=" >tmp && grep "<td class=.col-idle.><time " tmp ' @@ -645,7 +608,7 @@ test_expect_success 'a client that disconnects ends the request quietly' ' git add long.txt && git commit -m long ) && - { robq "url=rob/tree/long.txt" 2>err; echo $? >status; } | head -c 1 >/dev/null && + { cgit_rc robrc "url=rob/tree/long.txt" 2>err; echo $? >status; } | head -c 1 >/dev/null && test "$(cat status)" = 0 && ! grep -i "broken pipe" err && ! grep "die()" err @@ -655,20 +618,18 @@ test_expect_success 'a client that disconnects ends the request quietly' ' # shared with other visitors has to be told, while a site without a filter # keeps its pages free of any such header. test_expect_success 'pages behind an auth filter are marked private' ' - cat >letin.sh <<-\EOF && - #!/bin/sh + write_script letin.sh <<-\EOF && exit 1 EOF - chmod +x letin.sh && { echo "auth-filter=exec:$PWD/letin.sh" && cat robrc } >letinrc && - CGIT_CONFIG="$PWD/letinrc" QUERY_STRING="url=rob/log/" cgit >tmp && + cgit_rc letinrc "url=rob/log/" >tmp && grep "^Status: 200" tmp && grep "^Cache-Control: private$" tmp && grep "^Vary: Cookie$" tmp && - robq "url=rob/log/" >tmp && + cgit_rc robrc "url=rob/log/" >tmp && ! grep "^Cache-Control" tmp && ! grep "^Vary" tmp ' @@ -677,10 +638,10 @@ test_expect_success 'pages behind an auth filter are marked private' ' # the branch asked for has to survive that hop. test_expect_success 'the about redirect keeps the query string' ' git -C repos/rob branch other HEAD && - robq "url=rob/about&h=other&x=1" >tmp && + cgit_rc robrc "url=rob/about&h=other&x=1" >tmp && grep "^Status: 302" tmp && grep "^Location: /rob/?h=other&x=1$" tmp && - CGIT_CONFIG="$PWD/aboutrc" QUERY_STRING="url=rob/about&h=other" cgit >tmp && + cgit_rc aboutrc "url=rob/about&h=other" >tmp && grep "^Status: 200" tmp ' @@ -692,9 +653,9 @@ test_expect_success 'the readme follows an annotated tag as the head' ' cat robrc && echo "repo.readme=:file-1" } >tagreadmerc && - CGIT_CONFIG="$PWD/tagreadmerc" QUERY_STRING="url=rob/about/&h=v1" cgit >tmp && + cgit_rc tagreadmerc "url=rob/about/&h=v1" >tmp && grep "^Status: 200" tmp && - CGIT_CONFIG="$PWD/tagreadmerc" QUERY_STRING="url=rob/tree/&h=v1" cgit >tmp && + cgit_rc tagreadmerc "url=rob/tree/&h=v1" >tmp && grep ">about<" tmp ' @@ -704,18 +665,6 @@ test_expect_success 'the about redirect encodes the repository url once' ' grep "^Location: /foo%2Bbar/$" tmp ' -# The about page shows the readme alone and its links lead into the tree, -# so a path below it names nothing, the site about page included. -test_expect_success 'a path below the about page answers 404' ' - for path in file-1 sub/ ../robrc - do - CGIT_CONFIG="$PWD/aboutrc" QUERY_STRING="url=rob/about/$path" cgit >tmp && - grep "^Status: 404" tmp || return 1 - done && - CGIT_CONFIG="$PWD/aboutrc" QUERY_STRING="p=about&path=x" cgit >tmp && - grep "^Status: 404" tmp -' - test_expect_success 'a symlink whose target is a large blob is listed without it' ' big=$(head -c 5000 /dev/zero | tr "\0" a | git -C repos/rob hash-object -w --stdin) && ( @@ -723,7 +672,7 @@ test_expect_success 'a symlink whose target is a large blob is listed without it git update-index --add --cacheinfo 120000,$big,biglink && git commit -m biglink ) && - robq "url=rob/tree/" >tmp && + cgit_rc robrc "url=rob/tree/" >tmp && grep "biglink" tmp && ! grep "aaaaaaaaaa" tmp ' @@ -737,7 +686,7 @@ test_expect_success 'a chain of single directories is followed a bounded number git add d && git commit -m deep ) && - robq "url=rob/tree/" >tmp && + cgit_rc robrc "url=rob/tree/" >tmp && test $(grep -o "class=.ls-dir." tmp | wc -l) -le 17 ' @@ -752,7 +701,7 @@ test_expect_success 'an unreadable project list is named with its error' ' echo "project-list=$PWD/projdir" && echo "scan-path=$PWD/repos" } >projrc && - CGIT_CONFIG="$PWD/projrc" QUERY_STRING="url=" cgit >tmp 2>err && + cgit_rc projrc "url=" >tmp 2>err && grep "project list .*projdir: Is a directory" err ' @@ -765,7 +714,7 @@ test_expect_success SANITY 'a scan entry that cannot be searched is named in ful echo "cache-size=0" && echo "scan-path=$PWD/scanperm" } >scanpermrc && - CGIT_CONFIG="$PWD/scanpermrc" QUERY_STRING="url=" cgit >tmp 2>err && + cgit_rc scanpermrc "url=" >tmp 2>err && grep "Unable to stat .*scanperm/locked/objects: Permission denied" err ' @@ -780,7 +729,7 @@ test_expect_success 'a readme past max-blob-size is reported on the about page' cat robrc && echo "repo.readme=:big-readme" } >bigreadmerc && - CGIT_CONFIG="$PWD/bigreadmerc" QUERY_STRING="url=rob/about/" cgit >tmp && + cgit_rc bigreadmerc "url=rob/about/" >tmp && grep "The readme is larger than max-blob-size (1 KB)" tmp ' @@ -788,10 +737,9 @@ test_expect_success 'a readme past max-blob-size is reported on the about page' # that was just refused must not be looked up to get there, since the wider # revision syntax walks the whole history for a match. test_expect_success 'the error page does not resolve the id it refused' ' - sha=$(git -C repos/rob rev-parse HEAD~1) && - robq "url=rob/log/&id=$sha" >tmp && + cgit_rc robrc "url=rob/log/&id=$sha" >tmp && grep "<title>.* @ " tmp && - robq "url=rob/log/&id=%3A/commit" >tmp && + cgit_rc robrc "url=rob/log/&id=%3A/commit" >tmp && grep "^Status: 400" tmp && ! grep "<title>.* @ " tmp ' @@ -816,22 +764,17 @@ test_expect_success 'odd dates and zones in pushed objects render' ' git hash-object -t commit -w --literally --stdin) && git update-ref refs/heads/zone $zone ) && - { - echo "virtual-root=/" && - echo "cache-size=0" && - echo "repo.url=dates" && - echo "repo.path=$PWD/repos/dates/.git" - } >datesrc && + cgitrc_repo datesrc dates && for q in "url=dates/" "url=dates/refs/" "url=dates/tag/&h=early" \ "url=dates/log/&h=far" "url=dates/commit/&h=far" "url=dates/commit/&h=zone" \ "url=dates/atom/&h=far" do - CGIT_CONFIG="$PWD/datesrc" QUERY_STRING="$q" cgit >tmp 2>err && + cgit_rc datesrc "$q" >tmp 2>err && grep "^Status: 200" tmp && ! grep "Unable to complete" tmp && ! grep "Timestamp" err || return 1 done && - CGIT_CONFIG="$PWD/datesrc" QUERY_STRING="url=dates/commit/&h=far" cgit >tmp && + cgit_rc datesrc "url=dates/commit/&h=far" >tmp && grep "1970-01-01" tmp ' @@ -848,7 +791,7 @@ test_expect_success 'a notes ref that names no tree is passed over' ' for q in "url=dates/" "url=dates/log/" "url=dates/refs/" "url=dates/tree/" \ "url=dates/snapshot/dates-base.tar.asc" do - CGIT_CONFIG="$PWD/notesrc" QUERY_STRING="$q" cgit >tmp 2>err && + cgit_rc notesrc "$q" >tmp 2>err && ! grep "Unable to complete" tmp && ! grep "notes tree" err || return 1 done && @@ -861,7 +804,7 @@ test_expect_success 'a scanned defbranch cannot name a path outside the reposito mkdir -p secret && touch -t 200001010000 secret/file && echo "defbranch=../../../../secret/file" >scan/b.git/cgitrc && - CGIT_CONFIG="$PWD/gitcfgrc" QUERY_STRING="url=" cgit >tmp && + cgit_rc gitcfgrc "url=" >tmp && grep ">b.git</a>" tmp && ! grep "data-ut=.946" tmp ' diff --git a/tests/t0401-serve.sh b/tests/t0401-serve.sh index 5a2b9b6..341bbb6 100755 --- a/tests/t0401-serve.sh +++ b/tests/t0401-serve.sh @@ -1,11 +1,9 @@ #!/bin/sh # Checks how tools/serve.py, the preview server that runs the built binary and -# hands its output back over HTTP, tells the CGI headers from the body. That -# split once keyed on the wrong blank line and swallowed the document head, -# which left a page that still validated but arrived with no stylesheet and no -# chrome. The cases live in serve-split-check.py beside this file, and nothing -# here touches a repository, so the fixtures stay unbuilt. +# hands its output back over HTTP, tells the CGI headers from the body. The +# cases live in serve-split-check.py beside this file, and nothing here touches +# a repository, so the fixtures stay unbuilt. test_description='Check the preview server splits CGI output correctly' CGIT_TEST_NO_CREATE_REPOS=YesPlease @@ -14,7 +12,6 @@ CGIT_TEST_NO_CREATE_REPOS=YesPlease command -v python3 >/dev/null 2>&1 || { skip_all='Skipping preview server tests: python3 not found' test_done - exit } test_expect_success 'CGI headers and body are split at the first blank line' ' diff --git a/tests/t0501-about-render.sh b/tests/t0501-about-render.sh index 339c5e2..e4eaf5c 100755 --- a/tests/t0501-about-render.sh +++ b/tests/t0501-about-render.sh @@ -13,10 +13,6 @@ CGIT_TEST_NO_CREATE_REPOS=YesPlease . ./setup.sh . "$TEST_OUTPUT_DIRECTORY/extensions/lib.sh" -interpreters=$(ext_lua_interpreters 5) -test -z "$interpreters" && - say 'no standalone lua on the path, unit checks skipped' - for lua in $interpreters do test_expect_success "unit checks under $lua" " @@ -77,18 +73,13 @@ test_expect_success 'point cgit at it through the about filter' ' EOF ' -if test "$CGIT_HAS_LUA" -eq 1 +if cgit_lua_probe "$PWD/repos/md/.git" lpeg +then + test_set_prereq CGIT_LUA_LPEG +elif test "$CGIT_HAS_LUA" -eq 1 then - test_set_prereq CGIT_LUA - if cgit_lua_probe "$PWD/repos/md/.git" lpeg - then - test_set_prereq CGIT_LUA_LPEG - else - test_set_prereq CGIT_LUA_NOLPEG - say 'cgit lua has no lpeg, expecting the plain text fallback' - fi -else - say 'cgit built without lua, filter checks through cgit skipped' + test_set_prereq CGIT_LUA_NOLPEG + say 'cgit lua has no lpeg, expecting the plain text fallback' fi test_expect_success CGIT_LUA_LPEG 'the about page renders the markdown' ' @@ -109,13 +100,13 @@ test_expect_success CGIT_LUA_LPEG 'a relative link leads into the tree' ' ' test_expect_success CGIT_LUA_LPEG 'the link stays on the branch shown' ' - cgit_query "url=md/about/&h=next" >tmp && + cgit_url "md/about/&h=next" >tmp && grep "<a href=./md/tree/ROADMAP.md?h=next#plan.>roadmap</a>" tmp ' test_expect_success CGIT_LUA_LPEG 'the about page renders a man page' ' cgit_url "man/about/" >tmp && - grep "<div class=.markdown manpage.><h2 id=.name.>NAME</h2>" tmp && + grep "<div class=.markdown.><h2 id=.name.>NAME</h2>" tmp && grep "tool - does one thing — well" tmp && grep "<strong>-v</strong><br>" tmp && grep "<pre><code>tool -v <file>" tmp && diff --git a/tests/t0502-syntax-highlight.sh b/tests/t0502-syntax-highlight.sh index ced9edd..2ca3c9f 100755 --- a/tests/t0502-syntax-highlight.sh +++ b/tests/t0502-syntax-highlight.sh @@ -7,17 +7,14 @@ # fake lexers found and once with an empty directory so the escaped fallback # is what everything renders through. The run through cgit itself places the # fake lexers beside the config file, which is the probe the filter documents -# for a scintillua directory next to cgitrc. +# for a scintillua directory next to cgitrc, and then points the lexer path +# at nothing to see the page fall back to escaped text. test_description='Check the shipped syntax-highlight extension' CGIT_TEST_NO_CREATE_REPOS=YesPlease . ./setup.sh . "$TEST_OUTPUT_DIRECTORY/extensions/lib.sh" -interpreters=$(ext_lua_interpreters 5) -test -z "$interpreters" && - say 'no standalone lua on the path, unit checks skipped' - for lua in $interpreters do test_expect_success "highlighting checks under $lua" " @@ -34,10 +31,6 @@ do " done -test "$CGIT_HAS_LUA" -eq 1 && test_set_prereq CGIT_LUA -test "$CGIT_HAS_LUA" -eq 1 || - say 'cgit built without lua, filter checks through cgit skipped' - test_expect_success 'create a repository holding a lexable file' ' test_create_repo repos/fake && ( @@ -70,6 +63,16 @@ test_expect_success CGIT_LUA 'bytes between tokens reach the page escaped' ' grep "<span class=.hl-number.>42</span> & " tmp ' +# A missing scintillua leaves the filter with nothing to highlight with, and +# a filter that failed there would take the whole page down with it, so it +# has to hand the source back escaped instead. +test_expect_success CGIT_LUA 'without scintillua the source is served escaped' ' + CGIT_SCINTILLUA_PATH=/nonexistent CGIT_CONFIG="$PWD/cgitrc" \ + QUERY_STRING="url=fake/tree/hello.fake" cgit >tmp && + grep "if 42 & zz" tmp && + ! grep "hl-keyword" tmp +' + test_expect_success CGIT_LUA 'a lone carriage return reaches the page as an escape' ' ( cd repos/fake && diff --git a/tests/t0503-link-commits.sh b/tests/t0503-link-commits.sh index e6e8816..d38093a 100755 --- a/tests/t0503-link-commits.sh +++ b/tests/t0503-link-commits.sh @@ -11,10 +11,6 @@ CGIT_TEST_NO_CREATE_REPOS=YesPlease . ./setup.sh . "$TEST_OUTPUT_DIRECTORY/extensions/lib.sh" -interpreters=$(ext_lua_interpreters 5) -test -z "$interpreters" && - say 'no standalone lua on the path, unit checks skipped' - for lua in $interpreters do test_expect_success "unit checks under $lua" " @@ -22,10 +18,6 @@ do " done -test "$CGIT_HAS_LUA" -eq 1 && test_set_prereq CGIT_LUA -test "$CGIT_HAS_LUA" -eq 1 || - say 'cgit built without lua, filter checks through cgit skipped' - test_expect_success 'create a repository whose commit message holds matches' ' test_create_repo repos/linky && ( diff --git a/tests/t0504-email-avatar.sh b/tests/t0504-email-avatar.sh index ec1f5da..60db6d6 100755 --- a/tests/t0504-email-avatar.sh +++ b/tests/t0504-email-avatar.sh @@ -14,10 +14,6 @@ CGIT_TEST_NO_CREATE_REPOS=YesPlease . ./setup.sh . "$TEST_OUTPUT_DIRECTORY/extensions/lib.sh" -interpreters=$(ext_lua_interpreters 5) -test -z "$interpreters" && - say 'no standalone lua on the path, unit checks skipped' - for lua in $interpreters do for script in email-gravatar email-libravatar @@ -48,18 +44,14 @@ test_expect_success 'create a repository with one commit' ' ) ' -if test "$CGIT_HAS_LUA" -eq 1 && - cgit_lua_probe "$PWD/repos/avatar/.git" openssl.digest +if cgit_lua_probe "$PWD/repos/avatar/.git" openssl.digest then test_set_prereq CGIT_LUA_OSSL elif test "$CGIT_HAS_LUA" -eq 1 then test_set_prereq CGIT_LUA_NOOSSL say 'cgit lua has no luaossl, expecting names without icons' -else - say 'cgit built without lua, filter checks through cgit skipped' fi -test "$CGIT_HAS_LUA" -eq 1 && test_set_prereq CGIT_LUA test_expect_success CGIT_LUA 'point cgit at it through the email filter' ' cat >cgitrc <<-EOF diff --git a/tests/t0505-auth.sh b/tests/t0505-auth.sh index be0f0ce..7dd4a36 100755 --- a/tests/t0505-auth.sh +++ b/tests/t0505-auth.sh @@ -13,10 +13,6 @@ CGIT_TEST_NO_CREATE_REPOS=YesPlease . ./setup.sh . "$TEST_OUTPUT_DIRECTORY/extensions/lib.sh" -interpreters=$(ext_lua_interpreters 5) -test -z "$interpreters" && - say 'no standalone lua on the path, unit checks skipped' - for lua in $interpreters do for variant in inline file @@ -38,17 +34,11 @@ test_expect_success 'create a repository with one commit' ' ' if test "$CGIT_HAS_LUA" -eq 1 && - cgit_lua_probe "$PWD/repos/authy/.git" openssl.rand openssl.hmac posix.sys.stat posix.unistd -then - test_set_prereq CGIT_LUA_AUTH -elif test "$CGIT_HAS_LUA" -eq 1 + ! cgit_lua_probe "$PWD/repos/authy/.git" openssl.rand openssl.hmac posix.sys.stat posix.unistd then test_set_prereq CGIT_LUA_NOAUTH say 'cgit lua lacks luaossl or luaposix, expecting protected pages refused' -else - say 'cgit built without lua, checks through cgit skipped' fi -test "$CGIT_HAS_LUA" -eq 1 && test_set_prereq CGIT_LUA test_expect_success CGIT_LUA 'point cgit at the unedited auth filter' ' cat >cgitrc <<-EOF @@ -79,7 +69,7 @@ test_expect_success CGIT_LUA_NOAUTH 'without the modules a protected repository repo.url=authy repo.path=$PWD/repos/authy/.git EOF - CGIT_CONFIG="$PWD/guardedrc" QUERY_STRING="url=authy/log/" cgit >tmp && + cgit_rc guardedrc "url=authy/log/" >tmp && ! grep "guarded commit" tmp && grep "Unable to complete the request" tmp ' diff --git a/tests/t0506-link-trailers.sh b/tests/t0506-link-trailers.sh index c2e4f91..03db63a 100755 --- a/tests/t0506-link-trailers.sh +++ b/tests/t0506-link-trailers.sh @@ -11,10 +11,6 @@ CGIT_TEST_NO_CREATE_REPOS=YesPlease . ./setup.sh . "$TEST_OUTPUT_DIRECTORY/extensions/lib.sh" -interpreters=$(ext_lua_interpreters 5) -test -z "$interpreters" && - say 'no standalone lua on the path, unit checks skipped' - for lua in $interpreters do test_expect_success "unit checks under $lua" " @@ -22,10 +18,6 @@ do " done -test "$CGIT_HAS_LUA" -eq 1 && test_set_prereq CGIT_LUA -test "$CGIT_HAS_LUA" -eq 1 || - say 'cgit built without lua, filter checks through cgit skipped' - test_expect_success 'create a repository whose commit carries linkable trailers' ' test_create_repo repos/linky && ( diff --git a/tests/t0507-hooks.sh b/tests/t0507-hooks.sh index 8a45f81..ec31546 100755 --- a/tests/t0507-hooks.sh +++ b/tests/t0507-hooks.sh @@ -32,8 +32,8 @@ test_expect_success 'set up a cache of two repositories and the index' ' CGIT_CONFIG="$PWD/cachedrc" PATH_INFO="$page" QUERY_STRING= cgit >/dev/null || return 1 done && - CGIT_CONFIG="$PWD/cachedrc" QUERY_STRING="url=a.git/refs/" cgit >/dev/null && - CGIT_CONFIG="$PWD/cachedrc" QUERY_STRING="url=b.git/refs/" cgit >/dev/null && + cgit_rc cachedrc "url=a.git/refs/" >/dev/null && + cgit_rc cachedrc "url=b.git/refs/" >/dev/null && echo writer >slots/0123abcd.lock && cp -R slots pristine && sed -e "s|^cache_root=.*|cache_root='\''$PWD/slots'\''|" \ @@ -115,6 +115,29 @@ do " done +for shell in $shells +do + test_expect_success SANITY "the age hook shows a pusher no error under $shell" " + echo kept >kept && + cp kept plain.git/info/web/last-modified && + test_when_finished 'chmod 755 plain.git/info/web' && + chmod 555 plain.git/info/web && + (cd plain.git && $shell \"\$HOOK_DIRECTORY/post-receive.cgit-age\" 2>../err) && + test_must_be_empty err && + test_cmp kept plain.git/info/web/last-modified + " +done + +test_expect_success SANITY 'the age hook shows no error when it cannot make its directory' ' + git init -q --bare closed.git && + git -C work push -q ../closed.git HEAD:refs/heads/main && + test_when_finished "chmod 755 closed.git/info" && + chmod 555 closed.git/info && + (cd closed.git && sh "$HOOK_DIRECTORY/post-receive.cgit-age" 2>../err) && + test_must_be_empty err && + test_path_is_missing closed.git/info/web +' + # git keeps a shared repository group writable whatever the pusher's umask, # and the next member to push has to be able to replace the file. test_expect_success SANITY 'the age hook keeps a shared directory group writable' ' diff --git a/tools/build-development.sh b/tools/build-development.sh index 79b9259..00ff569 100755 --- a/tools/build-development.sh +++ b/tools/build-development.sh @@ -1,13 +1,10 @@ #!/bin/sh -# Build cgit for development and debugging. It compiles without optimisation -# and with full debug information, holds the sources to git's DEVELOPER -# warnings, and links in AddressSanitizer and UndefinedBehaviorSanitizer, so a -# memory error or undefined behaviour stops the program where it happens. With -# clang it also leaves a compilation database in build/compile_commands.json, -# where a language server looks for one, and with ctags a tags file in -# build/tags. The sanitizers make cgit slow, so the result is for testing and -# debugging rather than serving. +# Build cgit for development and debugging. The sanitizers the compiler offers +# stop the program at a memory error or undefined behaviour, which makes it +# slow, so the result is for testing rather than serving. With clang it also +# leaves a compilation database in build/ for a language server, and with +# ctags a tags file there. # # Any arguments go on to make, the way Lua flags do where pkg-config cannot # find Lua: @@ -18,14 +15,33 @@ set -eu -# Every make target below is written relative to the repository root. cd "$(dirname "$0")/.." # The build starts from clean so the bundled git objects pick up the same # flags, and cleanall rather than clean because only cleanall descends into # vendor/git. make cleanall -make "$@" CFLAGS="-O0 -g3" DEVELOPER=1 SANITIZE=address,undefined \ + +# Named once, so the probe and the build use the same compiler. CC goes on +# make's command line because git's Makefile sets it, which would override +# the one from the environment. +CC=${CC:-cc} + +# OpenBSD's compiler has no AddressSanitizer, so the build falls back to the +# undefined behaviour checks alone, and to no sanitizer where neither links. +probe=$(mktemp "${TMPDIR:-/tmp}/cgit-probe.XXXXXX") +trap 'rm -f "$probe" "$probe.c"' EXIT +printf 'int main(void){return 0;}\n' >"$probe.c" +sanitize="" +for checks in address,undefined undefined +do + if $CC -fsanitize="$checks" -o "$probe" "$probe.c" >/dev/null 2>&1 + then + sanitize="SANITIZE=$checks" + break + fi +done +make "$@" CC="$CC" CFLAGS="-O0 -g3" DEVELOPER=1 $sanitize \ GENERATE_COMPILATION_DATABASE=yes # clang writes one entry per object under vendor/git/compile_commands, each diff --git a/tools/build-release.sh b/tools/build-release.sh index ade979c..b5e6faa 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -1,8 +1,9 @@ #!/bin/sh -# Build cgit for a release with Linux hardening flags, which are ELF and GCC or -# Clang specific. The flags add a stack protector, fortified libc calls, a -# position independent executable, and full RELRO. By default Lua is pinned off +# Build cgit for a release with hardening flags: a stack protector, fortified +# libc calls, a position independent executable, and full RELRO. Each flag a +# toolchain may lack is probed first and left out where it is missing, so the +# script builds on Linux, the BSDs and macOS alike. By default Lua is pinned off # so the binary needs no Lua at runtime, and running it as # ./tools/build-release.sh lua links in the backend behind the "lua:" filter # prefix instead, which needs a Lua dev package installed. @@ -27,7 +28,6 @@ lua) ;; esac -# Every make target below is written relative to the repository root. cd "$(dirname "$0")/.." CC=${CC:-cc} @@ -71,17 +71,24 @@ then CFLAGS="$CFLAGS -ffile-prefix-map=$PWD=." fi -LDFLAGS="-pie -Wl,-z,relro,-z,now -Wl,-z,noexecstack" +# The linker flags are probed the same way, since Apple's linker has no -z +# options and makes a position independent executable unasked. +LDFLAGS="" +if supports "-fPIE -pie" +then + LDFLAGS="-pie" +fi +for flag in -Wl,-z,relro,-z,now -Wl,-z,noexecstack +do + if supports "$flag" + then + LDFLAGS="$LDFLAGS $flag" + fi +done # These reach only the cgit objects, so git's own sources are not held to them. # -Wformat-security is an error because a non-literal format with no arguments -# is never intentional. -Wformat-nonliteral would catch the module-link shape, -# a non-literal that does take arguments, but it also fires on va_list -# forwarding and on local format constants, so it is left to manual runs when -# touching anything that formats. -# -# make cgit CGIT_EXTRA_CFLAGS=-Wformat-nonliteral -# +# is never intentional. CGIT_EXTRA_CFLAGS="-Wformat -Wformat-security -Werror=format-security" # The build starts from clean so the bundled git objects pick up the same @@ -90,5 +97,5 @@ CGIT_EXTRA_CFLAGS="-Wformat -Wformat-security -Werror=format-security" # which would override the one from the environment the flags were probed # with. make cleanall -exec make "$@" CC="$CC" CFLAGS="$CFLAGS" LDFLAGS="$LDFLAGS" \ +make "$@" CC="$CC" CFLAGS="$CFLAGS" LDFLAGS="$LDFLAGS" \ CGIT_EXTRA_CFLAGS="$CGIT_EXTRA_CFLAGS" diff --git a/tools/serve.py b/tools/serve.py index 8924e53..492345e 100755 --- a/tools/serve.py +++ b/tools/serve.py @@ -65,12 +65,9 @@ def split_cgi_output(output: bytes) -> CgiResponse: The header block ends at the first blank line. cgit ends its lines with a bare LF while a filter writing its own headers may use CRLF, so whichever - terminator appears earliest is the real one. Looking for CRLF everywhere - before falling back to LF would instead find the first CRLF in the body, - and a commit message with DOS line endings has one, which swallowed the - whole document head into the headers. Splitting on the separator rather - than on a non-empty body keeps a legitimately empty body, such as the one - a redirect leaves behind, from being read as headers. + terminator appears earliest is the real one, and a later blank line of the + other kind, such as one in a commit message with DOS line endings, belongs + to the body. The body may also be empty, as a redirect leaves it. """ ends = [(at, len(sep)) for sep in (b"\r\n\r\n", b"\n\n") if (at := output.find(sep)) >= 0] if ends: @@ -233,8 +230,6 @@ class CgitHandler(BaseHTTPRequestHandler): _ = self.wfile.write(response.body) def log_message(self, format: str, *args: object) -> None: - # The signature matches BaseHTTPRequestHandler, so the parameter here - # shadows the builtin of the same name. _ = sys.stderr.write(f" {format % args}\n") @@ -262,14 +257,14 @@ class Options(argparse.Namespace): data: str = str(REPO_ROOT / "assets") -def parse_args(argv: list[str] | None = None) -> Options: +def parse_args() -> Options: parser = argparse.ArgumentParser(description="Preview cgit locally.") _ = parser.add_argument("--config", default=Options.config, help="path to cgitrc (default: ./cgitrc)") _ = parser.add_argument("--port", type=int, default=Options.port) _ = parser.add_argument("--host", default=Options.host) _ = parser.add_argument("--cgit", default=Options.cgit, help="path to the cgit binary") _ = parser.add_argument("--data", default=Options.data, help="directory holding cgit.css, cgit.js, images") - return parser.parse_args(argv, namespace=Options()) + return parser.parse_args(namespace=Options()) def main() -> None: |
