diff --git a/src/support.c b/src/support.c
index 45d2126de..293dc7231 100644
--- a/src/support.c
+++ b/src/support.c
@@ -2,7 +2,7 @@
  * ProFTPD - FTP server daemon
  * Copyright (c) 1997, 1998 Public Flood Software
  * Copyright (c) 1999, 2000 MacGyver aka Habeeb J. Dihu <macgyver@tos.net>
- * Copyright (c) 2001-2016 The ProFTPD Project team
+ * Copyright (c) 2001-2017 The ProFTPD Project team
  *
  * 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
@@ -440,6 +440,9 @@ int dir_readlink(pool *p, const char *path, char *buf, size_t bufsz,
     return -1;
   }
 
+  pr_trace_msg("fsio", 9,
+    "dir_readlink() read link '%.*s' for path '%s'", (int) len, buf, path);
+
   if (len == 0 ||
       (size_t) len == bufsz) {
     /* If we read nothing in, OR if the given buffer was completely
@@ -530,15 +533,24 @@ int dir_readlink(pool *p, const char *path, char *buf, size_t bufsz,
      */
 
     ptr = strrchr(path, '/');
-    if (ptr != NULL &&
-        ptr != path) {
-      char *parent_dir;
+    if (ptr != NULL) {
+      if (ptr != path) {
+        char *parent_dir;
 
-      parent_dir = pstrndup(tmp_pool, path, (ptr - path));
-      dst_path = pdircat(tmp_pool, parent_dir, dst_path, NULL);
+        parent_dir = pstrndup(tmp_pool, path, (ptr - path));
+        dst_path = pdircat(tmp_pool, parent_dir, dst_path, NULL);
 
-    } else {
-      dst_path = pdircat(tmp_pool, path, dst_path, NULL);
+      } else {
+        /* Watch out for the case where the destination path might start
+         * with a period.
+         */
+        if (*dst_path != '.') {
+          dst_path = pdircat(tmp_pool, path, dst_path, NULL);
+
+        } else {
+          dst_path = pdircat(tmp_pool, "/", dst_path, NULL);
+        }
+      }
     }
   }
 
diff --git a/tests/api/misc.c b/tests/api/misc.c
index 926d9b3e3..122463681 100644
--- a/tests/api/misc.c
+++ b/tests/api/misc.c
@@ -1,6 +1,6 @@
 /*
  * ProFTPD - FTP server testsuite
- * Copyright (c) 2015-2016 The ProFTPD Project team
+ * Copyright (c) 2015-2017 The ProFTPD Project team
  *
  * 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
@@ -533,6 +533,28 @@ START_TEST (dir_readlink_test) {
   fail_unless(strcmp(buf, expected_path) == 0, "Expected '%s', got '%s'",
     expected_path, buf);
 
+  /* Now use a relative path that does not start with '.' */
+  memset(buf, '\0', bufsz);
+  dst_path = "file.txt";
+  dst_pathlen = strlen(dst_path);
+  expected_path = "./file.txt";
+  expected_pathlen = strlen(expected_path);
+
+  (void) unlink(path);
+  res = symlink(dst_path, path);
+  fail_unless(res == 0, "Failed to symlink '%s' to '%s': %s", path, dst_path,
+    strerror(errno));
+
+  session.chroot_path = "/tmp";
+  flags = PR_DIR_READLINK_FL_HANDLE_REL_PATH;
+  res = dir_readlink(p, path, buf, bufsz, flags);
+  fail_if(res < 0, "Failed to read '%s' symlink: %s", path, strerror(errno));
+  fail_unless((size_t) res == expected_pathlen,
+    "Expected length %lu, got %d (%s)", (unsigned long) expected_pathlen, res,
+    buf);
+  fail_unless(strcmp(buf, expected_path) == 0, "Expected '%s', got '%s'",
+    expected_path, buf);
+
   (void) unlink(misc_test_readlink);
 }
 END_TEST
diff --git a/tests/t/lib/ProFTPD/Tests/Commands/LIST.pm b/tests/t/lib/ProFTPD/Tests/Commands/LIST.pm
index 5528a2542..63e93691c 100644
--- a/tests/t/lib/ProFTPD/Tests/Commands/LIST.pm
+++ b/tests/t/lib/ProFTPD/Tests/Commands/LIST.pm
@@ -218,6 +218,21 @@ my $TESTS = {
     test_class => [qw(bug forking)],
   },
 
+  list_symlink_rel_path_chrooted_bug4322 => {
+    order => ++$order,
+    test_class => [qw(bug forking rootprivs)],
+  },
+
+  list_symlink_rel_path_subdir_chrooted_bug4322 => {
+    order => ++$order,
+    test_class => [qw(bug forking rootprivs)],
+  },
+
+  list_symlink_rel_path_subdir_cwd_chrooted_bug4322 => {
+    order => ++$order,
+    test_class => [qw(bug forking rootprivs)],
+  },
+
   # XXX Plenty of other tests needed: params, maxfiles, maxdirs, depth, etc
 };
 
@@ -6205,4 +6220,389 @@ sub list_option_parsing {
   unlink($log_file);
 }
 
+sub list_symlink_rel_path_chrooted_bug4322 {
+  my $self = shift;
+  my $tmpdir = $self->{tmpdir};
+  my $setup = test_setup($tmpdir, 'cmds');
+
+  my $dst_path = 'domains/test.oxilion.nl/public_html';
+  my $dst_dir = File::Spec->rel2abs("$tmpdir/$dst_path");
+  mkpath($dst_dir);
+
+  my $cwd = getcwd();
+  unless (chdir("$tmpdir")) {
+    die("Can't chdir to $tmpdir: $!");
+  }
+
+  unless (symlink("./$dst_path", 'public_html')) {
+    die("Can't symlink 'public_html' to './$dst_path': $!");
+  }
+
+  unless (chdir($cwd)) {
+    die("Can't chdir to $cwd: $!");
+  }
+
+  if ($< == 0) {
+    unless (chmod(0755, $dst_dir)) {
+      die("Can't set perms on $dst_dir to 0755: $!");
+    }
+
+    unless (chown($setup->{uid}, $setup->{gid}, $dst_dir)) {
+      die("Can't set owner of $dst_dir to $setup->{uid}/$setup->{gid}: $!");
+    }
+  }
+
+  my $config = {
+    PidFile => $setup->{pid_file},
+    ScoreboardFile => $setup->{scoreboard_file},
+    SystemLog => $setup->{log_file},
+
+    AuthUserFile => $setup->{auth_user_file},
+    AuthGroupFile => $setup->{auth_group_file},
+    DefaultRoot => '~',
+
+    IfModules => {
+      'mod_delay.c' => {
+        DelayEngine => 'off',
+      },
+    },
+  };
+
+  my ($port, $config_user, $config_group) = config_write($setup->{config_file},
+    $config);
+
+  # Open pipes, for use between the parent and child processes.  Specifically,
+  # the child will indicate when it's done with its test by writing a message
+  # to the parent.
+  my ($rfh, $wfh);
+  unless (pipe($rfh, $wfh)) {
+    die("Can't open pipe: $!");
+  }
+
+  my $ex;
+
+  # Fork child
+  $self->handle_sigchld();
+  defined(my $pid = fork()) or die("Can't fork: $!");
+  if ($pid) {
+    eval {
+      my $client = ProFTPD::TestSuite::FTP->new('127.0.0.1', $port);
+      $client->login($setup->{user}, $setup->{passwd});
+
+      my $conn = $client->list_raw();
+      unless ($conn) {
+        die("LIST failed: " . $client->response_code() . " " .
+          $client->response_msg());
+      }
+
+      my $buf;
+      my $res = $conn->read($buf, 8192, 25);
+      eval { $conn->close() };
+
+      my $resp_code = $client->response_code();
+      my $resp_msg = $client->response_msg();
+      $self->assert_transfer_ok($resp_code, $resp_msg);
+
+      if ($ENV{TEST_VERBOSE}) {
+        print STDERR "# LIST:\n$buf\n";
+      }
+
+      $res = {};
+      my $lines = [split(/(\r)?\n/, $buf)];
+      foreach my $line (@$lines) {
+        if ($line =~ /\s+(\S+)$/) {
+          $res->{$1} = 1;
+        }
+      }
+
+      my $list_count = scalar(keys(%$res));
+      my $expected = 8;
+      $self->assert($list_count == $expected,
+        "Expected $expected entries, got $list_count");
+
+      $self->assert($res->{'/domains/test.oxilion.nl/public_html'},
+        "Expected '/domains/test.oxilion.nl/public_html'");
+      $client->quit();
+    };
+    if ($@) {
+      $ex = $@;
+    }
+
+    $wfh->print("done\n");
+    $wfh->flush();
+
+  } else {
+    eval { server_wait($setup->{config_file}, $rfh) };
+    if ($@) {
+      warn($@);
+      exit 1;
+    }
+
+    exit 0;
+  }
+
+  # Stop server
+  server_stop($setup->{pid_file});
+  $self->assert_child_ok($pid);
+
+  test_cleanup($setup->{log_file}, $ex);
+}
+
+sub list_symlink_rel_path_subdir_chrooted_bug4322 {
+  my $self = shift;
+  my $tmpdir = $self->{tmpdir};
+  my $setup = test_setup($tmpdir, 'cmds');
+
+  my $dst_path = 'domains/test.oxilion.nl/public_html';
+  my $dst_dir = File::Spec->rel2abs("$tmpdir/test.d/$dst_path");
+  mkpath($dst_dir);
+
+  my $cwd = getcwd();
+  unless (chdir("$tmpdir/test.d")) {
+    die("Can't chdir to $tmpdir: $!");
+  }
+
+  unless (symlink("./$dst_path", 'public_html')) {
+    die("Can't symlink 'public_html' to './$dst_path': $!");
+  }
+
+  unless (chdir($cwd)) {
+    die("Can't chdir to $cwd: $!");
+  }
+
+  if ($< == 0) {
+    unless (chmod(0755, $dst_dir)) {
+      die("Can't set perms on $dst_dir to 0755: $!");
+    }
+
+    unless (chown($setup->{uid}, $setup->{gid}, $dst_dir)) {
+      die("Can't set owner of $dst_dir to $setup->{uid}/$setup->{gid}: $!");
+    }
+  }
+
+  my $config = {
+    PidFile => $setup->{pid_file},
+    ScoreboardFile => $setup->{scoreboard_file},
+    SystemLog => $setup->{log_file},
+
+    AuthUserFile => $setup->{auth_user_file},
+    AuthGroupFile => $setup->{auth_group_file},
+    DefaultRoot => '~',
+
+    IfModules => {
+      'mod_delay.c' => {
+        DelayEngine => 'off',
+      },
+    },
+  };
+
+  my ($port, $config_user, $config_group) = config_write($setup->{config_file},
+    $config);
+
+  # Open pipes, for use between the parent and child processes.  Specifically,
+  # the child will indicate when it's done with its test by writing a message
+  # to the parent.
+  my ($rfh, $wfh);
+  unless (pipe($rfh, $wfh)) {
+    die("Can't open pipe: $!");
+  }
+
+  my $ex;
+
+  # Fork child
+  $self->handle_sigchld();
+  defined(my $pid = fork()) or die("Can't fork: $!");
+  if ($pid) {
+    eval {
+      my $client = ProFTPD::TestSuite::FTP->new('127.0.0.1', $port);
+      $client->login($setup->{user}, $setup->{passwd});
+
+      my $conn = $client->list_raw('test.d');
+      unless ($conn) {
+        die("LIST test.d failed: " . $client->response_code() . " " .
+          $client->response_msg());
+      }
+
+      my $buf;
+      my $res = $conn->read($buf, 8192, 25);
+      eval { $conn->close() };
+
+      my $resp_code = $client->response_code();
+      my $resp_msg = $client->response_msg();
+      $self->assert_transfer_ok($resp_code, $resp_msg);
+
+      if ($ENV{TEST_VERBOSE}) {
+        print STDERR "# LIST:\n$buf\n";
+      }
+
+      $res = {};
+      my $lines = [split(/(\r)?\n/, $buf)];
+      foreach my $line (@$lines) {
+        if ($line =~ /\s+(\S+)$/) {
+          $res->{$1} = 1;
+        }
+      }
+
+      my $list_count = scalar(keys(%$res));
+      my $expected = 2;
+      $self->assert($list_count == $expected,
+        "Expected $expected entries, got $list_count");
+
+      $self->assert($res->{'/domains/test.oxilion.nl/public_html'},
+        "Expected '/domains/test.oxilion.nl/public_html'");
+      $client->quit();
+    };
+    if ($@) {
+      $ex = $@;
+    }
+
+    $wfh->print("done\n");
+    $wfh->flush();
+
+  } else {
+    eval { server_wait($setup->{config_file}, $rfh) };
+    if ($@) {
+      warn($@);
+      exit 1;
+    }
+
+    exit 0;
+  }
+
+  # Stop server
+  server_stop($setup->{pid_file});
+  $self->assert_child_ok($pid);
+
+  test_cleanup($setup->{log_file}, $ex);
+}
+
+sub list_symlink_rel_path_subdir_cwd_chrooted_bug4322 {
+  my $self = shift;
+  my $tmpdir = $self->{tmpdir};
+  my $setup = test_setup($tmpdir, 'cmds');
+
+  my $dst_path = 'domains/test.oxilion.nl/public_html';
+  my $dst_dir = File::Spec->rel2abs("$tmpdir/test.d/$dst_path");
+  mkpath($dst_dir);
+
+  my $cwd = getcwd();
+  unless (chdir("$tmpdir/test.d")) {
+    die("Can't chdir to $tmpdir: $!");
+  }
+
+  unless (symlink("./$dst_path", 'public_html')) {
+    die("Can't symlink 'public_html' to './$dst_path': $!");
+  }
+
+  unless (chdir($cwd)) {
+    die("Can't chdir to $cwd: $!");
+  }
+
+  if ($< == 0) {
+    unless (chmod(0755, $dst_dir)) {
+      die("Can't set perms on $dst_dir to 0755: $!");
+    }
+
+    unless (chown($setup->{uid}, $setup->{gid}, $dst_dir)) {
+      die("Can't set owner of $dst_dir to $setup->{uid}/$setup->{gid}: $!");
+    }
+  }
+
+  my $config = {
+    PidFile => $setup->{pid_file},
+    ScoreboardFile => $setup->{scoreboard_file},
+    SystemLog => $setup->{log_file},
+
+    AuthUserFile => $setup->{auth_user_file},
+    AuthGroupFile => $setup->{auth_group_file},
+    DefaultRoot => '~',
+
+    IfModules => {
+      'mod_delay.c' => {
+        DelayEngine => 'off',
+      },
+    },
+  };
+
+  my ($port, $config_user, $config_group) = config_write($setup->{config_file},
+    $config);
+
+  # Open pipes, for use between the parent and child processes.  Specifically,
+  # the child will indicate when it's done with its test by writing a message
+  # to the parent.
+  my ($rfh, $wfh);
+  unless (pipe($rfh, $wfh)) {
+    die("Can't open pipe: $!");
+  }
+
+  my $ex;
+
+  # Fork child
+  $self->handle_sigchld();
+  defined(my $pid = fork()) or die("Can't fork: $!");
+  if ($pid) {
+    eval {
+      my $client = ProFTPD::TestSuite::FTP->new('127.0.0.1', $port);
+      $client->login($setup->{user}, $setup->{passwd});
+      $client->cwd('test.d');
+
+      my $conn = $client->list_raw();
+      unless ($conn) {
+        die("LIST failed: " . $client->response_code() . " " .
+          $client->response_msg());
+      }
+
+      my $buf;
+      my $res = $conn->read($buf, 8192, 25);
+      eval { $conn->close() };
+
+      my $resp_code = $client->response_code();
+      my $resp_msg = $client->response_msg();
+      $self->assert_transfer_ok($resp_code, $resp_msg);
+
+      if ($ENV{TEST_VERBOSE}) {
+        print STDERR "# LIST:\n$buf\n";
+      }
+
+      $res = {};
+      my $lines = [split(/(\r)?\n/, $buf)];
+      foreach my $line (@$lines) {
+        if ($line =~ /\s+(\S+)$/) {
+          $res->{$1} = 1;
+        }
+      }
+
+      my $list_count = scalar(keys(%$res));
+      my $expected = 2;
+      $self->assert($list_count == $expected,
+        "Expected $expected entries, got $list_count");
+
+      $self->assert($res->{'/domains/test.oxilion.nl/public_html'},
+        "Expected '/domains/test.oxilion.nl/public_html'");
+      $client->quit();
+    };
+    if ($@) {
+      $ex = $@;
+    }
+
+    $wfh->print("done\n");
+    $wfh->flush();
+
+  } else {
+    eval { server_wait($setup->{config_file}, $rfh) };
+    if ($@) {
+      warn($@);
+      exit 1;
+    }
+
+    exit 0;
+  }
+
+  # Stop server
+  server_stop($setup->{pid_file});
+  $self->assert_child_ok($pid);
+
+  test_cleanup($setup->{log_file}, $ex);
+}
+
 1;
diff --git a/tests/t/lib/ProFTPD/Tests/Commands/MLSD.pm b/tests/t/lib/ProFTPD/Tests/Commands/MLSD.pm
index 8c343bbfb..410e5c59a 100644
--- a/tests/t/lib/ProFTPD/Tests/Commands/MLSD.pm
+++ b/tests/t/lib/ProFTPD/Tests/Commands/MLSD.pm
@@ -127,6 +127,21 @@ my $TESTS = {
     test_class => [qw(bug forking)],
   },
 
+  mlsd_symlink_rel_path_chrooted_bug4322 => {
+    order => ++$order,
+    test_class => [qw(bug forking rootprivs)],
+  },
+
+  mlsd_symlink_rel_path_subdir_chrooted_bug4322 => {
+    order => ++$order,
+    test_class => [qw(bug forking rootprivs)],
+  },
+
+  mlsd_symlink_rel_path_subdir_cwd_chrooted_bug4322 => {
+    order => ++$order,
+    test_class => [qw(bug forking rootprivs)],
+  },
+
   # XXX Plenty of other tests needed: params, maxfiles, maxdirs, depth, etc
 };
 
@@ -625,7 +640,7 @@ sub mlsd_ok_cwd_dir {
       # Make sure that the 'type' fact for the current directory is
       # "cdir" (Bug#4198).
       my $type = $res->{'.'};
-      my $expected = 'cdir';
+      $expected = 'cdir';
       $self->assert($expected eq $type,
         test_msg("Expected type '$expected', got '$type'"));
     };
@@ -767,14 +782,14 @@ sub mlsd_ok_other_dir_bug4198 {
       # Make sure that the 'type' fact for the current directory is
       # "cdir" (Bug#4198).
       my $type = $res->{'.'};
-      my $expected = 'cdir';
+      $expected = 'cdir';
       $self->assert($expected eq $type,
         test_msg("Expected type '$expected', got '$type'"));
 
       # Similarly, make sure that the 'type' fact for parent directory
       # (by name) is NOT "cdir", but is just "dir" (Bug#4198).
-      my $type = $res->{'sub.d'};
-      my $expected = 'dir';
+      $type = $res->{'sub.d'};
+      $expected = 'dir';
       $self->assert($expected eq $type,
         test_msg("Expected type '$expected', got '$type'"));
     };
@@ -3150,4 +3165,407 @@ sub mlsd_wide_dir {
   test_cleanup($setup->{log_file}, $ex);
 }
 
+sub mlsd_symlink_rel_path_chrooted_bug4322 {
+  my $self = shift;
+  my $tmpdir = $self->{tmpdir};
+  my $setup = test_setup($tmpdir, 'cmds');
+
+  my $dst_path = 'domains/test.oxilion.nl/public_html';
+  my $dst_dir = File::Spec->rel2abs("$tmpdir/$dst_path");
+  mkpath($dst_dir);
+
+  my $cwd = getcwd();
+  unless (chdir("$tmpdir")) {
+    die("Can't chdir to $tmpdir: $!");
+  }
+
+  unless (symlink("./$dst_path", 'public_html')) {
+    die("Can't symlink 'public_html' to './$dst_path': $!");
+  }
+
+  unless (chdir($cwd)) {
+    die("Can't chdir to $cwd: $!");
+  }
+
+  if ($< == 0) {
+    unless (chmod(0755, $dst_dir)) {
+      die("Can't set perms on $dst_dir to 0755: $!");
+    }
+
+    unless (chown($setup->{uid}, $setup->{gid}, $dst_dir)) {
+      die("Can't set owner of $dst_dir to $setup->{uid}/$setup->{gid}: $!");
+    }
+  }
+
+  my $config = {
+    PidFile => $setup->{pid_file},
+    ScoreboardFile => $setup->{scoreboard_file},
+    SystemLog => $setup->{log_file},
+
+    AuthUserFile => $setup->{auth_user_file},
+    AuthGroupFile => $setup->{auth_group_file},
+    ShowSymlinks => 'on',
+    DefaultRoot => '~',
+
+    IfModules => {
+      'mod_delay.c' => {
+        DelayEngine => 'off',
+      },
+    },
+  };
+
+  my ($port, $config_user, $config_group) = config_write($setup->{config_file},
+    $config);
+
+  # Open pipes, for use between the parent and child processes.  Specifically,
+  # the child will indicate when it's done with its test by writing a message
+  # to the parent.
+  my ($rfh, $wfh);
+  unless (pipe($rfh, $wfh)) {
+    die("Can't open pipe: $!");
+  }
+
+  my $ex;
+
+  # Fork child
+  $self->handle_sigchld();
+  defined(my $pid = fork()) or die("Can't fork: $!");
+  if ($pid) {
+    eval {
+      my $client = ProFTPD::TestSuite::FTP->new('127.0.0.1', $port);
+      $client->login($setup->{user}, $setup->{passwd});
+
+      my $conn = $client->mlsd_raw();
+      unless ($conn) {
+        die("MLSD failed: " . $client->response_code() . " " .
+          $client->response_msg());
+      }
+
+      my $buf;
+      $conn->read($buf, 8192, 30);
+      eval { $conn->close() };
+
+      if ($ENV{TEST_VERBOSE}) {
+        print STDERR "# MLSD:\n$buf\n";
+      }
+
+      my $res = {};
+      my $lines = [split(/(\r)?\n/, $buf)];
+
+      foreach my $line (@$lines) {
+        if ($line =~ /^modify=\S+;perm=\S+;type=(\S+);unique=(\S+);UNIX\.group=\d+;UNIX\.groupname=\S+;UNIX\.mode=\d+;UNIX\.owner=\d+;UNIX\.ownername=\S+; (.*?)$/) {
+          $res->{$3} = { type => $1, unique => $2 };
+        }
+      }
+
+      my $count = scalar(keys(%$res));
+      my $expected = 10;
+      unless ($count == $expected) {
+        die("MLSD returned wrong number of entries (expected $expected, got $count)");
+      }
+
+      # public_html is a symlink to domains/test.oxilion.nl/public_html.
+      # According to RFC3659, the unique fact for both of these should thus
+      # be the same, since they are the same underlying object.
+
+      $expected = 'OS.unix=symlink';
+      my $got = $res->{'public_html'}->{type};
+      $self->assert(qr/$expected/i, $got,
+        "Expected type fact '$expected', got '$got'");
+
+      $client->quit();
+    };
+    if ($@) {
+      $ex = $@;
+    }
+
+    $wfh->print("done\n");
+    $wfh->flush();
+
+  } else {
+    eval { server_wait($setup->{config_file}, $rfh) };
+    if ($@) {
+      warn($@);
+      exit 1;
+    }
+
+    exit 0;
+  }
+
+  # Stop server
+  server_stop($setup->{pid_file});
+  $self->assert_child_ok($pid);
+
+  test_cleanup($setup->{log_file}, $ex);
+}
+
+sub mlsd_symlink_rel_path_subdir_chrooted_bug4322 {
+  my $self = shift;
+  my $tmpdir = $self->{tmpdir};
+  my $setup = test_setup($tmpdir, 'cmds');
+
+  my $dst_path = 'domains/test.oxilion.nl/public_html';
+  my $dst_dir = File::Spec->rel2abs("$tmpdir/test.d/$dst_path");
+  mkpath($dst_dir);
+
+  my $cwd = getcwd();
+  unless (chdir("$tmpdir/test.d")) {
+    die("Can't chdir to $tmpdir/test.d: $!");
+  }
+
+  unless (symlink("./$dst_path", 'public_html')) {
+    die("Can't symlink 'public_html' to './$dst_path': $!");
+  }
+
+  unless (chdir($cwd)) {
+    die("Can't chdir to $cwd: $!");
+  }
+
+  if ($< == 0) {
+    unless (chmod(0755, $dst_dir)) {
+      die("Can't set perms on $dst_dir to 0755: $!");
+    }
+
+    unless (chown($setup->{uid}, $setup->{gid}, $dst_dir)) {
+      die("Can't set owner of $dst_dir to $setup->{uid}/$setup->{gid}: $!");
+    }
+  }
+
+  my $config = {
+    PidFile => $setup->{pid_file},
+    ScoreboardFile => $setup->{scoreboard_file},
+    SystemLog => $setup->{log_file},
+
+    AuthUserFile => $setup->{auth_user_file},
+    AuthGroupFile => $setup->{auth_group_file},
+    ShowSymlinks => 'on',
+    DefaultRoot => '~',
+
+    IfModules => {
+      'mod_delay.c' => {
+        DelayEngine => 'off',
+      },
+    },
+  };
+
+  my ($port, $config_user, $config_group) = config_write($setup->{config_file},
+    $config);
+
+  # Open pipes, for use between the parent and child processes.  Specifically,
+  # the child will indicate when it's done with its test by writing a message
+  # to the parent.
+  my ($rfh, $wfh);
+  unless (pipe($rfh, $wfh)) {
+    die("Can't open pipe: $!");
+  }
+
+  my $ex;
+
+  # Fork child
+  $self->handle_sigchld();
+  defined(my $pid = fork()) or die("Can't fork: $!");
+  if ($pid) {
+    eval {
+      my $client = ProFTPD::TestSuite::FTP->new('127.0.0.1', $port);
+      $client->login($setup->{user}, $setup->{passwd});
+
+      my $conn = $client->mlsd_raw('test.d');
+      unless ($conn) {
+        die("MLSD test.d failed: " . $client->response_code() . " " .
+          $client->response_msg());
+      }
+
+      my $buf;
+      $conn->read($buf, 8192, 30);
+      eval { $conn->close() };
+
+      if ($ENV{TEST_VERBOSE}) {
+        print STDERR "# MLSD:\n$buf\n";
+      }
+
+      my $res = {};
+      my $lines = [split(/(\r)?\n/, $buf)];
+
+      foreach my $line (@$lines) {
+        if ($line =~ /^modify=\S+;perm=\S+;type=(\S+);unique=(\S+);UNIX\.group=\d+;UNIX\.groupname=\S+;UNIX\.mode=\d+;UNIX\.owner=\d+;UNIX\.ownername=\S+; (.*?)$/) {
+          $res->{$3} = { type => $1, unique => $2 };
+        }
+      }
+
+      my $count = scalar(keys(%$res));
+      my $expected = 4;
+      unless ($count == $expected) {
+        die("MLSD returned wrong number of entries (expected $expected, got $count)");
+      }
+
+      # public_html is a symlink to domains/test.oxilion.nl/public_html.
+      # According to RFC3659, the unique fact for both of these should thus
+      # be the same, since they are the same underlying object.
+
+      $expected = 'OS.unix=symlink';
+      my $got = $res->{'public_html'}->{type};
+      $self->assert(qr/$expected/i, $got,
+        "Expected type fact '$expected', got '$got'");
+
+      $client->quit();
+    };
+    if ($@) {
+      $ex = $@;
+    }
+
+    $wfh->print("done\n");
+    $wfh->flush();
+
+  } else {
+    eval { server_wait($setup->{config_file}, $rfh) };
+    if ($@) {
+      warn($@);
+      exit 1;
+    }
+
+    exit 0;
+  }
+
+  # Stop server
+  server_stop($setup->{pid_file});
+  $self->assert_child_ok($pid);
+
+  test_cleanup($setup->{log_file}, $ex);
+}
+
+sub mlsd_symlink_rel_path_subdir_cwd_chrooted_bug4322 {
+  my $self = shift;
+  my $tmpdir = $self->{tmpdir};
+  my $setup = test_setup($tmpdir, 'cmds');
+
+  my $dst_path = 'domains/test.oxilion.nl/public_html';
+  my $dst_dir = File::Spec->rel2abs("$tmpdir/test.d/$dst_path");
+  mkpath($dst_dir);
+
+  my $cwd = getcwd();
+  unless (chdir("$tmpdir/test.d")) {
+    die("Can't chdir to $tmpdir/test.d: $!");
+  }
+
+  unless (symlink("./$dst_path", 'public_html')) {
+    die("Can't symlink 'public_html' to './$dst_path': $!");
+  }
+
+  unless (chdir($cwd)) {
+    die("Can't chdir to $cwd: $!");
+  }
+
+  if ($< == 0) {
+    unless (chmod(0755, $dst_dir)) {
+      die("Can't set perms on $dst_dir to 0755: $!");
+    }
+
+    unless (chown($setup->{uid}, $setup->{gid}, $dst_dir)) {
+      die("Can't set owner of $dst_dir to $setup->{uid}/$setup->{gid}: $!");
+    }
+  }
+
+  my $config = {
+    PidFile => $setup->{pid_file},
+    ScoreboardFile => $setup->{scoreboard_file},
+    SystemLog => $setup->{log_file},
+
+    AuthUserFile => $setup->{auth_user_file},
+    AuthGroupFile => $setup->{auth_group_file},
+    ShowSymlinks => 'on',
+    DefaultRoot => '~',
+
+    IfModules => {
+      'mod_delay.c' => {
+        DelayEngine => 'off',
+      },
+    },
+  };
+
+  my ($port, $config_user, $config_group) = config_write($setup->{config_file},
+    $config);
+
+  # Open pipes, for use between the parent and child processes.  Specifically,
+  # the child will indicate when it's done with its test by writing a message
+  # to the parent.
+  my ($rfh, $wfh);
+  unless (pipe($rfh, $wfh)) {
+    die("Can't open pipe: $!");
+  }
+
+  my $ex;
+
+  # Fork child
+  $self->handle_sigchld();
+  defined(my $pid = fork()) or die("Can't fork: $!");
+  if ($pid) {
+    eval {
+      my $client = ProFTPD::TestSuite::FTP->new('127.0.0.1', $port);
+      $client->login($setup->{user}, $setup->{passwd});
+      $client->cwd('test.d');
+
+      my $conn = $client->mlsd_raw();
+      unless ($conn) {
+        die("MLSD failed: " . $client->response_code() . " " .
+          $client->response_msg());
+      }
+
+      my $buf;
+      $conn->read($buf, 8192, 30);
+      eval { $conn->close() };
+
+      if ($ENV{TEST_VERBOSE}) {
+        print STDERR "# MLSD:\n$buf\n";
+      }
+
+      my $res = {};
+      my $lines = [split(/(\r)?\n/, $buf)];
+
+      foreach my $line (@$lines) {
+        if ($line =~ /^modify=\S+;perm=\S+;type=(\S+);unique=(\S+);UNIX\.group=\d+;UNIX\.groupname=\S+;UNIX\.mode=\d+;UNIX\.owner=\d+;UNIX\.ownername=\S+; (.*?)$/) {
+          $res->{$3} = { type => $1, unique => $2 };
+        }
+      }
+
+      my $count = scalar(keys(%$res));
+      my $expected = 4;
+      unless ($count == $expected) {
+        die("MLSD returned wrong number of entries (expected $expected, got $count)");
+      }
+
+      # public_html is a symlink to domains/test.oxilion.nl/public_html.
+      # According to RFC3659, the unique fact for both of these should thus
+      # be the same, since they are the same underlying object.
+
+      $expected = 'OS.unix=symlink';
+      my $got = $res->{'public_html'}->{type};
+      $self->assert(qr/$expected/i, $got,
+        "Expected type fact '$expected', got '$got'");
+
+      $client->quit();
+    };
+    if ($@) {
+      $ex = $@;
+    }
+
+    $wfh->print("done\n");
+    $wfh->flush();
+
+  } else {
+    eval { server_wait($setup->{config_file}, $rfh) };
+    if ($@) {
+      warn($@);
+      exit 1;
+    }
+
+    exit 0;
+  }
+
+  # Stop server
+  server_stop($setup->{pid_file});
+  $self->assert_child_ok($pid);
+
+  test_cleanup($setup->{log_file}, $ex);
+}
+
 1;
