はじめに

平素は大変お世話になっております。
クイックガードのパー子です。

先日、とある Ubuntuサーバの /etc/cron.d/配下に debパッケージのゴミ (= *.dpkg-old とか) がいくつも放置されていることに気がつきました。
このようなゴミは cron がよしなに無視してくれることを経験的には知っていたのですが、正確な仕様が気になってあらためて man をじっくり読んでみたところ、いろいろ興味深い発見 (というか罠) があったのでご紹介したいと思います。

この記事で書くこと、書かないこと

crontab の書き方などの基本的な使い方は今さらなのでググっていただくとして、この記事では Debian系における /etc/周りの制約と罠をご紹介します。
スプール領域 (/var/spool/cron/) には触れません。

man に DEBIAN SPECIFIC というセクションがあり、Debian固有の制約が詳しく書かれているので、特にこれを重点的に見ていきます。

検証環境

検証には以下の環境を使いました。

  • OS: Ubuntu (20.04.1 LTS Focal Fossa)
  • cron実装: apt install cron でインストールした Vixie Cron (3.0pl1-136ubuntu1)

/etc/配下の crontabファイル

本題に入る前に、まずはおさらいとして /etc/配下にある crontab (cron table) ファイルの中身を見ていきましょう。

ご存知のとおり、Debian系の cron は以下の 2か所に置かれた crontabファイルを認識します。

  • /etc/crontab
  • /etc/cron.d/*

また、/etc/cron.hourly//etc/cron.daily/ などの事前定義済みのディレクトリ (以降、総称して「/etc/cron.<PERIOD>/」と呼びます。) 配下に実行ファイルを置いておけば、配置したディレクトリに応じて毎時〜毎月の頻度で定期実行してくれます。

/etc/crontab

Debian系ではこのファイルで /etc/cron.<PERIOD>/配下のジョブをスケジューリングしています。

/etc/crontab
# /etc/crontab: system-wide crontab
# Unlike any other crontab you don't have to run the `crontab'
# command to install the new version when you edit this file
# and files in /etc/cron.d. These files also have username fields,
# that none of the other crontabs do.

SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

# Example of job definition:
# .---------------- minute (0 - 59)
# |  .------------- hour (0 - 23)
# |  |  .---------- day of month (1 - 31)
# |  |  |  .------- month (1 - 12) OR jan,feb,mar,apr ...
# |  |  |  |  .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
# |  |  |  |  |
# *  *  *  *  * user-name command to be executed
17 *    * * *   root    cd / && run-parts --report /etc/cron.hourly
25 6    * * *   root    test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.daily )
47 6    * * 7   root    test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.weekly )
52 6    1 * *   root    test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.monthly )
#

run-parts というのは、指定したディレクトリ内の実行ファイルをまとめて実行してくれるコマンド です。

このように「毎時〜毎月1回実行されるよう定義したスケジュールにしたがって、時がきたらそれぞれのディレクトリ配下のスクリプトを run-parts で素朴に実行するだけ」というのが /etc/cron.<PERIOD>/ の仕組みなのです。

ちなみに anacron がインストールされている (= test -x ... にマッチする) 場合、日レベル以上のジョブは anacron に委譲されますが、anacron でも同じように run-parts を実行しています。

/etc/anacrontab
# /etc/anacrontab: configuration file for anacron

# See anacron(8) and anacrontab(5) for details.

SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
HOME=/root
LOGNAME=root

# These replace cron's entries
1       5       cron.daily      run-parts --report /etc/cron.daily
7       10      cron.weekly     run-parts --report /etc/cron.weekly
@monthly        15      cron.monthly    run-parts --report /etc/cron.monthly

なお、もちろん /etc/crontab には任意のジョブ定義を追加することもできますが、ペライチの単一ファイルなので多数のジョブを書き連ねるとゴチャゴチャして管理が難しくなってきます。
その場合は、次のセクションで詳しく述べますが、/etc/cron.d/ のほうに適度にファイルを分割しながら配置することをオススメします。

/etc/cron.d/

/etc/cron.d/配下には任意のファイル名で複数の crontabファイルを配置できます。
(任意と言いつつ細かい制約があるので、詳細は後述します。)

ジョブごとにファイルを分けて配置できるので、/etc/crontab よりもこちらのディレクトリに程よい粒度で配置したほうが管理しやすいでしょう。

なお、man には「/etc/cron.d/ じゃなくて /etc/crontab を使え。」とか、

… In
general, the system administrator should not use /etc/cron.d/, but use the standard system
crontab /etc/crontab.

別の箇所 には「/etc/cron.d/ は debパッケージ用だ。」などと書かれていますが、

The intended purpose of this feature is to allow packages that require finer control of
their scheduling than the /etc/cron.{hourly,daily,weekly,monthly} directories to add a
crontab file to /etc/cron.d. Such files should be named after the package that supplies
them.

とはいえ、/etc/crontab の取り回しの悪さは受け入れがたいので、個人的にはやはり /etc/cron.d/ を優先して使っていきたい気分です。

ファイルの制約

/etc/cron.<PERIOD>//etc/cron.d/ に配置するファイルには、ファイル名やパーミッションなど、それぞれ細かい制約がいくつもあります。

man に (嘘がちょいちょい混ぜ込まれているものの) 詳しくまとまっているので、どのような制約があるのか見ていきます。

いきなり結論

詳細はあとで見ていくので、とりあえずいきなり結論を書きます。

/etc/cron.<PERIOD>/

普通に run-parts経由で実行しているだけなので、run-parts の探索ルール (以下2点) を満たせば OK です。

  • 実行ファイルであること
  • ファイル名が次の文字種のみで構成されること
    • 英字
    • 数字
    • アンダースコア
    • ハイフン

具体例を示します。

実行ファイル:

✘ 実行権限が付与されていないと NG です。

$ ls -lh /etc/cron.hourly/
-rw-r--r-- 1 root staff 47 Oct  8 02:28 ng-job  # [NG] 実行ファイルではない

✘ シンボリック・リンクの場合は、参照先ファイルに実行権限が付与されていないといけません。

$ ls -lh /etc/cron.hourly/
lrwxrwxrwx 1 root staff 13 Oct  8 01:56 ng-job -> /scripts/orig  # [NG] 参照先が実行ファイルではない

$ ls -lh /scripts/
-rw-r--r-- 1 root staff 47 Oct  8 02:28 orig

ファイル名:

. (ドット) など、[A-Za-z0-9_-]以外の文字が含まれていると NG です。

$ ls -lh /etc/cron.hourly/
-rwxr-xr-x 1 root staff 47 Oct  8 02:28 ng-job.dot  # [NG] `[A-Za-z0-9_-]`以外の文字が含まれている

✔ シンボリック・リンクの場合は、参照先ファイルの名称に制約はありません。
(. などが含まれていてもよい。)

$ ls -lh /etc/cron.hourly/
lrwxrwxrwx 1 root staff 13 Oct  8 01:56 ok-job -> /scripts/orig.dot  # [OK] :)

✘ もちろんシンボリック・リンク自体のファイル名は run-parts に準拠している必要があります。

$ ls -lh /etc/cron.hourly/
lrwxrwxrwx 1 root staff 13 Oct  8 01:56 ng-job.dot -> /scripts/orig  # [NG] シンボリック・リンク名に `[A-Za-z0-9_-]`以外の文字が含まれている

所有者:

✔ 所有者は root でなくても OK です。

$ ls -lh /etc/cron.hourly/
-rwxr-xr-x 1 yumeko staff 47 Oct  8 02:28 ok-job  # [OK] :)

書き込み権限:

✔ 所有者以外の書き込み権限があっても OK です。

$ ls -lh /etc/cron.hourly/
-rwxrwxrwx 1 root staff 47 Oct  8 02:28 ok-job  # [OK] :)

/etc/cron.d/

以下の条件をすべて満たす必要があります。

  • ファイル名が次の文字種のみで構成されること
    • 英字
    • 数字
    • アンダースコア
    • ハイフン
  • 所有者が root であること
  • 所有者以外に書き込み権限がないこと

/etc/cron.<PERIOD>/ とは異なり、実行ファイルである必要はありません。
(スクリプトではなく /etc/crontab相当の設定ファイルなので)

具体例を示します。

ファイル名:

run-parts は関係しないものの、/etc/cron.<PERIOD>/ と同じルールが適用されます。

. が含まれていると crontabファイルとして認識されないので、*.dpkg-old などの debパッケージの残骸が放置されていても安心です。

所有者:

✘ 所有者は root でないといけません。

$ ls -lh /etc/cron.d/
-rw-r--r-- 1 yumeko staff 47 Oct  8 02:28 ng-tab  # [NG] 所有者が root ではない

✘ シンボリック・リンクの場合は、シンボリック・リンク自体と参照先ファイルの両方の所有者が root でないといけません。

$ ls -lh /etc/cron.d/
lrwxrwxrwx 1 root   staff  22 Oct  8 03:02 ng-tab-1 -> /crontabs/orig-1  # [NG] 参照先ファイルの所有者が root ではない
lrwxrwxrwx 1 yumeko staff  22 Oct  8 03:06 ng-tab-2 -> /crontabs/orig-2  # [NG] シンボリック・リンク自体の所有者が root ではない

$ ls -lh /crontabs/
-rw-r--r-- 1 yumeko staff 125 Oct  8 03:00 orig-1
-rw-r--r-- 1 root   staff 125 Oct  8 03:00 orig-2

書き込み権限:

✘ 所有者以外の書き込み権限があると NG です。

$ ls -lh /etc/cron.d/
-rw-rw-rw- 1 root staff 117 Oct  8 02:55 ng-tab  # [NG] 所有者以外の書き込み権限がある

✘ シンボリック・リンクの場合は、参照先ファイルに所有者以外の書き込み権限があると NG です。

$ ls -lh /etc/cron.d/
lrwxrwxrwx 1 root staff  22 Oct  8 03:02 ng-tab -> /crontabs/orig  # [NG] 参照先ファイルに所有者以外の書き込み権限がある

$ ls -lh /crontabs/
-rw-rw-rw- 1 root staff 125 Oct  8 03:00 orig

調査結果

結論と順番が前後しますが、制約に関する man の記述と調査結果です。

/etc/cron.<PERIOD>/

man には以下のように書かれています。

As described above, the files under these directories have to pass some sanity checks
including the following: be executable, be owned by root, not be writable by group or
other and, if symlinks, point to files owned by root. Additionally, the file names must
conform to the filename requirements of run-parts: they must be entirely made up of
letters, digits and can only contain the special signs underscores ('_') and hyphens
('-'). Any file that does not conform to these requirements will not be executed by run-
parts. For example, any file containing dots will be ignored.

個別の条件を列挙すると次のとおりなのですが、

  • 実行ファイルであること
  • 所有者が root であること
    (シンボリック・リンクの場合は参照先ファイルが root であること)
  • 所有者以外に書き込み権限がないこと
  • ファイル名が次の文字種のみで構成されること
    • 英字
    • 数字
    • アンダースコア
    • ハイフン

しかし、/etc/crontab の中身で見たとおり、/etc/cron.<PERIOD>/配下のスクリプトは単純に run-parts で実行しているだけなので、どうもこの記述は怪しそうです。
(run-partsman では所有者や書き込み権限には言及されていません。)

結果:

以下のスクリプト群を作成して実際に cron の毎時実行を待ってみたところ、やはりこの記述は正確ではありませんでした。

$ ls -lh /etc/cron.hourly/
total 24K
-rwxr-xr-x 1 root   staff 47 Oct  8 02:28 job-1
-rw-r--r-- 1 root   staff 47 Oct  8 02:28 job-2
-rwxr-xr-x 1 yumeko staff 47 Oct  8 02:28 job-3
-rwxrwxrwx 1 root   staff 47 Oct  8 02:28 job-4
-rwxr-xr-x 1 root   staff 47 Oct  8 02:28 job-5.dot
lrwxrwxrwx 1 root   staff 13 Oct  8 01:56 job-sym-a-1 -> /scripts/orig.dot-1
lrwxrwxrwx 1 root   staff 13 Oct  8 01:56 job-sym-a-2 -> /scripts/orig.dot-2
lrwxrwxrwx 1 root   staff 13 Oct  8 01:59 job-sym-a-3 -> /scripts/orig.dot-3
lrwxrwxrwx 1 root   staff 13 Oct  8 01:59 job-sym-a-4 -> /scripts/orig.dot-4
lrwxrwxrwx 1 root   staff 13 Oct  8 01:59 job-sym-a-5.dot -> /scripts/orig.dot-5
lrwxrwxrwx 1 yumeko staff 13 Oct  8 01:56 job-sym-b-1 -> /scripts/orig.dot-1
lrwxrwxrwx 1 yumeko staff 13 Oct  8 01:56 job-sym-b-2 -> /scripts/orig.dot-2
lrwxrwxrwx 1 yumeko staff 13 Oct  8 02:00 job-sym-b-3 -> /scripts/orig.dot-3
lrwxrwxrwx 1 yumeko staff 13 Oct  8 02:00 job-sym-b-4 -> /scripts/orig.dot-4
lrwxrwxrwx 1 yumeko staff 13 Oct  8 02:00 job-sym-b-5.dot -> /scripts/orig.dot-5

$ ls -lh /scripts/
total 12K
-rwxr-xr-x 1 root   staff 47 Oct  8 02:28 orig.dot-1
-rw-r--r-- 1 root   staff 47 Oct  8 02:28 orig.dot-2
-rwxr-xr-x 1 yumeko staff 47 Oct  8 02:28 orig.dot-3
-rwxrwxrwx 1 root   staff 47 Oct  8 02:28 orig.dot-4
-rwxr-xr-x 1 root   staff 47 Oct  8 02:28 orig.dot-5

それぞれのスクリプトに込めた意図と、そのうち実際に実行されたものをわかりやすくまとめた表が以下です。

ファイル名所有者実行権限書き込み権限 [※1]名称規則 [※2]実行された
job-1rootu - -
job-2rootu - -
job-3yumekou - -
job-4rootu g o
job-5.dotrootu - -
ファイル名
(シンボリック・リンク)
所有者
(シンボリック・リンク)
名称規則 [※2]
(シンボリック・リンク)
所有者
(参照先)
実行権限
(参照先)
書き込み権限 [※1]
(参照先)
名称規則 [※2]
(参照先)
実行された
job-sym-a-1rootrootu - -
job-sym-a-2rootrootu - -
job-sym-a-3rootyumekou - -
job-sym-a-4rootrootu g o
job-sym-a-5.dotrootrootu - -
job-sym-b-1yumekorootu - -
job-sym-b-2yumekorootu - -
job-sym-b-3yumekoyumekou - -
job-sym-b-4yumekorootu g o
job-sym-b-5.dotyumekorootu - -

※1 u: 所有者 / g: グループ / o: それ以外
※2 run-parts のファイル名の規則に適合するか?

考察:

見てのとおり、実行ファイルでないものやファイル名がまずいものは実行されない一方、所有者や書き込み権限は実行可否に影響しないこと、また、シンボリック・リンクの参照先ファイルの名称も関係しないことがわかりました。

やはり run-parts の探索ルール (実行ファイル + ファイル名) を満たしていれば OK なのです。

/etc/cron.d/

/etc/cron.d/ の制約も man の続く段落に書かれています。

Files in this directory have to be owned by root, do not need to be executable (they are
configuration files, just like /etc/crontab) and must conform to the same naming
convention as used by run-parts(8) : they must consist solely of upper- and lower-case
letters, digits, underscores, and hyphens. This means that they cannot contain any dots.

/etc/cron.<PERIOD>/ と同じく、所有者やファイル名の規則 (run-parts と同等) が挙げられています。

一方、/etc/cron.<PERIOD>/ とは違って、スクリプトそのものではなく crontabファイルを配置するため、実行権限は不要となっています。

また、書き込み権限の制約は NOTESセクションで言及されています。

/etc/crontab and the files in /etc/cron.d must be owned by root, and must not be group- or
other-writable.

結果:

以下のファイル群について検証しました。

$ ls -lh /etc/cron.d/
total 24K
-rw-r--r-- 1 root   staff 117 Oct  8 02:54 tab-1
-rw-r--r-- 1 yumeko staff 117 Oct  8 02:55 tab-2
-rw-rw-rw- 1 root   staff 117 Oct  8 02:55 tab-3
-rw-r--r-- 1 root   staff 117 Oct  8 02:57 tab-4.dot
lrwxrwxrwx 1 root   staff  22 Oct  8 03:02 tab-sym-a-1 -> /crontabs/orig.dot-1
lrwxrwxrwx 1 root   staff  22 Oct  8 03:02 tab-sym-a-2 -> /crontabs/orig.dot-2
lrwxrwxrwx 1 root   staff  22 Oct  8 03:02 tab-sym-a-3 -> /crontabs/orig.dot-3
lrwxrwxrwx 1 root   staff  22 Oct  8 03:03 tab-sym-a-4.dot -> /crontabs/orig.dot-4
lrwxrwxrwx 1 yumeko staff  22 Oct  8 03:06 tab-sym-b-1 -> /crontabs/orig.dot-1
lrwxrwxrwx 1 yumeko staff  22 Oct  8 03:06 tab-sym-b-2 -> /crontabs/orig.dot-2
lrwxrwxrwx 1 yumeko staff  22 Oct  8 03:06 tab-sym-b-3 -> /crontabs/orig.dot-3
lrwxrwxrwx 1 yumeko staff  22 Oct  8 03:06 tab-sym-b-4.dot -> /crontabs/orig.dot-4

$ ls -lh /crontabs/
total 12K
-rw-r--r-- 1 root   staff 125 Oct  8 03:03 orig.dot-1
-rw-r--r-- 1 yumeko staff 125 Oct  8 03:00 orig.dot-2
-rw-rw-rw- 1 root   staff 125 Oct  8 03:00 orig.dot-3
-rw-r--r-- 1 root   staff 125 Oct  8 03:03 orig.dot-4
ファイル名所有者書き込み権限 [※1]名称規則 [※2]実行された
tab-1rootu - -
tab-2yumekou - -
tab-3rootu g o
tab-4.dotrootu - -
ファイル名
(シンボリック・リンク)
所有者
(シンボリック・リンク)
名称規則 [※2]
(シンボリック・リンク)
所有者
(参照先)
書き込み権限 [※1]
(参照先)
名称規則 [※2]
(参照先)
実行された
tab-sym-a-1rootrootu - -
tab-sym-a-2rootyumekou - -
tab-sym-a-3rootrootu g o
tab-sym-a-4.dotrootrootu - -
tab-sym-b-1yumekorootu - -
tab-sym-b-2yumekoyumekou - -
tab-sym-b-3yumekorootu g o
tab-sym-b-4.dotyumekorootu - -

※1 u: 所有者 / g: グループ / o: それ以外
※2 run-parts のファイル名の規則に適合するか?

考察:

実行されたのは tab-1tab-sym-a-1 のみでした。
/etc/cron.<PERIOD>/ とは違って、こちらの man の記述は正しいようです。
(ファイル名 + 所有者 + 書き込み権限)

Appendix: LSB namespace

cron には -l というオプションがあり、これを指定すると /etc/cron.d/配下のファイルの探索ルールが変わるようです。

…が、ここでも man の記述に誤りがあるので注意が必要です。

cron - OPTIONS:

-l   Enable LSB compliant names for /etc/cron.d files. This setting, however, does not
      affect the parsing of files under /etc/cron.hourly, /etc/cron.daily,
      /etc/cron.weekly or /etc/cron.monthly.

cron - DEBIAN SPECIFIC:

If the -l option is specified to cron (this option can be setup through /etc/default/cron,
see below), then they must conform to the LSB namespace specification, exactly as in the
–lsbsysinit option in run-parts.

run-parts - OPTIONS:

–lsbsysinit
      use LSB namespaces instead of classical behavior.

run-parts - DESCRIPTION:

If the –lsbsysinit option is given, then the names must not end in .dpkg-old or
.dpkg-dist or .dpkg-new or .dpkg-tmp, and must belong to one or more of the following
namespaces: the LANANA-assigned namespace (^[a-z0-9]+$); the LSB hierarchical and reserved
namespaces (^_?([a-z0-9_.]+-)+[a-z0-9]+$); and the Debian cron script namespace (^[a-zA-
Z0-9_-]+$).

突然出てきた「namespace」という概念がよくわからないものの、どうやらファイル名が以下2つの条件の両方を満たすものだけが crontabファイルとして認識されるようです。

  1. 拡張子が以下ではないこと
    • .dpkg-old
    • .dpkg-dist
    • .dpkg-new
    • .dpkg-tmp
  2. 以下の namespace (単にファイル名と思ってよい?) のいずれかに属すること
    • the LANANA-assigned namespace: ^[a-z0-9]+$
    • the LSB hierarchical and reserved namespace: ^_?([a-z0-9_.]+-)+[a-z0-9]+$
    • the Debian cron script namespace: ^[a-zA-Z0-9_-]+$

ところがコードを読んでみたところ、実際の挙動と解離していることがわかりました。

コードの確認

Launchpad からソースをダウンロードして、run-parts.c の実装を見てみました。

ファイル名のチェックをしているのは valid_name関数で、regex_mode == RUNPARTS_LSBSYSINIT のブロックを見ればよさそうです。

run-parts.c#L146-L169
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
/* True or false? Is this a valid filename? */
int valid_name(const struct dirent *d)
{
    char         *s;
    unsigned int  retval;

    s = (char *)&(d->d_name);

    if (regex_mode == RUNPARTS_ERE)
        retval = !regexec(&customre, s, 0, NULL, 0);

    else if (regex_mode == RUNPARTS_LSBSYSINIT) {

        if (!regexec(&hierre, s, 0, NULL, 0))
            retval = regexec(&excsre, s, 0, NULL, 0);

        else
            retval = !regexec(&tradre, s, 0, NULL, 0);

    } else
        retval = !regexec(&classicalre, s, 0, NULL, 0);

    return retval;
}

regexec関数で何度か正規表現のマッチングを試みていますね。
ここで拡張子と namespace を判定していそうです。

各正規表現の実体は regex_compile_pattern関数で定義されていました。

run-parts.c#L651-L693
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
/*
 * Compile patterns used by the application
 *
 * In order for a string to be matched by a pattern, this pattern must be
 * compiled with the regcomp function. If an error occurs, the application
 * exits and displays the error.
 */
static void
regex_compile_pattern (void)
{
    int      err;
    regex_t *pt_regex;

    if (regex_mode == RUNPARTS_ERE) {

        if ((err = regcomp(&customre, custom_ere,
                    REG_EXTENDED | REG_NOSUB)) != 0)
            pt_regex = &customre;

    } else if (regex_mode == RUNPARTS_LSBSYSINIT) {

        if ( (err = regcomp(&hierre, "^_?([a-z0-9_.]+-)+[a-z0-9]+$",
                    REG_EXTENDED | REG_NOSUB)) != 0)
            pt_regex = &hierre;

        else if ( (err = regcomp(&excsre, "^[a-z0-9-].*\\.dpkg-(old|dist|new|tmp)$",
                    REG_EXTENDED | REG_NOSUB)) != 0)
            pt_regex = &excsre;

        else if ( (err = regcomp(&tradre, "^[a-z0-9][a-z0-9-]*$", REG_NOSUB))
                    != 0)
            pt_regex = &tradre;

    } else if ( (err = regcomp(&classicalre, "^[a-zA-Z0-9_-]+$",
                    REG_EXTENDED | REG_NOSUB)) != 0)
        pt_regex = &classicalre;

    if (err != 0) {
        fprintf(stderr, "Unable to build regexp: %s", \
                            regex_get_error(err, pt_regex));
        exit(1);
    }
}

この正規表現の実体を使って valid_name関数の 157-164行目をわかりやすく表記すると次のようになります。

`--lsbsysinit`オプション付きで実行した場合
│
└── /^_?([a-z0-9_.]+-)+[a-z0-9]+$/ (= hierre) にマッチ?
     │
     ├── する
     │    │
     │    └── /^[a-z0-9-].*\.dpkg-(old|dist|new|tmp)$/ (= excsre) にマッチ?
     │         │
     │         ├── する
     │         │    │
     │         │    └── false  (Path 1)
     │         │
     │         └── しない
     │              │
     │              └── true  (Path 2)
     │
     └── しない
          │
          └── /^[a-z0-9][a-z0-9-]*$/ (= tradre) にマッチ?
               │
               ├── する
               │    │ 
               │    └── true  (Path 3)
               │
               └── しない
                    │
                    └── false  (Path 4)

戻り値が true (= Path 2, 3) になるファイルが実行対象として認識されるのですが、どう見ても man の記述とは違いますね。

なお、man に書かれているとおり cron も run-parts の実装を模倣しているため、ほぼ同等の挙動になるはずです。
(拡張子の判定が若干異なります。)

ソース: cron_3.0pl1-136ubuntu1.debian.tar.xz

patches/features/Drop-in-drop.d-directory-support.patch#L322-L358
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
+#include <regex.h>
+
+/* True or false? Is this a valid filename? */
+
+/* Taken from Clint Adams 'run-parts' version to support lsb style
+   names, originally GPL, but relicensed to cron license per e-mail of
+   27 September 2003. I've changed it to do regcomp() only once. */
+
+static int
+valid_name(char *filename)
+{
+  static regex_t hierre, tradre, excsre, classicalre;
+  static int donere = 0;
+
+  if (!donere) {
+      donere = 1;
+      if (regcomp(&hierre, "^_?([a-z0-9_.]+-)+[a-z0-9]+$",
+                  REG_EXTENDED | REG_NOSUB)
+          || regcomp(&excsre, "^[a-z0-9-].*dpkg-(old|dist)$",
+                     REG_EXTENDED | REG_NOSUB)
+          || regcomp(&tradre, "^[a-z0-9][a-z0-9-]*$", REG_NOSUB)
+          || regcomp(&classicalre, "^[a-zA-Z0-9_-]+$",
+                     REG_EXTENDED | REG_NOSUB)) {
+          log_it("CRON", getpid(), "REGEX FAILED", "valid_name");
+          (void) exit(ERROR_EXIT);
+      }
+  }
+  if (lsbsysinit_mode) {
+      if (!regexec(&hierre, filename, 0, NULL, 0)) {
+          return regexec(&excsre, filename, 0, NULL, 0);
+      } else {
+          return !regexec(&tradre, filename, 0, NULL, 0);
+      }
+  }
+  /* Old standard style */
+  return !regexec(&classicalre, filename, 0, NULL, 0);
+}

動作確認

念のため Path 1〜4 をそれぞれ通過するファイル名のスクリプトを用意して、実際に run-parts を実行してみました。

ファイル名hierreexcsretradrePath
a.dpkg-old1
_b.dpkg-old2
c3
D4

ファイル名の選定の正しさは RSpec で担保しています。
(C は不慣れなので…。)

regexec_spec.rb
RSpec.describe 'regexec' do
  let!(:hierre) { /^_?([a-z0-9_.]+-)+[a-z0-9]+$/ }
  let!(:excsre) { /^[a-z0-9-].*\.dpkg-(old|dist|new|tmp)$/ }
  let!(:tradre) { /^[a-z0-9][a-z0-9-]*$/ }

  describe 'a.dpkg-old' do
    subject { 'a.dpkg-old' }

    it { is_expected.to     match(hierre) }
    it { is_expected.to     match(excsre) }
    it { is_expected.to_not match(tradre) }
  end

  describe '_b.dpkg-old' do
    subject { '_b.dpkg-old' }

    it { is_expected.to     match(hierre) }
    it { is_expected.to_not match(excsre) }
    it { is_expected.to_not match(tradre) }
  end

  describe 'c' do
    subject { 'c' }

    it { is_expected.to_not match(hierre) }
    it { is_expected.to_not match(excsre) }
    it { is_expected.to     match(tradre) }
  end

  describe 'D' do
    subject { 'D' }

    it { is_expected.to_not match(hierre) }
    it { is_expected.to_not match(excsre) }
    it { is_expected.to_not match(tradre) }
  end
end
$ bundle exec rspec -f d ./regexec_spec.rb

regexec
  a.dpkg-old
    is expected to match /^_?([a-z0-9_.]+-)+[a-z0-9]+$/
    is expected to match /^[a-z0-9-].*\.dpkg-(old|dist|new|tmp)$/
    is expected not to match /^[a-z0-9][a-z0-9-]*$/
  _b.dpkg-old
    is expected to match /^_?([a-z0-9_.]+-)+[a-z0-9]+$/
    is expected not to match /^[a-z0-9-].*\.dpkg-(old|dist|new|tmp)$/
    is expected not to match /^[a-z0-9][a-z0-9-]*$/
  c
    is expected not to match /^_?([a-z0-9_.]+-)+[a-z0-9]+$/
    is expected not to match /^[a-z0-9-].*\.dpkg-(old|dist|new|tmp)$/
    is expected to match /^[a-z0-9][a-z0-9-]*$/
  D
    is expected not to match /^_?([a-z0-9_.]+-)+[a-z0-9]+$/
    is expected not to match /^[a-z0-9-].*\.dpkg-(old|dist|new|tmp)$/
    is expected not to match /^[a-z0-9][a-z0-9-]*$/

Finished in 0.00337 seconds (files took 0.09024 seconds to load)
12 examples, 0 failures

なお、スクリプトの中身はすべて共通で、自身のファイル名を出力するだけです。

スクリプトの中身は共通
#!/bin/bash

echo ${0}

以上で準備が整いました。

$ ls -lh
total 16K
-rwxr-xr-x 1 yumeko staff 33 Sep 29 08:27 D
-rwxr-xr-x 1 yumeko staff 33 Sep 29 08:28 _b.dpkg-old
-rwxr-xr-x 1 yumeko staff 33 Sep 29 08:28 a.dpkg-old
-rwxr-xr-x 1 yumeko staff 33 Sep 29 08:27 c

--lsbsysinitオプション付きで run-parts を実行してみると、

$ run-parts --lsbsysinit .
./_b.dpkg-old
./c

思ったとおり Path 2 と 3 を通過するファイルだけ実行されました。

やはり man と実際の挙動が解離しているようです。

まとめ

以上、/etc/配下の cron関連ファイルの概要をおさらいしつつ、Debian系cron の制約と罠を見ていきました。

cron の認識対象となるための条件 (ファイルの所有者やパーミッション) が man に誤って記載されているため、man を読むだけで安心せず手を動かして実際の挙動を確かめてみることが大事だと思います。

また、普段は使うことはないと思いますが、cron -l / run-parts --lsbsysinit というオプションを詳しく見てみました。
コードを読んでみたところ、こちらも man と実装が解離していることがわかりました。

RedHat系の cron実装はまた違った挙動になると思いますが、この記事が Debian系cron に不意を衝かれないための一助になれば幸いです。

今後ともよろしくお願い申し上げます。