0079: Import from other tools, and breakpoints

On this page

Status: accepted. It amends 0005: Short Leash still never changes other tools' files, and now reads them in the two cases below.

Context

0005 and invariant 3 said Short Leash never reads or modifies other tools' files, so Short Leash started with no sites and people moving from other local development tools (the import guide names the ones Short Leash reads) parked folders, linked sites, turned on HTTPS and pinned PHP versions again by hand. That cost is a reason not to switch. Separately, PhpStorm keeps a project's breakpoints in the project's own .idea folder, which Short Leash couldn't look at either, so debugging a request needed a browser extension or a trigger in the URL. Both need reading files Short Leash doesn't own, without giving up what 0005 protects: Short Leash never changes another app's files, never stops it, and never acts on its files unasked.

Decision

Invariant 3 gains two read-only exceptions. Nothing else that belongs to another app is read, and Short Leash never writes, renames or deletes another app's files in either case.

1. A one-time import the person starts

  • Finding a tool. When onboarding's last step is shown, or when the person asks in Settings › Import, Short Leash checks whether a known tool's settings file exists (a stat of that file). It never opens or reads it, and nothing runs in the background.
  • Reading. Only after the person chooses Import from … does Short Leash read that tool's site list, and it changes nothing until they choose Import. leash import --dry-run reads the same and changes nothing. Each Import scans again and applies only the items the person ticked that are still there: an item already in Short Leash shows under "Already in Short Leash" and is never added twice, and a ticked item that's gone since the list was shown is reported "No longer found", never applied.
  • What it may read, and only this:
    • the tool's config.json (its parked folders and its TLD, and, for a tool that keeps its sites there instead of in a Sites folder, each linked site's name, folder, HTTPS and PHP version);
    • the names and targets of the links in its Sites folder (the links are read, never followed to open a file);
    • its per-site Nginx files, only for the PHP version comment and the HTTPS listen line;
    • whether Certificates/<domain>.crt exists (a stat; the certificate isn't opened);
    • the names of the subfolders of its parked folders;
    • in each project being imported, the per-project settings file that tool reads from the project's folder (the import guide names them).
  • What it never opens: anything in the tool's services folder (the services' own data folders, with their keys), private keys (*.key, *-key.pem), certificate requests (*.csr), the tool's CA and certificates folders (CA, certs), any .env, and the tool's own databases (*.sqlite). Values from .env are never copied: a ${DB_PORT}-style reference in a project's settings file stays a reference (invariant 9).
  • Limits. Every file read is capped at 64 KiB (import::MAX_READ). A file that's bigger, missing, or doesn't parse goes on a "couldn't read" list; it never fails the whole import. A project's YAML settings file is read through Short Leash's one bounded YAML reader (yaml_guard), as the parser's stream of events, with YAML 1.2's rules (yes is text, not true). Other tools' files may use anchors, so it reads in budget mode: aliases may copy in up to 10,000 entries or 1 MB of text per file, and a file past that budget, with more than 10,000 entries or nesting deeper than 32 levels is refused before it's built past the limit, so a few KB can't grow into gigabytes. Values keep their text as written (8.10 stays 8.10).
  • What it writes: Short Leash's own config, through the same calls as the app's own controls (park, link, HTTPS, the site's PHP version, installing a PHP version), and a project's leash.toml (0078) only when the person ticks that project, never over an existing one. The other tool is never stopped, changed or told.
  • What it maps: sites answer only at .test (invariant 8); a tool set to another TLD gets a note, not a different TLD. A PHP version becomes a pin only when it's one Short Leash offers (runtimes::php::SUPPORTED); other spellings are normalized to a supported minor or dropped with a note, never guessed.
  • Services aren't imported. Other tools keep their services' definitions beside their data and keys, which Short Leash doesn't open. A database comes across in an optional second step: Short Leash shows the dump command to run with the other tool's client, and never runs it. Nothing in the command is quoted: a database name with anything but letters, digits, _ and - is left out, and a placeholder with a line saying to fill it in takes its place. When the person chooses the file it wrote, Short Leash opens it once, without following a link or waiting on a pipe, and refuses anything but a regular file (on Windows, links and junctions are refused; other reparse points, such as OneDrive's cloud files, are read). An SQL dump is read once, before a service is touched, by the same check every MySQL-family restore runs (below), and refused with the line and what's there. Short Leash then starts its own matching service, copies exactly the length the file had when opened into its backups folder (readable only by the person's account, in a private folder), and restores it through backups' restore into a new database, never over one that's there. Names are compared without case, as MySQL on macOS and Windows compares them. Every MySQL-family restore, from Backups or from an import (imported dumps live in Backups), first reads the whole dump with one lexer (backups::script) that follows the mysql client's own rules: strings in ', " and backticks (a backslash escapes only in the first two), -- and # comments, /* */ comments, versioned comments (/*!…*/, whose insides the client treats as code), and the current DELIMITER. Outside strings and comments it refuses any backslash command (\T, \., \e, \!… anywhere on a line; only \N, which is NULL, is data), the name of any client command the client's help lists (tee, source, edit, system, ssl_session_data_print…) wherever the client might take one: first on a line, right after any comment closes (plain, versioned or /*M!) or a versioned one opens, and right after the delimiter, whatever part of a statement is pending. The real client takes a command after a comment that starts a line, even a versioned one with SQL in it, so the lexer doesn't try to follow exactly when it does: a place where the two disagree about what's pending fails safe, at the cost of refusing SQL with such a name in one of those places (a line that starts with a column called status, say), and the message says to take out the line break or comment in front of it or to put the name in backticks. Only DELIMITER on its own line between statements is followed. Where no statement is pending, -- with no space after it is refused too: MariaDB's client skips it and the rest of its line, while MySQL's reads --' as code that opens a string, so the two would read the next line differently. MariaDB's own sandbox and costs are refused like MySQL's commands. It also refuses a statement that reaches another database (USE, CREATE, DROP or ALTER DATABASE), PREPARE or EXECUTE at the top level, and anything that would make the client read the rest differently: an SQL mode with NO_BACKSLASH_ESCAPES or ANSI_QUOTES, or one set from anything but a literal or the copy mysqldump takes (SET @OLD_SQL_MODE=@@SQL_MODE … SET SQL_MODE=@OLD_SQL_MODE). Inside MariaDB's /*M! comments, code to its client and a comment to MySQL's, only what both read alike is allowed. It keeps a bounded amount of state whatever the file holds: the user variables it follows are capped at 10,000 (backups::script::MAX_VARIABLES), and a dump naming more is refused. An ignored test (backups::run::differential) runs the real mysql client, 8.4 and 8.0, and MariaDB's mariadb client with --sandbox, 11.8 and 10.11, with Short Leash's restore command on a scratch server over a grid of every command from the client's help, bare and with an argument, and every backslash command, after 36 kinds of prefix (comments of each kind, chained comments, a delimiter, strings across lines, a line continuation, a custom DELIMITER including a command word, a 1 MB line, the word inside a versioned comment…) joined up to 14 ways (the same or the next line, CRLF, CR only, TAB, VT, FF, NUL, U+00A0, U+2028), and checks that whatever the client acts on, the lexer refuses. The client runs with --default-character-set=utf8mb4, the character set the lexer reads in. The client's flags are a second layer behind the lexer. The main one is --binary-mode: reading a file, both clients (MySQL's since 5.6, MariaDB's since 10.0) take no command of their own but charset and delimiter, so \!, \., \e, \T, system, source, tee, edit, pager, prompt and the rest are refused by the client itself, making the lexer defense in depth rather than the only thing between a dump and a shell. Its side effects, \r\n kept as it is and a NUL read as data rather than ending a statement, are harmless for dumps: the real-server restore tests (MySQL 8.4 and 8.0, MariaDB 11.8 and 10.11) restore binary data and strings with quotes, backslashes, CR LF and NUL byte for byte with it, and the differential grid passes with it on every client. (A statement the server reads as starting with a NUL is empty to it and leaves nothing in its history, so for those cases the grid counts only what the client does on disk or to tables.) --binary-mode also leaves use to the server as SQL, where --one-database can't follow the change of database, so the lexer keeps refusing USE, CREATE, DROP and ALTER DATABASE, and every client command, as before. Besides it, --system-command=OFF (MySQL) turns off \! and system, and MariaDB's --sandbox, which every restore into Short Leash's MariaDB passes (chosen by the service's kind, not by which programs its install has), turns off \! and system, tee (\T) and source (\.). The restore also passes --one-database, which applies statements only while the target is the current default database. It filters by that alone and doesn't look inside statements, so a hand-written file with an inline INSERT INTO other.t … still writes to other. That is the accepted residual: the file is one the person made and chose, and dumps from mysqldump, mariadb-dump and the usual exporters don't name other databases inline. PostgreSQL archives go through pg_restore -d without -C. The dump is kept as a backup with reason Imported, under the same retention as the others.
  • No MCP tool. Import reads another app's files, and agents don't need it; leash mcp and chat can't start it.

2. PhpStorm's breakpoint list

While Xdebug is on for a site's PHP version, a request with no debug trigger still goes to the Xdebug pool when PhpStorm has enabled line breakpoints in the site's project (runtimes::breakpoints).

  • One file: <site root>/.idea/workspace.xml, the .idea folder at the site's root only, never a parent folder. No other file in .idea is read.
  • Only the breakpoints: the line-breakpoint entries with enabled="true" and type php or php-line-method directly in the XDebuggerManager component's breakpoint list. Disabled, JavaScript and exception breakpoints, the defaults templates and every other component are ignored.
  • Only when it applies: while Xdebug is on for the site's PHP version and the site's Debug when PhpStorm has breakpoints switch is on (on by default). Otherwise the file isn't opened at all.
  • Off the request path: a request only asks a cache in memory; it never touches the disk. A watcher checks the sites asked about in the last 10 minutes (by a request, or by the app showing the count) once a second: a stat each time, and a read and parse only when the file's modified time or length changed. The first request after a cold start may not stop at a breakpoint; the next one does.
  • Read-only and careful: opened without following a link (a link in place of .idea or of the file isn't read), never waiting on a FIFO, only a plain file, at most 8 MiB (breakpoints::MAX_FILE), parsed as a stream with no DTD processing or entity expansion. Short Leash never writes the file.

Naming other tools

The repo rule against naming other local development tools gains one exception: import's own surfaces may name the tools it imports from. These are the import screen, its docs page, leash import --help and the generated CLI reference, its CHANGELOG line, the Sites feature page, the Privacy policy and its canvas board. Code identifiers and fixtures that name the tools stay inside the import module (leash-core/src/import/), its fixtures (leash-core/tests/fixtures/import/), the CLI's import.rs, the import API's source values, and the UI's screens/import/, ipc/mockImport.ts and import types in ipc/types.ts. Everywhere else, this ADR included, the rule stands.

Consequences

  • Moving to Short Leash is one checklist instead of an afternoon, and nothing happens without the person choosing it.
  • The other tools' formats are undocumented and may change with any release. The readers are tolerant, a file they can't make sense of lands on the "couldn't read" list, and fixtures per tool and per OS pin the layouts Short Leash knows. A tool whose global config can't be located reliably on a real install may be imported from its per-project files only until it can. All three tools Short Leash imports from have a reader for their global settings: the third one's single, daemon-owned config.json (parked folders, and linked sites with their folder, HTTPS and PHP version) was confirmed from its published source and documentation rather than a copy from a real install, so checking it against a real install, on macOS and Windows, is part of the manual pass.
  • A folder is matched against the tools' settings folders by its identity (device and file number) as well as its name, so another spelling of the same folder is still caught. A home mirrored through a second file system (a FUSE mount, an SMB or NFS loopback) gives the same files other identities; there, only the name check catches them.
  • Import doesn't move services or their data, and doesn't copy secrets; databases take the extra dump step.
  • If the other tool's processes still hold ports 80 and 443, Short Leash names the process and asks the person to quit it, as before; it never stops it.
  • PhpStorm may move breakpoints out of workspace.xml, or not have saved them yet (it writes the file on save or when the project closes); then requests don't stop until it does, and the docs say so. Projects in the older .ipr format, or whose workspace lives in a container or on a remote machine, aren't covered.
  • While breakpoints are set, every request to the site uses the slower Xdebug pool, even with PhpStorm closed, until they're removed or the switch is off.
  • Invariant 3 now has exceptions to keep narrow: a new file of another app's to read needs an ADR that amends this one.