Files
LocalAI/core/gallery/models.go
Ettore Di Giacinto 4835c4373f fix(gallery): deep-copy meta overrides and make two specs functional
ResolveMetaModel detached the resolved entry's Overrides and ConfigFile with
maps.Clone, which only copies the top level. Gallery overrides are nested in
practice (parameters.model is near-universal) and the install path merges the
caller's request with mergo.WithOverride, which recurses into nested maps and
overwrites them in place, so the gallery entry's own inner maps were still
reachable and still got rewritten by the last caller to install.

Copy both maps all the way down instead, recursing through the container shapes
a YAML decoder produces. ConfigFile is not mutated on the install path today,
but it carries the same kind of nested payload and leaving it shallowly cloned
would invite the bug back.

Also fix two specs that passed whether or not their target fix was present:

- "does not write the caller's overrides back into the gallery entry" re-read
  the catalog from disk, which re-unmarshals fresh structs and so cannot
  observe in-memory aliasing. It now asserts against the in-memory gallery
  entry and drives the real mergo merge.
- "round-trips the resolution record to disk under the meta's name" asserted a
  name that is already correct in the config_file branch. It now drives the url
  branch via a file:// fixture, where the meta-name overlay actually applies.

Both were verified red by reverting their fix.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-18 10:53:27 +00:00

710 lines
24 KiB
Go

package gallery
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"dario.cat/mergo"
lconfig "github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/modelartifacts"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/pkg/utils"
"github.com/mudler/xlog"
"gopkg.in/yaml.v3"
)
/*
description: |
foo
license: ""
urls:
-
-
name: "bar"
config_file: |
# Note, name will be injected. or generated by the alias wanted by the user
threads: 14
files:
- filename: ""
sha: ""
uri: ""
prompt_templates:
- name: ""
content: ""
*/
// ModelConfig is the model configuration which contains all the model details
// This configuration is read from the gallery endpoint and is used to download and install the model
// It is the internal structure, separated from the request
type ModelConfig struct {
Description string `yaml:"description"`
Icon string `yaml:"icon"`
License string `yaml:"license"`
URLs []string `yaml:"urls"`
Name string `yaml:"name"`
ConfigFile string `yaml:"config_file"`
Files []File `yaml:"files"`
PromptTemplates []PromptTemplate `yaml:"prompt_templates"`
// The fields below record how a meta entry was resolved, so a reinstall
// or upgrade can honor the same pin and so operators can see which
// variant a stable model name is actually backed by.
MetaName string `yaml:"meta_name,omitempty"`
ResolvedVariant string `yaml:"resolved_variant,omitempty"`
PinnedVariant string `yaml:"pinned_variant,omitempty"`
}
type File struct {
Filename string `yaml:"filename" json:"filename"`
SHA256 string `yaml:"sha256" json:"sha256"`
URI string `yaml:"uri" json:"uri"`
}
type PromptTemplate struct {
Name string `yaml:"name"`
Content string `yaml:"content"`
}
// ResolveMetaModel turns a meta gallery entry into the concrete entry that
// should be installed on this host, returning it renamed to the meta's name
// and carrying the meta's presentation metadata.
//
// Why the metadata split: the payload (url, config_file, files, overrides)
// must come from the variant because that is what actually gets downloaded,
// while the presentation (name, description, icon, tags) must come from the
// meta so the installed model presents as the model rather than the variant.
func ResolveMetaModel(models []*GalleryModel, meta *GalleryModel, env ResolveEnv, pin string) (*GalleryModel, Candidate, error) {
candidate, err := ResolveCandidate(meta.Candidates, env, pin)
if err != nil {
return nil, Candidate{}, fmt.Errorf("resolving variant for model %q: %w", meta.Name, err)
}
concrete := FindGalleryElement(models, candidate.Model)
if concrete == nil {
return nil, Candidate{}, fmt.Errorf("model %q references variant %q which does not exist in any configured gallery", meta.Name, candidate.Model)
}
if concrete.IsMeta() {
return nil, Candidate{}, fmt.Errorf("model %q references variant %q which is itself a meta entry; meta entries may not nest", meta.Name, candidate.Model)
}
// A pin is an operator override and deliberately bypasses the hardware
// checks, but a silent bypass makes a later out-of-memory failure
// impossible to trace back to the pin, so it is recorded loudly here.
// It is warned about only once the pin is known to name a real, installable
// entry, otherwise a pin naming a listed-but-nonexistent variant would warn
// about VRAM and then fail for an entirely unrelated reason.
if pin != "" {
if floor, declared, verr := candidate.EffectiveMinVRAM(); verr == nil && declared && env.VRAM < floor {
xlog.Warn("Pinned model variant declares more VRAM than this system reports; installing anyway because the pin overrides hardware resolution",
"model", meta.Name, "variant", candidate.Model, "required_vram", floor, "available_vram", env.VRAM)
}
}
resolved := *concrete
resolved.Name = meta.Name
resolved.Description = meta.Description
resolved.Icon = meta.Icon
resolved.License = meta.License
resolved.Candidates = nil
// The struct copy above is shallow, so every reference-typed field still
// aliases the gallery's own entries. The install path mutates Overrides in
// place (mergo merges the caller's request overrides into it) and appends to
// the URL and tag slices, which would write the caller's request into the
// gallery catalog itself and leak between installs the moment this path
// reads from a cached, long-lived gallery listing. Detach them here.
//
// Overrides and ConfigFile are copied all the way down rather than cloned at
// the top level only: gallery overrides are nested in practice (a
// parameters.model map is near-universal), and mergo recurses into nested
// maps and overwrites them in place, so a top-level clone would still hand
// the caller the gallery's own inner maps. The slices below hold value types,
// so cloning them once fully detaches them.
resolved.Overrides = deepCopyStringMap(concrete.Overrides)
resolved.ConfigFile = deepCopyStringMap(concrete.ConfigFile)
resolved.AdditionalFiles = slices.Clone(concrete.AdditionalFiles)
resolved.URLs = slices.Clone(meta.URLs)
resolved.Tags = slices.Clone(meta.Tags)
return &resolved, candidate, nil
}
// deepCopyStringMap copies a decoded YAML map so no part of the result, at any
// depth, is reachable from the original.
func deepCopyStringMap(m map[string]any) map[string]any {
if m == nil {
return nil
}
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = deepCopyYAMLValue(v)
}
return out
}
// deepCopyYAMLValue recurses through the only container shapes a YAML decoder
// produces. Scalars are returned as-is because they cannot be mutated through
// the copy. map[any]any is handled as well because gopkg.in/yaml.v2 decodes
// non-string keys into it, and gallery documents are not guaranteed to have
// passed through the v3 decoder.
func deepCopyYAMLValue(v any) any {
switch t := v.(type) {
case map[string]any:
return deepCopyStringMap(t)
case map[any]any:
out := make(map[any]any, len(t))
for k, val := range t {
out[k] = deepCopyYAMLValue(val)
}
return out
case []any:
out := make([]any, len(t))
for i, val := range t {
out[i] = deepCopyYAMLValue(val)
}
return out
default:
return v
}
}
// Installs a model from the gallery
func InstallModelFromGallery(
ctx context.Context,
modelGalleries, backendGalleries []lconfig.Gallery,
systemState *system.SystemState,
modelLoader *model.ModelLoader,
name string, req GalleryModel, downloadStatus func(string, string, string, float64), enforceScan, automaticallyInstallBackend, requireBackendIntegrity bool, options ...InstallOption) error {
installOpts := applyInstallOptions(options...)
applyModel := func(model *GalleryModel, record *ModelConfig) error {
name = strings.ReplaceAll(name, string(os.PathSeparator), "__")
var config ModelConfig
if len(model.URL) > 0 {
var err error
config, err = GetGalleryConfigFromURLWithContext[ModelConfig](ctx, model.URL, systemState.Model.ModelsPath)
if err != nil {
return err
}
config.Description = model.Description
config.License = model.License
} else if len(model.ConfigFile) > 0 {
// TODO: is this worse than using the override method with a blank cfg yaml?
reYamlConfig, err := yaml.Marshal(model.ConfigFile)
if err != nil {
return err
}
config = ModelConfig{
ConfigFile: string(reYamlConfig),
Description: model.Description,
License: model.License,
Name: model.Name,
Files: make([]File, 0), // Real values get added below, must be blank
// URLs are deliberately not seeded here: they are appended once
// below for both branches, and seeding them too would write every
// URL twice into the persisted gallery file.
// Prompt Template Skipped for now - I expect in this mode that they will be delivered as files.
}
} else {
return fmt.Errorf("invalid gallery model %+v", model)
}
if record != nil {
config.MetaName = record.MetaName
config.ResolvedVariant = record.ResolvedVariant
config.PinnedVariant = record.PinnedVariant
// The variant's own name would otherwise be persisted here, which
// contradicts the whole point of a meta entry: the model is known by
// the meta's stable name regardless of which variant backs it.
config.Name = record.MetaName
}
installName := model.Name
if req.Name != "" {
installName = req.Name
}
// Copy the model configuration from the request schema
config.URLs = append(config.URLs, model.URLs...)
config.Icon = model.Icon
config.Files = append(config.Files, req.AdditionalFiles...)
config.Files = append(config.Files, model.AdditionalFiles...)
// TODO model.Overrides could be merged with user overrides (not defined yet)
if req.Overrides != nil {
if err := mergo.Merge(&model.Overrides, req.Overrides, mergo.WithOverride); err != nil {
return err
}
}
installedModel, err := InstallModel(ctx, systemState, installName, &config, model.Overrides, downloadStatus, enforceScan, options...)
if err != nil {
return err
}
xlog.Debug("Installed model", "model", installedModel.Name)
if automaticallyInstallBackend && installedModel.Backend != "" {
xlog.Debug("Installing backend", "backend", installedModel.Backend)
if err := InstallBackendFromGallery(ctx, backendGalleries, systemState, modelLoader, installedModel.Backend, downloadStatus, false, requireBackendIntegrity); err != nil {
return err
}
}
return nil
}
models, err := AvailableGalleryModels(modelGalleries, systemState)
if err != nil {
return err
}
model := FindGalleryElement(models, name)
if model == nil {
return fmt.Errorf("no model found with name %q", name)
}
// Meta-ness is checked before anything looks at the URL: a meta entry also
// carries a url as a fallback for older LocalAI releases that do not
// understand candidates, so carrying both is normal and meta wins here.
if !model.IsMeta() {
return applyModel(model, nil)
}
pin := installOpts.variant
// A previously recorded pin survives reinstalls and upgrades, so a user
// who deliberately chose a variant is not silently re-resolved onto a
// different one by a hardware or gallery change.
//
// The record is keyed by the name the model was installed under, not by the
// gallery entry name: applyModel writes it to ._gallery_<installName>.yaml,
// where installName is req.Name whenever the caller supplied one. Reading it
// back under the meta's own name would miss the record for every custom-named
// install and silently re-resolve a deliberately pinned model onto a
// different variant, possibly swapping its backend.
if pin == "" {
installName := model.Name
if req.Name != "" {
installName = req.Name
}
if previous, err := GetLocalModelConfiguration(systemState.Model.ModelsPath, installName); err == nil && previous != nil {
pin = previous.PinnedVariant
}
}
env := ResolveEnv{
Capability: systemState.DetectedCapability(),
VRAM: systemState.VRAM,
}
resolved, candidate, err := ResolveMetaModel(models, model, env, pin)
if err != nil {
return err
}
xlog.Info("Resolved meta model to variant",
"model", model.Name, "variant", candidate.Model,
"capability", env.Capability, "vram", env.VRAM, "pinned", pin != "")
return applyModel(resolved, &ModelConfig{
MetaName: model.Name,
ResolvedVariant: candidate.Model,
PinnedVariant: pin,
})
}
func InstallModel(ctx context.Context, systemState *system.SystemState, nameOverride string, galleryConfig *ModelConfig, configOverrides map[string]any, downloadStatus func(string, string, string, float64), enforceScan bool, options ...InstallOption) (*lconfig.ModelConfig, error) {
installOptions := applyInstallOptions(options...)
config := galleryConfig
basePath := systemState.Model.ModelsPath
// Create base path if it doesn't exist
err := os.MkdirAll(basePath, 0750)
if err != nil {
return nil, fmt.Errorf("failed to create base path: %v", err)
}
if len(configOverrides) > 0 {
xlog.Debug("Config overrides", "overrides", configOverrides)
}
name := config.Name
if nameOverride != "" {
name = nameOverride
}
if err := utils.VerifyPath(name+".yaml", basePath); err != nil {
return nil, err
}
modelConfig := lconfig.ModelConfig{}
configFilePath := filepath.Join(basePath, name+".yaml")
var updatedConfigYAML []byte
writeConfig := len(configOverrides) != 0 || len(config.ConfigFile) != 0
if writeConfig {
// Read and update config file as map[string]interface{}
configMap := make(map[string]any)
err = yaml.Unmarshal([]byte(config.ConfigFile), &configMap)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal config YAML: %v", err)
}
configMap["name"] = name
if configOverrides != nil {
if err := mergo.Merge(&configMap, configOverrides, mergo.WithOverride); err != nil {
return nil, err
}
}
// Marshal the merged map for typed validation and default application.
updatedConfigYAML, err = yaml.Marshal(configMap)
if err != nil {
return nil, fmt.Errorf("failed to marshal updated config YAML: %v", err)
}
err = yaml.Unmarshal(updatedConfigYAML, &modelConfig)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal updated config YAML: %v", err)
}
// Apply model-family-specific inference defaults so they are persisted in the config YAML.
// Apply to the typed struct for validation, and merge into configMap for serialization
// (configMap preserves unknown fields that ModelConfig would drop).
lconfig.ApplyInferenceDefaults(&modelConfig, name, modelConfig.Model)
// Merge inference defaults into configMap so they are persisted without losing unknown fields.
if modelConfig.Temperature != nil {
if _, exists := configMap["temperature"]; !exists {
configMap["temperature"] = *modelConfig.Temperature
}
}
if modelConfig.TopP != nil {
if _, exists := configMap["top_p"]; !exists {
configMap["top_p"] = *modelConfig.TopP
}
}
if modelConfig.TopK != nil {
if _, exists := configMap["top_k"]; !exists {
configMap["top_k"] = *modelConfig.TopK
}
}
if modelConfig.MinP != nil {
if _, exists := configMap["min_p"]; !exists {
configMap["min_p"] = *modelConfig.MinP
}
}
if modelConfig.RepeatPenalty != 0 {
if _, exists := configMap["repeat_penalty"]; !exists {
configMap["repeat_penalty"] = modelConfig.RepeatPenalty
}
}
if modelConfig.PresencePenalty != 0 {
if _, exists := configMap["presence_penalty"]; !exists {
configMap["presence_penalty"] = modelConfig.PresencePenalty
}
}
if valid, err := modelConfig.Validate(); !valid {
return nil, fmt.Errorf("failed to validate updated config YAML: %v", err)
}
if len(config.Files) == 0 {
artifactSpec, inferred, hasArtifact, err := modelConfig.PrimaryArtifactSpec(basePath)
if err != nil {
return nil, err
}
if hasArtifact && enforceScan {
artifact, err := artifactSpec.Normalize()
if err != nil {
return nil, err
}
probe := downloader.URI(fmt.Sprintf("%s/%s/resolve/%s/.gitattributes", strings.TrimRight(downloader.HF_ENDPOINT, "/"), artifact.Source.Repo, url.PathEscape(artifact.Source.Revision)))
if _, err := downloader.HuggingFaceScan(probe); errors.Is(err, downloader.ErrUnsafeFilesFound) {
return nil, err
}
}
if hasArtifact {
applied, err := bindPrimaryArtifact(ctx, basePath, &modelConfig, configMap, installOptions.materializer, artifactSpec, inferred)
if err != nil {
return nil, err
}
if applied {
// Re-marshal from configMap after artifact binding to preserve unknown fields.
updatedConfigYAML, err = yaml.Marshal(configMap)
if err != nil {
return nil, fmt.Errorf("failed to marshal config with inference defaults: %v", err)
}
}
}
}
}
// Download files and verify their SHA
tasks := make([]downloader.FileTask, 0, len(config.Files))
for i, file := range config.Files {
// Check for cancellation before each file
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
xlog.Debug("Checking file exists and matches SHA", "filename", file.Filename)
if err := utils.VerifyPath(file.Filename, basePath); err != nil {
return nil, err
}
// Create file path
filePath := filepath.Join(basePath, file.Filename)
if enforceScan {
scanResults, err := downloader.HuggingFaceScan(downloader.URI(file.URI))
if err != nil && errors.Is(err, downloader.ErrUnsafeFilesFound) {
xlog.Error("Contains unsafe file(s)!", "model", config.Name, "clamAV", scanResults.ClamAVInfectedFiles, "pickles", scanResults.DangerousPickles)
return nil, err
}
}
tasks = append(tasks, downloader.FileTask{
URI: downloader.URI(file.URI),
Destination: filePath,
SHA256: file.SHA256,
FileIndex: i,
TotalFiles: len(config.Files),
})
}
if err := downloader.DownloadFilesWithContext(ctx, tasks, downloadStatus); err != nil {
return nil, err
}
// Write prompt template contents to separate files
for _, template := range config.PromptTemplates {
if err := utils.VerifyPath(template.Name+".tmpl", basePath); err != nil {
return nil, err
}
// Create file path
filePath := filepath.Join(basePath, template.Name+".tmpl")
// Create parent directory
err := os.MkdirAll(filepath.Dir(filePath), 0750)
if err != nil {
return nil, fmt.Errorf("failed to create parent directory for prompt template %q: %v", template.Name, err)
}
// Create and write file content
err = os.WriteFile(filePath, []byte(template.Content), 0644)
if err != nil {
return nil, fmt.Errorf("failed to write prompt template %q: %v", template.Name, err)
}
xlog.Debug("Prompt template written", "template", template.Name)
}
if writeConfig {
if len(modelConfig.Artifacts) > 0 {
modelartifacts.ReportProgress(ctx, modelartifacts.ProgressEvent{
Phase: modelartifacts.PhasePersisting, Artifact: modelConfig.Artifacts[0].Name,
})
}
if err := writeModelConfigAtomic(configFilePath, updatedConfigYAML); err != nil {
return nil, fmt.Errorf("failed to atomically write updated config file: %w", err)
}
xlog.Debug("Written config file", "file", configFilePath)
}
// Save the model gallery file for further reference
modelFile := filepath.Join(basePath, galleryFileName(name))
data, err := yaml.Marshal(config)
if err != nil {
return nil, err
}
xlog.Debug("Written gallery file", "file", modelFile)
return &modelConfig, os.WriteFile(modelFile, data, 0644)
}
func galleryFileName(name string) string {
return "._gallery_" + name + ".yaml"
}
// GalleryFileName returns the on-disk filename of the gallery metadata file
// for a given installed model name (e.g. "._gallery_<name>.yaml").
func GalleryFileName(name string) string {
return galleryFileName(name)
}
func GetLocalModelConfiguration(basePath string, name string) (*ModelConfig, error) {
name = strings.ReplaceAll(name, string(os.PathSeparator), "__")
galleryFile := filepath.Join(basePath, galleryFileName(name))
return ReadConfigFile[ModelConfig](galleryFile)
}
func listModelFiles(systemState *system.SystemState, name string) ([]string, error) {
configFile := filepath.Join(systemState.Model.ModelsPath, fmt.Sprintf("%s.yaml", name))
if err := utils.VerifyPath(configFile, systemState.Model.ModelsPath); err != nil {
return nil, fmt.Errorf("failed to verify path %s: %w", configFile, err)
}
// os.PathSeparator is not allowed in model names. Replace them with "__" to avoid conflicts with file paths.
name = strings.ReplaceAll(name, string(os.PathSeparator), "__")
galleryFile := filepath.Join(systemState.Model.ModelsPath, galleryFileName(name))
if err := utils.VerifyPath(galleryFile, systemState.Model.ModelsPath); err != nil {
return nil, fmt.Errorf("failed to verify path %s: %w", galleryFile, err)
}
additionalFiles := []string{}
allFiles := []string{}
// Galleryname is the name of the model in this case
dat, err := os.ReadFile(configFile)
if err == nil {
modelConfig := &lconfig.ModelConfig{}
err = yaml.Unmarshal(dat, &modelConfig)
if err != nil {
return nil, err
}
if modelConfig.Model != "" && len(modelConfig.Artifacts) == 0 {
additionalFiles = append(additionalFiles, modelConfig.ModelFileName())
}
if modelConfig.MMProj != "" {
additionalFiles = append(additionalFiles, modelConfig.MMProjFileName())
}
}
// read the model config
galleryconfig, err := ReadConfigFile[ModelConfig](galleryFile)
if err == nil && galleryconfig != nil {
for _, f := range galleryconfig.Files {
fullPath := filepath.Join(systemState.Model.ModelsPath, f.Filename)
if err := utils.VerifyPath(fullPath, systemState.Model.ModelsPath); err != nil {
return allFiles, fmt.Errorf("failed to verify path %s: %w", fullPath, err)
}
allFiles = append(allFiles, fullPath)
}
} else {
xlog.Error("failed to read gallery file", "error", err, "file", configFile)
}
for _, f := range additionalFiles {
fullPath := filepath.Join(filepath.Join(systemState.Model.ModelsPath, f))
if err := utils.VerifyPath(fullPath, systemState.Model.ModelsPath); err != nil {
return allFiles, fmt.Errorf("failed to verify path %s: %w", fullPath, err)
}
allFiles = append(allFiles, fullPath)
}
allFiles = append(allFiles, galleryFile)
// skip duplicates
allFiles = utils.Unique(allFiles)
return allFiles, nil
}
func DeleteModelFromSystem(systemState *system.SystemState, name string) error {
configFile := filepath.Join(systemState.Model.ModelsPath, fmt.Sprintf("%s.yaml", name))
filesToRemove, err := listModelFiles(systemState, name)
if err != nil {
return err
}
allOtherFiles := []string{}
// Get all files of all other models
fi, err := os.ReadDir(systemState.Model.ModelsPath)
if err != nil {
return err
}
for _, f := range fi {
if f.IsDir() {
continue
}
if strings.HasPrefix(f.Name(), "._gallery_") {
continue
}
if !strings.HasSuffix(f.Name(), ".yaml") && !strings.HasSuffix(f.Name(), ".yml") {
continue
}
if f.Name() == fmt.Sprintf("%s.yaml", name) || f.Name() == fmt.Sprintf("%s.yml", name) {
continue
}
name := strings.TrimSuffix(f.Name(), ".yaml")
name = strings.TrimSuffix(name, ".yml")
xlog.Debug("Checking file", "file", f.Name())
files, err := listModelFiles(systemState, name)
if err != nil {
xlog.Debug("failed to list files for model", "error", err, "model", f.Name())
continue
}
allOtherFiles = append(allOtherFiles, files...)
}
xlog.Debug("Files to remove", "files", filesToRemove)
xlog.Debug("All other files", "files", allOtherFiles)
// Removing files
for _, f := range filesToRemove {
if slices.Contains(allOtherFiles, f) {
xlog.Debug("Skipping file because it is part of another model", "file", f)
continue
}
if e := os.Remove(f); e != nil {
xlog.Error("failed to remove file", "error", e, "file", f)
}
}
return os.Remove(configFile)
}
// This is ***NEVER*** going to be perfect or finished.
// This is a BEST EFFORT function to surface known-vulnerable models to users.
func SafetyScanGalleryModels(galleries []lconfig.Gallery, systemState *system.SystemState) error {
galleryModels, err := AvailableGalleryModels(galleries, systemState)
if err != nil {
return err
}
for _, gM := range galleryModels {
if gM.Installed {
err = errors.Join(err, SafetyScanGalleryModel(gM))
}
}
return err
}
func SafetyScanGalleryModel(galleryModel *GalleryModel) error {
for _, file := range galleryModel.AdditionalFiles {
scanResults, err := downloader.HuggingFaceScan(downloader.URI(file.URI))
if err != nil && errors.Is(err, downloader.ErrUnsafeFilesFound) {
xlog.Error("Contains unsafe file(s)!", "model", galleryModel.Name, "clamAV", scanResults.ClamAVInfectedFiles, "pickles", scanResults.DangerousPickles)
return err
}
}
return nil
}