The build is green; the problem remains
Chapter 29 gave us a catalog that compiles, passes five tests and starts from a JAR and a packaged application. This is real progress: a colleague can repeat those steps. But can we deliver that program to anyone loading an arbitrary file? TitleArchive uses a line stream, normalizes titles and accumulates everything in a list. It imposes no maximum on lines read or title count. A very large file may consume memory; an enormous line may be read before Titles.normalize rejects it. The five tests did not ask for this behavior, so their success does not answer the new question.
Our case study now changes one condition: the file may come from a user and be untrusted. We must establish what we accept, how much work we are willing to do, what we record when something goes wrong and how we gather evidence if the process slows down. These decisions intertwine quality, security and operability, but are not the same property. A correct input rule may be written in unreadable code; a detailed log may expose confidential data; a clean build may distribute a vulnerable dependency. This is why the chapter continues the catalog's journey rather than offering a list of commands to tick off.
Definition – Observable quality. Correctness means respecting declared behavior. Readability and maintainability concern the ability to understand and change that behavior. Performance concerns time and resources under a defined load. Security concerns treatment of data, permissions and trust boundaries. Operability means being able to understand and govern a running program. No single figure demonstrates all these properties: for each we must say which evidence we seek.
Figure 30.1 connects five steps. We prevent a defect by setting a contract; detect it with tests and tools; diagnose it with evidence; correct it through a controlled change; retain a regression test. The arrow returns to the beginning because a new requirement, such as an untrusted file, modifies what we must prevent.
A new requirement forces us to revisit the code
In chapter 29's importer, Files.lines reads one line at a time, but this is not a length limit: the library must still form the line's string. In addition, the list grows with title count. If the file is internal, small and controlled, the trade-off may be reasonable. If instead a user can upload it, first define the contract: in our experiment we accept at most one hundred nonempty titles and eighty UTF-16 units per line. The number is a demonstration choice; a real service must choose it based on domain, costs, error messages and available capacity.
Figure 30.2 distinguishes file boundaries: path, bytes, decoding, lines, normalized titles and domain objects. Every boundary may fail differently. A path may leave the permitted directory; malformed bytes may not be UTF-8 text; a line may be too long; a title may be syntactically valid but fail the catalog's rules. A single catch turning everything into “invalid file” is insufficient, because we lose the useful cause for deciding whether to correct input, permissions or the program.
The complete ControlledImport program works on a Reader, rather than a path. The caller decides where characters come from; the method handles lines and limits. In main we use StringReader for a small test independent of the file system. A real application would construct the Reader with explicit decoding, for example Files.newBufferedReader(path, StandardCharsets.UTF_8), after defining which path is authorized. Separating these two tasks makes the parser easier to test without pretending to have solved the path problem.
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
public final class ControlledImport {
private static final int MAX_LINE = 80;
private static final int MAX_TITLES = 100;
private static final System.Logger LOG =
System.getLogger(ControlledImport.class.getName());
private ControlledImport() {
}
public static List<String> read(Reader source) throws IOException {
List<String> titles = new ArrayList<>();
StringBuilder line = new StringBuilder();
try (BufferedReader reader = new BufferedReader(source)) {
int read;
while ((read = reader.read()) != -1) {
if (read == '\n') {
add(line, titles);
line.setLength(0);
} else if (read != '\r') {
if (line.length() == MAX_LINE) {
throw new IOException("Line too long");
}
line.append((char) read);
}
}
if (!line.isEmpty()) {
add(line, titles);
}
}
LOG.log(System.Logger.Level.INFO,
"Imported {0} titles", titles.size());
return List.copyOf(titles);
}
private static void add(StringBuilder line, List<String> titles)
throws IOException {
String title = line.toString().strip().replaceAll("\\s+", " ");
if (title.isEmpty()) {
return;
}
if (titles.size() == MAX_TITLES) {
throw new IOException("Too many titles");
}
titles.add(title);
}
public static void main(String[] args) throws IOException {
String data = " Modern Java \nIt is time to learn — café\n";
List<String> titles = read(new StringReader(data));
System.out.println("Valid titles: " + titles.size());
}
}
BufferedReader avoids asking the physical source for one character per call. The loop reads one char unit at a time: before adding it to StringBuilder, it checks whether the line has already reached eighty units. Thus a line of thousands of characters is not accumulated entirely in our StringBuilder before rejection. The reader may still have its own limited-size internal buffer; the contract concerns state the parser retains and the point where it stops reading, rather than whether any subsequent byte has ever been fetched from the operating system. At each \n, the line is normalized and inserted only if nonempty. \r is ignored to allow the teaching case's CRLF line ending. This simple format is not a universal parser: a rule for CR-only lines or other separators would need to be declared and tested.
add checks title count before inserting the one hundred and first. If it exceeds the limit, the method throws IOException and the partial list is not returned. try-with-resources closes the supplied Reader: this is part of the method's contract, to document so as not to surprise someone wishing to reuse the same source. List.copyOf prevents the caller modifying the returned list; it does not, however, make a subsequent database write atomic. Chapter 20A taught us to specify precisely this boundary.
One limit remains to be designed before using this code as public input: a file with millions of blank lines never exceeds MAX_TITLES, and the method continues reading. Line memory stays contained, but time and total characters read do not. A service must add an overall maximum for bytes or characters, a maximum for lines including blank ones and, if the source is a network, a timeout at the level governing reading. Those limits must have their own errors and tests. Our example tests two precise boundaries; calling it a “secure importer” without declaring the others would be false.
The two limits do not replace semantic checking. We might reject duplicate titles, control characters or strings not matching an editorial policy; or preserve them intentionally. First write the rule, then implement and test it. Validation is not “cleaning everything up” until dangerous data seems harmless: a silent transformation may change a title's meaning. In the program we normalize spaces because the case study requires it; we do not arbitrarily remove punctuation or accents.
A test arising from the new risk
The TestControlledImport program verifies three properties. Valid input with a blank line produces two titles; an 81-unit line is rejected; 101 nonempty lines are rejected. rejected does more than catch any exception: it checks the expected message, so an accidental NullPointerException would not appear to be the intended rejection. The test is a small JDK harness, executable without JUnit too; in a Maven project it would be natural to transfer the same cases to parameterized JUnit tests with appropriate assertions.
import java.io.IOException;
import java.io.StringReader;
import java.util.List;
public final class TestControlledImport {
private TestControlledImport() {
}
private static void rejected(String data, String message) {
try {
ControlledImport.read(new StringReader(data));
throw new AssertionError("Input accepted: " + message);
} catch (IOException expected) {
if (!expected.getMessage().equals(message)) {
throw new AssertionError("Different error", expected);
}
}
}
public static void main(String[] args) throws IOException {
List<String> valid = ControlledImport.read(
new StringReader(" Modern Java \n\nNetworks\n"));
if (!valid.equals(List.of("Modern Java", "Networks"))) {
throw new AssertionError("Unexpected titles: " + valid);
}
rejected("x".repeat(81), "Line too long");
rejected("x\n".repeat(101), "Too many titles");
System.out.println("Valid input and two limits verified");
}
}
Compiling from examples/C30 with javac --release 25 -Xlint:all -d build *.java, then executing java -cp build TestControlledImport, we obtain Valid input and two limits verified. An informational log line may also appear on stderr; format, date and language depend on the environment's logging provider, so the test does not compare those bytes. It instead checks the result and two rejections, which belong to the contract. A positive execution alone would not have exposed the previous code's unbounded growth.
Key concept – Syntax, semantics and resources. Input may respect the form and violate a domain rule; it may respect both but cost too much to process; it may be valid in itself but arrive from a path the caller must not be allowed to read. Checks belong at the relevant boundary. Moving a check between phases without understanding its cost may leave open exactly the problem we intended to solve.
Making code clearer without changing the promise
The example also shows a structural choice: read governs the flow, add contains the line rule, main offers a demonstration. A class with one method hundreds of lines long, reading files, validating, saving, logging and printing, would be harder to test. We speak of cohesion when a piece of code has an understandable responsibility; of coupling when one part depends on another's details. We do not measure quality merely by counting methods: dividing without reason can scatter logic as much as piling it together.
A refactoring modifies internal structure while preserving agreed observable behavior. For example, we could replace the StringBuilder with a LimitedLineReader component, provided tests for the limit, blank lines and closure continue to pass. Introducing the eighty-unit limit, on the other hand, is a functional change: it changes accepted inputs and requires a requirements decision; it cannot be hidden under “code cleanup”. Taking small steps and keeping a readable commit for each decision helps distinguish an introduced error from an intentional correction.
Duplication is not always the main enemy. Two similar lines in different contexts may represent different contracts: normalization for catalog titles and for filenames need not become the same generic function. First seek which knowledge is truly repeated. Names such as MAX_LINE, MAX_TITLES, add and source make the choice visible; a generic name such as process would force the reader to reconstruct it from the method body. Clear code is a security tool because it facilitates review, but readability alone does not prove that every boundary is defended.
The compiler and analyzers: signals, rather than verdicts
javac -Xlint:all enables warnings in various categories. A warning is not automatically an exploitable defect; it is a request for attention to connect to code and risk. A deprecation warning may require migration, while one generated by an isolated historical example can be documented. Suppression with @SuppressWarnings is a local decision: it should name the relevant category and explain why the case is accepted. Suppressing everything in global configuration makes a new warning harder to notice.
A linter checks conventions; a bug-pattern analyzer looks for forms associated with defects; a SAST tool, meaning static application security testing, looks for dangerous flows and uses without executing the program. Checkstyle, PMD and SpotBugs are examples external to the JDK, with different purposes and rules. A rule may produce false positives and a clean check may leave false negatives. Therefore choose a manageable set, save configuration with code and annotate justified exceptions. The gate is not “zero warnings at any cost”: it is knowing which warnings remain, who evaluated them and which decision was made.
Static analysis does not see everything that happens during execution, such as a plugin loaded by configuration or a particular combination of inputs and permissions. Tests do not see every possible path. Human review may identify an incorrect requirement but fail to observe a rare race condition. These checks complement each other, each with a declared limit. The table accompanying a correction should say which property is defended and which test checks it, rather than merely list tools that “passed”.
The boundary of files and other untrusted data
Our parser receives characters from a Reader; it does not decide whether a path is legal. If a service receives a filename from a user, Path.resolve and normalize construct and simplify paths, but do not alone demonstrate that the opened file remains inside an allowed folder. .. segments, absolute paths, symbolic links and concurrent file system changes can invalidate a naive check that “the normalized path starts with the base folder”. Strategy depends on requirements: often it is better not to accept a free-form path, but an identifier the server translates to an already known file. If the file system is modifiable by an adversary, checking and opening must be designed together with appropriate APIs and permissions; chapter 25 already distinguished the name from the real file.
The same principle applies to an external command. Constructing a string such as "program " + userInput and passing it to a shell puts input inside shell syntax. ProcessBuilder with separate program and arguments avoids that particular interpretation, but does not guarantee that the argument is semantically safe for the called program. For untrusted XML, configure the parser regarding external entities and accessible resources, rather than assuming “it is XML” means “it is only text”. For HTTP URLs, the client must decide permitted hosts, redirects, timeouts and response sizes according to context. The common question is always: which part of the data does another party control, and which capability are we granting it?
Java object serialization deserves separate attention. An ObjectInputStream reading untrusted bytes can construct an object graph and activate behavior during deserialization. For new interfaces between systems, prefer formats with schemas or explicit parsing, together with size limits and validation. If we must maintain an existing serialized format, ObjectInputFilter and filter configuration can reduce accepted classes and sizes; they do not convert an unknown stream into harmless input by definition. We do not show a positive arbitrary-deserialization example that readers could copy outside its context.
Important note – Secrets and logs. A password, API key or token must not be embedded in source or printed in an exception for convenience. Moving them to an environment variable or dedicated store does not remove the need to control access, rotation and visibility during execution. Even an apparently harmless log may include personal paths, confidential titles or HTTP bodies. In our example we record only the number of imported titles, rather than the file's content.
Essential reference – Security developments in JDK 25. The Key Derivation Function API is stable and offers a standard contract for deriving cryptographic keys from secret material and other data. It answers a different problem from limiting importer lines or hiding a token in a log: when an application must obtain a key for a protocol, algorithm, parameters and secret management must be chosen and verified in context. The same guide lists the API for encoding cryptographic objects in PEM format as preview. We record them here to position Java 25 developments correctly, without turning a book-import example into an incomplete cryptographic recipe. Real use would require a defined security case, API contracts and specific tests.
Dependencies: the project also includes what we did not write
In C29's POM, JUnit is a test dependency with a fixed version. Even a test-only library must be updated and acquired from a trustworthy source; a production library also affects the delivered program. Reading the transitive graph, recording versions and provenance, verifying artifact integrity and observing known-vulnerability advisories are recurring tasks. A software bill of materials, often called an SBOM, lists a build's components and versions; it helps answer quickly “is this vulnerable version present here?”. It does not by itself demonstrate that the component was used on a dangerous path or that the package is secure.
Updating a dependency is a program change, rather than a formality. Read release notes and compatibility, run a clean build, verify use cases, update the bill of materials and retain a residual-risk decision. If a reported vulnerability concerns code unreachable in our case, record the assessment with evidence, rather than hide it by removing the check. If an artifact arrives from an unexpected repository, understand who publishes it and how it is verified before adopting it. Chapter 29 provided the repeatable basis for asking these questions about a precise set of files.
Recording useful events without confusing logs and results
System.getLogger returns a logger from the platform API. The actual implementation may forward messages to a different backend according to environment; we do not rely on message text as the program's interface. In ControlledImport, LOG.log(Level.INFO, "Imported {0} titles", titles.size()) records a useful fact at the end of import without repeating titles in the log. An input error might be logged by a higher level with request identifier, category and cause, avoiding publication of all rejected content. If every method logs and rethrows the same error, one failure can generate many identical lines: first decide who owns logging responsibility.
Levels help distinguish ordinary noise, informational events, recoverable problems and failures. Request context, such as an opaque identifier, helps connect several operations without writing personal data. A structured log makes filtering fields easier than free-form sentences, but schema, retention and log access are system decisions, rather than decisions of System.Logger alone. In an automated test, compare the operation's behavior, rather than the date or language chosen by the logging backend. When logging itself is an audit requirement, test it with a dedicated contract and controlled provider.
From the message to JVM behavior
Suppose import is correct but slow. The log “Imported 2 titles” does not say where time was spent. A thread dump shows where threads are at an instant; jcmd <pid> Thread.print is one diagnostic command the JVM may offer. A single dump may find an obvious wait, but does not alone tell how often it occurred. Java Flight Recorder, or JFR, collects events over time. It can show JVM activity, waits and application events we define. Diagnostics have a cost and may contain sensitive data: in production, choose settings, duration and access according to the problem.
The ImportDiagnostics program defines an example.catalog.Import event with title count. It starts a JFR recording, enables the event with no minimum duration threshold for this example, imports the same small text three times and saves the file specified on the command line. event.begin() marks the start of measurement; commit() concludes and records the event. We put no titles in JFR, only their count. The program is not a benchmark: the three durations depend on startup, JIT, operating system and load.
import java.io.StringReader;
import java.nio.file.Path;
import java.time.Duration;
import jdk.jfr.Event;
import jdk.jfr.Label;
import jdk.jfr.Name;
import jdk.jfr.Recording;
public final class ImportDiagnostics {
@Name("example.catalog.Import")
@Label("Catalog import")
static class ImportEvent extends Event {
@Label("Titles")
int titles;
}
private ImportDiagnostics() {
}
public static void main(String[] args) throws Exception {
if (args.length != 1) {
throw new IllegalArgumentException("Usage: ImportDiagnostics <file.jfr>");
}
try (Recording recording = new Recording()) {
recording.enable(ImportEvent.class)
.withThreshold(Duration.ZERO);
recording.start();
for (int i = 0; i < 3; i++) {
ImportEvent event = new ImportEvent();
event.begin();
event.titles = ControlledImport.read(
new StringReader("Modern Java\nNetworks\n")).size();
event.commit();
}
recording.stop();
recording.dump(Path.of(args[0]));
}
System.out.println("Recorded 3 imports");
}
}
From examples/C30, after the compilation already described, use java -cp build ImportDiagnostics build/catalog.jfr and then jfr print --events example.catalog.Import build/catalog.jfr. The verified recording contains three events with titles = 2. Figure 30.3 is an annotated reading of that result: look for the event name, domain field, thread and variable durations. Do not use the figure's timing values as a threshold for another computer.
example.catalog.Import events record two titles each; observed durations are data from that single test, rather than performance promises.jcmd and JFR answer different questions. If the process is blocked now, a dump may indicate the current wait. If slowness appears intermittently, a recording may show event distribution, allocations and waits during the observed period, depending on settings. If our event is missing, check that it was enabled, that recording covered the right moment and that the --events filter specifies the correct name. “I see no events” does not immediately mean “the code was not executed”. The jdk.jfr module belongs to the JDK used in the test; a reduced runtime created with jlink must include it if the application uses this API directly.
Measuring performance means declaring an experiment
Chapter 20A warned against concluding that LongAdder is always faster than AtomicLong. The same applies to the parser. Timing a single call to ControlledImport.read with System.nanoTime() mixes JVM startup, JIT compilation, input reading and system noise. A serious microbenchmark requires warmup, multiple measurements, control of consumed data and a declared environment. JMH, the external OpenJDK-project tool for JVM benchmarks, helps build that experiment; it does not turn an ill-posed comparison into a useful answer. If the real problem is the time to import a hundred-megabyte archive from disk, a representative load test is also needed, rather than merely the speed of a function on two in-memory lines.
Performance has several dimensions: single-request latency, throughput of many requests, memory, CPU, queues and pauses. Optimizing the average while a small share of users waits too long may worsen the experience. Before changing code, describe load and measurement, reproduce the bottleneck, compare one change at a time and retain results with machine, JDK and configuration. A JFR recording may suggest where to investigate; a benchmark or load test may verify whether the change helps in the chosen context. Neither decides for us which property matters to the reader or user.
An incident becomes knowledge when we leave a test
Imagine a production application terminating during import of a very long file. Before changing a line, gather conditions: artifact version, JDK, input size and format, error message, relevant logs, any JFR recording and machine limits. Reduce the case to a file reproducing failure without retaining confidential data. If the problem is the unbounded line, write a test exceeding it and observe the defect in the previous version; then introduce the limit, run again and check that valid inputs continue to work. The change enters a build a colleague can repeat thanks to chapter 29.
Not every incident originates in a single defective line. A remote service may have slowed down, a dependency may have changed behavior, a file system may have run out of space. The final report distinguishes observed cause, excluded hypotheses, correction, regression test and residual risk. If the cause is not yet demonstrated, write it as a hypothesis rather than turn it into certainty to close the ticket. This discipline respects whoever must maintain the program after us.
The C29–C30 activities ask you to recognize the boundary where a defect originates, add a test making it visible and choose suitable diagnostics. The final transfer changes the problem again: instead of a file, lines arrive from an HTTP response, and the catalog must record only the complete, valid set in a database. Parser limits remain useful, but are insufficient. Response timeout and size, network error, archive transaction and retry behavior must be defined. Readers have built the model when they can extend the questions to the new boundary without believing that a green build or a class called “secure” solves everything.
Final question – Which evidence is missing? For every change, ask: which concrete problem does it tackle, which invariant or risk does it govern, how do we see that it works and which nearby case would make it fail? Good code is not a static trophy. It is a set of decisions that others can read, verify and improve without guessing why they were made.
Essential references
Essential reference – Contracts and diagnostics. The
System.Loggerspecification clarifies the logging API; the Java 25 serialization filter guide andObjectInputFilterspecification describe the defensive perimeter for legacy code. The specifications ofjcmd,jfr,EventandRecordingsupport the diagnostic test. The OpenJDK JMH project documents the benchmark tool. None of these sources alone supplies a security policy or performance threshold valid for every application: those decisions depend on the system and gathered evidence.