クライアント側でのクッキーの扱い方

Gaucheでクライアント側でのクッキー処理のプログラムを書こうとしたら、 クッキーの知識を全然もっていないことに気づいたのでメモ。

クッキーの扱い

  • 広く使われているクッキーは、ネットスケープで実装され提案されたものに沿ったもの。RFC 2109やRFC 2965に記述されているクッキーは全く使われていない。
    1. ネットスケープの提案(日本語訳): かなり説明不足な仕様。たいていのウェブブラウザが受け付けるクッキーは、ここで説明されているものにもとづいている。
    2. RFC 2109: 普及しなかった仕様その1。ネットスケープ版のクッキーにいくつか非互換の変更を加えている。expires属性が無くて代わりにmax-age属性。version属性が必須。domain属性の値は必ずドットから始める、など。RFC2965によりObsoleted(破棄)になった。
    3. RFC 2965: 普及しなかった仕様その2。Set-CookieヘッダではなくSet-Cookie2ヘッダを使う。(追記: RFC6265により破棄になった)
    4. (追記: RFC 6265: 2011年に成立したもの。ネットスケープ版の拡張に近いが、非互換な部分もある→Cookieの日付形式。普及するかは不明)
  • サーバーがクライアントに送るクッキーと、クライアントがサーバーに送るクッキーの2種類がある。

ネットスケープが策定したクッキー仕様は決まった呼び名がないみたい。ネットスケープクッキーと呼ぶのが妥当かなと思ったけど、Pythonのマニュアルのcookielibの項では、RFC 2109のクッキーをネットスケープクッキーと呼んでいた。
普及しているクッキーを元にした仕様を現在、策定中みたい(draft-ietf-httpstate-cookie-20 - HTTP State Management Mechanism 追記:RFC 6265となった)。なのでRFC 2109やRFC 2965の仕様が普及することはたぶんない。

サーバー → クライアント

サーバーからクライアントへはSet-Cookieヘッダで送られ、

Set-Cookie: 名前=値; Expires=日付; Domain=...; Path=...; Secure ; HttpOnly

という形式になっている。「名前」「Expires」「Domain」などの大文字小文字は無視される(と思うのだけど、元の仕様には書いてない)。「名前=値」以外のフィールドは省略してもよい。
Expires(破棄時間)の日付は「Wdy, DD-Mon-YYYY HH:MM:SS GMT」という特殊なフォーマットをとる(たとえば「Thu, 30-Dec-2010 21:51:09 GMT」みたいになる)。RFC 2109に厳密に従うライブラリで字句解析するとコンマを区切りに解釈してしまうかもしれない(Gaucheのライブラリは対応している)。

クライアント → サーバー

クライアントからサーバーへはCookieヘッダで送られる。形式は次のようになっている。

Cookie: 名前1=値1; 名前2=値2 ; ...
Gaucheでクッキーを扱う。

Gaucherfc.cookieモジュールは、RFC2965のクッキーをサーバー側(主にCGI用途)で扱うためのモジュール。でもクッキー文字列をパーズするparse-cookie-string関数は、Set-CookieCookieのどちらのクッキー文字列にも使える。パーズした結果は連想リストで得られる(Secure、HttpOnlyなど、値がない場合は#fが値になる)。
逆に連想リストからネットスケープ形式のクッキー文字列を作るには

(use util.list)
(use text.tree)

(define (alist->ns-cookie-string alist)
  (tree->string
    (intersperse ";"
                 (map (lambda (x) (intersperse "=" x)) alist))))

などとすればできる。日付の解析は、

(use srfi-19)

(define (ns-cookie-time-string->date str)
  (string->date str "~a, ~d-~b-~Y ~H:~M:~S GMT"))

(define (date->ns-cookie-time-string date)
  (date->string date "~a, ~d-~b-~Y ~H:~M:~S GMT"))

で。

ネットワーク送信テストの仕方

ネットワーク通信をするプログラムを書くと、相手に意図どおりにちゃんと送信できているのかをテストしたくなる。
送ったものをそのまま返信するプログラムを書けばいいのかとか、返信にもヘッダーなんかをつける必要があるからそのプログラムのテストも要るのかなどと考えたけど、テストしたい関数を実行する方を別プロセスや別スレッドで走らせて、受信側でテストするのが簡単だと気づいた。

送信側(テストしたい関数を呼んで送信) → 受信側(受信してテスト)

ひょっとすると重大な問題点があるかもしれないけど、とりあえずテストはできる。
例えばこんな感じ。

(use gauche.net)
(use gauche.threads)
(use rfc.http)
(use srfi-1)

;;; 送信側(クライアント)の動作はclient-thunkで指定
;;; 受信側(サーバ)の動作を表すprocは引数に入力ポートと出力ポートを取る
(define (self-sufficient-server port client-thunk proc)
  (let ((server-sock (make-server-socket 'inet port :reuse-addr? #t))
        (client-thunk2
          (lambda () (guard (e (else (format (current-error-port)
                                             "client side error:~s:~a\n"
                                             e (ref e 'message))
                                 (raise e)))
                       (client-thunk)))))
    (guard (e (else (socket-close server-sock) (raise e)))
      (let ((th (make-thread client-thunk2)))
        (thread-start! th)
        (call-with-client-socket
          (socket-accept server-sock)
          proc)
        (thread-terminate! th)
        (guard (e ((<terminated-thread-exception> e) 'thread-terminate)
                  (else (raise e)))
          (thread-join! th))))
    (socket-close server-sock)))


;;; テスト例
(use gauche.test)
(self-sufficient-server
  12345
  ; クライアント側
  (lambda ()
    (http-get "localhost:12345"
              ;  間違った指定の仕方
              '("/aaa/bbb/ccc?x=1" ("y" "2") ("z" "3"))
              :foo-header "abcdef"))
  ; サーバ側
  (lambda (in out)
    (test* "http-get:request-uri"
      "GET /aaa/bbb/ccc?x=1&y=2&z=3 HTTP/1.1" (read-line in))))

test http-get:request-uri, expects "GET /aaa/bbb/ccc?x=1&y=2&z=3 HTTP/1.1"
                    ==> ERROR: GOT "GET /aaa/bbb/ccc?x=1?y=2&z=3 HTTP/1.1"