How to solve: Stage directory is a subdirectory

Created on 21 April 2025, 24 days ago

Problem/Motivation

Drupal CMS/D11.1.6 gives me a status report error:

Update readiness checks
Your site does not pass some readiness checks for automatic updates. It cannot be automatically updated until further action is performed.

    Stage directory is a subdirectory of the active directory.

I cannot find any information on the required folder structure.
Also I don't know what "active directory" means or where a stage directory should be located...
Pls. help with some information.

Steps to reproduce

Proposed resolution

Remaining tasks

User interface changes

Introduced terminology

API changes

Data model changes

Release notes snippet

πŸ’¬ Support request
Status

Active

Version

11.1 πŸ”₯

Component

package_manager.module

Created by

πŸ‡¦πŸ‡ΉAustria maxilein

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

  • Issue created by @maxilein
  • Does that Drupal instance have as its configured temporary directory something adjacent to or below the directory containing composer.json?

  • πŸ‡ΊπŸ‡ΈUnited States phenaproxima Massachusetts

    In Package Manager terminology, the "active directory" is the root of the Drupal project: basically, wherever the main project-level composer.json is.

    The "stage directory" (which we are going to rename "sandbox directory" for clarity) needs to be in a temporary directory that is outside the Drupal project. Usually Package Manager will try to compute it by asking the system where the overall temporary directory is (for example, /tmp on most Linux systems), so if the temporary directory is, for some reason, a subdirectory of the Drupal project, you'd get this error.

  • πŸ‡¦πŸ‡ΉAustria maxilein

    Thank you. For the example below I added /active_directory in the structure.

    /../active_directory/composer.json

    /../active_directory/web/ (= Drupal) index.php

    /../active_directory/web/vendor
    /../active_directory/web/tmp
    /../active_directory/web/recipes
    /../active_directory/web/private

    wasn't this the recommended structure until Drupal 9 or 10?

  • πŸ‡ΊπŸ‡ΈUnited States phenaproxima Massachusetts

    /../active_directory/composer.json

    /../active_directory/web/tmp

    This is probably the core of the problem. The temp directory needs to NOT be inside the active directory. In other words, something like this would work better, if you can configure your set-up accordingly:

    ../active_directory/composer.json

    /tmp

  • πŸ‡³πŸ‡ΏNew Zealand quietone
  • πŸ‡¦πŸ‡ΉAustria maxilein

    Here is what I learned asking myself how I came to the conclusion it was the recommended place:

    Drupal 11 was upgraded from a site I originally installed using Drupal 9 and then I adapted the folder structure with Drupal 10.

    The drupal/recommended-project Composer template used to sets up the following general structure:

    your-project-name/
    β”œβ”€β”€ composer.json # Defines project dependencies, scripts, etc.
    β”œβ”€β”€ composer.lock # Locks dependency versions.
    β”œβ”€β”€ config/ # Often used for configuration management (e.g., config/sync).
    β”‚ └── sync/ # Default location for configuration sync.
    β”œβ”€β”€ drush/ # Drush site aliases, commands, and configuration.
    β”œβ”€β”€ vendor/ # Composer-managed dependencies (core, contrib modules, themes, PHP libraries). Not committed to Git.
    β”œβ”€β”€ web/ # The web server's document root (sometimes called docroot, public_html, etc.).
    β”‚ β”œβ”€β”€ core/ # Drupal core files (managed by Composer).
    β”‚ β”œβ”€β”€ index.php # Drupal's front controller.

    There isn't a predefined tmp folder in the standard drupal/recommended-project structure.
    Its location is determined by the setting found in Admin > Configuration > Media > File system.  
    But most of the recommendations derive from web server configuration: It's best practice to explicitly configure a path outside the web root (web/) that is writable by the web server.

    So I always figured that the composer.json above all folders is a safe bet - if the web server's root location is below it.
    In my case the web server cannot access the folder where the composer.json file is located, because it is above the permissions of the web folder.
    So a /tmp folder which is on the same level as the /web should be safe.
    Why is it a problem that the /tmp is below the composer.json?

    BTW: Here they call the /web structures Base-Level Directories: https://www.drupal.org/docs/getting-started/understanding-drupal/directo... β†’ . But htere is no mention of a tmp folder in regards to the new update manager functionality.
    Once this is cleared up we should not forget to add documentation there.

  • πŸ‡ΊπŸ‡ΈUnited States phenaproxima Massachusetts

    Why is it a problem that the /tmp is below the composer.json?

    Because Package Manager needs to copy the entire project in order to safely make changes to the copy, before it syncs changes to the live site. Since the copy is put into the temporary directory (a sensible place to put it), this would result in the whole site being copied into...itself. That is a no-go.

    I think I'm going to tentatively close this, since it sounds like it is working as intended, although we could probably improve the error message to clearly. If you feel there's more to discuss here, or there's some improvement that could be made to Package Manager, feel free to reopen. (One possibility would be for Package Manager to stop using file_temp_path under any circumstances, and always use the system temporary directory.)

  • πŸ‡¦πŸ‡ΉAustria maxilein

    Moving the tmp directory out from beneath the composer.json file location does not make the error go away.

    moving it from /var/www/tmp
    up to a new folder
    $settings['file_temp_path'] = '/var/www_tmp';

    Any other from php reported tmp directories are in on the testsystem.
    /tmp

    So I do not understand the error message. The message or check must be wrong.

    OR is the Stage directory something completely different. like the sync folder?!

  • πŸ‡¦πŸ‡ΉAustria maxilein
  • πŸ‡¦πŸ‡ΉAustria maxilein
  • πŸ‡¦πŸ‡ΉAustria maxilein

    The error message is not understandable. It does not make sense to talk about a directory called staging when there is no such folder in real Drupal folder structure.
    In oder to make it more easy to grasp and administer without deep knowledge add the path in question that should be moved.

    I could not find any documentation on the prerequisites for the new update module. Can someone point into that directions please.

  • πŸ‡¦πŸ‡ΉAustria maxilein

    Ok. I have tried to make visible what the validator does by adding these lines (in bold):
    ...modules\contrib\automatic_updates\package_manager\src\Validator\StageNotInActiveValidator.php
    lines 38+39

    public function validate(PreOperationStageEvent $event): void {
    $project_root = $this->pathLocator->getProjectRoot();
    $staging_root = $this->pathLocator->getStagingRoot();
    if (str_starts_with($staging_root, $project_root)) {
    $message = $this->t("Stage directory is a subdirectory of the active directory.");
    $currentconfiginfo = $message . " STAGE: " . $staging_root . " ACTIVE ROOT: " . $project_root;
    $event->addError([$currentconfiginfo]);
    }
    }

    The output gives:

    Stage directory is a subdirectory of the active directory. STAGE: /var/www_tmp/.package_managerX...Y ACTIVE ROOT: /var/www

    And that shows that str_starts_with just does not produce a proper result.

  • πŸ‡ΊπŸ‡ΈUnited States phenaproxima Massachusetts

    Thanks for looking at that! I think this is clearly a bug and needs further investigation.

  • πŸ‡¦πŸ‡ΉAustria maxilein

    Thinking about it ACTIVE ROOT: /var/www would correspond to my composer.json location ( see structure above #7)

    That seems ok. But I wonder if it would be reliable in all cases because in modules\contrib\automatic_updates\package_manager\src\PathLocator.php the function

      public function getProjectRoot(): string {
        // Assume that the vendor directory is immediately below the project root.
        return realpath($this->getVendorDirectory() . DIRECTORY_SEPARATOR . '..');
      }

    has a comment making an assumption about the vender directory being below ...
    What if it is somewhere else? Should we check for this assumption also - and at least warn that this check will not work if that is not the case?

  • πŸ‡ΊπŸ‡ΈUnited States phenaproxima Massachusetts

    Maybe, but that's a separate issue that's not in scope here. :)

  • I hated the title I wrote so I'm trying again.

  • πŸ‡ΊπŸ‡ΈUnited States phenaproxima Massachusetts

    EDIT: I see the bug here -- the str_contains() is not at all erroneous, but it's finding that both the active and stage start with the prefix /var/www!

    If you rename the temp directory to something totally different, like /var/drupaltemp or something, and adjust your settings.php to match...I bet that error will go away.

  • πŸ‡¦πŸ‡ΉAustria maxilein

    If you add this function to modules\contrib\automatic_updates\package_manager\src\PathLocator.php

     /**
     * Checks if a given path string ($stage) represents a subdirectory
     * of another path string ($active).
     *
     * This function compares absolute path strings, normalizes directory separators,
     * and handles edge cases like identical paths or root directories.
     * It does *not* check if the paths actually exist on the filesystem,
     * it purely performs a string comparison.
     *
     * @param string $stage The absolute path string that might be a subdirectory.
     * @param string $active The absolute path string that might be the parent directory.
     *
     * @return bool Returns true if $stage is a subdirectory of $active, false otherwise.
     */
        public function isStageSubdirectoryOfActive(string $stage, string $active): bool
        {
            // 1. Normalize directory separators to '/' for consistent comparison
            $stageNormalized = str_replace('\\', '/', $stage);
            $activeNormalized = str_replace('\\', '/', $active);
    
            // 2. If the normalized paths are identical, stage is not a *sub*directory.
            if ($stageNormalized === $activeNormalized) {
                return false;
            }
    
            // 3. Prepare the active path prefix for comparison.
            //    For a path to be a subdirectory, it must start with the parent path
            //    followed by a directory separator.
            //    We ensure the active path ends with a '/' for the check,
            //    unless the active path *is* the root directory itself ('/').
            $activePrefix = rtrim($activeNormalized, '/') . '/';
            if ($activeNormalized === '/') {
                 // If active is the root, the prefix should just be '/'
                $activePrefix = '/';
            }
    
            // 4. Check if the normalized stage path *starts with* the active path prefix.
            //    Using strpos() === 0 is an efficient way to check for "starts with".
            //    Example 1: stage="/var/log/nginx", active="/var/log"
            //       -> stageNormalized="/var/log/nginx", activePrefix="/var/log/"
            //       -> strpos("/var/log/nginx", "/var/log/") === 0 -> TRUE
            //    Example 2: stage="/var/log", active="/var/log"
            //       -> Handled by check #2 -> FALSE
            //    Example 3: stage="/var/logs", active="/var/log"
            //       -> stageNormalized="/var/logs", activePrefix="/var/log/"
            //       -> strpos("/var/logs", "/var/log/") === false -> FALSE
            //    Example 4: stage="/var/log/nginx/error.log", active="/var/log"
            //       -> stageNormalized="/var/log/nginx/error.log", activePrefix="/var/log/"
            //       -> strpos("/var/log/nginx/error.log", "/var/log/") === 0 -> TRUE
            //    Example 5: stage="/home/user", active="/"
            //       -> stageNormalized="/home/user", activePrefix="/"
            //       -> strpos("/home/user", "/") === 0 -> TRUE
    
            if (strpos($stageNormalized, $activePrefix) === 0) {
                // It starts with the correct prefix, confirming it's inside the active directory.
                // The check in step 2 already ensured they aren't identical.
                return true;
            }
    
            // If the stage path doesn't start with the active prefix, it's not a subdirectory.
            return false;
        }
    
    

    And then change ...modules\contrib\automatic_updates\package_manager\src\Validator\StageNotInActiveValidator.php to

      /**
       * Check if staging root is a subdirectory of active.
       */
      public function validate(PreOperationStageEvent $event): void {
        $project_root = $this->pathLocator->getProjectRoot();
        $staging_root = $this->pathLocator->getStagingRoot();
        
        if ($this->pathLocator->isStageSubdirectoryOfActive($staging_root, $project_root) == true )
        {
          $message = $this->t("Stage directory is a subdirectory of the active directory.");
          $currentconfiginfo = $message . " STAGE: " . $staging_root . " ACTIVE ROOT: " . $project_root;
          $event->addError([$currentconfiginfo]);
        }
      }

    the error goes away.

    And it seems that the comparison logic of paths does work. But I have not tested any other cases than mine.
    I will post examples in the next comment. Can someone please convert them into test cases?

    Attached is a patch to the 3.1.7 of automatic_updates contrib module.

  • πŸ‡¦πŸ‡ΉAustria maxilein

    Please someone check that code above and here are example cases.

    // Example 1: Stage is a direct subdirectory
    $stage1 = '/var/www/html/myproject/staging';
    $active1 = '/var/www/html/myproject';
    echo "Example 1: " . (isStageSubdirectoryOfActive($stage1, $active1) ? 'true' : 'false') . "\n"; // Output: true
    
    // Example 2: Stage is nested deeper
    $stage2 = '/var/www/html/myproject/staging/sub/folder';
    $active2 = '/var/www/html/myproject';
    echo "Example 2: " . (isStageSubdirectoryOfActive($stage2, $active2) ? 'true' : 'false') . "\n"; // Output: true
    
    // Example 3: Paths are identical
    $stage3 = '/var/www/html/myproject';
    $active3 = '/var/www/html/myproject';
    echo "Example 3: " . (isStageSubdirectoryOfActive($stage3, $active3) ? 'true' : 'false') . "\n"; // Output: false
    
    // Example 4: Stage is a parent directory (or unrelated)
    $stage4 = '/var/www/html';
    $active4 = '/var/www/html/myproject';
    echo "Example 4: " . (isStageSubdirectoryOfActive($stage4, $active4) ? 'true' : 'false') . "\n"; // Output: false
    
    // Example 5: Unrelated paths
    $stage5 = '/etc/nginx';
    $active5 = '/var/www/html';
    echo "Example 5: " . (isStageSubdirectoryOfActive($stage5, $active5) ? 'true' : 'false') . "\n"; // Output: false
    
    // Example 6: Partial match, but not a directory boundary
    $stage6 = '/var/www/html_extra';
    $active6 = '/var/www/html';
    echo "Example 6: " . (isStageSubdirectoryOfActive($stage6, $active6) ? 'true' : 'false') . "\n"; // Output: false
    
    // Example 7: Windows-style paths
    $stage7 = 'C:\\Users\\Test\\My Documents\\Stage';
    $active7 = 'C:\\Users\\Test\\My Documents';
    echo "Example 7: " . (isStageSubdirectoryOfActive($stage7, $active7) ? 'true' : 'false') . "\n"; // Output: true
    
    // Example 8: Mixed separators
    $stage8 = 'C:/Users/Test/My Documents/Stage/Sub';
    $active8 = 'C:\\Users\\Test\\My Documents';
    echo "Example 8: " . (isStageSubdirectoryOfActive($stage8, $active8) ? 'true' : 'false') . "\n"; // Output: true
    
    // Example 9: Trailing slashes
    $stage9 = '/var/www/html/myproject/staging/';
    $active9 = '/var/www/html/myproject/';
    echo "Example 9: " . (isStageSubdirectoryOfActive($stage9, $active9) ? 'true' : 'false') . "\n"; // Output: true
    
    // Example 10: Root directory cases
    $stage10a = '/var/log';
    $active10a = '/';
    echo "Example 10a (Root): " . (isStageSubdirectoryOfActive($stage10a, $active10a) ? 'true' : 'false') . "\n"; // Output: true
    
    $stage10b = '/';
    $active10b = '/';
    echo "Example 10b (Root): " . (isStageSubdirectoryOfActive($stage10b, $active10b) ? 'true' : 'false') . "\n"; // Output: false
    
    
    
  • πŸ‡¦πŸ‡ΉAustria maxilein

    And don't forget #17. It seems to be a rare trap.

  • πŸ‡ΊπŸ‡ΈUnited States phenaproxima Massachusetts

    I don't think we need to add a whole bespoke function for this; wouldn't \Symfony\Component\Filesystem\Path::isBasePath() do the trick for us? Agreed that extra test cases couldn't hurt.

    The patch should be against core, not Automatic Updates.

  • πŸ‡¦πŸ‡ΉAustria maxilein

    Everything is fine for me. You are the expert. I am not so deep in Drupal code in order to make these judgements.

Production build 0.71.5 2024