read what the binary says about itself: names, signatures, prototypes; gen v2
This commit is contained in:
parent
54ef572202
commit
c458b4cb50
34 changed files with 58363 additions and 192 deletions
483
src/concmd.rs
Normal file
483
src/concmd.rs
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
//! Console commands — the third place a stripped Source-2 module names its own functions, and the only
|
||||
//! one that is not a table.
|
||||
//!
|
||||
//! A `ConCommand` used to be a static object with a vtable, and scanning for that shape is the obvious
|
||||
//! route. It does not work on CS2: commands are registered through a handle-based `ConCommandRef` whose
|
||||
//! registry lives in tier0, so `libserver.so` holds no `ConCommand` object and no `_ZTV10ConCommand`
|
||||
//! relocation to one. Nothing static points at a command name — which is why this was carried for a long
|
||||
//! time as needing a LIVE process to walk the registry.
|
||||
//!
|
||||
//! It does not. The registration is an ordinary call from a static initialiser, and every argument is a
|
||||
//! constant in the instruction stream:
|
||||
//!
|
||||
//! ```text
|
||||
//! lea rdi, [rip+ref] ; the ConCommandRef this call fills in
|
||||
//! lea rsi, [rip+"bot_add"] ; the command name
|
||||
//! lea rdx, [rip+handler] ; the callback
|
||||
//! mov ecx, 0 ; which FORM the callback takes
|
||||
//! lea r8, [rip+"bot_add <t|ct> ..."]
|
||||
//! mov r9d, 0x80004 ; flags
|
||||
//! call <registrar>
|
||||
//! ```
|
||||
//!
|
||||
//! So the walk is offline: decode each function, track what the argument registers provably hold, and
|
||||
//! read the vector at every call. The name, the handler, the description and the flags all come from ONE
|
||||
//! instruction sequence, which is what makes the result checkable — Valve's own command dump states the
|
||||
//! name and description of each command, and those two arguments agreeing is evidence about the third.
|
||||
//!
|
||||
//! Like the table readers this is deliberately shape-driven: the registrar is recognised by what it DOES
|
||||
//! (it opens by writing the invalid-handle sentinel), a handler is accepted only if it lands in
|
||||
//! executable code, and a name only if it resolves to a plausible string. A layout change yields
|
||||
//! FEWER commands, never wrong ones.
|
||||
|
||||
use crate::elf::CodeImage;
|
||||
use iced_x86::{
|
||||
Decoder, DecoderOptions, FlowControl, Instruction, InstructionInfoFactory, Mnemonic, OpAccess,
|
||||
OpKind, Register,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// SysV argument registers, by the GPR index [`gpr`] produces.
|
||||
const RCX: usize = 1;
|
||||
const RDX: usize = 2;
|
||||
const RSI: usize = 6;
|
||||
const RDI: usize = 7;
|
||||
const R8: usize = 8;
|
||||
const R9: usize = 9;
|
||||
/// Caller-saved under SysV: a call destroys any constant we were tracking in these. The `this` a
|
||||
/// constructor threads through its registrations is callee-saved (rbx, r12-r15), so it survives — which
|
||||
/// is what makes the member-callback form readable at all.
|
||||
const CLOBBER: [usize; 9] = [0, RCX, RDX, RSI, RDI, R8, R9, 10, 11];
|
||||
|
||||
/// Longest string accepted as a command name. Names are identifiers; anything longer is not one, so the
|
||||
/// cap doubles as a validity gate.
|
||||
const MAX_NAME: usize = 64;
|
||||
|
||||
/// Bytes of the member-accessor object searched for the handler. The object holds a vtable, the receiver
|
||||
/// and the member function; the window is scanned for a UNIQUE executable pointer rather than indexed at
|
||||
/// a fixed slot, so a layout change drops the record instead of quietly moving the answer.
|
||||
const ACCESSOR_WINDOW: i64 = 0x40;
|
||||
|
||||
/// Instruction bytes of a registrar prologue examined for the invalid-handle store.
|
||||
const PROLOGUE: u64 = 96;
|
||||
|
||||
/// How a registration passes its callback. Recorded because it says how much inference produced the
|
||||
/// address: [`CallbackForm::Direct`] is read straight off the call, the other two resolve one step
|
||||
/// further.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum CallbackForm {
|
||||
/// The argument IS the handler.
|
||||
Direct,
|
||||
/// The argument is a static interface object; the handler is its first virtual.
|
||||
Interface,
|
||||
/// The argument is a member of the object under construction; the handler is the executable pointer
|
||||
/// the enclosing constructor stores into it.
|
||||
Member,
|
||||
}
|
||||
|
||||
impl CallbackForm {
|
||||
pub fn describe(self) -> &'static str {
|
||||
match self {
|
||||
CallbackForm::Direct => "direct",
|
||||
CallbackForm::Interface => "interface",
|
||||
CallbackForm::Member => "member",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One registered console command.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConsoleCommand {
|
||||
/// The console-facing name, exactly as Valve compiled it (`bot_add`, `+bugvoice`).
|
||||
pub name: String,
|
||||
/// Address of the handler.
|
||||
pub handler: u64,
|
||||
/// The raw flags word. Ships raw beside [`flag_names`] so a build that repurposes a bit can be
|
||||
/// re-read rather than silently mis-labelled.
|
||||
pub flags: u64,
|
||||
/// Valve's own help text. Empty when the registration passes none.
|
||||
pub description: String,
|
||||
pub form: CallbackForm,
|
||||
}
|
||||
|
||||
/// The flag bits whose meaning is MEASURED, not assumed: each was matched against Valve's published
|
||||
/// command dump across 742 CS2 commands carrying both a derived flags word and Valve's flag names, and
|
||||
/// each of these twelve separates that dump exactly — every command with the bit has the name, every
|
||||
/// command with the name has the bit.
|
||||
///
|
||||
/// Three further bits are set in the wild (1, 2 and 33) and are NOT listed, because no name in the dump
|
||||
/// matches them. Note also that the dump's `developmentonly`, `defensive` and `gamedll` are labels the
|
||||
/// dumper derives (the first two from the ABSENCE of `release`, the last from which module registered
|
||||
/// the command) rather than bits — inventing bits for them is the mistake this table exists to avoid.
|
||||
const FLAG_BITS: [(u32, &str); 12] = [
|
||||
(0, "linked_concommand"),
|
||||
(4, "hidden"),
|
||||
(11, "unlogged"),
|
||||
(13, "replicated"),
|
||||
(14, "cheat"),
|
||||
(17, "dontrecord"),
|
||||
(19, "release"),
|
||||
(23, "vconsole_fuzzy_matching"),
|
||||
(24, "server_can_execute"),
|
||||
(25, "client_can_execute"),
|
||||
(27, "vconsole_set_focus"),
|
||||
(28, "clientcmd_can_execute"),
|
||||
];
|
||||
|
||||
/// The names of the bits set in `flags` that have a measured meaning. Bits without one are omitted here
|
||||
/// and preserved in [`ConsoleCommand::flags`].
|
||||
pub fn flag_names(flags: u64) -> Vec<&'static str> {
|
||||
FLAG_BITS
|
||||
.iter()
|
||||
.filter(|(b, _)| flags & (1u64 << b) != 0)
|
||||
.map(|&(_, n)| n)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Index 0-15 of a GPR, after widening an 8/16/32-bit name to its 64-bit parent.
|
||||
fn gpr(r: Register) -> Option<u8> {
|
||||
let f = r.full_register();
|
||||
f.is_gpr64()
|
||||
.then(|| (f as usize - Register::RAX as usize) as u8)
|
||||
}
|
||||
|
||||
/// What a register provably holds. `Sym` is an offset from a value we never learned — a constructor's
|
||||
/// `this` — which is what lets a `lea rdx,[this+0x1c8]` argument be matched against a
|
||||
/// `mov [this+0x1e8],rax` store made by the same function.
|
||||
///
|
||||
/// The epoch is what makes that safe. Stores are collected across the WHOLE function, because the
|
||||
/// compiler sinks an accessor's handler store past the registration that consumes it. Over that span the
|
||||
/// base register is eventually reloaded — an epilogue's `pop rbx` alone would otherwise either discard
|
||||
/// every store or, worse, re-point them at a different object. Bumping a counter instead keeps each run
|
||||
/// of a register's life distinct, and only same-epoch pairs ever match.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum V {
|
||||
Unknown,
|
||||
Const(u64),
|
||||
Sym(u8, u32, i64),
|
||||
}
|
||||
|
||||
impl V {
|
||||
fn offset(self, d: i64) -> V {
|
||||
match self {
|
||||
V::Const(c) => V::Const(c.wrapping_add(d as u64)),
|
||||
V::Sym(b, e, dd) => V::Sym(b, e, dd.wrapping_add(d)),
|
||||
V::Unknown => V::Unknown,
|
||||
}
|
||||
}
|
||||
fn konst(self) -> Option<u64> {
|
||||
match self {
|
||||
V::Const(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Address of a `this`-relative slot: base register, that register's epoch, displacement.
|
||||
type Slot = (u8, u32, i64);
|
||||
|
||||
/// One call to a registrar, with what its argument registers held and the `this`-relative constants its
|
||||
/// enclosing function stored.
|
||||
struct Site {
|
||||
args: [V; 16],
|
||||
stores: std::sync::Arc<HashMap<Slot, u64>>,
|
||||
}
|
||||
|
||||
/// Does `f` open by storing the invalid-handle sentinel into `*rdi`? That is what a `ConCommandRef`
|
||||
/// constructor does before it registers, and it identifies the registrar SEMANTICALLY.
|
||||
///
|
||||
/// Ranking call targets by how many look like registrations is the tempting alternative and it is wrong
|
||||
/// twice over: libvscript's top-ranked such target is not the registrar (it yields six confident,
|
||||
/// entirely fictional commands), and libschemasystem passes an object rather than a function so its
|
||||
/// registrar never ranks at all.
|
||||
fn inits_invalid_handle(img: &CodeImage, f: u64) -> bool {
|
||||
// Saturating: `f` is a decoded near-branch target, so a crafted image can put it at the top of the
|
||||
// address space and a plain add wraps the range inside out. Found by `fuzz_concmd`.
|
||||
let Some(code) = img.code_range(f, f.saturating_add(PROLOGUE)) else {
|
||||
return false;
|
||||
};
|
||||
let mut insn = Instruction::default();
|
||||
let mut dec = Decoder::with_ip(64, code, f, DecoderOptions::NONE);
|
||||
while dec.can_decode() {
|
||||
dec.decode_out(&mut insn);
|
||||
if insn.mnemonic() == Mnemonic::Mov
|
||||
&& insn.op0_kind() == OpKind::Memory
|
||||
&& insn.memory_base() == Register::RDI
|
||||
&& insn.memory_index() == Register::None
|
||||
&& insn.memory_displacement64() == 0
|
||||
&& insn.memory_size().size() == 8
|
||||
&& matches!(
|
||||
insn.op1_kind(),
|
||||
OpKind::Immediate32 | OpKind::Immediate32to64 | OpKind::Immediate64
|
||||
)
|
||||
&& insn.immediate64() == 0xffff
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if insn.flow_control() == FlowControl::Return {
|
||||
break;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// A plausible console-command name: short, printable, no spaces or quoting.
|
||||
fn cmd_name(img: &CodeImage, va: u64) -> Option<String> {
|
||||
let s = img.read_c_string(va)?;
|
||||
let ok = !s.is_empty()
|
||||
&& s.len() <= MAX_NAME
|
||||
&& s.bytes()
|
||||
.all(|c| c.is_ascii_graphic() && c != b'"' && c != b'%');
|
||||
ok.then_some(s)
|
||||
}
|
||||
|
||||
/// Every console command `img` registers.
|
||||
pub fn console_commands(img: &CodeImage) -> Vec<ConsoleCommand> {
|
||||
let mut entries = crate::locate::candidate_entries(img);
|
||||
entries.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
|
||||
entries.sort_unstable();
|
||||
entries.dedup();
|
||||
|
||||
// Memoised so the prologue test runs once per distinct call target rather than once per call, and so
|
||||
// only registrar calls are ever materialised as a Site.
|
||||
let mut is_reg: HashMap<u64, bool> = HashMap::new();
|
||||
let mut sites: Vec<Site> = Vec::new();
|
||||
let mut factory = InstructionInfoFactory::new();
|
||||
|
||||
for (i, &start) in entries.iter().enumerate() {
|
||||
let end = entries.get(i + 1).copied().unwrap_or(u64::MAX);
|
||||
let Some(code) = img.code_range(start, end) else {
|
||||
continue;
|
||||
};
|
||||
// Values are tracked straight-line and reset at each function entry. A branch into the middle of
|
||||
// a tracked run could carry a stale value forward, which is why every recovered command is
|
||||
// re-validated against the image: the name string resolves, the handler is executable.
|
||||
let mut val = [V::Unknown; 16];
|
||||
let mut epoch = [0u32; 16];
|
||||
let mut stores: HashMap<Slot, u64> = HashMap::new();
|
||||
let mut found: Vec<[V; 16]> = Vec::new();
|
||||
let mut insn = Instruction::default();
|
||||
let mut dec = Decoder::with_ip(64, code, start, DecoderOptions::NONE);
|
||||
while dec.can_decode() {
|
||||
dec.decode_out(&mut insn);
|
||||
|
||||
if insn.flow_control() == FlowControl::Call
|
||||
&& matches!(
|
||||
insn.op0_kind(),
|
||||
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
|
||||
)
|
||||
{
|
||||
let t = insn.near_branch_target();
|
||||
if *is_reg
|
||||
.entry(t)
|
||||
.or_insert_with(|| inits_invalid_handle(img, t))
|
||||
{
|
||||
found.push(val);
|
||||
}
|
||||
for c in CLOBBER {
|
||||
val[c] = V::Unknown;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// A store of a known constant to a `this`-relative slot: how the member form records its
|
||||
// handler.
|
||||
if insn.mnemonic() == Mnemonic::Mov
|
||||
&& insn.op0_kind() == OpKind::Memory
|
||||
&& insn.memory_index() == Register::None
|
||||
&& insn.op1_kind() == OpKind::Register
|
||||
&& let Some(b) = gpr(insn.memory_base())
|
||||
&& let Some(s) = gpr(insn.op1_register())
|
||||
&& let Some(v) = val[s as usize].konst()
|
||||
{
|
||||
let d = insn.memory_displacement64() as i64;
|
||||
match val[b as usize] {
|
||||
V::Unknown => {
|
||||
stores.insert((b, epoch[b as usize], d), v);
|
||||
}
|
||||
V::Sym(bb, e, dd) => {
|
||||
stores.insert((bb, e, dd.wrapping_add(d)), v);
|
||||
}
|
||||
// An absolute address needs no note: it can be read back off the image directly.
|
||||
V::Const(_) => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match insn.mnemonic() {
|
||||
// `lea r,[rip+d]` is a string/global/function address; `lea r,[base+d]` walks to a member.
|
||||
Mnemonic::Lea => {
|
||||
if let Some(d) = gpr(insn.op0_register()) {
|
||||
val[d as usize] = if insn.is_ip_rel_memory_operand() {
|
||||
V::Const(insn.ip_rel_memory_address())
|
||||
} else if insn.memory_index() == Register::None {
|
||||
gpr(insn.memory_base())
|
||||
.map_or(V::Unknown, |b| match val[b as usize] {
|
||||
V::Unknown => V::Sym(b, epoch[b as usize], 0),
|
||||
v => v,
|
||||
})
|
||||
.offset(insn.memory_displacement64() as i64)
|
||||
} else {
|
||||
V::Unknown
|
||||
};
|
||||
}
|
||||
}
|
||||
Mnemonic::Mov => {
|
||||
if let Some(d) = gpr(insn.op0_register()) {
|
||||
val[d as usize] = match insn.op1_kind() {
|
||||
OpKind::Immediate8to64
|
||||
| OpKind::Immediate32to64
|
||||
| OpKind::Immediate64 => V::Const(insn.immediate64()),
|
||||
OpKind::Immediate8 | OpKind::Immediate16 | OpKind::Immediate32 => {
|
||||
V::Const(u64::from(insn.immediate32()))
|
||||
}
|
||||
OpKind::Register => {
|
||||
gpr(insn.op1_register()).map_or(V::Unknown, |s| val[s as usize])
|
||||
}
|
||||
_ => V::Unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
// the compiler's idiomatic zero
|
||||
Mnemonic::Xor => {
|
||||
if let (Some(d), Some(s)) = (gpr(insn.op0_register()), gpr(insn.op1_register()))
|
||||
{
|
||||
val[d as usize] = if d == s { V::Const(0) } else { V::Unknown };
|
||||
}
|
||||
}
|
||||
// Anything else: forget only what it WRITES. Invalidating every register OPERAND instead
|
||||
// ends the epoch of the `this` register on the first `push`/`cmp` against it, which is
|
||||
// most of a constructor and loses every store it made.
|
||||
_ => {
|
||||
for ur in factory.info(&insn).used_registers() {
|
||||
if matches!(
|
||||
ur.access(),
|
||||
OpAccess::Write | OpAccess::ReadWrite | OpAccess::CondWrite
|
||||
) && let Some(d) = gpr(ur.register())
|
||||
{
|
||||
val[d as usize] = V::Unknown;
|
||||
epoch[d as usize] = epoch[d as usize].saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found.is_empty() {
|
||||
let stores = std::sync::Arc::new(stores);
|
||||
sites.extend(found.into_iter().map(|args| Site {
|
||||
args,
|
||||
stores: stores.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let mut out: Vec<ConsoleCommand> = Vec::new();
|
||||
for s in &sites {
|
||||
let Some(name) = s.args[RSI].konst().and_then(|v| cmd_name(img, v)) else {
|
||||
continue;
|
||||
};
|
||||
// The fourth argument is the callback TYPE, and several of its values (0, 2 and 4 all occur)
|
||||
// pass a raw function pointer. So accept on what the third argument provably IS rather than on
|
||||
// a decoded enum: if it lands in executable code, it is the handler. Type 1 alone passes an
|
||||
// INTERFACE, and that one does need the enum to tell the two indirections apart.
|
||||
let (handler, form) = match (s.args[RCX], s.args[RDX]) {
|
||||
(_, V::Const(p)) if img.is_code(p) => (Some(p), CallbackForm::Direct),
|
||||
(V::Const(1), V::Const(p)) => (
|
||||
img.read_ptr(p)
|
||||
.and_then(|vt| img.read_ptr(vt))
|
||||
.filter(|&f| img.is_code(f)),
|
||||
CallbackForm::Interface,
|
||||
),
|
||||
(V::Const(1), V::Sym(b, e, d)) => {
|
||||
// The displacement is accumulated with wrapping arithmetic from file-controlled
|
||||
// values, so walking the window must wrap too rather than overflow.
|
||||
let mut hits: Vec<u64> = (0..ACCESSOR_WINDOW / 8)
|
||||
.filter_map(|k| s.stores.get(&(b, e, d.wrapping_add(k * 8))).copied())
|
||||
.filter(|&v| img.is_code(v))
|
||||
.collect();
|
||||
// Sorted before de-duplicating so "exactly one" means one distinct address, not one
|
||||
// run of adjacent slots — an object may hold the same pointer at two offsets.
|
||||
hits.sort_unstable();
|
||||
hits.dedup();
|
||||
// Exactly one executable pointer in the object, or none is claimed.
|
||||
((hits.len() == 1).then(|| hits[0]), CallbackForm::Member)
|
||||
}
|
||||
_ => (None, CallbackForm::Direct),
|
||||
};
|
||||
// Flags are only recorded when they were actually read. Defaulting an untracked r9 to zero
|
||||
// would ship "no flags" for a command whose flags we merely failed to follow, and a consumer
|
||||
// cannot tell those apart.
|
||||
let (Some(handler), Some(flags)) = (handler, s.args[R9].konst()) else {
|
||||
continue;
|
||||
};
|
||||
out.push(ConsoleCommand {
|
||||
name,
|
||||
handler,
|
||||
flags,
|
||||
description: s.args[R8]
|
||||
.konst()
|
||||
.and_then(|v| img.read_c_string(v))
|
||||
.unwrap_or_default(),
|
||||
form,
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| (&a.name, a.handler).cmp(&(&b.name, b.handler)));
|
||||
out.dedup_by(|a, b| a.name == b.name && a.handler == b.handler);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_measured_flag_bits_are_named() {
|
||||
// bot_add ships 0x80004 = bits 2 and 19. Bit 19 is `release`; bit 2 stays unnamed because no
|
||||
// name in Valve's dump matches it, and naming it anyway is the whole mistake to avoid.
|
||||
assert_eq!(flag_names(0x80004), vec!["release"]);
|
||||
// bot_place ships 0x4004 = bits 2 and 14 — bit 14 is `cheat`.
|
||||
assert_eq!(flag_names(0x4004), vec!["cheat"]);
|
||||
// A command with no flags names none, rather than falling back to a default.
|
||||
assert!(flag_names(0).is_empty());
|
||||
// Every listed bit is distinct and in range.
|
||||
let mut seen: Vec<u32> = FLAG_BITS.iter().map(|&(b, _)| b).collect();
|
||||
seen.sort_unstable();
|
||||
seen.dedup();
|
||||
assert_eq!(seen.len(), FLAG_BITS.len());
|
||||
assert!(FLAG_BITS.iter().all(|&(b, _)| b < 64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbolic_offsets_keep_the_base_and_track_the_epoch() {
|
||||
// A `this`-relative walk composes, so `lea rax,[rbx+0x1c8]` then `lea rdx,[rax+0x40]` addresses
|
||||
// the same object the constructor stored into.
|
||||
assert_eq!(V::Sym(3, 0, 0x1c8).offset(0x40), V::Sym(3, 0, 0x208));
|
||||
// A constant walk stays constant.
|
||||
assert_eq!(V::Const(0x1000).offset(8), V::Const(0x1008));
|
||||
// Nothing is invented from nothing.
|
||||
assert_eq!(V::Unknown.offset(8), V::Unknown);
|
||||
// Two runs of the same register never address each other's slots.
|
||||
assert_ne!(V::Sym(3, 0, 0x1c8), V::Sym(3, 1, 0x1c8));
|
||||
// Only a constant is a usable address.
|
||||
assert_eq!(V::Const(7).konst(), Some(7));
|
||||
assert_eq!(V::Sym(3, 0, 7).konst(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_name_is_an_identifier_not_prose() {
|
||||
// The gate is applied to a resolved string, so exercise it through the same predicate the
|
||||
// reader uses by checking the shape rules it encodes.
|
||||
let ok = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.len() <= MAX_NAME
|
||||
&& s.bytes()
|
||||
.all(|c| c.is_ascii_graphic() && c != b'"' && c != b'%')
|
||||
};
|
||||
assert!(ok("bot_add"));
|
||||
assert!(ok("+bugvoice")); // an on/off pair is a real command name
|
||||
assert!(!ok("")); // an empty string is not a name
|
||||
assert!(!ok("Adds a bot matching the given criteria.")); // a description
|
||||
assert!(!ok("%s: no varname specified\n")); // a format string
|
||||
assert!(!ok(&"x".repeat(MAX_NAME + 1)));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue