When calling file_move and destination directory does not exist, it does not work.

Just copying the relevant lines for better understanding:

Calling file move with a destination directory that does not exist:

file_move(stdClass $source, $destination = NULL, $replace = FILE_EXISTS_RENAME)

This calls:

if ($uri = file_unmanaged_move($source->uri, $destination, $replace)) {

Wich calls:

$filepath = file_unmanaged_copy($source, $destination, $replace);

And finally inside file_unmanaged_copy:

  // Prepare the destination directory.
  if (file_prepare_directory($destination, FILE_CREATE_DIRECTORY)) {
    // The destination is already a directory, so append the source basename.
    $destination = file_stream_wrapper_uri_normalize($destination . '/' . drupal_basename($source));
  }
  else {
    // Perhaps $destination is a dir/file?
    $dirname = drupal_dirname($destination);
    if (!file_prepare_directory($dirname)) {
      // The destination is not valid.
      watchdog('file', 'File %file could not be copied, because the destination directory %destination is not configured correctly.', array('%file' => $original_source, '%destination' => $dirname));
      drupal_set_message(t('The specified file %file could not be copied, because the destination directory is not properly configured. This may be caused by a problem with file or directory permissions. More information is available in the system log.', array('%file' => $original_source)), 'error');
      return FALSE;
    }
  }

So, if your destination URI when calling file_move is in a directory that does not exist, the following condition will fail:

if (!file_prepare_directory($dirname))

Because in order for the directory to be created and file_prepare_directory to return TRUE, it should be called this way:

if (!file_prepare_directory($dirname, FILE_CREATE_DIRECTORY))

OR..... if it was designed to behave this way, documentation should state that before calling file_move you should prepare the destination directory and make sure it does exist.

I'll be working on a proper patch in the next days, just wanted to write this down now that I have it fresh.

Comments

david_garcia’s picture

Issue summary: View changes
david_garcia’s picture

StatusFileSize
new2 KB

I need some opinion on this....

The file_unmanaged_move method prepares destination director this way:

  // Prepare the destination directory.
  if (file_prepare_directory($destination)) {
    // The destination is already a directory, so append the source basename.
    $destination = file_stream_wrapper_uri_normalize($destination . '/' . drupal_basename($source));
  }
  else {
    // Perhaps $destination is a dir/file?
    $dirname = drupal_dirname($destination);
    if (!file_prepare_directory($dirname)) {
      // The destination is not valid.
      watchdog('file', 'File %file could not be copied, because the destination directory %destination is not configured correctly.', array('%file' => $original_source, '%destination' => $dirname));
      drupal_set_message(t('The specified file %file could not be copied, because the destination directory is not properly configured. This may be caused by a problem with file or directory permissions. More information is available in the system log.', array('%file' => $original_source)), 'error');
      return FALSE;
    }
  }

But some things do not make sense to me:

1. ¿Why is it assuming that $destination is a directory and not a full file path?
2. ¿Why is it not telling file_prepare_directory to create de destination directory if unexisting by means of FILE_CREATE_DIRECTORY?

So I ended up rewriting the logic that takes care of preparing the directory:

// Prepare destination directory.
  // file_prepare_directory does not distinguish between URI's that are
  // filenames or plain paths, we must take care of that ourselves.
  $dirname = drupal_dirname($destination);
  if (!file_prepare_directory($dirname, FILE_CREATE_DIRECTORY)) {
      // The destination is not valid.
      watchdog('file', 'File %file could not be copied, because the destination directory %destination is not configured correctly.', array('%file' => $original_source, '%destination' => $dirname));
      drupal_set_message(t('The specified file %file could not be copied, because the destination directory is not properly configured. This may be caused by a problem with file or directory permissions. More information is available in the system log.', array('%file' => $original_source)), 'error');
      return FALSE;
  } else {
    if ($destination == $dirname) {
      // The destination is already a directory, so append the source basename.
      $destination = file_stream_wrapper_uri_normalize($destination . '/' . drupal_basename($source));
    }
  }

I attach patch in NON git, just for review...

david_garcia’s picture

Status: Active » Needs review

Status: Needs review » Needs work

The last submitted patch, 2: file.inc_.patch, failed testing.

david_garcia’s picture

Title: file_move not working properly when destination directory does not exist » file_unmanaged_move not working properly when destination if a full filename path and directory does not exist.
david_garcia’s picture

No way.... proposed solution is a NO GO, there is no reliable way I can think of telling if $destination is a file or a directory.

So, before consuming file_unmanaged_move or any of it's parents (file_move, file_copy, etc..) one must make sure that the destination directory is available and coherent with the $destination path it is sending.

Let's take it this way:

I want to move:

file://aa.docx

To:

file://directoryA/bb.docx

But, how is file_unmanaged_move going to know if I want this other destination:

file://directoryA/bb.docx/aa.docx

The only way is to not let file_unmanaged_move create directories at all (original implementation) and make sure your destination directory structure is properly populated before the call.

Yet, this will lead to ambiguous behaviours.

On the previous example, ¿what if a directory I am unaware of already exists that is coincident with my destination filename? Then it will be asumed that $destination is a directory and it will use that, combined with original filename, to create de real destination.

I could not find this documented anywhere, but it does not look trivial to me.

klaasvw’s picture

Title: file_unmanaged_move not working properly when destination if a full filename path and directory does not exist. » file_unmanaged_copy not working properly when the destination directory does not exist.
Status: Needs work » Needs review
StatusFileSize
new988 bytes

I had a similar issue:

  • File field using the media widget
  • File field has a File directory configured below the default drupal files directory
  • File field supports both public (sites/sitename/files) and private (sites/sitename/private) stream wrappers

When uploading a file to this field and selecting the private stream wrapper the file will never be copied to sites/sitename/private/sub because file_unmanaged_copy doesn't support creating directories.

The issue above is also caused by the same behavior because file_unmanaged_move relies on file_unmanaged_copy.

The attached patch tells file_prepare_directory to create the directory if the destination doesn't exist.

Julian Maurice’s picture

Hi,
I tested the last patch (in comment #7) and it works for me (I was having issues when changing file destination from private to public).
Does it have a chance to be in the next release ?

oadaeh’s picture

Status: Needs review » Reviewed & tested by the community

I have just applied the patch in #7 to a project I am working on.

When attaching a file to a privatemsg, the file would get saved to the private directory but not the public one, as desired. The public directory did not exist, and would not get created.

The patch fixed that for me, so that the functionality does what it's expected to, and there are no errors in watchdog.

Is there some reason that code change should not be included in core?

micnap’s picture

We were having problems uploading because the files subdirectories weren't being created (media/users/[uid] in our case) because the second parameter to file_prepare_directory function was never getting passed in from file_unmanaged_copy, preventing new directories from being created. Applied the patch #7 and it corrects the issue.

David_Rothstein’s picture

Version: 7.x-dev » 8.0.x-dev
Status: Reviewed & tested by the community » Needs work
Issue tags: +Needs backport to D7

This would need to go in Drupal 8 first.

Probably could use a test also?

oadaeh’s picture

Assigned: Unassigned » oadaeh
Status: Needs work » Needs review
StatusFileSize
new1005 bytes

I've attached a patch against the latest dev of Drupal 8.
Now I'll take a look at if a test is needed and what it would take to create it.

catch’s picture

Status: Needs review » Needs work
Issue tags: +Needs tests

Just ran into this.

Just creating a file in public:// thrn moving it to public://foo and then a couple of asserts wpuld be enough test coverage.

Tagging RC target since this is a straightforward, backportable fix.

Version: 8.0.x-dev » 8.1.x-dev

Drupal 8.0.6 was released on April 6 and is the final bugfix release for the Drupal 8.0.x series. Drupal 8.0.x will not receive any further development aside from security fixes. Drupal 8.1.0-rc1 is now available and sites should prepare to update to 8.1.0.

Bug reports should be targeted against the 8.1.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.2.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

oadaeh’s picture

Assigned: oadaeh » Unassigned

I'm going to do my best to follow @catch's test recommendation as soon as I can, but I haven't done anything with this since my last comment, and I'm not doing anything with it at the moment, so I'm un-assigning myself for now.

gnuget’s picture

Version: 8.1.x-dev » 8.3.x-dev
quietone’s picture

Status: Needs work » Needs review
StatusFileSize
new3.65 KB
new4.02 KB

This caught my eye because I've been working on a migration file issue. The patch didn't apply, so did the reroll. And then I just keep going and added some tests.

gnuget’s picture

I just added an extra test using php7 and myqsl 5.5 because in my local file_unmanaged_move is throwing errors.

Thanks for your patch quiteone.

gnuget’s picture

Status: Needs review » Needs work

Today I was able to give it a proper review to this patch.

In general, looks great, just a few nits:

  1. +++ b/core/tests/Drupal/KernelTests/Core/File/UnmanagedCopyTest.php
    @@ -84,6 +84,14 @@ function testOverwriteSelf() {
    +    $new_filepath = file_unmanaged_copy($uri, drupal_dirname($uri) . '/sub/' . drupal_basename($uri), FILE_EXISTS_ERROR);
    

    drupal_dirname and drupal_basename are deprecated functions, can we use
    FileSystem::dirname() and FileSystem::basename() instead?

  2. +++ b/core/tests/Drupal/KernelTests/Core/File/UnmanagedMoveTest.php
    @@ -38,6 +38,15 @@ function testNormal() {
    +    $newest_filepath= file_unmanaged_move($newer_filepath, $desired_filepath, FILE_EXISTS_ERROR);
    

    A missing space before the "=".

Thanks for your patch quiteone.

quietone’s picture

Status: Needs work » Needs review
StatusFileSize
new3.7 KB
new2.42 KB

@gnuget, thx. Both fixed. However, note that I also changed two of the assertions to not use deprecated functions.

gnuget’s picture

Status: Needs review » Reviewed & tested by the community

Looks great. this is RTBC to me.

Thanks!

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
Issue tags: -Needs tests +Needs change record

We have tests nice - but I think we need a change record to inform people about the behaviour change.

  1. +++ b/core/tests/Drupal/KernelTests/Core/File/UnmanagedCopyTest.php
    @@ -84,6 +84,14 @@ function testOverwriteSelf() {
    +    $this->assertTrue($new_filepath, 'Copying into same directory works.');
    
    +++ b/core/tests/Drupal/KernelTests/Core/File/UnmanagedMoveTest.php
    @@ -38,6 +38,15 @@ function testNormal() {
    +    $this->assertTrue($newest_filepath, 'Move was successful.');
    

    Seems unnecessary given file_exists(FALSE) is false...

  2. +++ b/core/tests/Drupal/KernelTests/Core/File/UnmanagedCopyTest.php
    @@ -84,6 +84,14 @@ function testOverwriteSelf() {
    +    $this->assertTrue(file_exists($uri), 'Original file exists after copying onto itself.');
    +    $this->assertTrue(file_exists($new_filepath), 'Copied file exists after copying onto itself.');
    
    +++ b/core/tests/Drupal/KernelTests/Core/File/UnmanagedMoveTest.php
    @@ -38,6 +38,15 @@ function testNormal() {
    +    $this->assertTrue(file_exists($newest_filepath), 'File exists at the new location.');
    +    $this->assertFalse(file_exists($newer_filepath), 'No file remains at the old location.');
    

    PHPunit has $this->assertFileExists() and assertFileNotExists() we should use them.

gnuget’s picture

Status: Needs work » Needs review
StatusFileSize
new3 KB
new5.16 KB

Point 1 and 2 fixed in this new patch.

I will write the change record now.

gnuget’s picture

Issue tags: -Needs change record

Here the changed record:

https://www.drupal.org/node/2786505

(It's my first time doing change records, sorry for any mistake :D)

quietone’s picture

@gnuget, thx. I;m new to change records as well. But When I read the description in the change record the term 'missing directories are needed' is unusual, at least to me.. How about just referring to them as the parent directories (like 'man mkdir'). file_unmanaged_copy and file_unmanaged_move now make parent directories as needed. And shouldn't the 'before' sections mention that an error message was generated?

gnuget’s picture

I think this is ready, the change record was already edited by AlexPott, so this just needs another review before to be RTBC. :-)

Anonymous’s picture

The file_unmanaged_prepare() function doc block states that both path arguments should be a file (see `filepath` and `destination file`), from this I would judge that you should never pass in a directory as a 2nd parameter as david_garcia suggested in #6.

 * @param $source
 *   A string specifying the filepath or URI of the source file.
 * @param $destination
 *   A URI containing the destination that $source should be moved/copied to.
 *   The URI may be a bare filepath (without a scheme) and in that case the
 *   default scheme (file://) will be used. If this value is omitted, Drupal's
 *   default files scheme will be used, usually "public://".
 * @param $replace
 *   Replace behavior when the destination file already exists:

But then again, in \Drupal\KernelTests\Core\File\UnmanagedCopyTest::testNormal() we have this TODO:

// TODO: test copying to a directory (rather than full directory/file path)

I believe that we should remove that TODO, add some tests and maybe make the comments more clear.

Allowing the 2nd argument to be a directory path would probably require an extra argument, or a convention like `if the path ends with a DIRECTORY_SEPARATOR, then...`, but this will only cause issues.

Attached tests for copying to an existing and a missing directory, test for move are still needed.

P.S. I would like to suggest that we shouldn't change file_unmanaged_prepare()

Status: Needs review » Needs work

The last submitted patch, 28: drupal-test_copy_to_directory-2211657-28.patch, failed testing.

Anonymous’s picture

StatusFileSize
new5.31 KB

Fixed spelling and UnmanagedCopyTest test and, added UnmanagedMoveTest tests.

Anonymous’s picture

Status: Needs work » Needs review

Status: Needs review » Needs work

The last submitted patch, 30: drupal-test_copy_and_move_to_directory-2211657-30.patch, failed testing.

Anonymous’s picture

Status: Needs work » Needs review
StatusFileSize
new5.32 KB

Fixing UnmanagedMoveTest.

Sorry for the noise/load.

Status: Needs review » Needs work

The last submitted patch, 33: drupal-test_copy_and_move_to_directory-2211657-33.patch, failed testing.

gnuget’s picture

Hi @SchWalter.

Thanks for your work on this issue.

Can you please provide an interdiff? so we can see more easily what you did?

Thanks!

20th’s picture

quietone’s picture

Status: Needs work » Needs review
StatusFileSize
new8.23 KB

Here is an interdiff between the patches in #24 AND #33.

Version: 8.3.x-dev » 8.4.x-dev

Drupal 8.3.0-alpha1 will be released the week of January 30, 2017, which means new developments and disruptive changes should now be targeted against the 8.4.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

Version: 8.4.x-dev » 8.5.x-dev

Drupal 8.4.0-alpha1 will be released the week of July 31, 2017, which means new developments and disruptive changes should now be targeted against the 8.5.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

joseph.olstad’s picture

Status: Needs review » Needs work
joseph.olstad’s picture

joseph.olstad’s picture

Status: Needs work » Needs review

rerolled , trigger tests

joseph.olstad’s picture

Status: Needs review » Closed (duplicate)

This issue is a duplicate issue of these two issues:

D8:
#1068266: drupal_mkdir does not set permissions to directories it created recursively

D7:
#2789723: [D7 backport] drupal_mkdir does not set permissions to directories it created recursively

The above D7 issue is marked as RTBC.

Previously I was using the patch for D7 from this issue and it was working but now I am aware of the RTBC issue, so I'm going to swap out the patch from this issue with the RTBC patch from 2789723 that already has tests and is already fully backported and ready for commit