diff --git a/README.md b/README.md index 65565b7..1bb7028 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ manifest.json: "blockSize": 2, "prefix": "img", "seed": 72411, - "restoreFileName": false + "preserveName": false }, "images": [ { diff --git a/packages/cli/README.md b/packages/cli/README.md index b7485a2..289b6ea 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -49,7 +49,7 @@ image-shield shuffle -o [options] | `-b, --block-size ` | Pixel block size (positive integer) | ❌ | 10 | | `-p, --prefix ` | Prefix for fragment files | ❌ | "fragment" | | `-s, --seed ` | Random seed (integer) | ❌ | auto-generated | -| `--restore-filename` | Restore original file names when restoring | ❌ | false | +| `--preserve-name` | Preserve original file names | ❌ | false | #### Examples @@ -60,7 +60,7 @@ image-shield shuffle image1.jpg image2.png -o ./fragments **Custom configuration:** ```bash -image-shield shuffle *.jpg -o ./output -b 20 -p "my_fragment" --restore-filename +image-shield shuffle *.jpg -o ./output -b 20 -p "my_fragment" --preserve-name ``` **With seed for reproducible results:** @@ -127,7 +127,7 @@ The CLI provides clear error messages for common issues: 2. **Fragment images:** ```bash - image-shield shuffle images/photo1.jpg images/photo2.png -o ./backup --restore-filename + image-shield shuffle images/photo1.jpg images/photo2.png -o ./backup --preserve-name ``` ``` 🔀 Starting image fragmentation... @@ -166,7 +166,7 @@ image-shield shuffle sensitive/*.jpg \ -b 5 \ -p "secure_chunk" \ -s 42 \ - --restore-filename + --preserve-name # The output will use smaller blocks (5x5 pixels) and custom naming ``` @@ -186,7 +186,7 @@ BACKUP_DIR="./backup" echo "Creating backup..." image-shield shuffle "$IMAGES_DIR"/*.{jpg,png} \ -o "$BACKUP_DIR" \ - --restore-filename + --preserve-name if [ $? -eq 0 ]; then echo "✅ Backup completed successfully" diff --git a/packages/cli/src/commands/shuffle.ts b/packages/cli/src/commands/shuffle.ts index 05eff7c..1640fa6 100644 --- a/packages/cli/src/commands/shuffle.ts +++ b/packages/cli/src/commands/shuffle.ts @@ -29,7 +29,7 @@ export function registerShuffleCommand(program: Command): void { } return num; }) - .option("--restore-filename", "Restore original file names") + .option("--preserve-name", "Preserve original file names") .action(handleShuffleCommand); } @@ -52,7 +52,7 @@ async function handleShuffleCommand( if (options.blockSize !== undefined) config.blockSize = options.blockSize; if (options.prefix !== undefined) config.prefix = options.prefix; if (options.seed !== undefined) config.seed = options.seed; - if (options.restoreFilename) config.restoreFileName = true; + if (options.preserveName) config.preserveName = true; await ImageShield.shuffle({ imagePaths, diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 132fb1c..60d2c47 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -3,7 +3,7 @@ export interface ShuffleOptions { blockSize?: number; prefix?: string; seed?: number; - restoreFilename?: boolean; + preserveName?: boolean; } export interface RestoreOptions { diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index e3f0156..a8ce713 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -4,7 +4,6 @@ "noEmit": true, "module": "ESNext", "moduleResolution": "bundler", - "rootDir": "./src", "baseUrl": ".", "paths": { "@image-shield/core": ["../core/src"], diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 395089c..b1d717d 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -3,5 +3,5 @@ export const MANIFEST_FILE_NAME = "manifest.json"; export const DEFAULT_FRAGMENTATION_CONFIG = { BLOCK_SIZE: 2, PREFIX: "img", - RESTORE_FILE_NAME: false, + PRESERVE_NAME: false, }; diff --git a/packages/core/src/helpers.test.ts b/packages/core/src/helpers.test.ts index e2d08dc..25a7767 100644 --- a/packages/core/src/helpers.test.ts +++ b/packages/core/src/helpers.test.ts @@ -1,4 +1,6 @@ import { + decodeFileName, + encodeFileName, generateFileName, generateFragmentFileName, generateRestoredFileName, @@ -95,9 +97,43 @@ describe("generateRestoredFileName", () => { }); }); +describe("encodeFileName / decodeFileName", () => { + test("should encode and decode file names correctly", () => { + const originalName = "test-image.png"; + const encoded = encodeFileName(originalName); + expect(encoded).toBeTruthy(); + expect(encoded).not.toBe(originalName); + expect(decodeFileName(encoded)).toBe(originalName); + }); + + test("should handle special characters", () => { + const specialNames = [ + "画像_テスト.png", + "test-image-日本語.png", + "file with spaces.png", + "file@#$%^&.png", + ]; + for (const name of specialNames) { + const encoded = encodeFileName(name); + expect(decodeFileName(encoded)).toBe(name); + } + }); +}); + describe("generateRestoredOriginalFileName", () => { - test("should generate original file name when name exists", () => { - const imageInfo = { name: "original" } as ManifestData["images"][number]; + test("should generate original file name when name exists (base64 encoded)", () => { + const originalName = "original"; + const encodedName = encodeFileName(originalName); + const imageInfo = { + name: encodedName, + } as ManifestData["images"][number]; + expect(generateRestoredOriginalFileName(imageInfo)).toBe("original.png"); + }); + + test("should handle backward compatibility (non-encoded names)", () => { + const imageInfo = { + name: "b3JpZ2luYWw=", + } as ManifestData["images"][number]; expect(generateRestoredOriginalFileName(imageInfo)).toBe("original.png"); }); diff --git a/packages/core/src/helpers.ts b/packages/core/src/helpers.ts index 15e4fec..1389630 100644 --- a/packages/core/src/helpers.ts +++ b/packages/core/src/helpers.ts @@ -1,5 +1,39 @@ import type { ManifestData } from "./types"; +/** + * Encode file name to base64 for safe storage (cross-platform) + * @param name - Original file name + * @returns Base64 encoded file name + */ +export function encodeFileName(name: string): string { + // Use TextEncoder for UTF-8 encoding (available in both browser and Node.js) + const encoder = new TextEncoder(); + const bytes = encoder.encode(name); + // Convert bytes to binary string for btoa (cross-platform) + // Use loop to avoid stack overflow with large arrays + let binaryString = ""; + for (let i = 0; i < bytes.length; i++) { + const byte = bytes[i]; + binaryString += String.fromCharCode(byte); + } + return btoa(binaryString); +} + +/** + * Decode file name from base64 (cross-platform) + * @param encodedName - Base64 encoded file name + * @returns Decoded original file name + */ +export function decodeFileName(encodedName: string): string { + // Use atob for base64 decoding (cross-platform) + const binaryString = atob(encodedName); + // Convert binary string to bytes + const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0)); + // Use TextDecoder for UTF-8 decoding (available in both browser and Node.js) + const decoder = new TextDecoder(); + return decoder.decode(bytes); +} + /** * Generate a file name with prefix, 1-based zero-padded index, and extension * @param manifest - Manifest data @@ -65,5 +99,14 @@ export function generateRestoredFileName( export function generateRestoredOriginalFileName( imageInfo: ManifestData["images"][number], ): string | undefined { - return imageInfo.name ? `${imageInfo.name}.png` : undefined; + if (!imageInfo.name) { + return undefined; + } + try { + const decodedName = decodeFileName(imageInfo.name); + return decodedName ? `${decodedName}.png` : undefined; + } catch { + // Fallback: if decoding fails, treat as already decoded (backward compatibility) + return `${imageInfo.name}.png`; + } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 27424df..de8de86 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -23,8 +23,8 @@ export interface FragmentationConfig { prefix?: string; /** Random seed (auto-generated if not specified) */ seed?: number | string; - /** Restore original file name (optional, default: false) */ - restoreFileName?: boolean; + /** Preserve original file name (optional, default: false) */ + preserveName?: boolean; } /** @@ -42,7 +42,7 @@ export interface ImageInfo { x: number; /** Number of blocks Y */ y: number; - /** Original file name (optional) */ + /** Original file name in base64 encoding (optional) */ name?: string; } diff --git a/packages/node/src/fragmenter.test.ts b/packages/node/src/fragmenter.test.ts index cc78d7b..190cacc 100644 --- a/packages/node/src/fragmenter.test.ts +++ b/packages/node/src/fragmenter.test.ts @@ -115,7 +115,7 @@ describe("ImageFragmenter", () => { blockSize: 8, prefix: "test", seed: "test-seed", - restoreFileName: true, + preserveName: true, }; const fragmenter = new ImageFragmenter(config); expect(fragmenter).toBeInstanceOf(ImageFragmenter); @@ -159,7 +159,7 @@ describe("ImageFragmenter", () => { blockSize: 2, prefix: "testprefix", seed: "test-seed-123", - restoreFileName: true, + preserveName: true, }; const fragmenter = new ImageFragmenter(config); @@ -171,7 +171,7 @@ describe("ImageFragmenter", () => { expect(result.manifest.config.blockSize).toBe(2); expect(result.manifest.config.prefix).toBe("testprefix"); expect(result.manifest.config.seed).toBe("test-seed-123"); - expect(result.manifest.config.restoreFileName).toBe(true); + expect(result.manifest.config.preserveName).toBe(true); }); test("creates valid fragment images", async () => { @@ -209,7 +209,7 @@ describe("ImageFragmenter", () => { ); }); - test("throws error on duplicate file names when restoreFileName is enabled", async () => { + test("throws error on duplicate file names when preserveName is enabled", async () => { // Create two files with the same base name in different directories const tmpDir2 = path.join(tmpdir(), "fragmenter_test_tmp2"); if (!fs.existsSync(tmpDir2)) fs.mkdirSync(tmpDir2); @@ -218,7 +218,7 @@ describe("ImageFragmenter", () => { await fs.promises.copyFile(testImagePath, duplicatePath); const fragmenter = new ImageFragmenter({ - restoreFileName: true, + preserveName: true, }); try { @@ -243,8 +243,8 @@ describe("ImageFragmenter", () => { expect(result.manifest.config.prefix).toBe( DEFAULT_FRAGMENTATION_CONFIG.PREFIX, ); - expect(result.manifest.config.restoreFileName).toBe( - DEFAULT_FRAGMENTATION_CONFIG.RESTORE_FILE_NAME, + expect(result.manifest.config.preserveName).toBe( + DEFAULT_FRAGMENTATION_CONFIG.PRESERVE_NAME, ); expect(result.manifest.config.seed).toBeDefined(); // Should be auto-generated }); diff --git a/packages/node/src/fragmenter.ts b/packages/node/src/fragmenter.ts index f6f4701..8e265a4 100644 --- a/packages/node/src/fragmenter.ts +++ b/packages/node/src/fragmenter.ts @@ -5,6 +5,8 @@ import { type ImageInfo, type ManifestData, calcBlocksPerFragment, + decodeFileName, + encodeFileName, } from "@image-shield/core"; import { SeededRandom, shuffle } from "@tuki0918/seeded-shuffle"; import { blocksToPngImage, imageFileToBlocks } from "./block"; @@ -26,9 +28,8 @@ export class ImageFragmenter { blockSize: config.blockSize ?? DEFAULT_FRAGMENTATION_CONFIG.BLOCK_SIZE, prefix: config.prefix ?? DEFAULT_FRAGMENTATION_CONFIG.PREFIX, seed: config.seed || SeededRandom.generateSeed(), - restoreFileName: - config.restoreFileName ?? - DEFAULT_FRAGMENTATION_CONFIG.RESTORE_FILE_NAME, + preserveName: + config.preserveName ?? DEFAULT_FRAGMENTATION_CONFIG.PRESERVE_NAME, }; } @@ -98,7 +99,7 @@ export class ImageFragmenter { } private _validateFileNames(imageInfos: ImageInfo[]): void { - if (!this.config.restoreFileName || imageInfos.length <= 1) { + if (!this.config.preserveName || imageInfos.length <= 1) { return; } @@ -106,10 +107,18 @@ export class ImageFragmenter { for (const info of imageInfos) { if (info.name !== undefined) { - if (nameSet.has(info.name)) { - throw new Error(`Duplicate file name detected: ${info.name}`); + // Decode base64 to get original name for comparison + let decodedName: string; + try { + decodedName = decodeFileName(info.name); + } catch { + // If decoding fails, treat as already decoded (backward compatibility) + decodedName = info.name; } - nameSet.add(info.name); + if (nameSet.has(decodedName)) { + throw new Error(`Duplicate file name detected: ${decodedName}`); + } + nameSet.add(decodedName); } } } @@ -163,8 +172,8 @@ export class ImageFragmenter { c: 4, // Always use 4 channels (RGBA) for generated PNG x: blockCountX, y: blockCountY, - name: this.config.restoreFileName - ? fileNameWithoutExtension(imagePath) + name: this.config.preserveName + ? encodeFileName(fileNameWithoutExtension(imagePath)) : undefined, }; diff --git a/packages/node/src/index.test.ts b/packages/node/src/index.test.ts index febe4bc..2e33172 100644 --- a/packages/node/src/index.test.ts +++ b/packages/node/src/index.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import type { ManifestData } from "@image-shield/core"; import { + decodeFileName, generateFragmentFileName, generateRestoredFileName, } from "@image-shield/core"; @@ -132,8 +133,8 @@ describe("ImageShield (integration)", () => { }); }); -describe("ImageShield (restoreFileName integration)", () => { - const tmpDir = path.join(tmpdir(), "index_test_tmp_restoreFileName"); +describe("ImageShield (preserveName integration)", () => { + const tmpDir = path.join(tmpdir(), "index_test_tmp_preserveName"); const originalImages = [ Buffer.from([ // 2x2 RGBA image 1 @@ -168,10 +169,10 @@ describe("ImageShield (restoreFileName integration)", () => { await image.write(filePath, JimpMime.png); imagePaths.push(filePath); } - // Fragment images using ImageShield.shuffle (with restoreFileName) + // Fragment images using ImageShield.shuffle (with preserveName) await ImageShield.shuffle({ imagePaths, - config: { blockSize, prefix, restoreFileName: true }, + config: { blockSize, prefix, preserveName: true }, outputDir: tmpDir, }); // Find manifest and fragment files @@ -214,13 +215,19 @@ describe("ImageShield (restoreFileName integration)", () => { if (fs.existsSync(tmpDir)) fs.rmdirSync(tmpDir); }); - test("manifest images[].name contains original file name when restoreFileName=true", () => { + test("manifest images[].name contains original file name when preserveName=true", () => { expect(manifest).toBeDefined(); - expect(manifest?.config.restoreFileName).toBe(true); + expect(manifest?.config.preserveName).toBe(true); expect(Array.isArray(manifest?.images)).toBe(true); for (let i = 0; i < imagePaths.length; i++) { const expectedName = path.parse(imagePaths[i]).name; - expect(manifest?.images[i].name).toBe(expectedName); + const encodedName = manifest?.images[i]?.name; + expect(encodedName).toBeTruthy(); + // Decode base64 encoded name + if (encodedName) { + const decodedName = decodeFileName(encodedName); + expect(decodedName).toBe(expectedName); + } } }); diff --git a/packages/node/src/restorer.test.ts b/packages/node/src/restorer.test.ts index 6165037..68c3576 100644 --- a/packages/node/src/restorer.test.ts +++ b/packages/node/src/restorer.test.ts @@ -343,11 +343,11 @@ describe("ImageRestorer", () => { }); describe("integration with different configurations", () => { - test("works with restoreFileName enabled", async () => { + test("works with preserveName enabled", async () => { const fragmenter = new ImageFragmenter({ blockSize: 2, seed: "test-seed", - restoreFileName: true, + preserveName: true, }); const { manifest, fragmentedImages } = await fragmenter.fragmentImages([ testImagePath, @@ -360,7 +360,7 @@ describe("ImageRestorer", () => { ); expect(restoredImages).toHaveLength(1); - expect(manifest.images[0].name).toBe("test_image"); + expect(manifest.images[0].name).toBe("dGVzdF9pbWFnZQ=="); }); test("works with custom prefix", async () => {