User crontabs belong to one account; system tables can name an account per entry. Copying lines between formats without adjusting fields changes the command boundary. Identify the format before debugging the calendar expression.

Contrast Debian user and system parser boundaries and execution accounts, then follow unescaped percent parsing into shell command text and standard input. A username inserted into a user table becomes command text.
In Debian user tables the command starts after five fields; system tables insert a username before it. Unescaped percent signs are handled before shell quoting and split off standard input. The figure is schematic; privileged examples were not executed.

The account column changes the parser boundary

User entries contain minute, hour, day of month, month, day of week, then command text; crontab installs them for the selected user. Debian’s /etc/crontab and /etc/cron.d files insert a username after those five fields. It is not an optional command annotation.

Month remains required. When day-of-month and day-of-week are both restricted, Debian/Vixie permits either day match, not month versus weekday. Check dialects before transferring expressions to schedulers with seconds fields or different day rules; similar spacing proves nothing.

System drop-ins require suitable ownership, permissions, filenames, and final newlines. Debian accepts root-owned files without group/other write permission and appropriate names, not arbitrary backup suffixes. Periodic directories such as /etc/cron.daily follow distribution scheduling, commonly through run-parts; scripts are not individually parsed as crontabs. Inspect the installed arrangement for timing.

When it breaks

Putting root after five fields in root’s user crontab asks the shell to execute a command named root; successful installation does not prove it exists. Omitting a system entry’s account instead puts command text in the username position. Check boundaries and effective identity, especially for protected data or writes.

Do not assume interactive aliases, shell startup files, or daemon-parent variables are available. Explicitly set required values, PATH, and interpreter. Nor should cron.d settings be assumed to inherit /etc/crontab’s environment. Test permissions and outputs as the execution account.

Before shell processing, unescaped percent signs delimit standard input; subsequent percents become newlines. Shell quoting cannot prevent this. Put complex commands in scripts and escape literal percents in entries. Check scheduling syntax, command execution, and application outcomes separately.

Check it quickly

Repair draft checked 2026-10-19: this revision was checked statically and with offline command/file-state doubles only. No daemon, account or task-table changes were executed. Technical and operational verification remain blocked. The version-specific outcomes below describe the original fixture.

A future run needs a separately authorized, freshly provisioned disposable Debian 12 lab with Python 3.11+, cron 3.0pl1-162, procps and passwd, an executable /tmp for the guarded shell script, no host mounts, no cron daemon or root table, no xbtime account or test drop-in, no enabled unrelated schedules in /etc/crontab, /etc/cron.d or any user’s spool (including periodic-directory dispatch), and no concurrent administration. Save as cron-tables.py; use python3 cron-tables.py --isolated-disposable-lab only after independently establishing those conditions. The flag and root identity do not prove isolation. This still modifies lab-wide account files and cron state; discard the whole lab afterward.

The temporary system account uses existing nogroup and the private work directory as its home, with no new home or private group and no lastlog initialization; its UID is discovered, not assumed to be 1000. Account creation, lookup, table installation and daemon startup are protected from the first attempted mutation. Both test tables disable mail with MAILTO. The exact pinned absence diagnostic is distinguished from permission or command failure. Workers publish JSON by same-directory replacement; the deliberately invalid entry still starts with root after its five fields. Only that entry selects a small SHELL wrapper that checks the permit under the shared lock, preserves the lock descriptor across exec and passes the unchanged command to /bin/sh. Its diagnostic is published by rename. A positive run permit and shared lifetime lock bound admitted worker cleanup. Every cleanup action is attempted independently, with bounded daemon and worker waits; errors preserve the original failure and skip private-file deletion; a failed deletion can leave only partial files. Revoking the permit prevents late workers from treating a deleted stop file as permission. The barrier does not prove every cron wrapper exited, so lab disposal remains required.

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()

Historically, the user job ran as UID zero and the new system-table account as UID 1000; this revision discovers its system account’s UID instead. Both receive separate LAB_TABLE values and explicit PATH, without the daemon parent’s PARENT_ONLY. Percent parsing supplies first\nsecond\n on stdin. The misplaced username produces root: not found despite successful installation.

A replacement missing its final newline is rejected with exit one, preserving the previous table. The original fixture reported passing assertions on cron 3.0pl1-162; this revision has no real daemon result. Separate probes found malformed minute text accepted even in dry-run mode, so zero exit does not guarantee valid fields. Account, environment, parsing, and installation observations leave mail delivery, daylight-saving handling, and business success untested; verify these separately.

Sources