001/*
002 * Logback: the reliable, generic, fast and flexible logging framework.
003 * Copyright (C) 1999-2026, QOS.ch. All rights reserved.
004 *
005 * This program and the accompanying materials are dual-licensed under
006 * either the terms of the Eclipse Public License v2.0 as published by
007 * the Eclipse Foundation
008 *
009 *   or (per the licensee's choosing)
010 *
011 * under the terms of the GNU Lesser General Public License version 2.1
012 * as published by the Free Software Foundation.
013 */
014
015package ch.qos.logback.core.model.processor;
016
017import ch.qos.logback.core.Context;
018import ch.qos.logback.core.FileAppender;
019import ch.qos.logback.core.model.AppenderModel;
020import ch.qos.logback.core.model.ImplicitModel;
021import ch.qos.logback.core.model.Model;
022import ch.qos.logback.core.model.SiftModel;
023import ch.qos.logback.core.rolling.RollingFileAppender;
024import ch.qos.logback.core.rolling.helper.FileNamePattern;
025
026import java.util.ArrayList;
027import java.util.HashMap;
028import java.util.List;
029import java.util.Map;
030import java.util.Optional;
031import java.util.function.Supplier;
032import java.util.stream.Collectors;
033import java.util.stream.Stream;
034
035@PhaseIndicator(phase = ProcessingPhase.DEPENDENCY_ANALYSIS)
036public class FileCollisionAnalyser extends ModelHandlerBase {
037
038    public FileCollisionAnalyser(Context context) {
039        super(context);
040    }
041
042    @Override
043    protected Class<AppenderModel> getSupportedModelClass() {
044        return AppenderModel.class;
045    }
046
047
048    @Override
049    public void handle(ModelInterpretationContext mic, Model model) throws ModelHandlerException {
050        AppenderModel appenderModel = (AppenderModel) model;
051
052        String originalClassName = appenderModel.getClassName();
053        String className = mic.getImport(originalClassName);
054
055        String appenderName = appenderModel.getName();
056
057        // A SiftingAppender instantiates its nested appender once per discriminator
058        // value at runtime, so the nested appender escapes the static collision maps
059        // handled below. If its file/fileNamePattern does not reference the
060        // discriminator key, every instance resolves to the same target and their
061        // output silently collides. Detect that at model-analysis time (see #1041).
062        Model siftModel = firstSubModelOfClass(appenderModel, SiftModel.class);
063        if (siftModel != null) {
064            checkSiftingAppenderForCollision(mic, appenderModel, siftModel, appenderName);
065            return;
066        }
067
068        if (!fileAppenderOrRollingFileAppender(className)) {
069            return;
070        }
071
072        String tagName0 = "file";
073        checkForCollisions(mic, MapKey.FILE_COLLISION_MAP_KEY, appenderModel, appenderName, tagName0);
074
075        String tagName1 = "fileNamePattern";
076        checkForCollisions(mic, MapKey.RFA_FILENAME_COLLISION_MAP, appenderModel, appenderName, tagName1);
077    }
078
079    private static boolean fileAppenderOrRollingFileAppender(String className) {
080        return FileAppender.class.getName().equals(className) || RollingFileAppender.class.getName().equals(className);
081    }
082
083
084    boolean tagPredicate(Model model, String tagName) {
085        return (model instanceof ImplicitModel) && tagName.equals(model.getTag());
086    }
087
088    enum MapKey {
089        FILE_COLLISION_MAP_KEY, RFA_FILENAME_COLLISION_MAP
090    }
091
092    private void checkForCollisions(ModelInterpretationContext mic, MapKey mapKey, AppenderModel appenderModel, String appenderName, final String tagName) {
093
094
095        Stream<Model> streamLevel1 = appenderModel.getSubModels().stream();
096        Stream<Model> streamLevel2 = appenderModel.getSubModels().stream().flatMap(child -> child.getSubModels().stream());
097
098        List<Model> matchingModels = Stream.concat(streamLevel1, streamLevel2).filter(m -> tagPredicate(m, tagName)).collect(Collectors.toList());
099
100        if(!matchingModels.isEmpty()) {
101            ImplicitModel implicitModel = (ImplicitModel) matchingModels.get(0);
102            String bodyValue = mic.subst(implicitModel.getBodyText());
103
104
105            Map<String, String> collisionMap = getCollisionMapByKey(mic, mapKey);
106
107            Optional<Map.Entry<String, String>> collision = collisionMap.entrySet()
108                    .stream()
109                    .filter(entry -> bodyValue.equals(entry.getValue()))
110                    .findFirst();
111
112            if (collision.isPresent()) {
113                addErrorForCollision(tagName, appenderName, collision.get().getKey(), bodyValue);
114                appenderModel.markAsHandled();
115                appenderModel.deepMarkAsSkipped();
116            } else {
117                // add to collision map if and only if no collision detected
118                // reasoning: single entry is as effective as multiple entries for collision detection
119                collisionMap.put(appenderName, bodyValue);
120            }
121        }
122    }
123
124    private Map<String, String> getCollisionMapByKey(ModelInterpretationContext mic, MapKey mapKey) {
125        Map<String, String> map = (Map<String, String>) mic.getObjectMap().get(mapKey.name());
126        if(map == null) {
127            map = new HashMap<>();
128            mic.getObjectMap().put(mapKey.name(), map);
129        }
130        return map;
131    }
132
133
134    static public final String COLLISION_DETECTED = "Collision detected. Skipping initialization of appender named [%s]";
135    static public final String COLLISION_MESSAGE = "In appender [%s] option '%s' has the same value '%s' as that set for appender [%s] defined earlier";
136    private void addErrorForCollision(String optionName, String appenderName, String previousAppenderName, String optionValue) {
137        addError(String.format(COLLISION_DETECTED, appenderName));
138        addError(String.format(COLLISION_MESSAGE, appenderName, optionName, optionValue, previousAppenderName));
139    }
140
141    static public final String SIFT_COLLISION_MESSAGE_0 =
142            "The nested appender of SiftingAppender [%s] does not reference the discriminator key [%s] in its 'file'/'fileNamePattern'. ";
143    static public final String SIFT_COLLISION_MESSAGE_1 =
144            "Every child appender will therefore write to the same file [%s] causing collisions. ";
145    static public final String SIFT_COLLISION_MESSAGE_2 = "Consider embedding ${%s} in the file path.";
146
147    /**
148     * Detect the SiftingAppender variant of a file collision: the nested appender is
149     * created once per discriminator value, so unless its file/fileNamePattern embeds a
150     * reference to the discriminator key, all instances resolve to the same target.
151     */
152    private void checkSiftingAppenderForCollision(ModelInterpretationContext mic, AppenderModel appenderModel,
153            Model siftModel, String appenderName) {
154
155        String discriminatorKey = findDiscriminatorKey(appenderModel);
156        if (discriminatorKey == null || discriminatorKey.isEmpty()) {
157            // discriminator key not declared in XML (e.g. a discriminator with a built-in
158            // default key); can't reason about the file pattern, so stay silent.
159            return;
160        }
161
162        Model nestedAppenderModel = firstSubModelOfClass(siftModel, AppenderModel.class);
163        if (nestedAppenderModel == null) {
164            return;
165        }
166
167        List<String> fileValues = new ArrayList<>();
168        collectBodyText(nestedAppenderModel, "file", fileValues);
169        collectBodyText(nestedAppenderModel, "fileNamePattern", fileValues);
170
171        if (fileValues.isEmpty()) {
172            // nested appender writes to no file (e.g. ConsoleAppender); nothing to collide.
173            return;
174        }
175
176        String keyReference = "${" + discriminatorKey;
177        boolean referencesKey = fileValues.stream().anyMatch(v -> v.contains(keyReference));
178        if (!referencesKey) {
179            String resolvedTarget = mic.subst(fileValues.get(0));
180            addWarn(String.format(SIFT_COLLISION_MESSAGE_0, appenderName, discriminatorKey));
181            addWarn(String.format(SIFT_COLLISION_MESSAGE_1, resolvedTarget));
182            addWarn(String.format(SIFT_COLLISION_MESSAGE_2, discriminatorKey));
183        }
184    }
185
186    private Model firstSubModelOfClass(Model parent, Class<? extends Model> modelClass) {
187        return parent.getSubModels().stream().filter(modelClass::isInstance).findFirst().orElse(null);
188    }
189
190    private String findDiscriminatorKey(AppenderModel appenderModel) {
191        Optional<Model> discriminator = appenderModel.getSubModels().stream()
192                .filter(m -> "discriminator".equalsIgnoreCase(m.getTag())).findFirst();
193
194        if (discriminator.isEmpty()) {
195            return null;
196        }
197
198        for (Model child : discriminator.get().getSubModels()) {
199            if ("key".equalsIgnoreCase(child.getTag())) {
200                String bodyText = child.getBodyText();
201                if (bodyText != null) {
202                    return bodyText.trim();
203                }
204            }
205        }
206        return null;
207    }
208
209    /**
210     * Collect the raw body text of every {@code <tagName>} element nested (one or two levels
211     * deep) inside the given appender model. Raw text is used on purpose: the discriminator
212     * key is only bound as a substitution property at runtime, so it must be matched textually.
213     */
214    private void collectBodyText(Model appenderModel, String tagName, List<String> out) {
215        for (Model child : appenderModel.getSubModels()) {
216            // level 1: direct children of the appender (e.g. <file>)
217            addBodyTextIfMatching(child, tagName, out);
218            // level 2: grandchildren (e.g. <fileNamePattern> under a rolling policy)
219            for (Model grandChild : child.getSubModels()) {
220                addBodyTextIfMatching(grandChild, tagName, out);
221            }
222        }
223    }
224
225    private void addBodyTextIfMatching(Model model, String tagName, List<String> out) {
226        if (!tagPredicate(model, tagName)) {
227            return;
228        }
229        String bodyText = model.getBodyText();
230        if (bodyText != null) {
231            out.add(bodyText);
232        }
233    }
234}