Advance N-023 through N-026 audited parity
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
|
||||
#include "BLI_endian_defines.h"
|
||||
#include "BLI_filereader.h"
|
||||
#include "BLI_string_utf8.h"
|
||||
#include "BLI_utildefines.h"
|
||||
|
||||
#include "BLO_core_bhead.hh"
|
||||
@@ -36,6 +37,9 @@
|
||||
#include "DNA_node_types.h"
|
||||
#include "DNA_sdna_types.h"
|
||||
#include "DNA_sequence_types.h"
|
||||
#include "DNA_screen_types.h"
|
||||
#include "DNA_space_enums.h"
|
||||
#include "DNA_workspace_types.h"
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -44,6 +48,73 @@ using namespace blender;
|
||||
|
||||
constexpr uint64_t MAX_BLEND_BLOCK_BYTES = UINT64_C(1024) * 1024 * 1024;
|
||||
constexpr size_t MAX_NON_MESH_POINTS = 1000000;
|
||||
constexpr size_t MAX_SCRIPT_SOURCE_BYTES = 1024 * 1024;
|
||||
|
||||
uint32_t rotate_right(const uint32_t value, const uint32_t bits)
|
||||
{
|
||||
return (value >> bits) | (value << (32 - bits));
|
||||
}
|
||||
|
||||
std::string sha256_hex(const std::string &value)
|
||||
{
|
||||
static constexpr std::array<uint32_t, 64> round_constants = {
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
|
||||
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
|
||||
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
|
||||
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
|
||||
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
|
||||
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
|
||||
std::array<uint32_t, 8> digest = {
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
|
||||
std::vector<uint8_t> message(value.begin(), value.end());
|
||||
const uint64_t bit_length = uint64_t(message.size()) * 8;
|
||||
message.push_back(0x80);
|
||||
while (message.size() % 64 != 56) message.push_back(0);
|
||||
for (int shift = 56; shift >= 0; shift -= 8) message.push_back(uint8_t(bit_length >> shift));
|
||||
for (size_t offset = 0; offset < message.size(); offset += 64) {
|
||||
std::array<uint32_t, 64> words{};
|
||||
for (size_t index = 0; index < 16; index++) {
|
||||
const size_t byte = offset + index * 4;
|
||||
words[index] = uint32_t(message[byte]) << 24 | uint32_t(message[byte + 1]) << 16 |
|
||||
uint32_t(message[byte + 2]) << 8 | uint32_t(message[byte + 3]);
|
||||
}
|
||||
for (size_t index = 16; index < words.size(); index++) {
|
||||
const uint32_t s0 = rotate_right(words[index - 15], 7) ^
|
||||
rotate_right(words[index - 15], 18) ^ (words[index - 15] >> 3);
|
||||
const uint32_t s1 = rotate_right(words[index - 2], 17) ^
|
||||
rotate_right(words[index - 2], 19) ^ (words[index - 2] >> 10);
|
||||
words[index] = words[index - 16] + s0 + words[index - 7] + s1;
|
||||
}
|
||||
uint32_t a = digest[0], b = digest[1], c = digest[2], d = digest[3];
|
||||
uint32_t e = digest[4], f = digest[5], g = digest[6], h = digest[7];
|
||||
for (size_t index = 0; index < words.size(); index++) {
|
||||
const uint32_t sum1 = rotate_right(e, 6) ^ rotate_right(e, 11) ^ rotate_right(e, 25);
|
||||
const uint32_t choice = (e & f) ^ (~e & g);
|
||||
const uint32_t temporary1 = h + sum1 + choice + round_constants[index] + words[index];
|
||||
const uint32_t sum0 = rotate_right(a, 2) ^ rotate_right(a, 13) ^ rotate_right(a, 22);
|
||||
const uint32_t majority = (a & b) ^ (a & c) ^ (b & c);
|
||||
const uint32_t temporary2 = sum0 + majority;
|
||||
h = g; g = f; f = e; e = d + temporary1;
|
||||
d = c; c = b; b = a; a = temporary1 + temporary2;
|
||||
}
|
||||
digest[0] += a; digest[1] += b; digest[2] += c; digest[3] += d;
|
||||
digest[4] += e; digest[5] += f; digest[6] += g; digest[7] += h;
|
||||
}
|
||||
static constexpr char hex[] = "0123456789abcdef";
|
||||
std::string result(64, '0');
|
||||
for (size_t index = 0; index < digest.size(); index++) {
|
||||
for (size_t nibble = 0; nibble < 8; nibble++) {
|
||||
result[index * 8 + nibble] = hex[(digest[index] >> (28 - nibble * 4)) & 0xf];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
struct FileReaderDeleter {
|
||||
void operator()(FileReader *reader) const
|
||||
@@ -465,6 +536,9 @@ std::string id_prefix(const std::string &type_name)
|
||||
if (type_name == "Text") return "text";
|
||||
if (type_name == "VFont") return "vfont";
|
||||
if (type_name == "Mask") return "mask";
|
||||
if (type_name == "Library") return "library";
|
||||
if (type_name == "WorkSpace") return "workspace";
|
||||
if (type_name == "bScreen") return "screen";
|
||||
return "datablock";
|
||||
}
|
||||
|
||||
@@ -477,7 +551,8 @@ bool is_exported_id_type(const std::string &type_name)
|
||||
type_name == "bArmature" || type_name == "World" || type_name == "Curve" ||
|
||||
type_name == "MetaBall" || type_name == "PointCloud" || type_name == "Curves" ||
|
||||
type_name == "Volume" || type_name == "GreasePencil" || type_name == "Text" ||
|
||||
type_name == "VFont" || type_name == "Mask";
|
||||
type_name == "VFont" || type_name == "Mask" || type_name == "Library" ||
|
||||
type_name == "WorkSpace" || type_name == "bScreen";
|
||||
}
|
||||
|
||||
std::string unique_id(const std::string &prefix,
|
||||
@@ -1050,6 +1125,178 @@ std::optional<json> mask_from_record(const ParsedBlend &blend,
|
||||
return json{{"id", mask_id}, {"name", mask_name}, {"layers", std::move(layers)}};
|
||||
}
|
||||
|
||||
const char *editor_type_from_space(const int64_t space_type)
|
||||
{
|
||||
switch (space_type) {
|
||||
case SPACE_VIEW3D: return "VIEW_3D";
|
||||
case SPACE_OUTLINER: return "OUTLINER";
|
||||
case SPACE_PROPERTIES: return "PROPERTIES";
|
||||
case SPACE_IMAGE: return "UV_IMAGE";
|
||||
case SPACE_NODE: return "NODE";
|
||||
case SPACE_GRAPH: return "GRAPH";
|
||||
case SPACE_ACTION: return "DOPE_SHEET";
|
||||
case SPACE_NLA: return "NLA";
|
||||
case SPACE_SPREADSHEET: return "SPREADSHEET";
|
||||
case SPACE_SEQ: return "SEQUENCER";
|
||||
case SPACE_CLIP: return "CLIP";
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
const char *editor_region_kind(const int64_t region_type)
|
||||
{
|
||||
if (region_type == RGN_TYPE_WINDOW || region_type == RGN_TYPE_PREVIEW) return "MAIN";
|
||||
if (region_type == RGN_TYPE_HEADER || region_type == RGN_TYPE_TOOL_HEADER ||
|
||||
region_type == RGN_TYPE_ASSET_SHELF_HEADER || region_type == RGN_TYPE_SCRUBBING)
|
||||
{
|
||||
return "HEADER";
|
||||
}
|
||||
if (region_type == RGN_TYPE_TOOLS || region_type == RGN_TYPE_TOOL_PROPS) return "TOOLBAR";
|
||||
if (region_type == RGN_TYPE_UI || region_type == RGN_TYPE_CHANNELS ||
|
||||
region_type == RGN_TYPE_NAV_BAR || region_type == RGN_TYPE_ASSET_SHELF)
|
||||
{
|
||||
return "SIDEBAR";
|
||||
}
|
||||
if (region_type == RGN_TYPE_FOOTER || region_type == RGN_TYPE_EXECUTE) return "FOOTER";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::optional<json> editor_workflow_from_records(
|
||||
const ParsedBlend &blend,
|
||||
const std::vector<IdRecord> &records,
|
||||
const uint64_t revision,
|
||||
const std::string &active_object_id)
|
||||
{
|
||||
json workspaces = json::array();
|
||||
size_t area_count = 0;
|
||||
size_t region_count = 0;
|
||||
for (const IdRecord &record : records) {
|
||||
if (record.type_name != "WorkSpace" || workspaces.size() >= 64) continue;
|
||||
const ElementRef workspace{record.block, 0, record.type_name};
|
||||
const std::vector<ElementRef> layouts = linked_list_elements(blend, workspace, "layouts");
|
||||
if (layouts.empty()) continue;
|
||||
const std::optional<uint64_t> screen_pointer = read_pointer(*blend.sdna, layouts.front(), "screen");
|
||||
const std::optional<ElementRef> screen = screen_pointer ? element_for_pointer(blend, *screen_pointer) :
|
||||
std::nullopt;
|
||||
if (!screen) continue;
|
||||
const std::vector<ElementRef> source_areas = linked_list_elements(blend, *screen, "areabase");
|
||||
if (source_areas.empty() || area_count > 1024 - source_areas.size()) continue;
|
||||
|
||||
struct AreaBounds {
|
||||
int x_min;
|
||||
int y_min;
|
||||
int x_max;
|
||||
int y_max;
|
||||
};
|
||||
std::vector<AreaBounds> bounds;
|
||||
bounds.reserve(source_areas.size());
|
||||
bool valid = true;
|
||||
int screen_x_min = std::numeric_limits<int>::max();
|
||||
int screen_y_min = std::numeric_limits<int>::max();
|
||||
int screen_x_max = std::numeric_limits<int>::min();
|
||||
int screen_y_max = std::numeric_limits<int>::min();
|
||||
for (const ElementRef &area : source_areas) {
|
||||
if (editor_type_from_space(read_integer(*blend.sdna, area, "spacetype").value_or(-1)) == nullptr) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
const std::optional<ElementRef> total_rect = embedded_element(*blend.sdna, area, "totrct");
|
||||
if (!total_rect) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
const std::vector<int64_t> area_rect = {
|
||||
read_integer(*blend.sdna, *total_rect, "xmin").value_or(0),
|
||||
read_integer(*blend.sdna, *total_rect, "ymin").value_or(0),
|
||||
read_integer(*blend.sdna, *total_rect, "xmax").value_or(0),
|
||||
read_integer(*blend.sdna, *total_rect, "ymax").value_or(0)};
|
||||
if (area_rect[2] <= area_rect[0] || area_rect[3] <= area_rect[1]) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
bounds.push_back({int(area_rect[0]), int(area_rect[1]), int(area_rect[2]), int(area_rect[3])});
|
||||
screen_x_min = std::min(screen_x_min, int(area_rect[0]));
|
||||
screen_y_min = std::min(screen_y_min, int(area_rect[1]));
|
||||
screen_x_max = std::max(screen_x_max, int(area_rect[2]));
|
||||
screen_y_max = std::max(screen_y_max, int(area_rect[3]));
|
||||
}
|
||||
const int screen_width = screen_x_max - screen_x_min;
|
||||
const int screen_height = screen_y_max - screen_y_min;
|
||||
if (!valid || screen_width <= 0 || screen_height <= 0) continue;
|
||||
|
||||
json areas = json::array();
|
||||
size_t workspace_region_count = 0;
|
||||
for (size_t area_index = 0; area_index < source_areas.size(); area_index++) {
|
||||
const ElementRef &area = source_areas[area_index];
|
||||
const char *editor = editor_type_from_space(
|
||||
read_integer(*blend.sdna, area, "spacetype").value_or(-1));
|
||||
json regions = json::array();
|
||||
bool has_main_region = false;
|
||||
for (const ElementRef ®ion : linked_list_elements(blend, area, "regionbase")) {
|
||||
const char *kind = editor_region_kind(
|
||||
read_integer(*blend.sdna, region, "regiontype").value_or(-1));
|
||||
if (kind == nullptr) continue;
|
||||
if (++workspace_region_count > 4096 - region_count) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
const int64_t flags = read_integer(*blend.sdna, region, "flag").value_or(0);
|
||||
regions.push_back({{"id", record.id + ":area:" + std::to_string(area_index) +
|
||||
":region:" + std::to_string(regions.size())},
|
||||
{"kind", kind},
|
||||
{"visible", (flags & RGN_FLAG_HIDDEN) == 0}});
|
||||
has_main_region |= std::strcmp(kind, "MAIN") == 0;
|
||||
}
|
||||
if (!valid || regions.empty() || !has_main_region) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
const AreaBounds &rect = bounds[area_index];
|
||||
const double x = double(rect.x_min - screen_x_min) / screen_width;
|
||||
const double y = double(rect.y_min - screen_y_min) / screen_height;
|
||||
const double width = rect.x_max == screen_x_max ? 1.0 - x :
|
||||
double(rect.x_max - rect.x_min) / screen_width;
|
||||
const double height = rect.y_max == screen_y_max ? 1.0 - y :
|
||||
double(rect.y_max - rect.y_min) / screen_height;
|
||||
areas.push_back({{"id", record.id + ":area:" + std::to_string(area_index)},
|
||||
{"editor", editor},
|
||||
{"regions", std::move(regions)},
|
||||
{"rect",
|
||||
{{"x", x}, {"y", y}, {"width", width}, {"height", height}}},
|
||||
{"maximized", false}});
|
||||
}
|
||||
if (!valid || areas.empty()) continue;
|
||||
area_count += areas.size();
|
||||
region_count += workspace_region_count;
|
||||
workspaces.push_back({{"id", record.id},
|
||||
{"name", record.name},
|
||||
{"areas", std::move(areas)},
|
||||
{"activeAreaId", record.id + ":area:0"},
|
||||
{"revision", revision}});
|
||||
}
|
||||
if (workspaces.empty()) return std::nullopt;
|
||||
const json &workspace = workspaces.front();
|
||||
const json &active_area = workspace["areas"].front();
|
||||
const std::string workspace_id = workspace.value("id", "");
|
||||
const std::string active_area_id = active_area.value("id", "");
|
||||
const std::string active_editor = active_area.value("editor", "");
|
||||
json selection = json::array();
|
||||
if (!active_object_id.empty()) selection.push_back(active_object_id);
|
||||
return json{{"schemaVersion", 1},
|
||||
{"workspaces", std::move(workspaces)},
|
||||
{"context",
|
||||
{{"workspaceId", workspace_id},
|
||||
{"activeAreaId", active_area_id},
|
||||
{"activeEditor", active_editor},
|
||||
{"mode", "OBJECT"},
|
||||
{"activeObjectId", active_object_id.empty() ? json(nullptr) : json(active_object_id)},
|
||||
{"selection", std::move(selection)},
|
||||
{"viewLayer", "ViewLayer"},
|
||||
{"pinnedData", nullptr},
|
||||
{"revision", revision}}},
|
||||
{"keymaps", json::array()}};
|
||||
}
|
||||
|
||||
struct RawBlockSpan {
|
||||
const BlendBlock *block = nullptr;
|
||||
size_t offset = 0;
|
||||
@@ -1670,6 +1917,64 @@ std::string read_raw_string(const ParsedBlend &blend,
|
||||
return std::string(data, strnlen(data, available));
|
||||
}
|
||||
|
||||
std::optional<json> script_source_from_record(const ParsedBlend &blend,
|
||||
const ElementRef &element,
|
||||
const IdRecord &record)
|
||||
{
|
||||
const std::vector<ElementRef> lines = linked_list_elements(blend, element, "lines");
|
||||
if (lines.empty() || lines.size() > 65536) return std::nullopt;
|
||||
std::string source;
|
||||
for (size_t index = 0; index < lines.size(); index++) {
|
||||
const int64_t length = read_integer(*blend.sdna, lines[index], "len").value_or(-1);
|
||||
const std::optional<uint64_t> pointer = read_pointer(*blend.sdna, lines[index], "line");
|
||||
const size_t separator_bytes = index > 0 ? 1 : 0;
|
||||
if (length < 0 || length > int64_t(MAX_SCRIPT_SOURCE_BYTES) || !pointer ||
|
||||
size_t(length) > MAX_SCRIPT_SOURCE_BYTES - separator_bytes ||
|
||||
source.size() > MAX_SCRIPT_SOURCE_BYTES - separator_bytes - size_t(length))
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::vector<uint8_t> bytes = read_raw_byte_array(blend, *pointer, size_t(length));
|
||||
if (bytes.size() != size_t(length) ||
|
||||
std::find(bytes.begin(), bytes.end(), uint8_t(0)) != bytes.end() ||
|
||||
(!bytes.empty() &&
|
||||
BLI_str_utf8_invalid_byte(reinterpret_cast<const char *>(bytes.data()), bytes.size()) != -1))
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
if (index > 0) source.push_back('\n');
|
||||
source.append(reinterpret_cast<const char *>(bytes.data()), bytes.size());
|
||||
}
|
||||
const std::optional<uint64_t> path_pointer = read_pointer(*blend.sdna, element, "filepath");
|
||||
const std::string source_path = path_pointer ? read_raw_string(blend, *path_pointer, 2049) :
|
||||
std::string();
|
||||
if (!source_path.empty()) {
|
||||
const bool project_path = source_path.rfind("//", 0) == 0 && source_path.size() > 2 &&
|
||||
source_path.size() <= 2050 &&
|
||||
source_path.find('\\') == std::string::npos &&
|
||||
source_path.find('%') == std::string::npos &&
|
||||
source_path.find("/../") == std::string::npos &&
|
||||
source_path.find("//", 2) == std::string::npos;
|
||||
if (!project_path) return std::nullopt;
|
||||
}
|
||||
const int64_t flags = read_integer(*blend.sdna, element, "flags").value_or(0);
|
||||
const bool module_autorun_requested = (flags & (1 << 4)) != 0;
|
||||
json result = {{"id", record.id},
|
||||
{"name", record.name},
|
||||
{"source", source},
|
||||
{"sourceSha256", sha256_hex(source)},
|
||||
{"byteLength", source.size()},
|
||||
{"lineCount", lines.size()},
|
||||
{"internal", source_path.empty()},
|
||||
{"moduleAutorunRequested", module_autorun_requested},
|
||||
{"readOnly", true},
|
||||
{"executionStatus", "BLOCKED"},
|
||||
{"errorCode", module_autorun_requested ? "SCRIPT_POLICY_DENIED" :
|
||||
"SCRIPT_SANDBOX_UNAVAILABLE"}};
|
||||
if (!source_path.empty()) result["sourcePath"] = source_path;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<uint64_t> read_raw_pointer_array(const ParsedBlend &blend,
|
||||
const uint64_t pointer,
|
||||
const size_t count)
|
||||
@@ -1829,8 +2134,7 @@ json non_mesh_data_from_record(const ParsedBlend &blend,
|
||||
record.type_name == "MetaBall" ? "METABALL" :
|
||||
record.type_name == "PointCloud" ? "POINT_CLOUD" :
|
||||
record.type_name == "Curves" ? "CURVES" :
|
||||
record.type_name == "Volume" ? "VOLUME" :
|
||||
record.type_name == "Text" ? "FONT" : "CURVE";
|
||||
record.type_name == "Volume" ? "VOLUME" : "CURVE";
|
||||
json data = { {"id", record.id},
|
||||
{"name", record.name},
|
||||
{"type", type_name},
|
||||
@@ -2122,19 +2426,6 @@ json non_mesh_data_from_record(const ParsedBlend &blend,
|
||||
"NON_MESH_DATA_UNSUPPORTED";
|
||||
}
|
||||
}
|
||||
else if (record.type_name == "Text") {
|
||||
json lines = json::array();
|
||||
size_t total_chars = 0;
|
||||
for (const ElementRef &line : linked_list_elements(blend, element, "lines")) {
|
||||
const std::optional<uint64_t> pointer = read_pointer(*blend.sdna, line, "line");
|
||||
const std::string value = pointer ? read_raw_string(blend, *pointer, 4096) : std::string();
|
||||
total_chars += value.size();
|
||||
lines.push_back(value);
|
||||
}
|
||||
data["text"] = lines.empty() ? "" : lines[0].get<std::string>();
|
||||
data["pointCount"] = total_chars;
|
||||
data["splineCount"] = lines.size();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -2756,8 +3047,12 @@ json scene_ir_from_blend(const ParsedBlend &blend,
|
||||
json non_mesh_data = json::array();
|
||||
json vfonts = json::array();
|
||||
json libraries = json::array();
|
||||
json script_sources = json::array();
|
||||
size_t script_source_bytes = 0;
|
||||
json masks = json::array();
|
||||
bool masks_blocked = false;
|
||||
bool libraries_blocked = false;
|
||||
bool script_sources_blocked = false;
|
||||
json animations = json::array();
|
||||
json nla_tracks = json::array();
|
||||
json armatures = json::array();
|
||||
@@ -3234,9 +3529,29 @@ json scene_ir_from_blend(const ParsedBlend &blend,
|
||||
}
|
||||
else if (record.type_name == "Curve" || record.type_name == "MetaBall" ||
|
||||
record.type_name == "PointCloud" || record.type_name == "Curves" ||
|
||||
record.type_name == "Volume" || record.type_name == "Text") {
|
||||
record.type_name == "Volume") {
|
||||
non_mesh_data.push_back(non_mesh_data_from_record(blend, element, record, ids_by_pointer));
|
||||
}
|
||||
else if (record.type_name == "Text") {
|
||||
if (script_sources.size() >= 1024) {
|
||||
script_sources_blocked = true;
|
||||
}
|
||||
else if (const std::optional<json> source = script_source_from_record(
|
||||
blend, element, record))
|
||||
{
|
||||
const size_t byte_length = source->value("byteLength", size_t(0));
|
||||
if (byte_length > MAX_SCRIPT_SOURCE_BYTES - script_source_bytes) {
|
||||
script_sources_blocked = true;
|
||||
}
|
||||
else {
|
||||
script_source_bytes += byte_length;
|
||||
script_sources.push_back(*source);
|
||||
}
|
||||
}
|
||||
else {
|
||||
script_sources_blocked = true;
|
||||
}
|
||||
}
|
||||
else if (record.type_name == "VFont") {
|
||||
const std::string filepath = read_string(*blend.sdna, element, "filepath");
|
||||
vfonts.push_back({{"id", record.id}, {"name", record.name}, {"sourcePath", filepath},
|
||||
@@ -3460,14 +3775,36 @@ json scene_ir_from_blend(const ParsedBlend &blend,
|
||||
if (block.type_name != "Library") continue;
|
||||
const ElementRef library{&block, 0, block.type_name};
|
||||
const std::string path = read_string(*blend.sdna, library, "filepath");
|
||||
const std::string library_name = read_id_name(*blend.sdna, library);
|
||||
const std::string library_id = "library:" + library_name;
|
||||
const bool project_path = path.rfind("//", 0) == 0 && path.size() > 2 && path.size() <= 2050 &&
|
||||
path.find('\\') == std::string::npos && path.find('%') == std::string::npos &&
|
||||
path.find("/../") == std::string::npos && path.find("//", 2) == std::string::npos;
|
||||
if (!project_path || library_name.empty() || library_id.size() > 256) {
|
||||
libraries_blocked = true;
|
||||
continue;
|
||||
}
|
||||
const std::optional<uint64_t> packed_file = read_pointer(*blend.sdna, library, "packedfile");
|
||||
const std::vector<uint8_t> packed_bytes = packed_file ? packed_file_bytes(blend, *packed_file) :
|
||||
std::vector<uint8_t>();
|
||||
json resource = {{"id", "library:" + read_id_name(*blend.sdna, library)},
|
||||
{"name", read_id_name(*blend.sdna, library)},
|
||||
json dependencies = json::array();
|
||||
const std::optional<uint64_t> parent_pointer = read_pointer(
|
||||
*blend.sdna, library, "archive_parent_library");
|
||||
if (parent_pointer && *parent_pointer != 0) {
|
||||
const auto parent_id = ids_by_pointer.find(*parent_pointer);
|
||||
if (parent_id == ids_by_pointer.end()) {
|
||||
libraries_blocked = true;
|
||||
continue;
|
||||
}
|
||||
dependencies.push_back(parent_id->second);
|
||||
}
|
||||
json resource = {{"id", library_id},
|
||||
{"name", library_name},
|
||||
{"sourcePath", path},
|
||||
{"packed", !packed_bytes.empty()},
|
||||
{"status", packed_bytes.empty() ? "EXTERNAL_REQUIRED" : "PACKED"}};
|
||||
{"status", packed_bytes.empty() ? "EXTERNAL_REQUIRED" : "PACKED"},
|
||||
{"dependencyIds", std::move(dependencies)},
|
||||
{"readOnly", true}};
|
||||
if (packed_bytes.empty()) resource["errorCode"] = "LINKED_LIBRARY_RESOURCE_REQUIRED";
|
||||
else resource["packedByteLength"] = packed_bytes.size();
|
||||
libraries.push_back(std::move(resource));
|
||||
@@ -3511,13 +3848,17 @@ json scene_ir_from_blend(const ParsedBlend &blend,
|
||||
sort_by_id(non_mesh_data);
|
||||
sort_by_id(vfonts);
|
||||
sort_by_id(libraries);
|
||||
sort_by_id(script_sources);
|
||||
sort_by_id(animations);
|
||||
sort_by_id(armatures);
|
||||
sort_by_id(collections);
|
||||
sort_by_id(scenes);
|
||||
populate_world_matrices(nodes);
|
||||
|
||||
|
||||
const std::string active_object_id = first_mesh_object_id.empty() ? first_object_id :
|
||||
first_mesh_object_id;
|
||||
std::optional<json> editor_workflow = editor_workflow_from_records(
|
||||
blend, records, revision, active_object_id);
|
||||
json snapshot = {
|
||||
{"schemaVersion", 1},
|
||||
{"revision", revision},
|
||||
@@ -3544,17 +3885,16 @@ json scene_ir_from_blend(const ParsedBlend &blend,
|
||||
{"images", std::move(images)},
|
||||
{"nonMeshData", std::move(non_mesh_data)},
|
||||
{"vfonts", std::move(vfonts)},
|
||||
{"libraries", std::move(libraries)},
|
||||
{"libraryStatus", libraries_blocked ? "BLOCKED" : "AVAILABLE"},
|
||||
{"trackingMaskStatus", masks_blocked ? "BLOCKED" : "AVAILABLE"},
|
||||
{"editorWorkflowStatus", editor_workflow ? "AVAILABLE" : "BLOCKED"},
|
||||
{"scriptSourceStatus", script_sources_blocked ? "BLOCKED" : "AVAILABLE"},
|
||||
{"animations", std::move(animations)},
|
||||
{"nlaTracks", std::move(nla_tracks)},
|
||||
{"armatures", std::move(armatures)},
|
||||
{"collections", std::move(collections)},
|
||||
{"scenes", std::move(scenes)},
|
||||
{"activeObjectId",
|
||||
(first_mesh_object_id.empty() ? first_object_id : first_mesh_object_id).empty() ?
|
||||
json(nullptr) :
|
||||
json(first_mesh_object_id.empty() ? first_object_id : first_mesh_object_id)},
|
||||
{"activeObjectId", active_object_id.empty() ? json(nullptr) : json(active_object_id)},
|
||||
{"frame", {{"current", frame_current}, {"start", frame_start}, {"end", frame_end}}}};
|
||||
if (!masks_blocked) {
|
||||
snapshot["trackingMasks"] = {{"schemaVersion", 1},
|
||||
@@ -3563,6 +3903,11 @@ json scene_ir_from_blend(const ParsedBlend &blend,
|
||||
{"masks", std::move(masks)},
|
||||
{"bindings", json::array()}};
|
||||
}
|
||||
if (!libraries_blocked) snapshot["libraries"] = std::move(libraries);
|
||||
if (editor_workflow) snapshot["editorWorkflow"] = std::move(*editor_workflow);
|
||||
if (!script_sources_blocked) {
|
||||
snapshot["scriptSources"] = {{"schemaVersion", 1}, {"sources", std::move(script_sources)}};
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# N-023 Asset、Library 与 IO
|
||||
|
||||
状态:`BLOCKED`(asset catalog、来源/许可证元数据、库依赖与 IO 安全门已落地;真实
|
||||
Append/Link/Override Main、非 GLB 本地导入和跨桌面重导入未实现)
|
||||
状态:`BLOCKED`(asset catalog、来源/许可证元数据、真实 Main library inventory、库依赖与
|
||||
IO 安全门已落地;Append/Link/Override Main、非 GLB 本地导入和跨桌面重导入未实现)
|
||||
|
||||
## 已验证切片
|
||||
|
||||
@@ -13,10 +13,15 @@ Append/Link/Override Main、非 GLB 本地导入和跨桌面重导入未实现
|
||||
3. N-023-C(门):现有 GLB 导出与 USD semantic analysis 可放行;GLTF/OBJ/PLY/STL、
|
||||
USD/Alembic 实际导入保持 `IO_FORMAT_UNSUPPORTED`,库 mutation 需要真实 Main。
|
||||
4. N-023-E:项目路径、外部 URI、archive entry 数量/单项/总量、压缩展开比率均有边界。
|
||||
5. N-023-B(部分):Blender 5.2 Main reader 输出 linked Library stable ID、项目相对路径、
|
||||
packed/external 状态、只读标志和 archive-parent dependency;1024 library/每库 1024 dependency、
|
||||
重复 ID、缺依赖、依赖环与项目外路径由 SceneIR 再校验。缺外部库字节时返回
|
||||
`LINKED_LIBRARY_RESOURCE_REQUIRED`,不从路径伪造 SHA-256 或声称已加载。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- N-023-B:Append/Link/Library Override、reload/relocate 和真实 Main transaction。
|
||||
- N-023-B:Append/Link/Library Override、reload/relocate 和真实 Main transaction;只读 library
|
||||
inventory 已完成。
|
||||
- N-023-C/D:GLTF/OBJ/PLY/STL、USD/Alembic import/export/save/reopen/desktop reimport。
|
||||
- N-023-E:zip fuzz、OPFS quota/recovery、license/source offer 发布审计和大文件流式性能。
|
||||
|
||||
@@ -24,4 +29,5 @@ Append/Link/Override Main、非 GLB 本地导入和跨桌面重导入未实现
|
||||
|
||||
```bash
|
||||
WEB_TEST_PORT=5323 npm --prefix web run test:e2e -- --grep "N-023 asset"
|
||||
npm --prefix web run test:library-main-reader
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# N-024 Editors 与工作流
|
||||
|
||||
状态:`BLOCKED`(统一 context、只读 editor manifest、selection sync、keymap 和布局预算
|
||||
已落地;完整 Blender editor writer、gizmo/触控和跨设备 golden 未实现)
|
||||
状态:`BLOCKED`(统一 context、真实 Main 只读布局清单、selection sync、keymap 和布局预算
|
||||
已落地;完整 Blender editor writer、运行时焦点、gizmo/触控和跨设备 golden 未实现)
|
||||
|
||||
## 已验证切片
|
||||
|
||||
@@ -12,16 +12,21 @@
|
||||
3. N-024-C(部分):selection sync 以 revision 事务更新 active object 与 selected IDs,
|
||||
active object 必须属于 selection,过期 revision 会拒绝。
|
||||
4. N-024-D(门):keymap 绑定和预算可验证;writer、gizmo 与 touch drag 保持能力阻断。
|
||||
5. N-024-B(Main reader):从 Blender `WorkSpace`、`WorkSpaceLayout`、`bScreen`、`ScrArea`
|
||||
和 `ARegion` 读取有界 workspace/area/region 清单、编辑器类型、可见性与归一化布局;
|
||||
不完整或包含未知 editor 的 workspace 被跳过,首个完整 workspace/area 只作为确定性只读 context,
|
||||
不声称恢复 Blender 运行时焦点。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- N-024-B/C:Properties、UV/Image、Node、Graph、Dope Sheet、NLA、Spreadsheet 等真实
|
||||
Blender 数据读取/写回、selection history、operator search 和 context menu。
|
||||
- N-024-B/C:Properties、UV/Image、Node、Graph、Dope Sheet、NLA、Spreadsheet 等 editor
|
||||
专属数据读取/写回、真实运行时焦点、selection history、operator search 和 context menu。
|
||||
- N-024-D/E:Blender-compatible keymap 执行、gizmo/drag preview/commit、桌面/mobile/笔
|
||||
触控布局、无重叠截图和 accessibility golden。
|
||||
|
||||
## 验收
|
||||
|
||||
```bash
|
||||
npm --prefix web run test:editor-main-reader
|
||||
WEB_TEST_PORT=5324 npm --prefix web run test:e2e -- --grep "N-024 editor"
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# N-025 Scripting 与平台
|
||||
|
||||
状态:`BLOCKED`(默认拒绝策略、签名 manifest、权限/资源预算、平台报告和服务端 hash
|
||||
门已落地;本地隔离执行、真实 server job 与发布审计未实现)
|
||||
状态:`BLOCKED`(默认拒绝策略、真实 Main Text 来源清单、签名 manifest、权限/资源预算、
|
||||
平台报告和服务端 hash 门已落地;本地隔离执行、真实 server job 与发布审计未实现)
|
||||
|
||||
## 已验证切片
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
hash 一致,但当前 endpoint 未配置,返回 `SERVER_JOB_UNAVAILABLE`。
|
||||
4. N-025-D:Worker/WebGPU/OffscreenCanvas/OPFS 与 native/CUDA/Metal/HIP/OptiX capability
|
||||
报告只反映 API 存在或显式 `BLOCKED`。
|
||||
5. N-025-A(Main reader):从 Blender `Text`/`TextLine` 重建最多 1 MiB、65,536 行的完整
|
||||
UTF-8 内嵌来源,记录项目相对外部路径、`use_module` autorun 请求和 SHA-256;所有来源均为
|
||||
只读且 `executionStatus=BLOCKED`,autorun 请求按默认拒绝处理,不引入 Python 执行入口。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
@@ -24,5 +27,6 @@
|
||||
## 验收
|
||||
|
||||
```bash
|
||||
npm --prefix web run test:script-main-reader
|
||||
WEB_TEST_PORT=5325 npm --prefix web run test:e2e -- --grep "N-025 script"
|
||||
```
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
# N-026 全域发布门
|
||||
|
||||
状态:`BLOCKED`(machine-readable parity manifest、依赖/状态检查、证据聚合和确定性
|
||||
序列化已落地;Chromium 完整套件、离线包、OPFS、性能/故障、SBOM 与发布审计证据未齐)
|
||||
状态:`BLOCKED`(machine-readable parity manifest、依赖/状态检查、命令/hash 证据绑定、
|
||||
Chromium 完整套件、离线包/OPFS、SPDX SBOM 和部分性能/故障证据已落地;10M/大纹理/
|
||||
长媒体、simulation cache 性能、OOM/device loss/network interruption 与上游能力仍未齐)
|
||||
|
||||
## 已验证切片
|
||||
|
||||
1. N-026-A:版本化 `ReleaseManifestIR` 为每个 family 记录 `LOCAL_EXACT`、
|
||||
1. N-026-A:版本化 schema 3 `ReleaseManifestIR` 为每个 family 记录 `LOCAL_EXACT`、
|
||||
`LOCAL_BOUNDED`、`SERVER` 或 `BLOCKED`、roadmap 状态、完成/阻断切片、验收命令和
|
||||
依赖;缺失 family、非法非阻断状态或依赖环会拒绝。
|
||||
2. N-026-B/C:Chromium 主线程/OffscreenCanvas、offline/Worker restart/OPFS recovery、
|
||||
1M/10M geometry、4K/8K texture、长媒体、simulation cache、OOM/device loss/网络
|
||||
中断/损坏 blend/zip bomb 等证据字段必须逐项为 true 才能放行。
|
||||
3. N-026-D/E:license、SBOM、source offer、deterministic package 为发布必需证据;
|
||||
manifest family 排序可确定性序列化,当前缺证据聚合为 `BLOCKED`,没有虚报发布通过。
|
||||
3. N-026-D/E:license、SBOM、source offer、deterministic package 为发布必需证据;每个 true
|
||||
字段必须绑定实际成功命令、耗时、输出与构件 SHA-256,manifest family 排序可确定性序列化,
|
||||
当前缺证据聚合为 `BLOCKED`,没有虚报发布通过。
|
||||
4. 当前 Chromium smoke 已通过引擎启动/Main 编辑与内容寻址资产恢复,并通过真实 64 KiB
|
||||
OPFS quota 下的失败保持旧 revision、Worker 重启恢复门。这只计为 partial evidence,不等于
|
||||
完整 suite 或其余 family 的桌面 golden。发布门 schema 2 仅接受 Chromium 浏览器证据。
|
||||
@@ -20,18 +22,23 @@
|
||||
PointCloud/Curves/Hair 共 7 对象已通过 GLB/USDA desktop round-trip。Volume/VDB 仍无 renderer
|
||||
与 loss fixture,这些证据也不替代其余 family 的 desktop golden。
|
||||
6. 当前发布包已通过本地 third-party notices、Blender/Three license 文件、无远程运行时依赖、
|
||||
非空 SHA-256 manifest、离线二进制/源码包确定性复建和 100k/1M decimate + 5 类损坏 blend
|
||||
拒绝门;这些是 partial evidence,不等于完整 SBOM/source offer、10M/长媒体或 OPFS quota。
|
||||
非空 SHA-256 manifest、SPDX 2.3 lockfile/vendored SBOM、对应源码提供和离线二进制/源码包
|
||||
确定性复建;100k/1M decimate、5 类损坏 blend 和 archive 高压缩比拒绝有实际命令记录。
|
||||
7. `docs/status/release-evidence.json` 记录 Chromium 完整 E2E + release suite 以及上述发布命令;
|
||||
`docs/web/sbom.spdx.json` 确定性覆盖 npm lockfile 与 notices 中显式 vendored/native 组件。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- Chromium 完整套件、跨 family 桌面 golden、离线/OPFS quota、1M/10M/长媒体基准和 fault fuzz
|
||||
尚未有真实证据;本项目当前不配置 Firefox/WebKit,上游 N-015 至 N-025 的阻断能力会传递到发布门。
|
||||
- 完整 release package 审计、许可证/source offer/SBOM、10M/长媒体性能和 OPFS quota 发布流程仍未完成;
|
||||
当前离线包确定性检查只覆盖已有二进制/源码归档门。
|
||||
- 跨 family 桌面 golden、10M、4K/8K texture、长媒体、simulation cache 性能和 OOM/device loss/
|
||||
network interruption fault 仍无真实证据;本项目当前只配置 Chromium,上游 N-015 至 N-025
|
||||
的阻断能力会传递到发布门。
|
||||
- 完整 native dependency/license 审计仍需发行审核;当前 SBOM 覆盖 lockfile 和显式 notices,
|
||||
不声称替代 Blender 全部传递依赖的发布级法律审计。
|
||||
|
||||
## 验收
|
||||
|
||||
```bash
|
||||
WEB_TEST_PORT=5326 npm --prefix web run test:e2e -- --grep "N-026 release"
|
||||
npm --prefix web run test:release-evidence
|
||||
npm --prefix web run release:evidence
|
||||
```
|
||||
|
||||
@@ -89,9 +89,9 @@
|
||||
"name": "Assets, libraries and IO",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": ["A-catalog-asset-license-source-schema", "A-content-addressed-opfs-capability", "B-library-dependency-order-partial", "C-glb-export-usd-analysis-gates", "E-archive-path-ratio-budget"],
|
||||
"completedSlices": ["A-catalog-asset-license-source-schema", "A-content-addressed-opfs-capability", "B-library-dependency-order-partial", "B-main-library-inventory-reader", "C-glb-export-usd-analysis-gates", "E-archive-path-ratio-budget"],
|
||||
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||
"acceptance": ["web:e2e:N-023 asset"],
|
||||
"acceptance": ["web:test:library-main-reader", "web:e2e:N-023 asset"],
|
||||
"dependencies": ["N-022"]
|
||||
},
|
||||
{
|
||||
@@ -99,9 +99,9 @@
|
||||
"name": "Editors and workflow",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": ["A-unified-area-region-context", "B-read-only-editor-manifest-partial", "C-selection-sync-revision-partial", "D-keymap-layout-budget-gates"],
|
||||
"completedSlices": ["A-unified-area-region-context", "B-read-only-editor-manifest-partial", "B-main-workspace-area-region-reader", "C-selection-sync-revision-partial", "D-keymap-layout-budget-gates"],
|
||||
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||
"acceptance": ["web:e2e:N-024 editor"],
|
||||
"acceptance": ["web:test:editor-main-reader", "web:e2e:N-024 editor"],
|
||||
"dependencies": ["N-023"]
|
||||
},
|
||||
{
|
||||
@@ -109,9 +109,9 @@
|
||||
"name": "Scripting and platform",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": ["A-default-deny-script-policy", "B-signed-manifest-permission-budget", "C-server-source-hash-job-gate", "D-platform-capability-report"],
|
||||
"completedSlices": ["A-default-deny-script-policy", "A-main-text-source-inventory-reader", "B-signed-manifest-permission-budget", "C-server-source-hash-job-gate", "D-platform-capability-report"],
|
||||
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||
"acceptance": ["web:e2e:N-025 script"],
|
||||
"acceptance": ["web:test:script-main-reader", "web:e2e:N-025 script"],
|
||||
"dependencies": ["N-024"]
|
||||
},
|
||||
{
|
||||
@@ -119,9 +119,9 @@
|
||||
"name": "Release gate",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": ["A-machine-readable-parity-manifest", "A-dependency-status-validation", "B-chromium-runtime-evidence-gate", "B-chromium-smoke-partial", "C-performance-fault-provenance-gate", "C-performance-1M-malicious-input-partial", "C-release-package-notices-partial", "D-deterministic-manifest-serialization", "D-offline-reproducible-source-archive-partial"],
|
||||
"completedSlices": ["A-machine-readable-parity-manifest", "A-dependency-status-validation", "A-command-hash-evidence-binding", "B-chromium-runtime-evidence-gate", "B-chromium-full-suite-evidence", "C-performance-fault-provenance-gate", "C-performance-1M-malicious-input-partial", "C-zip-bomb-ratio-rejection", "C-release-package-notices-partial", "C-spdx-2.3-lockfile-sbom", "D-deterministic-manifest-serialization", "D-offline-reproducible-source-archive-partial"],
|
||||
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||
"acceptance": ["web:e2e:N-026 release", "web:test:browser-smoke", "web:test:release-package", "web:release:offline", "web:test:release-performance", "web:test:malicious-blends"],
|
||||
"acceptance": ["web:e2e:N-026 release", "web:test:release-evidence", "web:test:browser", "web:test:release-package", "web:release:offline", "web:test:release-performance", "web:test:malicious-blends"],
|
||||
"dependencies": ["N-015", "N-016", "N-017", "N-018", "N-019", "N-020", "N-021", "N-022", "N-023", "N-024", "N-025"]
|
||||
}
|
||||
]
|
||||
|
||||
524
docs/status/release-evidence.json
Normal file
524
docs/status/release-evidence.json
Normal file
@@ -0,0 +1,524 @@
|
||||
{
|
||||
"schemaVersion": 3,
|
||||
"source": "docs/status/parity-ledger.json",
|
||||
"sourceSha256": "fbaba7086a985176b2100f066326d48b203776ea0706783fa0fd0e61712d0795",
|
||||
"generatedAt": "2026-08-12T19:18:35.501Z",
|
||||
"families": [
|
||||
{
|
||||
"id": "N-015",
|
||||
"name": "Non-mesh geometry",
|
||||
"status": "LOCAL_BOUNDED",
|
||||
"roadmapStatus": "in_progress",
|
||||
"completedSlices": [
|
||||
"A1",
|
||||
"A2-malformed-binary-partial",
|
||||
"A2-integer-overflow-fixtures",
|
||||
"A2-multichunk-attribute-completeness",
|
||||
"B2",
|
||||
"B3-metadata",
|
||||
"C1-topology-partial",
|
||||
"C1-handle-points-partial",
|
||||
"C1-handle-preview-raycast-partial",
|
||||
"C1-handle-identity-gizmo-main-roundtrip",
|
||||
"C1-multi-handle-selection-highlight-bounded-commit",
|
||||
"C1-rename-partial",
|
||||
"C1-create-delete-poly-partial",
|
||||
"C1-poly-bezier-nurbs-1d-conversion",
|
||||
"C1-multispline-create-delete-bulk-handle-cyclic-transaction",
|
||||
"C1-surface-2d-topology-transaction",
|
||||
"C2-font-geometry-partial",
|
||||
"C2-font-layout-partial",
|
||||
"C2-font-character-style-textbox-roundtrip",
|
||||
"C2-existing-packed-vfont-style-links",
|
||||
"C2-builtin-font-evaluation-exact",
|
||||
"C3-roundtrip",
|
||||
"D1-raycast-partial",
|
||||
"D1-offscreen-vert-edge-partial",
|
||||
"D1-selection-history-partial",
|
||||
"D1-cross-object-history-range-patch",
|
||||
"D1-handle-identity-axis-gizmo",
|
||||
"D2-partial",
|
||||
"E1-partial",
|
||||
"E1-desktop-geometry-golden",
|
||||
"E1-true-2d-surface-desktop-golden",
|
||||
"E1-glb-evaluated-nonmesh-roundtrip-partial",
|
||||
"E1-glb-curve-line-surface-mesh-evaluated",
|
||||
"E1-usda-four-object-desktop-roundtrip",
|
||||
"E1-pointcloud-curves-hair-glb-usd-loss-fixture",
|
||||
"E2-1M-chromium",
|
||||
"E2-chromium-worker-recovery",
|
||||
"E2-opfs-quota-chromium"
|
||||
],
|
||||
"blockedSlices": [
|
||||
"B3-vdb-renderer",
|
||||
"C1-continuous-handle-gizmo-preview",
|
||||
"C2-new-external-font-import",
|
||||
"E1-volume-loss-fixture"
|
||||
],
|
||||
"acceptance": [
|
||||
"web:test:nonmesh-roundtrip",
|
||||
"web:test:nonmesh-desktop-golden",
|
||||
"web:test:nonmesh-glb-blender-roundtrip",
|
||||
"web:test:nonmesh-usd-serialization",
|
||||
"web:test:nonmesh-usd-blender-roundtrip",
|
||||
"web:test:nonmesh-binary",
|
||||
"web:test:vdb",
|
||||
"web:test:selection-history",
|
||||
"web:e2e:N-015|non-mesh",
|
||||
"web:e2e:real OPFS quota"
|
||||
],
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "N-016",
|
||||
"name": "Grease Pencil",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": [
|
||||
"A-schema-budget",
|
||||
"A-main-reader",
|
||||
"B-layer-frame-stroke-transaction-partial",
|
||||
"C-point-radius-opacity-color-cyclic-material-partial",
|
||||
"C-bounded-previous-next-onion-preview",
|
||||
"D-current-frame-stroke-preview-main-offscreen-chromium"
|
||||
],
|
||||
"blockedSlices": [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E"
|
||||
],
|
||||
"acceptance": [
|
||||
"web:test:grease-pencil",
|
||||
"web:e2e:N-016 Grease Pencil"
|
||||
],
|
||||
"dependencies": [
|
||||
"N-015"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "N-017",
|
||||
"name": "Paint and weights",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": [
|
||||
"A-stroke-hit-weight-schema-budget-partial",
|
||||
"A-three-raycast-source-face-barycentric-uv",
|
||||
"B-main-vertex-color-partial",
|
||||
"B-main-vertex-weight-normalize-partial"
|
||||
],
|
||||
"blockedSlices": [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E"
|
||||
],
|
||||
"acceptance": [
|
||||
"web:test:paint-roundtrip",
|
||||
"web:e2e:N-017 paint"
|
||||
],
|
||||
"dependencies": [
|
||||
"N-016"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "N-018",
|
||||
"name": "Physics and simulation",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": [
|
||||
"A-family-capability-inventory",
|
||||
"B-settings-dependency-cache-manifest-partial",
|
||||
"C-exact-frame-selection-gate",
|
||||
"C-content-addressed-frame-range-read-hash-gate"
|
||||
],
|
||||
"blockedSlices": [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E"
|
||||
],
|
||||
"acceptance": [
|
||||
"web:e2e:N-018 physics",
|
||||
"web:test:simulation-cache"
|
||||
],
|
||||
"dependencies": [
|
||||
"N-017"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "N-019",
|
||||
"name": "Lighting and render",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": [
|
||||
"A-camera-light-world-scene-reader-partial",
|
||||
"A-main-camera-dof-properties-partial",
|
||||
"A-main-light-world-properties-partial",
|
||||
"B-three-exposure-shadow-mapping-partial",
|
||||
"A-white-balance-integrity-gate"
|
||||
],
|
||||
"blockedSlices": [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E"
|
||||
],
|
||||
"acceptance": [
|
||||
"web:test:lighting-roundtrip",
|
||||
"web:e2e:N-019 Scene exposure"
|
||||
],
|
||||
"dependencies": [
|
||||
"N-018"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "N-020",
|
||||
"name": "Compositor",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": [
|
||||
"A-graph-resource-cycle-schema",
|
||||
"A-main-graph-structure-reader-partial",
|
||||
"B-bounded-cpu-executor-partial",
|
||||
"C-image-operation-budget-cancel-partial",
|
||||
"D-unsupported-node-preservation-gate"
|
||||
],
|
||||
"blockedSlices": [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E"
|
||||
],
|
||||
"acceptance": [
|
||||
"web:test:compositor-main-reader",
|
||||
"web:e2e:N-020 CPU compositor"
|
||||
],
|
||||
"dependencies": [
|
||||
"N-019"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "N-021",
|
||||
"name": "Sequencer and audio",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": [
|
||||
"A-strip-resource-schema",
|
||||
"A-main-strip-timeline-reader-partial",
|
||||
"B-deterministic-move-trim-split-partial",
|
||||
"B-source-frame-seek",
|
||||
"C-runtime-codec-probe-gate"
|
||||
],
|
||||
"blockedSlices": [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E"
|
||||
],
|
||||
"acceptance": [
|
||||
"web:test:sequencer-main-reader",
|
||||
"web:e2e:N-021 sequencer"
|
||||
],
|
||||
"dependencies": [
|
||||
"N-020"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "N-022",
|
||||
"name": "Tracking and masks",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": [
|
||||
"A-clip-track-plane-mask-schema",
|
||||
"A-main-mask-layer-spline-point-reader",
|
||||
"B-revision-marker-mask-edit-partial",
|
||||
"B-source-hash-binding-validation",
|
||||
"C-browser-probe-solve-gate"
|
||||
],
|
||||
"blockedSlices": [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E"
|
||||
],
|
||||
"acceptance": [
|
||||
"web:test:mask-main-reader",
|
||||
"web:e2e:N-022 tracking"
|
||||
],
|
||||
"dependencies": [
|
||||
"N-021"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "N-023",
|
||||
"name": "Assets, libraries and IO",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": [
|
||||
"A-catalog-asset-license-source-schema",
|
||||
"A-content-addressed-opfs-capability",
|
||||
"B-library-dependency-order-partial",
|
||||
"B-main-library-inventory-reader",
|
||||
"C-glb-export-usd-analysis-gates",
|
||||
"E-archive-path-ratio-budget"
|
||||
],
|
||||
"blockedSlices": [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E"
|
||||
],
|
||||
"acceptance": [
|
||||
"web:test:library-main-reader",
|
||||
"web:e2e:N-023 asset"
|
||||
],
|
||||
"dependencies": [
|
||||
"N-022"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "N-024",
|
||||
"name": "Editors and workflow",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": [
|
||||
"A-unified-area-region-context",
|
||||
"B-read-only-editor-manifest-partial",
|
||||
"B-main-workspace-area-region-reader",
|
||||
"C-selection-sync-revision-partial",
|
||||
"D-keymap-layout-budget-gates"
|
||||
],
|
||||
"blockedSlices": [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E"
|
||||
],
|
||||
"acceptance": [
|
||||
"web:test:editor-main-reader",
|
||||
"web:e2e:N-024 editor"
|
||||
],
|
||||
"dependencies": [
|
||||
"N-023"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "N-025",
|
||||
"name": "Scripting and platform",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": [
|
||||
"A-default-deny-script-policy",
|
||||
"A-main-text-source-inventory-reader",
|
||||
"B-signed-manifest-permission-budget",
|
||||
"C-server-source-hash-job-gate",
|
||||
"D-platform-capability-report"
|
||||
],
|
||||
"blockedSlices": [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E"
|
||||
],
|
||||
"acceptance": [
|
||||
"web:test:script-main-reader",
|
||||
"web:e2e:N-025 script"
|
||||
],
|
||||
"dependencies": [
|
||||
"N-024"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "N-026",
|
||||
"name": "Release gate",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": [
|
||||
"A-machine-readable-parity-manifest",
|
||||
"A-dependency-status-validation",
|
||||
"A-command-hash-evidence-binding",
|
||||
"B-chromium-runtime-evidence-gate",
|
||||
"B-chromium-full-suite-evidence",
|
||||
"C-performance-fault-provenance-gate",
|
||||
"C-performance-1M-malicious-input-partial",
|
||||
"C-zip-bomb-ratio-rejection",
|
||||
"C-release-package-notices-partial",
|
||||
"C-spdx-2.3-lockfile-sbom",
|
||||
"D-deterministic-manifest-serialization",
|
||||
"D-offline-reproducible-source-archive-partial"
|
||||
],
|
||||
"blockedSlices": [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E"
|
||||
],
|
||||
"acceptance": [
|
||||
"web:e2e:N-026 release",
|
||||
"web:test:release-evidence",
|
||||
"web:test:browser",
|
||||
"web:test:release-package",
|
||||
"web:release:offline",
|
||||
"web:test:release-performance",
|
||||
"web:test:malicious-blends"
|
||||
],
|
||||
"dependencies": [
|
||||
"N-015",
|
||||
"N-016",
|
||||
"N-017",
|
||||
"N-018",
|
||||
"N-019",
|
||||
"N-020",
|
||||
"N-021",
|
||||
"N-022",
|
||||
"N-023",
|
||||
"N-024",
|
||||
"N-025"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidence": {
|
||||
"browser": {
|
||||
"chromium": true
|
||||
},
|
||||
"runtime": {
|
||||
"offline": true,
|
||||
"workerRestart": true,
|
||||
"opfsRecovery": true
|
||||
},
|
||||
"performance": {
|
||||
"geometry1M": true,
|
||||
"geometry10M": false,
|
||||
"texture4K": false,
|
||||
"texture8K": false,
|
||||
"longMedia": false,
|
||||
"simulationCache": false
|
||||
},
|
||||
"faults": {
|
||||
"oom": false,
|
||||
"deviceLoss": false,
|
||||
"networkInterrupt": false,
|
||||
"malformedBlend": true,
|
||||
"zipBomb": true
|
||||
},
|
||||
"provenance": {
|
||||
"license": true,
|
||||
"sbom": true,
|
||||
"sourceOffer": true,
|
||||
"deterministicPackage": true
|
||||
},
|
||||
"records": [
|
||||
{
|
||||
"id": "sbom",
|
||||
"fields": [
|
||||
"provenance.license",
|
||||
"provenance.sbom"
|
||||
],
|
||||
"command": "npm --prefix web run release:sbom",
|
||||
"exitCode": 0,
|
||||
"durationMs": 364,
|
||||
"output": "> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30",
|
||||
"artifactSha256": [
|
||||
"8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30",
|
||||
"d5ec6e6ec9e1e0a50fe4455513a2938838609d3809224eaf26a2ebfac474f0ae",
|
||||
"3c65ff96cc15c9072d2517555484ccfd0009a2814d96faadcc5bd7b2e3458503"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chromium",
|
||||
"fields": [
|
||||
"browser.chromium",
|
||||
"runtime.offline",
|
||||
"runtime.workerRestart",
|
||||
"runtime.opfsRecovery"
|
||||
],
|
||||
"command": "npm --prefix web run test:e2e -- --workers=1 && npm --prefix web run test:browser",
|
||||
"exitCode": 0,
|
||||
"durationMs": 135891,
|
||||
"output": "d LOD profiles at the protocol boundary (1.0s)\n ✓ 67 tests/e2e/smoke.spec.ts:1765:1 › normalizes skin influences and rejects shape-key loss explicitly (1.0s)\n ✓ 68 tests/e2e/smoke.spec.ts:1790:1 › remaps skin weights and shape keys through the native Collapse worker path (1.3s)\n ✓ 69 tests/e2e/smoke.spec.ts:1846:1 › reports GLB export blockers before any binary export (1.1s)\n ✓ 70 tests/e2e/smoke.spec.ts:1858:1 › blocks Shader graphs that cannot be mapped to glTF PBR (1.0s)\n ✓ 71 tests/e2e/smoke.spec.ts:1885:1 › maps bounded RGB and Value Shader constants to glTF PBR factors (1.0s)\n ✓ 72 tests/e2e/smoke.spec.ts:1918:1 › exports a local SceneIR mesh as a standards-shaped GLB (1.0s)\n ✓ 73 tests/e2e/smoke.spec.ts:1951:1 › keeps per-vertex UV and color attributes in GLB output (995ms)\n ✓ 74 tests/e2e/smoke.spec.ts:1978:1 › evaluates modifier dependency order and blocks unevaluated or cyclic stacks (1.0s)\n ✓ 75 tests/e2e/smoke.spec.ts:1993:1 › embeds local textures and exports glTF skin and animation records (1.0s)\n ✓ 76 tests/e2e/smoke.spec.ts:2043:1 › reports lightweight budget violations without altering usage (1.0s)\n ✓ 77 tests/e2e/smoke.spec.ts:2061:1 › aggregates project, collection, object and LOD budgets without double counting LOD (991ms)\n ✓ 78 tests/e2e/smoke.spec.ts:2087:1 › round-trips LOD geometry through the local binary mesh cache container (1.0s)\n ✓ 79 tests/e2e/smoke.spec.ts:2111:1 › blocks ImageIR paths outside the project asset sandbox (1.3s)\n ✓ 80 tests/e2e/smoke.spec.ts:2154:1 › extracts Blender packed image bytes through the local asset request API (1.4s)\n ✓ 81 tests/e2e/smoke.spec.ts:2199:1 › matches Blender Depsgraph deformation golden within the declared error budget (1.3s)\n ✓ 82 tests/e2e/smoke.spec.ts:2238:1 › evaluates the full Blender Depsgraph or reports its safe capability gate (1.3s)\n ✓ 83 tests/e2e/smoke.spec.ts:2298:1 › exports layered Action keyframes through SceneIR (1.3s)\n ✓ 84 tests/e2e/smoke.spec.ts:2339:1 › patches changed mesh buffer ranges without replacing stable topology (1.0s)\n ✓ 85 tests/e2e/smoke.spec.ts:2363:1 › renders through the capability-gated OffscreenCanvas worker (1.2s)\n ✓ 86 tests/e2e/smoke.spec.ts:2376:1 › coalesces linked mesh objects into a raycastable instance group (2.0s)\n ✓ 87 tests/e2e/smoke.spec.ts:2384:1 › returns structured gates for the undeclared capability protocols (1.1s)\n ✓ 88 tests/e2e/smoke.spec.ts:2443:1 › exposes PBR-007 to PBR-012 renderer security gates (1.0s)\n ✓ 89 tests/e2e/smoke.spec.ts:2477:1 › transfers packed raster assets into the PBR viewport with an explicit status (2.0s)\n ✓ 90 tests/e2e/smoke.spec.ts:2486:1 › uses the same packed texture payload in the OffscreenCanvas renderer (1.8s)\n\n 90 passed (2.1m)\n\n> blender-web-editor@0.1.0 test:browser\n> playwright test --config playwright.release.config.ts\n\n\nRunning 3 tests using 1 worker\n\n ✓ 1 [chromium] › tests/e2e/cross-browser.spec.ts:6:1 › boots the offline engine, renders SceneIR and performs a Main edit (2.8s)\n ✓ 2 [chromium] › tests/e2e/cross-browser.spec.ts:32:1 › keeps content-addressed asset recovery available in Chromium (1.2s)\n ✓ 3 [chromium] › tests/e2e/cross-browser.spec.ts:51:1 › keeps the committed project after quota failure and Worker restart in Chromium (1.1s)\n\n 3 passed (6.8s)\n\n[WebServer] (node:1250790) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1250802) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1255027) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1255039) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)",
|
||||
"artifactSha256": [
|
||||
"7176272d381a53d559d9b6ebe7ca8653ed32199ef6ddd4e2779637954f2c9ac3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "geometry-1m",
|
||||
"fields": [
|
||||
"performance.geometry1M"
|
||||
],
|
||||
"command": "npm --prefix web run test:release-performance",
|
||||
"exitCode": 0,
|
||||
"durationMs": 90534,
|
||||
"output": "> blender-web-editor@0.1.0 test:release-performance\n> node ../tools/web/check-release-performance.mjs\n\nrelease-performance-ok [{\"target\":100000,\"ratio\":0.9,\"outputTriangles\":89999,\"elapsedMs\":89462,\"heapBytes\":67108864},{\"target\":1000000,\"ratio\":1,\"outputTriangles\":1000000,\"elapsedMs\":531,\"heapBytes\":346554368}]\n\nHeap resize call from 67108864 to 80543744 took 0.22819400001026224 msecs. Success: true\nHeap resize call from 80543744 to 96665600 took 0.10745999999926426 msecs. Success: true\nHeap resize call from 96665600 to 115998720 took 0.06875900000159163 msecs. Success: true\nHeap resize call from 115998720 to 139198464 took 1.2809850000048755 msecs. Success: true\nHeap resize call from 139198464 to 167051264 took 1.4198880000039935 msecs. Success: true\nHeap resize call from 167051264 to 200474624 took 1.4606950000015786 msecs. Success: true\nHeap resize call from 200474624 to 240582656 took 1.2892889999930048 msecs. Success: true\nHeap resize call from 240582656 to 288751616 took 1.3983549999975367 msecs. Success: true\nHeap resize call from 288751616 to 346554368 took 1.341115000002901 msecs. Success: true",
|
||||
"artifactSha256": [
|
||||
"7176272d381a53d559d9b6ebe7ca8653ed32199ef6ddd4e2779637954f2c9ac3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "malformed-blend",
|
||||
"fields": [
|
||||
"faults.malformedBlend"
|
||||
],
|
||||
"command": "npm --prefix web run test:malicious-blends",
|
||||
"exitCode": 0,
|
||||
"durationMs": 533,
|
||||
"output": "> blender-web-editor@0.1.0 test:malicious-blends\n> node ../tools/web/check-malicious-blends.mjs\n\nmalicious-blends-ok rejected=5",
|
||||
"artifactSha256": [
|
||||
"6fcc55bda74da8c95da96ba068b60339bf60ca44147ff8000fbb95d296db2a73"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "zip-bomb",
|
||||
"fields": [
|
||||
"faults.zipBomb"
|
||||
],
|
||||
"command": "WEB_TEST_PORT=5323 npm --prefix web run test:asset-library",
|
||||
"exitCode": 0,
|
||||
"durationMs": 4077,
|
||||
"output": "> blender-web-editor@0.1.0 test:asset-library\n> playwright test --config playwright.config.ts -g \"N-023 asset\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:625:1 › validates N-023 asset catalogs, library graphs, archive budgets and IO gates (1.5s)\n\n 1 passed (3.2s)\n\n[WebServer] (node:1255677) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1255689) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)",
|
||||
"artifactSha256": [
|
||||
"6169748c5bf78a8e101196e964db67b8a65c2ba95bb3b4a54e052721be01a7bf"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "release-package",
|
||||
"fields": [],
|
||||
"command": "npm --prefix web run test:release-package",
|
||||
"exitCode": 0,
|
||||
"durationMs": 4783,
|
||||
"output": "> blender-web-editor@0.1.0 test:release-package\n> npm run build && npm run release:sbom && node ../tools/web/check-release-package.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 43 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-CnSyBTao.js 35.90 kB\ndist/assets/web-engine.worker-DR5p4R2R.js 238.07 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-CFVvLQ_x.wasm 15,003.09 kB │ gzip: 3,546.97 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-CPm-S3KG.js 879.04 kB │ gzip: 236.82 kB\n\n✓ built in 549ms\n\n> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\nrelease-package-ok files=10 bytes=31858159\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.",
|
||||
"artifactSha256": [
|
||||
"8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30",
|
||||
"7176272d381a53d559d9b6ebe7ca8653ed32199ef6ddd4e2779637954f2c9ac3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "offline-reproducibility",
|
||||
"fields": [
|
||||
"provenance.sourceOffer",
|
||||
"provenance.deterministicPackage"
|
||||
],
|
||||
"command": "npm --prefix web run release:offline",
|
||||
"exitCode": 0,
|
||||
"durationMs": 36219,
|
||||
"output": "> blender-web-editor@0.1.0 release:offline\n> npm run build && node ../tools/web/check-offline-reproducibility.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 43 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-CnSyBTao.js 35.90 kB\ndist/assets/web-engine.worker-DR5p4R2R.js 238.07 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-CFVvLQ_x.wasm 15,003.09 kB │ gzip: 3,546.97 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-CPm-S3KG.js 879.04 kB │ gzip: 236.82 kB\n\n✓ built in 548ms\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7521936 source=205665275 sha256=7f8bb6316d83f4a61c9357b5ab04ae996f83a4f2ff66312eb5263aef745c19e7\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7521936 source=205665275 sha256=7f8bb6316d83f4a61c9357b5ab04ae996f83a4f2ff66312eb5263aef745c19e7\noffline-reproducibility-ok binary=7f8bb6316d83f4a61c9357b5ab04ae996f83a4f2ff66312eb5263aef745c19e7 source=ca6d3fc1df2d7885a3602beddd427a8a2ac05440a11d250f323cfe33e4fddcff\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.",
|
||||
"artifactSha256": [
|
||||
"7f8bb6316d83f4a61c9357b5ab04ae996f83a4f2ff66312eb5263aef745c19e7",
|
||||
"ca6d3fc1df2d7885a3602beddd427a8a2ac05440a11d250f323cfe33e4fddcff",
|
||||
"c8f6b3287a67760855c145ef239fa8cc696cfb7a7d558d3284982df897caf4ed"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
4124
docs/web/sbom.spdx.json
Normal file
4124
docs/web/sbom.spdx.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -171,6 +171,13 @@
|
||||
"objects": 0,
|
||||
"meshes": 0,
|
||||
"features": ["mask-main-reader", "bezier-handles", "feather-selection"]
|
||||
},
|
||||
{
|
||||
"id": "script_scene",
|
||||
"path": "script_scene.blend",
|
||||
"objects": 0,
|
||||
"meshes": 0,
|
||||
"features": ["script-main-reader", "full-text-source", "module-autorun-default-deny", "project-relative-text-path"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
BIN
tests/files/web/script_scene.blend
Normal file
BIN
tests/files/web/script_scene.blend
Normal file
Binary file not shown.
66
tools/web/check-editor-main-reader.mjs
Normal file
66
tools/web/check-editor-main-reader.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import factory from "../../web/app/src/vendor/blender/web_engine.js";
|
||||
|
||||
const root = new URL("../../", import.meta.url);
|
||||
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
|
||||
const fixture = fs.readFileSync(new URL("tests/files/web/basic_scene.blend", root));
|
||||
|
||||
function open(engine, handle, bytes) {
|
||||
const pointer = engine._malloc(bytes.byteLength);
|
||||
try {
|
||||
engine.HEAPU8.set(bytes, pointer);
|
||||
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
|
||||
engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
}
|
||||
finally { engine._free(pointer); }
|
||||
}
|
||||
|
||||
function snapshot(engine, handle) {
|
||||
const dataOut = engine._malloc(4), lengthOut = engine._malloc(4);
|
||||
try {
|
||||
assert.equal(engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut), 0,
|
||||
engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
const pointer = engine.HEAPU32[dataOut >>> 2], length = engine.HEAPU32[lengthOut >>> 2];
|
||||
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
|
||||
}
|
||||
finally { engine._free(dataOut); engine._free(lengthOut); }
|
||||
}
|
||||
|
||||
const supportedEditors = new Set([
|
||||
"VIEW_3D", "OUTLINER", "PROPERTIES", "UV_IMAGE", "NODE", "GRAPH", "DOPE_SHEET", "NLA",
|
||||
"SPREADSHEET", "SEQUENCER", "CLIP",
|
||||
]);
|
||||
const regionKinds = new Set(["HEADER", "MAIN", "TOOLBAR", "SIDEBAR", "FOOTER"]);
|
||||
const engine = await factory({ wasmBinary: wasmBinary.slice() });
|
||||
const handle = engine._web_engine_create();
|
||||
open(engine, handle, fixture);
|
||||
const scene = snapshot(engine, handle);
|
||||
assert.equal(scene.editorWorkflowStatus, "AVAILABLE");
|
||||
const workflow = scene.editorWorkflow;
|
||||
assert.equal(workflow.schemaVersion, 1);
|
||||
assert.ok(workflow.workspaces.length > 0 && workflow.workspaces.length <= 64);
|
||||
let areaCount = 0, regionCount = 0;
|
||||
for (const workspace of workflow.workspaces) {
|
||||
assert.ok(workspace.areas.some((area) => area.id === workspace.activeAreaId));
|
||||
assert.equal(workspace.revision, scene.revision);
|
||||
areaCount += workspace.areas.length;
|
||||
for (const area of workspace.areas) {
|
||||
assert.ok(supportedEditors.has(area.editor), `unsupported editor ${area.editor}`);
|
||||
assert.ok(area.rect.x >= 0 && area.rect.y >= 0 && area.rect.width > 0 && area.rect.height > 0);
|
||||
assert.ok(area.rect.x + area.rect.width <= 1 && area.rect.y + area.rect.height <= 1);
|
||||
assert.ok(area.regions.some((region) => region.kind === "MAIN"));
|
||||
regionCount += area.regions.length;
|
||||
for (const region of area.regions) assert.ok(regionKinds.has(region.kind));
|
||||
}
|
||||
}
|
||||
assert.ok(areaCount <= 1024 && regionCount <= 4096);
|
||||
const activeWorkspace = workflow.workspaces.find((workspace) => workspace.id === workflow.context.workspaceId);
|
||||
const activeArea = activeWorkspace?.areas.find((area) => area.id === workflow.context.activeAreaId);
|
||||
assert.equal(activeArea?.editor, workflow.context.activeEditor);
|
||||
assert.equal(workflow.context.revision, scene.revision);
|
||||
assert.equal(workflow.context.activeObjectId, scene.activeObjectId);
|
||||
assert.deepEqual(workflow.context.selection, scene.activeObjectId === null ? [] : [scene.activeObjectId]);
|
||||
assert.deepEqual(workflow.keymaps, []);
|
||||
engine._web_engine_destroy(handle);
|
||||
process.stdout.write(`editor-main-reader-ok workspaces=${workflow.workspaces.length} areas=${areaCount} regions=${regionCount}\n`);
|
||||
46
tools/web/check-library-main-reader.mjs
Normal file
46
tools/web/check-library-main-reader.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import factory from "../../web/app/src/vendor/blender/web_engine.js";
|
||||
|
||||
const root = new URL("../../", import.meta.url);
|
||||
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
|
||||
const fixture = fs.readFileSync(new URL("tests/files/web/image_resource_matrix.blend", root));
|
||||
|
||||
function open(engine, handle, bytes) {
|
||||
const pointer = engine._malloc(bytes.byteLength);
|
||||
try {
|
||||
engine.HEAPU8.set(bytes, pointer);
|
||||
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
|
||||
engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
}
|
||||
finally { engine._free(pointer); }
|
||||
}
|
||||
|
||||
function snapshot(engine, handle) {
|
||||
const dataOut = engine._malloc(4), lengthOut = engine._malloc(4);
|
||||
try {
|
||||
assert.equal(engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut), 0,
|
||||
engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
const pointer = engine.HEAPU32[dataOut >>> 2], length = engine.HEAPU32[lengthOut >>> 2];
|
||||
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
|
||||
}
|
||||
finally { engine._free(dataOut); engine._free(lengthOut); }
|
||||
}
|
||||
|
||||
const engine = await factory({ wasmBinary: wasmBinary.slice() });
|
||||
const handle = engine._web_engine_create();
|
||||
open(engine, handle, fixture);
|
||||
const scene = snapshot(engine, handle);
|
||||
assert.equal(scene.libraryStatus, "AVAILABLE");
|
||||
assert.equal(scene.libraries.length, 1);
|
||||
const library = scene.libraries[0];
|
||||
assert.equal(library.id, "library:image_resource_library.blend");
|
||||
assert.equal(library.name, "image_resource_library.blend");
|
||||
assert.equal(library.sourcePath, "//resources/image_resource_library.blend");
|
||||
assert.equal(library.packed, false);
|
||||
assert.equal(library.status, "EXTERNAL_REQUIRED");
|
||||
assert.equal(library.errorCode, "LINKED_LIBRARY_RESOURCE_REQUIRED");
|
||||
assert.deepEqual(library.dependencyIds, []);
|
||||
assert.equal(library.readOnly, true);
|
||||
engine._web_engine_destroy(handle);
|
||||
process.stdout.write("library-main-reader-ok relative-path=passed readonly=passed dependency-inventory=passed\n");
|
||||
36
tools/web/check-release-evidence.mjs
Normal file
36
tools/web/check-release-evidence.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "release-evidence-"));
|
||||
const require = createRequire(import.meta.url);
|
||||
try {
|
||||
for (const name of ["capability-gates", "release-gate"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const result = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` });
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), result.outputText.replace('require("./capability-gates")', 'require("./capability-gates.cjs")'));
|
||||
}
|
||||
const module = require(path.join(temporary, "release-gate.cjs"));
|
||||
const reportPath = path.join(root, "docs/status/release-evidence.json");
|
||||
const ledgerPath = path.join(root, "docs/status/parity-ledger.json");
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
|
||||
assert.equal(report.sourceSha256, crypto.createHash("sha256").update(fs.readFileSync(ledgerPath)).digest("hex"), "release report is stale relative to parity ledger");
|
||||
const parsed = module.parseReleaseManifest(report);
|
||||
const evaluation = module.evaluateReleaseManifest(parsed);
|
||||
assert.equal(evaluation.status, "BLOCKED");
|
||||
assert.ok(evaluation.missing.includes("performance.geometry10M"));
|
||||
assert.ok(evaluation.missing.includes("faults.deviceLoss"));
|
||||
assert.equal(evaluation.missing.includes("browser.chromium"), false);
|
||||
assert.ok(parsed.evidence.records.length >= 7);
|
||||
assert.equal(parsed.evidence.records.some((record) => record.fields.includes("faults.zipBomb")), true);
|
||||
process.stdout.write(`release-evidence-check-ok records=${parsed.evidence.records.length} missing=${evaluation.missing.length} status=${evaluation.status}\n`);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
|
||||
const dist = path.join(root, "web/dist");
|
||||
const notices = JSON.parse(fs.readFileSync(path.join(root, "docs/web/third-party-notices.json"), "utf8"));
|
||||
const packageLock = JSON.parse(fs.readFileSync(path.join(root, "web/package-lock.json"), "utf8"));
|
||||
const sbom = JSON.parse(fs.readFileSync(path.join(root, "docs/web/sbom.spdx.json"), "utf8"));
|
||||
for (const dependency of ["react", "react-dom", "three", "vite", "typescript", "@playwright/test"]) {
|
||||
const normalize = (value) => value.toLowerCase().replace("@playwright/test", "playwright").replace(/\.js$/, "").replace(/[^a-z0-9]/g, "");
|
||||
const covered = notices.packages.some((entry) => normalize(entry.name) === normalize(dependency));
|
||||
@@ -16,6 +17,9 @@ for (const dependency of ["react", "react-dom", "three", "vite", "typescript", "
|
||||
assert.ok(packageLock.lockfileVersion >= 3, "npm lockfile must use an integrity-bearing format");
|
||||
assert.ok(fs.existsSync(path.join(root, "blender-5.2.0/COPYING")), "Blender GPL text is missing");
|
||||
assert.ok(fs.existsSync(path.join(root, "web/app/src/vendor/three/LICENSE")), "Three.js license is missing");
|
||||
assert.equal(sbom.spdxVersion, "SPDX-2.3", "SPDX SBOM schema is missing");
|
||||
assert.ok(sbom.packages.length >= Object.keys(packageLock.packages).length, "SPDX SBOM omits locked dependencies");
|
||||
assert.equal(new Set(sbom.packages.map((item) => item.SPDXID)).size, sbom.packages.length, "SPDX IDs must be unique");
|
||||
assert.ok(fs.existsSync(path.join(dist, "index.html")), "offline dist is missing; run npm build first");
|
||||
|
||||
const files = [];
|
||||
|
||||
55
tools/web/check-script-main-reader.mjs
Normal file
55
tools/web/check-script-main-reader.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import factory from "../../web/app/src/vendor/blender/web_engine.js";
|
||||
|
||||
const root = new URL("../../", import.meta.url);
|
||||
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
|
||||
const fixture = fs.readFileSync(new URL("tests/files/web/script_scene.blend", root));
|
||||
|
||||
function open(engine, handle, bytes) {
|
||||
const pointer = engine._malloc(bytes.byteLength);
|
||||
try {
|
||||
engine.HEAPU8.set(bytes, pointer);
|
||||
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
|
||||
engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
}
|
||||
finally { engine._free(pointer); }
|
||||
}
|
||||
|
||||
function snapshot(engine, handle) {
|
||||
const dataOut = engine._malloc(4), lengthOut = engine._malloc(4);
|
||||
try {
|
||||
assert.equal(engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut), 0,
|
||||
engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
const pointer = engine.HEAPU32[dataOut >>> 2], length = engine.HEAPU32[lengthOut >>> 2];
|
||||
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
|
||||
}
|
||||
finally { engine._free(dataOut); engine._free(lengthOut); }
|
||||
}
|
||||
|
||||
const engine = await factory({ wasmBinary: wasmBinary.slice() });
|
||||
const handle = engine._web_engine_create();
|
||||
open(engine, handle, fixture);
|
||||
const scene = snapshot(engine, handle);
|
||||
assert.equal(scene.scriptSourceStatus, "AVAILABLE");
|
||||
assert.equal(scene.scriptSources.schemaVersion, 1);
|
||||
assert.equal(scene.scriptSources.sources.length, 3);
|
||||
assert.equal(scene.nonMeshData.some((data) => data.id.startsWith("text:")), false);
|
||||
const byName = new Map(scene.scriptSources.sources.map((source) => [source.name, source]));
|
||||
for (const source of byName.values()) {
|
||||
assert.equal(source.byteLength, Buffer.byteLength(source.source));
|
||||
assert.equal(source.lineCount, source.source.split("\n").length);
|
||||
assert.equal(source.sourceSha256, crypto.createHash("sha256").update(source.source).digest("hex"));
|
||||
assert.equal(source.readOnly, true);
|
||||
assert.equal(source.executionStatus, "BLOCKED");
|
||||
}
|
||||
assert.equal(byName.get("InternalSafe.py").source, "value = 7\nprint(value)\n");
|
||||
assert.equal(byName.get("InternalSafe.py").internal, true);
|
||||
assert.equal(byName.get("InternalSafe.py").errorCode, "SCRIPT_SANDBOX_UNAVAILABLE");
|
||||
assert.equal(byName.get("ModuleAutorun.py").moduleAutorunRequested, true);
|
||||
assert.equal(byName.get("ModuleAutorun.py").errorCode, "SCRIPT_POLICY_DENIED");
|
||||
assert.equal(byName.get("ExternalProject.py").sourcePath, "//scripts/external_project.py");
|
||||
assert.equal(byName.get("ExternalProject.py").internal, false);
|
||||
engine._web_engine_destroy(handle);
|
||||
process.stdout.write("script-main-reader-ok full-source=passed sha256=passed autorun-default-deny=passed\n");
|
||||
52
tools/web/collect-release-evidence.mjs
Normal file
52
tools/web/collect-release-evidence.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const ledgerPath = path.join(root, "docs/status/parity-ledger.json");
|
||||
const outputPath = path.join(root, "docs/status/release-evidence.json");
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const evidence = {
|
||||
browser: { chromium: false },
|
||||
runtime: { offline: false, workerRestart: false, opfsRecovery: false },
|
||||
performance: { geometry1M: false, geometry10M: false, texture4K: false, texture8K: false, longMedia: false, simulationCache: false },
|
||||
faults: { oom: false, deviceLoss: false, networkInterrupt: false, malformedBlend: false, zipBomb: false },
|
||||
provenance: { license: false, sbom: false, sourceOffer: false, deterministicPackage: false },
|
||||
records: [],
|
||||
};
|
||||
|
||||
function run(id, fields, command, artifacts = []) {
|
||||
const started = Date.now();
|
||||
const result = spawnSync("bash", ["-lc", command], { cwd: root, encoding: "utf8", env: { ...process.env, WEB_TEST_PORT: process.env.WEB_TEST_PORT ?? "5326" }, maxBuffer: 16 * 1024 * 1024 });
|
||||
const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
||||
if (result.status !== 0) {
|
||||
process.stderr.write(`${combined}\n`);
|
||||
throw new Error(`${id} failed with exit code ${result.status}`);
|
||||
}
|
||||
for (const field of fields) {
|
||||
const [group, key] = field.split(".");
|
||||
evidence[group][key] = true;
|
||||
}
|
||||
evidence.records.push({ id, fields, command, exitCode: 0, durationMs: Date.now() - started, output: combined.slice(-4096) || `${id} passed`, artifactSha256: artifacts.filter((file) => fs.existsSync(path.join(root, file))).map((file) => sha256(path.join(root, file))) });
|
||||
process.stdout.write(`${id}-ok durationMs=${Date.now() - started}\n`);
|
||||
}
|
||||
|
||||
run("sbom", ["provenance.license", "provenance.sbom"], "npm --prefix web run release:sbom", ["docs/web/sbom.spdx.json", "docs/web/third-party-notices.json", "web/package-lock.json"]);
|
||||
run("chromium", ["browser.chromium", "runtime.offline", "runtime.workerRestart", "runtime.opfsRecovery"], "npm --prefix web run test:e2e -- --workers=1 && npm --prefix web run test:browser", ["web/app/src/vendor/blender/web_engine.wasm"]);
|
||||
run("geometry-1m", ["performance.geometry1M"], "npm --prefix web run test:release-performance", ["web/app/src/vendor/blender/web_engine.wasm"]);
|
||||
run("malformed-blend", ["faults.malformedBlend"], "npm --prefix web run test:malicious-blends", ["tests/files/web/basic_scene.blend"]);
|
||||
run("zip-bomb", ["faults.zipBomb"], "WEB_TEST_PORT=5323 npm --prefix web run test:asset-library", ["web/protocol/asset-library-io.ts"]);
|
||||
run("release-package", [], "npm --prefix web run test:release-package", ["docs/web/sbom.spdx.json", "web/app/src/vendor/blender/web_engine.wasm"]);
|
||||
run("offline-reproducibility", ["provenance.sourceOffer", "provenance.deterministicPackage"], "npm --prefix web run release:offline", ["release/blender-web-offline.tar.gz", "release/blender-web-corresponding-source.tar.gz", "release/SHA256SUMS.txt"]);
|
||||
|
||||
const ledgerBytes = fs.readFileSync(ledgerPath);
|
||||
const ledger = JSON.parse(ledgerBytes);
|
||||
const manifest = { schemaVersion: 3, source: "docs/status/parity-ledger.json", sourceSha256: crypto.createHash("sha256").update(ledgerBytes).digest("hex"), generatedAt: new Date().toISOString(), families: ledger.families, evidence };
|
||||
assert.ok(manifest.families.some((family) => family.status === "BLOCKED"));
|
||||
assert.equal(manifest.evidence.performance.geometry10M, false);
|
||||
assert.equal(manifest.evidence.faults.deviceLoss, false);
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
process.stdout.write(`release-evidence-ok records=${evidence.records.length} status=BLOCKED sha256=${sha256(outputPath)}\n`);
|
||||
@@ -8,11 +8,13 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
|
||||
const releaseRoot = path.join(root, "release");
|
||||
if (path.basename(releaseRoot) !== "release" || path.dirname(releaseRoot) !== root) throw new Error("unsafe release target");
|
||||
const bundle = path.join(releaseRoot, "blender-web-offline");
|
||||
execFileSync(process.execPath, [path.join(root, "tools/web/generate-sbom.mjs")], { stdio: "inherit" });
|
||||
fs.rmSync(bundle, { recursive: true, force: true });
|
||||
fs.mkdirSync(bundle, { recursive: true });
|
||||
fs.cpSync(path.join(root, "web/dist"), path.join(bundle, "app"), { recursive: true });
|
||||
fs.copyFileSync(path.join(root, "blender-5.2.0/COPYING"), path.join(bundle, "COPYING"));
|
||||
fs.copyFileSync(path.join(root, "docs/web/third-party-notices.json"), path.join(bundle, "third-party-notices.json"));
|
||||
fs.copyFileSync(path.join(root, "docs/web/sbom.spdx.json"), path.join(bundle, "sbom.spdx.json"));
|
||||
fs.copyFileSync(path.join(root, "blender-5.2.0/extern/opensubdiv-source/LICENSE.txt"), path.join(bundle, "LICENSE-OpenSubdiv.txt"));
|
||||
fs.copyFileSync(path.join(root, "blender-5.2.0/extern/gmp-source/COPYING.LESSERv3"), path.join(bundle, "LICENSE-GMP-LGPLv3.txt"));
|
||||
fs.writeFileSync(path.join(bundle, "README.txt"), [
|
||||
|
||||
48
tools/web/generate-sbom.mjs
Normal file
48
tools/web/generate-sbom.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const lockPath = path.join(root, "web/package-lock.json");
|
||||
const outputPath = path.join(root, "docs/web/sbom.spdx.json");
|
||||
const lockBytes = fs.readFileSync(lockPath);
|
||||
const lock = JSON.parse(lockBytes);
|
||||
const noticesBytes = fs.readFileSync(path.join(root, "docs/web/third-party-notices.json"));
|
||||
const notices = JSON.parse(noticesBytes);
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const spdxId = (value) => `SPDXRef-${value.replace(/[^A-Za-z0-9.-]/g, "-")}-${sha256(value).slice(0, 12)}`;
|
||||
const packages = [];
|
||||
|
||||
const rootId = "SPDXRef-Package-blender-web-editor";
|
||||
packages.push({ SPDXID: rootId, name: lock.name, versionInfo: lock.version, downloadLocation: "NOASSERTION", filesAnalyzed: false, licenseConcluded: "NOASSERTION", licenseDeclared: "NOASSERTION", copyrightText: "NOASSERTION", checksums: [{ algorithm: "SHA256", checksumValue: sha256(lockBytes) }] });
|
||||
|
||||
for (const [packagePath, entry] of Object.entries(lock.packages).sort(([left], [right]) => left.localeCompare(right))) {
|
||||
if (!packagePath || !entry.version) continue;
|
||||
const marker = packagePath.lastIndexOf("node_modules/");
|
||||
const name = entry.name ?? packagePath.slice(marker + "node_modules/".length);
|
||||
const item = { SPDXID: spdxId(`npm-${packagePath}`), name, versionInfo: entry.version, downloadLocation: entry.resolved ?? "NOASSERTION", filesAnalyzed: false, licenseConcluded: "NOASSERTION", licenseDeclared: "NOASSERTION", copyrightText: "NOASSERTION", externalRefs: [{ referenceCategory: "PACKAGE-MANAGER", referenceType: "purl", referenceLocator: `pkg:npm/${encodeURIComponent(name)}@${entry.version}` }] };
|
||||
if (typeof entry.integrity === "string" && entry.integrity.startsWith("sha512-")) item.checksums = [{ algorithm: "SHA512", checksumValue: Buffer.from(entry.integrity.slice(7), "base64").toString("hex") }];
|
||||
packages.push(item);
|
||||
}
|
||||
|
||||
for (const notice of notices.packages.filter((item) => !item.source.includes("node_modules"))) {
|
||||
packages.push({ SPDXID: spdxId(`vendored-${notice.name}-${notice.version}`), name: notice.name, versionInfo: notice.version, downloadLocation: "NOASSERTION", filesAnalyzed: false, licenseConcluded: "NOASSERTION", licenseDeclared: notice.license, copyrightText: "NOASSERTION", sourceInfo: notice.source });
|
||||
}
|
||||
packages.sort((left, right) => left.SPDXID.localeCompare(right.SPDXID));
|
||||
const document = {
|
||||
spdxVersion: "SPDX-2.3",
|
||||
dataLicense: "CC0-1.0",
|
||||
SPDXID: "SPDXRef-DOCUMENT",
|
||||
name: "blender-web-editor-sbom",
|
||||
documentNamespace: `https://blender-web.local/spdx/${sha256(Buffer.concat([lockBytes, noticesBytes]))}`,
|
||||
creationInfo: { created: "1970-01-01T00:00:00Z", creators: ["Tool: tools/web/generate-sbom.mjs"] },
|
||||
documentDescribes: [rootId],
|
||||
packages,
|
||||
relationships: packages.filter((item) => item.SPDXID !== rootId).map((item) => ({ spdxElementId: rootId, relationshipType: "DEPENDS_ON", relatedSpdxElement: item.SPDXID })),
|
||||
};
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(document, null, 2)}\n`);
|
||||
assert.equal(new Set(packages.map((item) => item.SPDXID)).size, packages.length);
|
||||
assert.ok(packages.length >= Object.keys(lock.packages).length);
|
||||
process.stdout.write(`sbom-ok packages=${packages.length} sha256=${sha256(fs.readFileSync(outputPath))}\n`);
|
||||
32
tools/web/generate-script-fixture.py
Normal file
32
tools/web/generate-script-fixture.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
SOURCES = {
|
||||
"InternalSafe.py": "value = 7\nprint(value)\n",
|
||||
"ModuleAutorun.py": "def register():\n return 'blocked'\n",
|
||||
"ExternalProject.py": "message = 'project relative'\n",
|
||||
}
|
||||
|
||||
|
||||
def main(output_path):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
for name, source in SOURCES.items():
|
||||
text = bpy.data.texts.new(name)
|
||||
text.write(source)
|
||||
bpy.data.texts["ModuleAutorun.py"].use_module = True
|
||||
bpy.data.texts["ExternalProject.py"].filepath = "//scripts/external_project.py"
|
||||
|
||||
path = pathlib.Path(output_path).resolve()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(path), compress=False)
|
||||
print(f"script-fixture-generated path={path} sources={len(SOURCES)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
arguments = sys.argv[sys.argv.index("--") + 1:]
|
||||
if len(arguments) != 1:
|
||||
raise SystemExit("usage: blender -b --python generate-script-fixture.py -- output.blend")
|
||||
main(arguments[0])
|
||||
@@ -13,7 +13,7 @@
|
||||
"id": "web-engine-bootstrap",
|
||||
"fileName": "web_engine.wasm",
|
||||
"url": "/vendor/blender/web_engine.wasm",
|
||||
"sha256": "acc2d6808dac4590aa3c3915495a6e24396a946b257a2d137f64ccf4c3ba6fbf",
|
||||
"sha256": "7176272d381a53d559d9b6ebe7ca8653ed32199ef6ddd4e2779637954f2c9ac3",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
|
||||
2
web/app/public/vendor/blender/web_engine.js
vendored
2
web/app/public/vendor/blender/web_engine.js
vendored
File diff suppressed because one or more lines are too long
BIN
web/app/public/vendor/blender/web_engine.wasm
vendored
BIN
web/app/public/vendor/blender/web_engine.wasm
vendored
Binary file not shown.
2
web/app/src/vendor/blender/web_engine.js
vendored
2
web/app/src/vendor/blender/web_engine.js
vendored
File diff suppressed because one or more lines are too long
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
Binary file not shown.
@@ -1,15 +1,17 @@
|
||||
import { gateRelease, parseReleaseManifest, serializeReleaseManifest } from "../../../protocol/release-gate";
|
||||
|
||||
const family = (id: string, dependencies: string[] = []) => ({ id, name: id, status: "BLOCKED", roadmapStatus: "planned", completedSlices: ["schema"], blockedSlices: ["A", "B"], acceptance: [], dependencies });
|
||||
const evidence = { browser: { chromium: false }, runtime: { offline: true, workerRestart: false, opfsRecovery: false }, performance: { geometry1M: true, geometry10M: false, texture4K: false, texture8K: false, longMedia: false, simulationCache: false }, faults: { oom: false, deviceLoss: false, networkInterrupt: false, malformedBlend: true, zipBomb: true }, provenance: { license: true, sbom: false, sourceOffer: false, deterministicPackage: false } };
|
||||
const base = { schemaVersion: 2, source: "docs/status/parity-ledger.json", generatedAt: "2026-08-11T00:00:00Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence };
|
||||
const evidenceRecord = { id: "fixture", fields: ["runtime.offline", "performance.geometry1M", "faults.malformedBlend", "faults.zipBomb", "provenance.license"], command: "fixture", exitCode: 0, durationMs: 1, output: "fixture passed", artifactSha256: ["a".repeat(64)] };
|
||||
const evidence = { browser: { chromium: false }, runtime: { offline: true, workerRestart: false, opfsRecovery: false }, performance: { geometry1M: true, geometry10M: false, texture4K: false, texture8K: false, longMedia: false, simulationCache: false }, faults: { oom: false, deviceLoss: false, networkInterrupt: false, malformedBlend: true, zipBomb: true }, provenance: { license: true, sbom: false, sourceOffer: false, deterministicPackage: false }, records: [evidenceRecord] };
|
||||
const base = { schemaVersion: 3, source: "docs/status/parity-ledger.json", sourceSha256: "b".repeat(64), generatedAt: "2026-08-11T00:00:00Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence };
|
||||
|
||||
self.onmessage = () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
try { const parsed = parseReleaseManifest(base); result.valid = [parsed.families.length, serializeReleaseManifest(base) === serializeReleaseManifest({ ...base, families: [...base.families].reverse() })]; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
|
||||
const gate = gateRelease(base); result.gate = [gate.status, gate.issues.map((issue) => issue.code)];
|
||||
try { parseReleaseManifest({ ...base, schemaVersion: 1 }); } catch (error) { result.oldSchema = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, schemaVersion: 2 }); } catch (error) { result.oldSchema = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, families: [family("N-015", ["N-016"]), family("N-016", ["N-015"])] }); } catch (error) { result.cycle = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), status: "LOCAL_EXACT", completedSlices: [] }] }); } catch (error) { result.status = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, evidence: { ...evidence, browser: { chromium: true } } }); } catch (error) { result.unbound = error instanceof Error ? error.message : String(error); }
|
||||
self.postMessage(result);
|
||||
};
|
||||
|
||||
@@ -36,8 +36,11 @@
|
||||
"test:tracking-mask": "playwright test --config playwright.config.ts -g \"N-022 tracking\"",
|
||||
"test:mask-main-reader": "node ../tools/web/check-mask-main-reader.mjs",
|
||||
"test:asset-library": "playwright test --config playwright.config.ts -g \"N-023 asset\"",
|
||||
"test:library-main-reader": "node ../tools/web/check-library-main-reader.mjs",
|
||||
"test:editor-workflow": "playwright test --config playwright.config.ts -g \"N-024 editor\"",
|
||||
"test:editor-main-reader": "node ../tools/web/check-editor-main-reader.mjs",
|
||||
"test:scripting-platform": "playwright test --config playwright.config.ts -g \"N-025 script\"",
|
||||
"test:script-main-reader": "node ../tools/web/check-script-main-reader.mjs",
|
||||
"test:release-gate": "playwright test --config playwright.config.ts -g \"N-026 release\"",
|
||||
"test:browser-smoke": "playwright test --config playwright.release.config.ts -g \"boots the offline engine\"",
|
||||
"test:cross-browser-smoke": "npm run test:browser-smoke",
|
||||
@@ -52,7 +55,10 @@
|
||||
"test:nonmesh-usd-blender-roundtrip": "USD_DESKTOP_REQUIRED=1 node ../tools/web/check-nonmesh-usd-blender-roundtrip.mjs",
|
||||
"test:release-performance": "node ../tools/web/check-release-performance.mjs",
|
||||
"test:malicious-blends": "node ../tools/web/check-malicious-blends.mjs",
|
||||
"test:release-package": "npm run build && node ../tools/web/check-release-package.mjs",
|
||||
"test:release-package": "npm run build && npm run release:sbom && node ../tools/web/check-release-package.mjs",
|
||||
"release:sbom": "node ../tools/web/generate-sbom.mjs",
|
||||
"release:evidence": "node ../tools/web/collect-release-evidence.mjs",
|
||||
"test:release-evidence": "node ../tools/web/check-release-evidence.mjs",
|
||||
"release:offline": "npm run build && node ../tools/web/check-offline-reproducibility.mjs",
|
||||
"diagnose:depsgraph": "node ../tools/web/diagnose-depsgraph.mjs"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const RELEASE_GATE_SCHEMA = 2 as const;
|
||||
export const RELEASE_GATE_SCHEMA = 3 as const;
|
||||
export type ParityStatus = "LOCAL_EXACT" | "LOCAL_BOUNDED" | "SERVER" | "BLOCKED";
|
||||
export interface ParityFamilyEvidenceIR { id: string; name: string; status: ParityStatus; roadmapStatus: "completed" | "in_progress" | "planned"; completedSlices: string[]; blockedSlices: string[]; acceptance: string[]; dependencies: string[] }
|
||||
export interface ReleaseEvidenceIR {
|
||||
@@ -10,8 +10,10 @@ export interface ReleaseEvidenceIR {
|
||||
performance: { geometry1M: boolean; geometry10M: boolean; texture4K: boolean; texture8K: boolean; longMedia: boolean; simulationCache: boolean };
|
||||
faults: { oom: boolean; deviceLoss: boolean; networkInterrupt: boolean; malformedBlend: boolean; zipBomb: boolean };
|
||||
provenance: { license: boolean; sbom: boolean; sourceOffer: boolean; deterministicPackage: boolean };
|
||||
records: ReleaseEvidenceRecordIR[];
|
||||
}
|
||||
export interface ReleaseManifestIR { schemaVersion: typeof RELEASE_GATE_SCHEMA; source: string; generatedAt: string; families: ParityFamilyEvidenceIR[]; evidence: ReleaseEvidenceIR }
|
||||
export interface ReleaseEvidenceRecordIR { id: string; fields: string[]; command: string; exitCode: 0; durationMs: number; output: string; artifactSha256: string[] }
|
||||
export interface ReleaseManifestIR { schemaVersion: typeof RELEASE_GATE_SCHEMA; source: string; sourceSha256: string; generatedAt: string; families: ParityFamilyEvidenceIR[]; evidence: ReleaseEvidenceIR }
|
||||
export interface ReleaseGateEvaluationIR { status: "READY" | "BLOCKED"; issueCodes: ErrorCode[]; missing: string[] }
|
||||
|
||||
export class ReleaseGateValidationError extends Error {
|
||||
@@ -28,7 +30,21 @@ function strings(value: unknown, name: string, maximum = 100_000): string[] { if
|
||||
function parseEvidence(value: unknown): ReleaseEvidenceIR {
|
||||
if (!record(value) || !record(value.browser) || !record(value.runtime) || !record(value.performance) || !record(value.faults) || !record(value.provenance)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "Release evidence groups are missing");
|
||||
const group = (name: string, keys: readonly string[]): Record<string, boolean> => { const item = value[name]; if (!record(item)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `evidence.${name} is invalid`); return Object.fromEntries(keys.map((key) => [key, bool(item[key], `evidence.${name}.${key}`)])); };
|
||||
return { browser: group("browser", ["chromium"]) as ReleaseEvidenceIR["browser"], runtime: group("runtime", ["offline", "workerRestart", "opfsRecovery"]) as ReleaseEvidenceIR["runtime"], performance: group("performance", ["geometry1M", "geometry10M", "texture4K", "texture8K", "longMedia", "simulationCache"]) as ReleaseEvidenceIR["performance"], faults: group("faults", ["oom", "deviceLoss", "networkInterrupt", "malformedBlend", "zipBomb"]) as ReleaseEvidenceIR["faults"], provenance: group("provenance", ["license", "sbom", "sourceOffer", "deterministicPackage"]) as ReleaseEvidenceIR["provenance"] };
|
||||
if (!Array.isArray(value.records) || value.records.length > 1024) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "evidence.records is invalid");
|
||||
const recordIds = new Set<string>();
|
||||
const records = value.records.map((item, index): ReleaseEvidenceRecordIR => {
|
||||
const name = `evidence.records[${index}]`; if (!record(item)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`);
|
||||
const id = text(item.id, `${name}.id`); if (recordIds.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate evidence record ${id}`); recordIds.add(id);
|
||||
const fields = strings(item.fields, `${name}.fields`, 64); if (new Set(fields).size !== fields.length || fields.some((field) => !/^(browser|runtime|performance|faults|provenance)\.[A-Za-z0-9]+$/.test(field))) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.fields is invalid`);
|
||||
if (item.exitCode !== 0 || typeof item.durationMs !== "number" || !Number.isSafeInteger(item.durationMs) || item.durationMs < 0 || item.durationMs > 86_400_000) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} did not complete successfully`);
|
||||
const artifactSha256 = strings(item.artifactSha256, `${name}.artifactSha256`, 1024); if (artifactSha256.some((digest) => !/^[a-f0-9]{64}$/.test(digest))) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.artifactSha256 is invalid`);
|
||||
return { id, fields, command: text(item.command, `${name}.command`, 2048), exitCode: 0, durationMs: item.durationMs, output: text(item.output, `${name}.output`, 4096), artifactSha256 };
|
||||
});
|
||||
const parsed = { browser: group("browser", ["chromium"]) as ReleaseEvidenceIR["browser"], runtime: group("runtime", ["offline", "workerRestart", "opfsRecovery"]) as ReleaseEvidenceIR["runtime"], performance: group("performance", ["geometry1M", "geometry10M", "texture4K", "texture8K", "longMedia", "simulationCache"]) as ReleaseEvidenceIR["performance"], faults: group("faults", ["oom", "deviceLoss", "networkInterrupt", "malformedBlend", "zipBomb"]) as ReleaseEvidenceIR["faults"], provenance: group("provenance", ["license", "sbom", "sourceOffer", "deterministicPackage"]) as ReleaseEvidenceIR["provenance"], records };
|
||||
for (const [groupName, groupValues] of Object.entries(parsed).filter(([name]) => name !== "records") as Array<[string, Record<string, boolean>]>) {
|
||||
for (const [key, enabled] of Object.entries(groupValues)) if (enabled && !records.some((item) => item.fields.includes(`${groupName}.${key}`))) throw new ReleaseGateValidationError("RELEASE_EVIDENCE_MISSING", `Enabled evidence ${groupName}.${key} has no successful record`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function assertDependencies(families: readonly ParityFamilyEvidenceIR[]): void {
|
||||
@@ -41,7 +57,8 @@ export function parseReleaseManifest(value: unknown): ReleaseManifestIR {
|
||||
if (!record(value) || value.schemaVersion !== RELEASE_GATE_SCHEMA || !Array.isArray(value.families)) throw new ReleaseGateValidationError("PROTOCOL_MISMATCH", "Unsupported release manifest schema");
|
||||
const ids = new Set<string>(); const families = value.families.map((item, index): ParityFamilyEvidenceIR => { const name = `families[${index}]`; if (!record(item) || !STATUSES.has(item.status as ParityStatus) || !["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`); ids.add(id); const completedSlices = strings(item.completedSlices, `${name}.completedSlices`); const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`); if (item.status !== "BLOCKED" && completedSlices.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must declare completed slices`); return { id, name: text(item.name, `${name}.name`), status: item.status as ParityStatus, roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"], completedSlices, blockedSlices, acceptance: strings(item.acceptance, `${name}.acceptance`), dependencies: strings(item.dependencies, `${name}.dependencies`) }; });
|
||||
assertDependencies(families);
|
||||
return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), generatedAt: text(value.generatedAt, "generatedAt", 128), families, evidence: parseEvidence(value.evidence) };
|
||||
const sourceSha256 = text(value.sourceSha256, "sourceSha256", 64); if (!/^[a-f0-9]{64}$/.test(sourceSha256)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "sourceSha256 is invalid");
|
||||
return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), sourceSha256, generatedAt: text(value.generatedAt, "generatedAt", 128), families, evidence: parseEvidence(value.evidence) };
|
||||
}
|
||||
|
||||
export function evaluateReleaseManifest(value: unknown): ReleaseGateEvaluationIR {
|
||||
|
||||
@@ -3,6 +3,9 @@ import { parseGreasePencilData, type GreasePencilDataIR } from "./grease-pencil"
|
||||
import { parseCompositorGraph, type CompositorGraphIR } from "./compositor";
|
||||
import { parseSequencerTimeline, type SequencerTimelineIR } from "./sequencer";
|
||||
import { parseTrackingMaskProject, type TrackingMaskProjectIR } from "./tracking-mask";
|
||||
import { normalizeProjectAssetPath } from "./asset-path";
|
||||
import { parseEditorWorkflow, type EditorWorkflowIR } from "./editor-workflow";
|
||||
import { parseScriptSourceInventory, type ScriptSourceInventoryIR } from "./scripting-platform";
|
||||
|
||||
export type SceneNodeType =
|
||||
| "EMPTY"
|
||||
@@ -474,6 +477,11 @@ export interface SceneSnapshotIR {
|
||||
greasePencils?: GreasePencilDataIR[];
|
||||
trackingMasks?: TrackingMaskProjectIR;
|
||||
trackingMaskStatus?: "AVAILABLE" | "BLOCKED";
|
||||
libraryStatus?: "AVAILABLE" | "BLOCKED";
|
||||
editorWorkflow?: EditorWorkflowIR;
|
||||
editorWorkflowStatus?: "AVAILABLE" | "BLOCKED";
|
||||
scriptSources?: ScriptSourceInventoryIR;
|
||||
scriptSourceStatus?: "AVAILABLE" | "BLOCKED";
|
||||
libraries?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -482,6 +490,8 @@ export interface SceneSnapshotIR {
|
||||
packedByteLength?: number;
|
||||
status: "PACKED" | "EXTERNAL_REQUIRED";
|
||||
errorCode?: "LINKED_LIBRARY_RESOURCE_REQUIRED";
|
||||
dependencyIds: string[];
|
||||
readOnly: true;
|
||||
}>;
|
||||
animations: AnimationIR[];
|
||||
nlaTracks?: NlaTrackIR[];
|
||||
@@ -1055,5 +1065,72 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
parseTrackingMaskProject(value.trackingMasks);
|
||||
if (value.trackingMaskStatus !== "AVAILABLE") throw new Error("SceneIR.trackingMaskStatus must be AVAILABLE when trackingMasks is present");
|
||||
}
|
||||
if (value.libraryStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.libraryStatus as string)) {
|
||||
throw new Error("SceneIR.libraryStatus is invalid");
|
||||
}
|
||||
if (value.libraries !== undefined) {
|
||||
const libraries = value.libraries as unknown[];
|
||||
if (libraries.length > 0 && value.libraryStatus !== "AVAILABLE") throw new Error("SceneIR.libraryStatus must be AVAILABLE when libraries are present");
|
||||
if (libraries.length > 1024) throw new Error("SceneIR.libraries exceeds the budget");
|
||||
const libraryIds = new Set<string>();
|
||||
for (const [index, library] of libraries.entries()) {
|
||||
if (!isRecord(library)) throw new Error(`SceneIR.libraries[${index}] must be an object`);
|
||||
const id = requireString(library.id, `libraries[${index}].id`);
|
||||
if (!id || id.length > 256 || libraryIds.has(id)) throw new Error(`SceneIR.libraries[${index}].id is invalid`);
|
||||
libraryIds.add(id);
|
||||
requireString(library.name, `libraries[${index}].name`);
|
||||
const sourcePath = requireString(library.sourcePath, `libraries[${index}].sourcePath`);
|
||||
if (typeof library.packed !== "boolean" || library.readOnly !== true || !Array.isArray(library.dependencyIds) ||
|
||||
library.dependencyIds.length > 1024 || library.dependencyIds.some((dependency) => typeof dependency !== "string") ||
|
||||
new Set(library.dependencyIds).size !== library.dependencyIds.length ||
|
||||
!["PACKED", "EXTERNAL_REQUIRED"].includes(library.status as string)) {
|
||||
throw new Error(`SceneIR.libraries[${index}] is invalid`);
|
||||
}
|
||||
let projectPath = true;
|
||||
try { normalizeProjectAssetPath(sourcePath); } catch { projectPath = false; }
|
||||
if (!projectPath) {
|
||||
throw new Error(`SceneIR.libraries[${index}].sourcePath is outside the project`);
|
||||
}
|
||||
if (library.status === "PACKED" && (!library.packed || !Number.isSafeInteger(library.packedByteLength) || (library.packedByteLength as number) <= 0)) {
|
||||
throw new Error(`SceneIR.libraries[${index}] packed payload is invalid`);
|
||||
}
|
||||
if (library.status === "EXTERNAL_REQUIRED" && (library.packed || library.errorCode !== "LINKED_LIBRARY_RESOURCE_REQUIRED")) {
|
||||
throw new Error(`SceneIR.libraries[${index}] external resource state is invalid`);
|
||||
}
|
||||
}
|
||||
for (const [index, library] of libraries.entries()) {
|
||||
const dependencies = (library as Record<string, unknown>).dependencyIds as string[];
|
||||
if (dependencies.some((dependency) => !libraryIds.has(dependency))) throw new Error(`SceneIR.libraries[${index}] references a missing dependency`);
|
||||
}
|
||||
const byId = new Map(libraries.map((library) => {
|
||||
const record = library as Record<string, unknown>;
|
||||
return [record.id as string, record.dependencyIds as string[]] as const;
|
||||
}));
|
||||
const active = new Set<string>();
|
||||
const complete = new Set<string>();
|
||||
const visit = (id: string): void => {
|
||||
if (active.has(id)) throw new Error(`SceneIR.libraries dependency cycle includes ${id}`);
|
||||
if (complete.has(id)) return;
|
||||
active.add(id);
|
||||
for (const dependency of byId.get(id) ?? []) visit(dependency);
|
||||
active.delete(id);
|
||||
complete.add(id);
|
||||
};
|
||||
byId.forEach((_dependencies, id) => visit(id));
|
||||
}
|
||||
if (value.editorWorkflowStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.editorWorkflowStatus as string)) {
|
||||
throw new Error("SceneIR.editorWorkflowStatus is invalid");
|
||||
}
|
||||
if (value.editorWorkflow !== undefined) {
|
||||
parseEditorWorkflow(value.editorWorkflow);
|
||||
if (value.editorWorkflowStatus !== "AVAILABLE") throw new Error("SceneIR.editorWorkflowStatus must be AVAILABLE when editorWorkflow is present");
|
||||
}
|
||||
if (value.scriptSourceStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.scriptSourceStatus as string)) {
|
||||
throw new Error("SceneIR.scriptSourceStatus is invalid");
|
||||
}
|
||||
if (value.scriptSources !== undefined) {
|
||||
parseScriptSourceInventory(value.scriptSources);
|
||||
if (value.scriptSourceStatus !== "AVAILABLE") throw new Error("SceneIR.scriptSourceStatus must be AVAILABLE when scriptSources is present");
|
||||
}
|
||||
return value as unknown as SceneSnapshotIR;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } fr
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const SCRIPTING_PLATFORM_SCHEMA = 1 as const;
|
||||
export const SCRIPTING_BUDGET = { maxScripts: 1_024, maxPermissions: 64, maxDependencies: 128, maxCpuMs: 60_000, maxMemoryBytes: 512 * 1024 * 1024, maxWallMs: 300_000 } as const;
|
||||
export const SCRIPT_SOURCE_SCHEMA = 1 as const;
|
||||
export const SCRIPTING_BUDGET = { maxScripts: 1_024, maxPermissions: 64, maxDependencies: 128, maxCpuMs: 60_000, maxMemoryBytes: 512 * 1024 * 1024, maxWallMs: 300_000, maxSourceBytes: 1024 * 1024, maxSourceLines: 65_536 } as const;
|
||||
export const SCRIPT_PERMISSIONS = ["READ_MAIN", "WRITE_MAIN", "READ_ASSET", "WRITE_ASSET", "SUBMIT_SERVER_JOB"] as const;
|
||||
export type ScriptPermission = typeof SCRIPT_PERMISSIONS[number];
|
||||
|
||||
@@ -27,6 +28,21 @@ export interface ScriptManifestIR {
|
||||
addonInstall: false;
|
||||
}
|
||||
export interface ScriptingManifestIR { schemaVersion: typeof SCRIPTING_PLATFORM_SCHEMA; scripts: ScriptManifestIR[] }
|
||||
export interface ScriptSourceIR {
|
||||
id: string;
|
||||
name: string;
|
||||
source: string;
|
||||
sourceSha256: string;
|
||||
byteLength: number;
|
||||
lineCount: number;
|
||||
sourcePath?: string;
|
||||
internal: boolean;
|
||||
moduleAutorunRequested: boolean;
|
||||
readOnly: true;
|
||||
executionStatus: "BLOCKED";
|
||||
errorCode: "SCRIPT_POLICY_DENIED" | "SCRIPT_SANDBOX_UNAVAILABLE";
|
||||
}
|
||||
export interface ScriptSourceInventoryIR { schemaVersion: typeof SCRIPT_SOURCE_SCHEMA; sources: ScriptSourceIR[] }
|
||||
export interface ServerScriptJobIR { scriptId: string; sourceSha256: string; inputBlendSha256: string; outputBlendSha256?: string; status: "QUEUED" | "RUNNING" | "COMPLETE" | "FAILED" }
|
||||
|
||||
export class ScriptingPlatformValidationError extends Error {
|
||||
@@ -41,6 +57,31 @@ function digest(value: unknown, name: string): string { if (typeof value !== "st
|
||||
function path(value: unknown, name: string): string { try { return normalizeProjectAssetPath(text(value, name, 2048)); } catch { throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is outside the project`); } }
|
||||
function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name} exceeds the budget`); return value; }
|
||||
|
||||
export function parseScriptSourceInventory(value: unknown): ScriptSourceInventoryIR {
|
||||
if (!record(value) || value.schemaVersion !== SCRIPT_SOURCE_SCHEMA || !Array.isArray(value.sources)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script source inventory schema");
|
||||
if (value.sources.length > SCRIPTING_BUDGET.maxScripts) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script source count exceeds the budget");
|
||||
const ids = new Set<string>(); let totalBytes = 0;
|
||||
const sources = value.sources.map((item, index): ScriptSourceIR => {
|
||||
const name = `sources[${index}]`; if (!record(item) || typeof item.source !== "string") throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`);
|
||||
const id = text(item.id, `${name}.id`); if (!id.startsWith("text:") || ids.has(id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name}.id is invalid`); ids.add(id);
|
||||
const byteLength = integer(item.byteLength, `${name}.byteLength`, 0, SCRIPTING_BUDGET.maxSourceBytes); totalBytes += byteLength;
|
||||
if (totalBytes > SCRIPTING_BUDGET.maxSourceBytes || new TextEncoder().encode(item.source).byteLength !== byteLength) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name}.source exceeds or disagrees with the byte budget`);
|
||||
const moduleAutorunRequested = item.moduleAutorunRequested === true;
|
||||
if (item.readOnly !== true || item.executionStatus !== "BLOCKED" || item.internal !== (item.sourcePath === undefined) ||
|
||||
item.errorCode !== (moduleAutorunRequested ? "SCRIPT_POLICY_DENIED" : "SCRIPT_SANDBOX_UNAVAILABLE")) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", `${name} execution policy is invalid`);
|
||||
const sourcePath = item.sourcePath === undefined ? undefined : path(item.sourcePath, `${name}.sourcePath`);
|
||||
const lineCount = integer(item.lineCount, `${name}.lineCount`, 1, SCRIPTING_BUDGET.maxSourceLines);
|
||||
if (item.source.split("\n").length !== lineCount) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name}.lineCount disagrees with source`);
|
||||
return { id, name: text(item.name, `${name}.name`), source: item.source, sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`), byteLength, lineCount, sourcePath, internal: item.internal as boolean, moduleAutorunRequested, readOnly: true, executionStatus: "BLOCKED", errorCode: item.errorCode as ScriptSourceIR["errorCode"] };
|
||||
});
|
||||
return { schemaVersion: SCRIPT_SOURCE_SCHEMA, sources };
|
||||
}
|
||||
|
||||
export async function verifyScriptSource(source: ScriptSourceIR): Promise<boolean> {
|
||||
const digestBytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source.source));
|
||||
return [...new Uint8Array(digestBytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("") === source.sourceSha256;
|
||||
}
|
||||
|
||||
export function parseScriptingManifest(value: unknown): ScriptingManifestIR {
|
||||
if (!record(value) || value.schemaVersion !== SCRIPTING_PLATFORM_SCHEMA || !Array.isArray(value.scripts)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported scripting manifest schema");
|
||||
if (value.scripts.length > SCRIPTING_BUDGET.maxScripts) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script count exceeds the budget");
|
||||
|
||||
@@ -690,6 +690,7 @@ test("keeps N-026 release manifest deterministic and blocks missing evidence", a
|
||||
expect(result.oldSchema).toContain("PROTOCOL_MISMATCH");
|
||||
expect(result.cycle).toContain("RELEASE_DEPENDENCY_CYCLE");
|
||||
expect(result.status).toContain("RELEASE_MANIFEST_INVALID");
|
||||
expect(result.unbound).toContain("RELEASE_EVIDENCE_MISSING");
|
||||
});
|
||||
|
||||
test("keeps N-015 selection history bounded and rejects stale raycast hits", async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user