/*-
* Copyright (C) 2007-2014 Erik Larsson
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.catacombae.hfsexplorer.tools;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.util.Arrays;
import java.util.Date;
import org.catacombae.dmg.encrypted.ReadableCEncryptedEncodingStream;
import org.catacombae.dmg.sparsebundle.ReadableSparseBundleStream;
import org.catacombae.dmg.sparseimage.ReadableSparseImageStream;
import org.catacombae.dmg.sparseimage.SparseImageRecognizer;
import org.catacombae.dmg.udif.UDIFDetector;
import org.catacombae.dmg.udif.UDIFRandomAccessStream;
import org.catacombae.hfsexplorer.HFSExplorer;
import org.catacombae.hfsexplorer.IOUtil;
import org.catacombae.hfsexplorer.Java7Util;
import org.catacombae.hfsexplorer.fs.AppleSingleBuilder;
import org.catacombae.hfsexplorer.fs.AppleSingleBuilder.AppleSingleVersion;
import org.catacombae.hfsexplorer.fs.AppleSingleBuilder.FileSystem;
import org.catacombae.hfsexplorer.fs.AppleSingleBuilder.FileType;
import org.catacombae.storage.io.win32.ReadableWin32FileStream;
import org.catacombae.io.ReadableFileStream;
import org.catacombae.io.ReadableRandomAccessStream;
import org.catacombae.io.RuntimeIOException;
import org.catacombae.storage.io.DataLocator;
import org.catacombae.storage.io.ReadableStreamDataLocator;
import org.catacombae.storage.io.SubDataLocator;
import org.catacombae.storage.fs.FSEntry;
import org.catacombae.storage.fs.FSFile;
import org.catacombae.storage.fs.FSFolder;
import org.catacombae.storage.fs.FSFork;
import org.catacombae.storage.fs.FSForkType;
import org.catacombae.storage.fs.FSLink;
import org.catacombae.storage.fs.FileSystemDetector;
import org.catacombae.storage.fs.FileSystemHandler;
import org.catacombae.storage.fs.FileSystemHandlerFactory;
import org.catacombae.storage.fs.FileSystemHandlerFactory.CustomAttribute;
import org.catacombae.storage.fs.FileSystemMajorType;
import org.catacombae.storage.ps.Partition;
import org.catacombae.storage.ps.PartitionSystemDetector;
import org.catacombae.storage.ps.PartitionSystemHandler;
import org.catacombae.storage.ps.PartitionSystemHandlerFactory;
import org.catacombae.storage.ps.PartitionSystemType;
import org.catacombae.storage.ps.PartitionType;
/**
* Command line program which extracts all or part of the contents of a
* HFS/HFS+/HFSX file system to a specified path.
*
* @author <a href="http://www.catacombae.org/" target="_top">Erik Larsson</a>
*/
public class UnHFS {
private static boolean debug = false;
private static final int RETVAL_NEED_PASSWORD = 10;
private static final int RETVAL_INCORRECT_PASSWORD = 11;
/**
* Prints program usage instructions to the PrintStream <code>ps</code>.
*
* @param ps the PrintStream to print usage instruction to.
*/
private static void printUsage(PrintStream ps) {
// 80 <-------------------------------------------------------------------------------->
ps.println("unhfs " + HFSExplorer.VERSION);
ps.println(HFSExplorer.COPYRIGHT.replaceAll("\u00A9", "(C)"));
for(String s : HFSExplorer.NOTICES) {
ps.println(s.replaceAll("\u00A9", "(C)"));
}
ps.println();
ps.println("usage: unhfs [options...] <input file>");
ps.println(" Input file can be in raw, UDIF (.dmg) and/or encrypted format.");
ps.println(" Options:");
ps.println(" -o <output dir>");
ps.println(" The target directory in the local file system where all extracted files");
ps.println(" should go.");
ps.println(" When this option is omitted, all files go to the currect working");
ps.println(" directory.");
ps.println(" -fsroot <path to extract>");
ps.println(" A POSIX path in the HFS file system that should be extracted.");
ps.println(" Example which extracts all the contents of joe's user dir from a backup");
ps.println(" disk image to the current directory:");
ps.println(" unhfs -o . -fsroot /Users/joe FullBackup.dmg");
ps.println(" When this option is omitted, all the contents of the file system is");
ps.println(" extracted.");
ps.println(" -create");
ps.println(" If the -fsroot path refers to a folder, create that folder inside");
ps.println(" the output directory, rather than extracting into the output directory");
ps.println(" itself.");
ps.println(" -resforks NONE|APPLEDOUBLE");
ps.println(" Determines whether resource forks should be extracted, and in what");
ps.println(" format. Currently only the APPLEDOUBLE format, which puts each resource");
ps.println(" fork in its own file with the '._' prefix, is supported.");
ps.println(" When this option is omitted, no resource forks are extracted.");
ps.println(" -partition <partition number>");
ps.println(" If the input file is partitioned, extracts files from the specified HFS");
ps.println(" partition. Partitions are numbered from 0 and up.");
ps.println(" When this options is omitted, the application chooses the first");
ps.println(" available HFS partition.");
ps.println(" -password <password>");
ps.println(" Specifies the password for an encrypted image. The special marker \"-\" ");
ps.println(" causes the password to be read from stdin.");
ps.println(" -v");
ps.println(" Verbose mode. Prints the POSIX path of every extracted file to stdout.");
ps.println(" --");
ps.println(" Signals that there are no more option arguments. Useful for accessing");
ps.println(" input files with names identical to an option signature.");
}
/**
* UnHFS entry point. The main method's only responsibility is to parse and
* validate program arguments. It then passes them on to the static method
* unhfs(...), which contains the actual program logic.
*
* @param args program arguments.
*/
public static void main(String[] args) {
String outputDirname = ".";
String fsRoot = "/";
boolean extractFolderDirectly = true;
boolean extractResourceForks = false;
boolean verbose = false;
int partitionNumber = -1; // -1 means search for first supported partition
char[] password = null;
int i;
for(i = 0; i < args.length; ++i) {
String curArg = args[i];
if(curArg.equals("-o")) {
if(i+1 < args.length)
outputDirname = args[++i];
else {
printUsage(System.err);
System.exit(1);
}
}
else if(curArg.equals("-fsroot")) {
if(i+1 < args.length)
fsRoot = args[++i];
else {
printUsage(System.err);
System.exit(1);
}
}
else if(curArg.equals("-create")) {
extractFolderDirectly = false;
}
else if(curArg.equals("-resforks")) {
if(i+1 < args.length) {
String value = args[++i];
if(value.equalsIgnoreCase("NONE")) {
extractResourceForks = false;
}
else if(value.equalsIgnoreCase("APPLEDOUBLE")) {
extractResourceForks = true;
}
else {
System.err.println("Error: Invalid value \"" + value +
"\" for -resforks!");
printUsage(System.err);
System.exit(1);
}
}
else {
printUsage(System.err);
System.exit(1);
}
}
else if(curArg.equals("-partition")) {
if(i+1 < args.length) {
try {
partitionNumber = Integer.parseInt(args[++i]);
} catch(NumberFormatException nfe) {
System.err.println("Error: Invalid partition number \"" +
args[i] + "\"!");
printUsage(System.err);
System.exit(1);
}
}
else {
printUsage(System.err);
System.exit(1);
}
}
else if(curArg.equals("-password")) {
if(i+1 < args.length) {
password = args[++i].toCharArray();
if(password.length == 1 && password[0] == '-') {
/* Read password from stdin. */
InputStreamReader r = new InputStreamReader(System.in);
char[] tmp = new char[4096];
int offset = 0;
int readLength = 0;
try {
while((readLength = r.read(tmp, offset,
tmp.length - offset)) > 0)
{
System.err.println("readLength: " + readLength);
char[] newTmp = new char[tmp.length * 2];
System.arraycopy(tmp, 0, newTmp, 0, tmp.length);
Arrays.fill(tmp, '\0');
offset += readLength;
tmp = newTmp;
}
} catch(IOException ex) {
System.err.println("Got IOException while " +
"reading password from stdin:");
ex.printStackTrace();
}
int passwordLength = offset;
char[] lineSeparator =
System.getProperty("line.separator").
toCharArray();
boolean trailingLineSeparator = true;
for(int j = 0; j < lineSeparator.length; ++j) {
int lineSeparatorIndex =
lineSeparator.length - 1 - j;
int tmpIndex =
passwordLength - 1 - j;
if(tmp[tmpIndex] !=
lineSeparator[lineSeparatorIndex])
{
trailingLineSeparator = false;
break;
}
}
if(trailingLineSeparator) {
passwordLength -= lineSeparator.length;
}
password = new char[passwordLength];
System.arraycopy(tmp, 0, password, 0, passwordLength);
Arrays.fill(tmp, '\0');
}
}
else {
printUsage(System.err);
System.exit(1);
}
}
else if(curArg.equals("-v")) {
verbose = true;
}
else if(curArg.equals("--")) {
++i;
break;
}
else
break;
}
if(i != args.length-1) {
printUsage(System.err);
System.exit(1);
}
String inputFilename = args[i];
File inputFile = new File(inputFilename);
if(!inputFile.isDirectory() &&
!(inputFile.exists() && inputFile.canRead()))
{
System.err.println("Error: Input file \"" + inputFilename + "\" can not be read!");
printUsage(System.err);
System.exit(1);
}
File outputDir = new File(outputDirname);
if(!(outputDir.exists() && outputDir.isDirectory())) {
System.err.println("Error: Invalid output directory \"" + outputDirname + "\"!");
printUsage(System.err);
System.exit(1);
}
ReadableRandomAccessStream inputStream;
if(inputFile.isDirectory()) {
inputStream = new ReadableSparseBundleStream(inputFile);
}
else if(ReadableWin32FileStream.isSystemSupported())
inputStream = new ReadableWin32FileStream(inputFilename);
else
inputStream = new ReadableFileStream(inputFilename);
try {
unhfs(System.out, inputStream, outputDir, fsRoot, password,
extractFolderDirectly, extractResourceForks, partitionNumber, verbose);
System.exit(0);
} catch(RuntimeIOException e) {
System.err.println("Exception while executing main routine:");
e.printStackTrace();
System.exit(1);
}
}
/**
* The main routine in the program, which gets invoked after arguments
* parsing is complete. The routine expects all arguments to be fully parsed
* and valid.
*
* @param outputStream the PrintStream where all the messages will go
* (should normally be System.out).
* @param inFileStream the stream containing the file system data.
* @param outputDir
* @param fsRoot
* @param password the password used to unlock an encrypted image.
* @param extractFolderDirectly if fsRoot is a folder, extract directly into outputDir?
* @param extractResourceForks
* @param partitionNumber
* @param verbose
* @throws org.catacombae.io.RuntimeIOException
*/
public static void unhfs(PrintStream outputStream,
ReadableRandomAccessStream inFileStream, File outputDir,
String fsRoot, char[] password, boolean extractFolderDirectly,
boolean extractResourceForks, int partitionNumber, boolean verbose)
throws RuntimeIOException {
// First detect any outer layers of UDIF and/or encryption.
logDebug("Trying to detect encrypted structure...");
if(ReadableCEncryptedEncodingStream.isCEncryptedEncoding(inFileStream)) {
if(password != null) {
try {
ReadableCEncryptedEncodingStream stream =
new ReadableCEncryptedEncodingStream(inFileStream, password);
inFileStream = stream;
} catch(Exception e) {
// TODO: Differentiate between exceptions...
System.err.println("Incorrect password for encrypted image.");
System.exit(RETVAL_INCORRECT_PASSWORD);
}
}
else {
System.err.println("Image is encrypted, and no password was specified.");
System.exit(RETVAL_NEED_PASSWORD);
}
}
logDebug("Trying to detect sparseimage structure...");
if(SparseImageRecognizer.isSparseImage(inFileStream)) {
try {
ReadableSparseImageStream stream =
new ReadableSparseImageStream(inFileStream);
inFileStream = stream;
} catch(Exception e) {
System.err.println("Exception while creating readable " +
"sparseimage stream:");
e.printStackTrace();
System.exit(1);
}
}
logDebug("Trying to detect UDIF structure...");
if(UDIFDetector.isUDIFEncoded(inFileStream)) {
UDIFRandomAccessStream stream = null;
try {
stream = new UDIFRandomAccessStream(inFileStream);
inFileStream = stream;
} catch(Exception e) {
e.printStackTrace();
System.err.println("Unhandled exception while trying to load UDIF wrapper.");
System.exit(1);
}
}
DataLocator inputDataLocator = new ReadableStreamDataLocator(inFileStream);
PartitionSystemType[] psTypes =
PartitionSystemDetector.detectPartitionSystem(inputDataLocator,
false);
if(psTypes.length >= 1) {
outer:
for(PartitionSystemType chosenType : psTypes) {
PartitionSystemHandlerFactory fact = chosenType.createDefaultHandlerFactory();
PartitionSystemHandler psHandler =
fact.createHandler(inputDataLocator);
if(psHandler.getPartitionCount() > 0) {
Partition[] partitionsToProbe;
if(partitionNumber >= 0) {
if(partitionNumber < psHandler.getPartitionCount()) {
partitionsToProbe = new Partition[] { psHandler.getPartition(partitionNumber) };
}
else {
break;
}
}
else if(partitionNumber == -1) {
partitionsToProbe = psHandler.getPartitions();
}
else {
System.err.println("Invalid partition number: " + partitionNumber);
System.exit(1);
return;
}
for(Partition p : partitionsToProbe) {
if(p.getType() == PartitionType.APPLE_HFS_CONTAINER) {
// DataLocator subDataLocator =
// new SubDataLocator(inputDataLocator, p.getStartOffset(), p.getLength());
// ContainerHandlerFactory chFact =
// p.getType().getAssociatedContainerType().createDefaultHandlerFactory();
// ContainerHandler ch = chFact.createHandler(subDataLocator);
// if(ch.containsFileSystem()) {
// FileSystemMajorType fsType = ch.detectFileSystemType();
// switch(fsType) {
// case APPLE_HFS:
// case APPLE_HFS_PLUS:
// case APPLE_HFSX:
// inputDataLocator = subDataLocator;
// break outer;
// default:
// }
// }
inputDataLocator =
new SubDataLocator(inputDataLocator, p.getStartOffset(), p.getLength());
break outer;
}
else if(p.getType() == PartitionType.APPLE_HFSX) {
inputDataLocator =
new SubDataLocator(inputDataLocator, p.getStartOffset(), p.getLength());
break outer;
}
}
}
}
}
FileSystemMajorType[] fsTypes = FileSystemDetector.detectFileSystem(inputDataLocator);
FileSystemHandlerFactory fact = null;
outer:
for(FileSystemMajorType type : fsTypes) {
switch(type) {
case APPLE_HFS:
case APPLE_HFS_PLUS:
case APPLE_HFSX:
fact = type.createDefaultHandlerFactory();
break outer;
default:
}
}
if(fact == null) {
System.err.println("No HFS file system found.");
System.exit(1);
}
CustomAttribute posixFilenamesAttribute =
fact.getCustomAttribute("POSIX_FILENAMES");
if(posixFilenamesAttribute == null) {
System.err.println("Unexpected: HFS-ish file system handler does " +
"not support POSIX_FILENAMES attribute.");
System.exit(1);
return;
}
fact.getCreateAttributes().setBooleanAttribute(posixFilenamesAttribute,
true);
FileSystemHandler fsHandler = fact.createHandler(inputDataLocator);
logDebug("Getting entry by posix path: \"" + fsRoot + "\"");
FSEntry entry = fsHandler.getEntryByPosixPath(fsRoot);
if(entry instanceof FSFolder) {
FSFolder folder = (FSFolder)entry;
File dirForFolder;
String folderName = folder.getName();
if(extractFolderDirectly || folderName.equals("/") || folderName.length() == 0) {
dirForFolder = outputDir;
}
else {
dirForFolder = getFileForFolder(outputDir, folder, verbose);
}
if(dirForFolder != null) {
extractFolder(folder, dirForFolder, extractResourceForks, verbose);
}
}
else if(entry instanceof FSFile) {
FSFile file = (FSFile)entry;
extractFile(file, outputDir, extractResourceForks, verbose);
}
else {
System.err.println("Requested path is not a folder or a file!");
System.exit(1);
}
}
private static void setFileTimes(File file, FSEntry entry, String fileType)
{
Long createdTime = null;
Long lastAccessedTime = null;
Long lastModifiedTime = null;
if(entry.getAttributes().hasCreateDate()) {
createdTime = entry.getAttributes().getCreateDate().getTime();
}
if(entry.getAttributes().hasAccessDate()) {
lastAccessedTime = entry.getAttributes().getAccessDate().getTime();
}
if(entry.getAttributes().hasModifyDate()) {
lastModifiedTime = entry.getAttributes().getModifyDate().getTime();
}
boolean fileTimesSet = false;
if(Java7Util.isJava7OrHigher()) {
try {
Java7Util.setFileTimes(file.getPath(),
createdTime != null ? new Date(createdTime) : null,
lastAccessedTime != null ? new Date(lastAccessedTime) :
null,
lastModifiedTime != null ? new Date(lastModifiedTime) :
null);
fileTimesSet = true;
} catch(Exception e) {
e.printStackTrace();
}
}
if(!fileTimesSet && lastModifiedTime != null) {
boolean setLastModifiedResult;
if(lastModifiedTime < 0) {
System.err.println("Warning: Can not set " + fileType + "'s " +
"last modified timestamp to pre-1970 date " +
new Date(lastModifiedTime) + " " + "(raw: " +
lastModifiedTime + "). Setting to earliest possible " +
"timestamp (" + new Date(0) + ").");
lastModifiedTime = (long) 0;
}
setLastModifiedResult = file.setLastModified(lastModifiedTime);
if(!setLastModifiedResult) {
System.err.println("Warning: Failed to set last modified " +
"timestamp (" + lastModifiedTime + ") for " +
fileType + " \"" + file.getPath() + "\" after " +
"extraction.");
}
}
}
private static void extractFolder(FSFolder folder, File targetDir,
boolean extractResourceForks, boolean verbose) {
boolean wasEmpty = targetDir.list().length == 0;
for(FSEntry e : folder.listEntries()) {
if(e instanceof FSFile) {
FSFile file = (FSFile)e;
extractFile(file, targetDir, extractResourceForks, verbose);
}
else if(e instanceof FSFolder) {
FSFolder subFolder = (FSFolder)e;
File subFolderFile = getFileForFolder(targetDir, subFolder, verbose);
if(subFolderFile != null) {
extractFolder(subFolder, subFolderFile, extractResourceForks, verbose);
}
}
else if(e instanceof FSLink) {
// We don't currently handle links.
}
}
if(wasEmpty) {
setFileTimes(targetDir, folder, "folder");
}
}
private static void extractFile(FSFile file, File targetDir,
boolean extractResourceForks, boolean verbose)
throws RuntimeIOException {
File dataFile = new File(targetDir, scrub(file.getName()));
if(!extractRawForkToFile(file.getMainFork(), dataFile)) {
System.err.println("Failed to extract data " +
"fork to " + dataFile.getPath());
}
else {
if(verbose) {
System.out.println(dataFile.getPath());
}
setFileTimes(dataFile, file, "data file");
}
if(extractResourceForks) {
FSFork resourceFork = file.getForkByType(FSForkType.MACOS_RESOURCE);
if(resourceFork.getLength() > 0) {
File resFile = new File(targetDir, "._" + scrub(file.getName()));
if(!extractResourceForkToAppleDoubleFile(resourceFork, resFile)) {
System.err.println("Failed to extract resource " +
"fork to " + resFile.getPath());
}
else {
if(verbose) {
System.out.println(resFile.getPath());
}
setFileTimes(resFile, file,
"resource fork AppleDouble file");
}
}
}
}
private static File getFileForFolder(File targetDir, FSFolder folder,
boolean verbose) {
File folderFile = new File(targetDir, scrub(folder.getName()));
if(folderFile.isDirectory() || folderFile.mkdir()) {
if(verbose)
System.out.println(folderFile.getPath());
}
else {
System.err.println("Failed to create directory " +
folderFile.getPath());
folderFile = null;
}
return folderFile;
}
private static boolean extractRawForkToFile(FSFork fork, File targetFile) throws RuntimeIOException {
FileOutputStream os = null;
ReadableRandomAccessStream in = null;
try {
os = new FileOutputStream(targetFile);
in = fork.getReadableRandomAccessStream();
long extractedBytes = IOUtil.streamCopy(in, os, 128*1024);
if(extractedBytes != fork.getLength()) {
System.err.println("WARNING: Did not extract intended number of bytes to \"" +
targetFile.getPath() + "\"! Intended: " + fork.getLength() +
" Extracted: " + extractedBytes);
}
return true;
} catch(FileNotFoundException fnfe) {
return false;
} catch(Exception ioe) {
ioe.printStackTrace();
return false;
//throw new RuntimeIOException(ioe);
} finally {
if(os != null) {
try { os.close(); }
catch(Exception e) {}
}
if(in != null) {
try { in.close(); }
catch(Exception e) {}
}
}
}
private static boolean extractResourceForkToAppleDoubleFile(FSFork resourceFork, File targetFile) {
FileOutputStream os = null;
ReadableRandomAccessStream in = null;
try {
AppleSingleBuilder builder = new AppleSingleBuilder(FileType.APPLEDOUBLE,
AppleSingleVersion.VERSION_2_0, FileSystem.MACOS_X);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
in = resourceFork.getReadableRandomAccessStream();
long extractedBytes = IOUtil.streamCopy(in, baos, 128*1024);
if(extractedBytes != resourceFork.getLength()) {
System.err.println("WARNING: Did not extract intended number of bytes to \"" +
targetFile.getPath() + "\"! Intended: " + resourceFork.getLength() +
" Extracted: " + extractedBytes);
}
builder.addResourceFork(baos.toByteArray());
os = new FileOutputStream(targetFile);
os.write(builder.getResult());
return true;
} catch(FileNotFoundException fnfe) {
return false;
} catch(Exception ioe) {
ioe.printStackTrace();
return false;
//throw new RuntimeIOException(ioe);
} finally {
if(os != null) {
try { os.close(); }
catch(Exception e) {}
}
if(in != null) {
try { in.close(); }
catch(Exception e) {}
}
}
}
/**
* Scrubs away all control characters from a string and replaces them with '_'.
* @param s the string to be processed.
* @return a scrubbed string.
*/
private static String scrub(String s) {
char[] cdata = s.toCharArray();
for(int i = 0; i < cdata.length; ++i) {
if((cdata[i] >= 0 && cdata[i] <= 31) ||
(cdata[i] == 127))
{
cdata[i] = '_';
}
}
return new String(cdata);
}
private static void logDebug(String s) {
if(debug)
System.err.println("DEBUG: " + s);
}
}