mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* fix(installtxn): put back an install a killed commit left behind
CommitDir publishes by two renames: the live target moves into a workspace
backup, then the staged copy is renamed into place. Neither is journaled, so a
process killed between them left the target absent with the install's only copy
retained in a workspace nothing ever read. plugins.Load and skills.Load
enumerate directories, so the extension simply disappeared, while the lockfile
went on listing it. Recovering needed one fact the transaction never wrote
down: which install a backup belonged to. CommitDir now records that before it
moves anything, and Recover puts the backup back when the target is absent.
Recovery is a second data-loss surface, so it is bounded on every side. It
refuses a name that is not a single element inside the install root, never
replaces a live target, identifies a workspace by the name StageDir gives one
rather than by contents alone, and leaves intact anything it cannot attribute.
Every path that takes the install lock recovers, not just the two that install.
Recovering on the install path alone is worse than not recovering: a removal
takes the not-present branch, drops the lockfile entry and reports success
while the backup it never looked at stays on disk, and the next install
republishes it, reinstating an extension the user deleted. Removal and the
terminalpet install hold the same lock and now do the same thing first.
Recovery stays an explicit call rather than a side effect of Lock, matching how
the other staged-swap transactions here invoke their repair pass and keeping a
filesystem mutation visible at the sites that cause it.
* fix(installtxn): retire a backup the publish rename already superseded
Recover skipped any workspace whose target was occupied, which left the backup
of a commit that was killed after its publish rename but before its cleanup.
Removing that install then deleted the live target and its lockfile entry but
not the skipped backup, and the next recovery read the absent target as an
interrupted swap and published the stale tree again. plugins.Load and
skills.Load enumerate directories, so the removed extension was loadable again
with nothing in the lockfile naming it.
The target already records how far the commit got, so recovery reads it as the
phase rather than carrying a phase file. Absent means the swap never finished
and the backup is put back as before. Present means the publish rename
committed, so the backup beside it is superseded and its workspace is retired.
The live install is never replaced or removed either way, and the guards that
skip a workspace with no backup, an unreadable or missing marker, or a recorded
name that is not a single element inside the install root are unchanged. The
retire path removes the workspace directly rather than through
cleanupWorkspace, which refuses one holding a previous precisely because it
cannot tell a superseded backup from one still owed a restore.
Reading the target that way is only safe once nothing can leave a partial tree
there. rollback deleted the failed install in place before restoring, so a
process killed partway through that delete left a husk at the target while the
backup was still the only complete copy, and recovery would have taken the husk
for a committed publish and deleted the last good tree. The renames are now
ordered so the target is never partial: the failed install moves aside into the
workspace, the backup moves back to the target, and only then is the set aside
tree removed. A first install has no backup to protect and recorded no target,
so it still deletes in place.
The move aside can fail too, and then the failed install stays live at the
target with the backup still the only copy of what it replaced. Rollback drops
the workspace marker on that path, which leaves a workspace nothing can
attribute, and recovery already leaves those alone.
* fix(installtxn): reconcile both halves of an interrupted commit
Recovery inferred three facts from incidental filesystem shape: target
presence stood in for the transaction phase, a public dot prefix plus two
ordinary filenames stood in for ownership, and a void best effort call stood
in for both "nothing to recover" and "recovery failed".
CommitDir swaps the trees and only then publishes the lockfile, so a kill
between the two leaves exactly what a kill after both leaves. A phase bit
written after the publish only inverts which side of the write the ambiguity
falls on, so Recover now asks the caller which of the two trees its published
metadata records. plugins and skills answer from the recorded hash; the
terminalpet install publishes no metadata and passes nil, where a live target
is the committed one. A backup the metadata still records goes back over the
target through the same move aside ordering rollback uses.
Recover returns an error, and every caller that takes the install lock aborts
on it before reading the lockfile, inspecting the target, installing over it,
or reporting a successful removal. Malformed, legacy, and unattributable
workspaces are still skipped without error; a workspace we own and could not
resolve is reported, and every workspace is processed before returning.
Ownership is now proven rather than inferred. The workspace marker carries a
magic and version first line, so a user authored skill directory that happens
to be named with the workspace prefix and to hold a previous directory is left
alone. A recorded name carrying the workspace prefix is refused too, since it
names another transaction rather than an install. Retirement clears the backup
before the workspace around it: os.RemoveAll unlinks the marker first, so a
failure inside the backup used to cost the workspace the attribution that gets
it reported at all.
* fix(installtxn): do not read a target we could not probe as absent
recoverWorkspace read any os.Lstat failure on the target as nothing being
there, which enters the restore branch: the one branch that moves a tree,
taken on the strength of a question that never got an answer. Only a
not-exist error means the swap did not finish; every other probe failure
leaves the phase unknown, so it is reported and the workspace is left as it
was.
The same distinction the reconcilers already draw between a missing file and
a file that could not be read, applied to the probe that decides which branch
recovery takes at all.
* fix(installtxn): ask the reconciler about the name that resolved the path
recoverableTarget trims the recorded name before resolving the path, but the
raw line went to the reconciler, so the two halves of one decision ran on two
different values. A marker recording "demo " resolves dir/demo and then asks
the lockfile about "demo ". The lookup misses, a miss proves the publish never
ran, and the committed target is replaced with the tree it superseded. Found
by CodeRabbit on the round-2 push; the test observes that replacement before
the fix.
Also fixes the two CI failures on the new tests, neither of which was a
product defect:
macOS and Windows: the skills recovery matrix compared the lock source against
the path the test handed the installer, while Install records
canonicalSource(source). t.TempDir returns /var on macOS where the recorded
value is /private/var, and an 8.3 short name on Windows where it is the long
one. The expectation now comes from the same resolver the installer uses.
Windows: the two plugins failure-injection tests make a directory unwritable to
force a rename or removal to fail. Windows does not block either that way and
root ignores the bits, so the injected failure never fired and the tests
asserted against a success they never meant to produce. They now skip there, as
the installtxn and skills equivalents already did.
* fix(installtxn): stop recovery acting on trees the lockfile never described
An adversarial pass over the recovery protocol, driven end to end through the
built binary rather than package tests, found that the protocol still acted on
evidence it did not have.
The one that loses data: a missing lock entry was read as proof the publish
never ran, so recovery replaced whatever was at the target with the retained
backup. A missing entry proves no such thing. The entry can be lost after a
publish that did run, since a truncated or deleted lockfile reads as an empty
one, and a directory with no entry is a state these packages already support
(Load returns it, Remove handles it). Worse, the tree at the target may have
nothing to do with the transaction: `zero tools make foo` scaffolds into the
plugins root, and a hand-written skill directory is an ordinary thing to find
in the skills root. Both were destroyed and replaced by a stale backup, and the
install reported success. Reproduced through the CLI both ways before the fix.
A missing entry is now PhaseUnknown, which stops every caller and touches
nothing.
The rest are the same class as the target probe fixed in 005fff72, a probe
whose failure was read as a fact:
- A marker that could not be read was skipped as somebody else's content, so
the only copy of an install stayed stranded while the caller was told there
was nothing to do. Present but unreadable is now reported.
- The retained backup was probed with Stat, which follows symlinks and never
checked the type, so a symlink there published content from outside the
install root and a regular file published a file where a directory belongs.
Only a directory is a tree this package set aside.
- An entry at the target was read as an install at the target. A dangling
symlink satisfies Lstat, and treating it as live retired the only real copy.
- Two workspaces claiming one install were acted on in turn, which published
one over the other and deleted the rest. Nothing ranks two claims, so both
are reported.
- A stale `failed` tree from an earlier rollback made the restore rename fail
for good, and since every caller aborts on a recovery error that wedged every
install and removal in the root. The workspace is proven ours, so the stale
tree is cleared first.
- A recorded name carrying a NUL or longer than a path element can be reached
Lstat and came back with an error that is not not-exist, reported on every
pass forever with no way for a caller to clear it. Such a name is not an
install to recover and is skipped.
Unresolved-transaction errors now name the workspace directory, because the
only remedy is to delete it by hand and nothing in the CLI can reach it.
Left alone deliberately: an interrupted RemoveDir still writes no marker, which
the PR body already discloses as a pre-existing residual.
637 lines
20 KiB
Go
637 lines
20 KiB
Go
package plugins
|
|
|
|
// Distribution: install a plugin from a git URL or a local path into a plugins
|
|
// directory, with the manifest validated and a content hash recorded in a
|
|
// lockfile (plugins.lock). Install copies the plugin tree verbatim but NEVER
|
|
// executes any of it — installed plugins still go through normal Stage 09
|
|
// activation with permission gating before any tool can run.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/Gitlawb/zero/internal/installtxn"
|
|
)
|
|
|
|
// manifestFileName is the plugin manifest filename, matching the loader.
|
|
const manifestFileName = "plugin.json"
|
|
|
|
// LockFileName maps an installed plugin id to its source and content hash.
|
|
const LockFileName = "plugins.lock"
|
|
|
|
// ErrNameClash is returned when an install would overwrite a plugin already
|
|
// installed from a DIFFERENT source, unless InstallOptions.Force is set.
|
|
var ErrNameClash = errors.New("a different plugin with that id is already installed")
|
|
|
|
// GitRunner fetches the plugin at source into destination. The default runner
|
|
// shallow-clones with the system git (inheriting the process environment, so
|
|
// proxy/egress settings are honored). It is injectable so tests never hit the
|
|
// network. A runner must only fetch — it must never execute fetched content.
|
|
type GitRunner func(ctx context.Context, destination string, source string) error
|
|
|
|
// InstallOptions configures a single plugin install.
|
|
type InstallOptions struct {
|
|
// Source is a git URL or a local filesystem path to a plugin directory (one
|
|
// that contains a plugin.json, or whose tree contains exactly one).
|
|
Source string
|
|
// Dir is the plugins directory to install into (typically the user plugins
|
|
// root from ResolveRoots).
|
|
Dir string
|
|
// Force allows overwriting a plugin installed from a different source.
|
|
Force bool
|
|
// GitRunner overrides the fetch implementation. When nil, a git source is
|
|
// shallow-cloned with the system git.
|
|
GitRunner GitRunner
|
|
}
|
|
|
|
// InstallResult reports what an install did.
|
|
type InstallResult struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
ManifestPath string `json:"manifestPath"`
|
|
Hash string `json:"hash"`
|
|
Source string `json:"source"`
|
|
Updated bool `json:"updated"`
|
|
PreviousHash string `json:"previousHash,omitempty"`
|
|
}
|
|
|
|
// LockEntry records the source and content hash for one installed plugin.
|
|
type LockEntry struct {
|
|
Source string `json:"source"`
|
|
Hash string `json:"hash"`
|
|
}
|
|
|
|
// Install fetches the plugin at options.Source, validates its manifest, copies
|
|
// the plugin tree into options.Dir/<id>/, and records a content hash (over the
|
|
// manifest bytes) in the lockfile. Fetched content is never executed.
|
|
func Install(ctx context.Context, options InstallOptions) (InstallResult, error) {
|
|
source := strings.TrimSpace(options.Source)
|
|
if source == "" {
|
|
return InstallResult{}, errors.New("a plugin source (git URL or path) is required")
|
|
}
|
|
dir := strings.TrimSpace(options.Dir)
|
|
if dir == "" {
|
|
return InstallResult{}, errors.New("a plugins directory is required")
|
|
}
|
|
// Canonicalize a local source so clash detection keys off the resolved path,
|
|
// not the spelling the user typed (relative vs absolute, symlinked vs not).
|
|
source = canonicalSource(source)
|
|
|
|
fetchDir, cleanup, err := fetchSource(ctx, source, options.GitRunner)
|
|
if err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
defer cleanup()
|
|
|
|
pluginDir, err := locatePluginDir(fetchDir)
|
|
if err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
|
|
manifestPath := filepath.Join(pluginDir, manifestFileName)
|
|
data, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
return InstallResult{}, fmt.Errorf("read %s: %w", manifestFileName, err)
|
|
}
|
|
var raw any
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return InstallResult{}, fmt.Errorf("parse %s: %w", manifestFileName, err)
|
|
}
|
|
|
|
// Validate against the same schema the loader uses. The install target id is
|
|
// derived from the (validated) manifest id, so it is safe as a directory name.
|
|
parsed, err := ParseManifest(raw, ParseManifestOptions{
|
|
Source: SourceUser,
|
|
Root: dir,
|
|
PluginDir: filepath.Join(dir, "pending"),
|
|
ManifestPath: manifestPath,
|
|
})
|
|
if err != nil {
|
|
return InstallResult{}, fmt.Errorf("invalid plugin manifest: %w", err)
|
|
}
|
|
id := parsed.ID
|
|
|
|
// Hash the SAME filtered tree that copyTree installs (not just the manifest),
|
|
// so a change to any installed file — a tool script, prompt, or bundled skill —
|
|
// is reflected in the lock hash and reported as an update.
|
|
hash, err := hashTree(pluginDir)
|
|
if err != nil {
|
|
return InstallResult{}, fmt.Errorf("hash plugin: %w", err)
|
|
}
|
|
|
|
staged, cleanupStage, err := installtxn.StageDir(dir)
|
|
if err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
defer cleanupStage()
|
|
// Copy the whole plugin tree (entry scripts, prompts, skills) into a sibling
|
|
// staging area. Copy DATA only — never execute it.
|
|
if err := copyTree(pluginDir, staged); err != nil {
|
|
return InstallResult{}, fmt.Errorf("stage plugin: %w", err)
|
|
}
|
|
stagedHash, err := hashTree(staged)
|
|
if err != nil {
|
|
return InstallResult{}, fmt.Errorf("validate staged plugin: %w", err)
|
|
}
|
|
if stagedHash != hash {
|
|
return InstallResult{}, errors.New("validate staged plugin: copied content hash differs from source")
|
|
}
|
|
|
|
unlock, err := installtxn.Lock(dir)
|
|
if err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
defer unlock()
|
|
// Put back anything an earlier run was killed mid-commit, and abort if a
|
|
// transaction could not be resolved: installing over a tree that is still
|
|
// owed a restore destroys the only copy of it. See installtxn.Recover.
|
|
if err := installtxn.Recover(dir, reconcileInterrupted(dir)); err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
|
|
// Re-read under the cross-process lock. Another install may have updated the
|
|
// lockfile while this plugin was fetched and staged.
|
|
lock, err := ReadLock(dir)
|
|
if err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
previous, existed := lock[id]
|
|
if existed && previous.Source != source && !options.Force {
|
|
return InstallResult{}, fmt.Errorf("%w: %q is installed from %s (use --force to overwrite)", ErrNameClash, id, previous.Source)
|
|
}
|
|
|
|
target := filepath.Join(dir, id)
|
|
lock[id] = LockEntry{Source: source, Hash: hash}
|
|
if err := installtxn.CommitDir(target, staged, func() error {
|
|
return writeLock(dir, lock)
|
|
}); err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
|
|
result := InstallResult{
|
|
ID: id,
|
|
Name: parsed.Name,
|
|
Version: parsed.Version,
|
|
ManifestPath: filepath.Join(target, manifestFileName),
|
|
Hash: hash,
|
|
Source: source,
|
|
}
|
|
if existed {
|
|
result.Updated = previous.Hash != hash
|
|
result.PreviousHash = previous.Hash
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Remove deletes an installed plugin directory and its lockfile entry. It errors
|
|
// if the named plugin is not present in either the dir or the lockfile.
|
|
func Remove(dir string, id string) error {
|
|
dir = strings.TrimSpace(dir)
|
|
id = strings.TrimSpace(id)
|
|
if dir == "" || id == "" {
|
|
return errors.New("a plugins directory and plugin id are required")
|
|
}
|
|
if !validInstallID(id) {
|
|
return fmt.Errorf("invalid plugin id %q", id)
|
|
}
|
|
|
|
unlock, err := installtxn.Lock(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
// Put back anything an earlier run was killed mid-commit, and abort if a
|
|
// transaction could not be resolved: reporting a removal while a backup this
|
|
// run never saw stays on disk lets the next install publish it again. See
|
|
// installtxn.Recover.
|
|
if err := installtxn.Recover(dir, reconcileInterrupted(dir)); err != nil {
|
|
return err
|
|
}
|
|
|
|
lock, err := ReadLock(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, locked := lock[id]
|
|
target := filepath.Join(dir, id)
|
|
_, statErr := os.Stat(target)
|
|
present := statErr == nil
|
|
if !locked && !present {
|
|
return fmt.Errorf("plugin %q is not installed", id)
|
|
}
|
|
if present {
|
|
if err := installtxn.RemoveDir(target, func() error {
|
|
if !locked {
|
|
return nil
|
|
}
|
|
delete(lock, id)
|
|
return writeLock(dir, lock)
|
|
}); err != nil {
|
|
return fmt.Errorf("remove plugin dir: %w", err)
|
|
}
|
|
} else if locked {
|
|
delete(lock, id)
|
|
if err := writeLock(dir, lock); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reconcileInterrupted answers the one question installtxn.Recover cannot read
|
|
// off the filesystem: of the two trees an interrupted commit left, which one is
|
|
// the one the lockfile records. CommitDir swaps the trees and only then
|
|
// publishes, so a kill between the two leaves exactly what a kill after both
|
|
// leaves, and only the published metadata tells them apart. The recorded hash is
|
|
// the same hashTree the install computed over the tree it copied, so matching it
|
|
// against each tree is an exact answer rather than a guess at filesystem shape.
|
|
func reconcileInterrupted(dir string) installtxn.Reconciler {
|
|
return func(id string, target string, backup string) (installtxn.Phase, error) {
|
|
lock, err := ReadLock(dir)
|
|
if err != nil {
|
|
// An unreadable lockfile is not an empty one. Reporting a phase from it
|
|
// would be reading a fact out of a file we could not read.
|
|
return installtxn.PhaseUnknown, err
|
|
}
|
|
entry, ok := lock[id]
|
|
if !ok {
|
|
// A missing entry is not proof that the publish never ran. The entry can
|
|
// also be lost after a publish that did run: a truncated or deleted
|
|
// lockfile reads as an empty one, and a directory with no entry is a
|
|
// state this package supports (Load returns it, Remove handles it). The
|
|
// live tree here may be nothing to do with this transaction at all, so
|
|
// there is no phase to report and recovery must not pick between them.
|
|
return installtxn.PhaseUnknown, nil
|
|
}
|
|
if entry.Hash == "" {
|
|
// A hand edited entry records no tree at all, so there is nothing to
|
|
// match either side against and no phase to report.
|
|
return installtxn.PhaseUnknown, nil
|
|
}
|
|
matched, err := treeMatchesHash(target, entry.Hash)
|
|
if err != nil {
|
|
return installtxn.PhaseUnknown, err
|
|
}
|
|
if matched {
|
|
return installtxn.PhaseCommitted, nil
|
|
}
|
|
matched, err = treeMatchesHash(backup, entry.Hash)
|
|
if err != nil {
|
|
return installtxn.PhaseUnknown, err
|
|
}
|
|
if matched {
|
|
return installtxn.PhasePrePublish, nil
|
|
}
|
|
return installtxn.PhaseUnknown, nil
|
|
}
|
|
}
|
|
|
|
// treeMatchesHash reports whether the tree at path hashes to want. A path that
|
|
// is not there is a non-match rather than an error: recovery asks about two
|
|
// trees it has just seen on disk, so one going missing under it only means that
|
|
// tree is not the one the lockfile records. Every other hashing failure is
|
|
// returned, because a tree we could not read may well be the recorded one.
|
|
func treeMatchesHash(path string, want string) (bool, error) {
|
|
hash, err := hashTree(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return hash == want, nil
|
|
}
|
|
|
|
// ReadLock loads the plugins lockfile from dir. A missing lockfile yields an
|
|
// empty map with no error.
|
|
func ReadLock(dir string) (map[string]LockEntry, error) {
|
|
dir = strings.TrimSpace(dir)
|
|
if dir == "" {
|
|
return map[string]LockEntry{}, nil
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(dir, LockFileName))
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return map[string]LockEntry{}, nil
|
|
}
|
|
return nil, fmt.Errorf("read %s: %w", LockFileName, err)
|
|
}
|
|
entries := map[string]LockEntry{}
|
|
if len(strings.TrimSpace(string(data))) == 0 {
|
|
return entries, nil
|
|
}
|
|
if err := json.Unmarshal(data, &entries); err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", LockFileName, err)
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func writeLock(dir string, entries map[string]LockEntry) error {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("create plugins dir: %w", err)
|
|
}
|
|
data, err := json.MarshalIndent(entries, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode %s: %w", LockFileName, err)
|
|
}
|
|
if err := installtxn.WriteFileAtomically(filepath.Join(dir, LockFileName), append(data, '\n'), 0o644); err != nil {
|
|
return fmt.Errorf("write %s: %w", LockFileName, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fetchSource resolves a source into a local directory. A local path is used in
|
|
// place; a git URL is shallow-cloned into a temp dir via the runner.
|
|
func fetchSource(ctx context.Context, source string, runner GitRunner) (string, func(), error) {
|
|
if isLocalPath(source) {
|
|
info, err := os.Stat(source)
|
|
if err != nil {
|
|
return "", func() {}, fmt.Errorf("read source: %w", err)
|
|
}
|
|
if !info.IsDir() {
|
|
return "", func() {}, fmt.Errorf("source must be a directory: %s", source)
|
|
}
|
|
abs, err := filepath.Abs(source)
|
|
if err != nil {
|
|
return "", func() {}, err
|
|
}
|
|
return abs, func() {}, nil
|
|
}
|
|
|
|
if runner == nil {
|
|
runner = defaultGitRunner
|
|
}
|
|
temp, err := os.MkdirTemp("", "zero-plugin-fetch-")
|
|
if err != nil {
|
|
return "", func() {}, fmt.Errorf("create temp dir: %w", err)
|
|
}
|
|
cleanup := func() { _ = os.RemoveAll(temp) }
|
|
if err := runner(ctx, temp, source); err != nil {
|
|
cleanup()
|
|
return "", func() {}, fmt.Errorf("fetch %s: %w", source, err)
|
|
}
|
|
return temp, cleanup, nil
|
|
}
|
|
|
|
// defaultGitRunner shallow-clones source into destination. exec.CommandContext
|
|
// inherits the process environment, so proxy/egress settings are honored;
|
|
// GIT_TERMINAL_PROMPT=0 prevents a credential prompt from blocking. Cloning only
|
|
// fetches; it never executes repository content.
|
|
func defaultGitRunner(ctx context.Context, destination string, source string) error {
|
|
command := exec.CommandContext(ctx, "git", "clone", "--depth", "1", "--", source, destination)
|
|
command.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
|
if output, err := command.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("git clone failed: %v: %s", err, strings.TrimSpace(string(output)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// locatePluginDir finds the directory holding plugin.json within root: the root
|
|
// itself, or exactly one immediate subdirectory.
|
|
func locatePluginDir(root string) (string, error) {
|
|
if fileExists(filepath.Join(root, manifestFileName)) {
|
|
return root, nil
|
|
}
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return "", fmt.Errorf("scan source: %w", err)
|
|
}
|
|
matches := []string{}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
candidate := filepath.Join(root, entry.Name())
|
|
if fileExists(filepath.Join(candidate, manifestFileName)) {
|
|
matches = append(matches, candidate)
|
|
}
|
|
}
|
|
sort.Strings(matches)
|
|
switch len(matches) {
|
|
case 0:
|
|
return "", fmt.Errorf("no %s found in source", manifestFileName)
|
|
case 1:
|
|
return matches[0], nil
|
|
default:
|
|
return "", fmt.Errorf("source contains multiple plugins (%d); install one at a time", len(matches))
|
|
}
|
|
}
|
|
|
|
// copyTree recursively copies regular files and directories from src to dst. It
|
|
// skips the .git directory (clone metadata) and refuses symlinks so a malicious
|
|
// source cannot smuggle a link that escapes the install dir. Copying is pure
|
|
// I/O — it never executes anything it copies.
|
|
func copyTree(src string, dst string) error {
|
|
entries, err := os.ReadDir(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(dst, 0o755); err != nil {
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if name == ".git" {
|
|
continue
|
|
}
|
|
srcPath := filepath.Join(src, name)
|
|
dstPath := filepath.Join(dst, name)
|
|
info, err := os.Lstat(srcPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch {
|
|
case info.Mode()&os.ModeSymlink != 0:
|
|
// Never recreate a symlink: it could point outside the install dir and
|
|
// turn a copy into a write/read primitive elsewhere.
|
|
continue
|
|
case info.IsDir():
|
|
if err := copyTree(srcPath, dstPath); err != nil {
|
|
return err
|
|
}
|
|
case info.Mode().IsRegular():
|
|
if err := copyFile(srcPath, dstPath, info.Mode().Perm()); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
// Skip FIFOs, sockets, devices.
|
|
continue
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyFile(src string, dst string, perm os.FileMode) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = in.Close() }()
|
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
_ = out.Close()
|
|
return err
|
|
}
|
|
return out.Close()
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && info.Mode().IsRegular()
|
|
}
|
|
|
|
// canonicalSource normalizes a local filesystem source to an absolute,
|
|
// symlink-evaluated path so a re-install via a different spelling of the same
|
|
// directory is recognized as the same source. Remote sources (git URLs) are
|
|
// returned unchanged. On any resolution error the input is returned as-is so a
|
|
// non-existent local path still surfaces its real error later in fetchSource.
|
|
func canonicalSource(source string) string {
|
|
if !isLocalPath(source) {
|
|
return source
|
|
}
|
|
abs, err := filepath.Abs(source)
|
|
if err != nil {
|
|
return source
|
|
}
|
|
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
|
return resolved
|
|
}
|
|
return abs
|
|
}
|
|
|
|
// isLocalPath reports whether source is a local filesystem path rather than a
|
|
// remote URL. URLs (scheme://… or scp-style host:path) and git shorthand are
|
|
// remote.
|
|
func isLocalPath(source string) bool {
|
|
if source == "" {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(source, ".") || strings.HasPrefix(source, "/") || strings.HasPrefix(source, "~") {
|
|
return true
|
|
}
|
|
if filepath.IsAbs(source) {
|
|
return true
|
|
}
|
|
if hasURLScheme(source) {
|
|
return false
|
|
}
|
|
if idx := strings.Index(source, ":"); idx > 0 {
|
|
host := source[:idx]
|
|
if strings.Contains(host, "@") {
|
|
return false
|
|
}
|
|
if len(host) == 1 {
|
|
return true // drive letter
|
|
}
|
|
if strings.Contains(host, ".") {
|
|
return false // hostname
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func hasURLScheme(source string) bool {
|
|
for _, scheme := range []string{"http://", "https://", "git://", "ssh://", "git+ssh://", "ftp://", "ftps://", "file://"} {
|
|
if strings.HasPrefix(strings.ToLower(source), scheme) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// validInstallID guards a plugin id used as a directory component. Manifest ids
|
|
// already match pluginIDPattern, but Remove takes an id directly from the user.
|
|
func validInstallID(id string) bool {
|
|
if !pluginIDPattern.MatchString(id) {
|
|
return false
|
|
}
|
|
return id == filepath.Base(id) && !strings.ContainsAny(id, `/\`) && !strings.Contains(id, "..")
|
|
}
|
|
|
|
// hashTree computes a content hash over the same filtered tree that copyTree
|
|
// installs: regular files only, .git and symlinks skipped, walked in a stable
|
|
// sorted order. Each file contributes its plugin-relative path, executable bit,
|
|
// and bytes, so renames, mode flips, and content edits all change the hash.
|
|
func hashTree(root string) (string, error) {
|
|
hasher := sha256.New()
|
|
if err := hashTreeInto(hasher, root, root); err != nil {
|
|
return "", err
|
|
}
|
|
return "sha256:" + hex.EncodeToString(hasher.Sum(nil)), nil
|
|
}
|
|
|
|
func hashTreeInto(hasher io.Writer, root string, dir string) error {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
names := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
names = append(names, entry.Name())
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
if name == ".git" {
|
|
continue
|
|
}
|
|
path := filepath.Join(dir, name)
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch {
|
|
case info.Mode()&os.ModeSymlink != 0:
|
|
// Skipped by copyTree, so excluded from the hash too.
|
|
continue
|
|
case info.IsDir():
|
|
if err := hashTreeInto(hasher, root, path); err != nil {
|
|
return err
|
|
}
|
|
case info.Mode().IsRegular():
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
executable := 0
|
|
if info.Mode().Perm()&0o111 != 0 {
|
|
executable = 1
|
|
}
|
|
// Null-delimited header keeps file boundaries unambiguous (paths cannot
|
|
// contain null bytes) so two trees cannot collide by shifting bytes.
|
|
header := fmt.Sprintf("%s\x00%d\x00", filepath.ToSlash(rel), executable)
|
|
if _, err := io.WriteString(hasher, header); err != nil {
|
|
return err
|
|
}
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(hasher, file); err != nil {
|
|
_ = file.Close()
|
|
return err
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
// FIFOs, sockets, devices: skipped by copyTree, excluded here.
|
|
continue
|
|
}
|
|
}
|
|
return nil
|
|
}
|