| // Copyright (C) 2022 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.permissions; |
| |
| import com.google.common.collect.ImmutableMap; |
| import com.google.common.collect.ImmutableSet; |
| import com.google.common.flogger.FluentLogger; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.entities.Change; |
| import com.google.gerrit.entities.Project; |
| import com.google.gerrit.exceptions.StorageException; |
| import com.google.gerrit.server.git.SearchingChangeCacheImpl; |
| import com.google.gerrit.server.notedb.ChangeNotes; |
| import com.google.gerrit.server.query.change.ChangeData; |
| import java.io.IOException; |
| import java.util.HashMap; |
| import java.util.Objects; |
| import java.util.Set; |
| import java.util.stream.Stream; |
| import org.eclipse.jgit.lib.Repository; |
| |
| /** |
| * This class can tell efficiently if changes are visible to a user. It is intended to be used when |
| * serving Git traffic on the Git wire protocol and in similar use cases when we need to know |
| * efficiently if a (potentially large number) of changes are visible to a user. |
| * |
| * <p>The efficiency of this class comes from heuristic optimization: |
| * |
| * <ul> |
| * <li>For a low number of expected checks, we check visibility one-by-one. |
| * <li>For a high number of expected checks and settings where the change index is available, we |
| * load the N most recent changes from the index and filter them by visibility. This is fast, |
| * but comes with the caveat that older changes are pretended to be invisible. |
| * <li>For a high number of expected checks and settings where the change index is unavailable, we |
| * scan the repo and determine visibility one-by-one. This is *very* expensive. |
| * </ul> |
| * |
| * <p>Changes that fail to load are pretended to be invisible. This is important on the Git paths as |
| * we don't want to advertise change refs where we were unable to check the visibility (e.g. due to |
| * data corruption on that change). At the same time, the overall operation should succeed as |
| * otherwise a single broken change would break Git operations for an entire repo. |
| */ |
| public class GitVisibleChangeFilter { |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| private static final int CHANGE_LIMIT_FOR_DIRECT_FILTERING = 5; |
| |
| private GitVisibleChangeFilter() {} |
| |
| /** Returns a map of all visible changes. Might pretend old changes are invisible. */ |
| static ImmutableMap<Change.Id, ChangeData> getVisibleChanges( |
| @Nullable SearchingChangeCacheImpl searchingChangeCache, |
| ChangeNotes.Factory changeNotesFactory, |
| ChangeData.Factory changeDataFactory, |
| Project.NameKey projectName, |
| PermissionBackend.ForProject forProject, |
| Repository repository, |
| ImmutableSet<Change.Id> changes) { |
| Stream<ChangeData> changeDatas; |
| if (changes.size() < CHANGE_LIMIT_FOR_DIRECT_FILTERING) { |
| logger.atFine().log("Loading changes one by one for project %s", projectName); |
| changeDatas = loadChangeDatasOneByOne(changes, changeDataFactory, projectName); |
| } else if (searchingChangeCache != null) { |
| logger.atFine().log("Loading changes from SearchingChangeCache for project %s", projectName); |
| changeDatas = searchingChangeCache.getChangeData(projectName); |
| } else { |
| logger.atFine().log("Loading changes from all refs for project %s", projectName); |
| changeDatas = |
| scanRepoForChangeDatas(changeNotesFactory, changeDataFactory, repository, projectName); |
| } |
| HashMap<Change.Id, ChangeData> result = new HashMap<>(); |
| changeDatas |
| .filter(cd -> changes.contains(cd.getId())) |
| .filter( |
| cd -> { |
| try { |
| return forProject.change(cd).test(ChangePermission.READ); |
| } catch (PermissionBackendException e) { |
| throw new StorageException(e); |
| } |
| }) |
| .forEach( |
| cd -> { |
| if (result.containsKey(cd.getId())) { |
| logger.atWarning().log( |
| "Duplicate change datas for the repo %s: [%s, %s]", |
| projectName, cd, result.get(cd.getId())); |
| } |
| result.put(cd.getId(), cd); |
| }); |
| return ImmutableMap.copyOf(result); |
| } |
| |
| /** Get a stream of changes by loading them individually. */ |
| private static Stream<ChangeData> loadChangeDatasOneByOne( |
| Set<Change.Id> ids, ChangeData.Factory changeDataFactory, Project.NameKey projectName) { |
| return ids.stream() |
| .map( |
| id -> { |
| try { |
| ChangeData cd = changeDataFactory.create(projectName, id); |
| cd.notes(); // Make sure notes are available. This will trigger loading notes and |
| // throw an exception in case the change is corrupt and can't be loaded. It will |
| // then be omitted from the result. |
| return cd; |
| } catch (Exception e) { |
| // We drop changes that we can't load. The repositories contain 'dead' change refs |
| // and we want to overall operation to continue. |
| logger.atFinest().withCause(e).log("Can't load Change notes for %s", id); |
| return null; |
| } |
| }) |
| .filter(Objects::nonNull); |
| } |
| |
| /** Get a stream of all changes by scanning the repo. This is extremely slow. */ |
| private static Stream<ChangeData> scanRepoForChangeDatas( |
| ChangeNotes.Factory changeNotesFactory, |
| ChangeData.Factory changeDataFactory, |
| Repository repository, |
| Project.NameKey projectName) { |
| Stream<ChangeData> cds; |
| try { |
| cds = |
| changeNotesFactory |
| .scan(repository, projectName) |
| .map( |
| notesResult -> { |
| if (!notesResult.error().isPresent()) { |
| return changeDataFactory.create(notesResult.notes()); |
| } |
| logger.atWarning().withCause(notesResult.error().get()).log( |
| "Unable to load ChangeNotes for %s", notesResult.id()); |
| return null; |
| }) |
| .filter(Objects::nonNull); |
| } catch (IOException e) { |
| throw new StorageException(e); |
| } |
| return cds; |
| } |
| } |