Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/gui/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ mainClassName = 'org.mobilitydata.gtfsvalidator.app.gui.Main'
dependencies {
implementation project(':core')
implementation project(':main')
implementation libs.guava
implementation libs.flogger
implementation libs.flogger.system.backend
testImplementation libs.junit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package org.mobilitydata.gtfsvalidator.app.gui;

import com.google.common.collect.ImmutableMap;
import com.google.common.flogger.FluentLogger;
import java.awt.Color;
import java.awt.Component;
Expand Down Expand Up @@ -45,7 +46,9 @@
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
Expand Down Expand Up @@ -78,6 +81,8 @@ public class GtfsValidatorApp extends JFrame {

private final JSpinner numThreadsSpinner = new JSpinner();
private final JTextField countryCodeField = new JTextField("", 3);
private final JTextArea httpHeadersField = new JTextArea(4, TEXT_FIELD_COLUMN_WIDTH);
private final JLabel httpHeadersErrorLabel = new JLabel();

private final MonitoredValidationRunner validationRunner;
private final ValidationDisplay validationDisplay;
Expand Down Expand Up @@ -129,6 +134,17 @@ public String getCountryCode() {
return countryCodeField.getText();
}

public void setHttpHeaders(String httpHeaders) {
httpHeadersField.setText(httpHeaders);
}

/**
* Returns the raw text from the HTTP headers field (newline-separated {@code Name: Value} lines).
*/
public String getHttpHeaders() {
return httpHeadersField.getText();
}

void addPreValidationCallback(Runnable callback) {
preValidationCallbacks.add(callback);
}
Expand Down Expand Up @@ -291,6 +307,34 @@ private void constructAdvancedOptionsPanel(JPanel parent) {
fieldConstraints.gridy = 1;
panel.add(countryCodeField, fieldConstraints);

// HTTP Headers — spans both columns so the text area gets full width
GridBagConstraints fullRowConstraints = new GridBagConstraints();
fullRowConstraints.gridx = 0;
fullRowConstraints.gridwidth = 2;
fullRowConstraints.anchor = GridBagConstraints.NORTHWEST;
fullRowConstraints.fill = GridBagConstraints.HORIZONTAL;
fullRowConstraints.weightx = 1.0;

fullRowConstraints.gridy = 2;
panel.add(new JLabel(bundle.getString("http_headers")), fullRowConstraints);

httpHeadersField.setLineWrap(false);
JScrollPane headersScrollPane = new JScrollPane(httpHeadersField);
fullRowConstraints.gridy = 3;
panel.add(headersScrollPane, fullRowConstraints);

httpHeadersErrorLabel.setForeground(Color.RED);
httpHeadersErrorLabel.setVisible(false);
fullRowConstraints.gridy = 4;
panel.add(httpHeadersErrorLabel, fullRowConstraints);

fullRowConstraints.gridy = 5;
panel.add(new JLabel(bundle.getString("http_headers_description")), fullRowConstraints);

httpHeadersField
.getDocument()
.addDocumentListener(documentChangeListener(this::updateValidationButtonStatus));

advancedOptionsPanel.setVisible(false);
}

Expand Down Expand Up @@ -342,6 +386,13 @@ private void constructValidateButton(JPanel panel) {
}

private void updateValidationButtonStatus() {
String headerError = validateHttpHeadersText(httpHeadersField.getText());
if (headerError != null) {
httpHeadersErrorLabel.setText(headerError);
httpHeadersErrorLabel.setVisible(true);
} else {
httpHeadersErrorLabel.setVisible(false);
}
validateButton.setEnabled(isValidationReadyToRun());
}

Expand All @@ -352,6 +403,9 @@ private boolean isValidationReadyToRun() {
if (outputDirectoryField.getText().isBlank()) {
return false;
}
if (validateHttpHeadersText(httpHeadersField.getText()) != null) {
return false;
}
return true;
}

Expand All @@ -373,7 +427,7 @@ private ValidationRunnerConfig buildConfig() throws URISyntaxException {
config.setPrettyJson(true);
config.setStdoutOutput(false);

String gtfsInput = gtfsInputField.getText();
String gtfsInput = gtfsInputField.getText().strip();
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not quite related to the PR's main purpose. However, this fixes a critical error when the URL contains extra spaces.

if (gtfsInput.isBlank()) {
throw new IllegalStateException("gtfsInputField is blank");
}
Expand All @@ -383,7 +437,7 @@ private ValidationRunnerConfig buildConfig() throws URISyntaxException {
config.setGtfsSource(Path.of(gtfsInput).toUri());
}

String outputDirectory = outputDirectoryField.getText();
String outputDirectory = outputDirectoryField.getText().strip();
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not quite related to the PR's main purpose. However, this fixes a critical error when the output directory contains extra spaces.

if (outputDirectory.isBlank()) {
throw new IllegalStateException("outputDirectoryField is blank");
}
Expand All @@ -399,9 +453,50 @@ private ValidationRunnerConfig buildConfig() throws URISyntaxException {
config.setCountryCode(CountryCode.forStringOrUnknown(countryCode));
}

config.setHttpHeaders(parseHttpHeaders(httpHeadersField.getText()));

return config.build();
}

/**
* Validates newline-separated {@code Name: Value} header lines.
*
* @return {@code null} if all lines are valid, or a human-readable error message for the first
* invalid line.
*/
static String validateHttpHeadersText(String text) {
for (String line : text.split("\n")) {
if (line.isBlank()) {
continue;
}
int colon = line.indexOf(':');
if (colon <= 0) {
return "Invalid header (expected \u201cName: Value\u201d): " + line.trim();
}
}
return null;
}

/**
* Parses newline-separated {@code Name: Value} header lines into an {@link ImmutableMap}. Blank
* lines are skipped. Colons inside the value are preserved.
*
* <p>Callers must ensure the text is valid via {@link #validateHttpHeadersText} first.
*/
static ImmutableMap<String, String> parseHttpHeaders(String text) {
// Use LinkedHashMap so duplicate header names keep the last value (last-wins semantics)
// rather than throwing, which is a more forgiving user experience.
java.util.LinkedHashMap<String, String> map = new java.util.LinkedHashMap<>();
for (String line : text.split("\n")) {
if (line.isBlank()) {
continue;
}
int colon = line.indexOf(':');
map.put(line.substring(0, colon).trim(), line.substring(colon + 1).trim());
Comment thread
davidgamez marked this conversation as resolved.
}
return ImmutableMap.copyOf(map);
}

private static Font createBoldFont() {
JLabel label = new JLabel();
Font baseFont = label.getFont();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public class GtfsValidatorPreferences {
private static final String KEY_OUTPUT_DIRECTORY = "output_directory";
private static final String KEY_NUM_THREADS = "num_threads";
private static final String KEY_COUNTRY_CODE = "country_code";
private static final String KEY_HTTP_HEADERS = "http_headers";

private final Preferences prefs;

Expand All @@ -27,13 +28,17 @@ public void loadPreferences(GtfsValidatorApp app) {
loadPathSetting(KEY_OUTPUT_DIRECTORY, app::setOutputDirectory);
loadIntSetting(KEY_NUM_THREADS, app::setNumThreads);
loadStringSetting(KEY_COUNTRY_CODE, app::setCountryCode);
// HTTP headers are intentionally NOT loaded and any previously stored value is deleted to
// avoid leaking credentials (tokens, passwords) across sessions.
prefs.remove(KEY_HTTP_HEADERS);
}

public void savePreferences(GtfsValidatorApp app) {
saveStringSetting(app::getGtfsSource, KEY_GTFS_SOURCE);
saveStringSetting(app::getOutputDirectory, KEY_OUTPUT_DIRECTORY);
saveIntSetting(app::getNumThreads, KEY_NUM_THREADS);
saveStringSetting(app::getCountryCode, KEY_COUNTRY_CODE);
// HTTP headers are intentionally NOT saved — they may contain credentials.
}

private void loadStringSetting(String key, Consumer<String> setter) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,7 @@ advanced=Advanced
advanced_options=Advanced Options
number_of_threads=Number of threads used to run the validator:
country_code=Country Code (for phone validation):
http_headers=Custom HTTP Headers (one per line, Name: Value format):
http_headers_description=Only applied when downloading from a URL. Example: Authorization: Bearer token

validate=Validate
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static com.google.common.truth.Truth8.assertThat;
import static org.mockito.Mockito.verify;

import com.google.common.collect.ImmutableMap;
import java.awt.GraphicsEnvironment;
import java.net.URI;
import java.net.URISyntaxException;
Expand Down Expand Up @@ -101,4 +102,101 @@ public void testPreValidationCallback() throws URISyntaxException {

verify(callback).run();
}

@Test
public void testHttpHeadersPassedToConfig() throws URISyntaxException {
app.setGtfsSource("http://transit/gtfs.zip");
app.setOutputDirectory(Path.of("/path/to/output"));
app.setHttpHeaders("Authorization: Bearer token123\nUser-Agent: my-app/2.0");

app.getValidateButtonForTesting().doClick();

verify(runner).run(configCaptor.capture(), Mockito.same(app));

ValidationRunnerConfig config = configCaptor.getValue();
assertThat(config.httpHeaders())
.isEqualTo(
ImmutableMap.of(
"Authorization", "Bearer token123",
"User-Agent", "my-app/2.0"));
}

@Test
public void testParseHttpHeaders_empty() {
assertThat(GtfsValidatorApp.parseHttpHeaders("")).isEmpty();
assertThat(GtfsValidatorApp.parseHttpHeaders(" \n \n")).isEmpty();
}

@Test
public void testParseHttpHeaders_duplicateKeyKeepsLast() {
ImmutableMap<String, String> result =
GtfsValidatorApp.parseHttpHeaders(
"Authorization: Bearer first\nAuthorization: Bearer second");
assertThat(result).isEqualTo(ImmutableMap.of("Authorization", "Bearer second"));
}

@Test
public void testParseHttpHeaders_valueContainsColon() {
ImmutableMap<String, String> result =
GtfsValidatorApp.parseHttpHeaders("X-Endpoint: http://trace.example.com/id");
assertThat(result).isEqualTo(ImmutableMap.of("X-Endpoint", "http://trace.example.com/id"));
}

@Test
public void testNoHttpHeadersGivesEmptyMap() throws URISyntaxException {
app.setGtfsSource("http://transit/gtfs.zip");
app.setOutputDirectory(Path.of("/path/to/output"));

app.getValidateButtonForTesting().doClick();

verify(runner).run(configCaptor.capture(), Mockito.same(app));
assertThat(configCaptor.getValue().httpHeaders()).isEmpty();
}

// --- Validation tests ---

@Test
public void testValidateHttpHeadersText_validHeaders() {
assertThat(GtfsValidatorApp.validateHttpHeadersText("")).isNull();
assertThat(GtfsValidatorApp.validateHttpHeadersText(" \n ")).isNull();
assertThat(GtfsValidatorApp.validateHttpHeadersText("Authorization: Bearer token")).isNull();
assertThat(
GtfsValidatorApp.validateHttpHeadersText(
"Authorization: Bearer token\nUser-Agent: app/1.0"))
.isNull();
}

@Test
public void testValidateHttpHeadersText_missingColon() {
// Value-only line (the bug that was reported)
String error =
GtfsValidatorApp.validateHttpHeadersText(
"Basic YXBpQG1vYmlsaXR5ZGF0YS5vcmc6cWN2M3RoaypOWk00cGFmX3V5YQ==");
assertThat(error).isNotNull();
assertThat(error).contains("Basic YXBpQG1vYmlsaXR5ZGF0YS5vcmc6cWN2M3RoaypOWk00cGFmX3V5YQ==");
}

@Test
public void testValidateHttpHeadersText_colonAtStart() {
// Colon at position 0 → name is empty → invalid
String error = GtfsValidatorApp.validateHttpHeadersText(": value");
assertThat(error).isNotNull();
}

@Test
public void testInvalidHeaderDisablesValidateButton() {
app.setGtfsSource("http://transit/gtfs.zip");
app.setOutputDirectory(Path.of("/path/to/output"));
// Valid so far — button should be enabled
assertThat(app.getValidateButtonForTesting().isEnabled()).isTrue();

// Set an invalid header — button should become disabled
app.setHttpHeaders("Basic YXBpQG1vYmlsaXR5ZGF0YS5vcmc6cWN2M3RoaypOWk00cGFmX3V5YQ==");
assertThat(app.getValidateButtonForTesting().isEnabled()).isFalse();

// Fix the header — button re-enabled
app.setHttpHeaders(
"Authorization: Basic YXBpQG1vYmlsaXR5ZGF0YS5vcmc6cWN2M3RoaypOWk00cGFmX3V5YQ==");
assertThat(app.getValidateButtonForTesting().isEnabled()).isTrue();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,26 @@ public void testEndToEnd() {
assertThat(dest.getCountryCode()).isEqualTo("CA");
}
}

@Test
public void testHttpHeadersAreNotPersisted() {
// Headers must not survive a save/load cycle (security: they may contain credentials).
{
GtfsValidatorApp source = new GtfsValidatorApp(runner, display);
source.setGtfsSource("http://gtfs.org/gtfs.zip");
source.setOutputDirectory(Path.of("/tmp/gtfs"));
source.setHttpHeaders("Authorization: Bearer secret-token");

GtfsValidatorPreferences prefs = new GtfsValidatorPreferences();
prefs.savePreferences(source);
}

{
GtfsValidatorPreferences prefs = new GtfsValidatorPreferences();
GtfsValidatorApp dest = new GtfsValidatorApp(runner, display);
prefs.loadPreferences(dest);

assertThat(dest.getHttpHeaders()).isEmpty();
}
}
}
Loading
Loading