diff --git a/SabreTools.IO.Test/ReadOnlyCompositeStreamTests.cs b/SabreTools.IO.Test/ReadOnlyCompositeStreamTests.cs new file mode 100644 index 0000000..96a69c7 --- /dev/null +++ b/SabreTools.IO.Test/ReadOnlyCompositeStreamTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace SabreTools.IO.Test +{ + public class ReadOnlyCompositeStreamTests + { + [Fact] + public void DefaultConstructorTest() + { + var stream = new ReadOnlyCompositeStream(); + Assert.Equal(0, stream.Length); + Assert.Equal(0, stream.Position); + } + + [Fact] + public void EmptyArrayConstructorTest() + { + Stream[] arr = [new MemoryStream()]; + var stream = new ReadOnlyCompositeStream(arr); + Assert.Equal(0, stream.Length); + Assert.Equal(0, stream.Position); + } + + [Fact] + public void EmptyEnumerableConstructorTest() + { + // Empty enumerable constructor + List list = [new MemoryStream()]; + var stream = new ReadOnlyCompositeStream(list); + Assert.Equal(0, stream.Length); + Assert.Equal(0, stream.Position); + } + + [Fact] + public void FilledArrayConstructorTest() + { + Stream[] arr = [new MemoryStream(new byte[1024]), new MemoryStream(new byte[1024])]; + var stream = new ReadOnlyCompositeStream(arr); + Assert.Equal(2048, stream.Length); + Assert.Equal(0, stream.Position); + } + + [Fact] + public void FilledEnumerableConstructorTest() + { + List list = [new MemoryStream(new byte[1024]), new MemoryStream(new byte[1024])]; + var stream = new ReadOnlyCompositeStream(list); + Assert.Equal(2048, stream.Length); + Assert.Equal(0, stream.Position); + } + + [Fact] + public void AddStreamTest() + { + var stream = new ReadOnlyCompositeStream(); + Assert.Equal(0, stream.Length); + Assert.Equal(0, stream.Position); + + stream.AddStream(new MemoryStream(new byte[1024])); + Assert.Equal(1024, stream.Length); + Assert.Equal(0, stream.Position); + } + + [Fact] + public void EmptyStreamReadTest() + { + var stream = new ReadOnlyCompositeStream(); + + byte[] buf = new byte[512]; + Assert.Throws(() => stream.Read(buf, 0, 512)); + } + + [Fact] + public void SingleStreamReadTest() + { + Stream[] arr = [new MemoryStream(new byte[1024])]; + var stream = new ReadOnlyCompositeStream(arr); + + byte[] buf = new byte[512]; + int read = stream.Read(buf, 0, 512); + + Assert.Equal(512, read); + } + + [Fact] + public void MultipleStreamSingleContainedReadTest() + { + Stream[] arr = [new MemoryStream(new byte[1024]), new MemoryStream(new byte[1024])]; + var stream = new ReadOnlyCompositeStream(arr); + + byte[] buf = new byte[512]; + int read = stream.Read(buf, 0, 512); + + Assert.Equal(512, read); + } + + [Fact] + public void MultipleStreamMultipleContainedReadTest() + { + Stream[] arr = [new MemoryStream(new byte[256]), new MemoryStream(new byte[256])]; + var stream = new ReadOnlyCompositeStream(arr); + + byte[] buf = new byte[512]; + int read = stream.Read(buf, 0, 512); + + Assert.Equal(512, read); + } + + [Fact] + public void SingleStreamExtraReadTest() + { + Stream[] arr = [new MemoryStream(new byte[256])]; + var stream = new ReadOnlyCompositeStream(arr); + + byte[] buf = new byte[512]; + int read = stream.Read(buf, 0, 512); + + Assert.Equal(256, read); + } + + [Fact] + public void MultipleStreamExtraReadTest() + { + Stream[] arr = [new MemoryStream(new byte[128]), new MemoryStream(new byte[128])]; + var stream = new ReadOnlyCompositeStream(arr); + + byte[] buf = new byte[512]; + int read = stream.Read(buf, 0, 512); + + Assert.Equal(256, read); + } + } +} \ No newline at end of file diff --git a/SabreTools.IO.Test/SabreTools.IO.Test.csproj b/SabreTools.IO.Test/SabreTools.IO.Test.csproj index cab2f2c..2e59ed6 100644 --- a/SabreTools.IO.Test/SabreTools.IO.Test.csproj +++ b/SabreTools.IO.Test/SabreTools.IO.Test.csproj @@ -1,27 +1,28 @@  - - net6.0;net8.0 - false - enable - + + net6.0;net8.0 + false + latest + enable + true + - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + - - - + + + - - \ No newline at end of file + diff --git a/SabreTools.IO/ReadOnlyCompositeStream.cs b/SabreTools.IO/ReadOnlyCompositeStream.cs new file mode 100644 index 0000000..a280129 --- /dev/null +++ b/SabreTools.IO/ReadOnlyCompositeStream.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace SabreTools.IO +{ + /// + /// Read-only stream wrapper around multiple, consecutive streams + /// + public class ReadOnlyCompositeStream : Stream + { + #region Properties + + /// + public override bool CanRead => true; + + /// + public override bool CanSeek => true; + + /// + public override bool CanWrite => false; + + /// + public override long Length => _length; + + /// + public override long Position + { + get => _position; + set + { + _position = value; + if (_position < 0) + _position = 0; + else if (_position >= _length) + _position = _length - 1; + } + } + + #endregion + + #region Internal State + + /// + /// Internal collection of streams to read from + /// + private readonly List _streams; + + /// + /// Total length of all internal streams + /// + private long _length; + + /// + /// Overall position in the stream wrapper + /// + private long _position; + + #endregion + + /// + /// Create a new, empty ReadOnlyCompositeStream + /// + public ReadOnlyCompositeStream() + { + _streams = []; + _length = 0; + _position = 0; + } + + /// + /// Create a new ReadOnlyCompositeStream from an existing collection of Streams + /// + public ReadOnlyCompositeStream(Stream[] streams) + { + _streams = [.. streams]; + _length = 0; + _position = 0; + + // Verify the streams and add to the length + foreach (var stream in streams) + { + if (!stream.CanRead || !stream.CanSeek) + throw new ArgumentException($"All members of {nameof(streams)} need to be readable and seekable"); + + _length += stream.Length; + } + } + + /// + /// Create a new ReadOnlyCompositeStream from an existing collection of Streams + /// + public ReadOnlyCompositeStream(IEnumerable streams) + { + _streams = streams.ToList(); + _length = 0; + _position = 0; + + // Verify the streams and add to the length + foreach (var stream in streams) + { + if (!stream.CanRead || !stream.CanSeek) + throw new ArgumentException($"All members of {nameof(streams)} need to be readable and seekable"); + + _length += stream.Length; + } + } + + /// + /// Add a new stream to the collection + /// + public bool AddStream(Stream stream) + { + // Verify the stream + if (!stream.CanRead || !stream.CanSeek) + return false; + + // Add the stream to the end + _streams.Add(stream); + _length += stream.Length; + return true; + } + + #region Stream Implementations + + /// + public override void Flush() => throw new NotImplementedException(); + + /// + public override int Read(byte[] buffer, int offset, int count) + { + // Determine which stream we start reading from + (int streamIndex, long streamOffset) = DetermineStreamIndex(offset); + if (streamIndex == -1) + throw new ArgumentOutOfRangeException(nameof(offset)); + + // Determine if the stream fully contains the requested segment + bool singleStream = StreamContains(streamIndex, streamOffset, count); + + // If we can read from a single stream + if (singleStream) + { + _position += count; + _streams[streamIndex].Seek(streamOffset, SeekOrigin.Begin); + return _streams[streamIndex].Read(buffer, offset, count); + } + + // For all other cases, we read until there's no more + int readBytes = 0, originalCount = count; + while (readBytes < originalCount) + { + // Determine how much can be read from the current stream + long currentBytes = _streams[streamIndex].Length - streamOffset; + int shouldRead = Math.Min((int)currentBytes, count); + + // Read from the current stream + _position += shouldRead; + _streams[streamIndex].Seek(streamOffset, SeekOrigin.Begin); + readBytes += _streams[streamIndex].Read(buffer, offset, shouldRead); + + // Update the read variables + offset += shouldRead; + count -= shouldRead; + + // Move to the next stream + streamIndex++; + streamOffset = 0; + + // Validate the next stream exists + if (streamIndex >= _streams.Count) + break; + } + + // Return the number of bytes that could be read + return readBytes; + } + + /// + public override long Seek(long offset, SeekOrigin origin) + { + // Handle the "seek" + switch (origin) + { + case SeekOrigin.Begin: _position = offset; break; + case SeekOrigin.Current: _position += offset; break; + case SeekOrigin.End: _position = _length - offset - 1; break; + default: throw new ArgumentException($"Invalid value for {nameof(origin)}"); + }; + + // Handle out-of-bounds seeks + if (_position < 0) + _position = 0; + else if (_position >= _length) + _position = _length - 1; + + return _position; + } + + /// + public override void SetLength(long value) => throw new NotImplementedException(); + + /// + public override void Write(byte[] buffer, int offset, int count) => throw new NotImplementedException(); + + #endregion + + #region Helpers + + /// + /// Determine the index of the stream that contains a particular offset + /// + /// Index of the stream containing the offset and the real offset in the stream, (-1, -1) on error + private (int index, long realOffset) DetermineStreamIndex(int offset) + { + // If the offset is out of bounds + if (offset < 0 || offset >= _length) + return (-1, -1); + + // Seek through until we hit the correct offset + long currentLength = 0; + for (int i = 0; i < _streams.Count; i++) + { + currentLength += _streams[i].Length; + if (currentLength > offset) + { + long realOffset = offset - (currentLength - _streams[i].Length); + return (i, realOffset); + } + } + + // Should never happen + return (-1, -1); + } + + /// + /// Determines if a stream contains a particular segment + /// + private bool StreamContains(int streamIndex, long offset, int length) + { + // Ensure the arguments are valid + if (streamIndex < 0 || streamIndex >= _streams.Count) + throw new ArgumentOutOfRangeException(nameof(streamIndex)); + if (offset < 0 || offset >= _streams[streamIndex].Length) + throw new ArgumentOutOfRangeException(nameof(offset)); + + // Handle the general case + return _streams[streamIndex].Length - offset >= length; + } + + #endregion + } +} \ No newline at end of file