diff --git a/.github/workflows/pre-merge.yaml b/.github/workflows/pre-merge.yaml index 54bc17b..ffd0249 100644 --- a/.github/workflows/pre-merge.yaml +++ b/.github/workflows/pre-merge.yaml @@ -14,6 +14,8 @@ jobs: os: [ ubuntu-latest, macos-latest ] fail-fast: false runs-on: ${{ matrix.os }} + env: + SENTRY_URL: http://127.0.0.1:8000 steps: - name: Checkout Repo @@ -25,5 +27,7 @@ jobs: distribution: 'temurin' java-version: '17' - - name: Build the Release variant - run: ./mvnw clean verify -PintegrationTests + - name: Build the Release variant, run integration tests + run: | + test/integration-test-server-start.sh & + ./mvnw clean verify -PintegrationTests diff --git a/CHANGELOG.md b/CHANGELOG.md index f2c533f..d191ed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Fix `isSaas` check for telemetry ([#66](https://github.com/getsentry/sentry-maven-plugin/pull/66)) +- Escape spaces in paths ([#64](https://github.com/getsentry/sentry-maven-plugin/pull/64)) ### Features diff --git a/src/main/java/io/sentry/UploadSourceBundleMojo.java b/src/main/java/io/sentry/UploadSourceBundleMojo.java index 5701a9f..c855868 100644 --- a/src/main/java/io/sentry/UploadSourceBundleMojo.java +++ b/src/main/java/io/sentry/UploadSourceBundleMojo.java @@ -135,7 +135,8 @@ private void bundleSources( bundleSourcesCommand.add("debug-files"); bundleSourcesCommand.add("bundle-jvm"); - bundleSourcesCommand.add("--output=" + sourceBundleTargetDir.getAbsolutePath()); + bundleSourcesCommand.add( + "--output=" + cliRunner.escape(sourceBundleTargetDir.getAbsolutePath())); bundleSourcesCommand.add("--debug-id=" + bundleId); if (org != null) { bundleSourcesCommand.add("--org=" + org); @@ -143,7 +144,7 @@ private void bundleSources( if (project != null) { bundleSourcesCommand.add("--project=" + project); } - bundleSourcesCommand.add(sourceRoot); + bundleSourcesCommand.add(cliRunner.escape(sourceRoot)); cliRunner.runSentryCli(String.join(" ", bundleSourcesCommand), true); } else { @@ -190,7 +191,7 @@ private void uploadSourceBundle( if (project != null) { command.add("--project=" + project); } - command.add(sourceBundleTargetDir.getAbsolutePath()); + command.add(cliRunner.escape(sourceBundleTargetDir.getAbsolutePath())); cliRunner.runSentryCli(String.join(" ", command), true); } catch (Throwable t) { diff --git a/src/main/java/io/sentry/cli/SentryCliRunner.java b/src/main/java/io/sentry/cli/SentryCliRunner.java index 9a9902a..b0ffbce 100644 --- a/src/main/java/io/sentry/cli/SentryCliRunner.java +++ b/src/main/java/io/sentry/cli/SentryCliRunner.java @@ -42,8 +42,7 @@ public SentryCliRunner( public @Nullable String runSentryCli( final @NotNull String sentryCliCommand, final boolean failOnError) throws MojoExecutionException { - final boolean isWindows = - System.getProperty("os.name").toLowerCase(Locale.ROOT).startsWith("windows"); + final boolean isWindows = isWindows(); final @NotNull String executable = isWindows ? "cmd.exe" : "/bin/sh"; final @NotNull String cArg = isWindows ? "/c" : "-c"; @@ -65,16 +64,17 @@ public SentryCliRunner( attributes( attribute("executable", executable), attribute("failOnError", String.valueOf(failOnError)), - attribute("output", logFile.getAbsolutePath())), + attribute("output", escape(logFile.getAbsolutePath()))), element(name("arg"), attributes(attribute("value", cArg))), element( name("arg"), attributes( attribute( "value", - getCliPath(mavenProject, sentryCliExecutablePath) - + " " - + sentryCliCommand)))))), + wrapForWindows( + escape(getCliPath(mavenProject, sentryCliExecutablePath)) + + " " + + sentryCliCommand))))))), executionEnvironment(mavenProject, mavenSession, pluginManager)); return collectAndMaybePrintOutput(logFile, debugSentryCli); @@ -92,6 +92,38 @@ public SentryCliRunner( } } + private static boolean isWindows() { + final boolean isWindows = + System.getProperty("os.name").toLowerCase(Locale.ROOT).startsWith("windows"); + return isWindows; + } + + private @Nullable String wrapForWindows(final @Nullable String toWrap) { + // Wrap whole command in double quotes as Windows cmd will remove the first and last double + // quote + if (toWrap != null && isWindows()) { + return "\"" + toWrap + "\""; + } else { + return toWrap; + } + } + + public @Nullable String escape(final @Nullable String escapePath) { + if (escapePath == null) { + return null; + } + if (isWindows()) { + // Wrap paths that contain a whitespace in double quotes + // For some reason wrapping paths that do not contain a whitespace leads to an error + if (escapePath.contains(" ")) { + return "\"" + escapePath + "\""; + } + return escapePath; + } else { + return escapePath.replaceAll(" ", "\\\\ "); + } + } + private @Nullable String collectAndMaybePrintOutput( final @NotNull File logFile, final boolean shouldPrint) { try { diff --git a/src/test/java/io/sentry/integration/cli/PomUtils.kt b/src/test/java/io/sentry/integration/cli/PomUtils.kt new file mode 100644 index 0000000..755ac3f --- /dev/null +++ b/src/test/java/io/sentry/integration/cli/PomUtils.kt @@ -0,0 +1,66 @@ +package io.sentry.integration.cli + +fun basePom( + skipPlugin: Boolean = false, + skipSourceBundle: Boolean = false, + sentryCliPath: String? = null, +): String { + return """ + + 4.0.0 + + io.sentry.maven + cli-tests + 1.0-SNAPSHOT + + jar + + + 11 + 11 + + + + + com.graphql-java + graphql-java + 2.0.0 + + + io.sentry + sentry-graphql + 6.32.0 + + + + + + + io.sentry + sentry-maven-plugin + 1.0-SNAPSHOT + true + + true + $skipPlugin + $skipSourceBundle + true + sentry-sdks + sentry-maven + \<token\> + ${if (sentryCliPath.isNullOrBlank()) "" else "$sentryCliPath"} + + + + + uploadSourceBundle + + + + + + + + """.trimIndent() +} diff --git a/src/test/java/io/sentry/integration/cli/SentryCliWhitespacesTestIT.kt b/src/test/java/io/sentry/integration/cli/SentryCliWhitespacesTestIT.kt new file mode 100644 index 0000000..bccfaac --- /dev/null +++ b/src/test/java/io/sentry/integration/cli/SentryCliWhitespacesTestIT.kt @@ -0,0 +1,110 @@ +package io.sentry.integration.cli + +import io.sentry.SentryCliProvider +import io.sentry.integration.installMavenWrapper +import org.apache.maven.it.VerificationException +import org.apache.maven.it.Verifier +import org.apache.maven.project.MavenProject +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.nio.charset.Charset +import java.nio.file.Files +import java.nio.file.StandardOpenOption +import java.util.Properties +import kotlin.io.path.Path +import kotlin.io.path.absolutePathString +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SentryCliWhitespacesTestIT { + @TempDir() + lateinit var file: File + + fun getPOM( + baseDir: File, + skipPlugin: Boolean = false, + skipSourceBundle: Boolean = false, + sentryCliPath: String? = null, + ): String { + val pomContent = basePom(skipPlugin, skipSourceBundle, sentryCliPath) + + Files.write(Path("${baseDir.absolutePath}/pom.xml"), pomContent.toByteArray(), StandardOpenOption.CREATE) + + return baseDir.absolutePath + } + + @Test + @Throws(VerificationException::class, IOException::class) + fun sentryCliExecutionInProjectPathWithSpaces() { + val baseDir = setupProject() + val path = getPOM(baseDir) + val verifier = Verifier(path) + verifier.isAutoclean = false + verifier.executeGoal("install") + verifier.verifyErrorFreeLog() + + val output = verifier.loadLines(verifier.logFileName, Charset.defaultCharset().name()).joinToString("\n") + + val uploadedId = getUploadedBundleIdFromLog(output) + val bundleId = getBundleIdFromProperties(baseDir.absolutePath) + + assertEquals(bundleId, uploadedId, "Bundle ID from properties file should match the one from the log") + + verifier.resetStreams() + } + + @Test + @Throws(VerificationException::class, IOException::class) + fun sentryCliExecutionInProjectAndCliPathWithSpaces() { + val cliPath = SentryCliProvider.getCliPath(MavenProject(), null) + val baseDir = setupProject() + val cliPathWithSpaces = Files.copy(Path(cliPath), Path(baseDir.absolutePath, "sentry-cli")) + cliPathWithSpaces.toFile().setExecutable(true) + + val path = getPOM(baseDir, sentryCliPath = cliPathWithSpaces.absolutePathString()) + + val verifier = Verifier(path) + verifier.isAutoclean = false + verifier.executeGoal("install") + verifier.verifyErrorFreeLog() + + val output = verifier.loadLines(verifier.logFileName, Charset.defaultCharset().name()).joinToString("\n") + + val uploadedId = getUploadedBundleIdFromLog(output) + val bundleId = getBundleIdFromProperties(baseDir.absolutePath) + + assertEquals(bundleId, uploadedId, "Bundle ID from properties file should match the one from the log") + + verifier.resetStreams() + } + + private fun setupProject(): File { + val baseDir = File(file, "base with spaces") + val srcDir = File(baseDir, "/src/main/java") + val srcFile = File(srcDir, "Main.java") + val baseDirResult = baseDir.mkdir() + val srcDirResult = srcDir.mkdirs() + val srcFileResult = srcFile.createNewFile() + + assertTrue(baseDirResult, "Error creating base directory") + assertTrue(srcDirResult, "Error creating source directory") + assertTrue(srcFileResult, "Error creating source file") + installMavenWrapper(baseDir, "3.8.6") + + return baseDir + } + + private fun getUploadedBundleIdFromLog(output: String): String? { + val uploadedIdRegex = """\w+":\{"state":"ok","missingChunks":\[],"uploaded_id":"(\w+-\w+-\w+-\w+-\w+)""".toRegex() + return uploadedIdRegex.find(output)?.groupValues?.get(1) + } + + private fun getBundleIdFromProperties(baseDir: String): String { + val myProps = Properties() + myProps.load(FileInputStream("$baseDir/target/sentry/properties/sentry-debug-meta.properties")) + return myProps.getProperty("io.sentry.bundle-ids") + } +} diff --git a/test/assets/artifact.json b/test/assets/artifact.json new file mode 100644 index 0000000..43bce35 --- /dev/null +++ b/test/assets/artifact.json @@ -0,0 +1,10 @@ +{ + "id": "fixture-id", + "sha1": "fixture-sha1", + "name": "fixture-name", + "size": 1, + "dist": null, + "headers": { + "fixture-header-key": "fixture-header-value" + } +} diff --git a/test/assets/artifacts.json b/test/assets/artifacts.json new file mode 100644 index 0000000..a7d5237 --- /dev/null +++ b/test/assets/artifacts.json @@ -0,0 +1,22 @@ +[ + { + "id": "6796495645", + "name": "~/dist/bundle.min.js", + "dist": "foo", + "headers": { + "Sourcemap": "dist/bundle.min.js.map" + }, + "size": 497, + "sha1": "2fb719956748ab7ec5ae9bcb47606733f5589b72", + "dateCreated": "2022-05-12T11:08:01.520199Z" + }, + { + "id": "6796495646", + "name": "~/dist/bundle.min.js.map", + "dist": "foo", + "headers": {}, + "size": 1522, + "sha1": "f818059cbf617a8fae9b4e46d08f6c0246bb1624", + "dateCreated": "2022-05-12T11:08:01.496220Z" + } +] \ No newline at end of file diff --git a/test/assets/assemble-artifacts-response.json b/test/assets/assemble-artifacts-response.json new file mode 100644 index 0000000..0c71ed6 --- /dev/null +++ b/test/assets/assemble-artifacts-response.json @@ -0,0 +1,5 @@ +{ + "state": "ok", + "missingChunks": [], + "detail": null +} diff --git a/test/assets/associate-dsyms-response.json b/test/assets/associate-dsyms-response.json new file mode 100644 index 0000000..6b7f7f4 --- /dev/null +++ b/test/assets/associate-dsyms-response.json @@ -0,0 +1,15 @@ +{ + "associatedDsymFiles": [ + { + "uuid": null, + "debugId": null, + "objectName": "fixture-objectName", + "cpuName": "fixture-cpuName", + "sha1": "fixture-sha1", + "data": { + "type": null, + "features": ["fixture-feature"] + } + } + ] +} diff --git a/test/assets/debug-info-files.json b/test/assets/debug-info-files.json new file mode 100644 index 0000000..7ef5e52 --- /dev/null +++ b/test/assets/debug-info-files.json @@ -0,0 +1,13 @@ +[ + { + "uuid": null, + "debugId": null, + "objectName": "fixture-objectName", + "cpuName": "fixture-cpuName", + "sha1": "fixture-sha1", + "data": { + "type": null, + "features": ["fixture-feature"] + } + } +] \ No newline at end of file diff --git a/test/assets/deploy.json b/test/assets/deploy.json new file mode 100644 index 0000000..39d8169 --- /dev/null +++ b/test/assets/deploy.json @@ -0,0 +1,8 @@ +{ + "id": "1", + "environment": "production", + "dateStarted": null, + "dateFinished": "2022-01-01T12:00:00.000000Z", + "name": "fixture-deploy", + "url": null +} diff --git a/test/assets/release.json b/test/assets/release.json new file mode 100644 index 0000000..27af305 --- /dev/null +++ b/test/assets/release.json @@ -0,0 +1,44 @@ +{ + "dateReleased": "2022-01-01T12:00:00.000000Z", + "newGroups": 0, + "commitCount": 0, + "url": null, + "data": + {}, + "lastDeploy": null, + "deployCount": 0, + "dateCreated": "2022-01-01T12:00:00.000000Z", + "lastEvent": null, + "version": "1.1.0", + "firstEvent": null, + "lastCommit": null, + "shortVersion": "1.1.0", + "authors": + [], + "owner": null, + "versionInfo": + { + "buildHash": null, + "version": + { + "raw": "1.1.0" + }, + "description": "1.1.0", + "package": null + }, + "ref": null, + "projects": + [ + { + "name": "Sentry Fastlane App", + "platform": "ruby", + "slug": "sentry-fastlane-plugin", + "platforms": + [ + "ruby" + ], + "newGroups": 0, + "id": 1234567 + } + ] +} diff --git a/test/assets/repos.json b/test/assets/repos.json new file mode 100644 index 0000000..998b89b --- /dev/null +++ b/test/assets/repos.json @@ -0,0 +1,13 @@ +[ + { + "id": "1", + "name": "sentry/sentry-fastlane-plugin", + "url": null, + "provider": { + "id": "1", + "name": "fixture-name" + }, + "status": "fixture-status", + "dateCreated": "2022-01-01T12:00:00.000000Z" + } +] diff --git a/test/integration-test-server-start.sh b/test/integration-test-server-start.sh new file mode 100755 index 0000000..9fc54bf --- /dev/null +++ b/test/integration-test-server-start.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +python3 test/integration-test-server.py diff --git a/test/integration-test-server-stop.sh b/test/integration-test-server-stop.sh new file mode 100755 index 0000000..a9168ed --- /dev/null +++ b/test/integration-test-server-stop.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +curl http://127.0.0.1:8000/STOP diff --git a/test/integration-test-server.py b/test/integration-test-server.py new file mode 100644 index 0000000..1ee7c94 --- /dev/null +++ b/test/integration-test-server.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 + +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse +import sys +import threading +import binascii +import json + +apiOrg = 'sentry-sdks' +apiProject = 'sentry-maven' +uri = urlparse(sys.argv[1] if len(sys.argv) > 1 else 'http://127.0.0.1:8000') +version='1.1.0' +appIdentifier='com.sentry.fastlane.app' + +class Handler(BaseHTTPRequestHandler): + body = None + + def do_GET(self): + self.start_response(HTTPStatus.OK) + + if self.path == "/STOP": + print("HTTP server stopping!") + threading.Thread(target=self.server.shutdown).start() + return + + if self.isApi('api/0/organizations/{}/chunk-upload/'.format(apiOrg)): + self.writeJSON('{"url":"' + uri.geturl() + self.path + '",' + '"chunkSize":8388608,"chunksPerRequest":64,"maxFileSize":2147483648,' + '"maxRequestSize":33554432,"concurrency":1,"hashAlgorithm":"sha1","compression":["gzip"],' + '"accept":["debug_files","release_files","pdbs","sources","bcsymbolmaps"]}') + elif self.isApi('/api/0/organizations/{}/repos/?cursor='.format(apiOrg)): + self.writeJSONFile("test/assets/repos.json") + elif self.isApi('/api/0/organizations/{}/releases/{}/previous-with-commits/'.format(apiOrg, version)): + self.writeJSONFile("test/assets/release.json") + elif self.isApi('/api/0/projects/{}/{}/releases/{}/files/?cursor='.format(apiOrg, apiProject, version)): + self.writeJSONFile("test/assets/artifacts.json") + else: + self.end_headers() + + self.flushLogs() + + def do_POST(self): + if self.isApi('/api/0/projects/{}/{}/files/proguard-artifact-releases/'.format(apiOrg, apiProject)): + self.start_response(HTTPStatus.NOT_FOUND) + else: + self.start_response(HTTPStatus.OK) + + if self.isApi('api/0/projects/{}/{}/files/difs/assemble/'.format(apiOrg, apiProject)): + # Request body example: + # { + # "9a01653a...":{"name":"UnityPlayer.dylib","debug_id":"eb4a7644-...","chunks":["f84d3907945cdf41b33da8245747f4d05e6ffcb4", ...]}, + # "4185e454...":{"name":"UnityPlayer.dylib","debug_id":"86d95b40-...","chunks":[...]} + # } + # Response body to let the CLI know we have the symbols already (we don't need to test the actual upload): + # { + # "9a01653a...":{"state":"ok","missingChunks":[]}, + # "4185e454...":{"state":"ok","missingChunks":[]} + # } + jsonRequest = json.loads(self.body) + jsonResponse = '{' + for key, value in jsonRequest.items(): + jsonResponse += '"{}":{{"state":"ok","missingChunks":[],"uploaded_id":"{}"}},'.format( + key, value['debug_id']) + self.log_message('Received: %40s %40s %s', key, + value['debug_id'], value['name']) + jsonResponse = jsonResponse.rstrip(',') + '}' + self.writeJSON(jsonResponse) + elif self.isApi('api/0/projects/{}/{}/releases/'.format(apiOrg, apiProject)): + self.writeJSONFile("test/assets/release.json") + elif self.isApi('/api/0/organizations/{}/releases/{}@{}/deploys/'.format(apiOrg, appIdentifier, version)): + self.writeJSONFile("test/assets/deploy.json") + elif self.isApi('/api/0/projects/{}/{}/releases/{}@{}/files/'.format(apiOrg, apiProject, appIdentifier, version)): + self.writeJSONFile("test/assets/artifact.json") + elif self.isApi('/api/0/organizations/{}/releases/{}/assemble/'.format(apiOrg, version)): + self.writeJSONFile("test/assets/assemble-artifacts-response.json") + elif self.isApi('/api/0/projects/{}/{}/files/dsyms/'.format(apiOrg, apiProject)): + self.writeJSONFile("test/assets/debug-info-files.json") + elif self.isApi('/api/0/projects/{}/{}/files/dsyms/associate/'.format(apiOrg, apiProject)): + self.writeJSONFile("test/assets/associate-dsyms-response.json") + else: + self.end_headers() + + self.flushLogs() + + def do_PUT(self): + self.start_response(HTTPStatus.OK) + + if self.isApi('/api/0/organizations/{}/releases/{}/'.format(apiOrg, version)): + self.writeJSONFile("test/assets/release.json") + else: + self.end_headers() + + self.flushLogs() + + def start_response(self, code): + self.body = None + self.log_request(code) + self.send_response_only(code) + + def log_request(self, code=None, size=None): + if isinstance(code, HTTPStatus): + code = code.value + body = self.body = self.requestBody() + if body: + body = self.body[0:min(1000, len(body))] + self.log_message('"%s" %s %s%s', + self.requestline, str(code), "({} bytes)".format(size) if size else '', body) + + # Note: this may only be called once during a single request - can't `.read()` the same stream again. + def requestBody(self): + if self.command == "POST" and 'Content-Length' in self.headers: + length = int(self.headers['Content-Length']) + content = self.rfile.read(length) + try: + return content.decode("utf-8") + except: + return binascii.hexlify(bytearray(content)) + return None + + def isApi(self, api: str): + if self.path.strip('/') == api.strip('/'): + self.log_message("Matched API endpoint {}".format(api)) + return True + return False + + def writeJSONFile(self, file_name: str): + json_file = open(file_name, "r") + self.writeJSON(json_file.read()) + json_file.close() + + def writeJSON(self, string: str): + self.send_header("Content-type", "application/json") + self.end_headers() + self.wfile.write(str.encode(string)) + + def flushLogs(self): + sys.stdout.flush() + sys.stderr.flush() + + +print("HTTP server listening on {}".format(uri.geturl())) +print("To stop the server, execute a GET request to {}/STOP".format(uri.geturl())) +httpd = ThreadingHTTPServer((uri.hostname, uri.port), Handler) +target = httpd.serve_forever()