diff --git a/README.md b/README.md index f40cacc..f96a5b5 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,12 @@ msxforge raw [out.bin] cluster map (named/RAW/free) + extract RAW msxforge carve split the RAW region into BLOAD/BASIC/gap blocks msxforge rawput [outimage] write RAW data back into the image msxforge format [360|720] create a blank formatted disk -msxforge add [/DIR] [NAME] add a new file +msxforge pack [360|720] [--label L] build a disk from a folder tree +msxforge unpack extract the whole tree to a folder +msxforge undelete <[DIR/]?NAME> [LETTER] [outimage] recover a deleted file +msxforge attrib [+R -R +H -H +S -S +A -A] show / set attributes +msxforge label [LABEL|--clear] show / set the volume label +msxforge add [/DIR] [NAME] add a new file (long names -> MYPROG~1.BAS) msxforge rename msxforge mkdir [/PARENT] msxforge crc [FILE] CRC32 of a file or the whole image @@ -143,7 +148,8 @@ Drag a `.dsk` / `.dmk` / `.rom` / `.cas` onto the window, use **File > Open** - **Edit** a file three ways: as a **BASIC listing** (re-tokenized on save), as **text**, or as **hex** (click a byte or use the offset/value poke box; changed bytes highlight yellow). -- **Import & replace**, **Export to file**, or **Delete** a file. +- **Import & replace**, **Export to file**, or **Delete** a file; **Undelete** a + deleted one (type its lost first letter); **Add file** shortens long names to 8.3. - **Save image** / **Save image as** (Ctrl+S / Ctrl+Shift+S) to persist. - Inspect the physical sector table of protected images. - Built-in **Manual** (Help > Manual / F1) and `(?)` tooltips throughout. diff --git a/cli/hostfs.h b/cli/hostfs.h new file mode 100644 index 0000000..58e5f06 --- /dev/null +++ b/cli/hostfs.h @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Alonso (GS·RUN). MSXFileForge. +// Free software under the GNU GPL v3 (or later) - see LICENSE. Modified +// versions must be distributed under the same license. +// MSXFileForge CLI - tiny host filesystem helpers (directory listing, mkdir). +// Kept off std::filesystem so the macOS build can still target 10.13. +#pragma once +#include +#include +#include +#if defined(_WIN32) +#include +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#include +#include +#endif + +namespace hostfs { + +struct Entry { std::string name; bool isDir; }; + +// Entries of `dir` (no "." / ".."), sorted by name. Empty on error. +inline std::vector list(const std::string& dir) { + std::vector out; +#if defined(_WIN32) + WIN32_FIND_DATAA fd; + HANDLE h = FindFirstFileA((dir + "\\*").c_str(), &fd); + if (h == INVALID_HANDLE_VALUE) return out; + do { + std::string n = fd.cFileName; + if (n == "." || n == "..") continue; + out.push_back({n, (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0}); + } while (FindNextFileA(h, &fd)); + FindClose(h); +#else + DIR* d = opendir(dir.c_str()); + if (!d) return out; + while (dirent* de = readdir(d)) { + std::string n = de->d_name; + if (n == "." || n == "..") continue; + struct stat st; + bool isDir = stat((dir + "/" + n).c_str(), &st) == 0 && S_ISDIR(st.st_mode); + out.push_back({n, isDir}); + } + closedir(d); +#endif + std::sort(out.begin(), out.end(), [](const Entry& a, const Entry& b) { return a.name < b.name; }); + return out; +} + +inline bool isDir(const std::string& path) { +#if defined(_WIN32) + DWORD a = GetFileAttributesA(path.c_str()); + return a != INVALID_FILE_ATTRIBUTES && (a & FILE_ATTRIBUTE_DIRECTORY); +#else + struct stat st; + return stat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode); +#endif +} + +// Create one directory level; true if it exists afterwards. +inline bool makeDir(const std::string& path) { +#if defined(_WIN32) + _mkdir(path.c_str()); +#else + mkdir(path.c_str(), 0777); +#endif + return isDir(path); +} + +inline std::string join(const std::string& a, const std::string& b) { + if (a.empty()) return b; + char last = a.back(); + return (last == '/' || last == '\\') ? a + b : a + "/" + b; +} + +} // namespace hostfs diff --git a/cli/main.cpp b/cli/main.cpp index 390874a..e249b87 100644 --- a/cli/main.cpp +++ b/cli/main.cpp @@ -13,6 +13,7 @@ #include "../core/cas.h" #include "../core/util.h" #include "../core/tape.h" +#include "hostfs.h" #include #include #include @@ -286,7 +287,13 @@ static int cmd_add(int argc, char** argv) { if (argc < 4) { fprintf(stderr, "need [/DIR] [NAME]\n"); return 1; } DiskImage dk; Fat12 fs; int rc = mountDisk(argv[2], dk, fs); if (rc) return rc; std::string dir = (argc >= 5) ? argv[4] : "/"; - std::string name = (argc >= 6) ? argv[5] : baseName(argv[3]); + std::string name; + if (argc >= 6) name = argv[5]; + else { // derive a valid 8.3 name from the host file name + std::vector taken; + for (auto& x : fs.list(dir, false)) taken.push_back(x.name); + name = Fat12::suggestName83(baseName(argv[3]), taken); + } auto data = slurp(argv[3]); std::string err; if (!fs.createFile(dir, name, data, &err)) { fprintf(stderr, "add: %s\n", err.c_str()); return 6; } @@ -317,6 +324,160 @@ static int cmd_mkdir(int argc, char** argv) { printf("created dir %s in %s\n", argv[3], parent.c_str()); return 0; } +static std::string upper(std::string s) { + for (auto& c : s) c = (char)toupper((unsigned char)c); + return s; +} + +static int cmd_undelete(int argc, char** argv) { + // undelete <[DIR/]NAME> [LETTER] [outimage] + if (argc < 4) { fprintf(stderr, "need <[DIR/]NAME> [LETTER] [outimage]\n"); return 1; } + DiskImage dk; Fat12 fs; int rc = mountDisk(argv[2], dk, fs); if (rc) return rc; + std::string path = argv[3]; + size_t cut = path.find_last_of("/\\"); + std::string dir = cut == std::string::npos ? "/" : path.substr(0, cut); + if (dir.empty()) dir = "/"; + std::string leaf = upper(cut == std::string::npos ? path : path.substr(cut + 1)); + if (leaf.size() < 2) { fprintf(stderr, "name too short\n"); return 1; } + int argi = 4; + char letter = leaf[0]; + if (letter == '?') { + if (argc <= argi || strlen(argv[argi]) != 1) { + fprintf(stderr, "the first letter is lost: give it, e.g. `undelete img ?AME.BIN G`\n"); return 1; + } + letter = argv[argi++][0]; + } else if (argc > argi && strlen(argv[argi]) == 1) { + letter = argv[argi++][0]; + } + const char* out = argc > argi ? argv[argi] : argv[2]; + std::vector hits; + for (auto& e : fs.list(dir, true)) + if (e.deleted && upper(e.name).substr(1) == leaf.substr(1)) hits.push_back(e); + if (hits.empty()) { fprintf(stderr, "no deleted entry matches %s in %s\n", leaf.c_str(), dir.c_str()); return 4; } + std::string err; + for (auto& e : hits) { // several deletions of the same name: newest-recoverable first + if (fs.undelete(e, letter, &err)) { + if (!dk.save(out, &err)) { fprintf(stderr, "save: %s\n", err.c_str()); return 7; } + printf("recovered %s (%u bytes) -> %s\n", e.name.c_str(), e.size, out); + return 0; + } + } + fprintf(stderr, "undelete: %s\n", err.c_str()); + return 6; +} + +static int cmd_attrib(int argc, char** argv) { + // attrib [+R -R +H -H +S -S +A -A ...] + if (argc < 4) { fprintf(stderr, "need [+R|-R|+H|-H|+S|-S|+A|-A ...]\n"); return 1; } + DiskImage dk; Fat12 fs; int rc = mountDisk(argv[2], dk, fs); if (rc) return rc; + DirEntry e; + if (!findEntry(fs, argv[3], e)) { fprintf(stderr, "not found: %s\n", argv[3]); return 4; } + if (argc == 4) { printf("%s %s\n", e.attrString().c_str(), e.name.c_str()); return 0; } + uint8_t a = e.attr; + for (int i = 4; i < argc; ++i) { + std::string f = upper(argv[i]); + uint8_t bit = 0; + if (f.size() == 2) bit = f[1] == 'R' ? 0x01 : f[1] == 'H' ? 0x02 : f[1] == 'S' ? 0x04 : f[1] == 'A' ? 0x20 : 0; + if (!bit || (f[0] != '+' && f[0] != '-')) { fprintf(stderr, "bad flag %s\n", argv[i]); return 1; } + a = f[0] == '+' ? (uint8_t)(a | bit) : (uint8_t)(a & ~bit); + } + std::string err; + if (!fs.setAttributes(e, a, &err)) { fprintf(stderr, "attrib: %s\n", err.c_str()); return 6; } + if (!dk.save(argv[2], &err)) { fprintf(stderr, "save: %s\n", err.c_str()); return 7; } + printf("%s %s\n", e.attrString().c_str(), e.name.c_str()); + return 0; +} + +static int cmd_label(int argc, char** argv) { + // label [LABEL | --clear] + DiskImage dk; Fat12 fs; int rc = mountDisk(argv[2], dk, fs); if (rc) return rc; + if (argc < 4) { printf("%s\n", fs.volumeLabel().empty() ? "(no label)" : fs.volumeLabel().c_str()); return 0; } + std::string want = strcmp(argv[3], "--clear") == 0 ? "" : argv[3]; + std::string err; + if (!fs.setVolumeLabel(want, &err)) { fprintf(stderr, "label: %s\n", err.c_str()); return 6; } + if (!dk.save(argv[2], &err)) { fprintf(stderr, "save: %s\n", err.c_str()); return 7; } + printf("label: %s\n", fs.volumeLabel().empty() ? "(removed)" : fs.volumeLabel().c_str()); + return 0; +} + +static bool writeHost(const std::string& path, const std::vector& d) { + FILE* f = fopen(path.c_str(), "wb"); + if (!f) return false; + bool ok = fwrite(d.data(), 1, d.size(), f) == d.size(); + return fclose(f) == 0 && ok; +} + +static int unpackDir(const Fat12& fs, const std::string& dir, const std::string& host, + std::vector& seen, int& files) { + if (!hostfs::makeDir(host)) { fprintf(stderr, "cannot create %s\n", host.c_str()); return 5; } + for (auto& e : fs.list(dir, false)) { + if (e.isVolume() || e.name == "." || e.name == "..") continue; + std::string sub = (dir == "/" ? "" : dir) + "/" + e.name; + std::string target = hostfs::join(host, e.name); + if (e.isDir()) { + if (std::find(seen.begin(), seen.end(), (int)e.startCluster) != seen.end()) continue; + seen.push_back((int)e.startCluster); + if (int rc = unpackDir(fs, sub, target, seen, files)) return rc; + } else { + if (!writeHost(target, fs.readFile(e))) { fprintf(stderr, "cannot write %s\n", target.c_str()); return 5; } + files++; + } + } + return 0; +} + +static int cmd_unpack(int argc, char** argv) { + // unpack + if (argc < 4) { fprintf(stderr, "need \n"); return 1; } + DiskImage dk; Fat12 fs; int rc = mountDisk(argv[2], dk, fs); if (rc) return rc; + std::vector seen; int files = 0; + if ((rc = unpackDir(fs, "/", argv[3], seen, files))) return rc; + printf("extracted %d file(s) to %s\n", files, argv[3]); + return 0; +} + +static int packDir(Fat12& fs, const std::string& host, const std::string& dir, int& files) { + std::vector taken; + for (auto& e : fs.list(dir, false)) taken.push_back(e.name); + for (auto& h : hostfs::list(host)) { + std::string name = Fat12::suggestName83(h.name, taken); + taken.push_back(name); + if (upper(h.name) != name) printf(" %s -> %s\n", hostfs::join(host, h.name).c_str(), name.c_str()); + std::string err, sub = (dir == "/" ? "" : dir) + "/" + name; + if (h.isDir) { + if (!fs.makeDir(dir, name, &err)) { fprintf(stderr, "mkdir %s: %s\n", sub.c_str(), err.c_str()); return 6; } + if (int rc = packDir(fs, hostfs::join(host, h.name), sub, files)) return rc; + } else { + auto data = slurp(hostfs::join(host, h.name).c_str()); + if (!fs.createFile(dir, name, data, &err)) { fprintf(stderr, "add %s: %s\n", sub.c_str(), err.c_str()); return 6; } + files++; + } + } + return 0; +} + +static int cmd_pack(int argc, char** argv) { + // pack [360|720] [--label NAME] + if (argc < 4) { fprintf(stderr, "need [360|720] [--label NAME]\n"); return 1; } + if (!hostfs::isDir(argv[2])) { fprintf(stderr, "not a directory: %s\n", argv[2]); return 2; } + int kb = 720; std::string label; + for (int i = 4; i < argc; ++i) { + if (!strcmp(argv[i], "--label") && i + 1 < argc) label = argv[++i]; + else if (!strcmp(argv[i], "360")) kb = 360; + else if (!strcmp(argv[i], "720")) kb = 720; + else { fprintf(stderr, "unknown option %s\n", argv[i]); return 1; } + } + DiskImage dk; Fat12 fs; std::string err; + dk.loadBytes(formatBlankImage(kb == 360 ? 720 : 1440), argv[3], &err); + if (!fs.mount(dk, &err)) { fprintf(stderr, "format: %s\n", err.c_str()); return 3; } + if (!label.empty() && !fs.setVolumeLabel(label, &err)) { fprintf(stderr, "label: %s\n", err.c_str()); return 6; } + int files = 0; + if (int rc = packDir(fs, argv[2], "/", files)) return rc; + if (!dk.save(argv[3], &err)) { fprintf(stderr, "save: %s\n", err.c_str()); return 7; } + printf("packed %d file(s) into %dKB image %s (%ld bytes free)\n", files, kb, argv[3], fs.freeBytes()); + return 0; +} + static int cmd_crc(int argc, char** argv) { if (argc < 3) { fprintf(stderr, "need [FILE]\n"); return 1; } if (argc >= 4) { @@ -450,6 +611,11 @@ int main(int argc, char** argv) { " rename rename in place\n" " mkdir [/PARENT] create a directory\n" " format [360|720] blank MSX-DOS disk\n" + " pack [360|720] [--label L] build a disk from a folder\n" + " unpack extract the whole tree to a folder\n" + " undelete <[DIR/]?NAME> [LETTER] [outimg] recover a deleted file\n" + " attrib [+R -R +H -H +S -S +A -A] show / set attributes\n" + " label [LABEL|--clear] show / set the volume label\n" " fsck FAT integrity check\n" " diff compare all files by CRC\n" " crc [FILE] CRC32 of an image or a file in it\n" @@ -484,6 +650,11 @@ int main(int argc, char** argv) { if (cmd == "rename") return cmd_rename(argc, argv); if (cmd == "mkdir") return cmd_mkdir(argc, argv); if (cmd == "crc") return cmd_crc(argc, argv); + if (cmd == "undelete") return cmd_undelete(argc, argv); + if (cmd == "attrib") return cmd_attrib(argc, argv); + if (cmd == "label") return cmd_label(argc, argv); + if (cmd == "pack") return cmd_pack(argc, argv); + if (cmd == "unpack") return cmd_unpack(argc, argv); if (cmd == "fsck") return cmd_fsck(argv[2]); if (cmd == "diff") return cmd_diff(argc, argv); if (cmd == "cas2wav" || cmd == "cas2tsx" || cmd == "tsx2cas" || cmd == "wav2cas") diff --git a/core/fat12.cpp b/core/fat12.cpp index b8e234c..0004de6 100644 --- a/core/fat12.cpp +++ b/core/fat12.cpp @@ -181,6 +181,8 @@ std::vector Fat12::parseEntries(const std::vector& buf, std::string ext = trim(std::string((const char*)e + 8, 3)); if (del && !base.empty()) base[0] = '?'; // first char was overwritten d.name = ext.empty() ? base : base + "." + ext; + if ((attr & 0x08) && !(attr & 0x10)) // volume label: 11 chars, no dot + d.name = trim(std::string((const char*)e, 11)); d.attr = attr; d.time = rd16(e + 0x16); d.date = rd16(e + 0x18); @@ -505,6 +507,101 @@ bool Fat12::removeDir(const DirEntry& e, std::string* err) { return true; } +bool Fat12::undelete(DirEntry& e, char firstChar, std::string* err) { + if (!mounted_) { if (err) *err = "not mounted"; return false; } + if (!e.deleted || e.entryOffset < 0 || e.raw83.size() != 11) { + if (err) *err = "entry is not a deleted file"; + return false; + } + if (e.isDir() || e.isVolume()) { + if (err) *err = "only files can be recovered (directory chains are not recorded)"; + return false; + } + std::string candidate = e.name; + if (candidate.empty()) { if (err) *err = "entry has no name"; return false; } + candidate[0] = (char)toupper((unsigned char)firstChar); + if (!validName83(candidate, err)) return false; + uint8_t nm[11]; + memcpy(nm, e.raw83.data(), 11); + nm[0] = (uint8_t)toupper((unsigned char)firstChar); + + // Which directory holds the entry? (Needed for the name collision check.) + int dirOf = -1; + std::set seen; + std::vector todo{0}; + while (!todo.empty() && dirOf < 0) { + int dc = todo.back(); todo.pop_back(); + if (!seen.insert(dc).second) continue; + for (auto& x : readDir(dc, true)) { + if (x.entryOffset == e.entryOffset) { dirOf = dc; break; } + if (!x.deleted && x.isDir() && x.startCluster >= 2 && x.name != "." && x.name != "..") + todo.push_back(x.startCluster); + } + } + if (dirOf < 0) { if (err) *err = "entry not found in the directory tree"; return false; } + if (nameTaken(dirOf, nm)) { if (err) *err = "a live file already has that name"; return false; } + + long clusterBytes = (long)spc_ * 512; + long need = ((long)e.size + clusterBytes - 1) / clusterBytes; + if (need > 0) { + if (e.startCluster < 2 || e.startCluster - 2 + need > totalClusters_) { + if (err) *err = "start cluster / size out of range"; + return false; + } + for (long i = 0; i < need; ++i) { + if (fatEntry(e.startCluster + (int)i) != 0) { + if (err) *err = "cluster " + std::to_string(e.startCluster + i) + + " has been reused; the data cannot be recovered intact"; + return false; + } + } + for (long i = 0; i < need; ++i) { + int c = e.startCluster + (int)i; + setFatEntry(c, i + 1 < need ? c + 1 : 0xFFF); + } + } + writeByteAt(e.entryOffset, nm[0]); + e.raw83.assign((const char*)nm, 11); + e.name = candidate; + e.deleted = false; + return true; +} + +bool Fat12::setAttributes(DirEntry& e, uint8_t attr, std::string* err) { + if (!mounted_ || e.entryOffset < 0 || e.deleted) { if (err) *err = "not a live entry"; return false; } + if (!e.raw83.empty() && e.raw83[0] == '.') { if (err) *err = "cannot change . or .."; return false; } + uint8_t v = (uint8_t)((e.attr & 0x18) | (attr & 0x27)); + if (!writeByteAt(e.entryOffset + 0x0B, v)) { if (err) *err = "write error"; return false; } + e.attr = v; + return true; +} + +bool Fat12::setVolumeLabel(const std::string& label, std::string* err) { + if (!mounted_) { if (err) *err = "not mounted"; return false; } + if (label.size() > 11) { if (err) *err = "a volume label has at most 11 characters"; return false; } + for (char ch : label) { + unsigned char c = (unsigned char)ch; + if (c < 0x20 || c == 0x7F || strchr("\"*+,./:;<=>?[\\]|", c)) { + if (err) *err = "reserved character in the label"; + return false; + } + } + long existing = -1; + for (auto& e : readRootDir(false)) if (e.isVolume()) { existing = e.entryOffset; break; } + if (label.empty()) { + if (existing >= 0) writeByteAt(existing, 0xE5); + volLabel_.clear(); + return true; + } + uint8_t nm[11]; memset(nm, ' ', 11); + for (size_t i = 0; i < label.size(); ++i) nm[i] = (uint8_t)toupper((unsigned char)label[i]); + long slot = existing >= 0 ? existing : freeDirSlot(0, false); + if (slot < 0) { if (err) *err = "root directory is full"; return false; } + writeDirEntry(slot, nm, 0x08, 0, 0); + volLabel_ = trim(std::string((const char*)nm, 11)); + return true; +} + // ---- RAW / uncatalogued data ---------------------------------------------- long Fat12::clusterByteOffset(int cluster) const { @@ -612,6 +709,43 @@ bool Fat12::validName83(const std::string& name, std::string* err) { return true; } +std::string Fat12::suggestName83(const std::string& hostName, + const std::vector& taken) { + auto clean = [](const std::string& in, size_t maxLen) { + std::string out; + for (char ch : in) { + unsigned char c = (unsigned char)ch; + if (c == ' ' || c == '.') continue; + if (c < 0x20 || c == 0x7F || strchr("\"*+,/:;<=>?[\\]|", c)) c = '_'; + out += (char)toupper(c); + } + if (!out.empty() && (unsigned char)out[0] == 0xE5) out[0] = '_'; + if (out.size() > maxLen) out.resize(maxLen); + return out; + }; + size_t dot = hostName.find_last_of('.'); + bool hasExt = dot != std::string::npos && dot > 0 && dot + 1 < hostName.size(); + std::string base = clean(hasExt ? hostName.substr(0, dot) : hostName, 64); + std::string ext = clean(hasExt ? hostName.substr(dot + 1) : "", 3); + if (base.empty()) base = "FILE"; + auto isTaken = [&](const std::string& n) { + for (auto t : taken) { + for (auto& c : t) c = (char)toupper((unsigned char)c); + if (t == n) return true; + } + return false; + }; + auto join = [&](const std::string& b) { return ext.empty() ? b : b + "." + ext; }; + std::string first = join(base.substr(0, 8)); + if (base.size() <= 8 && !isTaken(first)) return first; + for (int n = 1; n < 100000; ++n) { + std::string tail = "~" + std::to_string(n); + std::string cand = join(base.substr(0, 8 - tail.size()) + tail); + if (!isTaken(cand)) return cand; + } + return first; +} + void Fat12::pack83(const std::string& name, uint8_t out[11]) { memset(out, ' ', 11); if (name == ".") { out[0] = '.'; return; } diff --git a/core/fat12.h b/core/fat12.h index 7feb2fb..0bc4ec7 100644 --- a/core/fat12.h +++ b/core/fat12.h @@ -77,6 +77,20 @@ class Fat12 { // Remove an EMPTY subdirectory (only "." and ".." left) and free its chain. bool removeDir(const DirEntry& e, std::string* err = nullptr); + // Recover a deleted FILE (first name byte 0xE5). Like MSX-DOS UNDEL, this + // assumes the file's clusters were contiguous from its start cluster and + // requires every one of them to still be free; `firstChar` replaces the + // lost first letter. Updates `e`. Directories are not recoverable. + bool undelete(DirEntry& e, char firstChar, std::string* err = nullptr); + + // Set the read-only / hidden / system / archive bits (0x01 0x02 0x04 0x20) + // of an entry; the directory and volume bits are kept. Updates `e`. + bool setAttributes(DirEntry& e, uint8_t attr, std::string* err = nullptr); + + // Set (or, with an empty string, remove) the volume label: the root + // directory VOL entry, up to 11 characters, stored in upper case. + bool setVolumeLabel(const std::string& label, std::string* err = nullptr); + // Find a file or directory by path ("/GAMES/SAVE/SLOT1.DAT", case-insensitive). // Returns false if any component is missing. bool lookup(const std::string& path, DirEntry& out) const; @@ -85,6 +99,12 @@ class Fat12 { // char extension, no spaces or reserved characters). Case-insensitive. static bool validName83(const std::string& name, std::string* err = nullptr); + // Turn an arbitrary host file name into a valid 8.3 name that is not in + // `taken` (case-insensitive): upper case, spaces dropped, reserved + // characters -> '_', base cut to 8 and extension to 3, "~N" on clashes. + static std::string suggestName83(const std::string& hostName, + const std::vector& taken); + // Timestamp written into new/modified directory entries (FAT date/time // words). By default the current local time is used; a fixed stamp makes // output reproducible (tests, deterministic builds). diff --git a/gui/main.cpp b/gui/main.cpp index e6f3971..6deac48 100644 --- a/gui/main.cpp +++ b/gui/main.cpp @@ -95,6 +95,7 @@ struct App { // B1/B3 UI state bool openRename = false, openNewFolder = false; + char undelBuf[2] = {0}; char nameBuf[16] = {0}; bool backupOnSave = true; bool showFsck = false; @@ -649,12 +650,16 @@ static void drawFileTree(App& a) { std::string p = openDialog("Add file to disk", {"*.*"}, "All files"); if (!p.empty()) { auto data = slurp(p); std::string err; - if (a.fs.createFile(a.cwd, fileBaseName(p), data, &err)) { - a.status = "Added " + fileBaseName(p) + " (" + std::to_string(data.size()) + " B)"; refreshListing(a); + std::vector taken; + for (auto& x : a.fs.list(a.cwd, false)) taken.push_back(x.name); + std::string name = Fat12::suggestName83(fileBaseName(p), taken); + if (a.fs.createFile(a.cwd, name, data, &err)) { + a.status = "Added " + fileBaseName(p) + " as " + name + " (" + std::to_string(data.size()) + " B)"; refreshListing(a); } else a.status = "Add failed: " + err; } } - help("Copy a file from your PC into the current directory as a new 8.3 entry (grows the FAT as needed)."); + help("Copy a file from your PC into the current directory as a new 8.3 entry (grows the FAT as needed). " + "Long or invalid names are shortened, e.g. 'My Program.bas' -> MYPROG~1.BAS."); ImGui::SameLine(); if (ImGui::Button("New folder")) { a.nameBuf[0] = 0; a.openNewFolder = true; } ImGui::SameLine(); @@ -768,6 +773,20 @@ static void drawFileToolbar(App& a) { } help("Replace this file's contents with a file from your PC (grows/shrinks the cluster chain automatically)."); ImGui::SameLine(); + if (e.deleted) { + ImGui::SetNextItemWidth(30); + ImGui::InputText("##first", a.undelBuf, 2); + ImGui::SameLine(); + if (ImGui::Button("Undelete") ) { + DirEntry copy = e; std::string err; + char letter = a.undelBuf[0] ? a.undelBuf[0] : 'X'; + if (a.fs.undelete(copy, letter, &err)) { a.status = "Recovered " + copy.name + " (image modified)"; refreshListing(a); } + else a.status = "Undelete failed: " + err; + } + help("Recover this deleted file. Type the lost first letter in the box. Works when its clusters " + "were contiguous and have not been reused since (like MSX-DOS UNDEL)."); + return; + } if (ImGui::Button("Delete file")) { std::string err, name = a.selName; if (a.fs.deleteFile(e, &err)) { a.status = "Deleted " + name + " (image modified)"; refreshListing(a); } diff --git a/tests/cli_smoke.cmake b/tests/cli_smoke.cmake index cd2f9d0..a807283 100644 --- a/tests/cli_smoke.cmake +++ b/tests/cli_smoke.cmake @@ -32,6 +32,9 @@ run(0 "${MSXFORGE}" add "${IMG}" "${WORK}/HELLO.BAS") run(0 "${MSXFORGE}" mkdir "${IMG}" DOCS) run(0 "${MSXFORGE}" add "${IMG}" "${WORK}/notes.txt" /DOCS README.TXT) run(6 "${MSXFORGE}" add "${IMG}" "${WORK}/notes.txt" / WAYTOOLONGNAME.TXT) +file(WRITE "${WORK}/A Long Host Name.txt" "x\n") +run(0 "${MSXFORGE}" add "${IMG}" "${WORK}/A Long Host Name.txt") # name derived +expect_in("ALONGH~1.TXT") run(0 "${MSXFORGE}" ls "${IMG}" /DOCS) expect_in("README.TXT") run(0 "${MSXFORGE}" cat "${IMG}" docs/readme.txt) @@ -75,4 +78,33 @@ file(SHA256 "${WORK}/back.cas" h2) if(NOT h1 STREQUAL h2) message(FATAL_ERROR "cas -> tsx -> cas changed the bytes") endif() +# ---- pack / unpack / undelete / attrib / label ----------------------------- +file(MAKE_DIRECTORY "${WORK}/src/Sub Folder") +file(WRITE "${WORK}/src/game.bas" "10 PRINT 1\n") +file(WRITE "${WORK}/src/My Long Program Name.txt" "long name\n") +file(WRITE "${WORK}/src/Sub Folder/deep.dat" "deep data\n") +run(0 "${MSXFORGE}" pack "${WORK}/src" "${WORK}/p.dsk" 720 --label "Smoke Test") +expect_in("MYLONG~1.TXT") +expect_in("SUBFOL~1") +run(0 "${MSXFORGE}" label "${WORK}/p.dsk") +expect_in("SMOKE TEST") +run(0 "${MSXFORGE}" fsck "${WORK}/p.dsk") +run(0 "${MSXFORGE}" unpack "${WORK}/p.dsk" "${WORK}/dst") +expect_in("extracted 3 file(s)") +file(READ "${WORK}/dst/SUBFOL~1/DEEP.DAT" deep) +if(NOT deep STREQUAL "deep data\n") + message(FATAL_ERROR "pack -> unpack changed DEEP.DAT") +endif() +run(0 "${MSXFORGE}" attrib "${WORK}/p.dsk" GAME.BAS +R +h) +expect_in("RH") +run(1 "${MSXFORGE}" attrib "${WORK}/p.dsk" GAME.BAS +X) +run(0 "${MSXFORGE}" del "${WORK}/p.dsk" SUBFOL~1/DEEP.DAT) +run(1 "${MSXFORGE}" undelete "${WORK}/p.dsk" SUBFOL~1/?EEP.DAT) # letter needed +run(0 "${MSXFORGE}" undelete "${WORK}/p.dsk" SUBFOL~1/?EEP.DAT D) +run(0 "${MSXFORGE}" cat "${WORK}/p.dsk" SUBFOL~1/DEEP.DAT) +expect_in("deep data") +run(0 "${MSXFORGE}" fsck "${WORK}/p.dsk") +run(0 "${MSXFORGE}" label "${WORK}/p.dsk" --clear) +expect_in("removed") + message(STATUS "cli smoke test passed") diff --git a/tests/test_fat12.cpp b/tests/test_fat12.cpp index 415159e..b5336b8 100644 --- a/tests/test_fat12.cpp +++ b/tests/test_fat12.cpp @@ -486,3 +486,122 @@ TEST(fat12_full_directory_growth_is_accounted) { CHECK_EQ(d.fs.freeBytes(), 0L); CHECK(clean(d.fs)); } + +// ---- Undelete, attributes, volume label ------------------------------------ + +TEST(fat12_undelete_restores_file) { + Disk d; + std::string err; + auto data = pattern(5000, 3); + CHECK(d.fs.createFile("/", "GAME.BIN", data, &err)); + CHECK(d.fs.createFile("/", "KEEP.BIN", pattern(100, 4), &err)); + DirEntry e; CHECK(d.find("/GAME.BIN", e)); + CHECK(d.fs.deleteFile(e, &err)); + DirEntry gone; + for (auto& x : d.fs.list("/", true)) if (x.deleted) gone = x; + CHECK_EQ(gone.name, std::string("?AME.BIN")); + CHECK(!d.fs.undelete(gone, '*', &err)); // illegal first character + CHECK(d.fs.undelete(gone, 'g', &err)); + CHECK_EQ(gone.name, std::string("GAME.BIN")); + d.remount(); + CHECK(d.find("/GAME.BIN", e)); + CHECK(d.fs.readFile(e) == data); + CHECK(clean(d.fs)); + CHECK_EQ(d.fs.freeBytes(), (713L - 5 - 1) * 1024); +} + +TEST(fat12_undelete_refuses_reused_clusters_and_collisions) { + Disk d; + std::string err; + CHECK(d.fs.createFile("/", "OLD.BIN", pattern(3000, 1), &err)); // clusters 2-4 + CHECK(d.fs.createFile("/", "KEEP.BIN", pattern(100, 2), &err)); // cluster 5 + DirEntry e; CHECK(d.find("/OLD.BIN", e)); + CHECK(d.fs.deleteFile(e, &err)); + DirEntry k; CHECK(d.find("/KEEP.BIN", k)); + CHECK(d.fs.writeFile(k, pattern(2000, 3), &err)); // grows into cluster 2 + DirEntry gone; + for (auto& x : d.fs.list("/", true)) if (x.deleted) gone = x; + CHECK(gone.deleted); + CHECK(!d.fs.undelete(gone, 'O', &err)); + CHECK(err.find("reused") != std::string::npos); + // Name collision: a live file took the deleted file's name meanwhile. + CHECK(d.fs.createFile("/", "TWIN.TXT", {}, &err)); + CHECK(d.fs.createFile("/", "OTHER.TXT", {}, &err)); + DirEntry t; CHECK(d.find("/TWIN.TXT", t)); CHECK(d.fs.deleteFile(t, &err)); + DirEntry o; CHECK(d.find("/OTHER.TXT", o)); CHECK(d.fs.renameEntry(o, "TWIN.TXT", &err)); + DirEntry gt; + for (auto& x : d.fs.list("/", true)) if (x.deleted && x.name == "?WIN.TXT") gt = x; + CHECK(gt.deleted); + CHECK(!d.fs.undelete(gt, 'T', &err)); + CHECK(err.find("already") != std::string::npos); + CHECK(clean(d.fs)); +} + +TEST(fat12_undelete_in_subdirectory) { + Disk d; + std::string err; + CHECK(d.fs.makeDir("/", "SAVE", &err)); + CHECK(d.fs.createFile("/SAVE", "SLOT.DAT", pattern(1500, 9), &err)); + DirEntry e; CHECK(d.find("/SAVE/SLOT.DAT", e)); + CHECK(d.fs.deleteFile(e, &err)); + DirEntry gone; + for (auto& x : d.fs.list("/SAVE", true)) if (x.deleted) gone = x; + CHECK(d.fs.undelete(gone, 'S', &err)); + CHECK(d.find("/SAVE/SLOT.DAT", e)); + CHECK(d.fs.readFile(e) == pattern(1500, 9)); + CHECK(clean(d.fs)); +} + +TEST(fat12_set_attributes) { + Disk d; + std::string err; + CHECK(d.fs.createFile("/", "A.BIN", pattern(10, 1), &err)); + CHECK(d.fs.makeDir("/", "DIR", &err)); + DirEntry e; CHECK(d.find("/A.BIN", e)); + CHECK(d.fs.setAttributes(e, 0x01 | 0x02 | 0x10 /* dir bit ignored */, &err)); + d.remount(); + CHECK(d.find("/A.BIN", e)); + CHECK_EQ(e.attr, (uint8_t)0x03); + DirEntry dir; CHECK(d.find("/DIR", dir)); + CHECK(d.fs.setAttributes(dir, 0x02, &err)); + CHECK(d.find("/DIR", dir)); + CHECK_EQ(dir.attr, (uint8_t)0x12); // still a directory + CHECK(clean(d.fs)); +} + +TEST(fat12_volume_label) { + Disk d; + std::string err; + CHECK(d.fs.volumeLabel().empty()); + CHECK(d.fs.setVolumeLabel("my disk 123", &err)); + CHECK_EQ(d.fs.volumeLabel(), std::string("MY DISK 123")); + d.remount(); + CHECK_EQ(d.fs.volumeLabel(), std::string("MY DISK 123")); + CHECK(d.fs.setVolumeLabel("GAMES", &err)); // replaces, no second entry + int vols = 0; + for (auto& e : d.fs.list("/")) if (e.isVolume()) vols++; + CHECK_EQ(vols, 1); + CHECK(!d.fs.setVolumeLabel("TWELVE CHARS", &err)); + CHECK(!d.fs.setVolumeLabel("A*B", &err)); + CHECK(d.fs.setVolumeLabel("", &err)); + d.remount(); + CHECK(d.fs.volumeLabel().empty()); + CHECK(clean(d.fs)); +} + +TEST(fat12_suggest_name83) { + std::vector none; + CHECK_EQ(Fat12::suggestName83("game.bin", none), std::string("GAME.BIN")); + CHECK_EQ(Fat12::suggestName83("My Long Program.bas", none), std::string("MYLONG~1.BAS")); + CHECK_EQ(Fat12::suggestName83("archive.tar.gz", none), std::string("ARCHIV~1.GZ")); + CHECK_EQ(Fat12::suggestName83("readme", none), std::string("README")); + CHECK_EQ(Fat12::suggestName83("a+b=c.text", none), std::string("A_B_C.TEX")); + CHECK_EQ(Fat12::suggestName83(".hidden", none), std::string("HIDDEN")); + CHECK_EQ(Fat12::suggestName83("...", none), std::string("FILE")); + std::vector taken{"GAME.BIN", "mylong~1.bas"}; + CHECK_EQ(Fat12::suggestName83("game.bin", taken), std::string("GAME~1.BIN")); + CHECK_EQ(Fat12::suggestName83("my long program.bas", taken), std::string("MYLONG~2.BAS")); + std::string err; + for (const char* h : {"x y z.q", "über.dat", "12345678901234567890", "-.-"}) + CHECK(Fat12::validName83(Fat12::suggestName83(h, none), &err)); +}