LevelFileProperties.java
package io.jstach.rainbowgum.simple.props;
import java.io.IOException;
import java.io.StringReader;
import java.io.UncheckedIOException;
import java.lang.System.Logger.Level;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFileAttributes;
import java.nio.file.attribute.PosixFilePermission;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.jspecify.annotations.Nullable;
import io.jstach.rainbowgum.LogConfig;
import io.jstach.rainbowgum.LogEventFactory;
import io.jstach.rainbowgum.LogProperties;
import io.jstach.rainbowgum.LogReporter;
/*
* Levels from a file that is polled for changes. Only logging.level keys are answered,
* ahead of every other source; any other key in the file is ignored with a warning. Keys
* may leave off the logging prefix, as in logging.properties.
*
* Whoever can write the file controls levels, and so what reaches the logs, so like ssh's
* StrictModes the file is refused while it or its directory can be written by group or
* others, or is owned by someone other than the user running the application or root.
* Symbolic links also have the target's directory checked. The permission check is
* skipped where POSIX permissions are not available.
*/
final class LevelFileProperties implements LogProperties.Listable, LogReporter.Reportable, AutoCloseable {
static final String FILE_NAME = "level.properties";
static final Duration POLL_INTERVAL = Duration.ofSeconds(2);
/*
* Above system properties (400), so a level in the file wins over every other source.
*/
static final int ORDER = 500;
private static final LogEventFactory EVENTS = LogEventFactory.of(SimpleProperties.class.getName());
private final Path path;
private final Duration pollInterval;
private volatile SimpleLogProperties current;
private volatile @Nullable Thread watcher;
/*
* Why the file is refused, or null while it is read.
*/
private volatile @Nullable String refused;
LevelFileProperties(Path path, Duration pollInterval) {
this.path = path.toAbsolutePath();
this.pollInterval = pollInterval;
this.refused = refusal(this.path);
this.current = refused == null ? read(this.path) : emptyFor(this.path);
}
enum Poll {
UNCHANGED, CHANGED, REFUSED
}
Path path() {
return path;
}
static boolean isLevelKey(String key) {
return key.equals(LogProperties.ROOT_PREFIX + "level") || key.startsWith(LogProperties.ROOT_PREFIX + "level.");
}
@Override
public @Nullable String valueOrNull(String key) {
return isLevelKey(key) ? current.valueOrNull(key) : null;
}
@Override
public String description(String key) {
return current.description(key);
}
@Override
public List<String> keys() {
return current.keys();
}
@Override
public int order() {
return ORDER;
}
@Override
public void report(Appendable out) throws IOException {
out.append("LEVEL_FILE[").append(path.toString()).append("]");
}
/*
* Keys in the file this ignores, each as a warning message.
*/
List<String> ignoredKeyMessages() {
return current.keys()
.stream()
.filter(k -> !isLevelKey(k))
.map(k -> "Ignoring property key '" + k + "' from " + current.description(k)
+ ": the level file only sets logging.level keys.")
.toList();
}
/*
* Rereads the file, which is small, rather than trusting its modification time, so a
* quick edit of the same length is not missed. Rewriting the file with the same
* levels is not a change. A file that becomes refused keeps the levels last read from
* it and is REFUSED once per reason, so polling does not repeat the alert.
*/
Poll poll() {
String reason = refusal(path);
if (reason != null) {
boolean changed = !reason.equals(refused);
refused = reason;
return changed ? Poll.REFUSED : Poll.UNCHANGED;
}
refused = null;
var read = read(path);
if (levels(read).equals(levels(current))) {
return Poll.UNCHANGED;
}
current = read;
return Poll.CHANGED;
}
@Nullable String refused() {
return refused;
}
/*
* Why the file or its directory could be changed by someone other than the user
* running the application or root, or null. A missing file only has its directory
* checked, since the file can appear later.
*/
static @Nullable String refusal(Path file) {
var dir = file.getParent();
String reason = dir == null ? null : refusal(file, dir, "its directory " + dir);
if (reason != null) {
return reason;
}
Path resolved;
try {
resolved = file.toRealPath();
}
catch (NoSuchFileException e) {
return Files.isSymbolicLink(file)
? "Refusing level file " + file + ": its symbolic link target does not exist." : null;
}
catch (IOException e) {
throw new UncheckedIOException(e);
}
var resolvedDir = resolved.getParent();
if (resolvedDir != null && !resolvedDir.equals(dir)) {
reason = refusal(file, resolvedDir, "its directory " + resolvedDir);
if (reason != null) {
return reason;
}
}
return refusal(file, resolved, "it");
}
private static @Nullable String refusal(Path file, Path checked, String what) {
PosixFileAttributes attributes;
try {
attributes = Files.readAttributes(checked, PosixFileAttributes.class);
}
catch (UnsupportedOperationException | NoSuchFileException e) {
return null;
}
catch (IOException e) {
throw new UncheckedIOException(e);
}
var permissions = attributes.permissions();
if (permissions.contains(PosixFilePermission.GROUP_WRITE)
|| permissions.contains(PosixFilePermission.OTHERS_WRITE)) {
return "Refusing level file " + file + ": " + what
+ " is writable by group or others. Remove that access, for example with chmod go-w.";
}
String owner = attributes.owner().getName();
String user = System.getProperty("user.name");
if (!owner.equals("root") && user != null && !user.equals("?") && !owner.equals(user)) {
return "Refusing level file " + file + ": " + what + " is owned by '" + owner + "', not by '" + user
+ "' running the application or root.";
}
return null;
}
/*
* Starts polling: on a change, an info alert, warnings for ignored keys, and a change
* published so levels are resolved again.
*/
void watch(LogConfig config) {
if (watcher != null) {
return;
}
var alerts = config.alerts();
String reason = refused;
if (reason != null) {
alerts.alert(EVENTS.eventNoArg(Level.ERROR, reason, null));
}
for (var message : ignoredKeyMessages()) {
alerts.alert(EVENTS.eventNoArg(Level.WARNING, message, null));
}
watcher = Thread.ofPlatform().daemon().name("rainbowgum-level-file").start(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
Thread.sleep(pollInterval);
switch (poll()) {
case CHANGED -> {
alerts.info(SimpleProperties.class, "Level file changed: " + path);
for (var message : ignoredKeyMessages()) {
alerts.alert(EVENTS.eventNoArg(Level.WARNING, message, null));
}
config.changePublisher().publish();
}
case REFUSED -> {
String r = refused;
if (r != null) {
alerts.alert(EVENTS.eventNoArg(Level.ERROR, r, null));
}
}
case UNCHANGED -> {
}
}
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
catch (RuntimeException e) {
alerts.alert(EVENTS.eventNoArg(Level.ERROR, "Failed to read level file: " + path, e));
}
}
});
}
@Override
public void close() {
var w = watcher;
if (w != null) {
w.interrupt();
watcher = null;
}
}
private static Map<String, String> levels(SimpleLogProperties properties) {
var levels = new TreeMap<String, String>();
for (var key : properties.keys()) {
if (isLevelKey(key)) {
var value = properties.valueOrNull(key);
if (value != null) {
levels.put(key, value);
}
}
}
return levels;
}
private static SimpleLogProperties read(Path path) {
try {
String content = Files.readString(path, StandardCharsets.UTF_8);
return SimpleLogProperties.read(new StringReader(content), path.toString()).withShortKeys();
}
catch (NoSuchFileException e) {
return emptyFor(path);
}
catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static SimpleLogProperties emptyFor(Path path) {
try {
return SimpleLogProperties.read(new StringReader(""), path.toString());
}
catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}