| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | |
| 7 | "github.com/ipfs/kubo/core/commands/cmdutils" |
| 8 | "github.com/stretchr/testify/assert" |
| 9 | ) |
| 10 | |
| 11 | func TestCleanAndTrimUnicode(t *testing.T) { |
| 12 | tests := []struct { |
| 13 | name string |
| 14 | input string |
| 15 | expected string |
| 16 | }{ |
| 17 | { |
| 18 | name: "Basic ASCII", |
| 19 | input: "kubo/1.0.0", |
| 20 | expected: "kubo/1.0.0", |
| 21 | }, |
| 22 | { |
| 23 | name: "Polish characters preserved", |
| 24 | input: "test-ąęćłńóśźż", |
| 25 | expected: "test-ąęćłńóśźż", |
| 26 | }, |
| 27 | { |
| 28 | name: "Chinese characters preserved", |
| 29 | input: "版本-中文测试", |
| 30 | expected: "版本-中文测试", |
| 31 | }, |
| 32 | { |
| 33 | name: "Arabic text preserved", |
| 34 | input: "اختبار-العربية", |
| 35 | expected: "اختبار-العربية", |
| 36 | }, |
| 37 | { |
| 38 | name: "Emojis preserved", |
| 39 | input: "version-1.0-🚀-🎉", |
| 40 | expected: "version-1.0-🚀-🎉", |
| 41 | }, |
| 42 | { |
| 43 | name: "Complex Unicode with combining marks preserved", |
| 44 | input: "h̸̢̢̢̢̢̢̢̢̢̢e̵̵̵̵̵̵̵̵̵̵l̷̷̷̷̷̷̷̷̷̷l̶̶̶̶̶̶̶̶̶̶o̴̴̴̴̴̴̴̴̴̴", |
| 45 | expected: "h̸̢̢̢̢̢̢̢̢̢̢e̵̵̵̵̵̵̵̵̵̵l̷̷̷̷̷̷̷̷̷̷l̶̶̶̶̶̶̶̶̶̶o̴̴̴̴̴̴̴̴̴̴", // Preserved as-is (only 50 runes) |
| 46 | }, |
| 47 | { |
| 48 | name: "Long text with combining marks truncated at 128", |
| 49 | input: strings.Repeat("ẽ̸̢̛̖̬͈͉͖͇͈̭̥́̓̌̾͊̊̂̄̍̅̂͌́", 10), // Very long text (260 runes) |
| 50 | expected: "ẽ̸̢̛̖̬͈͉͖͇͈̭̥́̓̌̾͊̊̂̄̍̅̂͌́ẽ̸̢̛̖̬͈͉͖͇͈̭̥́̓̌̾͊̊̂̄̍̅̂͌́ẽ̸̢̛̖̬͈͉͖͇͈̭̥́̓̌̾͊̊̂̄̍̅̂͌́ẽ̸̢̛̖̬͈͉͖͇͈̭̥́̓̌̾͊̊̂̄̍̅̂͌́ẽ̸̢̛̖̬͈͉͖͇͈̭̥́̓̌̾͊̊̂̄̍̅̂", // Truncated at 128 runes |
| 51 | }, |
| 52 | { |
| 53 | name: "Zero-width characters replaced with U+FFFD", |
| 54 | input: "test\u200Bzero\u200Cwidth\u200D\uFEFFchars", |
| 55 | expected: "test�zero�width��chars", |
| 56 | }, |
| 57 | { |
| 58 | name: "RTL/LTR override replaced with U+FFFD", |
| 59 | input: "test\u202Drtl\u202Eltr\u202Aoverride", |
| 60 | expected: "test�rtl�ltr�override", |
| 61 | }, |
| 62 | { |
| 63 | name: "Bidi isolates replaced with U+FFFD", |
| 64 | input: "test\u2066bidi\u2067isolate\u2068text\u2069end", |
| 65 | expected: "test�bidi�isolate�text�end", |
| 66 | }, |
| 67 | { |
| 68 | name: "Control characters replaced with U+FFFD", |
| 69 | input: "test\x00null\x1Fescape\x7Fdelete", |
| 70 | expected: "test�null�escape�delete", |
| 71 | }, |
| 72 | { |
| 73 | name: "Combining marks preserved", |
| 74 | input: "e\u0301\u0302\u0303\u0304\u0305", // e with 5 combining marks |
| 75 | expected: "e\u0301\u0302\u0303\u0304\u0305", // All preserved |
| 76 | }, |
| 77 | { |
| 78 | name: "No truncation at 70 characters", |
| 79 | input: "123456789012345678901234567890123456789012345678901234567890123456789", |
| 80 | expected: "123456789012345678901234567890123456789012345678901234567890123456789", |
| 81 | }, |
| 82 | { |
| 83 | name: "No truncation with Unicode - 70 rockets preserved", |
| 84 | input: strings.Repeat("🚀", 70), |
| 85 | expected: strings.Repeat("🚀", 70), |
| 86 | }, |
| 87 | { |
| 88 | name: "Empty string", |
| 89 | input: "", |
| 90 | expected: "", |
| 91 | }, |
| 92 | { |
| 93 | name: "Only whitespace with control chars", |
| 94 | input: " \t\n ", |
| 95 | expected: "\uFFFD\uFFFD", // Tab and newline become U+FFFD, spaces trimmed |
| 96 | }, |
| 97 | { |
| 98 | name: "Leading and trailing whitespace", |
| 99 | input: " test ", |
| 100 | expected: "test", |
| 101 | }, |
| 102 | { |
| 103 | name: "Complex mix - invisible chars replaced with U+FFFD, Unicode preserved", |
| 104 | input: "kubo/1.0-🚀\u200B h̸̢̏̔ḛ̶̽̀s̵t\u202E-ąęł-中文", |
| 105 | expected: "kubo/1.0-🚀� h̸̢̏̔ḛ̶̽̀s̵t�-ąęł-中文", |
| 106 | }, |
| 107 | { |
| 108 | name: "Emoji with skin tone preserved", |
| 109 | input: "👍🏽", // Thumbs up with skin tone modifier |
| 110 | expected: "👍🏽", // Preserved as-is |
| 111 | }, |
| 112 | { |
| 113 | name: "Mixed scripts preserved", |
| 114 | input: "Hello-你好-مرحبا-Здравствуйте", |
| 115 | expected: "Hello-你好-مرحبا-Здравствуйте", |
| 116 | }, |
| 117 | { |
| 118 | name: "Format characters replaced with U+FFFD", |
| 119 | input: "test\u00ADsoft\u2060word\u206Fnom\u200Ebreak", |
| 120 | expected: "test�soft�word�nom�break", // Soft hyphen, word joiner, etc replaced |
| 121 | }, |
| 122 | { |
| 123 | name: "Complex Unicode text with many combining marks (91 runes, no truncation)", |
| 124 | input: "ț̸̢͙̞̖̏̔ȩ̶̰͓̪͎̱̠̥̳͔̽̀̃̿̌̾̀͗̕̕͜s̵̢̛̖̬͈͉͖͇͈̭̥̃́̓̌̾͊̊̂̄̍̅̂͌́ͅţ̴̯̹̪͖͓̘̊́̑̄̋̈́͐̈́̔̇̄̂́̎̓͛͠ͅ test", |
| 125 | expected: "ț̸̢͙̞̖̏̔ȩ̶̰͓̪͎̱̠̥̳͔̽̀̃̿̌̾̀͗̕̕͜s̵̢̛̖̬͈͉͖͇͈̭̥̃́̓̌̾͊̊̂̄̍̅̂͌́ͅţ̴̯̹̪͖͓̘̊́̑̄̋̈́͐̈́̔̇̄̂́̎̓͛͠ͅ test", // Not truncated (91 < 128) |
| 126 | }, |
| 127 | { |
| 128 | name: "Truncation at 128 characters", |
| 129 | input: strings.Repeat("a", 150), |
| 130 | expected: strings.Repeat("a", 128), |
| 131 | }, |
| 132 | { |
| 133 | name: "Truncation with Unicode at 128", |
| 134 | input: strings.Repeat("🚀", 150), |
| 135 | expected: strings.Repeat("🚀", 128), |
| 136 | }, |
| 137 | { |
| 138 | name: "Private use characters preserved (per spec)", |
| 139 | input: "test\uE000\uF8FF", // Private use area characters |
| 140 | expected: "test\uE000\uF8FF", // Should be preserved |
| 141 | }, |
| 142 | { |
| 143 | name: "U+FFFD replacement for multiple categories", |
| 144 | input: "a\x00b\u200Cc\u202Ed", // control, format chars |
| 145 | expected: "a\uFFFDb\uFFFDc\uFFFDd", // All replaced with U+FFFD |
| 146 | }, |
| 147 | } |
| 148 | |
| 149 | for _, tt := range tests { |
| 150 | t.Run(tt.name, func(t *testing.T) { |
| 151 | result := cmdutils.CleanAndTrim(tt.input) |
| 152 | assert.Equal(t, tt.expected, result, "CleanAndTrim(%q) = %q, want %q", tt.input, result, tt.expected) |
| 153 | }) |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | func TestCleanAndTrimIdempotent(t *testing.T) { |
| 158 | // Test that applying CleanAndTrim twice gives the same result |
| 159 | inputs := []string{ |
| 160 | "test-ąęćłńóśźż", |
| 161 | "版本-中文测试", |
| 162 | "version-1.0-🚀-🎉", |
| 163 | "h̸e̵l̷l̶o̴ w̸o̵r̷l̶d̴", |
| 164 | "test\u200Bzero\u200Cwidth", |
| 165 | } |
| 166 | |
| 167 | for _, input := range inputs { |
| 168 | once := cmdutils.CleanAndTrim(input) |
| 169 | twice := cmdutils.CleanAndTrim(once) |
| 170 | assert.Equal(t, once, twice, "CleanAndTrim should be idempotent for %q", input) |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | func TestCleanAndTrimSecurity(t *testing.T) { |
| 175 | // Test that all invisible/dangerous characters are removed |
| 176 | tests := []struct { |
| 177 | name string |
| 178 | input string |
| 179 | check func(string) bool |
| 180 | }{ |
| 181 | { |
| 182 | name: "No zero-width spaces", |
| 183 | input: "test\u200B\u200C\u200Dtest", |
| 184 | check: func(s string) bool { |
| 185 | return !strings.Contains(s, "\u200B") && !strings.Contains(s, "\u200C") && !strings.Contains(s, "\u200D") |
| 186 | }, |
| 187 | }, |
| 188 | { |
| 189 | name: "No bidi overrides", |
| 190 | input: "test\u202A\u202B\u202C\u202D\u202Etest", |
| 191 | check: func(s string) bool { |
| 192 | for _, r := range []rune{0x202A, 0x202B, 0x202C, 0x202D, 0x202E} { |
| 193 | if strings.ContainsRune(s, r) { |
| 194 | return false |
| 195 | } |
| 196 | } |
| 197 | return true |
| 198 | }, |
| 199 | }, |
| 200 | { |
| 201 | name: "No control characters", |
| 202 | input: "test\x00\x01\x02\x1F\x7Ftest", |
| 203 | check: func(s string) bool { |
| 204 | for _, r := range s { |
| 205 | if r < 0x20 || r == 0x7F { |
| 206 | return false |
| 207 | } |
| 208 | } |
| 209 | return true |
| 210 | }, |
| 211 | }, |
| 212 | } |
| 213 | |
| 214 | for _, tt := range tests { |
| 215 | t.Run(tt.name, func(t *testing.T) { |
| 216 | result := cmdutils.CleanAndTrim(tt.input) |
| 217 | assert.True(t, tt.check(result), "Security check failed for %q -> %q", tt.input, result) |
| 218 | }) |
| 219 | } |
| 220 | } |