From 06b408f1137d48b98a8893fe723326f534ced51f Mon Sep 17 00:00:00 2001 From: Andrey Loskutov Date: Fri, 3 Jul 2026 13:36:25 +0200 Subject: [PATCH] Reflect broken symlinks in resources model Broken file links were not present in resource model before, now they are (at least on Linux x86_64, in the future hopefully on all Unix platforms). For "broken" link overlays we need an additional patch in Platform UI. Fixes https://github.com/eclipse-platform/eclipse.platform/issues/2797 --- .../os/linux/x86_64/libfastlinuxfile_1_0_0.so | Bin 14408 -> 14408 bytes .../natives/unix/fastlinux/fastlinuxfile.c | 2 +- .../internal/filesystem/local/LocalFile.java | 13 ++ .../local/linux/LinuxFileNatives.java | 9 +- .../localstore/FileSystemResourceManager.java | 2 +- .../internal/localstore/UnifiedTreeNode.java | 6 +- .../core/internal/resources/ResourceTree.java | 32 ++++- .../core/tests/filesystem/SymlinkTest.java | 72 ++++++++-- .../localstore/SymlinkResourceTest.java | 128 ++++++++++++++++++ 9 files changed, 239 insertions(+), 25 deletions(-) diff --git a/resources/bundles/org.eclipse.core.filesystem.linux.x86_64/os/linux/x86_64/libfastlinuxfile_1_0_0.so b/resources/bundles/org.eclipse.core.filesystem.linux.x86_64/os/linux/x86_64/libfastlinuxfile_1_0_0.so index 8246930cd6ad1d78bd9bdba7c702154801770084..ea56e3c1ebae1f893f24dbb626059dc079733a11 100644 GIT binary patch delta 872 zcmY+DZAep57{~9q>bz5O8?>2jZo6}lV0953Zc+GxxeFB)Mkz#SAIuTuDubdAC1h=k zh4vWfhkW^xR;I&2U?|REuu{b6gF;c-2c2kZH4^FV^vqkP_rw2p|L6al=bUpnH{=XC zo2uk3W@hbaX?BdLI9@XA1RLqtnmQXyetd7B`uf7*_OWfNY+}R775_5nWyvJ8C3X;> z-?*?F>q0I1YD2bbp{tQ8xabRK^<_(UTy0x3o(&UC^V@ zaZbtHf(bNrk~s@eY;`~~2jke>>W=YWt2C)553I`PGxrQg*kc;YtL#UUa4z&`RH5tSXPc(bTMgP2pvSb?OGmdd1vb9YQ zK}lvEc>@EPD#aj-YaILAw_x^l6*gM z8?^{tXA3M71J+!V)QTM^{pZZr9_0&ip8>avj}PYZ3*>>n YE&#lxK>q)KsYZ8&mnyILyDZ)8AD%5a-T(jq delta 918 zcmY+DZAep57{~9qwzVO4HQMrJdcE6?1k)k7)F?{v%`+|3@r^uXi5J@Tv=g@@ zO|hXS-C*Jjs?AV_k*u0^DCTrW=4A0X0JB#z&rrLM4ZJI|g;++i1WDGA*K*GobaOhD zT^3#N0mXe;Sipx-S7mW6fZb8H*nd%pa?d$P4Shi#gFjSMg>cw!)tDW8m^TQ=_GwI) zZU-~W@Lt@hWd2h6it+ioxR!CQX*6~G=A>jjaXDbCw(X7`_EWT_-S(*+wiWFp=+x+0 zZ7{sk2xJ4kVx0xGRv_;o9%~=uVFjqfS_6&R2vP>U+GC{2F|Va8S%>+E^Qsx;)}p5& zBeIfA!eC?p&Q?ank+WcoiX{ncA9}rnLN`wA2pO zF}h5jm(tUgtiXBYnpddZi7B~e&*GKelyZC>s=!A^#13TA= z$W&ad2kAuO?wJN3rRB)#RZ3ftPNWN2zD8*%{$z1}N(+%LWCzmuolH^5a%Zcf)AWS> E1Cm2iApigX diff --git a/resources/bundles/org.eclipse.core.filesystem/natives/unix/fastlinux/fastlinuxfile.c b/resources/bundles/org.eclipse.core.filesystem/natives/unix/fastlinux/fastlinuxfile.c index 6bc4238134a..98d3b70f9be 100644 --- a/resources/bundles/org.eclipse.core.filesystem/natives/unix/fastlinux/fastlinuxfile.c +++ b/resources/bundles/org.eclipse.core.filesystem/natives/unix/fastlinux/fastlinuxfile.c @@ -481,7 +481,7 @@ JNIEXPORT jobjectArray JNICALL Java_org_eclipse_core_internal_filesystem_local_l /* Follow symlink and update stat for further processing. */ if (fstatat(directoryFd, entry->d_name, &st, 0) != 0) { - memset(&st, 0, sizeof(st)); + /* Keep lstat data from the symlink itself for dangling links. */ statErrno = errno; } diff --git a/resources/bundles/org.eclipse.core.filesystem/src/org/eclipse/core/internal/filesystem/local/LocalFile.java b/resources/bundles/org.eclipse.core.filesystem/src/org/eclipse/core/internal/filesystem/local/LocalFile.java index 3fc03131d99..acdd0f6f418 100644 --- a/resources/bundles/org.eclipse.core.filesystem/src/org/eclipse/core/internal/filesystem/local/LocalFile.java +++ b/resources/bundles/org.eclipse.core.filesystem/src/org/eclipse/core/internal/filesystem/local/LocalFile.java @@ -18,6 +18,7 @@ *******************************************************************************/ package org.eclipse.core.internal.filesystem.local; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; @@ -533,6 +534,12 @@ public InputStream openInputStream(int options, IProgressMonitor monitor) throws try { return new FileInputStream(file); } catch (FileNotFoundException e) { + IFileInfo info = fetchInfo(); + if (!info.exists() && info.getAttribute(EFS.ATTRIBUTE_SYMLINK) && info.getStringAttribute(EFS.ATTRIBUTE_LINK_TARGET) != null) { + // If the file is a symbolic link and the target is set but does not exist, return an empty + // stream instead of throwing an exception + return new ByteArrayInputStream(new byte[0]); + } handleReadIOException(e); return null; } @@ -544,6 +551,12 @@ public byte[] readAllBytes(int options, IProgressMonitor monitor) throws CoreExc try { return Files.readAllBytes(file.toPath()); } catch (IOException e) { + IFileInfo info = fetchInfo(); + if (!info.exists() && info.getAttribute(EFS.ATTRIBUTE_SYMLINK) && info.getStringAttribute(EFS.ATTRIBUTE_LINK_TARGET) != null) { + // If the file is a symbolic link and the target is set but does not exist, return an empty + // stream instead of throwing an exception + return new byte[0]; + } handleReadIOException(e); return null; } diff --git a/resources/bundles/org.eclipse.core.filesystem/src/org/eclipse/core/internal/filesystem/local/linux/LinuxFileNatives.java b/resources/bundles/org.eclipse.core.filesystem/src/org/eclipse/core/internal/filesystem/local/linux/LinuxFileNatives.java index a50462736e2..b8995b3dd67 100644 --- a/resources/bundles/org.eclipse.core.filesystem/src/org/eclipse/core/internal/filesystem/local/linux/LinuxFileNatives.java +++ b/resources/bundles/org.eclipse.core.filesystem/src/org/eclipse/core/internal/filesystem/local/linux/LinuxFileNatives.java @@ -121,10 +121,15 @@ public static FileInfo fetchFileInfo(String fileName) { info = new FileInfo(); } else if ((stat.st_mode & S_IFMT) == S_IFLNK) { // it's a link! LinuxStructStat targetStat = new LinuxStructStat(); - if (stat(name, targetStat) == 0) { // get the information about the file the link points to + if (stat(name, targetStat) == 0 && targetStat.errno == 0) { // get the information about the file the link points to info = targetStat.toFileInfo(); // store the target file stats in info } else { // invalid link target - info = new FileInfo(); + // Keep symlink metadata (permissions/timestamp) from lstat for dangling links. + info = stat.toFileInfo(); + if (targetStat.errno == ENOENT) { + // Preserve historical "broken target" semantics while still providing link attributes. + info.setExists(false); + } if (targetStat.errno != ENOENT) { info.setError(IFileInfo.IO_ERROR); } diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/FileSystemResourceManager.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/FileSystemResourceManager.java index e1d3fff3a8e..0f99318d17f 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/FileSystemResourceManager.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/FileSystemResourceManager.java @@ -329,7 +329,7 @@ public IResource[] allResourcesFor(URI location, boolean files, int memberFlags) public ResourceAttributes attributes(IResource resource) { IFileStore store = getStore(resource); IFileInfo fileInfo = store.fetchInfo(); - if (!fileInfo.exists()) { + if (!fileInfo.exists() && !fileInfo.getAttribute(EFS.ATTRIBUTE_SYMLINK)) { return null; } return FileUtil.fileInfoToAttributes(fileInfo); diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/UnifiedTreeNode.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/UnifiedTreeNode.java index 5c6de678e21..8c0b434317f 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/UnifiedTreeNode.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/UnifiedTreeNode.java @@ -15,7 +15,9 @@ package org.eclipse.core.internal.localstore; import java.util.Iterator; -import org.eclipse.core.filesystem.*; +import org.eclipse.core.filesystem.EFS; +import org.eclipse.core.filesystem.IFileInfo; +import org.eclipse.core.filesystem.IFileStore; import org.eclipse.core.internal.resources.Resource; import org.eclipse.core.resources.IResource; @@ -41,7 +43,7 @@ public UnifiedTreeNode(UnifiedTree tree, IResource resource, IFileStore store, I } public boolean existsInFileSystem() { - return fileInfo != null && fileInfo.exists(); + return fileInfo != null && (fileInfo.exists() || fileInfo.getAttribute(EFS.ATTRIBUTE_SYMLINK)); } /** diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/ResourceTree.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/ResourceTree.java index cac927a0644..7b95c9374ca 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/ResourceTree.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/ResourceTree.java @@ -15,14 +15,35 @@ package org.eclipse.core.internal.resources; import java.net.URI; -import org.eclipse.core.filesystem.*; +import org.eclipse.core.filesystem.EFS; +import org.eclipse.core.filesystem.IFileInfo; +import org.eclipse.core.filesystem.IFileStore; +import org.eclipse.core.filesystem.IFileSystem; import org.eclipse.core.filesystem.URIUtil; import org.eclipse.core.internal.localstore.FileSystemResourceManager; import org.eclipse.core.internal.properties.IPropertyManager; -import org.eclipse.core.internal.utils.*; -import org.eclipse.core.resources.*; +import org.eclipse.core.internal.utils.BitMask; +import org.eclipse.core.internal.utils.Messages; +import org.eclipse.core.internal.utils.Policy; +import org.eclipse.core.resources.IContainer; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IProjectDescription; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.IResourceStatus; +import org.eclipse.core.resources.IResourceVisitor; +import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.resources.team.IResourceTree; -import org.eclipse.core.runtime.*; +import org.eclipse.core.runtime.Assert; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.MultiStatus; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.core.runtime.SubMonitor; import org.eclipse.core.runtime.jobs.ILock; import org.eclipse.osgi.util.NLS; @@ -289,7 +310,8 @@ private boolean internalDeleteFile(IFile file, int flags, IProgressMonitor monit // If the file doesn't exist on disk then signal to the workspace to delete the // file and return. IFileStore fileStore = localManager.getStore(file); - boolean localExists = fileStore.fetchInfo().exists(); + IFileInfo localFileInfo = fileStore.fetchInfo(); + boolean localExists = localFileInfo.exists() || localFileInfo.getAttribute(EFS.ATTRIBUTE_SYMLINK); if (!localExists) { deletedFile(file); // Indicate that the delete was successful. diff --git a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/filesystem/SymlinkTest.java b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/filesystem/SymlinkTest.java index 421fe599257..6104a421d58 100644 --- a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/filesystem/SymlinkTest.java +++ b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/filesystem/SymlinkTest.java @@ -35,6 +35,10 @@ import java.io.IOException; import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Paths; +import java.nio.file.attribute.PosixFileAttributes; import java.util.function.Consumer; import java.util.function.Function; import org.eclipse.core.filesystem.EFS; @@ -50,6 +54,18 @@ import org.junit.jupiter.api.extension.RegisterExtension; public class SymlinkTest { + + private static final boolean SUPPORT_BROKEN_SYMLINKS = Platform.OS.isLinux() + && Platform.ARCH_X86_64.equals(Platform.getOSArch()); + + /** + * Tolerance in milliseconds applied when comparing a file's last modified time against a + * timestamp captured via {@link System#currentTimeMillis()}. The file system last modified + * time may be provided by a finer-resolution native (JNI) source, so rounding/truncation can + * make it appear a few milliseconds earlier than the wall-clock value. + */ + private static final long TIMESTAMP_TOLERANCE_MS = 1000; + /** * Symbolic links on Windows behave differently compared to Unix-based systems. Symbolic links * on Windows have their own set of attributes independent from the attributes of the link's @@ -57,7 +73,9 @@ public class SymlinkTest { * existence of the symbolic link itself, not its target. */ private static final boolean SYMLINKS_ARE_FIRST_CLASS_FILES_OR_DIRECTORIES = Platform.OS_WIN32 - .equals(Platform.getOS()) ? true : false; + .equals(Platform.getOS()); + + private static String specialCharName = "äöüß ÄÖÜ àÀâ µ²³úá"; //$NON-NLS-1$ protected IFileStore aDir, aFile; //actual Dir, File @@ -109,7 +127,11 @@ protected void mkLink(IFileStore dir, String src, String tgt, boolean isDir) thr @Test public void testBrokenSymlinkAttributes() throws Exception { - long testStartTime = System.currentTimeMillis(); + // Use a tolerance because getLastModified() may originate from a finer-resolution + // native (JNI) timer than System.currentTimeMillis(). Truncation/rounding can make + // the reported modification time appear a few milliseconds before testStartTime even + // though the file was created afterwards. + long testStartTime = System.currentTimeMillis() - TIMESTAMP_TOLERANCE_MS; makeLinkStructure(); //break links by removing actual dir and file ensureDoesNotExist(aDir); @@ -124,22 +146,39 @@ public void testBrokenSymlinkAttributes() throws Exception { assertEquals(SYMLINKS_ARE_FIRST_CLASS_FILES_OR_DIRECTORIES, ilDir.isDirectory()); assertEquals(SYMLINKS_ARE_FIRST_CLASS_FILES_OR_DIRECTORIES, illDir.exists()); assertEquals(SYMLINKS_ARE_FIRST_CLASS_FILES_OR_DIRECTORIES, illDir.isDirectory()); - if (SYMLINKS_ARE_FIRST_CLASS_FILES_OR_DIRECTORIES) { + if (SYMLINKS_ARE_FIRST_CLASS_FILES_OR_DIRECTORIES || SUPPORT_BROKEN_SYMLINKS) { // Symlinks on Windows have their own modification time. - assertTrue(ilFile.getLastModified() >= testStartTime); - assertTrue(ilDir.getLastModified() >= testStartTime); - assertTrue(illFile.getLastModified() >= testStartTime); - assertTrue(illDir.getLastModified() >= testStartTime); + assertTrue(ilFile.getLastModified() >= testStartTime, + "getLastModified()=" + ilFile.getLastModified() + " testStartTime=" + testStartTime); + assertTrue(ilDir.getLastModified() >= testStartTime, + "getLastModified()=" + ilDir.getLastModified() + " testStartTime=" + testStartTime); + assertTrue(illFile.getLastModified() >= testStartTime, + "getLastModified()=" + illFile.getLastModified() + " testStartTime=" + testStartTime); + assertTrue(illDir.getLastModified() >= testStartTime, + "getLastModified()=" + illDir.getLastModified() + " testStartTime=" + testStartTime); } else { assertEquals(0, ilFile.getLastModified()); assertEquals(0, ilDir.getLastModified()); assertEquals(0, illFile.getLastModified()); assertEquals(0, illDir.getLastModified()); } - assertEquals(0, ilFile.getLength()); - assertEquals(0, ilDir.getLength()); - assertEquals(0, illFile.getLength()); - assertEquals(0, illDir.getLength()); + + if (SUPPORT_BROKEN_SYMLINKS) { + PosixFileAttributes ilFileAttrs = posixAttributess(lFile); + PosixFileAttributes illFileAttrs = posixAttributess(llFile); + PosixFileAttributes ilDirAttrs = posixAttributess(llDir); + PosixFileAttributes illDirAttrs = posixAttributess(llDir); + + assertEquals(ilFileAttrs.size(), ilFile.getLength()); + assertEquals(ilDirAttrs.size(), ilDir.getLength()); + assertEquals(illFileAttrs.size(), illFile.getLength()); + assertEquals(illDirAttrs.size(), illDir.getLength()); + } else { + assertEquals(0, ilFile.getLength()); + assertEquals(0, ilDir.getLength()); + assertEquals(0, illFile.getLength()); + assertEquals(0, illDir.getLength()); + } assertTrue(ilFile.getAttribute(EFS.ATTRIBUTE_SYMLINK)); assertEquals(ilFile.getStringAttribute(EFS.ATTRIBUTE_LINK_TARGET), "aFile"); @@ -151,6 +190,11 @@ public void testBrokenSymlinkAttributes() throws Exception { assertEquals(illDir.getStringAttribute(EFS.ATTRIBUTE_LINK_TARGET), "lDir"); } + private static PosixFileAttributes posixAttributess(IFileStore store) throws IOException { + return Files.readAttributes(Paths.get(store.toURI()), PosixFileAttributes.class, + LinkOption.NOFOLLOW_LINKS); + } + // Moving a broken symlink is possible. @Test public void testBrokenSymlinkMove() throws Exception { @@ -213,7 +257,7 @@ public void testRecursiveSymlink() throws Exception { mkLink(baseStore, "l2", "l1", false); IFileStore l1 = baseStore.getChild("l1"); IFileInfo i1 = l1.fetchInfo(); - assertEquals(SYMLINKS_ARE_FIRST_CLASS_FILES_OR_DIRECTORIES, i1.exists()); + assertEquals(SYMLINKS_ARE_FIRST_CLASS_FILES_OR_DIRECTORIES || SUPPORT_BROKEN_SYMLINKS, i1.exists()); assertFalse(i1.isDirectory()); assertTrue(i1.getAttribute(EFS.ATTRIBUTE_SYMLINK)); @@ -235,7 +279,7 @@ public void testRecursiveSymlink() throws Exception { assertTrue(exceptionThrown); assertTrue(i1.getAttribute(EFS.ATTRIBUTE_READ_ONLY)); } - assertEquals(SYMLINKS_ARE_FIRST_CLASS_FILES_OR_DIRECTORIES, i1.exists()); + assertEquals(SYMLINKS_ARE_FIRST_CLASS_FILES_OR_DIRECTORIES || SUPPORT_BROKEN_SYMLINKS, i1.exists()); i1.setLastModified(12345); exceptionThrown = false; @@ -248,7 +292,7 @@ public void testRecursiveSymlink() throws Exception { //FIXME bug: putInfo neither sets attributes nor throws an exception for broken symbolic links //assertTrue(exceptionThrown); //assertEquals(i1.getLastModified(), 12345); - assertEquals(SYMLINKS_ARE_FIRST_CLASS_FILES_OR_DIRECTORIES, i1.exists()); + assertEquals(SYMLINKS_ARE_FIRST_CLASS_FILES_OR_DIRECTORIES || SUPPORT_BROKEN_SYMLINKS, i1.exists()); l1.delete(EFS.NONE, getMonitor()); infos = baseStore.childInfos(EFS.NONE, getMonitor()); diff --git a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/internal/localstore/SymlinkResourceTest.java b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/internal/localstore/SymlinkResourceTest.java index 288a0ee4cd2..7cfaed54176 100644 --- a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/internal/localstore/SymlinkResourceTest.java +++ b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/internal/localstore/SymlinkResourceTest.java @@ -22,20 +22,32 @@ import static org.eclipse.core.tests.resources.ResourceTestUtil.createTestMonitor; import static org.eclipse.core.tests.resources.ResourceTestUtil.waitForRefresh; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.util.Set; import org.eclipse.core.filesystem.EFS; +import org.eclipse.core.filesystem.IFileInfo; import org.eclipse.core.filesystem.IFileStore; import org.eclipse.core.internal.localstore.UnifiedTree; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.IResource; import org.eclipse.core.resources.IResourceVisitor; import org.eclipse.core.resources.IWorkspaceRunnable; +import org.eclipse.core.resources.ResourceAttributes; import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IPath; import org.eclipse.core.runtime.Platform.OS; @@ -244,4 +256,120 @@ public void testBug358830(boolean useAdvancedLinkCheck) throws Exception { } } + @Test + public void testBrokenSymlinkSeenAsIFileChild() throws Exception { + assumeTrue(canCreateSymLinks(), "only relevant for platforms supporting symbolic links"); + + IProject project = getWorkspace().getRoot().getProject("testBrokenSymlinkSeenAsIFileChild"); + createInWorkspace(project); + + IFileStore folderStore = EFS.getStore(project.getLocationURI()).getChild("parent"); + folderStore.mkdir(EFS.NONE, createTestMonitor()); + mkLink(folderStore, "broken.txt", "missing.txt", false); + + project.refreshLocal(IResource.DEPTH_INFINITE, createTestMonitor()); + + IFolder parent = project.getFolder("parent"); + IFile brokenLink = project.getFile("parent/broken.txt"); + assertTrue(parent.exists()); + assertTrue(brokenLink.exists()); + assertEquals(IResource.FILE, brokenLink.getType()); + + try (InputStream contents = brokenLink.getContents()) { + assertNotNull(contents); + assertEquals(0, contents.readAllBytes().length); + } + + // A broken symlink is exposed as an empty file. All read-oriented IFile + // API must handle it gracefully (empty content / sensible defaults) + // rather than fail. + try (InputStream forcedContents = brokenLink.getContents(true)) { + assertNotNull(forcedContents); + assertEquals(0, forcedContents.readAllBytes().length); + } + assertEquals(0, brokenLink.readAllBytes().length); + assertEquals(0, brokenLink.readNBytes(1024).length); + assertEquals(0, brokenLink.readAllChars().length); + assertEquals("", brokenLink.readString()); + + // Charset / encoding lookups must not fail for a broken link. + assertNotNull(brokenLink.getCharset()); + assertNotNull(brokenLink.getCharset(true)); + // getCharset(false) may return null when no charset was explicitly set; + // it just must not throw. + assertNull(brokenLink.getCharset(false)); + + // Content description of an empty file must be obtainable without + // throwing (the value itself may be null when no type can be inferred). + brokenLink.getContentDescription(); + + // Line separator lookup must not fail. + assertNotNull(brokenLink.getLineSeparator(true)); + + // A broken link has no local history and is not content-restricted. + assertEquals(0, brokenLink.getHistory(createTestMonitor()).length); + assertFalse(brokenLink.isContentRestricted()); + + IFileStore fileStore = EFS.getStore(brokenLink.getLocationURI()); + IFileInfo info = fileStore.fetchInfo(); + + ResourceAttributes resourceAttributes = brokenLink.getResourceAttributes(); + assertNotNull(resourceAttributes); + assertTrue(resourceAttributes.isSymbolicLink()); + assertFalse(resourceAttributes.isReadOnly()); + assertTrue(resourceAttributes.isExecutable()); + assertFalse(resourceAttributes.isHidden()); + assertFalse(resourceAttributes.isArchive()); + + // java.io.File follows symlinks and thus has no data for broken links. + // Use java.nio.file with NOFOLLOW_LINKS (lstat semantics) to obtain the + // symlink's own metadata and validate the attributes reported by EFS. + Path linkPath = brokenLink.getLocation().toFile().toPath(); + assertTrue(Files.isSymbolicLink(linkPath)); + PosixFileAttributes linkAttributes = Files.readAttributes(linkPath, PosixFileAttributes.class, + LinkOption.NOFOLLOW_LINKS); + + // symlink attribute and link target + assertTrue(info.getAttribute(EFS.ATTRIBUTE_SYMLINK)); + assertEquals(Files.readSymbolicLink(linkPath).toString(), info.getStringAttribute(EFS.ATTRIBUTE_LINK_TARGET)); + + // length and modification time come from the link node itself + assertEquals(linkAttributes.size(), info.getLength()); + assertEquals(linkAttributes.lastModifiedTime().toMillis() / 1000, info.getLastModified() / 1000); + + // permissions come from the link node itself + Set permissions = linkAttributes.permissions(); + assertEquals(permissions.contains(PosixFilePermission.OWNER_READ), + info.getAttribute(EFS.ATTRIBUTE_OWNER_READ)); + assertEquals(permissions.contains(PosixFilePermission.OWNER_WRITE), + info.getAttribute(EFS.ATTRIBUTE_OWNER_WRITE)); + assertEquals(permissions.contains(PosixFilePermission.OWNER_EXECUTE), + info.getAttribute(EFS.ATTRIBUTE_OWNER_EXECUTE)); + assertEquals(permissions.contains(PosixFilePermission.GROUP_READ), + info.getAttribute(EFS.ATTRIBUTE_GROUP_READ)); + assertEquals(permissions.contains(PosixFilePermission.GROUP_WRITE), + info.getAttribute(EFS.ATTRIBUTE_GROUP_WRITE)); + assertEquals(permissions.contains(PosixFilePermission.GROUP_EXECUTE), + info.getAttribute(EFS.ATTRIBUTE_GROUP_EXECUTE)); + assertEquals(permissions.contains(PosixFilePermission.OTHERS_READ), + info.getAttribute(EFS.ATTRIBUTE_OTHER_READ)); + assertEquals(permissions.contains(PosixFilePermission.OTHERS_WRITE), + info.getAttribute(EFS.ATTRIBUTE_OTHER_WRITE)); + assertEquals(permissions.contains(PosixFilePermission.OTHERS_EXECUTE), + info.getAttribute(EFS.ATTRIBUTE_OTHER_EXECUTE)); + + boolean foundAsIFileChild = false; + for (IResource member : parent.members()) { + if ("broken.txt".equals(member.getName())) { + foundAsIFileChild = true; + assertTrue(member instanceof IFile); + assertEquals(IResource.FILE, member.getType()); + assertTrue(member.exists()); + assertNotNull(member.getResourceAttributes()); + assertTrue(brokenLink.getResourceAttributes().isSymbolicLink()); + } + } + assertTrue(foundAsIFileChild, "Expected parent folder to contain broken symlink as child IFile"); + } + }