Skip to content

Commit 862d966

Browse files
committed
feat(dev): add interface for safe downloads
1 parent fe508e1 commit 862d966

File tree

7 files changed

+314
-1
lines changed

7 files changed

+314
-1
lines changed

docs/configuration.org

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,36 @@ See [[https://orgmode.org/manual/Property-Inheritance.html][Property Inheritance
555555
- Default: ~{ [':tangle'] = 'no', [':noweb'] = no }~
556556
Default header args for extracting source code. See [[#extract-source-code-tangle][Extract source code (tangle)]] for more details.
557557

558+
*** org_resource_download_policy
559+
:PROPERTIES:
560+
:CUSTOM_ID: org_resource_download_policy
561+
:END:
562+
- Type: ='always' | 'prompt' | 'safe' | 'never'=
563+
- Default: ='prompt'=
564+
Policy applied to requests to obtain remote resources.
565+
566+
- =always= - Always download remote resources (dangerous!)
567+
- =prompt= - Prompt before downloading an unsafe resource
568+
- =safe= - Only download resources allowed by [[#org_safe_remote_resources][org_safe_remote_resources]]
569+
- =never= - Never download any resources
570+
571+
In Emacs Orgmode, this affects keywords like =#+setupfile= and =#+include=
572+
on export, =org-persist-write:url=; and =org-attach-url= in non-interactive
573+
sessions. Nvim Orgmode currently does not use this option, but defines it
574+
for future use.
575+
576+
*** org_safe_remote_resources
577+
:PROPERTIES:
578+
:CUSTOM_ID: org_safe_remote_resources
579+
:END:
580+
- Type: =string[]=
581+
- Default: ={}=
582+
583+
List of regex patterns matching safe URIs. URI regexps are applied to both
584+
URLs and Org files requesting remote resources. The test uses
585+
=vim.regex()=, so the regexes are always interpreted as magic and
586+
case-sensitive.
587+
558588
*** calendar_week_start_day
559589
:PROPERTIES:
560590
:CUSTOM_ID: calendar_week_start_day

lua/orgmode/config/_meta.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@
240240
---@field org_id_link_to_org_use_id? boolean If true, Storing a link to the headline will automatically generate ID for that headline. Default: false
241241
---@field org_use_property_inheritance boolean | string | string[] If true, properties are inherited by sub-headlines; may also be a regex or list of property names. Default: false
242242
---@field org_babel_default_header_args? table<string, string> Default header args for org-babel blocks: Default: { [':tangle'] = 'no', [':noweb'] = 'no' }
243+
---@field org_resource_download_policy 'always' | 'prompt' | 'safe' | 'never' Policy for downloading files from the Internet. Default: 'prompt'
244+
---@field org_safe_remote_resources string[] List of regex patterns for URIs considered always safe to download from. Default: {}
243245
---@field win_split_mode? 'horizontal' | 'vertical' | 'auto' | 'float' | string[] How to open agenda and capture windows. Default: 'horizontal'
244246
---@field win_border? 'none' | 'single' | 'double' | 'rounded' | 'solid' | 'shadow' | string[] Border configuration for `win_split_mode = 'float'`. Default: 'single'
245247
---@field notifications? OrgNotificationsConfig Notification settings

lua/orgmode/config/defaults.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ local DefaultConfig = {
7171
[':tangle'] = 'no',
7272
[':noweb'] = 'no',
7373
},
74+
org_resource_download_policy = 'prompt',
75+
org_safe_remote_resources = {},
7476
win_split_mode = 'horizontal',
7577
win_border = 'single',
7678
notifications = {
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
local config = require('orgmode.config')
2+
local fs = require('orgmode.utils.fs')
3+
local utils = require('orgmode.utils')
4+
local Menu = require('orgmode.ui.menu')
5+
local State = require('orgmode.state.state')
6+
local Promise = require('orgmode.utils.promise')
7+
8+
local M = {}
9+
10+
---Return true if the URI should be fetched.
11+
---@param uri string
12+
---@return OrgPromise<boolean> safe
13+
function M.should_fetch(uri)
14+
local policy = config.org_resource_download_policy
15+
return Promise.resolve(policy == 'always' or M.is_uri_safe(uri)):next(function(safe)
16+
if safe then
17+
return true
18+
end
19+
if policy == 'prompt' then
20+
return M.confirm_safe(uri)
21+
end
22+
return false
23+
end)
24+
end
25+
26+
---@param resource_uri string
27+
---@param file_uri string | false
28+
---@param patterns string[]
29+
---@return boolean matches
30+
local function check_patterns(resource_uri, file_uri, patterns)
31+
for _, pattern in ipairs(patterns) do
32+
local re = vim.regex(pattern)
33+
if re:match_str(resource_uri) or (file_uri and re:match_str(file_uri)) then
34+
return true
35+
end
36+
end
37+
return false
38+
end
39+
40+
---Check the uri matches any of the (configured or cached) safe patterns.
41+
---@param uri string
42+
---@return OrgPromise<boolean> safe
43+
function M.is_uri_safe(uri)
44+
local current_file = fs.get_real_path(utils.current_file_path())
45+
---@type string | false # deduced type is `string | boolean`
46+
local file_uri = current_file and vim.uri_from_fname(current_file) or false
47+
local uri_patterns = {}
48+
if config.org_safe_remote_resources then
49+
vim.list_extend(uri_patterns, config.org_safe_remote_resources)
50+
end
51+
return State:load():next(function(state)
52+
local cached = state['org_safe_remote_resources']
53+
if cached then
54+
vim.list_extend(uri_patterns, cached)
55+
end
56+
return check_patterns(uri, file_uri, uri_patterns)
57+
end)
58+
end
59+
60+
---@param uri string
61+
---@return string escaped
62+
local function uri_to_pattern(uri)
63+
-- Escape backslashes, disable magic characters, anchor front and back of the
64+
-- pattern.
65+
return string.format([[\V\^%s\$]], uri:gsub([[\]], [[\\]]))
66+
end
67+
68+
---@param filename string
69+
---@return string escaped
70+
local function filename_to_pattern(filename)
71+
return uri_to_pattern(vim.uri_from_fname(filename))
72+
end
73+
74+
---@param domain string
75+
---@return string escaped
76+
local function domain_to_pattern(domain)
77+
-- We construct the following regex:
78+
-- 1. http or https protocol;
79+
-- 2. followed by userinfo (`name:password@`),
80+
-- 3. followed by potentially `www.` (for convenience),
81+
-- 4. followed by the domain (in very-nomagic mode)
82+
-- 5. followed by either a slash or nothing at all.
83+
return string.format(
84+
[[\v^https?://([^@/?#]*\@)?(www\.)?(\V%s\v)($|/)]],
85+
-- `domain` here includes the host name and port. If it doesn't contain
86+
-- characters illegal in a host or port, this encoding should do nothing.
87+
-- If it contains illegal characters, the domain is broken in a safe way.
88+
vim.uri_encode(domain)
89+
)
90+
end
91+
92+
---@param pattern string
93+
---@return OrgPromise<OrgState>
94+
local function cache_safe_pattern(pattern)
95+
---@param state OrgState
96+
return State:load():next(function(state)
97+
-- We manipulate `cached` in a strange way here to ensure that `state` gets
98+
-- marked as dirty.
99+
local patterns = { pattern }
100+
local cached = state['org_safe_remote_resources']
101+
if cached then
102+
vim.list_extend(patterns, cached)
103+
end
104+
state['org_safe_remote_resources'] = patterns
105+
end)
106+
end
107+
108+
---Ask the user if URI should be considered safe.
109+
---@param uri string
110+
---@return OrgPromise<boolean> safe
111+
function M.confirm_safe(uri)
112+
---@type OrgMenu
113+
return Promise.new(function(resolve)
114+
local menu = Menu:new({
115+
title = string.format('An org-mode document would like to download %s, which is not considered safe.', uri),
116+
prompt = 'Do you want to download this?',
117+
})
118+
menu:add_option({
119+
key = '!',
120+
label = 'Yes, and mark it as safe.',
121+
action = function()
122+
cache_safe_pattern(uri_to_pattern(uri))
123+
return true
124+
end,
125+
})
126+
local authority = uri:match('^https?://([^/?#]*)')
127+
-- `domain` here includes the host name and port.
128+
local domain = authority and authority:match('^[^@]*@(.*)$') or authority
129+
if domain then
130+
menu:add_option({
131+
key = 'd',
132+
label = string.format('Yes, and mark the domain as safe. (%s)', domain),
133+
action = function()
134+
cache_safe_pattern(domain_to_pattern(domain))
135+
return true
136+
end,
137+
})
138+
end
139+
local filename = fs.get_real_path(utils.current_file_path())
140+
if filename then
141+
menu:add_option({
142+
key = 'f',
143+
label = string.format('Yes, and mark the org file as safe. (%s)', filename),
144+
action = function()
145+
cache_safe_pattern(filename_to_pattern(filename))
146+
return true
147+
end,
148+
})
149+
end
150+
menu:add_option({
151+
key = 'y',
152+
label = 'Yes, just this once.',
153+
action = function()
154+
return true
155+
end,
156+
})
157+
menu:add_option({
158+
key = 'n',
159+
label = 'No, skip this resource.',
160+
action = function()
161+
return false
162+
end,
163+
})
164+
menu:add_separator({ icon = ' ', length = 1 })
165+
resolve(menu:open())
166+
end)
167+
end
168+
169+
return M

lua/orgmode/state/state.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ function OrgState:wipe(overwrite)
172172
self._ctx.saved = false
173173
self._ctx.dirty = true
174174
if overwrite then
175-
state:save_sync()
175+
self:save_sync()
176176
end
177177
end
178178

lua/orgmode/utils/fs.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ function M.substitute_path(path_str, base)
2626
end
2727

2828
---@param filepath string
29+
---@return string | false
2930
function M.get_real_path(filepath)
3031
if not filepath then
3132
return false
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
local State = require('orgmode.state.state')
2+
local config = require('orgmode.config')
3+
local remote = require('orgmode.objects.remote_resource')
4+
5+
local config_backup = vim.deepcopy(config.opts)
6+
7+
describe('Remote resource', function()
8+
local SAFE_URL = 'https://example.com/'
9+
local UNSAFE_URL = 'http://bad.example.org/'
10+
11+
after_each(function()
12+
config:extend(config_backup)
13+
end)
14+
15+
describe('with policy "always"', function()
16+
before_each(function()
17+
config:extend({ org_resource_download_policy = 'always' })
18+
end)
19+
it('accepts everything', function()
20+
assert.is.True(remote.should_fetch(UNSAFE_URL):wait())
21+
end)
22+
end)
23+
24+
describe('with policy "safe"', function()
25+
before_each(function()
26+
config:extend({
27+
org_resource_download_policy = 'safe',
28+
-- This implicitly tests that we use actual regexes and not Lua
29+
-- patterns (which would use `%` as escape character, not `\`).
30+
org_safe_remote_resources = { '^https://.*\\.com/\\?$' },
31+
})
32+
end)
33+
it('accepts safe URLs', function()
34+
assert.is.True(remote.should_fetch(SAFE_URL):wait())
35+
end)
36+
it('rejects unsafe URLs', function()
37+
assert.is.False(remote.should_fetch(UNSAFE_URL):wait())
38+
end)
39+
end)
40+
41+
describe('with policy "prompt"', function()
42+
before_each(function()
43+
config:extend({ org_resource_download_policy = 'prompt' })
44+
end)
45+
it('opens a prompt', function()
46+
vim.api.nvim_input('<Esc>')
47+
assert.is.Nil(remote.should_fetch(SAFE_URL):wait())
48+
end)
49+
it('accepts on "y"', function()
50+
vim.api.nvim_input('y')
51+
assert.is.True(remote.should_fetch(SAFE_URL):wait())
52+
end)
53+
it('rejects on "n"', function()
54+
vim.api.nvim_input('n')
55+
assert.is.False(remote.should_fetch(SAFE_URL):wait())
56+
end)
57+
describe('and saving decisions', function()
58+
after_each(function()
59+
State:wipe()
60+
end)
61+
it('accepts forever with "!"', function()
62+
vim.api.nvim_input('!')
63+
assert.is.True(remote.should_fetch(SAFE_URL):wait())
64+
config:extend({ org_resource_download_policy = 'safe' })
65+
assert.is.True(remote.should_fetch(SAFE_URL):wait())
66+
assert.is.False(remote.should_fetch(SAFE_URL .. '/more'):wait())
67+
assert.is.False(remote.should_fetch(UNSAFE_URL):wait())
68+
end)
69+
it('accepts forever with "d"', function()
70+
vim.api.nvim_input('d')
71+
assert.is.True(remote.should_fetch(SAFE_URL):wait())
72+
config:extend({ org_resource_download_policy = 'safe' })
73+
assert.is.True(remote.should_fetch(SAFE_URL):wait())
74+
assert.is.True(remote.should_fetch(SAFE_URL .. '/more'):wait())
75+
assert.is.False(remote.should_fetch(UNSAFE_URL):wait())
76+
end)
77+
it('accepts forever with "f"', function()
78+
local todo_file = vim.fn.getcwd() .. '/tests/plenary/fixtures/todo.org'
79+
vim.cmd.edit(todo_file)
80+
vim.api.nvim_input('f')
81+
assert.is.True(remote.should_fetch(SAFE_URL):wait())
82+
config:extend({ org_resource_download_policy = 'safe' })
83+
assert.is.True(remote.should_fetch(SAFE_URL):wait())
84+
assert.is.True(remote.should_fetch(UNSAFE_URL):wait())
85+
vim.api.nvim_buf_set_name(0, '')
86+
assert.is.False(remote.should_fetch(SAFE_URL):wait())
87+
end)
88+
end)
89+
end)
90+
91+
describe('with policy "never"', function()
92+
before_each(function()
93+
config:extend({ org_resource_download_policy = 'never' })
94+
end)
95+
it('rejects everything', function()
96+
assert.is.False(remote.should_fetch(SAFE_URL):wait())
97+
end)
98+
end)
99+
100+
describe('default config', function()
101+
it('prompts', function()
102+
assert.are.equal('prompt', config.org_resource_download_policy)
103+
end)
104+
105+
it('has no safe patterns', function()
106+
assert.are.same({}, config.org_safe_remote_resources)
107+
end)
108+
end)
109+
end)

0 commit comments

Comments
 (0)