Repository: PyFilesystem/pyfilesystem2 (python)

Issue:
FTPFS does not list certain folders
It seems that the pub folder is not listed and igrnoed. ftplib does show the folder. This is with python 3.8.

```python
import fs
fs.__version__

# 2.4.12

from fs.ftpfs import FTPFS
a = FTP(host='ftp.ensemblgenomes.org', user="anonymous", passwd="")
a.dir()

""""
drwxr-sr-x   72 ftp      ftp          8192 Feb 16 02:00 pub
-rw-r--r--    1 ftp      ftp            25 Jan 06 22:41 robots.txt
""""

from ftplib import FTP
a = FTPFS(host='ftp.ensemblgenomes.org')
a.listdir('.')

# ['robots.txt']
```

Pull request description:
Fix FTP not properly parsing lines with sticky/SUID/SGID permissions
## Type of changes

- Bug fix

## Checklist

- [x] I've run the latest [black](https://github.com/ambv/black) with default args on new code.
- [x] I've updated CHANGELOG.md and CONTRIBUTORS.md where appropriate.
- [x] I've added tests for new code.
- [x] I accept that @PyFilesystem/maintainers may be pedantic in the code review.

## Description

The Linux regex in `fs._ftp_parse` was unable to recognize sticky/SUID/SGID bits in permissions. Closes #451, which was caused by a SGID bit. I also fixed the regex so that is can recognize more Unix file types, instead of just `-`, `l` or `d` as before.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c99ffcc..aacded9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,7 +25,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
   test suites.
 - `FSTestCases` now builds the large data required for `upload` and `download` tests only
   once in order to reduce the total testing time.
-- `MemoryFS.move` and `MemoryFS.movedir` will now avoid copying data. 
+- `MemoryFS.move` and `MemoryFS.movedir` will now avoid copying data.
    Closes [#452](https://github.com/PyFilesystem/pyfilesystem2/issues/452).
 
 ### Fixed
@@ -36,6 +36,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Avoid creating a new connection on every call of `FTPFS.upload`. Closes [#455](https://github.com/PyFilesystem/pyfilesystem2/issues/455).
 - `WrapReadOnly.removetree` not raising a `ResourceReadOnly` when called. Closes [#468](https://github.com/PyFilesystem/pyfilesystem2/issues/468).
 - `WrapCachedDir.isdir` and `WrapCachedDir.isfile` raising a `ResourceNotFound` error on non-existing path ([#470](https://github.com/PyFilesystem/pyfilesystem2/pull/470)).
+- `FTPFS` not listing certain entries with sticky/SUID/SGID permissions set by Linux server ([#473](https://github.com/PyFilesystem/pyfilesystem2/pull/473)).
+  Closes [#451](https://github.com/PyFilesystem/pyfilesystem2/issues/451).
 
 
 ## [2.4.12] - 2021-01-14
diff --git a/fs/_ftp_parse.py b/fs/_ftp_parse.py
index 9e15a26..a9088ab 100644
--- a/fs/_ftp_parse.py
+++ b/fs/_ftp_parse.py
@@ -19,7 +19,8 @@ EPOCH_DT = datetime.datetime.fromtimestamp(0, UTC)
 RE_LINUX = re.compile(
     r"""
     ^
-    ([ldrwx-]{10})
+    ([-dlpscbD])
+    ([r-][w-][xsS-][r-][w-][xsS-][r-][w-][xtT-][\.\+]?)
     \s+?
     (\d+)
     \s+?
@@ -110,14 +111,14 @@ def _decode_linux_time(mtime):
 
 
 def decode_linux(line, match):
-    perms, links, uid, gid, size, mtime, name = match.groups()
-    is_link = perms.startswith("l")
-    is_dir = perms.startswith("d") or is_link
+    ty, perms, links, uid, gid, size, mtime, name = match.groups()
+    is_link = ty == "l"
+    is_dir = ty == "d" or is_link
     if is_link:
         name, _, _link_name = name.partition("->")
         name = name.strip()
         _link_name = _link_name.strip()
-    permissions = Permissions.parse(perms[1:])
+    permissions = Permissions.parse(perms)
 
     mtime_epoch = _decode_linux_time(mtime)
 


Test patch:
diff --git a/tests/test_ftp_parse.py b/tests/test_ftp_parse.py
index b9a69cf..d027082 100644
--- a/tests/test_ftp_parse.py
+++ b/tests/test_ftp_parse.py
@@ -1,5 +1,6 @@
 from __future__ import unicode_literals
 
+import textwrap
 import time
 import unittest
 
@@ -33,7 +34,7 @@ class TestFTPParse(unittest.TestCase):
         self.assertEqual(ftp_parse._parse_time("notadate", formats=["%b %d %Y"]), None)
 
     def test_parse(self):
-        self.assertEqual(ftp_parse.parse([""]), [])
+        self.assertListEqual(ftp_parse.parse([""]), [])
 
     def test_parse_line(self):
         self.assertIs(ftp_parse.parse_line("not a dir"), None)
@@ -41,15 +42,17 @@ class TestFTPParse(unittest.TestCase):
     @mock.patch("time.localtime")
     def test_decode_linux(self, mock_localtime):
         mock_localtime.return_value = time2017
-        directory = """\
-lrwxrwxrwx    1 0        0              19 Jan 18  2006 debian -> ./pub/mirror/debian
-drwxr-xr-x   10 0        0            4096 Aug 03 09:21 debian-archive
-lrwxrwxrwx    1 0        0              27 Nov 30  2015 debian-backports -> pub/mirror/debian-backports
-drwxr-xr-x   12 0        0            4096 Sep 29 13:13 pub
--rw-r--r--    1 0        0              26 Mar 04  2010 robots.txt
-drwxr-xr-x   8 foo      bar          4096 Oct  4 09:05 test
-drwxr-xr-x   2 foo-user foo-group         0 Jan  5 11:59 240485
-"""
+        directory = textwrap.dedent(
+            """
+            lrwxrwxrwx    1 0        0              19 Jan 18  2006 debian -> ./pub/mirror/debian
+            drwxr-xr-x   10 0        0            4096 Aug 03 09:21 debian-archive
+            lrwxrwxrwx    1 0        0              27 Nov 30  2015 debian-backports -> pub/mirror/debian-backports
+            drwxr-xr-x   12 0        0            4096 Sep 29 13:13 pub
+            -rw-r--r--    1 0        0              26 Mar 04  2010 robots.txt
+            drwxr-xr-x   8 foo      bar          4096 Oct  4 09:05 test
+            drwxr-xr-x   2 foo-user foo-group         0 Jan  5 11:59 240485
+            """
+        )
 
         expected = [
             {
@@ -158,25 +161,27 @@ drwxr-xr-x   2 foo-user foo-group         0 Jan  5 11:59 240485
             },
         ]
 
-        parsed = ftp_parse.parse(directory.splitlines())
-        self.assertEqual(parsed, expected)
+        parsed = ftp_parse.parse(directory.strip().splitlines())
+        self.assertListEqual(parsed, expected)
 
     @mock.patch("time.localtime")
     def test_decode_windowsnt(self, mock_localtime):
         mock_localtime.return_value = time2017
-        directory = """\
-unparsable line
-11-02-17  02:00AM       <DIR>          docs
-11-02-17  02:12PM       <DIR>          images
-11-02-17 02:12PM <DIR> AM to PM
-11-02-17  03:33PM                 9276 logo.gif
-05-11-20   22:11  <DIR>       src
-11-02-17   01:23       1      12
-11-02-17    4:54                 0 icon.bmp
-11-02-17    4:54AM                 0 icon.gif
-11-02-17    4:54PM                 0 icon.png
-11-02-17    16:54                 0 icon.jpg
-"""
+        directory = textwrap.dedent(
+            """
+            unparsable line
+            11-02-17  02:00AM       <DIR>          docs
+            11-02-17  02:12PM       <DIR>          images
+            11-02-17 02:12PM <DIR> AM to PM
+            11-02-17  03:33PM                 9276 logo.gif
+            05-11-20   22:11  <DIR>       src
+            11-02-17   01:23       1      12
+            11-02-17    4:54                 0 icon.bmp
+            11-02-17    4:54AM                 0 icon.gif
+            11-02-17    4:54PM                 0 icon.png
+            11-02-17    16:54                 0 icon.jpg
+            """
+        )
         expected = [
             {
                 "basic": {"is_dir": True, "name": "docs"},
@@ -230,5 +235,94 @@ unparsable line
             },
         ]
 
-        parsed = ftp_parse.parse(directory.splitlines())
+        parsed = ftp_parse.parse(directory.strip().splitlines())
         self.assertEqual(parsed, expected)
+
+    @mock.patch("time.localtime")
+    def test_decode_linux_suid(self, mock_localtime):
+        # reported in #451
+        mock_localtime.return_value = time2017
+        directory = textwrap.dedent(
+            """
+            drwxr-sr-x   66 ftp      ftp          8192 Mar 16 17:54 pub
+            -rw-r--r--    1 ftp      ftp            25 Mar 18 19:34 robots.txt
+            """
+        )
+        expected = [
+            {
+                "access": {
+                    "group": "ftp",
+                    "permissions": [
+                        "g_r",
+                        "g_s",
+                        "o_r",
+                        "o_x",
+                        "u_r",
+                        "u_w",
+                        "u_x",
+                    ],
+                    "user": "ftp",
+                },
+                "basic": {"is_dir": True, "name": "pub"},
+                "details": {"modified": 1489686840.0, "size": 8192, "type": 1},
+                "ftp": {
+                    "ls": "drwxr-sr-x   66 ftp      ftp          8192 Mar 16 17:54 pub"
+                },
+            },
+            {
+                "access": {
+                    "group": "ftp",
+                    "permissions": [
+                        "g_r",
+                        "o_r",
+                        "u_r",
+                        "u_w",
+                    ],
+                    "user": "ftp",
+                },
+                "basic": {"is_dir": False, "name": "robots.txt"},
+                "details": {"modified": 1489865640.0, "size": 25, "type": 2},
+                "ftp": {
+                    "ls": "-rw-r--r--    1 ftp      ftp            25 Mar 18 19:34 robots.txt"
+                },
+            },
+        ]
+
+        parsed = ftp_parse.parse(directory.strip().splitlines())
+        self.assertListEqual(parsed, expected)
+
+    @mock.patch("time.localtime")
+    def test_decode_linux_sticky(self, mock_localtime):
+        # reported in #451
+        mock_localtime.return_value = time2017
+        directory = textwrap.dedent(
+            """
+            drwxr-xr-t   66 ftp      ftp          8192 Mar 16 17:54 pub
+            """
+        )
+        expected = [
+            {
+                "access": {
+                    "group": "ftp",
+                    "permissions": [
+                        "g_r",
+                        "g_x",
+                        "o_r",
+                        "o_t",
+                        "u_r",
+                        "u_w",
+                        "u_x",
+                    ],
+                    "user": "ftp",
+                },
+                "basic": {"is_dir": True, "name": "pub"},
+                "details": {"modified": 1489686840.0, "size": 8192, "type": 1},
+                "ftp": {
+                    "ls": "drwxr-xr-t   66 ftp      ftp          8192 Mar 16 17:54 pub"
+                },
+            },
+        ]
+
+        self.maxDiff = None
+        parsed = ftp_parse.parse(directory.strip().splitlines())
+        self.assertListEqual(parsed, expected)