Cron 可以从不止一种任务表读取计划。用户任务表属于某个账号,而系统任务表能够为每一条记录指定运行账号。把一行原样复制到另一种表中,如果没有调整字段,cron 对命令起点的理解就会变化。排查看似正确的五字段日历表达式之前,应先确认正在编辑的到底是哪种任务表,而不是只检查分钟和小时。

对比 Debian 用户表与系统表的解析边界和执行账号,再跟踪未转义百分号如何把内容分成 shell 命令与标准输入。用户表中多写的用户名会成为命令正文。
Debian 用户表在五个字段后开始命令,系统表则先增加用户名。未转义百分号先于 shell 引号处理,并分离标准输入。本图为示意图;没有执行需要特权的示例。

账号列改变了解析边界

传统用户记录依次填写分钟、小时、月内日期、月份、星期,再接命令正文,由 crontab 命令安装给选定用户。Debian 的 /etc/crontab 和 /etc/cron.d 下文件,则在五个日历字段之后额外增加用户名。这个用户名属于系统表格式,并不是可以随意放进用户任务表的说明性字段;多写或少写都会改变后续解释。

月份限制仍然必须满足。当月内日期与星期都受到限制时,Debian/Vixie 的日期规则允许满足其中之一,但月份与星期并不是二选一。把表达式迁移到带秒字段或采用其他日期组合方式的调度产品前,应核对具体方言。表面上相似的空格布局,不能证明两个调度器提供相同语义。

系统任务文件还受所有权、权限、名称及末尾换行要求约束。Debian 中应采用 root 所有、组和其他用户不可写且名称被接受的文件,不要把任意备份后缀当成有效任务名。/etc/cron.daily 等周期目录通常通过发行版安排和 run-parts 被调用,不是每份脚本都独立当作 crontab 解析。实际运行时间需要查看本机已安装的调度安排。

坏掉的样子

在 root 用户自己的任务表中,五字段后再写 root,意味着让 shell 执行一个名为 root 的命令。任务表安装成功,不证明这个命令存在。反过来,系统表漏掉账号列,就会把命令文字当成用户名。审阅时需要同时检查解析边界与最终身份,尤其是任务会写文件或读取受保护数据时,不能仅凭“都是 root 配置的”推断执行账号。

任务环境同样是一份契约。不要默认交互别名、shell 启动文件或启动 cron 的父进程环境都会传给任务。需要的值应明确配置,同时选择合适的 PATH 和解释器。cron.d 文件中的环境也不应被认为自动继承 /etc/crontab 中的设置。验证时要采用真正的运行账号检查权限与输出,避免管理员手工执行成功掩盖低权限任务失败。

百分号在完整命令交给 shell 之前就有特殊含义。未转义的百分号会划分标准输入,后续百分号再变成换行。仅用 shell 引号包围内容,并不能取消 cron 的这一步解释。复杂逻辑适合放进脚本;确实需要把字面百分号写在表中时,应按 cron 规则转义。调度语法、命令能否执行和应用结果是否成功,必须分别检查。

快速验证

2026-10-19 修复草案:此版本只进行了静态检查与离线命令/文件状态替身测试,没有执行守护进程、账号或任务表变更。技术与实际运行验证仍然受阻。下文版本限定的结果属于原始示例。

未来运行需要另行授权的新建一次性 Debian 12 环境,预装 Python 3.11+、cron 3.0pl1-162、procps 与 passwd,并允许执行 /tmp 中的包装 shell 脚本;不得挂载宿主目录,不得已有 cron 进程、root 任务表、xbtime 账号或实验任务文件,/etc/crontab、/etc/cron.d 与任何用户任务表也不得启用其他计划(包括周期目录的派发),并且不得有并发管理操作。保存为 cron-tables.py,只有独立核实这些条件后才能使用 python3 cron-tables.py --isolated-disposable-lab。该参数和 root 身份都不能证明隔离。脚本仍会修改实验环境范围的账号文件和 cron 状态,结束后必须丢弃整个环境。

临时系统账号使用既有 nogroup,并把既有私有工作目录作为主目录,不新建主目录或私有组,也不初始化 lastlog;实际读取其 UID,不再假定为一千。从首次尝试修改起,账号创建、查询、任务表安装与进程启动都处于清理范围内。两份实验任务表都用 MAILTO 禁用邮件;准确的固定版本“不存在”诊断与权限或命令失败分开处理。工作进程通过同目录替换发布完整 JSON,故意错误的条目仍在五个字段后直接写 root。仅该条目选择一个简短的 SHELL 包装脚本:持共享锁核对许可,使锁描述符跨 exec 保留,再将原始命令交给 /bin/sh;诊断通过改名发布。正向运行许可与共享生命周期锁用于约束已获准工作的清理。每项清理独立尝试,进程和工作等待都有期限;错误会保留原始失败并跳过私有文件删除,但删除失败也可能只留下部分文件。撤销许可可避免延迟工作把停止文件消失误当成允许运行。屏障不能证明每个 cron 包装进程都已退出,因此仍须丢弃整个环境。

from pathlib import Path
from tempfile import mkdtemp
import fcntl
import json
import os
import pwd
import shutil
import subprocess
import sys
import time


def command(arguments, **options):
    return subprocess.run(arguments, capture_output=True, text=True, timeout=5,
                          env={**os.environ, 'LC_ALL': 'C'}, **options)


def table_absent(result):
    # Debian cron 3.0pl1-162: other listing failures must abort.
    return (result.returncode == 1 and result.stdout == ''
            and result.stderr == 'no crontab for root\n')


def remove_table():
    current = command(['crontab', '-l'])
    if table_absent(current):
        return
    current.check_returncode()
    command(['crontab', '-r']).check_returncode()
    if not table_absent(command(['crontab', '-l'])):
        raise RuntimeError('could not verify table removal')


def attempt(errors, name, operation):
    try:
        operation()
    except Exception as error:
        errors.append(f'{name}: {error!r}')


def stop_daemon(daemon, errors):
    if daemon is None:
        return
    attempt(errors, 'terminate cron', daemon.terminate)
    try:
        daemon.wait(timeout=5)
    except subprocess.TimeoutExpired:
        attempt(errors, 'kill owned cron', daemon.kill)
        attempt(errors, 'reap cron', lambda: daemon.wait(timeout=5))
    except Exception as error:
        errors.append(f'wait cron: {error!r}')


def remove_account(username):
    try:
        pwd.getpwnam(username)
    except KeyError:
        return
    command(['userdel', username]).check_returncode()
    try:
        pwd.getpwnam(username)
    except KeyError:
        return
    raise RuntimeError('account removal not verified')


def wait_workers(root):
    with (root / 'lifetime.lock').open() as gate:
        deadline = time.monotonic() + 5
        while True:
            try:
                fcntl.flock(gate, fcntl.LOCK_EX | fcntl.LOCK_NB)
                return
            except BlockingIOError:
                if time.monotonic() >= deadline:
                    raise TimeoutError('workers did not release lifetime lock')
                time.sleep(0.1)


worker_source = r'''from pathlib import Path
import fcntl,json,os,sys
root=Path(__file__).parent
mode=sys.argv[1]
with (root/'lifetime.lock').open() as gate:
    fcntl.flock(gate,fcntl.LOCK_SH)
    if not (root/'run-permit').exists(): raise SystemExit(0)
    data={'uid':os.getuid(),'table':os.environ.get('LAB_TABLE'),
          'path':os.environ.get('PATH'),'shell':os.environ.get('SHELL'),
          'parent_only':os.environ.get('PARENT_ONLY')}
    if mode=='percent': data['stdin']=sys.stdin.read()
    destination=root/(mode+'.json')
    temporary=root/f'{mode}-{os.getpid()}.tmp'
    temporary.write_text(json.dumps(data))
    temporary.replace(destination)
'''


shell_source = r'''#!/usr/bin/python3
from pathlib import Path
import fcntl,os,sys
root=Path(__file__).parent
with (root/'lifetime.lock').open() as gate:
    fcntl.flock(gate,fcntl.LOCK_SH)
    if not (root/'run-permit').exists(): raise SystemExit(0)
    os.set_inheritable(gate.fileno(),True)
    os.execv('/bin/sh',['/bin/sh',*sys.argv[1:]])
'''


def main():
    if sys.argv[1:] != ['--isolated-disposable-lab']:
        raise SystemExit('requires a separately verified disposable Debian 12 lab')
    if os.geteuid() != 0:
        raise RuntimeError('root is required, but does not prove isolation')
    probe = command(['pgrep', '-x', 'cron'])
    if probe.returncode != 1 or probe.stdout or probe.stderr:
        raise RuntimeError(f'cron absence not established: {probe!r}')
    if not table_absent(command(['crontab', '-l'])):
        raise RuntimeError('expected absent root table; existing table or listing error')
    if shutil.which('root', path='/usr/bin:/bin') is not None:
        raise RuntimeError('the deliberately invalid command exists')
    username = 'xbtime'
    try:
        pwd.getpwnam(username)
    except KeyError:
        pass
    else:
        raise RuntimeError('test account already exists')
    system_table = Path('/etc/cron.d/xbcatm_time_test')
    if system_table.exists() or system_table.is_symlink():
        raise RuntimeError('system test table already exists')
    root = Path(mkdtemp(prefix='cron-tables-', dir='/tmp'))
    daemon = None
    account_attempted = account_created = table_attempted = system_owned = False
    try:
        (root / 'lifetime.lock').touch(mode=0o644)
        (root / 'run-permit').touch()
        account_attempted = True
        command(['useradd', '--system', '--no-create-home', '--no-user-group',
                 '--gid', 'nogroup', '--home-dir', str(root), '--no-log-init', '--shell', '/bin/sh', username]).check_returncode()
        account_created = True
        account = pwd.getpwnam(username)
        os.chown(root, account.pw_uid, account.pw_gid)
        worker = root / 'worker.py'
        worker.write_text(worker_source)
        # The account must read both files even under a restrictive parent umask.
        worker.chmod(0o644)
        guarded_shell = root / 'guarded-shell'
        guarded_shell.write_text(shell_source)
        guarded_shell.chmod(0o755)
        (root / 'lifetime.lock').chmod(0o644)
        user_table = ('SHELL=/bin/sh\nPATH=/usr/bin:/bin\nLC_ALL=C\nMAILTO=""\nLAB_TABLE=user\n'
                      f'* * * * * /usr/bin/python3 {worker} user\n'
                      f'* * * * * /usr/bin/python3 {worker} percent%first%second\n'
                      f'SHELL={guarded_shell}\n'
                      f'* * * * * root /usr/bin/id >{root}/wrong-column.tmp 2>&1; '
                      f'/bin/mv {root}/wrong-column.tmp {root}/wrong-column.err\n')
        table_attempted = True
        command(['crontab', '-'], input=user_table).check_returncode()
        invalid = command(['crontab', '-'], input='* * * * * /bin/true')
        assert invalid.returncode != 0
        listed = command(['crontab', '-l'])
        listed.check_returncode()
        assert listed.stdout == user_table
        with system_table.open('x') as handle:
            system_owned = True
            handle.write('SHELL=/bin/sh\nPATH=/usr/bin:/bin\nLC_ALL=C\nMAILTO=""\nLAB_TABLE=system\n'
                         f'* * * * * {username} /usr/bin/python3 {worker} system\n')
        system_table.chmod(0o644)
        daemon = subprocess.Popen(['cron', '-f'], env={**os.environ, 'PARENT_ONLY': 'parent-demo'},
                                  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        deadline = time.monotonic() + 75
        names = ['user.json', 'system.json', 'percent.json', 'wrong-column.err']
        while time.monotonic() < deadline:
            if daemon.poll() is not None:
                raise RuntimeError('cron exited')
            if all((root / name).exists() for name in names):
                break
            time.sleep(0.2)
        else:
            raise AssertionError('scheduled table outputs were not produced')
        user = json.loads((root / 'user.json').read_text())
        system = json.loads((root / 'system.json').read_text())
        percent = json.loads((root / 'percent.json').read_text())
        wrong = (root / 'wrong-column.err').read_text()
        assert user['uid'] == 0 and user['table'] == 'user'
        assert system['uid'] == account.pw_uid and system['table'] == 'system'
        assert all(value['parent_only'] is None for value in (user, system))
        assert all(value['path'] == '/usr/bin:/bin' for value in (user, system))
        assert all(value['shell'] == '/bin/sh' for value in (user, system))
        assert percent['stdin'] == 'first\nsecond\n'
        assert 'root: not found' in wrong
        print(json.dumps({'user_table': user, 'system_table': system,
                          'missing_final_newline_rejected': invalid.returncode,
                          'previous_table_preserved': True, 'percent_stdin': percent['stdin'],
                          'username_in_user_table_fails': wrong.strip()}), flush=True)
    finally:
        original = sys.exc_info()[1]
        errors = []
        attempt(errors, 'revoke run permit', lambda: (root / 'run-permit').unlink(missing_ok=True))
        if system_owned:
            attempt(errors, 'remove system table', lambda: system_table.unlink(missing_ok=True))
        if table_attempted:
            attempt(errors, 'remove root table', remove_table)
        stop_daemon(daemon, errors)
        attempt(errors, 'wait for workers', lambda: wait_workers(root))
        if account_attempted:
            attempt(errors, 'remove account', lambda: remove_account(username))
            if not account_created:
                errors.append('account creation failed partway; database state needs lab disposal')
        if not errors:
            attempt(errors, 'remove private files', lambda: shutil.rmtree(root))
        if errors:
            message = 'cleanup incomplete; discard lab: ' + '; '.join(errors)
            if original is not None:
                original.add_note(message)
            else:
                raise RuntimeError(message)


if __name__ == '__main__':
    main()

原始历史运行中,用户表任务以 UID 零执行,系统表任务使用当时分配 UID 一千的新建账号。本次修订改为创建系统账号,并核对实际查询到的 UID。两者分别得到自己的 LAB_TABLE 值和明确配置的 PATH,都没有继承启动守护进程的父环境变量 PARENT_ONLY。包含百分号的记录把 first\nsecond\n 传入标准输入。错误加入用户名的用户记录则输出 root: not found,即使它所在的任务表此前已经成功安装。

程序还尝试用没有末尾换行的新内容替换任务表,该操作以退出码一被拒绝,原表保持不变。原始示例曾报告全部断言在 cron 3.0pl1-162 上通过;修订版本尚无真实守护进程结果。额外探测发现,这个软件包即使使用检查选项,也接受了错误的分钟文字,因此这里不会从退出码零推导出所有字段都已经有效;记录的是版本限定的实际观察,而不是猜测其他版本必然相同。

实验验证账号、环境、百分号解析及安装失败后的保留行为,没有测试邮件投递、夏令时处理或实际业务成功。部署真正任务时,应把这些额外要求继续列为可验证条件,并检查最终运行结果,不能以文件存在或安装命令成功替代完成证据。

来源