///|
priv struct Request {
command : String
host : String
export_path : String
operands : Array[String]
}
///|
fn command_arity(command : String) -> (Int, Int) raise {
match command {
"exports"
| "mounts"
| "services"
| "verify"
| "ping"
| "fsinfo"
| "space" => (0, 0)
"ls" | "stat" | "inspect" | "tree" | "du" | "pathconf" | "doctor" => (0, 1)
"mkdir"
| "mkdirs"
| "rm"
| "rmdir"
| "rm-tree"
| "cat"
| "readlink"
| "realpath"
| "touch"
| "exists"
| "mkfifo" => (1, 1)
"get"
| "put"
| "mv"
| "cp"
| "cp-checked"
| "link"
| "symlink"
| "get-tree"
| "put-tree"
| "cp-tree"
| "cmp"
| "diff-tree"
| "resume-get"
| "resume-put"
| "stream-copy"
| "chmod"
| "truncate"
| "access"
| "verify-file"
| "verify-tree"
| "bench-read" => (2, 2)
"chown"
| "settime"
| "atomic-get"
| "atomic-put"
| "atomic-replace"
| "atomic-copy" => (3, 3)
"get-range" | "copy-to" | "resume-to" | "tree-to" => (4, 4)
"copy-range" => (5, 5)
_ => fail("unknown command '\{command}'; see --help")
}
}
///|
fn service_command(command : String) -> Bool {
command == "exports" || command == "mounts" || command == "services"
}
///|
fn parse_request(args : Array[String]) -> Request raise {
if args.length() < 3 {
fail("expected COMMAND HOST; see --help")
}
let command = args[1]
let (minimum, maximum) = command_arity(command)
let prefix = if service_command(command) { 3 } else { 4 }
if args.length() < prefix ||
args.length() - prefix < minimum ||
args.length() - prefix > maximum {
fail("wrong argument count for '\{command}'; see --help")
}
let operands = Array::makei(args.length() - prefix, i => args[prefix + i])
// Reject numeric syntax errors before opening a remote connection.
match command {
"chmod" => ignore(@string.parse_uint(operands[1], base=8))
"truncate" => ignore(@string.parse_uint64(operands[1]))
"access" | "bench-read" => ignore(@string.parse_int(operands[1]))
"get-range" => {
ignore(@string.parse_uint64(operands[2]))
ignore(@string.parse_uint64(operands[3]))
}
"copy-range" =>
for i in 2..<5 {
ignore(@string.parse_uint64(operands[i]))
}
"settime" => {
ignore(@string.parse_uint(operands[1]))
ignore(@string.parse_uint(operands[2]))
}
"chown" =>
for i in 1..<3 {
if operands[i] != "-" {
ignore(@string.parse_uint(operands[i]))
}
}
_ => ()
}
{
command,
host: args[2],
export_path: if prefix == 4 {
args[3]
} else {
""
},
operands,
}
}
///|
fn print_help() -> Unit {
println("MoonNFS3: COMMAND HOST EXPORT [arguments]")
for
line in [
"ls|stat|inspect|tree|du|pathconf [PATH]", "get REMOTE LOCAL; put LOCAL REMOTE; mv SOURCE DEST",
"cp|cp-checked|stream-copy SOURCE DEST; cmp LEFT RIGHT", "mkdir|mkdirs PATH; rm|rmdir|rm-tree PATH",
"get-tree REMOTE LOCAL; put-tree LOCAL REMOTE; cp-tree SOURCE DEST", "diff-tree LEFT RIGHT; verify-file LOCAL REMOTE",
"resume-get REMOTE LOCAL; resume-put LOCAL REMOTE (existing destinations only)",
"atomic-get REMOTE LOCAL LOCAL_STAGING", "atomic-put|atomic-replace LOCAL REMOTE REMOTE_STAGING",
"get-range REMOTE LOCAL OFFSET LENGTH", "symlink PATH TARGET; link SOURCE DEST; readlink|realpath PATH",
"chmod PATH OCTAL_MODE; chown PATH UID|- GID|-; truncate PATH SIZE", "touch PATH; settime PATH SECONDS NANOSECONDS; access PATH MASK",
"cat PATH (UTF-8, bounded); exists PATH; mkfifo PATH", "space|fsinfo|ping|verify; bench-read PATH ROUNDS",
"exports|mounts|services HOST (no EXPORT argument)", "doctor [PATH]; verify-tree LOCAL REMOTE",
"copy-to|resume-to|tree-to SOURCE DEST_HOST DEST_EXPORT DEST_PATH", "copy-range SOURCE DEST SOURCE_OFFSET DEST_OFFSET LENGTH",
"atomic-copy SOURCE DEST REMOTE_STAGING",
] {
println(line)
}
println(
"get/put/cp create fresh destinations. rm-tree recursively deletes a subtree.",
)
println(
"Atomic no-replace upload requires hard links; failed operations can leave staging files.",
)
println(
"NFS_PORT / NFS_MOUNT_PORT: 0 = discovery; NFS_UID / NFS_GID default 65534",
)
println(
"NFS_BLOCK_SIZE / NFS_MAX_RECORD / NFS_TIMEOUT_MS / NFS_MAX_ENTRIES set budgets",
)
}
///|
fn env_uid(name : String, fallback : UInt) -> UInt raise {
match @env.get_env_var(name) {
Some(value) => @string.parse_uint(value)
None => fallback
}
}
///|
fn transfer_summary(value : @nfs.TreeTransfer) -> Unit {
println(
"files=\{value.files} directories=\{value.directories} symlinks=\{value.symlinks} bytes=\{value.bytes}",
)
}
///|
fn metadata_json(value : @nfs.Metadata) -> Json {
let a = value.attributes
Json::object(
Map([
("kind", @nfs.file_kind_name(a.kind).to_json()),
("mode", a.mode.to_json()),
("permissions", @nfs.permission_string(a.kind, a.mode).to_json()),
("size", a.size.to_string().to_json()),
("allocated", a.used.to_string().to_json()),
("fsid", a.fsid.to_string().to_json()),
("fileid", a.fileid.to_string().to_json()),
("uid", a.uid.to_json()),
("gid", a.gid.to_json()),
("links", a.links.to_json()),
("mtime_seconds", value.modified.seconds.to_json()),
("mtime_nanoseconds", value.modified.nanoseconds.to_json()),
("ctime_seconds", value.changed.seconds.to_json()),
("ctime_nanoseconds", value.changed.nanoseconds.to_json()),
("atime_seconds", value.accessed.seconds.to_json()),
("atime_nanoseconds", value.accessed.nanoseconds.to_json()),
("device_major", value.device_major.to_json()),
("device_minor", value.device_minor.to_json()),
]),
)
}
///|
async fn run_services(
request : Request,
options : @nfs.Options,
mount_port : Int,
) -> Unit {
match request.command {
"exports" =>
for entry in @nfs.list_exports(request.host, mount_port~, options~) {
let path = @utf8.decode(entry.directory)
let groups = entry.groups.map(group => @utf8.decode_lossy(group))
println(
Json::object(
Map([("directory", path.to_json()), ("groups", groups.to_json())]),
).stringify(),
)
}
"mounts" =>
for entry in @nfs.list_mounts(request.host, mount_port~, options~) {
println(
Json::object(
Map([
("host", @utf8.decode_lossy(entry.host).to_json()),
("directory", @utf8.decode_lossy(entry.directory).to_json()),
]),
).stringify(),
)
}
"services" =>
for entry in @nfs.list_services(request.host, options~) {
println(
"program=\{entry.program} version=\{entry.version} protocol=\{entry.protocol} port=\{entry.port}",
)
}
_ => fail("expected service query")
}
}
///|
async fn run_command(client : @nfs.Client, request : Request) -> Unit {
let values = request.operands
let path = if values.length() > 0 { values[0] } else { "/" }
match request.command {
"ls" => {
let cursor = client.open_directory(path)
defer cursor.close()
while cursor.next() is Some(entry) {
println(@utf8.decode_lossy(entry.name))
}
}
"stat" => {
let a = client.stat(path)
println(
"kind=\{a.kind} size=\{a.size} mode=\{a.mode} uid=\{a.uid} gid=\{a.gid}",
)
}
"inspect" =>
println(
metadata_json(client.metadata(client.resolve(path))).stringify(indent=2),
)
"doctor" =>
println(client.health_check(path~).to_json().stringify(indent=2))
"tree" => {
let walker = client.walk(path)
defer walker.close()
while walker.next() is Some(entry) {
println(
"\{@nfs.permission_string(entry.attributes.kind, entry.attributes.mode)} \{entry.attributes.size} \{entry.path.to_json().stringify()}",
)
}
}
"du" => {
let usage = client.disk_usage(path)
println(
"files=\{usage.files} directories=\{usage.directories} symlinks=\{usage.symlinks} special=\{usage.special} logical=\{usage.logical_bytes} allocated=\{usage.allocated_bytes}",
)
}
"space" => {
let space = client.space()
println(
"total=\{space.total_bytes} free=\{space.free_bytes} available=\{space.available_bytes}",
)
}
"fsinfo" => {
let info = client.fsinfo()
println(
"read_max=\{info.read_max} read_preferred=\{info.read_preferred} write_max=\{info.write_max} write_preferred=\{info.write_preferred} max_file_size=\{info.max_file_size} properties=\{info.properties}",
)
}
"pathconf" => {
let config = client.pathconf(handle=Some(client.resolve(path)))
println(
"link_max=\{config.link_max} name_max=\{config.name_max} no_truncation=\{config.no_truncation} case_insensitive=\{config.case_insensitive} case_preserving=\{config.case_preserving}",
)
}
"mkdir" => {
ignore(client.mkdir(path))
println("created")
}
"mkdirs" => {
ignore(client.ensure_directory(path))
println("created")
}
"rm" => client.remove(path)
"rmdir" => client.remove(path, directory=true)
"rm-tree" => println("removed=\{client.remove_tree(path)}")
"get" => println("downloaded \{client.download(path, values[1])} bytes")
"atomic-copy" =>
println("copied \{client.copy_atomic(path, values[1], values[2])} bytes")
"copy-range" => {
let source = client.resolve(path)
let destination = client.resolve(values[1])
let bytes = client.copy_range(
source,
destination,
@string.parse_uint64(values[2]),
@string.parse_uint64(values[3]),
@string.parse_uint64(values[4]),
)
println("copied \{bytes} bytes")
}
"put" => println("uploaded \{client.upload(path, values[1])} bytes")
"resume-get" =>
println("resumed \{client.resume_download(path, values[1])} bytes")
"resume-put" =>
println("resumed \{client.resume_upload(path, values[1])} bytes")
"atomic-get" =>
println(
"downloaded \{client.download_atomic(path, values[1], values[2])} bytes",
)
"atomic-put" | "atomic-replace" => {
let bytes = client.upload_atomic(
path,
values[1],
values[2],
replace=request.command == "atomic-replace",
)
println("uploaded \{bytes} bytes")
}
"get-tree" => transfer_summary(client.download_tree(path, values[1]))
"put-tree" => transfer_summary(client.upload_tree(path, values[1]))
"cp-tree" => transfer_summary(client.copy_tree(path, values[1]))
"cp" => println("copied \{client.copy_file(path, values[1])} bytes")
"cp-checked" =>
println("copied \{client.copy_file_checked(path, values[1])} bytes")
"mv" => client.rename(path, values[1])
"link" => client.hard_link(path, values[1])
"symlink" => ignore(client.symlink(path, @utf8.encode(values[1])))
"readlink" => println(@utf8.decode(client.readlink(client.resolve(path))))
"realpath" => println(client.canonical_path(path))
"cat" => println(client.read_text(path))
"touch" => client.touch(path)
"exists" => println(client.exists(path))
"mkfifo" => ignore(client.mknod(path, @nfs.NamedPipe))
"chmod" => client.chmod(path, @string.parse_uint(values[1], base=8))
"chown" =>
client.chown(
path,
if values[1] == "-" {
None
} else {
Some(@string.parse_uint(values[1]))
},
if values[2] == "-" {
None
} else {
Some(@string.parse_uint(values[2]))
},
)
"truncate" => client.truncate(path, @string.parse_uint64(values[1]))
"settime" =>
client.set_attributes(client.resolve(path), {
..@nfs.AttributeUpdate::default(),
modified: @nfs.ExactTime({
seconds: @string.parse_uint(values[1]),
nanoseconds: @string.parse_uint(values[2]),
}),
})
"access" =>
println(
"granted=\{client.access(client.resolve(path), @string.parse_uint(values[1]))}",
)
"cmp" => {
let comparison = client.compare_files(path, values[1])
println("equal=\{comparison.equal} compared=\{comparison.compared_bytes}")
if !comparison.equal {
match comparison.first_difference {
Some(offset) => fail("files differ at byte \{offset}")
None => fail("files differ")
}
}
}
"verify-file" => {
let comparison = client.verify_local_file(path, values[1])
println("equal=\{comparison.equal} compared=\{comparison.compared_bytes}")
if !comparison.equal {
fail("local and remote files differ")
}
}
"diff-tree" => {
let differences = client.compare_trees(path, values[1], compare_mode=true)
for difference in differences {
println(
"\{difference.kind.name()} \{difference.relative.to_json().stringify()}",
)
}
if differences.length() != 0 {
fail("trees differ")
}
println("equal=true")
}
"stream-copy" => {
let destination = connect_destination(request.host, request.export_path)
defer destination.close()
let reader = client.open_reader(path)
defer reader.close()
let writer = destination.open_writer(values[1])
defer writer.close()
writer.write_reader(reader)
println("copied \{writer.position()} bytes")
}
"copy-to" | "resume-to" | "tree-to" => {
let destination = connect_destination(values[1], values[2])
defer destination.close()
match request.command {
"copy-to" =>
println(
"copied \{client.copy_file_to(destination, path, values[3])} bytes",
)
"resume-to" =>
println(
"resumed \{client.resume_copy_to(destination, path, values[3])} bytes",
)
_ => transfer_summary(client.copy_tree_to(destination, path, values[3]))
}
}
"verify-tree" => {
let result = client.verify_local_tree(path, values[1])
for difference in result.differences {
println(
"\{difference.kind.name()} \{difference.relative.to_json().stringify()}",
)
}
if result.differences.length() != 0 {
fail("local and remote trees differ")
}
println("equal=true compared=\{result.compared_bytes}")
}
"get-range" => {
let handle = client.resolve(path)
if client.getattr(handle).kind != 1U {
fail("expected regular source")
}
let offset = @string.parse_uint64(values[2])
let length = @string.parse_uint64(values[3])
if offset > 18446744073709551615UL - length {
fail("requested range overflows 64-bit offsets")
}
let file = @fs.open(values[1], mode=WriteOnly, create_mode=CreateNew)
defer file.close()
let bytes = client.download_range(handle, file, offset, length)
file.sync()
println("downloaded \{bytes} bytes")
}
"bench-read" => {
let result = client.benchmark_read(
path,
rounds=@string.parse_int(values[1]),
)
println(
"bytes=\{result.bytes} elapsed_ms=\{result.elapsed_ms} MiB_per_second=\{result.mebibytes_per_second()} calls=\{result.rpc.calls} replies=\{result.rpc.replies}",
)
}
"ping" => {
client.ping()
println("ok")
}
"verify" => verify(client)
_ => fail("command is not an NFS file operation")
}
}