001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  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 implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.server.config.repository;
018
019import static org.apache.juneau.commons.utils.FileUtils.*;
020import static org.apache.juneau.commons.utils.Shorts.*;
021
022import java.io.*;
023import java.nio.file.*;
024import java.util.*;
025
026import org.apache.juneau.config.*;
027
028/**
029 * Command that fetches configuration from a Git repository for a given project and branch.
030 *
031 * <p>
032 * Clones or pulls the configured Git repo, then loads application and project-specific
033 * configuration files into a map of {@link ConfigItem} values.
034 */
035public class GetConfiguration implements Command, GetValue<Map<String,ConfigItem>> {
036
037   private static final String APPLICATION = "APPLICATION";
038   private static final String PROJECT = "PROJECT";
039   private static final String EXT = ".cfg";
040
041   // Serializes pull/checkout/pull on the single shared checkout so concurrent requests cannot interleave branches.
042   private static final Object GIT_LOCK = new Object();
043
044   // Remote pulls are throttled: at most one fetch per interval (settable as [GitServer] pullInterval, in seconds).
045   private static final long DEFAULT_PULL_INTERVAL_SECONDS = 60;
046   private static long lastPullNanos;
047   private static boolean pulledOnce;
048
049   private final Map<String,ConfigItem> configs = new HashMap<>();
050
051   @SuppressWarnings({
052      "java:S1845" // Field name intentionally differs only by case from static field PROJECT
053   })
054   private String project;
055   private final String branch;
056
057   /**
058    * Constructor.
059    *
060    * @param project Project name.
061    * @param branch Branch name.
062    */
063   public GetConfiguration(String project, String branch) {
064      // Path variables are URL-decoded after the path is split, so an encoded '%2F' or '..' can survive (CWE-22).
065      validate("project", project, false);
066      validate("branch", branch, true);
067      this.branch = branch;
068      this.project = project;
069   }
070
071   /** {@inheritDoc} */
072   @Override
073   public void execute() throws Exception {
074
075      String pathStr;
076      String git;
077      long pullInterval;
078      var config = Config.create().name("juneau-server-config.cfg").build();
079      try {
080         pathStr = config.get("GitServer/pathLocal").orElse(null);
081         git = config.get("GitServer/gitRemote").orElse(null);
082         pullInterval = config.get("GitServer/pullInterval").asLong().orElse(DEFAULT_PULL_INTERVAL_SECONDS);
083      } finally {
084         config.close();
085      }
086
087      // Fail fast with a clear message when the operator has not configured the local checkout directory,
088      // rather than surfacing an opaque NullPointerException from new File(null) below.
089      if (pathStr == null)
090         throw new IllegalStateException("Required configuration 'GitServer/pathLocal' is not set.");
091
092      // Trusted operator-configured local git checkout directory (GitServer/pathLocal).
093      // Serves as the confinement root for the project/application config file lookups below.
094      var root = new File(pathStr);
095
096      var fileDefaultStr = lcr(APPLICATION).concat(EXT);
097      var fileProjectStr = this.project.concat(EXT);
098
099      // The checkout is shared, so the git operations AND the file reads must happen under one lock
100      // (otherwise another request could switch branches between checkout and read).
101      synchronized (GIT_LOCK) {
102         try (var gitControl = new GitControl(pathStr, git)) {
103
104            var now = System.nanoTime();
105            var due = ! pulledOnce || now - lastPullNanos >= pullInterval * 1_000_000_000L;
106            if (! root.isDirectory()) {
107               gitControl.cloneRepo();
108               pulledOnce = true;
109               lastPullNanos = now;
110            } else if (due) {
111               gitControl.pullFromRepo();
112               pulledOnce = true;
113               lastPullNanos = now;
114            }
115
116            gitControl.branch(branch);
117            if (due)
118               gitControl.pullFromRepo();
119         }
120
121         readFiles(root, fileDefaultStr, fileProjectStr);
122      }
123   }
124
125   private void readFiles(File root, String fileDefaultStr, String fileProjectStr) throws IOException {
126      // Resolve the config file names under the checkout root through the shared boundary check
127      // so a crafted project name cannot escape the repo directory (empty = file absent).
128      var fileDefault = resolveSafely(root, fileDefaultStr).orElse(null);
129      if (fileDefault != null) {
130         var lines = new String(Files.readAllBytes(fileDefault.toPath()));
131         configs.put(APPLICATION, new ConfigItem(lines));
132      }
133
134      var fileProject = resolveSafely(root, fileProjectStr).orElse(null);
135      if (fileProject != null) {
136         var linesProject = new String(Files.readAllBytes(fileProject.toPath()));
137         configs.put(PROJECT, new ConfigItem(linesProject));
138      }
139   }
140
141   /** {@inheritDoc} */
142   @Override
143   public Map<String,ConfigItem> get() {
144      return Map.copyOf(configs);
145   }
146
147   // Project becomes a file name, so no separators at all.  Branch is only passed to git as a ref name, so
148   // legitimate names such as "feature/foo" are allowed, but '..', backslash and NUL are not.
149   private static void validate(String name, String value, boolean allowSlash) {
150      if (value == null || value.isEmpty() || value.contains("\\") || value.contains("..") || value.indexOf('\0') >= 0 || (! allowSlash && value.contains("/")))
151         throw new IllegalArgumentException("Invalid " + name + " value.");
152   }
153}