Module: Omnizip::Formats::Iso::Joliet

Defined in:
lib/omnizip/formats/iso/joliet.rb

Overview

Joliet Extensions for ISO 9660

Implements Microsoft's Joliet extensions to support long filenames (up to 64 characters) and Unicode (UCS-2) encoding in ISO 9660 images.

Joliet creates a parallel directory structure in a Supplementary Volume Descriptor with UCS-2 encoded filenames, allowing Windows systems to display proper long filenames while maintaining ISO 9660 compatibility.

Constant Summary collapse

ESCAPE_SEQUENCE_LEVEL_3 =

Joliet escape sequences for UCS-2 encoding Level 1: %/@ Level 2: %/C Level 3: %/E (most common, supports UCS-2)

"%/E"
MAX_FILENAME_LENGTH =

Maximum Joliet filename length in characters

64

Class Method Summary collapse

Class Method Details

.build_directory_record(name, entry_info, is_directory: false) ⇒ String

Build Joliet directory record

Parameters:

  • name (String)

    Entry name

  • entry_info (Hash)

    Entry information

  • is_directory (Boolean) (defaults to: false)

    Is this a directory

Returns:

  • (String)

    Joliet directory record



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/omnizip/formats/iso/joliet.rb', line 59

def self.build_directory_record(name, , is_directory: false)
  # Encode name to UCS-2
  name_ucs2 = encode_ucs2(name)
  name_len = name_ucs2.bytesize

  # Calculate record length
  # No padding needed for UCS-2 names (always even length)
  record_len = 33 + name_len

  record = +""

  # Byte 0: Length of directory record
  record << [record_len].pack("C")

  # Byte 1: Extended attribute record length
  record << [0].pack("C")

  # Bytes 2-9: Location of extent (both-endian)
  location = [:location] || 0
  record << [location].pack("V")
  record << [location].pack("N")

  # Bytes 10-17: Data length (both-endian)
  data_length = [:size] || 0
  record << [data_length].pack("V")
  record << [data_length].pack("N")

  # Bytes 18-24: Recording date and time
  mtime = [:mtime] || [:stat]&.mtime || Time.now
  record << encode_record_datetime(mtime)

  # Byte 25: File flags
  flags = 0
  flags |= Iso::FLAG_DIRECTORY if is_directory
  record << [flags].pack("C")

  # Byte 26: File unit size
  record << [0].pack("C")

  # Byte 27: Interleave gap size
  record << [0].pack("C")

  # Bytes 28-31: Volume sequence number (both-endian)
  record << [1].pack("v")
  record << [1].pack("n")

  # Byte 32: Length of file identifier
  record << [name_len].pack("C")

  # Bytes 33+: File identifier (UCS-2 encoded)
  record << name_ucs2

  record
end

.build_supplementary_vd(primary_vd, _root_dir) ⇒ String

Build Joliet supplementary volume descriptor

Parameters:

  • primary_vd (String)

    Primary volume descriptor data

  • root_dir (Hash)

    Root directory information

Returns:

  • (String)

    Joliet SVD (2048 bytes)



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/omnizip/formats/iso/joliet.rb', line 151

def self.build_supplementary_vd(primary_vd, _root_dir)
  # Start with primary VD as template
  svd = primary_vd.dup

  # Change type to supplementary
  svd[0] = [Iso::VD_SUPPLEMENTARY].pack("C")

  # Add Joliet escape sequence
  # Bytes 88-90: Escape sequences
  svd[88, 3] = ESCAPE_SEQUENCE_LEVEL_3

  # Encode volume identifier to UCS-2
  volume_id = primary_vd[40, 32].strip
  volume_id_ucs2 = encode_ucs2(volume_id, 16)
  svd[40, 32] = pad_ucs2_string(volume_id_ucs2, 32)

  # Update root directory record with UCS-2 name
  # Root is always "\x00" so no change needed

  svd
end

.decode_ucs2(ucs2_data) ⇒ String

Decode UCS-2 string to UTF-8

Parameters:

  • ucs2_data (String)

    UCS-2 encoded data

Returns:

  • (String)

    UTF-8 string



44
45
46
47
48
49
50
51
# File 'lib/omnizip/formats/iso/joliet.rb', line 44

def self.decode_ucs2(ucs2_data)
  ucs2_data.force_encoding("UTF-16BE").encode("UTF-8")
rescue Encoding::UndefinedConversionError
  # Fallback if decoding fails
  ucs2_data.force_encoding("UTF-16BE").encode("UTF-8",
                                              undef: :replace,
                                              replace: "?")
end

.encode_record_datetime(time) ⇒ String

Encode recording date/time (7-byte format)

Parameters:

  • time (Time)

    Time to encode

Returns:

  • (String)

    7-byte encoded time



190
191
192
193
194
195
196
197
198
199
200
# File 'lib/omnizip/formats/iso/joliet.rb', line 190

def self.encode_record_datetime(time)
  [
    time.year - 1900,
    time.month,
    time.day,
    time.hour,
    time.min,
    time.sec,
    0, # GMT offset
  ].pack("C7")
end

.encode_ucs2(str, max_length = MAX_FILENAME_LENGTH) ⇒ String

Encode string to UCS-2 (UTF-16BE)

Parameters:

  • str (String)

    String to encode

  • max_length (Integer) (defaults to: MAX_FILENAME_LENGTH)

    Maximum length in characters

Returns:

  • (String)

    UCS-2 encoded string



29
30
31
32
33
34
35
36
37
38
# File 'lib/omnizip/formats/iso/joliet.rb', line 29

def self.encode_ucs2(str, max_length = MAX_FILENAME_LENGTH)
  # Truncate to max length
  str = str[0, max_length] if str.length > max_length

  # Encode to UTF-16BE (UCS-2)
  str.encode("UTF-16BE")
rescue Encoding::UndefinedConversionError
  # Fallback to ASCII if conversion fails
  str.encode("UTF-16BE", undef: :replace, replace: "_")
end

.pad_ucs2_string(ucs2_str, byte_length) ⇒ String

Pad UCS-2 string to specified length

Parameters:

  • ucs2_str (String)

    UCS-2 string

  • byte_length (Integer)

    Target length in bytes

Returns:

  • (String)

    Padded string



178
179
180
181
182
183
184
# File 'lib/omnizip/formats/iso/joliet.rb', line 178

def self.pad_ucs2_string(ucs2_str, byte_length)
  if ucs2_str.bytesize > byte_length
    ucs2_str[0, byte_length]
  else
    ucs2_str + (" ".encode("UTF-16BE") * ((byte_length - ucs2_str.bytesize) / 2))
  end
end

.requires_joliet?(name) ⇒ Boolean

Check if filename requires Joliet

Parameters:

  • name (String)

    Filename

Returns:

  • (Boolean)

    true if Joliet needed



135
136
137
138
139
140
141
142
143
144
# File 'lib/omnizip/formats/iso/joliet.rb', line 135

def self.requires_joliet?(name)
  # Check if name exceeds ISO 9660 Level 2 limits
  return true if name.length > 31

  # Check if name contains non-ASCII or lowercase
  return true if /[^A-Z0-9_.-]/.match?(name)

  # Check if name contains Unicode
  name.encoding != Encoding::ASCII && name.bytes.any? { |b| b > 127 }
end

.sanitize_filename(name) ⇒ String

Sanitize filename for Joliet

Parameters:

  • name (String)

    Original filename

Returns:

  • (String)

    Sanitized filename



118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/omnizip/formats/iso/joliet.rb', line 118

def self.sanitize_filename(name)
  # Joliet allows most Unicode characters
  # Maximum 64 characters
  # Disallow: / * ? < > | " : \

  sanitized = name.gsub(/[\/*?<>|":\\]/, "_")
  if sanitized.length > MAX_FILENAME_LENGTH
    sanitized = sanitized[0,
                          MAX_FILENAME_LENGTH]
  end
  sanitized
end