Skip to content

Commit 8e5e227

Browse files
Merge pull request #13 from henrikottesorensen/claude/utf8-bom-enforcement-c44583
Warn on UTF-8 byte order marks with NOTA0002
2 parents 3db4ebb + 2258e74 commit 8e5e227

7 files changed

Lines changed: 242 additions & 29 deletions

File tree

Nota.CodeAnalysis.Verification/README.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,27 @@ looks redundant and is not: without it `IDE0005` silently stops reporting.
5959
| `UA1001` | no blank line between using blocks | `Unseparated.cs` |
6060
| `SA1516` | no blank line between members | `Unseparated.cs` |
6161

62+
Two more come from `build/Nota.CodeAnalysis.targets` rather than an analyser, and so are the only
63+
rules here that cannot be confirmed by reading a severity out of the globalconfig - they have to
64+
actually run:
65+
66+
| Rule | What it catches | Severity | Sample |
67+
|------------|-------------------------------------|----------|--------------------|
68+
| `NOTA0001` | a file that is not valid UTF-8 | error | `Latin1Encoded.cs` |
69+
| `NOTA0002` | a file with a UTF-8 byte order mark | warning | `BomMarked.cs` |
70+
71+
The severities differ on purpose. `NOTA0001` is corruption and stops the build outright. A mark costs
72+
nothing at runtime, so `NOTA0002` reports on a developer's build and becomes an error in a pipeline
73+
built with `-warnaserror` - an MSBuild engine switch, so unlike `TreatWarningsAsErrors` it promotes a
74+
task-logged warning. The greps here accept `warning` or `error` for that reason, and so keep working
75+
whichever way a build is invoked.
76+
77+
This pipeline does not pass that switch, and does not need to: `verify-encoding.sh` fails it on a
78+
mark anywhere in the tree, including the files no compiler opens.
79+
80+
`BomMarked.cs` is otherwise unremarkable on purpose: the fault is its first three bytes, and it must
81+
not also be mis-encoded, or `NOTA0001` would cover for `NOTA0002` never firing.
82+
6283
`SA1516` used to be the one asserting the blank line after the System group, and that worked only
6384
because `dotnet_separate_import_directive_groups` was set. The key had to go - at *any* value,
6485
including `false`, its presence arms the organize-imports stage of `dotnet format style`, which sorts
@@ -69,7 +90,7 @@ separation, which was always its own job.
6990

7091
## What `verify-encoding.sh` asserts
7192

72-
Every source file is valid UTF-8, or UTF-16 carrying a BOM.
93+
Every source file is valid UTF-8 without a byte order mark, or UTF-16 carrying one.
7394

7495
This is the guard that let `SA1412` be switched off. SA1412 demanded a byte order mark, which was
7596
never what anyone wanted, but it was the only thing standing between the build and a file saved as
@@ -80,6 +101,12 @@ already been decoded. It is also a property of `.resx` and `.json` files, which
80101
BOM-marked UTF-16 is accepted rather than flagged. svcutil and EF migrations emit it, the compiler
81102
reads it correctly, and those files must keep their BOM - it is the only record of their encoding.
82103

104+
A UTF-8 mark fails instead - here it stops the pipeline, where `NOTA0002` only warns a consumer. This
105+
repository ships that rule, so its own tree is the first place that has to be clean, and nothing in
106+
it should ever need the grace period a warning buys someone else. It is also wider than
107+
`NOTA0002` can be: the rule only ever sees `@(Compile)`, while this reads the `.md`, `.json` and
108+
`.targets` files no compiler opens.
109+
83110
It cannot catch a wrong encoding that happens to produce valid UTF-8, the classic `“` mojibake,
84111
which is indistinguishable from someone writing those characters on purpose.
85112

@@ -105,8 +132,9 @@ while this project was being written, both times caught before merging:
105132

106133
So it packs, installs into a throwaway project from a local feed, and compiles a file that breaks one
107134
rule per analyser - plus a deliberately mis-encoded file for `NOTA0001`, which proves
108-
`build/Nota.CodeAnalysis.targets` was packed and imported. It fails on `CS9057` too, since that is a
109-
warning nothing else would notice.
135+
`build/Nota.CodeAnalysis.targets` was packed and imported, and a marked one for `NOTA0002`, which is
136+
the only rule with an opt-out that defaults to enforcing and so the only one where a wrong default
137+
would ship as silence. It fails on `CS9057` too, since that is a warning nothing else would notice.
110138

111139
It packs under a throwaway version like `0.0.0-verify.20260802143000`. That is not cosmetic: NuGet
112140
extracts a package once per version into the global cache, so re-packing `2.2.0` and installing
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace Nota.Verification;
2+
3+
/// <summary>Saved with a UTF-8 byte order mark on purpose. See verify.sh.</summary>
4+
public static class BomMarked
5+
{
6+
/// <summary>Nothing wrong with the text - it is the first three bytes of the file.</summary>
7+
public const string Text = "marked";
8+
}

Nota.CodeAnalysis.Verification/verify-encoding.sh

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env sh
22
#
3-
# Fails if any source file is neither valid UTF-8 nor a BOM-marked UTF-16 file.
3+
# Fails if any source file is neither valid UTF-8 without a byte order mark, nor a BOM-marked UTF-16
4+
# file.
45
#
56
# This is the guard that has to exist before SA1412 is switched off. SA1412 required a byte order
67
# mark, which was never the point - but it was, by accident, the only thing standing between the
@@ -12,6 +13,11 @@
1213
# No analyser can cover this anyway - it is a property of bytes on disk, and it applies to .json and
1314
# .resx as much as to .cs. Hence a script.
1415
#
16+
# A UTF-8 mark is a failure, not an acceptance. It records nothing - UTF-8 is what the compiler
17+
# assumes when there is no mark - and it comes back on its own: an editor that opened a file with one
18+
# writes one back on every later save. NOTA0002 says the same thing to consumers, but only about
19+
# files the compiler sees; here it covers the .md, .json and .targets files as well.
20+
#
1521
# UTF-16 is accepted when it carries a BOM. Generated output - svcutil service references, EF
1622
# migrations - is often UTF-16, the compiler reads it correctly from the BOM, and such a file must
1723
# keep that BOM: it is the only thing recording the encoding.
@@ -36,6 +42,8 @@ set -eu
3642

3743
root="${1:-.}"
3844

45+
# Each line is a tag and a path. Two different faults are being looked for in one pass over the
46+
# bytes, and they want different advice: one is corruption, the other is noise that comes back.
3947
found="$(find "$root" \
4048
\( -name '*.cs' -o -name '*.csproj' -o -name '*.json' -o -name '*.resx' -o -name '*.md' -o -name '*.props' -o -name '*.targets' \) \
4149
-not -path '*/obj/*' -not -path '*/bin/*' -not -path '*/.git/*' \
@@ -45,17 +53,37 @@ found="$(find "$root" \
4553
bom=$(head -c 3 "$f" | xxd -p)
4654
case "$bom" in
4755
fffe*|feff*) ;;
48-
*) iconv -f UTF-8 -t UTF-8 "$f" >/dev/null 2>&1 || printf "%s\n" "$f" ;;
56+
efbbbf) printf "bom %s\n" "$f" ;;
57+
*) iconv -f UTF-8 -t UTF-8 "$f" >/dev/null 2>&1 || printf "invalid %s\n" "$f" ;;
4958
esac
5059
done
5160
' sh {} +)"
5261

53-
if [ -n "$found" ]; then
62+
invalid="$(printf '%s\n' "$found" | sed -n 's/^invalid //p')"
63+
marked="$(printf '%s\n' "$found" | sed -n 's/^bom //p')"
64+
65+
status=0
66+
67+
if [ -n "$invalid" ]; then
5468
printf 'Not valid UTF-8:\n' >&2
55-
printf '%s\n' "$found" | sed 's/^/ /' >&2
69+
printf '%s\n' "$invalid" | sed 's/^/ /' >&2
5670
printf '\nThese compile without complaint and land in the assembly as U+FFFD.\n' >&2
5771
printf 'Re-save them as UTF-8; check the tool or editor that last wrote them.\n' >&2
58-
exit 1
72+
status=1
5973
fi
6074

61-
printf 'All source files are valid UTF-8 or BOM-marked UTF-16.\n'
75+
# This repository ships NOTA0002, which fails a consumer build over exactly this. Its own tree is the
76+
# first place that has to be clean - and unlike NOTA0002, which only ever sees @(Compile), this pass
77+
# also covers the .md, .json and .targets files no compiler reads.
78+
if [ -n "$marked" ]; then
79+
[ "$status" -eq 0 ] || printf '\n' >&2
80+
printf 'Carrying a UTF-8 byte order mark:\n' >&2
81+
printf '%s\n' "$marked" | sed 's/^/ /' >&2
82+
printf '\nStrip them with tools/de-bom.sh, with the editor closed - one that opened a file with a\n' >&2
83+
printf 'mark writes the mark back on the next save, however the file on disk now looks.\n' >&2
84+
status=1
85+
fi
86+
87+
[ "$status" -eq 0 ] || exit "$status"
88+
89+
printf 'All source files are valid UTF-8 without a byte order mark, or BOM-marked UTF-16.\n'

Nota.CodeAnalysis.Verification/verify-package.sh

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,15 +130,23 @@ printf 'namespace App;\n\n/// <summary>Saved in the wrong encoding on purpose.</
130130
printf '\346\370\345' >> "$app/MisEncoded.cs"
131131
printf '";\n}\n' >> "$app/MisEncoded.cs"
132132

133+
# A UTF-8 byte order mark, for NOTA0002. Separate from the file above on purpose: NOTA0001 stopping
134+
# at the first byte of a mis-encoded file would hide it, and a mark on an otherwise clean file is the
135+
# case that actually occurs.
136+
printf '\357\273\277' > "$app/Marked.cs"
137+
printf 'namespace App;\n\n/// <summary>Carries a UTF-8 byte order mark on purpose.</summary>\npublic static class Marked\n{\n /// <summary>Ordinary text.</summary>\n public const string T = "marked";\n}\n' >> "$app/Marked.cs"
138+
133139
output="$(cd "$app" && dotnet build --no-incremental -v:m 2>&1 || true)"
134140

135141
# IDE0008 the globalconfig was packed, and the props file turned rule enforcement on
136142
# NOTA0001 build/Nota.CodeAnalysis.targets was packed and imported
143+
# NOTA0002 the byte order mark check runs by default - it is a warning with an opt-out, so it is
144+
# the rule here most easily lost without anything failing to say so
137145
# SA1208 StyleCop.Analyzers reached the consumer
138146
# VSTHRD100 Microsoft.VisualStudio.Threading.Analyzers reached the consumer
139147
# UA1000 UsingLayoutAnalyser reached the consumer, and loaded on this Roslyn
140148
# Serilog003 SerilogAnalyzer reached the consumer
141-
expected="IDE0008 NOTA0001 SA1208 VSTHRD100 UA1000 Serilog003"
149+
expected="IDE0008 NOTA0001 NOTA0002 SA1208 VSTHRD100 UA1000 Serilog003"
142150

143151
missing=""
144152
for rule in $expected; do

Nota.CodeAnalysis.Verification/verify.sh

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,13 @@ done
3737
# first party above the vendors and leaves --verify-no-changes failing permanently
3838
# SA1208 System usings not placed first
3939
# SA1516 no blank line between members - its own job, not the using one it lost
40-
# NOTA0001 a source file that is not valid UTF-8, from build/Nota.CodeAnalysis.targets - the one
41-
# rule here that is a build error rather than an analyser diagnostic, and so the only
42-
# one that cannot be confirmed by reading a severity out of the globalconfig
43-
expected=(IDE0005 IDE0008 UA1000 UA1001 SA1208 SA1516 NOTA0001)
40+
# NOTA0001 a source file that is not valid UTF-8, from build/Nota.CodeAnalysis.targets - one of
41+
# two rules here logged by an MSBuild task rather than an analyser, and so the only ones
42+
# that cannot be confirmed by reading a severity out of the globalconfig
43+
# NOTA0002 a source file carrying a UTF-8 byte order mark - Samples/BomMarked.cs. Also from the
44+
# targets, and the reason the samples are exempt from verify-encoding.sh. A warning
45+
# where NOTA0001 is an error, which is why the grep below accepts either
46+
expected=(IDE0005 IDE0008 UA1000 UA1001 SA1208 SA1516 NOTA0001 NOTA0002)
4447

4548
# VerifyRules is what pulls Samples/ into the compilation. Without it the project builds empty, which
4649
# is what every other build of this solution wants.

Nota.CodeAnalysis/build/Nota.CodeAnalysis.targets

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
<Project ToolsVersion="17.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
33

44
<!--
5-
Fails the build on a source file that is not valid UTF-8.
5+
Fails the build on a source file that is not valid UTF-8 (NOTA0001), and warns on one carrying a
6+
UTF-8 byte order mark (NOTA0002).
67
78
Nothing else can do this. A file saved as Windows-1252 compiles with no warning and no error, and
89
the compiler writes U+FFFD replacement characters into the assembly - measured, not assumed. An
@@ -15,18 +16,38 @@
1516
standing between a build and a mis-encoded file. Removing it without this would have left every
1617
consumer worse off than before.
1718
18-
Set NotaValidateSourceEncoding to false to switch it off.
19+
NOTA0002 is the other half of that: with SA1412 gone nothing objected to a mark either way, and
20+
marks come back on their own. An editor that opened a file with one writes one back on every
21+
subsequent save, so a single stray file re-BOMs itself indefinitely and spreads to whatever else
22+
that editor touches. The bytes have to be looked at to see it, which is this task's job already.
23+
24+
A warning where NOTA0001 is an error, and the difference is deliberate. NOTA0001 is corruption:
25+
the file is already wrong and the build should stop. A mark costs nothing at runtime - it
26+
compiles, it runs, it is only noise - so it says so on a developer's build and stops the one that
27+
counts, because dotnet build -warnaserror is an MSBuild engine switch and takes task warnings
28+
with it. A pipeline already carrying that flag fails on a marked file having configured nothing.
29+
WarningsAsErrors=NOTA0002 (the SDK feeds it into MSBuildWarningsAsErrors) and
30+
MSBuildTreatWarningsAsErrors do the same.
31+
32+
TreatWarningsAsErrors does not, however emphatically it is set - it is a compiler property, and
33+
this warning is logged by a task and never passes through the compiler. A repository relying on
34+
that one alone has no gate. All four measured.
35+
36+
Set NotaValidateSourceEncoding to false to switch both off, or NotaAllowUtf8Bom to true to keep
37+
NOTA0001 while permitting marks.
1938
-->
2039

2140
<PropertyGroup>
2241
<NotaValidateSourceEncoding Condition="'$(NotaValidateSourceEncoding)' == ''">true</NotaValidateSourceEncoding>
42+
<NotaAllowUtf8Bom Condition="'$(NotaAllowUtf8Bom)' == ''">false</NotaAllowUtf8Bom>
2343
</PropertyGroup>
2444

2545
<UsingTask TaskName="NotaValidateUtf8" TaskFactory="RoslynCodeTaskFactory"
2646
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll"
2747
Condition="'$(NotaValidateSourceEncoding)' == 'true'">
2848
<ParameterGroup>
2949
<Files ParameterType="Microsoft.Build.Framework.ITaskItem[]" Required="true" />
50+
<AllowByteOrderMark ParameterType="System.Boolean" Required="false" />
3051
</ParameterGroup>
3152
<Task>
3253
<Code Type="Fragment" Language="cs"><![CDATA[
@@ -50,6 +71,25 @@
5071
continue;
5172
}
5273
74+
// EF BB BF, and only EF BB BF. A UTF-8 mark records nothing - UTF-8 is what the compiler
75+
// assumes when there is no mark at all - so it is pure noise that editors keep putting
76+
// back. Checked before the decode, not instead of it: a file can have a mark and still be
77+
// mis-encoded further in, and both are worth saying.
78+
if (!AllowByteOrderMark
79+
&& bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
80+
{
81+
Log.LogWarning(
82+
null,
83+
"NOTA0002",
84+
null,
85+
path,
86+
1,
87+
1,
88+
0,
89+
0,
90+
"File begins with a UTF-8 byte order mark. Re-save it as UTF-8 without one, or strip the tree with tools/de-bom.sh from Nota.CodeAnalysis - and close the editor while you do, since one that opened the file with a mark writes the mark back on the next save.");
91+
}
92+
5393
try
5494
{
5595
new System.Text.UTF8Encoding(false, true).GetString(bytes);
@@ -80,7 +120,7 @@
80120
<Target Name="NotaValidateSourceEncoding"
81121
BeforeTargets="CoreCompile"
82122
Condition="'$(NotaValidateSourceEncoding)' == 'true' AND '@(Compile)' != ''">
83-
<NotaValidateUtf8 Files="@(Compile)" />
123+
<NotaValidateUtf8 Files="@(Compile)" AllowByteOrderMark="$(NotaAllowUtf8Bom)" />
84124
</Target>
85125

86126
</Project>

0 commit comments

Comments
 (0)