mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
Update the Go module path, imports, install scripts, docs, workflows, packaging and action defaults from Gitlawb/zero to Twigpine/zero. The npm scope (already @twigpine/zero on main, with its @gitlawb/zero migration notes), LICENSE, action author, the OpenGateway provider and historical CHANGELOG links are unchanged. The X-AIMLAPI-Integration-Repo header keeps Gitlawb/zero: it is what AIMLAPI sees for attribution, so changing it needs a maintainer's yes. docs/INSTALL.md keeps `go install github.com/Gitlawb/zero/...`, because every tagged release so far declares that module path. The Homebrew formula comment names the Twigpine/homebrew-tap tap. Co-authored-by: Claude Opus 5.5 <[email protected]>
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/Twigpine/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
|
|
}
|