Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 42 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,23 +112,47 @@ Encoding not identified: 0
Refused, ambiguous encoding: 1
Refused, unreadable: 0

Backups: enabled
Directory: C:\Source
Target: utf-8 without BOM
Source encoding: detected per file
Backups: enabled
Guarantees: strict codecs, verified output, atomic install, ambiguity refusal

No files modified.
```

The two indented lines break down `Will convert`; the rest sum exactly to
`Selected`, so the totals can be checked rather than trusted.

`-Apply` carries that plan out. It does not detect anything a second time: the
encodings, the target, and the backup setting all come from the plan, so
`-BasePath`, `-Target`, `-From`, and `-Backup` are rejected rather than silently
ignored.

The binding is the point of the feature, not the preview. Every scheduled file
carries the SHA-256 it had when the plan was made, and `-Apply` verifies each one
first. If any file has changed or been deleted in between, **nothing is
converted** — not even the files that still match. A plan reviewed as a whole
belongs to the directory it was reviewed against, and the files most likely to
have changed are the ones something else is actively writing.
ignored. `plan.json` is the whole approval.

The binding is the point of the feature, not the preview:

- **Bound to the files.** Every scheduled file carries the SHA-256 it had when
the plan was made, and `-Apply` verifies each one before writing anything. If
any file has changed or been deleted in between, **nothing is converted** — not
even the files that still match. A plan reviewed as a whole belongs to the
directory it was reviewed against, and the files most likely to have changed
are the ones something else is actively writing. Each file is checked once
more at the moment it is installed, which narrows the window between that
verification and the write.
- **Bound to the directory.** Paths are stored relative to a recorded root, so a
plan is a document about a directory rather than about one machine. Applying a
copy of a plan converts the tree it was approved for, not whichever tree it
happens to sit in, and an entry that resolves outside that root is refused.
- **Bound to the conversion.** The plan records the target encoding, BOM policy,
backup policy, whether the source encoding was detected or specified, and a
semantics version describing the conversion behaviour it was approved under. A
plan written under different behaviour is refused rather than carried out —
what was approved was a conversion, not a list of filenames.

The semantics version is deliberately separate from EC's version number: it moves
only when conversion or classification behaviour changes, so a release that
changes nothing about conversion does not invalidate plans and teach people to
work around the check.

```bash
EncodingChecker.exe -BasePath . -Include "*" -Target "utf-8" -Plan plan.json
Expand Down Expand Up @@ -197,10 +221,15 @@ These are the guarantees the implementation actually provides.
bytes is refused rather than guessed at, when the competing encodings would
produce different text. `-From` overrides the detection, not the conversion
safeguards. See [Ambiguous encodings](#ambiguous-encodings-and--from).
- A plan written by `-Plan` is bound to the SHA-256 of every file it schedules.
`-Apply` verifies all of them before writing anything and refuses the plan
whole if any file has changed, so a decision made about one set of bytes is
never applied to a different one.
- A plan written by `-Plan` is bound to the SHA-256 of every file it schedules,
to the directory those files are under, and to the conversion behaviour it was
approved under. `-Apply` verifies all of them before writing anything and
refuses the plan whole if any has changed, so a decision made about one set of
bytes is never applied to a different one. Under `-Apply`, each source is
re-hashed again immediately before installation. **This narrows the window
between verification and write; it does not close it** — a source rewritten
between that check and the replacement is still not detected, which would
require holding every source open against writers for the whole run.
- `-BasePath` itself is rejected if it is a symbolic link, junction, or
other reparse point. Reparse-point subdirectories are skipped during
traversal, and a file that is (or becomes) a reparse point is rejected at
Expand Down
214 changes: 204 additions & 10 deletions sources/EncodingChecker.Tests/ConversionPlanTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,20 @@ private ConversionPlan LoadPlan()
private PlannedFile PlannedFor(string name) =>
Assert.Single(
LoadPlan().Files,
f => string.Equals(Path.GetFileName(f.Path), name, StringComparison.Ordinal));
f => string.Equals(f.RelativePath, name, StringComparison.Ordinal));

/// <summary>Edits the plan on disk, the way someone with a text editor would.</summary>
private void Rewrite(Action<Dictionary<string, JsonElement>> edit)
{
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(PlanPath));

Dictionary<string, JsonElement> fields = document.RootElement
.EnumerateObject()
.ToDictionary(p => p.Name, p => p.Value.Clone());

edit(fields);
File.WriteAllText(PlanPath, JsonSerializer.Serialize(fields));
}

[Fact]
public void PlanningWritesNothing()
Expand Down Expand Up @@ -241,15 +254,7 @@ public void APlanFromAFutureVersionIsRefusedRatherThanGuessedAt()
Write("jp.txt", "こんにちは世界。テキスト", "shift_jis");
Assert.Equal(0, Plan());

using (JsonDocument document = JsonDocument.Parse(File.ReadAllText(PlanPath)))
{
Dictionary<string, JsonElement> fields = document.RootElement
.EnumerateObject()
.ToDictionary(p => p.Name, p => p.Value.Clone());

fields["PlanVersion"] = JsonSerializer.SerializeToElement(99);
File.WriteAllText(PlanPath, JsonSerializer.Serialize(fields));
}
Rewrite(fields => fields["PlanVersion"] = JsonSerializer.SerializeToElement(99));

Assert.Equal(1, Run("-Apply", PlanPath));
}
Expand Down Expand Up @@ -295,6 +300,195 @@ public void PlanIsRejectedInModesThatConvertNothing(params string[] mode)
["-BasePath", _root, "-Target", "utf-8", "-Plan", PlanPath, .. mode]));
}

[Fact]
public void ThePlanDescribesTheConversionAndNotOnlyTheFiles()
{
// "-Apply plan.json" must need no ambient option state to mean something exact,
// so everything that shapes the conversion has to be in the file.
Write("jp.txt", "こんにちは世界。テキスト", "shift_jis");

Assert.Equal(0, Plan("-Backup"));

ConversionPlan plan = LoadPlan();

Assert.Equal(ConversionPlan.CurrentPlanVersion, plan.PlanVersion);
Assert.Equal(ConversionSemantics.Current, plan.SemanticsVersion);
Assert.Equal("utf-8", plan.TargetEncoding);
Assert.False(plan.TargetHasBom);
Assert.True(plan.BackupEnabled);
Assert.Equal("Detected", plan.DetectionMode);
Assert.Equal(Path.GetFullPath(_root), plan.BaseDirectory);
Assert.NotEmpty(plan.ECVersion);

Assert.True(plan.Semantics.StrictDecoding);
Assert.True(plan.Semantics.StrictEncoding);
Assert.True(plan.Semantics.OutputVerification);
Assert.True(plan.Semantics.AtomicInstall);
Assert.True(plan.Semantics.AmbiguityRefusal);
}

[Fact]
public void ExplicitSourceSelectionIsRecordedAsSuch()
{
Write("jp.txt", "こんにちは世界。テキスト", "shift_jis");

Assert.Equal(0, Plan("-From", "shift_jis"));

ConversionPlan plan = LoadPlan();

Assert.Equal("Explicit", plan.DetectionMode);
Assert.Equal("shift_jis", plan.ExplicitSourceEncoding);
Assert.True(Assert.Single(plan.Files).SourceWasSpecified);
Assert.Contains("detection bypassed", plan.Summarize());
}

[Fact]
public void APlanMadeUnderDifferentConversionBehaviourIsRefused()
{
// The schema can be identical while the conversion it describes is not. What the
// user approved was a conversion, not a file listing.
string path = Write("jp.txt", "こんにちは世界。テキスト", "shift_jis");
byte[] original = File.ReadAllBytes(path);

Assert.Equal(0, Plan());
Rewrite(fields =>
fields["SemanticsVersion"] =
JsonSerializer.SerializeToElement(ConversionSemantics.Current + 1));

Assert.Equal(1, Run("-Apply", PlanPath));
Assert.Equal(original, File.ReadAllBytes(path));

Assert.Null(ConversionPlan.Load(PlanPath, out string? error));
Assert.Contains("different conversion behaviour", error);
}

[Fact]
public void APlanAppliedFromACopyStillConvertsTheTreeItWasApprovedFor()
{
// A plan carrying absolute paths that is copied alongside its tree still names
// the original tree, and every hash matches, because those are the files it was
// made from. Resolving against the recorded root makes the intent explicit
// instead of incidental: the plan is about one directory, and says which.
const string text = "こんにちは世界。テキスト";
string original = Write("jp.txt", text, "shift_jis");

Assert.Equal(0, Plan());

string copy = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(copy);

try
{
string copiedPlan = Path.Combine(copy, "plan.json");
File.Copy(original, Path.Combine(copy, "jp.txt"));
File.Copy(PlanPath, copiedPlan);

Assert.Equal(0, Run("-Apply", copiedPlan));

// The tree the plan named was converted; the copy was not touched.
Assert.Equal(text, Encoding.UTF8.GetString(File.ReadAllBytes(original)));
Assert.Equal(
Encoding.GetEncoding("shift_jis").GetBytes(text),
File.ReadAllBytes(Path.Combine(copy, "jp.txt")));
}
finally
{
Directory.Delete(copy, recursive: true);
}
}

[Fact]
public void APlanWhoseDirectoryIsGoneIsRefusedRatherThanResolvedElsewhere()
{
Write("jp.txt", "こんにちは世界。テキスト", "shift_jis");
Assert.Equal(0, Plan());

Rewrite(fields => fields["BaseDirectory"] = JsonSerializer.SerializeToElement(
Path.Combine(_root, "no-such-directory")));

Assert.Equal(3, Run("-Apply", PlanPath));
}

[Fact]
public void AnEntryReachingOutsideThePlansDirectoryIsRefused()
{
// A plan is an ordinary file that anyone can edit or receive from someone else.
// Its paths must stay inside the directory it claims to be about.
string outside = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
byte[] content = Encoding.GetEncoding("shift_jis").GetBytes("こんにちは世界。テキスト");
File.WriteAllBytes(outside, content);

try
{
Write("jp.txt", "こんにちは世界。テキスト", "shift_jis");
Assert.Equal(0, Plan());

Rewrite(fields =>
{
List<JsonElement> files = [.. fields["Files"].EnumerateArray()];

Dictionary<string, JsonElement> entry = files[0]
.EnumerateObject()
.ToDictionary(p => p.Name, p => p.Value.Clone());

entry["RelativePath"] = JsonSerializer.SerializeToElement(
Path.GetRelativePath(_root, outside));
entry["Sha256"] = JsonSerializer.SerializeToElement(
ConversionMetadataStore.ComputeSha256(outside));

fields["Files"] = JsonSerializer.SerializeToElement(new[] { entry });
});

Assert.Equal(3, Run("-Apply", PlanPath));

// Untouched, and still Shift_JIS rather than the UTF-8 it would have become.
Assert.Equal(content, File.ReadAllBytes(outside));
}
finally
{
File.Delete(outside);
}
}

[Fact]
public void TheDisplayedCategoriesSumToTheSelectedPopulation()
{
// A category total that does not add up is how a mechanism that is actually safe
// loses the confidence it earned.
Write("jp.txt", "こんにちは世界。日本語のテキストです。", "shift_jis");
Write("ambiguous.txt", "Le café était déjà prêt", "windows-1252");
Write("plain.txt", "just ascii here", "ascii");

Assert.Equal(0, Plan());

ConversionPlan plan = LoadPlan();
string summary = plan.Summarize();

int Line(string label)
{
string line = Assert.Single(
summary.Split(Environment.NewLine),
l => l.StartsWith(label, StringComparison.Ordinal));

return int.Parse(line[label.Length..].Trim());
}

// The indented pair breaks down "Will convert" and is not part of the sum.
Assert.Equal(
Line("Selected:"),
Line("Will convert:")
+ Line("Already in target encoding:")
+ Line("Encoding not identified:")
+ Line("Refused, ambiguous encoding:")
+ Line("Refused, unreadable:"));

Assert.Equal(
Line("Will convert:"),
Line(" encoding determined:") + Line(" same text either way:"));

Assert.Equal(plan.Files.Count, Line("Selected:"));
}

[Fact]
public void TheSummaryAccountsForEverySelectedFile()
{
Expand Down
Loading