// Copyright (C) 2009 The Android Open Source Project // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.google.gerrit.server.restapi.project; import static com.google.common.base.Strings.emptyToNull; import static com.google.common.base.Strings.isNullOrEmpty; import static com.google.common.collect.Ordering.natural; import static com.google.gerrit.extensions.client.ProjectState.HIDDEN; import static java.nio.charset.StandardCharsets.UTF_8; import com.google.common.base.Joiner; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSortedMap; import com.google.common.collect.Iterables; import com.google.common.flogger.FluentLogger; import com.google.gerrit.common.Nullable; import com.google.gerrit.common.data.GroupReference; import com.google.gerrit.common.errors.NoSuchGroupException; import com.google.gerrit.extensions.common.ProjectInfo; import com.google.gerrit.extensions.common.WebLinkInfo; import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.extensions.restapi.BadRequestException; import com.google.gerrit.extensions.restapi.BinaryResult; import com.google.gerrit.extensions.restapi.MethodNotAllowedException; import com.google.gerrit.extensions.restapi.RestReadView; import com.google.gerrit.extensions.restapi.TopLevelResource; import com.google.gerrit.extensions.restapi.Url; import com.google.gerrit.reviewdb.client.AccountGroup; import com.google.gerrit.reviewdb.client.Project; import com.google.gerrit.reviewdb.client.RefNames; import com.google.gerrit.server.CurrentUser; import com.google.gerrit.server.OutputFormat; import com.google.gerrit.server.WebLinks; import com.google.gerrit.server.account.GroupControl; import com.google.gerrit.server.git.GitRepositoryManager; import com.google.gerrit.server.group.GroupResolver; import com.google.gerrit.server.ioutil.RegexListSearcher; import com.google.gerrit.server.ioutil.StringUtil; import com.google.gerrit.server.permissions.PermissionBackend; import com.google.gerrit.server.permissions.PermissionBackendException; import com.google.gerrit.server.permissions.ProjectPermission; import com.google.gerrit.server.permissions.RefPermission; import com.google.gerrit.server.project.ProjectCache; import com.google.gerrit.server.project.ProjectState; import com.google.gerrit.server.util.TreeFormatter; import com.google.gson.reflect.TypeToken; import com.google.gwtorm.server.OrmException; import com.google.inject.Inject; import com.google.inject.Provider; import java.io.BufferedWriter; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.SortedMap; import java.util.SortedSet; import java.util.TreeMap; import java.util.TreeSet; import java.util.stream.Stream; import java.util.stream.StreamSupport; import org.eclipse.jgit.errors.RepositoryNotFoundException; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Repository; import org.kohsuke.args4j.Option; /** List projects visible to the calling user. */ public class ListProjects implements RestReadView { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); public enum FilterType { CODE { @Override boolean matches(Repository git) throws IOException { return !PERMISSIONS.matches(git); } @Override boolean useMatch() { return true; } }, PARENT_CANDIDATES { @Override boolean matches(Repository git) { return true; } @Override boolean useMatch() { return false; } }, PERMISSIONS { @Override boolean matches(Repository git) throws IOException { Ref head = git.getRefDatabase().exactRef(Constants.HEAD); return head != null && head.isSymbolic() && RefNames.REFS_CONFIG.equals(head.getLeaf().getName()); } @Override boolean useMatch() { return true; } }, ALL { @Override boolean matches(Repository git) { return true; } @Override boolean useMatch() { return false; } }; abstract boolean matches(Repository git) throws IOException; abstract boolean useMatch(); } private final CurrentUser currentUser; private final ProjectCache projectCache; private final GroupResolver groupResolver; private final GroupControl.Factory groupControlFactory; private final GitRepositoryManager repoManager; private final PermissionBackend permissionBackend; private final ProjectNode.Factory projectNodeFactory; private final WebLinks webLinks; @Deprecated @Option(name = "--format", usage = "(deprecated) output format") private OutputFormat format = OutputFormat.TEXT; @Option( name = "--show-branch", aliases = {"-b"}, usage = "displays the sha of each project in the specified branch") public void addShowBranch(String branch) { showBranch.add(branch); } @Option( name = "--tree", aliases = {"-t"}, usage = "displays project inheritance in a tree-like format\n" + "this option does not work together with the show-branch option") public void setShowTree(boolean showTree) { this.showTree = showTree; } @Option(name = "--type", usage = "type of project") public void setFilterType(FilterType type) { this.type = type; } @Option( name = "--description", aliases = {"-d"}, usage = "include description of project in list") public void setShowDescription(boolean showDescription) { this.showDescription = showDescription; } @Option(name = "--all", usage = "display all projects that are accessible by the calling user") public void setAll(boolean all) { this.all = all; } @Option( name = "--state", aliases = {"-s"}, usage = "filter by project state") public void setState(com.google.gerrit.extensions.client.ProjectState state) { this.state = state; } @Option( name = "--limit", aliases = {"-n"}, metaVar = "CNT", usage = "maximum number of projects to list") public void setLimit(int limit) { this.limit = limit; } @Option( name = "--start", aliases = {"-S"}, metaVar = "CNT", usage = "number of projects to skip") public void setStart(int start) { this.start = start; } @Option( name = "--prefix", aliases = {"-p"}, metaVar = "PREFIX", usage = "match project prefix") public void setMatchPrefix(String matchPrefix) { this.matchPrefix = matchPrefix; } @Option( name = "--match", aliases = {"-m"}, metaVar = "MATCH", usage = "match project substring") public void setMatchSubstring(String matchSubstring) { this.matchSubstring = matchSubstring; } @Option(name = "-r", metaVar = "REGEX", usage = "match project regex") public void setMatchRegex(String matchRegex) { this.matchRegex = matchRegex; } @Option( name = "--has-acl-for", metaVar = "GROUP", usage = "displays only projects on which access rights for this group are directly assigned") public void setGroupUuid(AccountGroup.UUID groupUuid) { this.groupUuid = groupUuid; } private final List showBranch = new ArrayList<>(); private boolean showTree; private FilterType type = FilterType.ALL; private boolean showDescription; private boolean all; private com.google.gerrit.extensions.client.ProjectState state; private int limit; private int start; private String matchPrefix; private String matchSubstring; private String matchRegex; private AccountGroup.UUID groupUuid; private final Provider queryProjectsProvider; @Inject protected ListProjects( CurrentUser currentUser, ProjectCache projectCache, GroupResolver groupResolver, GroupControl.Factory groupControlFactory, GitRepositoryManager repoManager, PermissionBackend permissionBackend, ProjectNode.Factory projectNodeFactory, WebLinks webLinks, Provider queryProjectsProvider) { this.currentUser = currentUser; this.projectCache = projectCache; this.groupResolver = groupResolver; this.groupControlFactory = groupControlFactory; this.repoManager = repoManager; this.permissionBackend = permissionBackend; this.projectNodeFactory = projectNodeFactory; this.webLinks = webLinks; this.queryProjectsProvider = queryProjectsProvider; } public List getShowBranch() { return showBranch; } public boolean isShowTree() { return showTree; } public boolean isShowDescription() { return showDescription; } public OutputFormat getFormat() { return format; } public ListProjects setFormat(OutputFormat fmt) { format = fmt; return this; } @Override public Object apply(TopLevelResource resource) throws BadRequestException, PermissionBackendException { if (format == OutputFormat.TEXT) { ByteArrayOutputStream buf = new ByteArrayOutputStream(); display(buf); return BinaryResult.create(buf.toByteArray()) .setContentType("text/plain") .setCharacterEncoding(UTF_8); } return apply(); } public SortedMap apply() throws BadRequestException, PermissionBackendException { Optional projectQuery = expressAsProjectsQuery(); if (projectQuery.isPresent()) { return applyAsQuery(projectQuery.get()); } format = OutputFormat.JSON; return display(null); } private Optional expressAsProjectsQuery() { return !all && state != HIDDEN && isNullOrEmpty(matchPrefix) && isNullOrEmpty(matchRegex) && isNullOrEmpty(matchSubstring) // TODO: see Issue 10446 && type == FilterType.ALL && showBranch.isEmpty() ? Optional.of(stateToQuery()) : Optional.empty(); } private String stateToQuery() { List queries = new ArrayList<>(); if (state == null) { queries.add("(state:active OR state:read-only)"); } else { queries.add(String.format("(state:%s)", state.name())); } return Joiner.on(" AND ").join(queries).toString(); } private SortedMap applyAsQuery(String query) throws BadRequestException { try { return queryProjectsProvider .get() .withQuery(query) .withStart(start) .withLimit(limit) .apply() .stream() .collect( ImmutableSortedMap.toImmutableSortedMap( natural(), p -> p.name, p -> showDescription ? p : nullifyDescription(p))); } catch (OrmException | MethodNotAllowedException e) { logger.atWarning().withCause(e).log( "Internal error while processing the query '{}' request", query); throw new BadRequestException("Internal error while processing the query request"); } } private ProjectInfo nullifyDescription(ProjectInfo p) { p.description = null; return p; } public SortedMap display(@Nullable OutputStream displayOutputStream) throws BadRequestException, PermissionBackendException { if (all && state != null) { throw new BadRequestException("'all' and 'state' may not be used together"); } if (groupUuid != null) { try { if (!groupControlFactory.controlFor(groupUuid).isVisible()) { return Collections.emptySortedMap(); } } catch (NoSuchGroupException ex) { return Collections.emptySortedMap(); } } PrintWriter stdout = null; if (displayOutputStream != null) { stdout = new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8))); } if (type == FilterType.PARENT_CANDIDATES) { // Historically, PARENT_CANDIDATES implied showDescription. showDescription = true; } int foundIndex = 0; int found = 0; TreeMap output = new TreeMap<>(); Map hiddenNames = new HashMap<>(); Map accessibleParents = new HashMap<>(); PermissionBackend.WithUser perm = permissionBackend.user(currentUser); final TreeMap treeMap = new TreeMap<>(); try { Iterable projectNames = filter(perm)::iterator; for (Project.NameKey projectName : projectNames) { final ProjectState e = projectCache.get(projectName); if (e == null || (e.getProject().getState() == HIDDEN && !all && state != HIDDEN)) { // If we can't get it from the cache, pretend it's not present. // If all wasn't selected, and it's HIDDEN, pretend it's not present. // If state HIDDEN wasn't selected, and it's HIDDEN, pretend it's not present. continue; } if (state != null && e.getProject().getState() != state) { continue; } if (groupUuid != null && !e.getLocalGroups() .contains(GroupReference.forGroup(groupResolver.parseId(groupUuid.get())))) { continue; } ProjectInfo info = new ProjectInfo(); if (showTree && !format.isJson()) { treeMap.put(projectName, projectNodeFactory.create(e.getProject(), true)); continue; } info.name = projectName.get(); if (showTree && format.isJson()) { ProjectState parent = Iterables.getFirst(e.parents(), null); if (parent != null) { if (isParentAccessible(accessibleParents, perm, parent)) { info.parent = parent.getName(); } else { info.parent = hiddenNames.get(parent.getName()); if (info.parent == null) { info.parent = "?-" + (hiddenNames.size() + 1); hiddenNames.put(parent.getName(), info.parent); } } } } if (showDescription) { info.description = emptyToNull(e.getProject().getDescription()); } info.state = e.getProject().getState(); try { if (!showBranch.isEmpty()) { try (Repository git = repoManager.openRepository(projectName)) { if (!type.matches(git)) { continue; } boolean canReadAllRefs = e.statePermitsRead(); if (canReadAllRefs) { try { permissionBackend .user(currentUser) .project(e.getNameKey()) .check(ProjectPermission.READ); } catch (AuthException exp) { canReadAllRefs = false; } } List refs = getBranchRefs(projectName, canReadAllRefs); if (!hasValidRef(refs)) { continue; } for (int i = 0; i < showBranch.size(); i++) { Ref ref = refs.get(i); if (ref != null && ref.getObjectId() != null) { if (info.branches == null) { info.branches = new LinkedHashMap<>(); } info.branches.put(showBranch.get(i), ref.getObjectId().name()); } } } } else if (!showTree && type.useMatch()) { try (Repository git = repoManager.openRepository(projectName)) { if (!type.matches(git)) { continue; } } } } catch (RepositoryNotFoundException err) { // If the Git repository is gone, the project doesn't actually exist anymore. continue; } catch (IOException err) { logger.atWarning().withCause(err).log("Unexpected error reading %s", projectName); continue; } if (type != FilterType.PARENT_CANDIDATES) { List links = webLinks.getProjectLinks(projectName.get()); info.webLinks = links.isEmpty() ? null : links; } if (foundIndex++ < start) { continue; } if (limit > 0 && ++found > limit) { break; } if (stdout == null || format.isJson()) { output.put(info.name, info); continue; } if (!showBranch.isEmpty()) { for (String name : showBranch) { String ref = info.branches != null ? info.branches.get(name) : null; if (ref == null) { // Print stub (forty '-' symbols) ref = "----------------------------------------"; } stdout.print(ref); stdout.print(' '); } } stdout.print(info.name); if (info.description != null) { // We still want to list every project as one-liners, hence escaping \n. stdout.print(" - " + StringUtil.escapeString(info.description)); } stdout.print('\n'); } for (ProjectInfo info : output.values()) { info.id = Url.encode(info.name); info.name = null; } if (stdout == null) { return output; } else if (format.isJson()) { format .newGson() .toJson(output, new TypeToken>() {}.getType(), stdout); stdout.print('\n'); } else if (showTree && treeMap.size() > 0) { printProjectTree(stdout, treeMap); } return null; } finally { if (stdout != null) { stdout.flush(); } } } private Stream filter(PermissionBackend.WithUser perm) throws BadRequestException { Stream matches = StreamSupport.stream(scan().spliterator(), false); if (type == FilterType.PARENT_CANDIDATES) { matches = matches.map(projectCache::get).map(this::parentOf).filter(Objects::nonNull).sorted(); } return matches.filter(p -> perm.project(p).testOrFalse(ProjectPermission.ACCESS)); } private Project.NameKey parentOf(ProjectState ps) { if (ps == null) { return null; } Project.NameKey parent = ps.getProject().getParent(); if (parent != null) { if (projectCache.get(parent) != null) { return parent; } logger.atWarning().log("parent project %s of project %s not found", ps.getName()); } return null; } private boolean isParentAccessible( Map checked, PermissionBackend.WithUser perm, ProjectState state) throws PermissionBackendException { Project.NameKey name = state.getNameKey(); Boolean b = checked.get(name); if (b == null) { try { // Hidden projects(permitsRead = false) should only be accessible by the project owners. // READ_CONFIG is checked here because it's only allowed to project owners(ACCESS may also // be allowed for other users). Allowing project owners to access here will help them to // view // and update the config of hidden projects easily. ProjectPermission permissionToCheck = state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG; perm.project(name).check(permissionToCheck); b = true; } catch (AuthException denied) { b = false; } checked.put(name, b); } return b; } private Stream scan() throws BadRequestException { if (matchPrefix != null) { checkMatchOptions(matchSubstring == null && matchRegex == null); return projectCache.byName(matchPrefix).stream(); } else if (matchSubstring != null) { checkMatchOptions(matchPrefix == null && matchRegex == null); return projectCache .all() .stream() .filter( p -> p.get().toLowerCase(Locale.US).contains(matchSubstring.toLowerCase(Locale.US))); } else if (matchRegex != null) { checkMatchOptions(matchPrefix == null && matchSubstring == null); RegexListSearcher searcher; try { searcher = new RegexListSearcher<>(matchRegex, Project.NameKey::get); } catch (IllegalArgumentException e) { throw new BadRequestException(e.getMessage()); } return searcher.search(ImmutableList.copyOf(projectCache.all())); } else { return projectCache.all().stream(); } } private static void checkMatchOptions(boolean cond) throws BadRequestException { if (!cond) { throw new BadRequestException("specify exactly one of p/m/r"); } } private void printProjectTree( final PrintWriter stdout, TreeMap treeMap) { final SortedSet sortedNodes = new TreeSet<>(); // Builds the inheritance tree using a list. // for (ProjectNode key : treeMap.values()) { if (key.isAllProjects()) { sortedNodes.add(key); continue; } ProjectNode node = treeMap.get(key.getParentName()); if (node != null) { node.addChild(key); } else { sortedNodes.add(key); } } final TreeFormatter treeFormatter = new TreeFormatter(stdout); treeFormatter.printTree(sortedNodes); stdout.flush(); } private List getBranchRefs(Project.NameKey projectName, boolean canReadAllRefs) { Ref[] result = new Ref[showBranch.size()]; try (Repository git = repoManager.openRepository(projectName)) { PermissionBackend.ForProject perm = permissionBackend.user(currentUser).project(projectName); for (int i = 0; i < showBranch.size(); i++) { Ref ref = git.findRef(showBranch.get(i)); if (all && canReadAllRefs) { result[i] = ref; } else if (ref != null && ref.getObjectId() != null) { try { perm.ref(ref.getLeaf().getName()).check(RefPermission.READ); result[i] = ref; } catch (AuthException e) { continue; } } } } catch (IOException | PermissionBackendException e) { // Fall through and return what is available. } return Arrays.asList(result); } private static boolean hasValidRef(List refs) { for (Ref ref : refs) { if (ref != null) { return true; } } return false; } }