Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.15% covered (warning)
71.15%
74 / 104
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileCopier
71.15% covered (warning)
71.15%
74 / 104
0.00% covered (danger)
0.00%
0 / 4
158.27
0.00% covered (danger)
0.00%
0 / 1
 ensureDirectory
64.71% covered (warning)
64.71%
11 / 17
0.00% covered (danger)
0.00%
0 / 1
16.32
 copyOne
72.50% covered (warning)
72.50%
58 / 80
0.00% covered (danger)
0.00%
0 / 1
95.92
 applyDirModeFromSource
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 isIgnorable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copies one file within the time budget, resumably, with a hardlink fast path.
4 *
5 * @package NewfoldLabs\WP\Module\Staging
6 */
7
8namespace NewfoldLabs\WP\Module\Staging\Copy;
9
10/**
11 * Moves a single manifest entry into place. Three behaviours, in order:
12 *
13 *   1. Hardlink fast path (deploy only): if a file at the link-dest (current production) matches the
14 *      source by size + mtime, hardlink it instead of copying bytes. This is the per-file analogue of
15 *      `rsync --link-dest`: unchanged files cost no bytes.
16 *   2. Resumable byte copy: otherwise stream the bytes in chunks, stopping cleanly at the deadline and
17 *      returning PARTIAL with the offset reached, so a file larger than one budget resumes mid-file
18 *      next call. The destination is truncated to the checkpointed offset before appending, so a dead
19 *      request can never corrupt the file.
20 *   3. Verify: on completion the destination's size must equal the manifest size, and its mtime is set
21 *      to the source mtime so a later deploy's delta still recognises it as unchanged.
22 *
23 * Pure PHP (no WordPress); unit-testable against temp dirs.
24 */
25class FileCopier {
26
27    const RESULT_DONE    = 'done';
28    const RESULT_PARTIAL = 'partial';
29    const RESULT_SKIP    = 'skip';
30    const RESULT_ERROR   = 'error';
31
32    /**
33     * Bytes moved per read/write iteration.
34     */
35    const CHUNK_BYTES = 1048576; // 1 MiB.
36
37    /**
38     * Maximum mid-file re-snapshots from offset 0 before pinning a growing file's target size.
39     */
40    const MAX_RESNAPSHOTS = 3;
41
42    /**
43     * Ensure one directory entry from the manifest exists on the destination tree.
44     *
45     * @param string $src         Absolute source directory path.
46     * @param string $dest        Absolute destination directory path.
47     * @param int    $mtime       Expected mtime from the manifest.
48     * @param string $source_root Absolute content root the copy reads from.
49     * @return string One of the RESULT_* constants.
50     */
51    public static function ensureDirectory( $src, $dest, $mtime, $source_root ) {
52        if ( ! is_dir( $src ) ) {
53            $parent_ok = is_dir( dirname( $src ) );
54            $root_ok   = is_dir( $source_root );
55            return ( $parent_ok && $root_ok ) ? self::RESULT_SKIP : self::RESULT_ERROR;
56        }
57
58        $dest_dir = dirname( $dest );
59        if ( ! is_dir( $dest_dir ) ) {
60            if ( ! mkdir( $dest_dir, 0755, true ) && ! is_dir( $dest_dir ) ) {
61                return self::RESULT_ERROR;
62            }
63            self::applyDirModeFromSource( dirname( $src ), $dest_dir );
64        }
65        if ( is_dir( $dest ) ) {
66            self::applyDirModeFromSource( $src, $dest );
67        } elseif ( ! mkdir( $dest, 0755, false ) && ! is_dir( $dest ) ) {
68            return self::RESULT_ERROR;
69        } else {
70            self::applyDirModeFromSource( $src, $dest );
71        }
72        if ( $mtime > 0 ) {
73            @touch( $dest, $mtime );
74        }
75        return self::RESULT_DONE;
76    }
77
78    /**
79     * Copy one file.
80     *
81     * @param string      $src         Absolute source path.
82     * @param string      $dest        Absolute destination path.
83     * @param int         $size        In/out: expected size (manifest or a persisted re-snapshot).
84     * @param int         $mtime       In/out: expected mtime (manifest or a persisted re-snapshot).
85     * @param string|null $link_cand   Absolute hardlink candidate (current production copy), or null.
86     * @param int         $offset      In/out: bytes already written; updated as copying proceeds.
87     * @param float       $deadline    microtime(true) to stop at.
88     * @param string      $source_root Absolute content root the copy reads from. Used to tell a single
89     *                                 file genuinely vanishing (safe to skip) from the whole tree
90     *                                 disappearing (catastrophic: fail closed).
91     * @param int         $resnapshots In/out: mid-file re-snapshot count for continuously changing files.
92     * @return string One of the RESULT_* constants.
93     */
94    public static function copyOne( $src, $dest, &$size, &$mtime, $link_cand, &$offset, $deadline, $source_root, &$resnapshots = 0 ) {
95        if ( ! file_exists( $src ) ) {
96            // The source is gone. Distinguish a single file genuinely removed between walk and copy (safe
97            // to skip) from the whole subtree or content root having vanished -- e.g. a concurrent clone
98            // `rm -rf`-ing staging mid-copy, or an EACCES/ESTALE that hides a tree still on disk. SKIPping
99            // a file that is really still there makes the finalize swap DELETE it from production, so skip
100            // only when the immediate parent dir AND the content root both still exist; else fail CLOSED.
101            $parent_ok = is_dir( dirname( $src ) );
102            $root_ok   = is_dir( $source_root );
103            return ( $parent_ok && $root_ok ) ? self::RESULT_SKIP : self::RESULT_ERROR;
104        }
105
106        $dest_dir = dirname( $dest );
107        if ( ! is_dir( $dest_dir ) ) {
108            if ( ! mkdir( $dest_dir, 0755, true ) && ! is_dir( $dest_dir ) ) {
109                return self::RESULT_ERROR;
110            }
111            self::applyDirModeFromSource( dirname( $src ), $dest_dir );
112        }
113
114        // 1. Hardlink fast path.
115        //
116        // The comparison is production against the source AS IT IS NOW, not against the size + mtime
117        // the manifest recorded at enumeration. The copy phase can run for hours, so a staging file
118        // edited after the walk still matches the manifest -- and so did production, which meant the
119        // edit was hardlinked away and silently not shipped. The byte copy below re-snapshots for
120        // exactly this reason; the two paths have to agree about which version of the file a deploy
121        // ships, or the answer depends on whether a hardlink happened to be possible.
122        if ( null !== $link_cand && 0 === $offset && is_file( $link_cand ) && ! is_link( $link_cand ) ) {
123            clearstatcache( true, $src );
124            clearstatcache( true, $link_cand );
125            $src_size  = filesize( $src );
126            $src_mtime = filemtime( $src );
127            if ( false !== $src_size && false !== $src_mtime
128                && filesize( $link_cand ) === $src_size && filemtime( $link_cand ) === $src_mtime ) {
129                if ( is_file( $dest ) || is_link( $dest ) ) {
130                    @unlink( $dest );
131                }
132                if ( @link( $link_cand, $dest ) ) {
133                    return self::RESULT_DONE; // Shares the inode (and the mtime); nothing to verify.
134                }
135                // Hardlink failed (cross-device, etc.); fall through to a byte copy.
136            }
137        }
138
139        // 2. Resumable byte copy.
140        $in = fopen( $src, 'rb' );
141        if ( false === $in ) {
142            // Enumerated but now unreadable. Tolerate the legacy htaccess exception (unreadable
143            // .htaccess.* backups); otherwise fail CLOSED rather than silently omit the file.
144            return self::isIgnorable( $src ) ? self::RESULT_SKIP : self::RESULT_ERROR;
145        }
146        // Snapshot integrity: the manifest recorded this file's size + mtime at enumeration. If either
147        // has changed, the file was rewritten/appended/truncated since, so copying exactly the manifest
148        // `$size` would ship a frankenfile (a prefix of new content that passes the size check, or a
149        // short read). Re-snapshot to the CURRENT size + mtime and copy THAT instead: that ships a
150        // self-consistent version of the file (its state right now) and lets the deploy COMPLETE, rather
151        // than failing every retry while a live file (regenerated CSS, an active log) keeps changing.
152        // Restart from offset 0 so no stale partial sized to the old snapshot is kept. CopyEngine persists
153        // the re-snapshotted size + mtime in the cursor so the next burst passes them back in and a
154        // large, stable post-change file can resume mid-file instead of re-snapshotting every burst.
155        $sstat = fstat( $in );
156        if ( false === $sstat ) {
157            fclose( $in );
158            return self::RESULT_ERROR;
159        }
160        if ( (int) $sstat['size'] !== (int) $size || (int) $sstat['mtime'] !== (int) $mtime ) {
161            if ( $offset > 0 && $resnapshots >= self::MAX_RESNAPSHOTS ) {
162                // A hot file (e.g. an active debug.log) has changed across every burst. If it only grew,
163                // keep copying up to the pinned snapshot size so the job finishes without livelocking; if
164                // it shrank below our progress, fail this attempt so CopyEngine's retry cap bounds it.
165                if ( (int) $sstat['size'] < (int) $size ) {
166                    fclose( $in );
167                    return self::RESULT_ERROR;
168                }
169            } else {
170                if ( $offset > 0 ) {
171                    ++$resnapshots;
172                }
173                $size   = (int) $sstat['size'];
174                $mtime  = (int) $sstat['mtime'];
175                $offset = 0;
176            }
177        }
178        // At offset 0, remove any existing destination first. If $dest is a hardlink to the production
179        // inode (a re-processed entry, or a stale -new), writing through it would truncate and corrupt
180        // live production; unlinking breaks the link so we write a fresh, independent file.
181        if ( 0 === $offset && ( is_file( $dest ) || is_link( $dest ) ) ) {
182            @unlink( $dest );
183            clearstatcache( true, $dest );
184            if ( is_file( $dest ) || is_link( $dest ) ) {
185                // The unlink failed and the dest is still there. If it is a hardlink to production, the
186                // byte copy below would write THROUGH it and corrupt live production. Fail CLOSED.
187                fclose( $in );
188                return self::RESULT_ERROR;
189            }
190        }
191        // 'cb' = create/keep, do not truncate. Cut any partial tail past the checkpointed offset, then
192        // position both handles at it, so resume is exact and a dead request leaves no stray bytes.
193        $out = fopen( $dest, 'cb' );
194        if ( false === $out ) {
195            fclose( $in );
196            return self::RESULT_ERROR;
197        }
198        if ( ! ftruncate( $out, $offset ) || -1 === fseek( $out, $offset ) || ( $offset > 0 && -1 === fseek( $in, $offset ) ) ) {
199            fclose( $in );
200            fclose( $out );
201            return self::RESULT_ERROR;
202        }
203
204        // Read exactly up to the manifest size, never past it: a file that GREW after enumeration must
205        // still yield a `$size`-byte copy (snapshot semantics), and a file that SHRANK hits an early
206        // EOF which we surface as an error rather than ship a short file. The deadline is checked after
207        // each chunk, so every burst makes at least one chunk of progress (no livelock).
208        $result = self::RESULT_DONE;
209        while ( $offset < $size ) {
210            $to_read = (int) min( self::CHUNK_BYTES, $size - $offset );
211            $buf     = fread( $in, $to_read );
212            if ( false === $buf || '' === $buf ) {
213                $result = self::RESULT_ERROR; // Read error or unexpected EOF (source shrank).
214                break;
215            }
216            $written = fwrite( $out, $buf );
217            if ( false === $written || $written < strlen( $buf ) ) {
218                // Short write = out of space / over quota. Do not advance the offset past what landed.
219                $offset += is_int( $written ) ? $written : 0;
220                $result  = self::RESULT_ERROR;
221                break;
222            }
223            $offset += $written;
224            if ( $offset < $size && microtime( true ) >= $deadline ) {
225                $result = self::RESULT_PARTIAL;
226                break;
227            }
228        }
229
230        fflush( $out );
231        fclose( $in );
232        fclose( $out );
233
234        if ( self::RESULT_DONE !== $result ) {
235            return $result;
236        }
237
238        // 3. Verify + stamp mode and mtime.
239        $mode = (int) $sstat['mode'] & 0777;
240        if ( $mode > 0 ) {
241            @chmod( $dest, $mode );
242        }
243        @touch( $dest, $mtime );
244        clearstatcache( true, $dest );
245        if ( filesize( $dest ) !== $size ) {
246            return self::RESULT_ERROR;
247        }
248        return self::RESULT_DONE;
249    }
250
251    /**
252     * Match a newly created destination directory to its source's permission bits while keeping owner
253     * rwx (0700) so files can still be written into it during the copy and sweeps can remove `-old`.
254     *
255     * @param string $src_dir  Absolute source directory.
256     * @param string $dest_dir Absolute destination directory just created.
257     * @return void
258     */
259    private static function applyDirModeFromSource( $src_dir, $dest_dir ) {
260        clearstatcache( true, $src_dir );
261        if ( ! is_dir( $src_dir ) ) {
262            return;
263        }
264        $raw_mode = fileperms( $src_dir ) & 0777;
265        if ( $raw_mode > 0 ) {
266            @chmod( $dest_dir, $raw_mode | 0700 );
267        }
268    }
269
270    /**
271     * Whether an unreadable source file may be skipped instead of failing the job. Matches the legacy
272     * worker's IGNORE_PATTERNS=("htaccess.") tolerance for unreadable .htaccess.* backup files.
273     *
274     * @param string $path Absolute source path.
275     * @return bool
276     */
277    private static function isIgnorable( $path ) {
278        return false !== strpos( basename( $path ), 'htaccess.' );
279    }
280}