Rework Build Site Agent binary UI with version auto-detect and rollback

Restructure the agent and tray binary blocks around an auto-detected current-release upload plus a separate legacy/rollback upload that stages a version without seizing current. Order staged versions by version number for the active-version dropdown, add a PE version detect route, and drop stale dev-status tags and manual-staging copy.
This commit is contained in:
benjamin.baker
2026-09-02 20:27:55 -04:00
parent 7a11851fbe
commit 729e488903
5 changed files with 541 additions and 35 deletions

View File

@@ -33,6 +33,7 @@ import io
import json
import os
import re
import struct
from datetime import datetime
from flask import current_app
@@ -75,6 +76,155 @@ def looks_like_pe(data):
return len(data) >= 2 and data[:2] == _PE_MAGIC
# RT_VERSION resource type id (the version resource Windows shows in file
# properties). We must read the version from THIS resource on the outer
# executable - not the first VS_FIXEDFILEINFO signature we can find in the bytes.
# A self-contained single-file .NET exe bundles dozens of framework assemblies,
# each carrying its own version block, so a naive byte scan returns a framework
# version (for example the .NET runtime's) instead of the app's. The only
# reliable value is the RT_VERSION resource reached by walking the PE resource
# directory, which belongs to the outer image alone.
_RT_VERSION = 16
# 0xFEEF04BD little-endian: the dwSignature that opens VS_FIXEDFILEINFO inside
# the RT_VERSION resource data.
_VS_FIXEDFILEINFO_SIGNATURE = b'\xbd\x04\xef\xfe'
def _rva_to_offset(data, rva, sections):
"""Map a resource RVA to a file offset using the section table, or None."""
for virtual_address, virtual_size, raw_offset, raw_size in sections:
if virtual_address <= rva < virtual_address + max(virtual_size, raw_size):
delta = rva - virtual_address
if delta < raw_size:
return raw_offset + delta
return None
def _first_resource_entry_offset(data, table_offset, resource_base, sections,
want_id):
"""Walk one IMAGE_RESOURCE_DIRECTORY level and return a child offset.
When want_id is not None, follow the entry whose id matches (used to select
RT_VERSION at the type level); otherwise follow the first entry (used to
descend name/language levels). Returns the absolute file offset of the child
directory or data entry, with the high bit stripped, or None.
"""
# IMAGE_RESOURCE_DIRECTORY: 12 fixed bytes, then NumberOfNamedEntries (2)
# and NumberOfIdEntries (2), then the entries.
named = struct.unpack_from('<H', data, table_offset + 12)[0]
id_count = struct.unpack_from('<H', data, table_offset + 14)[0]
entries_offset = table_offset + 16
for index in range(named + id_count):
entry_offset = entries_offset + index * 8
name_or_id, offset_to_data = struct.unpack_from('<II', data, entry_offset)
# Named entries carry the high bit in name_or_id; we only match numeric
# ids, so skip named entries when selecting by id.
is_named = bool(name_or_id & 0x80000000)
if want_id is not None:
if is_named or name_or_id != want_id:
continue
# Directory children have the high bit set on offset_to_data.
child = (offset_to_data & 0x7FFFFFFF) + resource_base
return child
return None
def read_pe_file_version(data):
"""Dotted four-part file version from the PE's RT_VERSION resource, or None.
Walks the PE resource directory to the RT_VERSION resource of the outer
executable and decodes its VS_FIXEDFILEINFO file version. Returns None when
the file has no version resource or the structures are malformed. Never
raises - callers treat None as "unknown, ask the operator".
"""
try:
if len(data) < 0x40 or data[:2] != _PE_MAGIC:
return None
pe_offset = struct.unpack_from('<I', data, 0x3C)[0]
if data[pe_offset:pe_offset + 4] != b'PE\x00\x00':
return None
coff = pe_offset + 4
number_of_sections = struct.unpack_from('<H', data, coff + 2)[0]
size_of_optional = struct.unpack_from('<H', data, coff + 16)[0]
optional = coff + 20
# Magic distinguishes PE32 (0x10b) from PE32+ (0x20b); the data-directory
# array sits at a different offset in each.
magic = struct.unpack_from('<H', data, optional)[0]
if magic == 0x20B:
data_dir = optional + 112
else:
data_dir = optional + 96
# Resource table is data directory index 2: its RVA then size.
resource_rva = struct.unpack_from('<I', data, data_dir + 2 * 8)[0]
if not resource_rva:
return None
section_table = optional + size_of_optional
sections = []
for index in range(number_of_sections):
base = section_table + index * 40
virtual_size = struct.unpack_from('<I', data, base + 8)[0]
virtual_address = struct.unpack_from('<I', data, base + 12)[0]
raw_size = struct.unpack_from('<I', data, base + 16)[0]
raw_offset = struct.unpack_from('<I', data, base + 20)[0]
sections.append((virtual_address, virtual_size, raw_offset, raw_size))
resource_base = _rva_to_offset(data, resource_rva, sections)
if resource_base is None:
return None
# Level 1: type -> pick RT_VERSION. Level 2: name/id -> first.
# Level 3: language -> first. Then a data entry pointing at the resource.
type_dir = _first_resource_entry_offset(
data, resource_base, resource_base, sections, _RT_VERSION)
if type_dir is None:
return None
name_dir = _first_resource_entry_offset(
data, type_dir, resource_base, sections, None)
if name_dir is None:
return None
lang_entry = _first_resource_entry_offset(
data, name_dir, resource_base, sections, None)
if lang_entry is None:
return None
# lang_entry is an IMAGE_RESOURCE_DATA_ENTRY: RVA (4), size (4), ...
version_rva = struct.unpack_from('<I', data, lang_entry)[0]
version_offset = _rva_to_offset(data, version_rva, sections)
if version_offset is None:
return None
# Within the version resource, find VS_FIXEDFILEINFO by its signature.
signature = data.find(_VS_FIXEDFILEINFO_SIGNATURE, version_offset,
version_offset + 512)
if signature == -1:
return None
base = signature + 4 + 4 # skip signature and dwStrucVersion
version_ms, version_ls = struct.unpack_from('<II', data, base)
parts = (
(version_ms >> 16) & 0xFFFF,
version_ms & 0xFFFF,
(version_ls >> 16) & 0xFFFF,
version_ls & 0xFFFF,
)
if not any(parts):
return None
return '.'.join(str(part) for part in parts)
except (struct.error, TypeError, IndexError):
return None
def read_pe_file_version_from_path(path):
"""Read a PE file's version from disk, or None on any failure."""
try:
with open(path, 'rb') as handle:
return read_pe_file_version(handle.read())
except OSError:
return None
def _siteagent_dir():
return os.path.join(current_app.instance_path, 'siteagent')
@@ -117,12 +267,32 @@ def _write_manifest(binkey, manifest):
handle.write('\n')
def _version_sort_key(version):
"""Parse a dotted-numeric version into a tuple of ints for ordering.
Pads to four parts so 1.6 and 1.6.0.0 compare equal-ish and shorter
versions do not sort above longer ones with the same prefix. A part that
is not numeric (should not happen after validation) sorts as 0.
"""
parts = []
for chunk in str(version).split('.'):
try:
parts.append(int(chunk))
except ValueError:
parts.append(0)
while len(parts) < 4:
parts.append(0)
return tuple(parts[:4])
def list_versions(binkey):
"""Staged versions for a binary, newest upload first.
"""Staged versions for a binary, highest version number first.
Each item is {version, uploadedat, size, iscurrent}. Reads the manifest as
the source of truth for order and currency; a version whose exe is missing
on disk is dropped so the caller never offers a broken push.
the source of truth for currency; a version whose exe is missing on disk is
dropped so the caller never offers a broken push. Ordered by version number
(descending) so a rollback dropdown reads newest-build-first regardless of
the order versions were uploaded.
"""
manifest = _read_manifest(binkey)
current = manifest.get('current')
@@ -137,6 +307,7 @@ def list_versions(binkey):
'size': entry.get('size'),
'iscurrent': version == current,
})
out.sort(key=lambda item: _version_sort_key(item['version']), reverse=True)
return out
@@ -164,6 +335,51 @@ def current_path(binkey):
return None
def legacy_status(binkey):
"""Describe an unmanaged legacy flat file, or None when there is none.
Returned only when a flat file exists and no managed version is current, so
the UI can show its detected version and offer one-click adoption without
the operator re-uploading. {version} is the PE file version if readable,
else None.
"""
if current_version(binkey):
return None
legacy = _legacy_path(binkey)
if not os.path.isfile(legacy):
return None
try:
size = os.path.getsize(legacy)
except OSError:
size = None
return {
'version': read_pe_file_version_from_path(legacy),
'size': size,
}
def adopt_legacy(binkey, version):
"""Copy the legacy flat file into the version store as a staged version.
Reads the flat file, validates it as a PE, and stores it under the given
version (making it current). Returns the fresh manifest view, or None when
there is no legacy file or it is not a PE. Caller supplies the version -
typically the detected PE version, or an operator override when the binary
carries no version resource.
"""
legacy = _legacy_path(binkey)
if not os.path.isfile(legacy):
return None
try:
with open(legacy, 'rb') as handle:
data = handle.read()
except OSError:
return None
if not looks_like_pe(data):
return None
return store_version(binkey, version, data)
def _prune(binkey, manifest):
"""Drop versions past the retention window, never removing the current one."""
current = manifest.get('current')
@@ -197,14 +413,16 @@ def _remove_version_dir(binkey, version):
pass
def store_version(binkey, version, data):
"""Stage an uploaded binary as a new version and make it current.
def store_version(binkey, version, data, make_current=True):
"""Stage an uploaded binary as a new version.
Writes data to siteagent/<binkey>/<version>/<exe>, records it newest-first in
the manifest (de-duping a re-upload of the same version in place), marks it
current, and prunes older versions past RETAIN. Returns the fresh manifest
view {current, versions:[...]}. Caller must have validated binkey, version,
and that data is a PE binary.
the manifest (de-duping a re-upload of the same version in place), and prunes
older versions past RETAIN. When make_current is True the new version also
becomes the current staged build; pass False to stage a rollback/legacy build
that should appear in the version list without displacing the active current.
Returns the fresh manifest view {current, versions:[...]}. Caller must have
validated binkey, version, and that data is a PE binary.
"""
os.makedirs(os.path.join(_binary_dir(binkey), version), exist_ok=True)
with open(_version_exe_path(binkey, version), 'wb') as handle:
@@ -219,7 +437,8 @@ def store_version(binkey, version, data):
'size': len(data),
})
manifest['versions'] = versions
manifest['current'] = version
if make_current or not manifest.get('current'):
manifest['current'] = version
_prune(binkey, manifest)
_write_manifest(binkey, manifest)
return {

View File

@@ -51,15 +51,19 @@ def list_agent_binaries(binkey):
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['POST'])
@require_permission('endpoints.agentpackage')
def upload_agent_binary(binkey):
"""Stage an uploaded binary as a new version and make it current.
"""Stage an uploaded binary as a new version.
Expects multipart/form-data with a 'file' upload and a 'version' field. The
version must be a strict dotted-numeric string (for example 1.6.3) so it is
safe as a directory name and orderable. The upload must be a Windows PE
executable. Older versions past the retention window are pruned, but never
the current one.
the current one. By default the upload becomes the current staged build; send
makecurrent=false to stage a rollback/legacy build that joins the version
list without displacing the active current.
"""
version = (request.form.get('version') or '').strip()
makecurrent = (request.form.get('makecurrent') or 'true').strip().lower()
makecurrent = makecurrent not in ('false', '0', 'no')
if not agentstore.is_valid_binary_key(binkey):
return error_response(
ErrorCodes.VALIDATION_ERROR,
@@ -90,7 +94,94 @@ def upload_agent_binary(binkey):
'uploaded file is not a Windows executable',
http_code=400)
result = agentstore.store_version(binkey, version, data)
result = agentstore.store_version(binkey, version, data,
make_current=makecurrent)
return success_response({
'binary': binkey,
'current': result['current'],
'versions': result['versions'],
})
@endpoints_bp.route('/siteagent/binaries/<binkey>/detect', methods=['POST'])
@require_permission('endpoints.agentpackage')
def detect_agent_binary_version(binkey):
"""Read the PE file version of an uploaded EXE without staging it.
The Build Site Agent page calls this on file-select so the version field is
prefilled from the binary's own version resource - the operator does not
have to read the EXE properties by hand for a current-release upload.
Returns {version} which is null when the file carries no readable version.
"""
if not agentstore.is_valid_binary_key(binkey):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'binkey must be one of: agent, tray',
http_code=400)
upload = request.files.get('file')
if upload is None or not upload.filename:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'a file upload is required',
http_code=400)
data = upload.read(_MAX_UPLOAD_BYTES + 1)
if len(data) > _MAX_UPLOAD_BYTES:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'uploaded binary exceeds the size limit',
http_code=400)
if not agentstore.looks_like_pe(data):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'uploaded file is not a Windows executable',
http_code=400)
return success_response({
'binary': binkey,
'version': agentstore.read_pe_file_version(data),
})
@endpoints_bp.route('/siteagent/binaries/<binkey>/adopt', methods=['POST'])
@require_permission('endpoints.agentpackage')
def adopt_legacy_agent_binary(binkey):
"""Adopt the unmanaged legacy flat file into the version store.
The pre-versioning install dropped a single shopdbagent.exe (or tray exe)
straight under siteagent/. This copies that file in as a real staged version
so it shows a version and joins retention, without the operator having to
re-upload it. The version comes from the request when the binary carries no
readable PE version resource; otherwise the detected version is used.
"""
if not agentstore.is_valid_binary_key(binkey):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'binkey must be one of: agent, tray',
http_code=400)
status = agentstore.legacy_status(binkey)
if status is None:
return error_response(
ErrorCodes.NOT_FOUND,
'there is no unmanaged legacy binary to adopt',
http_code=404)
source = request.get_json(silent=True) or {}
version = (source.get('version') or status.get('version') or '').strip()
if not agentstore.is_valid_version(version):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'version must be a dotted-numeric string such as 1.6.3',
http_code=400)
result = agentstore.adopt_legacy(binkey, version)
if result is None:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'the legacy binary could not be adopted',
http_code=400)
return success_response({
'binary': binkey,
'current': result['current'],

View File

@@ -462,6 +462,8 @@ def _siteagent_status_payload():
'traycurrent': agentstore.current_version('tray'),
'agentversions': agentstore.list_versions('agent'),
'trayversions': agentstore.list_versions('tray'),
'agentlegacy': agentstore.legacy_status('agent'),
'traylegacy': agentstore.legacy_status('tray'),
}

View File

@@ -160,7 +160,7 @@
</section>
<!-- ====================================================================
Adoption tool (spike)
Adoption tool
================================================================= -->
<section class="settings-section">
<h3>Adoption tool</h3>
@@ -211,7 +211,7 @@
</section>
<!-- ====================================================================
Build site agent (placeholder)
Build site agent
================================================================= -->
<section class="settings-section">
<h3>Build site agent</h3>
@@ -227,10 +227,9 @@
</p>
<p v-if="!agentstaged" class="field-hint">
<strong>No agent binary is staged on this server.</strong> The package
will still build with config and installer, but you must drop
<code>shopdbagent.exe</code> beside them before deploying. Stage a
prebuilt agent under <code>instance/siteagent/</code> to have it ship
in the download.
will still build with config and installer, but no agent will ship
inside it. Upload a current release in the binary section below to
have it included in every download.
</p>
<label class="field-label" for="agent-baseurl">Server base URL</label>
@@ -317,43 +316,112 @@
class="binary-block"
>
<h4>{{ binary.label }}</h4>
<p class="field-hint">
Upload {{ binary.name }} updated EXE. Uploading here will update the
binaries available for your "Build Site Agent" install package.
</p>
<p class="field-hint">
Current staged version:
<strong>{{ binary.current || 'none (legacy or unstaged)' }}</strong>
<strong>{{ binary.current || 'none staged yet - upload a build below' }}</strong>
</p>
<!-- Current release upload. The version is auto-detected from the
EXE's PE version resource on select; the operator can override.
This upload becomes the current staged build. -->
<label class="field-label">
Upload {{ binary.name }} binary current release
</label>
<div class="binary-upload">
<input
v-model="binary.version"
class="form-control"
type="text"
placeholder="Version, e.g. 1.6.3"
/>
<input
class="form-control"
type="file"
accept=".exe"
@change="onBinaryFileChange(binary.key, $event)"
/>
<input
v-model="binary.version"
class="form-control"
type="text"
:placeholder="binary.detecting
? 'Reading version...'
: 'Version, auto-detected from EXE'"
/>
<button
class="btn btn-secondary"
type="button"
:disabled="binary.busy || !binary.version || !binary.file"
@click="uploadBinary(binary.key)"
>
{{ binary.busy ? 'Uploading...' : 'Upload' }}
{{ binary.busy ? 'Uploading...' : 'Upload current release' }}
</button>
</div>
<!-- Rollback / legacy path. Stages a build into the version list
without displacing the current release so it is available to
roll back to. WARNING copy per intent: the version must be
confirmed from the EXE properties before uploading. -->
<label class="field-label">
Upload legacy build for rollback, please confirm agent version
</label>
<p class="field-warning">
WARNING: confirm agent version before uploading from the EXE
properties and enter the version number.
</p>
<div class="binary-upload">
<input
class="form-control"
type="file"
accept=".exe"
@change="onLegacyFileChange(binary.key, $event)"
/>
<input
v-model="binary.legacyversion"
class="form-control"
type="text"
placeholder="Version, e.g. 1.5.0"
/>
<button
class="btn btn-secondary"
type="button"
:disabled="binary.busy || !binary.legacyversion || !binary.legacyfile"
@click="uploadLegacy(binary.key)"
>
{{ binary.busy ? 'Uploading...' : 'Upload legacy build' }}
</button>
</div>
<p v-if="binary.message" class="field-hint">{{ binary.message }}</p>
<p v-if="binary.error" class="field-error">{{ binary.error }}</p>
<!-- Version selector: pick the staged build shipped by the next
download. Ordered by version number (highest first). -->
<div v-if="binary.versions.length" class="binary-upload">
<label class="field-label" :for="`select-${binary.key}`">
Active version
</label>
<select
:id="`select-${binary.key}`"
class="form-control"
:value="binary.current || ''"
:disabled="binary.busy"
@change="setCurrentBinary(binary.key, $event.target.value)"
>
<option
v-for="entry in binary.versions"
:key="entry.version"
:value="entry.version"
>
{{ entry.version }}{{ entry.iscurrent ? ' (current)' : '' }}
</option>
</select>
</div>
<div v-if="binary.versions.length" class="previous-versions">
<button
class="btn-link"
type="button"
@click="binary.showprevious = !binary.showprevious"
>
{{ binary.showprevious ? 'Hide' : 'Show' }} previous versions
{{ binary.showprevious ? 'Hide' : 'Show' }} staged versions
({{ binary.versions.length }})
</button>
<table v-if="binary.showprevious" class="versions-table">
@@ -434,7 +502,7 @@ const copied = ref(false)
const message = ref('')
const error = ref('')
// Adoption tool (spike): build a one-shot, agentless enrollment download.
// Adoption tool: build a one-shot, agentless enrollment download.
const adoptbaseurl = ref('')
const adoptcollectorkey = ref('')
const adoptbusy = ref(false)
@@ -458,11 +526,15 @@ const agenterror = ref('')
// version list, and busy/message state so the two rows act independently. The
// 'key' matches the backend binkey (agent | tray).
const binaries = ref([
{ key: 'agent', label: 'Endpoint agent (shopdbagent.exe)',
current: null, versions: [], version: '', file: null,
{ key: 'agent', label: 'ShopDB Agent (shopdbagent.exe)',
name: 'ShopDB Agent',
current: null, versions: [], version: '', file: '', detecting: false,
legacyversion: '', legacyfile: '',
busy: false, message: '', error: '', showprevious: false },
{ key: 'tray', label: 'Tray agent (shopdb-endpoint-tray.exe)',
current: null, versions: [], version: '', file: null,
{ key: 'tray', label: 'Tray Agent (shopdb-endpoint-tray.exe)',
name: 'Tray Agent',
current: null, versions: [], version: '', file: '', detecting: false,
legacyversion: '', legacyfile: '',
busy: false, message: '', error: '', showprevious: false },
])
@@ -664,11 +736,39 @@ async function buildSiteAgent() {
}
}
async function detectVersion(binkey, file) {
const form = new FormData()
form.append('file', file)
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}/detect`, form)
return response.data.data?.version || ''
}
function onBinaryFileChange(binkey, event) {
const binary = binaryFor(binkey)
if (!binary) return
const files = event.target.files
binary.file = files && files.length ? files[0] : null
binary.file = files && files.length ? files[0] : ''
binary.error = ''
if (!binary.file) return
binary.detecting = true
detectVersion(binkey, binary.file)
.then((version) => {
if (version) binary.version = version
})
.catch((detectError) => {
console.error(detectError)
})
.finally(() => {
binary.detecting = false
})
}
function onLegacyFileChange(binkey, event) {
const binary = binaryFor(binkey)
if (!binary) return
const files = event.target.files
binary.legacyfile = files && files.length ? files[0] : ''
}
async function uploadBinary(binkey) {
@@ -686,7 +786,7 @@ async function uploadBinary(binkey) {
const data = response.data.data || {}
applyBinaryStatus(binkey, data.current, data.versions)
binary.version = ''
binary.file = null
binary.file = ''
binary.message = 'Uploaded and set as current.'
} catch (uploadError) {
binary.error = uploadError.response?.data?.data?.error?.message
@@ -699,6 +799,35 @@ async function uploadBinary(binkey) {
}
}
async function uploadLegacy(binkey) {
const binary = binaryFor(binkey)
if (!binary || !binary.legacyfile || !binary.legacyversion) return
binary.busy = true
binary.message = ''
binary.error = ''
try {
const form = new FormData()
form.append('version', binary.legacyversion)
form.append('file', binary.legacyfile)
form.append('makecurrent', 'false')
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}`, form)
const data = response.data.data || {}
applyBinaryStatus(binkey, data.current, data.versions)
binary.legacyversion = ''
binary.legacyfile = ''
binary.message = 'Legacy build staged for rollback.'
} catch (uploadError) {
binary.error = uploadError.response?.data?.data?.error?.message
|| (uploadError.response?.status === 403
? 'You do not have permission to upload agent binaries'
: 'Could not upload the legacy build')
console.error(uploadError)
} finally {
binary.busy = false
}
}
async function setCurrentBinary(binkey, version) {
const binary = binaryFor(binkey)
if (!binary) return
@@ -775,6 +904,14 @@ function editConfiguration() {
}
.binary-upload .form-control[type="text"] { max-width: 12rem; }
.previous-versions { margin-top: 0.5rem; }
.field-warning {
margin: 0.25rem 0 0.5rem;
padding: 0.5rem 0.75rem;
border-left: 3px solid var(--color-warning, #b8860b);
background: rgba(184, 134, 11, 0.08);
font-size: 0.85rem;
font-weight: 600;
}
.btn-link {
background: none;
border: none;

View File

@@ -0,0 +1,57 @@
"""Unit tests for the versioned agent binary store helpers.
Covers the pure, context-free helpers: version validation, PE magic and PE
file-version parsing. The staging/retention paths that touch the Flask instance
directory are exercised through the API tests; here we keep to the logic that
needs no app context.
The PE version parser walks the resource directory to the outer image's
RT_VERSION resource (not the first VS_FIXEDFILEINFO signature in the bytes),
because a self-contained single-file .NET exe bundles many framework assemblies
that each carry their own version block. Building a full PE with a resource
directory inline is impractical here, so the parser's correctness is verified
against real agent binaries in the API/integration layer; these unit tests pin
the surrounding validators and the malformed-input contract (never raises,
returns None).
"""
from plugins.endpoints.api import agentstore
def test_is_valid_version_accepts_dotted_numeric():
assert agentstore.is_valid_version('1')
assert agentstore.is_valid_version('1.6.3')
assert agentstore.is_valid_version('1.6.3.0')
def test_is_valid_version_rejects_bad_input():
assert not agentstore.is_valid_version('')
assert not agentstore.is_valid_version('v1.6')
assert not agentstore.is_valid_version('1.6.3.0.1')
assert not agentstore.is_valid_version('../etc')
assert not agentstore.is_valid_version('latest')
def test_is_valid_binary_key():
assert agentstore.is_valid_binary_key('agent')
assert agentstore.is_valid_binary_key('tray')
assert not agentstore.is_valid_binary_key('other')
def test_looks_like_pe():
assert agentstore.looks_like_pe(b'MZ\x90\x00')
assert not agentstore.looks_like_pe(b'PK\x03\x04')
assert not agentstore.looks_like_pe(b'')
def test_read_pe_file_version_none_on_non_pe():
assert agentstore.read_pe_file_version(b'not a pe file') is None
def test_read_pe_file_version_none_on_truncated_pe():
# MZ header but nothing else valid: must return None, never raise.
assert agentstore.read_pe_file_version(b'MZ' + b'\x00' * 200) is None
def test_read_pe_file_version_none_on_empty():
assert agentstore.read_pe_file_version(b'') is None