Skip to content

Commit b23c66e

Browse files
committed
Add path-containment validation for resolved skill directories
1 parent e517e27 commit b23c66e

1 file changed

Lines changed: 74 additions & 19 deletions

File tree

pkg/skills/skillsvc/skillsvc.go

Lines changed: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -787,17 +787,32 @@ func (s *service) applyGitInstallExisting(
787787
files []gitresolver.FileEntry,
788788
) (*skills.InstallResult, error) {
789789
if existing.Digest != opts.Digest {
790-
return s.gitWriteMultiAndPersist(ctx, opts, scope, clientTypes, clientDirs, files,
791-
clientTypes, existing.Clients, true, true)
790+
allClients, allDirs, err := s.expandToExistingClients(
791+
existing.Clients, clientTypes, clientDirs, opts.Name, scope, opts.ProjectRoot)
792+
if err != nil {
793+
return nil, err
794+
}
795+
return s.gitWriteMultiAndPersist(ctx, opts, scope, allClients, allDirs, files,
796+
allClients, nil, true, true)
792797
}
798+
clientsExplicit := len(opts.Clients) > 0
793799
if clientsContainAll(existing.Clients, clientTypes) ||
794-
(len(existing.Clients) == 0 && len(clientTypes) <= 1) {
800+
(len(existing.Clients) == 0 && len(clientTypes) <= 1 && !clientsExplicit) {
795801
return &skills.InstallResult{Skill: existing}, nil
796802
}
797803
toWrite := missingClients(existing.Clients, clientTypes)
798804
if len(toWrite) == 0 {
799805
return &skills.InstallResult{Skill: existing}, nil
800806
}
807+
for _, ct := range toWrite {
808+
dir := clientDirs[ct]
809+
if _, statErr := os.Stat(dir); statErr == nil && !opts.Force {
810+
return nil, httperr.WithCode(
811+
fmt.Errorf("directory %q exists but is not managed by ToolHive; use force to overwrite", dir),
812+
http.StatusConflict,
813+
)
814+
}
815+
}
801816
return s.gitWriteMultiAndPersist(ctx, opts, scope, clientTypes, clientDirs, files,
802817
toWrite, existing.Clients, true, false)
803818
}
@@ -1240,6 +1255,35 @@ func (s *service) resolveAndValidateClients(
12401255
return requested, paths, nil
12411256
}
12421257

1258+
// expandToExistingClients merges existingClients into requestedClients and
1259+
// resolves paths for any existing client not already in clientDirs. This
1260+
// ensures upgrades write new files to all clients, not just the requested set.
1261+
func (s *service) expandToExistingClients(
1262+
existingClients, requestedClients []string,
1263+
clientDirs map[string]string,
1264+
skillName string, scope skills.Scope, projectRoot string,
1265+
) ([]string, map[string]string, error) {
1266+
allClients := mergeClientLists(requestedClients, existingClients)
1267+
allDirs := make(map[string]string, len(allClients))
1268+
for k, v := range clientDirs {
1269+
allDirs[k] = v
1270+
}
1271+
for _, ct := range allClients {
1272+
if _, ok := allDirs[ct]; ok {
1273+
continue
1274+
}
1275+
dir, err := s.pathResolver.GetSkillPath(ct, skillName, scope, projectRoot)
1276+
if err != nil {
1277+
return nil, nil, fmt.Errorf("resolving skill path for existing client %q: %w", ct, err)
1278+
}
1279+
if err := validateResolvedDir(dir); err != nil {
1280+
return nil, nil, fmt.Errorf("resolved path for client %q is unsafe: %w", ct, err)
1281+
}
1282+
allDirs[ct] = dir
1283+
}
1284+
return allClients, allDirs, nil
1285+
}
1286+
12431287
// validateResolvedDir ensures a directory path returned by the PathResolver is
12441288
// clean, absolute, and free of path-traversal segments. This provides
12451289
// defense-in-depth against tainted paths reaching filesystem operations.
@@ -1316,28 +1360,36 @@ func (s *service) installWithExtraction(
13161360
return nil, fmt.Errorf("checking existing skill: %w", storeErr)
13171361
}
13181362

1319-
digestMatches := storeErr == nil && existing.Digest == opts.Digest
1320-
// Legacy rows may have an empty Clients slice; treat as satisfied for a
1321-
// single-target install (previous behavior). Multi-client requests must
1322-
// still merge onto disk when the store has no client list.
1323-
allRequestedPresent := digestMatches && (clientsContainAll(existing.Clients, clientTypes) ||
1324-
(len(existing.Clients) == 0 && len(clientTypes) <= 1))
1325-
1326-
if digestMatches && allRequestedPresent {
1363+
if isExtractionNoOp(existing, storeErr, opts, clientTypes) {
13271364
return &skills.InstallResult{Skill: existing}, nil
13281365
}
13291366

1367+
digestMatches := storeErr == nil && existing.Digest == opts.Digest
13301368
if digestMatches && storeErr == nil {
13311369
return s.installExtractionSameDigestNewClients(ctx, opts, scope, existing, clientTypes, clientDirs)
13321370
}
13331371

1334-
if storeErr == nil && !digestMatches {
1372+
if storeErr == nil {
13351373
return s.installExtractionUpgradeDigest(ctx, opts, scope, existing, clientTypes, clientDirs)
13361374
}
13371375

13381376
return s.installExtractionFresh(ctx, opts, scope, clientTypes, clientDirs)
13391377
}
13401378

1379+
// isExtractionNoOp reports whether the install can be short-circuited because
1380+
// the same digest and all requested clients are already present. Legacy store
1381+
// rows (empty Clients slice) are treated as satisfied only when the user did
1382+
// not explicitly specify --clients.
1383+
func isExtractionNoOp(existing skills.InstalledSkill, storeErr error, opts skills.InstallOptions, clientTypes []string) bool {
1384+
if storeErr != nil || existing.Digest != opts.Digest {
1385+
return false
1386+
}
1387+
if clientsContainAll(existing.Clients, clientTypes) {
1388+
return true
1389+
}
1390+
return len(existing.Clients) == 0 && len(clientTypes) <= 1 && len(opts.Clients) == 0
1391+
}
1392+
13411393
func (s *service) installExtractionSameDigestNewClients(
13421394
ctx context.Context,
13431395
opts skills.InstallOptions,
@@ -1388,20 +1440,23 @@ func (s *service) installExtractionUpgradeDigest(
13881440
clientTypes []string,
13891441
clientDirs map[string]string,
13901442
) (*skills.InstallResult, error) {
1443+
allClients, allDirs, err := s.expandToExistingClients(
1444+
existing.Clients, clientTypes, clientDirs, opts.Name, scope, opts.ProjectRoot)
1445+
if err != nil {
1446+
return nil, err
1447+
}
13911448
var written []string
1392-
for _, ct := range clientTypes {
1393-
dir := clientDirs[ct]
1449+
for _, ct := range allClients {
1450+
dir := allDirs[ct]
13941451
if _, exErr := s.installer.Extract(opts.LayerData, dir, true); exErr != nil {
1395-
removeSkillDirs(s.installer, clientDirs, written)
1452+
removeSkillDirs(s.installer, allDirs, written)
13961453
return nil, fmt.Errorf("extracting skill upgrade: %w", exErr)
13971454
}
13981455
written = append(written, ct)
13991456
}
1400-
sk := buildInstalledSkill(opts, scope, clientTypes, existing.Clients)
1457+
sk := buildInstalledSkill(opts, scope, allClients, nil)
14011458
if err := s.store.Update(ctx, sk); err != nil {
1402-
for _, ct := range clientTypes {
1403-
_ = s.installer.Remove(clientDirs[ct])
1404-
}
1459+
removeSkillDirs(s.installer, allDirs, allClients)
14051460
return nil, err
14061461
}
14071462
return &skills.InstallResult{Skill: sk}, nil

0 commit comments

Comments
 (0)