فهرست مقاله
وقتی نام یک حسگر از بیرون برنامه میآید، باید روشن باشد که این متن قرار است «مقدارِ جستوجو» باشد یا بخشی از زبانِ دستور. یک کوتیشن در نام، مشکل کوچکی برای چسباندن رشتهها نیست؛ میتواند معنای عبارت ساختهشده را عوض کند. این درس با رکوردهای کاملاً ساختگی، اتصال مقدار را از انتخاب ساختار جدا میکند. همهٔ آزمایشها در حافظهاند و هیچ پایگاه دادهٔ موجود، شبکه یا اطلاعات مشتری به برنامه داده نمیشود.
۱. متنِ ورودی کجای دستور قرار میگیرد؟

رکورد آموزشی چهار مؤلفه دارد: شناسهٔ رویداد، برچسب متنی، زمان برحسب میلیثانیه از مبدأ دلخواه و ولتاژ صحیح برحسب میلیولت. پنج رکورد اولیه شامل برچسب ساده، نام O’Brien، متنِ دارای کوتیشن و عبارت منطقی، و یک برچسب فارسی دارای درصد و زیرخطاند. وجود این نویسهها بهتنهایی داده را نامعتبر نمیکند. معنای برچسب در این قرارداد، تطبیق دقیق متن است؛ نه نام دستگاهِ احرازشده یا دستور جستوجوی آزاد.
برای نامِ دارای آپاستروف، تابع درست همان شناسهٔ دو را برمیگرداند. شاهدِ حساستر، برچسبی است که با کوتیشن آغاز میشود و ادامهاش شرط OR و توضیح SQL دارد. چون این متن واقعاً برچسب رکورد سوم است، پاسخ درستِ جستوجوی دقیق فقط شناسهٔ سه است. یک SELECT عمداً نادرست در همان حافظه، متن را داخل دستور میچسباند و هر پنج ردیف را انتخاب میکند. این تفاوت، شاهدِ تغییر معنای فیلتر است؛ دستوری برای تخریب یا دسترسی به سامانهٔ دیگری اجرا نمیکنیم.
در مسیر اصلی، متن SQL ثابت میماند و مقدار از آرگومان دوم execute میآید. راهنمای رسمی جاینگهدارهای sqlite3 همین جدایی را توضیح میدهد. برای یک علامت سؤال، tuple تکعضوی لازم داریم؛ ویرگول آن بخشی از ساختن tuple است. تابع ما خطای تعداد نادرست پارامترها را به خروجی خالی تبدیل نمیکند: خطای رابط با «برچسبِ موجود نیست» معنای متفاوتی دارد و باید قابل تشخیص بماند.
۲. پارامتر برای مقدار است، نه نامِ ستون

جاینگهدار، محل یک مقدار در عبارت است. بخش Parameters در مرجع عبارتهای SQLite و مرجع اتصال مقدار به دستور آماده این قرارداد را مشخص میکنند. اگر متن voltage_mv را به SELECT ? بدهیم، هر ردیف همان رشته را برمیگرداند؛ ستون ولتاژ انتخاب نمیشود. بنابراین قرار دادن نام ستون در آرگومان پارامتر، راهی برای ساخت پرسوجوی دارای ستون متغیر نیست. این تفکیک را با خروجی واقعی در برنامه میبینیم.
برنامه فقط دو کلید ASCII برای ترتیب میپذیرد: time و voltage. اولی به قطعهٔ ثابتِ زمان و شناسه، دومی به قطعهٔ ثابتِ ولتاژ و شناسه نگاشت میشود. متن ورودیِ کاربر هرگز به جای آن قطعه قرار نمیگیرد. شناسهٔ یکتا، تساوی زمان یا ولتاژ را میشکند. کلید ناشناخته رد میشود؛ آن را به یک نام ستونِ نقلقولگذاریشده تبدیل نمیکنیم. این فهرستِ بسته، تصمیم صریح دربارهٔ ساختار مجاز است و وعدهٔ عمومی برای هر سازندهٔ SQL نیست.
برچسب برای ذخیره و فیلتر باید str دقیق، بین یک تا شصتوچهار نویسه، فاقد NUL و قابل تبدیل به UTF-8 باشد. این محدودیتها انتخاب رابط کوچک ما هستند؛ جاینگهدار بهتنهایی چنین اعتبارسنجیای انجام نمیدهد. کوتیشن، درصد و زیرخط مجاز میمانند. در شرط مساوی، درصد و زیرخط الگو نیستند؛ اگر بعداً عملگر LIKE اضافه شود، قواعد الگوی آن نیازمند قرارداد جداست. همچنین برنامه شکلهای ترکیبی یونیکد را یکسانسازی نمیکند؛ دو نمایش متفاوتِ یک حرف میتوانند دو برچسب جدا بمانند.
۳. کد کاملِ فیلتر و ترتیبِ مجاز

حداکثر شصتوچهار ردیف میپذیریم؛ مجموعه و هر ردیف، tuple دقیقاند. شناسه عدد صحیح یکتای یک تا یکمیلیون، زمان صفر تا یکمیلیارد میلیثانیه و ولتاژ منفی پنجهزار تا مثبت پنجهزار میلیولت است. bool، اعشاری، رشتهٔ عددی و زیرکلاسهای عدد صحیح در این مرز پذیرفته نمیشوند. اعدادِ کوچکِ قراردادی در دامنهٔ SQLite جا میشوند؛ این بازه از مشخصات یک حسگر واقعی نیامده و دقت اندازهگیری را تعیین نمیکند.
تابع سازنده تنها پایگاه حافظهایِ مخصوص همین طرح را ایجاد میکند؛ تابع انتخاب برای همین اتصال و جدول تعریف شده است. استفاده از آن روی طرح دلخواه، افزونه یا تابع SQL ثبتشدهٔ ناشناخته در قرارداد نیست. اتصال با autocommit=True ساخته و با closing صریح بسته میشود. مدیر زمینهٔ خود Connection مسئول بستن اتصال نیست. فایل زیر را با نام sqlite_bound_values_contract.py ذخیره و با python sqlite_bound_values_contract.py اجرا کنید؛ کتابخانهٔ خارجی لازم ندارد.
"""Bounded exact-label SELECT contract, local synthetic SQLite only."""
from contextlib import closing
import json
import platform
import sqlite3
ORDERS = {"time": "sample_ms, event_id", "voltage": "voltage_mv, event_id"}
def label_text(value):
if type(value) is not str:
raise TypeError("label must be exact str")
if not 1 <= len(value) <= 64 or "\x00" in value:
raise ValueError("label needs 1..64 characters without NUL")
try:
value.encode("utf-8")
except UnicodeEncodeError as error:
raise ValueError("label must encode as UTF-8") from error
return value
def validate_records(records):
if type(records) is not tuple or any(type(row) is not tuple for row in records):
raise TypeError("records and rows must be exact tuples")
if len(records) > 64 or any(len(row) != 4 for row in records):
raise ValueError("at most 64 four-field rows required")
identifiers = set()
for event_id, label, sample_ms, voltage_mv in records:
if any(type(x) is not int for x in (event_id, sample_ms, voltage_mv)):
raise TypeError("integer fields exclude bool and numeric subclasses")
if not 1 <= event_id <= 1_000_000 or event_id in identifiers:
raise ValueError("event identifiers must be unique in 1..1000000")
if not 0 <= sample_ms <= 1_000_000_000 or not -5000 <= voltage_mv <= 5000:
raise ValueError("time or voltage outside this example's bounds")
label_text(label)
identifiers.add(event_id)
return records
def database(records):
validate_records(records)
connection = sqlite3.connect(":memory:", autocommit=True)
try:
connection.execute("CREATE TABLE reading(event_id INTEGER PRIMARY KEY, label TEXT NOT NULL COLLATE BINARY, sample_ms INTEGER NOT NULL, voltage_mv INTEGER NOT NULL)")
connection.executemany("INSERT INTO reading VALUES(?,?,?,?)", records)
except BaseException:
connection.close()
raise
return connection
def select_label(connection, label, sort_by="time"):
label_text(label)
if type(sort_by) is not str:
raise TypeError("sort key must be exact str")
if sort_by not in ORDERS:
raise ValueError("unknown sort key")
# The only structural choice comes from fixed program text. User labels
# are always bound as a value, never interpolated into this statement.
sql = "SELECT event_id,label,sample_ms,voltage_mv FROM reading WHERE label = ? ORDER BY " + ORDERS[sort_by]
return tuple(connection.execute(sql, (label,)).fetchall())
def predicate_oracle(records, label, sort_by):
# No SQLite, SQL construction, escaping, parameter or query helper reused.
index = {"time": 2, "voltage": 3}[sort_by]
selected = [row for row in records if row[1] == label]
return tuple(sorted(selected, key=lambda row: (row[index], row[0])))
def raises(exception, function, *args):
try:
function(*args)
except exception:
return True
return False
def run():
payload = "' OR 1=1 --"
records = ((1, "pump-A", 0, 1200), (2, "O'Brien", 5, -400),
(3, payload, 3, 900), (4, "pump-A", 0, 1100),
(5, "\u067e\u0645\u067e_%", 10, 2000))
checks = {}
with closing(database(records)) as connection:
quoted = select_label(connection, "O'Brien")
safe_payload = select_label(connection, payload)
# Deliberately unsafe SELECT-only contrast on our own in-memory fixture.
unsafe_sql = "SELECT event_id FROM reading WHERE label = '" + payload + "' ORDER BY event_id"
unsafe_ids = tuple(row[0] for row in connection.execute(unsafe_sql))
checks["apostrophe_is_bound_literal_data"] = quoted == (records[1],)
checks["local_SELECT_only_interpolation_counterexample"] = (tuple(row[0] for row in safe_payload) == (3,) and unsafe_ids == (1, 2, 3, 4, 5))
checks["allowlisted_order_with_unique_secondary_key"] = (tuple(row[0] for row in select_label(connection, "pump-A")) == (1, 4)
and tuple(row[0] for row in select_label(connection, "pump-A", "voltage")) == (4, 1))
checks["unknown_label_is_empty_not_an_error"] = select_label(connection, "absent") == ()
checks["percent_underscore_are_literal_under_equality"] = select_label(connection, "\u067e\u0645\u067e_%") == (records[4],) and select_label(connection, "%") == ()
values = tuple(row[0] for row in connection.execute("SELECT ? FROM reading ORDER BY event_id", ("voltage_mv",)))
checks["bound_parameter_is_not_a_column_identifier"] = values == ("voltage_mv",) * 5 and values != tuple(row[3] for row in records)
checks["binding_arity_errors_propagate"] = raises(sqlite3.ProgrammingError, connection.execute, "SELECT event_id FROM reading WHERE label=?", ()) and raises(sqlite3.ProgrammingError, connection.execute, "SELECT ?", (1, 2))
checks["query_key_type_and_unknown_structure_rejected"] = all(raises(ValueError, select_label, connection, "pump-A", key) for key in ["sample_ms DESC", "time; SELECT 1", "TIME", ""]) and raises(TypeError, select_label, connection, "pump-A", True)
checks["filter_value_type_length_encoding_rejected"] = all(raises(TypeError, select_label, connection, x) for x in [None, True, 5, b"pump-A"]) and all(raises(ValueError, select_label, connection, x) for x in ["", "x" * 65, "a\x00b", "\ud800"])
checks["retrieval_does_not_mutate_fixture"] = tuple(connection.execute("SELECT event_id,label,sample_ms,voltage_mv FROM reading ORDER BY event_id")) == records
in_memory = tuple(connection.execute("PRAGMA database_list"))
checks["fixture_database_has_no_disk_filename"] = len(in_memory) == 1 and in_memory[0][2] == ""
unicode_records = ((1, "\u00e9", 0, 0), (2, "e\u0301", 0, 0), (3, "\u00e9", 0, 1))
with closing(database(unicode_records)) as connection:
checks["raw_unicode_forms_are_not_normalized"] = tuple(row[0] for row in select_label(connection, "\u00e9")) == (1, 3) and tuple(row[0] for row in select_label(connection, "e\u0301")) == (2,)
invalids = [((1, "x", 0, 0), (1, "y", 0, 0)), ((0, "x", 0, 0),), ((1, "x", -1, 0),), ((1, "x", 0, 5001),), ((1, "x", 0),)]
checks["record_shape_types_uniqueness_and_caps"] = all(raises(ValueError, database, x) for x in invalids) and raises(TypeError, database, [(1, "x", 0, 0)]) and raises(TypeError, database, ((True, "x", 0, 0),)) and raises(ValueError, database, tuple((i + 1, "x", 0, 0) for i in range(65)))
cases = 0
for count in (0, 1, 7, 32, 64):
labels = ("pump-A", "O'Brien", payload, "%_", "\u067e\u0645\u067e", "\u00e9", "e\u0301")
sample = tuple((i + 1, labels[i % len(labels)], (17 * i) % 11, ((31 * i) % 101) - 50) for i in range(count))
with closing(database(sample)) as connection:
for label in labels + ("missing",):
for sort_by in ORDERS:
assert select_label(connection, label, sort_by) == predicate_oracle(sample, label, sort_by)
cases += 1
checks["independent_predicate_and_sort_oracle_family"] = cases == 80
assert len(checks) == 14 and all(type(value) is bool and value for value in checks.values())
return dict(python=platform.python_version(), sqlite=sqlite3.sqlite_version, checks=checks,
own_example=dict(quoted_label_ids=[row[0] for row in quoted], bound_payload_ids=[row[0] for row in safe_payload], unsafe_local_SELECT_ids=unsafe_ids),
bound_column_name_result=values, independent_oracle_queries_in_one_group=cases,
scope="Own in-memory SELECT-only contrast; bound values plus fixed structural choices, not authentication, authorization or a whole-application security guarantee.")
if __name__ == "__main__":
print(json.dumps(run(), ensure_ascii=True, indent=2))
اجرای ثبتشده Python 3.14.5 و SQLite 3.50.4 داشته است. نسخهٔ هستهٔ SQLite از ویژگی sqlite3.sqlite_version خوانده میشود و نباید از شمارهٔ Python حدس زده شود. مستندات شاخهٔ برخط هنگام مطالعه نسخهٔ 3.14.8 را نمایش میداد. نمونهٔ ناامن فقط برای مقایسهٔ SELECT روی پنج ردیفِ متعلق به همین برنامه وجود دارد؛ تابع کاربردی از آن مسیر استفاده نمیکند. نتیجهٔ کاملِ همان اجرا در ادامه آمده است.
۴. مرجعِ پایتونی و شاهدِ نقلقول

مرجع مستقل، رکوردها را در پایتون با شرط برابریِ برچسب نگه میدارد و سپس با کلید عددی و شناسه مرتب میکند. برای ساخت جوابِ مرجع از یک پرسوجوی SQL دوم استفاده نشده است. پنج اندازهٔ صفر، یک، هفت، سیودو و شصتوچهار، هشت برچسب و دو ترتیب، هشتاد مقایسه میسازند؛ همهٔ این مقایسهها یک خانواده از چهارده گروه بررسیاند. پاسخِ دستیِ کوتیشن و شاهدِ SELECT نادرست، شواهد مشخص دیگری دربارهٔ معنای قرارداد فراهم میکنند.
گروههای دیگر، کلید نامجاز، تعداد پارامتر نادرست، نوع و شکل ورودی، مرز اندازه، شناسهٔ تکراری، متن یونیکد و حفظ محتوای جدول پس از انتخاب را میسنجند. خروجیِ خالی برای برچسب ناشناخته موفق است؛ خطای ورودی موفق نیست. تمام چهارده گروه در اجرای حاضر درستاند، اما این مجموعه آزمون اثبات امنیت کل برنامه نیست. کنترل دسترسی، محرمانگی، منشأ داده و محدودیت منابعِ سرویس از مشاهدهٔ موفق یک فیلتر نتیجه نمیشوند.
{
"python": "3.14.5",
"sqlite": "3.50.4",
"checks": {
"apostrophe_is_bound_literal_data": true,
"local_SELECT_only_interpolation_counterexample": true,
"allowlisted_order_with_unique_secondary_key": true,
"unknown_label_is_empty_not_an_error": true,
"percent_underscore_are_literal_under_equality": true,
"bound_parameter_is_not_a_column_identifier": true,
"binding_arity_errors_propagate": true,
"query_key_type_and_unknown_structure_rejected": true,
"filter_value_type_length_encoding_rejected": true,
"retrieval_does_not_mutate_fixture": true,
"fixture_database_has_no_disk_filename": true,
"raw_unicode_forms_are_not_normalized": true,
"record_shape_types_uniqueness_and_caps": true,
"independent_predicate_and_sort_oracle_family": true
},
"own_example": {
"quoted_label_ids": [
2
],
"bound_payload_ids": [
3
],
"unsafe_local_SELECT_ids": [
1,
2,
3,
4,
5
]
},
"bound_column_name_result": [
"voltage_mv",
"voltage_mv",
"voltage_mv",
"voltage_mv",
"voltage_mv"
],
"independent_oracle_queries_in_one_group": 80,
"scope": "Own in-memory SELECT-only contrast; bound values plus fixed structural choices, not authentication, authorization or a whole-application security guarantee."
}
برای تفکیک اعتبارسنجیِ پیش از ذخیره از تبدیل داخل موتور، درس STRICT و قرارداد نوع رکورد را بخوانید. تمرین ثبت دستهای و تراکنش حسگر نیز مرز موفقیت یک دسته را روشن میکند. اتصال درست مقدار، تصمیم تراکنش را تعیین نمیکند؛ حتی با همهٔ پارامترهای درست، انتخاب مرز نامناسبِ ثبت میتواند رفتار دیگری بسازد. این آموزش تنها قرارداد فیلتر و انتخاب بستهٔ ترتیب را بررسی کرده است.
۵. چهار پرسش دربارهٔ مرزِ حفاظت

پرسش اول: آیا باید آپاستروف را از نام حذف کنیم؟ پاسخ: در این قرارداد خیر؛ O’Brien دادهٔ مجاز است و با پارامتر دقیقاً رکورد دو را پیدا میکند. حذف نویسه، معنای نام را عوض میکند. اعتبارسنجیِ طول و رمزگذاری مستقل باقی میماند؛ برای حفظ مرز مقدار، از اتصال پارامتر استفاده میکنیم و داده را با بازنویسیِ خاموش «اصلاح» نمیکنیم.
پرسش دوم: آیا SELECT ? با مقدار voltage_mv ستون را انتخاب میکند؟ پاسخ: خیر؛ پنج رشتهٔ یکسان دریافت میکنیم. برای ترتیبِ مجاز باید کلید را در نگاشت بسته پیدا کنیم و همان قطعهٔ ثابتِ برنامه را به کار ببریم. نام ناشناخته خطاست؛ پذیرفتن هر رشته بهعنوان ساختار، قراردادِ بستهٔ این مثال را از بین میبرد.
پرسش سوم: چرا فیلتر فارسیِ دارای درصد فقط یک ردیف میدهد؟ پاسخ: چون عملگر این تابع مساوی است و متنِ کامل مقایسه میشود. پارامترگذاری خودِ عملگر را عوض نمیکند. اگر رابطی برای LIKE بنویسیم، باید روشن کنیم کاربر الگو میخواهد یا متنِ تحتاللفظی؛ آزمایش کنونی هیچ سیاست تازهای برای آن رابط پیاده نکرده است.
پرسش چهارم: آیا موفقیت چهارده گروه یعنی هر کاربر اجازهٔ خواندن جدول را دارد؟ پاسخ: خیر؛ مجوز در این کد وجود ندارد. انتخاب درستِ رکورد تنها وقتی مفید است که درخواست اصولاً مجاز باشد. نتیجهٔ محدود این اجرا، جدایی مقدار از متن دستور و محدودبودن انتخاب ترتیب در پایگاهِ ساختگی است؛ نه تأیید هویت دستگاه، اصالت اندازهگیری یا تضمین امنیت یک سرویس.
منابع و مطالعهٔ بیشتر
- SQLite؛ پارامترِ مقدار و عملگر الگو
- SQLite C Interface؛ اتصال مقدار به دستور آماده
- Python3.14؛ جاینگهدارها و بستن اتصال
بررسی منابع: ۲۶ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.

