From 3cff10dce8b8b541cdd832523faf1e413efe030b Mon Sep 17 00:00:00 2001 From: Natalia Portillo Date: Fri, 10 Apr 2026 23:35:34 +0100 Subject: [PATCH] [CP/M] Add support for analyze command --- Aaru.Filesystems/CPM/Analyze.cs | 386 ++++++++++++++++++++++++++++++++ Aaru.Filesystems/CPM/CPM.cs | 3 + Aaru.Filesystems/CPM/Super.cs | 10 +- 3 files changed, 394 insertions(+), 5 deletions(-) create mode 100644 Aaru.Filesystems/CPM/Analyze.cs diff --git a/Aaru.Filesystems/CPM/Analyze.cs b/Aaru.Filesystems/CPM/Analyze.cs new file mode 100644 index 000000000..406a15158 --- /dev/null +++ b/Aaru.Filesystems/CPM/Analyze.cs @@ -0,0 +1,386 @@ +// /*************************************************************************** +// Aaru Data Preservation Suite +// ---------------------------------------------------------------------------- +// +// Filename : Analyze.cs +// Author(s) : Natalia Portillo +// +// Component : CP/M filesystem plugin. +// +// --[ 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 . +// +// ---------------------------------------------------------------------------- +// Copyright © 2011-2026 Natalia Portillo +// ****************************************************************************/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Aaru.CommonTypes; +using Aaru.CommonTypes.Enums; +using Aaru.CommonTypes.Structs; +using Marshal = Aaru.Helpers.Marshal; + +namespace Aaru.Filesystems; + +/// +public sealed partial class CPM +{ +#region IReadOnlyFilesystem Members + + /// + public ErrorNumber GetFilesWithAffectedSectors(IEnumerable<(ulong Start, ulong End)> sectorExtents, + out List files, + InitProgressHandler initProgress = null, + UpdateProgressHandler updateProgress = null, + PulseProgressHandler pulseProgress = null, + EndProgressHandler endProgress = null) + { + files = []; + + if(!_mounted) return ErrorNumber.AccessDenied; + if(sectorExtents is null) return ErrorNumber.InvalidArgument; + + if(_device is null || _workingDefinition is null || _dpb is null || _sectorMask is null) + return ErrorNumber.InvalidArgument; + + List<(ulong Start, ulong End)> normalizedExtents = NormalizeAnalyzeExtents(sectorExtents); + + if(normalizedExtents.Count == 0) return ErrorNumber.NoError; + + try + { + initProgress?.Invoke(); + pulseProgress?.Invoke("/"); + + ErrorNumber errno = AnalyzeDirectory(normalizedExtents, files, updateProgress); + + return errno; + } + finally + { + endProgress?.Invoke(); + } + } + +#endregion + + ErrorNumber AnalyzeDirectory(IReadOnlyList<(ulong Start, ulong End)> sectorExtents, List files, + UpdateProgressHandler updateProgress) + { + int dirOff; + int dirSectors = (_dpb.drm + 1) * 32 / _workingDefinition.bytesPerSector; + + if(_workingDefinition.sofs > 0) + dirOff = _workingDefinition.sofs; + else + dirOff = _workingDefinition.ofs * _workingDefinition.sectorsPerTrack; + + using MemoryStream dirMs = new(); + + for(var d = 0; d < dirSectors; d++) + { + ulong physicalSector = LogicalSectorToPhysicalSector((ulong)(d + dirOff)); + + if(physicalSector >= _device.Info.Sectors) return ErrorNumber.OutOfRange; + + ErrorNumber errno = _device.ReadSector(physicalSector, false, out byte[] sector, out _); + + if(errno != ErrorNumber.NoError) return errno; + + if(_workingDefinition.complement) + for(var b = 0; b < sector.Length; b++) + sector[b] = (byte)(~sector[b] & 0xFF); + + dirMs.Write(sector, 0, sector.Length); + } + + Dictionary>> fileExtents = ParseFileExtents(dirMs.ToArray()); + + string[] orderedFiles = fileExtents.Keys.OrderBy(static entry => entry, StringComparer.CurrentCultureIgnoreCase) + .ToArray(); + + long maximum = orderedFiles.Length > 0 ? orderedFiles.Length : 1; + + for(var i = 0; i < orderedFiles.Length; i++) + { + string path = "/" + orderedFiles[i]; + + updateProgress?.Invoke(path, i + 1, maximum); + + AddOverlappingFile(path, fileExtents[orderedFiles[i]], sectorExtents, files); + } + + return ErrorNumber.NoError; + } + + Dictionary>> ParseFileExtents(byte[] directory) + { + Dictionary>> fileExtents = []; + int blockSize = 128 << _dpb.bsh; + ulong sectorCount = _partition.End >= _partition.Start ? _partition.End - _partition.Start + 1 : 0; + ulong allocationBlockCount = blockSize > 0 ? sectorCount * _device.Info.SectorSize / (ulong)blockSize : 0; + + for(var dOff = 0; dOff < directory.Length; dOff += 32) + { + switch(directory[dOff] & 0x7F) + { + case < 0x10 when allocationBlockCount > 256: + { + DirectoryEntry16 entry = + Marshal.ByteArrayToStructureLittleEndian(directory, dOff, 32); + + AddDirectoryEntryExtents(in entry, fileExtents); + + break; + } + case < 0x10: + { + DirectoryEntry entry = + Marshal.ByteArrayToStructureLittleEndian(directory, dOff, 32); + + AddDirectoryEntryExtents(in entry, fileExtents); + + break; + } + } + } + + return fileExtents; + } + + void AddDirectoryEntryExtents(in DirectoryEntry16 entry, + Dictionary>> fileExtents) + { + var filenameBytes = (byte[])entry.filename.Clone(); + var extensionBytes = (byte[])entry.extension.Clone(); + var validEntry = true; + + for(var i = 0; i < 8; i++) + { + filenameBytes[i] &= 0x7F; + validEntry &= filenameBytes[i] >= 0x20; + } + + for(var i = 0; i < 3; i++) + { + extensionBytes[i] &= 0x7F; + validEntry &= extensionBytes[i] >= 0x20; + } + + if(!validEntry) return; + + string filename = Encoding.ASCII.GetString(filenameBytes).Trim(); + string extension = Encoding.ASCII.GetString(extensionBytes).Trim(); + int user = entry.statusUser & 0x0F; + + if(user > 0) filename = $"{user:X1}:{filename}"; + if(!string.IsNullOrEmpty(extension)) filename = filename + "." + extension; + + filename = filename.Replace('/', '\u2215'); + + int entryNo = (32 * entry.extentCounter + entry.extentCounterHigh) / (_dpb.exm + 1); + + if(!fileExtents.TryGetValue(filename, out Dictionary> extentBlocks)) extentBlocks = []; + if(!extentBlocks.TryGetValue(entryNo, out List blocks)) blocks = []; + + foreach(ushort block in entry.allocations.Where(static blk => blk != 0 && blk != ushort.MaxValue)) + if(!blocks.Contains(block)) + blocks.Add(block); + + extentBlocks[entryNo] = blocks; + fileExtents[filename] = extentBlocks; + } + + void AddDirectoryEntryExtents(in DirectoryEntry entry, + Dictionary>> fileExtents) + { + var filenameBytes = (byte[])entry.filename.Clone(); + var extensionBytes = (byte[])entry.extension.Clone(); + var validEntry = true; + + for(var i = 0; i < 8; i++) + { + filenameBytes[i] &= 0x7F; + validEntry &= filenameBytes[i] >= 0x20; + } + + for(var i = 0; i < 3; i++) + { + extensionBytes[i] &= 0x7F; + validEntry &= extensionBytes[i] >= 0x20; + } + + if(!validEntry) return; + + string filename = Encoding.ASCII.GetString(filenameBytes).Trim(); + string extension = Encoding.ASCII.GetString(extensionBytes).Trim(); + int user = entry.statusUser & 0x0F; + + if(user > 0) filename = $"{user:X1}:{filename}"; + if(!string.IsNullOrEmpty(extension)) filename = filename + "." + extension; + + filename = filename.Replace('/', '\u2215'); + + int entryNo = (32 * entry.extentCounterHigh + entry.extentCounter) / (_dpb.exm + 1); + + if(!fileExtents.TryGetValue(filename, out Dictionary> extentBlocks)) extentBlocks = []; + if(!extentBlocks.TryGetValue(entryNo, out List blocks)) blocks = []; + + foreach(byte block in entry.allocations.Where(static blk => blk != 0)) + if(!blocks.Contains(block)) + blocks.Add(block); + + extentBlocks[entryNo] = blocks; + fileExtents[filename] = extentBlocks; + } + + void AddOverlappingFile(string path, IReadOnlyDictionary> extentBlocks, + IReadOnlyList<(ulong Start, ulong End)> sectorExtents, List files) + { + if(extentBlocks.Count == 0) return; + + List<(ulong Start, ulong End)> overlaps = FindOverlappingExtents(extentBlocks, sectorExtents); + + if(overlaps.Count == 0) return; + + ulong inode = extentBlocks.OrderBy(static entry => entry.Key) + .SelectMany(static entry => entry.Value) + .Select(static block => (ulong)block) + .FirstOrDefault(); + + files.Add(new FileSectorInfo + { + Path = path, + Inode = inode, + AffectedSectors = overlaps + }); + } + + List<(ulong Start, ulong End)> FindOverlappingExtents(IReadOnlyDictionary> extentBlocks, + IReadOnlyList<(ulong Start, ulong End)> sectorExtents) + { + List<(ulong Start, ulong End)> overlaps = []; + + foreach(KeyValuePair> extent in extentBlocks.OrderBy(static entry => entry.Key)) + { + foreach(ushort allocationBlock in extent.Value) + AddAllocationBlockOverlaps(allocationBlock, sectorExtents, overlaps); + } + + return NormalizeAnalyzeExtents(overlaps); + } + + void AddAllocationBlockOverlaps(ushort allocationBlock, IReadOnlyList<(ulong Start, ulong End)> sectorExtents, + List<(ulong Start, ulong End)> overlaps) + { + if(allocationBlock == 0) return; + + int blockSize = 128 << _dpb.bsh; + uint sectorSize = _device.Info.SectorSize; + + if(blockSize <= 0 || sectorSize == 0) return; + + if(sectorSize > blockSize) + { + uint blocksPerSector = sectorSize / (uint)blockSize; + + if(blocksPerSector == 0) return; + + AddLogicalSectorOverlap(allocationBlock / blocksPerSector, sectorExtents, overlaps); + + return; + } + + if(sectorSize < blockSize) + { + uint sectorsPerBlock = (uint)blockSize / sectorSize; + + if(sectorsPerBlock == 0) return; + + ulong firstLogicalSector = (ulong)allocationBlock * sectorsPerBlock; + + for(uint i = 0; i < sectorsPerBlock; i++) + AddLogicalSectorOverlap(firstLogicalSector + i, sectorExtents, overlaps); + + return; + } + + AddLogicalSectorOverlap(allocationBlock, sectorExtents, overlaps); + } + + void AddLogicalSectorOverlap(ulong logicalSector, IReadOnlyList<(ulong Start, ulong End)> sectorExtents, + List<(ulong Start, ulong End)> overlaps) + { + ulong physicalSector = LogicalSectorToPhysicalSector(logicalSector); + + AddExtentOverlaps(physicalSector, physicalSector, sectorExtents, overlaps); + } + + ulong LogicalSectorToPhysicalSector(ulong logicalSector) + { + if(_sectorMask is null || _sectorMask.Length == 0) return _partition.Start + logicalSector; + + return _partition.Start + + logicalSector / (ulong)_sectorMask.Length * (ulong)_sectorMask.Length + + (ulong)_sectorMask[(int)(logicalSector % (ulong)_sectorMask.Length)]; + } + + static void AddExtentOverlaps(ulong startSector, ulong endSector, + IReadOnlyList<(ulong Start, ulong End)> sectorExtents, + List<(ulong Start, ulong End)> overlaps) + { + foreach((ulong Start, ulong End) requestedExtent in sectorExtents) + { + if(requestedExtent.End < startSector || requestedExtent.Start > endSector) continue; + + overlaps.Add((Math.Max(startSector, requestedExtent.Start), Math.Min(endSector, requestedExtent.End))); + } + } + + static List<(ulong Start, ulong End)> NormalizeAnalyzeExtents(IEnumerable<(ulong Start, ulong End)> extents) + { + var orderedExtents = extents.Where(static extent => extent.End >= extent.Start) + .OrderBy(static extent => extent.Start) + .ThenBy(static extent => extent.End) + .ToList(); + + List<(ulong Start, ulong End)> normalizedExtents = []; + + if(orderedExtents.Count == 0) return normalizedExtents; + + (ulong Start, ulong End) currentExtent = orderedExtents[0]; + + for(var i = 1; i < orderedExtents.Count; i++) + { + if(orderedExtents[i].Start <= currentExtent.End + 1) + { + currentExtent.End = Math.Max(currentExtent.End, orderedExtents[i].End); + + continue; + } + + normalizedExtents.Add(currentExtent); + currentExtent = orderedExtents[i]; + } + + normalizedExtents.Add(currentExtent); + + return normalizedExtents; + } +} \ No newline at end of file diff --git a/Aaru.Filesystems/CPM/CPM.cs b/Aaru.Filesystems/CPM/CPM.cs index 18c55e378..87adc5961 100644 --- a/Aaru.Filesystems/CPM/CPM.cs +++ b/Aaru.Filesystems/CPM/CPM.cs @@ -37,6 +37,7 @@ using Aaru.CommonTypes.AaruMetadata; using Aaru.CommonTypes.Interfaces; using Aaru.CommonTypes.Structs; using FileSystemInfo = Aaru.CommonTypes.Structs.FileSystemInfo; +using Partition = Aaru.CommonTypes.Partition; namespace Aaru.Filesystems; @@ -71,6 +72,8 @@ public sealed partial class CPM : IReadOnlyFilesystem /// Timestamp in volume label for update byte[] _labelUpdateDate; bool _mounted; + /// The mounted partition + Partition _partition; /// Cached file passwords Dictionary _passwordCache; /// Sector deinterleaving mask diff --git a/Aaru.Filesystems/CPM/Super.cs b/Aaru.Filesystems/CPM/Super.cs index 212d48a43..e96aabbff 100644 --- a/Aaru.Filesystems/CPM/Super.cs +++ b/Aaru.Filesystems/CPM/Super.cs @@ -57,8 +57,9 @@ public sealed partial class CPM public ErrorNumber Mount(IMediaImage imagePlugin, Partition partition, Encoding encoding, Dictionary options, string @namespace) { - _device = imagePlugin; - _encoding = encoding ?? Encoding.GetEncoding("IBM437"); + _device = imagePlugin; + _partition = partition; + _encoding = encoding ?? Encoding.GetEncoding("IBM437"); // As the identification is so complex, just call Identify() and relay on its findings if(!Identify(_device, partition) || !_cpmFound || _workingDefinition == null || _dpb == null) @@ -165,9 +166,8 @@ public sealed partial class CPM if(errno != ErrorNumber.NoError) return errno; if(_workingDefinition.complement) - { - for(var b = 0; b < readSector.Length; b++) readSector[b] = (byte)(~readSector[b] & 0xFF); - } + for(var b = 0; b < readSector.Length; b++) + readSector[b] = (byte)(~readSector[b] & 0xFF); deinterleavedSectors.Add((ulong)p, readSector); }