git/list[1] front-page[2] threads[3] people[4] search[5] about
 

Re: [PATCH] range-diff: support reading mbox files

From
Johannes Schindelin <johannes.schindelin@gmx.de>
Date
Nov 18, 2022, 13:16 UTC
Message-ID
<06ro81n5-7sn1-070r-6747-5n77262o822p@tzk.qr>
In-Reply-To
<dfe0190c-1d2e-804a-5312-877b7b2f5822@dunelm.org.uk>
Hi Phillip,
On Wed, 16 Nov 2022, Phillip Wood wrote:
Show 22 quoted lines
> On 15/11/2022 18:20, Johannes Schindelin via GitGitGadget wrote:
> > From: Johannes Schindelin <johannes.schindelin@gmx.de>
> >
> > Internally, the `git range-diff` command spawns a `git log` process and
> > parses its output for the given commit ranges.
> >
> > This works well when the patches that need to be compared are present in
> > the local repository in the form of commits.
> >
> > In scenarios where that is not the case, the `range-diff` command is
> > currently less helpful.
> >
> > The Git mailing list is such a scenario: Instead of using Git to
> > exchange commits, the patches are sent there as plain-text and no commit
> > range can be specified to let `range-diff` consume those patches.
> >
> > Instead, the expectation is to download the mails, apply them locally
> > and then use `range-diff`. This can be quite cumbersome e.g. when a
> > suitable base revision has to be found first where the patch applies
> > cleanly.
>
> That's a good motivation for this change.

Side note (potentially fun to know): A couple of weeks ago, I wanted to work on this because I was asked to review a new patch series iteration on the Git mailing list (I forgot which one it was, precisely), which came without a range-diff, and I wanted to download both iterations using a variation of https://github.com/git-for-windows/build-extra/blob/HEAD/apply-from-lore.sh and then use the mbox mode of `range-diff` on them. Unfortunately, I ran out of time back then (it's a common theme for me these days), and the patch series (rightfully) advanced to `next` without my review, and I let this here patch slide.

Show 19 quoted lines
> > +static inline int strtost(char const *s, size_t *result, const char **end)
> > +{
> > +	unsigned long u;
> > +	char *p;
> > +
> > +	errno = 0;
> > +	/* negative values would be accepted by strtoul */
> > +	if (*s == '-')
> > +		return -1;
>
> I think it is right to treat the input as untrusted and so look for malformed
> hunk headers. However This test is not sufficient for that, we expect a digit
> so I think
>
> 	if (!isdigit(*s))
> 		return -1;
>
> would be safer. The use of strtoul() looks good as we set errno to zero before
> the call and check both errno and endp afterwards.

Good point. As you might have guessed, this function is a copy/edit of existing code, in this instance `strtoul_ui()`.

I have made the change you suggested.
Show 23 quoted lines
>
> > +	u = strtoul(s, &p, 10);
> > +	if (errno || p == s)
> > +		return -1;
> > +	if (result)
> > +		*result = u;
> > +	*end = p;
> > +
> > +	return 0;
> > +}
> > +
> > +static int parse_hunk_header(const char *p,
> > +			     size_t *old_count, size_t *new_count,
> > +			     const char **end)
> > +{
> > +	size_t o = 1, n = 1;
> > +
> > +	if (!skip_prefix(p, "@@ -", &p) ||
> > +	    strtost(p, NULL, &p) ||
> > +	    (*p != ' ' && (*p != ',' || strtost(p + 1, &o, &p))) ||
>
> It took me a minute to understand the double negatives but it is correctly
> checking if we have ' ' or ',<digits>'

I actually hesitated when I wrote this, and only kept the code as-is because I remembered how often Junio uses double negatives and thought it would be a good joke to do the same here. Joke's obviously on me, though.

I've hence changed it to:
	    /* The range is -<start>[,<count>], defaulting to count = 1 */
	    !(*p == ' ' || (*p == ',' && !strtost(p + 1, &o, &p))) ||
and
	    /* The range is * +<start>[,<count>], * defaulting to count = 1 */
	    !(*p == ' ' || (*p == ',' && !strtost(p + 1, &n, &p))) ||
Show 31 quoted lines
>
> > +	    !skip_prefix(p, " +", &p) ||
> > +	    strtost(p, NULL, &p) ||
> > +	    (*p != ' ' && (*p != ',' || strtost(p + 1, &n, &p))) ||
> > +	    !skip_prefix(p, " @@", &p))
> > +		return -1;
> > +
> > +	*old_count = o;
> > +	*new_count = n;
> > +	*end = p;
> > +
> > +	return 0;
> > +}
> > +
> > +static inline int find_eol(const char *line, size_t size)
> > +{
> > +	char *eol;
> > +
> > +	eol = memchr(line, '\n', size);
> > +	if (!eol)
> > +		return size;
> > +
> > +	if (eol != line && eol[-1] == '\r')
> > +		eol[-1] = '\0';
> > +	else
> > +		*eol = '\0';
> > +
> > +	return eol + 1 - line;
>
> We return the offset to the start of the next line, not the length of the
> line. This will be important later.

You're right. I changed the name to `find_next_line()` and added an informative comment on top of the function.

Show 33 quoted lines
>
> > +}
> > +
> > +static int read_mbox(const char *path, struct string_list *list)
> > +{
> > +	struct strbuf buf = STRBUF_INIT, contents = STRBUF_INIT;
> > +	struct strbuf long_subject = STRBUF_INIT;
> > +	struct patch_util *util = NULL;
> > +	enum {
> > +		MBOX_BEFORE_HEADER,
> > +		MBOX_IN_HEADER,
> > +		MBOX_IN_COMMIT_MESSAGE,
> > +		MBOX_AFTER_TRIPLE_DASH,
> > +		MBOX_IN_DIFF
> > +	} state = MBOX_BEFORE_HEADER;
> > +	char *line, *current_filename = NULL;
> > +	int len;
> > +	size_t size, old_count = 0, new_count = 0;
> > +	const char *author = NULL, *subject = NULL;
> > +
> > +	if (!strcmp(path, "-")) {
> > +		if (strbuf_read(&contents, STDIN_FILENO, 0) < 0)
> > +			return error_errno(_("could not read stdin"));
> > +	} else if (strbuf_read_file(&contents, path, 0) < 0)
> > +		return error_errno(_("could not read '%s'"), path);
> > +
> > +	line = contents.buf;
> > +	size = contents.len;
> > +	for (; size > 0; size -= len, line += len) {
>
> size is unsigned so we're effectively testing 'size != 0' which means if we're
> off by one somewhere we'll have an undetected buffer overflow. Using a signed
> type wouldn't prevent the buffer overflow but it would limit its extent.

`contents.len` is of type `size_t`, though, and I'd like to stay consistent.

I did remove the misleading `> 0` from the condition, though.
Show 22 quoted lines
>
> > +		const char *p;
> > +
> > +		len = find_eol(line, size);
>
> Here len is not the length of line if it originally ended "\r\n".
>
> > +		if (state == MBOX_BEFORE_HEADER) {
> > +			if (!skip_prefix(line, "From ", &p))
> > +				continue;
> > +
> > +			util = xcalloc(1, sizeof(*util));
> > +			if (get_oid_hex(p, &util->oid) < 0)
> > +				oidcpy(&util->oid, null_oid());
> > +			util->matching = -1;
> > +			author = subject = NULL;
> > +
> > +			state = MBOX_IN_HEADER;
>
> I wondered if there should there be a `continue;` here but I think it probably
> needs to "fall-through" to the MBOX_IN_HEADER handling below. A comment to
> clarify that would be helpful.

You're absolutely correct, there totally should be a `continue` here: we saw a `From `, and in the `MBOX_IN_HEADER` we look for either an empty line, the `From:` header or the `Subject:` header, so we know that the current line cannot match, no need to fall through.

Show 20 quoted lines
>
> > +		}
> > +
> > +		if (starts_with(line, "diff --git ")) {
> > +			struct patch patch = { 0 };
> > +			struct strbuf root = STRBUF_INIT;
> > +			int linenr = 0;
> > +			int orig_len;
> > +
> > +			state = MBOX_IN_DIFF;
> > +			old_count = new_count = 0;
> > +			strbuf_addch(&buf, '\n');
> > +			if (!util->diff_offset)
> > +				util->diff_offset = buf.len;
> > +			line[len - 1] = '\n';
>
> Here the line will still be NUL terminated if it originally ended "\r\n" which
> presumably messes up the call to parse_git_diff_header() below. I have not
> checked if parse_git_diff_header() can handle "\r\n" when it is parsing the
> rest of the diff header.

I changed it locally to reinstate the `\r\n`, only to figure out that almost the entire `apply.c` machinery totally falls over CR/LF line endings.

I changed the code to detect a Carriage Return and error out if one is detected.

Show 28 quoted lines
>
> > +			orig_len = len;
> > +			len = parse_git_diff_header(&root, &linenr, 1, line,
> > +						    len, size, &patch);
> > +			if (len < 0) {
> > +				error(_("could not parse git header '%.*s'"),
> > +				      orig_len, line);
> > +				free(util);
> > +				free(current_filename);
> > +				string_list_clear(list, 1);
> > +				strbuf_release(&buf);
> > +				strbuf_release(&contents);
> > +				strbuf_release(&long_subject);
> > +				return -1;
> > +			}
> > +
> > +			if (patch.old_name)
> > +				skip_prefix(patch.old_name, "a/",
> > +					    (const char **)&patch.old_name);
> > +			if (patch.new_name)
> > +				skip_prefix(patch.new_name, "b/",
> > +					    (const char **)&patch.new_name);
>
> I think this is fine for now but we might want to support other prefixes in
> the future. If it is not a copy or rename then the filename can be deduced by
> finding the common tail of patch.old_name and patch.new_name and stripping
> anything before the first '/'. If it is a copy or rename then I suspect there
> is no prefix (though I've not checked)

Since `skip_prefix()` does not do anything if there is no match, I think it is sane to err by not stripping anything unless the expected `a/` and `b/` prefixes are seen.

Show 6 quoted lines
>
> > +			strbuf_addstr(&buf, " ## ");
> > +			if (patch.is_new > 0)
>
> `patch.is_now` and `patch.is_delete` are booleans like `patch.is_rename` so we
> don't need the '> 0'
Good catch.
Show 33 quoted lines
>
> > +				strbuf_addf(&buf, "%s (new)", patch.new_name);
> > +			else if (patch.is_delete > 0)
> > +				strbuf_addf(&buf, "%s (deleted)",
> > patch.old_name);
> > +			else if (patch.is_rename)
> > +				strbuf_addf(&buf, "%s => %s", patch.old_name,
> > patch.new_name);
> > +			else
> > +				strbuf_addstr(&buf, patch.new_name);
> > +
> > +			free(current_filename);
> > +			if (patch.is_delete > 0)
> > +				current_filename = xstrdup(patch.old_name);
> > +			else
> > +				current_filename = xstrdup(patch.new_name);
> > +
> > +			if (patch.new_mode && patch.old_mode &&
> > +			    patch.old_mode != patch.new_mode)
> > +				strbuf_addf(&buf, " (mode change %06o =>
> > %06o)",
> > +					    patch.old_mode, patch.new_mode);
> > +
> > +			strbuf_addstr(&buf, " ##\n");
> > +			util->diffsize++;
> > +		} else if (state == MBOX_IN_HEADER) {
> > +			if (!line[0]) {
> > +				state = MBOX_IN_COMMIT_MESSAGE;
> > +				/* Look for an in-body From: */
> > +				if (size > 5 && skip_prefix(line + 1, "From:
> > ", &p)) {
>
> The "size > 5" seems a bit unnecessary as we're using skip_prefix()
Right!
Show 63 quoted lines
>
> > +					size -= p - line;
> > +					line += p - line;
>
> This is good, we're accounting for reading the next line.
>
> > +					len = find_eol(line, size);
> > +
> > +					while (isspace(*p))
> > +						p++;
> > +					author = p;
> > +				}
> > +				strbuf_addstr(&buf, " ## Metadata ##\n");
> > +				if (author)
> > +					strbuf_addf(&buf, "Author: %s\n",
> > author);
> > +				strbuf_addstr(&buf, "\n ## Commit message
> > ##\n");
> > +				if (subject)
> > +					strbuf_addf(&buf, "    %s\n\n",
> > subject);
> > +			} else if (skip_prefix(line, "From: ", &p)) {
> > +				while (isspace(*p))
> > +					p++;
> > +				author = p;
> > +			} else if (skip_prefix(line, "Subject: ", &p)) {
> > +				const char *q;
> > +
> > +				while (isspace(*p))
> > +					p++;
> > +				subject = p;
> > +
> > +				if (starts_with(p, "[PATCH") &&
> > +				    (q = strchr(p, ']'))) {
> > +					q++;
> > +					while (isspace(*q))
> > +						q++;
> > +					subject = q;
> > +				}
> > +
> > +				if (len < size && line[len] == ' ') {
> > +					/* handle long subject */
> > +					strbuf_reset(&long_subject);
> > +					strbuf_addstr(&long_subject, subject);
> > +					while (len < size && line[len] == ' ')
> > {
> > +						line += len;
> > +						size -= len;
> > +						len = find_eol(line, size);
> > +						strbuf_addstr(&long_subject,
> > line);
>
> Looks good
>
> > +					}
> > +					subject = long_subject.buf;
> > +				}
> > +			}
> > +		} else if (state == MBOX_IN_COMMIT_MESSAGE) {
> > +			if (!*line)
>
> Not a big issue elsewhere you've used "!line[0]"
> Style: there should be braces on this branch.
Fixed on both accounts.
Show 6 quoted lines
> [... addressed in follow-up mail...]
>
> I think this is a useful addition, it could perhaps benefit from more tests
> though. Having tests for bad input, "\r\n" line endings and getting the author
> from a From: header as well as an in-body From: line would give a bit more
> reassurance about how robust the parser is.
Good point, I've augmented the test case a bit more.

Thank you again. It is a real pleasure to receive these constructive reviews from you that pay so much attention to detail, and always lead to a clear path forward.

Thanks! Dscho

Previous: Johannes SchindelinNext: Ævar Arnfjörð Bjarmason
Message 6 of 16 in “range-diff: support reading mbox files”
  1. range-diff: support reading mbox filesJohannes Schindelin via GitGitGadget, Nov 15, 2022
  2. Taylor BlauNov 16, 2022
  3. Phillip WoodNov 16, 2022
  4. Phillip WoodNov 17, 2022
  5. Johannes SchindelinNov 18, 2022
  6. Johannes SchindelinNov 18, 2022
  7. Ævar Arnfjörð BjarmasonNov 17, 2022
  8. Johannes SchindelinNov 18, 2022
  9. range-diff: support reading mbox filesJohannes Schindelin via GitGitGadget, Nov 19, 2022
  10. Phillip WoodNov 21, 2022
  11. Johannes SchindelinNov 22, 2022
  12. Phillip WoodNov 22, 2022
  13. range-diff: support reading mbox filesJohannes Schindelin via GitGitGadget, Nov 22, 2022
  14. Phillip WoodNov 22, 2022
  15. Junio C HamanoNov 22, 2022
  16. Junio C HamanoMar 3, 2023

Read the whole thread, see it on lore, or plain text.

$ cat FOOTERMessages come from the public archive at lore.kernel.org/git, fetched every hour. The front page is chosen and written each morning by an AI editor and can be wrong; the threads themselves are the record. About and API. For agents: an MCP server at https://gitlist.dev/mcp, and any thread, story or person page as Markdown by adding .md to its URL (or sending Accept: text/markdown). Details in /llms.txt.