Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions cmd/pdfops/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ var commands = []command{
{"strip", "[-annotations] [-bookmarks] <in.pdf> <out.pdf>", "write the file without its metadata", runStrip},
{"sanitize", "<in.pdf> <out.pdf>", "remove what runs rather than shows: scripts, launching, embedded files", runSanitize},
{"flatten", "<in.pdf> <out.pdf>", "draw the annotations into the page and drop them", runFlatten},
{"compress", "<in.pdf> <out.pdf>", "pack the objects into compressed streams", runCompress},
{"encrypt", "-user <password> [-owner <password>] [-allow <what>] [-aes128] <in.pdf> <out.pdf>", "protect the file with a password", runEncrypt},
{"decrypt", "<in.pdf> <out.pdf>", "write the file without its protection", runDecrypt},
{"permissions", "<in.pdf>", "say how the file is protected and what it allows", runPermissions},
}

// run is the whole program, so that the tests can drive it.
Expand Down Expand Up @@ -625,3 +629,152 @@ func runStamp(c *context, args []string) error {
}
return save(d, fs.Arg(1))
}

// runCompress packs the file's objects into compressed streams.
func runCompress(c *context, args []string) error {
fs := flags("compress")
if err := fs.Parse(args); err != nil {
return err
}
if err := wantArgs(fs, 2, "<in.pdf> <out.pdf>"); err != nil {
return err
}
d, err := c.open(fs.Arg(0))
if err != nil {
return err
}
d.Compress()
return save(d, fs.Arg(1))
}

// permissionFlags names each permission the way a person would ask for it.
var permissionFlags = []struct {
name string
bit reader.Permissions
}{
{"print", reader.PermPrint},
{"print-faithful", reader.PermPrintFaithful},
{"modify", reader.PermModify},
{"assemble", reader.PermAssemble},
{"copy", reader.PermCopy},
{"extract", reader.PermExtract},
{"annotate", reader.PermAnnotate},
{"fill-forms", reader.PermFillForms},
{"all", reader.AllPermissions},
{"none", 0},
}

// parsePermissions reads a comma-separated list of what a reader may do.
func parsePermissions(spec string) (reader.Permissions, error) {
if spec == "" {
return reader.AllPermissions, nil
}
var out reader.Permissions
for _, word := range strings.Split(spec, ",") {
word = strings.TrimSpace(word)
found := false
for _, p := range permissionFlags {
if p.name == word {
out |= p.bit
found = true
break
}
}
if !found {
return 0, fmt.Errorf("no such permission %q; there is %s", word, permissionList())
}
}
return out, nil
}

// permissionList is every permission name, for an error message.
func permissionList() string {
names := make([]string, 0, len(permissionFlags))
for _, p := range permissionFlags {
names = append(names, p.name)
}
return strings.Join(names, ", ")
}

// runEncrypt protects a file with a password.
func runEncrypt(c *context, args []string) error {
fs := flags("encrypt")
user := fs.String("user", "", "the password that opens the file, subject to the permissions")
owner := fs.String("owner", "", "the password that opens it subject to nothing")
allow := fs.String("allow", "", "what a reader may do: "+permissionList())
aes128 := fs.Bool("aes128", false, "use the older 128-bit method, for readers from before 2008")
if err := fs.Parse(args); err != nil {
return err
}
if err := wantArgs(fs, 2, "<in.pdf> <out.pdf>"); err != nil {
return err
}
if *user == "" && *owner == "" {
return fmt.Errorf("encrypt needs a -user or an -owner password")
}
perms, err := parsePermissions(*allow)
if err != nil {
return err
}
d, err := c.open(fs.Arg(0))
if err != nil {
return err
}
d.Encrypt(reader.Encryption{
UserPassword: *user,
OwnerPassword: *owner,
Permissions: perms,
AES128: *aes128,
})
return save(d, fs.Arg(1))
}

// runDecrypt writes the file without its protection. The password to open it
// with is the global -password.
func runDecrypt(c *context, args []string) error {
fs := flags("decrypt")
if err := fs.Parse(args); err != nil {
return err
}
if err := wantArgs(fs, 2, "<in.pdf> <out.pdf>"); err != nil {
return err
}
d, err := c.open(fs.Arg(0))
if err != nil {
return err
}
d.Decrypt()
return save(d, fs.Arg(1))
}

// runPermissions says how a file is protected and what it allows.
func runPermissions(c *context, args []string) error {
fs := flags("permissions")
if err := fs.Parse(args); err != nil {
return err
}
if err := wantArgs(fs, 1, "<in.pdf>"); err != nil {
return err
}
d, err := c.open(fs.Arg(0))
if err != nil {
return err
}
p, ok := d.Protection()
if !ok {
fmt.Fprintln(c.out, "protection none")
return nil
}
fmt.Fprintf(c.out, "protection %s, revision %d\n", p.Method, p.Revision)
fmt.Fprintf(c.out, "opened as %s\n", openedAs(p.Owner))
fmt.Fprintf(c.out, "allows %s\n", p.Permissions)
return nil
}

// openedAs says which password the file was opened with.
func openedAs(owner bool) string {
if owner {
return "the owner, so the permissions do not apply"
}
return "the user"
}
107 changes: 107 additions & 0 deletions cmd/pdfops/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -529,3 +529,110 @@ func TestSanitizeFlattenAndStripFlags(t *testing.T) {
}
}
}

func TestCompressVerb(t *testing.T) {
in := fixture(t, 30)
out := filepath.Join(t.TempDir(), "packed.pdf")
if code, _, errOut := exec("compress", in, out); code != 0 {
t.Fatalf("compress said %d: %s", code, errOut)
}
before, _ := os.Stat(in)
after, _ := os.Stat(out)
if after.Size() >= before.Size() {
t.Errorf("packed is %d bytes and plain is %d", after.Size(), before.Size())
}
if got := contentsOf(t, out); len(got) != 30 || got[0] != "page 1" {
t.Errorf("the packed file reads back as %v", got)
}
if code, _, _ := exec("compress", in); code == 0 {
t.Error("compress with one path was accepted")
}
if code, _, _ := exec("compress", "nowhere.pdf", out); code == 0 {
t.Error("compress read a file that is not there")
}
if code, _, _ := exec("compress", "-nope", in, out); code == 0 {
t.Error("compress took a flag it has never heard of")
}
}

func TestEncryptDecryptAndPermissionsVerbs(t *testing.T) {
in := fixture(t, 3)
dir := t.TempDir()
locked := filepath.Join(dir, "locked.pdf")
if code, _, errOut := exec("encrypt", "-user", "u", "-owner", "o", "-allow", "print,copy", in, locked); code != 0 {
t.Fatalf("encrypt said %d: %s", code, errOut)
}
b, err := os.ReadFile(locked)
if err != nil {
t.Fatal(err)
}
if _, err := reader.Open(b); err == nil {
t.Error("the encrypted file opened with no password")
}

code, out, errOut := exec("-password", "u", "permissions", locked)
if code != 0 {
t.Fatalf("permissions said %d: %s", code, errOut)
}
for _, want := range []string{"AES-256", "the user", "print, copy"} {
if !strings.Contains(out, want) {
t.Errorf("permissions did not mention %q:\n%s", want, out)
}
}
if _, out, _ = exec("-password", "o", "permissions", locked); !strings.Contains(out, "the owner") {
t.Errorf("the owner password was not recognised:\n%s", out)
}
if _, out, _ = exec("permissions", in); !strings.Contains(out, "protection none") {
t.Errorf("an unprotected file reported:\n%s", out)
}

plain := filepath.Join(dir, "plain.pdf")
if code, _, errOut := exec("-password", "u", "decrypt", locked, plain); code != 0 {
t.Fatalf("decrypt said %d: %s", code, errOut)
}
if got := contentsOf(t, plain); len(got) != 3 || got[2] != "page 3" {
t.Errorf("the decrypted file reads back as %v", got)
}

// The older method, for readers from before 2008.
old := filepath.Join(dir, "old.pdf")
if code, _, errOut := exec("encrypt", "-user", "u", "-aes128", in, old); code != 0 {
t.Fatalf("encrypt -aes128 said %d: %s", code, errOut)
}
if _, out, _ = exec("-password", "u", "permissions", old); !strings.Contains(out, "AES-128") {
t.Errorf("-aes128 produced:\n%s", out)
}
}

func TestTheVerbsThatProtectRefuseWhatTheyShould(t *testing.T) {
in := fixture(t, 2)
out := filepath.Join(t.TempDir(), "out.pdf")
cases := []struct {
name string
args []string
}{
{"encrypt with no password at all", []string{"encrypt", in, out}},
{"encrypt with a permission nobody has heard of", []string{"encrypt", "-user", "u", "-allow", "fly", in, out}},
{"encrypt with one path", []string{"encrypt", "-user", "u", in}},
{"encrypt a file that is not there", []string{"encrypt", "-user", "u", "nowhere.pdf", out}},
{"encrypt with an unknown flag", []string{"encrypt", "-nope", in, out}},
{"decrypt with one path", []string{"decrypt", in}},
{"decrypt a file that is not there", []string{"decrypt", "nowhere.pdf", out}},
{"decrypt with an unknown flag", []string{"decrypt", "-nope", in, out}},
{"permissions with no path", []string{"permissions"}},
{"permissions on a file that is not there", []string{"permissions", "nowhere.pdf"}},
{"permissions with an unknown flag", []string{"permissions", "-nope", in}},
}
for _, c := range cases {
if code, _, _ := exec(c.args...); code == 0 {
t.Errorf("%s was accepted", c.name)
}
}
// Asking for everything is what saying nothing means, and "none" is a
// way of saying it out loud.
for _, allow := range []string{"", "all", "none", "print, copy"} {
if code, _, errOut := exec("encrypt", "-user", "u", "-allow", allow, in, out); code != 0 {
t.Errorf("-allow %q said %d: %s", allow, code, errOut)
}
}
}
5 changes: 5 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ type Doc struct {
flatten bool
dropAnnots bool
dropOutlines bool

// How the file is written: packed into compressed object streams, and
// protected or not.
packed bool
protect *reader.Encryption
}

// A Page is one page of a document, borrowed from the file it came from. The
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ module github.com/go-pdfkit/ops

go 1.26.4

require github.com/go-pdfkit/reader v0.2.0 // indirect
require github.com/go-pdfkit/reader v0.4.0
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
github.com/go-pdfkit/reader v0.1.0 h1:DbkTAXYWdhd6C5yl7+Ki68QWlg+h7vfLQw79q94qerw=
github.com/go-pdfkit/reader v0.1.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8=
github.com/go-pdfkit/reader v0.2.0 h1:jY8BZkfnHw/ImyRANapU/lQrTM1d1hR960Xb9nGC+nY=
github.com/go-pdfkit/reader v0.2.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8=
github.com/go-pdfkit/reader v0.4.0 h1:qPbNZSO+Xl+4NBvQoV1PYt7HlqHaEAQ1tUc6/IL8JAU=
github.com/go-pdfkit/reader v0.4.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8=
38 changes: 38 additions & 0 deletions protect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package ops

import "github.com/go-pdfkit/reader"

// Compress asks for the file to be written with its objects packed into
// compressed streams and a cross-reference stream, which is what every writer
// since PDF 1.5 does and what makes a file a good deal smaller. It costs
// nothing but a version of 1.5, which every reader in use has understood for
// twenty years.
func (d *Doc) Compress() { d.packed = true }

// Encrypt protects the file that will be written. Two people can open it:
// whoever knows the user password, subject to the permissions, and whoever
// knows the owner password, subject to nothing.
//
// An encrypted file is not byte-for-byte reproducible — encryption needs
// randomness, by design — so a document written twice with the same call comes
// out different both times, and neither can be compared with the other.
func (d *Doc) Encrypt(e reader.Encryption) { d.protect = &e }

// Decrypt writes the file without protection. A document opened with the right
// password is already decrypted, so this only undoes an earlier call to
// [Doc.Encrypt]; a file read with [OpenWithPassword] and written out is
// unprotected either way.
func (d *Doc) Decrypt() { d.protect = nil }

// Protection reports how the file this document was read from was protected,
// and false when it was not protected at all — or when the document was not
// read from a file. It says nothing about how the document will be written:
// that is what was passed to [Doc.Encrypt].
func (d *Doc) Protection() (reader.Protection, bool) {
for _, p := range d.pages {
if p.src != nil {
return p.src.Protection()
}
}
return reader.Protection{}, false
}
Loading
Loading