Following on from the work done in the parent issue addressing error handling.

The Download process doesn't currently handle any clean up in terms of deleting the destination file when the error occurs.

For example, downloading a http://example.com/example.jpg file which 404s will throw an exception, but will keep the destination example.jpg file on disk and have it contain the raw HTML for the 404 response, which might not be expected behaviour.

This is especially hard to work with when using the file_exists: 'use existing' option.

My proposed solution for this is to make it an option so that people who don't care about the content of the files can keep the old behaviour.

After discussion with Framwork managers, we should delete the file on a 400 response, and there is no need to preserve current behavior, as saving a 404 response to a jpg file is a bug.

Additional notes. There's currently some differing opinions on how to proceed here. I currently see three options.

  1. Do nothing, potentially add a log message or warning if we detect a non-200 response while downloading a file.
    • Benefits: it's easy.
    • Downside: you may have files in your filesystem named my_file.jpg that contain the body of a 404 or 403 response.
    • Delete the file on non-200 responses.
      • Benefits: No error messages stored in files with an image/document extension after the migration completes.
      • Downside: downloading and then deleting a file from the filesystem. The file will have to exist in the public file system, for at least a short while. Could we accidentally delete a file we don't want to? could we potentially overwrite a good file with a bad one and then delete it? What are the edge cases to handle?
    • Download files to the temporary file system, verify integrity, then move to the final destination.
      • Benefits: safest option, could eliminate files with bad responses from ever being in the filesystem, could also do integrity checks against the file to prevent malicious scripts or XSS attacks from being migrated into the filesystem. Currently migrated files are only as safe as your file source.
      • Downsides: potential slowdown of file migrations (which already can take a long time for large filesystems), particularly if the temp directory and the public directory are on different filesystems, which is often likely to be the case.

    Issue fork drupal-3114887

    Command icon Show commands

    Start within a Git clone of the project using the version control instructions.

    Or, if you do not have SSH keys set up on git.drupalcode.org:

    Comments

    codebymikey created an issue. See original summary.

    codebymikey’s picture

    Version: 9.1.x-dev » 9.0.x-dev
    xjm’s picture

    Version: 9.0.x-dev » 9.1.x-dev

    This would likely be a minor-only change. Since 8.9.x and 9.0.x are now in beta, I'm moving this to 9.1.x. Thanks!

    Version: 9.1.x-dev » 9.2.x-dev

    Drupal 9.1.0-alpha1 will be released the week of October 19, 2020, which means new developments and disruptive changes should now be targeted for the 9.2.x-dev branch. For more information see the Drupal 9 minor version schedule and the Allowed changes during the Drupal 9 release cycle.

    Version: 9.2.x-dev » 9.3.x-dev

    Drupal 9.2.0-alpha1 will be released the week of May 3, 2021, which means new developments and disruptive changes should now be targeted for the 9.3.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

    Version: 9.3.x-dev » 9.4.x-dev

    Drupal 9.3.0-rc1 was released on November 26, 2021, which means new developments and disruptive changes should now be targeted for the 9.4.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

    slucero made their first commit to this issue’s fork.

    slucero’s picture

    I ran into this issue as well even without using the file_existing flag. The end result of running my file migration was that I had a bunch of image requests producing 404 responses, so the migration rows failed due to the MigrateException being thrown, but since the file stream was open already and used directly by Guzzle with the sink option the file was created with the invalid response data that came through. This left my file system after the migration full of files with corrupted content even if they weren't represented in the file_managed table.

    The merge request I submitted is intended as a starting place for this issue to prevent invalid files from being left on the filesystem after failed download requests. It simply deletes the created file if an exception is caught from the download request.

    For reference, this is a trimmed down example of the migration being used to produce this issue:

    langcode: en
    status: true
    dependencies: {  }
    id: upgrade_d7_file
    class: Drupal\migrate\Plugin\Migration
    field_plugin_method: null
    cck_plugin_method: null
    migration_tags:
      - 'Drupal 7'
      - Content
    label: 'Public files'
    source:
      plugin: d7_file
      scheme: public
      constants:
        source_base_path: 'https://example.com/'
    process:
      filename:
        plugin: callback
        callable: basename
        source: uri
      source_full_path:
        -
          plugin: concat
          delimiter: /
          source:
            - constants/source_base_path
            - filepath
        -
          plugin: urlencode
      uri:
        -
          plugin: download
          source:
            - '@source_full_path'
            - uri
    destination:
      plugin: 'entity:file'
      validate: true
    migration_dependencies:
      required: {  }
      optional: {  }
    

    slucero’s picture

    Status: Active » Needs review
    mikelutz’s picture

    Issue tags: +Portland2022

    It makes sense to not have errors exist in the filesystem as files with image extensions. My gut says rather than deleting the file from the filesystem, we should download the file to the temp directory, and then move it once we know there is no errors. I'll leave at NR for others to comment.

    Version: 9.4.x-dev » 9.5.x-dev

    Drupal 9.4.0-alpha1 was released on May 6, 2022, which means new developments and disruptive changes should now be targeted for the 9.5.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

    mikelutz’s picture

    This was discussed a while back in the weekly meeting #3277845: [meeting] Migrate Meeting 2022-05-05 1400Z @heddn pointed out that it's very possible for the temp filesystem and the public file system to be on different disks, so we can't count on the final move being free, and this would definitely half the speed of a file migration in such cases. @heddn was inclined to leave the bad file, and potentially warn/log. I'd prefer to fix it, but I'm always wary of writing code in core that deletes things, even if it is a file we just created. I wouldn't mind some thoughts from a framework manager here, on how important it is that we don't store these responses and whether deleting them after the download is a good and safe idea.

    mikelutz’s picture

    So to recap, three options as I currently see it, though if anyone can think of a 4th, I'd love to hear it.

    1. Do nothing, potentially add a log message or warning if we detect a non-200 response while downloading a file.
      • Benefits: it's easy.
      • Downside: you may have files in your filesystem named my_file.jpg that contain the body of a 404 or 403 response.
    2. Delete the file on non-200 responses.
      • Benefits: No error messages stored in files with an image/document extension after the migration completes.
      • Downside: downloading and then deleting a file from the filesystem. The file will have to exist in the public file system, for at least a short while. Could we accidentally delete a file we don't want to? could we potentially overwrite a good file with a bad one and then delete it? What are the edge cases to handle?
    3. Download files to the temporary file system, verify integrity, then move to the final destination.
      • Benefits: safest option, could eliminate files with bad responses from ever being in the filesystem, could also do integrity checks against the file to prevent malicious scripts or XSS attacks from being migrated into the filesystem. Currently migrated files are only as safe as your file source.
      • Downsides: potential slowdown of file migrations (which already can take a long time for large filesystems), particularly if the temp directory and the public directory are on different filesystems, which is often likely to be the case.
    mikelutz’s picture

    Issue summary: View changes
    mikelutz’s picture

    Issue summary: View changes
    mikelutz’s picture

    Issue summary: View changes
    Status: Needs review » Needs work

    Discussed this with @alexpott. tldr; We should delete the file on a guzzle exception since we created it above. Saving a 404 response as a .jpg file is a bug, we don't need any BC compatibility layer to fix it. NW to tighten the exception handling.

    @alexpott Could I bug you for your thoughts on what we should do about #3114887: Error responses are stored when using the Download migration process sometime if you possibly get a chance? No rush, it’s not new, I’m just trying to figure out the best thing (if anything) to do, and I could use the opinion of framework manager.

    alexpott I don’t think that the migration is per-say responsible for the safety of the input.
    alexpott I think that is over extending responsibilities. I think it should be possible to make a migration do safety checks but I don’t think this should be a default capability.
    mikelutz (he/him) Not in a security sense, I agree. Hence the debate on what, if anything to do.
    alexpott I think saving a non 200 response as a .jpg file is a bug
    mikelutz (he/him) I agree with that as well. Problem being that we are directing the stream directly to the file, so we don’t know we got a non-200 until it’s already saved.
    alexpott I think my preference is for option 2. File migrations are hard enough. Option 3 will make them even trickier for performance / disk space reasons.
    alexpott The other option is a HEAD request first
    alexpott But that again will take time.
    mikelutz (he/him) Right. After typing out the options, I agree.  Okay, thanks, I’ll copy this convo into the issue and move it forward.
    alexpott Did we ever consider a HEAD request? This is pretty much what this is for.
    mikelutz (he/him) I don’t think we thought of it.  We can explore it.  It’s a second request, which will slow things down, but not as much as potentially copying the whole file over a network twice.
    mikelutz (he/him) Could be worth a benchmark test at lease.
    alexpott There must be another way…
    alexpott I think we could add middleware to guzzle… when we stream and trigger an exception for a non 200
    alexpott That way we never write anything to disk apart from for a 200 GET but we only have one request… well we’d need to account for redirects so 200/300 or okay… but any 400 should be an exception that we catch log and move on.
    mikelutz (he/him) You know, now that you mention that, isn't there just an option to send to guzzle to tell it to throw an exception on 400 errors, or am I crazy?
    mikelutz (he/him) I thought http_errors was the default.. Now I need to look at this again..
    alexpott It is the default
    alexpott Are we sure that a 404 is resulting in a file being created on the file system?
    mikelutz (he/him) Yeah, it looks like it’s throwing the exception on 404 but still writing the stream.
    mikelutz (he/him) Cause we are streaming the response directly to the file system, and it obviously needs to have the response to check it and throw the exception.
    mikelutz (he/him) I think I’m back to option #2, though deleting the file maybe should be wrapped in a more specific catch than \Exception
    alexpott Reading the code yeah we need to do option 2
    alexpott Because we’re creating the empty file :smile:
    alexpott We’re opening the file and passing the stream to Guzzle
    alexpott That’s why a file is left lying around
    alexpott If we passed a string to Guzzle and let it create the sink if necessary this would not happen
    mikelutz (he/him) // Try opening the file first, to avoid calling prepareDirectory()
    // unnecessarily. We're suppressing fopen() errors because we want to try
    // to prepare the directory before we give up and fail.
    mikelutz (he/him) So we test to see if the directory exists by creating the file, and then creating the directory if the file creation fails… sigh.
    alexpott Well…
    alexpott I think this way of doing it lets us use our stream wrappers… ie. public:// etc
    mikelutz (he/him) Right.. Okay, I’m back to just deleting the file on a guzzle exception again..
    alexpott Yep - but not because Guzzle does anything… it’s because we are creating it prior to knowing what’s up.
    mikelutz (he/him) The issue summary claims the body of the 404 response (if any) does end up in the file though. I guess I haven’t directly tested it, but I do believe it.
    mikelutz (he/him) This issue needs a test anyway, once we settle on the expected behavior, which I think is just that a file does not exist if guzzle returns a 400
    alexpott \Drupal\Tests\migrate\Functional\process\DownloadFunctionalTest has that already
    alexpott and the file does exist for the invalid file atm
    alexpott And yep that has the entire 404 there :slightly_smiling_face:
    alexpott inc
    Page not found

    The requested page could not be found.

    mikelutz (he/him) Alright, we’ll add a check that the file does not exist to that test, and just delete the file on a guzzle exception and call it a day.  There was talk about leaving an option to not delete the file for BC, but I think this is a bug, and we shouldn’t try to preserve an option for saving a 404 response.
    alexpott I agree… we should not be saving the 4xx or 5xx html for a file like a .jpg - that’s a bug
    mikelutz (he/him) Thanks for talking through it with me. Your insight is always valuable and appreciated. :slightly_smiling_face:

    Participants:

    Version: 9.5.x-dev » 10.1.x-dev

    Drupal 9.5.0-beta2 and Drupal 10.0.0-beta2 were released on September 29, 2022, which means new developments and disruptive changes should now be targeted for the 10.1.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

    Version: 10.1.x-dev » 11.x-dev

    Drupal core is moving towards using a “main” branch. As an interim step, a new 11.x branch has been opened, as Drupal.org infrastructure cannot currently fully support a branch named main. New developments and disruptive changes should now be targeted for the 11.x branch, which currently accepts only minor-version allowed changes. For more information, see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

    benjifisher made their first commit to this issue’s fork.

    benjifisher changed the visibility of the branch 3114887-cleanup-failed-downloads--rebase to hidden.

    benjifisher changed the visibility of the branch 9.4.x to hidden.

    benjifisher’s picture

    I rebased @slucero's branch on the current 11.x and made a new MR.

    xurizaemon’s picture

    I support the "delete on error" approach in MR 9785.

    Discussed above in 19 is the idea of using a HEAD request to check the file's existence. (I realise I'm responding to a Slack thread from two years ago here, but wanted to put this on record in case the solution turns back from the current MR approach.)

    Working recently with Migrate in Islandora and large file ingests (>1GB assets) recently, there's a case where an initial HEAD will not prevent this error: if the file URL is correct and the HEAD returns 200, then retrieval fails for eg timeout or resource limits (server returns a 200 for the second GET, but the content length is incorrect). My recollection is that Guzzle does correctly throw an exception and terminate the stream in this case.

    The current behaviour of the Download plugin is that the terminated stream will be written as a partial download when the request exits. This MR looks like it would correctly ensure the failed download is removed.

    das-peter’s picture

    Status: Needs work » Reviewed & tested by the community

    Just stumbled over this issue when migrating files.
    I'm bold enough to claim this is RTBC:

    • ✓ There's consent about the fact that this behavior is a bug
    • ✓ The implemented approach in the MR isn't contested - despite alternatives
    • ✓ The change ensures that no matter which file_exists is used, no file with unexpected / invalid / incomplete content remains.
    • ✓ Code looks fine to me - comment properly explains why and not what is done ;)
    • ✓ Has a test
    • ✓ Works. Have just tested it in the migration which made me aware of this issue and it behaves as expected now.
    • ? Does this need a change record? Is the change in behavior significant and / or unexpected enough?
    needs-review-queue-bot’s picture

    Status: Reviewed & tested by the community » Needs work
    StatusFileSize
    new90 bytes

    The Needs Review Queue Bot tested this issue. It no longer applies to Drupal core. Therefore, this issue status is now "Needs work".

    This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

    Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

    Version: 11.x-dev » main

    Drupal core is now using the main branch as the primary development branch. New developments and disruptive changes should now be targeted to the main branch.

    Read more in the announcement.

    benjifisher’s picture

    Status: Needs work » Reviewed & tested by the community

    I rebased on the main branch. The automated tests are passing, so back to RTBC.

    needs-review-queue-bot’s picture

    Status: Reviewed & tested by the community » Needs work
    StatusFileSize
    new793 bytes

    The Needs Review Queue Bot tested this issue. It fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".

    This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

    Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

    benjifisher’s picture

    Status: Needs work » Reviewed & tested by the community

    There seem to be problems with the bot. See this thread in Slack: https://drupal.slack.com/archives/C079NQPQUEN/p1769810618759899.

    I merged the current main branch, then pushed both the main branch and the feature branch to the issue fork. The CI jobs all pass.

    Back to RTBC.

    • godotislate committed ae4815ac on main
      fix: #3114887 Error responses are stored when using the Download...

    • godotislate committed 2a24c21a on 11.x
      fix: #3114887 Error responses are stored when using the Download...

    • godotislate committed f290315a on 11.3.x
      fix: #3114887 Error responses are stored when using the Download...

    • godotislate committed a809bf43 on 10.6.x
      fix: #3114887 Error responses are stored when using the Download...
    godotislate’s picture

    Version: main » 10.6.x-dev
    Issue summary: View changes
    Status: Reviewed & tested by the community » Fixed
    Issue tags: -Needs framework manager review

    Removed the "Needs framework manager review" tag because #19 has sign off.

    Committed and pushed to main, 11.x, and 11.3.x. It also applied cleanly to 10.6.x, so pushed there as well. Thanks everyone!

    Now that this issue is closed, review the contribution record.

    As a contributor, attribute any organization that helped you, or if you volunteered your own time.

    Maintainers, credit people who helped resolve this issue.

    Status: Fixed » Closed (fixed)

    Automatically closed - issue fixed for 2 weeks with no activity.