diff --git a/shell/plugins/clipboard/capture.sh b/shell/plugins/clipboard/capture.sh index cf4b980d..d6ce2b28 100755 --- a/shell/plugins/clipboard/capture.sh +++ b/shell/plugins/clipboard/capture.sh @@ -43,22 +43,47 @@ emit_image() { } emit_text() { - perl -MEncode=decode,FB_CROAK -MJSON::PP=encode_json -0777 -e ' + perl -MEncode=decode,FB_CROAK,LEAVE_SRC -MJSON::PP=encode_json -0777 -e ' my $raw = ; exit unless length $raw; my $encoding; + my $heuristic_encoding = 0; if ($raw =~ /^(?:\xFF\xFE|\xFE\xFF)/) { $encoding = "UTF-16"; - } elsif ($raw =~ /^(?:[^\0]\0)+\z/s) { - # BOM-less UTF-16 is indistinguishable from NUL-separated bytes, so only - # decode the consistent whole-payload pattern seen from affected apps. - $encoding = "UTF-16LE"; - } elsif ($raw =~ /^(?:\0[^\0])+\z/s) { - $encoding = "UTF-16BE"; + } elsif (length($raw) % 2 == 0 && index($raw, "\0") >= 0) { + my $units = length($raw) / 2; + my $nuls = $raw =~ tr/\0/\0/; + + # Neither byte lane can reach the padding threshold when the entire + # payload contains fewer NULs than that, so avoid two full string passes. + if ($nuls * 4 >= $units * 3) { + my $even_bytes = $raw; + $even_bytes =~ s/(.)./$1/sg; + my $even_nuls = $even_bytes =~ tr/\0/\0/; + undef $even_bytes; + + my $odd_bytes = $raw; + $odd_bytes =~ s/.(.)/$1/sg; + my $odd_nuls = $odd_bytes =~ tr/\0/\0/; + + # BOM-less UTF-16 is indistinguishable from NUL-separated bytes. Decode + # only when at least three quarters of the code units have consistent + # padding and fewer than one quarter have NULs in the opposite byte. + if ($odd_nuls * 4 >= $units * 3 && $even_nuls * 4 < $units) { + $encoding = "UTF-16LE"; + $heuristic_encoding = 1; + } elsif ($even_nuls * 4 >= $units * 3 && $odd_nuls * 4 < $units) { + $encoding = "UTF-16BE"; + $heuristic_encoding = 1; + } + } } - my $text = $encoding ? eval { decode($encoding, $raw, FB_CROAK) } : undef; + my $text = $encoding ? eval { decode($encoding, $raw, FB_CROAK | LEAVE_SRC) } : undef; + if ($heuristic_encoding && defined($text) && $text =~ /[\x00-\x08\x0E-\x1A\x1C-\x1F]/) { + $text = undef; + } $text = decode("UTF-8", $raw) unless defined $text; print "{\"type\":\"text\",\"text\":", encode_json($text), "}\n"; ' diff --git a/test/shell.d/clipboard-test.sh b/test/shell.d/clipboard-test.sh index aafcd75d..962d529e 100644 --- a/test/shell.d/clipboard-test.sh +++ b/test/shell.d/clipboard-test.sh @@ -296,8 +296,36 @@ capture_output=$(printf '%s' 'UTF-16 clipboard - fixed' | iconv -f UTF-8 -t UTF- [[ $capture_output == '{"type":"text","text":"UTF-16 clipboard - fixed"}' ]] || fail "clipboard capture decodes UTF-16LE text" pass "clipboard capture decodes UTF-16LE text" -# BOM-less UTF-16LE "A" is byte-identical to UTF-8 "A\0". The strict -# whole-payload pattern intentionally resolves that ambiguity as UTF-16. +capture_output=$(printf '%s' 'https://example.com/image — preview …' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text) +[[ $capture_output == '{"type":"text","text":"https://example.com/image — preview …"}' ]] || fail "clipboard capture decodes mostly ASCII UTF-16LE text with Unicode punctuation" +pass "clipboard capture decodes mostly ASCII UTF-16LE text with Unicode punctuation" + +capture_output=$(printf '%s' 'Text with 日本 and 😀' | iconv -f UTF-8 -t UTF-16BE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text) +[[ $capture_output == '{"type":"text","text":"Text with 日本 and 😀"}' ]] || fail "clipboard capture decodes mostly ASCII UTF-16BE text with Unicode characters" +pass "clipboard capture decodes mostly ASCII UTF-16BE text with Unicode characters" + +capture_output=$(printf '%s' 'ABC—' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text) +[[ $capture_output == '{"type":"text","text":"ABC—"}' ]] || fail "clipboard capture decodes UTF-16LE text at the padding threshold" +pass "clipboard capture decodes UTF-16LE text at the padding threshold" + +printf '%s' 'ABCDEFGH————' | iconv -f UTF-8 -t UTF-16LE >"$TMPDIR/below-utf16-threshold" +expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/below-utf16-threshold") +capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/below-utf16-threshold") +[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves UTF-16LE text below the padding threshold undecoded" "expected: $expected\nactual: $capture_output" +pass "clipboard capture leaves UTF-16LE text below the padding threshold undecoded" + +printf '%s' 'ABCĀ' | iconv -f UTF-8 -t UTF-16LE >"$TMPDIR/opposite-nul-threshold" +expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/opposite-nul-threshold") +capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/opposite-nul-threshold") +[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves UTF-16LE text at the opposite-byte NUL threshold undecoded" "expected: $expected\nactual: $capture_output" +pass "clipboard capture leaves UTF-16LE text at the opposite-byte NUL threshold undecoded" + +capture_output=$(printf 'a\fb' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text) +[[ $capture_output == '{"type":"text","text":"a\fb"}' ]] || fail "clipboard capture preserves UTF-16LE form feeds" +pass "clipboard capture preserves UTF-16LE form feeds" + +# BOM-less UTF-16LE "A" is byte-identical to UTF-8 "A\0". The padding +# heuristic intentionally resolves that ambiguity as UTF-16. capture_output=$(printf 'A' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text) [[ $capture_output == '{"type":"text","text":"A"}' ]] || fail "clipboard capture decodes exact NUL-padded UTF-16LE text" pass "clipboard capture decodes exact NUL-padded UTF-16LE text" @@ -327,6 +355,24 @@ assert_ambiguous_utf16_falls_back "clipboard capture leaves BOM-less UTF-16 punc assert_ambiguous_utf16_falls_back "clipboard capture leaves BOM-less UTF-16 CJK undecoded" '日本' assert_ambiguous_utf16_falls_back "clipboard capture leaves BOM-less UTF-16 surrogate pairs undecoded" '😀' +printf 'foo\0bar\0' >"$TMPDIR/nul-separated-utf8" +expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/nul-separated-utf8") +capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/nul-separated-utf8") +[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves sparse NUL-separated UTF-8 undecoded" "expected: $expected\nactual: $capture_output" +pass "clipboard capture leaves sparse NUL-separated UTF-8 undecoded" + +printf 'Hello\0\0\0\0\0\0\0\0\0\0\0' >"$TMPDIR/nul-padded-utf8" +expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/nul-padded-utf8") +capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/nul-padded-utf8") +[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves NUL-padded UTF-8 undecoded" "expected: $expected\nactual: $capture_output" +pass "clipboard capture leaves NUL-padded UTF-8 undecoded" + +printf '\001\000\001\000\001\000\001\000' >"$TMPDIR/ambiguous-control-text" +expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/ambiguous-control-text") +capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/ambiguous-control-text") +[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves endian-ambiguous control text undecoded" "expected: $expected\nactual: $capture_output" +pass "clipboard capture leaves endian-ambiguous control text undecoded" + printf '\377\376\075\330' >"$TMPDIR/malformed-utf16" expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/malformed-utf16") capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/malformed-utf16")