From 3fd7262f8adbd8995b178cbd50c3d30c5ac85008 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 11 Nov 2026 00:33:10 +0000 Subject: [PATCH] Give every 51Degrees environment variable the underscore name A POSIX shell cannot set a name that starts with a digit, so every name becomes _51DEGREES_ and the digit form is not read anywhere. The duplicate constants that held the two spellings of the resource key are reduced to one, and the guard test fails on any name in the digit form. --- AGENTS.md | 4 +- ci/run-integration-tests.ps1 | 2 +- examples/device-detection/README.md | 2 +- .../examples/web/GettingStartedWebOnPrem.java | 2 +- .../main/webapp/WEB-INF/51Degrees-OnPrem.xml | 4 +- .../web/OnPremDataFileOptionTest.java | 4 +- examples/ip-intelligence/README.md | 6 +- .../examples/console/OfflineProcessing.java | 2 +- .../examples/shared/DataFileHelper.java | 2 +- .../examples/shared/KeyHelper.java | 19 +- .../examples/shared/KeyHelperTest.java | 14 +- ip-intelligence/README.md | 4 +- .../shared/testhelpers/FileUtils.java | 2 +- .../shared/testhelpers/KeyUtils.java | 2 +- .../CountryTranslationIntegrationTest.java | 4 +- .../EnvironmentVariableNamesTest.java | 219 ++++++++++++++++++ 16 files changed, 253 insertions(+), 39 deletions(-) create mode 100644 pipeline/pipeline.common/src/test/java/fiftyone/common/testhelpers/EnvironmentVariableNamesTest.java diff --git a/AGENTS.md b/AGENTS.md index 971eb5d05..7a0e7b924 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,7 +125,7 @@ java -cp examples/ip-intelligence/console/target/ip-intelligence-java-examples.c ``` The example downloads the Lite file first when it is not there. In place of -the argument, the `51DEGREES_IPI_PATH` environment variable or system property +the argument, the `_51DEGREES_IPI_PATH` environment variable or system property can give the absolute path of a data file that is already there. On Java 24 or later, add `--enable-native-access=ALL-UNNAMED` before the class @@ -183,7 +183,7 @@ java -cp examples/device-detection/console/target/device-detection-java-examples It prints the same `Input values:` and `Results:` blocks as the on-premise example. The IP Intelligence cloud examples read their key from -`51DEGREES_RESOURCE_KEY` first and `TestResourceKey` second, and +`_51DEGREES_RESOURCE_KEY` first and `TestResourceKey` second, and `examples/ip-intelligence/README.md` gives the keys to create for them. ### Pitfalls diff --git a/ci/run-integration-tests.ps1 b/ci/run-integration-tests.ps1 index 99621cbd9..1eb386b2b 100644 --- a/ci/run-integration-tests.ps1 +++ b/ci/run-integration-tests.ps1 @@ -162,7 +162,7 @@ if (-not $TestResourceKey) { Module = 'web/getting-started.onprem' Port = 8098 ExampleEnv = @{ - '51DEGREES_DD_PATH' = (Resolve-Path "$RepoName/cxx/data/device-detection/TAC-HashV41.hash").Path + '_51DEGREES_DD_PATH' = (Resolve-Path "$RepoName/cxx/data/device-detection/TAC-HashV41.hash").Path } } ) diff --git a/examples/device-detection/README.md b/examples/device-detection/README.md index d908ca138..e3eee76d4 100644 --- a/examples/device-detection/README.md +++ b/examples/device-detection/README.md @@ -122,7 +122,7 @@ case the example runs until the process is stopped: PORT=8100 java -jar ./web/getting-started.onprem/target/device-detection-java-examples.web.getting-started.onprem-4.4.20-jar-with-dependencies.jar ``` -The on-premise web example takes its data file from the `51DEGREES_DD_PATH` +The on-premise web example takes its data file from the `_51DEGREES_DD_PATH` environment variable, then from `TestDataFile`, and otherwise uses the free Lite file in `cxx/data/device-detection`, which it downloads before the server starts if it is missing. diff --git a/examples/device-detection/web/getting-started.onprem/src/main/java/fiftyone/devicedetection/examples/web/GettingStartedWebOnPrem.java b/examples/device-detection/web/getting-started.onprem/src/main/java/fiftyone/devicedetection/examples/web/GettingStartedWebOnPrem.java index 8387720c6..1ed151902 100644 --- a/examples/device-detection/web/getting-started.onprem/src/main/java/fiftyone/devicedetection/examples/web/GettingStartedWebOnPrem.java +++ b/examples/device-detection/web/getting-started.onprem/src/main/java/fiftyone/devicedetection/examples/web/GettingStartedWebOnPrem.java @@ -48,7 +48,7 @@ *

* The configuration file for the pipeline is at src/main/webapp/WEB-INF/51Degrees-OnPrem.xml *

- * The data file is taken from the 51DEGREES_DD_PATH environment variable, then + * The data file is taken from the _51DEGREES_DD_PATH environment variable, then * from TestDataFile (an environment variable or system property), and otherwise * the free Lite file in cxx/data/device-detection is used, which is downloaded * before the server starts if it is missing. diff --git a/examples/device-detection/web/getting-started.onprem/src/main/webapp/WEB-INF/51Degrees-OnPrem.xml b/examples/device-detection/web/getting-started.onprem/src/main/webapp/WEB-INF/51Degrees-OnPrem.xml index bba6dd60e..d3e90a762 100644 --- a/examples/device-detection/web/getting-started.onprem/src/main/webapp/WEB-INF/51Degrees-OnPrem.xml +++ b/examples/device-detection/web/getting-started.onprem/src/main/webapp/WEB-INF/51Degrees-OnPrem.xml @@ -28,11 +28,11 @@ false false - ${51DEGREES_DD_PATH:-${TestDataFile:-cxx/data/device-detection/51Degrees-LiteV4.1.hash}} + ${_51DEGREES_DD_PATH:-${TestDataFile:-cxx/data/device-detection/51Degrees-LiteV4.1.hash}} LowMemory DeviceDetectionHashEngine diff --git a/examples/device-detection/web/getting-started.onprem/src/test/java/fiftyone/devicedetection/examples/web/OnPremDataFileOptionTest.java b/examples/device-detection/web/getting-started.onprem/src/test/java/fiftyone/devicedetection/examples/web/OnPremDataFileOptionTest.java index 108fbd615..c77110b95 100644 --- a/examples/device-detection/web/getting-started.onprem/src/test/java/fiftyone/devicedetection/examples/web/OnPremDataFileOptionTest.java +++ b/examples/device-detection/web/getting-started.onprem/src/test/java/fiftyone/devicedetection/examples/web/OnPremDataFileOptionTest.java @@ -35,12 +35,12 @@ /** * Checks the order in which the on-premise web example picks its data file, - * being 51DEGREES_DD_PATH first, then TestDataFile, then the Lite file. + * being _51DEGREES_DD_PATH first, then TestDataFile, then the Lite file. * System properties stand in for the environment variables, because the * lookup reads either and a test cannot set an environment variable. */ public class OnPremDataFileOptionTest { - private static final String DD_PATH = "51DEGREES_DD_PATH"; + private static final String DD_PATH = "_51DEGREES_DD_PATH"; private static final String TEST_DATA_FILE = "TestDataFile"; private static final String LITE = "cxx/data/device-detection/51Degrees-LiteV4.1.hash"; diff --git a/examples/ip-intelligence/README.md b/examples/ip-intelligence/README.md index 1c39b2453..21e41d4b6 100644 --- a/examples/ip-intelligence/README.md +++ b/examples/ip-intelligence/README.md @@ -28,7 +28,7 @@ gives a link to ask for a copy. The Compare, Metrics and Suspicious examples nee properties, so with a free data file they say which they need and stop. [Contact us](https://51degrees.com/contact-us?utm_source=github&utm_medium=readme&utm_campaign=51degrees-java&utm_content=readme.md&utm_term=enterprise-data-file) for a copy of the Enterprise data file. To use it, place it in `cxx/data/ip-intelligence` and pass its path -as the first argument, or set the `51DEGREES_IPI_PATH` environment variable or system +as the first argument, or set the `_51DEGREES_IPI_PATH` environment variable or system property to its path. The Enterprise data file is over 6 GB, and the MaxPerformance profile loads all of it into @@ -61,7 +61,7 @@ Compare and Suspicious examples with the Enterprise data file are skipped. The examples locate the data file in the following order: -1. The `51DEGREES_IPI_PATH` environment variable or system property, which can +1. The `_51DEGREES_IPI_PATH` environment variable or system property, which can be set to an explicit path to the data file. It is used in place of any file named without a full path. 2. The path given as the first argument, or the free file the example uses @@ -104,7 +104,7 @@ environment variable or system property. | MetadataCloud | How to access the meta-data that relates to the properties available from the cloud IP intelligence service. | Cloud examples need a resource key. The key is read from the -`51DEGREES_RESOURCE_KEY` environment variable or system property first. The +`_51DEGREES_RESOURCE_KEY` environment variable or system property first. The legacy `TestResourceKey` name is still supported and is checked second. The cloud property tiers changed in May 2026. The examples and this diff --git a/examples/ip-intelligence/console/src/main/java/fiftyone/ipintelligence/examples/console/OfflineProcessing.java b/examples/ip-intelligence/console/src/main/java/fiftyone/ipintelligence/examples/console/OfflineProcessing.java index c800a3d37..8bb5ea91b 100644 --- a/examples/ip-intelligence/console/src/main/java/fiftyone/ipintelligence/examples/console/OfflineProcessing.java +++ b/examples/ip-intelligence/console/src/main/java/fiftyone/ipintelligence/examples/console/OfflineProcessing.java @@ -184,7 +184,7 @@ public static void run(String dataFile, InputStream is, OutputStream os) throws // We use the low memory profile, which reads the data file // from disk as it is needed, because this example may be // given the Enterprise data file, which is over 6 GB, through - // its first argument or 51DEGREES_IPI_PATH. MaxPerformance + // its first argument or _51DEGREES_IPI_PATH. MaxPerformance // loads the whole data file into memory, which makes each // lookup faster where there is memory to hold it. .setPerformanceProfile(Constants.PerformanceProfiles.LowMemory) diff --git a/examples/ip-intelligence/shared/src/main/java/fiftyone/ipintelligence/examples/shared/DataFileHelper.java b/examples/ip-intelligence/shared/src/main/java/fiftyone/ipintelligence/examples/shared/DataFileHelper.java index 19c44616e..66e118cd2 100644 --- a/examples/ip-intelligence/shared/src/main/java/fiftyone/ipintelligence/examples/shared/DataFileHelper.java +++ b/examples/ip-intelligence/shared/src/main/java/fiftyone/ipintelligence/examples/shared/DataFileHelper.java @@ -133,7 +133,7 @@ public static String findAvailableDataFile(String... candidates) { * hold an explicit path to the IP Intelligence data file. This name is * checked first, before any search of the project space. */ - public static final String IPI_PATH_ENV_VAR = "51DEGREES_IPI_PATH"; + public static final String IPI_PATH_ENV_VAR = "_51DEGREES_IPI_PATH"; public static class DatafileInfo { FiftyOneDataFile fileInfo; diff --git a/examples/ip-intelligence/shared/src/main/java/fiftyone/ipintelligence/examples/shared/KeyHelper.java b/examples/ip-intelligence/shared/src/main/java/fiftyone/ipintelligence/examples/shared/KeyHelper.java index 846c714f6..e97d25578 100644 --- a/examples/ip-intelligence/shared/src/main/java/fiftyone/ipintelligence/examples/shared/KeyHelper.java +++ b/examples/ip-intelligence/shared/src/main/java/fiftyone/ipintelligence/examples/shared/KeyHelper.java @@ -30,12 +30,6 @@ import java.util.function.Function; public class KeyHelper { - /** - * Aligned name of the environment variable or system property which may - * hold the resource key. This name is checked first, before the legacy - * {@link #TEST_RESOURCE_KEY} name. - */ - public static final String RESOURCE_KEY_ENV_VAR = "51DEGREES_RESOURCE_KEY"; public static final String TEST_RESOURCE_KEY = "TestResourceKey"; static Logger logger = LoggerFactory.getLogger(KeyHelper.class); @@ -63,8 +57,8 @@ public class KeyHelper { */ public static String requireTestResourceKey(String asksFor, String... products) { - return requireResourceKey(KeyHelper::readKey, Arrays.asList( - RESOURCE_KEY_ENV_VAR, TEST_RESOURCE_KEY), asksFor, products); + return requireResourceKey(KeyHelper::readKey, + Collections.singletonList(TEST_RESOURCE_KEY), asksFor, products); } static String requireResourceKey(Function read, @@ -148,17 +142,18 @@ public static String getOrSetTestResourceKey(String value) { } /** * Obtain a resource key from the passed argument, - * from environment variable or from a property. The aligned - * {@link #RESOURCE_KEY_ENV_VAR} name is checked first, then the + * from environment variable or from a property. The first of + * {@link #SHARED_RESOURCE_KEY_NAMES} is checked first, then the * passed variable name. Store as System Property under the * passed variable name. */ public static String getOrSetResourceKey(String value, String variableName, String errorMessage, boolean shouldThrow) { + String alignedName = SHARED_RESOURCE_KEY_NAMES.get(0); if (Objects.isNull(value)) { // check the aligned name first, then the legacy name - value = KeyUtils.getNamedKey(RESOURCE_KEY_ENV_VAR); + value = KeyUtils.getNamedKey(alignedName); if (Objects.isNull(value)) { value = KeyUtils.getNamedKey(variableName); } @@ -170,7 +165,7 @@ public static String getOrSetResourceKey(String value, String variableName, "runnable example,\n - as an Environment Variable named \"\u001B[36m{}\u001B[0m\"," + "\n - as a System Property named \"\u001B[36m{}\u001B[0m\" " + "(the legacy name \"\u001B[36m{}\u001B[0m\" is also still accepted).", - RESOURCE_KEY_ENV_VAR, RESOURCE_KEY_ENV_VAR, variableName); + alignedName, alignedName, variableName); logger.error(errorMessage); if (shouldThrow) { throw new IllegalStateException("\"" + value + "\" is not a valid resource key"); diff --git a/examples/ip-intelligence/shared/src/test/java/fiftyone/ipintelligence/examples/shared/KeyHelperTest.java b/examples/ip-intelligence/shared/src/test/java/fiftyone/ipintelligence/examples/shared/KeyHelperTest.java index 67b2660cb..3dfd0a90b 100644 --- a/examples/ip-intelligence/shared/src/test/java/fiftyone/ipintelligence/examples/shared/KeyHelperTest.java +++ b/examples/ip-intelligence/shared/src/test/java/fiftyone/ipintelligence/examples/shared/KeyHelperTest.java @@ -24,7 +24,7 @@ import org.junit.Test; -import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -44,8 +44,8 @@ public class KeyHelperTest { /** * The names getOrSetTestResourceKey reads, in its order. */ - private static final List EXAMPLE_NAMES = Arrays.asList( - KeyHelper.RESOURCE_KEY_ENV_VAR, KeyHelper.TEST_RESOURCE_KEY); + private static final List EXAMPLE_NAMES = + Collections.singletonList(KeyHelper.TEST_RESOURCE_KEY); /** * With no key anywhere, the lookup logs that there is none and stores @@ -88,8 +88,8 @@ public void requireResourceKey_NoKey_FailsNamingEveryName() { assertEquals("This test calls the live 51Degrees cloud service, so " + "it needs a resource key and there is nothing to fall back " + "on. None of _51DEGREES_RESOURCE_KEY, " + - "_51DEGREES_RESOURCE_KEY_BESPOKE, 51DEGREES_RESOURCE_KEY, " + - "TestResourceKey is set, as an environment variable or as a " + + "_51DEGREES_RESOURCE_KEY_BESPOKE, TestResourceKey is set, " + + "as an environment variable or as a " + "system property, tried in that order. The key has to carry " + "the ip and device products, because the test asks the " + "service for the network and location of an IP address, and " + @@ -118,11 +118,11 @@ public void requireResourceKey_FirstValidNameWins() { @Test public void requireResourceKey_ExampleNamesInTheirOrder() { Map values = new HashMap<>(); - values.put(KeyHelper.RESOURCE_KEY_ENV_VAR, KEY_A); + values.put("_51DEGREES_RESOURCE_KEY", KEY_A); values.put(KeyHelper.TEST_RESOURCE_KEY, KEY_B); assertEquals(KEY_A, KeyHelper.requireResourceKey(values::get, EXAMPLE_NAMES, "every IP Intelligence property", "ip")); - values.remove(KeyHelper.RESOURCE_KEY_ENV_VAR); + values.remove("_51DEGREES_RESOURCE_KEY"); assertEquals(KEY_B, KeyHelper.requireResourceKey(values::get, EXAMPLE_NAMES, "every IP Intelligence property", "ip")); } diff --git a/ip-intelligence/README.md b/ip-intelligence/README.md index 8f4534ed9..22fc5c652 100644 --- a/ip-intelligence/README.md +++ b/ip-intelligence/README.md @@ -36,7 +36,7 @@ data file. The free 'Lite' and Asn data files come from the [data](https://github.com/51Degrees/data) repository, which is the `cxx/data` folder of this repository. Run `cxx/data/get-data-files.sh ip-intelligence` from the root of this repository to download them, or `cxx\data\get-data-files.ps1 ip-intelligence` in PowerShell on Windows. Otherwise [contact us](https://51degrees.com/contact-us?utm_source=github&utm_medium=readme&utm_campaign=51degrees-java&utm_content=readme.md&utm_term=on-premise) to obtain an 'Enterprise' data file. For tests, the data file can be supplied explicitly by setting the -`51DEGREES_IPI_PATH` environment variable or system property to the path of +`_51DEGREES_IPI_PATH` environment variable or system property to the path of the .ipi file. When it is not set, the project space is searched for the expected file name, with the free 'Lite' file expected in `cxx/data/ip-intelligence`. @@ -113,7 +113,7 @@ You will need [resource keys](https://51degrees.com/documentation/_services__clo The tests look for a resource key in the following order: -1. The aligned `51DEGREES_RESOURCE_KEY` name, set as an environment variable +1. The aligned `_51DEGREES_RESOURCE_KEY` name, set as an environment variable or as a system property. 2. The legacy `TestResourceKey` name, which is still supported and is checked after the aligned name. diff --git a/ip-intelligence/ip-intelligence.shared/src/main/java/fiftyone/ipintelligence/shared/testhelpers/FileUtils.java b/ip-intelligence/ip-intelligence.shared/src/main/java/fiftyone/ipintelligence/shared/testhelpers/FileUtils.java index a39dd6b80..d3db631eb 100644 --- a/ip-intelligence/ip-intelligence.shared/src/main/java/fiftyone/ipintelligence/shared/testhelpers/FileUtils.java +++ b/ip-intelligence/ip-intelligence.shared/src/main/java/fiftyone/ipintelligence/shared/testhelpers/FileUtils.java @@ -43,7 +43,7 @@ public class FileUtils { * supply an explicit path to the IPI data file. When set and the file * exists it is used in preference to searching the project space. */ - public static final String IPI_PATH_ENV_VAR = "51DEGREES_IPI_PATH"; + public static final String IPI_PATH_ENV_VAR = "_51DEGREES_IPI_PATH"; public static final String ENTERPRISE_IPI_DATA_FILE_NAME = "Enterprise-IpIntelligenceV41.ipi"; diff --git a/ip-intelligence/ip-intelligence.shared/src/main/java/fiftyone/ipintelligence/shared/testhelpers/KeyUtils.java b/ip-intelligence/ip-intelligence.shared/src/main/java/fiftyone/ipintelligence/shared/testhelpers/KeyUtils.java index cabb6648c..8c6b1f225 100644 --- a/ip-intelligence/ip-intelligence.shared/src/main/java/fiftyone/ipintelligence/shared/testhelpers/KeyUtils.java +++ b/ip-intelligence/ip-intelligence.shared/src/main/java/fiftyone/ipintelligence/shared/testhelpers/KeyUtils.java @@ -33,7 +33,7 @@ public class KeyUtils { * Name of the aligned environment variable or system property which * supplies the resource key. This name is checked before any legacy name. */ - public static final String RESOURCE_KEY_ENV_VAR = "51DEGREES_RESOURCE_KEY"; + public static final String RESOURCE_KEY_ENV_VAR = "_51DEGREES_RESOURCE_KEY"; /** * Obtain a resource key from an environment variable or a system property. diff --git a/ip-intelligence/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/CountryTranslationIntegrationTest.java b/ip-intelligence/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/CountryTranslationIntegrationTest.java index a5eb58df6..028bec978 100644 --- a/ip-intelligence/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/CountryTranslationIntegrationTest.java +++ b/ip-intelligence/ip-intelligence.translation/src/test/java/fiftyone/ipintelligence/translation/CountryTranslationIntegrationTest.java @@ -50,7 +50,7 @@ * complete, localized, ordered country dropdown is produced. *

* The data file is resolved from the {@code TestDataFile} system property, the - * {@code 51DEGREES_IPI_PATH} environment variable, or a few well known + * {@code _51DEGREES_IPI_PATH} environment variable, or a few well known * locations, and the test is skipped if none is found. *

* The IP Intelligence element data key has differed between engine versions @@ -66,7 +66,7 @@ public class CountryTranslationIntegrationTest { private static String resolveDataFile() { String[] candidates = { System.getProperty("TestDataFile"), - System.getenv("51DEGREES_IPI_PATH"), + System.getenv("_51DEGREES_IPI_PATH"), "51Degrees-IPIV4EnterpriseIpiV41.ipi", "../51Degrees-IPIV4EnterpriseIpiV41.ipi", "../../51Degrees-IPIV4EnterpriseIpiV41.ipi", diff --git a/pipeline/pipeline.common/src/test/java/fiftyone/common/testhelpers/EnvironmentVariableNamesTest.java b/pipeline/pipeline.common/src/test/java/fiftyone/common/testhelpers/EnvironmentVariableNamesTest.java new file mode 100644 index 000000000..ee7ad7e53 --- /dev/null +++ b/pipeline/pipeline.common/src/test/java/fiftyone/common/testhelpers/EnvironmentVariableNamesTest.java @@ -0,0 +1,219 @@ +/* ********************************************************************* + * This Original Work is copyright of 51 Degrees Mobile Experts Limited. + * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, + * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. + * + * This Original Work is licensed under the European Union Public Licence + * (EUPL) v.1.2 and is subject to its terms as set out below. + * + * If a copy of the EUPL was not distributed with this file, You can obtain + * one at https://opensource.org/licenses/EUPL-1.2. + * + * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be + * amended by the European Commission) shall be deemed incompatible for + * the purposes of the Work and the provisions of the compatibility + * clause in Article 5 of the EUPL shall not apply. + * + * If using the Work as, or as part of, a network application, by + * including the attribution notice(s) required under Article 5 of the EUPL + * in the end user terms of the application under an appropriate heading, + * such notice(s) shall fulfill the requirements of that article. + * ********************************************************************* */ + +package fiftyone.common.testhelpers; + +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Every 51Degrees environment variable name in this repository starts with an + * underscore, because a POSIX shell cannot set a name that starts with a + * digit. The scan reads every file git tracks, which leaves out build output + * and the submodules, and fails on any name that starts with the digit. + */ +public class EnvironmentVariableNamesTest { + + /** + * The start of a name a shell cannot set, built in two parts so this file + * does not match itself. + */ + private static final String DIGIT_INITIAL = "5" + "1DEGREES_"; + + /** + * The repository root, being the first folder at or above the working + * directory that holds a .git entry. + */ + private static File repositoryRoot() { + File folder = new File("").getAbsoluteFile(); + while (folder != null) { + if (new File(folder, ".git").exists()) { + return folder; + } + folder = folder.getParentFile(); + } + throw new IllegalStateException( + "No folder at or above " + new File("").getAbsolutePath() + + " holds a .git entry, so the repository root is unknown."); + } + + /** + * The paths git tracks, relative to the root, read as bytes so that a + * name with a character outside the platform encoding survives. + */ + private static List trackedFiles(File root) throws Exception { + Process git = new ProcessBuilder("git", "ls-files", "-z") + .directory(root) + .redirectErrorStream(true) + .start(); + byte[] output; + InputStream stream = git.getInputStream(); + try { + output = readAll(stream); + } finally { + stream.close(); + } + int status = git.waitFor(); + assertEquals("git ls-files failed: " + + new String(output, Charset.forName("UTF-8")), 0, status); + List paths = new ArrayList(); + int start = 0; + for (int at = 0; at <= output.length; at++) { + if (at == output.length || output[at] == 0) { + if (at > start) { + paths.add(new String(output, start, at - start, + Charset.forName("UTF-8"))); + } + start = at + 1; + } + } + return paths; + } + + private static byte[] readAll(InputStream stream) throws IOException { + ByteArrayOutputStream collected = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = stream.read(buffer)) != -1) { + collected.write(buffer, 0, read); + } + return collected.toByteArray(); + } + + /** + * True for a byte that can be part of an environment variable name. + */ + private static boolean isNameByte(byte value) { + return (value >= 'a' && value <= 'z') + || (value >= 'A' && value <= 'Z') + || (value >= '0' && value <= '9') + || value == '_'; + } + + private static boolean startsWithDigitInitial(byte[] line, int at) { + byte[] needle = DIGIT_INITIAL.getBytes(Charset.forName("UTF-8")); + if (at + needle.length > line.length) { + return false; + } + for (int index = 0; index < needle.length; index++) { + if (line[at + index] != needle[index]) { + return false; + } + } + return true; + } + + /** + * Each name in the line that starts with the digit form and is not the + * end of a longer name, such as one with a leading underscore. + */ + static List digitInitialNames(byte[] line) { + List names = new ArrayList(); + int at = 0; + while (at < line.length) { + if (startsWithDigitInitial(line, at) + && (at == 0 || !isNameByte(line[at - 1]))) { + int end = at; + while (end < line.length && isNameByte(line[end])) { + end++; + } + names.add(new String(line, at, end - at, + Charset.forName("UTF-8"))); + at = end; + } else { + at++; + } + } + return names; + } + + private static List lines(byte[] content) { + List lines = new ArrayList(); + int start = 0; + for (int at = 0; at <= content.length; at++) { + if (at == content.length || content[at] == '\n') { + lines.add(Arrays.copyOfRange(content, start, at)); + start = at + 1; + } + } + return lines; + } + + @Test + public void theNameMatcherFindsOnlyNamesThatStartWithTheDigit() { + String digitInitial = DIGIT_INITIAL + "DD_PATH"; + String line = "export " + digitInitial + "=a _" + digitInitial + + "=b X" + digitInitial; + assertEquals(Collections.singletonList(digitInitial), + digitInitialNames(line.getBytes(Charset.forName("UTF-8")))); + } + + @Test + public void noFileNamesAVariableThatStartsWithADigit() throws Exception { + File root = repositoryRoot(); + List found = new ArrayList(); + for (String relative : trackedFiles(root)) { + File file = new File(root, relative); + // A submodule is tracked as a directory, and its files belong to + // another repository. + if (!file.isFile()) { + continue; + } + byte[] content = readFile(file); + List lines = lines(content); + for (int index = 0; index < lines.size(); index++) { + for (String name : digitInitialNames(lines.get(index))) { + found.add(relative + ":" + (index + 1) + ": " + name); + } + } + } + StringBuilder message = new StringBuilder(); + message.append(found.size()); + message.append(" names start with a digit, which a POSIX shell "); + message.append("cannot set, so each must start with an underscore:"); + for (String line : found) { + message.append("\n").append(line); + } + assertTrue(message.toString(), found.isEmpty()); + } + + private static byte[] readFile(File file) throws IOException { + InputStream stream = new java.io.FileInputStream(file); + try { + return readAll(stream); + } finally { + stream.close(); + } + } +}