Skip to content
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@

- Never commit SSH keys, passwords, or credentials
- Use `--stdin` for sensitive input (avoid shell history leaks)
- `internal/cmd/dumpscan.go` must read a dump exactly as the mysql and mariadb clients do (`add_line` and `read_and_execute` in `client/mysql.cc` of mysql-server and MariaDB/server). Check each lexer rule against both sources, not memory. Where the clients or the server disagree, refuse the construct. For every bypass, add a rejection case to `TestScanDumpRefusesClientCommands`, which runs whole-input and one-byte reads.
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ wpgo -s mysite core verify-checksums
wpgo -s mysite db export # this machine: ~/wpgo-backups/mysite/mysite_DB_export_<timestamp>_<random>.sql
wpgo -s mysite db export pre-update.sql # this machine: ./pre-update.sql
(umask 077 && wpgo -s mysite db export - > dump.sql) # stdout; see below
wpgo -s mysite db import dump.sql
wpgo -s mysite db import dump.sql --yes --ack-destructive # dump.sql is on this machine
wpgo -s mysite db query "SELECT COUNT(*) FROM wp_posts"
wpgo -s mysite db size
wpgo -s mysite db tables
Expand All @@ -184,6 +184,13 @@ wpgo -s mysite db repair

`db export` and `backup` run `wp db export -` on the server and stream the dump over SSH to the machine running wpgo. Nothing is written on the server, and wpgo has no option to write one there. The default destination is `${WPGO_LOCAL_BACKUP_DIR:-~/wpgo-backups}/<site>/<site>_DB_<desc>_<timestamp>_<ms>_<random>.sql` (dir 0700, file 0600). wpgo writes to a temp file, fsyncs it and links it into place, then prints the path, byte size and sha256. It never replaces an existing file, including one created while the dump was streaming. If the remote export exits non-zero, the dump is empty, or its last non-empty line does not start with mysqldump's `-- Dump completed`, wpgo deletes the partial file and exits non-zero.

`db import` restores a dump file from the machine running wpgo, or stdin with `-`. It never imports a file stored on the server and refuses a path that does not exist locally. It:

1. reads the site's `DB_NAME`, then copies the dump into a private file next to the backups while checking it. It refuses the dump if the mysql client would run anything but SQL statements of the kinds mysqldump, mariadb-dump and `wp db export` write: a client command such as `\!` (shell), `\.` or `source` (run a file), `\r` or `connect`, `system`, `tee`, `pager`, `use` of another database, a statement other than `SET`, `INSERT`, `REPLACE`, `LOCK`/`UNLOCK TABLES`, `COMMIT`, `CREATE`/`DROP` of a table, view, trigger, routine or event, `ALTER TABLE`, or `CREATE`/`ALTER DATABASE` and `USE` of the site database, `OUTFILE`/`DUMPFILE`, or a `SET` that turns on `NO_BACKSLASH_ESCAPES` or a character set such as GBK or SJIS. Text inside quoted values is data and is not refused. `DELIMITER ;;` is allowed;
2. prints the site, database, dump and pre-restore backup path, and stops unless both `--yes` and `--ack-destructive` are given;
3. saves a backup of the current database like `wpgo db export`, to `<backup dir>/<site>/<site>_DB_pre-import_<timestamp>_<random>.sql`, and stops if the backup fails or is empty;
4. streams the checked copy over SSH into `wp db import -` and deletes the copy. If the import fails, the error names the backup to restore from.

`db export -` applies the same checks and exits non-zero on failure, but it cannot take back bytes already written to stdout, so check the exit status before using the output. Redirect it under `umask 077` so the dump is not world-readable:

```bash
Expand Down Expand Up @@ -303,7 +310,8 @@ Put wp-cli arguments after `--`. `raw` joins them with spaces and the server she
- `export` (the WXR exporter);
- `search-replace` or `db search-replace` with any `--export` flag;
- `db query` whose SQL contains `OUTFILE` or `DUMPFILE` in any case, a mysql client command that runs or writes files (`system`, `tee`, `pager`, `source` or `edit` at the start of a statement, or `\!`, `\T`, `\P`, `\.` or `\e` anywhere, in any case), or the same in an `--execute` value;
- `db query`, `import`, `create`, `drop`, `reset`, `clean`, `check`, `optimize`, `repair`, `tables`, `size`, `columns` and `prefix` with a flag outside a short per-command allowlist. wp-cli hands extra flags to mysql or mysqlcheck, so this blocks `--tee`, `--pager`, `--init-command`, `--execute` (except on `db query`, where its SQL is checked), `--defaults` and `--defaults-*` (`--no-defaults` passes), and their prefixes. `wpgo db query` and `wpgo db import` apply the same checks;
- `db import` (use `wpgo db import <local-file>`);
- `db query`, `create`, `drop`, `reset`, `clean`, `check`, `optimize`, `repair`, `tables`, `size`, `columns` and `prefix` with a flag outside a short per-command allowlist. wp-cli hands extra flags to mysql or mysqlcheck, so this blocks `--tee`, `--pager`, `--init-command`, `--execute` (except on `db query`, where its SQL is checked), `--defaults` and `--defaults-*` (`--no-defaults` passes), and their prefixes. `wpgo db query` applies the same checks;
- `db cli` and `db connect`;
- the `--exec` and `--require` globals.

Expand Down
5 changes: 5 additions & 0 deletions internal/adapter/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ type Adapter interface {
// stdout as it arrives; the returned ExecResult has an empty Stdout.
ExecStream(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string, stdout io.Writer) (internalssh.ExecResult, error)

// ExecStdin runs a wp-cli command like Exec with stdin copied to the
// command's stdin; the command sees EOF when stdin returns io.EOF or an
// error.
ExecStdin(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string, stdin io.Reader) (internalssh.ExecResult, error)

// Upload transfers a local file to the remote host.
Upload(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, localPath, remotePath string) error

Expand Down
5 changes: 5 additions & 0 deletions internal/adapter/standard.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ func (a *StandardAdapter) ExecStream(ctx context.Context, client *internalssh.SS
return client.ExecStream(ctx, siteToClientConfig(site), site.CanonicalHost, wpCmd, stdout)
}

// ExecStdin runs a pre-built remote shell command with stdin streamed to it.
func (a *StandardAdapter) ExecStdin(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string, stdin io.Reader) (internalssh.ExecResult, error) {
return client.ExecWithStdin(ctx, siteToClientConfig(site), site.CanonicalHost, wpCmd, stdin)
}

// Upload streams a local file to the remote host via stdin.
// Runs: cat > {remotePath} with the file content piped to stdin.
func (a *StandardAdapter) Upload(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, localPath, remotePath string) error {
Expand Down
8 changes: 8 additions & 0 deletions internal/adapter/wpengine.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ func (a *WPEngineAdapter) ExecStream(ctx context.Context, client *internalssh.SS
return client.ExecStream(execCtx, wpengineClientConfig(site), site.CanonicalHost, wpCmd, stdout)
}

// ExecStdin runs a pre-built remote shell command on WP Engine with stdin
// streamed to it, within the same session timeout as Exec.
func (a *WPEngineAdapter) ExecStdin(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string, stdin io.Reader) (internalssh.ExecResult, error) {
execCtx, cancel := wpengineSessionContext(ctx)
defer cancel()
return client.ExecWithStdin(execCtx, wpengineClientConfig(site), site.CanonicalHost, wpCmd, stdin)
}

// wpengineSessionContext enforces WP Engine's 10-minute session timeout.
func wpengineSessionContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := 10 * time.Minute
Expand Down
14 changes: 14 additions & 0 deletions internal/cmd/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,20 @@ func (rc *RunContext) ExecWPStream(ctx context.Context, site *registry.Site, wpC
return a.ExecStream(ctx, rc.SSHClient, site, wpCmd, stdout)
}

// ExecWPStdin executes a wp-cli command on a site with stdin streamed to it.
// In dry-run mode it prints the command and reads nothing.
func (rc *RunContext) ExecWPStdin(ctx context.Context, site *registry.Site, wpCmd string, stdin io.Reader) (internalssh.ExecResult, error) {
a := adapter.ForSite(site)
if rc.Globals.Verbose {
fmt.Fprintf(rc.Stderr, "[adapter: %s] %s\n", a.Name(), wpCmd)
}
if rc.Globals.DryRun {
fmt.Fprintf(rc.Stderr, "[dry-run] %s\n", wpCmd)
return internalssh.ExecResult{}, nil
}
return a.ExecStdin(ctx, rc.SSHClient, site, wpCmd, stdin)
}

// CacheGet checks the cache for a stored result. Returns the cached data
// string or empty string on miss. Handles nil cache gracefully.
func (rc *RunContext) CacheGet(siteAlias, commandCacheKey string) string {
Expand Down
37 changes: 1 addition & 36 deletions internal/cmd/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,6 @@ type DBCmd struct {
type DBExportCmd struct {
File string `arg:"" optional:"" help:"Local file on this machine (default $WPGO_LOCAL_BACKUP_DIR/<site>/, else ~/wpgo-backups/<site>/); '-' streams to stdout. Nothing is written on the server."`
}
type DBImportCmd struct {
File string `arg:"" help:"Import file path"`
}
type DBQueryCmd struct {
SQL string `arg:"" help:"SQL query"`
}
Expand Down Expand Up @@ -68,45 +65,13 @@ func (c *DBExportCmd) Run(g *Globals) error {
return err
}
}
dump, err := exportToLocal(rc, site, path)
dump, err := exportToLocal(context.Background(), rc, site, path)
if err != nil {
return err
}
return printDump(rc, "Exported to:", dump)
}

func (c *DBImportCmd) Run(g *Globals) error {
// wp-cli reads a file argument that starts with -- as a mysql flag.
if err := checkWPArgv([]string{"db", "import", c.File}); err != nil {
return err
}
rc, err := NewRunContext(g)
if err != nil {
return err
}
defer rc.Close()
site, err := rc.ResolveSite()
if err != nil {
return err
}

result, err := rc.ExecWP(context.Background(), site,
wpcli.New("db", "import").Arg(c.File).Build(site.WPPath))
if err != nil {
return err
}
if result.ExitCode != 0 {
return fmt.Errorf("wp db import: %s", result.Stderr)
}
// DB import can change everything — invalidate all categories.
rc.CacheInvalidate(site.Alias, []string{
cache.CategoryPlugins, cache.CategoryThemes, cache.CategoryCore,
cache.CategoryUsers, cache.CategoryOptions, cache.CategorySnapshot,
})
fmt.Fprint(rc.Stdout, result.Stdout)
return nil
}

func (c *DBQueryCmd) Run(g *Globals) error {
if err := checkWPArgv([]string{"db", "query", c.SQL}); err != nil {
return err
Expand Down
Loading
Loading