Show hg binary verification error messages (#1637)

Show hg verification error messages on global hg config page if trying to save invalid hg binary.
This commit is contained in:
Eduard Heimbuch
2021-05-03 19:04:08 +02:00
committed by GitHub
parent 7464baf767
commit 579b58ba5f
8 changed files with 113 additions and 48 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: Show hg binary verification error messages ([#1637](https://github.com/scm-manager/scm-manager/pull/1637))

View File

@@ -35,6 +35,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import sonia.scm.config.ConfigurationPermissions; import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.repository.HgGlobalConfig; import sonia.scm.repository.HgGlobalConfig;
import sonia.scm.repository.HgRepositoryHandler; import sonia.scm.repository.HgRepositoryHandler;
import sonia.scm.repository.HgVerifier;
import sonia.scm.web.HgVndMediaType; import sonia.scm.web.HgVndMediaType;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -48,6 +49,8 @@ import javax.ws.rs.Path;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import static sonia.scm.ScmConstraintViolationException.Builder.doThrow;
/** /**
* RESTful Web Service Resource to manage the configuration of the hg plugin. * RESTful Web Service Resource to manage the configuration of the hg plugin.
*/ */
@@ -144,6 +147,7 @@ public class HgConfigResource {
responseCode = "204", responseCode = "204",
description = "update success" description = "update success"
) )
@ApiResponse(responseCode = "400", description = "invalid configuration")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:write:hg\" privilege") @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:write:hg\" privilege")
@ApiResponse( @ApiResponse(
@@ -154,11 +158,16 @@ public class HgConfigResource {
schema = @Schema(implementation = ErrorDto.class) schema = @Schema(implementation = ErrorDto.class)
)) ))
public Response update(@Valid HgGlobalGlobalConfigDto configDto) { public Response update(@Valid HgGlobalGlobalConfigDto configDto) {
HgGlobalConfig config = dtoToConfigMapper.map(configDto); HgGlobalConfig config = dtoToConfigMapper.map(configDto);
ConfigurationPermissions.write(config).check(); ConfigurationPermissions.write(config).check();
if (config.getHgBinary() != null) {
HgVerifier.HgVerifyStatus verifyStatus = new HgVerifier().verify(config.getHgBinary());
doThrow()
.violation(verifyStatus.getDescription())
.when(verifyStatus != HgVerifier.HgVerifyStatus.VALID);
}
repositoryHandler.setConfig(config); repositoryHandler.setConfig(config);
repositoryHandler.storeConfig(); repositoryHandler.storeConfig();

View File

@@ -24,8 +24,6 @@
package sonia.scm.repository; package sonia.scm.repository;
//~--- non-JDK imports --------------------------------------------------------
import com.google.inject.Inject; import com.google.inject.Inject;
import com.google.inject.Singleton; import com.google.inject.Singleton;
import org.slf4j.Logger; import org.slf4j.Logger;

View File

@@ -29,6 +29,9 @@ import org.slf4j.LoggerFactory;
import sonia.scm.io.SimpleCommand; import sonia.scm.io.SimpleCommand;
import sonia.scm.io.SimpleCommandResult; import sonia.scm.io.SimpleCommandResult;
import javax.xml.bind.annotation.XmlEnum;
import javax.xml.bind.annotation.XmlEnumValue;
import javax.xml.bind.annotation.XmlType;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -48,23 +51,31 @@ public class HgVerifier {
} }
public boolean isValid(HgGlobalConfig config) { public boolean isValid(HgGlobalConfig config) {
return isValid(config.getHgBinary()); return verify(config) == HgVerifyStatus.VALID;
} }
public boolean isValid(String hg) { public boolean isValid(Path path) {
return isValid(Paths.get(hg)); return verify(path) == HgVerifyStatus.VALID;
} }
public boolean isValid(Path hg) { public HgVerifyStatus verify(HgGlobalConfig config) {
return verify(config.getHgBinary());
}
public HgVerifyStatus verify(String hg) {
return verify(Paths.get(hg));
}
public HgVerifyStatus verify(Path hg) {
LOG.trace("check if hg binary {} is valid", hg); LOG.trace("check if hg binary {} is valid", hg);
if (!Files.isRegularFile(hg)) { if (!Files.isRegularFile(hg)) {
LOG.warn("{} is not a regular file", hg); LOG.warn("{} is not a regular file", hg);
return false; return HgVerifyStatus.NOT_REGULAR_FILE;
} }
if (!Files.isExecutable(hg)) { if (!Files.isExecutable(hg)) {
LOG.warn("{} is not executable", hg); LOG.warn("{} is not executable", hg);
return false; return HgVerifyStatus.NOT_EXECUTABLE;
} }
try { try {
@@ -72,31 +83,31 @@ public class HgVerifier {
return isVersionValid(hg, version); return isVersionValid(hg, version);
} catch (IOException ex) { } catch (IOException ex) {
LOG.warn("failed to resolve version of {}: ", hg, ex); LOG.warn("failed to resolve version of {}: ", hg, ex);
return false; return HgVerifyStatus.COULD_NOT_RESOLVE_VERSION;
} catch (InterruptedException ex) { } catch (InterruptedException ex) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
LOG.warn("failed to resolve version of {}: ", hg, ex); LOG.warn("failed to resolve version of {}: ", hg, ex);
return false; return HgVerifyStatus.COULD_NOT_RESOLVE_VERSION;
} }
} }
private boolean isVersionValid(Path hg, String version) { private HgVerifyStatus isVersionValid(Path hg, String version) {
String[] parts = version.split("\\."); String[] parts = version.split("\\.");
if (parts.length < 2) { if (parts.length < 2) {
LOG.warn("{} returned invalid version: {}", hg, version); LOG.warn("{} returned invalid version: {}", hg, version);
return false; return HgVerifyStatus.INVALID_VERSION;
} }
try { try {
int major = Integer.parseInt(parts[0]); int major = Integer.parseInt(parts[0]);
if (major < 4) { if (major < 4) {
LOG.warn("{} is too old, we need at least mercurial 4.x", hg); LOG.warn("{} is too old, we need at least mercurial 4.x", hg);
return false; return HgVerifyStatus.VERSION_TOO_OLD;
} }
} catch (NumberFormatException ex) { } catch (NumberFormatException ex) {
LOG.warn("{} returned invalid version {}", hg, version); LOG.warn("{} returned invalid version {}", hg, version);
return false; return HgVerifyStatus.INVALID_VERSION;
} }
return true; return HgVerifyStatus.VALID;
} }
private VersionResolver defaultVersionResolver() { private VersionResolver defaultVersionResolver() {
@@ -115,4 +126,22 @@ public class HgVerifier {
String resolveVersion(Path hg) throws IOException, InterruptedException; String resolveVersion(Path hg) throws IOException, InterruptedException;
} }
public enum HgVerifyStatus {
VALID("hg binary is valid"),
NOT_REGULAR_FILE("hg binary is not a regular file"),
NOT_EXECUTABLE("hg binary is not executable"),
INVALID_VERSION("hg binary returned invalid version"),
VERSION_TOO_OLD("hg binary version is too old, we need at least 4.x"),
COULD_NOT_RESOLVE_VERSION("failed to resolve version of hg binary");
private final String description;
HgVerifyStatus(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
} }

View File

@@ -155,6 +155,13 @@ public class HgConfigResourceTest {
assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus());
} }
@Test
@SubjectAware(username = "writeOnly")
public void shouldNotUpdateConfigForInvalidBinary() throws URISyntaxException {
MockHttpResponse response = put("{\"hgBinary\":\"3.2.1\"}");
assertEquals(400, response.getStatus());
}
@Test @Test
@SubjectAware(username = "readOnly") @SubjectAware(username = "readOnly")
public void shouldNotUpdateConfigWhenNotAuthorized() throws URISyntaxException, UnsupportedEncodingException { public void shouldNotUpdateConfigWhenNotAuthorized() throws URISyntaxException, UnsupportedEncodingException {
@@ -172,9 +179,13 @@ public class HgConfigResourceTest {
} }
private MockHttpResponse put() throws URISyntaxException { private MockHttpResponse put() throws URISyntaxException {
return put("{\"disabled\":true}");
}
private MockHttpResponse put(String config) throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.put("/" + HgConfigResource.HG_CONFIG_PATH_V2) MockHttpRequest request = MockHttpRequest.put("/" + HgConfigResource.HG_CONFIG_PATH_V2)
.contentType(HgVndMediaType.CONFIG) .contentType(HgVndMediaType.CONFIG)
.content("{\"disabled\":true}".getBytes()); .content(config.getBytes());
MockHttpResponse response = new MockHttpResponse(); MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);

View File

@@ -51,22 +51,22 @@ class HgVerifierTest {
private final HgVerifier verifier = new HgVerifier(); private final HgVerifier verifier = new HgVerifier();
@Test @Test
void shouldReturnFalseIfFileDoesNotExists(@TempDir Path directory) { void shouldReturnNotRegularFileIfFileDoesNotExists(@TempDir Path directory) {
Path hg = directory.resolve("hg"); Path hg = directory.resolve("hg");
boolean isValid = verify(hg); HgVerifier.HgVerifyStatus verifyStatus = verify(hg);
assertThat(isValid).isFalse(); assertThat(verifyStatus).isEqualTo(HgVerifier.HgVerifyStatus.NOT_REGULAR_FILE);
} }
@Test @Test
void shouldReturnFalseIfFileIsADirectory(@TempDir Path directory) throws IOException { void shouldReturnNotRegularFileIfFileIsADirectory(@TempDir Path directory) throws IOException {
Path hg = directory.resolve("hg"); Path hg = directory.resolve("hg");
Files.createDirectories(hg); Files.createDirectories(hg);
boolean isValid = verify(hg); HgVerifier.HgVerifyStatus verifyStatus = verify(hg);
assertThat(isValid).isFalse(); assertThat(verifyStatus).isEqualTo(HgVerifier.HgVerifyStatus.NOT_REGULAR_FILE);
} }
@Test @Test
@@ -74,21 +74,21 @@ class HgVerifierTest {
Path hg = directory.resolve("hg"); Path hg = directory.resolve("hg");
Files.createFile(hg); Files.createFile(hg);
boolean isValid = verify(hg); HgVerifier.HgVerifyStatus verifyStatus = verify(hg);
assertThat(isValid).isFalse(); assertThat(verifyStatus).isEqualTo(HgVerifier.HgVerifyStatus.NOT_EXECUTABLE);
} }
@Test @Test
void shouldReturnTrueForIfMercurialIsAvailable() { void shouldReturnValidIfMercurialIsAvailable() {
Path hg = findHg(); Path hg = findHg();
// skip test if we could not find mercurial // skip test if we could not find mercurial
Assumptions.assumeTrue(hg != null, "skip test, because we could not find mercurial on path"); Assumptions.assumeTrue(hg != null, "skip test, because we could not find mercurial on path");
boolean isValid = verify(hg); HgVerifier.HgVerifyStatus verifyStatus = verify(hg);
assertThat(isValid).isTrue(); assertThat(verifyStatus).isEqualTo(HgVerifier.HgVerifyStatus.VALID);
} }
private Path findHg() { private Path findHg() {
@@ -106,48 +106,59 @@ class HgVerifierTest {
return null; return null;
} }
private boolean verify(Path hg) { private HgVerifier.HgVerifyStatus verify(Path hg) {
HgGlobalConfig config = new HgGlobalConfig(); HgGlobalConfig config = new HgGlobalConfig();
config.setHgBinary(hg.toString()); config.setHgBinary(hg.toString());
return verifier.isValid(config); return verifier.verify(config);
} }
} }
@ParameterizedTest @ParameterizedTest
@ValueSource(strings = { "3-2-1", "x.y.z", "3.2.0" }) @ValueSource(strings = { "3-2-1", "x.y.z" })
void shouldReturnFalseForInvalidVersions(String version, @TempDir Path directory) throws IOException { void shouldReturnInvalidVersions(String version, @TempDir Path directory) throws IOException {
HgVerifier verifier = new HgVerifier(hg -> version); HgVerifier verifier = new HgVerifier(hg -> version);
Path hg = createHg(directory); Path hg = createHg(directory);
boolean isValid = verifier.isValid(hg); HgVerifier.HgVerifyStatus verifyStatus = verifier.verify(hg);
assertThat(isValid).isFalse(); assertThat(verifyStatus).isEqualTo(HgVerifier.HgVerifyStatus.INVALID_VERSION);
} }
@Test @Test
void shouldReturnFalseOnIOException(@TempDir Path directory) throws IOException { void shouldReturnTooOldVersion(@TempDir Path directory) throws IOException {
HgVerifier verifier = new HgVerifier(hg -> "3.2.1");
Path hg = createHg(directory);
HgVerifier.HgVerifyStatus verifyStatus = verifier.verify(hg);
assertThat(verifyStatus).isEqualTo(HgVerifier.HgVerifyStatus.VERSION_TOO_OLD);
}
@Test
void shouldReturnCouldNotResolveOnIOException(@TempDir Path directory) throws IOException {
HgVerifier verifier = new HgVerifier(hg -> { HgVerifier verifier = new HgVerifier(hg -> {
throw new IOException("failed"); throw new IOException("failed");
}); });
Path hg = createHg(directory); Path hg = createHg(directory);
boolean isValid = verifier.isValid(hg); HgVerifier.HgVerifyStatus verifyStatus = verifier.verify(hg);
assertThat(isValid).isFalse(); assertThat(verifyStatus).isEqualTo(HgVerifier.HgVerifyStatus.COULD_NOT_RESOLVE_VERSION);
} }
@Test @Test
void shouldReturnTrue(@TempDir Path directory) throws IOException { void shouldReturnValid(@TempDir Path directory) throws IOException {
HgVerifier verifier = new HgVerifier(hg -> "4.2.0"); HgVerifier verifier = new HgVerifier(hg -> "4.2.0");
Path hg = createHg(directory); Path hg = createHg(directory);
boolean isValid = verifier.isValid(hg); HgVerifier.HgVerifyStatus verifyStatus = verifier.verify(hg);
assertThat(isValid).isTrue(); assertThat(verifyStatus).isEqualTo(HgVerifier.HgVerifyStatus.VALID);
} }
@Nonnull @Nonnull

View File

@@ -135,7 +135,8 @@ class Configuration extends React.Component<Props, State> {
event.preventDefault(); event.preventDefault();
this.setState({ this.setState({
modifying: true modifying: true,
error: undefined
}); });
const { modifiedConfiguration } = this.state; const { modifiedConfiguration } = this.state;
@@ -182,9 +183,7 @@ class Configuration extends React.Component<Props, State> {
const { t } = this.props; const { t } = this.props;
const { fetching, error, configuration, modifying, valid } = this.state; const { fetching, error, configuration, modifying, valid } = this.state;
if (error) { if (fetching || !configuration) {
return <ErrorNotification error={error} />;
} else if (fetching || !configuration) {
return <Loading />; return <Loading />;
} else { } else {
const readOnly = this.isReadOnly(); const readOnly = this.isReadOnly();
@@ -200,6 +199,12 @@ class Configuration extends React.Component<Props, State> {
{this.renderConfigChangedNotification()} {this.renderConfigChangedNotification()}
<form onSubmit={this.modifyConfiguration}> <form onSubmit={this.modifyConfiguration}>
{this.props.render(renderProps)} {this.props.render(renderProps)}
{error && (
<>
<hr />
<ErrorNotification error={error} />
</>
)}
<hr /> <hr />
<Level <Level
right={<SubmitButton label={t("config.form.submit")} disabled={!valid || readOnly} loading={modifying} />} right={<SubmitButton label={t("config.form.submit")} disabled={!valid || readOnly} loading={modifying} />}