diff --git a/README.md b/README.md index ed158d5..54f5fcb 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,26 @@ There is no way to send events without it short of turning reporting off; that is deliberate, so "how many servers" is a number that can be trusted. **Programs that are not plugins.** Without `serverWideConfig(...)`, no ID is -made up and no hidden file is written anywhere. A program that wants to be -counted passes one it stores itself, e.g. a UUID it generated on first run -and keeps in its own config: +made up and no hidden file is written anywhere unless the program asks for +one. Since 0.6.0 a program can name a file to keep the ID in — the program +chooses it; there is no default location: + +```java +TraceClient trace = TraceClient.builder(url, "mycli", version).key(key) + .enabled(settings.usageReportingEnabled()) + .installIdFile(new File(dataDir, "trace-install-id")) // e.g. ~/.local/share/mycli/ + .build(); +``` + +The first time an *enabled* client starts, it writes a new random UUID to +that file (creating parent directories) and reuses it on every later run. +The first line that is an ID (`[A-Za-z0-9_.-]`, at most 255 characters) is +the one used. If the file cannot be read or written, a fresh ID is used in +memory for that run only — `build()` never throws over it, and a file that +exists but cannot be read is never overwritten. Delete the file to reset it, +or put your own value on its first line. + +A program that already keeps its own settings can pass the ID instead: ```java TraceClient.builder(url, "MyCli", version).key(key) @@ -99,9 +116,18 @@ TraceClient.builder(url, "MyCli", version).key(key) .build(); ``` -An explicit `installId(...)` wins over `server-id:`. An event that passes its -own `install` tag keeps it. `trace.installId()` returns the ID in use (`null` -when disabled or when there is none), so a program can print it. +**Precedence.** An explicit `installId(...)` wins over `installIdFile(...)`, +which wins over the server-wide `server-id:` (the server-wide file then gains +no `server-id:` line; its `enabled:` switch and `tags:` still apply). An +event that passes its own `install` tag keeps it. `trace.installId()` returns +the ID in use (`null` when disabled or when there is none), so a program can +print it. + +`TraceClient.installIdFromFile(file)` is the same load-or-create step on its +own, for a program that wants the ID for something else. **Called directly, +it writes the file whatever the opt-outs say** — pass the file to +`installIdFile(...)` to keep the guarantee that a disabled client never +generates, reads or writes an ID. ## What `report` promises @@ -183,7 +209,7 @@ plugins already vendor bStats' `Metrics.java`. com.github.Stephenson-Software trace-client-java - 0.5.0 + 0.6.0 ``` diff --git a/pom.xml b/pom.xml index 414a666..4418c09 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ software.stephenson trace-client - 0.5.0 + 0.6.0 jar trace-client diff --git a/src/main/java/software/stephenson/trace/TraceClient.java b/src/main/java/software/stephenson/trace/TraceClient.java index dbd9132..348c20b 100644 --- a/src/main/java/software/stephenson/trace/TraceClient.java +++ b/src/main/java/software/stephenson/trace/TraceClient.java @@ -1,5 +1,5 @@ /* - * trace-client 0.5.0 -- https://github.com/Stephenson-Software/trace-client-java + * trace-client 0.6.0 -- https://github.com/Stephenson-Software/trace-client-java * * One call to report that a program was used. Copy this file into a project as * is, or depend on the artifact; either way there is nothing else to add. @@ -16,6 +16,7 @@ import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.Collections; @@ -85,10 +86,14 @@ * {@code plugins/trace/config.yml}, generated with * {@link UUID#randomUUID()} and appended to that file the first time an * enabled client finds none -- the same idea as bStats' {@code serverUuid}. - * Anything else may pass one with {@link Builder#installId(String)}; - * without either, no {@code install} tag is sent. The ID is random: it - * names no person, account or address. A disabled client never generates - * or writes one. An event's own {@code install} tag wins over it. + * Anything else may pass a file to keep it in with + * {@link Builder#installIdFile(File)}, or the ID itself with + * {@link Builder#installId(String)}. When more than one is given, the + * explicit {@code installId} wins, then {@code installIdFile}, then the + * server-wide {@code server-id:}; with none of them, no {@code install} tag + * is sent. The ID is random: it names no person, account or address. A + * disabled client never generates, reads or writes one. An event's own + * {@code install} tag wins over it. * *

A disabled client is a no-op that costs nothing. Programs that run on * other people's machines should expose their own switch in their @@ -119,7 +124,7 @@ public final class TraceClient { /** This client's version, as sent in the User-Agent. */ - public static final String VERSION = "0.5.0"; + public static final String VERSION = "0.6.0"; /** How many reports may wait to be sent before new ones are dropped. */ public static final int QUEUE_CAPACITY = 256; @@ -173,6 +178,9 @@ public final class TraceClient { /** The tag every event carries the installation's ID as. */ static final String INSTALL_TAG = "install"; + /** What {@link #installIdFromFile(File)} accepts as an ID on a line of its file. */ + private static final Pattern INSTALL_ID_LINE = Pattern.compile("[A-Za-z0-9_.\\-]{1,255}"); + private static final Pattern ENABLED_LINE = Pattern.compile("^\\s*enabled\\s*:\\s*(\\S+)"); private static final Pattern TAGS_LINE = Pattern.compile("^tags\\s*:\\s*(#.*)?$"); private static final Pattern SERVER_ID_LINE = Pattern.compile("^server-id\\s*:(.*)$"); @@ -263,16 +271,18 @@ public String disabledReason() { /** * The random per-installation ID every event carries as the tag * {@code install}, or {@code null} when the client is disabled or has - * none (no {@link Builder#serverWideConfig(File)} and no - * {@link Builder#installId(String)}). + * none (no {@link Builder#installId(String)}, no + * {@link Builder#installIdFile(File)} and no + * {@link Builder#serverWideConfig(File)}). */ public String installId() { return installId; } /** - * The installation's ID: the builder's, else the server-wide file's - * {@code server-id:}, else a new random one appended to that file. If the + * The installation's ID: the builder's, else the one kept in the + * builder's install ID file, else the server-wide file's + * {@code server-id:}, else a new random one appended to that file. If a * file could not be read or written, the new ID lives in memory for this * process only. Never throws. */ @@ -280,6 +290,9 @@ private String resolveInstallId(Builder builder, ServerWideConfig serverWide) { if (builder.installId != null) { return builder.installId; } + if (builder.installIdFile != null) { + return loadOrCreateInstallId(builder.installIdFile, logger); + } if (builder.pluginsDirectory == null) { return null; } @@ -316,6 +329,63 @@ private String resolveInstallId(Builder builder, ServerWideConfig serverWide) { } } + /** + * The installation's ID kept in {@code file}, which the program chooses + * -- there is no default location. The first line that is an ID + * ({@code [A-Za-z0-9_.-]}, at most {@value #MAX_TAG_LENGTH} characters, + * surrounding whitespace ignored) is returned. If the file does not exist + * or holds no such line, a new random {@link UUID#randomUUID()} is written + * to it (parent directories created) and returned. Delete the file to get + * a new one. + * + *

Never throws: if the file exists but cannot be read, or cannot be + * written, a fresh random ID is returned for this process only, and an + * unreadable file is left as it is. + * + *

Called directly, this writes whatever the opt-outs say. Pass + * the file to {@link Builder#installIdFile(File)} instead to keep the + * guarantee that a disabled client writes nothing. + */ + public static String installIdFromFile(File file) { + return loadOrCreateInstallId(file, null); + } + + private static String loadOrCreateInstallId(File file, Logger logger) { + String fresh = UUID.randomUUID().toString(); + if (file == null || file.getPath().trim().isEmpty()) { + return fresh; + } + Path path; + try { + // Inside the try: toPath() throws InvalidPathException for a path + // the file system cannot represent. + path = file.toPath(); + for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) { + String candidate = line.trim(); + if (INSTALL_ID_LINE.matcher(candidate).matches()) { + return candidate; + } + } + } catch (NoSuchFileException missing) { + path = file.toPath(); // it converted, or there would be no NoSuchFileException + } catch (IOException | RuntimeException failure) { + // There but unreadable (a directory, no permission, not UTF-8): + // never overwrite it. + log(logger, "could not read install ID file " + file + ", using an in-memory one: " + failure); + return fresh; + } + try { + Path parent = path.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.write(path, (fresh + "\n").getBytes(StandardCharsets.UTF_8)); + } catch (IOException | RuntimeException failure) { + log(logger, "could not write install ID file " + file + ", using an in-memory one: " + failure); + } + return fresh; + } + private static String disabledReason(Builder builder, ServerWideConfig serverWide) { if (environmentDisables()) { return REASON_ENVIRONMENT; @@ -714,6 +784,10 @@ private static void drain(InputStream stream) throws IOException { } private void log(String message) { + log(logger, message); + } + + private static void log(Logger logger, String message) { if (logger != null) { logger.log(Level.FINE, "[trace] " + message); } @@ -778,6 +852,7 @@ public static final class Builder { private boolean enabled = true; private File pluginsDirectory; private String installId; + private File installIdFile; private Logger logger; private Builder(String baseUrl, String application, String version) { @@ -845,8 +920,9 @@ public Builder serverWideConfig(File pluginsDirectory) { * server can count installations. It should be random -- e.g. a * {@link UUID#randomUUID()} the program stores in its own * configuration -- and never derived from a person, account or - * address. Takes precedence over the server-wide {@code server-id:}. - * Trimmed; {@code null} or blank means none. Without it, and without + * address. Takes precedence over {@link #installIdFile(File)} and the + * server-wide {@code server-id:}. Trimmed; {@code null} or blank means + * none. Without it, {@link #installIdFile(File)} or * {@link #serverWideConfig(File)}, no {@code install} tag is sent. * * @throws IllegalArgumentException when longer than {@value TraceClient#MAX_TAG_LENGTH} characters @@ -860,6 +936,26 @@ public Builder installId(String installId) { return this; } + /** + * A file to keep the installation's ID in, for programs that are not + * Spigot plugins -- e.g. {@code //trace-install-id}. + * {@link #build()} resolves it with {@link TraceClient#installIdFromFile(File)} + * only when the client is enabled, after every opt-out: the first + * run writes a new random UUID there (creating parent directories) and + * later runs reuse it. A file that cannot be read or written yields an + * in-memory ID for this process; an unreadable one is never + * overwritten; {@code build()} still never throws. + * + *

Precedence: an explicit {@link #installId(String)} wins over this, + * and this wins over the server-wide {@code server-id:} of + * {@link #serverWideConfig(File)} (whose file then gains no + * {@code server-id:} line). {@code null} means none. + */ + public Builder installIdFile(File installIdFile) { + this.installIdFile = installIdFile; + return this; + } + /** Where dropped reports are mentioned, at {@link Level#FINE}. Optional. */ public Builder logger(Logger logger) { this.logger = logger; diff --git a/src/test/java/software/stephenson/trace/TraceClientTest.java b/src/test/java/software/stephenson/trace/TraceClientTest.java index 4203cec..9b30939 100644 --- a/src/test/java/software/stephenson/trace/TraceClientTest.java +++ b/src/test/java/software/stephenson/trace/TraceClientTest.java @@ -1172,6 +1172,163 @@ void installId_anEventsOwnInstallTagWins(@TempDir Path plugins) throws Exception reportedBody(plugins, "startup", null)); } + @Test + void installIdFile_isCreatedOnceWithParentDirectoriesAndReused(@TempDir Path scratch) throws Exception { + // Arrange + Path file = scratch.resolve("nested").resolve("deeper").resolve("trace-install-id"); + + // Act + TraceClient first = TraceClient.builder(baseUrl(), "MyCli", "1.2.3").key("k").installIdFile(file.toFile()).build(); + first.close(); + TraceClient second = TraceClient.builder(baseUrl(), "MyCli", "1.2.3").key("k").installIdFile(file.toFile()).build(); + second.report("startup"); + + // Assert + assertTrue(arrived.await(5, TimeUnit.SECONDS)); + second.close(); + String id = first.installId(); + assertTrue(id.matches(UUID_PATTERN), id); + assertEquals(id + "\n", new String(Files.readAllBytes(file), StandardCharsets.UTF_8), "parent directories are created"); + assertEquals(id, second.installId(), "the next run reuses it"); + assertEquals("{\"application\":\"MyCli\",\"name\":\"startup\",\"tags\":{\"version\":\"1.2.3\",\"install\":\"" + id + "\"}}", + received.get(0).body); + } + + @Test + void installIdFromFile_readsTheFirstValidLineAndNeverRewritesTheFile(@TempDir Path scratch) throws Exception { + Path file = scratch.resolve("trace-install-id"); + String content = "\n# not an id\n my-own.id_1 \nsecond-id\n"; + Files.write(file, content.getBytes(StandardCharsets.UTF_8)); + + assertEquals("my-own.id_1", TraceClient.installIdFromFile(file.toFile())); + assertEquals(content, new String(Files.readAllBytes(file), StandardCharsets.UTF_8)); + } + + @Test + void installIdFromFile_replacesAFileWithNoValidLine(@TempDir Path scratch) throws Exception { + Path file = scratch.resolve("trace-install-id"); + StringBuilder overlong = new StringBuilder(); + for (int i = 0; i <= TraceClient.MAX_TAG_LENGTH; i++) { + overlong.append('x'); + } + Files.write(file, ("not an id\n" + overlong + "\n").getBytes(StandardCharsets.UTF_8)); + + String made = TraceClient.installIdFromFile(file.toFile()); + + assertTrue(made.matches(UUID_PATTERN), made); + assertEquals(made, TraceClient.installIdFromFile(file.toFile())); + assertEquals(made + "\n", new String(Files.readAllBytes(file), StandardCharsets.UTF_8)); + } + + @Test + void installIdFile_unwritableYieldsAnInMemoryIdWithoutThrowing(@TempDir Path scratch) throws Exception { + // Arrange: a path under a regular file cannot be created, even as root. + Path blocker = scratch.resolve("a-file"); + Files.write(blocker, "x".getBytes(StandardCharsets.UTF_8)); + Path file = blocker.resolve("trace-install-id"); + Logger logger = Logger.getAnonymousLogger(); + logger.setUseParentHandlers(false); + logger.setLevel(Level.FINE); + RecordingHandler handler = new RecordingHandler(); + logger.addHandler(handler); + + // Act + TraceClient client = assertDoesNotThrow(() -> TraceClient.builder(baseUrl(), "MyCli", "1.2.3").key("k") + .installIdFile(file.toFile()).logger(logger).build()); + client.report("startup"); + + // Assert + assertTrue(arrived.await(5, TimeUnit.SECONDS)); + client.close(); + assertTrue(client.isEnabled()); + assertTrue(client.installId().matches(UUID_PATTERN), client.installId()); + assertFalse(Files.exists(file)); + assertEquals("x", new String(Files.readAllBytes(blocker), StandardCharsets.UTF_8)); + assertNotEquals(client.installId(), TraceClient.installIdFromFile(file.toFile()), "in memory: a new one each process"); + assertTrue(received.get(0).body.contains("\"install\":\"" + client.installId() + "\""), received.get(0).body); + assertTrue(handler.records.stream().anyMatch(r -> r.getLevel() == Level.FINE && r.getMessage().contains("install ID file")), + "the fallback is logged at FINE"); + } + + @Test + void installIdFromFile_leavesAnUnreadableFileAlone(@TempDir Path scratch) throws Exception { + // A directory cannot be read as a file. + Path directory = Files.createDirectory(scratch.resolve("dir")); + + String made = TraceClient.installIdFromFile(directory.toFile()); + + assertTrue(made.matches(UUID_PATTERN), made); + assertTrue(Files.isDirectory(directory)); + try (java.util.stream.Stream entries = Files.list(directory)) { + assertEquals(0, entries.count()); + } + } + + @Test + void installIdFromFile_neverThrowsForABadPath() { + for (File file : new File[] {null, new File(""), new File(" "), new File("bad\u0000name")}) { + String made = assertDoesNotThrow(() -> TraceClient.installIdFromFile(file)); + assertTrue(made.matches(UUID_PATTERN), String.valueOf(file)); + } + } + + @Test + void installIdFile_aDisabledClientNeverReadsOrWritesIt(@TempDir Path scratch) throws Exception { + Path directory = Files.createDirectory(scratch.resolve("data")); + File file = directory.resolve("trace-install-id").toFile(); + + environment.put("DO_NOT_TRACK", "1"); + TraceClient byEnvironment = TraceClient.builder(baseUrl(), "MyCli", "1.2.3").key("k").installIdFile(file).build(); + environment.clear(); + environment.put("TRACE_USAGE_REPORTING", "off"); + TraceClient byVariable = TraceClient.builder(baseUrl(), "MyCli", "1.2.3").key("k").installIdFile(file).build(); + environment.clear(); + TraceClient byConfig = TraceClient.builder(baseUrl(), "MyCli", "1.2.3").key("k").enabled(false).installIdFile(file).build(); + TraceClient byNoKey = TraceClient.builder(baseUrl(), "MyCli", "1.2.3").installIdFile(file).build(); + + for (TraceClient client : Arrays.asList(byEnvironment, byVariable, byConfig, byNoKey)) { + assertNull(client.installId(), client.disabledReason()); + } + try (java.util.stream.Stream entries = Files.list(directory)) { + assertEquals(0, entries.count(), "nothing is written"); + } + } + + @Test + void installIdFile_precedenceIsExplicitThenFileThenServerWide(@TempDir Path scratch) throws Exception { + Path plugins = scratch.resolve("plugins"); + Path idFile = scratch.resolve("trace-install-id"); + Path serverWide = plugins.resolve("trace").resolve("config.yml"); + + // An explicit ID wins over the file, which is then not consulted. + TraceClient explicit = TraceClient.builder(baseUrl(), "MyCli", "1.2.3").key("k") + .installId(" abc-123 ").installIdFile(idFile.toFile()).serverWideConfig(plugins.toFile()).build(); + explicit.close(); + assertEquals("abc-123", explicit.installId()); + assertFalse(Files.exists(idFile), "the file is not consulted when an ID is given"); + + // The file wins over the server-wide server-id, and the server-wide + // file gains no server-id line. + TraceClient fromFile = TraceClient.builder(baseUrl(), "MyCli", "1.2.3").key("k") + .installIdFile(idFile.toFile()).serverWideConfig(plugins.toFile()).build(); + fromFile.close(); + assertEquals(new String(Files.readAllBytes(idFile), StandardCharsets.UTF_8).trim(), fromFile.installId()); + assertEquals(TraceClient.SERVER_WIDE_CONFIG_CONTENT, new String(Files.readAllBytes(serverWide), StandardCharsets.UTF_8)); + + writeServerWideConfigExactly(plugins, "enabled: true\nserver-id: from-server-wide\n"); + TraceClient overServerId = TraceClient.builder(baseUrl(), "MyCli", "1.2.3").key("k") + .installIdFile(idFile.toFile()).serverWideConfig(plugins.toFile()).build(); + overServerId.close(); + assertEquals(fromFile.installId(), overServerId.installId()); + + // The server-wide switch still disables a client given a file. + writeServerWideConfigExactly(plugins, "enabled: false\n"); + Files.delete(idFile); + assertNull(TraceClient.builder(baseUrl(), "MyCli", "1.2.3").key("k") + .installIdFile(idFile.toFile()).serverWideConfig(plugins.toFile()).build().installId()); + assertFalse(Files.exists(idFile)); + } + /** * Writes {@code content} as the server-wide file, followed by a fixed * {@code server-id: test-server} line so the tag tests get a predictable