Skip to content
261 changes: 261 additions & 0 deletions internal/setup/conflict.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
package setup

import (
"errors"
"fmt"
"maps"
"slices"
"strings"

"github.com/canonical/chisel/internal/strdist"
)

type segmentSlice struct {
Slice *Slice
// PathInfo is kept here as an optimization to avoid lookups on
// Slice.Contents for every slice.
PathInfo PathInfo
Comment thread
letFunny marked this conversation as resolved.
// WholePath is used to simplify both error reporting and matching against
// paths with "**"; both of which require reconstructing the whole path.
WholePath string
}

type segment struct {
Text string
// HasGlob is set when the path contains "*" or "?" or "**".
HasGlob bool
// HasDoubleGlob is set when the path contains "**".
HasDoubleGlob bool
}

type node struct {
Segment segment
Slices []*segmentSlice
Children map[string]*node
}

// pathConflictTree uses a custom trie to find conflicts that might arise from
// extracting different paths into the same root directory.
//
// It optimizes conflict resolution by calling strdist.GlobPath only when
Comment thread
letFunny marked this conversation as resolved.
Outdated
// strictly necessary and by passing it less data to compare. It relies on the
// fact that real chisel releases most paths often share a very long prefix
Comment thread
letFunny marked this conversation as resolved.
Outdated
// that does not need to be compared each time. Additionally, our grammar is
// very restrictive (only "*", "?" and "**") meaning that unless "**" is used,
// any symbol can only match until a "/" is found.
//
// Because of the above, this algorithm splits paths into segments that are
// delimited by "/". When inserting a path, each segment is compared at most
// once with the path independently of how many paths there are in the release.
// Lastly, when looking for conflicts, if the segments do not contain "**" then
// instead of comparing the whole path we can compare only the segment.
type pathConflictTree struct {
Root *node
PathToSlices map[string][]*Slice
}

func newConflictTree(pathToSlices map[string][]*Slice) pathConflictTree {
root := &node{
Segment: segment{"/", false, false},
Children: map[string]*node{},
}
return pathConflictTree{Root: root, PathToSlices: pathToSlices}
}

func (g *pathConflictTree) HasConflict() error {
// Make errors deterministic.
paths := slices.Collect(maps.Keys(g.PathToSlices))
slices.Sort(paths)

for _, path := range paths {
slices := g.PathToSlices[path]
var oldSlices []*segmentSlice
for _, oldSlice := range slices {
oldSlices = append(oldSlices, &segmentSlice{oldSlice, oldSlice.Contents[path], path})
}
Comment thread
letFunny marked this conversation as resolved.
segments, err := pathToSegments(path)
if err != nil {
return err
}
err = g.pathHasConflict(segments, oldSlices)
if err != nil {
return err
}
g.insertSegments(segments, oldSlices)
}
return nil
}
Comment thread
letFunny marked this conversation as resolved.

func (g *pathConflictTree) pathHasConflict(oldSegments []segment, oldSlices []*segmentSlice) error {
Comment thread
letFunny marked this conversation as resolved.
Outdated
conflictErrMsg := func(oldSegmentSlice, newSegmentSlice *segmentSlice) error {
oldSlice, oldPath := oldSegmentSlice.Slice, oldSegmentSlice.WholePath
newSlice, newPath := newSegmentSlice.Slice, newSegmentSlice.WholePath
if (oldSlice.Package > newSlice.Package) || (oldSlice.Package == newSlice.Package && oldSlice.Name > newSlice.Name) ||
(oldSlice.Package == newSlice.Package && oldSlice.Name == newSlice.Name && oldPath > newPath) {
oldSlice, newSlice = newSlice, oldSlice
oldPath, newPath = newPath, oldPath
}
return fmt.Errorf("slices %s and %s conflict on %s and %s", oldSlice, newSlice, oldPath, newPath)
}

var currentQueue []*node
var nextQueue []*node

// Skip "/".
currentQueue = slices.Collect(maps.Values(g.Root.Children))
Comment thread
letFunny marked this conversation as resolved.
oldSegments = oldSegments[1:]

// If we run out of segments from the graph or the path there cannot be a
// conflict (note paths with "**" are collapsed to one segment).
Comment thread
letFunny marked this conversation as resolved.
Outdated
for len(currentQueue) > 0 && len(oldSegments) > 0 {
oldSegment := oldSegments[0]
for _, newNode := range currentQueue {
newNodeLoop:
for _, oldSegmentSlice := range oldSlices {
oldSlice := oldSegmentSlice.Slice
oldPathInfo := oldSegmentSlice.PathInfo
for _, newSegmentSlice := range newNode.Slices {
newSlice := newSegmentSlice.Slice
newPathInfo := newSegmentSlice.PathInfo
newSegment := newNode.Segment

// If slices cannot conflict then skip the more expensive
// checks.
if (oldPathInfo.Kind == GlobPath || oldPathInfo.Kind == CopyPath) && (newPathInfo.Kind == GlobPath || newPathInfo.Kind == CopyPath) {
if newSlice.Package == oldSlice.Package {
// If content is **extracted** from the same
// package, it will necessarily be the same.
continue
}
}

if newSegment.HasDoubleGlob || oldSegment.HasDoubleGlob {
// Case 1: One of the strings has a double glob, we
Comment thread
letFunny marked this conversation as resolved.
Outdated
// need to check the whole remaining path against
// each other.
if strdist.GlobPath(oldSegmentSlice.WholePath, newSegmentSlice.WholePath) {
return conflictErrMsg(oldSegmentSlice, newSegmentSlice)
}
} else if newSegment.HasGlob || oldSegment.HasGlob {
// Case 2: Either segment has a single glob (* or ?).
// We only need to check the segment.
if strdist.GlobPath(oldSegment.Text, newSegment.Text) {
// Only when we get to leaf (i.e. no children, can
// we have a conflict).
Comment thread
letFunny marked this conversation as resolved.
Outdated
if len(newNode.Children) == 0 && len(oldSegments) == 1 {
// If we are at the terminal node of both paths we found a conflict.
return conflictErrMsg(oldSegmentSlice, newSegmentSlice)
}
for _, child := range newNode.Children {
nextQueue = append(nextQueue, child)
}
break newNodeLoop
} else {
// Once GlobPath returns false there cannot be a
// conflict between both paths, we can
// break here.
break newNodeLoop
}
} else {
// Case 3: No globs, we can compare the strings directly.
Comment thread
letFunny marked this conversation as resolved.
Outdated
Comment thread
letFunny marked this conversation as resolved.
Outdated
if oldSegment.Text == newSegment.Text {
if len(newNode.Children) == 0 && len(oldSegments) == 1 {
// If these are both terminal nodes, conflict found.
return conflictErrMsg(oldSegmentSlice, newSegmentSlice)
}
for _, child := range newNode.Children {
nextQueue = append(nextQueue, child)
}
break newNodeLoop
}
}
}
}
}
currentQueue, nextQueue = nextQueue, currentQueue
nextQueue = nextQueue[0:0]

oldSegments = oldSegments[1:]
}

return nil
}

// insertSegments inserts the path's segments blindly in the graph without
// looking at conflicts.
func (g *pathConflictTree) insertSegments(segments []segment, slices []*segmentSlice) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick]: It is a bit confusing to name something "slices" where it is not what we call "slices" in the rest of the code. I would suggest "segmentSlices" but the "segment" term is a bit long so using it everywhere as a prefix is making things harder to read. Also a search for alternative names for "segment" did not yield anything much better. Do you have other suggestions?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will use segmentSlices. I don't really know how to name it better without resorting to the usual info or details or something general. For now I have renamed it to segmentSlice. Let me know if you can find something better or if you prefer segmentInfo.

As for segment I don't have a better name pathComponent or component but it sounds in the same way to me.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

segmentSlice is the least bad I can think of too. component is also very long so not much better.

parent := g.Root
// Skip "/".
segments = segments[1:]

for _, segment := range segments {
current, ok := parent.Children[segment.Text]
if !ok {
current = &node{
Segment: segment,
Children: map[string]*node{},
}
}
current.Slices = append(current.Slices, slices...)
parent.Children[segment.Text] = current
parent = current
}
Comment thread
letFunny marked this conversation as resolved.
}

// pathToSegments returns the list of segments that compose the path.
// Directories, i.e. paths that end with "/", contain the empty segment "" for
// explicit termination in the trie to distinguish them from parent directories
// of other paths.
func pathToSegments(path string) ([]segment, error) {
if path[0] != '/' {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if path[0] != '/' {
if path == "" || path[0] != '/' {

otherwise path[0] panics

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i guess the error message needs adjusting or there should be two checks with different error messages then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aah! damn! sorry @letFunny / @upils i did not know the bot would do this. 😬 will do a comment and ping you in the future.

Screenshot 2026-07-10 at 07 55 19

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lczyk lczyk Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep. the legitimate case i found was my own validation of this PR for which i wrote some fuzzers, and just directly called the fun. can be easily added if we ever add fuzzers here.

for reference, my fuzzers

all clean on 5m runs

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ty for looking into this

return nil, errors.New("internal error: path does not start with '/'")
}
Comment thread
letFunny marked this conversation as resolved.
Comment thread
letFunny marked this conversation as resolved.
segments := []segment{segment{"/", false, false}}
Comment thread
letFunny marked this conversation as resolved.
Outdated
path = path[1:]
for {
end, singleGlob, doubleGlob := segmentEnd(path)
segment := segment{
Text: path[:end+1],
HasGlob: singleGlob,
HasDoubleGlob: doubleGlob,
}
segments = append(segments, segment)
path = path[end+1:]
if path == "" && !strings.HasSuffix(segment.Text, "/") {
// Non-directories: last segment is also termination node.
break
}
if segment.Text == "" {
// Directories: add the termination node.
break
}
}
return segments, nil
}

// segmentEnd finds the end of a segment according to the following rules:
// - If s contains "/" then segment will finish at the first "/" found unless
// there is a "**" before that, in that case segment = s.
// - Else segment = s.
Comment thread
letFunny marked this conversation as resolved.
//
// hasGlob is set to true if "*", "?" or "**" is found in the segment.
// hasDoubleGlob is set to true if "**" is found in the segment.
Comment thread
letFunny marked this conversation as resolved.
Outdated
func segmentEnd(s string) (end int, hasGlob bool, hasDoubleGlob bool) {
Comment thread
letFunny marked this conversation as resolved.
end = strings.IndexAny(s, "*?/")
if end == -1 {
end = len(s) - 1
} else if s[end] == '*' || s[end] == '?' {
hasGlob = true
slash := strings.IndexRune(s[end:], '/')
if slash != -1 {
end = end + slash
} else {
end = len(s) - 1
}
hasDoubleGlob = strings.Contains(s[:end+1], "**")
if hasDoubleGlob {
end = len(s) - 1
}
}
return end, hasGlob, hasDoubleGlob
}
Loading
Loading