/* * A simple rsync command line client implementation * * Copyright (C) 1996-2011 by Andrew Tridgell, Wayne Davison, and others * Copyright (C) 2013-2016 Per Lundqvist * * 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 com.github.perlundq.yajsync.ui; import java.io.BufferedReader; import java.io.Console; import java.io.FileReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.PrintStream; import java.net.URISyntaxException; import java.net.UnknownHostException; import java.nio.channels.UnresolvedAddressException; import java.nio.charset.Charset; import java.nio.charset.IllegalCharsetNameException; import java.nio.charset.UnsupportedCharsetException; import java.nio.file.FileSystem; import java.nio.file.FileSystems; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.attribute.FileTime; import java.text.SimpleDateFormat; import java.util.Arrays; import java.util.Date; import java.util.LinkedList; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; import com.github.perlundq.yajsync.AuthProvider; import com.github.perlundq.yajsync.FileSelection; import com.github.perlundq.yajsync.RsyncClient; import com.github.perlundq.yajsync.RsyncException; import com.github.perlundq.yajsync.RsyncServer; import com.github.perlundq.yajsync.Statistics; import com.github.perlundq.yajsync.attr.DeviceInfo; import com.github.perlundq.yajsync.attr.FileInfo; import com.github.perlundq.yajsync.attr.Group; import com.github.perlundq.yajsync.attr.RsyncFileAttributes; import com.github.perlundq.yajsync.attr.SymlinkInfo; import com.github.perlundq.yajsync.attr.User; import com.github.perlundq.yajsync.internal.channels.ChannelException; import com.github.perlundq.yajsync.internal.session.FileAttributeManager; import com.github.perlundq.yajsync.internal.session.FileAttributeManagerFactory; import com.github.perlundq.yajsync.internal.session.SessionStatistics; import com.github.perlundq.yajsync.internal.util.ArgumentParser; import com.github.perlundq.yajsync.internal.util.ArgumentParsingError; import com.github.perlundq.yajsync.internal.util.Environment; import com.github.perlundq.yajsync.internal.util.FileOps; import com.github.perlundq.yajsync.internal.util.Option; import com.github.perlundq.yajsync.internal.util.Pair; import com.github.perlundq.yajsync.internal.util.PathOps; import com.github.perlundq.yajsync.internal.util.Triple; import com.github.perlundq.yajsync.internal.util.Util; import com.github.perlundq.yajsync.net.ChannelFactory; import com.github.perlundq.yajsync.net.DuplexByteChannel; import com.github.perlundq.yajsync.net.SSLChannelFactory; import com.github.perlundq.yajsync.net.StandardChannelFactory; public class YajsyncClient { private static final int PORT_UNDEFINED = -1; private enum Mode { LOCAL_COPY, LOCAL_LIST, REMOTE_SEND, REMOTE_RECEIVE, REMOTE_LIST; public boolean isRemote() { return this == REMOTE_SEND || this == REMOTE_RECEIVE || this == REMOTE_LIST; } } private static final Logger _log = Logger.getLogger(YajsyncClient.class.getName()); private final AuthProvider _authProvider = new AuthProvider() { @Override public String getUser() { return _userName; } @Override public char[] getPassword() throws IOException { if (_passwordFile != null) { if (!_passwordFile.equals("-")) { Path p = Paths.get(_passwordFile); FileAttributeManager fileManager = FileAttributeManagerFactory.newMostAble(p.getFileSystem(), User.NOBODY, Group.NOBODY, Environment.DEFAULT_FILE_PERMS, Environment.DEFAULT_DIR_PERMS); RsyncFileAttributes attrs = fileManager.stat(p); if ((attrs.mode() & (FileOps.S_IROTH | FileOps.S_IWOTH)) != 0) { throw new IOException(String.format( "insecure permissions on %s: %s", _passwordFile, attrs)); } } try (BufferedReader br = new BufferedReader( _passwordFile.equals("-") ? new InputStreamReader(System.in) : new FileReader(_passwordFile))) { return br.readLine().toCharArray(); } } String passwordStr = Environment.getRsyncPasswordOrNull(); if (passwordStr != null) { return passwordStr.toCharArray(); } Console console = System.console(); if (console == null) { throw new IOException("no console available"); } return console.readPassword("Password: "); } }; private final SimpleDateFormat _timeFormatter = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss"); private boolean _isShowStatistics; private boolean _isTLS; private boolean _readStdin = false; private FileSelection _fileSelection; private FileSystem _fs = FileSystems.getDefault(); private int _contimeout = 0; private int _timeout = 0; private int _remotePort = PORT_UNDEFINED; private int _verbosity; private Path _cwd; private PrintStream _stderr = System.out; private PrintStream _stdout = System.out; private final RsyncClient.Builder _clientBuilder = new RsyncClient.Builder().authProvider(_authProvider); private Statistics _statistics = new SessionStatistics(); private String _cwdName = Environment.getWorkingDirectoryName(); private String _passwordFile; private String _userName; public YajsyncClient setStandardOut(PrintStream out) { _stdout = out; return this; } public YajsyncClient setStandardErr(PrintStream err) { _stderr = err; _clientBuilder.stderr(_stderr); return this; } public Statistics statistics() { return _statistics; } private List<Option> options() { List<Option> options = new LinkedList<>(); options.add(Option.newStringOption(Option.Policy.OPTIONAL, "charset", "", "which charset to use (default UTF-8)", option -> { String charsetName = (String) option.getValue(); try { Charset charset = Charset.forName(charsetName); _clientBuilder.charset(charset); return ArgumentParser.Status.CONTINUE; } catch (IllegalCharsetNameException | UnsupportedCharsetException e) { throw new ArgumentParsingError(String.format( "failed to set character set to %s: %s", charsetName, e)); }})); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "dirs", "d", "transfer directories without recursing (default " + "false unless listing files)", option -> { if (_fileSelection == FileSelection.RECURSE) { throw new ArgumentParsingError( "--recursive and --dirs are " + "incompatible options"); } _fileSelection = FileSelection.TRANSFER_DIRS; _clientBuilder.fileSelection(_fileSelection); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "recursive", "r", "recurse into directories (default false)", option -> { if (_fileSelection == FileSelection.TRANSFER_DIRS) { throw new ArgumentParsingError( "--recursive and --dirs are " + "incompatible options"); } _fileSelection = FileSelection.RECURSE; _clientBuilder.fileSelection(_fileSelection); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "verbose", "v", "increase output verbosity (default quiet)", option -> { _clientBuilder.verbosity(_verbosity++); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "devices", "", "_simulate_ preserve character device files and " + "block device files (default false)", option -> { _clientBuilder.isPreserveDevices(true); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "specials", "", "_simulate_ preserve special device files - named " + "sockets and named pipes (default false)", option -> { _clientBuilder.isPreserveSpecials(true); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "", "D", "same as --devices and --specials (default false)", option -> { _clientBuilder.isPreserveDevices(true); _clientBuilder.isPreserveSpecials(true); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "links", "l", "preserve symlinks (default false)", option -> { _clientBuilder.isPreserveLinks(true); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "perms", "p", "preserve file permissions (default false)", option -> { _clientBuilder.isPreservePermissions(true); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "times", "t", "preserve last modification time (default false)", option -> { _clientBuilder.isPreserveTimes(true); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "owner", "o", "preserve owner (default false)", option -> { _clientBuilder.isPreserveUser(true); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "group", "g", "preserve group (default false)", option -> { _clientBuilder.isPreserveGroup(true); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "archive", "a", "archive mode - same as -rlptgoD (default false)", option -> { _clientBuilder. fileSelection(FileSelection.RECURSE). isPreserveLinks(true). isPreservePermissions(true). isPreserveTimes(true). isPreserveGroup(true). isPreserveUser(true). isPreserveDevices(true). isPreserveSpecials(true); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "delete", "", "delete extraneous files (default false)", option -> { _clientBuilder.isDelete(true); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "numeric-ids", "", "don't map uid/gid values by user/group name " + "(default false)", option -> { _clientBuilder.isNumericIds(true); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "ignore-times", "I", "transfer files that match both size and time " + "(default false)", option -> { _clientBuilder.isIgnoreTimes(true); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "stats", "", "show file transfer statistics", option -> { _isShowStatistics = true; return ArgumentParser.Status.CONTINUE; })); options.add(Option.newStringOption(Option.Policy.OPTIONAL, "password-file", "", "read daemon-access password from specified file " + "(where `-' is stdin)", option -> { _passwordFile = (String) option.getValue(); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newIntegerOption(Option.Policy.OPTIONAL, "port", "", String.format("server port number (default %d)", RsyncServer.DEFAULT_LISTEN_PORT), option -> { int port = (int) option.getValue(); if (ConnInfo.isValidPortNumber(port)) { _remotePort = port; return ArgumentParser.Status.CONTINUE; } else { throw new ArgumentParsingError(String.format( "illegal port %d - must be within " + "the range [%d, %d]", port, ConnInfo.PORT_MIN, ConnInfo.PORT_MAX)); } })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "stdin", "", "read list of source files from stdin", option -> { _readStdin = true; return ArgumentParser.Status.CONTINUE; })); String deferredWriteHelp = "(receiver only) receiver defers writing into target tempfile as " + "long as possible to possibly eliminate all I/O writes for " + "identical files. This comes at the cost of a highly increased " + "risk of the file being modified by a process already having it " + "opened (default false)"; options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "defer-write", "", deferredWriteHelp, option -> { _clientBuilder.isDeferWrite(true); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newIntegerOption(Option.Policy.OPTIONAL, "timeout", "", "set I/O read timeout in seconds (default 0 - " + "disabled)", option -> { int timeout = (int) option.getValue(); if (timeout < 0) { throw new ArgumentParsingError(String.format( "invalid timeout %d - mut be greater " + "than or equal to 0", timeout)); } _timeout = timeout * 1000; // Timeout socket operations depend on // ByteBuffer.array and ByteBuffer.arrayOffset. // Disable direct allocation if the resulting // ByteBuffer won't have an array. if (timeout > 0 && !Environment.hasAllocateDirectArray()) { Environment.setAllocateDirect(false); } return ArgumentParser.Status.CONTINUE; })); options.add(Option.newIntegerOption(Option.Policy.OPTIONAL, "contimeout", "", "set daemon connection timeout in seconds (default " + "0 - disabled)", option -> { int contimeout = (int) option.getValue(); if (contimeout >= 0) { _contimeout = contimeout * 1000; return ArgumentParser.Status.CONTINUE; } else { throw new ArgumentParsingError(String.format( "invalid connection timeout %d - " + "must be greater than or equal to 0", contimeout)); } })); options.add(Option.newWithoutArgument(Option.Policy.OPTIONAL, "tls", "", String.format("tunnel all data over TLS/SSL " + "(default %s)", _isTLS), option -> { _isTLS = true; // SSLChannel.read and SSLChannel.write depends on // ByteBuffer.array and ByteBuffer.arrayOffset. // Disable direct allocation if the resulting // ByteBuffer won't have an array. if (!Environment.hasAllocateDirectArray()) { Environment.setAllocateDirect(false); } return ArgumentParser.Status.CONTINUE; })); options.add(Option.newStringOption(Option.Policy.OPTIONAL, "cwd", "", "change current working directory (usable in " + "combination with --fs)", option -> { _cwdName = (String) option.getValue(); return ArgumentParser.Status.CONTINUE; })); options.add(Option.newStringOption(Option.Policy.OPTIONAL, "fs", "", "use a non-default Java nio FileSystem implementation " + "(see also --cwd)", option -> { try { String fsName = (String) option.getValue(); _fs = PathOps.fileSystemOf(fsName); _cwdName = Util.firstOf(_fs.getRootDirectories()).toString(); return ArgumentParser.Status.CONTINUE; } catch (IOException | URISyntaxException e) { throw new ArgumentParsingError(e); } })); return options; } private Triple<Mode, RsyncUrls, RsyncUrl> parseUnnamedArgs(List<String> unnamed) throws ArgumentParsingError { try { int len = unnamed.size(); if (len == 0) { throw new ArgumentParsingError("Please specify at least one " + "non-option argument for (one " + "or more) source files and " + "optionally one destination " + "(defaults to current " + "directory)"); } int numSrcArgs = len == 1 ? 1 : len - 1; List<String> srcFileNames = unnamed.subList(0, numSrcArgs); RsyncUrls srcUrls = new RsyncUrls(_cwd, srcFileNames); if (len == 1) { if (srcUrls.isRemote()) { return new Triple<>(Mode.REMOTE_LIST, srcUrls, null); } return new Triple<>(Mode.LOCAL_LIST, srcUrls, null); } int indexOfLast = len - 1; String dstFileName = unnamed.get(indexOfLast); RsyncUrl dstUrl = RsyncUrl.parse(_cwd, dstFileName); if (srcUrls.isRemote() && dstUrl.isRemote()) { throw new ArgumentParsingError(String.format( "source arguments %s and destination argument %s must" + " not both be remote", srcUrls, dstUrl)); } else if (srcUrls.isRemote()) { return new Triple<>(Mode.REMOTE_RECEIVE, srcUrls, dstUrl); } else if (dstUrl.isRemote()) { return new Triple<>(Mode.REMOTE_SEND, srcUrls, dstUrl); } else { return new Triple<>(Mode.LOCAL_COPY, srcUrls, dstUrl); } } catch (IllegalUrlException e) { throw new ArgumentParsingError(e); } } private void showStatistics(Statistics stats) { _stdout.format("Number of files: %d%n" + "Number of files transferred: %d%n" + "Total file size: %d bytes%n" + "Total transferred file size: %d bytes%n" + "Literal data: %d bytes%n" + "Matched data: %d bytes%n" + "File list size: %d%n" + "File list generation time: %.3f seconds%n" + "File list transfer time: %.3f seconds%n" + "Total bytes sent: %d%n" + "Total bytes received: %d%n", stats.numFiles(), stats.numTransferredFiles(), stats.totalFileSize(), stats.totalTransferredSize(), stats.totalLiteralSize(), stats.totalMatchedSize(), stats.totalFileListSize(), stats.fileListBuildTime() / 1000.0, stats.fileListTransferTime() / 1000.0, stats.totalBytesWritten(), stats.totalBytesRead()); } private static List<String> readLinesFromStdin() throws IOException { List<String> lines = new LinkedList<>(); try (BufferedReader br = new BufferedReader(new InputStreamReader(System.in))) { while (true) { String line = br.readLine(); if (line == null) { return lines; } lines.add(line); } } } private Iterable<Path> getPaths(Iterable<String> pathNames) { List<Path> paths = new LinkedList<>(); for (String pathName : pathNames) { Path p = PathOps.get(_cwd.getFileSystem(), pathName); paths.add(p); } return paths; } private RsyncClient.Result remoteTransfer(Mode mode, RsyncUrls srcArgs, RsyncUrl dstArgOrNull) throws RsyncException, InterruptedException { ConnInfo connInfo = srcArgs.isRemote() ? srcArgs.connInfoOrNull() : dstArgOrNull.connInfoOrNull(); ChannelFactory socketFactory = _isTLS ? new SSLChannelFactory() : new StandardChannelFactory(); if (_log.isLoggable(Level.FINE)) { _log.fine(String.format("connecting to %s (TLS=%b)", connInfo, _isTLS)); } try (DuplexByteChannel sock = socketFactory.open(connInfo.address(), connInfo.portNumber(), _contimeout, _timeout)) { // throws IOException if (_log.isLoggable(Level.FINE)) { _log.fine("connected to " + sock); } _userName = connInfo.userName(); boolean isInterruptible = !_isTLS; RsyncClient.Remote client = _clientBuilder.buildRemote(sock /* in */, sock /* out */, isInterruptible); switch (mode) { case REMOTE_SEND: return client.send(getPaths(srcArgs.pathNames())). to(dstArgOrNull.moduleName(), dstArgOrNull.pathName()); case REMOTE_RECEIVE: return client.receive(srcArgs.moduleName(), srcArgs.pathNames()). to(PathOps.get(_cwd.getFileSystem(), dstArgOrNull.pathName())); case REMOTE_LIST: if (srcArgs.moduleName().isEmpty()) { RsyncClient.ModuleListing listing = client.listModules(); while (true) { String line = listing.take(); boolean isDone = line == null; if (isDone) { return listing.get(); } System.out.println(line); } } else { RsyncClient.FileListing listing = client.list(srcArgs.moduleName(), srcArgs.pathNames()); while (true) { FileInfo f = listing.take(); boolean isDone = f == null; if (isDone) { return listing.get(); } String ls = fileInfoToListingString(f); System.out.println(ls); } } default: throw new AssertionError(mode); } } catch (UnknownHostException | UnresolvedAddressException e) { if (_log.isLoggable(Level.SEVERE)) { _log.severe(String.format("Error: failed to resolve %s (%s)", connInfo.address(), e.getMessage())); } } catch (IOException e) { // SocketChannel.{open,close}() if (_log.isLoggable(Level.SEVERE)) { _log.severe("Error: socket open/close error: " + e.getMessage()); } } catch (ChannelException e) { if (_log.isLoggable(Level.SEVERE)) { _log.log(Level.SEVERE, "Error: communication closed with peer: ", e); } } return RsyncClient.Result.failure(); } public int start(String[] args) { ArgumentParser argsParser = ArgumentParser.newWithUnnamed(getClass().getSimpleName(), "files..."); argsParser.addHelpTextDestination(_stdout); try { for (Option o : options()) { argsParser.add(o); } ArgumentParser.Status rc = argsParser.parse(Arrays.asList(args)); if (rc != ArgumentParser.Status.CONTINUE) { return rc == ArgumentParser.Status.EXIT_OK ? 0 : 1; } _cwd = _fs.getPath(_cwdName); List<String> unnamed = new LinkedList<>(); if (_readStdin) { unnamed.addAll(readLinesFromStdin()); } unnamed.addAll(argsParser.getUnnamedArguments()); Triple<Mode, RsyncUrls, RsyncUrl> res = parseUnnamedArgs(unnamed); Mode mode = res.first(); RsyncUrls srcArgs = res.second(); RsyncUrl dstArgOrNull = res.third(); if (_remotePort != PORT_UNDEFINED && mode.isRemote()) { Pair<RsyncUrls, RsyncUrl> res2 = updateRemotePort(_cwd, _remotePort, srcArgs, dstArgOrNull); srcArgs = res2.first(); dstArgOrNull = res2.second(); } Level logLevel = Util.getLogLevelForNumber( Util.WARNING_LOG_LEVEL_NUM + _verbosity); Util.setRootLogLevel(logLevel); if (_log.isLoggable(Level.FINE)) { _log.fine(String.format("%s src: %s, dst: %s", mode, srcArgs, dstArgOrNull)); } RsyncClient.Result result; if (mode.isRemote()) { result = remoteTransfer(mode, srcArgs, dstArgOrNull); } else if (mode == Mode.LOCAL_COPY) { if (_log.isLoggable(Level.FINE)) { _log.fine("starting local transfer (using rsync's delta " + "transfer algorithm - i.e. will not run with a " + "--whole-file option, so performance is most " + "probably lower than rsync)"); } result = _clientBuilder.buildLocal(). copy(getPaths(srcArgs.pathNames())). to(PathOps.get(_cwd.getFileSystem(), dstArgOrNull.pathName())); } else if (mode == Mode.LOCAL_LIST) { RsyncClient.FileListing listing = _clientBuilder. buildLocal(). list(getPaths(srcArgs.pathNames())); while (true) { FileInfo f = listing.take(); boolean isDone = f == null; if (isDone) { result = listing.get(); break; } System.out.println(fileInfoToListingString(f)); } } else { throw new AssertionError(); } _statistics = result.statistics(); if (_isShowStatistics) { showStatistics(result.statistics()); } if (_log.isLoggable(Level.INFO)) { _log.info("exit status: " + (result.isOK() ? "OK" : "ERROR")); } return result.isOK() ? 0 : -1; } catch (ArgumentParsingError e) { _stderr.println(e.getMessage()); _stderr.println(argsParser.toUsageString()); } catch (IOException e) { // reading from stdinp _stderr.println(e.getMessage()); } catch (RsyncException e) { if (_log.isLoggable(Level.SEVERE)) { _log.severe(e.getMessage()); } } catch (InterruptedException e) { if (_log.isLoggable(Level.SEVERE)) { _log.log(Level.SEVERE, "", e); } } return -1; } private static Pair<RsyncUrls, RsyncUrl> updateRemotePort(Path cwd, int newPortNumber, RsyncUrls srcArgs, RsyncUrl dstArgOrNull) throws ArgumentParsingError { try { ConnInfo connInfo = srcArgs.isRemote() ? srcArgs.connInfoOrNull() : dstArgOrNull.connInfoOrNull(); // Note: won't detect ambiguous ports if explicitly specifying 873 // in rsync:// url + something else in --port= if (connInfo.portNumber() != RsyncServer.DEFAULT_LISTEN_PORT && newPortNumber != connInfo.portNumber()) { throw new ArgumentParsingError(String.format( "ambiguous remote ports: %d != %d", newPortNumber, connInfo.portNumber())); } ConnInfo newConnInfo = new ConnInfo.Builder(connInfo.address()). portNumber(newPortNumber). userName(connInfo.userName()).build(); if (srcArgs.isRemote()) { return new Pair<>(new RsyncUrls(newConnInfo, srcArgs.moduleName(), srcArgs.pathNames()), dstArgOrNull); } // else if (dstArg.isRemote()) { return new Pair<>(srcArgs, new RsyncUrl(cwd, newConnInfo, dstArgOrNull.moduleName(), dstArgOrNull.pathName())); } catch (IllegalUrlException e) { throw new RuntimeException(e); } } private String fileInfoToListingString(FileInfo f) { RsyncFileAttributes attrs = f.attrs(); Date t = new Date(FileTime.from(attrs.lastModifiedTime(), TimeUnit.SECONDS).toMillis()); if (f instanceof SymlinkInfo) { return String.format("%s %11d %s %s -> %s", FileOps.modeToString(attrs.mode()), attrs.size(), _timeFormatter.format(t), f.pathName(), ((SymlinkInfo) f).targetPathName()); } else if (f instanceof DeviceInfo) { DeviceInfo d = (DeviceInfo) f; return String.format("%s %11d %d,%d %s %s", FileOps.modeToString(attrs.mode()), attrs.size(), d.major(), d.minor(), _timeFormatter.format(t), d.pathName()); } return String.format("%s %11d %s %s", FileOps.modeToString(attrs.mode()), attrs.size(), _timeFormatter.format(t), f.pathName()); } }