Skip to content

Commit 58541aa

Browse files
authored
iso9660: Truncate short filenames to 8.3 format for compatibility (#315)
* iso9660: Truncate short filenames to 8.3 format for compatibility When creating ISO 9660 filesystems with Rock Ridge extensions, diskfs creates both a Rock Ridge long name and an ISO 9660 short name for compatibility with tools that don't support Rock Ridge. Previously, the calculateShortnameExtension() function would uppercase and sanitize filenames but did not enforce the ISO 9660 Level 1 8.3 format constraints. This resulted in short names with extensions longer than 3 characters and basenames longer than 8 characters. Some tools access files using their ISO 9660 short names and expect proper 8.3 format (8-character basename, 3-character extension). When diskfs creates ISOs with longer extensions or basenames, these tools fail to find the files. This commit updates calculateShortnameExtension() to enforce ISO 9660 Level 1 limits: - Extensions are truncated to a maximum of 3 characters - Basenames are truncated to a maximum of 8 characters This matches the behavior of traditional ISO creation tools like xorriso (default Level 1 mode) and ensures maximum compatibility. Comprehensive tests have been added to verify the 8.3 format is correctly enforced for various filename patterns including long names, special characters, and edge cases. Assisted-by: Claude Code * iso9660: Resolve filename collisions in Level 1 format When multiple files truncate to the same 8.3 short name, automatically assign unique numeric suffixes to resolve the collision. Files with naturally-occurring numeric suffixes are preserved, and the collision resolution uses the minimum number of digits needed. For example, when 76 files all truncate to FILENAME, collision resolution assigns names FILENA00 through FILENA75. This matches the behaviour of xorriso in Level 1 mode. Assisted-by: Claude Code
1 parent 5f6c428 commit 58541aa

File tree

3 files changed

+290
-8
lines changed

3 files changed

+290
-8
lines changed

filesystem/iso9660/finalize.go

Lines changed: 131 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"io"
66
"io/fs"
7+
"math"
78
"os"
89
"path"
910
"path/filepath"
@@ -52,6 +53,13 @@ type FinalizeOptions struct {
5253
// Nlink() uint32 // number of hardlinks, if supported
5354
// Uid() uint32 // uid, if supported
5455
// Gid() uint32 // gid, if supported
56+
type collisionGroup struct {
57+
files []*finalizeFileInfo
58+
parent *finalizeFileInfo // directory containing these files
59+
basename string // the truncated basename they all collide on
60+
extension string // the truncated extension (if any)
61+
}
62+
5563
type finalizeFileInfo struct {
5664
path string
5765
target string
@@ -914,10 +922,11 @@ func createPathTable(fi []*finalizeFileInfo) *pathTable {
914922

915923
func walkTree(workspace string) ([]*finalizeFileInfo, map[string]*finalizeFileInfo, error) {
916924
var (
917-
dirList = make(map[string]*finalizeFileInfo)
918-
fileList = make([]*finalizeFileInfo, 0)
919-
entry *finalizeFileInfo
920-
serial uint64
925+
dirList = make(map[string]*finalizeFileInfo)
926+
fileList = make([]*finalizeFileInfo, 0)
927+
collisionGroups = make(map[string]*collisionGroup)
928+
entry *finalizeFileInfo
929+
serial uint64
921930
)
922931
err := filepath.WalkDir(workspace, func(actualPath string, d fs.DirEntry, err error) error {
923932
if err != nil {
@@ -961,14 +970,120 @@ func walkTree(workspace string) ([]*finalizeFileInfo, map[string]*finalizeFileIn
961970
dirList[parentDir] = parentDirInfo
962971
fileList = append(fileList, entry)
963972
}
973+
974+
// Add to collision groups (for both files and directories)
975+
// Build collision key: parent path + truncated 8.3 name
976+
truncated := entry.shortname
977+
if entry.extension != "" {
978+
truncated += "." + entry.extension
979+
}
980+
collisionKey := parentDir + "/" + truncated
981+
if collisionGroups[collisionKey] == nil {
982+
collisionGroups[collisionKey] = &collisionGroup{
983+
files: make([]*finalizeFileInfo, 0),
984+
parent: parentDirInfo,
985+
basename: entry.shortname,
986+
extension: entry.extension,
987+
}
988+
}
989+
collisionGroups[collisionKey].files = append(collisionGroups[collisionKey].files, entry)
964990
return nil
965991
})
966992
if err != nil {
967993
return nil, nil, err
968994
}
995+
996+
// Resolve filename collisions
997+
for _, group := range collisionGroups {
998+
if len(group.files) > 1 {
999+
if err := resolveCollisionGroup(group); err != nil {
1000+
return nil, nil, fmt.Errorf("error resolving filename collisions: %v", err)
1001+
}
1002+
}
1003+
}
1004+
9691005
return fileList, dirList, nil
9701006
}
9711007

1008+
// resolveCollisionGroup resolves filename collisions for a group of files
1009+
// using xorriso's algorithm from libisofs/ecma119_tree.c
1010+
func resolveCollisionGroup(group *collisionGroup) error {
1011+
if len(group.files) <= 1 {
1012+
return nil
1013+
}
1014+
1015+
// Build hash table of all names in the parent directory
1016+
nameTable := make(map[string]bool)
1017+
for _, sibling := range group.parent.children {
1018+
name := sibling.shortname
1019+
if sibling.extension != "" {
1020+
name += "." + sibling.extension
1021+
}
1022+
nameTable[name] = true
1023+
}
1024+
1025+
// Calculate minimum digit width needed
1026+
digits := len(fmt.Sprintf("%d", len(group.files)-1))
1027+
1028+
// Try increasing digit widths until all files are resolved (max 7 digits)
1029+
for digits < 8 {
1030+
maxBasename := 8 - digits
1031+
1032+
// Truncate basename to max length
1033+
basename := group.basename
1034+
if len(basename) > maxBasename {
1035+
basename = basename[:maxBasename]
1036+
}
1037+
extension := group.extension
1038+
1039+
// Try to assign names to each file in the collision group
1040+
change := 0
1041+
maxChange := int(math.Pow10(digits))
1042+
allResolved := true
1043+
1044+
for _, file := range group.files {
1045+
// Try incrementing change values until we find a unique name
1046+
found := false
1047+
for change < maxChange {
1048+
// Format the candidate name
1049+
indexStr := fmt.Sprintf("%0*d", digits, change)
1050+
candidate := basename + indexStr
1051+
if extension != "" {
1052+
candidate += "." + extension
1053+
}
1054+
change++
1055+
1056+
// Check if this name is already taken
1057+
if !nameTable[candidate] {
1058+
// Update file shortname in-place
1059+
file.shortname = basename + indexStr
1060+
nameTable[candidate] = true
1061+
found = true
1062+
break
1063+
}
1064+
}
1065+
1066+
if !found {
1067+
allResolved = false
1068+
break
1069+
}
1070+
}
1071+
1072+
if allResolved {
1073+
return nil
1074+
}
1075+
1076+
// Need more digits, try again
1077+
digits++
1078+
}
1079+
1080+
name := group.basename
1081+
if group.extension != "" {
1082+
name += "." + group.extension
1083+
}
1084+
return fmt.Errorf("too many filename collisions for: %s", name)
1085+
}
1086+
9721087
func calculateBlocks(size, blocksize int64) uint32 {
9731088
blocks := uint32(size / blocksize)
9741089
// add one for partial
@@ -993,5 +1108,17 @@ func calculateShortnameExtension(name string) (shortname, extension string) {
9931108
shortname = re.ReplaceAllString(shortname, "_")
9941109
extension = re.ReplaceAllString(extension, "_")
9951110

1111+
// Truncate to ISO 9660 Level 1 (8.3 format) for maximum compatibility
1112+
// This ensures compatibility with tools that expect traditional short names
1113+
// Extension: max 3 characters
1114+
if len(extension) > 3 {
1115+
extension = extension[:3]
1116+
}
1117+
1118+
// Basename: max 8 characters
1119+
if len(shortname) > 8 {
1120+
shortname = shortname[:8]
1121+
}
1122+
9961123
return shortname, extension
9971124
}

filesystem/iso9660/finalize_internal_test.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,66 @@ func TestCreatePathTable(t *testing.T) {
114114
}
115115
}
116116

117+
func TestCalculateShortnameExtension(t *testing.T) {
118+
tests := []struct {
119+
name string
120+
expectedShortname string
121+
expectedExtension string
122+
}{
123+
// Test extension truncation to 3 characters
124+
{"kargs.json", "KARGS", "JSO"},
125+
{"file.html", "FILE", "HTM"},
126+
{"document.markdown", "DOCUMENT", "MAR"},
127+
{"archive.tar.gz", "ARCHIVE", "TAR"},
128+
// Test extensions that are already 3 characters or less
129+
{"test.js", "TEST", "JS"},
130+
{"readme.md", "README", "MD"},
131+
{"app.css", "APP", "CSS"},
132+
{"data.txt", "DATA", "TXT"},
133+
// Test files with no extension
134+
{"README", "README", ""},
135+
{"Makefile", "MAKEFILE", ""},
136+
// Test uppercasing
137+
{"MyFile.TxT", "MYFILE", "TXT"},
138+
{"example.HtMl", "EXAMPLE", "HTM"},
139+
// Test illegal character replacement and basename truncation
140+
{"my-file.json", "MY_FILE", "JSO"},
141+
{"test file.html", "TEST_FIL", "HTM"}, // "test file" = 9 chars after underscore replacement -> truncated to 8
142+
{"[email protected]", "DATA_HOM", "XML"}, // "data@home" = 9 chars after underscore replacement -> truncated to 8
143+
// Test basename truncation to 8 characters (ISO 9660 Level 1 - 8.3 format)
144+
{"verylongfilenamethatexceedsthirtychars.txt", "VERYLONG", "TXT"},
145+
{"anextremelylongbasenamethatgoeswaybeyondlimits.json", "ANEXTREM", "JSO"},
146+
{"exactlythirtycharsbasenamehere.log", "EXACTLYT", "LOG"},
147+
{"exactlyeightchars.js", "EXACTLYE", "JS"},
148+
{"eightchr.txt", "EIGHTCHR", "TXT"},
149+
// Test basename-only files that exceed 8 characters
150+
{"verylongbasenamethatexceedsthirtycharacterswithnoextension", "VERYLONG", ""},
151+
{"exactlythirtycharacterslong12", "EXACTLYT", ""},
152+
{"thisfilenameisexactlythirtyone", "THISFILE", ""},
153+
{"exactlyeightcharacters", "EXACTLYE", ""},
154+
{"eightchr", "EIGHTCHR", ""},
155+
}
156+
157+
for _, tt := range tests {
158+
t.Run(tt.name, func(t *testing.T) {
159+
shortname, extension := calculateShortnameExtension(tt.name)
160+
if shortname != tt.expectedShortname {
161+
t.Errorf("calculateShortnameExtension(%q) shortname = %q, want %q", tt.name, shortname, tt.expectedShortname)
162+
}
163+
if extension != tt.expectedExtension {
164+
t.Errorf("calculateShortnameExtension(%q) extension = %q, want %q", tt.name, extension, tt.expectedExtension)
165+
}
166+
// Verify ISO 9660 Level 1 (8.3 format) constraints
167+
if len(shortname) > 8 {
168+
t.Errorf("calculateShortnameExtension(%q) basename length = %d, exceeds 8 character limit", tt.name, len(shortname))
169+
}
170+
if len(extension) > 3 {
171+
t.Errorf("calculateShortnameExtension(%q) extension length = %d, exceeds 3 character limit", tt.name, len(extension))
172+
}
173+
})
174+
}
175+
}
176+
117177
func TestCollapseAndSortChildren(t *testing.T) {
118178
// we need to build a file tree, and then see that the results are correct and in order
119179
// the algorithm uses the following properties of finalizeFileInfo:
@@ -202,3 +262,98 @@ func TestCollapseAndSortChildren(t *testing.T) {
202262
t.Log(output)
203263
}
204264
}
265+
266+
func TestCollisionResolution(t *testing.T) {
267+
tests := []struct {
268+
name string
269+
files []string // filenames to create
270+
expected map[string]string // original -> expected short name
271+
}{
272+
{
273+
name: "two file collision",
274+
files: []string{"collision1.txt", "collision2.txt"},
275+
expected: map[string]string{
276+
"collision1.txt": "COLLISI0.TXT",
277+
"collision2.txt": "COLLISI1.TXT",
278+
},
279+
},
280+
{
281+
name: "natural name preserved with collision",
282+
files: []string{"filenam1", "filenamelong", "filenamereallylong"},
283+
expected: map[string]string{
284+
"filenam1": "FILENAM1",
285+
"filenamelong": "FILENAM0",
286+
"filenamereallylong": "FILENAM2",
287+
},
288+
},
289+
{
290+
name: "10 files needing 1 digit + natural filenam9",
291+
files: []string{
292+
"filenam9",
293+
"filenamereallylong0", "filenamereallylong1", "filenamereallylong2",
294+
"filenamereallylong3", "filenamereallylong4", "filenamereallylong5",
295+
"filenamereallylong6", "filenamereallylong7", "filenamereallylong8",
296+
"filenamereallylong9",
297+
},
298+
expected: map[string]string{
299+
"filenam9": "FILENAM9",
300+
"filenamereallylong0": "FILENA00",
301+
"filenamereallylong1": "FILENA01",
302+
"filenamereallylong2": "FILENA02",
303+
"filenamereallylong3": "FILENA03",
304+
"filenamereallylong4": "FILENA04",
305+
"filenamereallylong5": "FILENA05",
306+
"filenamereallylong6": "FILENA06",
307+
"filenamereallylong7": "FILENA07",
308+
"filenamereallylong8": "FILENA08",
309+
"filenamereallylong9": "FILENA09",
310+
},
311+
},
312+
}
313+
314+
for _, tt := range tests {
315+
t.Run(tt.name, func(t *testing.T) {
316+
// Create temp directory
317+
tmpDir, err := os.MkdirTemp("", "collision-test-*")
318+
if err != nil {
319+
t.Fatalf("Failed to create temp dir: %v", err)
320+
}
321+
defer os.RemoveAll(tmpDir)
322+
323+
// Create test files
324+
for _, filename := range tt.files {
325+
path := tmpDir + "/" + filename
326+
if err := os.WriteFile(path, []byte("test"), 0o600); err != nil {
327+
t.Fatalf("Failed to create file %s: %v", filename, err)
328+
}
329+
}
330+
331+
// Run walkTree which also resolves collisions
332+
_, dirList, err := walkTree(tmpDir)
333+
if err != nil {
334+
t.Fatalf("walkTree failed: %v", err)
335+
}
336+
337+
// Add parent properties
338+
root := dirList["."]
339+
root.addProperties(1)
340+
341+
// Check results
342+
for _, file := range root.children {
343+
expectedShort, ok := tt.expected[file.name]
344+
if !ok {
345+
continue // not in our test map
346+
}
347+
348+
actualShort := file.shortname
349+
if file.extension != "" {
350+
actualShort += "." + file.extension
351+
}
352+
353+
if actualShort != expectedShort {
354+
t.Errorf("File %s: expected short name %s, got %s", file.name, expectedShort, actualShort)
355+
}
356+
}
357+
})
358+
}
359+
}

filesystem/iso9660/finalize_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ func TestFinalize9660(t *testing.T) {
196196

197197
fooCount := 75
198198
for i := 0; i <= fooCount; i++ {
199-
filename := fmt.Sprintf("/FOO/FILENAME_%d", i)
199+
filename := fmt.Sprintf("/FOO/FILENAME_%02d", i)
200200
contents := []byte(fmt.Sprintf("filename_%d\n", i))
201201
isofile, err = fs.OpenFile(filename, os.O_CREATE|os.O_RDWR)
202202
if err != nil {
@@ -248,9 +248,9 @@ func TestFinalize9660(t *testing.T) {
248248

249249
// get a few files I expect
250250
fileContents := map[string]string{
251-
"/README.MD": "readme\n",
252-
"/FOO/FILENAME_50": "filename_50\n",
253-
"/FOO/FILENAME_2": "filename_2\n",
251+
"/README.MD": "readme\n",
252+
"/FOO/FILENA50": "filename_50\n",
253+
"/FOO/FILENA02": "filename_2\n",
254254
}
255255

256256
for k, v := range fileContents {

0 commit comments

Comments
 (0)