blob: 6b38e199b8f8b538123336eee4d84e24592e28f5 [file]
// Copyright 2026 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package agentpatch
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/apache/casbin-gateway/agentconfig"
"github.com/apache/casbin-gateway/conf"
)
// gatewayEntryName is the identifier Gateway writes into every agent
// configuration it touches, so operators can find and remove it by hand. The
// skill and MCP listings recognize it by the same name.
const gatewayEntryName = agentconfig.ManagedEntryName
type changeKind string
const (
changeFile changeKind = "file"
changeDir changeKind = "dir"
)
type change struct {
Kind changeKind `json:"kind"`
Path string `json:"path"`
Backup string `json:"backup,omitempty"`
Mode os.FileMode `json:"mode,omitempty"`
PatchedHash string `json:"patchedHash,omitempty"`
PatchedMode os.FileMode `json:"patchedMode,omitempty"`
}
type manifest struct {
AgentId string `json:"agentId"`
Target Target `json:"target"`
PatchedAt time.Time `json:"patchedAt"`
Changes []change `json:"changes"`
}
var stateMutex sync.Mutex
// ChangeSet records the files and directories a patch owns. Files are restored
// only when they still contain the patch's content, so Unpatch never overwrites
// a configuration an operator has changed since Patch.
type ChangeSet struct {
manifest *manifest
backupDir string
}
// MkdirAll creates dir and records only directories this patch created.
func (c *ChangeSet) MkdirAll(dir string) error {
var created []string
for current := filepath.Clean(dir); ; current = filepath.Dir(current) {
if _, err := os.Stat(current); err == nil {
break
}
created = append(created, current)
if parent := filepath.Dir(current); parent == current {
break
}
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for index := len(created) - 1; index >= 0; index-- {
c.manifest.Changes = append(c.manifest.Changes, change{Kind: changeDir, Path: created[index]})
}
return nil
}
// ReadFile reads a patch target, returning empty content for a file Patch will
// create.
func (c *ChangeSet) ReadFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, nil
}
return data, err
}
// WriteFile backs up the pre-patch file and records the replacement it owns.
func (c *ChangeSet) WriteFile(path string, data []byte, perm os.FileMode) error {
item := change{Kind: changeFile, Path: path}
if previous, err := os.ReadFile(path); err == nil {
info, err := os.Stat(path)
if err != nil {
return err
}
item.Mode = info.Mode().Perm()
perm = item.Mode
if err := os.MkdirAll(c.backupDir, 0o700); err != nil {
return err
}
item.Backup = fmt.Sprintf("%d-%s", len(c.manifest.Changes), filepath.Base(path))
if err := os.WriteFile(filepath.Join(c.backupDir, item.Backup), previous, 0o600); err != nil {
return err
}
} else if !os.IsNotExist(err) {
return err
}
if err := os.WriteFile(path, data, perm); err != nil {
return err
}
info, err := os.Stat(path)
if err != nil {
return err
}
item.PatchedHash = contentHash(data)
item.PatchedMode = info.Mode().Perm()
c.manifest.Changes = append(c.manifest.Changes, item)
return nil
}
// Apply runs a file-changing patch transaction and persists the information
// needed for Unpatch. A failed patch is rolled back before its error returns.
func Apply(target Target, apply func(*ChangeSet) error) error {
stateMutex.Lock()
defer stateMutex.Unlock()
// A previous patch whose files were edited externally must not block a fresh
// one: revertLocked has already cleaned up the tracked state by that point.
if err := revertLocked(target); err != nil && !errors.As(err, new(*PartialRevertError)) {
return err
}
changes := &ChangeSet{
manifest: &manifest{AgentId: target.AgentId, Target: target, PatchedAt: time.Now()},
backupDir: backupDir(target),
}
if err := apply(changes); err != nil {
_ = rollback(changes.manifest, changes.backupDir)
return err
}
if err := saveManifest(target, changes.manifest); err != nil {
_ = rollback(changes.manifest, changes.backupDir)
_ = os.Remove(manifestPath(target))
_ = os.RemoveAll(changes.backupDir)
return err
}
return nil
}
// Revert restores a patch's unchanged files. It is a no-op when the target is
// not currently tracked by Gateway.
func Revert(target Target) error {
stateMutex.Lock()
defer stateMutex.Unlock()
return revertLocked(target)
}
// revertLocked restores what it can and always clears the tracked state, so an
// installation can never end up unable to unpatch and unable to re-patch.
func revertLocked(target Target) error {
saved, err := loadManifest(target)
if err != nil || saved == nil {
return err
}
rollbackErr := rollback(saved, backupDir(target))
if rollbackErr != nil && !errors.As(rollbackErr, new(*PartialRevertError)) {
return rollbackErr
}
if err := os.Remove(manifestPath(target)); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.RemoveAll(backupDir(target)); err != nil {
return err
}
return rollbackErr
}
// discardStateLocked drops a target's manifest and backups without restoring
// them. Patchers that own a precise entry rather than a whole file use it to
// clear state written by an earlier, backup-based release.
func discardStateLocked(target Target) {
_ = os.Remove(manifestPath(target))
_ = os.RemoveAll(backupDir(target))
}
// IsApplied reports whether Gateway has the manifest needed to restore target.
func IsApplied(target Target) bool {
stateMutex.Lock()
defer stateMutex.Unlock()
saved, err := loadManifest(target)
return err == nil && saved != nil
}
// rollback restores the files this patch still owns. A file that changed after
// Patch is left exactly as the operator or the agent left it: refusing the whole
// unpatch instead would strand the installation, because Apply reverts first and
// so a stale manifest would block re-patching too. Skipped files are reported so
// the caller can tell the operator what to clean up by hand.
func rollback(saved *manifest, backups string) error {
var first error
var skipped []string
for index := len(saved.Changes) - 1; index >= 0; index-- {
item := saved.Changes[index]
if item.Kind == changeDir {
_ = os.Remove(item.Path)
continue
}
if !patchStillOwns(item) {
skipped = append(skipped, item.Path)
continue
}
var err error
if item.Backup == "" {
err = os.Remove(item.Path)
if os.IsNotExist(err) {
err = nil
}
} else if content, readErr := os.ReadFile(filepath.Join(backups, item.Backup)); readErr != nil {
err = readErr
} else {
err = os.WriteFile(item.Path, content, item.Mode)
if err == nil {
err = os.Chmod(item.Path, item.Mode)
}
}
if err != nil && first == nil {
first = fmt.Errorf("restore %s: %w", item.Path, err)
}
}
if first == nil && len(skipped) != 0 {
return &PartialRevertError{Paths: skipped}
}
return first
}
// PartialRevertError reports files that Unpatch deliberately left untouched
// because they changed after Patch. The patch state is still cleaned up, so the
// installation can be patched again.
type PartialRevertError struct {
Paths []string
}
func (e *PartialRevertError) Error() string {
return fmt.Sprintf("monitoring was disabled, but %s changed after Patch and was left unmodified; remove any remaining %q entry by hand",
strings.Join(e.Paths, ", "), gatewayEntryName)
}
// patchStillOwns reports whether a file is byte-for-byte what Patch wrote.
func patchStillOwns(item change) bool {
content, err := os.ReadFile(item.Path)
if err != nil {
// A file the patch created and something else deleted is already in the
// desired post-unpatch state; a file we cannot read is not ours to touch.
return os.IsNotExist(err) && item.Backup == ""
}
info, err := os.Stat(item.Path)
if err != nil {
return false
}
return item.PatchedHash != "" && item.PatchedHash == contentHash(content) && item.PatchedMode == info.Mode().Perm()
}
func stateDir() string {
return conf.GetAgentPatchStateDir()
}
func targetKey(target Target) string {
sum := sha256.Sum256([]byte(target.AgentId + "|" + target.Owner + "|" + target.Path))
return target.AgentId + "-" + hex.EncodeToString(sum[:])[:16]
}
func contentHash(content []byte) string {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:])
}
func manifestPath(target Target) string {
return filepath.Join(stateDir(), targetKey(target)+".json")
}
func backupDir(target Target) string {
return filepath.Join(stateDir(), targetKey(target))
}
func loadManifest(target Target) (*manifest, error) {
data, err := os.ReadFile(manifestPath(target))
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var saved manifest
if err := json.Unmarshal(data, &saved); err != nil {
return nil, err
}
return &saved, nil
}
func saveManifest(target Target, saved *manifest) error {
if err := os.MkdirAll(stateDir(), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(saved, "", " ")
if err != nil {
return err
}
return os.WriteFile(manifestPath(target), append(data, '\n'), 0o600)
}