You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucenenet.apache.org by pa...@apache.org on 2026/06/16 21:08:01 UTC

(lucenenet) 01/02: Validate file paths for FSDirectory and Replicator (#1357)

This is an automated email from the ASF dual-hosted git repository.

paulirwin pushed a commit to branch release/4.8.0-beta00018
in repository https://gitbox.apache.org/repos/asf/lucenenet.git

commit f7a62d6d1d9aaa328279da3fcc71f6c2a75611c9
Author: Paul Irwin <pa...@gmail.com>
AuthorDate: Tue Jun 16 14:56:11 2026 -0600

    Validate file paths for FSDirectory and Replicator (#1357)
    
    Rewritten from commit 7b7314634f58952d7e2752e589391eeac22fba78 for this release branch.
---
 src/Lucene.Net.Replicator/LocalReplicator.cs       |   7 ++
 .../PerSessionDirectoryFactory.cs                  |  23 +++-
 src/Lucene.Net.Replicator/SessionToken.cs          |  13 ++-
 .../LocalReplicatorTest.cs                         |  24 ++++-
 .../PerSessionDirectoryFactoryTest.cs              | 118 +++++++++++++++++++++
 .../SessionTokenTest.cs                            |  56 ++++++++++
 src/Lucene.Net.Tests/Store/TestDirectory.cs        |  97 +++++++++++++++++
 .../Support/Text/TestStringExtensions.cs           |  65 ++++++++++++
 src/Lucene.Net/Store/FSDirectory.cs                |  18 ++++
 src/Lucene.Net/Store/MMapDirectory.cs              |   2 +
 src/Lucene.Net/Store/NIOFSDirectory.cs             |   2 +
 src/Lucene.Net/Store/SimpleFSDirectory.cs          |   2 +
 src/Lucene.Net/Support/Text/StringExtensions.cs    |  23 ++++
 13 files changed, 447 insertions(+), 3 deletions(-)

diff --git a/src/Lucene.Net.Replicator/LocalReplicator.cs b/src/Lucene.Net.Replicator/LocalReplicator.cs
index a7c06b7df..b4c8019a8 100644
--- a/src/Lucene.Net.Replicator/LocalReplicator.cs
+++ b/src/Lucene.Net.Replicator/LocalReplicator.cs
@@ -1,4 +1,5 @@
 using J2N.Threading.Atomic;
+using Lucene.Net.Support.Text;
 using Lucene.Net.Support.Threading;
 using System;
 using System.Collections.Generic;
@@ -254,6 +255,12 @@ public virtual long ExpirationThreshold
 
         public virtual Stream ObtainFile(string sessionId, string source, string fileName)
         {
+            // LUCENENET-specific: validate the file name is valid for replication
+            if (!fileName.IsValidSinglePathComponent())
+            {
+                throw new ArgumentException("File name is not valid for replication", nameof(fileName));
+            }
+
             UninterruptableMonitor.Enter(syncLock);
             try
             {
diff --git a/src/Lucene.Net.Replicator/PerSessionDirectoryFactory.cs b/src/Lucene.Net.Replicator/PerSessionDirectoryFactory.cs
index 431bb34e3..894e3abaa 100644
--- a/src/Lucene.Net.Replicator/PerSessionDirectoryFactory.cs
+++ b/src/Lucene.Net.Replicator/PerSessionDirectoryFactory.cs
@@ -1,4 +1,5 @@
 using Lucene.Net.Store;
+using Lucene.Net.Support.Text;
 using System;
 using System.IO;
 using Directory = Lucene.Net.Store.Directory;
@@ -42,6 +43,17 @@ public PerSessionDirectoryFactory(string workingDirectory)
 
         public virtual Directory GetDirectory(string sessionId, string source)
         {
+            // LUCENENET-specific: validate sessionId and source are valid for paths
+            if (!sessionId.IsValidSinglePathComponent())
+            {
+                throw new ArgumentException("Session ID is not valid for replication", nameof(sessionId));
+            }
+
+            if (!source.IsValidSinglePathComponent())
+            {
+                throw new ArgumentException("Source is not valid for replication", nameof(source));
+            }
+
             string sourceDirectory = Path.Combine(workingDirectory, sessionId, source);
             System.IO.Directory.CreateDirectory(sourceDirectory);
             if (!System.IO.Directory.Exists(sourceDirectory))
@@ -51,7 +63,16 @@ public virtual Directory GetDirectory(string sessionId, string source)
 
         public virtual void CleanupSession(string sessionId)
         {
-            if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("sessionID cannot be empty", nameof(sessionId));
+            if (string.IsNullOrEmpty(sessionId))
+            {
+                throw new ArgumentException("sessionID cannot be empty", nameof(sessionId));
+            }
+
+            // LUCENENET-specific: validate sessionId is valid for paths
+            if (!sessionId.IsValidSinglePathComponent())
+            {
+                throw new ArgumentException("Session ID is not valid for replication", nameof(sessionId));
+            }
 
             string sessionDirectory = Path.Combine(workingDirectory, sessionId);
             System.IO.Directory.Delete(sessionDirectory, true);
diff --git a/src/Lucene.Net.Replicator/SessionToken.cs b/src/Lucene.Net.Replicator/SessionToken.cs
index ee04a3801..160d506e2 100644
--- a/src/Lucene.Net.Replicator/SessionToken.cs
+++ b/src/Lucene.Net.Replicator/SessionToken.cs
@@ -1,4 +1,6 @@
 using J2N.IO;
+using Lucene.Net.Support.Text;
+using System;
 using System.Collections.Generic;
 using System.IO;
 using JCG = J2N.Collections.Generic;
@@ -71,7 +73,16 @@ public SessionToken(IDataInput reader)
                 IList<RevisionFile> files = new JCG.List<RevisionFile>(numFiles);
                 for (int i = 0; i < numFiles; i++)
                 {
-                    files.Add(new RevisionFile(reader.ReadUTF(), reader.ReadInt64()));
+                    string fileName = reader.ReadUTF();
+                    long length = reader.ReadInt64();
+
+                    // LUCENENET-specific: validate that fileName is valid for replication
+                    if (!fileName.IsValidSinglePathComponent())
+                    {
+                        throw new ArgumentException("File name is not valid for replication", nameof(fileName));
+                    }
+
+                    files.Add(new RevisionFile(fileName, length));
                 }
                 sourceFiles.Add(source, files);
                 --numSources;
diff --git a/src/Lucene.Net.Tests.Replicator/LocalReplicatorTest.cs b/src/Lucene.Net.Tests.Replicator/LocalReplicatorTest.cs
index f11a4600f..78dc6790f 100644
--- a/src/Lucene.Net.Tests.Replicator/LocalReplicatorTest.cs
+++ b/src/Lucene.Net.Tests.Replicator/LocalReplicatorTest.cs
@@ -1,4 +1,5 @@
-using Lucene.Net.Documents;
+using Lucene.Net.Attributes;
+using Lucene.Net.Documents;
 using Lucene.Net.Index;
 using Lucene.Net.Support.Threading;
 using Lucene.Net.Util;
@@ -90,6 +91,27 @@ public void TestObtainFileAlreadyClosed()
             }
         }
 
+        // LUCENENET-specific: covers fileName validation in LocalReplicator.ObtainFile.
+        // Validation runs before session lookup, so a fresh (never-published) replicator is sufficient.
+        [Test, LuceneNetSpecific]
+        [TestCase("../../a.txt")]
+        [TestCase("..\\a.txt")]
+        [TestCase("/a/b")]
+        [TestCase("C:\\folder\\file")]
+        [TestCase("subdir/file.txt")]
+        [TestCase("subdir\\file.txt")]
+        [TestCase("..")]
+        [TestCase(".")]
+        [TestCase("name\0extra")]
+        [TestCase("")]
+        public void TestObtainFileRejectsInvalidFileName(string invalidFileName)
+        {
+            Assert.Throws<ArgumentException>(() =>
+            {
+                using Stream _ = replicator.ObtainFile("session1", "src", invalidFileName);
+            }, $"ObtainFile should reject fileName '{invalidFileName}'");
+        }
+
         [Test]
         public void TestPublishAlreadyClosed()
         {
diff --git a/src/Lucene.Net.Tests.Replicator/PerSessionDirectoryFactoryTest.cs b/src/Lucene.Net.Tests.Replicator/PerSessionDirectoryFactoryTest.cs
new file mode 100644
index 000000000..b1509d669
--- /dev/null
+++ b/src/Lucene.Net.Tests.Replicator/PerSessionDirectoryFactoryTest.cs
@@ -0,0 +1,118 @@
+using Lucene.Net.Attributes;
+using Lucene.Net.Util;
+using NUnit.Framework;
+using System;
+using System.IO;
+using Directory = Lucene.Net.Store.Directory;
+
+namespace Lucene.Net.Replicator
+{
+    /*
+     * 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.
+     */
+
+    // LUCENENET-specific: covers sessionId and source name validation in PerSessionDirectoryFactory
+    // for the GetDirectory and CleanupSession entry points.
+    [LuceneNetSpecific]
+    public class PerSessionDirectoryFactoryTest : ReplicatorTestCase
+    {
+        private static readonly string[] InvalidPathComponents =
+        {
+            "../a",
+            "..\\a",
+            "/a",
+            "C:\\folder",
+            "subdir/segment",
+            "subdir\\segment",
+            "..",
+            ".",
+            "name\0extra",
+            "",
+        };
+
+        [Test]
+        [TestCaseSource(nameof(InvalidPathComponents))]
+        public void TestGetDirectoryRejectsInvalidSessionId(string sessionId)
+        {
+            DirectoryInfo workDir = CreateTempDir("perSessionFactoryGetSession");
+            PerSessionDirectoryFactory factory = new PerSessionDirectoryFactory(workDir.FullName);
+
+            Assert.Throws<ArgumentException>(() =>
+            {
+                using Directory _ = factory.GetDirectory(sessionId, "src");
+            }, $"GetDirectory should reject sessionId '{sessionId}'");
+        }
+
+        [Test]
+        [TestCaseSource(nameof(InvalidPathComponents))]
+        public void TestGetDirectoryRejectsInvalidSource(string source)
+        {
+            DirectoryInfo workDir = CreateTempDir("perSessionFactoryGetSource");
+            PerSessionDirectoryFactory factory = new PerSessionDirectoryFactory(workDir.FullName);
+
+            Assert.Throws<ArgumentException>(() =>
+            {
+                using Directory _ = factory.GetDirectory("session1", source);
+            }, $"GetDirectory should reject source '{source}'");
+        }
+
+        [Test]
+        [TestCaseSource(nameof(InvalidPathComponents))]
+        public void TestCleanupSessionRejectsInvalidSessionId(string sessionId)
+        {
+            DirectoryInfo workDir = CreateTempDir("perSessionFactoryCleanup");
+            PerSessionDirectoryFactory factory = new PerSessionDirectoryFactory(workDir.FullName);
+
+            Assert.Throws<ArgumentException>(() =>
+            {
+                factory.CleanupSession(sessionId);
+            }, $"CleanupSession should reject sessionId '{sessionId}'");
+        }
+
+        [Test]
+        public void TestCleanupSessionDoesNotTouchSiblingDirectory()
+        {
+            // A sessionId that resolves to a sibling of the workingDirectory must be rejected by
+            // validation. The sibling directory and its contents must remain untouched.
+            DirectoryInfo workDir = CreateTempDir("perSessionFactorySiblingWork");
+            DirectoryInfo sibling = CreateTempDir("perSessionFactorySibling");
+            string siblingFile = Path.Combine(sibling.FullName, "marker.txt");
+            File.WriteAllText(siblingFile, "untouched");
+
+            PerSessionDirectoryFactory factory = new PerSessionDirectoryFactory(workDir.FullName);
+            // Path.GetRelativePath is not available on .NET Framework; compute manually.
+            // Both temp dirs share the same parent, so the relative path is "../<siblingName>".
+            string relative = Path.Combine("..", sibling.Name);
+
+            Assert.Throws<ArgumentException>(() => factory.CleanupSession(relative));
+            assertTrue("sibling directory must still exist", System.IO.Directory.Exists(sibling.FullName));
+            assertTrue("sibling file must still exist", File.Exists(siblingFile));
+        }
+
+        [Test]
+        public void TestGetDirectoryAcceptsValidNames()
+        {
+            DirectoryInfo workDir = CreateTempDir("perSessionFactoryValid");
+            PerSessionDirectoryFactory factory = new PerSessionDirectoryFactory(workDir.FullName);
+
+            using Directory dir = factory.GetDirectory("session-abc", "src");
+            assertNotNull(dir);
+
+            string expected = Path.Combine(workDir.FullName, "session-abc", "src");
+            assertTrue("expected session directory to exist on disk", System.IO.Directory.Exists(expected));
+        }
+    }
+}
diff --git a/src/Lucene.Net.Tests.Replicator/SessionTokenTest.cs b/src/Lucene.Net.Tests.Replicator/SessionTokenTest.cs
index c07b11b9c..344ffa61b 100644
--- a/src/Lucene.Net.Tests.Replicator/SessionTokenTest.cs
+++ b/src/Lucene.Net.Tests.Replicator/SessionTokenTest.cs
@@ -1,8 +1,10 @@
 using J2N.IO;
+using Lucene.Net.Attributes;
 using Lucene.Net.Documents;
 using Lucene.Net.Index;
 using Lucene.Net.Util;
 using NUnit.Framework;
+using System;
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
@@ -63,5 +65,59 @@ public void TestSerialization()
             IOUtils.Dispose(writer, directory);
         }
 
+        // LUCENENET-specific: covers RevisionFile.FileName validation in the
+        // SessionToken(IDataInput) deserialization constructor.
+        [Test, LuceneNetSpecific]
+        [TestCase("../../../a.txt")]
+        [TestCase("..\\..\\a.txt")]
+        [TestCase("/a/b")]
+        [TestCase("C:\\folder\\file")]
+        [TestCase("subdir/file.txt")]
+        [TestCase("..")]
+        [TestCase(".")]
+        [TestCase("name\0extra")]
+        [TestCase("")]
+        public void TestDeserializeRejectsInvalidFileName(string invalidFileName)
+        {
+            using MemoryStream ms = new MemoryStream();
+            DataOutputStream dos = new DataOutputStream(ms);
+            dos.WriteUTF("session1");
+            dos.WriteUTF("ver1");
+            dos.WriteInt32(1);
+            dos.WriteUTF("source1");
+            dos.WriteInt32(1);
+            dos.WriteUTF(invalidFileName);
+            dos.WriteInt64(123L);
+            dos.Flush();
+            ms.Position = 0;
+
+            Assert.Throws<ArgumentException>(() =>
+            {
+                _ = new SessionToken(new DataInputStream(ms));
+            }, $"SessionToken should reject RevisionFile.FileName '{invalidFileName}'");
+        }
+
+        [Test, LuceneNetSpecific]
+        [TestCase("segments.gen")]
+        [TestCase("_0.cfs")]
+        [TestCase(".hidden")]
+        public void TestDeserializeAcceptsValidFileName(string fileName)
+        {
+            using MemoryStream ms = new MemoryStream();
+            DataOutputStream dos = new DataOutputStream(ms);
+            dos.WriteUTF("session1");
+            dos.WriteUTF("ver1");
+            dos.WriteInt32(1);
+            dos.WriteUTF("source1");
+            dos.WriteInt32(1);
+            dos.WriteUTF(fileName);
+            dos.WriteInt64(123L);
+            dos.Flush();
+            ms.Position = 0;
+
+            SessionToken token = new SessionToken(new DataInputStream(ms));
+            assertEquals(fileName, token.SourceFiles["source1"][0].FileName);
+        }
+
     }
 }
\ No newline at end of file
diff --git a/src/Lucene.Net.Tests/Store/TestDirectory.cs b/src/Lucene.Net.Tests/Store/TestDirectory.cs
index 6c47cc70a..a7ce69462 100644
--- a/src/Lucene.Net.Tests/Store/TestDirectory.cs
+++ b/src/Lucene.Net.Tests/Store/TestDirectory.cs
@@ -536,5 +536,102 @@ private static void Search(Index.IndexReader r, int times)
                 }
             }
         }
+
+        // LUCENENET-specific: covers EnsureCanWrite name validation across FSDirectory subclasses.
+        [Test, LuceneNetSpecific]
+        [TestCase("../../foo.txt")]
+        [TestCase("..\\foo.txt")]
+        [TestCase("/tmp/foo.txt")]
+        [TestCase("subdir/file.txt")]
+        [TestCase("subdir\\file.txt")]
+        [TestCase("..")]
+        [TestCase(".")]
+        [TestCase("")]
+        public void TestCreateOutputRejectsInvalidName(string invalidName)
+        {
+            DirectoryInfo path = CreateTempDir("ensureCanWrite");
+            foreach (FSDirectory dir in new FSDirectory[]
+            {
+                new SimpleFSDirectory(path),
+                new NIOFSDirectory(path),
+                new MMapDirectory(path),
+            })
+            {
+                try
+                {
+                    Assert.Throws<ArgumentException>(() =>
+                    {
+                        using IndexOutput _ = dir.CreateOutput(invalidName, IOContext.DEFAULT);
+                    }, $"{dir.GetType().Name}.CreateOutput should reject '{invalidName}'");
+                }
+                finally
+                {
+                    dir.Dispose();
+                }
+            }
+        }
+
+        // LUCENENET-specific: covers EnsureCanRead name validation across FSDirectory subclasses.
+        [Test, LuceneNetSpecific]
+        [TestCase("../../foo.txt")]
+        [TestCase("..\\foo.txt")]
+        [TestCase("/tmp/foo.txt")]
+        [TestCase("subdir/file.txt")]
+        [TestCase("subdir\\file.txt")]
+        [TestCase("..")]
+        [TestCase(".")]
+        [TestCase("")]
+        public void TestOpenInputRejectsInvalidName(string invalidName)
+        {
+            DirectoryInfo path = CreateTempDir("ensureCanRead");
+            foreach (FSDirectory dir in new FSDirectory[]
+            {
+                new SimpleFSDirectory(path),
+                new NIOFSDirectory(path),
+                new MMapDirectory(path),
+            })
+            {
+                try
+                {
+                    Assert.Throws<ArgumentException>(() =>
+                    {
+                        using IndexInput _ = dir.OpenInput(invalidName, IOContext.DEFAULT);
+                    }, $"{dir.GetType().Name}.OpenInput should reject '{invalidName}'");
+                }
+                finally
+                {
+                    dir.Dispose();
+                }
+            }
+        }
+
+        // NIOFSDirectory and SimpleFSDirectory have their own CreateSlicer overload that opens its
+        // own FileStream rather than delegating to OpenInput, so validation must fire there too.
+        [Test, LuceneNetSpecific]
+        [TestCase("../../foo.txt")]
+        [TestCase("..\\foo.txt")]
+        [TestCase("/tmp/foo.txt")]
+        public void TestCreateSlicerRejectsInvalidName(string invalidName)
+        {
+            DirectoryInfo path = CreateTempDir("ensureCanReadSlicer");
+            foreach (FSDirectory dir in new FSDirectory[]
+            {
+                new SimpleFSDirectory(path),
+                new NIOFSDirectory(path),
+            })
+            {
+                try
+                {
+                    Assert.Throws<ArgumentException>(() =>
+                    {
+                        using Directory.IndexInputSlicer _ = dir.CreateSlicer(invalidName, IOContext.DEFAULT);
+                    }, $"{dir.GetType().Name}.CreateSlicer should reject '{invalidName}'");
+                }
+                finally
+                {
+                    dir.Dispose();
+                }
+            }
+        }
     }
 }
diff --git a/src/Lucene.Net.Tests/Support/Text/TestStringExtensions.cs b/src/Lucene.Net.Tests/Support/Text/TestStringExtensions.cs
new file mode 100644
index 000000000..b5461b58d
--- /dev/null
+++ b/src/Lucene.Net.Tests/Support/Text/TestStringExtensions.cs
@@ -0,0 +1,65 @@
+using Lucene.Net.Attributes;
+using Lucene.Net.Support.Text;
+using Lucene.Net.Util;
+using NUnit.Framework;
+
+namespace Lucene.Net.Support.Text
+{
+    /*
+     * 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.
+     */
+
+    [LuceneNetSpecific]
+    public class TestStringExtensions : LuceneTestCase
+    {
+        [TestCase("segments.gen")]
+        [TestCase("_0.cfs")]
+        [TestCase("_0_Lucene41_0.tip")]
+        [TestCase(".hidden")]
+        [TestCase("a..b..c")]
+        [TestCase("name-with-dashes_and_underscores.ext")]
+        [LuceneNetSpecific]
+        public void TestIsValidSinglePathComponent_AcceptsValidNames(string path)
+        {
+            Assert.IsTrue(path.IsValidSinglePathComponent(),
+                $"expected '{path}' to be a valid single path component");
+        }
+
+        [TestCase("")]                          // empty
+        [TestCase(".")]                         // current-directory literal
+        [TestCase("..")]                        // parent-directory literal
+        [TestCase("foo/bar")]                   // forward-slash separator
+        [TestCase("foo\\bar")]                  // backslash separator (rejected even on POSIX)
+        [TestCase("../../a/b")]                 // multi-level forward-slash sequence
+        [TestCase("..\\..\\a\\b")]              // multi-level backslash sequence
+        [TestCase("..\\a")]                     // single-level backslash sequence
+        [TestCase("/a/b")]                      // absolute POSIX path
+        [TestCase("/tmp/file.txt")]             // absolute POSIX path
+        [TestCase("C:\\folder\\file")]          // path containing a Windows volume separator
+        [TestCase("C:\\file.txt")]              // path containing a Windows volume separator
+        [TestCase("name\0extra")]               // embedded NUL
+        [TestCase("name/")]                     // trailing forward slash
+        [TestCase("name\\")]                    // trailing backslash
+        [TestCase("\\")]                        // bare backslash
+        [TestCase("/")]                         // bare forward slash
+        [LuceneNetSpecific]
+        public void TestIsValidSinglePathComponent_RejectsInvalidNames(string path)
+        {
+            Assert.IsFalse(path.IsValidSinglePathComponent(),
+                $"expected '{path}' to be rejected as a single path component");
+        }
+    }
+}
diff --git a/src/Lucene.Net/Store/FSDirectory.cs b/src/Lucene.Net/Store/FSDirectory.cs
index 66950592c..641a591bc 100644
--- a/src/Lucene.Net/Store/FSDirectory.cs
+++ b/src/Lucene.Net/Store/FSDirectory.cs
@@ -1,5 +1,6 @@
 using Lucene.Net.Support;
 using Lucene.Net.Support.IO;
+using Lucene.Net.Support.Text;
 using Lucene.Net.Support.Threading;
 using Lucene.Net.Util;
 using System;
@@ -362,6 +363,12 @@ public override IndexOutput CreateOutput(string name, IOContext context)
 
         protected virtual void EnsureCanWrite(string name)
         {
+            // LUCENENET-specific: validate the name is valid for a FSDirectory
+            if (!name.IsValidSinglePathComponent())
+            {
+                throw new ArgumentException("File name is not valid for a FSDirectory", nameof(name));
+            }
+
             if (!m_directory.Exists)
             {
                 try
@@ -388,6 +395,17 @@ protected virtual void EnsureCanWrite(string name)
             }
         }
 
+        // LUCENENET-specific: backported method signature (but not implementation, yet)
+        // from Lucene 6.0.0 as extensibility point to override name validation if needed
+        protected virtual void EnsureCanRead(string name)
+        {
+            // LUCENENET-specific: validate the name is valid for a FSDirectory
+            if (!name.IsValidSinglePathComponent())
+            {
+                throw new ArgumentException("File name is not valid for a FSDirectory", nameof(name));
+            }
+        }
+
         protected virtual void OnIndexOutputClosed(FSIndexOutput io)
         {
             // LUCENENET Specific: See remarks for m_staleFiles field.
diff --git a/src/Lucene.Net/Store/MMapDirectory.cs b/src/Lucene.Net/Store/MMapDirectory.cs
index 59e1cccf8..35c9db811 100644
--- a/src/Lucene.Net/Store/MMapDirectory.cs
+++ b/src/Lucene.Net/Store/MMapDirectory.cs
@@ -183,6 +183,7 @@ public MMapDirectory(string path, LockFactory lockFactory, int maxChunkSize)
         public override IndexInput OpenInput(string name, IOContext context)
         {
             EnsureOpen();
+            EnsureCanRead(name); // LUCENENET-specific: backported call site from Lucene 6.0.0
             var file = new FileInfo(Path.Combine(Directory.FullName, name));
             var fc = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
             return new MMapIndexInput(this, "MMapIndexInput(path=\"" + file + "\")", fc);
@@ -190,6 +191,7 @@ public override IndexInput OpenInput(string name, IOContext context)
 
         public override IndexInputSlicer CreateSlicer(string name, IOContext context)
         {
+            // LUCENENET NOTE: name is validated in OpenInput call below
             var full = (MMapIndexInput)OpenInput(name, context);
             return new IndexInputSlicerAnonymousClass(this, full);
         }
diff --git a/src/Lucene.Net/Store/NIOFSDirectory.cs b/src/Lucene.Net/Store/NIOFSDirectory.cs
index 684f42b68..6513bed26 100644
--- a/src/Lucene.Net/Store/NIOFSDirectory.cs
+++ b/src/Lucene.Net/Store/NIOFSDirectory.cs
@@ -102,6 +102,7 @@ public NIOFSDirectory(string path)
         public override IndexInput OpenInput(string name, IOContext context)
         {
             EnsureOpen();
+            EnsureCanRead(name); // LUCENENET-specific: backported call site from Lucene 6.0.0
             var path = new FileInfo(Path.Combine(Directory.FullName, name));
             var fc = new FileStream(path.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
             return new NIOFSIndexInput("NIOFSIndexInput(path=\"" + path + "\")", fc, context);
@@ -110,6 +111,7 @@ public override IndexInput OpenInput(string name, IOContext context)
         public override IndexInputSlicer CreateSlicer(string name, IOContext context)
         {
             EnsureOpen();
+            EnsureCanRead(name); // LUCENENET-specific: this method is not in Lucene 6.0.0 but added to match OpenInput above
             var path = new FileInfo(Path.Combine(Directory.FullName, name));
             var fc = new FileStream(path.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
             return new IndexInputSlicerAnonymousClass(context, path, fc);
diff --git a/src/Lucene.Net/Store/SimpleFSDirectory.cs b/src/Lucene.Net/Store/SimpleFSDirectory.cs
index 923ca685b..31a695d83 100644
--- a/src/Lucene.Net/Store/SimpleFSDirectory.cs
+++ b/src/Lucene.Net/Store/SimpleFSDirectory.cs
@@ -98,6 +98,7 @@ public SimpleFSDirectory(string path)
         public override IndexInput OpenInput(string name, IOContext context)
         {
             EnsureOpen();
+            EnsureCanRead(name); // LUCENENET-specific: backported call site from Lucene 6.0.0
             var path = new FileInfo(Path.Combine(Directory.FullName, name));
             var raf = new FileStream(path.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
             return new SimpleFSIndexInput("SimpleFSIndexInput(path=\"" + path.FullName + "\")", raf, context);
@@ -106,6 +107,7 @@ public override IndexInput OpenInput(string name, IOContext context)
         public override IndexInputSlicer CreateSlicer(string name, IOContext context)
         {
             EnsureOpen();
+            EnsureCanRead(name); // LUCENENET-specific: this method is not in Lucene 6.0.0 but added to match OpenInput above
             var file = new FileInfo(Path.Combine(Directory.FullName, name));
             var descriptor = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
             return new IndexInputSlicerAnonymousClass(context, file, descriptor);
diff --git a/src/Lucene.Net/Support/Text/StringExtensions.cs b/src/Lucene.Net/Support/Text/StringExtensions.cs
index eae084223..c7af2b017 100644
--- a/src/Lucene.Net/Support/Text/StringExtensions.cs
+++ b/src/Lucene.Net/Support/Text/StringExtensions.cs
@@ -1,5 +1,6 @@
 using J2N.Text;
 using System;
+using System.IO;
 using System.Runtime.CompilerServices;
 
 namespace Lucene.Net.Support.Text
@@ -48,5 +49,27 @@ public static bool ContainsAny(this string input, char[] charsToCompare)
             }
             return false;
         }
+
+#nullable enable
+        /// <summary>
+        /// Returns <c>true</c> if <paramref name="path"/> is a valid, single path component for use in index
+        /// file system access. A valid value is either a file name or a directory name, without any directory
+        /// or volume separators.
+        /// </summary>
+        /// <param name="path">The file name to check.</param>
+        /// <returns><c>true</c> if <paramref name="path"/> is a valid path component;
+        /// otherwise, <c>false</c>.</returns>
+        public static bool IsValidSinglePathComponent(this string path)
+        {
+            // Check IndexOfAny before Path.GetFileName: on .NET Framework, Path.GetFileName throws
+            // ArgumentException for strings containing NUL or other characters that are illegal in paths.
+            return !string.IsNullOrEmpty(path)
+                   && path != "."
+                   && path != ".."
+                   && path.IndexOfAny(Path.GetInvalidFileNameChars()) < 0
+                   && path.IndexOf('\\') < 0 // backslash is not an invalid character on Linux/macOS but is invalid for this purpose
+                   && path == Path.GetFileName(path); // NOTE: ensures no directory components (separators, volume names)
+        }
+#nullable restore
     }
 }