001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.commons.vfs2.provider.ftp;
018
019import java.io.IOException;
020import java.io.InputStream;
021import java.io.OutputStream;
022import java.time.Instant;
023import java.util.Calendar;
024import java.util.Collections;
025import java.util.List;
026import java.util.Map;
027import java.util.Objects;
028import java.util.TimeZone;
029import java.util.TreeMap;
030import java.util.concurrent.atomic.AtomicBoolean;
031
032import org.apache.commons.io.function.Uncheck;
033import org.apache.commons.lang3.ArrayUtils;
034import org.apache.commons.logging.Log;
035import org.apache.commons.logging.LogFactory;
036import org.apache.commons.net.ftp.FTPFile;
037import org.apache.commons.vfs2.FileName;
038import org.apache.commons.vfs2.FileNotFolderException;
039import org.apache.commons.vfs2.FileNotFoundException;
040import org.apache.commons.vfs2.FileObject;
041import org.apache.commons.vfs2.FileSystemException;
042import org.apache.commons.vfs2.FileType;
043import org.apache.commons.vfs2.RandomAccessContent;
044import org.apache.commons.vfs2.provider.AbstractFileName;
045import org.apache.commons.vfs2.provider.AbstractFileObject;
046import org.apache.commons.vfs2.provider.UriParser;
047import org.apache.commons.vfs2.util.FileObjectUtils;
048import org.apache.commons.vfs2.util.Messages;
049import org.apache.commons.vfs2.util.MonitorInputStream;
050import org.apache.commons.vfs2.util.MonitorOutputStream;
051import org.apache.commons.vfs2.util.RandomAccessMode;
052
053/**
054 * An FTP file.
055 */
056public class FtpFileObject extends AbstractFileObject<FtpFileSystem> {
057
058    /**
059     * An InputStream that monitors for end-of-file.
060     */
061    final class FtpInputStream extends MonitorInputStream {
062        private final FtpClient client;
063
064        FtpInputStream(final FtpClient client, final InputStream in) {
065            super(in);
066            this.client = client;
067        }
068
069        FtpInputStream(final FtpClient client, final InputStream in, final int bufferSize) {
070            super(in, bufferSize);
071            this.client = client;
072        }
073
074        void abort() throws IOException {
075            client.abort();
076            close();
077        }
078
079        private boolean isTransferAbortedOkReplyCode() throws IOException {
080            final List<Integer> transferAbortedOkReplyCodes = FtpFileSystemConfigBuilder
081                .getInstance()
082                .getTransferAbortedOkReplyCodes(getAbstractFileSystem().getFileSystemOptions());
083            return transferAbortedOkReplyCodes != null && transferAbortedOkReplyCodes.contains(client.getReplyCode());
084        }
085
086        /**
087         * Called after the stream has been closed.
088         */
089        @Override
090        protected void onClose() throws IOException {
091            final boolean ok;
092            try {
093                ok = client.completePendingCommand() || isTransferAbortedOkReplyCode();
094            } finally {
095                getAbstractFileSystem().putClient(client);
096            }
097
098            if (!ok) {
099                throw new FileSystemException("vfs.provider.ftp/finish-get.error", getName());
100            }
101        }
102    }
103    /**
104     * An OutputStream that monitors for end-of-file.
105     */
106    private final class FtpOutputStream extends MonitorOutputStream {
107        private final FtpClient client;
108
109        FtpOutputStream(final FtpClient client, final OutputStream outstr) {
110            super(outstr);
111            this.client = client;
112        }
113
114        /**
115         * Called after this stream is closed.
116         */
117        @Override
118        protected void onClose() throws IOException {
119            final boolean ok;
120            try {
121                ok = client.completePendingCommand();
122            } finally {
123                getAbstractFileSystem().putClient(client);
124            }
125
126            if (!ok) {
127                throw new FileSystemException("vfs.provider.ftp/finish-put.error", getName());
128            }
129        }
130    }
131
132    private static final long DEFAULT_TIMESTAMP = 0L;
133    private static final Map<String, FTPFile> EMPTY_FTP_FILE_MAP = Collections
134            .unmodifiableMap(new TreeMap<>());
135
136    private static final FTPFile UNKNOWN = new FTPFile();
137
138    private static final Log log = LogFactory.getLog(FtpFileObject.class);
139    private volatile boolean mdtmSet;
140    private final String relPath;
141    // Cached info
142    private volatile FTPFile ftpFile;
143    private volatile Map<String, FTPFile> childMap;
144
145    private volatile FileObject linkDestination;
146
147    private final AtomicBoolean inRefresh = new AtomicBoolean();
148
149    /**
150     * Constructs a new instance.
151     *
152     * @param fileName the file name.
153     * @param fileSystem the file system.
154     * @param rootName the root name.
155     * @throws FileSystemException if an file system error occurs.
156     */
157    protected FtpFileObject(final AbstractFileName fileName, final FtpFileSystem fileSystem, final FileName rootName)
158            throws FileSystemException {
159        super(fileName, fileSystem);
160        final String relPath = UriParser.decode(rootName.getRelativeName(fileName));
161        if (".".equals(relPath)) {
162            // do not use the "." as path against the ftp-server
163            // e.g. the uu.net ftp-server do a recursive listing then
164            // this.relPath = UriParser.decode(rootName.getPath());
165            // this.relPath = ".";
166            this.relPath = null;
167        } else {
168            this.relPath = relPath;
169        }
170    }
171
172    /**
173     * Attaches this file object to its file resource.
174     */
175    @Override
176    protected void doAttach() throws IOException {
177        // Get the parent folder to find the info for this file
178        // VFS-210 getInfo(false);
179    }
180
181    /**
182     * Creates this file as a folder.
183     */
184    @Override
185    protected void doCreateFolder() throws Exception {
186        final boolean ok;
187        final FtpClient client = getAbstractFileSystem().getClient();
188        try {
189            ok = client.makeDirectory(relPath);
190        } finally {
191            getAbstractFileSystem().putClient(client);
192        }
193
194        if (!ok) {
195            throw new FileSystemException("vfs.provider.ftp/create-folder.error", getName());
196        }
197    }
198
199    /**
200     * Deletes the file.
201     */
202    @Override
203    protected void doDelete() throws Exception {
204        synchronized (getFileSystem()) {
205            if (ftpFile != null) {
206                final boolean ok;
207                final FtpClient ftpClient = getAbstractFileSystem().getClient();
208                try {
209                    if (ftpFile.isDirectory()) {
210                        ok = ftpClient.removeDirectory(relPath);
211                    } else {
212                        ok = ftpClient.deleteFile(relPath);
213                    }
214                } finally {
215                    getAbstractFileSystem().putClient(ftpClient);
216                }
217
218                if (!ok) {
219                    throw new FileSystemException("vfs.provider.ftp/delete-file.error", getName());
220                }
221                ftpFile = null;
222            }
223            childMap = EMPTY_FTP_FILE_MAP;
224        }
225    }
226
227    /**
228     * Detaches this file object from its file resource.
229     */
230    @Override
231    protected void doDetach() {
232        synchronized (getFileSystem()) {
233            ftpFile = null;
234            childMap = null;
235            mdtmSet = false;
236        }
237    }
238
239    /**
240     * Fetches the children of this file, if not already cached.
241     */
242    private void doGetChildren() throws IOException {
243        if (childMap != null) {
244            return;
245        }
246
247        final FtpClient client = getAbstractFileSystem().getClient();
248        try {
249            final String path = ftpFile != null && ftpFile.isSymbolicLink()
250                    ? getFileSystem().getFileSystemManager().resolveName(getParent().getName(), ftpFile.getLink())
251                            .getPath()
252                    : relPath;
253            final FTPFile[] tmpChildren = client.listFiles(path);
254            if (ArrayUtils.isEmpty(tmpChildren)) {
255                childMap = EMPTY_FTP_FILE_MAP;
256            } else {
257                childMap = new TreeMap<>();
258
259                // Remove '.' and '..' elements
260                for (int i = 0; i < tmpChildren.length; i++) {
261                    final FTPFile child = tmpChildren[i];
262                    if (child == null) {
263                        if (log.isDebugEnabled()) {
264                            log.debug(Messages.getString("vfs.provider.ftp/invalid-directory-entry.debug",
265                                    Integer.valueOf(i), relPath));
266                        }
267                        continue;
268                    }
269                    if (!".".equals(child.getName()) && !"..".equals(child.getName())) {
270                        childMap.put(child.getName(), child);
271                    }
272                }
273            }
274        } finally {
275            getAbstractFileSystem().putClient(client);
276        }
277    }
278
279    /**
280     * Returns the size of the file content (in bytes).
281     */
282    @Override
283    protected long doGetContentSize() throws Exception {
284        synchronized (getFileSystem()) {
285            if (ftpFile == null) {
286                return 0;
287            }
288            if (ftpFile.isSymbolicLink()) {
289                final FileObject linkDest = getLinkDestination();
290                // VFS-437: Try to avoid a recursion loop.
291                if (isCircular(linkDest)) {
292                    return ftpFile.getSize();
293                }
294                return linkDest.getContent().getSize();
295            }
296            return ftpFile.getSize();
297        }
298    }
299
300    /**
301     * Creates an input stream to read the file content from.
302     */
303    @Override
304    protected InputStream doGetInputStream(final int bufferSize) throws Exception {
305        final FtpClient client = getAbstractFileSystem().getClient();
306        try {
307            final InputStream inputStream = client.retrieveFileStream(relPath, 0);
308            // VFS-210
309            if (inputStream == null) {
310                throw new FileNotFoundException(getName().toString());
311            }
312            return new FtpInputStream(client, inputStream, bufferSize);
313        } catch (final Exception e) {
314            getAbstractFileSystem().putClient(client);
315            throw e;
316        }
317    }
318
319    /**
320     * Gets the last modified time on an FTP file
321     *
322     * @see org.apache.commons.vfs2.provider.AbstractFileObject#doGetLastModifiedTime()
323     */
324    @Override
325    protected long doGetLastModifiedTime() throws Exception {
326        synchronized (getFileSystem()) {
327            if (ftpFile == null) {
328                return DEFAULT_TIMESTAMP;
329            }
330            if (ftpFile.isSymbolicLink()) {
331                final FileObject linkDest = getLinkDestination();
332                // VFS-437: Try to avoid a recursion loop.
333                if (isCircular(linkDest)) {
334                    return getTimestampMillis();
335                }
336                return linkDest.getContent().getLastModifiedTime();
337            }
338            return getTimestampMillis();
339        }
340    }
341
342    /**
343     * Creates an output stream to write the file content to.
344     */
345    @Override
346    protected OutputStream doGetOutputStream(final boolean bAppend) throws Exception {
347        final FtpClient client = getAbstractFileSystem().getClient();
348        try {
349            final OutputStream out;
350            if (bAppend) {
351                out = client.appendFileStream(relPath);
352            } else {
353                out = client.storeFileStream(relPath);
354            }
355
356            FileSystemException.requireNonNull(out, "vfs.provider.ftp/output-error.debug", getName(),
357                    client.getReplyString());
358
359            return new FtpOutputStream(client, out);
360        } catch (final Exception e) {
361            getAbstractFileSystem().putClient(client);
362            throw e;
363        }
364    }
365
366    @Override
367    protected RandomAccessContent doGetRandomAccessContent(final RandomAccessMode mode) throws Exception {
368        return new FtpRandomAccessContent(this, mode);
369    }
370
371    /**
372     * Determines the type of the file, returns null if the file does not exist.
373     */
374    @Override
375    protected FileType doGetType() throws Exception {
376        // VFS-210
377        synchronized (getFileSystem()) {
378            if (ftpFile == null) {
379                setFTPFile(false);
380            }
381
382            if (ftpFile == UNKNOWN) {
383                return FileType.IMAGINARY;
384            }
385            if (ftpFile.isDirectory()) {
386                return FileType.FOLDER;
387            }
388            if (ftpFile.isFile()) {
389                return FileType.FILE;
390            }
391            if (ftpFile.isSymbolicLink()) {
392                final FileObject linkDest = getLinkDestination();
393                // VFS-437: We need to check if the symbolic link links back to the symbolic link itself
394                if (isCircular(linkDest)) {
395                    // If the symbolic link links back to itself, treat it as an imaginary file to prevent following
396                    // this link. If the user tries to access the link as a file or directory, the user will end up with
397                    // a FileSystemException warning that the file cannot be accessed. This is to prevent the infinite
398                    // call back to doGetType() to prevent the StackOverFlow
399                    return FileType.IMAGINARY;
400                }
401                return linkDest.getType();
402
403            }
404        }
405        throw new FileSystemException("vfs.provider.ftp/get-type.error", getName());
406    }
407
408    /**
409     * Lists the children of the file.
410     */
411    @Override
412    protected String[] doListChildren() throws Exception {
413        // List the children of this file
414        doGetChildren();
415
416        // VFS-210
417        if (childMap == null) {
418            return null;
419        }
420
421        // TODO - get rid of this children stuff
422        final String[] childNames = childMap.values().stream().filter(Objects::nonNull).map(FTPFile::getName).toArray(String[]::new);
423
424        return UriParser.encode(childNames);
425    }
426
427    @Override
428    protected FileObject[] doListChildrenResolved() throws Exception {
429        synchronized (getFileSystem()) {
430            if (ftpFile != null && ftpFile.isSymbolicLink()) {
431                final FileObject linkDest = getLinkDestination();
432                // VFS-437: Try to avoid a recursion loop.
433                if (isCircular(linkDest)) {
434                    return null;
435                }
436                return linkDest.getChildren();
437            }
438        }
439        return null;
440    }
441
442    /**
443     * Renames the file
444     */
445    @Override
446    protected void doRename(final FileObject newFile) throws Exception {
447        synchronized (getFileSystem()) {
448            final boolean ok;
449            final FtpClient ftpClient = getAbstractFileSystem().getClient();
450            try {
451                final String newName = ((FtpFileObject) FileObjectUtils.getAbstractFileObject(newFile)).getRelPath();
452                ok = ftpClient.rename(relPath, newName);
453            } finally {
454                getAbstractFileSystem().putClient(ftpClient);
455            }
456
457            if (!ok) {
458                throw new FileSystemException("vfs.provider.ftp/rename-file.error", getName().toString(), newFile);
459            }
460            ftpFile = null;
461            childMap = EMPTY_FTP_FILE_MAP;
462        }
463    }
464
465    /**
466     * Called by child file objects, to locate their FTP file info.
467     *
468     * @param name the file name in its native form i.e. without URI stuff (%nn)
469     * @param flush recreate children cache
470     */
471    private FTPFile getChildFile(final String name, final boolean flush) throws IOException {
472        /*
473         * If we should flush cached children, clear our children map unless we're in the middle of a refresh in which
474         * case we've just recently refreshed our children. No need to do it again when our children are refresh()ed,
475         * calling getChildFile() for themselves from within getInfo(). See getChildren().
476         */
477        if (flush && !inRefresh.get()) {
478            childMap = null;
479        }
480
481        // List the children of this file
482        doGetChildren();
483
484        // Look for the requested child
485        // VFS-210 adds the null check.
486        return childMap != null ? childMap.get(name) : null;
487    }
488
489    /**
490     * Returns the file's list of children.
491     *
492     * @return The list of children
493     * @throws FileSystemException If there was a problem listing children
494     * @see AbstractFileObject#getChildren()
495     * @since 2.0
496     */
497    @Override
498    public FileObject[] getChildren() throws FileSystemException {
499        try {
500            if (doGetType() != FileType.FOLDER) {
501                throw new FileNotFolderException(getName());
502            }
503        } catch (final Exception ex) {
504            throw new FileNotFolderException(getName(), ex);
505        }
506
507        try {
508            /*
509             * Wrap our parent implementation, noting that we're refreshing so that we don't refresh() ourselves and
510             * each of our parents for each child. Note that refresh() will list children. Meaning, if this file
511             * has C children, P parents, there will be (C * P) listings made with (C * (P + 1)) refreshes, when there
512             * should really only be 1 listing and C refreshes.
513             */
514            inRefresh.set(true);
515            return super.getChildren();
516        } finally {
517            inRefresh.set(false);
518        }
519    }
520
521    FtpInputStream getInputStream(final long filePointer) throws IOException {
522        final FtpClient client = getAbstractFileSystem().getClient();
523        try {
524            final InputStream instr = client.retrieveFileStream(relPath, filePointer);
525            FileSystemException.requireNonNull(instr, "vfs.provider.ftp/input-error.debug", getName(),
526                    client.getReplyString());
527            return new FtpInputStream(client, instr);
528        } catch (final IOException e) {
529            getAbstractFileSystem().putClient(client);
530            throw e;
531        }
532    }
533
534    private FileObject getLinkDestination() throws FileSystemException {
535        if (linkDestination == null) {
536            final String path;
537            synchronized (getFileSystem()) {
538                path = ftpFile == null ? null : ftpFile.getLink();
539            }
540            final FileName parent = getName().getParent();
541            final FileName relativeTo = parent == null ? getName() : parent;
542            final FileName linkDestinationName = getFileSystem().getFileSystemManager().resolveName(relativeTo, path);
543            linkDestination = getFileSystem().resolveFile(linkDestinationName);
544        }
545        return linkDestination;
546    }
547
548    String getRelPath() {
549        return relPath;
550    }
551
552    /**
553     * ftpFile is not null.
554     */
555    @SuppressWarnings("resource") // abstractFileSystem is managed in the superclass.
556    private long getTimestampMillis() throws IOException {
557        final FtpFileSystem abstractFileSystem = getAbstractFileSystem();
558        final Boolean mdtmLastModifiedTime = FtpFileSystemConfigBuilder.getInstance()
559            .getMdtmLastModifiedTime(abstractFileSystem.getFileSystemOptions());
560        if (mdtmLastModifiedTime != null && mdtmLastModifiedTime.booleanValue()) {
561            final FtpClient client = abstractFileSystem.getClient();
562            if (!mdtmSet && client.hasFeature("MDTM")) {
563                final Instant mdtmInstant = client.mdtmInstant(relPath);
564                final Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
565                final long epochMilli = mdtmInstant.toEpochMilli();
566                calendar.setTimeInMillis(epochMilli);
567                ftpFile.setTimestamp(calendar);
568                mdtmSet = true;
569            }
570        }
571        return ftpFile.getTimestamp().getTime().getTime();
572    }
573
574    /**
575     * This is an over simplistic implementation for VFS-437.
576     */
577    private boolean isCircular(final FileObject linkDest) throws FileSystemException {
578        return linkDest.getName().getPathDecoded().equals(getName().getPathDecoded());
579    }
580
581    /**
582     * Called when the type or content of this file changes.
583     */
584    @Override
585    protected void onChange() throws IOException {
586        childMap = null;
587
588        if (getType().equals(FileType.IMAGINARY)) {
589            // file is deleted, avoid server lookup
590            synchronized (getFileSystem()) {
591                ftpFile = UNKNOWN;
592            }
593            return;
594        }
595
596        setFTPFile(true);
597    }
598
599    /**
600     * Called when the children of this file change.
601     */
602    @Override
603    protected void onChildrenChanged(final FileName child, final FileType newType) {
604        if (childMap != null && newType.equals(FileType.IMAGINARY)) {
605            Uncheck.run(() -> childMap.remove(UriParser.decode(child.getBaseName())));
606        } else {
607            // if child was added we have to rescan the children
608            // TODO - get rid of this
609            childMap = null;
610        }
611    }
612
613    /**
614     * @throws FileSystemException if an error occurs.
615     */
616    @Override
617    public void refresh() throws FileSystemException {
618        if (inRefresh.compareAndSet(false, true)) {
619            try {
620                super.refresh();
621                synchronized (getFileSystem()) {
622                    ftpFile = null;
623                }
624                /*
625                 * VFS-210 try { // this will tell the parent to recreate its children collection getInfo(true); } catch
626                 * (IOException e) { throw new FileSystemException(e); }
627                 */
628            } finally {
629                inRefresh.set(false);
630            }
631        }
632    }
633
634    /**
635     * Sets the internal FTPFile for this instance.
636     */
637    private void setFTPFile(final boolean flush) throws IOException {
638        synchronized (getFileSystem()) {
639            final FtpFileObject parent = (FtpFileObject) FileObjectUtils.getAbstractFileObject(getParent());
640            final FTPFile newFileInfo;
641            if (parent != null) {
642                newFileInfo = parent.getChildFile(UriParser.decode(getName().getBaseName()), flush);
643            } else {
644                // Assume the root is a directory and exists
645                newFileInfo = new FTPFile();
646                newFileInfo.setType(FTPFile.DIRECTORY_TYPE);
647            }
648            ftpFile = newFileInfo == null ? UNKNOWN : newFileInfo;
649        }
650    }
651}