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&lt;Path&gt;}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&lt;Path&gt;} 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}