diff --git a/README.md b/README.md
index 47a1a30c0..5a16ba211 100644
--- a/README.md
+++ b/README.md
@@ -139,7 +139,7 @@ The mounted view is constrained by the underlying filesystem, backing-stream acc
| [VirtualDiskConvert](Utilities/VirtualDiskConvert) | Convert virtual disks between supported input/output formats | Output is limited to formats/layouts with builders |
| [DiskDump](Utilities/DiskDump) | Inspect disks, partitioning and detected filesystems | Depends on the selected format and transport |
| [FileExtract](Utilities/FileExtract) | Extract files from supported disk/filesystem images | Depends on the selected format and transport |
-| [ISOCreate](Utilities/ISOCreate) | Build an ISO image from source files | Uses the ISO 9660 builder |
+| [ISOCreate](Utilities/ISOCreate) | Build an ISO or UDF image from source files | ISO 9660 by default; `-udf` selects UDF 2.01 |
The source tree contains further tools, including BCD, NTFS, VHD and VHDX diagnostics. Consult each utility's help for arguments and restrictions.
diff --git a/Tests/LibraryTests/LibraryTests.csproj b/Tests/LibraryTests/LibraryTests.csproj
index 014633ba8..f6dd9e0ab 100644
--- a/Tests/LibraryTests/LibraryTests.csproj
+++ b/Tests/LibraryTests/LibraryTests.csproj
@@ -31,6 +31,7 @@
+
diff --git a/Tests/LibraryTests/Utilities/ISOCreateTest.cs b/Tests/LibraryTests/Utilities/ISOCreateTest.cs
new file mode 100644
index 000000000..b7005c672
--- /dev/null
+++ b/Tests/LibraryTests/Utilities/ISOCreateTest.cs
@@ -0,0 +1,138 @@
+// SPDX-License-Identifier: MIT
+using System;
+using System.IO;
+using DiscUtils.Iso9660;
+using DiscUtils.Udf;
+using Xunit;
+
+namespace LibraryTests.Utilities;
+
+[Collection("ISOCreate command line")]
+public class ISOCreateTest
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void DefaultBuildsIsoWithExistingDirectoryBehavior(bool trailingSeparator)
+ {
+ using var tree = new SourceTree();
+ // The filename extension must not select UDF mode.
+ var output = Path.Combine(tree.Root, "default.udf");
+ new TestProgram().Execute("-q", "-vl", "ISO_TEST", output, tree.SourcePath(trailingSeparator));
+
+ using var image = File.OpenRead(output);
+ Assert.True(CDReader.Detect(image));
+ Assert.False(UdfReader.Detect(image));
+ using var isoReader = new CDReader(image, false);
+ Assert.Equal("ISO_TEST", isoReader.VolumeLabel);
+ using var reader = new CDReader(image, true);
+ Assert.False(reader.HasBootImage);
+ Assert.Equal("hello", Helpers.Helpers.GetFileContent("hello.txt", reader));
+ Assert.Equal("nested", Helpers.Helpers.GetFileContent("folder\\child.txt", reader));
+ Assert.Equal("unicode", Helpers.Helpers.GetFileContent("字幕\\繁體中文.txt", reader));
+ Assert.Equal(0, reader.GetFileLength("zero.txt"));
+ Assert.False(reader.DirectoryExists("empty"));
+ Assert.False(reader.DirectoryExists("folder\\empty"));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void UdfSwitchPreservesFilesNamesAndEmptyDirectories(bool trailingSeparator)
+ {
+ using var tree = new SourceTree();
+ var output = Path.Combine(tree.Root, "udf.iso");
+ new TestProgram().Execute("-q", "-udf", "-vollabel", "UDF_TEST", output, tree.SourcePath(trailingSeparator));
+
+ using var image = File.OpenRead(output);
+ Assert.True(UdfReader.Detect(image));
+ Assert.False(CDReader.Detect(image));
+ using var reader = new UdfReader(image);
+ Assert.Equal("UDF_TEST", reader.VolumeLabel);
+ Assert.Equal("hello", Helpers.Helpers.GetFileContent("hello.txt", reader));
+ Assert.Equal("nested", Helpers.Helpers.GetFileContent("folder\\child.txt", reader));
+ Assert.Equal("unicode", Helpers.Helpers.GetFileContent("字幕\\繁體中文.txt", reader));
+ Assert.Equal(0, reader.GetFileLength("zero.txt"));
+ Assert.True(reader.DirectoryExists("empty"));
+ Assert.True(reader.DirectoryExists("folder\\empty"));
+ }
+
+ [Fact]
+ public void UdfSwitchWorksWithoutVolumeLabel()
+ {
+ using var tree = new SourceTree();
+ var output = Path.Combine(tree.Root, "udf.iso");
+ new TestProgram().Execute("-q", output, tree.Source, "-udf");
+
+ using var image = File.OpenRead(output);
+ using var reader = new UdfReader(image);
+ Assert.Equal("DISC", reader.VolumeLabel);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void UdfBootImageFailsBeforeOpeningOrOverwritingOutput(bool existingOutput)
+ {
+ using var tree = new SourceTree();
+ var output = Path.Combine(tree.Root, "udf.iso");
+ var original = new byte[] { 1, 2, 3 };
+ if (existingOutput)
+ {
+ File.WriteAllBytes(output, original);
+ }
+
+ var previousExitCode = Environment.ExitCode;
+ try
+ {
+ Environment.ExitCode = 0;
+ new TestProgram().Execute("-q", "-udf", output, tree.Source, Path.Combine(tree.Root, "missing-boot.img"));
+ Assert.Equal(1, Environment.ExitCode);
+ if (existingOutput)
+ {
+ Assert.Equal(original, File.ReadAllBytes(output));
+ }
+ else
+ {
+ Assert.False(File.Exists(output));
+ }
+ }
+ finally
+ {
+ Environment.ExitCode = previousExitCode;
+ }
+ }
+
+ private sealed class TestProgram : ISOCreate.Program
+ {
+ public void Execute(params string[] args) => Run(args);
+ }
+
+ private sealed class SourceTree : IDisposable
+ {
+ public string Root { get; } = Path.Combine(Path.GetTempPath(), "isocreate-" + Guid.NewGuid().ToString("N"));
+ public string Source { get; }
+
+ public SourceTree()
+ {
+ Source = Path.Combine(Root, "source");
+ Directory.CreateDirectory(Path.Combine(Source, "empty"));
+ Directory.CreateDirectory(Path.Combine(Source, "folder", "empty"));
+ Directory.CreateDirectory(Path.Combine(Source, "字幕"));
+ File.WriteAllText(Path.Combine(Source, "hello.txt"), "hello");
+ File.WriteAllText(Path.Combine(Source, "folder", "child.txt"), "nested");
+ File.WriteAllText(Path.Combine(Source, "字幕", "繁體中文.txt"), "unicode");
+ File.WriteAllBytes(Path.Combine(Source, "zero.txt"), new byte[0]);
+ }
+
+ public string SourcePath(bool trailingSeparator) => trailingSeparator ? Source + Path.DirectorySeparatorChar : Source;
+
+ public void Dispose() => Directory.Delete(Root, recursive: true);
+ }
+}
+
+// These tests temporarily set the process exit code to exercise command-line failures.
+[CollectionDefinition("ISOCreate command line", DisableParallelization = true)]
+public class ISOCreateCollection
+{
+}
diff --git a/Utilities/ISOCreate/ISOCreate.csproj b/Utilities/ISOCreate/ISOCreate.csproj
index e9857b7c2..ff41fb75b 100644
--- a/Utilities/ISOCreate/ISOCreate.csproj
+++ b/Utilities/ISOCreate/ISOCreate.csproj
@@ -11,6 +11,8 @@
+
+
diff --git a/Utilities/ISOCreate/Program.cs b/Utilities/ISOCreate/Program.cs
index 36c95d1ac..ff84beef4 100644
--- a/Utilities/ISOCreate/Program.cs
+++ b/Utilities/ISOCreate/Program.cs
@@ -24,15 +24,17 @@
using System.IO;
using DiscUtils.Common;
using DiscUtils.Iso9660;
+using DiscUtils.Udf;
namespace ISOCreate;
class Program : ProgramBase
{
- private CommandLineParameter _isoFileParam;
+ private CommandLineParameter _imageFileParam;
private CommandLineParameter _srcDir;
private CommandLineParameter _bootImage;
private CommandLineSwitch _volLabelSwitch;
+ private CommandLineSwitch _udfSwitch;
static void Main(params string[] args)
{
@@ -42,15 +44,17 @@ static void Main(params string[] args)
protected override StandardSwitches DefineCommandLine(CommandLineParser parser)
{
- _isoFileParam = new CommandLineParameter("iso_file", "The ISO file to create.", false);
- _srcDir = new CommandLineParameter("sourcedir", "The directory to be added to the ISO", false);
- _bootImage = new CommandLineParameter("bootimage", "The bootable disk image, to create a bootable ISO", true);
- _volLabelSwitch = new CommandLineSwitch("vl", "vollabel", "label", "Volume Label for the ISO file.");
+ _imageFileParam = new CommandLineParameter("image_file", "The ISO or UDF image file to create.", false);
+ _srcDir = new CommandLineParameter("sourcedir", "The directory to be added to the image.", false);
+ _bootImage = new CommandLineParameter("bootimage", "The bootable disk image, to create a bootable ISO (unavailable with -udf).", true);
+ _volLabelSwitch = new CommandLineSwitch("vl", "vollabel", "label", "Volume label for the image file.");
+ _udfSwitch = new CommandLineSwitch("udf", null, "Create a UDF 2.01 image instead of the default ISO 9660 image.");
- parser.AddParameter(_isoFileParam);
+ parser.AddParameter(_imageFileParam);
parser.AddParameter(_srcDir);
parser.AddParameter(_bootImage);
parser.AddSwitch(_volLabelSwitch);
+ parser.AddSwitch(_udfSwitch);
return StandardSwitches.Default;
}
@@ -64,6 +68,26 @@ protected override void DoRun()
Environment.Exit(1);
}
+ if (_udfSwitch.IsPresent)
+ {
+ if (_bootImage.IsPresent)
+ {
+ Console.Error.WriteLine("The bootimage parameter cannot be used with -udf. Boot images are supported only for ISO 9660 images.");
+ Environment.ExitCode = 1;
+ return;
+ }
+
+ var udfBuilder = new UdfBuilder();
+ if (_volLabelSwitch.IsPresent)
+ {
+ udfBuilder.VolumeIdentifier = _volLabelSwitch.Value;
+ }
+
+ PopulateFromFolder(di, di.FullName, udfBuilder.AddFile, udfBuilder.AddDirectory);
+ udfBuilder.Build(_imageFileParam.Value);
+ return;
+ }
+
var builder = new CDBuilder();
if (_volLabelSwitch.IsPresent)
@@ -76,21 +100,24 @@ protected override void DoRun()
builder.SetBootImage(new FileStream(_bootImage.Value, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 2 << 20, useAsync: false), BootDeviceEmulation.NoEmulation, 0);
}
- PopulateFromFolder(builder, di, di.FullName);
+ PopulateFromFolder(di, di.FullName, (name, sourcePath) => builder.AddFile(name, sourcePath));
- builder.Build(_isoFileParam.Value);
+ builder.Build(_imageFileParam.Value);
}
- private static void PopulateFromFolder(CDBuilder builder, DirectoryInfo di, string basePath)
+ private static void PopulateFromFolder(DirectoryInfo di, string basePath, Action addFile, Action addDirectory = null)
{
foreach (var file in di.GetFiles())
{
- builder.AddFile(file.FullName[basePath.Length..], file.FullName);
+ var name = file.FullName[basePath.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ addFile(name, file.FullName);
}
foreach (var dir in di.GetDirectories())
{
- PopulateFromFolder(builder, dir, basePath);
+ var name = dir.FullName[basePath.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ addDirectory?.Invoke(name);
+ PopulateFromFolder(dir, basePath, addFile, addDirectory);
}
}
}
diff --git a/Utilities/ISOCreate/README.md b/Utilities/ISOCreate/README.md
new file mode 100644
index 000000000..1b4a27d6b
--- /dev/null
+++ b/Utilities/ISOCreate/README.md
@@ -0,0 +1,29 @@
+# ISOCreate
+
+Create an optical disc image from a source directory:
+
+```text
+ISOCreate [options] image_file sourcedir [bootimage]
+```
+
+The default is ISO 9660 with Joliet, using the existing ISO builder. Add `-udf`
+to create a UDF 2.01 image. The output filename extension does not select the
+format.
+
+```text
+ISOCreate -vl MY_DISC output.iso source
+ISOCreate -udf -vl MY_DISC output.iso source
+```
+
+`-vl label` (or `-vollabel label`) sets the volume label for either format.
+UDF labels use the builder's 31-byte encoded label limit (including the CS0
+compression identifier); the default UDF label is `DISC`.
+
+UDF mode includes files, subdirectories and empty directories. It writes the
+complete image and file contents to the output file.
+
+The optional `bootimage` parameter creates an El Torito bootable ISO, as before.
+Combining it with `-udf` reports an error and exits with code 1 before writing the
+output image. UDF mode does not create a bootable or hybrid ISO/UDF image.
+
+Use `ISOCreate -help` to see all options.