@pipeworx/mcp-edge-case-museum 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +17 -12
  2. package/bin/cli.js +55 -11
  3. package/lib/index.js +333 -0
  4. package/package.json +17 -16
  5. package/server.json +1 -1
  6. package/tools.json +98 -0
  7. package/src/bake.mjs +0 -112
  8. package/src/ecm-index-data.ts +0 -15
  9. package/src/exhibits/datetime/add-24-hours-vs-add-1-day-across-dst.json +0 -51
  10. package/src/exhibits/datetime/add-365-days-to-feb-29-not-next-feb-29.json +0 -49
  11. package/src/exhibits/datetime/age-calculator-feb29-birthday-crash-vs-silent.json +0 -52
  12. package/src/exhibits/datetime/century-leap-year-1900-not-leap.json +0 -68
  13. package/src/exhibits/datetime/datetime-utcnow-naive-deprecated.json +0 -39
  14. package/src/exhibits/datetime/dst-fall-back-ambiguous-fold.json +0 -51
  15. package/src/exhibits/datetime/dst-spring-forward-nonexistent-time.json +0 -57
  16. package/src/exhibits/datetime/fractional-utc-offsets-kathmandu-kolkata.json +0 -50
  17. package/src/exhibits/datetime/iso-week-year-vs-calendar-year.json +0 -48
  18. package/src/exhibits/datetime/js-date-field-overflow-rollover.json +0 -49
  19. package/src/exhibits/datetime/leap-second-rejected-vs-silently-invalid.json +0 -57
  20. package/src/exhibits/datetime/lord-howe-30-minute-dst.json +0 -47
  21. package/src/exhibits/datetime/midnight-that-doesnt-exist-sao-paulo.json +0 -53
  22. package/src/exhibits/datetime/python-naive-aware-comparison-typeerror.json +0 -46
  23. package/src/exhibits/datetime/samoa-skipped-day-2011.json +0 -50
  24. package/src/exhibits/datetime/southern-hemisphere-dst-reversed.json +0 -50
  25. package/src/exhibits/datetime/two-digit-year-century-pivot-mismatch.json +0 -49
  26. package/src/exhibits/datetime/year-2038-int32-overflow.json +0 -52
  27. package/src/exhibits/filenames/absolute-second-argument-resets-join.json +0 -62
  28. package/src/exhibits/filenames/bidi-override-extension-spoof.json +0 -58
  29. package/src/exhibits/filenames/case-insensitive-filesystem-pure-path-mismatch.json +0 -74
  30. package/src/exhibits/filenames/dotfile-has-no-extension.json +0 -61
  31. package/src/exhibits/filenames/extension-trap-double-extension.json +0 -62
  32. package/src/exhibits/filenames/filename-length-limits-bytes-not-chars.json +0 -66
  33. package/src/exhibits/filenames/leading-dash-filename-parsed-as-flag.json +0 -67
  34. package/src/exhibits/filenames/nfc-nfd-filename-lookup-mismatch.json +0 -68
  35. package/src/exhibits/filenames/ntfs-alternate-data-stream-colon.json +0 -59
  36. package/src/exhibits/filenames/ntpath-splitdrive-unc-paths.json +0 -58
  37. package/src/exhibits/filenames/nul-byte-in-filename-raises-at-open.json +0 -60
  38. package/src/exhibits/filenames/path-join-escapes-the-base-directory.json +0 -63
  39. package/src/exhibits/filenames/windows-backslash-traversal-ntpath.json +0 -62
  40. package/src/exhibits/filenames/windows-reserved-device-names.json +0 -73
  41. package/src/exhibits/filenames/windows-trailing-dot-space-stripped.json +0 -77
  42. package/src/exhibits/float-money/decimal-from-float-vs-string.json +0 -66
  43. package/src/exhibits/float-money/float-anchor-0-1-plus-0-2.json +0 -67
  44. package/src/exhibits/float-money/intl-number-format-de-de-roundtrip-misparsed.json +0 -62
  45. package/src/exhibits/float-money/iso4217-minor-units-not-always-two.json +0 -78
  46. package/src/exhibits/float-money/math-abs-negative-zero.json +0 -51
  47. package/src/exhibits/float-money/max-safe-integer-plus-two.json +0 -62
  48. package/src/exhibits/float-money/naive-loop-sum-order-dependence.json +0 -71
  49. package/src/exhibits/float-money/nan-breaks-equality-and-max-min.json +0 -70
  50. package/src/exhibits/float-money/negative-zero-balance-formatting.json +0 -74
  51. package/src/exhibits/float-money/number-epsilon-false-positive-small-magnitude.json +0 -52
  52. package/src/exhibits/float-money/parsefloat-comma-thousands-separator.json +0 -66
  53. package/src/exhibits/float-money/python-banker-rounding-vs-js-round-half-up.json +0 -65
  54. package/src/exhibits/float-money/python-round-2-675-float-imprecision.json +0 -64
  55. package/src/exhibits/float-money/splitting-100-dollars-three-ways-leaves-a-cent.json +0 -63
  56. package/src/exhibits/float-money/sum-ten-dimes-not-a-dollar.json +0 -77
  57. package/src/exhibits/float-money/tofixed-1-005-rounds-down-not-up.json +0 -63
  58. package/src/exhibits/float-money/tofixed-2-345-inconsistent-rounding.json +0 -64
  59. package/src/exhibits/integers/array-index-above-2-32-2-not-an-index.json +0 -42
  60. package/src/exhibits/integers/bigint-number-mixing-typeerror.json +0 -49
  61. package/src/exhibits/integers/bitwise-32-bit-truncation.json +0 -56
  62. package/src/exhibits/integers/bool-is-int-subclass-dict-key-collision.json +0 -58
  63. package/src/exhibits/integers/floor-div-mod-sign-python-vs-js.json +0 -48
  64. package/src/exhibits/integers/json-parse-loses-id-precision.json +0 -58
  65. package/src/exhibits/integers/max-safe-integer-plus-one-equals-plus-two.json +0 -58
  66. package/src/exhibits/integers/number-empty-and-whitespace-string-equals-zero.json +0 -55
  67. package/src/exhibits/integers/parseint-null-radix36-digit-soup.json +0 -57
  68. package/src/exhibits/integers/parseint-number-argument-scientific-notation.json +0 -48
  69. package/src/exhibits/integers/parseint-stops-at-first-invalid-char-vs-number.json +0 -58
  70. package/src/exhibits/integers/python-int-accepts-fullwidth-unicode-digits.json +0 -56
  71. package/src/exhibits/integers/python-int-accepts-underscores-and-whitespace.json +0 -48
  72. package/src/exhibits/integers/python-unbounded-int-vs-fixed-width-wrap.json +0 -49
  73. package/src/exhibits/names-addresses/address-with-no-street-number.json +0 -54
  74. package/src/exhibits/names-addresses/ascii-regex-rejects-accented-and-hyphenated-names.json +0 -55
  75. package/src/exhibits/names-addresses/house-number-suffix-collapsed-by-digit-extraction.json +0 -55
  76. package/src/exhibits/names-addresses/mononym-fails-first-last-required.json +0 -56
  77. package/src/exhibits/names-addresses/naive-lowercase-breaks-greek-final-sigma.json +0 -64
  78. package/src/exhibits/names-addresses/null-surname-coerced-to-null-value.json +0 -58
  79. package/src/exhibits/names-addresses/single-letter-surname-fails-min-length.json +0 -55
  80. package/src/exhibits/names-addresses/sort-by-surname-particle-convention-mismatch.json +0 -57
  81. package/src/exhibits/names-addresses/spanish-double-surname-treated-as-single.json +0 -55
  82. package/src/exhibits/names-addresses/split-surname-on-last-space-loses-particle.json +0 -59
  83. package/src/exhibits/names-addresses/title-case-mangles-mixed-case-and-apostrophes.json +0 -60
  84. package/src/exhibits/names-addresses/uk-canada-postal-code-assumed-numeric.json +0 -55
  85. package/src/exhibits/names-addresses/us-zip-code-leading-zero-lost-as-integer.json +0 -58
  86. package/src/exhibits/names-addresses/varchar-truncation-splits-name-mid-grapheme.json +0 -63
  87. package/src/exhibits/parsing/csv-crlf-trailing-blank-line.json +0 -87
  88. package/src/exhibits/parsing/csv-doubled-quote-escaping.json +0 -70
  89. package/src/exhibits/parsing/csv-formula-injection-excel.json +0 -71
  90. package/src/exhibits/parsing/csv-quoted-field-with-comma.json +0 -70
  91. package/src/exhibits/parsing/csv-quoted-field-with-newline.json +0 -70
  92. package/src/exhibits/parsing/csv-semicolon-delimited-european-excel.json +0 -70
  93. package/src/exhibits/parsing/csv-utf8-bom-first-header.json +0 -74
  94. package/src/exhibits/parsing/email-regex-rejects-valid-apostrophe-address.json +0 -68
  95. package/src/exhibits/parsing/json-big-integer-precision-loss.json +0 -65
  96. package/src/exhibits/parsing/json-duplicate-keys-last-wins.json +0 -64
  97. package/src/exhibits/parsing/json-lone-surrogate-roundtrip.json +0 -58
  98. package/src/exhibits/parsing/json-nan-infinity-not-valid-json.json +0 -54
  99. package/src/exhibits/parsing/url-backslash-treated-as-slash-whatwg.json +0 -60
  100. package/src/exhibits/parsing/url-urlparse-never-raises-on-garbage.json +0 -60
  101. package/src/exhibits/parsing/url-userinfo-trusted-looking-host.json +0 -60
  102. package/src/exhibits/sorting/accented-letters-sort-after-z.json +0 -63
  103. package/src/exhibits/sorting/byte-order-caps-before-lowercase.json +0 -66
  104. package/src/exhibits/sorting/case-insensitive-sort-lower-vs-casefold.json +0 -56
  105. package/src/exhibits/sorting/comparator-returns-boolean-js.json +0 -49
  106. package/src/exhibits/sorting/js-array-sort-stringifies-numbers.json +0 -68
  107. package/src/exhibits/sorting/locale-collation-de-vs-sv.json +0 -54
  108. package/src/exhibits/sorting/localecompare-numeric-option.json +0 -50
  109. package/src/exhibits/sorting/nan-in-sort.json +0 -59
  110. package/src/exhibits/sorting/natural-sort-breaks-lexicographic.json +0 -63
  111. package/src/exhibits/sorting/postgres-collation-documented.json +0 -61
  112. package/src/exhibits/sorting/python-cannot-compare-str-int.json +0 -70
  113. package/src/exhibits/sorting/reverse-sort-vs-reverse-after-sort-ties.json +0 -48
  114. package/src/exhibits/sorting/sorting-mixed-none-null.json +0 -56
  115. package/src/exhibits/sorting/typed-array-sort-is-numeric-by-default.json +0 -48
  116. package/src/exhibits/unicode/arabic-indic-digit-parses.json +0 -73
  117. package/src/exhibits/unicode/bidi-override-filename-spoof.json +0 -62
  118. package/src/exhibits/unicode/cyrillic-homoglyph-paypal-spoof.json +0 -67
  119. package/src/exhibits/unicode/fullwidth-bypasses-keyword-filter.json +0 -61
  120. package/src/exhibits/unicode/german-eszett-case-fold.json +0 -72
  121. package/src/exhibits/unicode/grapheme-vs-codepoint-length.json +0 -57
  122. package/src/exhibits/unicode/greek-final-sigma-casefold.json +0 -54
  123. package/src/exhibits/unicode/js-word-regex-ascii-only.json +0 -64
  124. package/src/exhibits/unicode/nfc-nfd-same-name-two-accounts.json +0 -70
  125. package/src/exhibits/unicode/nfkc-compatibility-folding.json +0 -63
  126. package/src/exhibits/unicode/truncation-orphans-combining-mark.json +0 -62
  127. package/src/exhibits/unicode/truncation-splits-surrogate-pair.json +0 -65
  128. package/src/exhibits/unicode/turkish-dotless-i-case-mapping.json +0 -72
  129. package/src/exhibits/unicode/zalgo-combining-mark-stacking.json +0 -57
  130. package/src/exhibits/unicode/zero-width-space-username.json +0 -62
  131. package/src/harness.mjs +0 -331
  132. package/src/index.ts +0 -686
  133. package/src/server.ts +0 -49
  134. package/src/verified.json +0 -977
  135. package/tsconfig.json +0 -18
@@ -1,47 +0,0 @@
1
- {
2
- "id": "lord-howe-30-minute-dst",
3
- "title": "Lord Howe Island's clocks only move 30 minutes for daylight saving, not an hour",
4
- "category": "datetime",
5
- "assumption": "Daylight saving time always shifts the clock by exactly one hour.",
6
- "input": {
7
- "display": "ZoneInfo(\"Australia/Lord_Howe\") offset: +10:30 in southern winter (July), +11:00 in southern summer (January)",
8
- "description": "Any scheduling code that assumes 'DST offset == standard offset +/- 60 minutes' and uses that to detect or compute DST transitions."
9
- },
10
- "what_goes_wrong": "Code that infers 'is this a DST transition' by checking whether the offset changed by exactly 60 minutes misses Lord Howe Island's transitions (and misclassifies the island's clock change as a data error).",
11
- "why": "Australia/Lord_Howe is currently the only zone in the tz database whose DST shift is 30 minutes: standard time is UTC+10:30, and daylight time is UTC+11:00, a half-hour step instead of the usual hour. The IANA tz database encodes this per-zone; there is no general rule that DST is always 60 minutes, so hardcoding that assumption breaks for this zone even though every API used (zoneinfo, Intl) reports the real offset correctly. (DST shifts of other sizes exist too: Antarctica/Troll moves two hours.)",
12
- "affects": [
13
- "python",
14
- "javascript"
15
- ],
16
- "repros": [
17
- {
18
- "language": "python",
19
- "code": "from zoneinfo import ZoneInfo\nfrom datetime import datetime\n\ntz = ZoneInfo(\"Australia/Lord_Howe\")\nsummer = datetime(2024, 1, 15, 12, 0, tzinfo=tz) # DST active (southern summer)\nwinter = datetime(2024, 7, 15, 12, 0, tzinfo=tz) # standard time\nprint(\"DST offset:\", summer.utcoffset(), \"dst() component:\", summer.dst())\nprint(\"standard offset:\", winter.utcoffset(), \"dst() component:\", winter.dst())\nprint(\"shift between the two, in minutes:\", (summer.utcoffset() - winter.utcoffset()).total_seconds() / 60)\n",
20
- "expected_output": "DST offset: 11:00:00 dst() component: 0:30:00\nstandard offset: 10:30:00 dst() component: 0:00:00\nshift between the two, in minutes: 30.0",
21
- "verified_with": "Python 3.14.2"
22
- },
23
- {
24
- "language": "javascript",
25
- "code": "function offsetMinutes(utcMs, timeZone) {\n const dtf = new Intl.DateTimeFormat('en-US', { timeZone, hour12: false,\n year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' });\n const parts = dtf.formatToParts(new Date(utcMs));\n const o = {};\n for (const p of parts) o[p.type] = p.value;\n const asUTC = Date.UTC(+o.year, +o.month - 1, +o.day, +o.hour, +o.minute, +o.second);\n return (asUTC - utcMs) / 60000;\n}\nconst summer = offsetMinutes(Date.UTC(2024, 0, 15, 12, 0, 0), 'Australia/Lord_Howe');\nconst winter = offsetMinutes(Date.UTC(2024, 6, 15, 12, 0, 0), 'Australia/Lord_Howe');\nconsole.log('DST offset (min):', summer, '| standard offset (min):', winter, '| shift (min):', summer - winter);\n",
26
- "expected_output": "DST offset (min): 660 | standard offset (min): 630 | shift (min): 30",
27
- "verified_with": "Node.js v22.22.2"
28
- }
29
- ],
30
- "fix": "Never hardcode the size of a DST shift. Ask the timezone library for the actual UTC offset at each instant and diff those; don't assume +/-60 minutes anywhere in validation or transition-detection logic.",
31
- "related": [
32
- "fractional-utc-offsets-kathmandu-kolkata",
33
- "southern-hemisphere-dst-reversed"
34
- ],
35
- "sources": [
36
- {
37
- "title": "IANA Time Zone Database",
38
- "url": "https://www.iana.org/time-zones"
39
- }
40
- ],
41
- "tags": [
42
- "timezone",
43
- "scheduling",
44
- "date parsing"
45
- ],
46
- "test_cases": []
47
- }
@@ -1,53 +0,0 @@
1
- {
2
- "id": "midnight-that-doesnt-exist-sao-paulo",
3
- "title": "Midnight on November 4, 2018 never happened in Sao Paulo",
4
- "category": "datetime",
5
- "assumption": "Midnight -- 00:00:00 local time -- always exists, so 'start of day' is always a safe local timestamp to construct.",
6
- "input": {
7
- "display": "America/Sao_Paulo: local clock jumps from 23:59:59 on Nov 3, 2018 (-03:00) directly to 01:00:00 on Nov 4, 2018 (-02:00) -- the entire hour from midnight to 1 AM is skipped",
8
- "description": "'Start of day' timestamps (billing cutoffs, daily report boundaries, cache-expiry-at-midnight logic) for Brazil's historical DST transitions, which took effect at local midnight rather than at the 2 AM US developers are used to."
9
- },
10
- "what_goes_wrong": "Code that constructs 'local midnight' as the boundary between two days builds a wall-clock time that never existed in Sao Paulo on the day its 2018 DST started, and zoneinfo resolves it with the pre-transition offset rather than flagging anything.",
11
- "why": "US and EU clock changes happen in the small hours, away from midnight, but Brazil changed its clocks at local midnight (00:00) for as long as it observed DST, which ended when Brazil abolished it in 2019 (the last DST period ended in February 2019). On 2018-11-04, America/Sao_Paulo's clock went straight from 23:59:59 (-03:00, standard time) on November 3rd to 01:00:00 (-02:00, daylight time) on November 4th -- so every local wall-clock time from 00:00 to 00:59 on the 4th does not exist, exactly like the America/New_York spring-forward gap, just at midnight instead of 2 AM. zoneinfo constructs the nonexistent 00:30 anyway, silently assigning it the pre-transition offset (-03:00). For 00:00 itself that happens to land on 03:00 UTC, which is the day's real first instant, 01:00 -02:00; but the object still claims to be 00:00, so code that formats it, or that assumes the day began 24 hours before the next midnight, is wrong (that day had 23 hours).",
12
- "affects": [
13
- "python"
14
- ],
15
- "repros": [
16
- {
17
- "language": "python",
18
- "code": "from zoneinfo import ZoneInfo\nfrom datetime import datetime\n\ntz = ZoneInfo(\"America/Sao_Paulo\")\nnonexistent_midnight = datetime(2018, 11, 4, 0, 30, tzinfo=tz)\nprint(nonexistent_midnight, nonexistent_midnight.utcoffset())\nprint(\"as UTC:\", nonexistent_midnight.astimezone(ZoneInfo(\"UTC\")))\n\n# compare to the clock on either side of the gap\nbefore = datetime(2018, 11, 3, 23, 30, tzinfo=tz)\nafter = datetime(2018, 11, 4, 1, 30, tzinfo=tz)\nprint(\"23:30 Nov 3 offset:\", before.utcoffset(), \"| 01:30 Nov 4 offset:\", after.utcoffset())\n",
19
- "expected_output": "2018-11-04 00:30:00-03:00 -1 day, 21:00:00\nas UTC: 2018-11-04 03:30:00+00:00\n23:30 Nov 3 offset: -1 day, 21:00:00 | 01:30 Nov 4 offset: -1 day, 22:00:00",
20
- "verified_with": "Python 3.14.2"
21
- },
22
- {
23
- "language": "javascript",
24
- "code": "const fmt = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Sao_Paulo', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });\nfor (const h of [2, 3]) { // UTC hours straddling the local midnight jump\n const t = Date.UTC(2018, 10, 4, h, 0, 0);\n console.log(h, 'UTC ->', fmt.format(new Date(t)));\n}\n",
25
- "expected_output": "2 UTC -> 11/03/2018, 23:00\n3 UTC -> 11/04/2018, 01:00",
26
- "verified_with": "Node.js v22.22.2"
27
- }
28
- ],
29
- "fix": "Never assume 'local midnight' exists as a timestamp without checking the zone's transition rules for that specific date; compute day boundaries from a UTC instant and a zone-aware library rather than hand-constructing 00:00:00 local time.",
30
- "related": [
31
- "dst-spring-forward-nonexistent-time"
32
- ],
33
- "sources": [
34
- {
35
- "title": "IANA Time Zone Database",
36
- "url": "https://www.iana.org/time-zones"
37
- },
38
- {
39
- "title": "Python docs: zoneinfo",
40
- "url": "https://docs.python.org/3/library/zoneinfo.html"
41
- },
42
- {
43
- "title": "IANA tz database source, southamerica file (Brazil rules)",
44
- "url": "https://github.com/eggert/tz/blob/main/southamerica"
45
- }
46
- ],
47
- "tags": [
48
- "timezone",
49
- "scheduling",
50
- "date parsing"
51
- ],
52
- "test_cases": []
53
- }
@@ -1,46 +0,0 @@
1
- {
2
- "id": "python-naive-aware-comparison-typeerror",
3
- "title": "Comparing a timezone-aware datetime to a naive one doesn't give the wrong answer -- it refuses to answer at all",
4
- "category": "datetime",
5
- "assumption": "Two datetime objects can always be compared with < or == as long as they represent real moments in time.",
6
- "input": {
7
- "display": "datetime(2024, 1, 1) < datetime(2024, 1, 1, tzinfo=timezone.utc)",
8
- "description": "Mixing a datetime parsed from a source that attaches a timezone (e.g. an API response with an offset) with one created locally via datetime.now() or a form field with no timezone info, then comparing or sorting them together."
9
- },
10
- "what_goes_wrong": "Sorting or comparing a list of datetimes where some came from a timezone-aware source and others didn't raises `TypeError` at the comparison, not at creation, often deep inside a sort() call far from where the mismatched datetime was built.",
11
- "why": "Python deliberately refuses to compare a 'naive' datetime (no attached tzinfo, interpretation of its clock reading is unstated) with an 'aware' one (tzinfo attached, unambiguous UTC instant) via the ordering operators (<, <=, >, >=), because there is no safe default assumption to make about what timezone the naive one is in. This is a documented decision, not a bug: Python could guess the naive value is UTC or local time, but either guess could silently be wrong, so it raises instead. Equality is the exception: since Python 3.3, == between a naive and an aware datetime returns False instead of raising, so a mixed pair silently never matches (the repro's last line).",
12
- "affects": [
13
- "python"
14
- ],
15
- "repros": [
16
- {
17
- "language": "python",
18
- "code": "from datetime import datetime, timezone\n\nnaive = datetime(2024, 1, 1)\naware = datetime(2024, 1, 1, tzinfo=timezone.utc)\ntry:\n naive < aware\nexcept TypeError as e:\n print(\"TypeError:\", e)\n\n# Equality does NOT raise -- it just always says they're different, which is its own trap.\nprint(\"naive == aware (no error, but is this right?):\", naive == aware)\n",
19
- "expected_output": "TypeError: can't compare offset-naive and offset-aware datetimes\nnaive == aware (no error, but is this right?): False",
20
- "verified_with": "Python 3.14.2"
21
- },
22
- {
23
- "language": "javascript",
24
- "code": "// JS Date has no naive/aware distinction: every Date is always a UTC instant,\n// so no equivalent comparison error can occur; the comparison always succeeds.\nconst a = new Date(Date.UTC(2024, 0, 1));\nconst b = new Date(Date.UTC(2024, 0, 1));\nconsole.log('a < b:', a < b, '| a.getTime() === b.getTime():', a.getTime() === b.getTime());\n",
25
- "expected_output": "a < b: false | a.getTime() === b.getTime(): true",
26
- "verified_with": "Node.js v22.22.2"
27
- }
28
- ],
29
- "fix": "Pick one representation (aware, ideally UTC) at every boundary where a datetime enters your system, and convert immediately; never let a naive and an aware datetime coexist in the same comparison, sort, or collection.",
30
- "related": [
31
- "datetime-utcnow-naive-deprecated"
32
- ],
33
- "sources": [
34
- {
35
- "title": "Python docs: datetime -- Aware and Naive Objects",
36
- "url": "https://docs.python.org/3/library/datetime.html#aware-and-naive-objects"
37
- }
38
- ],
39
- "tags": [
40
- "timezone",
41
- "sorting",
42
- "comparison",
43
- "database"
44
- ],
45
- "test_cases": []
46
- }
@@ -1,50 +0,0 @@
1
- {
2
- "id": "samoa-skipped-day-2011",
3
- "title": "December 30, 2011 does not exist on the calendar in Samoa",
4
- "category": "datetime",
5
- "assumption": "Every calendar date between two dates that both exist must itself exist.",
6
- "input": {
7
- "display": "Pacific/Apia: local calendar goes from 2011-12-29 directly to 2011-12-31 -- Dec 30, 2011 was skipped entirely",
8
- "description": "Date-range iteration, 'days since' calculations, or calendar/report generation for Samoa and a few other Pacific nations that moved across the International Date Line."
9
- },
10
- "what_goes_wrong": "Stepping through local days by adding 24 hours to an instant jumps straight from Dec 29 to Dec 31, 2011 in Samoa, while code that iterates naive calendar dates produces a Dec 30 row whose local times never happened -- and constructing one succeeds silently with the old -10:00 offset.",
11
- "why": "Samoa moved from the east side of the International Date Line to the west side to align with its main trading partners (Australia and New Zealand), skipping Friday, December 30, 2011 entirely: the clock went from 23:59:59 on Thursday the 29th straight to 00:00:00 on Saturday the 31st. This was a one-time political decision recorded in the IANA tz database, not a recurring rule. zoneinfo does not refuse to construct a datetime for the skipped day -- like the DST spring-forward gap, it silently resolves it using the offset in effect just before the jump (UTC-10) rather than raising an error.",
12
- "affects": [
13
- "python",
14
- "javascript"
15
- ],
16
- "repros": [
17
- {
18
- "language": "python",
19
- "code": "from zoneinfo import ZoneInfo\nfrom datetime import datetime\n\ntz = ZoneInfo(\"Pacific/Apia\")\nbefore = datetime(2011, 12, 29, 12, 0, tzinfo=tz)\nafter = datetime(2011, 12, 31, 12, 0, tzinfo=tz)\nskipped = datetime(2011, 12, 30, 12, 0, tzinfo=tz) # the day that never happened\nprint(\"before:\", before, before.utcoffset())\nprint(\"after:\", after, after.utcoffset())\nprint(\"'skipped day' constructed anyway:\", skipped, skipped.utcoffset())\nprint(\"before -> after, real hours apart:\", (after.astimezone(ZoneInfo('UTC')) - before.astimezone(ZoneInfo('UTC'))).total_seconds() / 3600)\n",
20
- "expected_output": "before: 2011-12-29 12:00:00-10:00 -1 day, 14:00:00\nafter: 2011-12-31 12:00:00+14:00 14:00:00\n'skipped day' constructed anyway: 2011-12-30 12:00:00-10:00 -1 day, 14:00:00\nbefore -> after, real hours apart: 24.0",
21
- "verified_with": "Python 3.14.2"
22
- },
23
- {
24
- "language": "javascript",
25
- "code": "const fmt = new Intl.DateTimeFormat('en-US', { timeZone: 'Pacific/Apia', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });\n// Walk hour by hour through the UTC window and watch the local calendar date jump.\nfor (let h = 8; h <= 11; h++) {\n const t = Date.UTC(2011, 11, 30, h, 0, 0);\n console.log(h, 'UTC on Dec 30 ->', fmt.format(new Date(t)));\n}\n",
26
- "expected_output": "8 UTC on Dec 30 -> 12/29/2011, 22:00\n9 UTC on Dec 30 -> 12/29/2011, 23:00\n10 UTC on Dec 30 -> 12/31/2011, 00:00\n11 UTC on Dec 30 -> 12/31/2011, 01:00",
27
- "verified_with": "Node.js v22.22.2"
28
- }
29
- ],
30
- "fix": "Never assume calendar dates are contiguous across arbitrary ranges for arbitrary zones; let the tz-aware library enumerate real local dates (or do the iteration in UTC and convert each instant to local for display) rather than looping over a naive date range.",
31
- "related": [
32
- "dst-spring-forward-nonexistent-time"
33
- ],
34
- "sources": [
35
- {
36
- "title": "IANA Time Zone Database",
37
- "url": "https://www.iana.org/time-zones"
38
- },
39
- {
40
- "title": "Python docs: zoneinfo",
41
- "url": "https://docs.python.org/3/library/zoneinfo.html"
42
- }
43
- ],
44
- "tags": [
45
- "timezone",
46
- "date parsing",
47
- "scheduling"
48
- ],
49
- "test_cases": []
50
- }
@@ -1,50 +0,0 @@
1
- {
2
- "id": "southern-hemisphere-dst-reversed",
3
- "title": "Sydney is on daylight saving time in January and off it in July -- the opposite of New York",
4
- "category": "datetime",
5
- "assumption": "Daylight saving time is 'on' roughly March through November and 'off' in the winter months.",
6
- "input": {
7
- "display": "Australia/Sydney: UTC+11 (DST) in January, UTC+10 (standard) in July; America/New_York: UTC-4 (DST) in July, UTC-5 (standard) in January",
8
- "description": "A 'business hours' or 'is it currently DST' heuristic that hardcodes a month range, applied to users in the Southern Hemisphere."
9
- },
10
- "what_goes_wrong": "A heuristic like 'DST is active for timestamps between March and November' (true for most of the Northern Hemisphere) silently inverts for Australian, South American, and other Southern Hemisphere users, who are in DST during the Northern Hemisphere's winter.",
11
- "why": "Daylight saving time is tied to each hemisphere's local summer, when the extra evening daylight is wanted, so the Southern Hemisphere's DST season (roughly October to April) runs opposite to the Northern Hemisphere's (roughly March to November). There is no universal 'DST months' rule in the tz database; each zone's transition rule is independent and the only correct way to know whether DST is active at a given instant is to ask the zone, not a hardcoded month range.",
12
- "affects": [
13
- "python",
14
- "javascript"
15
- ],
16
- "repros": [
17
- {
18
- "language": "python",
19
- "code": "from zoneinfo import ZoneInfo\nfrom datetime import datetime\n\nsydney = ZoneInfo(\"Australia/Sydney\")\nny = ZoneInfo(\"America/New_York\")\njan_sydney = datetime(2024, 1, 15, 12, 0, tzinfo=sydney)\njul_sydney = datetime(2024, 7, 15, 12, 0, tzinfo=sydney)\njan_ny = datetime(2024, 1, 15, 12, 0, tzinfo=ny)\njul_ny = datetime(2024, 7, 15, 12, 0, tzinfo=ny)\nprint(\"Sydney Jan DST active:\", jan_sydney.dst() != __import__('datetime').timedelta(0), \"| Jul DST active:\", jul_sydney.dst() != __import__('datetime').timedelta(0))\nprint(\"New York Jan DST active:\", jan_ny.dst() != __import__('datetime').timedelta(0), \"| Jul DST active:\", jul_ny.dst() != __import__('datetime').timedelta(0))\n",
20
- "expected_output": "Sydney Jan DST active: True | Jul DST active: False\nNew York Jan DST active: False | Jul DST active: True",
21
- "verified_with": "Python 3.14.2"
22
- },
23
- {
24
- "language": "javascript",
25
- "code": "function offsetMinutes(utcMs, timeZone) {\n const dtf = new Intl.DateTimeFormat('en-US', { timeZone, hour12: false,\n year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' });\n const parts = dtf.formatToParts(new Date(utcMs));\n const o = {};\n for (const p of parts) o[p.type] = p.value;\n const asUTC = Date.UTC(+o.year, +o.month - 1, +o.day, +o.hour, +o.minute, +o.second);\n return (asUTC - utcMs) / 60000;\n}\nconst jan = Date.UTC(2024, 0, 15, 12, 0, 0);\nconst jul = Date.UTC(2024, 6, 15, 12, 0, 0);\nconsole.log('Sydney Jan offset (min):', offsetMinutes(jan, 'Australia/Sydney'), '| Jul offset (min):', offsetMinutes(jul, 'Australia/Sydney'));\nconsole.log('New York Jan offset (min):', offsetMinutes(jan, 'America/New_York'), '| Jul offset (min):', offsetMinutes(jul, 'America/New_York'));\n",
26
- "expected_output": "Sydney Jan offset (min): 660 | Jul offset (min): 600\nNew York Jan offset (min): -300 | Jul offset (min): -240",
27
- "verified_with": "Node.js v22.22.2"
28
- }
29
- ],
30
- "fix": "Never infer whether DST is active from the calendar month. Ask the timezone library for the UTC offset (or, in Python, the `.dst()` component) at the specific instant and zone you care about.",
31
- "related": [
32
- "lord-howe-30-minute-dst"
33
- ],
34
- "sources": [
35
- {
36
- "title": "IANA Time Zone Database",
37
- "url": "https://www.iana.org/time-zones"
38
- },
39
- {
40
- "title": "Python docs: datetime.dst",
41
- "url": "https://docs.python.org/3/library/datetime.html#datetime.datetime.dst"
42
- }
43
- ],
44
- "tags": [
45
- "timezone",
46
- "scheduling",
47
- "date parsing"
48
- ],
49
- "test_cases": []
50
- }
@@ -1,49 +0,0 @@
1
- {
2
- "id": "two-digit-year-century-pivot-mismatch",
3
- "title": "The same two-digit year string lands in a different century depending on which language parses it",
4
- "category": "datetime",
5
- "assumption": "A two-digit year like '68' means the same calendar year no matter which system parses the date string it's embedded in.",
6
- "input": {
7
- "display": "\"01/01/68\" parsed by Python's strptime(\"%m/%d/%y\") vs JavaScript's legacy Date(string) parser",
8
- "description": "A date string with a 2-digit year (common in older CSV exports, legacy forms, and some locale-formatted dates) passed between a Python backend and a JavaScript frontend."
9
- },
10
- "what_goes_wrong": "The same string \"01/01/68\" is read as 2068 by Python and 1968 by Node.js, so a date passed between the two silently moves a century; years 00-49 and 69-99 agree, hiding the bug.",
11
- "why": "Python's strptime with %y follows the POSIX convention: two-digit years 00-68 become 2000-2068 and 69-99 become 1969-1999. V8's legacy (non-ISO) Date parser, used by Node.js and Chrome, pivots at 50 instead: 00-49 become 2000-2049 and 50-99 become 1950-1999. So for the nineteen years 50 through 68 the two runtimes disagree by exactly a century, while every other two-digit year agrees, which is why the bug survives casual testing. ECMA-262 leaves parsing of non-ISO date strings implementation-defined, so other JavaScript engines are free to choose differently again.",
12
- "affects": [
13
- "python",
14
- "javascript"
15
- ],
16
- "repros": [
17
- {
18
- "language": "python",
19
- "code": "from datetime import datetime\n\nfor s in (\"01/01/49\", \"01/01/50\", \"01/01/68\", \"01/01/69\"):\n print(s, \"->\", datetime.strptime(s, \"%m/%d/%y\").year)\n",
20
- "expected_output": "01/01/49 -> 2049\n01/01/50 -> 2050\n01/01/68 -> 2068\n01/01/69 -> 1969",
21
- "verified_with": "Python 3.14.2"
22
- },
23
- {
24
- "language": "javascript",
25
- "code": "for (const s of ['01/01/49', '01/01/50', '01/01/68', '01/01/69']) {\n console.log(s, '->', new Date(s).getFullYear());\n}\n",
26
- "expected_output": "01/01/49 -> 2049\n01/01/50 -> 1950\n01/01/68 -> 1968\n01/01/69 -> 1969",
27
- "verified_with": "Node.js v22.22.2"
28
- }
29
- ],
30
- "fix": "Never rely on implicit two-digit-year parsing across a language boundary. Require 4-digit years at every system edge, or, if you must accept 2-digit years, write and apply your own explicit pivot rule identically on both sides.",
31
- "related": [
32
- "century-leap-year-1900-not-leap"
33
- ],
34
- "sources": [
35
- {
36
- "title": "Python docs: strftime/strptime format codes (%y)",
37
- "url": "https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes"
38
- },
39
- {
40
- "title": "ECMA-262: Date.parse (non-ISO formats are implementation-defined)",
41
- "url": "https://tc39.es/ecma262/#sec-date.parse"
42
- }
43
- ],
44
- "tags": [
45
- "date parsing",
46
- "validation"
47
- ],
48
- "test_cases": []
49
- }
@@ -1,52 +0,0 @@
1
- {
2
- "id": "year-2038-int32-overflow",
3
- "title": "The Unix clock runs out of room in a signed 32-bit integer on January 19, 2038",
4
- "category": "datetime",
5
- "assumption": "A timestamp stored as 'seconds since the Unix epoch' can represent any date you throw at it.",
6
- "input": {
7
- "display": "2**31 - 1 seconds since 1970-01-01T00:00:00Z == 2038-01-19T03:14:07Z; one more second overflows a signed 32-bit integer",
8
- "description": "Any system storing Unix time in a 32-bit signed integer column or struct field: older database schemas, C structs, embedded firmware, some binary file formats."
9
- },
10
- "what_goes_wrong": "A timestamp stored as a signed 32-bit seconds-since-epoch field wraps to a large negative number (around 1901) the instant it passes 2038-01-19T03:14:07Z, making a future date look like it's over a century in the past.",
11
- "why": "2**31 - 1 (2,147,483,647) is the largest value a signed 32-bit integer can hold, and it corresponds to exactly 2038-01-19T03:14:07 UTC when interpreted as seconds since the Unix epoch. The next second would need to store 2**31, which a signed 32-bit integer cannot represent; it wraps around to -2**31, which as a Unix timestamp is in December 1901. Modern Python and JavaScript date objects aren't limited this way (Python's int is arbitrary precision and JS Date uses a 64-bit float of milliseconds), but anything that stores or transmits time as a literal signed 32-bit integer -- which includes plenty of still-running C code, old filesystems, and database columns -- is exposed exactly like a 2-digit-year Y2K bug, just 38 years later.",
12
- "affects": [
13
- "python",
14
- "javascript",
15
- "c",
16
- "any 32-bit signed time_t"
17
- ],
18
- "repros": [
19
- {
20
- "language": "python",
21
- "code": "from datetime import datetime, timezone, timedelta\n\nmax_int32_seconds = 2**31 - 1\nboundary = datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=max_int32_seconds)\nprint(\"last representable instant in signed 32-bit Unix time:\", boundary)\n\n# Simulate the wraparound a 32-bit signed integer would actually do.\ndef wrap_int32(n):\n n &= 0xFFFFFFFF\n return n - 0x100000000 if n >= 0x80000000 else n\n\nprint(\"next second, wrapped to signed 32-bit:\", wrap_int32(max_int32_seconds + 1))\n",
22
- "expected_output": "last representable instant in signed 32-bit Unix time: 2038-01-19 03:14:07+00:00\nnext second, wrapped to signed 32-bit: -2147483648",
23
- "verified_with": "Python 3.14.2"
24
- },
25
- {
26
- "language": "javascript",
27
- "code": "const maxInt32Seconds = 2 ** 31 - 1;\nconsole.log('boundary instant:', new Date(maxInt32Seconds * 1000).toISOString());\n\nfunction wrapInt32(n) {\n return n | 0; // JS bitwise OR forces 32-bit signed truncation\n}\nconsole.log('next second, wrapped to signed 32-bit:', wrapInt32(maxInt32Seconds + 1));\n",
28
- "expected_output": "boundary instant: 2038-01-19T03:14:07.000Z\nnext second, wrapped to signed 32-bit: -2147483648",
29
- "verified_with": "Node.js v22.22.2"
30
- }
31
- ],
32
- "fix": "Never store a timestamp in a signed 32-bit field for anything that must outlive 2038; use a 64-bit integer (or your language's native arbitrary-precision/64-bit date type) for any seconds-since-epoch storage, and audit older schemas and binary formats specifically for this.",
33
- "related": [
34
- "century-leap-year-1900-not-leap"
35
- ],
36
- "sources": [
37
- {
38
- "title": "Wikipedia: Year 2038 problem (background, verify against POSIX time_t definition)",
39
- "url": "https://en.wikipedia.org/wiki/Year_2038_problem"
40
- },
41
- {
42
- "title": "IEEE Std 1003.1 (POSIX): time_t",
43
- "url": "https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap04.html#tag_04_16"
44
- }
45
- ],
46
- "tags": [
47
- "timestamps",
48
- "database",
49
- "validation"
50
- ],
51
- "test_cases": []
52
- }
@@ -1,62 +0,0 @@
1
- {
2
- "id": "absolute-second-argument-resets-join",
3
- "title": "os.path.join(\"/srv/uploads\", \"/etc/passwd\") returns \"/etc/passwd\" — the base directory just vanishes",
4
- "category": "filenames",
5
- "assumption": "Passing an absolute-looking string as the second argument to a join function is harmless, since the first argument is the 'real' base.",
6
- "input": {
7
- "display": "os.path.join(\"/srv/uploads\", \"/etc/passwd\")",
8
- "description": "A filename field a user can make start with \"/\" — a tar entry name, a form field never checked for a leading slash, or a URL path segment decoded before joining."
9
- },
10
- "what_goes_wrong": "If the user-controlled piece itself looks absolute, Python's join discards the trusted base entirely and returns the attacker-controlled absolute path unchanged — a complete, silent takeover of the result, with no `..` traversal syntax required at all.",
11
- "why": "Per the Python docs, os.path.join treats any argument that looks absolute as resetting the path: every previously joined component is discarded and joining continues from that absolute component onward. Node's path.join does NOT implement this reset — it treats the leading slash of a later argument as just another separator and concatenates everything — so the identical call in Node keeps the base directory intact. path.resolve, however, DOES implement the Python-style reset, because resolve is documented to process its arguments right-to-left until it has built an absolute path, stopping the moment one argument is itself absolute.",
12
- "affects": [
13
- "python os.path.join (resets on an absolute argument)",
14
- "node path.resolve (resets on an absolute argument)",
15
- "node path.join (does NOT reset — differs from Python's join)"
16
- ],
17
- "repros": [
18
- {
19
- "language": "python",
20
- "code": "import os.path\nprint(os.path.join(\"/srv/uploads\", \"/etc/passwd\"))\n",
21
- "expected_output": "/etc/passwd",
22
- "verified_with": "Python 3.14.2"
23
- },
24
- {
25
- "language": "javascript",
26
- "code": "import path from 'node:path';\nconsole.log(\"join:\", path.join(\"/srv/uploads\", \"/etc/passwd\"));\nconsole.log(\"resolve:\", path.resolve(\"/srv/uploads\", \"/etc/passwd\"));\n",
27
- "expected_output": "join: /srv/uploads/etc/passwd\nresolve: /etc/passwd",
28
- "verified_with": "Node.js v22.22.2"
29
- }
30
- ],
31
- "fix": "Never pass a user-controlled string straight into join/resolve as if it were just a filename; reject any value that starts with a path separator (or a drive letter on Windows) before it ever reaches a join call.",
32
- "related": [
33
- "path-join-escapes-the-base-directory"
34
- ],
35
- "sources": [
36
- {
37
- "title": "Python docs: os.path.join",
38
- "url": "https://docs.python.org/3/library/os.path.html#os.path.join"
39
- },
40
- {
41
- "title": "Node.js docs: path.resolve",
42
- "url": "https://nodejs.org/api/path.html#pathresolvepaths"
43
- }
44
- ],
45
- "tags": [
46
- "filename",
47
- "path",
48
- "security",
49
- "validation",
50
- "upload"
51
- ],
52
- "test_cases": [
53
- {
54
- "inputs": [
55
- "/srv/uploads",
56
- "/etc/passwd"
57
- ],
58
- "relation": "no_crash",
59
- "note": "Check whether your join/resolve call discards the base directory on an absolute second argument — the two most common path libraries disagree with each other here."
60
- }
61
- ]
62
- }
@@ -1,58 +0,0 @@
1
- {
2
- "id": "bidi-override-extension-spoof",
3
- "title": "A filename can display with a totally different extension than the one splitext actually finds",
4
- "category": "filenames",
5
- "assumption": "The characters your eyes read, left to right, are the characters the code is operating on.",
6
- "input": {
7
- "display": "\"invoice\" + U+202E (RIGHT-TO-LEFT OVERRIDE) + \"cod.exe\"",
8
- "description": "A malicious email attachment or download name designed to visually read as a harmless document while actually being an executable."
9
- },
10
- "what_goes_wrong": "A person reviewing the filename in a file list or email client sees what renders as \"invoiceexe.doc\" (the override reverses everything after it) and approves it as a document, while the real file, in logical order, is \"invoice\" + override + \"cod.exe\" — a .exe file.",
11
- "why": "U+202E is a Unicode bidirectional-formatting control character that tells a rendering engine to display everything after it in right-to-left visual order, with no change at all to the underlying code point sequence. splitext/extname operate on that unchanged logical sequence, so they correctly report the real extension as \".exe\"; it is only the ON-SCREEN rendering a human uses to judge the file that gets visually reversed into something else entirely.",
12
- "affects": [
13
- "any renderer following the unicode bidirectional algorithm (terminals, file managers, email clients)",
14
- "python os.path.splitext (reports the true, unreversed extension)",
15
- "node path.extname (reports the true, unreversed extension)"
16
- ],
17
- "repros": [
18
- {
19
- "language": "python",
20
- "code": "import os.path\nRLO = \"\\u202e\"\nname = \"invoice\" + RLO + \"cod.exe\"\nprint([hex(ord(c)) for c in name])\nprint(\"real extension:\", os.path.splitext(name)[1])\n",
21
- "expected_output": "['0x69', '0x6e', '0x76', '0x6f', '0x69', '0x63', '0x65', '0x202e', '0x63', '0x6f', '0x64', '0x2e', '0x65', '0x78', '0x65']\nreal extension: .exe",
22
- "verified_with": "Python 3.14.2"
23
- },
24
- {
25
- "language": "javascript",
26
- "code": "import path from 'node:path';\nconst RLO = \"\\u202e\";\nconst name = \"invoice\" + RLO + \"cod.exe\";\nconsole.log([...name].map((c) => \"0x\" + c.codePointAt(0).toString(16)));\nconsole.log(\"real extension:\", path.extname(name));\n",
27
- "expected_output": "[\n '0x69', '0x6e', '0x76',\n '0x6f', '0x69', '0x63',\n '0x65', '0x202e', '0x63',\n '0x6f', '0x64', '0x2e',\n '0x65', '0x78', '0x65'\n]\nreal extension: .exe",
28
- "verified_with": "Node.js v22.22.2"
29
- }
30
- ],
31
- "fix": "Reject or strip Unicode bidirectional control characters (U+202A-U+202E, U+2066-U+2069) from filenames before display or validation, and always trust the logical extension splitext/extname reports, never what a filename visually appears to end in.",
32
- "related": [
33
- "extension-trap-double-extension"
34
- ],
35
- "sources": [
36
- {
37
- "title": "Unicode Technical Report #9: Unicode Bidirectional Algorithm",
38
- "url": "https://www.unicode.org/reports/tr9/"
39
- }
40
- ],
41
- "tags": [
42
- "filename",
43
- "path",
44
- "security",
45
- "validation",
46
- "upload",
47
- "unicode"
48
- ],
49
- "test_cases": [
50
- {
51
- "inputs": [
52
- "invoice‮cod.exe"
53
- ],
54
- "relation": "no_crash",
55
- "note": "Your extension check should see '.exe' here even though the filename could render on screen as something else entirely."
56
- }
57
- ]
58
- }
@@ -1,74 +0,0 @@
1
- {
2
- "id": "case-insensitive-filesystem-pure-path-mismatch",
3
- "title": "\"Report.txt\" and \"report.txt\" are the same file on a default Mac or Windows disk, but \"==\" says no",
4
- "category": "filenames",
5
- "assumption": "If two path strings compare unequal, they refer to different files.",
6
- "input": {
7
- "display": "\"/Users/me/Report.txt\" vs \"/Users/me/report.txt\"",
8
- "description": "A second upload, a cache key, or a git checkout where only the case of a filename differs from an existing one."
9
- },
10
- "what_goes_wrong": "A uniqueness check (a set of seen paths, a dict key, a plain == comparison) treats the two strings as different files, while the OS silently aliases the second \"create\" onto the first, or vice versa.",
11
- "why": "macOS's default APFS volume format is case-insensitive but case-preserving: it keeps the case you typed but treats names differing only in case as the same entry (a case-sensitive APFS variant exists but is not the default). Windows treats names case-insensitively by default too, though NTFS directories can be flagged case-sensitive individually; Linux ext4 is case-sensitive. Pure path libraries never consult the disk: PurePosixPath and Node's path.posix compare the literal strings, so a case difference always compares unequal. (pathlib.PureWindowsPath, by contrast, compares case-insensitively, so pathlib's answer depends on which flavour you picked, not on the disk underneath.)",
12
- "affects": [
13
- "macos apfs (default variant: case-insensitive, case-preserving)",
14
- "windows ntfs (case-insensitive by default; per-directory case sensitivity can be enabled)",
15
- "linux ext4 (case-sensitive)",
16
- "pathlib.PurePosixPath / node path.posix (literal string comparison)"
17
- ],
18
- "repros": [
19
- {
20
- "language": "python",
21
- "code": "from pathlib import PurePosixPath\na = PurePosixPath(\"/Users/me/Report.txt\")\nb = PurePosixPath(\"/Users/me/report.txt\")\nprint(a == b, str(a) == str(b))\n",
22
- "expected_output": "False False",
23
- "verified_with": "Python 3.14.2"
24
- },
25
- {
26
- "language": "javascript",
27
- "code": "import path from 'node:path';\nconst a = path.posix.normalize(\"/Users/me/Report.txt\");\nconst b = path.posix.normalize(\"/Users/me/report.txt\");\nconsole.log(a === b);\n",
28
- "expected_output": "false",
29
- "verified_with": "Node.js v22.22.2"
30
- },
31
- {
32
- "language": "macos",
33
- "status": "documented",
34
- "note": "On a default (case-insensitive) APFS volume, opening /Users/me/report.txt for writing when /Users/me/Report.txt already exists opens the existing file; the directory keeps one entry, still spelled Report.txt.",
35
- "source": "https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/APFS_Guide/FAQ/FAQ.html"
36
- }
37
- ],
38
- "fix": "Decide your own case policy at the application layer (usually: normalize to one case before using a path as a key) rather than trusting string equality or the filesystem's default behavior, since the two disagree and the filesystem's own behavior varies by volume format.",
39
- "related": [
40
- "windows-trailing-dot-space-stripped"
41
- ],
42
- "sources": [
43
- {
44
- "title": "Apple File System Guide: FAQ (case sensitivity)",
45
- "url": "https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/APFS_Guide/FAQ/FAQ.html"
46
- },
47
- {
48
- "title": "Microsoft Learn: Naming Files, Paths, and Namespaces",
49
- "url": "https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file"
50
- },
51
- {
52
- "title": "Microsoft Learn: Case sensitivity (per-directory flag)",
53
- "url": "https://learn.microsoft.com/en-us/windows/wsl/case-sensitivity"
54
- }
55
- ],
56
- "tags": [
57
- "filename",
58
- "path",
59
- "uniqueness",
60
- "validation",
61
- "upload",
62
- "database"
63
- ],
64
- "test_cases": [
65
- {
66
- "inputs": [
67
- "Report.txt",
68
- "report.txt"
69
- ],
70
- "relation": "equal",
71
- "note": "A filename-key function meant to be safe on default macOS and Windows disks should map these to the same key, because those disks treat them as one file."
72
- }
73
- ]
74
- }
@@ -1,61 +0,0 @@
1
- {
2
- "id": "dotfile-has-no-extension",
3
- "title": "\".bashrc\" has no extension at all, by the exact same rule that found \".gz\" in \"archive.tar.gz\"",
4
- "category": "filenames",
5
- "assumption": "Any filename starting with a dot has an extension equal to everything after that dot.",
6
- "input": {
7
- "display": "\".bashrc\"",
8
- "description": "A Unix dotfile (.bashrc, .gitignore, .env) passed through the same extension-detection code used for ordinary uploads."
9
- },
10
- "what_goes_wrong": "A filter that rejects uploads without a 'recognized extension', or a viewer that picks a syntax highlighter by extension, treats a dotfile as having none, because by the splitext/extname rule it genuinely doesn't.",
11
- "why": "splitext and extname both special-case a leading dot: they look for the LAST dot that is not the first character of the filename. That rule is exactly what correctly makes \"archive.tar.gz\" yield \".gz\" instead of treating the whole name as one giant extension, but the same rule means a name that starts with a dot and has no OTHER dot, like \".bashrc\", has nothing after any qualifying dot — Python returns (\".bashrc\", \"\") and Node returns the empty string.",
12
- "affects": [
13
- "python os.path.splitext",
14
- "node path.extname"
15
- ],
16
- "repros": [
17
- {
18
- "language": "python",
19
- "code": "import os.path\nprint(os.path.splitext(\".bashrc\"))\n",
20
- "expected_output": "('.bashrc', '')",
21
- "verified_with": "Python 3.14.2"
22
- },
23
- {
24
- "language": "javascript",
25
- "code": "import path from 'node:path';\nconsole.log(JSON.stringify(path.extname(\".bashrc\")));\n",
26
- "expected_output": "\"\"",
27
- "verified_with": "Node.js v22.22.2"
28
- }
29
- ],
30
- "fix": "Don't assume every file needs a non-empty extension to be valid; treat a leading-dot, no-further-dot name as a legitimate zero-extension dotfile rather than a malformed upload.",
31
- "related": [
32
- "extension-trap-double-extension"
33
- ],
34
- "sources": [
35
- {
36
- "title": "Python docs: os.path.splitext",
37
- "url": "https://docs.python.org/3/library/os.path.html#os.path.splitext"
38
- },
39
- {
40
- "title": "Node.js docs: path.extname",
41
- "url": "https://nodejs.org/api/path.html#pathextnamepath"
42
- }
43
- ],
44
- "tags": [
45
- "filename",
46
- "path",
47
- "parsing",
48
- "validation",
49
- "upload"
50
- ],
51
- "test_cases": [
52
- {
53
- "inputs": [
54
- ".bashrc"
55
- ],
56
- "relation": "returns",
57
- "expected": "",
58
- "note": "Your extension-required upload filter needs to decide, deliberately, whether an extension-free dotfile is allowed."
59
- }
60
- ]
61
- }