diff --git a/core/src/main/java/hudson/Util.java b/core/src/main/java/hudson/Util.java index 07ff96fb00..74625b841c 100644 --- a/core/src/main/java/hudson/Util.java +++ b/core/src/main/java/hudson/Util.java @@ -567,6 +567,25 @@ public class Util { return true; } + /** + * A check if a file path is a descendant of a parent path + * @param forParent the parent the child should be a descendant of + * @param potentialChild the path to check + * @return true if so + * @throws IOException for invalid paths + * @since FIXME + * @see InvalidPathException + */ + public static boolean isDescendant(File forParent, File potentialChild) throws IOException { + try { + Path child = potentialChild.getAbsoluteFile().toPath().normalize(); + Path parent = forParent.getAbsoluteFile().toPath().normalize(); + return child.startsWith(parent); + } catch (InvalidPathException e) { + throw new IOException(e); + } + } + /** * Creates a new temporary directory. */ diff --git a/core/src/main/java/hudson/model/Job.java b/core/src/main/java/hudson/model/Job.java index d63b615788..a5e477ede9 100644 --- a/core/src/main/java/hudson/model/Job.java +++ b/core/src/main/java/hudson/model/Job.java @@ -29,6 +29,7 @@ import hudson.EnvVars; import hudson.Extension; import hudson.ExtensionPoint; import hudson.FeedAdapter; +import hudson.FilePath; import hudson.PermalinkList; import hudson.Util; import hudson.cli.declarative.CLIResolver; @@ -68,6 +69,7 @@ import java.awt.Paint; import java.io.File; import java.io.IOException; import java.net.URLEncoder; +import java.nio.file.Files; import java.util.ArrayList; import java.util.Calendar; import java.util.Collection; @@ -680,6 +682,40 @@ public abstract class Job, RunT extends Run"); + if (oldBuildsDir.contains("")) { + LOGGER.severe(String.format("Builds directory for job %1$s appears to be outside of item root," + + " but somehow still containing the item root path, which is unknown. Cannot move builds from %2$s to %1$s.", newFullName, oldFullName)); + } else { + File oldDir = new File(oldBuildsDir); + if (oldDir.isDirectory()) { + try { + FileUtils.moveDirectory(oldDir, newBuildDir); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, String.format("Failed to move %s to %s", oldBuildsDir, newBuildDir.getAbsolutePath()), e); + } + } + } + } + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Failed to inspect " + item.getRootDir() + ". Builds might not be moved.", e); + } + } + } + } + /** * Returns true if we should display "build now" icon */ diff --git a/core/src/main/java/jenkins/model/Jenkins.java b/core/src/main/java/jenkins/model/Jenkins.java index fedc633eb7..43c68621b1 100644 --- a/core/src/main/java/jenkins/model/Jenkins.java +++ b/core/src/main/java/jenkins/model/Jenkins.java @@ -409,7 +409,7 @@ public class Jenkins extends AbstractCIBase implements DirectlyModifiableTopLeve * This value will be variable-expanded as per {@link #expandVariablesForDirectory}. * @see #getBuildDirFor(Job) */ - private String buildsDir = "${ITEM_ROOTDIR}/builds"; + private String buildsDir = DEFAULT_BUILDS_DIR; /** * Message displayed in the top page. @@ -2439,12 +2439,22 @@ public class Jenkins extends AbstractCIBase implements DirectlyModifiableTopLeve return expandVariablesForDirectory(buildsDir, job); } + /** + * If the configured buildsDir has it's default value or has been changed. + * + * @return true if default value. + */ + @Restricted(NoExternalUse.class) + public boolean isDefaultBuildDir() { + return DEFAULT_BUILDS_DIR.equals(buildsDir); + } + private File expandVariablesForDirectory(String base, Item item) { return new File(expandVariablesForDirectory(base, item.getFullName(), item.getRootDir().getPath())); } @Restricted(NoExternalUse.class) - static String expandVariablesForDirectory(String base, String itemFullName, String itemRootDir) { + public static String expandVariablesForDirectory(String base, String itemFullName, String itemRootDir) { return Util.replaceMacro(base, ImmutableMap.of( "JENKINS_HOME", Jenkins.getInstance().getRootDir().getPath(), "ITEM_ROOTDIR", itemRootDir, @@ -5068,6 +5078,12 @@ public class Jenkins extends AbstractCIBase implements DirectlyModifiableTopLeve */ private static final String WORKSPACE_DIRNAME = Configuration.getStringConfigParameter("workspaceDirName", "workspace"); + /** + * Default value of job's builds dir. + * @see #getRawBuildsDir() + */ + private static final String DEFAULT_BUILDS_DIR = "${ITEM_ROOTDIR}/builds"; + /** * Automatically try to launch an agent when Jenkins is initialized or a new agent computer is created. */ diff --git a/core/src/test/java/hudson/UtilTest.java b/core/src/test/java/hudson/UtilTest.java index b8a36923d7..69573726b6 100644 --- a/core/src/test/java/hudson/UtilTest.java +++ b/core/src/test/java/hudson/UtilTest.java @@ -672,4 +672,42 @@ public class UtilTest { } } + @Test + public void testIsDescendant() throws IOException { + File root; + File other; + if (Functions.isWindows()) { + root = new File("C:\\Temp"); + other = new File("C:\\Windows"); + } else { + root = new File("/tmp"); + other = new File("/usr"); + + } + assertTrue(Util.isDescendant(root, new File(root,"child"))); + assertTrue(Util.isDescendant(root, new File(new File(root,"child"), "grandchild"))); + assertFalse(Util.isDescendant(root, other)); + assertFalse(Util.isDescendant(root, new File(other, "child"))); + + assertFalse(Util.isDescendant(new File(root,"child"), root)); + assertFalse(Util.isDescendant(new File(new File(root,"child"), "grandchild"), root)); + + //.. whithin root + File convoluted = new File(root, "child"); + convoluted = new File(convoluted, ".."); + convoluted = new File(convoluted, "child"); + assertTrue(Util.isDescendant(root, convoluted)); + + //.. going outside of root + convoluted = new File(root, ".."); + convoluted = new File(convoluted, other.getName()); + convoluted = new File(convoluted, "child"); + assertFalse(Util.isDescendant(root, convoluted)); + + //. on root + assertTrue(Util.isDescendant(new File(root, "."), new File(root, "child"))); + //. on both + assertTrue(Util.isDescendant(new File(root, "."), new File(new File(root, "child"), "."))); + } + } diff --git a/test/src/test/java/hudson/model/JobTest.java b/test/src/test/java/hudson/model/JobTest.java index d5a519bb69..d20b9c28a6 100644 --- a/test/src/test/java/hudson/model/JobTest.java +++ b/test/src/test/java/hudson/model/JobTest.java @@ -31,17 +31,25 @@ import com.gargoylesoftware.htmlunit.html.HtmlFormUtil; import com.gargoylesoftware.htmlunit.html.HtmlPage; import com.gargoylesoftware.htmlunit.TextPage; +import hudson.FilePath; import hudson.Functions; import hudson.model.queue.QueueTaskFuture; +import hudson.tasks.ArtifactArchiver; +import hudson.tasks.BatchFile; +import hudson.tasks.Shell; import hudson.util.TextFile; + +import java.io.ByteArrayOutputStream; +import java.io.File; import java.util.concurrent.TimeUnit; import java.io.IOException; import java.net.HttpURLConnection; import java.text.MessageFormat; +import java.util.Arrays; +import java.util.Comparator; import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; import jenkins.model.ProjectNamingStrategy; import org.hamcrest.Matchers; @@ -51,6 +59,7 @@ import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.FailureBuilder; import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.JenkinsRule.WebClient; +import org.jvnet.hudson.test.MockFolder; import org.jvnet.hudson.test.RunLoadCounter; import org.jvnet.hudson.test.SleepBuilder; import org.jvnet.hudson.test.recipes.LocalData; @@ -299,6 +308,107 @@ public class JobTest { p.renameTo("different-name"); } + @Issue("JENKINS-44657") + @Test public void testRenameWithCustomBuildsDirWithBuildsIntact() throws Exception { + j.jenkins.setRawBuildsDir("${JENKINS_HOME}/builds/${ITEM_FULL_NAME}/builds"); + final FreeStyleProject p = j.createFreeStyleProject(); + final File oldBuildsDir = p.getBuildDir(); + j.buildAndAssertSuccess(p); + String oldDirContent = dirContent(oldBuildsDir); + p.renameTo("different-name"); + final File newBuildDir = p.getBuildDir(); + assertNotNull(newBuildDir); + assertNotEquals(oldBuildsDir.getAbsolutePath(), newBuildDir.getAbsolutePath()); + String newDirContent = dirContent(newBuildDir); + assertEquals(oldDirContent, newDirContent); + } + + @Issue("JENKINS-44657") + @Test public void testRenameWithCustomBuildsDirWithBuildsIntactInFolder() throws Exception { + j.jenkins.setRawBuildsDir("${JENKINS_HOME}/builds/${ITEM_FULL_NAME}/builds"); + final MockFolder f = j.createFolder("F"); + + final FreeStyleProject p1 = f.createProject(FreeStyleProject.class, "P1"); + j.buildAndAssertSuccess(p1); + File oldP1BuildsDir = p1.getBuildDir(); + final String oldP1DirContent = dirContent(oldP1BuildsDir); + f.renameTo("different-name"); + + File newP1BuildDir = p1.getBuildDir(); + assertNotNull(newP1BuildDir); + assertNotEquals(oldP1BuildsDir.getAbsolutePath(), newP1BuildDir.getAbsolutePath()); + String newP1DirContent = dirContent(newP1BuildDir); + assertEquals(oldP1DirContent, newP1DirContent); + + final FreeStyleProject p2 = f.createProject(FreeStyleProject.class, "P2"); + if (Functions.isWindows()) { + p2.getBuildersList().add(new BatchFile("echo hello > hello.txt")); + } else { + p2.getBuildersList().add(new Shell("echo hello > hello.txt")); + } + p2.getPublishersList().add(new ArtifactArchiver("*.txt")); + j.buildAndAssertSuccess(p2); + + File oldP2BuildsDir = p2.getBuildDir(); + final String oldP2DirContent = dirContent(oldP2BuildsDir); + FreeStyleBuild b2 = p2.getBuilds().getLastBuild(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + b2.getLogText().writeRawLogTo(0, out); + final String oldB2Log = new String(out.toByteArray()); + assertTrue(b2.getArtifactManager().root().child("hello.txt").exists()); + f.renameTo("something-else"); + + //P1 check again + newP1BuildDir = p1.getBuildDir(); + assertNotNull(newP1BuildDir); + assertNotEquals(oldP1BuildsDir.getAbsolutePath(), newP1BuildDir.getAbsolutePath()); + newP1DirContent = dirContent(newP1BuildDir); + assertEquals(oldP1DirContent, newP1DirContent); + + //P2 check + + b2 = p2.getBuilds().getLastBuild(); + assertNotNull(b2); + out = new ByteArrayOutputStream(); + b2.getLogText().writeRawLogTo(0, out); + final String newB2Log = new String(out.toByteArray()); + assertEquals(oldB2Log, newB2Log); + assertTrue(b2.getArtifactManager().root().child("hello.txt").exists()); + + File newP2BuildDir = p2.getBuildDir(); + assertNotNull(newP2BuildDir); + assertNotEquals(oldP2BuildsDir.getAbsolutePath(), newP2BuildDir.getAbsolutePath()); + String newP2DirContent = dirContent(newP2BuildDir); + assertEquals(oldP2DirContent, newP2DirContent); + } + + private String dirContent(File dir) throws IOException, InterruptedException { + if (dir == null || !dir.isDirectory()) { + return null; + } + StringBuilder str = new StringBuilder(""); + final FilePath[] list = new FilePath(dir).list("**/*"); + Arrays.sort(list, Comparator.comparing(FilePath::getRemote)); + for (FilePath path : list) { + str.append(relativePath(dir, path)); + str.append(' ').append(path.length()).append('\n'); + } + return str.toString(); + } + + private String relativePath(File base, FilePath path) throws IOException, InterruptedException { + if (path.absolutize().getRemote().equals(base.getAbsolutePath())) { + return ""; + } else { + final String s = relativePath(base, path.getParent()); + if (s.isEmpty()) { + return path.getName(); + } else { + return s + "/" + path.getName(); + } + } + } + @Issue("JENKINS-30502") @Test public void testRenameTrimsLeadingSpace() throws Exception {