Repository: ergochat/ergo (go)

Issue:
casemapping: support rfc1459/rfc1459-strict
This was discussed some in IRC, but it would be nice if ergo supported `rfc1459` and `rfc1459-strict` casemapping as well.

Docs: [Horse Docs](https://modern.ircdocs.horse/#casemapping-parameter)

---

Notes: [InspIRCd](https://github.com/inspircd/inspircd) and [UnrealIRCd](https://github.com/unrealircd/unrealircd) both seem to also default to `ascii` like ergo. (based on GitHub code search)

Libera (the largest IRC network) uses `rfc1459`. I imagine most of [the top 10](https://netsplit.de/networks/top10.php) do.

<details>
<summary>chatlogs</summary>

```
20:31:41 <xnaas> Yes, but my question is really: can ergo support rfc1459 (and rfc1459-strict) and should one of those be the default?
20:32:07 <@slingamn> i think my answers would be "potentially, but why would we want to?" and "no"
20:32:29 <@slingamn> the behavior we implement is a de facto standard and we publish an 005 token describing it
20:32:38 <xnaas> to match what users expect if other IRC networks already do so, mostly
20:33:04 <xnaas> Can say one of my users was very shocked to see someone could take {their_nick} when they had [their_nick] already. :P
20:34:07 <@slingamn> hmm
20:34:59 <xnaas> I'm checking InspIRCd and UnrealIRCd which dwarf ergo by raw server count. I know Libera uses rfc1459.
20:35:58 <xnaas> (And Freenode before it)
20:36:01 <@slingamn> i think it's not a clear user expectation at this point
20:36:09 <@slingamn> you can open an issue...i think dan might have thoughts
20:37:10 <xnaas> looks like InspIRCd and UnrealIRCd both also default to ascii
20:37:37 <xnaas> But I'll have to check the top 10 now :P https://netsplit.de/networks/top10.php
20:37:38 -@ErgoBot- IRC Networks - Top 10 in the annual comparison
20:38:29 <@slingamn> i would guess that most large networks use rfc1459
20:38:45 <@slingamn> but i would also guess that the distinction is important to very few people
20:39:10 <@dan> I’d rather default to ascii, easier for new client authors to implement imo and more appropriate for newer networks, but ye smol distinction
20:39:27 <@slingamn> ohai dan
20:39:33 <@dan> haihai
20:40:25 <@slingamn> dan: we could open a ticket to support rfc1459 and/or rfc1459-strict, and make it incompatible with PRECIS (if you have PRECIS enabled then you must use CASEMAPPING=ascii)
20:40:37 <@slingamn> but, i don't think it's much of a priority
20:40:49 * xnaas can definitely open an issue and you can comment "PRs welcome" 😉
20:40:55 * xnaas knows how FOSS works
20:40:57 <xnaas> 😉
20:41:02 <@slingamn> it would also create some terminological confusion because the config key that controls utf8mapping is named 'casemapping', lol
20:41:11 <@dan> if you’d like to maintain it then totally open to having it ;D
20:41:16 <@dan> ehe
```

</details>

Pull request description:
fix #2099
Add optional support for rfc1459 and rfc1459-strict casemappings

Patch:
diff --git a/default.yaml b/default.yaml
index cbe6960f..3c8f72da 100644
--- a/default.yaml
+++ b/default.yaml
@@ -134,9 +134,10 @@ server:
     # the recommended default is 'ascii' (traditional ASCII-only identifiers).
     # the other options are 'precis', which allows UTF8 identifiers that are "sane"
     # (according to UFC 8265), with additional mitigations for homoglyph attacks,
-    # and 'permissive', which allows identifiers containing unusual characters like
+    # 'permissive', which allows identifiers containing unusual characters like
     # emoji, at the cost of increased vulnerability to homoglyph attacks and potential
-    # client compatibility problems. we recommend leaving this value at its default;
+    # client compatibility problems, and the legacy mappings 'rfc1459' and
+    # 'rfc1459-strict'. we recommend leaving this value at its default;
     # however, note that changing it once the network is already up and running is
     # problematic.
     casemapping: "ascii"
diff --git a/irc/config.go b/irc/config.go
index 96fa4ef4..e90d9dca 100644
--- a/irc/config.go
+++ b/irc/config.go
@@ -453,6 +453,10 @@ func (cm *Casemapping) UnmarshalYAML(unmarshal func(interface{}) error) (err err
 		result = CasemappingPRECIS
 	case "permissive", "fun":
 		result = CasemappingPermissive
+	case "rfc1459":
+		result = CasemappingRFC1459
+	case "rfc1459-strict":
+		result = CasemappingRFC1459Strict
 	default:
 		return fmt.Errorf("invalid casemapping value: %s", orig)
 	}
@@ -1622,7 +1626,16 @@ func (config *Config) generateISupport() (err error) {
 	isupport.Initialize()
 	isupport.Add("AWAYLEN", strconv.Itoa(config.Limits.AwayLen))
 	isupport.Add("BOT", "B")
-	isupport.Add("CASEMAPPING", "ascii")
+	var casemappingToken string
+	switch config.Server.Casemapping {
+	default:
+		casemappingToken = "ascii" // this is published for ascii, precis, or permissive
+	case CasemappingRFC1459:
+		casemappingToken = "rfc1459"
+	case CasemappingRFC1459Strict:
+		casemappingToken = "rfc1459-strict"
+	}
+	isupport.Add("CASEMAPPING", casemappingToken)
 	isupport.Add("CHANLIMIT", fmt.Sprintf("%s:%d", chanTypes, config.Channels.MaxChannelsPerClient))
 	isupport.Add("CHANMODES", chanmodesToken)
 	if config.History.Enabled && config.History.ChathistoryMax > 0 {
diff --git a/irc/strings.go b/irc/strings.go
index b67bdac6..fb5c3da0 100644
--- a/irc/strings.go
+++ b/irc/strings.go
@@ -60,6 +60,10 @@ const (
 	// confusables detection: standard skeleton algorithm (which may be ineffective
 	// over the larger set of permitted identifiers)
 	CasemappingPermissive
+	// rfc1459 is a legacy mapping as defined here: https://modern.ircdocs.horse/#casemapping-parameter
+	CasemappingRFC1459
+	// rfc1459-strict is a legacy mapping as defined here: https://modern.ircdocs.horse/#casemapping-parameter
+	CasemappingRFC1459Strict
 )
 
 // XXX this is a global variable without explicit synchronization.
@@ -110,6 +114,10 @@ func casefoldWithSetting(str string, setting Casemapping) (string, error) {
 		return foldASCII(str)
 	case CasemappingPermissive:
 		return foldPermissive(str)
+	case CasemappingRFC1459:
+		return foldRFC1459(str, false)
+	case CasemappingRFC1459Strict:
+		return foldRFC1459(str, true)
 	}
 }
 
@@ -214,7 +222,7 @@ func Skeleton(name string) (string, error) {
 	switch globalCasemappingSetting {
 	default:
 		return realSkeleton(name)
-	case CasemappingASCII:
+	case CasemappingASCII, CasemappingRFC1459, CasemappingRFC1459Strict:
 		// identity function is fine because we independently case-normalize in Casefold
 		return name, nil
 	}
@@ -302,6 +310,23 @@ func foldASCII(str string) (result string, err error) {
 	return strings.ToLower(str), nil
 }
 
+var (
+	rfc1459Replacer       = strings.NewReplacer("[", "{", "]", "}", "\\", "|", "~", "^")
+	rfc1459StrictReplacer = strings.NewReplacer("[", "{", "]", "}", "\\", "|")
+)
+
+func foldRFC1459(str string, strict bool) (result string, err error) {
+	asciiFold, err := foldASCII(str)
+	if err != nil {
+		return "", err
+	}
+	replacer := rfc1459Replacer
+	if strict {
+		replacer = rfc1459StrictReplacer
+	}
+	return replacer.Replace(asciiFold), nil
+}
+
 func IsPrintableASCII(str string) bool {
 	for i := 0; i < len(str); i++ {
 		// allow space here because it's technically printable;
diff --git a/traditional.yaml b/traditional.yaml
index d26d36e9..d10b4e55 100644
--- a/traditional.yaml
+++ b/traditional.yaml
@@ -108,9 +108,10 @@ server:
     # the recommended default is 'ascii' (traditional ASCII-only identifiers).
     # the other options are 'precis', which allows UTF8 identifiers that are "sane"
     # (according to UFC 8265), with additional mitigations for homoglyph attacks,
-    # and 'permissive', which allows identifiers containing unusual characters like
+    # 'permissive', which allows identifiers containing unusual characters like
     # emoji, at the cost of increased vulnerability to homoglyph attacks and potential
-    # client compatibility problems. we recommend leaving this value at its default;
+    # client compatibility problems, and the legacy mappings 'rfc1459' and
+    # 'rfc1459-strict'. we recommend leaving this value at its default;
     # however, note that changing it once the network is already up and running is
     # problematic.
     casemapping: "ascii"


Test patch:
diff --git a/irc/strings_test.go b/irc/strings_test.go
index 4ee5d1a9..8c9d11f5 100644
--- a/irc/strings_test.go
+++ b/irc/strings_test.go
@@ -279,3 +279,31 @@ func TestFoldASCIIInvalid(t *testing.T) {
 		t.Errorf("control characters should be invalid in identifiers")
 	}
 }
+
+func TestFoldRFC1459(t *testing.T) {
+	folder := func(str string) (string, error) {
+		return foldRFC1459(str, false)
+	}
+	tester := func(first, second string, equal bool) {
+		validFoldTester(first, second, equal, folder, t)
+	}
+	tester("shivaram", "SHIVARAM", true)
+	tester("shivaram[a]", "shivaram{a}", true)
+	tester("shivaram\\a]", "shivaram{a}", false)
+	tester("shivaram\\a]", "shivaram|a}", true)
+	tester("shivaram~a]", "shivaram^a}", true)
+}
+
+func TestFoldRFC1459Strict(t *testing.T) {
+	folder := func(str string) (string, error) {
+		return foldRFC1459(str, true)
+	}
+	tester := func(first, second string, equal bool) {
+		validFoldTester(first, second, equal, folder, t)
+	}
+	tester("shivaram", "SHIVARAM", true)
+	tester("shivaram[a]", "shivaram{a}", true)
+	tester("shivaram\\a]", "shivaram{a}", false)
+	tester("shivaram\\a]", "shivaram|a}", true)
+	tester("shivaram~a]", "shivaram^a}", false)
+}