Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/Lucene.Net.Replicator/LocalReplicator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using J2N.Threading.Atomic;
using Lucene.Net.Support.Text;
using Lucene.Net.Support.Threading;
using System;
using System.Collections.Generic;
Expand Down Expand Up @@ -256,6 +257,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
{
Expand Down
23 changes: 22 additions & 1 deletion src/Lucene.Net.Replicator/PerSessionDirectoryFactory.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Lucene.Net.Store;
using Lucene.Net.Support.Text;
using System;
using System.IO;
using Directory = Lucene.Net.Store.Directory;
Expand Down Expand Up @@ -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))
Expand All @@ -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);
Expand Down
12 changes: 11 additions & 1 deletion src/Lucene.Net.Replicator/SessionToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Threading;
using System.Threading.Tasks;
using Lucene.Net.Support.IO;
using Lucene.Net.Support.Text;

namespace Lucene.Net.Replicator
{
Expand Down Expand Up @@ -76,7 +77,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;
Expand Down
22 changes: 22 additions & 0 deletions src/Lucene.Net.Tests.Replicator/LocalReplicatorTest.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Lucene.Net.Attributes;
using Lucene.Net.Documents;
using Lucene.Net.Index;
using Lucene.Net.Support.Threading;
Expand Down Expand Up @@ -91,6 +92,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()
{
Expand Down
116 changes: 116 additions & 0 deletions src/Lucene.Net.Tests.Replicator/PerSessionDirectoryFactoryTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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);
string relative = Path.GetRelativePath(workDir.FullName, sibling.FullName);

Check failure on line 96 in src/Lucene.Net.Tests.Replicator/PerSessionDirectoryFactoryTest.cs

View workflow job for this annotation

GitHub Actions / Test (windows-latest, net472, x64, Release)

'Path' does not contain a definition for 'GetRelativePath'

Check failure on line 96 in src/Lucene.Net.Tests.Replicator/PerSessionDirectoryFactoryTest.cs

View workflow job for this annotation

GitHub Actions / Test (windows-latest, net472, x64, Release)

'Path' does not contain a definition for 'GetRelativePath'

Check failure on line 96 in src/Lucene.Net.Tests.Replicator/PerSessionDirectoryFactoryTest.cs

View workflow job for this annotation

GitHub Actions / Test (windows-latest, net48, x64, Release)

'Path' does not contain a definition for 'GetRelativePath'

Check failure on line 96 in src/Lucene.Net.Tests.Replicator/PerSessionDirectoryFactoryTest.cs

View workflow job for this annotation

GitHub Actions / Test (windows-latest, net48, x64, Release)

'Path' does not contain a definition for 'GetRelativePath'

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));
}
}
}
54 changes: 54 additions & 0 deletions src/Lucene.Net.Tests.Replicator/SessionTokenTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,60 @@ public void TestToString()
Assert.IsFalse(result.Contains("System.Collections.Generic.Dictionary"), "Should not contain generic Dictionary type");
}

// 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);
}

// Mock implementation for testing
private class MockRevision : IRevision
{
Expand Down
Loading
Loading