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.