fix(managed-agent): look for the configuration files Spring looks for, however a location is spelled

The check re-derived Spring's config-data location grammar from strings,
so a spelling it did not classify was dropped: optional:File:/x/, with
the scheme in another case, or the extension hint of
file:app.conf[.properties] gave "no configuration file" over a file the
server reads. prepare now gives Spring a resource loader that records
every file Spring's own resolver looks a configuration file up at, there
or not. The candidates are then Spring's: any spelling it reads,
resource: prefixes, extension hints, bare names with a colon, imports,
the variant of each profile Spring activates, and none at all for an
empty spring.config.location or spring.config.name.

Config trees and wildcards, which Spring opens or lists without such a
lookup, still come from the location values, read with Spring's
ConfigDataLocation and resource rules. A file's wildcard
(.../*/application.yml) is covered as a directory's is, its file named as
Spring names it, a directory
whose name starts with ".." is skipped as Spring skips it, and a
wildcard's directory this process may list but not enter is named as
one it cannot enter.

The secret scan drops a leading UTF-8 byte order mark, as YAML does, so
a commented-out key on the first line is seen.

A root service account reads and enters anything, as the kernel lets
it, rather than being judged by permission bits, and the advice no
longer says "run --check as root or as root".
This commit is contained in:
wenshao committed 2026-11-07 14:23:42 +08:00
1 parent 25f4836539
commit 9b2e2b4910
7 files changed
+512 -138

No files matched your search

@@ -91,10 +91,20 @@ environment post-processor on the class path through
no context, and `ResolvedConfiguration`, which the upgrade command reads its
settings through, prepares the same environment. The `config` rows name the
files the prepared property sources came from, then the files Spring read at a
location without finding a setting. When the preparation throws, the row
names what Spring refused: the file, read as missing or unreadable, or the
YAML error's file and line, found among the files Spring looks for, since
Spring's error names neither: each file whose own error is Spring's, the same
location without finding a setting. The check does not read a location's
spelling itself: it records each file Spring's own resolver looks a
configuration file up at, there or not, so a scheme in another case, a
`resource:` prefix, an extension hint, a bare name with a colon, an empty
`spring.config.location` or `spring.config.name`, and each variant of a profile
Spring activates come out as Spring reads them. Spring opens a config tree and
lists a wildcard's directories without such a lookup, so those two come from the
location values, read with Spring's location rules: a wildcard's directories,
but none whose name starts with `..`, as Spring skips those, and in each, the
files of a directory location or the one name of a file's wildcard. A directory
there that this process cannot list or enter is named as one it cannot look in.
When the preparation throws, the row names what Spring refused: the file, read
as missing or unreadable, or the YAML error's file and line, found among the
files Spring looked for before it refused, since Spring's error names neither: each file whose own error is Spring's, the same
problem at the same place, unless a profile the arguments or the environment
activate tells which one Spring read; a file this process cannot read names the
service account when that cannot read it either, and otherwise says to run the
@@ -106,11 +116,13 @@ no default, that row fails with Spring's reason and the others still print.
Spring skips an optional file it cannot see, so where this process cannot enter
a directory the server looks in for a configuration file or a config tree, or
list one it lists, as it lists `config/` for `config/*/`, a `config` row names
that directory and says to run the check as the service account or as root. When
that account cannot look in it either, Spring reads nothing there, and the
directory gets no row; nor does it without a service account, or when the check
runs as that account, as this process then stands in for the server's, as it
does for every file row.
that directory and says to run the check as the service account or as root, or
as root alone when that is the service account. When that account cannot look
in it either, Spring reads nothing there, and the directory gets no row; nor
does it without a service account, or when the check runs as that account, as
this process then stands in for the server's, as it does for every file row. A
service account of root reads and enters whatever the modes say, as the kernel
lets it.
A file holds a secret by one rule, the configuration reference's: a key named as
a password, token, key or secret, or a map of keys, in any spelling Spring
@@ -119,14 +131,15 @@ key a file holds: those of each document, including one no profile activates,
those in a comment, a commented-out line or, in a file that may be YAML, the
comment after a value, the property a config tree value file holds, and a
parameter of a value, such as a JDBC URL's `?password=`, or the password of a
URL's user information. A path can be a configuration file and a config tree
value file at once, as `config/application.yml` is under a config tree imported
over `config/`, and so is a file whose extension a location hints, as
`file:app.conf[.properties]` does, under one imported over its directory; it is
then judged both ways. A value file Spring reads as no configuration file holds
its value alone, whatever its name. `Binder` produces `ManagedAgentProperties`,
the Harness probe uses the JDK HTTP client, and the database probe uses JDBC
with a five-second connect and socket timeout.
URL's user information. A UTF-8 byte order mark, which YAML drops, does not hide
a commented-out key on the first line. A path can be a configuration file and a
config tree value file at once, as `config/application.yml` is under a config
tree imported over `config/`, and so is a file whose extension a location hints,
as `file:app.conf[.properties]` does, under one imported over its directory; it
is then judged both ways. A value file Spring reads as no configuration file
holds its value alone, whatever its name. `Binder` produces
`ManagedAgentProperties`, the Harness probe uses the JDK HTTP client, and the
database probe uses JDBC with a five-second connect and socket timeout.
## Acceptance
@@ -49,11 +49,11 @@
## 实现
`ManagedAgentServerApplication` 在 `SpringApplication.run` 之前拦截 `--check`,因此不会启动监听、调度器、数据源连接池或 worker。`ServerEnvironment` 按 `SpringApplication` 的方式准备 servlet 环境:参数、进程环境,然后经 `EnvironmentPostProcessorApplicationListener` 运行 classpath 上的所有 environment post-processor,并启用宽松绑定。它不创建 context;升级命令读取设置所用的 `ResolvedConfiguration` 也准备同一个环境。`config` 行先列出准备好的属性源来自的文件,再列出 Spring 在某个位置读到但其中没有任何设置的文件。准备过程抛出异常时,该行写明 Spring 拒绝的原因:被当作不存在或不可读的文件,或 YAML 错误所在的文件和行号。由于 Spring 的错误信息不包含这两者,需要在 Spring 会查找的文件中定位:列出自身错误与 Spring 一致(问题相同、位置相同)的每个文件;若参数或环境变量激活的 profile 能确定 Spring 读的是哪一个,则只列那一个。本进程读不了的文件,若 service account 也读不了,就写 service account;否则提示以该账号运行检查。此时其余各行读取参数、进程环境和打包默认值。环境准备成功、但某一行读取的设置无法解析时(例如没有取值也没有默认值的占位符),该行报 FAIL 并给出 Spring 的原因,其余各行照常输出。
`ManagedAgentServerApplication` 在 `SpringApplication.run` 之前拦截 `--check`,因此不会启动监听、调度器、数据源连接池或 worker。`ServerEnvironment` 按 `SpringApplication` 的方式准备 servlet 环境:参数、进程环境,然后经 `EnvironmentPostProcessorApplicationListener` 运行 classpath 上的所有 environment post-processor,并启用宽松绑定。它不创建 context;升级命令读取设置所用的 `ResolvedConfiguration` 也准备同一个环境。`config` 行先列出准备好的属性源来自的文件,再列出 Spring 在某个位置读到但其中没有任何设置的文件。检查不自行解读 location 的写法,而是记录 Spring 自己的解析器去查找配置文件的每个路径(无论文件是否存在)。因此,大小写不同的 scheme、`resource:` 前缀、扩展名提示、带冒号的裸文件名、为空的 `spring.config.location` 或 `spring.config.name`,以及 Spring 激活的每个 profile 的变体,结果都与 Spring 的读取一致。Spring 打开 config tree、列出通配符目录时不经过这种查找,这两种情况从 location 的取值得出,并按 Spring 的 location 规则读取:通配符下的各个目录(名称以 `..` 开头的除外,Spring 会跳过它们),以及每个目录中目录 location 对应的各个文件,或文件通配符给出的那一个文件名。其中本进程无法列出或进入的目录,会作为它看不进去的目录报出。准备过程抛出异常时,该行写明 Spring 拒绝的原因:被当作不存在或不可读的文件,或 YAML 错误所在的文件和行号。由于 Spring 的错误信息不包含这两者,需要在 Spring 拒绝之前查找过的文件中定位:列出自身错误与 Spring 一致(问题相同、位置相同)的每个文件;若参数或环境变量激活的 profile 能确定 Spring 读的是哪一个,则只列那一个。本进程读不了的文件,若 service account 也读不了,就写 service account;否则提示以该账号运行检查。此时其余各行读取参数、进程环境和打包默认值。环境准备成功、但某一行读取的设置无法解析时(例如没有取值也没有默认值的占位符),该行报 FAIL 并给出 Spring 的原因,其余各行照常输出。
Spring 会跳过它看不到的 optional 文件。因此,本进程进不去服务端查找配置文件或 config tree 的某个目录,或列不出服务端要列出的目录(例如为 `config/*/` 列出 `config/`)时,会有一条 `config` 行写明该目录,并提示以 service account 或 root 运行检查。若 service account 也进不去,Spring 在那里什么都不读,该目录就不出行。未配置 service account,或检查本身就以该账号运行时,也不出行:此时与每个文件行一样,由本进程代表服务端的账号。
Spring 会跳过它看不到的 optional 文件。因此,本进程进不去服务端查找配置文件或 config tree 的某个目录,或列不出服务端要列出的目录(例如为 `config/*/` 列出 `config/`)时,会有一条 `config` 行写明该目录,并提示以 service account 或 root 运行检查;service account 就是 root 时,只提示以 root 运行。若 service account 也进不去,Spring 在那里什么都不读,该目录就不出行。未配置 service account,或检查本身就以该账号运行时,也不出行:此时与每个文件行一样,由本进程代表服务端的账号。service account 为 root 时,与内核的处理一致,不论权限位如何都可以读取和进入。
文件是否含 secret 只按一条规则判断,即配置参考的规则:键名表示 password、token、key 或 secret,或是一组 key 的映射(Spring 宽松绑定认可的任何写法都算),且不是 ID,也不是保存 secret 的文件的路径。这条规则作用于文件中的每个键:每个文档里的键(包括没有任何 profile 激活的文档)、注释里的键(注释掉的行,以及可能是 YAML 的文件中值后面的注释)、config tree 值文件对应的属性,以及取值中的参数(例如 JDBC URL 的 `?password=`)或 URL 用户信息中的密码。同一路径可以既是配置文件又是 config tree 值文件(例如 config tree 导入在 `config/` 之上时的 `config/application.yml`;又如由位置提示扩展名的文件,像 `file:app.conf[.properties]`,而 config tree 导入在它所在的目录之上),此时两种身份都要判断。Spring 不把它当配置文件读取的值文件,无论叫什么名字,都只按取值判断。`Binder` 生成 `ManagedAgentProperties`,Harness 探测使用 JDK HTTP client,数据库探测使用 JDBC,连接与 socket 超时均为 5 秒。
文件是否含 secret 只按一条规则判断,即配置参考的规则:键名表示 password、token、key 或 secret,或是一组 key 的映射(Spring 宽松绑定认可的任何写法都算),且不是 ID,也不是保存 secret 的文件的路径。这条规则作用于文件中的每个键:每个文档里的键(包括没有任何 profile 激活的文档)、注释里的键(注释掉的行,以及可能是 YAML 的文件中值后面的注释)、config tree 值文件对应的属性,以及取值中的参数(例如 JDBC URL 的 `?password=`)或 URL 用户信息中的密码。YAML 会丢弃 UTF-8 字节序标记,因此第一行前的字节序标记不会让该行被注释掉的键漏判。同一路径可以既是配置文件又是 config tree 值文件(例如 config tree 导入在 `config/` 之上时的 `config/application.yml`;又如由位置提示扩展名的文件,像 `file:app.conf[.properties]`,而 config tree 导入在它所在的目录之上),此时两种身份都要判断。Spring 不把它当配置文件读取的值文件,无论叫什么名字,都只按取值判断。`Binder` 生成 `ManagedAgentProperties`,Harness 探测使用 JDK HTTP client,数据库探测使用 JDBC,连接与 socket 超时均为 5 秒。
## 验收
@@ -318,7 +318,13 @@ public final class ManagedAgentPreflight {
directory + " cannot be " + (hidden.listed() ? "listed" : "entered") + " by "
+ runner + ", which runs this check, and the server may read a"
+ " configuration file in it",
"run --check as " + account.name() + " or as root");
runAs(account));
}
// The account the check sees the server's files as.
private static String runAs(ServiceAccount account) {
return "run --check as " + account.name()
+ (account.superuser() ? "" : " or as root");
}
/**
@@ -344,7 +350,7 @@ public final class ManagedAgentPreflight {
return serverReads
? new Check("config", Status.FAIL,
path + " is not readable by " + runner + ", which runs this check",
"run --check as " + account.name() + " or as root")
runAs(account))
: new Check("config", Status.FAIL,
path + " is not readable by " + account.name(),
unreadableFix(account.name(), path));
@@ -647,12 +653,17 @@ public final class ManagedAgentPreflight {
// Spring reads YAML as UTF-8, or UTF-16 after a byte order mark, and
// .properties as ISO-8859-1. A comment's key is ASCII, which ISO-8859-1,
// decoding any byte, reads in all but UTF-16.
// decoding any byte, reads in all but UTF-16. YAML drops a UTF-8 byte
// order mark, as an editor may write one, so its first line is read
// without it.
private static String text(byte[] bytes) {
boolean utf16 = bytes.length >= 2
&& ((bytes[0] & 0xff) == 0xfe && (bytes[1] & 0xff) == 0xff
|| (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0xfe);
return new String(bytes, utf16 ? StandardCharsets.UTF_16 : StandardCharsets.ISO_8859_1);
int start = !utf16 && bytes.length >= 3 && (bytes[0] & 0xff) == 0xef
&& (bytes[1] & 0xff) == 0xbb && (bytes[2] & 0xff) == 0xbf ? 3 : 0;
return new String(bytes, start, bytes.length - start,
utf16 ? StandardCharsets.UTF_16 : StandardCharsets.ISO_8859_1);
}
static String unreadableFix(String account, String path) {
@@ -3,6 +3,7 @@ package com.alibaba.qwen.code.managedagent.preflight;
import org.springframework.boot.DefaultBootstrapContext;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.context.config.ConfigDataLocation;
import org.springframework.boot.context.config.ConfigDataNotFoundException;
import org.springframework.boot.context.config.ConfigDataResourceNotFoundException;
import org.springframework.boot.context.config.StandardConfigDataResource;
@@ -25,8 +26,12 @@ import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.PropertySource;
import org.springframework.core.env.SimpleCommandLinePropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.util.ResourceUtils;
import org.springframework.util.StringUtils;
import org.yaml.snakeyaml.error.Mark;
import org.yaml.snakeyaml.error.MarkedYAMLException;
@@ -46,10 +51,12 @@ import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
@@ -64,14 +71,19 @@ import java.util.regex.Pattern;
* the server's only when it runs with the unit's working directory.
*/
final class ServerEnvironment {
// Where Spring Boot looks without being told, relative to the working
// directory; config/*/ is a wildcard.
private static final List<String> DEFAULT_DIRECTORIES = List.of("", "config", "config/*");
// The one of Spring Boot's default locations it lists rather than looks
// a file up in, relative to the working directory.
private static final String DEFAULT_WILDCARD = "config/*";
// Those of the property source loaders Spring Boot registers.
private static final List<String> EXTENSIONS = List.of("yml", "yaml", "properties", "xml");
// A location such as classpath: or configtree:, as Spring tells one from a path.
private static final Pattern SCHEME = Pattern.compile("^[a-zA-Z][a-zA-Z0-9*]*?:");
// A location Spring reads as written rather than beside the file that
// imports it, as its StandardConfigDataLocationResolver tells one.
private static final Pattern FIXED = Pattern.compile("^[a-zA-Z][a-zA-Z0-9*]*?:");
// A file name with the extension it is read by, as Spring's
// FileExtensionHint tells one: app.conf[.properties].
private static final Pattern HINT = Pattern.compile("^(.*)\\[(\\.\\w+)](?!\\[)$");
private static final String IMPORT = "spring.config.import";
private static final String LOOKED_UP = "qwen-managed-agent-preflight-looked-up";
private ServerEnvironment() {
}
@@ -85,6 +97,10 @@ final class ServerEnvironment {
ConfigurableEnvironment environment) {
SpringApplication application = new SpringApplication();
application.setWebApplicationType(WebApplicationType.SERVLET);
// Kept before Spring reads a file, so a failure leaves what it looked for.
LookedUp lookedUp = new LookedUp();
environment.getPropertySources().addLast(lookedUp);
application.setResourceLoader(lookedUp.loader());
environment.setConversionService(new ApplicationConversionService());
if (args.length > 0) {
environment.getPropertySources().addFirst(
@@ -99,6 +115,74 @@ final class ServerEnvironment {
return environment;
}
/**
* The files Spring looked a configuration file up at while it prepared
* an environment, whether one was there or not, as its own resolver
* named them from a location's spelling: with the extension a location
* hints, the variant of each profile it activated, and relative to the
* working directory where the location is. It holds no property; it
* keeps them with the environment for {@link #files} and the rest.
*/
private static final class LookedUp extends EnumerablePropertySource<Set<Path>> {
LookedUp() {
super(LOOKED_UP, new LinkedHashSet<>());
}
// Enumerable and empty, so a binder skips it for every name.
@Override
public String[] getPropertyNames() {
return new String[0];
}
@Override
public Object getProperty(String name) {
return null;
}
ResourceLoader loader() {
return new DefaultResourceLoader() {
@Override
public Resource getResource(String location) {
Resource resource = super.getResource(location);
// A directory is one Spring lists for a wildcard, or
// checks is there when it found no file in it.
if (!location.endsWith("/")) {
Path file = path(resource);
if (file != null) {
getSource().add(file);
}
}
return resource;
}
};
}
}
/** The files Spring looked up for this environment, in the working directory. */
private static Set<Path> lookedUp(ConfigurableEnvironment environment,
Path workingDirectory) {
Set<Path> files = new LinkedHashSet<>();
if (environment.getPropertySources().get(LOOKED_UP) instanceof LookedUp lookedUp) {
for (Path file : lookedUp.getSource()) {
files.add(workingDirectory.resolve(file).normalize());
}
}
return files;
}
/** The file a resource names, as relative as its location, or null for none. */
private static Path path(Resource resource) {
if (resource instanceof ClassPathResource || !resource.isFile()) {
return null;
}
try {
return resource.getFile().toPath();
} catch (IOException | RuntimeException error) {
// Nor can Spring read a file there.
return null;
}
}
/**
* The configuration files the server reads from the file system, highest
* precedence first, each once, absolute and normalized: those that hold
@@ -298,25 +382,30 @@ final class ServerEnvironment {
}
/**
* A file system location, which names a directory when it ends in a
* slash, or a config tree's directory.
* A location Spring reads without looking a file up: a config tree's
* directory, or a wildcard, whose last name is *, which Spring lists,
* with the name of the file it looks for in each directory of it, or
* null where it looks for each configuration file's.
*/
private record Location(Path path, boolean directory, boolean tree) {
private record Location(Path path, boolean tree, String file) {
}
/**
* The file system locations named at the top level, by the arguments or
* the process environment, and those imported, at the top level or by a
* file Spring read. Locations of another kind, such as classpath: or
* configtree:, are left out.
* The config trees and wildcards named at the top level, by the
* arguments or the process environment, and those imported, at the top
* level or by a file Spring read. Spring looks up the files of every
* other location, which prepare records.
*/
private static List<Location> named(ConfigurableEnvironment environment,
Path workingDirectory) {
List<Location> named = new ArrayList<>();
Binder binder = topLevel(environment);
for (String property : List.of("spring.config.import",
"spring.config.additional-location", "spring.config.location")) {
locations(String.join(",", topLevel(environment, property)), workingDirectory,
workingDirectory, named);
for (String value : binder.bind(property, Bindable.listOf(String.class))
.orElse(List.of())) {
locations(value, workingDirectory, null, named);
}
}
// Spring honours an import a file declares, and no other location a
// file sets; it reads a bare relative one beside that file.
@@ -332,8 +421,10 @@ final class ServerEnvironment {
Path declaring = file(resource(tracked.getOrigin(key)));
if (declaring != null) {
// Spring resolves a placeholder in it as in any value.
locations(environment.resolvePlaceholders(String.valueOf(entry.getValue())),
workingDirectory, declaring.getParent(), named);
for (String value : environment.resolvePlaceholders(
String.valueOf(entry.getValue())).split(",")) {
locations(value, workingDirectory, declaring.getParent(), named);
}
}
}
}
@@ -341,39 +432,58 @@ final class ServerEnvironment {
}
/**
* Adds the file system locations of a value: a file: one, read in the
* working directory when relative, and a bare one, read in base.
* Adds the config trees and wildcards of one location value, which
* Spring splits at semicolons, read with Spring's own rules: a
* configtree: location, in the working directory when relative; and one
* whose last directory is named *, opened as Spring opens a location,
* beside the importing file in base when Spring reads it there.
*/
private static void locations(String value, Path workingDirectory, Path base,
List<Location> named) {
if (value == null) {
return;
}
for (String location : value.split("[,;]")) {
String path = location.strip().replaceFirst("^optional:", "");
Path relativeTo = base;
boolean tree = path.startsWith("configtree:");
if (tree || path.startsWith("file:")) {
// A colon after it is part of the path; Spring reads a config
// tree in the working directory too.
path = path.substring(path.indexOf(':') + 1);
relativeTo = workingDirectory;
} else if (SCHEME.matcher(path).lookingAt()) {
for (String each : value.split(";")) {
ConfigDataLocation location = ConfigDataLocation.of(each.strip());
if (location == null) {
continue;
}
if (!path.isEmpty()) {
named.add(new Location(relativeTo.resolve(path), tree || path.endsWith("/"),
tree));
if (location.hasPrefix("configtree:")) {
named.add(new Location(workingDirectory.resolve(
location.getNonPrefixedValue("configtree:")), true, null));
continue;
}
// Spring takes a wildcard as the last directory, of a directory
// location or of a file's.
String resource = location.getNonPrefixedValue("resource:");
int slash = resource.lastIndexOf('/');
if (!resource.substring(0, slash + 1).endsWith("*/")) {
continue;
}
String file = resource.substring(slash + 1);
String parent = resource.substring(0, slash + 1 - "*/".length());
if (base != null && !parent.startsWith("/") && !FIXED.matcher(parent).lookingAt()) {
String directory = base.toUri().toString();
parent = (directory.endsWith("/") ? directory : directory + "/") + parent;
}
Path directory = path(open(parent));
if (directory != null) {
named.add(new Location(workingDirectory.resolve(directory).resolve("*"), false,
file.isEmpty() ? null : file));
}
}
}
// As Spring's LocationResourceLoader opens a location.
private static Resource open(String location) {
String cleaned = StringUtils.cleanPath(location);
return new DefaultResourceLoader().getResource(ResourceUtils.isUrl(cleaned) ? cleaned
: ResourceUtils.FILE_URL_PREFIX + cleaned);
}
/**
* A property as Spring reads it before any file, as it reads the
* locations and spring.config.name: from the arguments, the process
* A binder over the sources Spring reads before any file, as it reads
* the locations and spring.config.name: the arguments, the process
* environment and the other sources no configuration file adds.
*/
private static List<String> topLevel(ConfigurableEnvironment environment, String property) {
private static Binder topLevel(ConfigurableEnvironment environment) {
List<PropertySource<?>> sources = new ArrayList<>();
for (PropertySource<?> source : environment.getPropertySources()) {
if (!(source instanceof OriginTrackedMapPropertySource)
@@ -384,63 +494,73 @@ final class ServerEnvironment {
// from() skips the attached view of every source, files' included;
// a placeholder resolves against the same sources, as Spring's.
return new Binder(ConfigurationPropertySources.from(sources),
new PropertySourcesPlaceholdersResolver(sources))
.bind(property, Bindable.listOf(String.class)).orElse(List.of());
new PropertySourcesPlaceholdersResolver(sources));
}
private static List<String> topLevel(ConfigurableEnvironment environment, String property) {
return topLevel(environment).bind(property, Bindable.listOf(String.class))
.orElse(List.of());
}
/**
* The files Spring looks for at the locations {@link #named} finds and,
* unless spring.config.location replaces them, at its default ones in
* the working directory: each location's files, then their variants for
* these profiles, or for any profile when profiles is null. Where Spring
* refused to start, the files it read are not known, so neither is a
* location one of them imports. Adds to unlisted each directory a
* wildcard names whose directories this process may not list.
* The files Spring looks for: those prepare saw it look up, at a
* location of any spelling and for each profile it activated; then, as
* Spring lists a wildcard's directory itself, those in each directory a
* wildcard names, the default config/* among them unless
* spring.config.location is set: each name and extension, then its
* variants for these profiles, or for any profile when profiles is null,
* each with its profile. Adds to unlisted each directory a wildcard
* names whose directories this process may not list.
*/
private static Map<Path, String> locationFiles(ConfigurableEnvironment environment,
Path workingDirectory, List<String> profiles, Set<Path> unlisted) {
List<String> names = new ArrayList<>();
for (String name : topLevel(environment, "spring.config.name")) {
if (!name.isBlank()) {
names.add(name.strip());
}
Map<Path, String> located = new LinkedHashMap<>();
for (Path file : lookedUp(environment, workingDirectory)) {
located.put(file, null);
}
if (names.isEmpty()) {
names.add("application");
}
List<Path> directories = new ArrayList<>();
Set<Path> files = new LinkedHashSet<>();
List<Location> wildcards = new ArrayList<>();
for (Location named : named(environment, workingDirectory)) {
if (named.tree()) {
// Its files are values, which files() finds in their source.
continue;
}
if (named.directory()) {
directories.add(named.path());
} else {
files.add(named.path());
if (!named.tree()) {
wildcards.add(named);
}
}
if (topLevel(environment, "spring.config.location").isEmpty()) {
for (String directory : DEFAULT_DIRECTORIES) {
directories.add(workingDirectory.resolve(directory));
}
Binder binder = topLevel(environment);
// An empty spring.config.location replaces the defaults too.
if (!binder.bind("spring.config.location", Bindable.listOf(String.class)).isBound()) {
wildcards.add(new Location(workingDirectory.resolve(DEFAULT_WILDCARD), false, null));
}
for (Path location : directories) {
for (Path directory : expand(location, unlisted)) {
for (String name : names) {
for (String extension : EXTENSIONS) {
files.add(directory.resolve(name + "." + extension));
String[] names = binder.bind("spring.config.name", String[].class)
.orElse(new String[] {"application"});
List<Named> files = new ArrayList<>();
for (Location wildcard : wildcards) {
for (Path directory : expand(wildcard.path(), unlisted)) {
if (wildcard.file() == null) {
for (String name : names) {
for (String extension : EXTENSIONS) {
files.add(new Named(directory, name, "." + extension));
}
}
continue;
}
// As Spring names it: by the first loader whose extension it
// ends in, in any case, with that loader's extension, or with
// none where a location hints it; with none, Spring reads nothing.
Matcher hint = HINT.matcher(wildcard.file());
String name = hint.matches() ? hint.group(1) + hint.group(2) : wildcard.file();
for (String extension : EXTENSIONS) {
if (name.toLowerCase(Locale.ROOT).endsWith(extension)) {
files.add(new Named(directory,
name.substring(0, name.length() - extension.length() - 1),
hint.matches() ? "" : "." + extension));
break;
}
}
}
}
// Each file, then its variants, each with the profile it is for.
Map<Path, String> located = new LinkedHashMap<>();
for (Path file : files) {
located.put(file, null);
for (Named file : files) {
located.putIfAbsent(file.directory().resolve(file.root() + file.extension()), null);
}
for (Path file : files) {
for (Named file : files) {
variants(file, profiles).forEach(located::putIfAbsent);
}
return located;
@@ -457,7 +577,8 @@ final class ServerEnvironment {
}
List<Path> directories = new ArrayList<>();
Path parent = location.getParent() == null ? Path.of("") : location.getParent();
try (DirectoryStream<Path> nested = Files.newDirectoryStream(parent, Files::isDirectory)) {
try (DirectoryStream<Path> nested =
Files.newDirectoryStream(parent, ServerEnvironment::listedDirectory)) {
nested.forEach(directories::add);
} catch (AccessDeniedException denied) {
unlisted.add(parent);
@@ -469,18 +590,42 @@ final class ServerEnvironment {
}
/**
* The profile variants of a file, as application-edge.yml is of
* application.yml, each with its profile.
* Whether Spring reads an entry of a wildcard's directory as a directory
* to look in: one not named from "..", as Spring skips those. One this
* process cannot look at, as in a directory it may list but not enter,
* counts, since the service account may; hidden() then names the
* directory.
*/
private static Map<Path, String> variants(Path file, List<String> profiles) {
String name = file.getFileName().toString();
int dot = name.lastIndexOf('.');
if (dot <= 0) {
return Map.of();
private static boolean listedDirectory(Path entry) {
if (entry.getFileName().toString().startsWith("..")) {
return false;
}
String stem = name.substring(0, dot) + "-";
String extension = name.substring(dot);
Path directory = file.getParent() == null ? Path.of("") : file.getParent();
try {
return Files.readAttributes(entry, BasicFileAttributes.class).isDirectory();
} catch (AccessDeniedException denied) {
return true;
} catch (IOException gone) {
return false;
}
}
/**
* A file as Spring names it: its directory, its name without the
* extension, which a variant's profile follows, and the extension, empty
* where a location hints it.
*/
private record Named(Path directory, String root, String extension) {
}
/**
* The profile variants of a file, as application-edge.yml is of
* application.yml and app.conf-edge of app.conf[.properties], each with
* its profile.
*/
private static Map<Path, String> variants(Named file, List<String> profiles) {
Path directory = file.directory();
String stem = file.root() + "-";
String extension = file.extension();
Map<Path, String> variants = new LinkedHashMap<>();
if (profiles != null) {
for (String profile : profiles) {
@@ -594,14 +739,14 @@ final class ServerEnvironment {
}
MarkedYAMLException marked = cause(error, MarkedYAMLException.class);
if (marked != null) {
// The files with the same problem at the same place: those Spring
// reads for certain, a base file or a variant of a profile the
// arguments or the environment activate, else every one, since a
// file may activate a profile too.
// The files with the same problem at the same place, each once:
// those Spring reads for certain, one it looked up or a variant of
// a profile the arguments or the environment activate, else every
// one, since a file may activate a profile too.
Set<String> active = new LinkedHashSet<>(topLevel(environment, "spring.profiles.active"));
active.addAll(topLevel(environment, "spring.profiles.include"));
List<String> matches = new ArrayList<>();
List<String> read = new ArrayList<>();
Set<String> matches = new LinkedHashSet<>();
Set<String> read = new LinkedHashSet<>();
for (Map.Entry<Path, String> candidate : located.entrySet()) {
Path file = candidate.getKey();
if (Files.isRegularFile(file) && sameProblem(yamlError(file), marked)) {
@@ -613,7 +758,8 @@ final class ServerEnvironment {
}
}
if (matches.isEmpty()) {
// In a file an import names from inside another.
// In a file Spring found without a lookup, as an import beside
// a file a wildcard found.
return yamlFailure("a configuration file", marked);
}
return yamlFailure(String.join(" or ", read.isEmpty() ? matches : read), marked);
@@ -3,9 +3,11 @@ package com.alibaba.qwen.code.managedagent.preflight;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.AccessDeniedException;
import java.nio.file.FileSystemLoopException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.PosixFileAttributes;
import java.nio.file.attribute.PosixFilePermission;
import java.util.ArrayDeque;
@@ -22,12 +24,16 @@ import java.util.stream.Collectors;
/**
* The account the server runs as. Preflight often runs as root, which can
* read every file, so it checks file access for this account from the
* owners and modes instead. ACLs are not consulted.
* owners and modes instead, unless the account is root too. ACLs are not
* consulted.
*/
final class ServiceAccount {
private static final long LOOKUP_TIMEOUT_SECONDS = 5;
// Linux's limit on symbolic links followed in one path lookup.
static final int MAX_SYMBOLIC_LINKS = 40;
// Whom the kernel lets read any file and enter any directory, whatever
// their modes.
private static final String SUPERUSER = "root";
private final String name;
private final Set<String> groups;
@@ -82,6 +88,11 @@ final class ServiceAccount {
return groups;
}
/** Whether the account is root, by its name. */
boolean superuser() {
return name.equals(SUPERUSER);
}
/**
* Whether the account can read the file. It must enter every directory
* the kernel looks a name up in while it resolves the path: those on the
@@ -100,6 +111,15 @@ final class ServiceAccount {
}
private boolean can(Path file, boolean read) throws IOException {
if (superuser()) {
// Root may wherever the file is; where it is not, nor may root.
try {
Files.readAttributes(file, BasicFileAttributes.class);
} catch (AccessDeniedException hidden) {
// This process cannot look; root can.
}
return true;
}
Path absolute = file.toAbsolutePath();
Path current = Objects.requireNonNull(absolute.getRoot(), "an absolute path has a root");
Deque<Path> names = new ArrayDeque<>();
@@ -139,9 +159,13 @@ final class ServiceAccount {
return allows(Files.readAttributes(current, PosixFileAttributes.class), read);
}
// As the kernel does: the owner bits apply to the owner alone, then the
// group bits to any member, then the others bits.
// As the kernel does: root may, and for any other account the owner bits
// apply to the owner alone, then the group bits to any member, then the
// others bits.
boolean allows(PosixFileAttributes attributes, boolean read) {
if (superuser()) {
return true;
}
Set<PosixFilePermission> permissions = attributes.permissions();
if (attributes.owner().getName().equals(name)) {
return permissions.contains(read ? PosixFilePermission.OWNER_READ : PosixFilePermission.OWNER_EXECUTE);
@@ -96,6 +96,19 @@ class ServerEnvironmentTest {
return rows.get(0);
}
/**
* An environment Spring has prepared, or failed to, which keeps the
* files it looked for, as the one a failure is read from does.
*/
private static ConfigurableEnvironment looked(ConfigurableEnvironment environment) {
try {
ServerEnvironment.prepare(new String[0], environment);
} catch (RuntimeException refused) {
// Spring looked before it refused.
}
return environment;
}
/** What Spring throws for a file, as the server would meet it. */
private static RuntimeException thrown(ConfigurableEnvironment environment) {
return assertThrows(RuntimeException.class,
@@ -247,10 +260,11 @@ class ServerEnvironmentTest {
assertEquals(broken + ", line 2, column 1: expected the node content,"
+ " but found '<stream end>'", yaml.detail());
assertEquals("fix the YAML syntax", yaml.fix());
// In a file an import names from inside another, it says what it can.
// As in a file an import names from inside another, which Spring
// looked up as any other.
Path importer = write("importer.yml",
"spring.config.import: file:" + broken + "\n");
assertEquals("a configuration file, line 2, column 1: expected the node"
assertEquals(broken + ", line 2, column 1: expected the node"
+ " content, but found '<stream end>'",
refused(named("file:" + importer)).detail());
}
@@ -504,6 +518,39 @@ class ServerEnvironmentTest {
assertEquals("no configuration file; packaged defaults and the"
+ " environment only", rows.get(0).detail());
}
// Root enters it whatever its modes.
rows = ManagedAgentPreflight.configChecks(prepared,
new ServiceAccount("root", Set.of("root")), runner);
assertEquals(1, rows.size(), rows.toString());
assertEquals(ManagedAgentPreflight.Status.FAIL, rows.get(0).status());
assertEquals("run --check as root", rows.get(0).fix());
// A wildcard's directory it may list but not enter hides the
// directories in it, which Spring then finds none of.
Path wild = Files.createDirectories(directory.resolve("wild/edge")).getParent();
Path realWild = wild.toRealPath();
Files.setPosixFilePermissions(wild, PosixFilePermissions.fromString("r--r-xr--"));
try {
ManagedAgentPreflight.Prepared listed = prepare(
named("optional:file:" + wild + "/*/"), new ArrayList<>());
assertEquals(List.of(new ServerEnvironment.Hidden(realWild, false)),
listed.hidden());
assertEquals(ManagedAgentPreflight.Status.FAIL, ManagedAgentPreflight.hiddenCheck(
listed.hidden().get(0), member, runner).status());
} finally {
Files.setPosixFilePermissions(wild, PosixFilePermissions.fromString("rwxr-xr-x"));
}
// As does one it may not list for a file's wildcard.
assertEquals(List.of(new ServerEnvironment.Hidden(realEtc, true)), prepare(
named("optional:file:" + etc + "/*/application.yml"),
new ArrayList<>()).hidden());
// However Spring is told of a file there: a scheme in another
// case, a resource: prefix or an extension hint.
for (String location : List.of("optional:File:" + etc + "/",
"optional:resource:file:" + secret,
"optional:file:" + etc.resolve("app.conf") + "[.properties]")) {
assertEquals(List.of(new ServerEnvironment.Hidden(realEtc, false)),
prepare(named(location), new ArrayList<>()).hidden(), location);
}
// A file it does see is still judged beside the directory it cannot.
Path seen = write("seen.yml", "server.port: 9090\n");
prepared = prepare(named("file:" + seen + ",optional:file:" + secret),
@@ -548,25 +595,33 @@ class ServerEnvironmentTest {
Path locked = write("dist/config/application.yml", "server.port: 9091\n");
String user = System.getProperty("user.name");
ServerEnvironment.Failure failure = ServerEnvironment.failure(cannotRead("x"),
named("file:" + fine + ",optional:file:" + locked.getParent() + "/"),
looked(named("file:" + fine + ",optional:file:" + locked.getParent() + "/")),
file -> !file.toAbsolutePath().normalize().equals(locked),
file -> false, directory);
assertEquals(locked + " is not readable by " + user, failure.detail());
assertEquals(locked, failure.unreadable());
// A file it reads and that parses is no culprit.
assertEquals("x (Permission denied)", ServerEnvironment.failure(cannotRead("x"),
named("file:" + fine), file -> true, file -> false, directory).detail());
looked(named("file:" + fine)), file -> true, file -> false, directory).detail());
// Nor is one it cannot read when Spring failed on no read.
assertEquals("bad value", ServerEnvironment.failure(
new IllegalStateException("bad value"),
named("file:" + fine + ",optional:file:" + locked.getParent() + "/"),
looked(named("file:" + fine + ",optional:file:" + locked.getParent() + "/")),
file -> !file.toAbsolutePath().normalize().equals(locked),
file -> false, directory).detail());
// A profile variant may be what Spring could not read.
// A variant of a profile Spring activated may be what it could not read.
Path variant = write("dist/config/application-edge.yml", "server.port: 9092\n");
Map<String, Object> edge = new HashMap<>();
edge.put("SPRING_CONFIG_ADDITIONAL_LOCATION",
"file:" + fine + ",optional:file:" + locked.getParent() + "/");
edge.put("SPRING_PROFILES_ACTIVE", "edge");
assertEquals(variant + " is not readable by " + user, ServerEnvironment.failure(
cannotRead("x"),
named("file:" + fine + ",optional:file:" + locked.getParent() + "/"),
cannotRead("x"), looked(environment(edge)),
file -> !file.toAbsolutePath().normalize().equals(variant),
file -> false, directory).detail());
// Not that of a profile it did not.
assertEquals("x (Permission denied)", ServerEnvironment.failure(cannotRead("x"),
looked(named("file:" + fine + ",optional:file:" + locked.getParent() + "/")),
file -> !file.toAbsolutePath().normalize().equals(variant),
file -> false, directory).detail());
}
@@ -578,12 +633,12 @@ class ServerEnvironmentTest {
Path config = write("config/application.yml", "server:\n port: [\n");
RuntimeException error = thrown(named("file:" + config));
ServerEnvironment.Failure failure = ServerEnvironment.failure(error,
environment(Map.of()), file -> true, file -> false, directory);
looked(environment(Map.of())), file -> true, file -> false, directory);
assertEquals(config + ", line 3, column 1: expected the node content,"
+ " but found '<stream end>'", failure.detail());
// Unless spring.config.location replaces them.
ServerEnvironment.Failure replaced = ServerEnvironment.failure(error,
environment(Map.of("SPRING_CONFIG_LOCATION", "optional:classpath:/")),
looked(environment(Map.of("SPRING_CONFIG_LOCATION", "optional:classpath:/"))),
file -> true, file -> false, directory);
assertEquals("a configuration file, line 3, column 1: expected the node content,"
+ " but found '<stream end>'", replaced.detail());
@@ -598,14 +653,25 @@ class ServerEnvironmentTest {
// A location named relative to the working directory is read there.
Path relative = write("etc/relative.yml", "server.port: 9094\n");
assertEquals(relative + " is not readable by " + System.getProperty("user.name"),
ServerEnvironment.failure(cannotRead("x"), named("file:etc/relative.yml"),
ServerEnvironment.failure(cannotRead("x"),
looked(named("file:etc/relative.yml")),
file -> !file.toAbsolutePath().normalize().equals(relative),
file -> false, directory).detail());
// A file both a location and config/*/ name is named once, though
// one names it from a relative working directory, as the server's is.
Files.writeString(nested, "server: [\n");
RuntimeException twice = thrown(named("optional:file:" + nested.getParent() + "/"));
assertEquals(nested + ", line 2, column 1: expected the node content, but found"
+ " '<stream end>'", ServerEnvironment.failure(twice,
looked(named("optional:file:" + nested.getParent() + "/")),
file -> true, file -> false,
Path.of("").toAbsolutePath().relativize(directory)).detail());
Files.writeString(nested, "server.port: 9091\n");
// So is spring.config.name's.
Path renamed = write("config/server.yml", "server.port: 9093\n");
assertEquals(renamed + " is not readable by " + System.getProperty("user.name"),
ServerEnvironment.failure(cannotRead("x"),
environment(Map.of("SPRING_CONFIG_NAME", "server")),
looked(environment(Map.of("SPRING_CONFIG_NAME", "server"))),
file -> !file.toAbsolutePath().normalize().equals(renamed),
file -> false, directory).detail());
}
@@ -645,6 +711,75 @@ class ServerEnvironmentTest {
assertEquals(List.of(named), ServerEnvironment.files(prepared.environment(), directory));
}
// The files, each once, in no order: those of locations that hold no
// setting have no precedence to keep.
private static void assertSameFiles(Set<Path> expected, List<Path> files) {
assertEquals(expected, Set.copyOf(files), files.toString());
assertEquals(expected.size(), files.size(), files.toString());
}
@Test
void findsTheFilesSpringLooksForHoweverALocationIsSpelled() throws IOException {
// Each holds no setting, so only where Spring looked finds it: a
// scheme in another case, a resource: prefix, and a name with a
// colon, which Spring reads as a file in the working directory.
Path upper = write("upper/application.yml", "# spring.datasource.password: x\n");
Path resource = write("resource/application.yml", "# server.port: 9090\n");
Path colon = write("foo:bar.yml", "# server.port: 9090\n");
ManagedAgentPreflight.Prepared prepared = prepare(named("optional:File:"
+ upper.getParent() + "/,optional:resource:file:" + resource.getParent()
+ "/,optional:foo:bar.yml"), new ArrayList<>());
assertNull(prepared.failure());
assertSameFiles(Set.of(colon, resource, upper),
ServerEnvironment.files(prepared.environment(), directory));
// A wildcard, which Spring lists rather than looks a file up in,
// named so or imported beside the file that imports it.
Path listed = write("listed/edge/application.yml", "# server.port: 9090\n");
Path importer = write("importer/application.yml",
"spring.config.import: optional:conf.d/*/\n");
Path imported = write("importer/conf.d/edge/application.yml", "# server.port: 9091\n");
prepared = prepare(named("optional:File:" + directory.resolve("listed") + "/*/,optional:file:"
+ importer.getParent() + "/"), new ArrayList<>());
assertNull(prepared.failure());
assertSameFiles(Set.of(importer, listed, imported),
ServerEnvironment.files(prepared.environment(), directory));
// A file's wildcard: Spring looks for the one name in each directory,
// and for the variant of each profile, named as for a hinted file.
Path wild = write("fw/b/application.yml", "# server.port: 9090\n");
Path hinted = write("fn/a/notes.conf", "qwen.preflight.probe=hinted\n");
Path variant = write("fn/a/notes.conf-edge", "# server.port: 9091\n");
prepared = prepare(named("optional:file:" + directory.resolve("fw") + "/*/application.yml"
+ ",optional:file:" + directory.resolve("fn") + "/*/notes.conf[.properties]"),
new ArrayList<>(), "--spring.profiles.active=edge");
assertNull(prepared.failure());
assertEquals("hinted", prepared.environment().getProperty("qwen.preflight.probe"));
assertSameFiles(Set.of(wild, hinted, variant),
ServerEnvironment.files(prepared.environment(), directory));
// Spring matches the extension in any case and looks for the
// loader's own, and reads nothing for an extension no loader takes.
Path lower = write("fu/a/application.yml", "# server.port: 9090\n");
write("fj/a/notes.conf", "# server.port: 9090\n");
prepared = prepare(named("optional:file:" + directory.resolve("fu") + "/*/application.YML"
+ ",optional:file:" + directory.resolve("fj") + "/*/notes.conf[.json]"),
new ArrayList<>());
assertNull(prepared.failure());
assertSameFiles(Set.of(lower), ServerEnvironment.files(prepared.environment(), directory));
// An empty spring.config.location or spring.config.name leaves
// Spring no file to look for, not its defaults.
Path config = write("config/application.yml", "# server.port: 9090\n");
write("config/edge/application.yml", "# server.port: 9091\n");
// Spring lists no directory named from "..", as a ConfigMap's ..data.
write("config/..data/application.yml", "# server.port: 9092\n");
assertEquals(List.of(config, directory.resolve("config/edge/application.yml")),
ServerEnvironment.files(prepare(environment(Map.of()), new ArrayList<>())
.environment(), directory));
for (String property : List.of("SPRING_CONFIG_LOCATION", "SPRING_CONFIG_NAME")) {
assertEquals(List.of(), ServerEnvironment.files(prepare(
environment(Map.of(property, "")), new ArrayList<>()).environment(),
directory), property);
}
}
@Test
void listsEachFileOfAConfigTree() throws IOException {
// Kubernetes mounts a Secret as one file for each property.
@@ -869,6 +1004,21 @@ class ServerEnvironmentTest {
assertEquals("s3cret", prepared.environment().getProperty("spring.datasource.password"));
assertEquals(List.of(hinted), prepared.files());
assertEquals(ManagedAgentPreflight.Status.WARN, rows(prepared).get(0).status());
// Spring reads one whose lines are all comments too, though it
// declares no key, and the variant of a profile beside it, which
// Spring names after the hinted file as written.
Path template = write("notes/notes.conf", "# spring.datasource.password=change-me\n");
Path edge = write("notes/notes.conf-edge", "# spring.datasource.password=change-me\n");
for (Path file : List.of(template, edge)) {
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-r--r--"));
}
prepared = prepare(named("file:" + template + "[.properties]"), new ArrayList<>(),
"--spring.profiles.active=edge");
assertEquals(List.of(template, edge), prepared.files());
rows = rows(prepared);
assertEquals(List.of(ManagedAgentPreflight.Status.WARN, ManagedAgentPreflight.Status.WARN),
rows.stream().map(ManagedAgentPreflight.Check::status).toList(), rows.toString());
assertEquals("chmod 600 " + template, rows.get(0).fix());
// It is a value file too, of a property named app.conf, under a config
// tree imported over its directory; its keys still tell.
prepared = prepare(named("file:" + hinted + "[.properties]"), new ArrayList<>(),
@@ -967,9 +1117,9 @@ class ServerEnvironmentTest {
variables.put("SPRING_PROFILES_INCLUDE", "zzz");
assertEquals(zzz + problem, refused(environment(variables)).detail());
// The profile set in a file, which the environment Spring was given
// does not hold: both are named.
// does not hold: Spring looked up its variant alone.
Files.writeString(config, "spring.profiles.active: zzz\n");
assertEquals(aaa + " or " + zzz + problem,
assertEquals(zzz + problem,
refused(named("optional:file:" + config.getParent() + "/")).detail());
// A base file Spring reads for certain is the one, even beside a
// variant with the same error.
@@ -1066,7 +1216,11 @@ class ServerEnvironmentTest {
Path little = directory.resolve("config/application.yaml");
Files.write(little, ("\ufeffspring.datasource.password: s3cret\n")
.getBytes(StandardCharsets.UTF_16LE));
for (Path file : List.of(properties, big, little)) {
// Or as UTF-8 after one, which it drops, as before a commented-out key.
Path marked = directory.resolve("config/marked.yml");
Files.write(marked, ("\ufeff# spring.datasource.password: s3cret\n")
.getBytes(StandardCharsets.UTF_8));
for (Path file : List.of(properties, big, little, marked)) {
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-r--r--"));
ManagedAgentPreflight.Check check = ManagedAgentPreflight.configFileCheck(file, null);
assertEquals(ManagedAgentPreflight.Status.WARN, check.status(), check.toString());
@@ -30,6 +30,12 @@ class ServiceAccountTest {
Path file = Files.writeString(directory.resolve("harness.env"), "");
PosixFileAttributes attributes = Files.readAttributes(file, PosixFileAttributes.class);
String owner = attributes.owner().getName();
if (owner.equals("root")) {
// Root reads any file, so the owner bits need another owner.
owner = "nobody";
Files.setOwner(file, file.getFileSystem().getUserPrincipalLookupService()
.lookupPrincipalByName(owner));
}
String group = attributes.group().getName();
ServiceAccount self = new ServiceAccount(owner, Set.of());
ServiceAccount member = new ServiceAccount(STRANGER, Set.of(group));
@@ -50,6 +56,26 @@ class ServiceAccountTest {
assertFalse(self.canRead(file));
}
@Test
void letsRootReadAndEnterWhateverTheModes(@TempDir Path directory) throws IOException {
Path locked = Files.createDirectory(directory.resolve("locked"));
Path file = Files.writeString(locked.resolve("application.yml"), "");
mode(file, "---------");
mode(locked, "---------");
try {
ServiceAccount root = new ServiceAccount("root", Set.of("root"));
assertTrue(root.canEnter(locked));
assertTrue(root.canRead(file));
assertTrue(root.allows(Files.readAttributes(locked, PosixFileAttributes.class), true));
// Not an account of root's group, which the modes keep out.
assertFalse(new ServiceAccount(STRANGER, Set.of("root")).canEnter(locked));
// Nor root a file that is not there.
assertThrows(IOException.class, () -> root.canRead(directory.resolve("missing.env")));
} finally {
mode(locked, "rwx------");
}
}
@Test
void needsEveryDirectoryAboveTheFile(@TempDir Path directory) throws IOException {
Path nested = Files.createDirectory(directory.resolve("config"));