INitial service

This commit is contained in:
Stephen Barriball 2026-06-22 12:01:41 +01:00
parent 2777a4034f
commit 1665bc401c
60 changed files with 1738 additions and 0 deletions

View File

@ -0,0 +1,30 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
!**/.gitignore
!.git/HEAD
!.git/config
!.git/packed-refs
!.git/refs/heads/**

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,7 @@
namespace AquaCubeIT.Service.NetFloppy.DataAccess
{
public class Class1
{
}
}

View File

@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace AquaCubeIT.Service.NetFloppy.DataAccess.Models
{
public class MediaBlock
{
public Guid Id { get; set; }
public required MediaImage MediaImage { get; set; }
public long BlockId { get; set; }
public byte[] Data { get; set; }
public DateTime DateStamp { get; set; }
}
}

View File

@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace AquaCubeIT.Service.NetFloppy.DataAccess.Models
{
public class MediaImage
{
public Guid Id { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public required MediaType MediaType { get; set; }
public long SizeInBytes { get; set; }
public int Hash { get; set; }
public long NoOfBlocks { get; set; }
public long BlockSize { get; set; }
public DateTime DateStamp { get; set; }
}
}

View File

@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace AquaCubeIT.Service.NetFloppy.DataAccess.Models
{
/// <summary>
/// Media type of the stored data image
/// </summary>
public class MediaType
{
public Guid Id { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public DateTime DateStamp { get; set; }
}
}

View File

@ -0,0 +1,31 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.13.35828.75
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AquaCubeIT.Service.NetFloppy", "AquaCubeIT.Service.NetFloppy\AquaCubeIT.Service.NetFloppy.csproj", "{BBCB4FB7-219C-49D4-9F20-C65189D820BD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AquaCubeIT.Service.NetFloppy.DataAccess", "AquaCubeIT.Service.NetFloppy.DataAccess\AquaCubeIT.Service.NetFloppy.DataAccess.csproj", "{883D7BF4-EC7F-436F-AE3A-5BDE9E024801}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{BBCB4FB7-219C-49D4-9F20-C65189D820BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BBCB4FB7-219C-49D4-9F20-C65189D820BD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BBCB4FB7-219C-49D4-9F20-C65189D820BD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BBCB4FB7-219C-49D4-9F20-C65189D820BD}.Release|Any CPU.Build.0 = Release|Any CPU
{883D7BF4-EC7F-436F-AE3A-5BDE9E024801}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{883D7BF4-EC7F-436F-AE3A-5BDE9E024801}.Debug|Any CPU.Build.0 = Debug|Any CPU
{883D7BF4-EC7F-436F-AE3A-5BDE9E024801}.Release|Any CPU.ActiveCfg = Release|Any CPU
{883D7BF4-EC7F-436F-AE3A-5BDE9E024801}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {BE3474A5-542F-4060-AE03-C0FB9EBA0A0E}
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>f3d90137-247e-42ea-b274-3bbba8be89c4</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="Helpers\" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ActiveDebugProfile>http</ActiveDebugProfile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebuggerFlavor>ProjectDebugger</DebuggerFlavor>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,9 @@
@AquaCubeIT.Service.NetFloppy_HostAddress = http://localhost:9000
GET {{AquaCubeIT.Service.NetFloppy_HostAddress}}/register/
Accept: application/json
GET {{AquaCubeIT.Service.NetFloppy_HostAddress}}/logon/
Accept: application/json
###

View File

@ -0,0 +1,177 @@
using AquaCubeIT.Service.NetFloppy.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Sockets;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using RefreshRequest = AquaCubeIT.Service.NetFloppy.Models.RefreshRequest;
namespace AquaCubeIT.Service.NetFloppy.Controllers;
[ApiController]
[Route("[controller]")]
public class LogonController : ControllerBase
{
private readonly IConfiguration _config;
private readonly ILogger<LogonController> _logger;
public LogonController(ILogger<LogonController> logger, IConfiguration config)
{
_config = config;
_logger = logger;
}
[HttpGet]
[Authorize]
public async Task<string> Get()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var email = User.FindFirstValue(ClaimTypes.Email);
var roles = User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList();
var allClaims = User.Claims.Select(c => c.Value).ToList();
return $"User: {userId}, {email}, {string.Join(",", roles)}, {string.Join(",",allClaims)}";
}
private RefreshResponsePayload GenerateToken(RefreshRequestPayload request)
{
if (request.RefreshToken == null)
{
throw new BadHttpRequestException("Bad Payload");
}
// Hash incoming token the same way
byte[] raw = Convert.FromBase64String(request.RefreshToken);
string reqHash = Convert.ToHexString(SHA256.HashData(raw));
// Get token by hash
// Check family is ok
string? family = null;
var userName = "Test";
// Check if revoked
// Throw error if revoked or expired kill all family tokens
// Save refresh token by hash
// Check
return GenerateToken(userName, GenerateRefreshToken(family));
}
private string GenerateRefreshToken(string? family)
{
// Update refreshToken
var bytes = RandomNumberGenerator.GetBytes(64);
var refreshToken = Convert.ToBase64String(bytes);
var hash = Convert.ToHexString(SHA256.HashData(bytes));
family = family ?? Guid.NewGuid().ToString("N");
// Save refresh token by hash
return refreshToken;
}
private LogonResponsePayload GenerateToken(LogonRequestPayload request)
{
return GenerateToken(request.UserName, GenerateRefreshToken(null));
}
private RefreshResponsePayload GenerateToken(string? userName, string refreshToken)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userName),
new Claim(ClaimTypes.Name, "Stephen Barriball"),
new Claim(ClaimTypes.Role, "User")
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var expiryDate = DateTime.Now.AddMinutes(15);
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Audience"],
claims: claims,
expires: expiryDate,
signingCredentials: creds);
// Return new token and refresh token
return new RefreshResponsePayload() { Token = new JwtSecurityTokenHandler().WriteToken(token), RefreshToken = refreshToken, TokenExpiry = expiryDate };
}
[HttpPost]
public async Task<LogonResponse> Post(LogonRequest request)
{
var response = new LogonResponse();
if (request.Payload == null)
{
throw new Exception("Invalid Payload");
}
// Validate user here
if (request.Payload.UserName == "test" && request.Payload.Password == "password")
{
response.Payload = GenerateToken(request.Payload);
response.Success = true;
}
else
{
response.Success = false;
response.Error = new ErrorResponsePayload() { ErrorNo = 15, Message = "Invalid Login" };
}
return response;
}
[HttpPost("refresh")]
public async Task<RefreshResponse> Refresh(RefreshRequest request)
{
try
{
if (request.Payload == null)
{
throw new BadHttpRequestException("Invalid Payload");
}
// Hash incoming token the same way
byte[] raw = Convert.FromBase64String(request.Payload.RefreshToken);
string reqHash = Convert.ToHexString(SHA256.HashData(raw));
//var stored = await _repo.GetRefreshTokenByHashAsync(reqHash);
//if (stored is null || stored.RevokedAtUtc != null || stored.ExpiresAtUtc < DateTime.UtcNow)
//{
// throw new UnauthorizedAccessException();
//return Unauthorized();
//}
// Rotate: revoke old, issue new pair in same family
//stored.RevokedAtUtc = DateTime.UtcNow;
//var user = await _users.GetByIdAsync(stored.UserId);
return new RefreshResponse() { Payload = GenerateToken(request.Payload), Success = true };
}
catch(Exception e)
{
return new RefreshResponse() { Success = false, Error = new ErrorResponsePayload() { Exception = e, Message = e.Message, ErrorNo = 14 } };
}
}
}

View File

@ -0,0 +1,174 @@
using AquaCubeIT.Service.NetFloppy.Models;
using Fat16Lib;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
using System;
using System.IO;
using System.Text;
namespace AquaCubeIT.Service.NetFloppy.Controllers;
[ApiController]
[Route("[controller]")]
public class OperationsController : ControllerBase
{
private readonly ILogger<OperationsController> _logger;
private const int DISK_SIZE_MB = 5;
private const int DISK_SECTOR_SIZE = 512; // 512 bytes/sector
private const int DISK_SECTOR_COUNT = DISK_SIZE_MB * 1024 * 1024 / DISK_SECTOR_SIZE; // ~2 MB (4000 * 512)
private const long DISK_BYTE_SIZE = DISK_SECTOR_COUNT * DISK_SECTOR_SIZE;
public OperationsController(ILogger<OperationsController> logger)
{
_logger = logger;
}
private ErrorResponsePayload GetExceptionPayload(Exception exception)
{
return new ErrorResponsePayload() { ErrorNo = 16, Message = exception.Message, Exception = exception };
}
[Authorize(Policy = "MustBeUser")]
[HttpPost("LoadImage")]
public async Task<LoadImageResponse> LoadImage(LoadImageRequest request)
{
try
{
// Check image exists
// Check permissions of user
// Get image data
byte[] data = null;
if (System.IO.File.Exists("c:\\temp\\test_image1.img"))
{
data = await System.IO.File.ReadAllBytesAsync("c:\\temp\\test_image1.img");
}
else
{
var img = new Fat16Image(new Fat16ImageOptions
{
TotalSizeBytes = DISK_BYTE_SIZE,
VolumeLabel = "NetFloppy"
});
img.AddFile("README.TXT", Encoding.ASCII.GetBytes("Welcome to NetFloppy\r\n"));
img.AddFile("FF.CFG", Encoding.ASCII.GetBytes("# FF.CFG - FlashFloppy configuration (served inside FAT by backend)\r\ninterface = ibmpc\r\nhost = acorn\r\nindex-suppression = no\r\n\r\n# Enable speaker / fake disk drive sounds\r\nsound = on\r\nsound-volume = 19\r\n\r\n# Disable display devices\r\ndisplay-type = none\r\n"));
img.AddFile("TEST.ADF", System.IO.File.ReadAllBytes("TestDisk.adf"));
data = img.GetImage();
img.SaveToFile("c:\\temp\\test_image1.img");
}
return new LoadImageResponse() { Success = true, Payload = new LoadImageResponsePayload() { DiskImage = data } };
}
catch(Exception e)
{
return new LoadImageResponse() { Success = false, Error = GetExceptionPayload(e) };
}
}
private bool InRange(int lba, int offset, int len)
{
long start = lba * DISK_SECTOR_SIZE + offset;
long end = start + len;
return end <= DISK_BYTE_SIZE;
}
//[Authorize(Policy = "MustBeUser")]
[HttpPost("LoadSector")]
public async Task<IActionResult> LoadSector(LoadSectorRequest request)
{
try
{
// Check image exists
// Check permissions of user
// Get image data
byte[] data = null;
if (System.IO.File.Exists("c:\\temp\\test_image1.img"))
{
data = await System.IO.File.ReadAllBytesAsync("c:\\temp\\test_image1.img");
}
else
{
var img = new Fat16Image(new Fat16ImageOptions
{
TotalSizeBytes = DISK_BYTE_SIZE,
VolumeLabel = "NetFloppy"
});
img.AddFile("README.TXT", Encoding.ASCII.GetBytes("Welcome to NetFloppy\r\n"));
img.AddFile("FF.CFG", Encoding.ASCII.GetBytes("# FF.CFG - FlashFloppy configuration (served inside FAT by backend)\r\ninterface = ibmpc\r\nhost = acorn\r\nindex-suppression = no\r\n\r\n# Enable speaker / fake disk drive sounds\r\nsound = on\r\nsound-volume = 19\r\n\r\n# Disable display devices\r\ndisplay-type = none\r\n"));
img.AddFile("TEST.ADF", System.IO.File.ReadAllBytes("TestDisk.adf"));
data = img.GetImage();
//img.SaveToFile("c:\\temp\\test_image1.img");
}
if (!this.InRange(request.Payload.Lba, request.Payload.Offset, request.Payload.Length))
{
throw new Exception("Invalid Disk Location");
}
var startPosition = (request.Payload.Lba * DISK_SECTOR_SIZE) + request.Payload.Offset;
var sectorData = data.Skip(startPosition).Take(request.Payload.Length).ToArray();
//Response.ContentType = "application/octet-stream";
//Response.ContentLength = sectorData.Length;
//await Response.Body.WriteAsync(sectorData,0 , sectorData.Length);
return new LoadSectorResponse() { Success = true, Payload = new LoadSectorResponsePayload() { Data = sectorData, Length = sectorData.Length } };
//return new EmptyResult();
}
catch (Exception e)
{
//return new LoadSectorResponse() { Success = false, Error = GetExceptionPayload(e) };
return BadRequest(e.Message);
}
}
[Authorize(Policy = "MustBeUser")]
[HttpPost("SaveImage")]
public async Task<SaveImageResponse> SaveImage(SaveImageRequest request)
{
try
{
// Check image exists
// Check permissions of user
// Get image data
var data = request.Payload.Data;
// Save to file
await System.IO.File.WriteAllBytesAsync("c:\\temp\\test_image1.img", data);
return new SaveImageResponse() { Success = true, Payload = new SaveImageResponsePayload() { BytesSaved = data.Length } };
}
catch (Exception e)
{
return new SaveImageResponse() { Success = false, Error = GetExceptionPayload(e) };
}
}
}

View File

@ -0,0 +1,30 @@
using AquaCubeIT.Service.NetFloppy.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace AquaCubeIT.Service.NetFloppy.Controllers;
[ApiController]
[Route("[controller]")]
public class RegisterController : ControllerBase
{
private readonly ILogger<RegisterController> _logger;
public RegisterController(ILogger<RegisterController> logger)
{
_logger = logger;
}
[Authorize(Policy = "MustBeUser")]
[HttpPost]
public RegisterResponse Post(RegisterRequest request)
{
return new RegisterResponse() { Success = true, Payload = new RegisterResponsePayload() { Name = "Test", Description = "Test Drive", EmulationType = "Unknown Floppy Type" }};
}
[HttpGet]
public RegisterResponse Get()
{
return new RegisterResponse() { Success = true, Payload = new RegisterResponsePayload() { Name = "Test", Description = "Test Drive", EmulationType = "Unknown Floppy Type" } };
}
}

View File

@ -0,0 +1,30 @@
# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
# This stage is used when running from VS in fast mode (Default for Debug configuration)
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
# This stage is used to build the service project
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["AquaCubeIT.Service.NetFloppy/AquaCubeIT.Service.NetFloppy.csproj", "AquaCubeIT.Service.NetFloppy/"]
RUN dotnet restore "./AquaCubeIT.Service.NetFloppy/AquaCubeIT.Service.NetFloppy.csproj"
COPY . .
WORKDIR "/src/AquaCubeIT.Service.NetFloppy"
RUN dotnet build "./AquaCubeIT.Service.NetFloppy.csproj" -c $BUILD_CONFIGURATION -o /app/build
# This stage is used to publish the service project to be copied to the final stage
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./AquaCubeIT.Service.NetFloppy.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "AquaCubeIT.Service.NetFloppy.dll"]

View File

@ -0,0 +1,341 @@
// Fat12ImageLib.cs
// Minimal C# library for creating a FAT12 disk image and adding files (root directory only)
// Target: .NET 6+
// Limitations: 8.3 filenames, root directory only (no subdirectories), no long file names, no deletions/compaction.
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace Fat12Lib
{
public class Fat12ImageOptions
{
// Total size of the image in bytes. Common sizes: 360 KiB, 720 KiB, 1.2 MiB, 1.44 MiB, 2.88 MiB, etc.
public long TotalSizeBytes { get; set; } = 1440L * 1024; // 1.44 MiB default
public ushort BytesPerSector { get; set; } = 512;
public byte SectorsPerCluster { get; set; } = 0; // 0 = auto-select based on size
public byte NumFats { get; set; } = 2;
public ushort ReservedSectors { get; set; } = 1; // boot sector
public ushort MaxRootDirEntries { get; set; } = 224; // typical for 1.44MB floppy
public byte MediaDescriptor { get; set; } = 0xF0; // floppy
public ushort SectorsPerTrack { get; set; } = 18; // 1.44MB geometry (not critical)
public ushort NumberOfHeads { get; set; } = 2; // geometry (not critical)
public uint HiddenSectors { get; set; } = 0;
public string OemId { get; set; } = "MSDOS5.0";
public string VolumeLabel { get; set; } = "NO NAME "; // exactly 11 chars; will be padded/trimmed
public string FsTypeLabel { get; set; } = "FAT12 "; // 8 chars in boot sector label
public ushort? VolumeId { get; set; } = null; // null => random
}
public class Fat12Image
{
private readonly Fat12ImageOptions _opt;
// Computed layout
private uint _totalSectors; // 16-bit or 32-bit depending on size
private ushort _rootDirSectors;
private ushort _fatSectors; // sectors per FAT
private uint _firstFatSectorLba;
private uint _firstRootDirSectorLba;
private uint _firstDataSectorLba;
private uint _clusterCount; // data clusters (not counting 0 and 1)
private byte _sectorsPerCluster;
// Runtime buffers
private byte[] _bootSector = Array.Empty<byte>();
private byte[] _fat; // a single FAT table; will be duplicated NumFats times
private byte[] _rootDir; // root directory area (fixed size)
private byte[] _data; // data region sized to contain all clusters (clusterCount * spc * bps)
// Allocation state
private uint _nextFreeCluster = 2; // FAT clusters start at 2
private int _nextFreeRootDirEntry = 0;
public Fat12Image(Fat12ImageOptions options)
{
_opt = options;
if (_opt.BytesPerSector != 512)
throw new NotSupportedException("Only 512-byte sectors supported in this minimal implementation.");
if (_opt.TotalSizeBytes % _opt.BytesPerSector != 0)
throw new ArgumentException("TotalSizeBytes must be a multiple of BytesPerSector.");
InitializeLayout();
BuildBootSector();
InitializeFatAndRoot();
}
// Adds a file into the root directory. Uses 8.3 name rules.
public void AddFile(string fileName, byte[] content, DateTime? timestamp = null)
{
if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("fileName");
if (content == null) throw new ArgumentNullException(nameof(content));
var (name83, ext83) = ToShortName(fileName);
if (_nextFreeRootDirEntry >= _opt.MaxRootDirEntries)
throw new IOException("Root directory is full.");
// Compute how many clusters needed
uint bytesPerCluster = (uint)(_opt.BytesPerSector * _sectorsPerCluster);
uint clustersNeeded = (uint)((content.Length + bytesPerCluster - 1) / bytesPerCluster);
if (clustersNeeded == 0) clustersNeeded = 1; // allocate 1 cluster to store 0-byte file metadata/simple handling
if ((_nextFreeCluster - 2) + clustersNeeded > _clusterCount)
throw new IOException("Not enough free space for file.");
// Allocate cluster chain
List<uint> chain = new();
for (int i = 0; i < clustersNeeded; i++)
{
uint c = _nextFreeCluster++;
chain.Add(c);
}
// Write data to clusters
int srcOffset = 0;
foreach (var c in chain)
{
uint lba = ClusterToLba(c);
uint offset = (lba - _firstDataSectorLba) * _opt.BytesPerSector;
int toCopy = (int)Math.Min(bytesPerCluster, (uint)(content.Length - srcOffset));
if (toCopy > 0)
Buffer.BlockCopy(content, srcOffset, _data, (int)offset, toCopy);
srcOffset += toCopy;
}
// Link FAT entries (12-bit values)
for (int i = 0; i < chain.Count - 1; i++)
SetFat12Entry(chain[i], (ushort)chain[i + 1]);
SetFat12Entry(chain[^1], 0xFFF); // EOF for FAT12
// Build directory entry
var dt = timestamp ?? DateTime.Now;
ushort time = ToFatTime(dt);
ushort date = ToFatDate(dt);
Span<byte> entry = stackalloc byte[32];
entry.Clear();
Encoding.ASCII.GetBytes(name83, entry);
Encoding.ASCII.GetBytes(ext83, entry.Slice(8));
entry[11] = 0x20; // Archive
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(14), time); // Create time
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(16), date); // Create date
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(18), date); // Last access date (approx)
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(22), time); // Write time
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(24), date); // Write date
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(26), (ushort)chain[0]); // First cluster
BinaryPrimitives.WriteUInt32LittleEndian(entry.Slice(28), (uint)content.Length); // File size
// Write entry into root dir
int rootOffset = _nextFreeRootDirEntry * 32;
_rootDir.AsSpan(rootOffset, 32).Clear();
entry.CopyTo(_rootDir.AsSpan(rootOffset));
_nextFreeRootDirEntry++;
}
public byte[] GetImage()
{
using var ms = new MemoryStream((int)(_opt.TotalSizeBytes));
// Boot sector
ms.Write(_bootSector, 0, _bootSector.Length);
// FATs
for (int f = 0; f < _opt.NumFats; f++)
ms.Write(_fat, 0, _fat.Length);
// Root directory
ms.Write(_rootDir, 0, _rootDir.Length);
// Data region
ms.Write(_data, 0, _data.Length);
// Pad to exact size if necessary (should be exact)
if (ms.Length < _opt.TotalSizeBytes)
ms.Write(new byte[_opt.TotalSizeBytes - ms.Length], 0, (int)(_opt.TotalSizeBytes - ms.Length));
return ms.ToArray();
}
public void SaveToFile(string path)
{
File.WriteAllBytes(path, GetImage());
}
private void InitializeLayout()
{
_totalSectors = (uint)(_opt.TotalSizeBytes / _opt.BytesPerSector);
// Decide sectors per cluster if not specified
_sectorsPerCluster = _opt.SectorsPerCluster != 0 ? _opt.SectorsPerCluster : SelectSectorsPerCluster(_totalSectors, _opt.BytesPerSector);
_rootDirSectors = (ushort)(((uint)_opt.MaxRootDirEntries * 32 + (_opt.BytesPerSector - 1)) / _opt.BytesPerSector);
// Iteratively solve for FAT size and cluster count (FAT12 uses 12-bit entries => 1.5 bytes per entry)
ushort fatSectors = 1;
while (true)
{
uint dataSectors = _totalSectors - _opt.ReservedSectors - (uint)(_opt.NumFats * fatSectors) - _rootDirSectors;
uint clusterCount = dataSectors / _sectorsPerCluster;
if (clusterCount < 1 || clusterCount > 4084)
throw new NotSupportedException($"Cluster count {clusterCount} out of FAT12 bounds (needs 1..4,084). Adjust size or sectors/cluster.");
// FAT12 entries: (clusterCount + 2) entries, each 12 bits => total bytes = ceil(3 * entries / 2)
uint entries = clusterCount + 2;
uint fatBytes = (entries * 3 + 1) / 2; // ceil(1.5 * entries)
ushort requiredFatSectors = (ushort)((fatBytes + (_opt.BytesPerSector - 1)) / _opt.BytesPerSector);
if (requiredFatSectors == fatSectors)
{
_fatSectors = fatSectors;
_clusterCount = clusterCount;
break;
}
fatSectors = requiredFatSectors;
}
_firstFatSectorLba = _opt.ReservedSectors;
_firstRootDirSectorLba = _firstFatSectorLba + (uint)(_opt.NumFats * _fatSectors);
_firstDataSectorLba = _firstRootDirSectorLba + _rootDirSectors;
// Buffers
_bootSector = new byte[_opt.BytesPerSector];
_fat = new byte[_fatSectors * _opt.BytesPerSector];
_rootDir = new byte[_rootDirSectors * _opt.BytesPerSector];
uint dataSectorsFinal = _totalSectors - _opt.ReservedSectors - (uint)(_opt.NumFats * _fatSectors) - _rootDirSectors;
_data = new byte[dataSectorsFinal * _opt.BytesPerSector];
}
private void BuildBootSector()
{
Span<byte> bs = _bootSector;
bs.Clear();
// Jump + OEM
bs[0] = 0xEB; bs[1] = 0x3C; bs[2] = 0x90; // JMP short
Encoding.ASCII.GetBytes((_opt.OemId ?? "MSDOS5.0").PadRight(8).Substring(0,8), bs.Slice(3, 8));
// BIOS Parameter Block (BPB)
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(11), _opt.BytesPerSector);
bs[13] = _sectorsPerCluster;
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(14), _opt.ReservedSectors);
bs[16] = _opt.NumFats;
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(17), _opt.MaxRootDirEntries);
ushort totalSectors16 = _totalSectors <= 0xFFFF ? (ushort)_totalSectors : (ushort)0;
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(19), totalSectors16);
bs[21] = _opt.MediaDescriptor;
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(22), _fatSectors);
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(24), _opt.SectorsPerTrack);
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(26), _opt.NumberOfHeads);
BinaryPrimitives.WriteUInt32LittleEndian(bs.Slice(28), _opt.HiddenSectors);
BinaryPrimitives.WriteUInt32LittleEndian(bs.Slice(32), _totalSectors);
// Extended BPB for FAT12/FAT16
ushort volId = _opt.VolumeId ?? (ushort)Random.Shared.Next(1, 0xFFFF);
BinaryPrimitives.WriteUInt32LittleEndian(bs.Slice(39), volId);
string label = (_opt.VolumeLabel ?? "NO NAME").ToUpperInvariant();
label = new string(label.Where(ch => ch >= 0x20 && ch <= 0x7E).ToArray());
label = label.PadRight(11).Substring(0, 11);
Encoding.ASCII.GetBytes(label, bs.Slice(43, 11));
string fstype = (_opt.FsTypeLabel ?? "FAT12").PadRight(8).Substring(0, 8);
Encoding.ASCII.GetBytes(fstype, bs.Slice(54, 8));
// Minimal bootstrap code area (ignored for data disks)
bs[_opt.BytesPerSector - 2] = 0x55; // signature
bs[_opt.BytesPerSector - 1] = 0xAA;
}
private void InitializeFatAndRoot()
{
// FAT initial entries for FAT12
// First three bytes: media descriptor and end markers -> usually F0 FF FF for 0xF0 media
Array.Clear(_fat, 0, _fat.Length);
_fat[0] = _opt.MediaDescriptor; // e.g., 0xF0
_fat[1] = 0xFF;
_fat[2] = 0xFF; // cluster 1 reserved/end
}
private uint ClusterToLba(uint cluster)
{
if (cluster < 2) throw new ArgumentOutOfRangeException(nameof(cluster));
return _firstDataSectorLba + (cluster - 2) * _sectorsPerCluster;
}
private void SetFat12Entry(uint cluster, ushort value)
{
if (cluster >= _clusterCount + 2) throw new ArgumentOutOfRangeException(nameof(cluster));
// value is 12-bit
value &= 0x0FFF;
int index = (int)(cluster + (cluster / 2)); // floor(1.5 * cluster)
if ((cluster & 1) == 0)
{
// even cluster: low 12 bits starting at index
_fat[index] = (byte)((_fat[index] & 0x00) | (value & 0xFF));
_fat[index + 1] = (byte)((_fat[index + 1] & 0xF0) | ((value >> 8) & 0x0F));
}
else
{
// odd cluster: high 12 bits overlapping starting at index
_fat[index] = (byte)((_fat[index] & 0x0F) | ((value << 4) & 0xF0));
_fat[index + 1] = (byte)((_fat[index + 1] & 0x00) | ((value >> 4) & 0xFF));
}
}
private static (string name, string ext) ToShortName(string fileName)
{
// Extract base and extension, sanitize to 8.3 uppercase ASCII
string name = Path.GetFileNameWithoutExtension(fileName) ?? "FILE";
string ext = Path.GetExtension(fileName);
if (ext.StartsWith('.')) ext = ext.Substring(1);
string Sanitize(string s) => new string(s.ToUpperInvariant()
.Select(c => (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || "!#$%&'()-@^_`{}~".Contains(c) ? c : '_')
.ToArray());
string n = Sanitize(name);
string e = Sanitize(ext);
if (n.Length > 8) n = n.Substring(0, 8);
if (e.Length > 3) e = e.Substring(0, 3);
n = n.PadRight(8);
e = e.PadRight(3);
return (n, e);
}
private static ushort ToFatDate(DateTime dt)
{
int year = Math.Clamp(dt.Year - 1980, 0, 127);
int month = Math.Clamp(dt.Month, 1, 12);
int day = Math.Clamp(dt.Day, 1, 31);
return (ushort)((year << 9) | (month << 5) | day);
}
private static ushort ToFatTime(DateTime dt)
{
int hour = Math.Clamp(dt.Hour, 0, 23);
int minute = Math.Clamp(dt.Minute, 0, 59);
int second = Math.Clamp(dt.Second / 2, 0, 29); // 2-second resolution
return (ushort)((hour << 11) | (minute << 5) | second);
}
private static byte SelectSectorsPerCluster(uint totalSectors, ushort bytesPerSector)
{
// Simple mapping aimed at floppy-like sizes with 512B sectors
long sizeKiB = (long)totalSectors * bytesPerSector / 1024;
if (sizeKiB <= 720) return 1; // 360K/720K
if (sizeKiB <= 1440) return 1; // 1.44MB
if (sizeKiB <= 2400) return 1; // 2.4MB
if (sizeKiB <= 2880) return 1; // 2.88MB
if (sizeKiB <= 4096) return 2; // up to 4MB
if (sizeKiB <= 8192) return 4; // up to 8MB
return 8; // larger volumes still FAT12 but uncommon
}
}
}
// --------- Example usage (not part of the library) ---------
// using System.Text;
// var img = new Fat12Lib.Fat12Image(new Fat12Lib.Fat12ImageOptions { TotalSizeBytes = 1440L * 1024, VolumeLabel = "MY12DISK" });
// img.AddFile("HELLO.TXT", Encoding.ASCII.GetBytes("Hello FAT12!"));
// img.SaveToFile("fat12.img");

View File

@ -0,0 +1,10 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class Error
{
public long ErrorNo { get; set; }
public string Message { get; set; }
}
}

View File

@ -0,0 +1,11 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class ErrorResponsePayload
{
public long ErrorNo { get; set; }
public string? Message { get; set; } = null;
public Exception? Exception { get; set; }
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class LoadImageRequest : Request<LoadImageRequestPayload>
{
}
}

View File

@ -0,0 +1,10 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class LoadImageRequestPayload
{
public string UnitId { get; set; } = string.Empty;
public string DiskId { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class LoadImageResponse : Response<LoadImageResponsePayload>
{
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class LoadImageResponsePayload
{
public byte[] DiskImage { get; set; }
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class LoadSectorRequest : Request<LoadSectorRequestPayload>
{
}
}

View File

@ -0,0 +1,13 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class LoadSectorRequestPayload
{
public string UnitId { get; set; } = string.Empty;
public string DiskId { get; set; } = string.Empty;
public int Lba { get; set; } = 0;
public int Offset { get; set; } = 0;
public int Length { get; set; } = 0;
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class LoadSectorResponse : Response<LoadSectorResponsePayload>
{
}
}

View File

@ -0,0 +1,10 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class LoadSectorResponsePayload
{
public byte[] Data { get; set; }
public int Length { get; set; }
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class LogonRequest : Request<LogonRequestPayload>
{
}
}

View File

@ -0,0 +1,10 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class LogonRequestPayload
{
public string UserName { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class LogonResponse : Response<LogonResponsePayload>
{
}
}

View File

@ -0,0 +1,11 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class LogonResponsePayload
{
public required string Token { get; set; }
public required DateTime TokenExpiry { get; set; }
public string RefreshToken { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class RefreshRequest : Request<RefreshRequestPayload>
{
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class RefreshRequestPayload
{
public string RefreshToken { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class RefreshResponse : Response<RefreshResponsePayload>
{
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class RefreshResponsePayload : LogonResponsePayload
{
public string RefreshToken { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class RegisterResponse : Response<RegisterResponsePayload>
{
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class RegisterRequest : Request<RegisterRequestPayload>
{
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class RegisterRequestPayload
{
public string UnitId { get; set; }
}
}

View File

@ -0,0 +1,12 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class RegisterResponsePayload
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public DateTime ExpiryDate { get; set; } = DateTime.MaxValue;
public string EmulationType { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,12 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class Response<T>
{
public bool Success { get; set; }
// public Type Type { get; set; } = typeof(T);
public T? Payload { get; set; }
public ErrorResponsePayload? Error { get; set; }
}
}

View File

@ -0,0 +1,10 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class Request<T>
{
public long Id { get; set; }
public T? Payload { get; set; }
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class SaveImageRequest : Request<SaveImageRequestPayload>
{
}
}

View File

@ -0,0 +1,10 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class SaveImageRequestPayload
{
public string UnitId { get; set; } = string.Empty;
public byte[]? Data { get; set; }
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class SaveImageResponse : Response<SaveImageResponsePayload>
{
}
}

View File

@ -0,0 +1,9 @@
using System.Numerics;
namespace AquaCubeIT.Service.NetFloppy.Models
{
public class SaveImageResponsePayload
{
public int BytesSaved { get; set; }
}
}

View File

@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.SerializerOptions.WriteIndented = true;
options.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow;
});
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("MustBeAdmin", p => p.RequireRole("Admin"));
options.AddPolicy("MustBeUser", p => p.RequireRole("Admin", "User"));
// options.AddPolicy("CanReadOrders", p => p.RequireClaim("permission", "orders.read"));
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
//app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@ -0,0 +1,31 @@
{
"profiles": {
"http": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5250"
},
"https": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7289;http://localhost:5250"
},
"Container (Dockerfile)": {
"commandName": "Docker",
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_HTTPS_PORTS": "8081",
"ASPNETCORE_HTTP_PORTS": "8080"
},
"publishAllPorts": true,
"useSSL": true
}
},
"$schema": "https://json.schemastore.org/launchsettings.json"
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,20 @@
{
"Kestrel": {
"Endpoints": {
"Http": { "Url": "http://0.0.0.0:5250" },
"Https": { "Url": "https://0.0.0.0:5251" }
}
},
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Debug"
}
},
"AllowedHosts": "*",
"Jwt": {
"Key": "k&FS^docD5woeaDVczTZfmb44ijwm%W9AfE`Zf2h#%wjc",
"Issuer": "NetFloppy",
"Audience": "NetFloppy User"
}
}

View File

@ -0,0 +1,5 @@
{
"version": "3.0",
"defaultProvider": "cdnjs",
"libraries": []
}

Binary file not shown.

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="4.0">
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<Name>TEst</Name>
<SchemaVersion>2.0</SchemaVersion>
<ProjectVersion>4.1</ProjectVersion>
<ProjectGuid>{89a44dbb-da85-4401-8b94-ed76e238e3bb}</ProjectGuid>
<DSP>Microsoft.Data.Tools.Schema.Sql.Sql160DatabaseSchemaProvider</DSP>
<OutputType>Database</OutputType>
<RootPath>
</RootPath>
<RootNamespace>TEst</RootNamespace>
<AssemblyName>TEst</AssemblyName>
<ModelCollation>1033, CI</ModelCollation>
<DefaultFileStructure>BySchemaAndSchemaType</DefaultFileStructure>
<DeployToDatabase>True</DeployToDatabase>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<TargetLanguage>CS</TargetLanguage>
<AppDesignerFolder>Properties</AppDesignerFolder>
<SqlServerVerification>False</SqlServerVerification>
<IncludeCompositeObjects>True</IncludeCompositeObjects>
<TargetDatabaseSet>True</TargetDatabaseSet>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<OutputPath>bin\Release\</OutputPath>
<BuildScriptName>$(MSBuildProjectName).sql</BuildScriptName>
<TreatWarningsAsErrors>False</TreatWarningsAsErrors>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<DefineDebug>false</DefineDebug>
<DefineTrace>true</DefineTrace>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<OutputPath>bin\Debug\</OutputPath>
<BuildScriptName>$(MSBuildProjectName).sql</BuildScriptName>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<DefineDebug>true</DefineDebug>
<DefineTrace>true</DefineTrace>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup>
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">11.0</VisualStudioVersion>
<!-- Default to the v11.0 targets path if the targets file for the current VS version is not found -->
<SSDTExists Condition="Exists('$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\SSDT\Microsoft.Data.Tools.Schema.SqlTasks.targets')">True</SSDTExists>
<VisualStudioVersion Condition="'$(SSDTExists)' == ''">11.0</VisualStudioVersion>
</PropertyGroup>
<Import Condition="'$(SQLDBExtensionsRefPath)' != ''" Project="$(SQLDBExtensionsRefPath)\Microsoft.Data.Tools.Schema.SqlTasks.targets" />
<Import Condition="'$(SQLDBExtensionsRefPath)' == ''" Project="$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\SSDT\Microsoft.Data.Tools.Schema.SqlTasks.targets" />
<ItemGroup>
<Folder Include="Properties" />
</ItemGroup>
<ItemGroup>
<Build Include="Table1.sql" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
</Project>

View File

@ -0,0 +1,20 @@
CREATE TABLE [dbo].[Table1]
(
[Id] INT NOT NULL,
[ledger_start_transaction_id] bigint GENERATED ALWAYS AS TRANSACTION_ID START HIDDEN NOT NULL,
[ledger_start_sequence_number] bigint GENERATED ALWAYS AS SEQUENCE_NUMBER START
)
WITH
(
LEDGER = ON
(
APPEND_ONLY = ON,
LEDGER_VIEW = [dbo].[Table1_Ledger]
(
transaction_id_column_name = [ledger_transaction_id],
sequence_number_column_name = [ledger_sequence_number],
operation_type_column_name = [ledger_operation_type],
operation_type_desc_column_name = [ledger_operation_type_desc]
)
)
)

335
Old/Fat16Disk.cs Normal file
View File

@ -0,0 +1,335 @@
// Fat16ImageLib.cs
// Minimal C# library for creating a FAT16 disk image and adding files (root directory only)
// Target: .NET 6+
// Limitations: 8.3 filenames, root directory only (no subdirectories), no long file names, no deletions/compaction.
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace Fat16Lib
{
public class Fat16ImageOptions
{
// Total size of the image in bytes. Common sizes: 16 MiB, 32 MiB, 64 MiB, 128 MiB, 256 MiB, etc.
public long TotalSizeBytes { get; set; } = 32L * 1024 * 1024; // 32 MiB default
public ushort BytesPerSector { get; set; } = 512;
public byte SectorsPerCluster { get; set; } = 0; // 0 = auto-select based on size
public byte NumFats { get; set; } = 2;
public ushort ReservedSectors { get; set; } = 1; // boot sector
public ushort MaxRootDirEntries { get; set; } = 512; // typical for FAT16
public byte MediaDescriptor { get; set; } = 0xF8; // fixed disk
public ushort SectorsPerTrack { get; set; } = 32; // geometry hints (not critical)
public ushort NumberOfHeads { get; set; } = 64; // geometry hints (not critical)
public uint HiddenSectors { get; set; } = 0;
public string OemId { get; set; } = "MSDOS5.0";
public string VolumeLabel { get; set; } = "NO NAME "; // exactly 11 chars; will be padded/trimmed
public string FsTypeLabel { get; set; } = "FAT16 "; // 8 chars in boot sector label
public ushort? VolumeId { get; set; } = null; // null => random
}
public class Fat16Image
{
private readonly Fat16ImageOptions _opt;
// Computed layout
private uint _totalSectors; // 16-bit or 32-bit depending on size
private ushort _rootDirSectors;
private ushort _fatSectors; // sectors per FAT
private uint _firstFatSectorLba;
private uint _firstRootDirSectorLba;
private uint _firstDataSectorLba;
private uint _clusterCount; // data clusters (not counting 0 and 1)
private byte _sectorsPerCluster;
// Runtime buffers
private byte[] _bootSector = Array.Empty<byte>();
private byte[] _fat; // a single FAT table; will be duplicated NumFats times
private byte[] _rootDir; // root directory area (fixed size)
private byte[] _data; // data region sized to contain all clusters (clusterCount * spc * bps)
// Allocation state
private uint _nextFreeCluster = 2; // FAT clusters start at 2
private int _nextFreeRootDirEntry = 0;
public Fat16Image(Fat16ImageOptions options)
{
_opt = options;
if (_opt.BytesPerSector != 512)
throw new NotSupportedException("Only 512-byte sectors supported in this minimal implementation.");
if (_opt.TotalSizeBytes % _opt.BytesPerSector != 0)
throw new ArgumentException("TotalSizeBytes must be a multiple of BytesPerSector.");
InitializeLayout();
BuildBootSector();
InitializeFatAndRoot();
}
// Adds a file into the root directory. Uses 8.3 name rules.
public void AddFile(string fileName, byte[] content, DateTime? timestamp = null)
{
if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("fileName");
if (content == null) throw new ArgumentNullException(nameof(content));
var (name83, ext83) = ToShortName(fileName);
if (_nextFreeRootDirEntry >= _opt.MaxRootDirEntries)
throw new IOException("Root directory is full.");
// Compute how many clusters needed
uint bytesPerCluster = (uint)(_opt.BytesPerSector * _sectorsPerCluster);
uint clustersNeeded = (uint)((content.Length + bytesPerCluster - 1) / bytesPerCluster);
if (clustersNeeded == 0) clustersNeeded = 1; // zero-length file occupies 0 clusters in FAT16 spec? Practically some tools allow 0. We'll allocate 1 for simplicity.
if ((_nextFreeCluster - 2) + clustersNeeded > _clusterCount)
throw new IOException("Not enough free space for file.");
// Allocate cluster chain
List<uint> chain = new();
for (int i = 0; i < clustersNeeded; i++)
{
uint c = _nextFreeCluster++;
chain.Add(c);
}
// Write data to clusters
int srcOffset = 0;
foreach (var c in chain)
{
uint lba = ClusterToLba(c);
uint offset = (lba - _firstDataSectorLba) * _opt.BytesPerSector;
int toCopy = (int)Math.Min(bytesPerCluster, (uint)(content.Length - srcOffset));
if (toCopy > 0)
Buffer.BlockCopy(content, srcOffset, _data, (int)offset, toCopy);
// Zero the remainder of the cluster (already zeroed by default construction)
srcOffset += toCopy;
}
// Link FAT entries
for (int i = 0; i < chain.Count - 1; i++)
SetFatEntry(chain[i], (ushort)chain[i + 1]);
SetFatEntry(chain[^1], 0xFFFF); // EOF
// Build directory entry
var dt = timestamp ?? DateTime.Now;
ushort time = ToFatTime(dt);
ushort date = ToFatDate(dt);
Span<byte> entry = stackalloc byte[32];
entry.Clear();
Encoding.ASCII.GetBytes(name83, entry);
Encoding.ASCII.GetBytes(ext83, entry.Slice(8));
entry[11] = 0x20; // Archive
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(14), time); // Create time
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(16), date); // Create date
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(18), date); // Last access date (approx)
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(22), time); // Write time
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(24), date); // Write date
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(26), (ushort)chain[0]); // First cluster
BinaryPrimitives.WriteUInt32LittleEndian(entry.Slice(28), (uint)content.Length); // File size
// Write entry into root dir
int rootOffset = _nextFreeRootDirEntry * 32;
_rootDir.AsSpan(rootOffset, 32).Clear();
entry.CopyTo(_rootDir.AsSpan(rootOffset));
_nextFreeRootDirEntry++;
}
public byte[] GetImage()
{
using var ms = new MemoryStream((int)(_opt.TotalSizeBytes));
// Boot sector
ms.Write(_bootSector, 0, _bootSector.Length);
// FATs
for (int f = 0; f < _opt.NumFats; f++)
ms.Write(_fat, 0, _fat.Length);
// Root directory
ms.Write(_rootDir, 0, _rootDir.Length);
// Data region
ms.Write(_data, 0, _data.Length);
// Pad to exact size if necessary (should be exact)
if (ms.Length < _opt.TotalSizeBytes)
ms.Write(new byte[_opt.TotalSizeBytes - ms.Length], 0, (int)(_opt.TotalSizeBytes - ms.Length));
return ms.ToArray();
}
public void SaveToFile(string path)
{
File.WriteAllBytes(path, GetImage());
}
private void InitializeLayout()
{
_totalSectors = (uint)(_opt.TotalSizeBytes / _opt.BytesPerSector);
// Decide sectors per cluster if not specified, per rough guidance for FAT16
_sectorsPerCluster = _opt.SectorsPerCluster != 0 ? _opt.SectorsPerCluster : SelectSectorsPerCluster(_totalSectors, _opt.BytesPerSector);
_rootDirSectors = (ushort)(((uint)_opt.MaxRootDirEntries * 32 + (_opt.BytesPerSector - 1)) / _opt.BytesPerSector);
// We need to iteratively solve for FAT size and cluster count
ushort fatSectors = 1;
while (true)
{
uint dataSectors = _totalSectors - _opt.ReservedSectors - (uint)(_opt.NumFats * fatSectors) - _rootDirSectors;
uint clusterCount = dataSectors / _sectorsPerCluster;
if (clusterCount < 4085 || clusterCount >= 65525)
throw new NotSupportedException($"Cluster count {clusterCount} out of FAT16 bounds (needs 4,085..65,524). Adjust size or sectors/cluster.");
// FAT16 entry size 2 bytes, need (clusterCount + 2) entries
ushort requiredFatSectors = (ushort)(((clusterCount + 2) * 2 + (_opt.BytesPerSector - 1)) / _opt.BytesPerSector);
if (requiredFatSectors == fatSectors)
{
_fatSectors = fatSectors;
_clusterCount = clusterCount;
break;
}
fatSectors = requiredFatSectors;
}
_firstFatSectorLba = _opt.ReservedSectors;
_firstRootDirSectorLba = _firstFatSectorLba + (uint)(_opt.NumFats * _fatSectors);
_firstDataSectorLba = _firstRootDirSectorLba + _rootDirSectors;
// Buffers
_bootSector = new byte[_opt.BytesPerSector];
_fat = new byte[_fatSectors * _opt.BytesPerSector];
_rootDir = new byte[_rootDirSectors * _opt.BytesPerSector];
uint dataSectorsFinal = _totalSectors - _opt.ReservedSectors - (uint)(_opt.NumFats * _fatSectors) - _rootDirSectors;
_data = new byte[dataSectorsFinal * _opt.BytesPerSector];
}
private void BuildBootSector()
{
Span<byte> bs = _bootSector;
bs.Clear();
// Jump + OEM
bs[0] = 0xEB; bs[1] = 0x3C; bs[2] = 0x90; // JMP short
Encoding.ASCII.GetBytes((_opt.OemId ?? "MSDOS5.0").PadRight(8).Substring(0,8), bs.Slice(3, 8));
// BIOS Parameter Block (BPB)
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(11), _opt.BytesPerSector);
bs[13] = _sectorsPerCluster;
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(14), _opt.ReservedSectors);
bs[16] = _opt.NumFats;
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(17), _opt.MaxRootDirEntries);
ushort totalSectors16 = _totalSectors <= 0xFFFF ? (ushort)_totalSectors : (ushort)0;
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(19), totalSectors16);
bs[21] = _opt.MediaDescriptor;
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(22), _fatSectors);
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(24), _opt.SectorsPerTrack);
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(26), _opt.NumberOfHeads);
BinaryPrimitives.WriteUInt32LittleEndian(bs.Slice(28), _opt.HiddenSectors);
BinaryPrimitives.WriteUInt32LittleEndian(bs.Slice(32), _totalSectors);
// Extended BPB for FAT16
ushort volId = _opt.VolumeId ?? (ushort)Random.Shared.Next(1, 0xFFFF);
BinaryPrimitives.WriteUInt32LittleEndian(bs.Slice(39), volId);
string label = (_opt.VolumeLabel ?? "NO NAME").ToUpperInvariant();
label = new string(label.Where(ch => ch >= 0x20 && ch <= 0x7E).ToArray());
label = label.PadRight(11).Substring(0, 11);
Encoding.ASCII.GetBytes(label, bs.Slice(43, 11));
string fstype = (_opt.FsTypeLabel ?? "FAT16").PadRight(8).Substring(0, 8);
Encoding.ASCII.GetBytes(fstype, bs.Slice(54, 8));
// Minimal bootstrap code area (ignored for data disks)
bs[_opt.BytesPerSector - 2] = 0x55; // signature
bs[_opt.BytesPerSector - 1] = 0xAA;
}
private void InitializeFatAndRoot()
{
// FAT initial entries
// Entry 0: media descriptor in low byte, 0xFF in high byte
SetFatEntryRaw(0, (ushort)(_opt.MediaDescriptor | 0xFF00));
// Entry 1: EOF
SetFatEntryRaw(1, 0xFFFF);
// Zero rest (already zero)
}
private uint ClusterToLba(uint cluster)
{
if (cluster < 2) throw new ArgumentOutOfRangeException(nameof(cluster));
return _firstDataSectorLba + (cluster - 2) * _sectorsPerCluster;
}
private void SetFatEntry(uint cluster, ushort value)
{
if (cluster >= _clusterCount + 2) throw new ArgumentOutOfRangeException(nameof(cluster));
SetFatEntryRaw(cluster, value);
}
private void SetFatEntryRaw(uint cluster, ushort value)
{
int index = (int)(cluster * 2);
BinaryPrimitives.WriteUInt16LittleEndian(_fat.AsSpan(index, 2), value);
}
private static (string name, string ext) ToShortName(string fileName)
{
// Extract base and extension, sanitize to 8.3 uppercase ASCII
string name = Path.GetFileNameWithoutExtension(fileName) ?? "FILE";
string ext = Path.GetExtension(fileName);
if (ext.StartsWith('.')) ext = ext.Substring(1);
string Sanitize(string s) => new string(s.ToUpperInvariant()
.Select(c => (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || "!#$%&'()-@^_`{}~".Contains(c) ? c : '_')
.ToArray());
string n = Sanitize(name);
string e = Sanitize(ext);
if (n.Length > 8) n = n.Substring(0, 8);
if (e.Length > 3) e = e.Substring(0, 3);
n = n.PadRight(8);
e = e.PadRight(3);
return (n, e);
}
private static ushort ToFatDate(DateTime dt)
{
int year = Math.Clamp(dt.Year - 1980, 0, 127);
int month = Math.Clamp(dt.Month, 1, 12);
int day = Math.Clamp(dt.Day, 1, 31);
return (ushort)((year << 9) | (month << 5) | day);
}
private static ushort ToFatTime(DateTime dt)
{
int hour = Math.Clamp(dt.Hour, 0, 23);
int minute = Math.Clamp(dt.Minute, 0, 59);
int second = Math.Clamp(dt.Second / 2, 0, 29); // 2-second resolution
return (ushort)((hour << 11) | (minute << 5) | second);
}
private static byte SelectSectorsPerCluster(uint totalSectors, ushort bytesPerSector)
{
// Very rough mapping consistent with FAT16 recommendations for 512-byte sectors
// Values chosen to keep cluster count in FAT16 range and cluster size reasonable
long sizeMiB = (long)totalSectors * bytesPerSector / (1024 * 1024);
return sizeMiB switch
{
<= 16 => 2, // 1 KiB clusters
<= 32 => 4, // 2 KiB
<= 64 => 8, // 4 KiB
<= 128 => 16, // 8 KiB
<= 256 => 32, // 16 KiB
<= 512 => 64, // 32 KiB
_ => 64, // 32 KiB clusters for larger images in this minimal impl
};
}
}
}
// --------- Example usage (not part of the library) ---------
// var img = new Fat16Lib.Fat16Image(new Fat16Lib.Fat16ImageOptions { TotalSizeBytes = 32L * 1024 * 1024, VolumeLabel = "MYDISK" });
// img.AddFile("HELLO.TXT", Encoding.ASCII.GetBytes("Hello FAT16!\r\n"));
// img.SaveToFile("fat16.img");