INitial service
This commit is contained in:
parent
2777a4034f
commit
1665bc401c
30
AquaCubeIT.Service.NetFloppy/.dockerignore
Normal file
30
AquaCubeIT.Service.NetFloppy/.dockerignore
Normal 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/**
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
namespace AquaCubeIT.Service.NetFloppy.DataAccess
|
||||||
|
{
|
||||||
|
public class Class1
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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
|
||||||
|
|
||||||
|
###
|
||||||
@ -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 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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" } };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"]
|
||||||
@ -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");
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class LoadImageRequest : Request<LoadImageRequestPayload>
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class LoadImageResponse : Response<LoadImageResponsePayload>
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class LoadImageResponsePayload
|
||||||
|
{
|
||||||
|
public byte[] DiskImage { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class LoadSectorRequest : Request<LoadSectorRequestPayload>
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class LoadSectorResponse : Response<LoadSectorResponsePayload>
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class LogonRequest : Request<LogonRequestPayload>
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class LogonResponse : Response<LogonResponsePayload>
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class RefreshRequest : Request<RefreshRequestPayload>
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class RefreshRequestPayload
|
||||||
|
{
|
||||||
|
public string RefreshToken { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class RefreshResponse : Response<RefreshResponsePayload>
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class RefreshResponsePayload : LogonResponsePayload
|
||||||
|
{
|
||||||
|
public string RefreshToken { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class RegisterResponse : Response<RegisterResponsePayload>
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class RegisterRequest : Request<RegisterRequestPayload>
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class RegisterRequestPayload
|
||||||
|
{
|
||||||
|
public string UnitId { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class SaveImageRequest : Request<SaveImageRequestPayload>
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class SaveImageResponse : Response<SaveImageResponsePayload>
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AquaCubeIT.Service.NetFloppy.Models
|
||||||
|
{
|
||||||
|
public class SaveImageResponsePayload
|
||||||
|
{
|
||||||
|
public int BytesSaved { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
@ -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"
|
||||||
|
}
|
||||||
Binary file not shown.
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"version": "3.0",
|
||||||
|
"defaultProvider": "cdnjs",
|
||||||
|
"libraries": []
|
||||||
|
}
|
||||||
BIN
AquaCubeIT.Service.NetFloppy/TEst/TEst.dbmdl
Normal file
BIN
AquaCubeIT.Service.NetFloppy/TEst/TEst.dbmdl
Normal file
Binary file not shown.
63
AquaCubeIT.Service.NetFloppy/TEst/TEst.sqlproj
Normal file
63
AquaCubeIT.Service.NetFloppy/TEst/TEst.sqlproj
Normal 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>
|
||||||
3
AquaCubeIT.Service.NetFloppy/TEst/TEst.sqlproj.user
Normal file
3
AquaCubeIT.Service.NetFloppy/TEst/TEst.sqlproj.user
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
</Project>
|
||||||
20
AquaCubeIT.Service.NetFloppy/TEst/Table1.sql
Normal file
20
AquaCubeIT.Service.NetFloppy/TEst/Table1.sql
Normal 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
335
Old/Fat16Disk.cs
Normal 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");
|
||||||
Loading…
Reference in New Issue
Block a user