unicode/norm: let any starter block composition in compose

reorderBuffer.compose only refreshed s, the index of the last starter,
inside the ii.combinesBackward() branch. A rune that does not combine
backward therefore left s pointing at whatever starter preceded it, and
the next backward-combining mark composed with that stale starter even
though an intervening starter should have blocked it.

For example, NFKC of U+16D68 U+FB01 U+113C2 U+0300 U+0316 decomposes to
U+16D68 f i U+113C2 U+0316 U+0300. U+0316 does not combine backward, so
s was left pointing at the "i" rather than at the starter U+113C2, and
U+0300 composed with the "i" to give U+00EC, dropping a code point and
producing a string that is not canonically equivalent to the input.

Update s on every iteration, as combineHangul already does.

Fixes golang/go#81001.

Change-Id: I4d2332b4a0c44d60bd0961036391617a728c12df
Reviewed-on: https://go-review.googlesource.com/c/text/+/822081
Reviewed-by: David Chase <drchase@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Damien Neil <dneil@google.com>
diff --git a/unicode/norm/composition.go b/unicode/norm/composition.go
index 1ad6b5b..e024e74 100644
--- a/unicode/norm/composition.go
+++ b/unicode/norm/composition.go
@@ -494,25 +494,27 @@
 			return
 		}
 		ii := b[i]
+		// Track the last starter unconditionally: b[i] must be blocked by
+		// any starter between it and s, even one that b[i] itself cannot
+		// combine with, and even if the runes in between never enter the
+		// combinesBackward branch below.
+		cccB := b[k-1].ccc
+		cccC := ii.ccc
+		blocked := false // b[i] blocked by starter or greater or equal CCC?
+		if cccB == 0 {
+			s = k - 1
+		} else {
+			blocked = s != k-1 && cccB >= cccC
+		}
 		// We can only use combineForward as a filter if we later
 		// get the info for the combined character. This is more
 		// expensive than using the filter. Using combinesBackward()
 		// is safe.
-		if ii.combinesBackward() {
-			cccB := b[k-1].ccc
-			cccC := ii.ccc
-			blocked := false // b[i] blocked by starter or greater or equal CCC?
-			if cccB == 0 {
-				s = k - 1
-			} else {
-				blocked = s != k-1 && cccB >= cccC
-			}
-			if !blocked {
-				combined := combine(rb.runeAt(s), rb.runeAt(i))
-				if combined != 0 {
-					rb.assignRune(s, combined)
-					continue
-				}
+		if ii.combinesBackward() && !blocked {
+			combined := combine(rb.runeAt(s), rb.runeAt(i))
+			if combined != 0 {
+				rb.assignRune(s, combined)
+				continue
 			}
 		}
 		b[k] = b[i]
diff --git a/unicode/norm/composition_test.go b/unicode/norm/composition_test.go
index 3bc034c..5aab245 100644
--- a/unicode/norm/composition_test.go
+++ b/unicode/norm/composition_test.go
@@ -178,3 +178,27 @@
 		}
 	}
 }
+
+// TestCompositionBlockedByStarter tests that a starter blocks composition
+// across it even when the starter itself takes no part in any composition and
+// the runes between it and the following mark never combine backward.
+// See go.dev/issue/81001.
+func TestCompositionBlockedByStarter(t *testing.T) {
+	tests := []struct {
+		name    string
+		f       Form
+		in, out string
+	}{
+		// U+113C2 is a starter (ccc 0), so it blocks U+0300 from
+		// composing with the "i" of the U+FB01 expansion.
+		{"NFKC", NFKC, "\U00016d68\ufb01\U000113c2\u0300\u0316", "\U00016d68fi\U000113c2\u0316\u0300"},
+		{"NFC", NFC, "i\U000113c2\u0300\u0316", "i\U000113c2\u0316\u0300"},
+		// Without the intervening starter the composition must still happen.
+		{"NFC", NFC, "i\u0300\u0316", "\u00ec\u0316"},
+	}
+	for _, test := range tests {
+		if got := test.f.String(test.in); got != test.out {
+			t.Errorf("%s.String(%+q) = %+q; want %+q", test.name, test.in, got, test.out)
+		}
+	}
+}