diff --git a/src/Neo.CLI/CLI/MainService.Tools.cs b/src/Neo.CLI/CLI/MainService.Tools.cs index a8c998b25..675b592e5 100644 --- a/src/Neo.CLI/CLI/MainService.Tools.cs +++ b/src/Neo.CLI/CLI/MainService.Tools.cs @@ -409,17 +409,10 @@ private static string Base64Fixed(string str) /// /// Base64 Smart Contract Script Analysis /// input: DARkYXRhAgBlzR0MFPdcrAXPVptVduMEs2lf1jQjxKIKDBT3XKwFz1abVXbjBLNpX9Y0I8SiChTAHwwIdHJhbnNmZXIMFKNSbimM12LkFYX/8KGvm2ttFxulQWJ9W1I= - /// output: - /// PUSHDATA1 data - /// PUSHINT32 500000000 - /// PUSHDATA1 0x0aa2c42334d65f69b304e376559b56cf05ac5cf7 - /// PUSHDATA1 0x0aa2c42334d65f69b304e376559b56cf05ac5cf7 - /// PUSH4 - /// PACK - /// PUSH15 - /// PUSHDATA1 transfer - /// PUSHDATA1 0xa51b176d6b9bafa1f0ff8515e462d78c296e52a3 - /// SYSCALL System.Contract.Call + /// output (VMInstruction.ToString, with an L-prefix padded to at least 4 digits): + /// L0000:0000 PUSHDATA1 64617461 // data + /// L0001:0006 PUSHINT32 500000000 + /// L0002:000B PUSHDATA1 0AA2C42334D65F69B304E376559B56CF05AC5CF7 /// [ParseFunction("Base64 Smart Contract Script Analysis")] private string? ScriptsToOpCode(string base64) @@ -427,19 +420,7 @@ private static string Base64Fixed(string str) try { var bytes = Convert.FromBase64String(base64); - var sb = new StringBuilder(); - var line = 0; - - foreach (var instruct in new VMInstruction(bytes)) - { - if (instruct.OperandSize == 0) - sb.AppendFormat("L{0:D04}:{1:X04} {2}{3}", line, instruct.Position, instruct.OpCode, Environment.NewLine); - else - sb.AppendFormat("L{0:D04}:{1:X04} {2,-10}{3}{4}", line, instruct.Position, instruct.OpCode, instruct.DecodeOperand(), Environment.NewLine); - line++; - } - - return sb.ToString(); + return VMInstruction.FormatListing(bytes); } catch { diff --git a/src/Neo.CLI/Neo.CLI.csproj b/src/Neo.CLI/Neo.CLI.csproj index 387151cf8..66f872596 100644 --- a/src/Neo.CLI/Neo.CLI.csproj +++ b/src/Neo.CLI/Neo.CLI.csproj @@ -20,6 +20,10 @@ + + + + diff --git a/src/Neo.CLI/Tools/VMInstruction.cs b/src/Neo.CLI/Tools/VMInstruction.cs index 086864156..a39b9f6b7 100644 --- a/src/Neo.CLI/Tools/VMInstruction.cs +++ b/src/Neo.CLI/Tools/VMInstruction.cs @@ -9,15 +9,18 @@ // Redistribution and use in source and binary forms with or without // modifications are permitted. +using Neo.Cryptography.ECC; using Neo.SmartContract; using Neo.VM; using System.Buffers.Binary; using System.Collections; using System.Diagnostics; +using System.Globalization; using System.Numerics; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; +using System.Text.Unicode; namespace Neo.CLI; @@ -97,11 +100,43 @@ IEnumerator IEnumerable.GetEnumerator() => public override string ToString() { + var posWidth = HexWidth(_script.Length); + if (OperandSize == 0) + return string.Format($"{{0:X{posWidth}}} {{1}}", Position, OpCode); + return string.Format($"{{0:X{posWidth}}} {{1,-10}}{{2}}", Position, OpCode, DecodeOperand()); + } + + /// + /// Lists instructions as L0000:0000 NOP. The L-prefix width is at least 4 digits + /// and grows with the script size / instruction count. + /// + internal static string FormatListing(ReadOnlyMemory script) + { + var rows = new List(); + foreach (var instruct in new VMInstruction(script)) + rows.Add(instruct); + + var lastIndex = Math.Max(0, rows.Count - 1); + var width = DecimalWidth(Math.Max(lastIndex, script.Length)); var sb = new StringBuilder(); - sb.AppendFormat("{0:X04} {1,-10}{2}", Position, OpCode, DecodeOperand()); + for (var i = 0; i < rows.Count; i++) + { + sb.Append('L'); + sb.Append(i.ToString($"D{width}", CultureInfo.InvariantCulture)); + sb.Append(':'); + sb.Append(rows[i]); + sb.AppendLine(); + } + return sb.ToString(); } + internal static int DecimalWidth(int value) + => Math.Max(4, Math.Max(0, value).ToString(CultureInfo.InvariantCulture).Length); + + internal static int HexWidth(int length) + => Math.Max(4, Math.Max(0, length == 0 ? 0 : length - 1).ToString("X").Length); + public T AsToken(uint index = 0) where T : unmanaged { @@ -119,8 +154,6 @@ public T AsToken(uint index = 0) public string DecodeOperand() { var operand = Operand[OperandPrefixSize..].ToArray(); - var asStr = Encoding.UTF8.GetString(operand); - var readable = asStr.All(char.IsAsciiLetterOrDigit); return OpCode switch { @@ -157,17 +190,109 @@ OpCode.STSFLD or OpCode.LDARG or OpCode.STARG or OpCode.INITSSLOT => $"{AsToken()}", - OpCode.PUSHINT8 => $"{AsToken()}", - OpCode.PUSHINT16 => $"{AsToken()}", - OpCode.PUSHINT32 => $"{AsToken()}", - OpCode.PUSHINT64 => $"{AsToken()}", + OpCode.PUSHINT8 => FormatInteger(AsToken()), + OpCode.PUSHINT16 => FormatInteger(AsToken()), + OpCode.PUSHINT32 => FormatInteger(AsToken()), + OpCode.PUSHINT64 => FormatInteger(AsToken()), OpCode.PUSHINT128 or OpCode.PUSHINT256 => $"{new BigInteger(operand)}", OpCode.SYSCALL => $"[{ApplicationEngine.Services[Unsafe.As(ref operand[0])].Name}]", OpCode.PUSHDATA1 or OpCode.PUSHDATA2 or - OpCode.PUSHDATA4 => readable ? $"{Convert.ToHexString(operand)} // {asStr}" : Convert.ToHexString(operand), - _ => readable ? $"\"{asStr}\"" : $"{Convert.ToHexString(operand)}", + OpCode.PUSHDATA4 => FormatPushData(operand), + _ => TryGetReadableText(operand, out var text) + ? $"\"{text}\"" + : $"{Convert.ToHexString(operand)} // blob {operand.Length} bytes", + }; + } + + private static string FormatPushData(byte[] operand) + { + var hex = Convert.ToHexString(operand); + if (TryGetReadableText(operand, out var text)) + return $"{hex} // {text}"; + + if (TryDecodeEcPoint(operand, out var point)) + return $"{hex} // {point}"; + + if (operand.Length == UInt160.Length) + return $"{hex} // {new UInt160(operand)}"; + + if (operand.Length == UInt256.Length) + return $"{hex} // {new UInt256(operand)}"; + + return $"{hex} // blob {operand.Length} bytes"; + } + + private static string FormatInteger(long value) + => value.ToString(CultureInfo.InvariantCulture); + + /// + /// Strict UTF-8 with at least one non-control rune. Control runes are escaped + /// (\n, \r, \t, \xNN) so they are not written as raw output. + /// Invalid sequences are rejected; replacement is not used. + /// + private static bool TryGetReadableText(ReadOnlySpan utf8, out string text) + { + text = null!; + if (utf8.IsEmpty || !Utf8.IsValid(utf8)) + return false; + + var decoded = Encoding.UTF8.GetString(utf8); + var hasGraphic = false; + var sb = new StringBuilder(decoded.Length); + foreach (var rune in decoded.EnumerateRunes()) + { + if (Rune.IsControl(rune) || rune.Value == 0x7F) + sb.Append(EscapeControlRune(rune)); + else + { + hasGraphic = true; + sb.Append(rune); + } + } + + if (!hasGraphic) + return false; + + text = sb.ToString(); + return true; + } + + private static string EscapeControlRune(Rune rune) + => rune.Value switch + { + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + '\0' => "\\0", + _ => rune.Value <= 0xFF ? $"\\x{rune.Value:X2}" : $"\\u{rune.Value:X4}", }; + + private static bool TryDecodeEcPoint(byte[] operand, out ECPoint point) + { + point = null!; + if (operand.Length is not (33 or 65)) + return false; + if (operand[0] is not (0x02 or 0x03 or 0x04)) + return false; + try + { + point = ECPoint.DecodePoint(operand, ECCurve.Secp256r1); + return true; + } + catch (FormatException) + { + try + { + point = ECPoint.DecodePoint(operand, ECCurve.Secp256k1); + return true; + } + catch (FormatException) + { + return false; + } + } } + } diff --git a/tests/Neo.CLI.Tests/UT_VMInstruction.cs b/tests/Neo.CLI.Tests/UT_VMInstruction.cs new file mode 100644 index 000000000..3373f3637 --- /dev/null +++ b/tests/Neo.CLI.Tests/UT_VMInstruction.cs @@ -0,0 +1,163 @@ +// Copyright (C) 2015-2026 The Neo Project. +// +// UT_VMInstruction.cs file belongs to the neo project and is free +// software distributed under the MIT software license, see the +// accompanying file LICENSE in the main directory of the +// repository or http://www.opensource.org/licenses/mit-license.php +// for more details. +// +// Redistribution and use in source and binary forms with or without +// modifications are permitted. + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Neo.CLI; +using Neo.Cryptography.ECC; +using Neo.Extensions; +using Neo.VM; + +namespace Neo.CLI.Tests; + +[TestClass] +public class UT_VMInstruction +{ + [TestMethod] + public void ToString_OmitsOperandWhenNone() + { + var instruction = new VMInstruction(new byte[] { (byte)OpCode.NOP }); + Assert.AreEqual("0000 NOP", instruction.ToString()); + } + + [TestMethod] + public void ToString_IncludesDecodedOperand() + { + var instruction = new VMInstruction(new byte[] { (byte)OpCode.PUSHINT8, 7 }); + Assert.AreEqual("0000 PUSHINT8 7", instruction.ToString()); + } + + [TestMethod] + public void Enumerator_WalksFullScript() + { + var script = new byte[] { (byte)OpCode.NOP, (byte)OpCode.RET }; + var lines = new VMInstruction(script).Select(i => i.ToString()).ToArray(); + Assert.HasCount(2, lines); + Assert.AreEqual("0000 NOP", lines[0]); + Assert.AreEqual("0001 RET", lines[1]); + } + + [TestMethod] + public void DecimalWidth_GrowsPastFourDigits() + { + Assert.AreEqual(4, VMInstruction.DecimalWidth(0)); + Assert.AreEqual(4, VMInstruction.DecimalWidth(9999)); + Assert.AreEqual(5, VMInstruction.DecimalWidth(10000)); + Assert.AreEqual(6, VMInstruction.DecimalWidth(100000)); + } + + [TestMethod] + public void HexWidth_GrowsWithScriptLength() + { + Assert.AreEqual(4, VMInstruction.HexWidth(1)); + Assert.AreEqual(4, VMInstruction.HexWidth(0x10000)); + Assert.AreEqual(5, VMInstruction.HexWidth(0x10001)); + } + + [TestMethod] + public void FormatListing_PadsLineNumbersToAtLeastFourDigits() + { + var listing = VMInstruction.FormatListing(new byte[] { (byte)OpCode.NOP, (byte)OpCode.RET }); + Assert.StartsWith("L0000:", listing); + Assert.Contains("L0001:", listing); + } + + [TestMethod] + public void DecodeOperand_CommentsReadableTextIncludingColon() + { + var text = "TWELVEDATA:CNY-USD"u8.ToArray(); + var instruction = new VMInstruction(PushData1(text)); + Assert.Contains(" // TWELVEDATA:CNY-USD", instruction.DecodeOperand()); + } + + [TestMethod] + public void DecodeOperand_CommentsStrictUtf8PrintableRunes() + { + var text = "价格 café — ✓"u8.ToArray(); + var instruction = new VMInstruction(PushData1(text)); + Assert.Contains(" // 价格 café — ✓", instruction.DecodeOperand()); + } + + [TestMethod] + public void DecodeOperand_RejectsInvalidUtf8AsText() + { + var invalid = new byte[] { 0xC0, 0x80, 0xFF }; + var instruction = new VMInstruction(PushData1(invalid)); + Assert.AreEqual($"{Convert.ToHexString(invalid)} // blob {invalid.Length} bytes", instruction.DecodeOperand()); + } + + [TestMethod] + public void DecodeOperand_EscapesControlRunesInText() + { + var withNl = "ab\ncd"u8.ToArray(); + var instruction = new VMInstruction(PushData1(withNl)); + Assert.Contains(" // ab\\ncd", instruction.DecodeOperand()); + Assert.DoesNotContain("ab\ncd", instruction.DecodeOperand()); + } + + [TestMethod] + public void DecodeOperand_FormatsUInt160() + { + var hash = new UInt160(Convert.FromHexString("ABCC7F51C334D4F958BE8B6C54142AC4493F0103")); + var instruction = new VMInstruction(PushData1(hash.ToArray())); + Assert.AreEqual($"{Convert.ToHexString(hash.ToArray())} // {hash}", instruction.DecodeOperand()); + } + + [TestMethod] + public void DecodeOperand_FormatsUInt256() + { + var hash = new UInt256(new byte[32]); + var instruction = new VMInstruction(PushData1(hash.ToArray())); + Assert.AreEqual($"{Convert.ToHexString(hash.ToArray())} // {hash}", instruction.DecodeOperand()); + } + + [TestMethod] + public void DecodeOperand_FormatsEcPoint() + { + var point = ECPoint.Parse("03b209fd4f53a7170ea4444e0cb0a6bb6a53c2bd016926989cf85f9b0fba17a70c", ECCurve.Secp256r1); + var encoded = point.EncodePoint(true); + var instruction = new VMInstruction(PushData1(encoded)); + Assert.AreEqual($"{Convert.ToHexString(encoded)} // {point}", instruction.DecodeOperand()); + } + + [TestMethod] + public void DecodeOperand_DoesNotTreatIntegersAsUnixTimestamps() + { + // 2491470000 fits 2000–2100 as unix seconds (2048-12-13) but is a + // contract integer, not a timestamp. Same for 1787908101. + var pushInt64 = new byte[9]; + pushInt64[0] = (byte)OpCode.PUSHINT64; + BitConverter.GetBytes(2_491_470_000L).CopyTo(pushInt64, 1); + Assert.AreEqual("2491470000", new VMInstruction(pushInt64).DecodeOperand()); + + var pushInt32 = new byte[5]; + pushInt32[0] = (byte)OpCode.PUSHINT32; + BitConverter.GetBytes(1_787_908_101).CopyTo(pushInt32, 1); + Assert.AreEqual("1787908101", new VMInstruction(pushInt32).DecodeOperand()); + } + + [TestMethod] + public void DecodeOperand_LeavesNonTypedPushDataAsHex() + { + var blob = Convert.FromHexString("71BDDFD76DBDEF67BCF1C71AE77E787B973C69F79C79FF1F"); + Assert.AreEqual(24, blob.Length); + var instruction = new VMInstruction(PushData1(blob)); + Assert.AreEqual($"{Convert.ToHexString(blob)} // blob 24 bytes", instruction.DecodeOperand()); + } + + private static byte[] PushData1(byte[] data) + { + var script = new byte[2 + data.Length]; + script[0] = (byte)OpCode.PUSHDATA1; + script[1] = (byte)data.Length; + Buffer.BlockCopy(data, 0, script, 2, data.Length); + return script; + } +}