Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4261382567
|
||
|
|
2b5a84e805
|
||
|
|
7c825b05d5 | ||
|
|
5401070321
|
||
|
|
75bf74967d |
No files matched your search
@@ -27,9 +27,11 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
- Conformance suite against MinIO and Garage (Testcontainers), service, index and action tests, and the index and actions on PostgreSQL.
|
||||
|
||||
### Removed
|
||||
- The QRun-IO/qqq#1165 stand-ins: `SessionCredentialsClient` (session tokens now go through core `S3BackendMetaData.sessionToken` / `S3ClientFactory`, including for the presigner) and the file reading in `SecretRefs` (core `QMetaDataVariableInterpreter` resolves `${file.PATH}`). **Behavior change:** core reads `${file.PATH}` only inside its allowed roots, which are `/var/run/secrets` and `/run/secrets` by default. A host that mounts storage secrets elsewhere must set `qqq.fileReference.roots` or `QQQ_FILE_REFERENCE_ROOTS`. The qbit's default allow-list (`file./var/run/secrets/storage/*`) is already inside the default roots.
|
||||
- None for the health migration; both Test connection record actions, including on-demand write tests, are retained.
|
||||
|
||||
### Changed
|
||||
- Read the qbit metadata version from the Maven-filtered artifact version so release metadata matches the build and help bundle.
|
||||
- Rechecked the released RC.8 JARs: core still has no S3 session-token/provider hook or file-reference interpreter. Retained both compatibility shims and moved their review deadline to RC.9 under issue #10.
|
||||
- Raised the QQQ BOM baseline to released `4.1.0-RC.8` for the central health API.
|
||||
- Raised the QQQ BOM baseline to released `4.1.0-RC.7` for the core help-bundle registry and coverage assertions.
|
||||
|
||||
@@ -26,16 +26,21 @@ The public qbit configuration API is unchanged.
|
||||
|
||||
## Remaining local code
|
||||
|
||||
- Core RC.5 has no session-token field or credentials-provider customization
|
||||
in `S3BackendMetaData` / `S3ClientFactory.buildClient`. The narrow
|
||||
`SessionCredentialsClient` bridge adds session credentials to requests while
|
||||
retaining their overrides; core still creates the client and HTTP transport.
|
||||
Core needs session-token metadata (including enrichment and cache-key support)
|
||||
and `AwsSessionCredentials` construction, or a caller-provided credentials hook.
|
||||
Marker: `STAND-IN(core=QRun-IO/qqq#1165, removeBy=4.1.0-RC.7)`.
|
||||
- Core has no presigner factory. The local presigner retains its separate public
|
||||
endpoint and least-privilege keys, along with existing session-token handling.
|
||||
- `SecretRefs` file resolution is unchanged and carries the same RC.7 marker.
|
||||
- Core has no presigner factory. The local presigner keeps its separate public
|
||||
endpoint and least-privilege keys; its credentials (including the session
|
||||
token) come from core `S3ClientFactory.buildCredentialsProvider`.
|
||||
- `SecretRefs` keeps only the qbit's allow-list; resolution, including
|
||||
`${file.PATH}`, is core `QMetaDataVariableInterpreter`.
|
||||
|
||||
## Stand-ins removed (QRun-IO/qqq#1165)
|
||||
|
||||
- `SessionCredentialsClient` is gone: core `S3BackendMetaData.sessionToken`
|
||||
makes `S3ClientFactory` sign with `AwsSessionCredentials`.
|
||||
- `SecretRefs` no longer reads files itself. Core reads `${file.PATH}`
|
||||
read-only, and only inside its allowed roots: `/var/run/secrets` and
|
||||
`/run/secrets` by default, or `qqq.fileReference.roots` /
|
||||
`QQQ_FILE_REFERENCE_ROOTS` (comma-separated). The qbit's default allow-list
|
||||
(`file./var/run/secrets/storage/*`) is inside the default roots.
|
||||
|
||||
## Resolved dependency diff
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-object-storage.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
@@ -186,10 +187,20 @@
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-object-storage.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables combine.children="append">
|
||||
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
@@ -272,14 +283,14 @@
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
|
||||
<!-- imports the released 4.1 BOM, this profile is active by default and -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.7 ahead of the parent's 4.0.0 BOM. -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.15 ahead of the parent's 4.0.0 BOM. -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.8</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -21,8 +21,11 @@
|
||||
package com.kingsrook.qbits.objectstorage;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.objectstorage.app.StorageWidgets;
|
||||
import com.kingsrook.qbits.objectstorage.health.ObjectStorageConnectionChecks;
|
||||
import com.kingsrook.qbits.objectstorage.internal.ConfigSync;
|
||||
@@ -60,13 +63,41 @@ public class ObjectStorageQBitProducer implements QBitMetaDataProducer<ObjectSto
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-object-storage";
|
||||
public static final String VERSION = "0.1.0";
|
||||
public static final String VERSION = loadVersion();
|
||||
|
||||
private ObjectStorageQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the artifact version from the Maven-filtered resource.
|
||||
*******************************************************************************/
|
||||
private static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = ObjectStorageQBitProducer.class.getClassLoader().getResourceAsStream("qbit-object-storage.properties"))
|
||||
{
|
||||
if(inputStream == null)
|
||||
{
|
||||
throw new IllegalStateException("Missing qbit-object-storage.properties; build with Maven to generate the artifact version");
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(version == null || version.isBlank() || version.contains("${"))
|
||||
{
|
||||
throw new IllegalStateException("Missing or unfiltered version in qbit-object-storage.properties; build with Maven");
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw new IllegalStateException("Could not read artifact version from qbit-object-storage.properties", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the config.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -22,7 +22,6 @@ package com.kingsrook.qbits.objectstorage.internal;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.function.Function;
|
||||
import com.kingsrook.qbits.objectstorage.config.AddressingStyle;
|
||||
import com.kingsrook.qbits.objectstorage.config.CredentialsMode;
|
||||
import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
|
||||
@@ -30,17 +29,10 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3ChecksumMode;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.utils.S3ClientFactory;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
|
||||
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.awscore.AwsRequestOverrideConfiguration;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.DelegatingS3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.model.S3Request;
|
||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||
|
||||
|
||||
@@ -198,16 +190,7 @@ public class ConnectionRuntime
|
||||
*******************************************************************************/
|
||||
private S3Client buildClient()
|
||||
{
|
||||
S3BackendMetaData backend = backendMetaData();
|
||||
String token = config.getCredentialsMode() == CredentialsMode.STATIC_REFS ? SecretRefs.resolve(config.getSessionTokenRef()) : null;
|
||||
AwsCredentialsProvider sessionCredentials = null;
|
||||
if(token != null && backend.getAccessKey() != null && backend.getSecretKey() != null)
|
||||
{
|
||||
sessionCredentials = StaticCredentialsProvider.create(AwsSessionCredentials.create(backend.getAccessKey(), backend.getSecretKey(), token));
|
||||
}
|
||||
|
||||
S3Client coreClient = S3ClientFactory.buildClient(backend);
|
||||
return (sessionCredentials == null ? coreClient : new SessionCredentialsClient(coreClient, sessionCredentials));
|
||||
return (S3ClientFactory.buildClient(backendMetaData()));
|
||||
}
|
||||
|
||||
|
||||
@@ -229,8 +212,7 @@ public class ConnectionRuntime
|
||||
.withSocketTimeoutMillis(config.getReadTimeoutMs());
|
||||
if(config.getCredentialsMode() == CredentialsMode.STATIC_REFS)
|
||||
{
|
||||
backend.withAccessKey(SecretRefs.resolve(config.getAccessKeyRef()))
|
||||
.withSecretKey(SecretRefs.resolve(config.getSecretKeyRef()));
|
||||
withStaticCredentials(backend, config.getAccessKeyRef(), config.getSecretKeyRef());
|
||||
}
|
||||
return (backend);
|
||||
}
|
||||
@@ -270,63 +252,33 @@ public class ConnectionRuntime
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Static keys from the references, or the SDK default chain.
|
||||
** Static keys (with the session token, when set) from the references, or the
|
||||
** SDK default chain; core builds the provider.
|
||||
*******************************************************************************/
|
||||
private AwsCredentialsProvider credentials(String accessRef, String secretRef)
|
||||
{
|
||||
S3BackendMetaData credentials = new S3BackendMetaData();
|
||||
if(config.getCredentialsMode() == CredentialsMode.STATIC_REFS)
|
||||
{
|
||||
String accessKey = SecretRefs.resolve(accessRef);
|
||||
String secretKey = SecretRefs.resolve(secretRef);
|
||||
if(accessKey != null && secretKey != null)
|
||||
{
|
||||
String token = SecretRefs.resolve(config.getSessionTokenRef());
|
||||
return (StaticCredentialsProvider.create(token == null ? AwsBasicCredentials.create(accessKey, secretKey)
|
||||
: AwsSessionCredentials.create(accessKey, secretKey, token)));
|
||||
}
|
||||
withStaticCredentials(credentials, accessRef, secretRef);
|
||||
}
|
||||
return (DefaultCredentialsProvider.builder().build());
|
||||
return (S3ClientFactory.buildCredentialsProvider(credentials));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1165, removeBy=4.1.0-RC.9)
|
||||
** RC.8 S3BackendMetaData and S3ClientFactory still lack a session token or
|
||||
** credentials-provider hook (verified against the released JAR).
|
||||
** Override request credentials only; core still owns the client and transport.
|
||||
** Resolve static keys onto core meta data. The session token applies only
|
||||
** with both keys, as before (core rejects a token without keys).
|
||||
*******************************************************************************/
|
||||
private static class SessionCredentialsClient extends DelegatingS3Client
|
||||
private void withStaticCredentials(S3BackendMetaData backend, String accessRef, String secretRef)
|
||||
{
|
||||
private final AwsCredentialsProvider credentialsProvider;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Wrap a core-built client with the connection's session credentials.
|
||||
*******************************************************************************/
|
||||
private SessionCredentialsClient(S3Client delegate, AwsCredentialsProvider credentialsProvider)
|
||||
String accessKey = SecretRefs.resolve(accessRef);
|
||||
String secretKey = SecretRefs.resolve(secretRef);
|
||||
backend.withAccessKey(accessKey).withSecretKey(secretKey);
|
||||
if(accessKey != null && secretKey != null)
|
||||
{
|
||||
super(delegate);
|
||||
this.credentialsProvider = credentialsProvider;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve request overrides and the concrete request type while adding the
|
||||
** session token to signing, including streaming and paginated operations.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
@SuppressWarnings("unchecked") // toBuilder().build() retains the concrete request type T.
|
||||
protected <T extends S3Request, ReturnT> ReturnT invokeOperation(T request, Function<T, ReturnT> operation)
|
||||
{
|
||||
AwsRequestOverrideConfiguration.Builder overrides = request.overrideConfiguration()
|
||||
.map(AwsRequestOverrideConfiguration::toBuilder).orElseGet(AwsRequestOverrideConfiguration::builder);
|
||||
T signedRequest = (T) request.toBuilder()
|
||||
.overrideConfiguration(overrides.credentialsProvider(credentialsProvider).build())
|
||||
.build();
|
||||
return (operation.apply(signedRequest));
|
||||
backend.withSessionToken(SecretRefs.resolve(config.getSessionTokenRef()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,6 @@
|
||||
package com.kingsrook.qbits.objectstorage.internal;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
|
||||
@@ -32,14 +28,12 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolves secret references (${env.X}, ${prop.X}, ${file.PATH}) and enforces
|
||||
** the allow-list. Resolved values live only in memory, never in a record, a log
|
||||
** line or a process value.
|
||||
**
|
||||
** STAND-IN(core=QRun-IO/qqq#1165, removeBy=4.1.0-RC.9)
|
||||
** RC.8 QMetaDataVariableInterpreter still supports env/prop, not file references.
|
||||
** ${file.PATH} moves to QMetaDataVariableInterpreter in QQQ core
|
||||
** (spec section 8.2); then this class delegates entirely.
|
||||
** Enforces the qbit's secret-reference allow-list and resolves references
|
||||
** (${env.X}, ${prop.X}, ${file.PATH}) through core QMetaDataVariableInterpreter.
|
||||
** Core reads ${file.PATH} read-only from its allowed roots (default
|
||||
** /var/run/secrets and /run/secrets; qqq.fileReference.roots or
|
||||
** QQQ_FILE_REFERENCE_ROOTS). Resolved values live only in memory, never in a
|
||||
** record, a log line or a process value.
|
||||
*******************************************************************************/
|
||||
public final class SecretRefs
|
||||
{
|
||||
@@ -93,19 +87,7 @@ public final class SecretRefs
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String trimmed = ref.trim();
|
||||
if(trimmed.startsWith("${file.") && trimmed.endsWith("}"))
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(Path.of(trimmed.substring("${file.".length(), trimmed.length() - 1)), StandardCharsets.UTF_8).trim());
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
String value = new QMetaDataVariableInterpreter().interpret(trimmed);
|
||||
String value = new QMetaDataVariableInterpreter().interpret(ref.trim());
|
||||
return (value == null || value.startsWith("${") ? null : value);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
version=${project.version}
|
||||
@@ -59,7 +59,13 @@ class ObjectStorageHelpBundleTest
|
||||
|
||||
assertThat(instance.getTable("helpContent")).isNull();
|
||||
assertThat(HelpBundles.of(instance).getBundles()).singleElement()
|
||||
.satisfies(bundle -> assertThat(bundle.getId()).isEqualTo("qbit-object-storage"));
|
||||
.satisfies(bundle ->
|
||||
{
|
||||
assertThat(bundle.getId()).isEqualTo("qbit-object-storage");
|
||||
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank();
|
||||
assertThat(bundle.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion"))
|
||||
.isEqualTo(ObjectStorageQBitProducer.VERSION);
|
||||
});
|
||||
HelpBundles.syncOnStartup(instance);
|
||||
Set<String> keys = HelpBundles.of(instance).getBundles().get(0).getEntries().stream()
|
||||
.map(entry -> entry.getKey()).collect(Collectors.toSet());
|
||||
|
||||
@@ -44,6 +44,10 @@ class ObjectStorageQBitProducerTest
|
||||
ObjectStorageQBitConfig config = new ObjectStorageQBitConfig().withBackendName("rdbms");
|
||||
ObjectStorageQBitProducer producer = new ObjectStorageQBitProducer().withQBitConfig(config).withNamespace("object_storage");
|
||||
|
||||
String artifactVersion = System.getProperty("qbit.artifactVersion");
|
||||
assertThat(artifactVersion).isNotBlank();
|
||||
assertThat(ObjectStorageQBitProducer.VERSION).isEqualTo(artifactVersion);
|
||||
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo(artifactVersion);
|
||||
assertThat(producer.isEnabled()).isTrue();
|
||||
assertThat(producer.getQBitConfig()).isSameAs(config);
|
||||
assertThat(producer.getNamespace()).isEqualTo("object_storage");
|
||||
|
||||
+17
-3
@@ -38,6 +38,7 @@ import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMe
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3ChecksumMode;
|
||||
import com.sun.net.httpserver.Headers;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
|
||||
@@ -58,9 +59,21 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
*******************************************************************************/
|
||||
class ConnectionRuntimeTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Core reads ${file.PATH} only inside its allowed roots; reference() points
|
||||
** them at each test's temporary directory.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void afterEach()
|
||||
{
|
||||
System.clearProperty("qqq.fileReference.roots");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every core-supported setting is mapped after resolving references. Session
|
||||
** tokens and presign settings have no core fields and are exercised below.
|
||||
** tokens and presign settings are exercised below.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testBackendMetaDataCarriesEverySetting(@TempDir Path dir) throws Exception
|
||||
@@ -199,8 +212,8 @@ class ConnectionRuntimeTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Session credentials sign streaming reads/writes and paginator requests.
|
||||
** Existing request headers survive the bridge's credentials override.
|
||||
** Session credentials (core sessionToken) sign streaming reads/writes and
|
||||
** paginator requests; request overrides are untouched.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSessionSigningAndRequestOverrides(@TempDir Path dir) throws Exception
|
||||
@@ -313,6 +326,7 @@ class ConnectionRuntimeTest
|
||||
*******************************************************************************/
|
||||
private static String reference(Path dir, String name, String value) throws Exception
|
||||
{
|
||||
System.setProperty("qqq.fileReference.roots", dir.toString());
|
||||
Path file = dir.resolve(name);
|
||||
Files.writeString(file, value);
|
||||
return ("${file." + file + "}");
|
||||
|
||||
@@ -83,10 +83,23 @@ class InternalsTest
|
||||
assertThat(SecretRefs.resolve(null)).isNull();
|
||||
assertThat(SecretRefs.resolveSetting("")).isNull();
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// core reads ${file.PATH} only inside its allowed roots (by default //
|
||||
// /var/run/secrets and /run/secrets); point them at the temp dir. //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
Path file = dir.resolve("key");
|
||||
Files.writeString(file, " from-file\n");
|
||||
assertThat(SecretRefs.resolve("${file." + file + "}")).isEqualTo("from-file");
|
||||
assertThat(SecretRefs.resolve("${file." + dir.resolve("missing") + "}")).isNull();
|
||||
assertThat(SecretRefs.resolve("${file." + file + "}")).as("outside the allowed roots").isNull();
|
||||
System.setProperty("qqq.fileReference.roots", dir.toString());
|
||||
try
|
||||
{
|
||||
assertThat(SecretRefs.resolve("${file." + file + "}")).isEqualTo("from-file");
|
||||
assertThat(SecretRefs.resolve("${file." + dir.resolve("missing") + "}")).isNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.clearProperty("qqq.fileReference.roots");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user