ProFTPD の HiddenStores で atomic なアップロード

TL;DR
----

- `HiddenStores` でアップロードを2段階にできる(atomic なアップロード)
    1. アップロード開始時に `.in.filename.` があればエラー応答で中断
    1. アップロード中は `.in.filename.` に書き込む
    1. アップロード完了後に `filename` にリネームする
- `HiddenStores` は真偽値だけでなく prefix/suffix も指定できる
- `DeleteAbortedStores` で中断時にファイルを削除できる
    - `HiddenStores` を使っている場合 `DeleteAbortedStores off` としない限り削除される
    - `HiddenStores` を使っていない場合 `DeleteAbortedStores on` とした場合のみ削除される


環境
----

- CentOS 7.1
- ProFTPD 1.3.5a (yum epel)


同時アップロード
----

ProFTPD のデフォルト設定だと、異なる接続から異なるファイルを同一のパスに同時にアップロードした場合、場合によっては内容が混ざってしまう事がある。

```
put a.txt |-------->| data.txt

put b.txt   |--->| data.txt
```

上記の様なタイミングでアップロードを実行した場合、ファイルサイズが `a.txt` と同じで、 `a.txt` と `b.txt` の内容を含んだ妙なファイルができあがる。
アップロード処理の実装は読んでいないので、理屈については割愛。


HiddenStores
----

[ProFTPD](http://www.proftpd.org/) の `mod_xfer` には [`HiddenStores`](http://www.proftpd.org/docs/directives/linked/config_ref_HiddenStores.html) というディレクティブがある。
これを有効にするとアップロードが2ステップになり、内容の混在やアップロード途中のファイルが参照されてしまう様な事態を防ぐことができる。

```:/etc/proftpd.conf
HiddenStores On
```

アップロード中は `.in.filename.` という中間ファイルに書き込まれる仕組みになっており、アップロード(`STOR`)開始時に該当ファイルが存在する場合は 550 エラー応答が返ってくる。

```shell-session
ftp> put hoge.dat
local: hoge.dat remote: hoge.dat
229 Entering Extended Passive Mode (|||9147|)
550 hoge.dat: Temporary hidden file /.in.hoge.dat. already exists
```

### 中間ファイルの名前を変える

`HiddenStores` ディレクティブは prefix と suffix を指定する事もできる。
以下の様に設定すると、中間ファイル名は `.pre.filename.suf.` の形式になる。

```:/etc/proftpd.conf
HiddenStores .pre. .suf.
```


アップロードをやめた場合
----

### ftp シェルと C-c

Mac OSX 付属の `ftp` コマンドの対話的シェルを利用してアップロードを行い、アップロード中に `C-c` で中断した場合の挙動を確認。

- `put hoge.dat` を実行
- リモートの `.in.hoge.dat.` に書き込まれていく
- `C-c` で中断
- リモートの `.in.hoge.dat.` が `hoge.dat` にリネームされた
    - `HiddenStore: complex path, will rename /.in.hoge.dat. to /hoge.dat`

### ftp シェルと SIGTERM

今度は、同様の手順でアップロード中に、そのプロセスに対して `SIGTERM` を送った場合の挙動を確認。

- `put hoge.dat` を実行(プロセスA)
- リモートの `.in.hoge.dat.` に書き込まれていく
- 別の zsh シェルで `pkill -TERM ftp` を実行
- リモートの `.in.hoge.dat.` はリネームされず削除された
    - `HiddenStore: complex path, will rename /.in.hoge.dat. to /hoge.dat`
    - `removing aborted HiddenStores file '/.in.hoge.dat.'`

### DeleteAbortedStores ディレクティブとは？

`mod_xfer` には [`DeleteAbortedStores`](http://www.proftpd.org/docs/directives/linked/config_ref_DeleteAbortedStores.html) というディレクティブもあり、これは部分的にアップロードされた `HiddenStores` ファイルを自動で削除する機能を有効にするものと説明されている。

ただし、対象となるのは `ABOR` コマンドによって中断された転送のみで、接続の失敗によるものは対象としないとのこと。

`SIGTERM` を送った場合に記録されたログメッセージ `removing aborted HiddenStores file '/.in.hoge.dat.'` から、ソースコードの該当行を確認すると `DeleteAbortedStores` ディレクティブによる挙動に見える。

```c:mod_xfer.c
  if (session.xfer.xfer_type == STOR_HIDDEN) {
    delete_stores = get_param_ptr(CURRENT_CONF, "DeleteAbortedStores", FALSE);
    if (delete_stores == NULL ||
        *delete_stores == TRUE) {
      /* If a hidden store was aborted, remove only hidden file, not real
       * one.
       */
      if (session.xfer.path_hidden) {
        pr_log_debug(DEBUG5, "removing aborted HiddenStores file '%s'",
          session.xfer.path_hidden);
        pr_fsio_unlink(session.xfer.path_hidden);
      }
    }
```

設定ファイルにディレクティブを書いていない状態なので `NULL` となっていて、ファイルを `unlink` する条件「 `NULL` **か** `TRUE` 」を満たしたのではないか。

### DeleteAbortedStores off

`DeleteAbortedStores off` とした場合の `C-c` による中断と `SIGTERM` による中断について挙動を確認。

#### C-c

- `put hoge.dat` を実行
- リモートの `.in.hoge.dat.` に書き込まれていく
- `C-c`
- リモートの `.in.hoge.dat.` が `hoge.dat` にリネームされた
    - `HiddenStore: complex path, will rename /.in.hoge.dat. to /hoge.dat`

#### SIGTERM

- `put hoge.dat` を実行(プロセスA)
- リモートの `.in.hoge.dat.` に書き込まれていく
- 別の zsh シェルで `pkill -TERM ftp` を実行
- リモートの `.in.hoge.dat.` が `hoge.dat` にリネームされた
    - `HiddenStore: complex path, will rename /.in.hoge.dat. to /hoge.dat`

### DeleteAbortedStores on

#### C-C で中断

- `put hoge.dat` を実行
- リモートの `.in.hoge.dat.` に書き込まれていく
- `C-c`
- リモートの `.in.hoge.dat.` が `hoge.dat` にリネームされた
    - `HiddenStore: complex path, will rename /.in.hoge.dat. to /hoge.dat`

#### SIGTERM

- `put hoge.dat` を実行(プロセスA)
- リモートの `.in.hoge.dat.` に書き込まれていく
- 別の zsh シェルで `pkill -TERM ftp` を実行
- リモートの `.in.hoge.dat.` はリネームされず削除された
    - `HiddenStore: complex path, will rename /.in.hoge.dat. to /hoge.dat`
    - `removing aborted HiddenStores file '/.in.hoge.dat.'`


HiddenStores off と DeleteAbortedStores
----

### DeleteAbortedStores なし

`C-c` による中断および `SIGTERM` による中断でアップロード途中のファイルは残る。

### DeleteAbortedStores on

`C-c` による中断でアップロード途中のファイルが残り、 `SIGTERM` による中断でアップロード途中のファイルは消される。

### DeleteAbortedStores off

`C-c` による中断および `SIGTERM` による中断でアップロード途中のファイルは残る。

### ソースコードで確認

`HiddenStores` が無効である場合、ファイルを `unlink` する条件が「 `NULL` ではなく `TRUE` である場合」となっている。
`HiddenStores` が有効か無効かで `unlink` する条件が異なる模様。

```c:mod_xfer.c
  if (session.xfer.xfer_type == STOR_HIDDEN) {
    ...
  } else if (session.xfer.path) {
    delete_stores = get_param_ptr(CURRENT_CONF, "DeleteAbortedStores", FALSE);
    if (delete_stores != NULL &&
        *delete_stores == TRUE) {
      pr_log_debug(DEBUG5, "removing aborted file '%s'", session.xfer.path);
      pr_fsio_unlink(session.xfer.path);
    }
  }
```


HiddenStores とファイルモード
----

`HiddenStores on` としている時の `STOR` は、ファイルをモード `O_WRONLY | O_CREAT | O_EXCL` でオープンする。
`O_EXCL` が指定されているため、すでに中間ファイルが存在している場合はオープンに失敗する。

※ ただし、NFS を使っている場合は `O_EXCL` を使わない。