Luther Monson, Staff Software Engineer at SUSE

Luther Monson

Go by way of PHP · Containers · VMs · Windows · Kubernetes

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.

10-Minute Read

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 ;1 version 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:

    • PX for mode/uid/gid/links
    • NM for the real name
    • SL for symlink targets
    • TF for timestamps
    • CL/PL/RE for relocating directories nested deeper than ISO 9660’s 8-level limit
    • an ER entry 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 (%/@, %/C or %/E for 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:

Sector map of a go-diskfs cidata ISO with Rock Ridge and Joliet

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() returned 0o755 for everything regardless of the PX entry. Now it reads the PX mode. 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 *RockRidgeInfo with UID, GID, link count, mode and symlink target. That’s the standard Go way to expose filesystem-specific detail through fs.FileInfo:

    type RockRidgeInfo struct {
        UID     uint32
        GID     uint32
        Nlink   uint32
        Mode    os.FileMode
        Symlink string
    }
    
  • RE was 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 the CL, 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 SL entry. 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 SL parser glued components together with a leading separator. A relative target.txt came 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.

Recent Posts

Categories

About

Luther is a 20+ year software veteran specializing in backend APIs and architecting products. Employed at SUSE as the Team Lead for the Containers on Windows Team working on Rancher + Kubernetes