A CRLF file re-CRLF'd (e.g. sed adding \r to already-CRLF lines) yields \r\r\n; the old _split_lines left a stray \r that split into a blank line between every line, so parse_boot_menu/settings/lint saw a garbled file and returned nothing. Now collapse any run of CR before a newline. Also re-deployed a clean-CRLF startnet.cmd into the live boot.wim (the earlier sed-based deploys had doubled the CR).
730 lines
26 KiB
Python
730 lines
26 KiB
Python
"""boot.wim manipulation via wimtools (wimextract / wimupdate / wimdir)
|
|
plus pure-Python helpers for the startnet.cmd editor.
|
|
|
|
Two layers live here:
|
|
|
|
1. wimtools wrappers (extract_startnet / update_startnet / list_files) that
|
|
read + write startnet.cmd inside boot.wim. update_startnet writes CRLF.
|
|
|
|
2. Framework-free text helpers used by the rebuilt startnet.cmd editor:
|
|
settings-as-forms (parse_settings / apply_settings), the boot-menu builder
|
|
(parse_boot_menu / apply_boot_menu), lint_startnet, timestamped snapshots
|
|
(save_snapshot / list_snapshots / read_snapshot) and unified_diff.
|
|
|
|
The helpers hold NO Flask imports on purpose; the routes stage wires them up.
|
|
startnet.cmd is a WinPE BATCH script with CRLF endings. Every regen helper
|
|
preserves the file's existing line ending and only rewrites the exact lines it
|
|
targets, leaving everything else byte-for-byte. When a structure cannot be
|
|
located unambiguously the regen helpers raise ValueError so the caller can fall
|
|
back to the raw full-text editor (the source of truth).
|
|
"""
|
|
|
|
import datetime
|
|
import difflib
|
|
import glob
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from collections import Counter
|
|
|
|
# Root-writable dir for timestamped startnet.cmd snapshots. Overridable by env
|
|
# so tests / alt deploys can redirect it. save_snapshot() creates it on demand.
|
|
BACKUPS_DIR = os.environ.get(
|
|
"STARTNET_BACKUPS_DIR", "/var/lib/pxe-webapp/startnet-backups"
|
|
)
|
|
|
|
|
|
def extract_startnet(wim_path):
|
|
"""Extract startnet.cmd from a WIM file. Returns the contents or None."""
|
|
tmpdir = tempfile.mkdtemp()
|
|
try:
|
|
result = subprocess.run(
|
|
["wimextract", wim_path, "1",
|
|
"/Windows/System32/startnet.cmd",
|
|
"--dest-dir", tmpdir],
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
startnet_path = os.path.join(tmpdir, "startnet.cmd")
|
|
if result.returncode == 0 and os.path.isfile(startnet_path):
|
|
# newline="" keeps the file's CRLF bytes verbatim; default universal
|
|
# newlines would fold CRLF to LF, breaking the lint CRLF check and
|
|
# storing LF-only snapshots.
|
|
with open(startnet_path, "r", encoding="utf-8", errors="replace",
|
|
newline="") as fh:
|
|
return fh.read()
|
|
return None
|
|
except Exception:
|
|
return None
|
|
finally:
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
def update_startnet(wim_path, content):
|
|
"""Update startnet.cmd inside a WIM file via wimupdate.
|
|
|
|
Returns (ok, error_message). Writes CRLF line endings.
|
|
"""
|
|
tmpdir = tempfile.mkdtemp()
|
|
try:
|
|
# Normalize to LF first: newline="\r\n" translates each bare \n, so
|
|
# CRLF input (browser textareas post CRLF) would otherwise become
|
|
# \r\r\n. After normalizing, output is always clean CRLF.
|
|
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
startnet_path = os.path.join(tmpdir, "startnet.cmd")
|
|
with open(startnet_path, "w", encoding="utf-8", newline="\r\n") as fh:
|
|
fh.write(content)
|
|
update_cmd = f"add {startnet_path} /Windows/System32/startnet.cmd\n"
|
|
result = subprocess.run(
|
|
["wimupdate", wim_path, "1"],
|
|
input=update_cmd,
|
|
capture_output=True, text=True, timeout=60,
|
|
)
|
|
if result.returncode != 0:
|
|
return False, result.stderr.strip()
|
|
return True, ""
|
|
except Exception as exc:
|
|
return False, str(exc)
|
|
finally:
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
def list_files(wim_path, path="/"):
|
|
"""List files inside a WIM at the given path."""
|
|
try:
|
|
result = subprocess.run(
|
|
["wimdir", wim_path, "1", path],
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
if result.returncode == 0:
|
|
return [l.strip() for l in result.stdout.splitlines() if l.strip()]
|
|
return []
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
# ===========================================================================
|
|
# Pure text helpers for the startnet.cmd editor (no Flask, no wimtools).
|
|
# All operate on the startnet.cmd text. Regen helpers preserve the file's
|
|
# existing line ending and only touch their target lines.
|
|
# ===========================================================================
|
|
|
|
def _detect_eol(content):
|
|
"""Return the dominant line ending, defaulting to CRLF for WinPE batch."""
|
|
if "\r\n" in content:
|
|
return "\r\n"
|
|
if "\n" in content:
|
|
return "\n"
|
|
return "\r\n"
|
|
|
|
|
|
def _split_lines(content):
|
|
"""Split into lines WITHOUT terminators, tolerant of CRLF/LF/CR.
|
|
|
|
Rejoining with eol.join(...) reproduces a trailing newline because a
|
|
trailing terminator yields a final empty element.
|
|
"""
|
|
# Collapse any run of CRs before a newline (handles CRLF and a doubled
|
|
# \r\r\n that a bad CRLF-conversion can leave behind), then lone CR.
|
|
return re.sub(r"\r+\n", "\n", content).replace("\r", "\n").split("\n")
|
|
|
|
|
|
# --- Shared regexes ---------------------------------------------------------
|
|
_LABEL_RE = re.compile(r"^\s*:([A-Za-z0-9_][A-Za-z0-9_\-]*)\s*$")
|
|
_NETUSE_IMG_RE = re.compile(
|
|
r"net use\s+\w+:\s+\\\\[0-9.]+\\winpeapps\\([A-Za-z0-9_\-]+)", re.IGNORECASE
|
|
)
|
|
_GOTO_END_RE = re.compile(r"^\s*goto\s+end\s*$", re.IGNORECASE)
|
|
_DISPATCH_RE = re.compile(
|
|
r'^(\s*)if\s+"%choice%"=="(\d+)"\s+goto\s+([A-Za-z0-9_\-]+)\s*$', re.IGNORECASE
|
|
)
|
|
_ECHO_NUM_RE = re.compile(r"^(\s*)echo\s+(\d+)\.\s?(.*?)\s*$", re.IGNORECASE)
|
|
_CHOICE_PROMPT_RE = re.compile(
|
|
r"^(\s*set\s+/p\s+choice=.*\(1-)(\d+)(\).*)$", re.IGNORECASE
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Feature 1: settings as forms
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _detect_server_ip(content):
|
|
"""Best-effort PXE server IP: the IP most used in \\\\IP\\ UNC paths.
|
|
|
|
Falls back to a `ping <ip>` target. Returns the dotted-quad or None.
|
|
"""
|
|
ips = re.findall(r"\\\\(\d{1,3}(?:\.\d{1,3}){3})\\", content)
|
|
if not ips:
|
|
ips = re.findall(
|
|
r"ping\b[^\r\n]*?\b(\d{1,3}(?:\.\d{1,3}){3})\b", content, re.IGNORECASE
|
|
)
|
|
if not ips:
|
|
return None
|
|
return Counter(ips).most_common(1)[0][0]
|
|
|
|
|
|
def _replace_server_ip(content, old, new):
|
|
"""Replace old IP with new everywhere it stands as a whole IPv4 token.
|
|
|
|
The negative look-around on [\\d.] keeps it from matching a fragment of a
|
|
longer dotted number, and only the detected server IP is ever passed in, so
|
|
unrelated addresses (e.g. 8.8.8.8 in the enroll.cmd here-doc) are untouched.
|
|
This covers UNC (\\\\IP\\...), the ping wait-for-network line, and any URL.
|
|
"""
|
|
pat = re.compile(r"(?<![\d.])" + re.escape(old) + r"(?![\d.])")
|
|
return pat.sub(new, content)
|
|
|
|
|
|
def _detect_menu_timeout_default(content):
|
|
"""Pull timeout seconds + default key off a `choice /t N /d X` line.
|
|
|
|
startnet uses `set /p` (no timeout) today, so this is usually (None, None).
|
|
Only a real `choice` command is recognised; nothing is invented.
|
|
"""
|
|
timeout = None
|
|
default = None
|
|
for line in _split_lines(content):
|
|
if re.search(r"\bchoice\b", line, re.IGNORECASE) and "/t" in line.lower():
|
|
mt = re.search(r"/t\s*:?\s*(\d+)", line, re.IGNORECASE)
|
|
md = re.search(r"/d\s*:?\s*([A-Za-z0-9]+)", line, re.IGNORECASE)
|
|
if mt:
|
|
timeout = int(mt.group(1))
|
|
if md:
|
|
default = md.group(1)
|
|
break
|
|
return timeout, default
|
|
|
|
|
|
def parse_settings(content):
|
|
"""content -> settings dict of safe, common knobs.
|
|
|
|
Keys:
|
|
server_ip str|None PXE server IP used in UNC/ping paths
|
|
menu_timeout int|None seconds, from a `choice /t` construct if present
|
|
menu_default str|None default choice key, from `choice /d` if present
|
|
"""
|
|
ip = _detect_server_ip(content)
|
|
timeout, default = _detect_menu_timeout_default(content)
|
|
return {"server_ip": ip, "menu_timeout": timeout, "menu_default": default}
|
|
|
|
|
|
def apply_settings(content, settings):
|
|
"""settings dict -> content, via targeted replacement only.
|
|
|
|
- server_ip: if given and different from the current detected IP, rewrites
|
|
that IP everywhere it appears as a whole IPv4 token (UNC, ping, URL).
|
|
- menu_timeout / menu_default: updated ONLY if a `choice /t../d..` construct
|
|
already exists; never injected (conservative - avoids risky rewrites).
|
|
Unknown/None keys are left alone. Line endings are preserved.
|
|
"""
|
|
out = content
|
|
new_ip = settings.get("server_ip")
|
|
if new_ip:
|
|
cur = _detect_server_ip(content)
|
|
if cur and cur != new_ip:
|
|
out = _replace_server_ip(out, cur, new_ip)
|
|
|
|
if settings.get("menu_timeout") is not None or settings.get("menu_default") is not None:
|
|
out = _apply_menu_timeout_default(
|
|
out, settings.get("menu_timeout"), settings.get("menu_default")
|
|
)
|
|
return out
|
|
|
|
|
|
def _apply_menu_timeout_default(content, timeout, default):
|
|
"""Rewrite /t and /d on an existing `choice` line only. No-op otherwise."""
|
|
eol = _detect_eol(content)
|
|
lines = _split_lines(content)
|
|
for i, line in enumerate(lines):
|
|
if re.search(r"\bchoice\b", line, re.IGNORECASE) and "/t" in line.lower():
|
|
if timeout is not None:
|
|
line = re.sub(
|
|
r"(/t\s*:?\s*)\d+", r"\g<1>" + str(timeout), line, flags=re.IGNORECASE
|
|
)
|
|
if default is not None:
|
|
line = re.sub(
|
|
r"(/d\s*:?\s*)[A-Za-z0-9]+",
|
|
r"\g<1>" + str(default),
|
|
line,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
lines[i] = line
|
|
break
|
|
return eol.join(lines)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Feature 2: boot-menu builder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _find_label_line(lines, name):
|
|
name = name.lower()
|
|
for i, line in enumerate(lines):
|
|
m = _LABEL_RE.match(line)
|
|
if m and m.group(1).lower() == name:
|
|
return i
|
|
return None
|
|
|
|
|
|
def _find_image_blocks(lines):
|
|
"""Find per-image action blocks: a ':<target>' label whose body maps
|
|
Z: to \\\\IP\\winpeapps\\<image> and ends with 'goto end'.
|
|
|
|
Returns list of dicts: {target, image, start, end} where start is the label
|
|
line index and end is the 'goto end' line index (inclusive block span).
|
|
"""
|
|
label_idxs = [i for i, l in enumerate(lines) if _LABEL_RE.match(l)]
|
|
label_set = set(label_idxs)
|
|
blocks = []
|
|
for i in label_idxs:
|
|
target = _LABEL_RE.match(lines[i]).group(1)
|
|
# body runs to the next label or EOF
|
|
end_bound = len(lines)
|
|
for j in range(i + 1, len(lines)):
|
|
if j in label_set:
|
|
end_bound = j
|
|
break
|
|
image = None
|
|
goto_end_idx = None
|
|
for j in range(i + 1, end_bound):
|
|
if image is None:
|
|
mi = _NETUSE_IMG_RE.search(lines[j])
|
|
if mi:
|
|
image = mi.group(1)
|
|
if _GOTO_END_RE.match(lines[j]):
|
|
goto_end_idx = j
|
|
if image is not None and goto_end_idx is not None:
|
|
blocks.append(
|
|
{"target": target, "image": image, "start": i, "end": goto_end_idx}
|
|
)
|
|
return blocks
|
|
|
|
|
|
def _find_dispatch_lines(lines):
|
|
"""All `if "%choice%"=="N" goto TARGET` lines as dicts {idx,num,target,indent}."""
|
|
out = []
|
|
for i, line in enumerate(lines):
|
|
m = _DISPATCH_RE.match(line)
|
|
if m:
|
|
out.append(
|
|
{
|
|
"idx": i,
|
|
"indent": m.group(1),
|
|
"num": int(m.group(2)),
|
|
"target": m.group(3),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _find_choice_prompt(lines, start):
|
|
for i in range(start, len(lines)):
|
|
if _CHOICE_PROMPT_RE.match(lines[i]):
|
|
return i
|
|
return None
|
|
|
|
|
|
def _find_numbered_echo_run(lines, lo, hi):
|
|
"""Contiguous run of numbered 'echo N. Label' lines within [lo, hi).
|
|
|
|
Returns (indices, indent, labels_by_num) or raises ValueError if the numbered
|
|
echo lines are not on consecutive source lines (ambiguous - refuse to edit).
|
|
"""
|
|
matched = []
|
|
for i in range(lo, hi):
|
|
m = _ECHO_NUM_RE.match(lines[i])
|
|
if m:
|
|
matched.append((i, m))
|
|
if not matched:
|
|
raise ValueError("no numbered 'echo N. ...' menu lines found")
|
|
idxs = [i for i, _ in matched]
|
|
if idxs != list(range(idxs[0], idxs[0] + len(idxs))):
|
|
raise ValueError("menu echo lines are not contiguous")
|
|
indent = matched[0][1].group(1)
|
|
labels = {int(m.group(2)): m.group(3).rstrip() for _, m in matched}
|
|
return idxs, indent, labels
|
|
|
|
|
|
def _analyze_boot_menu(lines):
|
|
"""Locate every editable region of the image menu. Raises ValueError if the
|
|
clearly-delimited structure is missing/ambiguous. Returns a dict of parts."""
|
|
menu_idx = _find_label_line(lines, "menu")
|
|
if menu_idx is None:
|
|
raise ValueError("no :menu label found")
|
|
choice_idx = _find_choice_prompt(lines, menu_idx)
|
|
if choice_idx is None:
|
|
raise ValueError("no 'set /p choice=' prompt found after :menu")
|
|
|
|
echo_idxs, echo_indent, echo_labels = _find_numbered_echo_run(
|
|
lines, menu_idx, choice_idx
|
|
)
|
|
|
|
image_blocks = _find_image_blocks(lines)
|
|
if not image_blocks:
|
|
raise ValueError("no image action blocks (net use winpeapps ... goto end)")
|
|
img_targets = {b["target"] for b in image_blocks}
|
|
|
|
all_dispatch = _find_dispatch_lines(lines)
|
|
image_dispatch = [d for d in all_dispatch if d["target"] in img_targets]
|
|
if not image_dispatch:
|
|
raise ValueError("no image %choice% dispatch lines found")
|
|
d_idxs = [d["idx"] for d in image_dispatch]
|
|
if d_idxs != list(range(d_idxs[0], d_idxs[-1] + 1)):
|
|
raise ValueError("image %choice% dispatch lines are not contiguous")
|
|
|
|
blocks_sorted = sorted(image_blocks, key=lambda b: b["start"])
|
|
region_start = blocks_sorted[0]["start"]
|
|
region_end = blocks_sorted[-1]["end"]
|
|
# every line inside the block region must belong to a block span or be blank
|
|
covered = set()
|
|
for b in blocks_sorted:
|
|
covered.update(range(b["start"], b["end"] + 1))
|
|
for j in range(region_start, region_end + 1):
|
|
if j not in covered and lines[j].strip() != "":
|
|
raise ValueError(
|
|
"image action blocks are not contiguous (foreign content at "
|
|
"line %d)" % (j + 1)
|
|
)
|
|
|
|
return {
|
|
"menu_idx": menu_idx,
|
|
"choice_idx": choice_idx,
|
|
"echo_idxs": echo_idxs,
|
|
"echo_indent": echo_indent,
|
|
"echo_labels": echo_labels,
|
|
"image_blocks": blocks_sorted,
|
|
"image_dispatch": image_dispatch,
|
|
"all_dispatch": all_dispatch,
|
|
"region_start": region_start,
|
|
"region_end": region_end,
|
|
"dispatch_start": d_idxs[0],
|
|
"dispatch_end": d_idxs[-1],
|
|
}
|
|
|
|
|
|
def parse_boot_menu(content):
|
|
"""content -> list of image-menu items, ordered by menu number.
|
|
|
|
Each item dict:
|
|
num int choice number that routes to this image
|
|
label str echo text after 'N. ' (kept verbatim, trailing ws only stripped)
|
|
target str goto label of the action block (the ':<target>')
|
|
target_image str winpeapps\\<image> the block maps Z: to
|
|
|
|
Returns [] if the delimited menu region cannot be found (caller uses raw text).
|
|
"""
|
|
lines = _split_lines(content)
|
|
try:
|
|
a = _analyze_boot_menu(lines)
|
|
except ValueError:
|
|
return []
|
|
by_target = {b["target"]: b for b in a["image_blocks"]}
|
|
items = []
|
|
for d in a["image_dispatch"]:
|
|
blk = by_target[d["target"]]
|
|
items.append(
|
|
{
|
|
"num": d["num"],
|
|
"label": a["echo_labels"].get(d["num"], ""),
|
|
"target": d["target"],
|
|
"target_image": blk["image"],
|
|
}
|
|
)
|
|
items.sort(key=lambda it: it["num"])
|
|
return items
|
|
|
|
|
|
def _synth_block(item, ip, eol_indent=""):
|
|
"""Build a fresh per-image action block for a newly added menu item."""
|
|
target = item["target"]
|
|
image = item.get("target_image") or target
|
|
label = item.get("label") or target
|
|
return [
|
|
":%s" % target,
|
|
"echo.",
|
|
"echo Starting %s setup..." % label,
|
|
r'start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe',
|
|
r"for /l %%i in (1,1,2000000) do rem",
|
|
r"net use Z: \\%s\winpeapps\%s /user:pxe-upload pxe /persistent:no"
|
|
% (ip, image),
|
|
"goto end",
|
|
]
|
|
|
|
|
|
def apply_boot_menu(content, items):
|
|
"""items -> content: regenerate ONLY the menu echo lines, the choice prompt
|
|
count, the image %choice% dispatch (renumbered 1..N), and reorder/add/remove
|
|
the per-image action blocks. Existing block BODIES are kept byte-for-byte;
|
|
only new blocks are synthesised. Everything else is untouched.
|
|
|
|
items: list of {label, target, target_image} (a 'num' key, if present, is
|
|
ignored - order in the list defines the new numbering).
|
|
|
|
Raises ValueError when the structure is ambiguous OR when renumbering would
|
|
desync a secondary `%choice%` router (e.g. the enrollment routing that also
|
|
keys off choice numbers). The caller then falls back to the raw editor.
|
|
"""
|
|
if not items:
|
|
raise ValueError("boot menu must have at least one item")
|
|
|
|
eol = _detect_eol(content)
|
|
lines = _split_lines(content)
|
|
ip = _detect_server_ip(content) or "172.16.9.1"
|
|
a = _analyze_boot_menu(lines)
|
|
|
|
old_num_by_target = {d["target"]: d["num"] for d in a["image_dispatch"]}
|
|
old_target_by_num = {d["num"]: d["target"] for d in a["image_dispatch"]}
|
|
new_num_by_target = {it["target"]: i + 1 for i, it in enumerate(items)}
|
|
|
|
# Safety: a secondary %choice% router (not one of the image blocks) that
|
|
# references an image number whose meaning is about to change is a desync.
|
|
image_dispatch_idxs = {d["idx"] for d in a["image_dispatch"]}
|
|
for d in a["all_dispatch"]:
|
|
if d["idx"] in image_dispatch_idxs:
|
|
continue
|
|
n = d["num"]
|
|
if n in old_target_by_num: # this number currently selects an image
|
|
tgt = old_target_by_num[n]
|
|
if tgt not in new_num_by_target or new_num_by_target[tgt] != n:
|
|
raise ValueError(
|
|
"refusing to renumber: secondary %%choice%% routing at line "
|
|
"%d references image choice %d whose number would change"
|
|
% (d["idx"] + 1, n)
|
|
)
|
|
|
|
blocks_by_target = {
|
|
b["target"]: lines[b["start"]:b["end"] + 1] for b in a["image_blocks"]
|
|
}
|
|
|
|
# --- build replacement lines for each region ---
|
|
new_echo = [
|
|
"%secho %d. %s" % (a["echo_indent"], i + 1, it.get("label") or it["target"])
|
|
for i, it in enumerate(items)
|
|
]
|
|
disp_indent = a["image_dispatch"][0]["indent"]
|
|
new_dispatch = [
|
|
'%sif "%%choice%%"=="%d" goto %s' % (disp_indent, i + 1, it["target"])
|
|
for i, it in enumerate(items)
|
|
]
|
|
cm = _CHOICE_PROMPT_RE.match(lines[a["choice_idx"]])
|
|
new_choice = "%s%d%s" % (cm.group(1), len(items), cm.group(3))
|
|
|
|
new_region = []
|
|
for i, it in enumerate(items):
|
|
if i > 0:
|
|
new_region.append("") # blank line between blocks, matching style
|
|
if it["target"] in blocks_by_target:
|
|
new_region.extend(blocks_by_target[it["target"]])
|
|
else:
|
|
new_region.extend(_synth_block(it, ip))
|
|
|
|
# Apply splices high-index-first so earlier indices stay valid.
|
|
splices = [
|
|
(a["region_start"], a["region_end"], new_region),
|
|
(a["dispatch_start"], a["dispatch_end"], new_dispatch),
|
|
(a["choice_idx"], a["choice_idx"], [new_choice]),
|
|
(a["echo_idxs"][0], a["echo_idxs"][-1], new_echo),
|
|
]
|
|
for start, end, repl in sorted(splices, key=lambda s: s[0], reverse=True):
|
|
lines[start:end + 1] = repl
|
|
|
|
return eol.join(lines)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Feature 3: lint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _strip_for_parens(line):
|
|
"""Neutralise a line for paren counting: drop comments, quoted spans, and
|
|
caret-escaped chars (so `^>NUL`, `echo ... ^(` etc. do not count)."""
|
|
s = line.lstrip()
|
|
low = s.lower()
|
|
if low.startswith("rem ") or low == "rem" or s.startswith("::"):
|
|
return ""
|
|
# remove escaped pairs first, then any caret-escaped char
|
|
s = s.replace("^^", "")
|
|
s = re.sub(r"\^.", "", s)
|
|
# drop double-quoted spans
|
|
s = re.sub(r'"[^"]*"', "", s)
|
|
return s
|
|
|
|
|
|
def lint_startnet(content):
|
|
"""Static checks over startnet.cmd. Advisory - never blocks a save.
|
|
|
|
Returns {"errors": [...], "warnings": [...]} where each entry is
|
|
{"line": int|None, "message": str}. Errors: goto with no matching label.
|
|
Warnings: lone-LF (non-CRLF) lines, unbalanced parentheses.
|
|
"""
|
|
errors = []
|
|
warnings = []
|
|
lines = _split_lines(content)
|
|
|
|
labels = set()
|
|
for line in lines:
|
|
m = _LABEL_RE.match(line)
|
|
if m:
|
|
labels.add(m.group(1).lower())
|
|
|
|
for i, line in enumerate(lines):
|
|
# A `goto` inside an `echo` line is emitted text (e.g. the enroll.cmd
|
|
# here-doc), not a jump - skip it to avoid false positives.
|
|
if line.lstrip().lower().startswith("echo"):
|
|
continue
|
|
for m in re.finditer(
|
|
r"goto\s+:?([A-Za-z0-9_][A-Za-z0-9_\-]*)", line, re.IGNORECASE
|
|
):
|
|
target = m.group(1).lower()
|
|
if target == "eof":
|
|
continue # goto :eof is a cmd.exe builtin
|
|
if target not in labels:
|
|
errors.append(
|
|
{
|
|
"line": i + 1,
|
|
"message": "goto %s has no matching :%s label"
|
|
% (m.group(1), m.group(1)),
|
|
}
|
|
)
|
|
|
|
# CRLF check: count lone LFs (a \n not preceded by \r)
|
|
crlf = content.count("\r\n")
|
|
lf = content.count("\n")
|
|
lone = lf - crlf
|
|
if lone > 0:
|
|
first = None
|
|
idx = content.find("\n")
|
|
ln = 1
|
|
while idx != -1:
|
|
if idx == 0 or content[idx - 1] != "\r":
|
|
first = ln
|
|
break
|
|
idx = content.find("\n", idx + 1)
|
|
ln += 1
|
|
warnings.append(
|
|
{
|
|
"line": first,
|
|
"message": "%d line(s) are not CRLF-terminated; WinPE batch "
|
|
"expects CRLF" % lone,
|
|
}
|
|
)
|
|
|
|
# Parenthesis balance (best-effort, comments/quotes/carets removed)
|
|
delta = 0
|
|
for line in lines:
|
|
cleaned = _strip_for_parens(line)
|
|
delta += cleaned.count("(") - cleaned.count(")")
|
|
if delta != 0:
|
|
warnings.append(
|
|
{
|
|
"line": None,
|
|
"message": "parentheses look unbalanced (delta %+d) - check "
|
|
"if(...) blocks" % delta,
|
|
}
|
|
)
|
|
|
|
return {"errors": errors, "warnings": warnings}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Feature 4: snapshots + diff
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _ensure_backups_dir():
|
|
os.makedirs(BACKUPS_DIR, exist_ok=True)
|
|
|
|
|
|
def _snapshot_meta(path):
|
|
st = os.stat(path)
|
|
base = os.path.basename(path)
|
|
note = ""
|
|
note_path = path + ".note"
|
|
if os.path.isfile(note_path):
|
|
try:
|
|
with open(note_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
note = fh.read().strip()
|
|
except Exception:
|
|
note = ""
|
|
return {
|
|
"id": base,
|
|
"path": path,
|
|
"mtime": st.st_mtime,
|
|
"timestamp": datetime.datetime.fromtimestamp(st.st_mtime).strftime(
|
|
"%Y-%m-%d %H:%M:%S"
|
|
),
|
|
"size": st.st_size,
|
|
"note": note,
|
|
}
|
|
|
|
|
|
def save_snapshot(content, note=""):
|
|
"""Write content verbatim to a timestamped file under BACKUPS_DIR.
|
|
|
|
Snapshots the bytes exactly (no line-ending translation) so a restore
|
|
round-trips faithfully. Returns the snapshot metadata dict (see
|
|
_snapshot_meta). Call this with the PRIOR startnet before overwriting it.
|
|
"""
|
|
_ensure_backups_dir()
|
|
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
base = "startnet-%s.cmd" % ts
|
|
path = os.path.join(BACKUPS_DIR, base)
|
|
n = 1
|
|
while os.path.exists(path):
|
|
base = "startnet-%s-%d.cmd" % (ts, n)
|
|
path = os.path.join(BACKUPS_DIR, base)
|
|
n += 1
|
|
with open(path, "w", encoding="utf-8", newline="") as fh:
|
|
fh.write(content)
|
|
if note:
|
|
with open(path + ".note", "w", encoding="utf-8", newline="") as fh:
|
|
fh.write(note)
|
|
return _snapshot_meta(path)
|
|
|
|
|
|
def list_snapshots():
|
|
"""Return snapshot metadata dicts, newest first."""
|
|
_ensure_backups_dir()
|
|
metas = []
|
|
for path in glob.glob(os.path.join(BACKUPS_DIR, "startnet-*.cmd")):
|
|
if path.endswith(".note"):
|
|
continue
|
|
try:
|
|
metas.append(_snapshot_meta(path))
|
|
except OSError:
|
|
continue
|
|
metas.sort(key=lambda m: m["mtime"], reverse=True)
|
|
return metas
|
|
|
|
|
|
def read_snapshot(snapshot_id):
|
|
"""Return a snapshot's text by id (its filename), or None if not found.
|
|
|
|
Rejects path traversal: the id must be a bare basename under BACKUPS_DIR.
|
|
"""
|
|
if not snapshot_id or os.path.basename(snapshot_id) != snapshot_id:
|
|
return None
|
|
if ".." in snapshot_id or "/" in snapshot_id or "\\" in snapshot_id:
|
|
return None
|
|
path = os.path.join(BACKUPS_DIR, snapshot_id)
|
|
if not os.path.isfile(path):
|
|
return None
|
|
# newline="" so the stored CRLF bytes come back verbatim (faithful restore).
|
|
with open(path, "r", encoding="utf-8", errors="replace", newline="") as fh:
|
|
return fh.read()
|
|
|
|
|
|
def unified_diff(a, b, fromfile="current", tofile="new"):
|
|
"""Unified diff between two startnet texts, as a single string ('' if equal).
|
|
|
|
Line endings are normalised for comparison so a pure CRLF/LF difference does
|
|
not produce a wall of noise; content differences are what surface.
|
|
"""
|
|
a_lines = _split_lines(a)
|
|
b_lines = _split_lines(b)
|
|
return "\n".join(
|
|
difflib.unified_diff(
|
|
a_lines, b_lines, fromfile=fromfile, tofile=tofile, lineterm=""
|
|
)
|
|
)
|