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