@mnstry/atelier 0.2.0-alpha.10 → 0.2.0-alpha.12
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/CHANGELOG.md +385 -0
- package/README.md +10 -6
- package/contracts/atelier-obsidian-generation-manifest.v2.schema.json +372 -0
- package/contracts/compat-baseline.json +1 -1
- package/docs/assurance-controls.md +3 -2
- package/docs/install.md +3 -3
- package/docs/local-services.md +62 -11
- package/docs/obsidian-contract.md +681 -39
- package/docs/obsidian-plugin.md +585 -0
- package/docs/obsidian.md +292 -48
- package/docs/release-engineering.md +7 -1
- package/docs/upgrade.md +32 -3
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/case-only-collision.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/flat-note-path.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/hidden-folder.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/identity-in-the-body.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/link-breaking-name.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-identity-region.v2.json +156 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-layout-version.v2.json +159 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/note-named-like-a-folder.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/unknown-region-kind.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/v1-contract-version.v2.json +161 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/valid/three-notes.v2.json +160 -0
- package/fixtures/obsidian/edits/workspace.json +1 -1
- package/fixtures/obsidian/materialization/expected-full.json +21 -21
- package/fixtures/obsidian/materialization/expected-full.layout-1.json +99 -0
- package/fixtures/obsidian/materialization/expected-scoped.json +7 -7
- package/fixtures/obsidian/materialization/expected-scoped.layout-1.json +39 -0
- package/package.json +7 -3
- package/plugins/obsidian/main.js +444 -0
- package/plugins/obsidian/manifest.json +9 -0
- package/plugins/obsidian/styles.css +22 -0
- package/src/commands/obsidian.mjs +89 -10
- package/src/contracts/corpus.mjs +6 -0
- package/src/egress/forbidden-egress.mjs +2 -0
- package/src/project/private-state.mjs +10 -1
- package/src/projection/obsidian/contracts.mjs +224 -18
- package/src/projection/obsidian/edits/apply.mjs +12 -7
- package/src/projection/obsidian/edits/regions.mjs +137 -42
- package/src/projection/obsidian/materialize/index.mjs +6 -3
- package/src/projection/obsidian/materialize/path-registry.mjs +443 -57
- package/src/projection/obsidian/materialize/prepare-view.mjs +382 -131
- package/src/projection/obsidian/materialize/redaction.mjs +327 -0
- package/src/projection/obsidian/materialize/settings.mjs +113 -34
- package/src/projection/obsidian/plugin-bridge/bundle.mjs +50 -0
- package/src/projection/obsidian/plugin-bridge/channel.mjs +174 -0
- package/src/projection/obsidian/plugin-bridge/index.mjs +10 -0
- package/src/projection/obsidian/proposals/observation.mjs +10 -5
- package/src/projection/obsidian/publication/bridge-script.mjs +11 -5
- package/src/projection/obsidian/publication/index.mjs +2 -1
- package/src/projection/obsidian/publication/publisher.mjs +173 -26
- package/src/projection/obsidian/publication/transport.mjs +124 -17
- package/src/projection/obsidian/publication/vault-list.mjs +199 -0
- package/src/projection/obsidian/recovery/restart.mjs +3 -1
- package/src/projection/obsidian/recovery/store.mjs +11 -4
- package/src/projection/obsidian/selection-ui/focus.mjs +14 -7
- package/src/projection/obsidian/selection-ui/selection-state.mjs +2 -2
- package/src/projection/obsidian/selection-ui/selection.mjs +8 -3
- package/src/runtime/obsidian/app-capability.mjs +115 -14
- package/src/runtime/obsidian/app-production-seams.mjs +126 -37
- package/src/runtime/obsidian/app-registration.mjs +166 -0
- package/src/runtime/obsidian/engine.mjs +127 -11
- package/src/runtime/obsidian/extension-points.mjs +1 -1
- package/src/runtime/obsidian/index.mjs +5 -1
- package/src/runtime/obsidian/launch-plan.mjs +88 -0
- package/src/runtime/obsidian/lifecycle.mjs +105 -14
- package/src/runtime/obsidian/opening.mjs +240 -51
- package/src/runtime/obsidian/pending-edits.mjs +10 -0
- package/src/runtime/obsidian/plugin-channel.mjs +281 -0
- package/src/runtime/obsidian/plugin-choice.mjs +170 -0
- package/src/runtime/obsidian/plugin-drift.mjs +76 -0
- package/src/runtime/obsidian/plugin-presence.mjs +48 -0
- package/src/runtime/obsidian/service-main.mjs +19 -4
- package/src/runtime/obsidian/service-record.mjs +36 -0
- package/src/runtime/obsidian/service-server.mjs +50 -12
- package/src/runtime/obsidian/service.mjs +100 -6
- package/src/runtime/obsidian/state-store.mjs +13 -1
- package/src/server/local-sidecar.mjs +24 -5
- package/src/server/server.mjs +18 -5
- package/src/upgrade/upgrade.mjs +1 -1
- package/templates/external-project-workspace/pack-lifecycle.json +1 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// The channel between Atelier's own plugin, inside a vault Atelier manages,
|
|
4
|
+
// and the maintenance service of the workspace that manages that vault.
|
|
5
|
+
//
|
|
6
|
+
// A closed command surface over the service's literal loopback listener:
|
|
7
|
+
//
|
|
8
|
+
// POST /plugin/challenge a fresh nonce and the time it was made, and a hint
|
|
9
|
+
// at which vault's key it means that only the holder
|
|
10
|
+
// of that key can read; the service answers, once
|
|
11
|
+
// and only while it is fresh, with its own nonce and
|
|
12
|
+
// a proof that it holds the key
|
|
13
|
+
// POST /plugin/hello only after the plugin checked that proof: its own
|
|
14
|
+
// proof, the plugin and app versions, an id of this
|
|
15
|
+
// launch of the plugin, and a proof of the vault path
|
|
16
|
+
// the app has open (the path itself is never sent)
|
|
17
|
+
// POST /plugin/lease "this vault is open in an app", renewed every two
|
|
18
|
+
// seconds; it lapses on its own six seconds later
|
|
19
|
+
// POST /plugin/release the vault closed, or the plugin unloaded
|
|
20
|
+
// POST /plugin/status freshness, reason and held edits of this view
|
|
21
|
+
//
|
|
22
|
+
// The key is the vault's bearer, which Atelier writes into the plugin's data
|
|
23
|
+
// file of that vault only and keeps in private state. It never crosses the
|
|
24
|
+
// wire, and neither does anything that names the view or the vault before the
|
|
25
|
+
// listener proved it holds the key: a program squatting the service's port
|
|
26
|
+
// while the service is down learns a nonce and a hint it cannot link to a
|
|
27
|
+
// vault, and cannot answer. Both proofs bind the listener's exact address, so
|
|
28
|
+
// an answer relayed from a service listening elsewhere does not verify.
|
|
29
|
+
//
|
|
30
|
+
// A challenge recorded by such a program and sent to the service later is
|
|
31
|
+
// worth little: the service answers a challenge only within thirty seconds of
|
|
32
|
+
// the time it names, and a nonce only once, and at most four handshakes wait
|
|
33
|
+
// for their hello per view, so one view's challenges never hold up another's.
|
|
34
|
+
//
|
|
35
|
+
// The handshake derives a key for one session. Every later request carries
|
|
36
|
+
// the session, a counter that only goes up, and a MAC over both and the
|
|
37
|
+
// command under that key; every answer carries a MAC over the request's counter
|
|
38
|
+
// and the exact bytes of the answer. A handshake is used once and lapses after
|
|
39
|
+
// five seconds, a session after fifteen minutes. Nothing here takes a path to
|
|
40
|
+
// act on, a command or code, and every command maps to state the service
|
|
41
|
+
// already keeps (see docs/obsidian-plugin.md).
|
|
42
|
+
//
|
|
43
|
+
// The plugin cannot import this module: it runs inside the app, from the
|
|
44
|
+
// vault. It carries the same constants and the same computations, and the test
|
|
45
|
+
// suite holds the two to each other: a plugin that computes any one of them
|
|
46
|
+
// otherwise cannot complete a handshake.
|
|
47
|
+
|
|
48
|
+
export const PLUGIN_ID = 'atelier-projection'
|
|
49
|
+
export const PLUGIN_DIRECTORY = `.obsidian/plugins/${PLUGIN_ID}`
|
|
50
|
+
// Shipped with the package under plugins/obsidian/ and copied into every vault unchanged.
|
|
51
|
+
export const PLUGIN_SOURCE_FILES = Object.freeze(['manifest.json', 'main.js', 'styles.css'])
|
|
52
|
+
// Written per vault: the channel address and that vault's bearer.
|
|
53
|
+
export const PLUGIN_DATA_FILE = 'data.json'
|
|
54
|
+
export const PLUGIN_DATA_PATH = `${PLUGIN_DIRECTORY}/${PLUGIN_DATA_FILE}`
|
|
55
|
+
// The data file holds the vault's key: only the person who runs the vault reads it. The app reads the others.
|
|
56
|
+
export const PLUGIN_DATA_MODE = 0o600
|
|
57
|
+
export const PLUGIN_SOURCE_MODE = 0o644
|
|
58
|
+
|
|
59
|
+
export const PLUGIN_CHANNEL_PROTOCOL = 'atelier-obsidian-plugin-channel/v2'
|
|
60
|
+
export const PLUGIN_DATA_SCHEMA = 'atelier-obsidian-plugin-data/v1'
|
|
61
|
+
export const PLUGIN_STATUS_SCHEMA = 'atelier-obsidian-plugin-status/v1'
|
|
62
|
+
|
|
63
|
+
export const PLUGIN_COMMANDS = Object.freeze(['challenge', 'hello', 'lease', 'release', 'status'])
|
|
64
|
+
export const PLUGIN_ROUTES = Object.freeze(Object.fromEntries(PLUGIN_COMMANDS.map((command) => [command, `/plugin/${command}`])))
|
|
65
|
+
export const PLUGIN_REQUEST_FIELDS = Object.freeze({
|
|
66
|
+
challenge: Object.freeze(['protocol', 'keyHint', 'clientNonce', 'issuedAt']),
|
|
67
|
+
hello: Object.freeze(['handshakeId', 'pluginVersion', 'appVersion', 'instanceId', 'vaultProof', 'clientProof']),
|
|
68
|
+
lease: Object.freeze(['sessionId', 'counter', 'mac']),
|
|
69
|
+
release: Object.freeze(['sessionId', 'counter', 'mac']),
|
|
70
|
+
status: Object.freeze(['sessionId', 'counter', 'mac']),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
export const PLUGIN_MAX_REQUEST_BYTES = 1024
|
|
74
|
+
export const PLUGIN_RENEW_INTERVAL_MS = 2000
|
|
75
|
+
// Three renewals may be missed before a vault stops counting as open.
|
|
76
|
+
export const PLUGIN_LEASE_TTL_MS = 3 * PLUGIN_RENEW_INTERVAL_MS
|
|
77
|
+
export const PLUGIN_MAX_SESSIONS_PER_SCOPE = 8
|
|
78
|
+
export const PLUGIN_HANDSHAKE_TTL_MS = 5000
|
|
79
|
+
// A challenge is answered only this close to the time it names (both sides read one machine's clock).
|
|
80
|
+
export const PLUGIN_CHALLENGE_WINDOW_MS = 30 * 1000
|
|
81
|
+
export const PLUGIN_MAX_PENDING_HANDSHAKES_PER_SCOPE = 4
|
|
82
|
+
// A session key is short-lived: the plugin shakes hands again after this long.
|
|
83
|
+
export const PLUGIN_MAX_SESSION_AGE_MS = 15 * 60 * 1000
|
|
84
|
+
// The app version the plugin needs, the floor of the publication protocol (app-capability.mjs).
|
|
85
|
+
export const PLUGIN_MINIMUM_APP_VERSION = '1.13.7'
|
|
86
|
+
|
|
87
|
+
export const PLUGIN_BEARER = /^[A-Za-z0-9_-]{43}$/
|
|
88
|
+
export const PLUGIN_NONCE = /^[0-9a-f]{64}$/
|
|
89
|
+
// A key hint, a proof and a MAC are each one HMAC-SHA256, in hex.
|
|
90
|
+
export const PLUGIN_MAC = /^[0-9a-f]{64}$/
|
|
91
|
+
export const PLUGIN_HANDSHAKE_ID = /^ph-[0-9a-f]{32}$/
|
|
92
|
+
export const PLUGIN_SESSION_ID = /^ps-[0-9a-f]{32}$/
|
|
93
|
+
export const PLUGIN_INSTANCE_ID = /^pi-[0-9a-f]{32}$/
|
|
94
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
95
|
+
const VERSION_TEXT = /^[0-9A-Za-z.+-]{1,40}$/
|
|
96
|
+
const LOOPBACK_HOSTS = Object.freeze(['127.0.0.1', '::1'])
|
|
97
|
+
|
|
98
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
99
|
+
|
|
100
|
+
// The route of a request path, or null. Exact paths only: no query, no prefix.
|
|
101
|
+
export function pluginCommandOf(requestPath) {
|
|
102
|
+
return PLUGIN_COMMANDS.find((command) => PLUGIN_ROUTES[command] === requestPath) ?? null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// { ok: true, body } or { ok: false, code }. The body has exactly the fields
|
|
106
|
+
// of its command, each of its own shape; anything else refuses.
|
|
107
|
+
export function validatePluginRequest(command, body) {
|
|
108
|
+
const fields = PLUGIN_REQUEST_FIELDS[command]
|
|
109
|
+
if (fields === undefined) return { ok: false, code: 'unknown-command' }
|
|
110
|
+
if (!isPlainObject(body)) return { ok: false, code: 'request-malformed' }
|
|
111
|
+
const keys = Object.keys(body)
|
|
112
|
+
if (keys.length !== fields.length || !fields.every((field) => Object.hasOwn(body, field))) return { ok: false, code: 'request-malformed' }
|
|
113
|
+
const shaped = (field, pattern) => typeof body[field] === 'string' && pattern.test(body[field])
|
|
114
|
+
if (command === 'challenge') {
|
|
115
|
+
if (body.protocol !== PLUGIN_CHANNEL_PROTOCOL) return { ok: false, code: 'protocol-unsupported' }
|
|
116
|
+
if (!shaped('keyHint', PLUGIN_MAC) || !shaped('clientNonce', PLUGIN_NONCE) || !Number.isSafeInteger(body.issuedAt) || body.issuedAt < 1) return { ok: false, code: 'request-malformed' }
|
|
117
|
+
} else if (command === 'hello') {
|
|
118
|
+
if (!shaped('handshakeId', PLUGIN_HANDSHAKE_ID) || !shaped('instanceId', PLUGIN_INSTANCE_ID) || !shaped('vaultProof', PLUGIN_MAC) || !shaped('clientProof', PLUGIN_MAC)) return { ok: false, code: 'request-malformed' }
|
|
119
|
+
if (!shaped('pluginVersion', VERSION_TEXT) || !shaped('appVersion', VERSION_TEXT)) return { ok: false, code: 'request-malformed' }
|
|
120
|
+
} else if (!shaped('sessionId', PLUGIN_SESSION_ID) || !shaped('mac', PLUGIN_MAC) || !Number.isSafeInteger(body.counter) || body.counter < 1) {
|
|
121
|
+
return { ok: false, code: 'request-malformed' }
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, body }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The computations of the handshake and the session, shared with the plugin.
|
|
127
|
+
// Every input but a vault path is one of the shapes above, without a line
|
|
128
|
+
// break, and a vault path is the only input of its computation, so joining
|
|
129
|
+
// them by lines is unambiguous. `key` is the vault's bearer text, or a
|
|
130
|
+
// session key.
|
|
131
|
+
const transcript = (label, fields) => [PLUGIN_CHANNEL_PROTOCOL, label, ...fields.map(String)].join('\n')
|
|
132
|
+
const hmac = (key, label, fields) => createHmac('sha256', key).update(transcript(label, fields), 'utf8').digest()
|
|
133
|
+
const hmacHex = (key, label, fields) => hmac(key, label, fields).toString('hex')
|
|
134
|
+
|
|
135
|
+
// A hint at the vault's key, fresh with every nonce and bound to the time the
|
|
136
|
+
// challenge names: the service finds the key it names by computing it for each
|
|
137
|
+
// key it holds, and a listener that holds none cannot tell two hints of one
|
|
138
|
+
// vault apart, nor move one to another time.
|
|
139
|
+
export const pluginKeyHint = ({ bearer, clientNonce, issuedAt }) => hmacHex(bearer, 'key-hint', [clientNonce, issuedAt])
|
|
140
|
+
// What binds a handshake: the view, the listener's exact address, both nonces and the handshake.
|
|
141
|
+
const handshakeFields = ({ scopeId, authority, clientNonce, serverNonce, handshakeId }) => [scopeId, authority, clientNonce, serverNonce, handshakeId]
|
|
142
|
+
export const pluginServerProof = (input) => hmacHex(input.bearer, 'server-proof', handshakeFields(input))
|
|
143
|
+
export const pluginSessionKey = (input) => hmac(input.bearer, 'session-key', handshakeFields(input))
|
|
144
|
+
// The vault the app has open, proven under the session key rather than named.
|
|
145
|
+
export const pluginVaultProof = ({ sessionKey, vaultPath }) => hmacHex(sessionKey, 'vault', [vaultPath])
|
|
146
|
+
export const pluginClientProof = (input) => hmacHex(input.bearer, 'client-proof', [...handshakeFields(input), input.pluginVersion, input.appVersion, input.instanceId, input.vaultProof])
|
|
147
|
+
export const pluginRequestMac = ({ sessionKey, command, sessionId, counter }) => hmacHex(sessionKey, 'request', [command, sessionId, counter])
|
|
148
|
+
// An answer is the exact text of its JSON document and a MAC over it, the command and the request's counter.
|
|
149
|
+
export const pluginResponseMac = ({ sessionKey, command, sessionId, counter, payload }) => hmacHex(sessionKey, 'response', [command, sessionId, counter, payload])
|
|
150
|
+
|
|
151
|
+
// The plugin's data file of one vault: where the service listens and the
|
|
152
|
+
// bearer of this vault. Canonical bytes, so an unchanged channel republishes
|
|
153
|
+
// nothing.
|
|
154
|
+
export function pluginDataDocument({ host, port, scopeId, bearer }) {
|
|
155
|
+
const document = { schema: PLUGIN_DATA_SCHEMA, channel: { host, port }, scopeId, bearer }
|
|
156
|
+
if (validatePluginData(document) === null) throw new TypeError('the plugin data needs a literal loopback address, a port, a view and a bearer')
|
|
157
|
+
return document
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function pluginDataBytes(input) {
|
|
161
|
+
const { schema, channel, scopeId, bearer } = pluginDataDocument(input)
|
|
162
|
+
return Buffer.from(`${JSON.stringify({ schema, channel: { host: channel.host, port: channel.port }, scopeId, bearer }, null, 2)}\n`, 'utf8')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The document when it is exactly a plugin data document, else null.
|
|
166
|
+
export function validatePluginData(document) {
|
|
167
|
+
if (!isPlainObject(document) || Object.keys(document).sort().join(',') !== 'bearer,channel,schema,scopeId') return null
|
|
168
|
+
if (document.schema !== PLUGIN_DATA_SCHEMA || typeof document.scopeId !== 'string' || !IDENTIFIER.test(document.scopeId)) return null
|
|
169
|
+
if (typeof document.bearer !== 'string' || !PLUGIN_BEARER.test(document.bearer)) return null
|
|
170
|
+
const { channel } = document
|
|
171
|
+
if (!isPlainObject(channel) || Object.keys(channel).sort().join(',') !== 'host,port') return null
|
|
172
|
+
if (!LOOPBACK_HOSTS.includes(channel.host) || !Number.isInteger(channel.port) || channel.port < 1024 || channel.port > 65535) return null
|
|
173
|
+
return document
|
|
174
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Atelier's own Obsidian plugin: the channel it speaks and the files that put
|
|
2
|
+
// it into a vault. The plugin source ships under plugins/obsidian/.
|
|
3
|
+
export {
|
|
4
|
+
PLUGIN_BEARER, PLUGIN_CHALLENGE_WINDOW_MS, PLUGIN_CHANNEL_PROTOCOL, PLUGIN_COMMANDS, PLUGIN_DATA_FILE, PLUGIN_DATA_PATH, PLUGIN_DATA_SCHEMA, PLUGIN_DIRECTORY, PLUGIN_HANDSHAKE_ID, PLUGIN_HANDSHAKE_TTL_MS,
|
|
5
|
+
PLUGIN_ID, PLUGIN_INSTANCE_ID, PLUGIN_LEASE_TTL_MS, PLUGIN_MAC, PLUGIN_MAX_PENDING_HANDSHAKES_PER_SCOPE, PLUGIN_MAX_REQUEST_BYTES, PLUGIN_MAX_SESSION_AGE_MS, PLUGIN_MAX_SESSIONS_PER_SCOPE,
|
|
6
|
+
PLUGIN_MINIMUM_APP_VERSION, PLUGIN_NONCE, PLUGIN_RENEW_INTERVAL_MS, PLUGIN_REQUEST_FIELDS, PLUGIN_ROUTES, PLUGIN_SESSION_ID, PLUGIN_SOURCE_FILES,
|
|
7
|
+
PLUGIN_STATUS_SCHEMA, pluginClientProof, pluginCommandOf, pluginDataBytes, pluginDataDocument, pluginKeyHint, pluginRequestMac, pluginResponseMac, pluginServerProof, pluginSessionKey,
|
|
8
|
+
pluginVaultProof, validatePluginData, validatePluginRequest,
|
|
9
|
+
} from './channel.mjs'
|
|
10
|
+
export { PLUGIN_DATA_MODE, PLUGIN_SOURCE_MODE, PLUGIN_SOURCE_ROOT, preparePluginFiles, readPluginSource } from './bundle.mjs'
|
|
@@ -5,7 +5,7 @@ import { compareText, isoTime } from '../../../runtime/obsidian/documents.mjs'
|
|
|
5
5
|
import { readObsidianEnablement } from '../../../runtime/obsidian/enablement.mjs'
|
|
6
6
|
import { ObsidianMaintenanceRefusal } from '../../../runtime/obsidian/errors.mjs'
|
|
7
7
|
import { OPEN_EDIT_STATES, createMaintenanceStateStore } from '../../../runtime/obsidian/state-store.mjs'
|
|
8
|
-
import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal } from '../contracts.mjs'
|
|
8
|
+
import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, manifestLayoutVersion } from '../contracts.mjs'
|
|
9
9
|
import { SOURCE_APPLY_PRIMITIVES, SourceApplyRefusal, locateSource } from '../edits/apply.mjs'
|
|
10
10
|
import { EditArbitrationRefusal } from '../edits/arbitrate.mjs'
|
|
11
11
|
import { createEditObserverForOracleTests, editIdempotencyKey, observeEdit } from '../edits/observe.mjs'
|
|
@@ -131,17 +131,22 @@ export function createTickObservation(options = {}) {
|
|
|
131
131
|
const kept = retained(store, noteEntry.noteDigest)
|
|
132
132
|
if (kept) return kept
|
|
133
133
|
workspace.prepared ??= new Map()
|
|
134
|
-
|
|
134
|
+
// Prepared in the vault layout of the edit's generation, once per generation.
|
|
135
|
+
const preparedKey = `${scope.scopeId}\u0000${manifest.generationId}`
|
|
136
|
+
if (!workspace.prepared.has(preparedKey)) {
|
|
135
137
|
let files = []
|
|
136
138
|
try {
|
|
137
139
|
const { graph, profile } = workspace.corpus()
|
|
138
140
|
const snapshot = seams.captureSnapshot({ project: workspace.project, graph, workspaceId: workspace.workspaceId, index: new Map(), configDigest: manifest.ext?.[EXT]?.configDigest ?? `sha256:${'0'.repeat(64)}`, capturedAt: now })
|
|
139
141
|
const persistentPathRegistry = createMaintenanceStateStore({ workspaceRoot: workspace.workspaceRoot, workspaceId: workspace.workspaceId }).readPathRegistry()
|
|
140
|
-
files = seams.prepareView({
|
|
142
|
+
files = seams.prepareView({
|
|
143
|
+
snapshot, profile, scope, persistentPathRegistry, priorManifest: manifest, existingSettings: null, clock: workspace.clock,
|
|
144
|
+
vaultRootBytes: Buffer.byteLength(store.vaultRoot, 'utf8'), layout: manifestLayoutVersion(manifest),
|
|
145
|
+
}).files
|
|
141
146
|
} catch (error) { if (!isTyped(error) && !GONE.has(error?.code)) throw error }
|
|
142
|
-
workspace.prepared.set(
|
|
147
|
+
workspace.prepared.set(preparedKey, files)
|
|
143
148
|
}
|
|
144
|
-
const file = workspace.prepared.get(
|
|
149
|
+
const file = workspace.prepared.get(preparedKey).find((item) => item.path === noteEntry.path && item.digest === noteEntry.noteDigest)
|
|
145
150
|
return file ? file.bytes : null
|
|
146
151
|
}
|
|
147
152
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createRequire } from 'node:module'
|
|
2
|
+
import { PLUGIN_OWNED_PATHS, POLICY_SETTINGS_PATHS } from '../materialize/settings.mjs'
|
|
2
3
|
import { EXCHANGE_CONSTANTS } from './exchange.mjs'
|
|
3
4
|
|
|
4
5
|
// The fixed script that performs one conditional publication. It runs in two
|
|
@@ -26,11 +27,14 @@ const KEYS = {
|
|
|
26
27
|
const isAbsolute = (value) => typeof value === 'string' && value.startsWith('/') && !value.includes('\u0000')
|
|
27
28
|
|
|
28
29
|
// A vault-relative path the publisher may address: visible files only, plus
|
|
29
|
-
// the
|
|
30
|
-
//
|
|
30
|
+
// the settings Atelier owns (the two policy settings files and its own
|
|
31
|
+
// plugin's files). Every other settings path is the person's and cannot be
|
|
32
|
+
// named in a payload at all.
|
|
33
|
+
const ATELIER_OWNED_SETTINGS = new Set([...POLICY_SETTINGS_PATHS, ...PLUGIN_OWNED_PATHS])
|
|
34
|
+
|
|
31
35
|
export function isAddressableVaultPath(value) {
|
|
32
36
|
if (typeof value !== 'string' || value.length === 0 || value.length > 1024 || value.startsWith('/') || value.includes('\\') || value.includes('\u0000')) return false
|
|
33
|
-
if (value
|
|
37
|
+
if (ATELIER_OWNED_SETTINGS.has(value)) return true
|
|
34
38
|
return value.split('/').every((part) => part !== '' && part !== '.' && part !== '..' && !part.startsWith('.'))
|
|
35
39
|
}
|
|
36
40
|
|
|
@@ -43,7 +47,7 @@ export function validatePayload(payload) {
|
|
|
43
47
|
for (const key of Object.keys(payload)) if (!allowed.includes(key)) fail(`unknown key ${key}`)
|
|
44
48
|
for (const key of allowed) if (payload[key] === undefined) fail(`missing key ${key}`)
|
|
45
49
|
if (!isAbsolute(payload.vaultRoot)) fail('vaultRoot must be absolute')
|
|
46
|
-
if (!isAddressableVaultPath(payload.path)) fail('path must be a relative visible path or
|
|
50
|
+
if (!isAddressableVaultPath(payload.path)) fail('path must be a relative visible path or a settings file Atelier owns')
|
|
47
51
|
if (payload.op === 'publish') {
|
|
48
52
|
if (!OPERATION_ID.test(payload.operationId)) fail('operationId required')
|
|
49
53
|
if (!SHA.test(payload.baseSha256)) fail('baseSha256 required')
|
|
@@ -76,7 +80,9 @@ export function criticalSection(P, host) {
|
|
|
76
80
|
const trace = []
|
|
77
81
|
const step = (event, detail) => trace.push({ ms: Number((host.performance.now() - t0).toFixed(3)), event, ...(detail || {}) })
|
|
78
82
|
const crash = (point) => { if (host.crashSeam && host.crashSeam.at === point) host.crashSeam.halt(point) }
|
|
79
|
-
|
|
83
|
+
// As the file system stores the path, except on Windows (see realPathAsStored): an app that holds the vault under
|
|
84
|
+
// another spelling of its folder (a letter case, on a volume that folds it) holds this vault.
|
|
85
|
+
const real = (target) => { try { return host.process.platform !== 'win32' && fs.realpathSync.native ? fs.realpathSync.native(target) : fs.realpathSync(target) } catch (error) { return null } }
|
|
80
86
|
const vaultBasePath = app ? real(app.vault.adapter.getBasePath()) : real(P.vaultRoot)
|
|
81
87
|
const full = nodePath.join(P.vaultRoot, P.path)
|
|
82
88
|
const views = () => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Conditional publication of a prepared view into an editable Obsidian vault.
|
|
2
2
|
export { PROTOCOL_ID, buildEvalCode, createInProcessHost, criticalSection, isAddressableVaultPath, runInProcess, validatePayload } from './bridge-script.mjs'
|
|
3
3
|
export { EXCHANGE_CONSTANTS, ExchangeRefusal, exchangeFiles, probeExchange, resetExchangeProbeCache, resolveExchange } from './exchange.mjs'
|
|
4
|
-
export { TransportTimeout, createDirectAdapter, createEditorAdapter, createInProcessCall, createObsidianCliAdapter, createObsidianCliCall, defaultCliPath, defaultObsidianProcessProbe } from './transport.mjs'
|
|
4
|
+
export { NEUTRAL_DIRECTORY, TransportTimeout, createDirectAdapter, createEditorAdapter, createInProcessCall, createObsidianCliAdapter, createObsidianCliCall, defaultCliPath, defaultObsidianProcessProbe, publicationRoute } from './transport.mjs'
|
|
5
|
+
export { MAX_OBSIDIAN_SETTINGS_BYTES, OBSIDIAN_SETTINGS_FILE, enclosingVaults, findVaultEntry, obsidianSandboxedBuild, obsidianUserDataDir, readObsidianSettings, vaultRoute } from './vault-list.mjs'
|
|
5
6
|
export { publishView } from './publisher.mjs'
|
|
@@ -3,8 +3,9 @@ import fsp from 'node:fs/promises'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { setTimeout as sleep } from 'node:timers/promises'
|
|
5
5
|
import { acquirePrivateLock, publishPrivateFile, syncPrivateDirectory } from '../../../project/durable-state.mjs'
|
|
6
|
-
import { ObsidianContractRefusal, assertObsidianContract } from '../contracts.mjs'
|
|
7
|
-
import { isPolicySettingsPath, isUserOwnedSettingsPath,
|
|
6
|
+
import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, assertObsidianContract, manifestLayoutVersion } from '../contracts.mjs'
|
|
7
|
+
import { isPluginOwnedPath, isPolicySettingsPath, isUserOwnedSettingsPath, preparePolicySettingsFile } from '../materialize/settings.mjs'
|
|
8
|
+
import { PLUGIN_DATA_MODE, PLUGIN_DATA_PATH, PLUGIN_SOURCE_MODE } from '../plugin-bridge/channel.mjs'
|
|
8
9
|
import { createJournal, newJournalId } from '../recovery/journal.mjs'
|
|
9
10
|
import { recheckDisplacedFiles } from '../recovery/late-writer.mjs'
|
|
10
11
|
import { publishedSinceCommit, reconcileUnit, recordDisplaced, recoverPublicationsLocked, retireStagedFile } from '../recovery/restart.mjs'
|
|
@@ -36,14 +37,30 @@ import { createDirectAdapter } from './transport.mjs'
|
|
|
36
37
|
// from `recovery/<journalId>/<unit>/`, never from staging. Staging holds only
|
|
37
38
|
// generated bytes that no exchange names: every candidate while it is written,
|
|
38
39
|
// and the candidates of created files until they are linked into the vault.
|
|
40
|
+
//
|
|
41
|
+
// Atelier's own plugin files (kind `plugin`) go through the same units, with
|
|
42
|
+
// two differences: each is compared with what the disk holds when the run
|
|
43
|
+
// begins rather than with an earlier generation, so whatever is there is
|
|
44
|
+
// displaced to recovery and never lost, and a plugin path that a person has to
|
|
45
|
+
// repair never holds the view back. The plugin's data file names a bearer and
|
|
46
|
+
// is written only into a vault root that is private to this user.
|
|
39
47
|
|
|
40
48
|
const hex = (digest) => digest.slice('sha256:'.length)
|
|
41
49
|
const iso = (clock) => { const value = clock(); return (value instanceof Date ? value : new Date(value)).toISOString() }
|
|
42
50
|
const REFUSED_BY_EDIT = new Set(['editor-edit', 'disk-changed'])
|
|
43
51
|
const unreleased = new Map()
|
|
44
52
|
|
|
53
|
+
// A note another program replaces by rename can change between the check of its leaf and the open
|
|
54
|
+
// (ELEAFCHANGED). Every rename leaves a complete file, so it is read again; one that keeps changing is left to
|
|
55
|
+
// publishView, which reports it for that unit only.
|
|
56
|
+
const READ_ATTEMPTS = 5
|
|
45
57
|
function readNote(file) {
|
|
46
|
-
|
|
58
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
59
|
+
try { return readFileBytes(file) } catch (error) {
|
|
60
|
+
if (error.code === 'ENOENT') return null
|
|
61
|
+
if (error.code !== 'ELEAFCHANGED' || attempt >= READ_ATTEMPTS) throw error
|
|
62
|
+
}
|
|
63
|
+
}
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
function validatePreparedView(preparedView, store) {
|
|
@@ -61,22 +78,35 @@ function validatePreparedView(preparedView, store) {
|
|
|
61
78
|
if (expected.has(declared)) refuse('invalid-prepared-view', 'the manifest declares one path more than once')
|
|
62
79
|
expected.set(declared, fileDigest)
|
|
63
80
|
}
|
|
81
|
+
// Atelier's plugin files are pinned by the settings unit's record in the manifest.
|
|
82
|
+
const pinnedPlugin = manifest.ext?.[OBSIDIAN_EXT_KEY]?.settings?.pluginOwned?.files
|
|
83
|
+
const pinned = new Map(Array.isArray(pinnedPlugin) ? pinnedPlugin.map((file) => [file?.path, file?.digest]) : [])
|
|
64
84
|
const seen = new Set()
|
|
65
85
|
for (const file of preparedView.files) {
|
|
66
86
|
if (!isAddressableVaultPath(file.path) || isUserOwnedSettingsPath(file.path)) refuse('invalid-prepared-view', 'a prepared path is outside what the publisher may write')
|
|
67
87
|
if (seen.has(file.path)) refuse('invalid-prepared-view', 'a prepared path appears twice')
|
|
68
88
|
seen.add(file.path)
|
|
69
89
|
if (file.kind === 'settings') {
|
|
70
|
-
if (!isPolicySettingsPath(file.path)) refuse('invalid-prepared-view', 'only
|
|
90
|
+
if (!isPolicySettingsPath(file.path)) refuse('invalid-prepared-view', 'only a policy settings file may be prepared as settings')
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
if (file.kind === 'plugin') {
|
|
94
|
+
if (!isPluginOwnedPath(file.path) || !Buffer.isBuffer(file.bytes) || sha256Digest(file.bytes) !== file.digest || pinned.get(file.path) !== file.digest) {
|
|
95
|
+
refuse('invalid-prepared-view', 'a plugin file, its digest and the digest the manifest pins disagree')
|
|
96
|
+
}
|
|
97
|
+
// Read by the app, never run by anyone: the data file owner-only, the others readable.
|
|
98
|
+
if (file.mode !== (file.path === PLUGIN_DATA_PATH ? PLUGIN_DATA_MODE : PLUGIN_SOURCE_MODE)) refuse('invalid-prepared-view', 'a plugin file has a mode Atelier never gives one')
|
|
99
|
+
pinned.delete(file.path)
|
|
71
100
|
continue
|
|
72
101
|
}
|
|
73
|
-
if (!['note', 'attachment'].includes(file.kind) || isPolicySettingsPath(file.path)) refuse('invalid-prepared-view', 'a prepared file has an unknown kind')
|
|
102
|
+
if (!['note', 'attachment'].includes(file.kind) || isPolicySettingsPath(file.path) || isPluginOwnedPath(file.path)) refuse('invalid-prepared-view', 'a prepared file has an unknown kind')
|
|
74
103
|
if (!Buffer.isBuffer(file.bytes) || sha256Digest(file.bytes) !== file.digest || expected.get(file.path) !== file.digest) {
|
|
75
104
|
refuse('invalid-prepared-view', 'prepared bytes, their digest and the manifest disagree')
|
|
76
105
|
}
|
|
77
106
|
expected.delete(file.path)
|
|
78
107
|
}
|
|
79
108
|
if (expected.size > 0) refuse('invalid-prepared-view', 'the manifest names a file the prepared view does not carry')
|
|
109
|
+
if (pinned.size > 0) refuse('invalid-prepared-view', 'the manifest pins a plugin file the prepared view does not carry')
|
|
80
110
|
let parsedBytes = null
|
|
81
111
|
if (Buffer.isBuffer(preparedView.manifestBytes)) {
|
|
82
112
|
try { parsedBytes = JSON.parse(preparedView.manifestBytes.toString('utf8')) } catch { refuse('invalid-prepared-view', 'manifest bytes are not JSON') }
|
|
@@ -169,7 +199,35 @@ async function holdsCandidateBytes(vaultRoot, relativePath, candidateDigest) {
|
|
|
169
199
|
}
|
|
170
200
|
}
|
|
171
201
|
|
|
172
|
-
|
|
202
|
+
// The digest of what a vault path holds now: null when it is absent, sits
|
|
203
|
+
// under a parent that is not a real directory, is not a regular file or cannot
|
|
204
|
+
// be read. Only Atelier's plugin files are planned from it.
|
|
205
|
+
function currentDigestOf(vaultRoot, relativePath) {
|
|
206
|
+
let current = vaultRoot
|
|
207
|
+
for (const part of relativePath.split('/').slice(0, -1)) {
|
|
208
|
+
current = path.join(current, part)
|
|
209
|
+
const stat = fs.lstatSync(current, { throwIfNoEntry: false })
|
|
210
|
+
if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) return null
|
|
211
|
+
}
|
|
212
|
+
try { return readFileDigest(path.join(vaultRoot, relativePath)) } catch { return null }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// A generation that is already committed is published again, as it is, when a
|
|
216
|
+
// plugin file it carries is not on disk as it pins it: the person repaired a
|
|
217
|
+
// path Atelier had to leave, or removed or changed a file. The bytes are
|
|
218
|
+
// Atelier's and nothing else of the view changed, so the generation stays the
|
|
219
|
+
// same; its notes are kept, and its plugin units are planned from the disk as
|
|
220
|
+
// ever. A file prepared only where present that is gone is not a drift.
|
|
221
|
+
function pluginFilesDrifted(preparedView, store) {
|
|
222
|
+
return preparedView.files.some((file) => {
|
|
223
|
+
if (file.kind !== 'plugin') return false
|
|
224
|
+
const current = currentDigestOf(store.vaultRoot, file.path)
|
|
225
|
+
return current !== file.digest && !(current === null && file.onlyIfPresent === true)
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// One unit per file the publication creates, keeps, replaces or removes. Exported for the oracle tests.
|
|
230
|
+
export function planUnits({ files, priorManifest, pointer, ledger, pluginDisk = new Map() }) {
|
|
173
231
|
const trusted = new Map()
|
|
174
232
|
for (const note of priorManifest?.notes ?? []) trusted.set(note.path, note.noteDigest)
|
|
175
233
|
for (const attachment of priorManifest?.attachments ?? []) trusted.set(attachment.path, attachment.digest)
|
|
@@ -177,17 +235,30 @@ function planUnits({ files, priorManifest, pointer, ledger }) {
|
|
|
177
235
|
for (const item of pointer?.retained ?? []) if (item.priorDigest && !item.movedToRecovery) trusted.set(item.path, item.priorDigest)
|
|
178
236
|
for (const [notePath, digest] of ledger) { if (digest === null) trusted.delete(notePath); else trusted.set(notePath, digest) }
|
|
179
237
|
|
|
238
|
+
// A file leaves as an attachment when the prior generation listed it as one. Layout 1 kept every file under
|
|
239
|
+
// `attachments/`, so there that folder says it too (of a file an interrupted run published); in layout 2 it is only
|
|
240
|
+
// a repository's folder.
|
|
241
|
+
const priorAttachments = new Set((priorManifest?.attachments ?? []).map((attachment) => attachment.path))
|
|
242
|
+
const underAttachments = !priorManifest || manifestLayoutVersion(priorManifest) === 1
|
|
180
243
|
const units = []
|
|
181
244
|
const present = new Set()
|
|
182
245
|
for (const file of files) {
|
|
183
246
|
present.add(file.path)
|
|
184
|
-
|
|
247
|
+
// A settings file prepared from the bytes it held when the view was prepared carries their digest: it is written only over them.
|
|
248
|
+
if (file.kind === 'settings') { units.push({ path: file.path, kind: 'settings', op: 'settings', ...(Object.hasOwn(file, 'expectedDigest') ? { expectedDigest: file.expectedDigest } : {}) }); continue }
|
|
249
|
+
if (file.kind === 'plugin') {
|
|
250
|
+
const current = pluginDisk.get(file.path) ?? null
|
|
251
|
+
// A plugin file the person removed while the plugin is off in this vault stays removed.
|
|
252
|
+
if (current === null && file.onlyIfPresent === true) { units.push({ path: file.path, kind: 'plugin', op: 'leave-absent' }); continue }
|
|
253
|
+
units.push({ path: file.path, kind: 'plugin', op: current === null ? 'create' : current === file.digest ? 'keep' : 'replace', baseDigest: current, candidateDigest: file.digest, bytes: file.bytes, mode: file.mode })
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
185
256
|
const base = trusted.get(file.path)
|
|
186
257
|
const op = base === undefined ? 'create' : base === file.digest ? 'keep' : 'replace'
|
|
187
258
|
units.push({ path: file.path, kind: file.kind, op, baseDigest: base ?? null, candidateDigest: file.digest, bytes: file.bytes })
|
|
188
259
|
}
|
|
189
260
|
for (const [notePath, digest] of [...trusted].sort(([left], [right]) => (left < right ? -1 : 1))) {
|
|
190
|
-
if (!present.has(notePath)) units.push({ path: notePath, kind: notePath.startsWith('attachments/') ? 'attachment' : 'note', op: 'remove', baseDigest: digest, candidateDigest: null })
|
|
261
|
+
if (!present.has(notePath)) units.push({ path: notePath, kind: priorAttachments.has(notePath) || (underAttachments && notePath.startsWith('attachments/')) ? 'attachment' : 'note', op: 'remove', baseDigest: digest, candidateDigest: null })
|
|
191
262
|
}
|
|
192
263
|
units.forEach((unit, index) => { unit.unit = index })
|
|
193
264
|
return units
|
|
@@ -224,14 +295,15 @@ export async function publishView(options = {}) {
|
|
|
224
295
|
acquire(store.vaultLockPath, () => acquireVaultLock(store), 'into this vault')
|
|
225
296
|
const recovered = recoverPublicationsLocked({ store, clock })
|
|
226
297
|
const pointer = store.readCurrent()
|
|
227
|
-
if (pointer?.generationId === manifest.generationId) {
|
|
298
|
+
if (pointer?.generationId === manifest.generationId && !pluginFilesDrifted(preparedView, store)) {
|
|
228
299
|
return { state: 'committed', alreadyCommitted: true, generationId: manifest.generationId, journalId: pointer.journalId, notes: [], retainedEdits: pointer.retained ?? [], lateWriters: [], recovered }
|
|
229
300
|
}
|
|
230
301
|
if ((pointer?.generationId ?? null) !== expectedGeneration) refuse('generation-mismatch', 'the committed generation is not the one this publication expects', { committed: pointer?.generationId ?? null })
|
|
231
302
|
|
|
232
|
-
// Path selection.
|
|
303
|
+
// Path selection. An app with this vault open in several windows is refused as such: publishing through one of
|
|
304
|
+
// them would leave the others uncoordinated.
|
|
233
305
|
const probe = await adapter.probe({ vaultRoot: store.vaultRoot })
|
|
234
|
-
if (probe.state !== 'coordinated' && probe.state !== 'absent') refuse('editor-uncoordinated', `an Obsidian process may have this vault open and cannot be coordinated with: ${probe.reason}`)
|
|
306
|
+
if (probe.state !== 'coordinated' && probe.state !== 'absent') refuse(probe.code === 'vault-open-in-several-windows' ? probe.code : 'editor-uncoordinated', `an Obsidian process may have this vault open and cannot be coordinated with: ${probe.reason}`)
|
|
235
307
|
const mode = probe.state === 'coordinated' ? 'in-app' : 'direct'
|
|
236
308
|
const channel = mode === 'in-app' ? adapter : createDirectAdapter({ crashSeam: seam })
|
|
237
309
|
|
|
@@ -247,7 +319,8 @@ export async function publishView(options = {}) {
|
|
|
247
319
|
const exchange = probeExchange({ directory: probeDir, ...exchangeOptions })
|
|
248
320
|
if (!exchange.supported) refuse(exchange.code, exchange.message)
|
|
249
321
|
|
|
250
|
-
const
|
|
322
|
+
const pluginDisk = new Map(preparedView.files.filter((file) => file.kind === 'plugin').map((file) => [file.path, currentDigestOf(store.vaultRoot, file.path)]))
|
|
323
|
+
const units = planUnits({ files: preparedView.files, priorManifest: store.readCurrentManifest(), pointer, ledger: publishedSinceCommit({ store }), pluginDisk })
|
|
251
324
|
|
|
252
325
|
// Stage every known candidate before anything is touched.
|
|
253
326
|
const journalId = newJournalId(clock)
|
|
@@ -259,7 +332,7 @@ export async function publishView(options = {}) {
|
|
|
259
332
|
for (const unit of units.filter((item) => item.op === 'replace' || item.op === 'create')) {
|
|
260
333
|
const existing = fs.lstatSync(path.join(store.vaultRoot, unit.path), { throwIfNoEntry: false })
|
|
261
334
|
unit.preparedPath = store.preparedPath(journalId, unit.unit)
|
|
262
|
-
stageCandidate(unit.preparedPath, unit.bytes, existing?.isFile() ? existing.mode & 0o777 : 0o644, created)
|
|
335
|
+
stageCandidate(unit.preparedPath, unit.bytes, unit.mode ?? (existing?.isFile() ? existing.mode & 0o777 : 0o644), created)
|
|
263
336
|
// A replacement is exchanged, and the exchange leaves the displaced bytes at this path: it is in recovery.
|
|
264
337
|
unit.stagedPath = unit.op === 'replace' ? store.exchangeCandidatePath(journalId, unit.unit) : unit.preparedPath
|
|
265
338
|
}
|
|
@@ -270,7 +343,7 @@ export async function publishView(options = {}) {
|
|
|
270
343
|
detail: { mode, manifestRef: store.ref(manifestFile), manifestDigest: sha256Digest(manifestBytes),
|
|
271
344
|
// The units this run may write. A kept note is compared and never written; the one case that writes
|
|
272
345
|
// it after all (a kept note gone missing) is a late candidate with its own write-ahead entry.
|
|
273
|
-
units: units.filter((item) => item.op !== 'keep').map(({ unit, path: unitPath, kind, op }) => ({ unit, path: unitPath, kind, op })),
|
|
346
|
+
units: units.filter((item) => item.op !== 'keep' && item.op !== 'leave-absent').map(({ unit, path: unitPath, kind, op }) => ({ unit, path: unitPath, kind, op })),
|
|
274
347
|
staged: units.filter((item) => item.stagedPath).map((item) => ({ unit: item.unit, path: item.path, candidateDigest: item.candidateDigest, stagedRef: store.ref(item.stagedPath),
|
|
275
348
|
...(item.stagedPath === item.preparedPath ? {} : { preparedRef: store.ref(item.preparedPath) }) })) } })
|
|
276
349
|
crash('before-candidate-move')
|
|
@@ -317,7 +390,12 @@ export async function publishView(options = {}) {
|
|
|
317
390
|
results.push({ path: unit.path, kind: unit.kind, op: unit.op, outcome: context.uncoordinated ? 'editor-uncoordinated' : 'unchanged', blocking: false })
|
|
318
391
|
continue
|
|
319
392
|
}
|
|
320
|
-
results.push(await publishUnit(unit, context))
|
|
393
|
+
try { results.push(await publishUnit(unit, context)) } catch (error) {
|
|
394
|
+
if (error.code !== 'ELEAFCHANGED') throw error
|
|
395
|
+
// Another program kept replacing this file while it was read: its bytes are left as they are, the unit's
|
|
396
|
+
// journal stays open for recovery like an interrupted one, and the other units go on.
|
|
397
|
+
results.push({ path: unit.path, kind: unit.kind, op: unit.op, outcome: 'disk-changed', blocking: true })
|
|
398
|
+
}
|
|
321
399
|
}
|
|
322
400
|
|
|
323
401
|
const blocking = results.filter((result) => result.blocking)
|
|
@@ -356,21 +434,72 @@ export async function publishView(options = {}) {
|
|
|
356
434
|
}
|
|
357
435
|
|
|
358
436
|
// ---------------------------------------------------------------------------
|
|
359
|
-
// One note, attachment or
|
|
437
|
+
// One note, attachment, settings file or plugin file
|
|
360
438
|
// ---------------------------------------------------------------------------
|
|
361
439
|
|
|
440
|
+
// Atelier's own plugin never waits on a person: a plugin path that cannot be
|
|
441
|
+
// written safely, a vault root not private enough for the bearer, or a file
|
|
442
|
+
// that could not be made or replaced there (a plugin folder the person can read
|
|
443
|
+
// but not write, say) is reported and left for the person while every note
|
|
444
|
+
// converges, and its candidate is retired at once. A plugin file that another
|
|
445
|
+
// writer changed under this run is a race: it blocks like a note, so the view
|
|
446
|
+
// is tried again and the file is planned from the disk anew. Nothing reads a
|
|
447
|
+
// plugin file as a person's edit of a note: its outcomes carry their own names.
|
|
448
|
+
const PLUGIN_LEFT_FOR_A_PERSON = new Set(['path-unsafe', 'vault-not-private', 'create-failed', 'exchange-failed'])
|
|
449
|
+
// A leaf or a parent nobody may look into or read (another owner, mode 000) or a link loop: the person's to repair,
|
|
450
|
+
// like a leaf that is not a file. It is reported, never thrown, so the rest of the view and every later view go on.
|
|
451
|
+
const UNREADABLE_PATH = new Set(['EACCES', 'EPERM', 'ELOOP'])
|
|
452
|
+
const PLUGIN_FILE_CHANGED = new Set(['edit-kept', 'disk-changed', 'editor-edit'])
|
|
453
|
+
|
|
362
454
|
async function publishUnit(unit, context) {
|
|
455
|
+
let result
|
|
456
|
+
try { result = await publishOneUnit(unit, context) } catch (error) {
|
|
457
|
+
// A plugin or settings file another program kept replacing while it was opened (see readNote) is a race of that
|
|
458
|
+
// file, like one that changed under the run: blocking, tried again, and never taken for a person's edit of a note.
|
|
459
|
+
if (error?.code !== 'ELEAFCHANGED' || (unit.kind !== 'plugin' && unit.kind !== 'settings')) throw error
|
|
460
|
+
// Its candidate is left as #83 leaves a note's: the unit's journal stays open for recovery like an interrupted one.
|
|
461
|
+
result = { path: unit.path, kind: unit.kind, op: unit.op, outcome: unit.kind === 'settings' ? 'settings-changed' : 'disk-changed', blocking: true }
|
|
462
|
+
}
|
|
463
|
+
if (unit.kind !== 'plugin') return result
|
|
464
|
+
const { changedAfterPublication, retained: _retained, ...rest } = result
|
|
465
|
+
if (rest.outcome === 'path-unsafe' && unit.stagedPath) retire(unit, context)
|
|
466
|
+
const changed = PLUGIN_FILE_CHANGED.has(rest.outcome)
|
|
467
|
+
return { ...rest, outcome: changed ? 'plugin-file-changed' : rest.outcome, blocking: changed || (rest.blocking && !PLUGIN_LEFT_FOR_A_PERSON.has(rest.outcome)), ...(changedAfterPublication ? { pluginFileChanged: true } : {}) }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// The vault root holds the plugin's bearer once its data file is there, so it
|
|
471
|
+
// must be private to this user. A vault root Atelier placed under its own data
|
|
472
|
+
// root is made private here; any other one is only looked at, and a vault path
|
|
473
|
+
// under the data root that is a link to somewhere else never receives it.
|
|
474
|
+
function ensurePrivateVaultRoot(store) {
|
|
475
|
+
if (store.linkedVaultRoot === true) return { private: false, reason: 'vault-root-is-a-link' }
|
|
476
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : null
|
|
477
|
+
let stat = fs.lstatSync(store.vaultRoot, { throwIfNoEntry: false })
|
|
478
|
+
if (!stat?.isDirectory() || uid === null || stat.uid !== uid) return { private: false, reason: 'vault-root-not-owned' }
|
|
479
|
+
if ((stat.mode & 0o077) !== 0 && store.managedVaultRoot === true) {
|
|
480
|
+
try { fs.chmodSync(store.vaultRoot, stat.mode & 0o700) } catch { /* looked at again below */ }
|
|
481
|
+
stat = fs.lstatSync(store.vaultRoot, { throwIfNoEntry: false })
|
|
482
|
+
}
|
|
483
|
+
return stat?.isDirectory() && (stat.mode & 0o077) === 0 ? { private: true } : { private: false, reason: 'vault-root-not-private' }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function publishOneUnit(unit, context) {
|
|
363
487
|
const { store, journal, journalId, clock } = context
|
|
364
488
|
const note = path.join(store.vaultRoot, unit.path)
|
|
365
489
|
const outcome = (code, extra = {}) => ({ path: unit.path, kind: unit.kind, op: unit.op, outcome: code, blocking: false, ...extra })
|
|
490
|
+
if (unit.op === 'leave-absent') return outcome('left-absent')
|
|
366
491
|
const observe = (bytes) => store.retainObject(bytes)
|
|
367
492
|
if (context.uncoordinated) return outcome('editor-uncoordinated', { blocking: unit.op !== 'keep' })
|
|
368
493
|
// A settings path that cannot be written safely (a symlinked `.obsidian`,
|
|
369
494
|
// say) is reported and left alone; it must not keep every note from
|
|
370
495
|
// converging while the person repairs it. Notes and attachments block.
|
|
371
496
|
const unsafeBlocks = unit.op !== 'settings'
|
|
372
|
-
if (!
|
|
373
|
-
|
|
497
|
+
const unreadable = (error) => { if (!UNREADABLE_PATH.has(error?.code)) throw error; return outcome('path-unsafe', { blocking: unsafeBlocks, errorCode: error.code }) }
|
|
498
|
+
let leaf
|
|
499
|
+
try {
|
|
500
|
+
if (!ensureParents(store.vaultRoot, unit.path)) return outcome('path-unsafe', { blocking: unsafeBlocks })
|
|
501
|
+
leaf = fs.lstatSync(note, { throwIfNoEntry: false })
|
|
502
|
+
} catch (error) { return unreadable(error) }
|
|
374
503
|
if (leaf && !leaf.isFile()) return outcome('path-unsafe', { blocking: unsafeBlocks })
|
|
375
504
|
|
|
376
505
|
let plan = unit
|
|
@@ -378,7 +507,8 @@ async function publishUnit(unit, context) {
|
|
|
378
507
|
plan = planSettings(unit, context)
|
|
379
508
|
if (plan.outcome) return plan
|
|
380
509
|
}
|
|
381
|
-
|
|
510
|
+
let current
|
|
511
|
+
try { current = readNote(note) } catch (error) { return unreadable(error) }
|
|
382
512
|
const currentDigest = current === null ? null : sha256Digest(current)
|
|
383
513
|
|
|
384
514
|
if (plan.op === 'keep') {
|
|
@@ -403,6 +533,14 @@ async function publishUnit(unit, context) {
|
|
|
403
533
|
return outcome('already-current')
|
|
404
534
|
}
|
|
405
535
|
|
|
536
|
+
if (unit.path === PLUGIN_DATA_PATH && plan.op !== 'remove') {
|
|
537
|
+
const privacy = ensurePrivateVaultRoot(store)
|
|
538
|
+
if (!privacy.private) {
|
|
539
|
+
if (plan.stagedPath) retire(plan, context)
|
|
540
|
+
return outcome('vault-not-private', { reason: privacy.reason })
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
406
544
|
const operationId = `${journalId}:${unit.unit}`
|
|
407
545
|
const displaced = plan.op === 'create' ? null : store.displacedPath(journalId, unit.unit)
|
|
408
546
|
// The journal state never goes backwards: once a note has been published the transition is `updating`.
|
|
@@ -552,18 +690,27 @@ function stageLate(plan, { store, journal, journalId, crash, updating }) {
|
|
|
552
690
|
return unit.stagedPath
|
|
553
691
|
}
|
|
554
692
|
|
|
555
|
-
// Policy settings are owned per key. The candidate is the file on
|
|
556
|
-
// with only
|
|
557
|
-
//
|
|
693
|
+
// Policy settings are owned per key or entry. The candidate is the file on
|
|
694
|
+
// disk now with only what Atelier owns set; the base is that same file. A file
|
|
695
|
+
// whose shape the policy does not know is the person's to repair and is left
|
|
696
|
+
// alone.
|
|
558
697
|
function planSettings(unit, context) {
|
|
559
698
|
const note = path.join(context.store.vaultRoot, unit.path)
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
699
|
+
let existing
|
|
700
|
+
try { existing = readNote(note) } catch (error) {
|
|
701
|
+
if (!UNREADABLE_PATH.has(error?.code)) throw error
|
|
702
|
+
return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'path-unsafe', blocking: false, errorCode: error.code }
|
|
703
|
+
}
|
|
704
|
+
// Prepared from other bytes than these: the person changed the file since, and whatever Atelier decided from the old
|
|
705
|
+
// bytes is not written over the change. The view is tried again, and the next preparation reads the file as it is.
|
|
706
|
+
if (Object.hasOwn(unit, 'expectedDigest') && (existing === null ? null : sha256Digest(existing)) !== unit.expectedDigest) {
|
|
707
|
+
return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'settings-changed', blocking: true }
|
|
708
|
+
}
|
|
709
|
+
let file
|
|
710
|
+
try { file = preparePolicySettingsFile(unit.path, existing) } catch (error) {
|
|
563
711
|
if (error instanceof ObsidianContractRefusal) return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'settings-invalid', blocking: false }
|
|
564
712
|
throw error
|
|
565
713
|
}
|
|
566
|
-
const [file] = prepared.files
|
|
567
714
|
if (existing !== null && file.bytes.equals(existing)) return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'policy-satisfied', blocking: false }
|
|
568
715
|
const plan = { ...unit, op: existing === null ? 'create' : 'replace', baseDigest: existing === null ? null : sha256Digest(existing), candidateDigest: file.digest, bytes: file.bytes,
|
|
569
716
|
mode: existing === null ? 0o644 : fs.lstatSync(note).mode & 0o777 }
|