Cron 可以从不止一种任务表读取计划。用户任务表属于某个账号,而系统任务表能够为每一条记录指定运行账号。把一行原样复制到另一种表中,如果没有调整字段,cron 对命令起点的理解就会变化。排查看似正确的五字段日历表达式之前,应先确认正在编辑的到底是哪种任务表,而不是只检查分钟和小时。
账号列改变了解析边界
传统用户记录依次填写分钟、小时、月内日期、月份、星期,再接命令正文,由 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 上通过;修订版本尚无真实守护进程结果。额外探测发现,这个软件包即使使用检查选项,也接受了错误的分钟文字,因此这里不会从退出码零推导出所有字段都已经有效;记录的是版本限定的实际观察,而不是猜测其他版本必然相同。
实验验证账号、环境、百分号解析及安装失败后的保留行为,没有测试邮件投递、夏令时处理或实际业务成功。部署真正任务时,应把这些额外要求继续列为可验证条件,并检查最终运行结果,不能以文件存在或安装命令成功替代完成证据。
来源
Debian 12: crontab(5),用户与系统任务表格式、环境及百分号处理,已核验 2026-09-13。
Debian 12: cron(8),系统任务文件、所有权和执行环境,已核验 2026-09-13。
Debian 12: crontab(1),任务表安装、检查选项和末尾换行要求,已核验 2026-09-13。
Python 3.11 subprocess,超时与子进程清理语义,已核验 2026-10-19。
Debian cron 3.0pl1-162 crontab.c,区分不存在任务表的诊断与其他失败,已核验 2026-10-19。
Python 3.11 os.replace,同文件系统原子发布,已核验 2026-10-19。
Debian cron 3.0pl1-162 do_command.c,所选 shell 接收 -c 与命令,已核验 2026-10-19。
Python 3.11 os,描述符继承与 exec,已核验 2026-10-19。
Linux flock(2),跨 exec 的锁生命周期,已核验 2026-10-19。
Debian 12 useradd(8),系统账号、组、主目录与日志初始化选项,已核验 2026-10-19。