2020年にruby-net-nntpとtmailを動かしてみる

# はじめに

必要に迫られてNNTPで通信をすることになりました。

昔に作成した自前のNNTPクラスでも良かったのですが、Gemfileが使えるruby-net-nntpを利用することにします。このライブラリは記事をTMail::Mailクラスに格納するため、bundle installで、tmail-1.2.7.1もダウンロードします。

net/nttpの利用と、tmailで発生した問題について、まとめてみました。

# 環境 & 準備作業

* ubuntu 18.04 LTS
* ruby 2.5.1 (Ubuntu付属のパッケージ)

## ライブラリのダウンロード

次のファイルを準備しました。

```Gemfile
source 'https://rubygems.org'
gem "ruby-net-nntp"
```

```bash:bundlerを利用してライブラリをダウンロードする
$ bundle install --path lib
$ ls lib/ruby/2.5.0/gems/
log4r-1.1.10/  ruby-net-nntp-1.0.0/  tmail-1.2.7.1/
```

# サンプルプログラムの作成

コードを動かそうとしたところ、さっそくエラーに遭遇しました。

## 遭遇したエラー

```require "net/nntp"``` を実行すると、次のようなエラーが発生します。

```bash:irbからエラーを再現する
$ irb
irb(main):001:0> require "rubygems"
=> false
irb(main):002:0> require "bundler/setup"
=> true
irb(main):003:0> require 'net/nntp'
Traceback (most recent call last):
       14: from /usr/bin/irb:11:in `<main>'
       ...
        2: from .../lib/ruby/2.5.0/gems/tmail-1.2.7.1/lib/tmail/utils.rb:110:in `<module:TMail>'
        1: from .../lib/ruby/2.5.0/gems/tmail-1.2.7.1/lib/tmail/utils.rb:117:in `<module:TextUtils>'
RegexpError (/.../n has a non escaped non ASCII character in non ASCII-8BIT script)
```

これは[http://www.ownway.info/Blog/2011/09/-invalid-multibyte-escape.html - 正規表現で invalid multibyte escape エラーが発生する問題の対処方法](http://www.ownway.info/Blog/2011/09/-invalid-multibyte-escape.html)に説明されています。

この記事にあるGoogleへのリンクは機能しないので、[https://www.ruby-forum.com/t/ruby-1-9-1-invalid-multibyte-escape-regexperror/164732](https://www.ruby-forum.com/t/ruby-1-9-1-invalid-multibyte-escape-regexperror/164732)で、オリジナルの議論が確認できます。


# 差分

単純に問題が起こる箇所の /../n 記法を、Regexp.new() で置き換えます。

```diff:tmailライブラリの差分
diff -ru tmail-1.2.7.1.orig/./lib/tmail/scanner_r.rb tmail-1.2.7.1/./lib/tmail/scanner_r.rb
--- tmail-1.2.7.1.orig/./lib/tmail/scanner_r.rb 2020-06-10 22:06:16.813972850 +0900
+++ tmail-1.2.7.1/./lib/tmail/scanner_r.rb      2020-06-11 11:17:51.741512220 +0900
@@ -63,24 +63,24 @@
     PATTERN_TABLE = {}
     PATTERN_TABLE['EUC'] =
       [
-        /\A(?:[#{atomchars}]+|#{iso2022str}|#{eucstr})+/n,
-        /\A(?:[#{tokenchars}]+|#{iso2022str}|#{eucstr})+/n,
+        Regexp.new("\A(?:[#{atomchars}]+|#{iso2022str}|#{eucstr})+", nil, "n"),
+        Regexp.new("\A(?:[#{tokenchars}]+|#{iso2022str}|#{eucstr})+", nil, "n"),
         quoted_with_iso2022,
         domlit_with_iso2022,
         comment_with_iso2022
       ] 
     PATTERN_TABLE['SJIS'] =
       [
-        /\A(?:[#{atomchars}]+|#{iso2022str}|#{sjisstr})+/n,
-        /\A(?:[#{tokenchars}]+|#{iso2022str}|#{sjisstr})+/n,
+        Regexp.new("\A(?:[#{atomchars}]+|#{iso2022str}|#{sjisstr})+", nil, "n"),
+        Regexp.new("\A(?:[#{tokenchars}]+|#{iso2022str}|#{sjisstr})+", nil, "n"),
         quoted_with_iso2022,
         domlit_with_iso2022,
         comment_with_iso2022
       ] 
     PATTERN_TABLE['UTF8'] =
       [
-        /\A(?:[#{atomchars}]+|#{utf8str})+/n,
-        /\A(?:[#{tokenchars}]+|#{utf8str})+/n,
+        Regexp.new("\A(?:[#{atomchars}]+|#{utf8str})+", nil, "n"),
+        Regexp.new("\A(?:[#{tokenchars}]+|#{utf8str})+", nil, "n"),
         quoted_without_iso2022,
         domlit_without_iso2022,
         comment_without_iso2022
@@ -258,4 +258,4 @@
   end

 end   # module TMail
-#:startdoc:
\ No newline at end of file
+#:startdoc:
diff -ru tmail-1.2.7.1.orig/./lib/tmail/utils.rb tmail-1.2.7.1/./lib/tmail/utils.rb
--- tmail-1.2.7.1.orig/./lib/tmail/utils.rb     2020-06-10 22:06:16.813972850 +0900
+++ tmail-1.2.7.1/./lib/tmail/utils.rb  2020-06-11 11:11:59.582616384 +0900
@@ -114,10 +114,11 @@
     lwsp         = %Q| \t\r\n|
     control      = %Q|\x00-\x1f\x7f-\xff|
 
-    CONTROL_CHAR  = /[#{control}]/n
-    ATOM_UNSAFE   = /[#{Regexp.quote aspecial}#{control}#{lwsp}]/n
-    PHRASE_UNSAFE = /[#{Regexp.quote aspecial}#{control}]/n
-    TOKEN_UNSAFE  = /[#{Regexp.quote tspecial}#{control}#{lwsp}]/n
+    ## reference: http://www.ownway.info/Blog/2011/09/-invalid-multibyte-escape.html
+    CONTROL_CHAR  = Regexp.new("[#{control}]", nil, "n")
+    ATOM_UNSAFE   = Regexp.new("[#{Regexp.quote aspecial}#{control}#{lwsp}]", nil, "n")
+    PHRASE_UNSAFE = Regexp.new("[#{Regexp.quote aspecial}#{control}]", nil, "n")
+    TOKEN_UNSAFE  = Regexp.new("[#{Regexp.quote tspecial}#{control}#{lwsp}]", nil, "n")
     
     # Returns true if the string supplied is free from characters not allowed as an ATOM
     def atom_safe?( str )
diff -ru tmail-1.2.7.1.orig/./lib/tmail/vendor/rchardet-1.3/lib/rchardet/universaldetector.rb tmail-1.2.7.1/./lib/tmail/vendor/rchardet-1.3/lib/rchardet/universaldetector.rb
--- tmail-1.2.7.1.orig/./lib/tmail/vendor/rchardet-1.3/lib/rchardet/universaldetector.rb        2020-06-10 22:06:16.829961402 +0900
+++ tmail-1.2.7.1/./lib/tmail/vendor/rchardet-1.3/lib/rchardet/universaldetector.rb     2020-06-11 11:18:58.457912881 +0900
@@ -36,7 +36,7 @@
   class UniversalDetector
     attr_accessor :result
     def initialize
-      @_highBitDetector = /[\x80-\xFF]/
+      @_highBitDetector = Regex.new("[\x80-\xFF]")
       @_escDetector = /(\033|\~\{)/
       @_mEscCharSetProber = nil
       @_mCharSetProbers = []
```

# 実際にNNTPサーバーにアクセスしてみる

オープンアクセスできるNetNewsサーバーが存在しているか分かりませんが、手元のINN 2.5.5にアクセスするコードは次のようになりました。

```ruby:test.rb
#!/usr/bin/ruby

require "bundler/setup"
Bundler.require

nntp = Net::NNTP.new
Net::NNTP.logger = Log4r::Logger.new("nntp")
nntp.host = "nntp.example.com"
nntp.port = 119 
welcome = nntp.connect
if Net::NNTP::OKResponse === welcome
  group_name = "misc.test"
  group_response = nntp.process(Net::NNTP::Group.new(group_name))
  listgroup_response = nntp.process(Net::NNTP::Listgroup.new(group_name))
  listgroup_response.list.each { |article_id|
    
  }
end
```

## Subjectの文字化けへの対応

これはDockerとは関係ないので、上のセクションに追記しておきます。
tmailは格納したメッセージのヘッダーについて、いくつかのフィールドは固有のクラスを持っていますが、Subject:など固有の処理を行なわないものは**UnstructuredHeader**クラスにその値が格納されています。

この中で、Decoder.decodeを呼び出しますが、この実装がNKF.nkfが"-mSj"オプション(MIME decode + Shift-JIS input encoding + ISO-2022-JP output encoding)で呼び出されるようになっています。

Decoder.decodeの実装を直してしまうと問題があるので、省略されている引数を利用し、output encodingをUTF8にしています。

またFrom:は、**StructuredHeader**を継承している**AddressHeader**クラスを利用するため、また別の対応が必要です。nkfを呼び出す前のencodingとして"jes"(ISO-2022-JP,EUC-JP,Shift-JIS)のみを前提としたチェックルーチンが入っているので、この点については少しDecoderクラスの定義にも手を入れています。

```diff:tmailへの追加の差分
diff -ur lib/ruby/2.5.0/gems/tmail-1.2.7.1.orig/lib/tmail/header.rb lib/ruby/2.5.0/gems/tmail-1.2.7.1/lib/tmail/header.rb
--- lib/ruby/2.5.0/gems/tmail-1.2.7.1.orig/lib/tmail/header.rb  2020-06-16 10:59:57.589086000 +0900
+++ lib/ruby/2.5.0/gems/tmail-1.2.7.1/lib/tmail/header.rb       2020-06-16 11:01:30.527304391 +0900
@@ -189,5 +189,5 @@
     end

     def parse
-      @body = Decoder.decode(@body.gsub(/\n|\r\n|\r/, ''))
+      @body = Decoder.decode(@body.gsub(/\n|\r\n|\r/, ''), 'w')
     end

     def isempty?
diff -ur lib/ruby/2.7.0/gems/tmail-1.2.7.1.orig/lib/tmail/encode.rb lib/ruby/2.7.0/gems/tmail-1.2.7.1/lib/tmail/encode.rb
--- lib/ruby/2.7.0/gems/tmail-1.2.7.1.orig/lib/tmail/encode.rb  2020-06-18 14:32:37.352341212 +0900
+++ lib/ruby/2.7.0/gems/tmail-1.2.7.1/lib/tmail/encode.rb       2020-06-18 14:45:57.637057274 +0900
@@ -128,7 +128,7 @@

     def initialize( dest, encoding = nil, eol = "\n" )
       @f = StrategyInterface.create_dest(dest)
-      @encoding = (/\A[ejs]/ === encoding) ? encoding[0,1] : nil
+      @encoding = (/\A[ejsw]/ === encoding) ? encoding[0,1] : nil
       @eol = eol
     end

diff -ur lib/ruby/2.7.0/gems/tmail-1.2.7.1.orig/lib/tmail/header.rb lib/ruby/2.7.0/gems/tmail-1.2.7.1/lib/tmail/header.rb
--- lib/ruby/2.7.0/gems/tmail-1.2.7.1.orig/lib/tmail/header.rb  2020-06-18 14:32:37.993666420 +0900
+++ lib/ruby/2.7.0/gems/tmail-1.2.7.1/lib/tmail/header.rb       2020-06-18 14:47:16.026796369 +0900
@@ -147,7 +147,7 @@

     def body
       ensure_parsed
-      v = Decoder.new(s = '')
+      v = Decoder.new(s = '', "w")
       do_accept v
       v.terminate
       s
@@ -225,7 +225,7 @@
       rescue SyntaxError
         if not save and mime_encoded? @body
           save = @body
-          @body = Decoder.decode(save)
+          @body = Decoder.decode(save, "w")
           retry
         elsif save
           @body = save
```

Dockerのruby:2.7-alpineイメージや、Ubuntu 20.04 LTSなど、ruby-2.7な環境でpatchをあてる時には、あらかじめファイルの"2.5.0"を"2.7.0"に変換してから利用してください。

```bash:2.7環境へのパッチ適用例
$ sed -e 's/2.5.0/2.7.0/g' tmail.diff | patch -p0
```

# Dockerfileで遭遇したエラー

Dockerイメージは、ruby:2.7-alpineを利用しています。
エラーはいくつかあり、ruby-2.5では顕在化しなかった問題、root以外のユーザーで実行しようとした事に由来するものなどがありました。

とりあえずシンプルにするためにmulti stage buildなどを省いたDockerfileを以下に示します。
カレントディレクトリには、Gemfileやconfig.ruなどopenapi-generatorで作成したruby-sinatraのテンプレートコードが存在します。

```dockerfile:Dockerfile
FROM ruby:2.7-alpine as rubydev

RUN apk --no-cache add tzdata bash ca-certificates make gcc libc-dev linux-headers build-base patch 

RUN mkdir /app
COPY . /app
WORKDIR /app

RUN cp /usr/local/include/ruby-2.7.0/ruby/defines.h /usr/local/include/ruby-2.7.0/defines.h

RUN bundle config path lib
RUN bundle install

ENV SINATRA_PORT 8080
EXPOSE $SINATRA_PORT

ADD run.sh /run.sh
RUN chmod +x /run.sh

RUN addgroup sinatra
RUN adduser -S -G sinatra sinatra
# RUN cp -r /root/.bundle /home/sinatra/.bundle
USER sinatra

ENTRYPOINT ["/run.sh"]

```

## ruby-2.7でtmailのnative extensionがコンパイルできない問題

defines.hがコンパイルできないとエラーになります。

```text:遭遇したエラー
Gem::Ext::BuildError: ERROR: Failed to build gem native extension.
...
current directory: /app/lib/ruby/2.7.0/gems/tmail-1.2.7.1/ext/tmailscanner/tmail
make "DESTDIR="
make: *** No rule to make target '/usr/local/include/ruby-2.7.0/defines.h',
needed by 'tmailscanner.o'.  Stop.
...
```

期待する場所にdefines.hが存在しないため、cpして対応しました。

```dockerfile:Dockerfileに加えた回避策
RUN cp /usr/local/include/ruby-2.7.0/ruby/defines.h \
  /usr/local/include/ruby-2.7.0/defines.h
```

Ubuntu 18.04 + ruby-2.5 (deb package)な環境では、Makefileの最後に明示的にruby/defines.hを指定していましたが、Dockerfileの中でtmailのMakefileを確認した限りでは、-Iフラグに/usr/local/include/ruby-2.7.0までが指定されているだけのようでした。

```Makefile:ubuntu+ruby-2.5の環境
tmailscanner.o: tmailscanner.c $(hdrdir)/ruby/ruby.h $(arch_hdrdir)/ruby/config.h $(hdrdir)/ruby/defines.h Makefile
```

ruby:2.7-alpineの環境では、defines.hはruby/defines.hとなっているべきところが異なっていました。

```Makefile:2.7-alpineなdockerイメージ
tmailscanner.o: tmailscanner.c $(hdrdir)/ruby.h $(arch_hdrdir)/ruby/config.h $(hdrdir)/defines.h Makefile
```

原因はmkmf.rbにあって、dependファイルを書き出す前処理で、``$(hdrdir)/defines.h`` となっている行を、``$(hdrdir)ruby/defines.h`` へと変更する処理がruby-2.7のmkmf.rbでは削除されているからでした。

```ruby:mkmf.rb
## ruby-2.5のmkmf.rb
   depend.each_line do |line|
      line.gsub!(/\.o\b/, ".#{$OBJEXT}")
      line.gsub!(/\{\$\(VPATH\)\}/, "") unless $nmake
      line.gsub!(/\$\((?:hdr|top)dir\)\/config.h/, $config_h)
      line.gsub!(%r"\$\(hdrdir\)/(?!ruby(?![^:;/\s]))(?=[-\w]+\.h)", '\&ruby/')
      if $nmake && /\A\s*\$\(RM|COPY\)/ =~ line

## ruby-2.7のmkmf.rb
    depend.each_line do |line|
      line.gsub!(/\.o\b/, ".#{$OBJEXT}")
      line.gsub!(/\{\$\(VPATH\)\}/, "") unless $nmake
      line.gsub!(/\$\((?:hdr|top)dir\)\/config.h/, $config_h)
      if $nmake && /\A\s*\$\(RM|COPY\)/
```

https://github.com/ruby/ruby/commit/3d1c86a26f0c96a9c1d0247b968aa96e6f3c30bb#diff-7aa560cb6196deeb96779cd175e8e589 でコミットされた変更のようです。

ruby.hの配置場所を修正したかったようですが、おそらく該当行を削除するのではなく、この下に”ruby/ruby.h"を"ruby.h"に書き換えるようなgsub!行を追加するべきだったのかなと思います。

RubyのIssue Tracking Systemには、https://bugs.ruby-lang.org/issues/16490 として登録されていますが、検討は進んでいないようですね。

Dockerで利用するような場合、mkmf.rbにpatchを当てるよりは、defines.hの場所を変更する方法で対応する回避策を取る他にないですね…。

## rackupが見つからないエラーへの対応

ruby-2.5では、```$ bundle install --path lib```を利用していましたが、deprecatedと警告がでたので```$ bundle config path lib```を利用するように変更しています。

このため、~/.bundle/configファイルの内容に依存するため、```USER sinatra```によって実行時のユーザーを変更したため、~/.bundle/configファイルが存在しないことでエラーに遭遇しました。

```text:エラーの内容
$ make docker-run
sudo docker run -it --rm -p 8080:8080 --name nntp-reader nntp-reader:latest
bundler: command not found: rackup
Install missing gem executables with `bundle install`
Makefile:43: recipe for target 'docker-run' failed
make: *** [docker-run] Error 127
```

以下のような対応がまず考えられます。

```bash:回避策1-run.shで実行時に解決する
#!/bin/bash

bundle config path lib  ## 追加したワークアラウンド
export SINATRA_PORT=${SINATRA_PORT:-8080}
bundle exec rackup --host 0.0.0.0 --port $SINATRA_PORT
```

今回採用したのは次の方法でした。

```dockerfile:回避策2-Dockerfileでイメージ作成時に解決する
RUN cp -r /root/.bundle /home/sinatra/.bundle
```

# さいごに

NNTPは学生時代に始めて触ったプロトコルの一つで、RFCを読みながら自分用のRubyライブラリを実装したりしていました。

現代ではオープンなプロトコルよりもクローズドでも便利そうなサービスが流行るようになってしまい廃れていくのが残念でなりませんが、もしまだ役に立つようであれば幸いです。


以上