CEP XXXX: Fix directory-hash collision via length-prefixed fields (v2) - #174
CEP XXXX: Fix directory-hash collision via length-prefixed fields (v2)#174pb01ka wants to merge 7 commits into
Conversation
Adds a new CEP superseding CEP 19. The original algorithm was vulnerable to hash collisions when filenames contained the type marker bytes (F, D, L) or the entry separator (-), since no field-length information was encoded in the stream. The fix length-prefixes every variable-length field (path and symlink target) using netstring-style `<len>:` notation, making all field boundaries unambiguous and provably collision-free. un-versioned `content_sha*` keys continue to work via the CEP-19 legacy path (emitting a PendingDeprecationWarning), while new `content_sha*_v2` keys use the fixed algorithm.
| ### Stream comparison: CEP 19 vs this CEP | ||
|
|
||
| The table below shows the raw byte sequences fed to the hasher for two structurally different | ||
| directory trees that produce a **collision under CEP 19** but distinct digests under this CEP. | ||
|
|
||
| **Tree 1:** one file named `testFhello-world` (16 UTF-8 bytes) with content `www`. | ||
|
|
||
| **Tree 2:** a file named `test` (content `hello`) and a file named `world` (content `www`). | ||
|
|
||
| | Algorithm | Tree 1 | Tree 2 | Collision? | | ||
| | ------------- | -------------------------- | --------------------------- | ---------- | | ||
| | CEP 19 | `testFhello-worldFwww-` | `testFhello-worldFwww-` | Yes | | ||
| | This proposal | `16:testFhello-worldFwww-` | `4:testFhello-5:worldFwww-` | No | | ||
|
|
||
| Under CEP 19 both trees yield the identical stream `testFhello-worldFwww-` and therefore the same | ||
| digest. Under this proposal, the `16:` length prefix on Tree 1 unambiguously marks the path as 16 | ||
| bytes, so the `F` that follows is the type marker - not part of the filename. Tree 2 produces a | ||
| completely different stream and a different digest. | ||
|
|
||
| More cases discussed in the Examples section. |
There was a problem hiding this comment.
Let's move this example to the Examples section, merging it in Example 3.
| The two streams are distinct, so the digests are distinct. Under CEP 19 both trees produced the | ||
| same stream `testFhello-worldFwww-`, making the collision possible. | ||
|
|
||
| ## Rationale |
There was a problem hiding this comment.
I'll note that this section is supposed to explain decisions made in the specification. As such, rules with MUST or SHOULD do not belong here. Python PEPs usually refrain from dictating UX for tools, though, so I don't think we should use RFC language here. Let's lowercase those suggestions because they are not part of the specification.
Co-authored-by: jaimergp <jaimergp@users.noreply.github.com>
- Add superseded-by note to CEP 19 - Move stream comparison table from Specification into Example 3 - Drop RFC keyword SHOULD from Rationale (non-normative section)
|
@jaimergp I have addressed your reviews. Please let me know if there is any other thing we can change here. Thanks. |
|
@jaimergp: I am honored to take a look :-) |
hunger
left a comment
There was a problem hiding this comment.
I think this is a step up from CEP-19, thank you for doing the work to get this fixed. I very much appreciate it.
One tiny nit-pick: I found the "utf8 encoded bytes" phrasing a bit confusing for ASCII letters, which are all 1 byte.
Some of my comments are over the top for a hasher. I registered them anyway as I assumed you are going to calculate a hash for something you want to package up or process in some other way and I expect you want to make sure the hasher and the tool using the hashed contents will agree on the files they accept or reject.
| Given a directory, recursively scan all its contents (without following symlinks) and sort them by | ||
| their full path as a Unicode string. More specifically, it MUST follow an ascending lexicographical | ||
| comparison using the numerical Unicode code points (i.e. the result of Python's built-in function | ||
| `ord()`) of their characters [^1]. |
There was a problem hiding this comment.
Any ordering will do here. We can not use the raw bytes straight from the file system though, as that would not be cross platform. You mandate utf-8 encoded file names later.
The cheapest would IMHO be comparing the raw utf8 bytes. This avoids extracting the unicode code point for a single comparison.
There was a problem hiding this comment.
You are right that we cannot use raw filesystem ordering - os.listdir() / Path.rglob() return entries in OS-dependent (and sometimes non-deterministic) order, so the hash would differ across platforms without an explicit sort. The sort is mandatory.
On the UTF-8 bytes point: agreed. UTF-8 was specifically designed so that lexicographic order on raw UTF-8 bytes is identical to lexicographic order on Unicode code points. Since we already mandate UTF-8 for all path names, comparing the raw rel.encode("utf-8") bytes and comparing the decoded Unicode strings produce exactly the same ordering - there is no semantic difference. Sorting on bytes is therefore a valid and marginally cheaper alternative (no decoding step needed for the comparison key).
I will update the spec to say "sort by raw UTF-8 bytes" instead of "sort by numerical Unicode code points". The footnote referencing Python's < operator will be removed since the byte-comparison framing is self-contained and does not need a language-specific reference.
|
|
||
| - Backslashes in the path MUST be normalized to forward slashes (e.g. `path\\to\\file` | ||
| becomes `path/to/file`). | ||
| - Redundant path components MUST be removed (e.g. `path/to/../to/file` becomes `path/to/file`). |
There was a problem hiding this comment.
I assume this is all text-based without looking up real files in the file system?
You could add "." into the example, so implementers won't forget to handle that.
What about leading '/'? Is that allowed? I think it should not be, as you are hashing a subtree.
What about the Windows drive letters, UNC paths?
There was a problem hiding this comment.
I assume this is all text-based without looking up real files in the file system?
Yes. Specifically, the examples you are looking at are constructed by hand to illustrate the byte stream; The byte streams shown are exactly what the algorithm would produce if run against the described directory structure.
You could add "." into the example, so implementers won't forget to handle that.
I think the two things the spec should clarify here are,
- A leading
./in a relative path MUST be stripped (e.g../README.txt->README.txt). The current normalization bullet only mentions..-removal; I will add an explicit rule for.-stripping. - The root directory itself (
.) MUST NOT be included as an entry. AFAIK, Python'srglob("*")never yields the root, but implementations in other languages (if done) that walk directories manually (e.g. usingreaddir) may encounter.and..entries and must skip them. I will add a sentence to the spec making this explicit, and add a short example showing a dotfile (e.g..gitignore) in a tree so the sort position of names starting with.is demonstrated.
What about leading '/'? Is that allowed? I think it should not be, as you are hashing a subtree.
Agreed - a leading / would make the path absolute, which contradicts the relative-path semantics of the algorithm. In the reference implementation path.relative_to(directory) guarantees this can never happen for entry paths, but other implementations could accidentally produce it. I will add an explicit normalization rule: "Paths MUST be relative; a leading / MUST be rejected."
Also, symlink targets are hashed as literal strings (not resolved), so an absolute symlink target like /usr/lib/foo is stored as-is with its / intact - that is intentional and I will add a note to distinguish the two cases.
What about the Windows drive letters, UNC paths?
AFAIK, for entry paths this cannot arise. path.relative_to(directory) strips any drive letter or UNC prefix, and after backslash-to-forward-slash normalization the result is a plain relative path. I will add a normalization rule making this explicit for non-Python implementations: "Drive letters (e.g. C:) and UNC prefixes (e.g. //server/share) MUST be stripped from entry paths before processing."
For symlink targets an absolute Windows path or UNC target could appear in principle. The spec should state that implementations MUST error out if a symlink target contains a drive letter or UNC prefix, because there is no meaningful cross-platform way to normalize those into the hash stream.
There was a problem hiding this comment.
I misunderstood your point 2) at first. A better wording might be:
The scanned directory itself (.) MUST NOT be included as an entry.
root directory made me think of '/'.
Good catch, I totally missed this one.
There was a problem hiding this comment.
"Drive letters (e.g. C:) and UNC prefixes (e.g. //server/share) MUST be stripped from entry paths before processing."
I'd argue those conditions should result in an error. No stripping.
There was a problem hiding this comment.
Actually, why are we doing this? Since the input is from a scanning function, I suppose such entries can only be symlink targets. Why would we want to consider foo/../bar the same as bar? Presumably if you use two different targets, there is a reason for the difference, right?
There was a problem hiding this comment.
This is over-specifying things a bit for a hash, I agree. But a hash will be used together with a archive in our use case, and we need to make sure that the hash and the archive generated will agree on the files they work on. It makes no sense to hash one set of files and then package up a (slightly) different set.
| - The UTF-8 encoded bytes of `F`. | ||
| - If the file is a text file (i.e. its entire contents can be UTF-8 decoded): the UTF-8 | ||
| encoded bytes of its line-ending-normalized contents (`\r\n` replaced with `\n`). If the | ||
| file can't be opened, it MUST be handled as if it were empty. |
There was a problem hiding this comment.
This seems very wrong to me: You are changing file contents for the hashing. That is a new way to introduce hash collisions!
Just treat all files as binary and do not try to make any sense of the contents whatsoever.
There was a problem hiding this comment.
You are right that this creates an intentional hash collision: a file containing hello\r\nworld and a file containing hello\nworld feed identical bytes into the hasher and therefore produce the same digest, even though their raw byte content differs.
The line-ending normalization was inherited from CEP 19 with the goal of cross-platform reproducibility: a conda recipe checked out on Windows (where git may materialise \r\n) should hash the same as when checked out on Linux (where git materialises \n).
However, your concern is well-founded for two reasons,
-
It really is a collision. Regardless of whether the equivalence is intentional, two files with different raw bytes silently hash to the same value. For a hash used in security contexts (e.g. verifying a package has not been tampered with) that is a meaningful weakness: an attacker who can substitute a CRLF version of a file for an LF version bypasses the check.
-
The text/binary heuristic is fragile. Any file whose bytes happen to be valid UTF-8 is treated as text and has its line endings silently transformed. AFAIK, many binary formats (e.g. certain compiled outputs; please correct me if I'm wrong) are legal UTF-8 and would be misclassified. The Python reference implementation compounds this by opening files in text mode first, which on Windows already performs platform-level CRLF translation before the explicit
\r\n->\nreplacement.
The cleaner fix is to drop the text/binary distinction entirely and treat all file contents as raw bytes, as you suggest. Cross-platform consistency should be the responsibility of the tooling that produces the directory (e.g. git's .gitattributes), not of the hashing algorithm. The
hashing algorithm should be a pure function of the bytes on disk and nothing more.
I will update the spec and the reference implementation accordingly: remove the UTF-8 decode
attempt, remove the \r\n -> \n replacement, and always feed the raw file bytes into the hasher.
@jaimergp - does this direction make sense to you, or do you think the cross-platform line-ending consistency is important enough to keep in the algorithm?
There was a problem hiding this comment.
This was added to prevent unix vs win checkouts producing different hashes. .gitattributes may not help here if there are data files which are meant to be newline-normalized for the target platform. Maybe we can offer it as an optional step so implementers can pick whether to add it as an additional method, while mentioning the caveats for potential collision?
| encoded bytes of its line-ending-normalized contents (`\r\n` replaced with `\n`). If the | ||
| file can't be opened, it MUST be handled as if it were empty. | ||
| - If the file is binary: the raw bytes of its contents. | ||
| - If the file can't be read, implementations MUST error out. |
| - The UTF-8 encoded bytes of `L`. | ||
| - The decimal representation of the byte length of the symlink target path, encoded as UTF-8, | ||
| followed by the UTF-8 encoded bytes of `:`. | ||
| - The UTF-8 encoded bytes of the symlink target path (normalized as above). |
There was a problem hiding this comment.
Is it OK to have a target reaching out of the tree we are hashing?
Is it OK for a target to be a absolute path or start with drive letters, etc.? I would think that is OK here, but it makes for a different normalization as above.
There was a problem hiding this comment.
Is it OK to have a target reaching out of the tree we are hashing?
Yes, and it is already handled correctly by the current spec.
The algorithm never follows symlinks (line: "without following symlinks") - it hashes the target string as literal bytes, not the content of whatever the target points to. So a symlink with target ../../outside/file contributes those exact bytes to the hash stream. If the content at the target changes, the hash is unaffected, but that is true of all symlinks regardless of whether the target is inside or outside the tree.
The hash captures the structure and metadata of the tree being scanned; content outside that tree is explicitly out of scope. I think no change should be needed here.
Please let me know if you think there is a case where this behavior is not what you want, and we can discuss.
Is it OK for a target to be a absolute path or start with drive letters, etc.? I would think that is OK here, but it makes for a different normalization as above.
Agreed - absolute symlink targets are valid and should be allowed. A symlink to /usr/lib/libfoo.so or C:/Windows/System32/foo.dll is perfectly legal and meaningful on its respective platform, and the hash should faithfully record that target string.
You are right that the normalization rules differ from those for entry paths:
- Entry paths must be relative; a leading
/or a drive letter is a bug in the implementation and MUST be rejected. - Symlink targets may be relative or absolute; a leading
/or drive letter is legitimate data and MUST be preserved.
The only normalization that applies to both is the mechanical one: backslashes -> forward slashes, and removal of redundant . / .. components. The spec currently says "normalized as above" for symlink targets, which is ambiguous because "as above" also implies the relative-path constraints.
I will clarify this by splitting the normalisation rules into two explicit lists: one for entry paths (relative-only) and one for symlink targets (absolute or relative, separators and redundant components only).
| | Symlink target | `<target_bytes>` | `<len(target_bytes)>:<target_bytes>` | | ||
|
|
||
| All other aspects of the algorithm (sorting order, text vs. binary detection, line-ending | ||
| normalization, error handling) are unchanged. |
There was a problem hiding this comment.
I suggest not doing line ending normalization nor touching the contents of any file in any way.
|
|
||
| 1. The decimal representation of the byte length of the normalized relative path, encoded as UTF-8, | ||
| followed by the UTF-8 encoded bytes of `:`. | ||
| 2. The UTF-8 encoded bytes of the normalized relative path. |
There was a problem hiding this comment.
This can in theory lead to more hash collisions as filenames using different encodings might end up with the same utf-8 file name. Just pointing that out here: I think you can not avoid that while having file names that are valid cross-platform.
You might want to mandate a unicode normalization form to be used? You might end up with the same file name having different hashes if you leave that out.
Should all characters be allowed? /, \\ and \0 and : might be problematic in a file name... "a:\foo" should probably not be a valid file name.
There was a problem hiding this comment.
This can in theory lead to more hash collisions as filenames using different encodings might end up with the same utf-8 file name.
Agreed, and the root cause might be Unicode equivalence rather than encoding per se. The spec already mandates UTF-8 encoding of paths, so the problematic case is not two different encodings for the same byte sequence, but rather two different Unicode representations of the same logical character.
For example, the filename é can be stored as:
- NFC: U+00E9 (2 UTF-8 bytes:
0xC3 0xA9) - NFD: U+0065 + U+0301 (3 UTF-8 bytes:
0x65 0xCC 0x81)
macOS stores filenames in NFD; Linux stores whatever bytes the application writes (?). A recipe hashed on macOS and the same recipe hashed on Linux could therefore differ on any filename containing a composed character. So we might end up in a permutation of characters leading to the same byte stream.
I think you are right that this cannot be fully eliminated while supporting all cross-platform filenames, but mandating a normalization form (see below) should close the practical gap.
You might want to mandate a unicode normalization form to be used? You might end up with the same file name having different hashes if you leave that out.
Good suggestion - I will add NFC as the required normalization form. I think NFC is de-facto standard in Python's standard library (it lets you use NFD), and in the vast majority of web and network protocols. Please correct me if I'm wrong.
Before encoding a path to UTF-8 for hashing or sorting, implementations MUST normalize it to NFC using their platform's Unicode library (e.g. unicodedata.normalize('NFC', path) in Python).
This makes the hash independent of whether the underlying filesystem stores filenames in NFC or NFD.
There was a problem hiding this comment.
Should all characters be allowed?
/,\\and\0and:might be problematic in a file name... "a:\foo" should probably not be a valid file name.
Each one explained in the following pointers,
-
/and\\: These are already handled. Backslashes are normalized to forward slashes, and forward slashes are path separators - they delimit components, they cannot appear inside a component after normalization. Any path that still contains a bare/inside a component after normalization is malformed and MUST be rejected. -
\0(null byte): AFAIK, Null bytes are not valid in filenames on any mainstream OS. If a path component contains\0the implementation MUST error out. I will add this to the normalization rules. -
:: On Linux,:is a legal filename character. On Windows, it is forbidden in ordinary filenames (because it is used for drive letters?). Thea:\fooform is a Windows drive path and is already covered by the "drive letters MUST be rejected for entry paths" rule from the earlier discussion. A bare:inside a filename component (e.g.foo:bar) is unusual but does not conflict with any part of the hash stream format (:is used as a length-prefix delimiter, but only at the position immediately after the decimal length, which is unambiguous). I think I will leave:inside components as implementation-defined for now.
There was a problem hiding this comment.
/, \\, \0 and : in malicious tar archive have been a constant source of problems, so they should not be allowed anywhere close to a tarball (or zip file ;-). Sure, you can store those safely, but you need to expect some tools to fail to extract that data safely. So there is a case to be made for conda to reject anything that looks fishy when people try to put it into or get it out of packages.
The hasher is a pretty harmless component in itself, but it might make sense to apply the same rules to the hasher as to the packager. Otherwise you might end up with the hasher considering a different set of files from a packaging tool, which would be unexpected.
| ## Specification | ||
|
|
||
| Given a directory, recursively scan all its contents (without following symlinks) and sort them by | ||
| their full path as a Unicode string. More specifically, it MUST follow an ascending lexicographical |
There was a problem hiding this comment.
I assume you mean utf8 string here? Technically are several encodings for unicode strings.
You might want to add that these strings must be normalized as specified below and deduplicated. Might be overdoing it, but somebody might try to throw a directory and a file with the same name into the hasher.
There was a problem hiding this comment.
I assume you mean utf8 string here? Technically are several encodings for unicode strings.
Yes, UTF-8 is the intended encoding. The current wording "sort by their full path as a Unicode string" is ambiguous.
Following my previous replies about mandating NFC normalization, I will tighten this to:
Paths MUST be NFC-normalized and then UTF-8-encoded before sorting, and the sort MUST be a lexicographic comparison of the resulting UTF-8 bytes.
This removes any ambiguity about which Unicode encoding or normalization form governs the ordering.
You might want to add that these strings must be normalized as specified below and deduplicated. Might be overdoing it, but somebody might try to throw a directory and a file with the same name into the hasher.
On normalization order
The current spec normalizes paths before hashing them (lines below the sort) but does not say whether the sort is performed on the raw or the normalized form.
I will make this explicit: normalization MUST be applied first, and the sort MUST be performed on the normalized paths.
On deduplication
The deduplication check is on the full normalized path, not the basename alone. A directory foo/ and a file foo/foo have different full paths and are never duplicates; neither are two entries named bar in different subdirectories.
On a real filesystem, two entries with the exact same full path genuinely cannot exist: the OS enforces name uniqueness within each directory, so the full path is globally unique within the tree.
The only practical edge case arises after NFC normalization on a case-sensitive filesystem such as Linux: two entries whose raw byte names differ (e.g. one stored in NFD, one in NFC) but collapse to the same NFC-normalized path would be indistinguishable to the algorithm. This might be rare, but the spec should be precise:
If normalization produces two entries with the same path, implementations MUST error out rather than silently feeding duplicate bytes into the hasher.
|
@hunger I responded to your review comments. If I misinterpreted your reviews then please let me know in the respective thread. I tried to be verbose in my replies. I will make the changes to the CEP tomorrow and subsequently push the commits. Thank you for your insightful review. |
|
Another random thought: My python is severely limited, but I remembered in the back of my head there was something funny happening there wrt. filenames and utf8 in python. Does the "surrogateescape" mechanismn effect your filesystem encoding, considering that your reference implementation is in python (a quick serch turned up pep-383, but this blog has a good summary of the discussions that happened in python)? As I read it, this standardizes encoding file names in a not fully utf8-compliant way into utf8 strings. Or do we need something like the surrogateescape mechanismn? IIRC it allows round-tripping legacy utf-16 windows filenames and deal with legacy "what encoding is this?!" filenames on unix systems. The CEP rules this out explicitly by requiring utf8 encoded strings as I read the proposal. But considering your reference implementation uses python, it might be worthwhile to mention this behavior explicitly. |
|
Thank you @hunger for in-depth discussion on this CEP. Based on our discussion I have added a TODO list in the description of the PR. Please go through it and let me know if you think I misunderstood anything from your comments. Please take a special look at Thank you again. ❤️ cc: @jaimergp |
|
@pb01ka: I think you covered everything I threw at you :-) |
|
Just a heads up folks - I will start working on my TODO list by tomorrow or Sunday. :)). |
- Switch sorting to NFC-normalized, UTF-8-encoded byte comparison - Add explicit rules for leading "./", absolute paths, drive letters/UNC prefixes, null bytes, non-UTF-8 paths, and NFC collisions. - Clarify that text/binary classification only gates \r\n normalization, with raw bytes always fed to the hasher. - Update the reference implementation and examples to match.
| Given a directory (the "scanned directory"), recursively scan all its contents (without following | ||
| symlinks) and sort them by their full path. The scanned directory itself (`.`) MUST NOT be included | ||
| as an entry. Paths MUST be NFC-normalized and then UTF-8-encoded before sorting; the sort MUST be a | ||
| lexicographic comparison of the resulting UTF-8 bytes. |
There was a problem hiding this comment.
I could be wrong but I think "lexicographic" is ambiguous, as that could depend on collation used. Is this referring to byte values?
There was a problem hiding this comment.
Also, perhaps worth making it clear that we're dealing with relative paths? Especially "full path" makes one think of absolute paths.
|
|
||
| For each entry in the sorted contents, feed the following bytes into the hasher in order: | ||
|
|
||
| 1. The decimal representation of the byte length of the normalized relative path, encoded as UTF-8, |
There was a problem hiding this comment.
This is a bit ambiguous whether "encoded as UTF-8" refers to "the decimal representation" or "the normalized relative path" (I suppose both).
| 2. The UTF-8 encoded bytes of the normalized relative path. | ||
| 3. Then, depending on the entry type: | ||
| - For a **regular file**: | ||
| - The UTF-8 encoded bytes of `F`. |
There was a problem hiding this comment.
| - The UTF-8 encoded bytes of `F`. | |
| - The UTF-8 encoded byte of `F`. |
We know it's always one byte.
| - In either case, the file is classified as text or binary only to decide whether the | ||
| `\r\n` -> `\n` substitution is applied; the resulting bytes are then fed into the hasher | ||
| directly as raw bytes, with no decode/re-encode round-trip. |
There was a problem hiding this comment.
This is confusing. Are we feeding raw unmodified bytes or newline-substituted bytes now?
| with every `\r\n` byte sequence (`0x0D 0x0A`) replaced by `\n` (`0x0A`). If the file can't be | ||
| opened, it MUST be handled as if it were empty. This is the default, normative mode; see |
There was a problem hiding this comment.
Unless I'm missing something, this is contradictory:
If the file can't be opened, it MUST be handled as if it were empty
If the file can't be read, implementations MUST error out.
|
|
||
| ### Reference implementation | ||
|
|
||
| For Python 3.6+: |
There was a problem hiding this comment.
Perhaps worth mentioning (if intentional) that this doesn't cover all error paths (i.e. disallowed paths per the rules).
| so these fields do not need their own length prefix. File contents are similarly unambiguous because | ||
| they are bracketed by the type marker on one side and the `-` separator on the other, with no | ||
| variable-length field interleaved. |
There was a problem hiding this comment.
File contents can introduce hash collision here. Consider the first example:
10:README.txtFHello\n-3:srcD-
A directory containing only README.txt with Hello\n-3:srcD produces the same hash.
There was a problem hiding this comment.
Do we need the number of items in the directory tree too?
There was a problem hiding this comment.
Or the number of bytes in the file?
| Choosing a separator byte that is unlikely to appear in filenames (e.g. `\0`) would reduce the | ||
| practical collision surface but would not eliminate it, since `\0` is a legal byte in many | ||
| filesystems' raw representations and the algorithm operates on Unicode strings. Length-prefixing is | ||
| the only approach that is provably collision-free regardless of file naming. |
There was a problem hiding this comment.
I suppose this is outdated since you forbid null bytes in filenames. They could occur in data, though.
…c wording
- File content was the last unprefixed variable-length field, so crafted content could reproduce a different tree's byte stream.
- Also completes the reference implementation (NFC normalization/collision detection, path validation, byte-wise sort) and,
- resolves several ambiguities flagged in review:
- lexicographic sort;
- path-length encoding order;
- text/binary byte handling, and
- the opened-vs-read error contradiction
| optional alternative mode. | ||
| - If the file is binary: its raw bytes, unmodified. | ||
| - If the file can't be opened or read, implementations MUST error out. | ||
| - The byte length of those content bytes, written in decimal and encoded as UTF-8, followed by |
There was a problem hiding this comment.
This goes before content bytes, right? Also should it be the length of the content bytes, or the length of the normalized content bytes. I think the latter, otherwise we get different hashes per platform.
There was a problem hiding this comment.
Perhaps we should move "content bytes normalization" into a separate algorithm, and then refer to it here; both for length (i.e. "length of normalized content, per algorithm …" and for content bytes ("normalized content, per algorithm …").
| bytes with every `\r\n` byte sequence (`0x0D 0x0A`) replaced by `\n` (`0x0A`). The | ||
| substitution MUST be performed directly on the raw bytes, not via a decode-to-text-then- | ||
| re-encode round-trip. This is the default, normative mode; see | ||
| [Rationale](#why-keep-the-textbinary-distinction-and-line-ending-normalization) for an | ||
| optional alternative mode. |
There was a problem hiding this comment.
This is dictating implementation choices for tools; the specification shouldn't concern itself with that detail, IMO. Good to note, sure, but not in this section. Perhaps just leave it in the Reference Implementation section.
There was a problem hiding this comment.
I would encourage you to remove this text file handling. It is error prone and out of scope for a hash function IMHO. That should be able to detect differences between files, not normalize files.
There was a problem hiding this comment.
As per, #174 (comment) we decided to have the line ending normalisations. However as per the above comment we shouldn't have it.
@jaimergp What do you say? Should we have the line ending normalisation?
IMO, we should have it because otherwise the hash values will become platform dependent. Anyone who is not aware of this specific detail might run into a problem. Two alternatives I can think of,
- Include line ending normalisation in the specification by default. Allow an option to not have it.
- Exclude line ending normalisation from this specification. However, recommend to give warnings on platforms (like Windows) while hashing files with
\r\nline endings. Through this someone who is unaware of this minor detail, will get to know that the hash value they are computing may not work uniformly on other platforms. If they intentionally want\r\n, then warnings are just minor noise which they can simply ignore.
Upon reading - #174 (comment) - it appears me that not having it is cleaner path - no confusion, all clean.
@mgorny I would like to have your opinion on this.
There was a problem hiding this comment.
That's a hard question. FWICS the hash is currently used to verify downloaded sources. IIRC git is capable of converting newlines upon checkout, so a need for newline normalization exists here. However, git generally carries indication as to binary/text files, so perhaps it would be good enough to limit the normalization to files where we know that the conversion happened.
That said, we probably shouldn't be using it for archives since it makes us vulnerable to zipbombs and compressor vulnerabilities.
There was a problem hiding this comment.
I sympathize with both points of view. "A hash function should only concern itself with bytes" is a very valid point, but I also want to get back to the intent of the original CEP: provide a(n) (extracted) contents-focused hash method for source verification. Independent of compression schemes, tools used or their versions. So it shouldn't matter if one clones the repository or downloads the tarball, same contents = same hash.
Line normalization is mentioned mostly because of git clone behavior across platforms. So maybe the thing to do here is to decouple line ending normalization rules from the hashing algorithm itself, and then mention in Rationale why this may be relevant on certain cases.
And worst case, folks would have to add a different hash on Windows.
However, git generally carries indication as to binary/text files, so perhaps it would be good enough to limit the normalization to files where we know that the conversion happened.
This is a good point, we only need this on git repos, so we can query git (e.g. via git ls-files --eol) to see what we need to do about line endings.
There was a problem hiding this comment.
After reading your comments, here's my proposal for how to handle the line ending normalization issue in the new hash algorithm.
1. Take line ending normalization out of the algorithm
The hash will cover raw bytes only: no UTF-8 decode attempt, no text/binary check, and no \r\n -> \n rewrite. Each file's contents are hashed exactly as they are on disk (length-prefixed).
This makes the hash a pure function of the directory contents.
2. Explain in the Rationale why line endings are left alone
The key point is that the file's bytes do not contain the answer. Take a Windows checkout with two files:
| File | index | working tree | bytes on disk |
|---|---|---|---|
lf.txt |
i/lf |
w/crlf |
a\r\nb\r\n |
crlf_committed.txt |
i/crlf |
w/crlf |
a\r\nb\r\n |
Both files have identical bytes on disk, but they need opposite treatment. lf.txt was converted by git on checkout, so it has to be converted back to match the tarball. crlf_committed.txt was committed with CRLF, so its CRLFs are real content and must be kept.
AFAICT, nothing inside the files can tell these two apart. Only the git index can. So the hash should stay a plain hash over bytes, and recovering the original form is a job for whoever has the index, not for the hashing algorithm.
This also shows a problem with the current rule: it normalizes both files and erases a difference that really exists.
3. Recommend how to avoid CRLF mismatches (in the Rationale, not normative)
Both of the following options apply to git checkouts. A downloaded tarball needs neither - extracting it does not rewrite line endings, so it already hashes the same on every platform. This is why the fix belongs at checkout time and not in the hash.
-
Fix it at checkout. Use
* text=auto eol=lfin.gitattributes, or setcore.autocrlf=input. This needs no tooling and solves the problem for everyone cloning the repository. -
If you cannot control the checkout, undo the conversion before hashing. Run
git ls-files --eoland look at each file's index and working tree line endings. For files reported asi/lf w/crlf, replace\r\nwith\nin memory before feeding the bytes to the hasher.
4. Use a separate hash only as a last resort
If two trees really differ in bytes, they are different contents and get different hashes. That is the algorithm doing its job and not a bug.
Where this is unavoidable - for example files marked eol=crlf or in sources that do not come from git, or files with mixed line endings - a recipe can carry a platform specific hash. This is the last fallback and Step 3 should make it less probable.
Please let me know your thoughts - if any step needs to be removed, added or modified. TY.
|
|
||
| This CEP supersedes [CEP 19](cep-0019.md) and amends the algorithm for computing the aggregated | ||
| hash of a directory's contents in a cross-platform way. The original algorithm was susceptible to | ||
| hash collisions when filenames contained the same byte sequences used as type markers or field |
There was a problem hiding this comment.
I suppose "or file contents" belongs here now.
| ## Specification | ||
|
|
||
| Given a directory (the "scanned directory"), recursively scan all its contents (without following | ||
| symlinks) and sort them by their path relative to the scanned directory. The scanned directory |
There was a problem hiding this comment.
This is still potentially ambiguous: you're talking of "sorting them" by relative paths, but you're not saying that relative paths are actually used until much later.
Also worth explicitly mentioning here that directories are collected as well (and whether that includes empty directories).
| - Before encoding a path to UTF-8 for sorting or hashing, implementations MUST normalize it to NFC. | ||
| - If NFC normalization produces two entries with the same path, implementations MUST error out |
There was a problem hiding this comment.
I'd combine these two points, given the second one is specifically talking about the result of the first one; while the points below are talking of separate steps.
|
|
||
| - Backslashes in the path MUST be normalized to forward slashes (e.g. `path\\to\\file` | ||
| becomes `path/to/file`). | ||
| - Redundant path components MUST be removed (e.g. `path/to/../to/file` becomes `path/to/file`). |
There was a problem hiding this comment.
Actually, why are we doing this? Since the input is from a scanning function, I suppose such entries can only be symlink targets. Why would we want to consider foo/../bar the same as bar? Presumably if you use two different targets, there is a reason for the difference, right?
| - Every path MUST be UTF-8-encodable. If a path contains bytes that are not valid UTF-8, | ||
| implementations MUST error out rather than silently substituting or passing through the invalid | ||
| bytes (see [Rationale](#why-require-utf-8-encodable-paths) for a Python-specific pitfall this | ||
| guards against). |
There was a problem hiding this comment.
Perhaps this point should be moved earlier on, especially that you're talking about UTF-8 encoding in the first two points, and making an explicit "before encoding" statement there.
| _DRIVE_LETTER_RE = re.compile(r"^[A-Za-z]:") | ||
|
|
||
|
|
||
| def _normalize_path(raw: str) -> str: |
There was a problem hiding this comment.
It's weird to be using "raw" when it's actually Unicode string. "Raw" makes me think of raw bytes.
| directory = Path(directory) | ||
| entries = [] | ||
| seen_paths = set() | ||
| for path in directory.rglob("*"): |
There was a problem hiding this comment.
Any reason not to use os.walk() here? I know it's just an example but I don't think you'd ever not want to use os.walk().
| seen_paths = set() | ||
| for path in directory.rglob("*"): | ||
| rel = _normalize_path(path.relative_to(directory).as_posix()) | ||
| _check_entry_path(rel) |
There was a problem hiding this comment.
This will never match anything, given how you obtain the input.
| seen_paths.add(rel) | ||
| entries.append((_encode_path(rel), path)) |
There was a problem hiding this comment.
Not a big deal but you could avoid duplicating the data by making entries a dict.
| endings does not fully solve this, since some data files are intentionally meant to carry | ||
| platform-specific line endings and would be misclassified if forced to a single style. |
There was a problem hiding this comment.
Well, strictly speaking, some data files may combine platform-specific line endings with raw data that doesn't decode as valid UTF-8 :-).
|
I have addressed the reviews locally. However, before pushing I would like to get an answer to my question in this comment. @hunger's opinion is already clear. Waiting for other participants. Once it's clear I will push the revised version. TYSM for your reviews. |
Checklist for submitter
cep-0000.mdnamedcep-XXXX.mdin the root level.CEP XX: Amend XYZ.## Changelogsection right above the final "Copyright" section with an item that uses syntaxYYYY-MM-DD: Brief explanation of changes.Checklist for CEP approvals
${greatest-number-in-main} + 1.cep-XXXX.mdfile has been renamed accordingly.# CEP XXXX -header has been edited accordingly.pre-commitchecks are passing.Summary
<len>:) to every variable-length field (relative path and symlink target) fed into the hasher, making all field boundaries unambiguous and provably collision-free.content_sha256/content_sha384/content_sha512recipe keys in favour of new_v2variants validated with the fixed algorithm; the legacy keys continue to work (with aPendingDeprecationWarning) for backwards compatibility.TODO - changes required in
cep-XXXX.mdbased on review threadSorting / ordering
<operator (it becomes unnecessary).Path normalization rules
./MUST be stripped (e.g../README.txt->README.txt)..) MUST NOT be included as an entry (rename "root directory" to "scanned directory" to avoid confusion with/)./MUST be rejected for entry paths.C:) and UNC prefixes (e.g.//server/share) MUST be stripped from entry paths before processing - Went as per @jaimergp's thoughts on this.\0) in any path component MUST cause the implementation to error out.\0,/,\\,:in filenames referencing their history as sources of archive-extraction vulnerabilities (or at minimum recommend that implementations reject them for consistency)..gitignore) in a directory tree, to demonstrate the sort position of names beginning with..Unicode normalization
Symlink target normalization
/, drive letters, and UNC prefixes MUST be rejected./is legitimate (valid absolute Unix path) and MUST be preserved. Windows drive letters (e.g.C:) and UNC prefixes (e.g.//server/share) MUST cause the implementation to error out, because there is no meaningful cross-platform way to normalize them into the hash stream../..removal still apply.File content hashing (text vs. binary)
\r\n->\nline-ending normalization - Went as per @jaimergp's thoughts on this.try/except UnicodeDecodeErrorblock and always open files in binary mode"rb")..gitattributes), not of the hashing algorithm.Python surrogateescape / non-UTF-8 filenames
surrogateescapeerror handler (PEP 383) may silently accept non-UTF-8 filenames on Unix.The reference implementation MUST encode paths such that non-UTF-8 filenames cause an explicit error rather than producing surrogates in the hash stream.
Add @hunger as co-author