mirror of
https://github.com/aaru-dps/Aaru.git
synced 2026-07-08 17:56:18 +00:00
987 lines
29 KiB
C#
987 lines
29 KiB
C#
// /***************************************************************************
|
|
// Aaru Data Preservation Suite
|
|
// ----------------------------------------------------------------------------
|
|
//
|
|
// Filename : BinHex.cs
|
|
// Author(s) : Natalia Portillo <claunia@claunia.com>
|
|
//
|
|
// Component : Filters.
|
|
//
|
|
// --[ Description ] ----------------------------------------------------------
|
|
//
|
|
// Provides a filter to open BinHex 4.0 (.hqx) encoded Macintosh files.
|
|
//
|
|
// --[ License ] --------------------------------------------------------------
|
|
//
|
|
// This library is free software; you can redistribute it and/or modify
|
|
// it under the terms of the GNU Lesser General Public License as
|
|
// published by the Free Software Foundation; either version 2.1 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// This library is distributed in the hope that it will be useful, but
|
|
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
// Lesser General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Lesser General Public
|
|
// License along with this library; if not, see <http://www.gnu.org/licenses/>.
|
|
//
|
|
// ----------------------------------------------------------------------------
|
|
// Copyright © 2011-2026 Natalia Portillo
|
|
// ****************************************************************************/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text;
|
|
using Aaru.CommonTypes.Enums;
|
|
using Aaru.CommonTypes.Interfaces;
|
|
using Aaru.Helpers;
|
|
using Aaru.Helpers.IO;
|
|
using Aaru.Logging;
|
|
using Sentry;
|
|
|
|
namespace Aaru.Filters;
|
|
|
|
/// <inheritdoc />
|
|
/// <summary>Decodes BinHex 4.0 files (Yves Lempereur's 6-bit + 0x90 RLE text format).</summary>
|
|
public sealed class BinHex : IFilter
|
|
{
|
|
const string MODULE_NAME = "BinHex Filter";
|
|
|
|
/// <summary>Decoded-size threshold above which the forks are served via a streaming decoder instead of a memory buffer.</summary>
|
|
const long STREAMING_THRESHOLD = 256L * 1024 * 1024;
|
|
|
|
/// <summary>Banner marking a BinHex 4.0 file; must appear before the start-of-data colon.</summary>
|
|
const string BANNER = "(This file must be converted with BinHex";
|
|
|
|
/// <summary>Standard BinHex 4.0 6-bit alphabet (64 chars).</summary>
|
|
const string ALPHABET = "!\"#$%&'()*+,-012345689@ABCDEFGHIJKLMNPQRSTUVXYZ[`abcdefhijklmpqr";
|
|
|
|
/// <summary>RLE marker byte within the decoded 6-bit stream.</summary>
|
|
const byte RLE_MARKER = 0x90;
|
|
|
|
/// <summary>Fixed portion of the normalized header after the Pascal name.</summary>
|
|
/// <remarks>null separator (1) + type (4) + creator (4) + flags (2) + dataLen (4) + rsrcLen (4) + CRC (2).</remarks>
|
|
const int HEADER_FIXED_SIZE = 1 + 4 + 4 + 2 + 4 + 4 + 2;
|
|
|
|
static readonly sbyte[] _decodeTable = BuildDecodeTable();
|
|
long _bodyStart;
|
|
long _dataForkOff;
|
|
BinHexDecodeStream _dataForkStream;
|
|
|
|
byte[] _decoded;
|
|
Header _header;
|
|
bool _isBytes;
|
|
long _rsrcForkOff;
|
|
BinHexDecodeStream _rsrcForkStream;
|
|
byte[] _sourceBytes;
|
|
Stream _sourceStream;
|
|
bool _streamingMode;
|
|
|
|
static sbyte[] BuildDecodeTable()
|
|
{
|
|
var table = new sbyte[256];
|
|
|
|
for(var i = 0; i < 256; i++) table[i] = -1;
|
|
|
|
for(var i = 0; i < ALPHABET.Length; i++) table[(byte)ALPHABET[i]] = (sbyte)i;
|
|
|
|
return table;
|
|
}
|
|
|
|
Stream OpenSource() => _isBytes ? new MemoryStream(_sourceBytes, false) : _sourceStream;
|
|
|
|
/// <summary>Locate the banner line and advance to the first byte of the 6-bit body (the ':' marker).</summary>
|
|
/// <returns>Offset of ':' within <paramref name="buf" />, or -1 on failure.</returns>
|
|
static int FindBodyStart(byte[] buf)
|
|
{
|
|
int bannerOff = IndexOfAscii(buf, buf.Length, BANNER);
|
|
|
|
if(bannerOff < 0) return -1;
|
|
|
|
int p = bannerOff + BANNER.Length;
|
|
|
|
while(p < buf.Length && buf[p] != '\n' && buf[p] != '\r') p++;
|
|
|
|
while(p < buf.Length && (buf[p] == '\n' || buf[p] == '\r' || buf[p] == '\t' || buf[p] == ' ')) p++;
|
|
|
|
if(p >= buf.Length || buf[p] != ':') return -1;
|
|
|
|
return p;
|
|
}
|
|
|
|
/// <summary>Shared Identify path: locate the banner, skip to the body, decode the header and validate its CRC.</summary>
|
|
static bool TryIdentifyBuffer(byte[] probe)
|
|
{
|
|
int bodyStart = FindBodyStart(probe);
|
|
|
|
if(bodyStart < 0) return false;
|
|
|
|
MemoryStream src = new(probe, bodyStart, probe.Length - bodyStart, false);
|
|
|
|
var headerBuf = new byte[1 + 63 + HEADER_FIXED_SIZE];
|
|
|
|
int got;
|
|
|
|
try
|
|
{
|
|
SixBitRleDecoder dec = new(src);
|
|
|
|
got = dec.ReadAtMost(headerBuf, 0, headerBuf.Length);
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
SentrySdk.CaptureException(ex);
|
|
AaruLogging.Exception(ex, "BinHex 4.0: identify decode threw: {0}", ex);
|
|
|
|
return false;
|
|
}
|
|
|
|
Array.Resize(ref headerBuf, got);
|
|
|
|
return TryParseHeader(headerBuf, 0, out _);
|
|
}
|
|
|
|
ErrorNumber OpenInternal(Stream source, long sourceLength)
|
|
{
|
|
source.Seek(0, SeekOrigin.Begin);
|
|
|
|
var probe = new byte[Math.Min(sourceLength, 16384)];
|
|
source.EnsureRead(probe, 0, probe.Length);
|
|
|
|
int bodyStart = FindBodyStart(probe);
|
|
|
|
if(bodyStart < 0) return ErrorNumber.InvalidArgument;
|
|
|
|
_bodyStart = bodyStart;
|
|
|
|
source.Seek(_bodyStart, SeekOrigin.Begin);
|
|
|
|
// Decode the maximum-size header (up to 1 + 63 + fixed) to learn fork sizes.
|
|
var headerBuf = new byte[1 + 63 + HEADER_FIXED_SIZE];
|
|
|
|
int headerBytes;
|
|
|
|
try
|
|
{
|
|
SixBitRleDecoder headerDec = new(new NonDisposingWrapper(source));
|
|
|
|
headerBytes = headerDec.ReadAtMost(headerBuf, 0, headerBuf.Length);
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
AaruLogging.Debug(MODULE_NAME, "BinHex 4.0: header decode threw: {0}", ex);
|
|
|
|
return ErrorNumber.InvalidArgument;
|
|
}
|
|
|
|
Array.Resize(ref headerBuf, headerBytes);
|
|
|
|
if(!TryParseHeader(headerBuf, sourceLength * 4, out _header)) return ErrorNumber.InvalidArgument;
|
|
|
|
_dataForkOff = _header.HeaderBytes;
|
|
_rsrcForkOff = _dataForkOff + _header.DataLength + 2; // + data-fork CRC
|
|
|
|
Filename = _header.Filename;
|
|
|
|
long totalDecoded = _header.HeaderBytes + _header.DataLength + 2 + _header.ResourceLength + 2;
|
|
|
|
_streamingMode = totalDecoded > STREAMING_THRESHOLD;
|
|
|
|
if(_streamingMode) return ErrorNumber.NoError;
|
|
|
|
// Decode the entire body in one shot.
|
|
source.Seek(_bodyStart, SeekOrigin.Begin);
|
|
|
|
SixBitRleDecoder dec = new(new NonDisposingWrapper(source));
|
|
|
|
_decoded = new byte[totalDecoded];
|
|
|
|
int total = dec.ReadAtMost(_decoded, 0, _decoded.Length);
|
|
|
|
if(total < totalDecoded)
|
|
{
|
|
AaruLogging.Debug(MODULE_NAME, "BinHex 4.0: decoded body truncated ({0}/{1} bytes).", total, totalDecoded);
|
|
Array.Resize(ref _decoded, total);
|
|
}
|
|
|
|
VerifyForkCrcs();
|
|
|
|
return ErrorNumber.NoError;
|
|
}
|
|
|
|
void VerifyForkCrcs()
|
|
{
|
|
if(_decoded == null) return;
|
|
|
|
long dOff = _dataForkOff;
|
|
long dLen = _header.DataLength;
|
|
|
|
if(_decoded.LongLength >= dOff + dLen + 2)
|
|
{
|
|
ushort stored = ReadUInt16Be(_decoded, (int)(dOff + dLen));
|
|
ushort computed = Crc16Ccitt(_decoded, (int)dOff, (int)dLen);
|
|
computed = Crc16Ccitt(new byte[2], 0, 2, computed);
|
|
|
|
if(stored != computed)
|
|
{
|
|
AaruLogging.Debug(MODULE_NAME,
|
|
"BinHex 4.0: data fork CRC mismatch (stored 0x{0:X4}, computed 0x{1:X4}).",
|
|
stored,
|
|
computed);
|
|
}
|
|
}
|
|
|
|
long rOff = _rsrcForkOff;
|
|
long rLen = _header.ResourceLength;
|
|
|
|
if(rLen == 0 || _decoded.LongLength < rOff + rLen + 2) return;
|
|
|
|
ushort storedR = ReadUInt16Be(_decoded, (int)(rOff + rLen));
|
|
ushort computedR = Crc16Ccitt(_decoded, (int)rOff, (int)rLen);
|
|
computedR = Crc16Ccitt(new byte[2], 0, 2, computedR);
|
|
|
|
if(storedR != computedR)
|
|
{
|
|
AaruLogging.Debug(MODULE_NAME,
|
|
"BinHex 4.0: resource fork CRC mismatch (stored 0x{0:X4}, computed 0x{1:X4}).",
|
|
storedR,
|
|
computedR);
|
|
}
|
|
}
|
|
|
|
#region Nested type: Header
|
|
|
|
struct Header
|
|
{
|
|
public string Filename;
|
|
public uint Type;
|
|
public uint Creator;
|
|
public ushort FinderFlags;
|
|
public uint DataLength;
|
|
public uint ResourceLength;
|
|
public int HeaderBytes;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Nested type: SixBitRleDecoder
|
|
|
|
/// <summary>Stateful BinHex 4.0 decoder: 6-bit alphabet → 3-byte groups with 0x90 RLE expansion.</summary>
|
|
sealed class SixBitRleDecoder(Stream input)
|
|
{
|
|
bool _eof;
|
|
byte _lastByte;
|
|
int _phase;
|
|
int _prevBits;
|
|
int _rleRemaining;
|
|
|
|
/// <summary>Attempt to read up to <paramref name="count" /> decoded bytes. Returns the number actually produced.</summary>
|
|
public int ReadAtMost(byte[] buffer, int offset, int count)
|
|
{
|
|
var produced = 0;
|
|
|
|
while(produced < count)
|
|
{
|
|
if(!TryProduceOne(out byte b)) break;
|
|
|
|
buffer[offset + produced++] = b;
|
|
}
|
|
|
|
return produced;
|
|
}
|
|
|
|
bool TryProduceOne(out byte b)
|
|
{
|
|
if(_rleRemaining > 0)
|
|
{
|
|
_rleRemaining--;
|
|
b = _lastByte;
|
|
|
|
return true;
|
|
}
|
|
|
|
if(!TryDecodeByte(out byte decoded))
|
|
{
|
|
b = 0;
|
|
|
|
return false;
|
|
}
|
|
|
|
if(decoded != RLE_MARKER)
|
|
{
|
|
_lastByte = decoded;
|
|
b = decoded;
|
|
|
|
return true;
|
|
}
|
|
|
|
if(!TryDecodeByte(out byte count))
|
|
{
|
|
b = 0;
|
|
|
|
return false;
|
|
}
|
|
|
|
switch(count)
|
|
{
|
|
case 0:
|
|
_lastByte = RLE_MARKER;
|
|
b = RLE_MARKER;
|
|
|
|
return true;
|
|
|
|
case 1:
|
|
// Invalid per spec; emit last byte and continue.
|
|
b = _lastByte;
|
|
|
|
return true;
|
|
|
|
default:
|
|
_rleRemaining = count - 2;
|
|
b = _lastByte;
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
bool TryDecodeByte(out byte result)
|
|
{
|
|
result = 0;
|
|
|
|
int bits1, bits2;
|
|
|
|
switch(_phase)
|
|
{
|
|
case 0:
|
|
if(!TryGetBits(out bits1)) return false;
|
|
if(!TryGetBits(out bits2)) return false;
|
|
|
|
_prevBits = bits2;
|
|
result = (byte)(bits1 << 2 | bits2 >> 4);
|
|
_phase = 1;
|
|
|
|
return true;
|
|
|
|
case 1:
|
|
bits1 = _prevBits;
|
|
|
|
if(!TryGetBits(out bits2)) return false;
|
|
|
|
_prevBits = bits2;
|
|
result = (byte)(bits1 << 4 | bits2 >> 2);
|
|
_phase = 2;
|
|
|
|
return true;
|
|
|
|
case 2:
|
|
bits1 = _prevBits;
|
|
|
|
if(!TryGetBits(out bits2)) return false;
|
|
|
|
result = (byte)(bits1 << 6 | bits2);
|
|
_phase = 0;
|
|
|
|
return true;
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool TryGetBits(out int bits)
|
|
{
|
|
bits = 0;
|
|
|
|
if(_eof) return false;
|
|
|
|
for(;;)
|
|
{
|
|
int raw = input.ReadByte();
|
|
|
|
switch(raw)
|
|
{
|
|
case < 0:
|
|
case ':':
|
|
_eof = true;
|
|
|
|
return false;
|
|
}
|
|
|
|
sbyte mapped = _decodeTable[raw];
|
|
|
|
if(mapped < 0) continue; // Skip CR/LF/whitespace and other non-alphabet bytes.
|
|
|
|
bits = mapped;
|
|
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Nested type: NonDisposingWrapper
|
|
|
|
/// <inheritdoc />
|
|
/// <summary>Wraps a Stream so that Dispose()/Close() on the wrapper does not close the underlying stream.</summary>
|
|
sealed class NonDisposingWrapper(Stream inner) : Stream
|
|
{
|
|
public override bool CanRead => inner.CanRead;
|
|
public override bool CanSeek => inner.CanSeek;
|
|
public override bool CanWrite => false;
|
|
public override long Length => inner.Length;
|
|
|
|
public override long Position
|
|
{
|
|
get => inner.Position;
|
|
set => inner.Position = value;
|
|
}
|
|
|
|
public override void Flush() => inner.Flush();
|
|
|
|
public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count);
|
|
|
|
public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin);
|
|
|
|
public override void SetLength(long value) => throw new NotSupportedException();
|
|
|
|
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
// Intentionally do not dispose the inner stream.
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Nested type: BinHexDecodeStream
|
|
|
|
/// <inheritdoc />
|
|
/// <summary>Seek-capable stream that lazily decodes a BinHex 4.0 body fork with a 256 MiB slice cache.</summary>
|
|
sealed class BinHexDecodeStream(Stream source, long bodyStart, long forkOffsetInBody, long forkLength) : Stream
|
|
{
|
|
const int SLICE_SHIFT = 28; // 256 MiB
|
|
const int SLICE_SIZE = 1 << SLICE_SHIFT;
|
|
const int RESIDENT_SLICES = 2;
|
|
readonly LinkedList<long> _lru = new();
|
|
|
|
readonly Dictionary<long, byte[]> _residentSlices = new();
|
|
readonly Dictionary<long, long> _spillOffsets = new();
|
|
|
|
FileStream _spill;
|
|
string _spillPath;
|
|
|
|
public override bool CanRead => true;
|
|
public override bool CanSeek => true;
|
|
public override bool CanWrite => false;
|
|
public override long Length => forkLength;
|
|
|
|
public override long Position { get; set; }
|
|
|
|
public override void Flush() {}
|
|
|
|
public override int Read(byte[] buffer, int offset, int count)
|
|
{
|
|
if(Position >= forkLength) return 0;
|
|
|
|
long remaining = forkLength - Position;
|
|
|
|
if(count > remaining) count = (int)remaining;
|
|
|
|
var produced = 0;
|
|
|
|
while(produced < count)
|
|
{
|
|
long sliceIndex = Position / SLICE_SIZE;
|
|
var sliceOff = (int)(Position % SLICE_SIZE);
|
|
|
|
byte[] slice = GetSlice(sliceIndex);
|
|
int take = Math.Min(count - produced, slice.Length - sliceOff);
|
|
|
|
Buffer.BlockCopy(slice, sliceOff, buffer, offset + produced, take);
|
|
|
|
produced += take;
|
|
Position += take;
|
|
}
|
|
|
|
return produced;
|
|
}
|
|
|
|
byte[] GetSlice(long sliceIndex)
|
|
{
|
|
if(_residentSlices.TryGetValue(sliceIndex, out byte[] slice))
|
|
{
|
|
TouchLru(sliceIndex);
|
|
|
|
return slice;
|
|
}
|
|
|
|
if(_spillOffsets.TryGetValue(sliceIndex, out long spillOff))
|
|
{
|
|
slice = new byte[GetSliceSize(sliceIndex)];
|
|
_spill.Seek(spillOff, SeekOrigin.Begin);
|
|
_spill.EnsureRead(slice, 0, slice.Length);
|
|
InsertResident(sliceIndex, slice);
|
|
|
|
return slice;
|
|
}
|
|
|
|
slice = DecodeSlice(sliceIndex);
|
|
InsertResident(sliceIndex, slice);
|
|
|
|
return slice;
|
|
}
|
|
|
|
int GetSliceSize(long sliceIndex)
|
|
{
|
|
long sliceStart = sliceIndex * SLICE_SIZE;
|
|
long sliceEnd = Math.Min(sliceStart + SLICE_SIZE, forkLength);
|
|
|
|
return (int)(sliceEnd - sliceStart);
|
|
}
|
|
|
|
byte[] DecodeSlice(long sliceIndex)
|
|
{
|
|
long sliceStart = sliceIndex * SLICE_SIZE;
|
|
int sliceSize = GetSliceSize(sliceIndex);
|
|
|
|
// Always decode from the start of the body. The 256 MiB slice cache keeps re-decodes cheap in practice.
|
|
source.Seek(bodyStart, SeekOrigin.Begin);
|
|
|
|
SixBitRleDecoder dec = new(new NonDisposingWrapper(source));
|
|
|
|
long skipTo = forkOffsetInBody + sliceStart;
|
|
var discard = new byte[64 * 1024];
|
|
|
|
long skipped = 0;
|
|
|
|
while(skipped < skipTo)
|
|
{
|
|
var want = (int)Math.Min(discard.Length, skipTo - skipped);
|
|
int got = dec.ReadAtMost(discard, 0, want);
|
|
|
|
if(got == 0) break;
|
|
|
|
skipped += got;
|
|
}
|
|
|
|
var slice = new byte[sliceSize];
|
|
var total = 0;
|
|
|
|
while(total < sliceSize)
|
|
{
|
|
int got = dec.ReadAtMost(slice, total, sliceSize - total);
|
|
|
|
if(got == 0) break;
|
|
|
|
total += got;
|
|
}
|
|
|
|
if(total < sliceSize) Array.Resize(ref slice, total);
|
|
|
|
return slice;
|
|
}
|
|
|
|
void InsertResident(long sliceIndex, byte[] slice)
|
|
{
|
|
_residentSlices[sliceIndex] = slice;
|
|
_lru.AddFirst(sliceIndex);
|
|
|
|
while(_lru.Count > RESIDENT_SLICES)
|
|
{
|
|
LinkedListNode<long> last = _lru.Last;
|
|
|
|
if(last == null) break;
|
|
|
|
long victim = last.Value;
|
|
_lru.RemoveLast();
|
|
|
|
if(_residentSlices.Remove(victim, out byte[] victimData)) SpillSlice(victim, victimData);
|
|
}
|
|
}
|
|
|
|
void TouchLru(long sliceIndex)
|
|
{
|
|
_lru.Remove(sliceIndex);
|
|
_lru.AddFirst(sliceIndex);
|
|
}
|
|
|
|
void SpillSlice(long sliceIndex, byte[] data)
|
|
{
|
|
if(_spillOffsets.ContainsKey(sliceIndex)) return;
|
|
|
|
if(_spill == null)
|
|
{
|
|
_spillPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(),
|
|
"aaru-binhex-" + Guid.NewGuid().ToString("N") + ".tmp");
|
|
|
|
_spill = new FileStream(_spillPath,
|
|
FileMode.CreateNew,
|
|
FileAccess.ReadWrite,
|
|
FileShare.None,
|
|
65536,
|
|
FileOptions.DeleteOnClose);
|
|
}
|
|
|
|
long off = _spill.Seek(0, SeekOrigin.End);
|
|
_spill.Write(data, 0, data.Length);
|
|
_spillOffsets[sliceIndex] = off;
|
|
}
|
|
|
|
public override long Seek(long offset, SeekOrigin origin)
|
|
{
|
|
long newPos = origin switch
|
|
{
|
|
SeekOrigin.Begin => offset,
|
|
SeekOrigin.Current => Position + offset,
|
|
SeekOrigin.End => forkLength + offset,
|
|
_ => Position
|
|
};
|
|
|
|
if(newPos < 0) newPos = 0;
|
|
|
|
if(newPos > forkLength) newPos = forkLength;
|
|
|
|
Position = newPos;
|
|
|
|
return Position;
|
|
}
|
|
|
|
public override void SetLength(long value) => throw new NotSupportedException();
|
|
|
|
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if(disposing)
|
|
{
|
|
_spill?.Close();
|
|
|
|
if(_spillPath != null && File.Exists(_spillPath))
|
|
{
|
|
try
|
|
{
|
|
File.Delete(_spillPath);
|
|
}
|
|
catch(IOException ex)
|
|
{
|
|
AaruLogging.Debug(MODULE_NAME,
|
|
"BinHex 4.0: could not delete spill file {0}: {1}",
|
|
_spillPath,
|
|
ex.Message);
|
|
}
|
|
}
|
|
|
|
_residentSlices.Clear();
|
|
_spillOffsets.Clear();
|
|
_lru.Clear();
|
|
}
|
|
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region IFilter Members
|
|
|
|
/// <inheritdoc />
|
|
public string Name => Localization.BinHex_Name;
|
|
|
|
/// <inheritdoc />
|
|
public Guid Id => new("A0E7D4F1-7C5B-4D2E-9B8A-2C1E7F3A4B5D");
|
|
|
|
/// <inheritdoc />
|
|
public string Author => Authors.NataliaPortillo;
|
|
|
|
/// <inheritdoc />
|
|
public string BasePath { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public DateTime CreationTime { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public long DataForkLength => _header.DataLength;
|
|
|
|
/// <inheritdoc />
|
|
public string Filename { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public DateTime LastWriteTime { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public long Length => _header.DataLength + _header.ResourceLength;
|
|
|
|
/// <inheritdoc />
|
|
public string ParentFolder => System.IO.Path.GetDirectoryName(BasePath);
|
|
|
|
/// <inheritdoc />
|
|
public string Path => BasePath;
|
|
|
|
/// <inheritdoc />
|
|
public long ResourceForkLength => _header.ResourceLength;
|
|
|
|
/// <inheritdoc />
|
|
public bool HasResourceFork => _header.ResourceLength > 0;
|
|
|
|
/// <inheritdoc />
|
|
public void Close()
|
|
{
|
|
_decoded = null;
|
|
_sourceBytes = null;
|
|
_sourceStream?.Close();
|
|
_sourceStream = null;
|
|
_dataForkStream?.Dispose();
|
|
_rsrcForkStream?.Dispose();
|
|
_dataForkStream = null;
|
|
_rsrcForkStream = null;
|
|
_isBytes = false;
|
|
_streamingMode = false;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Stream GetDataForkStream()
|
|
{
|
|
if(_header.DataLength == 0) return null;
|
|
|
|
if(!_streamingMode && _decoded != null)
|
|
return new OffsetStream(_decoded, _dataForkOff, _dataForkOff + _header.DataLength - 1);
|
|
|
|
_dataForkStream ??= new BinHexDecodeStream(OpenSource(), _bodyStart, _dataForkOff, _header.DataLength);
|
|
|
|
_dataForkStream.Seek(0, SeekOrigin.Begin);
|
|
|
|
return _dataForkStream;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Stream GetResourceForkStream()
|
|
{
|
|
if(_header.ResourceLength == 0) return null;
|
|
|
|
if(!_streamingMode && _decoded != null)
|
|
return new OffsetStream(_decoded, _rsrcForkOff, _rsrcForkOff + _header.ResourceLength - 1);
|
|
|
|
_rsrcForkStream ??= new BinHexDecodeStream(OpenSource(), _bodyStart, _rsrcForkOff, _header.ResourceLength);
|
|
|
|
_rsrcForkStream.Seek(0, SeekOrigin.Begin);
|
|
|
|
return _rsrcForkStream;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool Identify(byte[] buffer) => buffer != null && TryIdentifyBuffer(buffer);
|
|
|
|
/// <inheritdoc />
|
|
public bool Identify(Stream stream)
|
|
{
|
|
if(stream is not { CanRead: true }) return false;
|
|
|
|
stream.Seek(0, SeekOrigin.Begin);
|
|
|
|
var probe = new byte[Math.Min(stream.Length, 16384)];
|
|
stream.EnsureRead(probe, 0, probe.Length);
|
|
|
|
return TryIdentifyBuffer(probe);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool Identify(string path)
|
|
{
|
|
if(!File.Exists(path)) return false;
|
|
|
|
using FileStream fs = new(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
|
|
return Identify(fs);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ErrorNumber Open(byte[] buffer)
|
|
{
|
|
if(buffer == null) return ErrorNumber.InvalidArgument;
|
|
|
|
MemoryStream ms = new(buffer, false);
|
|
|
|
ErrorNumber err = OpenInternal(ms, buffer.LongLength);
|
|
|
|
if(err != ErrorNumber.NoError) return err;
|
|
|
|
_isBytes = true;
|
|
_sourceBytes = buffer;
|
|
|
|
CreationTime = DateTime.MinValue;
|
|
LastWriteTime = DateTime.MinValue;
|
|
|
|
return ErrorNumber.NoError;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ErrorNumber Open(Stream stream)
|
|
{
|
|
if(stream is not { CanRead: true, CanSeek: true }) return ErrorNumber.InvalidArgument;
|
|
|
|
ErrorNumber err = OpenInternal(stream, stream.Length);
|
|
|
|
if(err != ErrorNumber.NoError) return err;
|
|
|
|
_sourceStream = stream;
|
|
|
|
CreationTime = DateTime.MinValue;
|
|
LastWriteTime = DateTime.MinValue;
|
|
|
|
return ErrorNumber.NoError;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ErrorNumber Open(string path)
|
|
{
|
|
if(!File.Exists(path)) return ErrorNumber.NoSuchFile;
|
|
|
|
FileStream fs = new(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
|
|
ErrorNumber err = OpenInternal(fs, fs.Length);
|
|
|
|
if(err != ErrorNumber.NoError)
|
|
{
|
|
fs.Close();
|
|
|
|
return err;
|
|
}
|
|
|
|
BasePath = path;
|
|
_sourceStream = fs;
|
|
|
|
CreationTime = File.GetCreationTime(path);
|
|
LastWriteTime = File.GetLastWriteTime(path);
|
|
|
|
return ErrorNumber.NoError;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helpers
|
|
|
|
/// <summary>CRC-16/CCITT (poly 0x1021, init 0, no reflection) used by the BinHex 4.0 header and fork trailers.</summary>
|
|
static ushort Crc16Ccitt(byte[] data, int offset, int length, ushort seed = 0)
|
|
{
|
|
ushort crc = seed;
|
|
|
|
for(var i = 0; i < length; i++)
|
|
{
|
|
crc ^= (ushort)(data[offset + i] << 8);
|
|
|
|
for(var b = 0; b < 8; b++)
|
|
{
|
|
if((crc & 0x8000) != 0)
|
|
crc = (ushort)(crc << 1 ^ 0x1021);
|
|
else
|
|
crc <<= 1;
|
|
}
|
|
}
|
|
|
|
return crc;
|
|
}
|
|
|
|
static ushort ReadUInt16Be(byte[] buf, int off) => (ushort)(buf[off] << 8 | buf[off + 1]);
|
|
|
|
/// <summary>Scan <paramref name="buffer" /> for an ASCII needle within the first <paramref name="limit" /> bytes.</summary>
|
|
/// <returns>Offset of the match, or -1 if not found.</returns>
|
|
static int IndexOfAscii(byte[] buffer, int limit, string needle)
|
|
{
|
|
if(buffer == null) return -1;
|
|
|
|
int end = Math.Min(buffer.Length, limit);
|
|
int nl = needle.Length;
|
|
|
|
if(end < nl) return -1;
|
|
|
|
for(var i = 0; i <= end - nl; i++)
|
|
{
|
|
var match = true;
|
|
|
|
for(var j = 0; j < nl; j++)
|
|
{
|
|
if(buffer[i + j] == (byte)needle[j]) continue;
|
|
|
|
match = false;
|
|
|
|
break;
|
|
}
|
|
|
|
if(match) return i;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
/// <summary>Strictly parse and validate the BinHex 4.0 normalized header.</summary>
|
|
/// <param name="decoded">Decoded buffer; must hold at least the full header.</param>
|
|
/// <param name="maxDecodedSize">Upper bound for data + resource fork sizes. Pass 0 to skip the bound check.</param>
|
|
/// <param name="header">Parsed header on success.</param>
|
|
static bool TryParseHeader(byte[] decoded, long maxDecodedSize, out Header header)
|
|
{
|
|
header = default(Header);
|
|
|
|
if(decoded == null || decoded.Length < 1 + HEADER_FIXED_SIZE) return false;
|
|
|
|
int nameLen = decoded[0];
|
|
|
|
if(nameLen is < 1 or > 63) return false;
|
|
|
|
int totalHeader = 1 + nameLen + HEADER_FIXED_SIZE;
|
|
|
|
if(decoded.Length < totalHeader) return false;
|
|
|
|
// Name must not contain embedded NULs.
|
|
for(var i = 0; i < nameLen; i++)
|
|
if(decoded[1 + i] == 0)
|
|
return false;
|
|
|
|
// Reserved null separator after the filename.
|
|
if(decoded[1 + nameLen] != 0) return false;
|
|
|
|
int off = 1 + nameLen + 1;
|
|
|
|
var type = (uint)(decoded[off] << 24 | decoded[off + 1] << 16 | decoded[off + 2] << 8 | decoded[off + 3]);
|
|
off += 4;
|
|
var creator = (uint)(decoded[off] << 24 | decoded[off + 1] << 16 | decoded[off + 2] << 8 | decoded[off + 3]);
|
|
off += 4;
|
|
var flags = (ushort)(decoded[off] << 8 | decoded[off + 1]);
|
|
off += 2;
|
|
var dataLen = (uint)(decoded[off] << 24 | decoded[off + 1] << 16 | decoded[off + 2] << 8 | decoded[off + 3]);
|
|
off += 4;
|
|
var rsrcLen = (uint)(decoded[off] << 24 | decoded[off + 1] << 16 | decoded[off + 2] << 8 | decoded[off + 3]);
|
|
off += 4;
|
|
var storedCrc = (ushort)(decoded[off] << 8 | decoded[off + 1]);
|
|
|
|
// The header CRC is computed over every byte preceding it, then over two trailing zero bytes
|
|
// (equivalent to running the register out by 16 bits).
|
|
ushort computed = Crc16Ccitt(decoded, 0, totalHeader - 2);
|
|
computed = Crc16Ccitt(new byte[2], 0, 2, computed);
|
|
|
|
if(computed != storedCrc) return false;
|
|
|
|
if(maxDecodedSize > 0 && (long)dataLen + rsrcLen > maxDecodedSize) return false;
|
|
|
|
var nameBytes = new byte[nameLen];
|
|
Array.Copy(decoded, 1, nameBytes, 0, nameLen);
|
|
|
|
header = new Header
|
|
{
|
|
Filename = StringHandlers.CToString(nameBytes, Encoding.GetEncoding("macintosh")),
|
|
Type = type,
|
|
Creator = creator,
|
|
FinderFlags = flags,
|
|
DataLength = dataLen,
|
|
ResourceLength = rsrcLen,
|
|
HeaderBytes = totalHeader
|
|
};
|
|
|
|
return true;
|
|
}
|
|
|
|
#endregion
|
|
} |