omakit 0.5.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -45
- package/blocks/history.json +68 -0
- package/blocks/run/NOTICE +12 -0
- package/blocks/run/Run.qml +242 -0
- package/blocks/run/run-supervisor.py +522 -0
- package/blocks/store/NOTICE +12 -0
- package/blocks/store/Store.qml +157 -0
- package/blocks/store/store-helper.py +431 -0
- package/package.json +12 -5
- package/skills/omarchy-plugin-audit/SKILL.md +11 -5
- package/skills/omarchy-plugin-build/SKILL.md +164 -0
- package/skills/omarchy-plugin-check/SKILL.md +6 -3
- package/skills/omarchy-plugin-submit/SKILL.md +4 -1
- package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
- package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
- package/tests/fixtures/weigh/clean/Widget.qml +19 -0
- package/tests/fixtures/weigh/clean/manifest.json +9 -0
- package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
- package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
- package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
- package/tests/fixtures/weigh/poller/Service.qml +50 -0
- package/tests/fixtures/weigh/poller/manifest.json +9 -0
- package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
- package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
- package/tests/lab/run/harness/scenarios/controls.sh +6 -0
- package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
- package/tests/lab/run/harness/scenarios/forge.sh +11 -0
- package/tests/lab/run/harness/scenarios/holder.sh +5 -0
- package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
- package/tests/lab/run/harness/scenarios/stall.sh +5 -0
- package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
- package/tests/lab/run/harness/scenarios/tree.sh +7 -0
- package/tests/lab/run/harness/shell.qml +84 -0
- package/tests/lab/run/report.py +217 -0
- package/tests/lab/run/suite.sh +106 -0
- package/tests/lab/store/harness/shell.qml +73 -0
- package/tests/lab/store/report.py +133 -0
- package/tests/lab/store/suite.sh +109 -0
- package/tests/parity/corpus.mjs +8 -3
- package/tests/parity/run.mjs +4 -4
- package/tools/audit/audit.mjs +17 -6
- package/tools/audit/git.mjs +3 -3
- package/tools/audit/report.mjs +31 -5
- package/tools/blocks/add.mjs +138 -0
- package/tools/blocks/commit.json +5 -0
- package/tools/blocks/record-commit.mjs +77 -0
- package/tools/blocks/registry.mjs +191 -0
- package/tools/blocks/stamp.mjs +61 -0
- package/tools/inspect/contract.mjs +36 -5
- package/tools/inspect/helpers.mjs +217 -0
- package/tools/inspect/inspect.mjs +68 -3
- package/tools/inspect/patterns.mjs +18 -3
- package/tools/inspect/processes.mjs +38 -5
- package/tools/inspect/report.mjs +18 -2
- package/tools/inspect/writes.mjs +22 -4
- package/tools/lab/guest.mjs +155 -0
- package/tools/lab/harness.sh +119 -0
- package/tools/lab/host.mjs +177 -0
- package/tools/lab/inspect.mjs +240 -0
- package/tools/lab/omarchy.gpg +13 -0
- package/tools/lab/patches/omarchy-iso-test.patch +351 -0
- package/tools/lab/paths.mjs +173 -0
- package/tools/lab/pin.json +42 -0
- package/tools/lab/pin.mjs +64 -0
- package/tools/lab/prune.mjs +68 -0
- package/tools/lab/qemu.mjs +153 -0
- package/tools/lab/qmp-cli.mjs +21 -0
- package/tools/lab/report.mjs +183 -0
- package/tools/lab/run.mjs +344 -0
- package/tools/lab/setup.mjs +430 -0
- package/tools/lab/suites/run.sh +35 -0
- package/tools/lab/suites/store.sh +41 -0
- package/tools/lab/suites/weigh.sh +196 -0
- package/tools/lab/suites.mjs +142 -0
- package/tools/lab/verify.mjs +134 -0
- package/tools/marketplace/README.md +38 -1
- package/tools/marketplace/banner.mjs +23 -2
- package/tools/marketplace/cli.mjs +449 -146
- package/tools/marketplace/completion-check.mjs +27 -1
- package/tools/marketplace/completion.mjs +32 -4
- package/tools/marketplace/doctor.mjs +47 -9
- package/tools/marketplace/github.mjs +52 -6
- package/tools/marketplace/local-transport.mjs +1 -1
- package/tools/marketplace/options.mjs +16 -5
- package/tools/marketplace/outcome.mjs +244 -0
- package/tools/marketplace/pin.mjs +178 -33
- package/tools/marketplace/setup.mjs +16 -15
- package/tools/marketplace/tree.mjs +1 -1
- package/tools/marketplace/upgrade.mjs +5 -5
- package/tools/marketplace/usage.mjs +116 -72
- package/tools/subject/resolve.mjs +19 -6
- package/tools/weigh/audit.mjs +47 -16
- package/tools/weigh/config.mjs +105 -24
- package/tools/weigh/list.mjs +10 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// omakit block: store 0.2.0
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Copyright (c) 2026 Maarten Tolhuijs
|
|
4
|
+
// Source: omakit blocks/store/Store.qml, commit unstamped
|
|
5
|
+
// Body sha256: 6fafd2ec98b4d4fe18ead2ae1f1221386e9714eb9a33ef0f1b9cf48520f2c743
|
|
6
|
+
// end of omakit block header
|
|
7
|
+
//
|
|
8
|
+
// Store: one private file for a plugin, read and written the way the
|
|
9
|
+
// review asks for. The file lives in the plugin's own 0700 directory under
|
|
10
|
+
// the XDG state or cache base; store-helper.py, next to this file and
|
|
11
|
+
// started through the Run block (Run.qml, the same directory), reaches it
|
|
12
|
+
// by a descriptor walk with O_NOFOLLOW at every step, checks owner, kind
|
|
13
|
+
// and mode on the descriptor after every open and never before, caps a
|
|
14
|
+
// read in bytes, parses it against a schema, and writes through an
|
|
15
|
+
// exclusive 0600 staging file renamed into place. docs/BLOCKS.md is the
|
|
16
|
+
// contract and says which review comments each line answers.
|
|
17
|
+
//
|
|
18
|
+
// Store {
|
|
19
|
+
// id: memory
|
|
20
|
+
// pluginId: "io.github.me.plugin"
|
|
21
|
+
// name: "memory.json"
|
|
22
|
+
// schema: ({ type: "object", required: ["version"], properties: { version: { type: "integer" } } })
|
|
23
|
+
// onFinished: result => { if (result.op === "read" && result.state === "ok") apply(result.value) }
|
|
24
|
+
// }
|
|
25
|
+
// memory.read(); memory.write({ version: 1, themes: {} }); memory.remove()
|
|
26
|
+
import QtQuick
|
|
27
|
+
import Quickshell
|
|
28
|
+
|
|
29
|
+
QtObject {
|
|
30
|
+
id: store
|
|
31
|
+
|
|
32
|
+
// The plugin's id, which names its private directory: letters, digits,
|
|
33
|
+
// dots, dashes and underscores. The file name inside it, the same set.
|
|
34
|
+
property string pluginId: ""
|
|
35
|
+
property string name: ""
|
|
36
|
+
// "state" for $XDG_STATE_HOME (~/.local/state), "cache" for
|
|
37
|
+
// $XDG_CACHE_HOME (~/.cache); the base has to be inside HOME.
|
|
38
|
+
property string kind: "state"
|
|
39
|
+
// A read over this many bytes is overflow; a write is refused over it too.
|
|
40
|
+
property int maxBytes: 1048576
|
|
41
|
+
// The schema subset a read and a write are checked against: type,
|
|
42
|
+
// properties, required, additionalProperties: false, items, enum,
|
|
43
|
+
// maxLength, maxItems, maxProperties, minimum, maximum, pattern. Null
|
|
44
|
+
// checks nothing but that the file is JSON.
|
|
45
|
+
property var schema: null
|
|
46
|
+
|
|
47
|
+
readonly property bool busy: _queue.length > 0 || _helper.running
|
|
48
|
+
// One object per operation: op is read, write or remove; state is one
|
|
49
|
+
// of ok, missing, invalid, refused, overflow, failed, helper-failed;
|
|
50
|
+
// value (a read), bytes, mtime, path, and reason for every state that
|
|
51
|
+
// is not ok or missing.
|
|
52
|
+
signal finished(var result)
|
|
53
|
+
|
|
54
|
+
// A write goes to the helper as one argument; the kernel bounds one
|
|
55
|
+
// argument at 128 KiB, so a write is refused above this many UTF-8
|
|
56
|
+
// bytes before anything starts, and so is a schema. A cache a helper of
|
|
57
|
+
// the plugin downloads itself uses the helper's own transaction; Store
|
|
58
|
+
// is for the plugin's state.
|
|
59
|
+
readonly property int maxWriteBytes: 65536
|
|
60
|
+
readonly property string helper: decodeURIComponent(Qt.resolvedUrl("store-helper.py").toString().replace(/^file:\/\//, ""))
|
|
61
|
+
property var _queue: []
|
|
62
|
+
property var _current: null
|
|
63
|
+
|
|
64
|
+
readonly property var _environment: {
|
|
65
|
+
const environment = {}
|
|
66
|
+
for (const name of ["XDG_STATE_HOME", "XDG_CACHE_HOME"]) {
|
|
67
|
+
const value = Quickshell.env(name)
|
|
68
|
+
if (value) environment[name] = value
|
|
69
|
+
}
|
|
70
|
+
return environment
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function read() { _enqueue({ op: "read" }) }
|
|
74
|
+
function remove() { _enqueue({ op: "remove" }) }
|
|
75
|
+
|
|
76
|
+
function write(value) {
|
|
77
|
+
let text
|
|
78
|
+
try { text = JSON.stringify(value) } catch (error) { _report({ op: "write", state: "invalid", reason: "the value is not JSON: " + error }); return }
|
|
79
|
+
if (typeof text !== "string") { _report({ op: "write", state: "invalid", reason: "the value is undefined" }); return }
|
|
80
|
+
const bytes = _utf8Bytes(text)
|
|
81
|
+
if (bytes < 0) { _report({ op: "write", state: "invalid", reason: "the value holds a lone surrogate" }); return }
|
|
82
|
+
if (bytes > maxWriteBytes) { _report({ op: "write", state: "overflow", reason: "the value is " + bytes + " bytes, over " + maxWriteBytes }); return }
|
|
83
|
+
_enqueue({ op: "write", value: text })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// UTF-8 bytes of a string, counted the way the kernel counts an argument; -1 for a lone surrogate.
|
|
87
|
+
function _utf8Bytes(text) {
|
|
88
|
+
try { return encodeURIComponent(text).replace(/%[0-9A-F]{2}/g, ".").length } catch (error) { return -1 }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _schemaText() {
|
|
92
|
+
if (!schema) return null
|
|
93
|
+
const text = JSON.stringify(schema)
|
|
94
|
+
return typeof text === "string" && _utf8Bytes(text) > 0 && _utf8Bytes(text) <= maxWriteBytes ? text : ""
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function _enqueue(operation) {
|
|
98
|
+
_queue = _queue.concat([operation])
|
|
99
|
+
_next()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _next() {
|
|
103
|
+
if (_helper.running || _queue.length === 0) return
|
|
104
|
+
_current = _queue[0]
|
|
105
|
+
_queue = _queue.slice(1)
|
|
106
|
+
const schemaText = _schemaText()
|
|
107
|
+
if (schemaText === "") { _onHelper({ state: "spawn-failed", reason: "the schema is not a JSON object under " + maxWriteBytes + " bytes" }); return }
|
|
108
|
+
_helper.command = _argv(_current, schemaText)
|
|
109
|
+
_helper.start()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _argv(operation, schemaText) {
|
|
113
|
+
const argv = ["/usr/bin/python3", "-I", "-S", "-B", helper, operation.op, "--kind", kind, "--plugin", pluginId, "--name", name, "--max-bytes", String(maxBytes)]
|
|
114
|
+
if (schemaText) argv.push("--schema", schemaText)
|
|
115
|
+
if (operation.op === "write") argv.push("--value", operation.value)
|
|
116
|
+
return argv
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _report(result) {
|
|
120
|
+
finished(result)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function _parsed(run) {
|
|
124
|
+
if (run.state !== "ok") return null
|
|
125
|
+
try {
|
|
126
|
+
const result = JSON.parse(String(run.stdout || "").trim().split("\n").pop())
|
|
127
|
+
return result && result.ev === "result" ? result : null
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function _lost(op, run) {
|
|
134
|
+
const detail = (run.reason ? ": " + run.reason : "") + (run.stderr ? "; " + String(run.stderr).slice(0, 512) : "")
|
|
135
|
+
return { op: op, state: "helper-failed", reason: "the helper ended as " + run.state + detail }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function _onHelper(run) {
|
|
139
|
+
const operation = _current || { op: "unknown" }
|
|
140
|
+
_current = null
|
|
141
|
+
const result = _parsed(run) || _lost(operation.op, run)
|
|
142
|
+
delete result.ev
|
|
143
|
+
_report(result)
|
|
144
|
+
_next()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The helper: a descriptor walk, one read or write, a few fsyncs: 10 s
|
|
148
|
+
// covers a slow disk; its one line carries the value, so the cap is the
|
|
149
|
+
// file cap plus the line around it.
|
|
150
|
+
property Run _helper: Run {
|
|
151
|
+
environment: store._environment
|
|
152
|
+
deadlineMs: 10000
|
|
153
|
+
maxBytes: store.maxBytes + 65536
|
|
154
|
+
keepBytes: store.maxBytes + 65536
|
|
155
|
+
onFinished: function(result) { store._onHelper(result) }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
# omakit block: store 0.2.0
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# Copyright (c) 2026 Maarten Tolhuijs
|
|
4
|
+
# Source: omakit blocks/store/store-helper.py, commit unstamped
|
|
5
|
+
# Body sha256: 2dd4d6ad308bbed473936c29844793411d8849415812b96ca5e7a6688097e37c
|
|
6
|
+
# end of omakit block header
|
|
7
|
+
#
|
|
8
|
+
# The helper behind Store.qml, started through the Run block as
|
|
9
|
+
# /usr/bin/python3 -I -S -B <this file> read|write|remove --kind state|cache
|
|
10
|
+
# --plugin <id> --name <file> [--max-bytes N] [--schema JSON] [--value JSON]
|
|
11
|
+
# and never by hand. One private directory per plugin under the XDG base,
|
|
12
|
+
# reached by a descriptor walk from HOME: every directory opened with
|
|
13
|
+
# O_NOFOLLOW and O_DIRECTORY, checked on its descriptor to be a directory
|
|
14
|
+
# owned by this user and writable by nobody else, created with mode 0700
|
|
15
|
+
# where missing. Every file is opened relative to that descriptor with
|
|
16
|
+
# O_NOFOLLOW and O_NONBLOCK (a FIFO is refused, never waited on) and
|
|
17
|
+
# checked the same way after the open, never before it. A read is capped
|
|
18
|
+
# in bytes and parsed against a schema; a write goes to an exclusive 0600
|
|
19
|
+
# staging file, every byte written and fsynced before the rename, or the
|
|
20
|
+
# staging file is unlinked and the write fails. The shape is the
|
|
21
|
+
# catalog cache transaction of omarchy-theme-manager 0.5.15, which the
|
|
22
|
+
# marketplace review read without a further file or state comment. One
|
|
23
|
+
# JSON line on stdout is the result; docs/BLOCKS.md is the contract.
|
|
24
|
+
import errno
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import secrets
|
|
29
|
+
import stat
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
|
|
33
|
+
SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
34
|
+
STAGING = re.compile(r"^\.store-\d+-[0-9a-f]{16}\.tmp$")
|
|
35
|
+
STAGING_STALE_SECONDS = 600
|
|
36
|
+
DEFAULT_MAX_BYTES = 1048576
|
|
37
|
+
KINDS = {"state": ("XDG_STATE_HOME", ".local/state"), "cache": ("XDG_CACHE_HOME", ".cache")}
|
|
38
|
+
DIRECTORY_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
39
|
+
TYPES = {"object": dict, "array": list, "string": str, "boolean": bool, "null": type(None)}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Refused(Exception):
|
|
43
|
+
"""A check on a descriptor failed: the directory or file is not ours to use."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Invalid(Exception):
|
|
47
|
+
"""The file's content is not what the schema says."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Overflow(Exception):
|
|
51
|
+
"""The file, or the value to write, is over the cap."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
MAX_NESTING = 64
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def nesting(value, depth=0):
|
|
58
|
+
"""How deep a JSON value nests; past MAX_NESTING it stops counting, which is enough to refuse it."""
|
|
59
|
+
if depth > MAX_NESTING or not isinstance(value, (dict, list)):
|
|
60
|
+
return depth
|
|
61
|
+
members = value.values() if isinstance(value, dict) else value
|
|
62
|
+
return max([depth] + [nesting(member, depth + 1) for member in members])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def emit(obj):
|
|
66
|
+
"""One UTF-8 line: the value as it is, never \\u-escaped into more bytes than the file holds."""
|
|
67
|
+
sys.stdout.buffer.write(json.dumps(obj, ensure_ascii=False, sort_keys=True).encode("utf-8", "surrogatepass") + b"\n")
|
|
68
|
+
sys.stdout.flush()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse(argv):
|
|
72
|
+
"""The operation and its options; every value is a string, the caller converts."""
|
|
73
|
+
if not argv or argv[0] not in ("read", "write", "remove"):
|
|
74
|
+
raise SystemExit("store-helper: read, write or remove")
|
|
75
|
+
opts = {"op": argv[0], "max_bytes": str(DEFAULT_MAX_BYTES)}
|
|
76
|
+
index = 1
|
|
77
|
+
while index < len(argv):
|
|
78
|
+
key = argv[index][2:].replace("-", "_")
|
|
79
|
+
if not argv[index].startswith("--") or key not in ("kind", "plugin", "name", "max_bytes", "schema", "value") or index + 1 >= len(argv):
|
|
80
|
+
raise SystemExit("store-helper: unknown option %s" % argv[index])
|
|
81
|
+
opts[key] = argv[index + 1]
|
|
82
|
+
index += 2
|
|
83
|
+
return opts
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def check_options(opts):
|
|
87
|
+
if opts.get("kind") not in KINDS:
|
|
88
|
+
raise Refused("kind must be state or cache")
|
|
89
|
+
for key in ("plugin", "name"):
|
|
90
|
+
if not SAFE_NAME.match(opts.get(key, "")) or ".." in opts[key]:
|
|
91
|
+
raise Refused("%s is not a safe name: %r" % (key, opts.get(key, "")))
|
|
92
|
+
if not opts["max_bytes"].isdigit() or int(opts["max_bytes"]) < 1:
|
|
93
|
+
raise Refused("max-bytes must be a positive integer")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def check_schema(schema):
|
|
97
|
+
"""The schema is the documented subset in shape: an object whose keywords carry the right kinds, all the way down."""
|
|
98
|
+
if not isinstance(schema, dict):
|
|
99
|
+
raise Refused("schema is not an object")
|
|
100
|
+
kinds = {"type": str, "properties": dict, "required": list, "additionalProperties": bool, "items": dict, "enum": list,
|
|
101
|
+
"maxLength": int, "maxItems": int, "maxProperties": int, "minimum": (int, float), "maximum": (int, float), "pattern": str}
|
|
102
|
+
for key, value in schema.items():
|
|
103
|
+
if key not in kinds or not isinstance(value, kinds[key]) or (isinstance(value, bool) and kinds[key] is not bool):
|
|
104
|
+
raise Refused("schema keyword %r is not in the supported subset, or its value is of the wrong kind" % key)
|
|
105
|
+
for member in list(schema.get("properties", {}).values()) + ([schema["items"]] if "items" in schema else []):
|
|
106
|
+
check_schema(member)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def verify_owned(fd, label, directory):
|
|
110
|
+
"""The descriptor is the kind of thing expected, ours, and writable by nobody else."""
|
|
111
|
+
info = os.fstat(fd)
|
|
112
|
+
if directory and not stat.S_ISDIR(info.st_mode):
|
|
113
|
+
raise Refused("%s is not a directory" % label)
|
|
114
|
+
if not directory and not stat.S_ISREG(info.st_mode):
|
|
115
|
+
raise Refused("%s is not a regular file" % label)
|
|
116
|
+
if info.st_uid != os.getuid():
|
|
117
|
+
raise Refused("%s is not owned by this user" % label)
|
|
118
|
+
if info.st_mode & 0o022:
|
|
119
|
+
raise Refused("%s is writable by the group or by others" % label)
|
|
120
|
+
return info
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def open_child_directory(parent_fd, name, label, create):
|
|
124
|
+
"""A child directory by descriptor, created 0700 when missing and allowed."""
|
|
125
|
+
if create:
|
|
126
|
+
try:
|
|
127
|
+
os.mkdir(name, mode=0o700, dir_fd=parent_fd)
|
|
128
|
+
except FileExistsError:
|
|
129
|
+
pass
|
|
130
|
+
fd = open_no_follow(name, DIRECTORY_FLAGS, parent_fd, label)
|
|
131
|
+
try:
|
|
132
|
+
verify_owned(fd, label, True)
|
|
133
|
+
except Refused:
|
|
134
|
+
os.close(fd)
|
|
135
|
+
raise
|
|
136
|
+
return fd
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def open_no_follow(name, flags, dir_fd, label):
|
|
140
|
+
"""openat with O_NOFOLLOW; a symbolic link in the way is a refusal, by name."""
|
|
141
|
+
try:
|
|
142
|
+
return os.open(name, flags, dir_fd=dir_fd)
|
|
143
|
+
except OSError as error:
|
|
144
|
+
if error.errno == errno.ELOOP:
|
|
145
|
+
raise Refused("%s is a symbolic link" % label) from None
|
|
146
|
+
if error.errno == errno.ENOTDIR and flags & os.O_DIRECTORY:
|
|
147
|
+
raise Refused("%s is a symbolic link or a file, not a directory" % label) from None
|
|
148
|
+
raise
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def base_components(kind, environ):
|
|
152
|
+
"""HOME and the XDG base's path components below it; the base must be inside HOME."""
|
|
153
|
+
home = environ.get("HOME", "")
|
|
154
|
+
variable, default = KINDS[kind]
|
|
155
|
+
base = environ.get(variable) or os.path.join(home, default)
|
|
156
|
+
if not home.startswith("/") or not base.startswith("/"):
|
|
157
|
+
raise Refused("HOME and %s must be absolute paths" % variable)
|
|
158
|
+
home, base = os.path.normpath(home), os.path.normpath(base)
|
|
159
|
+
if os.path.commonpath([home, base]) != home:
|
|
160
|
+
raise Refused("%s is not inside HOME, so its ownership cannot be walked" % variable)
|
|
161
|
+
relative = os.path.relpath(base, home)
|
|
162
|
+
components = [] if relative == "." else relative.split(os.sep)
|
|
163
|
+
if any(part in ("", ".", "..") for part in components):
|
|
164
|
+
raise Refused("%s has an unsafe component" % variable)
|
|
165
|
+
return home, base, components
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def open_private_directory(kind, plugin, environ=None):
|
|
169
|
+
"""The plugin's private directory, by a descriptor walk from HOME; the descriptors on the way stay open.
|
|
170
|
+
|
|
171
|
+
`environ` is where HOME and the XDG base are read from: the process
|
|
172
|
+
environment by default, or a mapping a long-running importer passes so
|
|
173
|
+
a test can point the walk at a throwaway base without touching the
|
|
174
|
+
process. Measured before this: an importer's tests wrote into the
|
|
175
|
+
user's real state directory.
|
|
176
|
+
"""
|
|
177
|
+
home, base, components = base_components(kind, os.environ if environ is None else environ)
|
|
178
|
+
fds = [open_no_follow(home, DIRECTORY_FLAGS, None, "HOME")]
|
|
179
|
+
try:
|
|
180
|
+
verify_owned(fds[0], "HOME", True)
|
|
181
|
+
for part in components:
|
|
182
|
+
fds.append(open_child_directory(fds[-1], part, "the %s directory" % kind, True))
|
|
183
|
+
fds.append(open_child_directory(fds[-1], plugin, "the plugin's %s directory" % kind, True))
|
|
184
|
+
except BaseException:
|
|
185
|
+
for fd in fds: # a refusal partway leaves nothing open behind an importer
|
|
186
|
+
os.close(fd)
|
|
187
|
+
raise
|
|
188
|
+
return fds, os.path.join(base, plugin)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def read_capped(fd, maximum):
|
|
192
|
+
chunks, total = [], 0
|
|
193
|
+
while True:
|
|
194
|
+
chunk = os.read(fd, min(65536, maximum + 1 - total))
|
|
195
|
+
if not chunk:
|
|
196
|
+
return b"".join(chunks)
|
|
197
|
+
chunks.append(chunk)
|
|
198
|
+
total += len(chunk)
|
|
199
|
+
if total > maximum:
|
|
200
|
+
raise Overflow("the file is over %d bytes" % maximum)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def schema_problem(value, schema, path):
|
|
204
|
+
"""The first way `value` departs from the schema subset, or None."""
|
|
205
|
+
expected = schema.get("type")
|
|
206
|
+
if expected == "number" and (isinstance(value, bool) or not isinstance(value, (int, float))):
|
|
207
|
+
return "%s is not a number" % path
|
|
208
|
+
if expected == "integer" and (isinstance(value, bool) or not isinstance(value, int)):
|
|
209
|
+
return "%s is not an integer" % path
|
|
210
|
+
if expected in TYPES and not isinstance(value, TYPES[expected]):
|
|
211
|
+
return "%s is not %s" % (path, expected)
|
|
212
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
213
|
+
return "%s is not one of the allowed values" % path
|
|
214
|
+
return bounds_problem(value, schema, path) or members_problem(value, schema, path)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def bounds_problem(value, schema, path):
|
|
218
|
+
if isinstance(value, str) and len(value) > schema.get("maxLength", len(value)):
|
|
219
|
+
return "%s is longer than %d" % (path, schema["maxLength"])
|
|
220
|
+
if isinstance(value, str) and "pattern" in schema and not re.search(schema["pattern"], value):
|
|
221
|
+
return "%s does not match the pattern" % path
|
|
222
|
+
if isinstance(value, list) and len(value) > schema.get("maxItems", len(value)):
|
|
223
|
+
return "%s has more than %d items" % (path, schema["maxItems"])
|
|
224
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
225
|
+
if value < schema.get("minimum", value) or value > schema.get("maximum", value):
|
|
226
|
+
return "%s is out of range" % path
|
|
227
|
+
if isinstance(value, dict) and len(value) > schema.get("maxProperties", len(value)):
|
|
228
|
+
return "%s has more than %d keys" % (path, schema["maxProperties"])
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def members_problem(value, schema, path):
|
|
233
|
+
if isinstance(value, dict):
|
|
234
|
+
return object_problem(value, schema, path)
|
|
235
|
+
if isinstance(value, list) and "items" in schema:
|
|
236
|
+
return array_problem(value, schema, path)
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def object_problem(value, schema, path):
|
|
241
|
+
for key in schema.get("required", []):
|
|
242
|
+
if key not in value:
|
|
243
|
+
return "%s lacks %s" % (path, key)
|
|
244
|
+
for key, member in value.items():
|
|
245
|
+
member_schema = schema.get("properties", {}).get(key)
|
|
246
|
+
if member_schema is None and schema.get("additionalProperties") is False:
|
|
247
|
+
return "%s has an unexpected key %s" % (path, key)
|
|
248
|
+
problem = schema_problem(member, member_schema, "%s.%s" % (path, key)) if member_schema else None
|
|
249
|
+
if problem:
|
|
250
|
+
return problem
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def array_problem(value, schema, path):
|
|
255
|
+
for index, item in enumerate(value):
|
|
256
|
+
problem = schema_problem(item, schema["items"], "%s[%d]" % (path, index))
|
|
257
|
+
if problem:
|
|
258
|
+
return problem
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def parse_value(data, schema):
|
|
263
|
+
try:
|
|
264
|
+
value = json.loads(data.decode("utf-8"), parse_constant=lambda token: (_ for _ in ()).throw(ValueError(token)))
|
|
265
|
+
except (UnicodeDecodeError, ValueError) as error:
|
|
266
|
+
raise Invalid("not valid JSON: %s" % error) from None
|
|
267
|
+
except RecursionError:
|
|
268
|
+
raise Invalid("nested deeper than the parser follows") from None
|
|
269
|
+
if nesting(value) > MAX_NESTING:
|
|
270
|
+
raise Invalid("nested deeper than %d levels" % MAX_NESTING)
|
|
271
|
+
problem = schema_problem(value, schema, "value") if schema else None
|
|
272
|
+
if problem:
|
|
273
|
+
raise Invalid(problem)
|
|
274
|
+
return value
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def open_regular(dir_fd, name):
|
|
278
|
+
"""The file by descriptor: opened without blocking (a FIFO opens at once instead of waiting for a writer), checked to be a regular file that is ours, and only then made blocking."""
|
|
279
|
+
fd = open_no_follow(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, dir_fd, name)
|
|
280
|
+
try:
|
|
281
|
+
info = verify_owned(fd, name, False)
|
|
282
|
+
os.set_blocking(fd, True)
|
|
283
|
+
except BaseException:
|
|
284
|
+
os.close(fd)
|
|
285
|
+
raise
|
|
286
|
+
return fd, info
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def read_file(dir_fd, name, maximum, schema):
|
|
290
|
+
try:
|
|
291
|
+
fd, info = open_regular(dir_fd, name)
|
|
292
|
+
except FileNotFoundError:
|
|
293
|
+
return {"state": "missing"}
|
|
294
|
+
try:
|
|
295
|
+
data = read_capped(fd, maximum)
|
|
296
|
+
finally:
|
|
297
|
+
os.close(fd)
|
|
298
|
+
return {"state": "ok", "value": parse_value(data, schema), "bytes": len(data), "mtime": int(info.st_mtime)}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def stale_staging(dir_fd, entry):
|
|
302
|
+
"""A staging file of ours, older than a crashed writer could still be using."""
|
|
303
|
+
if not STAGING.match(entry):
|
|
304
|
+
return False
|
|
305
|
+
try:
|
|
306
|
+
info = os.stat(entry, dir_fd=dir_fd, follow_symlinks=False)
|
|
307
|
+
except OSError:
|
|
308
|
+
return False
|
|
309
|
+
return stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid() and time.time() - info.st_mtime > STAGING_STALE_SECONDS
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def unlink_quietly(dir_fd, entry):
|
|
313
|
+
try:
|
|
314
|
+
os.unlink(entry, dir_fd=dir_fd)
|
|
315
|
+
except OSError:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def sweep_staging(dir_fd):
|
|
320
|
+
"""Staging files a crashed writer left behind, once they are old enough to be nobody's."""
|
|
321
|
+
for entry in os.listdir(dir_fd):
|
|
322
|
+
if stale_staging(dir_fd, entry):
|
|
323
|
+
unlink_quietly(dir_fd, entry)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def create_staging(dir_fd):
|
|
327
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
328
|
+
for _attempt in range(100):
|
|
329
|
+
name = ".store-%d-%s.tmp" % (os.getpid(), secrets.token_hex(8))
|
|
330
|
+
try:
|
|
331
|
+
return name, os.open(name, flags, 0o600, dir_fd=dir_fd)
|
|
332
|
+
except FileExistsError:
|
|
333
|
+
continue
|
|
334
|
+
raise Refused("could not create an exclusive staging file")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def write_all(fd, data):
|
|
338
|
+
"""Every byte, or an error: a short write (a quota, a full disk, a size limit) never leaves a partial staging file to rename."""
|
|
339
|
+
written = 0
|
|
340
|
+
while written < len(data):
|
|
341
|
+
count = os.write(fd, data[written:])
|
|
342
|
+
if count <= 0:
|
|
343
|
+
raise OSError(errno.ENOSPC, "short write: %d of %d bytes" % (written, len(data)))
|
|
344
|
+
written += count
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def write_file(dir_fd, name, maximum, schema, raw):
|
|
348
|
+
data = json.dumps(parse_value(raw.encode("utf-8"), schema), ensure_ascii=False, sort_keys=True, indent=1).encode("utf-8") + b"\n"
|
|
349
|
+
if len(data) > maximum:
|
|
350
|
+
raise Overflow("the value is %d bytes, over %d" % (len(data), maximum))
|
|
351
|
+
sweep_staging(dir_fd)
|
|
352
|
+
staging, fd = create_staging(dir_fd)
|
|
353
|
+
try:
|
|
354
|
+
write_all(fd, data)
|
|
355
|
+
os.fsync(fd)
|
|
356
|
+
os.rename(staging, name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
|
357
|
+
staging = None
|
|
358
|
+
os.fsync(dir_fd)
|
|
359
|
+
finally:
|
|
360
|
+
os.close(fd)
|
|
361
|
+
if staging:
|
|
362
|
+
unlink_quietly(dir_fd, staging)
|
|
363
|
+
return {"state": "ok", "bytes": len(data)}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def remove_file(dir_fd, name):
|
|
367
|
+
try:
|
|
368
|
+
os.unlink(name, dir_fd=dir_fd)
|
|
369
|
+
except FileNotFoundError:
|
|
370
|
+
return {"state": "missing"}
|
|
371
|
+
os.fsync(dir_fd)
|
|
372
|
+
return {"state": "ok"}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def parse_schema(text):
|
|
376
|
+
"""The schema argument: JSON, the supported subset in shape, at most 64 KiB, or None."""
|
|
377
|
+
if not text:
|
|
378
|
+
return None
|
|
379
|
+
if len(text.encode("utf-8")) > 65536:
|
|
380
|
+
raise Refused("schema is over 65536 bytes")
|
|
381
|
+
try:
|
|
382
|
+
schema = json.loads(text)
|
|
383
|
+
except (ValueError, RecursionError) as error:
|
|
384
|
+
raise Refused("schema is not valid JSON: %s" % error) from None
|
|
385
|
+
check_schema(schema)
|
|
386
|
+
return schema
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def operate(opts, environ=None):
|
|
390
|
+
check_options(opts)
|
|
391
|
+
schema = parse_schema(opts.get("schema"))
|
|
392
|
+
fds, path = open_private_directory(opts["kind"], opts["plugin"], environ)
|
|
393
|
+
try:
|
|
394
|
+
if opts["op"] == "read":
|
|
395
|
+
result = read_file(fds[-1], opts["name"], int(opts["max_bytes"]), schema)
|
|
396
|
+
elif opts["op"] == "write":
|
|
397
|
+
result = write_file(fds[-1], opts["name"], int(opts["max_bytes"]), schema, opts.get("value", ""))
|
|
398
|
+
else:
|
|
399
|
+
result = remove_file(fds[-1], opts["name"])
|
|
400
|
+
finally:
|
|
401
|
+
for fd in fds:
|
|
402
|
+
os.close(fd)
|
|
403
|
+
result["path"] = os.path.join(path, opts["name"])
|
|
404
|
+
return result
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def result_of(opts, environ=None):
|
|
408
|
+
"""One operation as the result object Store.qml reports: importable by a long-running program that keeps its own state through this file."""
|
|
409
|
+
try:
|
|
410
|
+
result = operate(opts, environ)
|
|
411
|
+
except Refused as why:
|
|
412
|
+
result = {"state": "refused", "reason": str(why)}
|
|
413
|
+
except Invalid as why:
|
|
414
|
+
result = {"state": "invalid", "reason": str(why)}
|
|
415
|
+
except Overflow as why:
|
|
416
|
+
result = {"state": "overflow", "reason": str(why)}
|
|
417
|
+
except (OSError, ValueError) as why:
|
|
418
|
+
result = {"state": "failed", "reason": "%s: %s" % (type(why).__name__, why)}
|
|
419
|
+
except RecursionError:
|
|
420
|
+
result = {"state": "invalid", "reason": "nested deeper than the check follows"}
|
|
421
|
+
return dict(result, op=opts["op"])
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def main():
|
|
425
|
+
opts = parse(sys.argv[1:])
|
|
426
|
+
emit(dict(result_of(opts), ev="result"))
|
|
427
|
+
return 0
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
if __name__ == "__main__":
|
|
431
|
+
sys.exit(main())
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omakit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.6.2",
|
|
4
|
+
"description": "Tested plumbing, the marketplace's own checks, and a disposable Omarchy to test in.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Maarten Tolhuijs",
|
|
7
7
|
"keywords": [
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"quattro",
|
|
10
10
|
"plugin",
|
|
11
11
|
"marketplace",
|
|
12
|
+
"building-blocks",
|
|
13
|
+
"qml",
|
|
14
|
+
"quickshell",
|
|
12
15
|
"preflight",
|
|
13
16
|
"cli",
|
|
14
17
|
"agent"
|
|
@@ -22,9 +25,10 @@
|
|
|
22
25
|
},
|
|
23
26
|
"scripts": {
|
|
24
27
|
"pin": "node tools/marketplace/cli.mjs pin",
|
|
25
|
-
"test": "node --test tests/unit/*.test.mjs",
|
|
28
|
+
"test": "node --test --test-timeout=180000 tests/unit/*.test.mjs",
|
|
26
29
|
"parity": "node tools/marketplace/cli.mjs parity --count 30",
|
|
27
|
-
"doctor": "node tools/marketplace/cli.mjs doctor"
|
|
30
|
+
"doctor": "node tools/marketplace/cli.mjs doctor",
|
|
31
|
+
"pack:release": "node tools/blocks/record-commit.mjs && node tools/blocks/record-commit.mjs --check && npm pack --ignore-scripts; node tools/blocks/record-commit.mjs --clear"
|
|
28
32
|
},
|
|
29
33
|
"repository": {
|
|
30
34
|
"type": "git",
|
|
@@ -34,7 +38,10 @@
|
|
|
34
38
|
"bin",
|
|
35
39
|
"tools",
|
|
36
40
|
"skills",
|
|
41
|
+
"blocks",
|
|
37
42
|
"tests/parity/corpus.mjs",
|
|
38
|
-
"tests/parity/run.mjs"
|
|
43
|
+
"tests/parity/run.mjs",
|
|
44
|
+
"tests/lab",
|
|
45
|
+
"tests/fixtures/weigh"
|
|
39
46
|
]
|
|
40
47
|
}
|
|
@@ -3,7 +3,10 @@ name: omarchy-plugin-audit
|
|
|
3
3
|
description: Compare installed Omarchy Quattro plugin commits with the exact commits the marketplace validated. Use before touching an installed third-party plugin, before enabling or updating one, or when asked whether installed plugins match marketplace review. Read-only.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Track installed plugin commits
|
|
7
|
+
|
|
8
|
+
This is the other half of the track job: compare what the shell is running with
|
|
9
|
+
the exact commits the marketplace records, without changing either checkout.
|
|
7
10
|
|
|
8
11
|
Run `omakit audit` before touching an installed third-party plugin. Run
|
|
9
12
|
`omakit audit <plugin-id-or-dir>` when only one installed plugin is in scope.
|
|
@@ -38,7 +41,10 @@ replace it with `omarchy plugin update`; update moves to mutable HEAD and does
|
|
|
38
41
|
not establish marketplace validation.
|
|
39
42
|
|
|
40
43
|
`AUDITED`, exit 0, means every audited row is validated, or there was nothing
|
|
41
|
-
third-party to audit. `DRIFT`, exit 1, means
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
third-party to audit. `DRIFT`, exit 1, means a compared row is off its
|
|
45
|
+
validated commit. An `unknown` row could not be compared and is not drift:
|
|
46
|
+
the closing sentence counts it apart, with why, and when nothing drifted the
|
|
47
|
+
word is `NOT AUDITED`, exit 1. Exit 2 means the invocation was refused. `NOT
|
|
48
|
+
AUDITED` because the shell or catalog did not answer is a
|
|
49
|
+
result to report, not a reason to substitute a directory scan. On any
|
|
50
|
+
nonzero exit the report is on stderr.
|