go-diskfs: Getting Rock Ridge Working for Joliet on Windows
I fixed Rock Ridge and added Joliet to go-diskfs so a Go-built cloud-init ISO works for cloudbase-init. Then I found out what Windows actually wanted.
go-proxmox has had a CloudInit() helper since 2023. It builds a small
nocloud ISO in Go, uploads it to Proxmox and mounts it on the VM as a CD-ROM. It’s always worked fine for Linux guests. It
has never worked for Windows guests running cloudbase-init. The reason is a
filesystem extension from 1995 called Joliet.
Getting it fixed took three PRs to go-diskfs over about seven weeks. It also took a bug report from a user who ran it on Windows. That turned out to be the most important part.
Why Windows can’t read the ISO
Plain ISO 9660 is very strict about names. At interchange level 1 a file name has four parts:
- up to 8 characters of uppercase letters, digits and underscore
- a dot
- a 3 character extension
- a
;1version suffix
Here are the three nocloud files as go-diskfs writes them into the primary directory tree:
META_DAT.;1
NETWORK_.;1
USER_DAT.;1
Nobody reads those names. Two extensions layer real filenames on top. Which one an OS uses decides what it sees:
-
Rock Ridge is for Unix. It hides POSIX metadata inside each directory record’s System Use area using the SUSP framework. The entries are:
PXfor mode/uid/gid/linksNMfor the real nameSLfor symlink targetsTFfor timestampsCL/PL/REfor relocating directories nested deeper than ISO 9660’s 8-level limit- an
ERentry announcing the extension
Linux reads it, so Linux sees
meta-data. -
Joliet is Microsoft’s. It doesn’t touch the primary tree at all. It adds a Supplementary Volume Descriptor, or SVD (sector 17 here, right behind the PVD). The SVD is flagged with an escape sequence (
%/@,%/Cor%/Efor UCS-2 levels 1 to 3). It points at a second directory tree whose names are UCS-2 big-endian, up to 64 characters.
Windows’ CD-ROM filesystem doesn’t do Rock Ridge. Without a Joliet tree it sees META_DAT.;1. cloudbase-init is looking
for meta-data, so it finds nothing. Here’s the layout of the ISO that works, with both trees, read out of a real one:
There is no Go library for this
I went looking before writing anything. As of March:
- kdomanski/iso9660: its README says it plainly, “Joliet extension is NOT supported”. Rock Ridge reading is still experimental.
- hooklift/iso9660: a reader only, last touched in 2019.
- bgrewell/iso-kit: new (January 2025) with a couple of stars. It says it does Joliet but calls itself early development.
- go-diskfs: the serious one, and what go-proxmox already used. It could write Rock Ridge. “Joliet extensions to iso9660” was sitting in the plans section of the README. Issue #73 asking for it had been open since 2020.
So, go-diskfs. The two extensions are independent on disk. The maintainer pointed that out in review, and he’s right. But Rock Ridge was already in the tree and half-working. Before bolting a second directory tree onto the finalize code, I wanted the first one to be correct.
Step 1: Rock Ridge up to spec (#353)
PR #353 merged April 10th. Most of the fixes were small. Each one is a good example of the spec being specific about something the code had been loose about:
-
Modes were hardcoded.
Mode()returned0o755for everything regardless of thePXentry. Now it reads thePXmode. It only falls back to fixed values for non-Rock Ridge images:func (de *directoryEntry) Mode() os.FileMode { for _, ext := range de.extensions { if px, ok := ext.(rockRidgePosixAttributes); ok { return px.mode } } // Fallback for non-Rock Ridge if de.isSubdirectory { return os.ModeDir | 0o755 } // ... symlink fallback return 0o755 } -
No way to get at the metadata.
Sys()now returns a*RockRidgeInfowith UID, GID, link count, mode and symlink target. That’s the standard Go way to expose filesystem-specific detail throughfs.FileInfo:type RockRidgeInfo struct { UID uint32 GID uint32 Nlink uint32 Mode os.FileMode Symlink string } -
REwas 8 bytes. The Relocated Directory entry is just a marker. It holds 2 bytes of signature, a length byte and a version byte, so its length is 4. The code allocated 8 and zero-filled the rest. Harmless to most readers, still wrong. -
Relocated directories were looked up too late. When a tree is deeper than 8 levels, the deep directory is moved. A
CL(child link) entry points at its new home. The reader checked path depth before resolving theCL, so relocated directories didn’t resolve at every level. The lookup moved ahead of the depth check. -
Symlinks were treated as files during finalize. A symlink’s target lives inline in the
SLentry. It has no data extent. But finalize put symlinks in the list of files to copy.os.Open()would follow the link and either fail on a dangling one or copy the target’s bytes. They’re now excluded from data allocation while still getting directory entries. -
Relative symlinks came out absolute. The
SLparser glued components together with a leading separator. A relativetarget.txtcame back as/target.txt. The fix only adds a separator once something is already in the path. Review asked for that repeated check to be pulled into a helper:appendComponent := func(component string) { if name != "" && name != "/" { name += "/" } name += component }
The more interesting part was testing. I wanted to check go-diskfs against a reference implementation instead of against
itself. The reference for Rock Ridge is xorriso. The PR as first written added
Docker-based tests in both directions. One built with go-diskfs and checked with isoinfo. The other built with xorriso
and read with go-diskfs. On top of that was a byte-level comparison of the PX and NM entries each one writes.
Getting xorriso into the test image hit a fun Alpine problem. cdrkit (which provides isoinfo) and xorriso both
provide mkisofs and refuse to install together. My workaround involved shuffling binaries around and editing
/etc/apk/world. That got a well-deserved “why the whole rigamarole” in review. The maintainer dropped cdrkit from the
image entirely. I rewrote the test to read back with go-diskfs instead of isoinfo. Much simpler.
Reading the metadata back looks like this:
entries, err := fs.ReadDir(".")
if err != nil {
log.Fatal(err)
}
for _, e := range entries {
fi, _ := e.Info()
rr, _ := fi.Sys().(*iso9660.RockRidgeInfo)
fmt.Printf("%-16s %v size=%d rr=%+v\n", fi.Name(), fi.Mode(), fi.Size(), rr)
}
meta-data -rw-rw-rw- size=49 rr=&{UID:0 GID:0 Nlink:0 Mode:-rw-rw-rw- Symlink:}
network-config -rw-rw-rw- size=46 rr=&{UID:0 GID:0 Nlink:0 Mode:-rw-rw-rw- Symlink:}
user-data -rw-rw-rw- size=37 rr=&{UID:0 GID:0 Nlink:0 Mode:-rw-rw-rw- Symlink:}
(That ISO was written on Windows, hence 0666. go-diskfs’s Windows stat shim returns zero for link count, UID and GID.
That’s on my list.)
Step 2: Joliet (#372)
PR #372 went up the day #353 merged and landed April 16th. It closed #73 after more than five years. On the write side it’s a single flag:
err = fs.Finalize(iso9660.FinalizeOptions{
RockRidge: true,
Joliet: true,
VolumeIdentifier: "cidata",
})
Under that flag, finalize does three things:
- builds a parallel directory tree with UCS-2 names
- generates Joliet L and M path tables
- writes an SVD with the UCS-2 level 3 escape sequence
Both trees point at the same file extents, so the data is only on disk once. On the read side go-diskfs detects a Joliet SVD by its escape sequence. It transparently reads the Joliet tree when Rock Ridge isn’t active.
Two review questions are worth repeating. They’re the kind of thing the spec makes easy to get wrong. First, Rock Ridge and Joliet aren’t mutually exclusive. A comment in my code claiming “Joliet entries never have SUSP” read like they were. The flag is per directory entry. Entries in the Joliet tree carry no SUSP. Entries in the primary tree carry the full Rock Ridge set. Second, with both El Torito and Joliet enabled, does the root directory location get bumped twice? Yes. The boot record and the SVD are each their own volume descriptor sector, and that deserved a comment.
go-proxmox switched makeCloudInitISO() over to Joliet: true and shipped it in
v0.5.0 on May 7th, pinned to go-diskfs v1.9.2.
Step 3: what Windows actually wanted (#391)
A day later go-proxmox#258 came in from
rgl, titled “0.5.0 creates an invalid iso file”. On Ubuntu, blkid showed this as the label:
/dev/sr0: BLOCK_SIZE="2048" UUID="2026-05-08-21-11-46-82" LABEL="M-fM-^MM-)M-fM-^QM-!M-gM-^QM-!" TYPE="iso9660"
That’s cat -v escaping for 9 bytes of UTF-8. Decode them and you get three CJK characters: 捩摡瑡. That’s what you get
when you take the six ASCII bytes of cidata (63 69 64 61 74 61) and read them as UCS-2 big-endian: 0x6369,
0x6461, 0x7461.
$ printf 'cidata' | iconv -f UCS-2BE -t UTF-8 | cat -v
M-fM-^MM-)M-fM-^QM-!M-gM-^QM-!
The Joliet SVD’s volume identifier is a UCS-2 field like everything else in that descriptor. go-diskfs was copying the
ASCII string straight in. Linux preferred the Joliet label and got garbage. cloud-init’s nocloud datasource never found a
volume called cidata. So the Joliet work had broken Linux guests too. That’s the opposite of the plan.
Fixing the label got Linux back. On Windows the label now showed correctly and dir D:\ listed the files. But reading
one failed with Cannot find path 'D:\meta-data' because it does not exist. An xorriso-built ISO worked. The second bug
was in the Joliet path table. Every directory gets a path table record, and the root’s identifier is a single byte. I’d
written 0x01. ECMA-119 says the root’s path table identifier is a single 0x00 byte. That applies in the Joliet tables
as much as the primary ones. My best read is that Windows resolves paths through the path table. It could enumerate the
root through its directory record but couldn’t resolve a file path through the bad table entry.
PR #391 fixes both. The identifier encoding is gated on the escape sequence. Review pointed out that not every SVD is a Joliet SVD:
func (v *supplementaryVolumeDescriptor) toBytes() []byte {
b := volumeDescriptorFirstBytes(volumeDescriptorSupplementary)
b[7] = v.volumeFlags
if isJolietSVD(v) {
copy(b[8:40], ucs2StringToBytes(v.systemIdentifier))
copy(b[40:72], ucs2StringToBytes(v.volumeIdentifier))
} else {
copy(b[8:40], v.systemIdentifier)
copy(b[40:72], v.volumeIdentifier)
}
// ...
}
and the path table root becomes "\x00". rgl confirmed it worked against a Windows guest running cloudbase-init. The
maintainer tagged go-diskfs v1.9.3 on May 14th, and go-proxmox v0.5.1 pinned it the same day.
Why the tests didn’t catch it
I had round-trip tests and xorriso tests, and neither caught a wrong label. The round-trip tests couldn’t. The reader and
writer had the same bug in opposite directions. parseSupplementaryVolumeDescriptor() read the identifier as raw bytes.
toBytes() wrote raw bytes. So a go-diskfs ISO read back by go-diskfs said cidata every time. A round trip through your
own code proves your reader and writer agree with each other. It says nothing about whether either one agrees with the
spec.
The thing that caught it was someone booting a real VM. I’d add tests that assert specific bytes against the spec instead
of only asserting that what goes in comes back out. The SVD identifier is UCS-2. The root path table entry is 00.
How I tested
To check this for the post I built the same three-file ISO (RockRidge: true, Joliet: true, VolumeIdentifier: "cidata")
with go-diskfs v1.9.2 and v1.9.3. I compared them on Windows 11 (build 26200) with Mount-DiskImage:
v1.9.2 SVD volume id bytes: 63 69 64 61 74 61 00 00 ...
Joliet L path table root: 01 00 1a 00 00 00 01 00 01 00
Get-Volume label: 捩摡瑡 dir D:\ lists 3 files Get-Content D:\meta-data -> Cannot find path
v1.9.3 SVD volume id bytes: 00 63 00 69 00 64 00 61 00 74 00 61 ...
Joliet L path table root: 01 00 1a 00 00 00 01 00 00 00
Get-Volume label: cidata Get-Content D:\meta-data -> instance-id: vm-100 ...
Same symptoms rgl saw in the VM. One extra thing turned up. The raw files are 57,354 bytes, but the primary volume
descriptor claims 29 sectors (59,392 bytes). Mount-DiskImage refused both as “corrupted and unreadable” until I padded
them to 29 full sectors. Proxmox and QEMU don’t care, but it’s another thing for the list.
Links
- diskfs/go-diskfs#353 - Rock Ridge support
- diskfs/go-diskfs#372 - Joliet support
- diskfs/go-diskfs#391 - Joliet SVD UCS-2 volume identifier and path table root
- luthermonson/go-proxmox#258 - “0.5.0 creates an invalid iso file”
- rgl/proxmox-clone-vm-example - rgl’s Windows + cloudbase-init example
- ECMA-119 - the ISO 9660 standard
- Rock Ridge Interchange Protocol (IEEE P1282)
- Joliet Specification
