Skip to content
go-libfossil Technical erratum — draft-fossil-artifact-format-00 §4.2
Login

Technical erratum — draft-fossil-artifact-format-00 §4.2

Document draft-fossil-artifact-format-00
Section 4.2 — Line and Token Grammar (consequences in 4.7.4 C Card, 4.7.8 J Card, 4.7.16 T Card)
Type Technical (the token grammar is stricter than the reference implementation)
Status Reported; zit's reader corrected
Reported 2026-08-16
Submitter Dan Mestas
Found via Indexing --compatible fossil→zit imports of sqlite.org/src and core.tcl-lang.org/tcl; every ticket-change artifact carrying an empty field was rejected

1. Summary

§4.2 gives token = 1*tchar, making an empty token invalid everywhere. §4.7.17 then carves out a single exception for the U card, noting that its grammar "takes precedence over the general requirement that a card argument be a non-empty token."

Fossil's parser applies no such general requirement. next_token() returns a non-NULL empty string for a trailing space on any card, and the C, J and T cards accept it as a present, empty value. Real repositories rely on this: Fossil writes an empty value as a bare trailing space. A reader that implements §4.2 literally rejects those artifacts.

The U card exception is therefore not an exception — it is one instance of a general rule the document does not state. The rule is POSITIONAL, not per-card: it is not confined to grammars written [ SP value ]. The C card's value is mandatory, yet C LF-with-trailing-space is a present, empty comment that Fossil accepts, and it is the most common instance in practice.

2. The text in error

Page 11 (§4.2):

card-args   = token *( SP token )
token       = 1*tchar

and, page 22 (§4.7.8) and page 25 (§4.7.16), the [ SP value ] production in j-card and t-card, which inherits token's non-empty requirement and so cannot express a present-but-empty value.

and, page 20 (§4.7.4):

Exactly one required token, escape-decoded; single-occurrence; a C card with no token MUST be rejected.

This last conflates two distinct inputs. C LF (no space) genuinely has no token and Fossil rejects it. C SP LF has one token, which is empty, and Fossil accepts it as an empty comment.

3. What Fossil implements

manifest.c:

manifest.c:383-395   next_token(): returns 0 only when already at EOL;
                     otherwise n = strcspn(z, " \n") — which may be 0 —
                     and the token is returned as a NUL-terminated string.
manifest.c:965-966   T card: zValue = next_token(&x,0); if( zValue ) defossilize(zValue);
manifest.c:784-786   J card: zValue = next_token(&x,0); if( zValue==0 ) zValue = "";
manifest.c:629-630   C card: p->zComment = next_token(&x,0);
                             if( p->zComment==0 ) SYNTAX("missing comment text on C-card");
manifest.c:399-412   next_card(): only z[1]==' ' or z[1]=='\n' continues; the
                     '\n' branch sets atEol, which is what makes a bare "C"
                     yield NULL from the following next_token().

For the line T +date <hash> LF, the third next_token call sees strcspn return 0 at the LF, sets atEol, and returns a pointer to an empty string — non-NULL. The T card stores that as the tag's value. Only a card line with no trailing space at all yields NULL and hence an absent value.

This distinction is exactly the one §4.7.16 already draws ("An absent value yields null (unlike the J card's empty-string default)"): under Fossil,

Both forms occur in the wild. For the J card the two collapse, since an absent J value already defaults to the empty string.

4. Corrected text

In §4.2, after the grammar block, add:

The final token of a card MAY be empty, on any card letter. When a card line's last octet before LF is SP, the final token is PRESENT and its decoded form is the empty string — distinct from a card that omits the token entirely, which is the argument-less form card-letter LF. Whether an empty final token is legal for a given card is then decided by that card's own arity and validation in Section 4.7, not by this grammar. Empty tokens remain invalid in every other position: a card line containing two consecutive SP octets is malformed.

and amend the grammar so only the non-final tokens are bound by token:

card-args   = *( token SP ) value
token       = 1*tchar
value       = *tchar

Note this formulation covers the C card, whose value is mandatory rather than optional, without a second rule: C SP LF yields card-args of one empty value, which §4.7.4 then accepts as an empty comment.

In §4.7.4, replace the rejection clause so it distinguishes an absent token from an empty one:

Exactly one required token, escape-decoded; single-occurrence. A C card with no token — a bare C immediately followed by LF — MUST be rejected. A C card whose single token is empty (C, SP, LF) is a present comment whose decoded value is the empty string, and MUST be accepted.

In §4.7.17, the sentence beginning "This card's grammar, with its optional and possibly-empty login token, governs the U card and takes precedence over the general requirement…" should be reduced to a cross-reference, since after the §4.2 correction the U card no longer takes precedence over anything:

The U card's possibly-empty login token is the general trailing-value rule of Section 4.2; the substitution of anonymous is what is specific to this card.

5. Observable consequence of the uncorrected text

A conforming reader rejects the whole artifact on J name LF-with-trailing-space and on T +tag <hash> LF-with-trailing-space. Because these are ticket-change and tag artifacts, the loss is not partial degradation of one record — the artifact fails to parse at all and, in any implementation that falls back to treating unparsable artifacts as opaque content, disappears from derived state without an error surfacing to the user.

Measured against sqlite.org/src (131,558 artifacts) and core.tcl-lang.org/tcl (254,469 artifacts), with a reader implementing §4.2 literally:

The C-card case is the one most likely to be missed by an implementer reading §4.7.4 alone, because that section's rejection clause reads as though it already settles the question.

6. Scope note

This erratum concerns only the trailing position. §4.2.1 (Exact Arity) is unaffected and its maximum-token counts continue to apply: a trailing empty token occupies the card's final optional slot and does not add one. An interior empty token — two consecutive spaces — remains a rejection, which matches next_token, where an interior strcspn of 0 produces an empty token in a non-final position that each card's own validation then refuses.

Implementations should check whether their tokenizer rejects empty tokens before the per-card grammar is consulted; that ordering is what turns a legal artifact into a parse failure.

7. Conformance of this implementation

zit was not exempt, contrary to the first reading of the report. Its tokenizer distinguishes absent from empty correctly, but two card parsers then rejected the empty form before their own card grammar was consulted — the exact ordering flagged at the end of Section 6:

card reference implementation zit before the fix after
T +x <hash> SP LF value present, empty present, empty unchanged
U SP LF / bare U anonymous anonymous unchanged
C SP LF present, empty comment rejected present, empty
J field SP LF present, empty value rejected present, empty

Both rejections were load-bearing in tests that asserted them, so the defect was pinned rather than latent: §4.7.4-r1 asserted C SP LF was rejected under a title that said "a C card with no token", conflating the two inputs in exactly the way Section 2 describes. Those tests now assert the corrected behaviour, and a third covers the writer: an empty comment survives create as C SP LF, which matters because import-fossil --submanifest re-mints every manifest it carries.

The consequence for zit would have been the same class of loss measured against quarry, and for the same reason: a check-in whose manifest fails to parse leaves derived state without surfacing an error.