cffs-image-tool 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,161 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { basename } from 'node:path';
3
+ import { SECTOR_SIZE, FLAG_USED, DATA_START } from './constants.js';
4
+ import { parseName, formatName } from './filename.js';
5
+ import { writeSector } from './image.js';
6
+ import { readDirectory, writeDirectory, findFile, findFreeSlot, calcNextSector, calcSectorCount, } from './directory.js';
7
+ /**
8
+ * Add a host file to the image. Optionally rename with targetName (8.3 format).
9
+ */
10
+ export function addFile(buf, hostPath, targetName) {
11
+ const data = readFileSync(hostPath);
12
+ if (data.length > 0xFFFF) {
13
+ throw new Error(`File too large: ${data.length} bytes (max 65535)`);
14
+ }
15
+ const nameInput = targetName ?? basename(hostPath);
16
+ const { name, ext } = parseName(nameInput);
17
+ const entries = readDirectory(buf);
18
+ // If file with same name exists, clear old entry (overwrite behavior matches BIOS FsSaveFile)
19
+ const existing = findFile(entries, name, ext);
20
+ if (existing) {
21
+ existing.flags = 0;
22
+ }
23
+ const slotIndex = findFreeSlot(entries);
24
+ if (slotIndex === -1) {
25
+ throw new Error('Directory is full (16 entries maximum)');
26
+ }
27
+ // Recalculate next sector after potentially clearing old entry
28
+ const startSector = calcNextSector(entries);
29
+ // Check that the file data fits in the image
30
+ const sectorCount = calcSectorCount(data.length);
31
+ const endByte = (startSector + sectorCount) * SECTOR_SIZE;
32
+ if (endByte > buf.length) {
33
+ throw new Error(`Not enough space in image: need sector ${startSector + sectorCount - 1}, image has ${buf.length / SECTOR_SIZE} sectors`);
34
+ }
35
+ // Write file data to sectors
36
+ for (let i = 0; i < sectorCount; i++) {
37
+ const chunk = Buffer.alloc(SECTOR_SIZE);
38
+ const srcOffset = i * SECTOR_SIZE;
39
+ const remaining = Math.min(SECTOR_SIZE, data.length - srcOffset);
40
+ if (remaining > 0) {
41
+ data.copy(chunk, 0, srcOffset, srcOffset + remaining);
42
+ }
43
+ writeSector(buf, startSector + i, chunk);
44
+ }
45
+ // Update directory entry
46
+ entries[slotIndex] = {
47
+ name,
48
+ ext,
49
+ flags: FLAG_USED,
50
+ startSector,
51
+ fileSize: data.length,
52
+ index: slotIndex,
53
+ };
54
+ writeDirectory(buf, entries);
55
+ }
56
+ /**
57
+ * Remove a file by name (clears flags only, no compaction — matches BIOS FsDeleteFile).
58
+ */
59
+ export function removeFile(buf, nameInput) {
60
+ const { name, ext } = parseName(nameInput);
61
+ const entries = readDirectory(buf);
62
+ const entry = findFile(entries, name, ext);
63
+ if (!entry) {
64
+ throw new Error(`File not found: ${nameInput}`);
65
+ }
66
+ entry.flags = 0;
67
+ writeDirectory(buf, entries);
68
+ }
69
+ /**
70
+ * List all in-use directory entries.
71
+ */
72
+ export function listFiles(buf) {
73
+ return readDirectory(buf).filter((e) => (e.flags & FLAG_USED) !== 0);
74
+ }
75
+ /**
76
+ * Extract a file from the image to the host filesystem.
77
+ */
78
+ export function extractFile(buf, nameInput, outputPath) {
79
+ const { name, ext } = parseName(nameInput);
80
+ const entries = readDirectory(buf);
81
+ const entry = findFile(entries, name, ext);
82
+ if (!entry) {
83
+ throw new Error(`File not found: ${nameInput}`);
84
+ }
85
+ const sectorCount = calcSectorCount(entry.fileSize);
86
+ const output = Buffer.alloc(entry.fileSize);
87
+ for (let i = 0; i < sectorCount; i++) {
88
+ const offset = (entry.startSector + i) * SECTOR_SIZE;
89
+ const srcSlice = buf.subarray(offset, offset + SECTOR_SIZE);
90
+ const dstOffset = i * SECTOR_SIZE;
91
+ const remaining = Math.min(SECTOR_SIZE, entry.fileSize - dstOffset);
92
+ srcSlice.copy(output, dstOffset, 0, remaining);
93
+ }
94
+ const outPath = outputPath ?? formatName(entry);
95
+ writeFileSync(outPath, output);
96
+ }
97
+ /**
98
+ * Defragment the image: sort used entries by startSector, rewrite contiguously from LBA 1.
99
+ */
100
+ export function defragment(buf) {
101
+ const entries = readDirectory(buf);
102
+ const usedEntries = entries
103
+ .filter((e) => (e.flags & FLAG_USED) !== 0)
104
+ .sort((a, b) => a.startSector - b.startSector);
105
+ let currentSector = DATA_START;
106
+ for (const entry of usedEntries) {
107
+ const sectorCount = calcSectorCount(entry.fileSize);
108
+ // Only move if not already in the right spot
109
+ if (entry.startSector !== currentSector) {
110
+ // Read file data from current location
111
+ const fileData = Buffer.alloc(sectorCount * SECTOR_SIZE);
112
+ for (let i = 0; i < sectorCount; i++) {
113
+ const srcOffset = (entry.startSector + i) * SECTOR_SIZE;
114
+ buf.copy(fileData, i * SECTOR_SIZE, srcOffset, srcOffset + SECTOR_SIZE);
115
+ }
116
+ // Write to new location
117
+ for (let i = 0; i < sectorCount; i++) {
118
+ const chunk = fileData.subarray(i * SECTOR_SIZE, (i + 1) * SECTOR_SIZE);
119
+ writeSector(buf, currentSector + i, chunk);
120
+ }
121
+ entry.startSector = currentSector;
122
+ }
123
+ currentSector += sectorCount;
124
+ }
125
+ // Zero out any sectors after the last used file (reclaim space)
126
+ const totalSectors = buf.length / SECTOR_SIZE;
127
+ for (let s = currentSector; s < totalSectors; s++) {
128
+ buf.fill(0, s * SECTOR_SIZE, (s + 1) * SECTOR_SIZE);
129
+ }
130
+ // Write updated directory
131
+ writeDirectory(buf, entries);
132
+ }
133
+ /**
134
+ * Clear all directory entries (zero the directory sector).
135
+ */
136
+ export function clearImage(buf) {
137
+ const sector = Buffer.alloc(SECTOR_SIZE);
138
+ writeSector(buf, 0, sector);
139
+ }
140
+ /**
141
+ * Return image stats.
142
+ */
143
+ export function imageInfo(buf) {
144
+ const entries = readDirectory(buf);
145
+ const usedEntries = entries.filter((e) => (e.flags & FLAG_USED) !== 0);
146
+ const nextFreeSector = calcNextSector(entries);
147
+ const totalSectors = buf.length / SECTOR_SIZE;
148
+ let usedDataSectors = 0;
149
+ for (const e of usedEntries) {
150
+ usedDataSectors += calcSectorCount(e.fileSize);
151
+ }
152
+ return {
153
+ totalSectors,
154
+ usedEntries: usedEntries.length,
155
+ freeEntries: entries.length - usedEntries.length,
156
+ nextFreeSector,
157
+ usedDataSectors,
158
+ freeDataSectors: totalSectors - 1 - usedDataSectors, // -1 for directory sector
159
+ };
160
+ }
161
+ //# sourceMappingURL=operations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"operations.js","sourceRoot":"","sources":["../../src/lib/operations.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ export interface DirEntry {
2
+ name: string;
3
+ ext: string;
4
+ flags: number;
5
+ startSector: number;
6
+ fileSize: number;
7
+ index: number;
8
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/lib/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "cffs-image-tool",
3
+ "version": "1.0.0",
4
+ "description": "CompactFlash Filesystem Image Tool for A.C. Wright 6502 Project",
5
+ "type": "module",
6
+ "main": "dist/cli.js",
7
+ "bin": {
8
+ "cffs": "dist/cli.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "tsx src/cli.ts",
13
+ "start": "node dist/cli.js"
14
+ },
15
+ "keywords": ["6502", "compactflash", "filesystem"],
16
+ "author": "A.C. Wright",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/acwright/cffs.git"
21
+ },
22
+ "homepage": "https://github.com/acwright/cffs",
23
+ "dependencies": {
24
+ "commander": "^14.0.3"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^25.5.0",
28
+ "tsx": "^4.21.0",
29
+ "typescript": "^6.0.2"
30
+ }
31
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { stdin, stdout } from 'node:process';
6
+ import { openImage, saveImage, createImage } from './lib/image.js';
7
+ import { formatName } from './lib/filename.js';
8
+ import { calcSectorCount } from './lib/directory.js';
9
+ import {
10
+ addFile, removeFile, listFiles, extractFile,
11
+ defragment, clearImage, imageInfo,
12
+ } from './lib/operations.js';
13
+
14
+ const program = new Command();
15
+
16
+ program
17
+ .name('cffs')
18
+ .description('CompactFlash Filesystem Image Tool for A.C. Wright 6502 Project')
19
+ .version('1.0.0');
20
+
21
+ /**
22
+ * Parse a size string like "32M", "512K", "1G", or a plain number (bytes).
23
+ */
24
+ function parseSize(value: string): number {
25
+ const match = value.match(/^(\d+)\s*([KMGkmg])?[Bb]?$/);
26
+ if (!match) {
27
+ throw new Error(`Invalid size format: "${value}". Use e.g. 32M, 512K, or bytes.`);
28
+ }
29
+ const num = parseInt(match[1], 10);
30
+ const unit = (match[2] ?? '').toUpperCase();
31
+ switch (unit) {
32
+ case 'K': return num * 1024;
33
+ case 'M': return num * 1024 * 1024;
34
+ case 'G': return num * 1024 * 1024 * 1024;
35
+ default: return num;
36
+ }
37
+ }
38
+
39
+ // ── create ──────────────────────────────────────────────────────────────────
40
+ program
41
+ .command('create')
42
+ .description('Create a blank CompactFlash image')
43
+ .argument('<image>', 'Path to image file to create')
44
+ .option('-s, --size <size>', 'Image size (e.g. 32M, 512K)', '32M')
45
+ .action((image: string, opts: { size: string }) => {
46
+ const totalBytes = parseSize(opts.size);
47
+ if (totalBytes % 512 !== 0) {
48
+ console.error('Error: size must be a multiple of 512 bytes');
49
+ process.exit(1);
50
+ }
51
+ const totalSectors = totalBytes / 512;
52
+ const buf = createImage(totalSectors);
53
+ saveImage(image, buf);
54
+ console.log(`Created ${image} (${totalBytes.toLocaleString()} bytes, ${totalSectors.toLocaleString()} sectors)`);
55
+ });
56
+
57
+ // ── list ────────────────────────────────────────────────────────────────────
58
+ program
59
+ .command('list')
60
+ .description('List files in the image')
61
+ .argument('<image>', 'Path to image file')
62
+ .action((image: string) => {
63
+ const buf = openImage(image);
64
+ const files = listFiles(buf);
65
+
66
+ if (files.length === 0) {
67
+ console.log('No files in image.');
68
+ return;
69
+ }
70
+
71
+ console.log('Name Size Start Sectors');
72
+ console.log('──────────── ─────── ───── ───────');
73
+ for (const f of files) {
74
+ const display = formatName(f).padEnd(12);
75
+ const size = f.fileSize.toString().padStart(7);
76
+ const start = f.startSector.toString().padStart(5);
77
+ const sectors = calcSectorCount(f.fileSize).toString().padStart(7);
78
+ console.log(`${display} ${size} ${start} ${sectors}`);
79
+ }
80
+ });
81
+
82
+ // ── add ─────────────────────────────────────────────────────────────────────
83
+ program
84
+ .command('add')
85
+ .description('Add a host file to the image')
86
+ .argument('<image>', 'Path to image file')
87
+ .argument('<file>', 'Host file to add')
88
+ .option('-n, --name <name>', 'Target 8.3 filename (default: source filename)')
89
+ .action((image: string, file: string, opts: { name?: string }) => {
90
+ const buf = openImage(image);
91
+ addFile(buf, file, opts.name);
92
+ saveImage(image, buf);
93
+ console.log(`Added ${opts.name ?? file} to ${image}`);
94
+ });
95
+
96
+ // ── remove ──────────────────────────────────────────────────────────────────
97
+ program
98
+ .command('remove')
99
+ .description('Delete a file entry from the image')
100
+ .argument('<image>', 'Path to image file')
101
+ .argument('<name>', 'Filename to remove (8.3 format)')
102
+ .action((image: string, name: string) => {
103
+ const buf = openImage(image);
104
+ removeFile(buf, name);
105
+ saveImage(image, buf);
106
+ console.log(`Removed ${name} from ${image}`);
107
+ });
108
+
109
+ // ── extract ─────────────────────────────────────────────────────────────────
110
+ program
111
+ .command('extract')
112
+ .description('Extract a file from the image to the host filesystem')
113
+ .argument('<image>', 'Path to image file')
114
+ .argument('<name>', 'Filename to extract (8.3 format)')
115
+ .argument('[output]', 'Output path (default: original filename)')
116
+ .action((image: string, name: string, output?: string) => {
117
+ const buf = openImage(image);
118
+ extractFile(buf, name, output);
119
+ console.log(`Extracted ${name} from ${image}`);
120
+ });
121
+
122
+ // ── defrag ──────────────────────────────────────────────────────────────────
123
+ program
124
+ .command('defrag')
125
+ .description('Defragment the image (compact files)')
126
+ .argument('<image>', 'Path to image file')
127
+ .action((image: string) => {
128
+ const buf = openImage(image);
129
+ defragment(buf);
130
+ saveImage(image, buf);
131
+ console.log(`Defragmented ${image}`);
132
+ });
133
+
134
+ // ── clear ───────────────────────────────────────────────────────────────────
135
+ program
136
+ .command('clear')
137
+ .description('Clear all directory entries')
138
+ .argument('<image>', 'Path to image file')
139
+ .option('-y, --yes', 'Skip confirmation prompt')
140
+ .action(async (image: string, opts: { yes?: boolean }) => {
141
+ if (!opts.yes) {
142
+ const rl = createInterface({ input: stdin, output: stdout });
143
+ const answer = await rl.question(`Clear all entries in ${image}? [y/N] `);
144
+ rl.close();
145
+ if (answer.toLowerCase() !== 'y') {
146
+ console.log('Aborted.');
147
+ return;
148
+ }
149
+ }
150
+ const buf = openImage(image);
151
+ clearImage(buf);
152
+ saveImage(image, buf);
153
+ console.log(`Cleared all directory entries in ${image}`);
154
+ });
155
+
156
+ // ── info ────────────────────────────────────────────────────────────────────
157
+ program
158
+ .command('info')
159
+ .description('Display image statistics')
160
+ .argument('<image>', 'Path to image file')
161
+ .action((image: string) => {
162
+ const buf = openImage(image);
163
+ const info = imageInfo(buf);
164
+ console.log(`Image size: ${(info.totalSectors * 512).toLocaleString()} bytes (${info.totalSectors.toLocaleString()} sectors)`);
165
+ console.log(`Directory: ${info.usedEntries}/16 entries used, ${info.freeEntries} free`);
166
+ console.log(`Data sectors: ${info.usedDataSectors} used, ${info.freeDataSectors.toLocaleString()} free`);
167
+ console.log(`Next free sector: ${info.nextFreeSector}`);
168
+ });
169
+
170
+ program.parse();
@@ -0,0 +1,19 @@
1
+ export const SECTOR_SIZE = 512;
2
+ export const DIR_LBA = 0;
3
+ export const DATA_START = 1;
4
+ export const MAX_FILES = 16;
5
+ export const ENTRY_SIZE = 32;
6
+
7
+ // Field offsets within a directory entry
8
+ export const NAME_OFFSET = 0;
9
+ export const NAME_LENGTH = 8;
10
+ export const EXT_OFFSET = 8;
11
+ export const EXT_LENGTH = 3;
12
+ export const FLAGS_OFFSET = 11;
13
+ export const START_OFFSET = 12;
14
+ export const FSIZE_OFFSET = 14;
15
+ export const RESERVED_OFFSET = 16;
16
+ export const RESERVED_LENGTH = 16;
17
+
18
+ // Flags
19
+ export const FLAG_USED = 0x01;
@@ -0,0 +1,104 @@
1
+ import {
2
+ SECTOR_SIZE, DIR_LBA, MAX_FILES, ENTRY_SIZE,
3
+ NAME_OFFSET, NAME_LENGTH, EXT_OFFSET, EXT_LENGTH,
4
+ FLAGS_OFFSET, START_OFFSET, FSIZE_OFFSET, FLAG_USED,
5
+ DATA_START,
6
+ } from './constants.js';
7
+ import { DirEntry } from './types.js';
8
+ import { readSector, writeSector } from './image.js';
9
+
10
+ /**
11
+ * Parse all 16 directory entries from LBA 0.
12
+ */
13
+ export function readDirectory(buf: Buffer): DirEntry[] {
14
+ const sector = readSector(buf, DIR_LBA);
15
+ const entries: DirEntry[] = [];
16
+
17
+ for (let i = 0; i < MAX_FILES; i++) {
18
+ const off = i * ENTRY_SIZE;
19
+ const name = sector.subarray(off + NAME_OFFSET, off + NAME_OFFSET + NAME_LENGTH).toString('ascii');
20
+ const ext = sector.subarray(off + EXT_OFFSET, off + EXT_OFFSET + EXT_LENGTH).toString('ascii');
21
+ const flags = sector[off + FLAGS_OFFSET];
22
+ const startSector = sector.readUInt16LE(off + START_OFFSET);
23
+ const fileSize = sector.readUInt16LE(off + FSIZE_OFFSET);
24
+
25
+ entries.push({ name, ext, flags, startSector, fileSize, index: i });
26
+ }
27
+
28
+ return entries;
29
+ }
30
+
31
+ /**
32
+ * Serialize directory entries back to LBA 0.
33
+ */
34
+ export function writeDirectory(buf: Buffer, entries: DirEntry[]): void {
35
+ const sector = Buffer.alloc(SECTOR_SIZE);
36
+
37
+ for (const entry of entries) {
38
+ const off = entry.index * ENTRY_SIZE;
39
+ // Write name (8 bytes, space-padded)
40
+ sector.write(entry.name.padEnd(NAME_LENGTH, ' '), off + NAME_OFFSET, NAME_LENGTH, 'ascii');
41
+ // Write extension (3 bytes, space-padded)
42
+ sector.write(entry.ext.padEnd(EXT_LENGTH, ' '), off + EXT_OFFSET, EXT_LENGTH, 'ascii');
43
+ // Flags
44
+ sector[off + FLAGS_OFFSET] = entry.flags;
45
+ // Start sector (u16 LE)
46
+ sector.writeUInt16LE(entry.startSector, off + START_OFFSET);
47
+ // File size (u16 LE)
48
+ sector.writeUInt16LE(entry.fileSize, off + FSIZE_OFFSET);
49
+ // Reserved bytes stay zeroed
50
+ }
51
+
52
+ writeSector(buf, DIR_LBA, sector);
53
+ }
54
+
55
+ /**
56
+ * Find a used directory entry by name and extension (space-padded, uppercase).
57
+ */
58
+ export function findFile(entries: DirEntry[], name: string, ext: string): DirEntry | undefined {
59
+ return entries.find(
60
+ (e) => (e.flags & FLAG_USED) !== 0 && e.name === name && e.ext === ext
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Find the first free (unused) directory slot index, or -1 if full.
66
+ */
67
+ export function findFreeSlot(entries: DirEntry[]): number {
68
+ const entry = entries.find((e) => (e.flags & FLAG_USED) === 0);
69
+ return entry ? entry.index : -1;
70
+ }
71
+
72
+ /**
73
+ * Calculate the next free sector after all allocated files.
74
+ * Replicates BIOS FsCalcNextSec logic:
75
+ * sectors = highByte >> 1; if (highByte & 1 || lowByte != 0) sectors++
76
+ */
77
+ export function calcSectorCount(fileSize: number): number {
78
+ const lowByte = fileSize & 0xFF;
79
+ const highByte = (fileSize >> 8) & 0xFF;
80
+ let sectors = highByte >> 1;
81
+ if ((highByte & 1) !== 0 || lowByte !== 0) {
82
+ sectors++;
83
+ }
84
+ return sectors;
85
+ }
86
+
87
+ /**
88
+ * Calculate the next free sector for allocation.
89
+ * Returns max(startSector + sectorCount) across all used entries, minimum DATA_START.
90
+ */
91
+ export function calcNextSector(entries: DirEntry[]): number {
92
+ let next = DATA_START;
93
+
94
+ for (const e of entries) {
95
+ if ((e.flags & FLAG_USED) === 0) continue;
96
+ const sectorCount = calcSectorCount(e.fileSize);
97
+ const end = e.startSector + sectorCount;
98
+ if (end > next) {
99
+ next = end;
100
+ }
101
+ }
102
+
103
+ return next;
104
+ }
@@ -0,0 +1,55 @@
1
+ import { DirEntry } from './types.js';
2
+
3
+ /**
4
+ * Parse user input into 8.3 filename components.
5
+ * Splits on first dot, uppercases, space-pads to 8+3.
6
+ */
7
+ export function parseName(input: string): { name: string; ext: string } {
8
+ const dotIndex = input.indexOf('.');
9
+ let rawName: string;
10
+ let rawExt: string;
11
+
12
+ if (dotIndex === -1) {
13
+ rawName = input;
14
+ rawExt = '';
15
+ } else {
16
+ rawName = input.substring(0, dotIndex);
17
+ rawExt = input.substring(dotIndex + 1);
18
+ }
19
+
20
+ rawName = rawName.toUpperCase();
21
+ rawExt = rawExt.toUpperCase();
22
+
23
+ if (rawName.length === 0 || rawName.length > 8) {
24
+ throw new Error(`Invalid filename: name part must be 1-8 characters, got "${rawName}"`);
25
+ }
26
+ if (rawExt.length > 3) {
27
+ throw new Error(`Invalid filename: extension must be 0-3 characters, got "${rawExt}"`);
28
+ }
29
+
30
+ // Validate ASCII printable (no spaces or special control chars in input)
31
+ const validChars = /^[A-Z0-9!#$%&'()\-@^_`{}~]+$/;
32
+ if (!validChars.test(rawName)) {
33
+ throw new Error(`Invalid filename: name contains invalid characters "${rawName}"`);
34
+ }
35
+ if (rawExt.length > 0 && !validChars.test(rawExt)) {
36
+ throw new Error(`Invalid filename: extension contains invalid characters "${rawExt}"`);
37
+ }
38
+
39
+ const name = rawName.padEnd(8, ' ');
40
+ const ext = rawExt.padEnd(3, ' ');
41
+
42
+ return { name, ext };
43
+ }
44
+
45
+ /**
46
+ * Format a directory entry's name for display as NAME.EXT (trimmed).
47
+ */
48
+ export function formatName(entry: DirEntry): string {
49
+ const name = entry.name.trimEnd();
50
+ const ext = entry.ext.trimEnd();
51
+ if (ext.length === 0) {
52
+ return name;
53
+ }
54
+ return `${name}.${ext}`;
55
+ }
@@ -0,0 +1,50 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { SECTOR_SIZE } from './constants.js';
3
+
4
+ /**
5
+ * Read an entire image file into a Buffer.
6
+ */
7
+ export function openImage(path: string): Buffer {
8
+ return readFileSync(path);
9
+ }
10
+
11
+ /**
12
+ * Write a Buffer to disk as an image file.
13
+ */
14
+ export function saveImage(path: string, buf: Buffer): void {
15
+ writeFileSync(path, buf);
16
+ }
17
+
18
+ /**
19
+ * Create a new zeroed image buffer with the given number of sectors.
20
+ */
21
+ export function createImage(totalSectors: number): Buffer {
22
+ return Buffer.alloc(totalSectors * SECTOR_SIZE);
23
+ }
24
+
25
+ /**
26
+ * Read a single sector (512 bytes) from the image buffer at the given LBA.
27
+ */
28
+ export function readSector(buf: Buffer, lba: number): Buffer {
29
+ const offset = lba * SECTOR_SIZE;
30
+ if (offset + SECTOR_SIZE > buf.length) {
31
+ throw new Error(`Sector ${lba} is out of bounds (image size: ${buf.length} bytes)`);
32
+ }
33
+ return Buffer.from(buf.subarray(offset, offset + SECTOR_SIZE));
34
+ }
35
+
36
+ /**
37
+ * Write 512 bytes of data to the image buffer at the given LBA.
38
+ */
39
+ export function writeSector(buf: Buffer, lba: number, data: Buffer): void {
40
+ const offset = lba * SECTOR_SIZE;
41
+ if (offset + SECTOR_SIZE > buf.length) {
42
+ throw new Error(`Sector ${lba} is out of bounds (image size: ${buf.length} bytes)`);
43
+ }
44
+ if (data.length > SECTOR_SIZE) {
45
+ throw new Error(`Data exceeds sector size (${data.length} > ${SECTOR_SIZE})`);
46
+ }
47
+ // Zero the sector first, then copy data (handles data shorter than 512)
48
+ buf.fill(0, offset, offset + SECTOR_SIZE);
49
+ data.copy(buf, offset);
50
+ }