Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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,48 @@ 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) {
ImmutableMap.Builder<String, String> map = ImmutableMap.builder();
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 map.build();
}

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,93 @@ 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_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