Skip to content

Commit 03181c2

Browse files
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite] Iteration 20: Port bundle/ + output/ (Milestone 15)
Run: https://github.com/githubnext/apm/actions/runs/26527535633 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 56de173 commit 03181c2

4 files changed

Lines changed: 790 additions & 0 deletions

File tree

internal/bundle/bundle.go

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// Package bundle provides data types and helpers for APM bundle pack/unpack
2+
// operations. It mirrors the Python apm_cli.bundle module.
3+
package bundle
4+
5+
// PackResult describes the result of a pack operation.
6+
type PackResult struct {
7+
// BundlePath is the filesystem path to the produced bundle.
8+
BundlePath string
9+
// Files is the list of relative file paths included in the bundle.
10+
Files []string
11+
// LockfileEnriched reports whether the lockfile was enriched during packing.
12+
LockfileEnriched bool
13+
// MappedCount is the number of path-mapping entries applied.
14+
MappedCount int
15+
// PathMappings holds source->dest path remappings applied during packing.
16+
PathMappings map[string]string
17+
}
18+
19+
// UnpackResult describes the result of an unpack operation.
20+
type UnpackResult struct {
21+
// ExtractedDir is the directory where bundle contents were placed.
22+
ExtractedDir string
23+
// Files is the deduplicated list of relative file paths extracted.
24+
Files []string
25+
// Verified reports whether completeness verification was performed and passed.
26+
Verified bool
27+
// DependencyFiles maps dependency keys to their lists of deployed files.
28+
DependencyFiles map[string][]string
29+
// SkippedCount is the number of files skipped (symlinks, missing, etc.).
30+
SkippedCount int
31+
// SecurityWarnings is the count of files with non-critical hidden characters.
32+
SecurityWarnings int
33+
// SecurityCritical is the count of files with critical hidden characters.
34+
SecurityCritical int
35+
// PackMeta holds arbitrary pack-section metadata from the embedded lockfile.
36+
PackMeta map[string]interface{}
37+
}
38+
39+
// LocalBundleInfo is a frozen descriptor for a detected local bundle.
40+
type LocalBundleInfo struct {
41+
// SourceDir is the filesystem path to the bundle root.
42+
SourceDir string
43+
// PluginJSON is the parsed plugin.json content (empty map when absent).
44+
PluginJSON map[string]interface{}
45+
// PackageID is derived from plugin.json["id"], falling back to the bundle directory name.
46+
PackageID string
47+
// Lockfile is the parsed apm.lock.yaml content, or nil for older bundles.
48+
Lockfile map[string]interface{}
49+
// PackTargets lists the targets the bundle was packed for.
50+
PackTargets []string
51+
// IsArchive is true when the source path was a .tar.gz.
52+
IsArchive bool
53+
// TempDir is the extraction directory for tarballs (caller must clean up).
54+
// Empty string when not applicable.
55+
TempDir string
56+
}
57+
58+
// ExtractPackTargets returns the list of pack targets from a parsed bundle lockfile.
59+
// Returns an empty slice when the lockfile is nil or carries no target.
60+
func ExtractPackTargets(lockfile map[string]interface{}) []string {
61+
if lockfile == nil {
62+
return []string{}
63+
}
64+
pack, _ := lockfile["pack"].(map[string]interface{})
65+
if pack == nil {
66+
return []string{}
67+
}
68+
raw := pack["target"]
69+
if raw == nil {
70+
return []string{}
71+
}
72+
switch v := raw.(type) {
73+
case string:
74+
if v == "" {
75+
return []string{}
76+
}
77+
return []string{v}
78+
case []interface{}:
79+
targets := make([]string, 0, len(v))
80+
for _, item := range v {
81+
if s, ok := item.(string); ok && s != "" {
82+
targets = append(targets, s)
83+
}
84+
}
85+
return targets
86+
}
87+
return []string{}
88+
}
89+
90+
// CheckTargetMismatch returns a warning string when the bundle targets are not
91+
// covered by the install targets. Returns an empty string when:
92+
// - bundleTargets is empty (pre-constraint bundle, no metadata), OR
93+
// - bundleTargets contains "all" (target-agnostic bundle), OR
94+
// - installTargets is a superset of bundleTargets.
95+
func CheckTargetMismatch(bundleTargets, installTargets []string) string {
96+
if len(bundleTargets) == 0 {
97+
return ""
98+
}
99+
bundleSet := make(map[string]bool, len(bundleTargets))
100+
for _, t := range bundleTargets {
101+
if t != "" {
102+
bundleSet[t] = true
103+
}
104+
}
105+
if bundleSet["all"] {
106+
return ""
107+
}
108+
installSet := make(map[string]bool, len(installTargets))
109+
for _, t := range installTargets {
110+
if t != "" {
111+
installSet[t] = true
112+
}
113+
}
114+
var missing []string
115+
for t := range bundleSet {
116+
if !installSet[t] {
117+
missing = append(missing, t)
118+
}
119+
}
120+
if len(missing) == 0 {
121+
return ""
122+
}
123+
// Sort for determinism
124+
sortStrings(missing)
125+
packed := sortedKeys(bundleSet)
126+
active := sortedKeys(installSet)
127+
activeStr := joinStrings(active)
128+
if activeStr == "" {
129+
activeStr = "<none>"
130+
}
131+
return "Bundle was packed for targets [" + joinStrings(packed) + "] but install resolved to [" +
132+
activeStr + "]. The following packed targets will not receive files: " +
133+
joinStrings(missing)
134+
}
135+
136+
// IsSafeRelPath returns true when rel is safe to write inside an output directory.
137+
// It rejects absolute paths and paths containing ".." components.
138+
func IsSafeRelPath(rel string) bool {
139+
if rel == "" {
140+
return false
141+
}
142+
if rel[0] == '/' || rel[0] == '\\' {
143+
return false
144+
}
145+
// Check for Windows absolute (e.g. C:\)
146+
if len(rel) >= 2 && rel[1] == ':' {
147+
return false
148+
}
149+
// Walk path components
150+
parts := splitPathParts(rel)
151+
for _, p := range parts {
152+
if p == ".." {
153+
return false
154+
}
155+
}
156+
return true
157+
}
158+
159+
// --- string/path helpers (no external deps) ---
160+
161+
func sortStrings(s []string) {
162+
for i := 1; i < len(s); i++ {
163+
for j := i; j > 0 && s[j] < s[j-1]; j-- {
164+
s[j], s[j-1] = s[j-1], s[j]
165+
}
166+
}
167+
}
168+
169+
func sortedKeys(m map[string]bool) []string {
170+
keys := make([]string, 0, len(m))
171+
for k := range m {
172+
keys = append(keys, k)
173+
}
174+
sortStrings(keys)
175+
return keys
176+
}
177+
178+
func joinStrings(ss []string) string {
179+
out := ""
180+
for i, s := range ss {
181+
if i > 0 {
182+
out += ", "
183+
}
184+
out += s
185+
}
186+
return out
187+
}
188+
189+
func splitPathParts(p string) []string {
190+
var parts []string
191+
cur := ""
192+
for _, c := range p {
193+
if c == '/' || c == '\\' {
194+
if cur != "" {
195+
parts = append(parts, cur)
196+
cur = ""
197+
}
198+
} else {
199+
cur += string(c)
200+
}
201+
}
202+
if cur != "" {
203+
parts = append(parts, cur)
204+
}
205+
return parts
206+
}

0 commit comments

Comments
 (0)