001/* -*- mode: Java; c-basic-offset: 2; indent-tabs-mode: nil; coding: utf-8-unix -*- 002 * 003 * Copyright © 2017-2018 microBean. 004 * 005 * Licensed under the Apache License, Version 2.0 (the "License"); 006 * you may not use this file except in compliance with the License. 007 * You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 014 * implied. See the License for the specific language governing 015 * permissions and limitations under the License. 016 */ 017package org.microbean.helm.chart; 018 019import java.io.BufferedReader; 020import java.io.File; 021import java.io.IOException; 022import java.io.Reader; 023 024import java.nio.charset.StandardCharsets; 025 026import java.nio.file.Files; 027import java.nio.file.LinkOption; // for javadoc only 028import java.nio.file.Path; 029 030import java.nio.file.PathMatcher; 031 032import java.util.ArrayList; 033import java.util.Collection; 034import java.util.Collections; 035 036import java.util.function.Predicate; 037 038import java.util.regex.Matcher; 039import java.util.regex.Pattern; 040import java.util.regex.PatternSyntaxException; 041 042import java.util.stream.Collectors; 043 044/** 045 * A {@link PathMatcher} and a {@link Predicate Predicate<Path>} 046 * that {@linkplain #matches(Path) matches} paths using the syntax of 047 * a {@code .helmignore} file. 048 * 049 * <p>This class passes <a 050 * href="https://github.com/kubernetes/helm/blob/v2.5.0/pkg/ignore/rules_test.go#L91-L121">all 051 * of the unit tests present</a> in the <a 052 * href="http://godoc.org/k8s.io/helm/pkg/ignore">Helm project's 053 * package concerned with {@code .helmignore} files</a>. It may 054 * permit richer syntax, but there are no guarantees made regarding 055 * the behavior of this class in such cases.</p> 056 * 057 * <h2>Thread Safety</h2> 058 * 059 * <p>This class is safe for concurrent use by multiple threads.</p> 060 * 061 * @author <a href="https://about.me/lairdnelson" 062 * target="_parent">Laird Nelson</a> 063 * 064 * @see <a href="http://godoc.org/k8s.io/helm/pkg/ignore">The Helm 065 * project's package concerned with {@code .helmignore} files</a> 066 */ 067public class HelmIgnorePathMatcher implements PathMatcher, Predicate<Path> { 068 069 070 /* 071 * Instance fields. 072 */ 073 074 075 /** 076 * A {@link Collection} of {@link Predicate Predicate<Path>}s, 077 * one of which must {@linkplain #matches(Path) match} for the 078 * {@link #matches(Path)} method to return {@code true}. 079 * 080 * <p>This field is never {@code null}.</p> 081 * 082 * @see #addPatterns(Collection) 083 */ 084 private final Collection<Predicate<Path>> rules; 085 086 087 /* 088 * Constructors. 089 */ 090 091 092 /** 093 * Creates a new {@link HelmIgnorePathMatcher}. 094 */ 095 public HelmIgnorePathMatcher() { 096 super(); 097 this.rules = new ArrayList<>(); 098 this.addPattern("templates/.?*"); 099 } 100 101 /** 102 * Creates a new {@link HelmIgnorePathMatcher}. 103 * 104 * @param stringPatterns a {@link Collection} of <a 105 * href="http://godoc.org/k8s.io/helm/pkg/ignore">valid {@code 106 * .helmignore} patterns</a>; may be {@code null} 107 * 108 * @exception PatternSyntaxException if any of the patterns is 109 * invalid 110 */ 111 public HelmIgnorePathMatcher(final Collection<? extends String> stringPatterns) { 112 this(); 113 this.addPatterns(stringPatterns); 114 } 115 116 /** 117 * Creates a new {@link HelmIgnorePathMatcher}. 118 * 119 * @param reader a {@link Reader} expected to provide access to a 120 * logical collection of lines of text, each line of which is a <a 121 * href="http://godoc.org/k8s.io/helm/pkg/ignore">valid {@code 122 * .helmignore} pattern</a> (or blank line, or comment); may be 123 * {@code null}; never {@linkplain Reader#close() closed} 124 * 125 * @exception IOException if an error related to the supplied {@code 126 * reader} is encountered 127 * 128 * @exception PatternSyntaxException if any of the patterns is 129 * invalid 130 */ 131 public HelmIgnorePathMatcher(final Reader reader) throws IOException { 132 this(); 133 if (reader != null) { 134 final BufferedReader bufferedReader; 135 if (reader instanceof BufferedReader) { 136 bufferedReader = (BufferedReader)reader; 137 } else { 138 bufferedReader = new BufferedReader(reader); 139 } 140 assert bufferedReader != null; 141 this.addPatterns(bufferedReader.lines().collect(Collectors.toList())); 142 } 143 } 144 145 /** 146 * Creates a new {@link HelmIgnorePathMatcher}. 147 * 148 * @param helmIgnoreFile a {@link Path} expected to provide access 149 * to a logical collection of lines of text, each line of which is a 150 * <a href="http://godoc.org/k8s.io/helm/pkg/ignore">valid {@code 151 * .helmignore} pattern</a> (or blank line, or comment); may be 152 * {@code null}; never {@linkplain Reader#close() closed} 153 * 154 * @exception IOException if an error related to the supplied {@code 155 * helmIgnoreFile} is encountered 156 * 157 * @exception PatternSyntaxException if any of the patterns is 158 * invalid 159 * 160 * @see #HelmIgnorePathMatcher(Reader) 161 */ 162 public HelmIgnorePathMatcher(final Path helmIgnoreFile) throws IOException { 163 this(helmIgnoreFile == null ? (Collection<? extends String>)null : Files.readAllLines(helmIgnoreFile, StandardCharsets.UTF_8)); 164 } 165 166 167 /* 168 * Instance methods. 169 */ 170 171 172 /** 173 * Calls the {@link #addPatterns(Collection)} method with a 174 * {@linkplain Collections#singleton(Object) singleton 175 * <code>Set</code>} consisting of the supplied {@code 176 * stringPattern}. 177 * 178 * @param stringPattern a <a 179 * href="http://godoc.org/k8s.io/helm/pkg/ignore">valid {@code 180 * .helmignore} pattern</a>; may be {@code null} or {@linkplain 181 * String#isEmpty() empty} or prefixed with a {@code #} character, 182 * in which case no action will be taken 183 * 184 * @see #addPatterns(Collection) 185 * 186 * @see #matches(Path) 187 */ 188 public final void addPattern(final String stringPattern) { 189 this.addPatterns(stringPattern == null ? (Collection<? extends String>)null : Collections.singleton(stringPattern)); 190 } 191 192 /** 193 * Adds all of the <a 194 * href="http://godoc.org/k8s.io/helm/pkg/ignore">valid {@code 195 * .helmignore} patterns</a> present in the supplied {@link 196 * Collection} of such patterns. 197 * 198 * <p>Overrides must not call {@link #addPattern(String)}.</p> 199 * 200 * @param stringPatterns a {@link Collection} of <a 201 * href="http://godoc.org/k8s.io/helm/pkg/ignore">valid {@code 202 * .helmignore} patterns</a>; may be {@code null} in which case no 203 * action will be taken 204 * 205 * @see #matches(Path) 206 */ 207 public void addPatterns(final Collection<? extends String> stringPatterns) { 208 if (stringPatterns != null && !stringPatterns.isEmpty()) { 209 for (String stringPattern : stringPatterns) { 210 if (stringPattern != null && !stringPattern.isEmpty()) { 211 stringPattern = stringPattern.trim(); 212 if (!stringPattern.isEmpty() && !stringPattern.startsWith("#")) { 213 214 if (stringPattern.equals("!") || stringPattern.equals("/")) { 215 throw new IllegalArgumentException("invalid pattern: " + stringPattern); 216 } else if (stringPattern.contains("**")) { 217 throw new IllegalArgumentException("invalid pattern: " + stringPattern + " (double-star (**) syntax is not supported)"); // see rules.go 218 } 219 220 final boolean negate; 221 if (stringPattern.startsWith("!")) { 222 assert stringPattern.length() > 1; 223 negate = true; 224 stringPattern = stringPattern.substring(1); 225 } else { 226 negate = false; 227 } 228 229 final boolean requireDirectory; 230 if (stringPattern.endsWith("/")) { 231 assert stringPattern.length() > 1; 232 requireDirectory = true; 233 stringPattern = stringPattern.substring(0, stringPattern.length() - 1); 234 } else { 235 requireDirectory = false; 236 } 237 238 final boolean basename; 239 final int firstSlashIndex = stringPattern.indexOf('/'); 240 if (firstSlashIndex < 0) { 241 basename = true; 242 } else { 243 if (firstSlashIndex == 0) { 244 assert stringPattern.length() > 1; 245 stringPattern = stringPattern.substring(1); 246 } 247 basename = false; 248 } 249 250 final StringBuilder regex = new StringBuilder("^"); 251 final char[] chars = stringPattern.toCharArray(); 252 assert chars != null; 253 assert chars.length > 0; 254 final int length = chars.length; 255 for (int i = 0; i < length; i++) { 256 final char c = chars[i]; 257 switch (c) { 258 case '.': 259 regex.append("\\."); 260 break; 261 case '*': 262 regex.append("[^").append(File.separator).append("]*"); 263 break; 264 case '?': 265 regex.append("[^").append(File.separator).append("]?"); 266 break; 267 default: 268 regex.append(c); 269 break; 270 } 271 } 272 regex.append("$"); 273 274 final Predicate<Path> rule = new RegexRule(Pattern.compile(regex.toString()), requireDirectory, basename); 275 synchronized (this.rules) { 276 this.rules.add(negate ? rule.negate() : rule); 277 } 278 } 279 } 280 } 281 } 282 } 283 284 /** 285 * Calls the {@link #matches(Path)} method with the supplied {@link 286 * Path} and returns its results. 287 * 288 * @param path a {@link Path} to test; may be {@code null} 289 * 290 * @return {@code true} if the supplied {@code path} matches; {@code 291 * false} otherwise 292 * 293 * @see #matches(Path) 294 */ 295 @Override 296 public final boolean test(final Path path) { 297 return this.matches(path); 298 } 299 300 /** 301 * Returns {@code true} if the supplied {@link Path} is neither 302 * {@code null}, the empty path ({@code ""}) nor the "current 303 * directory" path ("{@code .}" or "{@code ./}"), and if at least 304 * one of the patterns added via the {@link 305 * #addPatterns(Collection)} method logically matches it. 306 * 307 * @param path the {@link Path} to match; may be {@code null} in 308 * which case {@code false} will be returned 309 * 310 * @return {@code true} if at least one of the patterns added via 311 * the {@link #addPatterns(Collection)} method logically matches the 312 * supplied {@link Path}; {@code false} otherwise 313 */ 314 @Override 315 public boolean matches(final Path path) { 316 boolean returnValue = false; 317 if (path != null) { 318 final String pathString = path.toString(); 319 // See https://github.com/kubernetes/helm/issues/1776 and 320 // https://github.com/kubernetes/helm/pull/3114 321 if (!pathString.isEmpty() && !pathString.equals(".") && !pathString.equals("./")) { 322 synchronized (this.rules) { 323 for (final Predicate<Path> rule : this.rules) { 324 if (rule != null && rule.test(path)) { 325 returnValue = true; 326 break; 327 } 328 } 329 } 330 } 331 } 332 return returnValue; 333 } 334 335 336 /* 337 * Inner and nested classes. 338 */ 339 340 341 /** 342 * A {@link Predicate Predicate<Path>} that may also apply 343 * {@link Path}-specific tests. 344 * 345 * @author <a href="https://about.me/lairdnelson" 346 * target="_parent">Laird Nelson</a> 347 */ 348 private static abstract class Rule implements Predicate<Path> { 349 350 351 /* 352 * Instance fields. 353 */ 354 355 356 /** 357 * Whether a {@link Path} must {@linkplain Files#isDirectory(Path, 358 * LinkOption...) be a directory} in order for this {@link Rule} 359 * to match. 360 */ 361 private final boolean requireDirectory; 362 363 /** 364 * Whether the {@linkplain Path#getFileName() final component in a 365 * <code>Path</code>} is matched, or the entire {@link Path}. 366 */ 367 private final boolean basename; 368 369 370 /* 371 * Constructors. 372 */ 373 374 375 /** 376 * Creates a new {@link Rule}. 377 * 378 * @param requireDirectory whether a {@link Path} must {@linkplain 379 * Files#isDirectory(Path, LinkOption...) be a directory} in order 380 * for this {@link Rule} to match 381 * 382 * @param basename hhether the {@linkplain Path#getFileName() 383 * final component in a <code>Path</code>} is matched, or the 384 * entire {@link Path} 385 */ 386 protected Rule(final boolean requireDirectory, final boolean basename) { 387 super(); 388 this.requireDirectory = requireDirectory; 389 this.basename = basename; 390 } 391 392 /** 393 * Returns a {@link Path} that can be tested, given a {@link Path} 394 * and the application of the {@code requireDirectory} and {@code 395 * basename} parameters passed to the constructor. 396 * 397 * <p>This method may return {@code null}.</p> 398 * 399 * @param path the {@link Path} to normalize; may be {@code null} 400 * in which case {@code null} will be returned 401 * 402 * @return a {@link Path} to be further tested; or {@code null} 403 */ 404 protected final Path normalizePath(final Path path) { 405 Path returnValue = path; 406 if (path != null) { 407 if (this.basename) { 408 returnValue = path.getFileName(); 409 } 410 if (this.requireDirectory && !Files.isDirectory(path)) { 411 returnValue = null; 412 } 413 } 414 return returnValue; 415 } 416 417 } 418 419 /** 420 * A {@link Rule} that uses regular expressions to match {@link Path}s. 421 * 422 * @author <a href="https://about.me/lairdnelson" 423 * target="_parent">Laird Nelson</a> 424 * 425 * @see Pattern 426 */ 427 private static final class RegexRule extends Rule { 428 429 430 /* 431 * Instance fields. 432 */ 433 434 435 /** 436 * The {@link Pattern} specifying what {@link Path} instances 437 * should be matched. 438 * 439 * <p>This field may be {@code null}.</p> 440 */ 441 private final Pattern pattern; 442 443 444 /* 445 * Constructors. 446 */ 447 448 449 /** 450 * Creates a new {@link RegexRule}. 451 * 452 * @param pattern the {@link Pattern} specifying what {@link Path} 453 * instances should be matched; may be {@code null} 454 * 455 * @param requireDirectory whether only {@link Path} instances 456 * that {@linkplain Files#isDirectory(Path, LinkOption...) are 457 * directories} are subject to further matching 458 * 459 * @param basename whether only {@linkplain Path#getFileName() the 460 * last component of a <code>Path</code>} is considered for 461 * matching 462 * 463 * @see #test(Path) 464 */ 465 private RegexRule(final Pattern pattern, final boolean requireDirectory, final boolean basename) { 466 super(requireDirectory, basename); 467 this.pattern = pattern; 468 } 469 470 471 /* 472 * Instance methods. 473 */ 474 475 476 /** 477 * Tests the supplied {@link Path} to see if it matches the 478 * conditions supplied at construction time. 479 * 480 * @param path the {@link Path} to test; may be {@code null} in 481 * which case {@code false} will be returned 482 * 483 * @return {@code true} if this {@link RegexRule} matches the 484 * supplied {@link Path}; {@code false} otherwise 485 */ 486 @Override 487 public final boolean test(Path path) { 488 boolean returnValue = false; 489 path = this.normalizePath(path); 490 if (path != null) { 491 if (this.pattern == null) { 492 returnValue = true; 493 } else { 494 final Matcher matcher = this.pattern.matcher(path.toString()); 495 assert matcher != null; 496 returnValue = matcher.matches(); 497 } 498 } 499 return returnValue; 500 } 501 } 502 503}