Skip to content

Audit 2.37 - #111

Open
Dylan-Brotherston wants to merge 55 commits into
masterfrom
audit-2.37
Open

Dylan-Brotherston wants to merge 55 commits into
masterfrom
audit-2.37

Conversation

@Dylan-Brotherston

Copy link
Copy Markdown
Contributor

Audit, issue triage and fuzzing of dcc 2.37

52 commits. Every one landed only after make check and the full end-to-end
suite passed. The suite is green on clang 17, 18, 19, 21 and 22.

before after
test suite 169 pass, 3 fail 309 pass, 0 fail
Python unit tests 0 68
static gate none make check
open issues closed — 14 of 31
defects found by fuzzing — 59, all fixed

What this is

Three passes over the project:

  1. An audit of correctness, architecture, security and performance, with
    the defects found fixed and a static-checking gate added.
  2. A triage of all 31 open issues, each investigated against a build of
    released 2.37 and a build of this branch so "already fixed" could be told
    from "fixed here".
  3. A fuzzing campaign across seven surfaces, which found 59 further
    defects. Each was independently reproduced and minimised before being fixed.

Every fix was adversarially reviewed by agents which rebuilt dcc and tried to
break the fix; that caught regressions in the fixes themselves, several of them
serious, which were repaired before landing.

Changes a user will notice

A program stopped by an error dcc detected now exits 141, whichever
sanitizer found it and whichever kind of error it is. A program dcc did not
stop keeps its own status, so a marking script can tell the two apart.

dcc says when it drops to one sanitizer. Detecting uninitialized variables
needs the second process, which is disabled for a library, threads, incremental
compilation, an object file, an unsafe system header, and on macOS. That was
silent; a student lost their best diagnostic without knowing. In C this is
uncommon. In C++ it is the usual case, so most C++ compiles gain a line.

Output checking counts everything the program writes to standard output,
not only what goes through the stdout stream: write(1, ...) and the output
of a command run with system() are now checked. A program which passed by
writing through a channel the checker could not see will now be judged on what
it actually wrote — this can change an autotest result. Six accepted-output
files in this repository recorded the old behaviour and were re-recorded; one
of them ended correct output rejected, because under MemorySanitizer the
checker's own reads were reported as uninitialized and every check failed.

Every C++ program that read to end of file used to hang. dcc's streambuf
never reported end of file and returned the last character forever, so
while (cin >> x) and while (getline(...)) never finished.

d++ prog.cpp -o prog.cpp destroyed the source. The guard against writing
a program over its own source covered only .c and .h.

Issues closed

Closed by a commit trailer, so GitHub will close them on merge:

#12 #44 #56 #59 #60 #62 #72 #80 #95 #96 #97 #100 #109

Also fixed, but without a trailer because the fix predates the triage:
#107 (--help printed nothing; it now prints 40 lines).

Partly addressed, and left open: #24 (three wrong explanations gone, some
suggestions unimplemented), #74 (every spelling of standard input is now
refused clearly; compiling from stdin is still unsupported), #75 (the
link step now says what detection an object file costs).

#49 has been closeable since 2019: it was fixed seventeen days after it was
filed and never closed.

#58 is diagnosed, not fixed. In the default mode the valgrind process is
built without UndefinedBehaviorSanitizer, which is why dcc misses
uninitialized values that dcc --valgrind reports — exactly what the reporter
guessed in 2019. Enabling it is not the fix: a program compiled with
--use-after-return is then killed by SIGKILL and prints nothing at all. The
exclusion now carries that explanation in the code, where it was previously a
bare condition with an identical commented-out copy above it.

#42 is a duplicate of #106 and can be closed with it.

🤖 Generated with Claude Code

Dylan-Brotherston and others added 30 commits September 20, 2026 19:39
do_tests.sh exited 0 even when tests failed, so a broken suite would have
reported success.  A test whose output could not be checked was also lost
when xargs stopped early, and single_test.sh recorded unreviewed output as
though it had been accepted and could block waiting for an answer that was
never coming.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
incremental_compilation.sh had been mangled by a source formatter and could
not run; its accepted output was the resulting error.  print_path.sh used
continue outside a loop.  The MemorySanitizer variant of
uninitialized-array-element-if compared uninitialized stack contents, which
differ on every run, and was the suite's permanent failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Runs pylint, mypy and a compile with Python warnings made fatal, shellcheck
on every script, and a -Wall -Wextra -Werror compile of the C embedded in
every student program across each combination of sanitizers.  dcc refuses to
compile a program if that code produces any diagnostic, so a warning enabled
by default in a future compiler would otherwise break every compilation.
Replaces the disabled prospector configuration.  A GitHub Actions workflow
runs it alongside the end-to-end suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The project had no unit tests.  These cover the C string-literal encoder
(round-tripped through clang), the compiler-message parser, explanation
selection and label uniqueness, option parsing, the source-line lookup, the
output-difference explanations, value display and the call traceback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A failed compile left a temporary file in /tmp every time and leaked its file
descriptor.  A response file was expanded by dcc and then again by clang, so
every argument was doubled.  -pthread was not recognised and -l never
disabled the valgrind sanitizer, because the test for it examined the wrong
flag.  --help printed a blank line, leaving the generated man page empty.
Paths spliced into the embedded C were not escaped, so a quote, a backslash
or a trigraph in an install path produced code that would not compile.  A
retry path rebuilt the support code without its sanitizer flags, and the
no-main message was produced by an unanchored substring match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five explanation entries shared a regular expression with another, so the
label that names their test file was not unique and one pair was an exact
duplicate.  Four reproduce programs did not produce the error they were
meant to demonstrate.  The uninitialized-variable entry matched wording gcc
dropped in gcc 10, so it never fired.  A stray reset escape was emitted in
uncoloured output, and helper scripts were given colour codes because a
function was passed where a boolean was expected.  A mistake in one template
replaced the compiler's message with a Python traceback; it now falls back
to the plain message.  Notes quoting system headers and include chains are
no longer shown to the student.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An error on one of the first lines of a file displayed lines from the end of
it, because the source lookup indexed with a negative line number.  Debug
output produced inside gdb was discarded, since the function setting the
stream ignored its argument.  A very deep stack made gdb walk hundreds of
thousands of frames, and truncating it broke the assumption that the
outermost frame is main, so the traceback now ends explicitly and the
command line is only read when main was reached.  A non-integer DCC_DEBUG
raised an exception.  valgrind output dcc does not recognise is reported
rather than discarded, so a renamed message cannot silently switch off a
class of checking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The byte count for excessive output went to a stream directed at /dev/null,
a wrong space was never highlighted because bytes were compared with a
string, and the non-printable range began at the wrong byte.  Extra
characters at the end of the expected output produced the message for the
opposite problem, or raised an exception.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
To make uninitialized memory recognisable dcc refills the stack after
library calls.  That refill wrote 256 KB on every printf and on every stdio
read, write, seek and close, and because the support code is compiled with
pattern initialisation the array was filled twice per call.  glibc's stdio
functions dirty about 4 KB, so the refill now scans from the top in 4 KB
chunks and rewrites only what was disturbed, stopping once eight consecutive
chunks are found intact; under valgrind the same scan runs after telling
memcheck the memory is defined.  The stdio callback reached from inside
dcc's own printf no longer scans the same stack a second time.

A program printing 300,000 lines: 1.74s to 0.23s with one sanitizer, 69s to
27s with the default pair.  Values displayed for uninitialized variables
were compared against the previous build on programs designed to defeat the
scan and are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
UndefinedBehaviorSanitizer reports were copied into 128-byte buffers, so a
long message or any path over about 100 characters lost the source excerpt
and the variable values; those buffers are now 4096 bytes and static, as
putenv keeps the pointer rather than copying.  gettid needs glibc 2.30 and
does not exist on macOS.  A stack overflow, usually from infinite recursion,
produced no explanation at all under AddressSanitizer because the handler
faulted again on the exhausted stack; SIGSEGV is now handled on an alternate
stack and a fault past the end of the stack is explained.  stderr was left
fully buffered, so output to it appeared out of order and was lost when a
program was stopped.  A valgrind error occurring after main returned let the
program exit successfully.  Forwarded standard input was read with a single
read, so a short read looked like lost synchronisation.  Restoring a C++
stream twice deleted its buffer twice.  Four latent compiler warnings were
fixed so the new warning check passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Accepted output which recorded the stray escape, the truncated
UndefinedBehaviorSanitizer message, the notes quoting system headers or the
off-by-one output limit can no longer be produced and has been removed, so
those defects cannot pass again.  Three tests needed a version recording
clang 19's wording, which was the suite's other standing failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds 37 tests: the exit status of every error class under every combination
of sanitizers, the runtime and compile helper interfaces, output checking
under a single sanitizer and the messages it produces, stack clearing after
deep recursion and large frames, infinite recursion, CPU limits, assertions,
leaks under AddressSanitizer, C++ input through cin, multi-file programs,
more open files than dcc has stream cookies, and the option handling and
temporary-file behaviour this branch fixes.  Each was checked by
reintroducing the defect and confirming the test fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The helper interfaces documented variable names the code does not use, the
sample outputs showed a message format dcc has not produced for years, and
the man page examples still showed the fill byte used before 0xaa.  Adds the
undocumented output-size limit, the conditions which disable the valgrind
sanitizer, how the helper programs are found, and how to run the tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was written to standard output on every release, where any terminal log
or CI transcript would capture it.  The environment variable now takes
precedence over the token file rather than being overwritten by it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Half the processors are used by default, which is a lot of memory on a
machine where each test also runs the program under valgrind.  DCC_TEST_JOBS
now overrides it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g them

The tar of the Python which explains errors, and in dual-sanitizer mode the
whole second executable, were written into the generated C as hexadecimal
array initializers, so the compiler parsed them on every compilation.  They
are now assembled into their own object files with .incbin and linked, which
takes the generated C from 265 KB and 104 KB down to 72 KB each and leaves
it as code rather than data.  The size of the tar reaches the valgrind
watcher in the environment instead of being written into a command line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The C dcc embeds is identical for every program compiled by the same dcc
with the same compiler and options, but it was recompiled at -O3 every time.
The object is now kept under $XDG_CACHE_HOME/dcc, keyed on the dcc version,
the platform, the compiler's version and the file itself, the compile
arguments and the source, so upgrading the compiler cannot re-use an object
the old one produced.

Compiling a hello-world program goes from 0.66s to 0.34s with the default
sanitizers and from 0.41s to 0.25s with one.  A miss only costs the time to
compile, so anything unexpected is treated as one: an object which is not an
ordinary file of our own, or whose recorded digest does not match, is
ignored and recompiled.  DCC_NO_WRAPPER_CACHE turns it off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wrapper_c/*.c was not valid C.  About 22 placeholders were replaced
textually by dcc before it could be compiled, so no editor, language server
or analyser could open it, and the only way to check it was to run dcc.

The placeholders are now macros.  dcc writes their values as #define lines
ahead of the source, and wrapper_c/dcc_defines.h gives every one of them a
default, so the sources compile on their own:

  cat wrapper_c/dcc_main.c wrapper_c/dcc_dual_sanitizers.c wrapper_c/dcc_util.c
  wrapper_c/dcc_check_output.c wrapper_c/dcc_save_stdin.c |
  clang -fsyntax-only -Wall -Wextra -D_GNU_SOURCE -include wrapper_c/dcc_defines.h -x c -

The five places where a placeholder sat inside a string literal now build
the string by concatenation or stringification.  tests/check_wrapper_warnings.sh
checks both compilers accept the sources on their own under eight settings,
as well as the generated ones, which takes it from 27 compilations to 43.

Compiling the same program before and after produces wrapper objects which
are identical except for the one deliberate change: the command which reads
valgrind's output is passed to the debugging message rather than being
written into it a second time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… arguments

clang 22 underlines the whole call rather than the function name, so the
explanation for an ignored return value read "the value returned by function
atoi(argv[0])".  The helper which strips the arguments already existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e to fail

The tests_all_clang_versions target ended its loop body with echo, so it
always succeeded however many tests failed, and it gave every clang the
default clang++ rather than its own.

With that fixed the whole suite passes with clang 17, 18, 19, 21 and 22.
Two compiler wording changes needed an accepted output: clang 21 calls
comparisons "non-overlapping" where earlier versions said "overlapping",
and clang 22 underlines a call differently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two sanitizers write the same bytes at different widths when a program
prints a pointer: ASan's heap addresses are wider than valgrind's, so %p makes
the write lengths disagree.  That was treated as lost synchronisation, which
kills the valgrind process, so from the first %p onwards the student silently
lost leak checking and uninitialized value checking, with nothing said.

The write lengths never needed to agree.  sanitizer2 discards its own buffer
and length in __dcc_cookie_write, so only which system call it is carries any
information.  Exempt sc_write from the length comparison and cap the result
handed back to sanitizer2 at the size it asked for, because stdio reads a
larger return as a failed write.

Fixes #80.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README described how to use dcc but never its scope, so the questions in
issue #97 -- embedded targets, whether it compiles the same as gcc, speed,
whether its executables can be shipped, whether it helps with security -- had
no answer anywhere in the repository.

Add a Scope section answering all five, and the same text as the [DESCRIPTION]
the generated man page was missing.  The performance numbers are measured
rather than estimated, and say which machine and compilers they came from.

The claims about the second process are qualified, because dcc drops to a
single sanitizer by itself for several common cases and the costs are then
much smaller.

help2man is not installed here, so the generated dcc.1 has not been checked.

Fixes #97.  Completes #100, whose other half -- --help printing nothing, so
help2man had no material -- was fixed by 3aacad6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sigaltstack handler decided a fault was a stack overflow from the faulting
address alone, and required it to be within 1 MB of the stack limit.  A single
oversized frame faults its own size past the limit, which is unbounded, so

    int a[3000000];

was reported as "invalid pointer or string" rather than as a stack overflow.
An oversized local array is one of the two ways a novice overflows the stack.

Decide from the interrupted stack pointer instead, which the handler already
received and ignored: in an overflow the stack pointer is itself past the end
of the stack and the fault is where it points, while a wild or NULL pointer
faults far from a stack pointer still well inside the stack.  The test no
longer depends on frame size, so an arbitrarily large frame is recognised.

Only the main thread is classified.  stack_top and main_thread_id describe its
stack, so without that gate any fault on another thread's stack looked like an
overflow -- a wild pointer in a pthread was reported as infinite recursion.
Another thread overflowing its own stack keeps the generic explanation.

Where the stack pointer cannot be read, the previous test is unchanged, so
platforms other than Linux x86-64, i386 and aarch64 behave as before.

Fixes #44.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
valgrind returns 0 from posix_spawn where it would natively fail, so dcc
pre-checked the path and returned 2.  Three things were wrong with that.

posix_spawnp was not covered at all, so it still returned 0.  It resolves a
bare name against $PATH, which the check now does the way execvp does,
including reporting EACCES when a candidate existed but was not executable.

The errno was always 2, where execve reports EACCES for a directory or an
unexecutable file, and ENOTDIR, ELOOP or ENAMETOOLONG for a malformed path.
Report what stat and faccessat actually say.

The check resolves the path in the parent, before file_actions has run, so it
cannot be applied to a relative path when file_actions is present -- an
addchdir_np changes what that path means.  Skip it there and let the child
resolve it, which also fixes posix_spawn with addchdir_np, broken before this.

An eight case comparison against a native build now agrees in every case.

Fixes #59.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
printf("%s\n", p) with p == NULL was explained wrongly.  gcc rewrites that
call into __builtin_puts and then numbers the null argument for the rewritten
call, so dcc reported it as argument 1 to printf, which is the format string.
Recognise the printf to puts rewrite and say the string being printed is NULL.
Only that rewrite is recognised: gcc rewrites plenty of calls which print
nothing, and strstr(NULL, "a") must keep the explanation it already had.

Three diagnostics which passed through bare now have explanations:

  array has incomplete element type 'int[]'  -- a multidimensional array
  needs a size for every dimension but the first.  A plain incomplete struct
  element type is deliberately not matched, as that advice would be wrong.

  '%s' directive argument is null  -- the directive gcc named is quoted back
  rather than assuming %s, and the wording does not say "printing", because
  sprintf and snprintf produce this too.

  incomplete definition of type 'struct x'  -- a misspelt struct name is a
  likelier cause for a novice than an ADT, so that is suggested first.

Fixes #72, #95, #96.  Part of #24.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two files which include each other make the compiler print the same two line
include chain about a hundred times before each diagnostic, so the error the
student has to read is somewhere in two hundred lines of noise.

Buffer the lines no explanation was found for, and print a consecutively
repeated block once followed by a count.  Collapsing is only done where it
removes more lines than the count it adds, so ordinary output is untouched and
a legitimate deep include chain still reads normally.  Looking for the
repeating block is quadratic in its length, so it is bounded.

"In file included from" no longer parses as a message, since it names the
chain a later message arrived through rather than a mistake, which means the
suppression of notes about system headers moves to where messages are printed.

Fixes #62.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
UndefinedBehaviorSanitizer reports "applying zero offset to null pointer" for

    (void)((int *)0 + 0);

which dcc turned into "accessing a value via a NULL pointer", telling the
student to look for a dereference that is not there.

"applying" was matched alongside "load" and "store", which really are
dereferences.  Give it its own case and decide from the source line whether a
dereference plausibly happened, so p[i] and *(p + i) with a NULL p keep the
message they had, which is the common novice mistake, and arithmetic alone
gets a message about arithmetic.

The wording avoids saying this is definitely an error: C23 makes it undefined
behaviour, but the next draft defines ptr + 0 on a null pointer.

Fixes #109.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…viour sanitizer

The exclusion was a bare condition with an identical commented-out copy above
it and no explanation, which is why issue #58 -- dcc missing uninitialized
values that dcc --valgrind reports -- went six years without a diagnosis.

Record what it does and why it cannot simply be removed: memcheck reports an
uninitialized value only once it reaches a branch, and an undefined behaviour
check is often the only branch on it, so the second process not having that
sanitizer is exactly the gap #58 describes.  Building it with the sanitizer is
not the fix.  A program compiled with --use-after-return is then killed by
SIGKILL and prints nothing at all, which is worse for a student than the
values it would have caught.

Diagnoses #58, which stays open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…itizer

Where clang needs a multiplication-overflow helper the platform's libgcc does
not have, the link fails with "undefined reference to `__mulodi4" and dcc
silently rebuilt without -fsanitize=undefined, so the student lost that
checking with nothing said.  clang still emits __muloti4 on aarch64 and
riscv64, where libgcc has no copy and compiler-rt does.

Retry with --rtlib=compiler-rt first, and only drop the sanitizer if that link
fails too.  The retry is skipped for gcc, which rejects the option, and on
macOS, where compiler-rt is already the default.

When the sanitizer is dropped the wrapper is rebuilt as well, because it had
already been compiled with DCC_UBSAN_IN_USE set and would otherwise intercept
reports from a sanitizer that is no longer there.

Fixes #12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dylan-Brotherston and others added 17 commits September 21, 2026 02:08
Found by fuzzing the diagnostic parser.  Three defects, all from treating text
the compiler produced as if dcc had written it.

The word under the compiler's caret was interpolated straight into a regular
expression.  Text which is not a valid regex, such as an unbalanced bracket,
raised re.PatternError, and a Python traceback replaced every word of the
compiler's own message.  Text which is a valid but pathological regex, such as
a nested quantifier, backtracked exponentially and dcc hung with no output at
all.  Escape the word, and run preconditions inside the same guard that already
stops a mistake in a template replacing the message with a traceback -- which
also covers int(line_number), Python refusing to convert more than 4300 digits.

The compiler positions its caret by how wide characters print, and dcc indexed
the echoed source line by how many there are, so a character which prints two
columns wide -- a Chinese character in a string or a comment -- shifted every
word taken from that line.  A student writing a Chinese prompt string was told
to add an '&' before "rks", which is not in their program.  Index by column.

"type specifier missing" was explained as a missing return type when the word
was followed by '(', and as a missing parameter type otherwise, so a global
declared without a type -- count = 0; -- was explained as a function parameter
in a program with no parameters.  Distinguish the third case and say a variable
needs a type when you declare it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by fuzzing.  dcc redirects cin through its own streambuf so the two
sanitizers stay synchronized.  Its underflow() always published one readable
byte, including when fgetc returned EOF, and the byte it published was then
whatever the previous read had left in the buffer.  So the stream never
reported end of file and handed back the last character over and over.

Every C++ program which reads until the input runs out was affected, which is
most of them:

    while (std::cin >> value)      hung until it was killed
    while (getline(std::cin, s))   looped forever on the last line

compiled with g++ the same programs stop, as they should.  Reading with scanf
in a C++ program was unaffected, which is why this survived: the one test
covering cin reads a single integer and never reaches the end of the input.

Leave the get area empty at end of file so the stream reports it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by fuzzing.  dcc refuses to write a program over its own source, but the
check was for a name ending in .c or .h, so

    d++ prog.cpp -o prog.cpp

replaced 65 bytes of the student's C++ with a 426 KB executable and said
nothing.  The equivalent in C was correctly refused.  Check every extension
clang accepts as source instead, which covers .cc, .cpp, .cxx, .c++ and the
header spellings.

While here, -o with nothing after it silently did nothing and the program was
written to a.out; say the argument is missing, as -l already does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four defects found by fuzzing, all in how a stopped program is described.

Every abort() was explained as "A failed assert() calls abort()".  Under d++
that is wrong for the commonest C++ way of reaching abort: an uncaught
exception, a pure virtual call, or an exception escaping a noexcept function.
Decide from the stack which it was, and say so.

A function whose name begins with an underscore was dropped from the stack,
because the filter meant to hide dcc's own frames matched on a leading
underscore alone.  A student calling _helper() was told the error was in
main().  Match dcc's own names instead, which are all known.

A source path containing a space or a colon did not match the frame parser's
expectation of a pathname, so dcc printed "Execution stopped in ()" and no
source line at all.

UndefinedBehaviorSanitizer reports its own filename and line, which dcc
stamped onto whichever frame gdb had given it.  When the two disagree the
frame is a different function, so the name shown was wrong; use the location
without a function name rather than the wrong name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five defects found by fuzzing, all in the compiler driver.

Under d++ the renames dcc uses to keep a student's read() or write() away from
its own code were applied to the student's C++ as well, so std::cin.read and
std::ostream::write became calls to functions that do not exist.  Any C++
program using them failed to link with a message about __renamed_read.

An unusable TMPDIR -- missing, unwritable, or not a directory -- made dcc die
with "Internal error embedding dcc_tar_data", which says nothing a student can
act on and blames dcc for their environment.  A usable directory is chosen and
passed on to the compilers dcc runs, which inherit it.

A command line long enough for execve to refuse it came back as an unhandled
OSError traceback.  Every OSError from the compile now becomes a dcc error.

The wrapper object cache updated a shared file's timestamp outside the guard
that expects another dcc to be doing the same thing, so two compiles at once
could fail with a traceback.

A --c-compiler which exits 0 without producing a program made dcc exit 0 too,
reporting success for a program that does not exist.  Options which ask the
compiler for something other than a program are exempt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…as missing

Four defects found by fuzzing.

A program with 14 files open at once exhausted a fixed table of 16 stream
cookies.  Past that dcc lost synchronisation and killed the valgrind process,
so leak checking and uninitialized-value checking stopped for the rest of the
run with nothing said.  The table grows on demand instead.

Output checking only saw bytes written through the stdout FILE stream, so a
program writing with write(1, ...) or from a child process was told it had
produced no output when it had produced exactly the right output.  Both are
now fed to the checker.  This changes verdicts: a program which passed by
writing through a channel the checker could not see will now be judged on
what it actually wrote.

A forked child running exit() ran dcc's cleanup, which killed the parent's
valgrind process and unlinked the executable it was running.

Standard error written directly with write(2, ...) appeared twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed bytes

Four defects found by fuzzing, all in the values printed after an error.

balance_bracket() recursed once per character, so a line of about a thousand
characters inside brackets raised RecursionError from inside gdb.  A student
with a long array index or a long line anywhere in the ten lines around the
error got a Python traceback instead of their values.  It is a loop now.

The 0xAA byte dcc fills memory with was rewritten to "<uninitialized value>"
wherever it appeared, including in a variable the program had deliberately set
to that value, and in the middle of a multi-byte UTF-8 character.  A student
demonstrating memory patterns was told their initialized array was not.

The guard which stops an array declaration being printed as an expression did
not recognise a struct, union or enum element type, so those declarations were
printed as values.  Widening it to any identifier made it match N * a[i],
where N is a macro -- so the whole line was discarded and the pointer that
caused the error vanished from the values.  A type name is lower case, or
capitalised like a typedef, or FILE; an all upper case word is a macro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects found by fuzzing.

std::clog and the wide streams -- wcout, wcerr, wclog, wcin -- were never
redirected through dcc's own streams, so in the default two-process mode their
output was written once by each process and the student saw everything twice.
They are redirected like cout, cerr and cin, with the wide streams converting
through the stream's own imbued codecvt rather than the global C locale, so a
program which imbues a locale does not lose its output.

Tearing down deleted whatever streambuf was installed at exit, rather than the
one dcc installed, so a program which replaced cout's buffer itself -- a
common way to capture output in a test harness -- had a buffer it still owned
deleted underneath it.  Each stream's original buffer is restored and only
dcc's own is deleted.

Verified against plain clang++ for pushback, unget and non-ASCII wide output,
and -static-libstdc++ still links.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e main

Six defects found by fuzzing, four of them one root cause.

kill(0, ...) signals every process in the sender's process group, which for a
student is their shell.  dcc reached it three ways, all from signalling
sanitizer2_pid without asking whether that pid is a process dcc created: it is
0 before the fork, 0 in the forked child until it records its own pid, and an
inherited stranger in any child the student's program forks.  A SIGPIPE
arriving in that window made the child kill the group, which took out the
explanation being written and the shell that ran the program.  Measured at 10
in 400 runs under load with --use-after-return, 1 in 400 in the default mode.
Now: signals go only to a pid dcc started, the fork is done with those signals
blocked, and a child of the student's program does not run dcc's cleanup.

An error before main -- in a constructor, or in the initializer of a C++
global -- produced no output at all, because the explanation needs a program
to look at and __wrap_main had not yet recorded one.  It is read from
/proc/self/exe instead.  Not done for MemorySanitizer, where nothing has run
to say what went wrong and any error would be called an uninitialized read.

compiler-rt prints a line of 65 '=' characters before dcc's own explanation
and no option turns it off, so its report stream goes to /dev/null.  It is put
back for leak checking, which is the sanitizer's own output and is wanted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six defects found by fuzzing, in the checking that drives automarking.

Under -fsanitize=memory the checker's own code was not exempted from the
sanitizer, so reading the student's output was reported as an uninitialized
read.  Every output check under that sanitizer failed with "Runtime error:
uninitialized variable used" against the wrong line, and correct output was
rejected.  The accepted output for check_birds_single_sanitizer recorded
exactly that, ending "correct output rejected"; it now records the real
diagnosis of each case.

Expected output whose last line has no trailing newline was truncated by one
byte before being compared, so dcc both rejected byte-identical output and
accepted output missing its last character.  The same final line skipped the
trailing-whitespace and empty-line handling every other line gets.

A program producing only whitespace with no trailing newline was told it had
produced no output.  An expected line of 65536 bytes or more reported an
internal error as though the student had caused it.  "Your program printed N
lines of correct output" counted one line too many.

The zero-byte message numbered bytes from 0 while every other message numbers
from 1, so it named the wrong byte.  ab\0d now reports byte 3, as abxd does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the leak

Six defects found by fuzzing, in the valgrind side of dcc.

valgrind is run with --vgdb-error=1, so it stops on an error and waits for the
debugger dcc is supposed to attach.  Any exception in the Python which reads
valgrind's output left it waiting forever, with no message: the program hung
until it was killed.  The reader no longer raises on output which is not valid
UTF-8, and the program is stopped rather than abandoned when it does.

Every "Invalid write" was explained as a stack buffer overflow and every
"Invalid read" as a probable invalid FILE *, whatever valgrind had actually
found, so a heap overflow or a use after free was described as something the
program does not contain.  valgrind's own description of the memory decides,
and the wording matches what dcc says when AddressSanitizer finds the same
fault.  Where valgrind has not said what the memory was, neither does dcc.

A leak allocated inside a library function was reported at glibc's source
location, sending the student to a file they have never seen.  The frames are
walked outwards to the first one in a file of theirs.

A leak was dropped entirely when the allocating function's name began with an
underscore, because the filter for library internals matched the name rather
than the file.  A student calling _allocate() was told nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by fuzzing.

dcc drops to AddressSanitizer alone -- losing uninitialized variable detection
entirely -- when a program includes a header it can not run two sanitizers
with, links a library, uses threads, or compiles incrementally.  It said
nothing, so a student lost their best diagnostic without knowing.  The reason
was already worked out and then discarded; it is now printed as one note.  The
header named is the first in sorted order, so the same command says the same
thing every time.

single_test.sh treats a note as not a diagnostic, so a program whose compile
produces only a note is still run.

The rest:

  a response file including itself expanded forever; a cycle is refused and a
  file legitimately named twice still works

  a NUL byte in a path escaped into the compiler command line

  --c-compiler=gcc compiled the student's program with -O, leaked from a list
  meant only for gcc's extra warnings

  d++ omitted -fstandalone-debug, so gdb could not print a std::string and
  showed a Python exception instead

  every argument was read in full before the checks that it is a regular file
  of a sane size, so a character device made dcc block

  --use-funopen was offered with no check that it can work

  -fsanitize=undefined alone was refused as "only 1 or 2 sanitizers supported"

  --no_explanation turned explanations on, the opposite of its name

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects found by fuzzing, and two more the fixes for them introduced.

"Maybe you meant printf?" was suggested for printf itself, because the regex
meant to catch a misspelling matched the correctly spelled name.  A student
who forgot #include <stdio.h> was told to check their spelling.

A note about a system header ended where the compiler echoed the line it
points at, and that echoed line was then parsed as a new message, so the note
was cut short and the rest discarded.

Fixing that used a pattern for the line the compiler draws to point at a word,
but it also matched a line of the student's program containing a ^ -- so their
message was vetoed, the explanation lost, and the compiler's own summary
leaked through.  Only spaces and tildes may follow the gutter.

Widening the missing-header test made it read any note mentioning "include"
with a <...> anywhere in it, and a note echoes the student's source, so a
comment naming a header made dcc tell them to include it.  Only the compiler's
own note lines are read now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hree messages

Residual items from the fuzzing campaign, each left by an agent because the
fix was in a file outside the one it was allowed to edit.

system() ran its command through a pipe so the child's output could be checked,
but only when two sanitizers were running.  With one -- which is where a
student lands as soon as they include unistd.h -- the child's output was never
seen, so a correct program was failed.  run_system_command moves out of the two
sanitizer block, and a single sanitizer gets a wrapper of its own which does
the same without the handshake there is no second process for.

"The characters you printed were correct, but more characters were expected"
was printed whenever the last byte of a line differed, so abce against abcd
said the characters were correct when the last one was wrong.  It is said only
when what was printed really is the start of what was expected.  Where
characters are being ignored the two are compared in a form this cannot see,
so the wording there is left alone.

"internal error: expected line too long" blamed the student for dcc's own
buffer.  It now says the expected output has a line longer than dcc can check,
and gives the length, which is something the person who supplied it can act on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With --c-compiler=gcc every runtime error collapsed to "illegal array, pointer
or other operation", because the code which reads the sanitizer's report was
compiled out.  The condition tested the clang version, which is 0 when the
compiler is gcc, but gcc's libubsan exports the same two entry points clang's
does, so there was nothing to compile out.

Test the version only to exclude clang releases older than 7, and take the
accessor as a weak symbol checked before use, since the comment above it
records that some builds of the sanitizer runtime do not export it -- a missing
strong symbol would stop every program linking.

    dcc --c-compiler=gcc, an out of bounds read
    before: Runtime error: illegal array, pointer or other operation
    after:  Runtime error: index 5 out of bounds for type 'int [3]'

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The note dcc now prints when it drops to one sanitizer is documented in the
README and the man page, with which headers keep both sanitizers in C and the
fact that C++ nearly always loses the second process.

Output checking now covers bytes written with write(1, ...) and the output of a
command run with system(), which changes the verdict for a program that passed
by writing through a channel the checker could not see.  The one exception,
-fsanitize=memory, is recorded, as is the line length limit applying to the
expected output as well as to the program's.

The C++ section says what is now redirected and that reading to end of file
stops there.

make tests mentions DCC_TEST_JOBS, and the usage message mentions that the
older spellings of options are still accepted, since they are not listed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A regression from the check which stops a compiled program being given as
source.  clang takes the value of -x, -include, -isystem and others as a
separate argument, which dcc did not know, so the value was looked at as if it
were a source file.  That was harmless until the check made it fatal:

    $ dcc option_value.c -o c      # a program called c, as students have
    $ dcc -x c option_value.c -o a
    dcc: 'c' is a compiled program, not source code

master compiles that.  Take the value with the option, as -o and -l already do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

A stale expected-output variant permits the old exit status, and several new fixtures preserve misleading diagnostics or unrelated errors.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 Medium severity · 2 Low severity

Open (4)
What changed in this PR

Audits dcc’s compiler/runtime diagnostics and expands regression coverage across sanitizer synchronization, error reporting, output checking, and edge cases.

Changes:

  • Hardens runtime and compile-time diagnostics.
  • Adds CI/static checks and broad C/C++ regression coverage.
  • Refreshes expected outputs for newer compilers and behavior.
File Description
.github/​workflows/​ci.yml Adds CI build, checks, and end-to-end tests.
.gitignore Ignores static-check artifacts.
.pylintrc Configures Python static analysis.
compile_time_python/​colors.py Removes unreachable color parsing code.
packaging/​debian/​build.sh Improves build-script quoting and failure handling.
run_time_python/​gdb_interface.py Initializes the injected gdb interface.
wrapper_c/​dcc_save_stdin.c Uses generated stdin-buffer configuration macros.
tests/​check_output/​check_birds_single_sanitizer.sh Tests single-sanitizer output checking.
tests/​check_output/​check_output_expected_line_too_long.sh Tests oversized expected-output handling.
tests/​check_output/​check_output_system_single_sanitizer.sh Tests checking output from system().
tests/​compile_time_errors/​caret_in_source_line.c Tests diagnostic parsing with source carets.
tests/​compile_time_errors/​cyclic_include.c Adds cyclic-include coverage.
tests/​compile_time_errors/​cyclic_include.h Supports cyclic-include coverage.
tests/​compile_time_errors/​diagnostic_shaped_string.c Tests diagnostic-like source text.
tests/​compile_time_errors/​header_named_in_a_comment.c Tests include parsing in comments.
tests/​compile_time_errors/​implicit_printf_declaration.c Tests undeclared printf diagnostics.
tests/​compile_time_errors/​incomplete_struct_definition.c Tests incomplete-struct explanations.
tests/​compile_time_errors/​missing_h_file.c Tests missing-header behavior.
tests/​compile_time_errors/​multidimensional_array_no_bounds.c Tests missing multidimensional bounds.
tests/​compile_time_errors/​nonnull_null_stream.c Tests NULL stream diagnostics.
tests/​compile_time_errors/​nonnull_rewritten_builtin.c Tests rewritten built-in diagnostics.
tests/​compile_time_errors/​printf_null_pointer.c Tests NULL %s diagnostics.
tests/​compile_time_errors/​printf_null_pointer_newline.c Tests optimized NULL %s diagnostics.
tests/​compile_time_errors/​struct_array_incomplete_element.c Tests incomplete struct arrays.
tests/​compile_time_errors/​wide_character_before_caret.c Tests Unicode diagnostic columns.
tests/​run_time_no_errors/​argument_list_too_long.sh Tests oversized argument handling.
tests/​run_time_no_errors/​bad_temporary_directory.sh Tests unusable TMPDIR values.
tests/​run_time_no_errors/​compiler_rt_multiplication.c Tests compiler-rt linking.
tests/​run_time_no_errors/​cpp_sync_with_stdio.sh Tests C++ stream replacement cleanup.
tests/​run_time_no_errors/​debug_level_not_integer.sh Tests invalid debug levels.
tests/​run_time_no_errors/​desynchronization_detected.sh Tests genuine sanitizer divergence.
tests/​run_time_no_errors/​embedded_environment_variable.sh Tests escaped embedded environment values.
tests/​run_time_no_errors/​help.sh Tests help and version output.
tests/​run_time_no_errors/​no_temp_file_leak.sh Tests temporary-file cleanup.
tests/​run_time_no_errors/​option_value_not_a_source_file.sh Tests option-value parsing.
tests/​run_time_no_errors/​posix_spawn.c Selects stable sanitizer behavior.
tests/​run_time_no_errors/​posix_spawn_failure.c Tests corrected spawn failures.
tests/​run_time_no_errors/​print_path.sh Hardens pathname test quoting.
tests/​run_time_no_errors/​pthread_flag.c Tests thread compilation.
tests/​run_time_no_errors/​response_file.sh Tests response-file expansion.
tests/​run_time_no_errors/​syntax_only.sh Tests -fsyntax-only.
tests/​run_time_no_errors/​use_funopen_option.sh Tests missing libbsd diagnostics.
tests/​run_time_no_errors/​write_file_descriptor.c Tests direct stdout writes.
tests/​run_time_no_errors/​write_file_descriptor_2.sh Tests direct stderr writes.
tests/​run_time_errors/​abort_without_assert.c Distinguishes direct aborts from assertions.
tests/​run_time_errors/​assert_failure.c Tests assertion reporting.
tests/​run_time_errors/​cin_index.sh Tests synchronized C++ input errors.
tests/​run_time_errors/​colorized_output.sh Tests forced diagnostic colors.
tests/​run_time_errors/​compile_helper.sh Tests compile-error helpers.
tests/​run_time_errors/​compile_logger.sh Tests compilation logging.
tests/​run_time_errors/​cpp_string_value.cpp Tests C++ string value reporting.
tests/​run_time_errors/​cpu_time_limit.sh Tests CPU-limit diagnostics.
tests/​run_time_errors/​dereference_null_after_else.c Tests NULL writes after else.
tests/​run_time_errors/​dereference_null_one_line_if.c Tests one-line NULL writes.
tests/​run_time_errors/​dereference_null_with_arrow_after_arithmetic.c Tests NULL member access.
tests/​run_time_errors/​dunder_function_name.c Tests user functions with reserved-style names.
tests/​run_time_errors/​error_before_main.c Tests constructor-time C failures.
tests/​run_time_errors/​error_before_main_memory_sanitizer.sh Tests pre-main MemorySanitizer failures.
tests/​run_time_errors/​error_in_global_constructor.cpp Tests C++ global-constructor failures.
tests/​run_time_errors/​error_in_missing_source.sh Tests unavailable source files.
tests/​run_time_errors/​error_on_line_1.c Tests first-line source context.
tests/​run_time_errors/​executable_as_source.sh Tests binary-as-source diagnostics.
tests/​run_time_errors/​fork_child_exit.c Tests sanitizer ownership after fork.
tests/​run_time_errors/​gcc_compiler_option.sh Tests GCC sanitizer optimization behavior.
tests/​run_time_errors/​gcc_sanitizer_report.sh Tests GCC sanitizer hooks.
tests/​run_time_errors/​incremental_compilation.sh Repairs and expands incremental-build coverage.
tests/​run_time_errors/​infinite_recursion.sh Tests recursive stack overflow reporting.
tests/​run_time_errors/​large_stack_frame.sh Tests oversized stack-frame reporting.
tests/​run_time_errors/​large_stdin.sh Tests large synchronized stdin streams.
tests/​run_time_errors/​leak_after_printf_pointer.c Tests leak checking after pointer output.
tests/​run_time_errors/​leak_check_address.sh Tests AddressSanitizer leak reporting.
tests/​run_time_errors/​leak_getline.c Tests getline leak locations.
tests/​run_time_errors/​leak_location_binary_dir.sh Tests leak lookup near executables.
tests/​run_time_errors/​leak_location_not_found.sh Tests leak reporting without source files.
tests/​run_time_errors/​leak_strdup.c Tests strdup leak locations.
tests/​run_time_errors/​leak_underscore_function.c Tests leaks in underscore-prefixed functions.
tests/​run_time_errors/​long_path.sh Tests long source paths.
tests/​run_time_errors/​many_open_files_uninitialized.c Tests sanitizer synchronization with many streams.
tests/​run_time_errors/​multi_file_error.sh Tests diagnostics across source files.
tests/​run_time_errors/​no_explanations.sh Tests disabled explanations.
tests/​run_time_errors/​noexcept_throw.cpp Tests exceptions escaping noexcept.
tests/​run_time_errors/​null_pointer_arithmetic.c Tests NULL pointer arithmetic.
tests/​run_time_errors/​null_pointer_arithmetic_cast.c Tests casted NULL pointer arithmetic.
tests/​run_time_errors/​null_pointer_index_nonzero.c Tests indexed NULL pointers.
tests/​run_time_errors/​object_file_error_detection.sh Tests object-file sanitizer limitations.
tests/​run_time_errors/​pure_virtual_call.cpp Tests pure-virtual termination.
tests/​run_time_errors/​runtime_helper.sh Tests runtime helper metadata.
tests/​run_time_errors/​runtime_helper_non_utf8_source.sh Tests helper handling of non-UTF-8 source.
tests/​run_time_errors/​runtime_helper_stdin.sh Tests helper stdin capture.
tests/​run_time_errors/​shared_library_link.sh Tests shared-library linking.
tests/​run_time_errors/​source_name_with_leading_space.sh Tests leading-space filenames.
tests/​run_time_errors/​source_path_with_space.sh Tests spaces and colons in paths.
tests/​run_time_errors/​stderr_before_error.sh Tests stderr ordering before failures.
tests/​run_time_errors/​stdin_as_source.sh Tests unsupported stdin source spellings.
tests/​run_time_errors/​struct_array_declaration.c Tests struct-array value rendering.
tests/​run_time_errors/​suppressions.sh Tests Valgrind suppressions.
tests/​run_time_errors/​system_then_uninitialized.c Tests synchronization after system().
tests/​run_time_errors/​typedef_array_declaration.c Tests typedef-array rendering.
tests/​run_time_errors/​uncaught_exception.cpp Tests uncaught C++ exceptions.
tests/​run_time_errors/​underscore_function_name.c Tests underscore-prefixed function reporting.
tests/​run_time_errors/​uninitialized_after_deep_stack.c Tests stack reinitialization.
tests/​run_time_errors/​uninitialized_after_printf_pointer.c Tests uninitialized detection after pointer output.
tests/​run_time_errors/​uninitialized_after_recursion.c Tests uninitialized values after recursion.
tests/​run_time_errors/​uninitialized_with_lm.c Tests dual sanitizers with libm.
tests/​run_time_errors/​uninitialized-array-element-if.c Removes nondeterministic MemorySanitizer coverage.
tests/​run_time_errors/​utf8_lead_byte_before_uninitialized.c Tests UTF-8/uninitialized-byte distinction.
tests/​run_time_errors/​utf8_string_byte_not_uninitialized.c Tests valid UTF-8 continuation bytes.
tests/​run_time_errors/​valgrind_malloc_array_index.c Compares ASan and Valgrind explanations.
tests/​run_time_errors/​valgrind_non_utf8_path.sh Tests non-UTF-8 executable paths.
tests/​run_time_errors/​valgrind_use_after_free.c Tests Valgrind use-after-free explanations.
tests/​run_time_errors/​will_not_overwrite_source.sh Tests source overwrite protection.
tests/​expected_output/​** Adds and refreshes regression baselines for the tests above and newer compiler diagnostics.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

terminate called without an active exception

Execution stopped by the C++ library, which printed the reason above.
The most common reason is an exception which was thrown and never caught.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit fef979e by making the terminate-path diagnostic neutral and updating this expected output accordingly.

Comment thread tests/compile_time_errors/struct_array_incomplete_element.c Outdated
@@ -0,0 +1,8 @@
dcc: warning: using incremental compilation (-c) is not recommended with dcc
Signficant parts of dcc error detection do not work with incremental compilation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit fef979e by correcting "Signficant" to "Significant" in both the warning text generator and this expected output.

Co-authored-by: Dylan-Brotherston <22044664+Dylan-Brotherston@users.noreply.github.com>
Co-authored-by: Dylan-Brotherston <22044664+Dylan-Brotherston@users.noreply.github.com>
Co-authored-by: Dylan-Brotherston <22044664+Dylan-Brotherston@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants