| /* |
| * Licensed to the Apache Software Foundation (ASF) under one or more |
| * contributor license agreements. See the NOTICE file distributed with this |
| * work for additional information regarding copyright ownership. The ASF |
| * licenses this file to You 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 org.apache.sling.feature.cpconverter.vltpkg; |
| |
| import org.apache.commons.io.FileUtils; |
| import org.apache.commons.io.IOUtils; |
| import org.apache.commons.io.filefilter.DirectoryFileFilter; |
| import org.apache.jackrabbit.vault.fs.api.PathFilterSet; |
| import org.apache.jackrabbit.vault.fs.api.WorkspaceFilter; |
| import org.apache.jackrabbit.vault.fs.config.DefaultWorkspaceFilter; |
| import org.apache.jackrabbit.vault.fs.io.Archive; |
| import org.apache.jackrabbit.vault.fs.io.Archive.Entry; |
| import org.apache.jackrabbit.vault.packaging.Dependency; |
| import org.apache.jackrabbit.vault.packaging.PackageId; |
| import org.apache.jackrabbit.vault.packaging.PackageProperties; |
| import org.apache.jackrabbit.vault.packaging.PackageType; |
| import org.apache.jackrabbit.vault.packaging.VaultPackage; |
| import org.apache.jackrabbit.vault.util.PlatformNameFormat; |
| import org.apache.sling.feature.cpconverter.ContentPackage2FeatureModelConverter; |
| import org.apache.sling.feature.cpconverter.handlers.DefaultEntryParser; |
| import org.jetbrains.annotations.NotNull; |
| import org.jetbrains.annotations.Nullable; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import java.io.File; |
| import java.io.FileFilter; |
| import java.io.FileInputStream; |
| import java.io.FileOutputStream; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.OutputStream; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.Map; |
| import java.util.Objects; |
| import java.util.Properties; |
| import java.util.Set; |
| import java.util.function.Predicate; |
| import java.util.jar.JarEntry; |
| import java.util.jar.JarFile; |
| import java.util.jar.JarOutputStream; |
| import java.util.jar.Manifest; |
| import java.util.regex.Pattern; |
| import java.util.stream.Collectors; |
| import java.util.zip.Deflater; |
| |
| import static org.apache.jackrabbit.vault.util.Constants.FILTER_XML; |
| import static org.apache.jackrabbit.vault.util.Constants.META_DIR; |
| import static org.apache.jackrabbit.vault.util.Constants.PROPERTIES_XML; |
| import static org.apache.jackrabbit.vault.util.Constants.ROOT_DIR; |
| import static org.apache.sling.feature.cpconverter.ContentPackage2FeatureModelConverter.PACKAGE_CLASSIFIER; |
| import static org.apache.sling.feature.cpconverter.vltpkg.VaultPackageUtils.getDependencies; |
| import static org.apache.sling.feature.cpconverter.vltpkg.VaultPackageUtils.setDependencies; |
| import static org.apache.sling.feature.cpconverter.vltpkg.VaultPackageUtils.toRepositoryPath; |
| |
| public class VaultPackageAssembler { |
| |
| private static final Pattern OSGI_BUNDLE_PATTERN = Pattern.compile("(jcr_root)?/apps/[^/]+/install(\\.([^/]+))?/.+\\.jar"); |
| |
| public static final String VERSION_SUFFIX = '-' + PACKAGE_CLASSIFIER; |
| |
| private static final Logger log = LoggerFactory.getLogger(VaultPackageAssembler.class); |
| |
| private final Set<String> convertedCpPaths = new HashSet<>(); |
| private final Set<String> extractedConvertedRepoPaths = new HashSet<>(); |
| private final Set<String> allPaths = new HashSet<>(); |
| private final DefaultWorkspaceFilter filter = new DefaultWorkspaceFilter(); |
| private final Set<Dependency> dependencies; |
| private final File storingDirectory; |
| private final Properties properties; |
| private final File tmpDir; |
| |
| /** |
| * This class can not be instantiated from outside |
| */ |
| private VaultPackageAssembler(@NotNull File tempDir, @NotNull File storingDirectory, @NotNull Properties properties, |
| @NotNull Set<Dependency> dependencies) { |
| this.storingDirectory = storingDirectory; |
| this.properties = properties; |
| this.dependencies = dependencies; |
| this.tmpDir = tempDir; |
| } |
| |
| /** |
| * Creates a new package assembler based on an existing package. |
| * Takes over properties and filter rules from existing package. |
| * |
| * @param baseTempDir the temp dir |
| * @param vaultPackage the package to take as blueprint |
| * @param removeInstallHooks whether to remove install hooks or not |
| * @return the package assembler |
| */ |
| public static @NotNull VaultPackageAssembler create(@NotNull File baseTempDir, @NotNull VaultPackage vaultPackage, boolean removeInstallHooks) { |
| final File tempDir = new File(baseTempDir, "synthetic-content-packages_" + System.currentTimeMillis()); |
| PackageId packageId = vaultPackage.getId(); |
| File storingDirectory = initStoringDirectory(packageId, tempDir); |
| |
| Properties properties = new Properties(); |
| Map<Object, Object> originalPackageProperties = vaultPackage.getMetaInf().getProperties(); |
| if (originalPackageProperties == null) { |
| throw new IllegalArgumentException("No package properties found in " + vaultPackage.getId()); |
| } |
| if (removeInstallHooks) { |
| // filter install hook properties |
| log.info("Removing install hooks from original package"); |
| originalPackageProperties = originalPackageProperties.entrySet().stream().filter(new RemoveInstallHooksPredicate()).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); |
| } |
| properties.putAll(originalPackageProperties); |
| String version = vaultPackage.getId().getVersion().toString(); |
| if (!version.endsWith(VERSION_SUFFIX)) { |
| version += VERSION_SUFFIX; |
| } |
| properties.setProperty(PackageProperties.NAME_VERSION, version); |
| |
| Set<Dependency> dependencies = getDependencies(vaultPackage); |
| |
| VaultPackageAssembler assembler = new VaultPackageAssembler(tempDir, storingDirectory, properties, dependencies); |
| assembler.mergeFilters(Objects.requireNonNull(vaultPackage.getMetaInf().getFilter())); |
| return assembler; |
| } |
| |
| /** |
| * Creates a new package assembler. |
| * |
| * @param baseTempDir the temp dir |
| * @param packageId the package id from which to generate a minimal properties.xml |
| * @param description the description which should end up in the package properties |
| * @return the package assembler |
| */ |
| public static @NotNull VaultPackageAssembler create(@NotNull File baseTempDir, @NotNull PackageId packageId, String description) { |
| final File tempDir = new File(baseTempDir, "synthetic-content-packages_" + System.currentTimeMillis()); |
| File storingDirectory = initStoringDirectory(packageId, tempDir); |
| Properties props = new Properties(); |
| // generate minimal properties (http://jackrabbit.apache.org/filevault/properties.html) |
| props.put(PackageProperties.NAME_GROUP, packageId.getGroup()); |
| props.put(PackageProperties.NAME_NAME, packageId.getName()); |
| props.put(PackageProperties.NAME_VERSION, packageId.getVersionString() + VERSION_SUFFIX); |
| |
| props.put(PackageProperties.NAME_DESCRIPTION, description); |
| return new VaultPackageAssembler(tempDir, storingDirectory, props, new HashSet<>()); |
| } |
| |
| private static @NotNull File initStoringDirectory(PackageId packageId, @NotNull File tempDir) { |
| String fileName = packageId.toString().replace('/', '-').replace(':', '-'); |
| File storingDirectory = new File(tempDir, fileName + "-deflated"); |
| if (storingDirectory.exists()) { |
| try { |
| FileUtils.deleteDirectory(storingDirectory); |
| } catch (IOException e) { |
| throw new IllegalStateException("Unable to delete existing deflated folder: '" + storingDirectory + "'", e); |
| } |
| } |
| // avoid any possible Stream is not a content package. Missing 'jcr_root' error |
| File jcrRootDirectory = new File(storingDirectory, ROOT_DIR); |
| if (!jcrRootDirectory.mkdirs() && jcrRootDirectory.isDirectory()) { |
| throw new IllegalStateException("Unable to create jcr root dir: " + jcrRootDirectory); |
| } |
| return storingDirectory; |
| } |
| |
| File getTempDir() { |
| return this.tmpDir; |
| } |
| |
| public @NotNull Properties getPackageProperties() { |
| return this.properties; |
| } |
| |
| public void mergeFilters(@NotNull WorkspaceFilter filter) { |
| Map<String, PathFilterSet> propFilterSets = WorkspaceFilterBuilder.extractPropertyFilters(filter); |
| // copy over node-filters together with the corresponding property-filters |
| for (PathFilterSet pathFilterSet : filter.getFilterSets()) { |
| if (!OSGI_BUNDLE_PATTERN.matcher(pathFilterSet.getRoot()).matches()) { |
| PathFilterSet propSet = propFilterSets.remove(pathFilterSet.getRoot()); |
| if (propSet != null) { |
| this.filter.add(pathFilterSet, propSet); |
| } else { |
| this.filter.add(pathFilterSet); |
| } |
| } |
| } |
| } |
| |
| public DefaultWorkspaceFilter getFilter() { |
| return filter; |
| } |
| |
| public void addEntry(@NotNull String path, @NotNull Archive archive, @NotNull Entry entry) throws IOException { |
| try (InputStream input = Objects.requireNonNull(archive.openInputStream(entry))) { |
| addEntry(path, input); |
| } |
| String repoPath = toRepositoryPath(path); |
| if (!filter.covers(repoPath) && VaultPackageUtils.isContentEntry(path)) { |
| try (InputStream input = Objects.requireNonNull(archive.openInputStream(entry))) { |
| // need to inspect .content.xml to collect the complete set of converted paths (see SLING-10754) |
| DefaultEntryParser parser = new DefaultEntryParser(repoPath); |
| parser.parse(input); |
| extractedConvertedRepoPaths.addAll(parser.getParsingResult()); |
| } |
| } |
| } |
| |
| public void addEntry(@NotNull String path, @NotNull File file) throws IOException { |
| try (InputStream input = new FileInputStream(file)) { |
| addEntry(path, input); |
| } |
| } |
| |
| public void addEntry(@NotNull String path, @NotNull InputStream input) throws IOException { |
| try (OutputStream output = createEntry(path)) { |
| IOUtils.copy(input, output); |
| } |
| } |
| |
| public @NotNull OutputStream createEntry(@NotNull String path) throws IOException { |
| File target = new File(storingDirectory, path); |
| if (!target.getParentFile().mkdirs() && !target.getParentFile().isDirectory()) { |
| throw new IOException("Could not create parent directory: " + target.getParentFile()); |
| } |
| convertedCpPaths.add(path); |
| return new FileOutputStream(target); |
| } |
| |
| public @NotNull File getEntry(@NotNull String path) { |
| if (!path.startsWith(ROOT_DIR)) { |
| path = ROOT_DIR + path; |
| } |
| |
| return new File(storingDirectory, path); |
| } |
| |
| /** |
| * Records an entry path as it is processed by the {@link ContentPackage2FeatureModelConverter}. The path of all |
| * original entries that got processed will later be compared to the paths of those entries written back |
| * to this assembler to build the converted content package and generate an updated {@code WorkspaceFilter} that no |
| * longer refers to paths that got moved out to the feature model (see also https://issues.apache.org/jira/browse/SLING-10467) |
| * |
| * @param entryPath The path of a content package entry processed by the converter. |
| * @return {@code true} if the given path was successfully added to the internal set, {@code false} otherwise. |
| */ |
| public boolean recordEntryPath(@NotNull String entryPath) { |
| return allPaths.add(entryPath); |
| } |
| |
| public void updateDependencies(@NotNull Map<PackageId, Set<Dependency>> mutableContentsIds) { |
| Map<Dependency, Set<Dependency>> matches = new HashMap<>(); |
| for (Dependency dependency : dependencies) { |
| for (java.util.Map.Entry<PackageId, Set<Dependency>> mutableContentId : mutableContentsIds.entrySet()) { |
| if (dependency.matches(mutableContentId.getKey())) { |
| matches.put(dependency, mutableContentId.getValue()); |
| } |
| } |
| } |
| for (java.util.Map.Entry<Dependency, Set<Dependency>> match : matches.entrySet()) { |
| dependencies.remove(match.getKey()); |
| dependencies.addAll(match.getValue()); |
| } |
| } |
| |
| public void addDependency(@NotNull Dependency dependency) { |
| dependencies.add(dependency); |
| } |
| |
| public @NotNull File createPackage() throws IOException { |
| return createPackage(false); |
| } |
| |
| public @NotNull File createPackage(boolean generateFilters) throws IOException { |
| // generate the Vault properties XML file |
| |
| File metaDir = new File(storingDirectory, META_DIR); |
| if (!metaDir.exists() && !metaDir.mkdirs()) { |
| throw new IOException("Could not create meta Dir: " + metaDir); |
| } |
| |
| final PackageType sourcePackageType; |
| final String sourcePackageTypeValue = (String) properties.get(PackageProperties.NAME_PACKAGE_TYPE); |
| if (sourcePackageTypeValue != null) { |
| sourcePackageType = PackageType.valueOf(sourcePackageTypeValue.toUpperCase()); |
| } else { |
| sourcePackageType = null; |
| } |
| PackageType newPackageType = VaultPackageUtils.recalculatePackageType(sourcePackageType, storingDirectory); |
| if (newPackageType != null) { |
| properties.setProperty(PackageProperties.NAME_PACKAGE_TYPE, newPackageType.name().toLowerCase()); |
| } |
| |
| setDependencies(dependencies, properties); |
| |
| File xmlProperties = new File(metaDir, PROPERTIES_XML); |
| |
| try (FileOutputStream fos = new FileOutputStream(xmlProperties)) { |
| properties.storeToXML(fos, null); |
| } |
| |
| if (generateFilters) { |
| // generate the Vault filter XML file based on new contents of the package |
| computeFilters(storingDirectory); |
| } |
| |
| Set<String> allRepoPaths = VaultPackageUtils.toRepositoryPaths(allPaths); |
| Set<String> convertedCpRepoPaths = VaultPackageUtils.toRepositoryPaths(convertedCpPaths); |
| Set<String> filteredPaths = new HashSet<>(allRepoPaths); |
| filteredPaths.removeAll(convertedCpRepoPaths); |
| |
| WorkspaceFilterBuilder filterBuilder = new WorkspaceFilterBuilder(filter, filteredPaths, convertedCpRepoPaths, extractedConvertedRepoPaths); |
| WorkspaceFilter adjustedFilter = filterBuilder.build(); |
| |
| File xmlFilter = new File(metaDir, FILTER_XML); |
| try (InputStream input = adjustedFilter.getSource(); |
| FileOutputStream output = new FileOutputStream(xmlFilter)) { |
| IOUtils.copy(input, output); |
| } |
| |
| // create the target archiver |
| final String destFileName = storingDirectory.getName().substring(0, storingDirectory.getName().lastIndexOf('-')); |
| final File destFile = new File(this.tmpDir, destFileName); |
| final File manifestFile = new File(storingDirectory, JarFile.MANIFEST_NAME.replace('/', File.separatorChar)); |
| Manifest manifest = null; |
| if (manifestFile.exists()) { |
| try (final InputStream r = new FileInputStream(manifestFile)) { |
| manifest = new Manifest(r); |
| } |
| } |
| try (final JarOutputStream jos = manifest == null ? new JarOutputStream(new FileOutputStream(destFile)) |
| : new JarOutputStream(new FileOutputStream(destFile), manifest)) { |
| jos.setLevel(Deflater.DEFAULT_COMPRESSION); |
| addDirectory(jos, storingDirectory, storingDirectory.getAbsolutePath().length() + 1); |
| } |
| |
| return destFile; |
| } |
| |
| private static void addDirectory(@NotNull final JarOutputStream jos, @NotNull final File dir, final int prefixLength) throws IOException { |
| if (dir.getAbsolutePath().length() > prefixLength && dir.listFiles().length == 0) { |
| final String dirName = dir.getAbsolutePath().substring(prefixLength).replace(File.separatorChar, '/'); |
| final JarEntry entry = new JarEntry(dirName); |
| entry.setTime(dir.lastModified()); |
| entry.setSize(0); |
| jos.putNextEntry(entry); |
| jos.closeEntry(); |
| } |
| for (final File f : dir.listFiles()) { |
| final String name = f.getAbsolutePath().substring(prefixLength).replace(File.separatorChar, '/'); |
| if (f.isFile() && !JarFile.MANIFEST_NAME.equals(name)) { |
| final JarEntry entry = new JarEntry(name); |
| entry.setTime(f.lastModified()); |
| jos.putNextEntry(entry); |
| |
| try (final FileInputStream in = new FileInputStream(f)) { |
| IOUtils.copy(in, jos); |
| } |
| jos.closeEntry(); |
| } else if (f.isDirectory()) { |
| addDirectory(jos, f, prefixLength); |
| } |
| } |
| } |
| |
| private void computeFilters(@NotNull File outputDirectory) { |
| VaultPackageUtils.forEachDirectoryBelowJcrRoot(outputDirectory, (child, base) -> { |
| TreeNode node = lowestCommonAncestor(new TreeNode(child)); |
| File lowestCommonAncestor = node != null ? node.val : null; |
| if (lowestCommonAncestor != null) { |
| String root = "/" + PlatformNameFormat.getRepositoryPath(base.toURI().relativize(lowestCommonAncestor.toURI()).getPath(), true); |
| filter.add(new PathFilterSet(root)); |
| } |
| }); |
| } |
| |
| private static @Nullable TreeNode lowestCommonAncestor(@NotNull TreeNode root) { |
| int currMaxDepth = 0;//curr tree's deepest leaf depth |
| int countMaxDepth = 0;//num of deepest leaves |
| TreeNode node = null; |
| |
| for (File child : root.val.listFiles((FileFilter) DirectoryFileFilter.INSTANCE)) { |
| TreeNode temp = lowestCommonAncestor(new TreeNode(child)); |
| |
| if (temp == null) { |
| continue; |
| } else if (temp.maxDepth > currMaxDepth) {//if deeper leaf found,update everything to that deeper leaf |
| currMaxDepth = temp.maxDepth; |
| node = temp;//update the maxDepth leaf/LCA |
| countMaxDepth = 1;//reset count of maxDepth leaves |
| } else if (temp.maxDepth == currMaxDepth) { |
| countMaxDepth++;//more deepest leaves of curr (sub)tree found |
| } |
| } |
| |
| if (countMaxDepth > 1) { |
| //if there're several leaves at the deepest level of curr tree,curr root is the LCA of them |
| //OR if there're several LCA of several deepest leaves in curr tree,curr root is also the LCA of them |
| root.maxDepth = node.maxDepth + 1;//update root's maxDepth and return it |
| return root; |
| } else if (countMaxDepth == 1) { |
| //if there's only 1 deepest leaf or only 1 LCA of curr tree,return that leaf/LCA |
| node.maxDepth++;//update node's maxDepth and return it |
| return node; |
| } else if (countMaxDepth == 0) { |
| //if curr root's children have no children(all leaves,so all return null to temp),set root's maxDepth to 2,return |
| root.maxDepth = 2;//update node's maxDepth to 2 cuz its children are leaves |
| return root; |
| } |
| |
| return null; |
| } |
| |
| private static final class TreeNode { |
| |
| File val; |
| |
| int maxDepth;//this means the maxDepth of curr treenode-rooted (sub)tree |
| |
| TreeNode(@NotNull File x) { |
| val = x; |
| maxDepth = 0; |
| } |
| |
| } |
| |
| private static final class RemoveInstallHooksPredicate implements Predicate<Map.Entry<Object, Object>> { |
| @Override |
| public boolean test(java.util.Map.Entry<Object, Object> entry) { |
| String key = (String) entry.getKey(); |
| return !key.startsWith(PackageProperties.PREFIX_INSTALL_HOOK); |
| } |
| } |
| } |