///|
enum CliError {
MissingArgument(String)
InvalidFormat(String)
UnknownMagic
AmbiguousBinary
Io(String)
Archive(@cpio.CpioError)
UnsafePath(String, @cpio.ArchivePathError)
FilePathHasTrailingSlash(String)
UnsupportedFileType(String, UInt64)
DestinationExists(String)
DestinationParentMissing(String)
} derive(Eq, Debug)
///|
fn format_option() -> @argparse.OptionArg {
@argparse.OptionArg(
"format",
long="format",
about="Archive format: auto, newc, odc, binary-old, binary-new-le, binary-new-be",
default_values=["auto"],
)
}
///|
fn cli_command() -> @argparse.Command {
@argparse.Command(
"moon-cpio",
about="Inspect, validate, and safely extract cpio archives.",
version="0.1.0",
subcommand_required=true,
arg_required_else_help=true,
subcommands=[
@argparse.Command(
"list",
about="List archive entries.",
options=[format_option()],
positionals=[@argparse.PositionArg("archive")],
),
@argparse.Command(
"validate",
about="Validate an archive and all entry checksums.",
options=[format_option()],
positionals=[@argparse.PositionArg("archive")],
),
@argparse.Command(
"extract",
about="Safely extract regular files and directories.",
options=[format_option()],
positionals=[
@argparse.PositionArg("archive"),
@argparse.PositionArg("destination"),
],
),
],
)
}
///|
fn first_value(matches : @argparse.Matches, name : String) -> String {
match matches.values.get(name) {
Some(values) => if values.length() > 0 { values[0] } else { "" }
None => ""
}
}
///|
fn resolve_format(
input : Bytes,
name : String,
) -> Result[@cpio.CpioFormat, CliError] {
match name {
"newc" => Ok(@cpio.CpioFormat::Newc)
"odc" => Ok(@cpio.CpioFormat::Odc)
"binary-old" => Ok(@cpio.CpioFormat::BinaryOld)
"binary-new-le" =>
Ok(@cpio.CpioFormat::BinaryNew(@cpio.BinaryByteOrder::Little))
"binary-new-be" =>
Ok(@cpio.CpioFormat::BinaryNew(@cpio.BinaryByteOrder::Big))
"auto" => detect_format(input)
_ => Err(InvalidFormat(name))
}
}
///|
fn detect_format(input : Bytes) -> Result[@cpio.CpioFormat, CliError] {
if input.length() >= 6 {
match input[0:6] {
b"070701" | b"070702" => return Ok(@cpio.CpioFormat::Newc)
b"070707" => return Ok(@cpio.CpioFormat::Odc)
_ => ()
}
}
if input.length() >= 2 {
if input[0] == b'\xC7' && input[1] == b'\x71' {
return Err(AmbiguousBinary)
}
if input[0] == b'\x71' && input[1] == b'\xC7' {
return Err(AmbiguousBinary)
}
}
Err(UnknownMagic)
}
///|
fn format_name(format : @cpio.CpioFormat) -> String {
match format {
@cpio.CpioFormat::Newc => "newc"
@cpio.CpioFormat::Odc => "odc"
@cpio.CpioFormat::BinaryOld => "binary-old"
@cpio.CpioFormat::BinaryNew(@cpio.BinaryByteOrder::Little) =>
"binary-new-le"
@cpio.CpioFormat::BinaryNew(@cpio.BinaryByteOrder::Big) => "binary-new-be"
}
}
///|
fn read_archive(path : String) -> Result[Bytes, CliError] {
let bytes = @fs.read_file_to_bytes(path) catch {
error => return Err(Io(@debug.to_string(error)))
}
Ok(bytes)
}
///|
fn entry_mode(header : @cpio.StreamHeader) -> UInt64 {
match header {
@cpio.StreamHeader::Newc(header) => header.mode
@cpio.StreamHeader::Odc(header) => header.mode
@cpio.StreamHeader::Binary(header) => header.mode
}
}
///|
fn mode_type(mode : UInt64) -> String {
match mode & 0xF000UL {
0x4000UL => "d"
0x8000UL => "-"
0xA000UL => "l"
0x2000UL => "c"
0x6000UL => "b"
0x1000UL => "p"
0xC000UL => "s"
_ => "?"
}
}
///|
fn list_archive(
archive_path : String,
format_name_value : String,
) -> Result[Unit, CliError] {
let input = match read_archive(archive_path) {
Ok(value) => value
Err(error) => return Err(error)
}
let format = match resolve_format(input, format_name_value) {
Ok(value) => value
Err(error) => return Err(error)
}
let result = @cpio.stream_entries(input, format, entry => {
let mode = entry_mode(entry.header)
println(
"\{mode_type(mode)} \{mode.to_string(radix=8)} \{entry.data.length()} \{entry.name}",
)
})
match result {
Ok(_) => Ok(())
Err(error) => Err(Archive(error))
}
}
///|
fn validate_archive(
archive_path : String,
format_name_value : String,
) -> Result[Unit, CliError] {
let input = match read_archive(archive_path) {
Ok(value) => value
Err(error) => return Err(error)
}
let format = match resolve_format(input, format_name_value) {
Ok(value) => value
Err(error) => return Err(error)
}
let mut count = 0
let result = @cpio.stream_entries(input, format, _entry => count = count + 1)
match result {
Ok(_) => {
println("OK: \{count} entries (\{format_name(format)})")
Ok(())
}
Err(error) => Err(Archive(error))
}
}
///|
fn parent_path(path : String) -> String {
let mut index = -1
match path.rev_find("/") {
Some(value) => index = value
None => ()
}
match path.rev_find("\\") {
Some(value) => if value > index { index = value }
None => ()
}
if index > 0 {
path[0:index].to_owned()
} else {
""
}
}
///|
fn mkdir_all(path : String) -> Unit raise @fs.IOError {
if path.is_empty() || @fs.path_exists(path) {
return
}
let parent = parent_path(path)
if !parent.is_empty() {
mkdir_all(parent)
}
@fs.create_dir(path)
}
///|
fn preflight_extract(
input : Bytes,
format : @cpio.CpioFormat,
destination : String,
) -> Result[Int32, CliError] {
if @fs.path_exists(destination) {
return Err(DestinationExists(destination))
}
let mut count = 0
let mut failure : CliError? = None
let result = @cpio.stream_entries(input, format, entry => {
match failure {
Some(_) => ()
None =>
match @cpio.validate_archive_path(entry.name) {
Err(error) => failure = Some(UnsafePath(entry.name, error))
Ok(_) => {
let mode = entry_mode(entry.header)
match mode & 0xF000UL {
0x4000UL => count = count + 1
0x8000UL =>
if entry.name.has_suffix("/") {
failure = Some(FilePathHasTrailingSlash(entry.name))
} else {
count = count + 1
}
_ => failure = Some(UnsupportedFileType(entry.name, mode))
}
}
}
}
})
match failure {
Some(error) => return Err(error)
None => ()
}
match result {
Ok(_) => Ok(count)
Err(error) => Err(Archive(error))
}
}
///|
fn extract_entry(
destination : String,
entry : @cpio.StreamEntry,
) -> Result[Unit, CliError] {
let target = match @cpio.archive_path_to_fs(destination, entry.name) {
Ok(path) => path
Err(error) => return Err(UnsafePath(entry.name, error))
}
let mode = entry_mode(entry.header)
match mode & 0xF000UL {
0x4000UL => {
mkdir_all(target) catch {
error => return Err(Io(@debug.to_string(error)))
}
Ok(())
}
0x8000UL => {
let parent = parent_path(target)
if !parent.is_empty() {
mkdir_all(parent) catch {
error => return Err(Io(@debug.to_string(error)))
}
}
@fs.write_bytes_to_file(target, entry.data.to_owned()) catch {
error => return Err(Io(@debug.to_string(error)))
}
Ok(())
}
_ => Err(UnsupportedFileType(entry.name, mode))
}
}
///|
fn extract_archive(
archive_path : String,
destination : String,
format_name_value : String,
) -> Result[Unit, CliError] {
let input = match read_archive(archive_path) {
Ok(value) => value
Err(error) => return Err(error)
}
let format = match resolve_format(input, format_name_value) {
Ok(value) => value
Err(error) => return Err(error)
}
let total = match preflight_extract(input, format, destination) {
Ok(value) => value
Err(error) => return Err(error)
}
let parent = parent_path(destination)
if !parent.is_empty() && !@fs.path_exists(parent) {
return Err(DestinationParentMissing(parent))
}
mkdir_all(destination) catch {
error => return Err(Io(@debug.to_string(error)))
}
let mut extracted = 0
let mut failure : CliError? = None
let result = @cpio.stream_entries(input, format, entry => {
match failure {
Some(_) => ()
None =>
match extract_entry(destination, entry) {
Ok(_) => extracted = extracted + 1
Err(error) => failure = Some(error)
}
}
})
match failure {
Some(error) => return Err(error)
None => ()
}
match result {
Ok(_) => {
println("Extracted \{extracted}/\{total} entries to \{destination}")
Ok(())
}
Err(error) => Err(Archive(error))
}
}
///|
fn cli_error_message(error : CliError) -> String {
match error {
MissingArgument(name) => "missing required argument: '\{name}'"
InvalidFormat(name) =>
"invalid format '\{name}'; use auto, newc, odc, binary-old, binary-new-le, or binary-new-be"
UnknownMagic => "unrecognized cpio archive magic"
AmbiguousBinary =>
"binary cpio detected; specify --format binary-old, binary-new-le, or binary-new-be"
Io(message) => "filesystem error: \{message}"
Archive(message) => "archive error: \{@debug.to_string(message)}"
UnsafePath(path, reason) =>
"unsafe archive path '\{path}': \{@debug.to_string(reason)}"
FilePathHasTrailingSlash(path) =>
"regular file entry has a trailing slash: '\{path}'"
UnsupportedFileType(path, mode) =>
"unsupported file type for '\{path}' (mode \{mode.to_string(radix=8)})"
DestinationExists(path) =>
"destination already exists; choose a new directory: '\{path}'"
DestinationParentMissing(path) =>
"destination parent directory does not exist: '\{path}'"
}
}
///|
fn run_cli(matches : @argparse.Matches) -> Result[Unit, CliError] {
let (subcommand, submatches) = match matches.subcommand {
Some(value) => value
None => return Err(InvalidFormat("missing subcommand"))
}
let format_value = first_value(submatches, "format")
let archive_path = first_value(submatches, "archive")
if archive_path.is_empty() {
return Err(MissingArgument("archive"))
}
match subcommand {
"list" => list_archive(archive_path, format_value)
"validate" => validate_archive(archive_path, format_value)
"extract" => {
let destination = first_value(submatches, "destination")
if destination.is_empty() {
return Err(MissingArgument("destination"))
}
extract_archive(archive_path, destination, format_value)
}
_ => Err(InvalidFormat(subcommand))
}
}
///|
fn main {
let matches = cli_command().parse(env=Map([])) catch {
error => {
let message = error.to_string()
println(message)
if message.has_prefix("error:") {
abort("")
}
return
}
}
match run_cli(matches) {
Ok(_) => ()
Err(error) => {
println("error: \{cli_error_message(error)}")
abort("")
}
}
}