feat(modules): install a module from a zip package
For the site that has no SSH: module:install --from=blog.zip unpacks a
package and installs it, or updates the module if the site already has it.
The archive is looked into before a byte reaches the disk. Paths leading
outside the module, absolute paths, symlinks, an unpacked size beyond a
sane limit, too many entries, no module.php, more than one directory at the
top — each is refused with a sentence saying what is wrong with the file.
None of that is exotic: it is what an archive from an unknown author looks
like when it means harm, and it was verified against real ones — the zip
slip entry never created config/pwned.txt, the symlink to /etc/passwd was
refused by name.
Where the module ends up is decided by the key in its manifest, not by the
directory inside the archive. It is unpacked into data/tmp/, never straight
into modules/, where a half-written module would be picked up by the next
request; the previous version is moved into data/backups/ and the new one
takes its place with a single rename, which goes back if anything fails.
A system module cannot be replaced this way — that is what upgrading the
CMS is for.
This also fixes something older. The registry factory and the container
each built their own FilesystemModuleRepository and ModuleStateStore, and
each cached separately: unpacking put files on disk while the install
service went on insisting there was no such module. Both now come from
ModuleRegistryFactory, and the repository interface has forget(). The same
cause had already cost a run of migrations reporting "0 applied".
package and installs it, or updates the module if the site already has it.
The archive is looked into before a byte reaches the disk. Paths leading
outside the module, absolute paths, symlinks, an unpacked size beyond a
sane limit, too many entries, no module.php, more than one directory at the
top — each is refused with a sentence saying what is wrong with the file.
None of that is exotic: it is what an archive from an unknown author looks
like when it means harm, and it was verified against real ones — the zip
slip entry never created config/pwned.txt, the symlink to /etc/passwd was
refused by name.
Where the module ends up is decided by the key in its manifest, not by the
directory inside the archive. It is unpacked into data/tmp/, never straight
into modules/, where a half-written module would be picked up by the next
request; the previous version is moved into data/backups/ and the new one
takes its place with a single rename, which goes back if anything fails.
A system module cannot be replaced this way — that is what upgrading the
CMS is for.
This also fixes something older. The registry factory and the container
each built their own FilesystemModuleRepository and ModuleStateStore, and
each cached separately: unpacking put files on disk while the install
service went on insisting there was no such module. Both now come from
ModuleRegistryFactory, and the repository interface has forget(). The same
cause had already cost a run of migrations reporting "0 applied".