1   /*
2    * Copyright 2017 LINE Corporation
3    *
4    * LINE Corporation licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with the License. You may obtain a copy of the License at:
7    *
8    *   https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
15   */
16  
17  package com.linecorp.centraldogma.server.internal.storage.repository.git;
18  
19  import static com.linecorp.centraldogma.internal.Util.validatePathPattern;
20  
21  import java.util.ArrayList;
22  import java.util.List;
23  import java.util.Map;
24  import java.util.regex.Pattern;
25  
26  import org.eclipse.jgit.treewalk.TreeWalk;
27  import org.eclipse.jgit.treewalk.filter.TreeFilter;
28  
29  import com.linecorp.centraldogma.server.storage.repository.Repository;
30  
31  final class PathPatternFilter extends TreeFilter {
32  
33      private static final Pattern SPLIT = Pattern.compile("\\s*,\\s*");
34  
35      private static final ThreadLocal<LruMap<String, PathPatternFilter>> filterCache =
36              LruMap.newThreadLocal(512);
37  
38      private static final ThreadLocal<LruMap<String, Pattern>> regexCache = LruMap.newThreadLocal(1024);
39  
40      static PathPatternFilter of(String pathPattern) {
41          final LruMap<String, PathPatternFilter> map = filterCache.get();
42          PathPatternFilter f = map.get(pathPattern);
43          if (f == null) {
44              f = new PathPatternFilter(pathPattern);
45              map.put(pathPattern, f);
46          }
47  
48          return f;
49      }
50  
51      private final Pattern[] pathPatterns;
52      private final String pathPattern;
53  
54      private PathPatternFilter(String pathPattern) {
55          validatePathPattern(pathPattern, "pathPattern");
56  
57          final String[] pathPatterns = SPLIT.split(pathPattern);
58          final StringBuilder pathPatternBuf = new StringBuilder(pathPattern.length());
59          final List<Pattern> compiledPathPatterns = new ArrayList<>(pathPatterns.length);
60          boolean matchAll = false;
61          for (String p: pathPatterns) {
62              if (Repository.ALL_PATH.equals(p)) {
63                  matchAll = true;
64                  break;
65              }
66  
67              if (p.isEmpty()) {
68                  continue;
69              }
70  
71              final String normalized = normalize(p);
72              compiledPathPatterns.add(compile(normalized));
73              pathPatternBuf.append(normalized).append(',');
74          }
75  
76          if (matchAll) {
77              this.pathPatterns = null;
78              this.pathPattern = "/**";
79          } else {
80              if (compiledPathPatterns.isEmpty()) {
81                  throw new IllegalArgumentException("pathPattern is empty.");
82              }
83  
84              this.pathPatterns = compiledPathPatterns.toArray(new Pattern[compiledPathPatterns.size()]);
85              this.pathPattern = pathPatternBuf.substring(0, pathPatternBuf.length() - 1);
86          }
87      }
88  
89      private static String normalize(String p) {
90          final String normalized;
91          if (p.charAt(0) != '/') {
92              normalized = "/**/" + p;
93          } else {
94              normalized = p;
95          }
96          return normalized;
97      }
98  
99      private static Pattern compile(final String pathPattern) {
100         if (pathPattern.isEmpty()) {
101             throw new IllegalArgumentException("contains an empty path pattern");
102         }
103 
104         final Map<String, Pattern> map = regexCache.get();
105         Pattern compiled = map.get(pathPattern);
106         if (compiled == null) {
107             compiled = compileUncached(pathPattern);
108             map.put(pathPattern, compiled);
109         }
110 
111         return compiled;
112     }
113 
114     private static Pattern compileUncached(String pathPattern) {
115         if (pathPattern.charAt(0) != '/') {
116             pathPattern = "/**/" + pathPattern;
117         }
118 
119         final int pathPatternLen = pathPattern.length();
120         final StringBuilder buf = new StringBuilder(pathPatternLen).append('^');
121         int asterisks = 0;
122         char beforeAsterisk = '/';
123 
124         for (int i = 1; i < pathPatternLen; i++) { // Start from '1' to skip the first '/'.
125             final char c = pathPattern.charAt(i);
126             if (c == '*') {
127                 asterisks++;
128                 if (asterisks > 2) {
129                     throw new IllegalArgumentException(
130                             "contains a path pattern with invalid wildcard characters: " + pathPattern +
131                             " (only * and ** are allowed)");
132                 }
133                 continue;
134             }
135 
136             switch (asterisks) {
137             case 1:
138                 // Handle '/*/' specially.
139                 if (beforeAsterisk == '/' && c == '/') {
140                     buf.append("[^/]+");
141                 } else {
142                     buf.append("[^/]*");
143                 }
144                 break;
145             case 2:
146                 // Handle '/**/' specially.
147                 if (beforeAsterisk == '/' && c == '/') {
148                     buf.append("(?:.+/)?");
149                     asterisks = 0;
150                     beforeAsterisk = c;
151                     continue;
152                 }
153 
154                 buf.append(".*");
155                 break;
156             }
157 
158             asterisks = 0;
159             beforeAsterisk = c;
160 
161             switch (c) {
162             case '\\':
163             case '.':
164             case '^':
165             case '$':
166             case '?':
167             case '+':
168             case '{':
169             case '}':
170             case '[':
171             case ']':
172             case '(':
173             case ')':
174             case '|':
175                 buf.append('\\');
176                 buf.append(c);
177                 break;
178             default:
179                 buf.append(c);
180             }
181         }
182 
183         // Handle the case where the pattern ends with asterisk(s).
184         switch (asterisks) {
185         case 1:
186             if (beforeAsterisk == '/') {
187                 // '/*<END>'
188                 buf.append("[^/]+");
189             } else {
190                 buf.append("[^/]*");
191             }
192             break;
193         case 2:
194             buf.append(".*");
195             break;
196         }
197 
198         return Pattern.compile(buf.append('$').toString());
199     }
200 
201     @Override
202     public boolean include(TreeWalk walker) {
203         if (walker.isSubtree()) {
204             return true;
205         }
206 
207         return matches(walker);
208     }
209 
210     public boolean matches(TreeWalk walker) {
211         if (pathPatterns == null) {
212             return true;
213         }
214 
215         for (Pattern p: pathPatterns) {
216             if (p.matcher(walker.getPathString()).matches()) {
217                 return true;
218             }
219         }
220 
221         return false;
222     }
223 
224     public boolean matches(String path) {
225         if (pathPatterns == null) {
226             return true;
227         }
228 
229         for (Pattern p: pathPatterns) {
230             if (p.matcher(path).matches()) {
231                 return true;
232             }
233         }
234 
235         return false;
236     }
237 
238     public boolean matchesAll() {
239         return pathPatterns == null;
240     }
241 
242     @Override
243     public boolean shouldBeRecursive() {
244         return true;
245     }
246 
247     @Override
248     @SuppressWarnings("CloneInNonCloneableClass")
249     public PathPatternFilter clone() {
250         return this;
251     }
252 
253     @Override
254     public int hashCode() {
255         return pathPattern.hashCode();
256     }
257 
258     @Override
259     public boolean equals(Object obj) {
260         if (!(obj instanceof PathPatternFilter)) {
261             return false;
262         }
263 
264         if (this == obj) {
265             return true;
266         }
267 
268         return pathPattern.equals(((PathPatternFilter) obj).pathPattern);
269     }
270 
271     @Override
272     public String toString() {
273         return pathPattern;
274     }
275 }